diff --git a/.ai-workflow/manifest.json b/.ai-workflow/manifest.json new file mode 100644 index 00000000..99291355 --- /dev/null +++ b/.ai-workflow/manifest.json @@ -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." + ] + } +} diff --git a/.ai-workflow/workflow.md b/.ai-workflow/workflow.md new file mode 100644 index 00000000..5c90a6d0 --- /dev/null +++ b/.ai-workflow/workflow.md @@ -0,0 +1,74 @@ + + +# 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. diff --git a/.aiassistant/rules/Creating and maintaining tests.md b/.aiassistant/rules/Creating and maintaining tests.md index d8a49b30..436ef950 100644 --- a/.aiassistant/rules/Creating and maintaining tests.md +++ b/.aiassistant/rules/Creating and maintaining tests.md @@ -2,261 +2,20 @@ 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. +# 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//YourTest.php` where `` 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//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//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 "\nTest 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::`). 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 - 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//Test.php` - - Run integration‑style test in Docker: `docker compose exec -T php1 php /var/www/html/tests//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//YourTest.php` where `` 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//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//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 "\nTest 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::`). 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 - 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//Test.php` -- Run integration‑style test in Docker: `docker compose exec -T php1 php /var/www/html/tests//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`. diff --git a/.aiassistant/rules/Creating and securing routes.md b/.aiassistant/rules/Creating and securing routes.md index 75c22fcb..552d7e88 100644 --- a/.aiassistant/rules/Creating and securing routes.md +++ b/.aiassistant/rules/Creating and securing routes.md @@ -2,172 +2,18 @@ apply: always --- -### Creating and securing routes — Rules (Project‑specific) + -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 - 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 - 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/` or `http://localhost/api/` - - 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`. diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 00000000..c2678940 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,48 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "api" + +[setup] +script = ''' +./scripts/setup.sh +''' + +[setup.win32] +script = ''' +powershell -ExecutionPolicy Bypass -File .\scripts\setup.ps1 +''' + +[[actions]] +name = "Start API" +icon = "run" +command = ''' +docker compose up -d traefik redis mysql-debug php1 caddy +''' + +[[actions]] +name = "Stop API" +icon = "run" +command = ''' +docker compose down +''' + +[[actions]] +name = "PHP logs" +icon = "debug" +command = ''' +docker compose logs -f --tail=200 php1 +''' + +[[actions]] +name = "PHP unit tests" +icon = "test" +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 +''' diff --git a/.env.example b/.env.example index 047f3c89..50780466 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,54 @@ LETSENCRYPT_PATH=/etc/letsencrypt # - If you do not have certs for cloud.truckwash.dk locally, either comment out that # TLS server block in services/nginx/nginx.conf or place a temporary self-signed # cert/key pair at the expected path. + +# Database target selection used by services/nginx/app/config.php +# Allowed values: live, debug +CONFIG_DB_TARGET=live + +# Live DB credentials +CONFIG_DB_HOST= +CONFIG_DB_USER= +CONFIG_DB_PASSWORD= +CONFIG_DB_DATABASE= +CONFIG_DB_PORT=3306 +CONFIG_DB_SSL_MODE=DISABLED + +# Browser origins allowed to call the API. Path-like entries are normalized to origins by the PHP CORS policy. +CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://api-v2.truckwash.io,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173 + +# Debug DB credentials (used when CONFIG_DB_TARGET=debug) +# Any blank debug value falls back to the live value above. +CONFIG_DB_DEBUG_HOST=mysql-debug +CONFIG_DB_DEBUG_USER=root +CONFIG_DB_DEBUG_PASSWORD=debug_root_password +CONFIG_DB_DEBUG_DATABASE=nnks_db_debug +CONFIG_DB_DEBUG_PORT=3306 +CONFIG_DB_DEBUG_SSL_MODE=DISABLED + +# e-conomic credentials +# Required: ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN +# Optional-but-recommended: ECONOMIC_API_APP_ACCESS_GRANT2 (falls back to primary grant when blank) +ECONOMIC_API_APP_ACCESS_GRANT= +ECONOMIC_API_APP_ACCESS_GRANT2= +ECONOMIC_API_APP_SECRET_TOKEN= + +# Edge broker defaults for shell relay and gateway dispatch. +EDGE_BROKER_URL=http://edge-broker:4300 +EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker +EDGE_AUTH_MODE=strict +EDGE_BROKER_SHARED_SECRET= + +# Redis credentials +REDIS_CONFIG_HOST=redis +REDIS_CONFIG_DATABASE=0 +REDIS_CONFIG_PASSWORD= +REDIS_CONFIG_PORT=6379 +REDIS_CONFIG_USER=default + +# Redis debug credentials +REDIS_CONFIG_DEBUG_HOST=redis +REDIS_CONFIG_DEBUG_DATABASE=0 +REDIS_CONFIG_DEBUG_PASSWORD= +REDIS_CONFIG_DEBUG_PORT=6379 +REDIS_CONFIG_DEBUG_USER=default diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2aef528d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.pdf binary +*.zip binary +*.webm binary diff --git a/.github/ci.env b/.github/ci.env new file mode 100644 index 00000000..1f679569 --- /dev/null +++ b/.github/ci.env @@ -0,0 +1,48 @@ +USE_ENV=true +DEBUG=true +ENCRYPTION_KEY=ci-test-encryption-key +CORS=* +CONFIG_TIMEZONE=Europe/Copenhagen + +CONFIG_DB_TARGET=debug +CONFIG_DB_HOST=mysql-debug +CONFIG_DB_USER=root +CONFIG_DB_PASSWORD=debug_root_password +CONFIG_DB_DATABASE=nnks_db_debug +CONFIG_DB_PORT=3306 +CONFIG_DB_SSL_MODE=DISABLED +CONFIG_DB_DEBUG_HOST=mysql-debug +CONFIG_DB_DEBUG_USER=root +CONFIG_DB_DEBUG_PASSWORD=debug_root_password +CONFIG_DB_DEBUG_DATABASE=nnks_db_debug +CONFIG_DB_DEBUG_PORT=3306 +CONFIG_DB_DEBUG_SSL_MODE=DISABLED + +REDIS_CONFIG_HOST=redis +REDIS_CONFIG_USER=default +REDIS_CONFIG_DATABASE=0 +REDIS_CONFIG_PASSWORD= +REDIS_CONFIG_PORT=6379 +REDIS_CONFIG_DEBUG_HOST=redis +REDIS_CONFIG_DEBUG_USER=default +REDIS_CONFIG_DEBUG_DATABASE=0 +REDIS_CONFIG_DEBUG_PASSWORD= +REDIS_CONFIG_DEBUG_PORT=6379 + +ECONOMIC_API_APP_ACCESS_GRANT=ci-test +ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary +ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret +WORDPRESS_STATIC_TOKEN=ci-test +EMAIL_WASH_CERTIFICATE_TOKEN=ci-test +WORDPRESS_API_URL=http://localhost +MINIO_ENDPOINT= +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +SLACK_DEFAULT_WEBHOOK= + +EDGE_BROKER_URL=http://edge-broker:4300 +EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker +EDGE_AUTH_MODE=manager +EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci +EDGE_GATEWAY_VIEW_CACHE_TTL=0 +TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1 diff --git a/.github/ci.env.staging b/.github/ci.env.staging new file mode 100644 index 00000000..1f679569 --- /dev/null +++ b/.github/ci.env.staging @@ -0,0 +1,48 @@ +USE_ENV=true +DEBUG=true +ENCRYPTION_KEY=ci-test-encryption-key +CORS=* +CONFIG_TIMEZONE=Europe/Copenhagen + +CONFIG_DB_TARGET=debug +CONFIG_DB_HOST=mysql-debug +CONFIG_DB_USER=root +CONFIG_DB_PASSWORD=debug_root_password +CONFIG_DB_DATABASE=nnks_db_debug +CONFIG_DB_PORT=3306 +CONFIG_DB_SSL_MODE=DISABLED +CONFIG_DB_DEBUG_HOST=mysql-debug +CONFIG_DB_DEBUG_USER=root +CONFIG_DB_DEBUG_PASSWORD=debug_root_password +CONFIG_DB_DEBUG_DATABASE=nnks_db_debug +CONFIG_DB_DEBUG_PORT=3306 +CONFIG_DB_DEBUG_SSL_MODE=DISABLED + +REDIS_CONFIG_HOST=redis +REDIS_CONFIG_USER=default +REDIS_CONFIG_DATABASE=0 +REDIS_CONFIG_PASSWORD= +REDIS_CONFIG_PORT=6379 +REDIS_CONFIG_DEBUG_HOST=redis +REDIS_CONFIG_DEBUG_USER=default +REDIS_CONFIG_DEBUG_DATABASE=0 +REDIS_CONFIG_DEBUG_PASSWORD= +REDIS_CONFIG_DEBUG_PORT=6379 + +ECONOMIC_API_APP_ACCESS_GRANT=ci-test +ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary +ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret +WORDPRESS_STATIC_TOKEN=ci-test +EMAIL_WASH_CERTIFICATE_TOKEN=ci-test +WORDPRESS_API_URL=http://localhost +MINIO_ENDPOINT= +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +SLACK_DEFAULT_WEBHOOK= + +EDGE_BROKER_URL=http://edge-broker:4300 +EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker +EDGE_AUTH_MODE=manager +EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci +EDGE_GATEWAY_VIEW_CACHE_TTL=0 +TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1 diff --git a/.github/docker-compose.ci.yml b/.github/docker-compose.ci.yml new file mode 100644 index 00000000..76726460 --- /dev/null +++ b/.github/docker-compose.ci.yml @@ -0,0 +1,82 @@ +services: + traefik: + container_name: "${COMPOSE_PROJECT_NAME:-api}-traefik" + + redis: + container_name: "${COMPOSE_PROJECT_NAME:-api}-redis" + + mysql-debug: + container_name: "${COMPOSE_PROJECT_NAME:-api}-mysql-debug" + ports: !reset [] + + edge-broker: + container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker" + labels: + - "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local-ci.entrypoints=web" + - "traefik.http.routers.edge-broker-local-ci.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local-ci.priority=190" + - "traefik.http.routers.edge-broker-local-ci.service=edge-broker" + + caddy: + container_name: "${COMPOSE_PROJECT_NAME:-api}-caddy" + depends_on: !reset [] + labels: + - "traefik.http.routers.local-api-ci.rule=PathPrefix(`/api`)" + - "traefik.http.routers.local-api-ci.entrypoints=web" + - "traefik.http.routers.local-api-ci.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-api-ci.priority=90" + - "traefik.http.routers.local-api-ci.service=caddy" + volumes: + - ci_php_app:/var/www/html + + php1: + container_name: "${COMPOSE_PROJECT_NAME:-api}-php1" + depends_on: !reset [] + environment: + AUTO_COMPOSER_INSTALL: "false" + USE_ENV: "true" + CONFIG_DB_TARGET: "debug" + CONFIG_DB_HOST: "mysql-debug" + CONFIG_DB_USER: "root" + CONFIG_DB_PASSWORD: "debug_root_password" + CONFIG_DB_DATABASE: "nnks_db_debug" + CONFIG_DB_PORT: "3306" + CONFIG_DB_DEBUG_HOST: "mysql-debug" + CONFIG_DB_DEBUG_USER: "root" + CONFIG_DB_DEBUG_PASSWORD: "debug_root_password" + CONFIG_DB_DEBUG_DATABASE: "nnks_db_debug" + CONFIG_DB_DEBUG_PORT: "3306" + REDIS_CONFIG_HOST: "redis" + REDIS_CONFIG_PORT: "6379" + REDIS_CONFIG_DATABASE: "0" + REDIS_CONFIG_DEBUG_HOST: "redis" + REDIS_CONFIG_DEBUG_PORT: "6379" + REDIS_CONFIG_DEBUG_DATABASE: "0" + TRUCKWASH_TEST_BLOCK_REAL_SHELLY: "1" + EDGE_GATEWAY_VIEW_CACHE_TTL: "0" + volumes: + - ci_php_app:/var/www/html + + php2: + container_name: "${COMPOSE_PROJECT_NAME:-api}-php2" + volumes: + - ci_php_app:/var/www/html + + php3: + container_name: "${COMPOSE_PROJECT_NAME:-api}-php3" + volumes: + - ci_php_app:/var/www/html + + php4: + container_name: "${COMPOSE_PROJECT_NAME:-api}-php4" + volumes: + - ci_php_app:/var/www/html + + php5: + container_name: "${COMPOSE_PROJECT_NAME:-api}-php5" + volumes: + - ci_php_app:/var/www/html + +volumes: + ci_php_app: diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 4d9655f5..1ee1d907 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -9,20 +9,43 @@ on: jobs: qodana: - runs-on: ubuntu-latest + # Run on our self-hosted runner to avoid GitHub-hosted Actions budget limits. + runs-on: [self-hosted, Linux, X64, default] permissions: contents: write pull-requests: write checks: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit + ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA. fetch-depth: 0 # a full history is required for pull request analysis + - name: Mark repository as safe for Git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Prepare Qodana cache directories + run: | + mkdir -p "${RUNNER_TEMP}/qodana/caches" + mkdir -p "${RUNNER_TEMP}/qodana/results" + - name: Detect Qodana Cloud token + id: qodana-token + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} + run: | + if [ -n "${QODANA_TOKEN:-}" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + - name: 'Qodana Scan' - uses: JetBrains/qodana-action@v2025.3 + if: ${{ steps.qodana-token.outputs.present == 'true' }} + uses: JetBrains/qodana-action@v2026.1 with: pr-mode: false env: QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} QODANA_ENDPOINT: 'https://qodana.cloud' + + - name: 'Skip Qodana Scan (missing cloud token)' + if: ${{ steps.qodana-token.outputs.present != 'true' }} + run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository." diff --git a/.github/workflows/copilot.yml b/.github/workflows/copilot.yml new file mode 100644 index 00000000..6177bd06 --- /dev/null +++ b/.github/workflows/copilot.yml @@ -0,0 +1,39 @@ +name: Copilot Task Dispatcher +on: + workflow_dispatch: + inputs: + task: + description: 'The task description for Copilot' + 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 <- - --health-cmd="mysqladmin ping -h 127.0.0.1 -proot" - --health-interval=10s - --health-timeout=5s - --health-retries=10 steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - php-version: '8.2' - extensions: mysqli, curl, openssl, json, redis + node-version: 22 + cache: npm + cache-dependency-path: services/edge-agent/package-lock.json + + - name: Install native build tools + run: | + set -euo pipefail + if command -v make >/dev/null 2>&1 && command -v g++ >/dev/null 2>&1; then + exit 0 + fi + + if ! command -v apt-get >/dev/null 2>&1; then + echo "make and g++ are required to install node-pty, but apt-get is not available on this runner." >&2 + exit 1 + fi + + apt_cmd=(apt-get) + if [ "$(id -u)" -ne 0 ]; then + if ! command -v sudo >/dev/null 2>&1; then + echo "make and g++ are missing, and sudo is not available to install them." >&2 + exit 1 + fi + apt_cmd=(sudo apt-get) + fi + + "${apt_cmd[@]}" update + "${apt_cmd[@]}" install -y --no-install-recommends build-essential python3 + + - name: Install dependencies + working-directory: services/edge-agent + run: npm ci + + - name: Run edge agent tests + working-directory: services/edge-agent + run: npm test + + edge-broker: + name: Edge Broker (required) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Materialize CI compose env files + run: | + set -euo pipefail + cp .github/ci.env .env + cp .github/ci.env.staging .env.staging + + - name: Validate compose contracts + run: | + docker compose -f docker-compose.yml -f docker-compose.prod.yml config > /dev/null + docker compose -f docker-compose.example.yml config > /dev/null + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: services/edge-broker/package-lock.json + + - name: Install dependencies + working-directory: services/edge-broker + run: npm ci + + - name: Run edge broker tests + working-directory: services/edge-broker + run: npm test + + edge-gateway-backend: + name: Edge Gateway Backend (required) + runs-on: ubuntu-latest + env: + COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml + COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }} + TRAEFIK_WEB_PORT: "18080" + TRAEFIK_WEBSECURE_PORT: "18443" + TRAEFIK_WEBSECURE_STAGING_PORT: "18433" + TRAEFIK_METRICS_PORT: "19100" + EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Materialize CI compose env files + run: | + set -euo pipefail + cp .github/ci.env .env + cp .github/ci.env.staging .env.staging + printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Boot local stack + run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy + + - name: Sync PHP app checkout + run: > + tar + --exclude='./vendor' + --exclude='./.phpunit.cache' + --exclude='./build/logs' + -C services/nginx/app -cf - . + | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf - - name: Resolve dependencies - working-directory: services/nginx/app - run: composer update --no-interaction --prefer-dist + run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress" - - name: Run integration tests - working-directory: services/nginx/app + - name: Verify edge gateway test files + run: > + docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc + "cd /var/www/html && + php -r '\$composer = json_decode(file_get_contents(\"composer.json\"), true); echo \"Composer scripts: \", implode(\",\", array_keys(\$composer[\"scripts\"] ?? [])), PHP_EOL;' && + find tests/Api -maxdepth 1 -type f -name 'EdgeGateway*ApiTest.php' -print && + test -f tests/Api/EdgeGatewayAgentApiTest.php && + test -f tests/Api/EdgeGatewayBrokerApiTest.php && + test -f tests/Api/EdgeGatewayOperatorApiTest.php" + + - name: Run edge gateway API tests + run: > + docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc + "cd /var/www/html && + RUN_API_TESTS=1 + API_TEST_BOOTSTRAP_SCHEMA=1 + API_TEST_ALLOW_LIVE_DB=1 + CONFIG_DB_TARGET=debug + CONFIG_DB_HOST=mysql-debug + CONFIG_DB_USER=\${CONFIG_DB_USER:-root} + CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password} + CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug} + CONFIG_DB_PORT=3306 + CONFIG_DB_DEBUG_HOST=mysql-debug + CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root} + CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password} + CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} + CONFIG_DB_DEBUG_PORT=3306 + API_TEST_REQUEST_TIMEOUT=180 + EDGE_GATEWAY_VIEW_CACHE_TTL=0 + EDGE_BROKER_URL= + vendor/bin/pest + tests/Api/EdgeGatewayAgentApiTest.php + tests/Api/EdgeGatewayBrokerApiTest.php + tests/Api/EdgeGatewayOperatorApiTest.php + --colors=always" + + - name: Run edge gateway integration tests + run: > + docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc + "cd /var/www/html && + RUN_INTEGRATION_TESTS=1 + CONFIG_DB_TARGET=debug + CONFIG_DB_HOST=mysql-debug + CONFIG_DB_USER=\${CONFIG_DB_USER:-root} + CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password} + CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug} + CONFIG_DB_PORT=3306 + CONFIG_DB_DEBUG_HOST=mysql-debug + CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root} + CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password} + CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} + CONFIG_DB_DEBUG_PORT=3306 + EDGE_BROKER_URL= + vendor/bin/pest tests/Integration/EdgeGateway --colors=always" + + - name: Run edge gateway E2E smoke + run: | + set -euo pipefail + compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}" + runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + docker rm -f "$runner" >/dev/null 2>&1 || true + trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT + docker create \ + --name "$runner" \ + --network "${compose_project}_default" \ + -e COMPOSE_FILE="$COMPOSE_FILE" \ + -e COMPOSE_PROJECT_NAME="$compose_project" \ + -e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \ + -e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \ + -e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \ + -e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \ + -e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \ + -e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \ + -e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \ + -e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -w /workspace \ + node:22-alpine \ + sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs" + docker cp . "$runner:/workspace" + docker start "$runner" >/dev/null + docker logs -f "$runner" + exit_code="$(docker wait "$runner")" + exit "$exit_code" + + - name: Tear down local stack + if: always() + run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v + + release-manager-gate: + name: Release Manager gate + runs-on: ubuntu-latest + needs: [php, edge-agent, edge-broker, edge-gateway-backend] + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + + steps: + - name: Record Release Manager API gate + run: | + set -euo pipefail + test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) + curl --fail --show-error --silent \ + --connect-timeout 10 \ + --retry 5 \ + --retry-all-errors \ + --retry-delay 15 \ + --retry-max-time 300 \ + -X POST "$RELEASE_MANAGER_GATE_URL" \ + -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ + -H "Content-Type: application/json" \ + --data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}" env: - RUN_INTEGRATION_TESTS: '1' - REDIS_CONFIG_HOST: 127.0.0.1 - REDIS_CONFIG_DATABASE: '0' - REDIS_CONFIG_PASSWORD: '' - CONFIG_DB_HOST: 127.0.0.1 - CONFIG_DB_USER: root - CONFIG_DB_PASSWORD: root - CONFIG_DB_DATABASE: app_test - run: composer test:integration + RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }} + RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_BRANCH: ${{ github.ref_name }} + RELEASE_EXPECTED_COMMIT: ${{ github.sha }} + RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.gitignore b/.gitignore index 777f5acf..e1f233f2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,8 @@ /services/php/logs/ /.idea/ .env -/services/caddy/logs* \ No newline at end of file +/services/caddy/logs* +.env.old +/.tmp/ +/.env.staging +/services/nginx/app/storage/replication-bootstrap.json \ No newline at end of file diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 38ca5fe5..0bbab60e 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -1,148 +1,28 @@ -### Copenhagen Truck Wash API — Development Guidelines (Project‑specific) + -#### 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//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`. diff --git a/.tmp-db-clone/n8n-database.sqlite b/.tmp-db-clone/n8n-database.sqlite new file mode 100644 index 00000000..102ce97a Binary files /dev/null and b/.tmp-db-clone/n8n-database.sqlite differ diff --git a/Dockerfile b/Dockerfile index fb3e0914..66cde638 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,8 @@ COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer # Runtime bootstrap: lightweight entrypoint to ensure Composer deps exist when app is bind-mounted COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -RUN chmod +x /usr/local/bin/docker-entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \ + && chmod +x /usr/local/bin/docker-entrypoint.sh # Install PHP dependencies through Composer (only where composer.json exists) # Main app dependencies @@ -72,4 +73,4 @@ EXPOSE 80 443 ENTRYPOINT ["docker-entrypoint.sh"] # Start services when no command is provided (docker-compose overrides this with ["php-fpm"]) -CMD ["php-fpm"] \ No newline at end of file +CMD ["php-fpm"] diff --git a/Dockerfile.coolify-api b/Dockerfile.coolify-api new file mode 100644 index 00000000..e768acf4 --- /dev/null +++ b/Dockerfile.coolify-api @@ -0,0 +1,73 @@ +FROM php:8.2.15-fpm + +WORKDIR /var/www/html + +COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + $PHPIZE_DEPS \ + ca-certificates \ + curl \ + default-mysql-client \ + git \ + imagemagick \ + libfreetype6-dev \ + libjpeg62-turbo-dev \ + libmagickcore-dev \ + libmagickwand-dev \ + libonig-dev \ + libpng-dev \ + libssl-dev \ + libxml2-dev \ + libzip-dev \ + mariadb-client \ + nginx \ + pkg-config \ + redis-tools \ + unzip \ + zip; \ + update-ca-certificates; \ + docker-php-ext-configure gd --with-freetype --with-jpeg; \ + docker-php-ext-install -j"$(nproc)" \ + bcmath \ + exif \ + gd \ + mbstring \ + mysqli \ + pcntl \ + pdo_mysql \ + sockets \ + zip; \ + pecl install imagick-3.7.0 redis; \ + docker-php-ext-enable imagick redis; \ + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $PHPIZE_DEPS; \ + rm -rf /var/lib/apt/lists/* + +COPY services/nginx/app/ /var/www/html/ +COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf +COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start + +RUN set -eux; \ + sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \ + chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \ + COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \ + if [ -f /var/www/html/modules/washcertificates/composer.json ]; then \ + COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html/modules/washcertificates; \ + fi; \ + COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \ + php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \ + chown -R www-data:www-data /var/www/html; \ + chmod -R 755 /var/www/html + +ENV APP_DIR=/var/www/html \ + MODULE_DIR=/var/www/html/modules/washcertificates \ + AUTO_COMPOSER_INSTALL=false \ + COMPOSER_ALLOW_SUPERUSER=1 + +EXPOSE 80 + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["coolify-api-start"] diff --git a/README.md b/README.md index eae69567..9007c42e 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Backend API for Copenhagen Truck Wash services. - PHP 8.2 CLI (optional, for host-side testing) ### Local Development -To bring up the minimal development stack (Traefik, Redis, Caddy, and one PHP worker): +To bring up the minimal development stack (Traefik, Redis, MySQL debug DB, Caddy, and one PHP worker): ```powershell -docker compose up -d traefik redis php1 caddy +docker compose up -d traefik redis mysql-debug php1 caddy ``` The API is accessible at: @@ -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 +``` + +```bash +./scripts/test-gateway.sh start --install-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 @@ -37,6 +63,18 @@ Configuration is primarily managed via environment variables. - `services/nginx/app/config.php` loads configuration from the environment (requires `USE_ENV=true`). - `php1` performs an automatic `composer install` on startup if `AUTO_COMPOSER_INSTALL=true`. +#### Environment Change Runbook +When updating `.env` values used by PHP containers (for example e-conomic tokens), recreate affected services so Docker applies the new env: + +```powershell +docker compose up -d --force-recreate php1 php2 php3 php4 php5 php-cron +``` + +#### Edge Broker Public URL +Set `EDGE_PUBLIC_BROKER_URL` to the public route that serves the edge broker, including the path prefix handled by the proxy. Local Traefik uses `http://localhost/api/edge-broker`; production routes use the public broker prefix, for example `https://api.truckwash.dk/edge-broker`. + +The browser terminal connects to the exact advertised `EDGE_PUBLIC_BROKER_URL` plus `/ws/browser-shell`. That URL must be routable through the proxy to the edge-broker service. Do not rely on derived `/api/edge-broker` fallback paths outside the local Traefik setup. + ## Testing The project now uses [Pest](https://pestphp.com/) as the primary test runner in `services/nginx/app`. @@ -48,9 +86,20 @@ All commands are run from `services/nginx/app`: composer test composer test:unit composer test:integration +composer test:api composer test:coverage ``` +For local Docker development, run the PHP suites inside `php1`: + +```powershell +docker exec php1 sh -lc "cd /var/www/html && composer test:unit" +docker exec php1 sh -lc "cd /var/www/html && composer test:integration" +docker exec php1 sh -lc "cd /var/www/html && composer test:api" +docker exec php1 sh -lc "cd /var/www/html && composer run-script test:api:edge" +docker exec php1 sh -lc "cd /var/www/html && composer run-script test:integration:edge" +``` + Integration tests are opt-in and should be run with required services available: ```powershell @@ -58,9 +107,106 @@ $env:RUN_INTEGRATION_TESTS='1' composer test:integration ``` +### Edge Gateway Regression Coverage +The dedicated backend regression lane for the PHP edge gateway stack is split into: + +- API contract tests for operator, agent, and broker-facing routes +- DB-backed integration tests for install sessions, heartbeats, tasks, logs, statistics, and shell persistence +- a local dockerized smoke that runs the real PHP edge agent against the local backend and broker + +Run the targeted PHP suites inside `php1`: + +```powershell +docker exec php1 sh -lc "cd /var/www/html && composer run-script test:api:edge" +docker exec php1 sh -lc "cd /var/www/html && composer run-script test:integration:edge" +``` + +Run the full local smoke from `backend-php` on the host: + +```powershell +node .\scripts\edge-gateway-e2e.mjs +``` + +The E2E smoke expects the local compose stack and Docker daemon to be available. It boots a disposable gateway container, waits for a real heartbeat, validates live operations and telemetry, and verifies browser shell transcript persistence. + +### 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 +``` + +```bash +node ./scripts/staging-edge-gateway-smoke.mjs --install-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=` + +### API Test Suite +The `Api` suite exercises real HTTP endpoints instead of calling route handlers in-process. + +- `composer test:api` enables `RUN_API_TESTS=1` automatically. +- By default the suite starts a temporary PHP server with `php -S 127.0.0.1:18080 index.php` and hits the app over HTTP. +- The suite covers the real router, request parsing, auth headers, status codes, and JSON response envelopes. +- Tests run serially and use explicit database/Redis fixtures plus reverse-order cleanup instead of transaction rollbacks. +- The default phase-1 coverage includes `/ping`, auth session routes, departments, department categories, and orders CRUD including the legacy `PUT /order` alias. +- API server logs are written to `services/nginx/app/build/logs/api-server.out.log` and `services/nginx/app/build/logs/api-server.err.log`. + +To point the suite at an already-running base URL instead of the self-started PHP server: + +```powershell +$env:API_TEST_BASE_URL='http://127.0.0.1:18080' +composer test:api +``` + +The suite requires an initialized application schema. Redis-backed flows are used when Redis is configured, but the suite can still boot without `caddy` or the full reverse-proxy stack. + +### Run Tests Against A Cloned Live DB (Docker-Isolated) +Use the helper scripts in `scripts/` to: +1. Clone the configured live DB into a local MySQL Docker container. +2. Start an isolated Redis container. +3. Run tests in a separate temporary PHP Docker container pointed at that clone. + +PowerShell: +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\clone-live-db-and-test.ps1 -Force +``` + +Bash: +```bash +FORCE=1 ./scripts/clone-live-db-and-test.sh +``` + +Optional overrides: +- Test command: `-TestCommand "composer test:integration"` or `TEST_COMMAND="composer test:integration"` +- Keep containers after run: `-KeepContainers` or `KEEP_CONTAINERS=1` +- Skip image build: `-SkipBuild` or `SKIP_BUILD=1` +- Force image rebuild: `-ForceBuild` or `FORCE_BUILD=1` + +### Clone Live DB Into `mysql-debug` Service +To refresh the docker-compose debug DB from live credentials in `.env`: + +PowerShell: +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\clone-live-to-debug-db.ps1 -Force +``` + +Bash: +```bash +FORCE=1 ./scripts/clone-live-to-debug-db.sh +``` + ### Test Layout - `tests/Unit/*`: isolated unit and route-level behavior tests. - `tests/Integration/*`: Redis/DB-backed tests intended for Docker/CI environments. +- `tests/Api/*`: real HTTP endpoint tests that boot a temporary PHP server and assert full request/response behavior. +- `tests/Api/api_coverage_manifest.php`: selected phase-1 endpoint manifest used by the API meta-test to enforce happy-path and failure coverage. - `tests//*`: legacy procedural scripts retained during migration; keep them runnable until matching Pest coverage exists. ## Logs & Monitoring @@ -71,4 +217,22 @@ composer test:integration ## API Documentation - **OpenAPI:** The authoritative OpenAPI 3.0 contract is at `openapi.yaml`. +- **Self-Serve Module Guide:** Implementation and API guide at `services/nginx/app/modules/selfserve/selfserve.md`. - **Writerside:** Documentation projects are located in `/Writerside` and `/Writerside2`. +- **Generated Writerside API Reference:** The active Writerside project lives in `/documentation` and is generated from `openapi.yaml`. + +### Writerside OpenAPI Generation Workflow +Prerequisite: +- Python 3 with PyYAML (`pip install pyyaml`) + +Run from repository root: + +```powershell +python scripts/generate_writerside_openapi_docs.py generate +python scripts/generate_writerside_openapi_docs.py check +``` + +Contribution rule: +1. Update `openapi.yaml`. +2. Regenerate docs (`python scripts/generate_writerside_openapi_docs.py generate`). +3. Verify (`python scripts/generate_writerside_openapi_docs.py check`). diff --git a/Writerside2/v.list.style-guide.yaml b/Writerside2/v.list.style-guide.yaml new file mode 100644 index 00000000..65d3ea81 --- /dev/null +++ b/Writerside2/v.list.style-guide.yaml @@ -0,0 +1,76 @@ +# Copenhagen Truck Wash API — Writerside Style Guide +# This file defines the style rules for our documentation. +# For more details, see https://vale.sh/docs/topics/styles/#extension-points + +# --- RULE: Encourage descriptive language (existence) --- +extends: existence +message: "Avoid using '%s'. Try to be more descriptive or direct." +level: warning +ignorecase: true +tokens: + - simply + - just + - easy + - easily + - simple + - basically + - obviously + - actually + - very + - really + - pretty + - quite + - rather + +--- +# --- RULE: Flag incomplete documentation (existence) --- +extends: existence +message: "Incomplete documentation: '%s' found. Please provide comprehensive details." +level: error +ignorecase: true +tokens: + - TBD + - TO BE DETERMINED + - TODO + - FIXME + - Placeholder + - Coming soon + +--- +# --- RULE: Preferred terminology (substitution) --- +extends: substitution +message: "Consider using '%s' instead of its informal or less descriptive counterpart." +level: suggestion +ignorecase: true +swap: + check[ -]box: checkbox + right-click menu: context menu|popup menu + webpage: page + click on: click|select + press: press|select + setup: set up + log[ -]in: sign in|log in + e-mail: email + interface: UI|interface + the following: : + utilize: use + functionality: feature|function + additional: more|extra + +--- +# --- RULE: Avoid jargon and filler (existence) --- +extends: existence +message: "Avoid jargon or filler phrases like '%s'." +level: warning +ignorecase: true +tokens: + - leverage + - bandwidth + - synergy + - best-in-class + - cutting-edge + - robust + - state-of-the-art + - mission-critical + - go-forward + - paradigm shift diff --git a/config.example.php b/config.example.php index 8b77dae1..f7f6b01b 100644 --- a/config.example.php +++ b/config.example.php @@ -4,12 +4,13 @@ $CONFIG_DB = [ 'user' => '', // Username of the database server e.g. root 'password' => '', // Password of the database server e.g. password123 'database' => '', // Name of the database e.g. my_database + 'port' => 3306, // Port of the database server e.g. 3306 'ssl_mode' => 'DISABLED' // SSL mode for mysqldump: DISABLED, PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY ]; $DEBUG = true; // Set to true to enable debugging (Error messages will be shown, and this should never be used in production) $USE_PROD_ECONOMIC_IN_DEBUG = true; // Set to true to use the production economic API in debug mode $ENCRYPTION_KEY = ''; // 44 Characters long encryption key -$CORS = '*'; // Set to the domain that should be allowed to access the API e.g. https://example.com +$CORS = '*'; // Set to comma-separated allowed origins e.g. https://example.com,https://api-v2.truckwash.io $ECONOMIC_API = [ 'app_access_grant' => '', // Economic API access grant token (1) 'app_access_grant2' => '', // Economic API access grant token (2) @@ -33,8 +34,10 @@ $MINIO = [ $SLACK_DEFAULT_WEBHOOK = ''; // Default Slack webhook URL e.g. https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX $REDIS_CONFIG = [ 'host' => '', // Redis host (IP address) + 'user' => '', // Redis user 'database' => 0, // Redis database number (0-15) - 'password' => '' // Redis password + 'password' => '', // Redis password + 'port' => 6379 // Redis port ]; // Set the timezone @@ -50,6 +53,7 @@ if (isset($_ENV['USE_ENV']) && $_ENV['USE_ENV'] === 'true') { 'CONFIG_DB_USER' => 'user', 'CONFIG_DB_PASSWORD' => 'password', 'CONFIG_DB_DATABASE' => 'database', + 'CONFIG_DB_PORT' => 'port', 'CONFIG_DB_SSL_MODE' => 'ssl_mode', 'DEBUG' => 'DEBUG', 'ENCRYPTION_KEY' => 'ENCRYPTION_KEY', @@ -66,17 +70,39 @@ if (isset($_ENV['USE_ENV']) && $_ENV['USE_ENV'] === 'true') { 'SLACK_DEFAULT_WEBHOOK' => 'SLACK_DEFAULT_WEBHOOK', 'REDIS_CONFIG_HOST' => 'host', 'REDIS_CONFIG_DATABASE' => 'database', - 'REDIS_CONFIG_PASSWORD' => 'password' + 'REDIS_CONFIG_PASSWORD' => 'password', + 'REDIS_CONFIG_PORT' => 'port', + 'REDIS_CONFIG_USER' => 'user', + 'REDIS_CONFIG_DEBUG_PASSWORD' => 'debug_password' ]; + $dbTarget = strtolower(trim((string)($_ENV['CONFIG_DB_TARGET'] ?? 'live'))); + if ($dbTarget !== 'live' && $dbTarget !== 'debug') { + $dbTarget = 'live'; + } + + $resolveDbValue = function (string $key) use ($dbTarget): string { + $liveKey = 'CONFIG_DB_' . $key; + $debugKey = 'CONFIG_DB_DEBUG_' . $key; + $liveValue = (string)($_ENV[$liveKey] ?? ''); + $debugValue = (string)($_ENV[$debugKey] ?? ''); + + if ($dbTarget === 'debug' && $debugValue !== '') { + return $debugValue; + } + + return $liveValue; + }; + /** - * Set the db configuration + * Set the db configuration from selected target (live/debug) */ $CONFIG_DB = [ - 'host' => $_ENV['CONFIG_DB_HOST'], - 'user' => $_ENV['CONFIG_DB_USER'], - 'password' => $_ENV['CONFIG_DB_PASSWORD'], - 'database' => $_ENV['CONFIG_DB_DATABASE'], - 'ssl_mode' => $_ENV['CONFIG_DB_SSL_MODE'] ?? 'DISABLED' + 'host' => $resolveDbValue('HOST'), + 'user' => $resolveDbValue('USER'), + 'password' => $resolveDbValue('PASSWORD'), + 'database' => $resolveDbValue('DATABASE'), + 'port' => (int)($resolveDbValue('PORT') ?: 3306), + 'ssl_mode' => $resolveDbValue('SSL_MODE') !== '' ? $resolveDbValue('SSL_MODE') : 'DISABLED' ]; /** * Set the debug configuration @@ -122,16 +148,31 @@ if (isset($_ENV['USE_ENV']) && $_ENV['USE_ENV'] === 'true') { * Set the Slack default webhook */ $SLACK_DEFAULT_WEBHOOK = $_ENV['SLACK_DEFAULT_WEBHOOK']; + $resolveRedisValue = function (string $key) use ($dbTarget): string { + $liveKey = 'REDIS_CONFIG_' . $key; + $debugKey = 'REDIS_CONFIG_DEBUG_' . $key; + $liveValue = (string)($_ENV[$liveKey] ?? ''); + $debugValue = (string)($_ENV[$debugKey] ?? ''); + + if ($dbTarget === 'debug' && $debugValue !== '') { + return $debugValue; + } + + return $liveValue; + }; + /** * Set the Redis configuration */ $REDIS_CONFIG = [ - 'host' => $_ENV['REDIS_CONFIG_HOST'], - 'database' => $_ENV['REDIS_CONFIG_DATABASE'], - 'password' => $_ENV['REDIS_CONFIG_PASSWORD'] + 'host' => $resolveRedisValue('HOST'), + 'user' => $resolveRedisValue('USER'), + 'database' => $resolveRedisValue('DATABASE'), + 'password' => $resolveRedisValue('PASSWORD'), + 'port' => (int)($resolveRedisValue('PORT') ?: 6379) ]; // Set the timezone date_default_timezone_set($_ENV['CONFIG_TIMEZONE']) ?? 'Europe/Copenhagen'; -} \ No newline at end of file +} diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 7789d95b..e5b223e0 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,73 +1,175 @@ -version: '3.9' - services: - app: - build: - context: . - dockerfile: Dockerfile - container_name: php_app - ports: - - "8080:80" # Map port 80 in the container to port 8080 on the host - volumes: - - .:/var/www/html # Mount the current directory to the container - - ./logs:/var/log/apache2 # Mount logs to be accessible on the host - environment: - # Pass environment variables for the application - DEBUG: true - USE_ENV: true - CONFIG_TIMEZONE: "Europe/Copenhagen" # Set the timezone - # Database configuration - CONFIG_DB_HOST: db - CONFIG_DB_USER: root - CONFIG_DB_PASSWORD: example_password - CONFIG_DB_DATABASE: example_db - # Security configuration - ENCRYPTION_KEY: "" # Set the encryption key - CORS: "*" - # Economic API credentials - ECONOMIC_API_APP_ACCESS_GRANT: "" # Economic API access grant token (1) - ECONOMIC_API_APP_ACCESS_GRANT2: "" # Economic API access grant token (2) - ECONOMIC_API_APP_SECRET_TOKEN: "" # Economic API secret token - # WordPress configuration - WORDPRESS_API_URL: "" # WordPress API URL (e.g. https://www.example.com/wp-admin/admin-ajax.php) - WORDPRESS_STATIC_TOKEN: "" # WordPress static token for authentication - EMAIL_WASH_CERTIFICATE_TOKEN: "" # Email wash certificate token - # MINIO configuration - MINIO_ENDPOINT: "" # Minio endpoint (e.g. http://localhost:9000) - MINIO_ACCESS_KEY: "" # Minio access key - MINIO_SECRET_KEY: "" # Minio secret key - # Redis configuration - REDIS_CONFIG_HOST: redis - REDIS_CONFIG_DATABASE: 0 - REDIS_CONFIG_PASSWORD: "" + traefik: + image: traefik:2.11 + container_name: traefik + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./services/traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - ./services/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro + - ./services/traefik/acme.json:/acme.json + labels: + - "traefik.enable=true" + # Dashboard configuration (replace with your domain) + - "traefik.http.routers.traefik.rule=Host(`traefik.example.com`)" + - "traefik.http.routers.traefik.entrypoints=websecure" + - "traefik.http.routers.traefik.tls=true" + - "traefik.http.routers.traefik.tls.certresolver=le" + - "traefik.http.routers.traefik.service=api@internal" - # Slack configuration - SLACK_DEFAULT_WEBHOOK: "" # Slack default webhook URL - depends_on: - - db - - redis + redis: + image: redis:7 + container_name: redis + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 - db: - image: mysql:8.0 - container_name: mysql - ports: - - "3306:3306" # Map MySQL container's port 3306 to the host - volumes: - - db_data:/var/lib/mysql # Persist database data - environment: - MYSQL_ROOT_PASSWORD: example_password - MYSQL_DATABASE: example_db - MYSQL_USER: app_user - MYSQL_PASSWORD: app_password + mysql: + image: mysql:8.4 + container_name: mysql + environment: + MYSQL_ROOT_PASSWORD: ${CONFIG_DB_PASSWORD:-example_root_password} + MYSQL_DATABASE: ${CONFIG_DB_DATABASE:-example_db} + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin -u root ping --silent"] + interval: 10s + timeout: 5s + retries: 10 - redis: - image: redis:6.2 - container_name: redis - ports: - - "6379:6379" # Map Redis container's port 6379 to the host - volumes: - - redis_data:/data # Persist Redis data + edge-broker: + build: + context: . + dockerfile: services/edge-broker/Dockerfile + container_name: edge-broker + environment: + EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict} + EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy} + EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env} + labels: + - "traefik.enable=true" + - "traefik.http.routers.edge-broker-api.rule=Host(`api.example.com`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-api.entrypoints=websecure" + - "traefik.http.routers.edge-broker-api.tls=true" + - "traefik.http.routers.edge-broker-api.tls.certresolver=le" + - "traefik.http.routers.edge-broker-api.middlewares=edge-broker-strip" + - "traefik.http.routers.edge-broker-api.service=edge-broker" + - "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local.entrypoints=web" + - "traefik.http.routers.edge-broker-local.middlewares=edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local.service=edge-broker" + - "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker" + - "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker" + - "traefik.http.services.edge-broker.loadbalancer.server.port=4300" + + + caddy: + image: caddy:2.7.6-alpine + container_name: caddy + depends_on: + - php1 + volumes: + - ./services/nginx/app:/var/www/html + - ./services/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./services/caddy/logs:/var/log/caddy + labels: + - "traefik.enable=true" + # API routing (replace with your domain) + - "traefik.http.routers.api.rule=Host(`api.example.com`)" + - "traefik.http.routers.api.entrypoints=websecure" + - "traefik.http.routers.api.tls=true" + - "traefik.http.routers.api.tls.certresolver=le" + - "traefik.http.routers.api.service=caddy" + # HTTP to HTTPS redirect + - "traefik.http.routers.api-http.rule=Host(`api.example.com`)" + - "traefik.http.routers.api-http.entrypoints=web" + - "traefik.http.routers.api-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.api-http.service=caddy" + # Local development (HTTP only) + - "traefik.http.routers.local.rule=Host(`localhost`)" + - "traefik.http.routers.local.entrypoints=web" + - "traefik.http.routers.local.service=caddy" + + php1: + build: + context: . + dockerfile: services/php/Dockerfile + container_name: php1 + depends_on: + - redis + - mysql + - edge-broker + command: ["php-fpm"] + env_file: + - .env.example + environment: + AUTO_COMPOSER_INSTALL: "true" + EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} + EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env} + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs:/var/log/php + + php-cron: + build: + context: . + dockerfile: services/php/Dockerfile + container_name: php-cron + depends_on: + - redis + - mysql + - edge-broker + command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"] + env_file: + - .env.example + environment: + AUTO_COMPOSER_INSTALL: "false" + EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} + EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env} + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + + n8n: + image: n8nio/n8n:latest + container_name: n8n + restart: always + environment: + - N8N_HOST=n8n.example.com + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - NODE_ENV=production + - WEBHOOK_URL=https://n8n.example.com/ + - GENERIC_TIMEZONE=${CONFIG_TIMEZONE:-Europe/Copenhagen} + volumes: + - n8n_data:/home/node/.n8n + labels: + - "traefik.enable=true" + # n8n over HTTPS (replace with your domain and cert resolver) + - "traefik.http.routers.n8n.rule=Host(`n8n.example.com`)" + - "traefik.http.routers.n8n.entrypoints=websecure" + - "traefik.http.routers.n8n.tls=true" + - "traefik.http.routers.n8n.tls.certresolver=le" + - "traefik.http.routers.n8n.service=n8n" + # n8n HTTP to HTTPS redirect + - "traefik.http.routers.n8n-http.rule=Host(`n8n.example.com`)" + - "traefik.http.routers.n8n-http.entrypoints=web" + - "traefik.http.routers.n8n-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.n8n-http.service=n8n" + # n8n service port + - "traefik.http.services.n8n.loadbalancer.server.port=5678" volumes: - db_data: # Persistent data for MySQL - redis_data: # Persistent data for Redis \ No newline at end of file + mysql_data: + redis_data: + n8n_data: diff --git a/docker-compose.prod.standalone.yml b/docker-compose.prod.standalone.yml new file mode 100644 index 00000000..33a89c6f --- /dev/null +++ b/docker-compose.prod.standalone.yml @@ -0,0 +1,440 @@ +services: + traefik: + image: traefik:2.11 + container_name: traefik + ports: + - "80:80" + - "443:443" + - "4433:4433" + - "9100:9100" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./services/traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - ./services/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro + - ./services/traefik/acme.json:/acme.json + - ./services/traefik/acme-io.json:/acme-io.json + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik.rule=Host(`traefik.truckwash.dk`)" + - "traefik.http.routers.traefik.entrypoints=websecure" + - "traefik.http.routers.traefik.tls=true" + - "traefik.http.routers.traefik.tls.certresolver=le" + - "traefik.http.routers.traefik.service=api@internal" + - "traefik.http.routers.traefik.middlewares=dashboard-allow-local@file,dashboard-auth@file" + - "traefik.http.routers.traefik-http.rule=Host(`traefik.truckwash.dk`)" + - "traefik.http.routers.traefik-http.entrypoints=web" + - "traefik.http.routers.traefik-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.traefik-http.service=api@internal" + - "traefik.http.routers.traefik-local.rule=Host(`traefik.localhost`)" + - "traefik.http.routers.traefik-local.entrypoints=web" + - "traefik.http.routers.traefik-local.service=api@internal" + - "traefik.http.routers.traefik-local.middlewares=dashboard-allow-local@file,dashboard-auth@file" + + redis: + image: redis:7 + container_name: redis + volumes: + - nnks_redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + redis-staging: + image: redis:7 + container_name: redis-staging + volumes: + - nnks_redis_staging:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + mysql-debug: + image: mysql:8.4 + container_name: mysql-debug + environment: + MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password} + MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} + ports: + - "3307:3306" + volumes: + - db_debug_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin -u root ping --silent"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + edge-broker: + build: + context: . + dockerfile: services/edge-broker/Dockerfile + container_name: edge-broker + environment: + EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager} + EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy} + EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} + labels: + - "traefik.enable=true" + - "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-api.entrypoints=websecure" + - "traefik.http.routers.edge-broker-api.tls=true" + - "traefik.http.routers.edge-broker-api.tls.certresolver=le" + - "traefik.http.routers.edge-broker-api.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-api.priority=200" + - "traefik.http.routers.edge-broker-api.service=edge-broker" + - "traefik.http.routers.edge-broker-api-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-api-io.entrypoints=websecure" + - "traefik.http.routers.edge-broker-api-io.tls=true" + - "traefik.http.routers.edge-broker-api-io.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-api-io.priority=200" + - "traefik.http.routers.edge-broker-api-io.service=edge-broker" + - "traefik.http.routers.edge-broker-api-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-api-v2.entrypoints=websecure" + - "traefik.http.routers.edge-broker-api-v2.tls=true" + - "traefik.http.routers.edge-broker-api-v2.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-api-v2.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-api-v2.priority=200" + - "traefik.http.routers.edge-broker-api-v2.service=edge-broker" + - "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging" + - "traefik.http.routers.edge-broker-api-staging.tls=true" + - "traefik.http.routers.edge-broker-api-staging.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-api-staging.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-api-staging.priority=200" + - "traefik.http.routers.edge-broker-api-staging.service=edge-broker" + - "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local.entrypoints=web" + - "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local.priority=200" + - "traefik.http.routers.edge-broker-local.service=edge-broker" + - "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local-secure.entrypoints=websecure" + - "traefik.http.routers.edge-broker-local-secure.tls=true" + - "traefik.http.routers.edge-broker-local-secure.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local-secure.priority=200" + - "traefik.http.routers.edge-broker-local-secure.service=edge-broker" + - "traefik.http.routers.edge-broker-local-staging.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local-staging.entrypoints=websecure-staging" + - "traefik.http.routers.edge-broker-local-staging.tls=true" + - "traefik.http.routers.edge-broker-local-staging.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local-staging.priority=200" + - "traefik.http.routers.edge-broker-local-staging.service=edge-broker" + - "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker" + - "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker" + - "traefik.http.services.edge-broker.loadbalancer.server.port=4300" + + caddy: + image: caddy:2.7.6-alpine + container_name: caddy + depends_on: + - php1 + - php2 + - php3 + - php4 + - php5 + volumes: + - ./services/nginx/app:/var/www/html + - ./services/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./services/caddy/logs:/var/log/caddy + labels: + - "traefik.enable=true" + - "traefik.http.routers.api.rule=Host(`api.truckwash.dk`)" + - "traefik.http.routers.api.entrypoints=websecure" + - "traefik.http.routers.api.tls=true" + - "traefik.http.routers.api.tls.domains[0].main=api.truckwash.dk" + - "traefik.http.routers.api.tls.certresolver=le" + - "traefik.http.routers.api.service=caddy" + - "traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file" + - "traefik.http.routers.api-io.rule=Host(`api.truckwash.io`)" + - "traefik.http.routers.api-io.entrypoints=websecure" + - "traefik.http.routers.api-io.tls=true" + - "traefik.http.routers.api-io.tls.domains[0].main=api.truckwash.io" + - "traefik.http.routers.api-io.tls.certresolver=le_io" + - "traefik.http.routers.api-io.service=caddy" + - "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file" + - "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)" + - "traefik.http.routers.api-v2.entrypoints=websecure" + - "traefik.http.routers.api-v2.tls=true" + - "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io" + - "traefik.http.routers.api-v2.tls.certresolver=le_io" + - "traefik.http.routers.api-v2.service=caddy" + - "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file" + - "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)" + - "traefik.http.routers.api-http.entrypoints=web" + - "traefik.http.routers.api-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.api-http.service=caddy" + - "traefik.http.routers.local.rule=Host(`localhost`)" + - "traefik.http.routers.local.entrypoints=web" + - "traefik.http.routers.local.service=caddy" + - "traefik.http.routers.local.middlewares=secure-headers@file" + - "traefik.http.routers.local-secure.rule=Host(`localhost`)" + - "traefik.http.routers.local-secure.entrypoints=websecure" + - "traefik.http.routers.local-secure.tls=true" + - "traefik.http.routers.local-secure.service=caddy" + - "traefik.http.routers.local-secure.middlewares=secure-headers@file" + - "traefik.http.routers.local-api.rule=Host(`localhost`) && PathPrefix(`/api`)" + - "traefik.http.routers.local-api.entrypoints=web" + - "traefik.http.routers.local-api.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-api.service=caddy" + - "traefik.http.routers.local-api.priority=100" + - "traefik.http.routers.local-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)" + - "traefik.http.routers.local-api-secure.entrypoints=websecure" + - "traefik.http.routers.local-api-secure.tls=true" + - "traefik.http.routers.local-api-secure.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-api-secure.service=caddy" + - "traefik.http.routers.local-api-secure.priority=100" + - "traefik.http.services.caddy.loadbalancer.server.port=80" + + caddy-staging: + image: caddy:2.7.6-alpine + container_name: caddy-staging + depends_on: + - php-staging + volumes: + - ./services/nginx/staging:/var/www/html + - ./services/caddy/Caddyfile-staging:/etc/caddy/Caddyfile:ro + - ./services/caddy/logs-staging:/var/log/caddy + labels: + - "traefik.enable=true" + - "traefik.http.routers.api-staging.rule=Host(`api.truckwash.io`)" + - "traefik.http.routers.api-staging.entrypoints=websecure-staging" + - "traefik.http.routers.api-staging.tls=true" + - "traefik.http.routers.api-staging.tls.domains[0].main=api.truckwash.io" + - "traefik.http.routers.api-staging.tls.certresolver=le_io" + - "traefik.http.routers.api-staging.service=caddy-staging" + - "traefik.http.routers.api-staging.middlewares=secure-headers@file,api-ratelimit@file" + - "traefik.http.routers.local-staging.rule=Host(`localhost`)" + - "traefik.http.routers.local-staging.entrypoints=websecure-staging" + - "traefik.http.routers.local-staging.service=caddy-staging" + - "traefik.http.routers.local-staging.middlewares=secure-headers@file" + - "traefik.http.routers.local-staging-secure.rule=Host(`localhost`)" + - "traefik.http.routers.local-staging-secure.entrypoints=websecure-staging" + - "traefik.http.routers.local-staging-secure.tls=true" + - "traefik.http.routers.local-staging-secure.service=caddy-staging" + - "traefik.http.routers.local-staging-secure.middlewares=secure-headers@file" + - "traefik.http.routers.local-staging-api.rule=Host(`localhost`) && PathPrefix(`/api`)" + - "traefik.http.routers.local-staging-api.entrypoints=websecure-staging" + - "traefik.http.routers.local-staging-api.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-staging-api.service=caddy-staging" + - "traefik.http.routers.local-staging-api.priority=100" + - "traefik.http.routers.local-staging-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)" + - "traefik.http.routers.local-staging-api-secure.entrypoints=websecure-staging" + - "traefik.http.routers.local-staging-api-secure.tls=true" + - "traefik.http.routers.local-staging-api-secure.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-staging-api-secure.service=caddy-staging" + - "traefik.http.routers.local-staging-api-secure.priority=100" + - "traefik.http.services.caddy-staging.loadbalancer.server.port=80" + + php1: + build: + context: . + dockerfile: services/php/Dockerfile + 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 + + php2: + build: + context: . + dockerfile: services/php/Dockerfile + 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 + + php3: + build: + context: . + dockerfile: services/php/Dockerfile + 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 + + php4: + build: + context: . + dockerfile: services/php/Dockerfile + 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 + + php5: + build: + context: . + dockerfile: services/php/Dockerfile + 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 + + php-staging: + build: + context: . + dockerfile: services/php/Dockerfile + 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/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs-staging:/var/log/php + + php-cron: + build: + context: . + dockerfile: services/php/Dockerfile + 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: + image: portainer/portainer-ce:2.21.4 + container_name: portainer + profiles: + - dev + ports: + - "9443:9443" + - "9000:9000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - portainer_data:/data + + jaeger: + image: jaegertracing/all-in-one:1.53 + container_name: jaeger + profiles: + - dev + environment: + - COLLECTOR_ZIPKIN_HTTP_PORT=9411 + ports: + - "16686:16686" + + n8n: + image: n8nio/n8n:latest + container_name: n8n + restart: always + environment: + - N8N_HOST=n8n.truckwash.io + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - NODE_ENV=production + - WEBHOOK_URL=https://n8n.truckwash.io/ + - GENERIC_TIMEZONE=${CONFIG_TIMEZONE:-Europe/Copenhagen} + volumes: + - n8n_data:/home/node/.n8n + labels: + - "traefik.enable=true" + - "traefik.http.routers.n8n.rule=Host(`n8n.truckwash.io`)" + - "traefik.http.routers.n8n.entrypoints=websecure" + - "traefik.http.routers.n8n.tls=true" + - "traefik.http.routers.n8n.tls.certresolver=le_io" + - "traefik.http.routers.n8n.service=n8n" + - "traefik.http.routers.n8n-http.rule=Host(`n8n.truckwash.io`)" + - "traefik.http.routers.n8n-http.entrypoints=web" + - "traefik.http.routers.n8n-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.n8n-http.service=n8n" + - "traefik.http.services.n8n.loadbalancer.server.port=5678" + +volumes: + db_data: + db_debug_data: + nnks_redis: + nnks_redis_staging: + es_data: + portainer_data: + fleet-server-data: + elastic-agent-data: + n8n_data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index be496e01..e7d257d4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -27,5 +27,5 @@ services: ## docker compose up -d traefik caddy php1 php2 php3 php4 php5 db redis ## ## Notes: -## - Traefik uses Let’s Encrypt production. Ensure DNS A/AAAA records for api.truckwash.dk, api.truckwash.io and traefik.truckwash.dk point to this host and ports 80/443 are reachable. -## - The dashboard is protected by basic auth and an IP allowlist (defined in dynamic.yml). Replace the bcrypt hash before enabling in production. \ No newline at end of file +## - Traefik uses Let’s Encrypt production. Ensure DNS A/AAAA records for api.truckwash.dk, api.truckwash.io, api-v2.truckwash.io and traefik.truckwash.dk point to the expected ingress and ports 80/443 are reachable. +## - The dashboard is protected by basic auth and an IP allowlist (defined in dynamic.yml). Replace the bcrypt hash before enabling in production. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..5cfa5284 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,498 @@ + +services: + traefik: + image: traefik:2.11 + container_name: traefik + ports: + - "${TRAEFIK_WEB_PORT:-80}:80" + - "${TRAEFIK_WEBSECURE_PORT:-443}:443" + - "${TRAEFIK_WEBSECURE_STAGING_PORT:-4433}:4433" + # Prometheus metrics endpoint (local dev) + - "${TRAEFIK_METRICS_PORT:-9100}:9100" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./services/traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - ./services/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro + - ./services/traefik/acme.json:/acme.json + - ./services/traefik/acme-io.json:/acme-io.json + labels: + - "traefik.enable=true" + # Dashboard over HTTPS (production) + - "traefik.http.routers.traefik.rule=Host(`traefik.truckwash.dk`)" + - "traefik.http.routers.traefik.entrypoints=websecure" + - "traefik.http.routers.traefik.tls=true" + - "traefik.http.routers.traefik.tls.certresolver=le" + - "traefik.http.routers.traefik.service=api@internal" + - "traefik.http.routers.traefik.middlewares=dashboard-allow-local@file,dashboard-auth@file" + # Dashboard over HTTP (dev) -> redirect to HTTPS + - "traefik.http.routers.traefik-http.rule=Host(`traefik.truckwash.dk`)" + - "traefik.http.routers.traefik-http.entrypoints=web" + - "traefik.http.routers.traefik-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.traefik-http.service=api@internal" + # Local dashboard on traefik.localhost (HTTP only for dev) + - "traefik.http.routers.traefik-local.rule=Host(`traefik.localhost`)" + - "traefik.http.routers.traefik-local.entrypoints=web" + - "traefik.http.routers.traefik-local.service=api@internal" + - "traefik.http.routers.traefik-local.middlewares=dashboard-allow-local@file,dashboard-auth@file" + # Broker API (handled in edge-broker service) + - "traefik.http.routers.edge-broker.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker.entrypoints=websecure" + - "traefik.http.routers.edge-broker.tls=true" + - "traefik.http.routers.edge-broker.tls.certresolver=le" + - "traefik.http.routers.edge-broker.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker.priority=200" + - "traefik.http.routers.edge-broker.service=edge-broker" + - "traefik.http.routers.edge-broker-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-io.entrypoints=websecure" + - "traefik.http.routers.edge-broker-io.tls=true" + - "traefik.http.routers.edge-broker-io.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-io.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-io.priority=200" + - "traefik.http.routers.edge-broker-io.service=edge-broker" + - "traefik.http.routers.edge-broker-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-v2.entrypoints=websecure" + - "traefik.http.routers.edge-broker-v2.tls=true" + - "traefik.http.routers.edge-broker-v2.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-v2.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-v2.priority=200" + - "traefik.http.routers.edge-broker-v2.service=edge-broker" + - "traefik.http.routers.edge-broker-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-staging.entrypoints=websecure-staging" + - "traefik.http.routers.edge-broker-staging.tls=true" + - "traefik.http.routers.edge-broker-staging.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-staging.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-staging.priority=200" + - "traefik.http.routers.edge-broker-staging.service=edge-broker" + - "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local.entrypoints=web" + - "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local.priority=200" + - "traefik.http.routers.edge-broker-local.service=edge-broker" + - "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + + redis: + image: redis:7 + container_name: redis +# ports: +# - "6379:6379" + volumes: + - nnks_redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + redis-staging: + image: redis:7 + container_name: redis-staging +# ports: +# - "6380:6379" + volumes: + - nnks_redis_staging:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + mysql-debug: + image: mysql:8.4 + container_name: mysql-debug + profiles: [dev] + environment: + MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug} + MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} + ports: + - "3307:3306" + volumes: + - db_debug_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin -u root ping --silent"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + edge-broker: + build: + context: . + dockerfile: services/edge-broker/Dockerfile + container_name: edge-broker + environment: + EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict} + EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy} + EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env} + labels: + - "traefik.enable=true" + - "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-api.entrypoints=websecure" + - "traefik.http.routers.edge-broker-api.tls=true" + - "traefik.http.routers.edge-broker-api.tls.certresolver=le" + - "traefik.http.routers.edge-broker-api.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-api.priority=200" + - "traefik.http.routers.edge-broker-api.service=edge-broker" + - "traefik.http.routers.edge-broker-api-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-api-io.entrypoints=websecure" + - "traefik.http.routers.edge-broker-api-io.tls=true" + - "traefik.http.routers.edge-broker-api-io.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-api-io.priority=200" + - "traefik.http.routers.edge-broker-api-io.service=edge-broker" + - "traefik.http.routers.edge-broker-api-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-api-v2.entrypoints=websecure" + - "traefik.http.routers.edge-broker-api-v2.tls=true" + - "traefik.http.routers.edge-broker-api-v2.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-api-v2.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-api-v2.priority=200" + - "traefik.http.routers.edge-broker-api-v2.service=edge-broker" + - "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging" + - "traefik.http.routers.edge-broker-api-staging.tls=true" + - "traefik.http.routers.edge-broker-api-staging.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-api-staging.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-api-staging.priority=200" + - "traefik.http.routers.edge-broker-api-staging.service=edge-broker" + - "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local.entrypoints=web" + - "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local.priority=200" + - "traefik.http.routers.edge-broker-local.service=edge-broker" + - "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local-secure.entrypoints=websecure" + - "traefik.http.routers.edge-broker-local-secure.tls=true" + - "traefik.http.routers.edge-broker-local-secure.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local-secure.priority=200" + - "traefik.http.routers.edge-broker-local-secure.service=edge-broker" + - "traefik.http.routers.edge-broker-local-staging.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local-staging.entrypoints=websecure-staging" + - "traefik.http.routers.edge-broker-local-staging.tls=true" + - "traefik.http.routers.edge-broker-local-staging.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local-staging.priority=200" + - "traefik.http.routers.edge-broker-local-staging.service=edge-broker" + - "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker" + - "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker" + - "traefik.http.services.edge-broker.loadbalancer.server.port=4300" + + caddy: + image: caddy:2.7.6-alpine + container_name: caddy + depends_on: + - php1 + - php2 + - php3 + - php4 + - php5 + volumes: + - ./services/nginx/app:/var/www/html + - ./services/caddy:/etc/caddy:ro + - ./services/caddy/logs:/var/log/caddy + labels: + - "traefik.enable=true" + # Public API (HTTPS via Traefik + LE) + - "traefik.http.routers.api.rule=Host(`api.truckwash.dk`)" + - "traefik.http.routers.api.entrypoints=websecure" + - "traefik.http.routers.api.tls=true" + - "traefik.http.routers.api.tls.domains[0].main=api.truckwash.dk" + - "traefik.http.routers.api.tls.certresolver=le" + - "traefik.http.routers.api.service=caddy" + - "traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file" + # Public API (.io version) + - "traefik.http.routers.api-io.rule=Host(`api.truckwash.io`)" + - "traefik.http.routers.api-io.entrypoints=websecure" + - "traefik.http.routers.api-io.tls=true" + - "traefik.http.routers.api-io.tls.domains[0].main=api.truckwash.io" + - "traefik.http.routers.api-io.tls.certresolver=le_io" + - "traefik.http.routers.api-io.service=caddy" + - "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file" + # Public API (.io load-balanced gateway) + - "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)" + - "traefik.http.routers.api-v2.entrypoints=websecure" + - "traefik.http.routers.api-v2.tls=true" + - "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io" + - "traefik.http.routers.api-v2.tls.certresolver=le_io" + - "traefik.http.routers.api-v2.service=caddy" + - "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file" + # HTTP to HTTPS redirect for both API domains + - "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)" + - "traefik.http.routers.api-http.entrypoints=web" + - "traefik.http.routers.api-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.api-http.service=caddy" + # Local development (HTTP only) + - "traefik.http.routers.local.rule=Host(`localhost`)" + - "traefik.http.routers.local.entrypoints=web" + - "traefik.http.routers.local.service=caddy" + - "traefik.http.routers.local.middlewares=secure-headers@file" + # Local development over HTTPS (self-signed/default Traefik cert) + - "traefik.http.routers.local-secure.rule=Host(`localhost`)" + - "traefik.http.routers.local-secure.entrypoints=websecure" + - "traefik.http.routers.local-secure.tls=true" + - "traefik.http.routers.local-secure.service=caddy" + - "traefik.http.routers.local-secure.middlewares=secure-headers@file" + # Local alias: http://localhost/api -> Caddy (strip /api prefix) + - "traefik.http.routers.local-api.rule=Host(`localhost`) && PathPrefix(`/api`)" + - "traefik.http.routers.local-api.entrypoints=web" + - "traefik.http.routers.local-api.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-api.service=caddy" + - "traefik.http.routers.local-api.priority=100" + # Local alias over HTTPS as well: https://localhost/api -> Caddy (strip /api prefix) + - "traefik.http.routers.local-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)" + - "traefik.http.routers.local-api-secure.entrypoints=websecure" + - "traefik.http.routers.local-api-secure.tls=true" + - "traefik.http.routers.local-api-secure.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-api-secure.service=caddy" + - "traefik.http.routers.local-api-secure.priority=100" + # Tell Traefik which port Caddy listens on + - "traefik.http.services.caddy.loadbalancer.server.port=80" + + caddy-staging: + image: caddy:2.7.6-alpine + container_name: caddy-staging + depends_on: + - php-staging + command: ["caddy", "run", "--config", "/etc/caddy/Caddyfile-staging", "--adapter", "caddyfile"] + volumes: + - ./services/nginx/staging:/var/www/html + - ./services/caddy:/etc/caddy:ro + - ./services/caddy/logs-staging:/var/log/caddy + labels: + - "traefik.enable=true" + # Staging API (.io version on port 4433) + - "traefik.http.routers.api-staging.rule=Host(`api.truckwash.io`)" + - "traefik.http.routers.api-staging.entrypoints=websecure-staging" + - "traefik.http.routers.api-staging.tls=true" + - "traefik.http.routers.api-staging.tls.domains[0].main=api.truckwash.io" + - "traefik.http.routers.api-staging.tls.certresolver=le_io" + - "traefik.http.routers.api-staging.service=caddy-staging" + - "traefik.http.routers.api-staging.middlewares=secure-headers@file,api-ratelimit@file" + # Local staging development (HTTP on port 4433) + - "traefik.http.routers.local-staging.rule=Host(`localhost`)" + - "traefik.http.routers.local-staging.entrypoints=websecure-staging" + - "traefik.http.routers.local-staging.service=caddy-staging" + - "traefik.http.routers.local-staging.middlewares=secure-headers@file" + # Local staging development (HTTPS on port 4433) + - "traefik.http.routers.local-staging-secure.rule=Host(`localhost`)" + - "traefik.http.routers.local-staging-secure.entrypoints=websecure-staging" + - "traefik.http.routers.local-staging-secure.tls=true" + - "traefik.http.routers.local-staging-secure.service=caddy-staging" + - "traefik.http.routers.local-staging-secure.middlewares=secure-headers@file" + # Local staging alias: http://localhost:4433/api -> Caddy staging (strip /api prefix) + - "traefik.http.routers.local-staging-api.rule=Host(`localhost`) && PathPrefix(`/api`)" + - "traefik.http.routers.local-staging-api.entrypoints=websecure-staging" + - "traefik.http.routers.local-staging-api.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-staging-api.service=caddy-staging" + - "traefik.http.routers.local-staging-api.priority=100" + # Local staging alias over HTTPS: https://localhost:4433/api -> Caddy staging (strip /api prefix) + - "traefik.http.routers.local-staging-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)" + - "traefik.http.routers.local-staging-api-secure.entrypoints=websecure-staging" + - "traefik.http.routers.local-staging-api-secure.tls=true" + - "traefik.http.routers.local-staging-api-secure.middlewares=strip-api-prefix@file,secure-headers@file" + - "traefik.http.routers.local-staging-api-secure.service=caddy-staging" + - "traefik.http.routers.local-staging-api-secure.priority=100" + # Tell Traefik which port Caddy listens on + - "traefik.http.services.caddy-staging.loadbalancer.server.port=80" + + php1: + build: + context: . + dockerfile: services/php/Dockerfile + 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:?set EDGE_BROKER_SHARED_SECRET in .env} + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs:/var/log/php + + php2: + build: + context: . + dockerfile: services/php/Dockerfile + 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:?set EDGE_BROKER_SHARED_SECRET in .env} + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs:/var/log/php + + php3: + build: + context: . + dockerfile: services/php/Dockerfile + 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:?set EDGE_BROKER_SHARED_SECRET in .env} + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs:/var/log/php + + php4: + build: + context: . + dockerfile: services/php/Dockerfile + 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:?set EDGE_BROKER_SHARED_SECRET in .env} + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs:/var/log/php + + php5: + build: + context: . + dockerfile: services/php/Dockerfile + 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:?set EDGE_BROKER_SHARED_SECRET in .env} + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs:/var/log/php + + php-staging: + build: + context: . + dockerfile: services/php/Dockerfile + 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:?set EDGE_BROKER_SHARED_SECRET in .env} + volumes: + - ./services/nginx/staging:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs-staging:/var/log/php + + php-cron: + build: + context: . + dockerfile: services/php/Dockerfile + 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:?set EDGE_BROKER_SHARED_SECRET in .env} + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs:/var/log/php + portainer: + image: portainer/portainer-ce:2.21.4 + container_name: portainer + profiles: + - dev + ports: + - "9443:9443" + - "9000:9000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - portainer_data:/data + + # Jaeger all-in-one for local tracing (Traefik → Jaeger) + jaeger: + image: jaegertracing/all-in-one:1.53 + container_name: jaeger + profiles: + - dev + environment: + - COLLECTOR_ZIPKIN_HTTP_PORT=9411 + ports: + - "16686:16686" # Jaeger UI + # No volumes needed for dev; data is ephemeral + + n8n: + image: n8nio/n8n:latest + container_name: n8n + restart: always + environment: + - N8N_HOST=n8n.truckwash.io + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - NODE_ENV=production + - WEBHOOK_URL=https://n8n.truckwash.io/ + - GENERIC_TIMEZONE=${CONFIG_TIMEZONE:-Europe/Copenhagen} + volumes: + - n8n_data:/home/node/.n8n + labels: + - "traefik.enable=true" + # n8n over HTTPS (le_io cert resolver) + - "traefik.http.routers.n8n.rule=Host(`n8n.truckwash.io`)" + - "traefik.http.routers.n8n.entrypoints=websecure" + - "traefik.http.routers.n8n.tls=true" + - "traefik.http.routers.n8n.tls.certresolver=le_io" + - "traefik.http.routers.n8n.service=n8n" + # n8n HTTP to HTTPS redirect + - "traefik.http.routers.n8n-http.rule=Host(`n8n.truckwash.io`)" + - "traefik.http.routers.n8n-http.entrypoints=web" + - "traefik.http.routers.n8n-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.n8n-http.service=n8n" + # n8n service port + - "traefik.http.services.n8n.loadbalancer.server.port=5678" + +volumes: + db_data: + db_debug_data: + nnks_redis: + nnks_redis_staging: + es_data: + portainer_data: + fleet-server-data: + elastic-agent-data: + n8n_data: diff --git a/documentation/.wrs-style-guide.yaml b/documentation/.wrs-style-guide.yaml new file mode 100644 index 00000000..65d3ea81 --- /dev/null +++ b/documentation/.wrs-style-guide.yaml @@ -0,0 +1,76 @@ +# Copenhagen Truck Wash API — Writerside Style Guide +# This file defines the style rules for our documentation. +# For more details, see https://vale.sh/docs/topics/styles/#extension-points + +# --- RULE: Encourage descriptive language (existence) --- +extends: existence +message: "Avoid using '%s'. Try to be more descriptive or direct." +level: warning +ignorecase: true +tokens: + - simply + - just + - easy + - easily + - simple + - basically + - obviously + - actually + - very + - really + - pretty + - quite + - rather + +--- +# --- RULE: Flag incomplete documentation (existence) --- +extends: existence +message: "Incomplete documentation: '%s' found. Please provide comprehensive details." +level: error +ignorecase: true +tokens: + - TBD + - TO BE DETERMINED + - TODO + - FIXME + - Placeholder + - Coming soon + +--- +# --- RULE: Preferred terminology (substitution) --- +extends: substitution +message: "Consider using '%s' instead of its informal or less descriptive counterpart." +level: suggestion +ignorecase: true +swap: + check[ -]box: checkbox + right-click menu: context menu|popup menu + webpage: page + click on: click|select + press: press|select + setup: set up + log[ -]in: sign in|log in + e-mail: email + interface: UI|interface + the following: : + utilize: use + functionality: feature|function + additional: more|extra + +--- +# --- RULE: Avoid jargon and filler (existence) --- +extends: existence +message: "Avoid jargon or filler phrases like '%s'." +level: warning +ignorecase: true +tokens: + - leverage + - bandwidth + - synergy + - best-in-class + - cutting-edge + - robust + - state-of-the-art + - mission-critical + - go-forward + - paradigm shift diff --git a/documentation/_build/algolia-indexes-CTW.zip b/documentation/_build/algolia-indexes-CTW.zip new file mode 100644 index 00000000..e3786566 Binary files /dev/null and b/documentation/_build/algolia-indexes-CTW.zip differ diff --git a/documentation/_build/report.html b/documentation/_build/report.html new file mode 100644 index 00000000..7faab3e6 --- /dev/null +++ b/documentation/_build/report.html @@ -0,0 +1,493 @@ + + + + + Build results + + +

Tests

+

180 tests total.

+ +

Errors

+ +

MRK004 — Topic ID doesn't match the containing file name

+
    +
  • In Config_Module_Backups.topic:4:1
  • +
  • In Config_Module_Backups_Page_1.topic:4:1
  • +
  • In Config_Module_Bird.topic:4:1
  • +
  • In Config_Module_Bird_Page_1.topic:4:1
  • +
  • In Config_Module_Email.topic:4:1
  • +
  • In Config_Module_Email_Page_1.topic:4:1
  • +
  • In Config_Module_Entra.topic:4:1
  • +
  • In Config_Module_Entra_Page_1.topic:4:1
  • +
  • In Config_Module_FXRatesAPI.topic:4:1
  • +
  • In Config_Module_FXRatesAPI_Page_1.topic:4:1
  • +
  • In Config_Module_GatewayAPI.topic:4:1
  • +
  • In Config_Module_GatewayAPI_Page_1.topic:4:1
  • +
  • In Config_Module_LicensePlateRecognizer.topic:4:1
  • +
  • In Config_Module_LicensePlateRecognizer_Page_1.topic:4:1
  • +
  • In Config_Module_Limble.topic:4:1
  • +
  • In Config_Module_Limble_Page_1.topic:4:1
  • +
  • In Config_Module_MotorAPI.topic:4:1
  • +
  • In Config_Module_MotorAPI_Page_1.topic:4:1
  • +
  • In Config_Module_OcrSpace.topic:4:1
  • +
  • In Config_Module_OcrSpace_Page_1.topic:4:1
  • +
  • In Config_Module_OpenAI.topic:4:1
  • +
  • In Config_Module_OpenAI_Page_1.topic:4:1
  • +
  • In Config_Module_Self_Serve.topic:4:1
  • +
  • In Config_Module_Self_Serve_Page_1.topic:4:1
  • +
  • In Config_Module_Shelly.topic:4:1
  • +
  • In Config_Module_Shelly_Page_1.topic:4:1
  • +
  • In Config_Module_Stripe.topic:4:1
  • +
  • In Config_Module_Stripe_Page_1.topic:4:1
  • +
  • In Config_Module_VirkData.topic:4:1
  • +
  • In Config_Module_VirkData_Page_1.topic:4:1
  • +
  • In Config_Module_WeatherAPI.topic:4:1
  • +
  • In Config_Module_WeatherAPI_Page_1.topic:4:1
  • +
  • In Config_Module_XLVask.topic:4:1
  • +
  • In Config_Module_XLVask_Page_1.topic:4:1
  • +
  • In Config_Module_e_conomic.topic:4:1
  • +
  • In Config_Module_e_conomic_Page_1.topic:4:1
  • +
  • In Config_Module_reCAPTCHA.topic:4:1
  • +
  • In Config_Module_reCAPTCHA_Page_1.topic:4:1
  • +
  • In Modules_Module_Action_Logs.topic:4:1
  • +
  • In Modules_Module_Action_Logs_Page_1.topic:4:1
  • +
  • In Modules_Module_Backup.topic:4:1
  • +
  • In Modules_Module_Backup_Page_1.topic:4:1
  • +
  • In Modules_Module_CVR.topic:4:1
  • +
  • In Modules_Module_CVR_Page_1.topic:4:1
  • +
  • In Modules_Module_Entra.topic:4:1
  • +
  • In Modules_Module_Entra_Page_1.topic:4:1
  • +
  • In Modules_Module_FXRatesAPI.topic:4:1
  • +
  • In Modules_Module_FXRatesAPI_Page_1.topic:4:1
  • +
  • In Modules_Module_MotorAPI.topic:4:1
  • +
  • In Modules_Module_MotorAPI_Page_1.topic:4:1
  • +
  • In Modules_Module_Self_Serve.topic:4:1
  • +
  • In Modules_Module_Self_Serve_Page_1.topic:4:1
  • +
  • In Modules_Module_Stripe.topic:4:1
  • +
  • In Modules_Module_Stripe_Page_1.topic:4:1
  • +
  • In Modules_Module_VirkData.topic:4:1
  • +
  • In Modules_Module_VirkData_Page_1.topic:4:1
  • +
  • In Modules_Module_Wash_Certificates.topic:4:1
  • +
  • In Modules_Module_Wash_Certificates_Page_1.topic:4:1
  • +
  • In Modules_Module_WeatherAPI.topic:4:1
  • +
  • In Modules_Module_WeatherAPI_Page_1.topic:4:1
  • +
  • In Modules_Module_XLVask.topic:4:1
  • +
  • In Modules_Module_XLVask_Page_1.topic:4:1
  • +
  • In Modules_Module_e_conomic.topic:4:1
  • +
  • In Modules_Module_e_conomic_Page_1.topic:4:1
  • +
+

REF006 — 'toc-element' for the current instance references a topic file that cannot be found

+
    +
  • ""API_Reference.topic"" in ctw.tree:613:5
  • +
  • ""Admin_Bookings_CompleteWithoutWashCertificate_POST.topic"" in ctw.tree:629:5
  • +
  • ""Admin_Bookings_Delete_POST.topic"" in ctw.tree:626:5
  • +
  • ""Admin_Bookings_Department_Count_GET.topic"" in ctw.tree:632:5
  • +
  • ""Admin_Bookings_Sync_POST.topic"" in ctw.tree:624:5
  • +
  • ""Bookings_Download_PDF_GET.topic"" in ctw.tree:621:5
  • +
  • ""Bookings_GET.topic"" in ctw.tree:633:5
  • +
  • ""Bookings_PUT.topic"" in ctw.tree:628:5
  • +
  • ""Customers_GET.topic"" in ctw.tree:618:5
  • +
  • ""Customers_POST.topic"" in ctw.tree:615:5
  • +
  • ""Customers_PUT.topic"" in ctw.tree:608:5
  • +
  • ""Customers_id_DELETE.topic"" in ctw.tree:610:5
  • +
  • ""Department_Timebookings_Entries_Public_GET.topic"" in ctw.tree:625:5
  • +
  • ""Department_Timebookings_Entries_Public_POST.topic"" in ctw.tree:620:5
  • +
  • ""Department_Timebookings_OpeningHours_Public_GET.topic"" in ctw.tree:630:5
  • +
  • ""Department_Timebookings_Types_Public_GET.topic"" in ctw.tree:627:5
  • +
  • ""Superuser_Bookings_Sync_All_POST.topic"" in ctw.tree:631:5
  • +
  • ""UsageLog_GET.topic"" in ctw.tree:617:5
  • +
  • ""User_Bookings_Delete_POST.topic"" in ctw.tree:619:5
  • +
  • ""User_Bookings_GET.topic"" in ctw.tree:623:5
  • +
  • ""User_Bookings_Washcertificate_Download_POST.topic"" in ctw.tree:622:5
  • +
  • ""Vehicles_GET.topic"" in ctw.tree:609:5
  • +
  • ""Vehicles_POST.topic"" in ctw.tree:616:5
  • +
  • ""Vehicles_PUT.topic"" in ctw.tree:607:5
  • +
  • ""Vehicles_id_DELETE.topic"" in ctw.tree:614:5
  • +
  • ""config_module_backups.topic"" in ctw.tree:394:13
  • +
  • ""config_module_backups_page_1.topic"" in ctw.tree:395:17
  • +
  • ""config_module_bird.topic"" in ctw.tree:400:13
  • +
  • ""config_module_bird_page_1.topic"" in ctw.tree:401:17
  • +
  • ""config_module_e_conomic.topic"" in ctw.tree:406:13
  • +
  • ""config_module_e_conomic_page_1.topic"" in ctw.tree:407:17
  • +
  • ""config_module_email.topic"" in ctw.tree:412:13
  • +
  • ""config_module_email_page_1.topic"" in ctw.tree:413:17
  • +
  • ""config_module_entra.topic"" in ctw.tree:419:13
  • +
  • ""config_module_entra_page_1.topic"" in ctw.tree:420:17
  • +
  • ""config_module_fxratesapi.topic"" in ctw.tree:425:13
  • +
  • ""config_module_fxratesapi_page_1.topic"" in ctw.tree:426:17
  • +
  • ""config_module_gatewayapi.topic"" in ctw.tree:431:13
  • +
  • ""config_module_gatewayapi_page_1.topic"" in ctw.tree:432:17
  • +
  • ""config_module_licenseplaterecognizer.topic"" in ctw.tree:437:13
  • +
  • ""config_module_licenseplaterecognizer_page_1.topic"" in ctw.tree:438:17
  • +
  • ""config_module_limble.topic"" in ctw.tree:443:13
  • +
  • ""config_module_limble_page_1.topic"" in ctw.tree:444:17
  • +
  • ""config_module_motorapi.topic"" in ctw.tree:449:13
  • +
  • ""config_module_motorapi_page_1.topic"" in ctw.tree:450:17
  • +
  • ""config_module_ocrspace.topic"" in ctw.tree:455:13
  • +
  • ""config_module_ocrspace_page_1.topic"" in ctw.tree:456:17
  • +
  • ""config_module_openai.topic"" in ctw.tree:461:13
  • +
  • ""config_module_openai_page_1.topic"" in ctw.tree:462:17
  • +
  • ""config_module_recaptcha.topic"" in ctw.tree:467:13
  • +
  • ""config_module_recaptcha_page_1.topic"" in ctw.tree:468:17
  • +
  • ""config_module_self_serve.topic"" in ctw.tree:473:13
  • +
  • ""config_module_self_serve_page_1.topic"" in ctw.tree:474:17
  • +
  • ""config_module_shelly.topic"" in ctw.tree:479:13
  • +
  • ""config_module_shelly_page_1.topic"" in ctw.tree:480:17
  • +
  • ""config_module_stripe.topic"" in ctw.tree:485:13
  • +
  • ""config_module_stripe_page_1.topic"" in ctw.tree:486:17
  • +
  • ""config_module_virkdata.topic"" in ctw.tree:491:13
  • +
  • ""config_module_virkdata_page_1.topic"" in ctw.tree:492:17
  • +
  • ""config_module_weatherapi.topic"" in ctw.tree:497:13
  • +
  • ""config_module_weatherapi_page_1.topic"" in ctw.tree:498:17
  • +
  • ""config_module_xlvask.topic"" in ctw.tree:503:13
  • +
  • ""config_module_xlvask_page_1.topic"" in ctw.tree:504:17
  • +
  • ""modules_module_action_logs.topic"" in ctw.tree:257:13
  • +
  • ""modules_module_action_logs_page_1.topic"" in ctw.tree:258:17
  • +
  • ""modules_module_backup.topic"" in ctw.tree:262:13
  • +
  • ""modules_module_backup_page_1.topic"" in ctw.tree:263:17
  • +
  • ""modules_module_cvr.topic"" in ctw.tree:268:13
  • +
  • ""modules_module_cvr_page_1.topic"" in ctw.tree:269:17
  • +
  • ""modules_module_e_conomic.topic"" in ctw.tree:274:13
  • +
  • ""modules_module_e_conomic_page_1.topic"" in ctw.tree:275:17
  • +
  • ""modules_module_entra.topic"" in ctw.tree:288:13
  • +
  • ""modules_module_entra_page_1.topic"" in ctw.tree:289:17
  • +
  • ""modules_module_fxratesapi.topic"" in ctw.tree:293:13
  • +
  • ""modules_module_fxratesapi_page_1.topic"" in ctw.tree:294:17
  • +
  • ""modules_module_motorapi.topic"" in ctw.tree:299:13
  • +
  • ""modules_module_motorapi_page_1.topic"" in ctw.tree:300:17
  • +
  • ""modules_module_self_serve.topic"" in ctw.tree:304:13
  • +
  • ""modules_module_self_serve_page_1.topic"" in ctw.tree:305:17
  • +
  • ""modules_module_stripe.topic"" in ctw.tree:314:13
  • +
  • ""modules_module_stripe_page_1.topic"" in ctw.tree:315:17
  • +
  • ""modules_module_virkdata.topic"" in ctw.tree:327:13
  • +
  • ""modules_module_virkdata_page_1.topic"" in ctw.tree:328:17
  • +
  • ""modules_module_wash_certificates.topic"" in ctw.tree:332:13
  • +
  • ""modules_module_wash_certificates_page_1.topic"" in ctw.tree:333:17
  • +
  • ""modules_module_weatherapi.topic"" in ctw.tree:337:13
  • +
  • ""modules_module_weatherapi_page_1.topic"" in ctw.tree:338:17
  • +
  • ""modules_module_xlvask.topic"" in ctw.tree:344:13
  • +
  • ""modules_module_xlvask_page_1.topic"" in ctw.tree:345:17
  • +
+

TOC001 — <toc-element> points to an article that doesn't exist

+
    +
  • "API_Reference.topic" in ctw.tree:613:5
  • +
  • "Admin_Bookings_CompleteWithoutWashCertificate_POST.topic" in ctw.tree:629:5
  • +
  • "Admin_Bookings_Delete_POST.topic" in ctw.tree:626:5
  • +
  • "Admin_Bookings_Department_Count_GET.topic" in ctw.tree:632:5
  • +
  • "Admin_Bookings_Sync_POST.topic" in ctw.tree:624:5
  • +
  • "Bookings_Download_PDF_GET.topic" in ctw.tree:621:5
  • +
  • "Bookings_GET.topic" in ctw.tree:633:5
  • +
  • "Bookings_PUT.topic" in ctw.tree:628:5
  • +
  • "Customers_GET.topic" in ctw.tree:618:5
  • +
  • "Customers_POST.topic" in ctw.tree:615:5
  • +
  • "Customers_PUT.topic" in ctw.tree:608:5
  • +
  • "Customers_id_DELETE.topic" in ctw.tree:610:5
  • +
  • "Department_Timebookings_Entries_Public_GET.topic" in ctw.tree:625:5
  • +
  • "Department_Timebookings_Entries_Public_POST.topic" in ctw.tree:620:5
  • +
  • "Department_Timebookings_OpeningHours_Public_GET.topic" in ctw.tree:630:5
  • +
  • "Department_Timebookings_Types_Public_GET.topic" in ctw.tree:627:5
  • +
  • "Superuser_Bookings_Sync_All_POST.topic" in ctw.tree:631:5
  • +
  • "UsageLog_GET.topic" in ctw.tree:617:5
  • +
  • "User_Bookings_Delete_POST.topic" in ctw.tree:619:5
  • +
  • "User_Bookings_GET.topic" in ctw.tree:623:5
  • +
  • "User_Bookings_Washcertificate_Download_POST.topic" in ctw.tree:622:5
  • +
  • "Vehicles_GET.topic" in ctw.tree:609:5
  • +
  • "Vehicles_POST.topic" in ctw.tree:616:5
  • +
  • "Vehicles_PUT.topic" in ctw.tree:607:5
  • +
  • "Vehicles_id_DELETE.topic" in ctw.tree:614:5
  • +
  • "config_module_backups.topic" in ctw.tree:394:13
  • +
  • "config_module_backups_page_1.topic" in ctw.tree:395:17
  • +
  • "config_module_bird.topic" in ctw.tree:400:13
  • +
  • "config_module_bird_page_1.topic" in ctw.tree:401:17
  • +
  • "config_module_e_conomic.topic" in ctw.tree:406:13
  • +
  • "config_module_e_conomic_page_1.topic" in ctw.tree:407:17
  • +
  • "config_module_email.topic" in ctw.tree:412:13
  • +
  • "config_module_email_page_1.topic" in ctw.tree:413:17
  • +
  • "config_module_entra.topic" in ctw.tree:419:13
  • +
  • "config_module_entra_page_1.topic" in ctw.tree:420:17
  • +
  • "config_module_fxratesapi.topic" in ctw.tree:425:13
  • +
  • "config_module_fxratesapi_page_1.topic" in ctw.tree:426:17
  • +
  • "config_module_gatewayapi.topic" in ctw.tree:431:13
  • +
  • "config_module_gatewayapi_page_1.topic" in ctw.tree:432:17
  • +
  • "config_module_licenseplaterecognizer.topic" in ctw.tree:437:13
  • +
  • "config_module_licenseplaterecognizer_page_1.topic" in ctw.tree:438:17
  • +
  • "config_module_limble.topic" in ctw.tree:443:13
  • +
  • "config_module_limble_page_1.topic" in ctw.tree:444:17
  • +
  • "config_module_motorapi.topic" in ctw.tree:449:13
  • +
  • "config_module_motorapi_page_1.topic" in ctw.tree:450:17
  • +
  • "config_module_ocrspace.topic" in ctw.tree:455:13
  • +
  • "config_module_ocrspace_page_1.topic" in ctw.tree:456:17
  • +
  • "config_module_openai.topic" in ctw.tree:461:13
  • +
  • "config_module_openai_page_1.topic" in ctw.tree:462:17
  • +
  • "config_module_recaptcha.topic" in ctw.tree:467:13
  • +
  • "config_module_recaptcha_page_1.topic" in ctw.tree:468:17
  • +
  • "config_module_self_serve.topic" in ctw.tree:473:13
  • +
  • "config_module_self_serve_page_1.topic" in ctw.tree:474:17
  • +
  • "config_module_shelly.topic" in ctw.tree:479:13
  • +
  • "config_module_shelly_page_1.topic" in ctw.tree:480:17
  • +
  • "config_module_stripe.topic" in ctw.tree:485:13
  • +
  • "config_module_stripe_page_1.topic" in ctw.tree:486:17
  • +
  • "config_module_virkdata.topic" in ctw.tree:491:13
  • +
  • "config_module_virkdata_page_1.topic" in ctw.tree:492:17
  • +
  • "config_module_weatherapi.topic" in ctw.tree:497:13
  • +
  • "config_module_weatherapi_page_1.topic" in ctw.tree:498:17
  • +
  • "config_module_xlvask.topic" in ctw.tree:503:13
  • +
  • "config_module_xlvask_page_1.topic" in ctw.tree:504:17
  • +
  • "modules_module_action_logs.topic" in ctw.tree:257:13
  • +
  • "modules_module_action_logs_page_1.topic" in ctw.tree:258:17
  • +
  • "modules_module_backup.topic" in ctw.tree:262:13
  • +
  • "modules_module_backup_page_1.topic" in ctw.tree:263:17
  • +
  • "modules_module_cvr.topic" in ctw.tree:268:13
  • +
  • "modules_module_cvr_page_1.topic" in ctw.tree:269:17
  • +
  • "modules_module_e_conomic.topic" in ctw.tree:274:13
  • +
  • "modules_module_e_conomic_page_1.topic" in ctw.tree:275:17
  • +
  • "modules_module_entra.topic" in ctw.tree:288:13
  • +
  • "modules_module_entra_page_1.topic" in ctw.tree:289:17
  • +
  • "modules_module_fxratesapi.topic" in ctw.tree:293:13
  • +
  • "modules_module_fxratesapi_page_1.topic" in ctw.tree:294:17
  • +
  • "modules_module_motorapi.topic" in ctw.tree:299:13
  • +
  • "modules_module_motorapi_page_1.topic" in ctw.tree:300:17
  • +
  • "modules_module_self_serve.topic" in ctw.tree:304:13
  • +
  • "modules_module_self_serve_page_1.topic" in ctw.tree:305:17
  • +
  • "modules_module_stripe.topic" in ctw.tree:314:13
  • +
  • "modules_module_stripe_page_1.topic" in ctw.tree:315:17
  • +
  • "modules_module_virkdata.topic" in ctw.tree:327:13
  • +
  • "modules_module_virkdata_page_1.topic" in ctw.tree:328:17
  • +
  • "modules_module_wash_certificates.topic" in ctw.tree:332:13
  • +
  • "modules_module_wash_certificates_page_1.topic" in ctw.tree:333:17
  • +
  • "modules_module_weatherapi.topic" in ctw.tree:337:13
  • +
  • "modules_module_weatherapi_page_1.topic" in ctw.tree:338:17
  • +
  • "modules_module_xlvask.topic" in ctw.tree:344:13
  • +
  • "modules_module_xlvask_page_1.topic" in ctw.tree:345:17
  • +
+

Warnings

+ +

INT002 — Map ID is not unique

+
    +
  • ""Bird - Page 1 of 1"" in ctw.tree:589:13; ctw.tree:677:5
  • +
  • ""Bird"" in ctw.tree:588:9; ctw.tree:669:5
  • +
  • ""Bird+-+Page+1+of+1"" in ctw.tree:589:13; ctw.tree:677:5
  • +
  • ""Entra - Page 1 of 1"" in ctw.tree:636:5; ctw.tree:690:5
  • +
  • ""Entra"" in ctw.tree:645:5; ctw.tree:692:5
  • +
  • ""Entra+-+Page+1+of+1"" in ctw.tree:636:5; ctw.tree:690:5
  • +
  • ""FXRatesAPI - Page 1 of 1"" in ctw.tree:663:5; ctw.tree:682:5
  • +
  • ""FXRatesAPI"" in ctw.tree:644:5; ctw.tree:651:5
  • +
  • ""FXRatesAPI+-+Page+1+of+1"" in ctw.tree:663:5; ctw.tree:682:5
  • +
  • ""MotorAPI - Page 1 of 1"" in ctw.tree:659:5; ctw.tree:673:5
  • +
  • ""MotorAPI"" in ctw.tree:648:5; ctw.tree:671:5
  • +
  • ""MotorAPI+-+Page+1+of+1"" in ctw.tree:659:5; ctw.tree:673:5
  • +
  • ""Record machine start button press webhook"" in ctw.tree:389:17; ctw.tree:390:17
  • +
  • ""Record+machine+start+button+press+webhook"" in ctw.tree:389:17; ctw.tree:390:17
  • +
  • ""Self-Serve - Page 1 of 1"" in ctw.tree:649:5; ctw.tree:691:5
  • +
  • ""Self-Serve"" in ctw.tree:527:9; ctw.tree:666:5; ctw.tree:668:5
  • +
  • ""Self-Serve+-+Page+1+of+1"" in ctw.tree:649:5; ctw.tree:691:5
  • +
  • ""Stripe - Page 1 of 1"" in ctw.tree:641:5; ctw.tree:683:5
  • +
  • ""Stripe"" in ctw.tree:650:5; ctw.tree:681:5
  • +
  • ""Stripe+-+Page+1+of+1"" in ctw.tree:641:5; ctw.tree:683:5
  • +
  • ""System-wide search"" in ctw.tree:84:17; ctw.tree:85:17
  • +
  • ""System-wide+search"" in ctw.tree:84:17; ctw.tree:85:17
  • +
  • ""VirkData - Page 1 of 1"" in ctw.tree:661:5; ctw.tree:693:5
  • +
  • ""VirkData"" in ctw.tree:638:5; ctw.tree:695:5
  • +
  • ""VirkData+-+Page+1+of+1"" in ctw.tree:661:5; ctw.tree:693:5
  • +
  • ""WeatherAPI - Page 1 of 1"" in ctw.tree:643:5; ctw.tree:646:5
  • +
  • ""WeatherAPI"" in ctw.tree:658:5; ctw.tree:665:5
  • +
  • ""WeatherAPI+-+Page+1+of+1"" in ctw.tree:643:5; ctw.tree:646:5
  • +
  • ""XLVask - Page 1 of 1"" in ctw.tree:639:5; ctw.tree:642:5
  • +
  • ""XLVask"" in ctw.tree:637:5; ctw.tree:688:5
  • +
  • ""XLVask+-+Page+1+of+1"" in ctw.tree:639:5; ctw.tree:642:5
  • +
  • ""e-conomic - Page 1 of 1"" in ctw.tree:672:5; ctw.tree:694:5
  • +
  • ""e-conomic"" in ctw.tree:657:5; ctw.tree:685:5
  • +
  • ""e-conomic+-+Page+1+of+1"" in ctw.tree:672:5; ctw.tree:694:5
  • +
+

Passed

+
    +
  • API001 — API documentation markup validation issue
  • +
  • API002 — API Reference Generation Error
  • +
  • API003 — Specification file contains features or content not supported by our generator
  • +
  • API004 — API model building problem
  • +
  • CDE001 — The <compare> element must contain exactly two code blocks
  • +
  • CDE002 — The 'collapsed-title-line-number' attribute value on a 'code-block' element is not a valid number
  • +
  • CDE003 — The code snippet doesn't contain the line with the number specified in the 'collapsed-title-line-number' attribute
  • +
  • CDE004 — Cannot read the source code snippet from the specified location, falling back to the 'code-block' element content
  • +
  • CDE005 — Cannot read the source code snippet from the specified location, and there is no fallback content in the 'code-block' element
  • +
  • CDE006 — 'code-block' cannot be empty
  • +
  • CDE007 — The 'include-lines' attribute on a 'code-block' element must represent a line number range like 1-3, or multiple comma-separated ranges like 1-3,5-7
  • +
  • CDE008 — The code snippet doesn't contain the lines specified in the 'include-lines' attribute of the corresponding 'code-block' element
  • +
  • CDE009 — Cannot use both the 'include-symbol' and the 'include-lines' attributes to reference a code snippet
  • +
  • CDE010 — The specified code construct is not found in the source file or the file language is not recognized
  • +
  • CDE011 — Code sample contains errors
  • +
  • CDE012 — Cannot validate code sample because its language is not supported
  • +
  • CDE013 — Cannot validate code sample because its language is not specified
  • +
  • CDE014 — Content other than text or CDATA in a code block, for example, HTML/XML-like tags
  • +
  • CDE015 — Unsupported 'style' value on the <compare> element
  • +
  • CDE016 — Unknown language is specified for a code block
  • +
  • CDE017 — Syntax error in a code block
  • +
  • CDE018 — Expected at least one closed @start...@end block, but found none.
  • +
  • CNF001 — Category specified in <seealso> is not declared in c.list
  • +
  • CNF002 — Topic title cannot be empty
  • +
  • CNF003 — Invalid buildprofiles.xml property value
  • +
  • CNF005 — The 'accepts-web-file-names' attribute contains symbols that cannot appear in a file name
  • +
  • CNF006 — The 'accepts-web-file-names' attribute points to a non-existing redirection rule
  • +
  • CNF008 — The 'start-page' attribute on 'instance-profile' cannot be empty
  • +
  • CNF009 — Global variable name in v.list is duplicated
  • +
  • CNF010 — Cannot find specified resource in the resources folder
  • +
  • CNF011 — Category sort order is not a valid number
  • +
  • CNF012 — Tooltip not found
  • +
  • CNF013 — Cannot reference a resource as the r.list file is missing
  • +
  • CNF014 — The instance version links require 'web-path' in instance declaration
  • +
  • CNF015 — Cannot write the result to the specified output directory
  • +
  • CNF016 — Redirect map ID is not unique
  • +
  • CNF017 — Cannot read analytics script or html snippet
  • +
  • CNF018 — Footer link without 'href' attribute
  • +
  • CNF019 — Unknown social link type {0}
  • +
  • CNF020 — Absolute images web-path was given, but images are bundled into single artifact
  • +
  • CNF021 — Web filename is reserved, use a different value for the <web-file-name> tag
  • +
  • CNF022 — Web filename is duplicated, ensure that web filename is unique
  • +
  • CNF023 — Topic filenames must be unique, regardless of letter case
  • +
  • CNF024 — The 'start-page' attribute on 'instance-profile' is empty
  • +
  • CNF025 — Web filename is empty. Add letters or digits to the topic filename or specify a custom web filename
  • +
  • CNF026 — Referenced file {0} does not exist or is not possible to read.
  • +
  • CNF027 — Multiple values for {0} are not allowed here.
  • +
  • CNF028 — Expected true or false.
  • +
  • CTT001 — Inappropriate language detected
  • +
  • CTT002 — The generated HTML file size exceeds 1 MB, which may lead to poor performance when rendered in the browser. Consider splitting large topics into multiple smaller ones.
  • +
  • CTT004 — Undefined variable
  • +
  • CTT005 — Variable depends on itself
  • +
  • INT001 — Error description template is not found
  • +
  • INT004 — Unexpected article rendering error:
  • +
  • INT007 — XML serialization error
  • +
  • INT008 — Unexpected HTML rendering error: cannot produce a PDF
  • +
  • INT009 — Unexpected diagram rendering error
  • +
  • INT010 — Unexpected PlantUML error:
  • +
  • MRK001 — The element doesn't comply with validation rules
  • +
  • MRK002 — Source file syntax is corrupted
  • +
  • MRK003 — Element ID is not unique
  • +
  • MRK006 — The 'term' attribute is missing for the <tooltip> element
  • +
  • MRK007 — The 'resource-id' attribute is missing for the <res> element
  • +
  • MRK008 — The 'ref' attribute is missing for the <category> element
  • +
  • MRK009 — Element is not allowed in the current context
  • +
  • MRK011 — Only <code>, <property>, <shortcut>, image or text with inline formatting is allowed inside an <a> element to define link text
  • +
  • MRK012 — Element is unknown
  • +
  • MRK013 — Starting page title is empty
  • +
  • MRK014 — Section starting page description is empty
  • +
  • MRK015 — Section starting page must contain exactly two links under the 'spotlight' element
  • +
  • MRK016 — Section starting page link summary is empty
  • +
  • MRK017 — Section starting page group title is empty
  • +
  • MRK018 — Section starting page group is empty
  • +
  • MRK019 — Element is not allowed on a section starting page
  • +
  • MRK020 — Javascript expression specified in the 'use-when' attribute failed to execute
  • +
  • MRK026 — Unknown 'type' attribute value
  • +
  • MRK027 — The source file for the 'include' is corrupted
  • +
  • MRK028 — Invalid 'style' attribute value on a deflist
  • +
  • MRK032 — Element ID contains whitespace characters
  • +
  • MRK033 — Unknown 'style' attribute value on a list
  • +
  • MRK034 — The value of the 'columns' attribute on a list must be a number between 1 and 5
  • +
  • MRK035 — The value of the 'start' attribute on a list must be a valid number
  • +
  • MRK036 — The 'start' attribute on a list is only valid for 'alpha-lower' and 'decimal' list types
  • +
  • MRK037 — Unknown 'sorted' attribute value
  • +
  • MRK038 — The value of the 'level' attribute on a chapter must be a valid number between 2 and 6
  • +
  • MRK039 — Unknown 'style' attribute value on a procedure
  • +
  • MRK040 — Tab with an empty title
  • +
  • MRK041 — Unknown 'style' attribute value on a table
  • +
  • MRK042 — 'colspan' and 'rowspan' attributes of a table cell must be valid numbers
  • +
  • MRK043 — Cannot sort table with 'colspan' or 'rowspan'
  • +
  • MRK044 — Cannot sort table with rows of different length
  • +
  • MRK045 — 'sorted' attribute must appear on the first row cells
  • +
  • MRK046 — Element referred from 'rel' attribute does not exist or is inaccessible in the current context
  • +
  • MRK047 — Summary element has both 'rel' attribute and text specified
  • +
  • MRK048 — Summary element does not provide any text
  • +
  • MRK049 — Unknown 'style' attribute value on a format element
  • +
  • MRK050 — Unknown 'color' attribute value on a format element
  • +
  • MRK051 — The topic must contain exactly one <tldr> element. All except the first one were ignored
  • +
  • MRK052 — Web name is empty
  • +
  • MRK053 — Element has no title
  • +
  • MRK054 — Collapsible procedure title is empty
  • +
  • MRK055 — Unknown 'caps' attribute value
  • +
  • MRK056 — Title can not contain inline formatting
  • +
  • MRK057 — Paragraph can only contain inline elements
  • +
  • MRK058 — Large image in paragraph rendered as a block element by default. Put it outside the paragraph or set the 'style' attribute to indicate your intent.
  • +
  • MRK059 — Nested paragraphs are not allowed. Consider unwrapping or properly breaking the paragraphs.
  • +
  • MRK060 — The chapter must contain exactly one <tldr> element. All except the first one were ignored
  • +
  • MRK061 — Chapter titles with level more than 6 are rendered as level 6
  • +
  • MRK062 — The value of the 'depth' attribute on a <toc> must be a valid number
  • +
  • MRK063 — The 'src' attribute is missing for the <resource> element
  • +
  • MRK064 — The topic must contain exactly one <primary-label> element. All except the first one were ignored
  • +
  • MRK065 — The chapter must contain exactly one <primary-label> element. All except the first one were ignored
  • +
  • MRK066 — Unknown 'border' attribute value on a table. It should be 'false' for no border or 'true' for a border
  • +
  • MRK067 — The 'height' attribute of an iframe cannot be set to 100%. Height will be set to 250px
  • +
  • MRK068 — A 'card' can have only one of the following attributes: 'image', 'icon', or 'badge'
  • +
  • MRK069 — Last modified date must be either a specific date in the format YYYY-MM-DD, 'git' (to get the date from Git history) or 'file' (to get the date from the file system)
  • +
  • PTY001 — The <property> element must contain the 'bundle' and 'key' attributes
  • +
  • PTY002 — The property bundle referenced from a <property> element cannot be found
  • +
  • PTY003 — The specified property key cannot be found in the bundle specified by <property> element
  • +
  • PTY004 — Product specified in the 'from-product' attribute on the <property> element is not found
  • +
  • PTY005 — The specified property bundle is not available for the current product
  • +
  • PTY006 — Cannot process regular expression that cleans up a property value
  • +
  • PTY007 — Property content is corrupted
  • +
  • REF001 — Cannot link to a topic that is not included in the current instance
  • +
  • REF002 — Referenced topic doesn't exist
  • +
  • REF003 — Cannot include element with the specified ID because it does not exist
  • +
  • REF004 — Link uses anchor that does not exist
  • +
  • REF005 — Link inside topic points to the same topic without an anchor
  • +
  • REF007 — Cannot redirect from file name that is associated with an existing article
  • +
  • REF008 — Link points to a topic in a 'draft' state that is not included in the current build
  • +
  • REF009 — Cannot include from topic that does not exist
  • +
  • REF010 — Link in the 'seealso' section points to itself
  • +
  • REF012 — Unknown 'style' attribute value on a 'seealso' section
  • +
  • REF013 — Cannot include a parent element in its child element
  • +
  • REF014 — Cannot include an include directly
  • +
  • REF015 — Include points to multiple IDs
  • +
  • SCT001 — Hard-coded shortcut: consider referencing the corresponding action in the 'key' attribute of the 'shortcut' element
  • +
  • SCT002 — Shortcut is not defined for the requested keymap
  • +
  • SCT003 — Shortcut is not defined for the requested platform
  • +
  • SCT004 — The file containing the keymap referenced from platforms.xml cannot be found or is empty
  • +
  • SCT005 — Action ID is not found in the product keymap.
  • +
  • SCT006 — Shortcut is not defined for layout
  • +
  • SCT007 — Shortcut is not defined for the default keymap
  • +
  • SCT008 — Shortcut is not defined in the current product's keymap
  • +
  • SCT009 — The requested keymap cannot be found
  • +
  • SCT011 — platforms.xml file is corrupted
  • +
  • TOC002 — <toc-element> points to a library topic
  • +
  • TOC003 — The nesting level for a <toc-element> is more than 3. Consider restructuring content for easier navigation.
  • +
  • TOC004 — Wrapper <toc-element> cannot have the 'accepts-web-file-names' attribute as it does not produce any content, so it cannot take redirects
  • +
  • TOC005 — Wrapper <toc-element> cannot have the 'help-id' attribute as it does not produce any content, so there is nothing to link from the UI
  • +
  • TOC006 — <toc-element> ID is duplicated
  • +
  • TOC007 — The 'toc-title' attribute is redundant as it matches the topic title
  • +
  • TOC010 — <toc-element> cannot have both the 'accepts-web-file-names' and the 'accepts-web-file-names-ref' attribute. Consider moving all references to redirection-rules.xml
  • +
  • TOC011 — The 'accepts-web-file-names' attribute references the same topic in several TOC elements
  • +
  • TOC012 — The 'custom-title' attribute on a <toc-element> is deprecated. Move it to the topic itself.
  • +
  • TOC013 — The value of the 'show-structure-depth' attribute on a <toc-element> must be a valid number
  • +
  • TOC016 — <toc-element> should have one of following attributes: 'topic', 'toc-title', or 'ref'
  • +
  • TOC017 — Invalid value of attribute "for"
  • +
  • TOC018 — Target for external redirect conflicts with topic reference
  • +
  • TOC019 — Target for external redirect requires web file names
  • +
  • TOC020 — Target for external redirect must be a hidden TOC element
  • +
  • VIS001 — Image or video file cannot be found
  • +
  • VIS002 — GIF animations and image thumbnails can only be block elements, do not place them inside a paragraph
  • +
  • VIS003 — Logo aspect ratio H:W must be between 0.24 and 1.20
  • +
  • VIS004 — Image or video must have the 'src' attribute
  • +
  • VIS005 — Origin module for image cannot be found in the project
  • +
  • VIS006 — Image or video 'width' and 'height' attributes must be positive integers
  • +
  • VIS007 — Unable to count frames in a GIF image
  • +
  • VIS008 — GIF animations cannot be rendered without a border, do not use 'border-effect="none"'
  • +
  • VIS009 — Unknown image 'border-effect' value
  • +
  • VIS010 — Unknown image style
  • +
  • VIS011 — Dark version of image or video file cannot be found
  • +
  • VIS012 — Image file type is not supported, must be PNG or SVG
  • +
  • VIS013 — Video file type is not supported, must be MP4
  • +
  • VIS014 — Referenced file is outside the documentation project
  • +
  • VIS015 — Image file type is not supported
  • +
+ + diff --git a/documentation/_build/report.json b/documentation/_build/report.json new file mode 100644 index 00000000..08312a9d --- /dev/null +++ b/documentation/_build/report.json @@ -0,0 +1,2634 @@ +{ + "testsErrorsCount" : 3, + "testsTotal" : 180, + "testsWarningsCount" : 1, + "testsPassedCount" : 176, + "testsWarnings" : { + "INT002" : [ { + "id" : "INT002---Bird---Page-1-of-1---in-ctw-tree-589-13--ctw-tree-677-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Bird - Page 1 of 1\"\" in ctw.tree:589:13; ctw.tree:677:5" + }, { + "id" : "INT002---Bird---in-ctw-tree-588-9--ctw-tree-669-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Bird\"\" in ctw.tree:588:9; ctw.tree:669:5" + }, { + "id" : "INT002---Bird---Page-1-of-1---in-ctw-tree-589-13--ctw-tree-677-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Bird+-+Page+1+of+1\"\" in ctw.tree:589:13; ctw.tree:677:5" + }, { + "id" : "INT002---Entra---Page-1-of-1---in-ctw-tree-636-5--ctw-tree-690-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Entra - Page 1 of 1\"\" in ctw.tree:636:5; ctw.tree:690:5" + }, { + "id" : "INT002---Entra---in-ctw-tree-645-5--ctw-tree-692-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Entra\"\" in ctw.tree:645:5; ctw.tree:692:5" + }, { + "id" : "INT002---Entra---Page-1-of-1---in-ctw-tree-636-5--ctw-tree-690-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Entra+-+Page+1+of+1\"\" in ctw.tree:636:5; ctw.tree:690:5" + }, { + "id" : "INT002---FXRatesAPI---Page-1-of-1---in-ctw-tree-663-5--ctw-tree-682-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"FXRatesAPI - Page 1 of 1\"\" in ctw.tree:663:5; ctw.tree:682:5" + }, { + "id" : "INT002---FXRatesAPI---in-ctw-tree-644-5--ctw-tree-651-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"FXRatesAPI\"\" in ctw.tree:644:5; ctw.tree:651:5" + }, { + "id" : "INT002---FXRatesAPI---Page-1-of-1---in-ctw-tree-663-5--ctw-tree-682-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"FXRatesAPI+-+Page+1+of+1\"\" in ctw.tree:663:5; ctw.tree:682:5" + }, { + "id" : "INT002---MotorAPI---Page-1-of-1---in-ctw-tree-659-5--ctw-tree-673-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"MotorAPI - Page 1 of 1\"\" in ctw.tree:659:5; ctw.tree:673:5" + }, { + "id" : "INT002---MotorAPI---in-ctw-tree-648-5--ctw-tree-671-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"MotorAPI\"\" in ctw.tree:648:5; ctw.tree:671:5" + }, { + "id" : "INT002---MotorAPI---Page-1-of-1---in-ctw-tree-659-5--ctw-tree-673-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"MotorAPI+-+Page+1+of+1\"\" in ctw.tree:659:5; ctw.tree:673:5" + }, { + "id" : "INT002---Record-machine-start-button-press-webhook---in-ctw-tree-389-17--ctw-tree-390-17", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Record machine start button press webhook\"\" in ctw.tree:389:17; ctw.tree:390:17" + }, { + "id" : "INT002---Record-machine-start-button-press-webhook---in-ctw-tree-389-17--ctw-tree-390-17", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Record+machine+start+button+press+webhook\"\" in ctw.tree:389:17; ctw.tree:390:17" + }, { + "id" : "INT002---Self-Serve---Page-1-of-1---in-ctw-tree-649-5--ctw-tree-691-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Self-Serve - Page 1 of 1\"\" in ctw.tree:649:5; ctw.tree:691:5" + }, { + "id" : "INT002---Self-Serve---in-ctw-tree-527-9--ctw-tree-666-5--ctw-tree-668-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Self-Serve\"\" in ctw.tree:527:9; ctw.tree:666:5; ctw.tree:668:5" + }, { + "id" : "INT002---Self-Serve---Page-1-of-1---in-ctw-tree-649-5--ctw-tree-691-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Self-Serve+-+Page+1+of+1\"\" in ctw.tree:649:5; ctw.tree:691:5" + }, { + "id" : "INT002---Stripe---Page-1-of-1---in-ctw-tree-641-5--ctw-tree-683-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Stripe - Page 1 of 1\"\" in ctw.tree:641:5; ctw.tree:683:5" + }, { + "id" : "INT002---Stripe---in-ctw-tree-650-5--ctw-tree-681-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Stripe\"\" in ctw.tree:650:5; ctw.tree:681:5" + }, { + "id" : "INT002---Stripe---Page-1-of-1---in-ctw-tree-641-5--ctw-tree-683-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"Stripe+-+Page+1+of+1\"\" in ctw.tree:641:5; ctw.tree:683:5" + }, { + "id" : "INT002---System-wide-search---in-ctw-tree-84-17--ctw-tree-85-17", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"System-wide search\"\" in ctw.tree:84:17; ctw.tree:85:17" + }, { + "id" : "INT002---System-wide-search---in-ctw-tree-84-17--ctw-tree-85-17", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"System-wide+search\"\" in ctw.tree:84:17; ctw.tree:85:17" + }, { + "id" : "INT002---VirkData---Page-1-of-1---in-ctw-tree-661-5--ctw-tree-693-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"VirkData - Page 1 of 1\"\" in ctw.tree:661:5; ctw.tree:693:5" + }, { + "id" : "INT002---VirkData---in-ctw-tree-638-5--ctw-tree-695-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"VirkData\"\" in ctw.tree:638:5; ctw.tree:695:5" + }, { + "id" : "INT002---VirkData---Page-1-of-1---in-ctw-tree-661-5--ctw-tree-693-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"VirkData+-+Page+1+of+1\"\" in ctw.tree:661:5; ctw.tree:693:5" + }, { + "id" : "INT002---WeatherAPI---Page-1-of-1---in-ctw-tree-643-5--ctw-tree-646-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"WeatherAPI - Page 1 of 1\"\" in ctw.tree:643:5; ctw.tree:646:5" + }, { + "id" : "INT002---WeatherAPI---in-ctw-tree-658-5--ctw-tree-665-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"WeatherAPI\"\" in ctw.tree:658:5; ctw.tree:665:5" + }, { + "id" : "INT002---WeatherAPI---Page-1-of-1---in-ctw-tree-643-5--ctw-tree-646-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"WeatherAPI+-+Page+1+of+1\"\" in ctw.tree:643:5; ctw.tree:646:5" + }, { + "id" : "INT002---XLVask---Page-1-of-1---in-ctw-tree-639-5--ctw-tree-642-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"XLVask - Page 1 of 1\"\" in ctw.tree:639:5; ctw.tree:642:5" + }, { + "id" : "INT002---XLVask---in-ctw-tree-637-5--ctw-tree-688-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"XLVask\"\" in ctw.tree:637:5; ctw.tree:688:5" + }, { + "id" : "INT002---XLVask---Page-1-of-1---in-ctw-tree-639-5--ctw-tree-642-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"XLVask+-+Page+1+of+1\"\" in ctw.tree:639:5; ctw.tree:642:5" + }, { + "id" : "INT002---e-conomic---Page-1-of-1---in-ctw-tree-672-5--ctw-tree-694-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"e-conomic - Page 1 of 1\"\" in ctw.tree:672:5; ctw.tree:694:5" + }, { + "id" : "INT002---e-conomic---in-ctw-tree-657-5--ctw-tree-685-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"e-conomic\"\" in ctw.tree:657:5; ctw.tree:685:5" + }, { + "id" : "INT002---e-conomic---Page-1-of-1---in-ctw-tree-672-5--ctw-tree-694-5", + "problemId" : "INT002", + "name" : "Map ID is not unique", + "description" : "\"\"e-conomic+-+Page+1+of+1\"\" in ctw.tree:672:5; ctw.tree:694:5" + } ] + }, + "testsErrors" : { + "MRK004" : [ { + "id" : "MRK004-In-Config_Module_Backups-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Backups.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Backups_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Backups_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Bird-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Bird.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Bird_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Bird_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Email-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Email.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Email_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Email_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Entra-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Entra.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Entra_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Entra_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_FXRatesAPI-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_FXRatesAPI.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_FXRatesAPI_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_FXRatesAPI_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_GatewayAPI-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_GatewayAPI.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_GatewayAPI_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_GatewayAPI_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_LicensePlateRecognizer-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_LicensePlateRecognizer.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_LicensePlateRecognizer_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_LicensePlateRecognizer_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Limble-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Limble.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Limble_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Limble_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_MotorAPI-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_MotorAPI.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_MotorAPI_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_MotorAPI_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_OcrSpace-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_OcrSpace.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_OcrSpace_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_OcrSpace_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_OpenAI-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_OpenAI.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_OpenAI_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_OpenAI_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Self_Serve-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Self_Serve.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Self_Serve_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Self_Serve_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Shelly-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Shelly.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Shelly_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Shelly_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Stripe-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Stripe.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_Stripe_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_Stripe_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_VirkData-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_VirkData.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_VirkData_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_VirkData_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_WeatherAPI-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_WeatherAPI.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_WeatherAPI_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_WeatherAPI_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_XLVask-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_XLVask.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_XLVask_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_XLVask_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_e_conomic-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_e_conomic.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_e_conomic_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_e_conomic_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_reCAPTCHA-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_reCAPTCHA.topic:4:1" + }, { + "id" : "MRK004-In-Config_Module_reCAPTCHA_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Config_Module_reCAPTCHA_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Action_Logs-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Action_Logs.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Action_Logs_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Action_Logs_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Backup-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Backup.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Backup_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Backup_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_CVR-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_CVR.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_CVR_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_CVR_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Entra-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Entra.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Entra_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Entra_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_FXRatesAPI-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_FXRatesAPI.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_FXRatesAPI_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_FXRatesAPI_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_MotorAPI-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_MotorAPI.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_MotorAPI_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_MotorAPI_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Self_Serve-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Self_Serve.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Self_Serve_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Self_Serve_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Stripe-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Stripe.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Stripe_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Stripe_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_VirkData-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_VirkData.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_VirkData_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_VirkData_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Wash_Certificates-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Wash_Certificates.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_Wash_Certificates_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_Wash_Certificates_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_WeatherAPI-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_WeatherAPI.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_WeatherAPI_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_WeatherAPI_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_XLVask-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_XLVask.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_XLVask_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_XLVask_Page_1.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_e_conomic-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_e_conomic.topic:4:1" + }, { + "id" : "MRK004-In-Modules_Module_e_conomic_Page_1-topic-4-1", + "problemId" : "MRK004", + "name" : "Topic ID doesn't match the containing file name", + "description" : "In Modules_Module_e_conomic_Page_1.topic:4:1" + } ], + "REF006" : [ { + "id" : "REF006---API_Reference-topic---in-ctw-tree-613-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"API_Reference.topic\"\" in ctw.tree:613:5" + }, { + "id" : "REF006---Admin_Bookings_CompleteWithoutWashCertificate_POST-topic---in-ctw-tree-629-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Admin_Bookings_CompleteWithoutWashCertificate_POST.topic\"\" in ctw.tree:629:5" + }, { + "id" : "REF006---Admin_Bookings_Delete_POST-topic---in-ctw-tree-626-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Admin_Bookings_Delete_POST.topic\"\" in ctw.tree:626:5" + }, { + "id" : "REF006---Admin_Bookings_Department_Count_GET-topic---in-ctw-tree-632-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Admin_Bookings_Department_Count_GET.topic\"\" in ctw.tree:632:5" + }, { + "id" : "REF006---Admin_Bookings_Sync_POST-topic---in-ctw-tree-624-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Admin_Bookings_Sync_POST.topic\"\" in ctw.tree:624:5" + }, { + "id" : "REF006---Bookings_Download_PDF_GET-topic---in-ctw-tree-621-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Bookings_Download_PDF_GET.topic\"\" in ctw.tree:621:5" + }, { + "id" : "REF006---Bookings_GET-topic---in-ctw-tree-633-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Bookings_GET.topic\"\" in ctw.tree:633:5" + }, { + "id" : "REF006---Bookings_PUT-topic---in-ctw-tree-628-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Bookings_PUT.topic\"\" in ctw.tree:628:5" + }, { + "id" : "REF006---Customers_GET-topic---in-ctw-tree-618-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Customers_GET.topic\"\" in ctw.tree:618:5" + }, { + "id" : "REF006---Customers_POST-topic---in-ctw-tree-615-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Customers_POST.topic\"\" in ctw.tree:615:5" + }, { + "id" : "REF006---Customers_PUT-topic---in-ctw-tree-608-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Customers_PUT.topic\"\" in ctw.tree:608:5" + }, { + "id" : "REF006---Customers_id_DELETE-topic---in-ctw-tree-610-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Customers_id_DELETE.topic\"\" in ctw.tree:610:5" + }, { + "id" : "REF006---Department_Timebookings_Entries_Public_GET-topic---in-ctw-tree-625-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Department_Timebookings_Entries_Public_GET.topic\"\" in ctw.tree:625:5" + }, { + "id" : "REF006---Department_Timebookings_Entries_Public_POST-topic---in-ctw-tree-620-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Department_Timebookings_Entries_Public_POST.topic\"\" in ctw.tree:620:5" + }, { + "id" : "REF006---Department_Timebookings_OpeningHours_Public_GET-topic---in-ctw-tree-630-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Department_Timebookings_OpeningHours_Public_GET.topic\"\" in ctw.tree:630:5" + }, { + "id" : "REF006---Department_Timebookings_Types_Public_GET-topic---in-ctw-tree-627-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Department_Timebookings_Types_Public_GET.topic\"\" in ctw.tree:627:5" + }, { + "id" : "REF006---Superuser_Bookings_Sync_All_POST-topic---in-ctw-tree-631-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Superuser_Bookings_Sync_All_POST.topic\"\" in ctw.tree:631:5" + }, { + "id" : "REF006---UsageLog_GET-topic---in-ctw-tree-617-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"UsageLog_GET.topic\"\" in ctw.tree:617:5" + }, { + "id" : "REF006---User_Bookings_Delete_POST-topic---in-ctw-tree-619-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"User_Bookings_Delete_POST.topic\"\" in ctw.tree:619:5" + }, { + "id" : "REF006---User_Bookings_GET-topic---in-ctw-tree-623-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"User_Bookings_GET.topic\"\" in ctw.tree:623:5" + }, { + "id" : "REF006---User_Bookings_Washcertificate_Download_POST-topic---in-ctw-tree-622-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"User_Bookings_Washcertificate_Download_POST.topic\"\" in ctw.tree:622:5" + }, { + "id" : "REF006---Vehicles_GET-topic---in-ctw-tree-609-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Vehicles_GET.topic\"\" in ctw.tree:609:5" + }, { + "id" : "REF006---Vehicles_POST-topic---in-ctw-tree-616-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Vehicles_POST.topic\"\" in ctw.tree:616:5" + }, { + "id" : "REF006---Vehicles_PUT-topic---in-ctw-tree-607-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Vehicles_PUT.topic\"\" in ctw.tree:607:5" + }, { + "id" : "REF006---Vehicles_id_DELETE-topic---in-ctw-tree-614-5", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"Vehicles_id_DELETE.topic\"\" in ctw.tree:614:5" + }, { + "id" : "REF006---config_module_backups-topic---in-ctw-tree-394-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_backups.topic\"\" in ctw.tree:394:13" + }, { + "id" : "REF006---config_module_backups_page_1-topic---in-ctw-tree-395-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_backups_page_1.topic\"\" in ctw.tree:395:17" + }, { + "id" : "REF006---config_module_bird-topic---in-ctw-tree-400-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_bird.topic\"\" in ctw.tree:400:13" + }, { + "id" : "REF006---config_module_bird_page_1-topic---in-ctw-tree-401-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_bird_page_1.topic\"\" in ctw.tree:401:17" + }, { + "id" : "REF006---config_module_e_conomic-topic---in-ctw-tree-406-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_e_conomic.topic\"\" in ctw.tree:406:13" + }, { + "id" : "REF006---config_module_e_conomic_page_1-topic---in-ctw-tree-407-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_e_conomic_page_1.topic\"\" in ctw.tree:407:17" + }, { + "id" : "REF006---config_module_email-topic---in-ctw-tree-412-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_email.topic\"\" in ctw.tree:412:13" + }, { + "id" : "REF006---config_module_email_page_1-topic---in-ctw-tree-413-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_email_page_1.topic\"\" in ctw.tree:413:17" + }, { + "id" : "REF006---config_module_entra-topic---in-ctw-tree-419-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_entra.topic\"\" in ctw.tree:419:13" + }, { + "id" : "REF006---config_module_entra_page_1-topic---in-ctw-tree-420-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_entra_page_1.topic\"\" in ctw.tree:420:17" + }, { + "id" : "REF006---config_module_fxratesapi-topic---in-ctw-tree-425-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_fxratesapi.topic\"\" in ctw.tree:425:13" + }, { + "id" : "REF006---config_module_fxratesapi_page_1-topic---in-ctw-tree-426-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_fxratesapi_page_1.topic\"\" in ctw.tree:426:17" + }, { + "id" : "REF006---config_module_gatewayapi-topic---in-ctw-tree-431-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_gatewayapi.topic\"\" in ctw.tree:431:13" + }, { + "id" : "REF006---config_module_gatewayapi_page_1-topic---in-ctw-tree-432-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_gatewayapi_page_1.topic\"\" in ctw.tree:432:17" + }, { + "id" : "REF006---config_module_licenseplaterecognizer-topic---in-ctw-tree-437-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_licenseplaterecognizer.topic\"\" in ctw.tree:437:13" + }, { + "id" : "REF006---config_module_licenseplaterecognizer_page_1-topic---in-ctw-tree-438-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_licenseplaterecognizer_page_1.topic\"\" in ctw.tree:438:17" + }, { + "id" : "REF006---config_module_limble-topic---in-ctw-tree-443-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_limble.topic\"\" in ctw.tree:443:13" + }, { + "id" : "REF006---config_module_limble_page_1-topic---in-ctw-tree-444-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_limble_page_1.topic\"\" in ctw.tree:444:17" + }, { + "id" : "REF006---config_module_motorapi-topic---in-ctw-tree-449-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_motorapi.topic\"\" in ctw.tree:449:13" + }, { + "id" : "REF006---config_module_motorapi_page_1-topic---in-ctw-tree-450-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_motorapi_page_1.topic\"\" in ctw.tree:450:17" + }, { + "id" : "REF006---config_module_ocrspace-topic---in-ctw-tree-455-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_ocrspace.topic\"\" in ctw.tree:455:13" + }, { + "id" : "REF006---config_module_ocrspace_page_1-topic---in-ctw-tree-456-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_ocrspace_page_1.topic\"\" in ctw.tree:456:17" + }, { + "id" : "REF006---config_module_openai-topic---in-ctw-tree-461-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_openai.topic\"\" in ctw.tree:461:13" + }, { + "id" : "REF006---config_module_openai_page_1-topic---in-ctw-tree-462-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_openai_page_1.topic\"\" in ctw.tree:462:17" + }, { + "id" : "REF006---config_module_recaptcha-topic---in-ctw-tree-467-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_recaptcha.topic\"\" in ctw.tree:467:13" + }, { + "id" : "REF006---config_module_recaptcha_page_1-topic---in-ctw-tree-468-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_recaptcha_page_1.topic\"\" in ctw.tree:468:17" + }, { + "id" : "REF006---config_module_self_serve-topic---in-ctw-tree-473-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_self_serve.topic\"\" in ctw.tree:473:13" + }, { + "id" : "REF006---config_module_self_serve_page_1-topic---in-ctw-tree-474-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_self_serve_page_1.topic\"\" in ctw.tree:474:17" + }, { + "id" : "REF006---config_module_shelly-topic---in-ctw-tree-479-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_shelly.topic\"\" in ctw.tree:479:13" + }, { + "id" : "REF006---config_module_shelly_page_1-topic---in-ctw-tree-480-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_shelly_page_1.topic\"\" in ctw.tree:480:17" + }, { + "id" : "REF006---config_module_stripe-topic---in-ctw-tree-485-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_stripe.topic\"\" in ctw.tree:485:13" + }, { + "id" : "REF006---config_module_stripe_page_1-topic---in-ctw-tree-486-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_stripe_page_1.topic\"\" in ctw.tree:486:17" + }, { + "id" : "REF006---config_module_virkdata-topic---in-ctw-tree-491-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_virkdata.topic\"\" in ctw.tree:491:13" + }, { + "id" : "REF006---config_module_virkdata_page_1-topic---in-ctw-tree-492-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_virkdata_page_1.topic\"\" in ctw.tree:492:17" + }, { + "id" : "REF006---config_module_weatherapi-topic---in-ctw-tree-497-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_weatherapi.topic\"\" in ctw.tree:497:13" + }, { + "id" : "REF006---config_module_weatherapi_page_1-topic---in-ctw-tree-498-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_weatherapi_page_1.topic\"\" in ctw.tree:498:17" + }, { + "id" : "REF006---config_module_xlvask-topic---in-ctw-tree-503-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_xlvask.topic\"\" in ctw.tree:503:13" + }, { + "id" : "REF006---config_module_xlvask_page_1-topic---in-ctw-tree-504-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"config_module_xlvask_page_1.topic\"\" in ctw.tree:504:17" + }, { + "id" : "REF006---modules_module_action_logs-topic---in-ctw-tree-257-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_action_logs.topic\"\" in ctw.tree:257:13" + }, { + "id" : "REF006---modules_module_action_logs_page_1-topic---in-ctw-tree-258-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_action_logs_page_1.topic\"\" in ctw.tree:258:17" + }, { + "id" : "REF006---modules_module_backup-topic---in-ctw-tree-262-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_backup.topic\"\" in ctw.tree:262:13" + }, { + "id" : "REF006---modules_module_backup_page_1-topic---in-ctw-tree-263-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_backup_page_1.topic\"\" in ctw.tree:263:17" + }, { + "id" : "REF006---modules_module_cvr-topic---in-ctw-tree-268-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_cvr.topic\"\" in ctw.tree:268:13" + }, { + "id" : "REF006---modules_module_cvr_page_1-topic---in-ctw-tree-269-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_cvr_page_1.topic\"\" in ctw.tree:269:17" + }, { + "id" : "REF006---modules_module_e_conomic-topic---in-ctw-tree-274-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_e_conomic.topic\"\" in ctw.tree:274:13" + }, { + "id" : "REF006---modules_module_e_conomic_page_1-topic---in-ctw-tree-275-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_e_conomic_page_1.topic\"\" in ctw.tree:275:17" + }, { + "id" : "REF006---modules_module_entra-topic---in-ctw-tree-288-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_entra.topic\"\" in ctw.tree:288:13" + }, { + "id" : "REF006---modules_module_entra_page_1-topic---in-ctw-tree-289-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_entra_page_1.topic\"\" in ctw.tree:289:17" + }, { + "id" : "REF006---modules_module_fxratesapi-topic---in-ctw-tree-293-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_fxratesapi.topic\"\" in ctw.tree:293:13" + }, { + "id" : "REF006---modules_module_fxratesapi_page_1-topic---in-ctw-tree-294-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_fxratesapi_page_1.topic\"\" in ctw.tree:294:17" + }, { + "id" : "REF006---modules_module_motorapi-topic---in-ctw-tree-299-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_motorapi.topic\"\" in ctw.tree:299:13" + }, { + "id" : "REF006---modules_module_motorapi_page_1-topic---in-ctw-tree-300-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_motorapi_page_1.topic\"\" in ctw.tree:300:17" + }, { + "id" : "REF006---modules_module_self_serve-topic---in-ctw-tree-304-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_self_serve.topic\"\" in ctw.tree:304:13" + }, { + "id" : "REF006---modules_module_self_serve_page_1-topic---in-ctw-tree-305-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_self_serve_page_1.topic\"\" in ctw.tree:305:17" + }, { + "id" : "REF006---modules_module_stripe-topic---in-ctw-tree-314-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_stripe.topic\"\" in ctw.tree:314:13" + }, { + "id" : "REF006---modules_module_stripe_page_1-topic---in-ctw-tree-315-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_stripe_page_1.topic\"\" in ctw.tree:315:17" + }, { + "id" : "REF006---modules_module_virkdata-topic---in-ctw-tree-327-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_virkdata.topic\"\" in ctw.tree:327:13" + }, { + "id" : "REF006---modules_module_virkdata_page_1-topic---in-ctw-tree-328-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_virkdata_page_1.topic\"\" in ctw.tree:328:17" + }, { + "id" : "REF006---modules_module_wash_certificates-topic---in-ctw-tree-332-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_wash_certificates.topic\"\" in ctw.tree:332:13" + }, { + "id" : "REF006---modules_module_wash_certificates_page_1-topic---in-ctw-tree-333-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_wash_certificates_page_1.topic\"\" in ctw.tree:333:17" + }, { + "id" : "REF006---modules_module_weatherapi-topic---in-ctw-tree-337-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_weatherapi.topic\"\" in ctw.tree:337:13" + }, { + "id" : "REF006---modules_module_weatherapi_page_1-topic---in-ctw-tree-338-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_weatherapi_page_1.topic\"\" in ctw.tree:338:17" + }, { + "id" : "REF006---modules_module_xlvask-topic---in-ctw-tree-344-13", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_xlvask.topic\"\" in ctw.tree:344:13" + }, { + "id" : "REF006---modules_module_xlvask_page_1-topic---in-ctw-tree-345-17", + "problemId" : "REF006", + "name" : "'toc-element' for the current instance references a topic file that cannot be found", + "description" : "\"\"modules_module_xlvask_page_1.topic\"\" in ctw.tree:345:17" + } ], + "TOC001" : [ { + "id" : "TOC001--API_Reference-topic--in-ctw-tree-613-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"API_Reference.topic\" in ctw.tree:613:5" + }, { + "id" : "TOC001--Admin_Bookings_CompleteWithoutWashCertificate_POST-topic--in-ctw-tree-629-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Admin_Bookings_CompleteWithoutWashCertificate_POST.topic\" in ctw.tree:629:5" + }, { + "id" : "TOC001--Admin_Bookings_Delete_POST-topic--in-ctw-tree-626-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Admin_Bookings_Delete_POST.topic\" in ctw.tree:626:5" + }, { + "id" : "TOC001--Admin_Bookings_Department_Count_GET-topic--in-ctw-tree-632-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Admin_Bookings_Department_Count_GET.topic\" in ctw.tree:632:5" + }, { + "id" : "TOC001--Admin_Bookings_Sync_POST-topic--in-ctw-tree-624-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Admin_Bookings_Sync_POST.topic\" in ctw.tree:624:5" + }, { + "id" : "TOC001--Bookings_Download_PDF_GET-topic--in-ctw-tree-621-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Bookings_Download_PDF_GET.topic\" in ctw.tree:621:5" + }, { + "id" : "TOC001--Bookings_GET-topic--in-ctw-tree-633-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Bookings_GET.topic\" in ctw.tree:633:5" + }, { + "id" : "TOC001--Bookings_PUT-topic--in-ctw-tree-628-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Bookings_PUT.topic\" in ctw.tree:628:5" + }, { + "id" : "TOC001--Customers_GET-topic--in-ctw-tree-618-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Customers_GET.topic\" in ctw.tree:618:5" + }, { + "id" : "TOC001--Customers_POST-topic--in-ctw-tree-615-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Customers_POST.topic\" in ctw.tree:615:5" + }, { + "id" : "TOC001--Customers_PUT-topic--in-ctw-tree-608-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Customers_PUT.topic\" in ctw.tree:608:5" + }, { + "id" : "TOC001--Customers_id_DELETE-topic--in-ctw-tree-610-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Customers_id_DELETE.topic\" in ctw.tree:610:5" + }, { + "id" : "TOC001--Department_Timebookings_Entries_Public_GET-topic--in-ctw-tree-625-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Department_Timebookings_Entries_Public_GET.topic\" in ctw.tree:625:5" + }, { + "id" : "TOC001--Department_Timebookings_Entries_Public_POST-topic--in-ctw-tree-620-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Department_Timebookings_Entries_Public_POST.topic\" in ctw.tree:620:5" + }, { + "id" : "TOC001--Department_Timebookings_OpeningHours_Public_GET-topic--in-ctw-tree-630-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Department_Timebookings_OpeningHours_Public_GET.topic\" in ctw.tree:630:5" + }, { + "id" : "TOC001--Department_Timebookings_Types_Public_GET-topic--in-ctw-tree-627-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Department_Timebookings_Types_Public_GET.topic\" in ctw.tree:627:5" + }, { + "id" : "TOC001--Superuser_Bookings_Sync_All_POST-topic--in-ctw-tree-631-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Superuser_Bookings_Sync_All_POST.topic\" in ctw.tree:631:5" + }, { + "id" : "TOC001--UsageLog_GET-topic--in-ctw-tree-617-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"UsageLog_GET.topic\" in ctw.tree:617:5" + }, { + "id" : "TOC001--User_Bookings_Delete_POST-topic--in-ctw-tree-619-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"User_Bookings_Delete_POST.topic\" in ctw.tree:619:5" + }, { + "id" : "TOC001--User_Bookings_GET-topic--in-ctw-tree-623-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"User_Bookings_GET.topic\" in ctw.tree:623:5" + }, { + "id" : "TOC001--User_Bookings_Washcertificate_Download_POST-topic--in-ctw-tree-622-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"User_Bookings_Washcertificate_Download_POST.topic\" in ctw.tree:622:5" + }, { + "id" : "TOC001--Vehicles_GET-topic--in-ctw-tree-609-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Vehicles_GET.topic\" in ctw.tree:609:5" + }, { + "id" : "TOC001--Vehicles_POST-topic--in-ctw-tree-616-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Vehicles_POST.topic\" in ctw.tree:616:5" + }, { + "id" : "TOC001--Vehicles_PUT-topic--in-ctw-tree-607-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Vehicles_PUT.topic\" in ctw.tree:607:5" + }, { + "id" : "TOC001--Vehicles_id_DELETE-topic--in-ctw-tree-614-5", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"Vehicles_id_DELETE.topic\" in ctw.tree:614:5" + }, { + "id" : "TOC001--config_module_backups-topic--in-ctw-tree-394-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_backups.topic\" in ctw.tree:394:13" + }, { + "id" : "TOC001--config_module_backups_page_1-topic--in-ctw-tree-395-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_backups_page_1.topic\" in ctw.tree:395:17" + }, { + "id" : "TOC001--config_module_bird-topic--in-ctw-tree-400-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_bird.topic\" in ctw.tree:400:13" + }, { + "id" : "TOC001--config_module_bird_page_1-topic--in-ctw-tree-401-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_bird_page_1.topic\" in ctw.tree:401:17" + }, { + "id" : "TOC001--config_module_e_conomic-topic--in-ctw-tree-406-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_e_conomic.topic\" in ctw.tree:406:13" + }, { + "id" : "TOC001--config_module_e_conomic_page_1-topic--in-ctw-tree-407-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_e_conomic_page_1.topic\" in ctw.tree:407:17" + }, { + "id" : "TOC001--config_module_email-topic--in-ctw-tree-412-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_email.topic\" in ctw.tree:412:13" + }, { + "id" : "TOC001--config_module_email_page_1-topic--in-ctw-tree-413-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_email_page_1.topic\" in ctw.tree:413:17" + }, { + "id" : "TOC001--config_module_entra-topic--in-ctw-tree-419-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_entra.topic\" in ctw.tree:419:13" + }, { + "id" : "TOC001--config_module_entra_page_1-topic--in-ctw-tree-420-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_entra_page_1.topic\" in ctw.tree:420:17" + }, { + "id" : "TOC001--config_module_fxratesapi-topic--in-ctw-tree-425-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_fxratesapi.topic\" in ctw.tree:425:13" + }, { + "id" : "TOC001--config_module_fxratesapi_page_1-topic--in-ctw-tree-426-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_fxratesapi_page_1.topic\" in ctw.tree:426:17" + }, { + "id" : "TOC001--config_module_gatewayapi-topic--in-ctw-tree-431-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_gatewayapi.topic\" in ctw.tree:431:13" + }, { + "id" : "TOC001--config_module_gatewayapi_page_1-topic--in-ctw-tree-432-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_gatewayapi_page_1.topic\" in ctw.tree:432:17" + }, { + "id" : "TOC001--config_module_licenseplaterecognizer-topic--in-ctw-tree-437-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_licenseplaterecognizer.topic\" in ctw.tree:437:13" + }, { + "id" : "TOC001--config_module_licenseplaterecognizer_page_1-topic--in-ctw-tree-438-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_licenseplaterecognizer_page_1.topic\" in ctw.tree:438:17" + }, { + "id" : "TOC001--config_module_limble-topic--in-ctw-tree-443-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_limble.topic\" in ctw.tree:443:13" + }, { + "id" : "TOC001--config_module_limble_page_1-topic--in-ctw-tree-444-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_limble_page_1.topic\" in ctw.tree:444:17" + }, { + "id" : "TOC001--config_module_motorapi-topic--in-ctw-tree-449-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_motorapi.topic\" in ctw.tree:449:13" + }, { + "id" : "TOC001--config_module_motorapi_page_1-topic--in-ctw-tree-450-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_motorapi_page_1.topic\" in ctw.tree:450:17" + }, { + "id" : "TOC001--config_module_ocrspace-topic--in-ctw-tree-455-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_ocrspace.topic\" in ctw.tree:455:13" + }, { + "id" : "TOC001--config_module_ocrspace_page_1-topic--in-ctw-tree-456-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_ocrspace_page_1.topic\" in ctw.tree:456:17" + }, { + "id" : "TOC001--config_module_openai-topic--in-ctw-tree-461-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_openai.topic\" in ctw.tree:461:13" + }, { + "id" : "TOC001--config_module_openai_page_1-topic--in-ctw-tree-462-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_openai_page_1.topic\" in ctw.tree:462:17" + }, { + "id" : "TOC001--config_module_recaptcha-topic--in-ctw-tree-467-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_recaptcha.topic\" in ctw.tree:467:13" + }, { + "id" : "TOC001--config_module_recaptcha_page_1-topic--in-ctw-tree-468-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_recaptcha_page_1.topic\" in ctw.tree:468:17" + }, { + "id" : "TOC001--config_module_self_serve-topic--in-ctw-tree-473-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_self_serve.topic\" in ctw.tree:473:13" + }, { + "id" : "TOC001--config_module_self_serve_page_1-topic--in-ctw-tree-474-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_self_serve_page_1.topic\" in ctw.tree:474:17" + }, { + "id" : "TOC001--config_module_shelly-topic--in-ctw-tree-479-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_shelly.topic\" in ctw.tree:479:13" + }, { + "id" : "TOC001--config_module_shelly_page_1-topic--in-ctw-tree-480-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_shelly_page_1.topic\" in ctw.tree:480:17" + }, { + "id" : "TOC001--config_module_stripe-topic--in-ctw-tree-485-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_stripe.topic\" in ctw.tree:485:13" + }, { + "id" : "TOC001--config_module_stripe_page_1-topic--in-ctw-tree-486-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_stripe_page_1.topic\" in ctw.tree:486:17" + }, { + "id" : "TOC001--config_module_virkdata-topic--in-ctw-tree-491-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_virkdata.topic\" in ctw.tree:491:13" + }, { + "id" : "TOC001--config_module_virkdata_page_1-topic--in-ctw-tree-492-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_virkdata_page_1.topic\" in ctw.tree:492:17" + }, { + "id" : "TOC001--config_module_weatherapi-topic--in-ctw-tree-497-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_weatherapi.topic\" in ctw.tree:497:13" + }, { + "id" : "TOC001--config_module_weatherapi_page_1-topic--in-ctw-tree-498-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_weatherapi_page_1.topic\" in ctw.tree:498:17" + }, { + "id" : "TOC001--config_module_xlvask-topic--in-ctw-tree-503-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_xlvask.topic\" in ctw.tree:503:13" + }, { + "id" : "TOC001--config_module_xlvask_page_1-topic--in-ctw-tree-504-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"config_module_xlvask_page_1.topic\" in ctw.tree:504:17" + }, { + "id" : "TOC001--modules_module_action_logs-topic--in-ctw-tree-257-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_action_logs.topic\" in ctw.tree:257:13" + }, { + "id" : "TOC001--modules_module_action_logs_page_1-topic--in-ctw-tree-258-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_action_logs_page_1.topic\" in ctw.tree:258:17" + }, { + "id" : "TOC001--modules_module_backup-topic--in-ctw-tree-262-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_backup.topic\" in ctw.tree:262:13" + }, { + "id" : "TOC001--modules_module_backup_page_1-topic--in-ctw-tree-263-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_backup_page_1.topic\" in ctw.tree:263:17" + }, { + "id" : "TOC001--modules_module_cvr-topic--in-ctw-tree-268-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_cvr.topic\" in ctw.tree:268:13" + }, { + "id" : "TOC001--modules_module_cvr_page_1-topic--in-ctw-tree-269-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_cvr_page_1.topic\" in ctw.tree:269:17" + }, { + "id" : "TOC001--modules_module_e_conomic-topic--in-ctw-tree-274-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_e_conomic.topic\" in ctw.tree:274:13" + }, { + "id" : "TOC001--modules_module_e_conomic_page_1-topic--in-ctw-tree-275-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_e_conomic_page_1.topic\" in ctw.tree:275:17" + }, { + "id" : "TOC001--modules_module_entra-topic--in-ctw-tree-288-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_entra.topic\" in ctw.tree:288:13" + }, { + "id" : "TOC001--modules_module_entra_page_1-topic--in-ctw-tree-289-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_entra_page_1.topic\" in ctw.tree:289:17" + }, { + "id" : "TOC001--modules_module_fxratesapi-topic--in-ctw-tree-293-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_fxratesapi.topic\" in ctw.tree:293:13" + }, { + "id" : "TOC001--modules_module_fxratesapi_page_1-topic--in-ctw-tree-294-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_fxratesapi_page_1.topic\" in ctw.tree:294:17" + }, { + "id" : "TOC001--modules_module_motorapi-topic--in-ctw-tree-299-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_motorapi.topic\" in ctw.tree:299:13" + }, { + "id" : "TOC001--modules_module_motorapi_page_1-topic--in-ctw-tree-300-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_motorapi_page_1.topic\" in ctw.tree:300:17" + }, { + "id" : "TOC001--modules_module_self_serve-topic--in-ctw-tree-304-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_self_serve.topic\" in ctw.tree:304:13" + }, { + "id" : "TOC001--modules_module_self_serve_page_1-topic--in-ctw-tree-305-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_self_serve_page_1.topic\" in ctw.tree:305:17" + }, { + "id" : "TOC001--modules_module_stripe-topic--in-ctw-tree-314-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_stripe.topic\" in ctw.tree:314:13" + }, { + "id" : "TOC001--modules_module_stripe_page_1-topic--in-ctw-tree-315-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_stripe_page_1.topic\" in ctw.tree:315:17" + }, { + "id" : "TOC001--modules_module_virkdata-topic--in-ctw-tree-327-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_virkdata.topic\" in ctw.tree:327:13" + }, { + "id" : "TOC001--modules_module_virkdata_page_1-topic--in-ctw-tree-328-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_virkdata_page_1.topic\" in ctw.tree:328:17" + }, { + "id" : "TOC001--modules_module_wash_certificates-topic--in-ctw-tree-332-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_wash_certificates.topic\" in ctw.tree:332:13" + }, { + "id" : "TOC001--modules_module_wash_certificates_page_1-topic--in-ctw-tree-333-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_wash_certificates_page_1.topic\" in ctw.tree:333:17" + }, { + "id" : "TOC001--modules_module_weatherapi-topic--in-ctw-tree-337-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_weatherapi.topic\" in ctw.tree:337:13" + }, { + "id" : "TOC001--modules_module_weatherapi_page_1-topic--in-ctw-tree-338-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_weatherapi_page_1.topic\" in ctw.tree:338:17" + }, { + "id" : "TOC001--modules_module_xlvask-topic--in-ctw-tree-344-13", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_xlvask.topic\" in ctw.tree:344:13" + }, { + "id" : "TOC001--modules_module_xlvask_page_1-topic--in-ctw-tree-345-17", + "problemId" : "TOC001", + "name" : " points to an article that doesn't exist", + "description" : "\"modules_module_xlvask_page_1.topic\" in ctw.tree:345:17" + } ] + }, + "idsAndTitles" : { + "CDE001" : "The element must contain exactly two code blocks", + "CDE002" : "The 'collapsed-title-line-number' attribute value on a 'code-block' element is not a valid number", + "CDE003" : "The code snippet doesn't contain the line with the number specified in the 'collapsed-title-line-number' attribute", + "CDE004" : "Cannot read the source code snippet from the specified location, falling back to the 'code-block' element content", + "CDE005" : "Cannot read the source code snippet from the specified location, and there is no fallback content in the 'code-block' element", + "CDE006" : "'code-block' cannot be empty", + "CDE007" : "The 'include-lines' attribute on a 'code-block' element must represent a line number range like 1-3, or multiple comma-separated ranges like 1-3,5-7", + "CDE008" : "The code snippet doesn't contain the lines specified in the 'include-lines' attribute of the corresponding 'code-block' element", + "CDE009" : "Cannot use both the 'include-symbol' and the 'include-lines' attributes to reference a code snippet", + "CDE010" : "The specified code construct is not found in the source file or the file language is not recognized", + "CDE011" : "Code sample contains errors", + "CDE012" : "Cannot validate code sample because its language is not supported", + "CDE013" : "Cannot validate code sample because its language is not specified", + "CDE014" : "Content other than text or CDATA in a code block, for example, HTML/XML-like tags", + "CDE015" : "Unsupported 'style' value on the element", + "CDE016" : "Unknown language is specified for a code block", + "CDE017" : "Syntax error in a code block", + "CDE018" : "Expected at least one closed @start...@end block, but found none.", + "CNF001" : "Category specified in is not declared in c.list", + "CNF002" : "Topic title cannot be empty", + "CNF003" : "Invalid buildprofiles.xml property value", + "CNF005" : "The 'accepts-web-file-names' attribute contains symbols that cannot appear in a file name", + "CNF006" : "The 'accepts-web-file-names' attribute points to a non-existing redirection rule", + "CNF008" : "The 'start-page' attribute on 'instance-profile' cannot be empty", + "CNF009" : "Global variable name in v.list is duplicated", + "CNF010" : "Cannot find specified resource in the resources folder", + "CNF011" : "Category sort order is not a valid number", + "CNF012" : "Tooltip not found", + "CNF013" : "Cannot reference a resource as the r.list file is missing", + "CNF014" : "The instance version links require 'web-path' in instance declaration", + "CNF015" : "Cannot write the result to the specified output directory", + "CNF016" : "Redirect map ID is not unique", + "CNF017" : "Cannot read analytics script or html snippet", + "CNF018" : "Footer link without 'href' attribute", + "CNF019" : "Unknown social link type {0}", + "CNF020" : "Absolute images web-path was given, but images are bundled into single artifact", + "CNF021" : "Web filename is reserved, use a different value for the tag", + "CNF022" : "Web filename is duplicated, ensure that web filename is unique", + "CNF023" : "Topic filenames must be unique, regardless of letter case", + "CNF024" : "The 'start-page' attribute on 'instance-profile' is empty", + "CNF025" : "Web filename is empty. Add letters or digits to the topic filename or specify a custom web filename", + "CNF026" : "Referenced file {0} does not exist or is not possible to read.", + "CNF027" : "Multiple values for {0} are not allowed here.", + "CNF028" : "Expected true or false.", + "CTT001" : "Inappropriate language detected", + "CTT002" : "The generated HTML file size exceeds 1 MB, which may lead to poor performance when rendered in the browser. Consider splitting large topics into multiple smaller ones.", + "CTT004" : "Undefined variable", + "CTT005" : "Variable depends on itself", + "INT001" : "Error description template is not found", + "INT002" : "Map ID is not unique", + "INT004" : "Unexpected article rendering error:", + "INT007" : "XML serialization error", + "INT008" : "Unexpected HTML rendering error: cannot produce a PDF", + "INT009" : "Unexpected diagram rendering error", + "INT010" : "Unexpected PlantUML error:", + "MRK001" : "The element doesn't comply with validation rules", + "MRK002" : "Source file syntax is corrupted", + "MRK003" : "Element ID is not unique", + "MRK004" : "Topic ID doesn't match the containing file name", + "MRK006" : "The 'term' attribute is missing for the element", + "MRK007" : "The 'resource-id' attribute is missing for the element", + "MRK008" : "The 'ref' attribute is missing for the element", + "MRK009" : "Element is not allowed in the current context", + "MRK011" : "Only , , , image or text with inline formatting is allowed inside an element to define link text", + "MRK012" : "Element is unknown", + "MRK013" : "Starting page title is empty", + "MRK014" : "Section starting page description is empty", + "MRK015" : "Section starting page must contain exactly two links under the 'spotlight' element", + "MRK016" : "Section starting page link summary is empty", + "MRK017" : "Section starting page group title is empty", + "MRK018" : "Section starting page group is empty", + "MRK019" : "Element is not allowed on a section starting page", + "MRK020" : "Javascript expression specified in the 'use-when' attribute failed to execute", + "MRK026" : "Unknown 'type' attribute value", + "MRK027" : "The source file for the 'include' is corrupted", + "MRK028" : "Invalid 'style' attribute value on a deflist", + "MRK032" : "Element ID contains whitespace characters", + "MRK033" : "Unknown 'style' attribute value on a list", + "MRK034" : "The value of the 'columns' attribute on a list must be a number between 1 and 5", + "MRK035" : "The value of the 'start' attribute on a list must be a valid number", + "MRK036" : "The 'start' attribute on a list is only valid for 'alpha-lower' and 'decimal' list types", + "MRK037" : "Unknown 'sorted' attribute value", + "MRK038" : "The value of the 'level' attribute on a chapter must be a valid number between 2 and 6", + "MRK039" : "Unknown 'style' attribute value on a procedure", + "MRK040" : "Tab with an empty title", + "MRK041" : "Unknown 'style' attribute value on a table", + "MRK042" : "'colspan' and 'rowspan' attributes of a table cell must be valid numbers", + "MRK043" : "Cannot sort table with 'colspan' or 'rowspan'", + "MRK044" : "Cannot sort table with rows of different length", + "MRK045" : "'sorted' attribute must appear on the first row cells", + "MRK046" : "Element referred from 'rel' attribute does not exist or is inaccessible in the current context", + "MRK047" : "Summary element has both 'rel' attribute and text specified", + "MRK048" : "Summary element does not provide any text", + "MRK049" : "Unknown 'style' attribute value on a format element", + "MRK050" : "Unknown 'color' attribute value on a format element", + "MRK051" : "The topic must contain exactly one element. All except the first one were ignored", + "MRK052" : "Web name is empty", + "MRK053" : "Element has no title", + "MRK054" : "Collapsible procedure title is empty", + "MRK055" : "Unknown 'caps' attribute value", + "MRK056" : "Title can not contain inline formatting", + "MRK057" : "Paragraph can only contain inline elements", + "MRK058" : "Large image in paragraph rendered as a block element by default. Put it outside the paragraph or set the 'style' attribute to indicate your intent.", + "MRK059" : "Nested paragraphs are not allowed. Consider unwrapping or properly breaking the paragraphs.", + "MRK060" : "The chapter must contain exactly one element. All except the first one were ignored", + "MRK061" : "Chapter titles with level more than 6 are rendered as level 6", + "MRK062" : "The value of the 'depth' attribute on a must be a valid number", + "MRK063" : "The 'src' attribute is missing for the element", + "MRK064" : "The topic must contain exactly one element. All except the first one were ignored", + "MRK065" : "The chapter must contain exactly one element. All except the first one were ignored", + "MRK066" : "Unknown 'border' attribute value on a table. It should be 'false' for no border or 'true' for a border", + "MRK067" : "The 'height' attribute of an iframe cannot be set to 100%. Height will be set to 250px", + "MRK068" : "A 'card' can have only one of the following attributes: 'image', 'icon', or 'badge'", + "MRK069" : "Last modified date must be either a specific date in the format YYYY-MM-DD, 'git' (to get the date from Git history) or 'file' (to get the date from the file system)", + "PTY001" : "The element must contain the 'bundle' and 'key' attributes", + "PTY002" : "The property bundle referenced from a element cannot be found", + "PTY003" : "The specified property key cannot be found in the bundle specified by element", + "PTY004" : "Product specified in the 'from-product' attribute on the element is not found", + "PTY005" : "The specified property bundle is not available for the current product", + "PTY006" : "Cannot process regular expression that cleans up a property value", + "PTY007" : "Property content is corrupted", + "REF001" : "Cannot link to a topic that is not included in the current instance", + "REF002" : "Referenced topic doesn't exist", + "REF003" : "Cannot include element with the specified ID because it does not exist", + "REF004" : "Link uses anchor that does not exist", + "REF005" : "Link inside topic points to the same topic without an anchor", + "REF006" : "'toc-element' for the current instance references a topic file that cannot be found", + "REF007" : "Cannot redirect from file name that is associated with an existing article", + "REF008" : "Link points to a topic in a 'draft' state that is not included in the current build", + "REF009" : "Cannot include from topic that does not exist", + "REF010" : "Link in the 'seealso' section points to itself", + "REF012" : "Unknown 'style' attribute value on a 'seealso' section", + "REF013" : "Cannot include a parent element in its child element", + "REF014" : "Cannot include an include directly", + "REF015" : "Include points to multiple IDs", + "SCT001" : "Hard-coded shortcut: consider referencing the corresponding action in the 'key' attribute of the 'shortcut' element", + "SCT002" : "Shortcut is not defined for the requested keymap", + "SCT003" : "Shortcut is not defined for the requested platform", + "SCT004" : "The file containing the keymap referenced from platforms.xml cannot be found or is empty", + "SCT005" : "Action ID is not found in the product keymap.", + "SCT006" : "Shortcut is not defined for layout", + "SCT007" : "Shortcut is not defined for the default keymap", + "SCT008" : "Shortcut is not defined in the current product's keymap", + "SCT009" : "The requested keymap cannot be found", + "SCT011" : "platforms.xml file is corrupted", + "TOC001" : " points to an article that doesn't exist", + "TOC002" : " points to a library topic", + "TOC003" : "The nesting level for a is more than 3. Consider restructuring content for easier navigation.", + "TOC004" : "Wrapper cannot have the 'accepts-web-file-names' attribute as it does not produce any content, so it cannot take redirects", + "TOC005" : "Wrapper cannot have the 'help-id' attribute as it does not produce any content, so there is nothing to link from the UI", + "TOC006" : " ID is duplicated", + "TOC007" : "The 'toc-title' attribute is redundant as it matches the topic title", + "TOC010" : " cannot have both the 'accepts-web-file-names' and the 'accepts-web-file-names-ref' attribute. Consider moving all references to redirection-rules.xml", + "TOC011" : "The 'accepts-web-file-names' attribute references the same topic in several TOC elements", + "TOC012" : "The 'custom-title' attribute on a is deprecated. Move it to the topic itself.", + "TOC013" : "The value of the 'show-structure-depth' attribute on a must be a valid number", + "TOC016" : " should have one of following attributes: 'topic', 'toc-title', or 'ref'", + "TOC017" : "Invalid value of attribute \"for\"", + "TOC018" : "Target for external redirect conflicts with topic reference", + "TOC019" : "Target for external redirect requires web file names", + "TOC020" : "Target for external redirect must be a hidden TOC element", + "VIS001" : "Image or video file cannot be found", + "VIS002" : "GIF animations and image thumbnails can only be block elements, do not place them inside a paragraph", + "VIS003" : "Logo aspect ratio H:W must be between 0.24 and 1.20", + "VIS004" : "Image or video must have the 'src' attribute", + "VIS005" : "Origin module for image cannot be found in the project", + "VIS006" : "Image or video 'width' and 'height' attributes must be positive integers", + "VIS007" : "Unable to count frames in a GIF image", + "VIS008" : "GIF animations cannot be rendered without a border, do not use 'border-effect=\"none\"'", + "VIS009" : "Unknown image 'border-effect' value", + "VIS010" : "Unknown image style", + "VIS011" : "Dark version of image or video file cannot be found", + "VIS012" : "Image file type is not supported, must be PNG or SVG", + "VIS013" : "Video file type is not supported, must be MP4", + "VIS014" : "Referenced file is outside the documentation project", + "VIS015" : "Image file type is not supported", + "API001" : "API documentation markup validation issue", + "API002" : "API Reference Generation Error", + "API003" : "Specification file contains features or content not supported by our generator", + "API004" : "API model building problem" + }, + "testsPassed" : { + "API001" : [ { + "id" : "API001-", + "problemId" : "API001", + "name" : "API documentation markup validation issue", + "description" : "" + } ], + "API002" : [ { + "id" : "API002-", + "problemId" : "API002", + "name" : "API Reference Generation Error", + "description" : "" + } ], + "API003" : [ { + "id" : "API003-", + "problemId" : "API003", + "name" : "Specification file contains features or content not supported by our generator", + "description" : "" + } ], + "API004" : [ { + "id" : "API004-", + "problemId" : "API004", + "name" : "API model building problem", + "description" : "" + } ], + "CDE001" : [ { + "id" : "CDE001-", + "problemId" : "CDE001", + "name" : "The element must contain exactly two code blocks", + "description" : "" + } ], + "CDE002" : [ { + "id" : "CDE002-", + "problemId" : "CDE002", + "name" : "The 'collapsed-title-line-number' attribute value on a 'code-block' element is not a valid number", + "description" : "" + } ], + "CDE003" : [ { + "id" : "CDE003-", + "problemId" : "CDE003", + "name" : "The code snippet doesn't contain the line with the number specified in the 'collapsed-title-line-number' attribute", + "description" : "" + } ], + "CDE004" : [ { + "id" : "CDE004-", + "problemId" : "CDE004", + "name" : "Cannot read the source code snippet from the specified location, falling back to the 'code-block' element content", + "description" : "" + } ], + "CDE005" : [ { + "id" : "CDE005-", + "problemId" : "CDE005", + "name" : "Cannot read the source code snippet from the specified location, and there is no fallback content in the 'code-block' element", + "description" : "" + } ], + "CDE006" : [ { + "id" : "CDE006-", + "problemId" : "CDE006", + "name" : "'code-block' cannot be empty", + "description" : "" + } ], + "CDE007" : [ { + "id" : "CDE007-", + "problemId" : "CDE007", + "name" : "The 'include-lines' attribute on a 'code-block' element must represent a line number range like 1-3, or multiple comma-separated ranges like 1-3,5-7", + "description" : "" + } ], + "CDE008" : [ { + "id" : "CDE008-", + "problemId" : "CDE008", + "name" : "The code snippet doesn't contain the lines specified in the 'include-lines' attribute of the corresponding 'code-block' element", + "description" : "" + } ], + "CDE009" : [ { + "id" : "CDE009-", + "problemId" : "CDE009", + "name" : "Cannot use both the 'include-symbol' and the 'include-lines' attributes to reference a code snippet", + "description" : "" + } ], + "CDE010" : [ { + "id" : "CDE010-", + "problemId" : "CDE010", + "name" : "The specified code construct is not found in the source file or the file language is not recognized", + "description" : "" + } ], + "CDE011" : [ { + "id" : "CDE011-", + "problemId" : "CDE011", + "name" : "Code sample contains errors", + "description" : "" + } ], + "CDE012" : [ { + "id" : "CDE012-", + "problemId" : "CDE012", + "name" : "Cannot validate code sample because its language is not supported", + "description" : "" + } ], + "CDE013" : [ { + "id" : "CDE013-", + "problemId" : "CDE013", + "name" : "Cannot validate code sample because its language is not specified", + "description" : "" + } ], + "CDE014" : [ { + "id" : "CDE014-", + "problemId" : "CDE014", + "name" : "Content other than text or CDATA in a code block, for example, HTML/XML-like tags", + "description" : "" + } ], + "CDE015" : [ { + "id" : "CDE015-", + "problemId" : "CDE015", + "name" : "Unsupported 'style' value on the element", + "description" : "" + } ], + "CDE016" : [ { + "id" : "CDE016-", + "problemId" : "CDE016", + "name" : "Unknown language is specified for a code block", + "description" : "" + } ], + "CDE017" : [ { + "id" : "CDE017-", + "problemId" : "CDE017", + "name" : "Syntax error in a code block", + "description" : "" + } ], + "CDE018" : [ { + "id" : "CDE018-", + "problemId" : "CDE018", + "name" : "Expected at least one closed @start...@end block, but found none.", + "description" : "" + } ], + "CNF001" : [ { + "id" : "CNF001-", + "problemId" : "CNF001", + "name" : "Category specified in is not declared in c.list", + "description" : "" + } ], + "CNF002" : [ { + "id" : "CNF002-", + "problemId" : "CNF002", + "name" : "Topic title cannot be empty", + "description" : "" + } ], + "CNF003" : [ { + "id" : "CNF003-", + "problemId" : "CNF003", + "name" : "Invalid buildprofiles.xml property value", + "description" : "" + } ], + "CNF005" : [ { + "id" : "CNF005-", + "problemId" : "CNF005", + "name" : "The 'accepts-web-file-names' attribute contains symbols that cannot appear in a file name", + "description" : "" + } ], + "CNF006" : [ { + "id" : "CNF006-", + "problemId" : "CNF006", + "name" : "The 'accepts-web-file-names' attribute points to a non-existing redirection rule", + "description" : "" + } ], + "CNF008" : [ { + "id" : "CNF008-", + "problemId" : "CNF008", + "name" : "The 'start-page' attribute on 'instance-profile' cannot be empty", + "description" : "" + } ], + "CNF009" : [ { + "id" : "CNF009-", + "problemId" : "CNF009", + "name" : "Global variable name in v.list is duplicated", + "description" : "" + } ], + "CNF010" : [ { + "id" : "CNF010-", + "problemId" : "CNF010", + "name" : "Cannot find specified resource in the resources folder", + "description" : "" + } ], + "CNF011" : [ { + "id" : "CNF011-", + "problemId" : "CNF011", + "name" : "Category sort order is not a valid number", + "description" : "" + } ], + "CNF012" : [ { + "id" : "CNF012-", + "problemId" : "CNF012", + "name" : "Tooltip not found", + "description" : "" + } ], + "CNF013" : [ { + "id" : "CNF013-", + "problemId" : "CNF013", + "name" : "Cannot reference a resource as the r.list file is missing", + "description" : "" + } ], + "CNF014" : [ { + "id" : "CNF014-", + "problemId" : "CNF014", + "name" : "The instance version links require 'web-path' in instance declaration", + "description" : "" + } ], + "CNF015" : [ { + "id" : "CNF015-", + "problemId" : "CNF015", + "name" : "Cannot write the result to the specified output directory", + "description" : "" + } ], + "CNF016" : [ { + "id" : "CNF016-", + "problemId" : "CNF016", + "name" : "Redirect map ID is not unique", + "description" : "" + } ], + "CNF017" : [ { + "id" : "CNF017-", + "problemId" : "CNF017", + "name" : "Cannot read analytics script or html snippet", + "description" : "" + } ], + "CNF018" : [ { + "id" : "CNF018-", + "problemId" : "CNF018", + "name" : "Footer link without 'href' attribute", + "description" : "" + } ], + "CNF019" : [ { + "id" : "CNF019-", + "problemId" : "CNF019", + "name" : "Unknown social link type {0}", + "description" : "" + } ], + "CNF020" : [ { + "id" : "CNF020-", + "problemId" : "CNF020", + "name" : "Absolute images web-path was given, but images are bundled into single artifact", + "description" : "" + } ], + "CNF021" : [ { + "id" : "CNF021-", + "problemId" : "CNF021", + "name" : "Web filename is reserved, use a different value for the tag", + "description" : "" + } ], + "CNF022" : [ { + "id" : "CNF022-", + "problemId" : "CNF022", + "name" : "Web filename is duplicated, ensure that web filename is unique", + "description" : "" + } ], + "CNF023" : [ { + "id" : "CNF023-", + "problemId" : "CNF023", + "name" : "Topic filenames must be unique, regardless of letter case", + "description" : "" + } ], + "CNF024" : [ { + "id" : "CNF024-", + "problemId" : "CNF024", + "name" : "The 'start-page' attribute on 'instance-profile' is empty", + "description" : "" + } ], + "CNF025" : [ { + "id" : "CNF025-", + "problemId" : "CNF025", + "name" : "Web filename is empty. Add letters or digits to the topic filename or specify a custom web filename", + "description" : "" + } ], + "CNF026" : [ { + "id" : "CNF026-", + "problemId" : "CNF026", + "name" : "Referenced file {0} does not exist or is not possible to read.", + "description" : "" + } ], + "CNF027" : [ { + "id" : "CNF027-", + "problemId" : "CNF027", + "name" : "Multiple values for {0} are not allowed here.", + "description" : "" + } ], + "CNF028" : [ { + "id" : "CNF028-", + "problemId" : "CNF028", + "name" : "Expected true or false.", + "description" : "" + } ], + "CTT001" : [ { + "id" : "CTT001-", + "problemId" : "CTT001", + "name" : "Inappropriate language detected", + "description" : "" + } ], + "CTT002" : [ { + "id" : "CTT002-", + "problemId" : "CTT002", + "name" : "The generated HTML file size exceeds 1 MB, which may lead to poor performance when rendered in the browser. Consider splitting large topics into multiple smaller ones.", + "description" : "" + } ], + "CTT004" : [ { + "id" : "CTT004-", + "problemId" : "CTT004", + "name" : "Undefined variable", + "description" : "" + } ], + "CTT005" : [ { + "id" : "CTT005-", + "problemId" : "CTT005", + "name" : "Variable depends on itself", + "description" : "" + } ], + "INT001" : [ { + "id" : "INT001-", + "problemId" : "INT001", + "name" : "Error description template is not found", + "description" : "" + } ], + "INT004" : [ { + "id" : "INT004-", + "problemId" : "INT004", + "name" : "Unexpected article rendering error:", + "description" : "" + } ], + "INT007" : [ { + "id" : "INT007-", + "problemId" : "INT007", + "name" : "XML serialization error", + "description" : "" + } ], + "INT008" : [ { + "id" : "INT008-", + "problemId" : "INT008", + "name" : "Unexpected HTML rendering error: cannot produce a PDF", + "description" : "" + } ], + "INT009" : [ { + "id" : "INT009-", + "problemId" : "INT009", + "name" : "Unexpected diagram rendering error", + "description" : "" + } ], + "INT010" : [ { + "id" : "INT010-", + "problemId" : "INT010", + "name" : "Unexpected PlantUML error:", + "description" : "" + } ], + "MRK001" : [ { + "id" : "MRK001-", + "problemId" : "MRK001", + "name" : "The element doesn't comply with validation rules", + "description" : "" + } ], + "MRK002" : [ { + "id" : "MRK002-", + "problemId" : "MRK002", + "name" : "Source file syntax is corrupted", + "description" : "" + } ], + "MRK003" : [ { + "id" : "MRK003-", + "problemId" : "MRK003", + "name" : "Element ID is not unique", + "description" : "" + } ], + "MRK006" : [ { + "id" : "MRK006-", + "problemId" : "MRK006", + "name" : "The 'term' attribute is missing for the element", + "description" : "" + } ], + "MRK007" : [ { + "id" : "MRK007-", + "problemId" : "MRK007", + "name" : "The 'resource-id' attribute is missing for the element", + "description" : "" + } ], + "MRK008" : [ { + "id" : "MRK008-", + "problemId" : "MRK008", + "name" : "The 'ref' attribute is missing for the element", + "description" : "" + } ], + "MRK009" : [ { + "id" : "MRK009-", + "problemId" : "MRK009", + "name" : "Element is not allowed in the current context", + "description" : "" + } ], + "MRK011" : [ { + "id" : "MRK011-", + "problemId" : "MRK011", + "name" : "Only , , , image or text with inline formatting is allowed inside an element to define link text", + "description" : "" + } ], + "MRK012" : [ { + "id" : "MRK012-", + "problemId" : "MRK012", + "name" : "Element is unknown", + "description" : "" + } ], + "MRK013" : [ { + "id" : "MRK013-", + "problemId" : "MRK013", + "name" : "Starting page title is empty", + "description" : "" + } ], + "MRK014" : [ { + "id" : "MRK014-", + "problemId" : "MRK014", + "name" : "Section starting page description is empty", + "description" : "" + } ], + "MRK015" : [ { + "id" : "MRK015-", + "problemId" : "MRK015", + "name" : "Section starting page must contain exactly two links under the 'spotlight' element", + "description" : "" + } ], + "MRK016" : [ { + "id" : "MRK016-", + "problemId" : "MRK016", + "name" : "Section starting page link summary is empty", + "description" : "" + } ], + "MRK017" : [ { + "id" : "MRK017-", + "problemId" : "MRK017", + "name" : "Section starting page group title is empty", + "description" : "" + } ], + "MRK018" : [ { + "id" : "MRK018-", + "problemId" : "MRK018", + "name" : "Section starting page group is empty", + "description" : "" + } ], + "MRK019" : [ { + "id" : "MRK019-", + "problemId" : "MRK019", + "name" : "Element is not allowed on a section starting page", + "description" : "" + } ], + "MRK020" : [ { + "id" : "MRK020-", + "problemId" : "MRK020", + "name" : "Javascript expression specified in the 'use-when' attribute failed to execute", + "description" : "" + } ], + "MRK026" : [ { + "id" : "MRK026-", + "problemId" : "MRK026", + "name" : "Unknown 'type' attribute value", + "description" : "" + } ], + "MRK027" : [ { + "id" : "MRK027-", + "problemId" : "MRK027", + "name" : "The source file for the 'include' is corrupted", + "description" : "" + } ], + "MRK028" : [ { + "id" : "MRK028-", + "problemId" : "MRK028", + "name" : "Invalid 'style' attribute value on a deflist", + "description" : "" + } ], + "MRK032" : [ { + "id" : "MRK032-", + "problemId" : "MRK032", + "name" : "Element ID contains whitespace characters", + "description" : "" + } ], + "MRK033" : [ { + "id" : "MRK033-", + "problemId" : "MRK033", + "name" : "Unknown 'style' attribute value on a list", + "description" : "" + } ], + "MRK034" : [ { + "id" : "MRK034-", + "problemId" : "MRK034", + "name" : "The value of the 'columns' attribute on a list must be a number between 1 and 5", + "description" : "" + } ], + "MRK035" : [ { + "id" : "MRK035-", + "problemId" : "MRK035", + "name" : "The value of the 'start' attribute on a list must be a valid number", + "description" : "" + } ], + "MRK036" : [ { + "id" : "MRK036-", + "problemId" : "MRK036", + "name" : "The 'start' attribute on a list is only valid for 'alpha-lower' and 'decimal' list types", + "description" : "" + } ], + "MRK037" : [ { + "id" : "MRK037-", + "problemId" : "MRK037", + "name" : "Unknown 'sorted' attribute value", + "description" : "" + } ], + "MRK038" : [ { + "id" : "MRK038-", + "problemId" : "MRK038", + "name" : "The value of the 'level' attribute on a chapter must be a valid number between 2 and 6", + "description" : "" + } ], + "MRK039" : [ { + "id" : "MRK039-", + "problemId" : "MRK039", + "name" : "Unknown 'style' attribute value on a procedure", + "description" : "" + } ], + "MRK040" : [ { + "id" : "MRK040-", + "problemId" : "MRK040", + "name" : "Tab with an empty title", + "description" : "" + } ], + "MRK041" : [ { + "id" : "MRK041-", + "problemId" : "MRK041", + "name" : "Unknown 'style' attribute value on a table", + "description" : "" + } ], + "MRK042" : [ { + "id" : "MRK042-", + "problemId" : "MRK042", + "name" : "'colspan' and 'rowspan' attributes of a table cell must be valid numbers", + "description" : "" + } ], + "MRK043" : [ { + "id" : "MRK043-", + "problemId" : "MRK043", + "name" : "Cannot sort table with 'colspan' or 'rowspan'", + "description" : "" + } ], + "MRK044" : [ { + "id" : "MRK044-", + "problemId" : "MRK044", + "name" : "Cannot sort table with rows of different length", + "description" : "" + } ], + "MRK045" : [ { + "id" : "MRK045-", + "problemId" : "MRK045", + "name" : "'sorted' attribute must appear on the first row cells", + "description" : "" + } ], + "MRK046" : [ { + "id" : "MRK046-", + "problemId" : "MRK046", + "name" : "Element referred from 'rel' attribute does not exist or is inaccessible in the current context", + "description" : "" + } ], + "MRK047" : [ { + "id" : "MRK047-", + "problemId" : "MRK047", + "name" : "Summary element has both 'rel' attribute and text specified", + "description" : "" + } ], + "MRK048" : [ { + "id" : "MRK048-", + "problemId" : "MRK048", + "name" : "Summary element does not provide any text", + "description" : "" + } ], + "MRK049" : [ { + "id" : "MRK049-", + "problemId" : "MRK049", + "name" : "Unknown 'style' attribute value on a format element", + "description" : "" + } ], + "MRK050" : [ { + "id" : "MRK050-", + "problemId" : "MRK050", + "name" : "Unknown 'color' attribute value on a format element", + "description" : "" + } ], + "MRK051" : [ { + "id" : "MRK051-", + "problemId" : "MRK051", + "name" : "The topic must contain exactly one element. All except the first one were ignored", + "description" : "" + } ], + "MRK052" : [ { + "id" : "MRK052-", + "problemId" : "MRK052", + "name" : "Web name is empty", + "description" : "" + } ], + "MRK053" : [ { + "id" : "MRK053-", + "problemId" : "MRK053", + "name" : "Element has no title", + "description" : "" + } ], + "MRK054" : [ { + "id" : "MRK054-", + "problemId" : "MRK054", + "name" : "Collapsible procedure title is empty", + "description" : "" + } ], + "MRK055" : [ { + "id" : "MRK055-", + "problemId" : "MRK055", + "name" : "Unknown 'caps' attribute value", + "description" : "" + } ], + "MRK056" : [ { + "id" : "MRK056-", + "problemId" : "MRK056", + "name" : "Title can not contain inline formatting", + "description" : "" + } ], + "MRK057" : [ { + "id" : "MRK057-", + "problemId" : "MRK057", + "name" : "Paragraph can only contain inline elements", + "description" : "" + } ], + "MRK058" : [ { + "id" : "MRK058-", + "problemId" : "MRK058", + "name" : "Large image in paragraph rendered as a block element by default. Put it outside the paragraph or set the 'style' attribute to indicate your intent.", + "description" : "" + } ], + "MRK059" : [ { + "id" : "MRK059-", + "problemId" : "MRK059", + "name" : "Nested paragraphs are not allowed. Consider unwrapping or properly breaking the paragraphs.", + "description" : "" + } ], + "MRK060" : [ { + "id" : "MRK060-", + "problemId" : "MRK060", + "name" : "The chapter must contain exactly one element. All except the first one were ignored", + "description" : "" + } ], + "MRK061" : [ { + "id" : "MRK061-", + "problemId" : "MRK061", + "name" : "Chapter titles with level more than 6 are rendered as level 6", + "description" : "" + } ], + "MRK062" : [ { + "id" : "MRK062-", + "problemId" : "MRK062", + "name" : "The value of the 'depth' attribute on a must be a valid number", + "description" : "" + } ], + "MRK063" : [ { + "id" : "MRK063-", + "problemId" : "MRK063", + "name" : "The 'src' attribute is missing for the element", + "description" : "" + } ], + "MRK064" : [ { + "id" : "MRK064-", + "problemId" : "MRK064", + "name" : "The topic must contain exactly one element. All except the first one were ignored", + "description" : "" + } ], + "MRK065" : [ { + "id" : "MRK065-", + "problemId" : "MRK065", + "name" : "The chapter must contain exactly one element. All except the first one were ignored", + "description" : "" + } ], + "MRK066" : [ { + "id" : "MRK066-", + "problemId" : "MRK066", + "name" : "Unknown 'border' attribute value on a table. It should be 'false' for no border or 'true' for a border", + "description" : "" + } ], + "MRK067" : [ { + "id" : "MRK067-", + "problemId" : "MRK067", + "name" : "The 'height' attribute of an iframe cannot be set to 100%. Height will be set to 250px", + "description" : "" + } ], + "MRK068" : [ { + "id" : "MRK068-", + "problemId" : "MRK068", + "name" : "A 'card' can have only one of the following attributes: 'image', 'icon', or 'badge'", + "description" : "" + } ], + "MRK069" : [ { + "id" : "MRK069-", + "problemId" : "MRK069", + "name" : "Last modified date must be either a specific date in the format YYYY-MM-DD, 'git' (to get the date from Git history) or 'file' (to get the date from the file system)", + "description" : "" + } ], + "PTY001" : [ { + "id" : "PTY001-", + "problemId" : "PTY001", + "name" : "The element must contain the 'bundle' and 'key' attributes", + "description" : "" + } ], + "PTY002" : [ { + "id" : "PTY002-", + "problemId" : "PTY002", + "name" : "The property bundle referenced from a element cannot be found", + "description" : "" + } ], + "PTY003" : [ { + "id" : "PTY003-", + "problemId" : "PTY003", + "name" : "The specified property key cannot be found in the bundle specified by element", + "description" : "" + } ], + "PTY004" : [ { + "id" : "PTY004-", + "problemId" : "PTY004", + "name" : "Product specified in the 'from-product' attribute on the element is not found", + "description" : "" + } ], + "PTY005" : [ { + "id" : "PTY005-", + "problemId" : "PTY005", + "name" : "The specified property bundle is not available for the current product", + "description" : "" + } ], + "PTY006" : [ { + "id" : "PTY006-", + "problemId" : "PTY006", + "name" : "Cannot process regular expression that cleans up a property value", + "description" : "" + } ], + "PTY007" : [ { + "id" : "PTY007-", + "problemId" : "PTY007", + "name" : "Property content is corrupted", + "description" : "" + } ], + "REF001" : [ { + "id" : "REF001-", + "problemId" : "REF001", + "name" : "Cannot link to a topic that is not included in the current instance", + "description" : "" + } ], + "REF002" : [ { + "id" : "REF002-", + "problemId" : "REF002", + "name" : "Referenced topic doesn't exist", + "description" : "" + } ], + "REF003" : [ { + "id" : "REF003-", + "problemId" : "REF003", + "name" : "Cannot include element with the specified ID because it does not exist", + "description" : "" + } ], + "REF004" : [ { + "id" : "REF004-", + "problemId" : "REF004", + "name" : "Link uses anchor that does not exist", + "description" : "" + } ], + "REF005" : [ { + "id" : "REF005-", + "problemId" : "REF005", + "name" : "Link inside topic points to the same topic without an anchor", + "description" : "" + } ], + "REF007" : [ { + "id" : "REF007-", + "problemId" : "REF007", + "name" : "Cannot redirect from file name that is associated with an existing article", + "description" : "" + } ], + "REF008" : [ { + "id" : "REF008-", + "problemId" : "REF008", + "name" : "Link points to a topic in a 'draft' state that is not included in the current build", + "description" : "" + } ], + "REF009" : [ { + "id" : "REF009-", + "problemId" : "REF009", + "name" : "Cannot include from topic that does not exist", + "description" : "" + } ], + "REF010" : [ { + "id" : "REF010-", + "problemId" : "REF010", + "name" : "Link in the 'seealso' section points to itself", + "description" : "" + } ], + "REF012" : [ { + "id" : "REF012-", + "problemId" : "REF012", + "name" : "Unknown 'style' attribute value on a 'seealso' section", + "description" : "" + } ], + "REF013" : [ { + "id" : "REF013-", + "problemId" : "REF013", + "name" : "Cannot include a parent element in its child element", + "description" : "" + } ], + "REF014" : [ { + "id" : "REF014-", + "problemId" : "REF014", + "name" : "Cannot include an include directly", + "description" : "" + } ], + "REF015" : [ { + "id" : "REF015-", + "problemId" : "REF015", + "name" : "Include points to multiple IDs", + "description" : "" + } ], + "SCT001" : [ { + "id" : "SCT001-", + "problemId" : "SCT001", + "name" : "Hard-coded shortcut: consider referencing the corresponding action in the 'key' attribute of the 'shortcut' element", + "description" : "" + } ], + "SCT002" : [ { + "id" : "SCT002-", + "problemId" : "SCT002", + "name" : "Shortcut is not defined for the requested keymap", + "description" : "" + } ], + "SCT003" : [ { + "id" : "SCT003-", + "problemId" : "SCT003", + "name" : "Shortcut is not defined for the requested platform", + "description" : "" + } ], + "SCT004" : [ { + "id" : "SCT004-", + "problemId" : "SCT004", + "name" : "The file containing the keymap referenced from platforms.xml cannot be found or is empty", + "description" : "" + } ], + "SCT005" : [ { + "id" : "SCT005-", + "problemId" : "SCT005", + "name" : "Action ID is not found in the product keymap.", + "description" : "" + } ], + "SCT006" : [ { + "id" : "SCT006-", + "problemId" : "SCT006", + "name" : "Shortcut is not defined for layout", + "description" : "" + } ], + "SCT007" : [ { + "id" : "SCT007-", + "problemId" : "SCT007", + "name" : "Shortcut is not defined for the default keymap", + "description" : "" + } ], + "SCT008" : [ { + "id" : "SCT008-", + "problemId" : "SCT008", + "name" : "Shortcut is not defined in the current product's keymap", + "description" : "" + } ], + "SCT009" : [ { + "id" : "SCT009-", + "problemId" : "SCT009", + "name" : "The requested keymap cannot be found", + "description" : "" + } ], + "SCT011" : [ { + "id" : "SCT011-", + "problemId" : "SCT011", + "name" : "platforms.xml file is corrupted", + "description" : "" + } ], + "TOC002" : [ { + "id" : "TOC002-", + "problemId" : "TOC002", + "name" : " points to a library topic", + "description" : "" + } ], + "TOC003" : [ { + "id" : "TOC003-", + "problemId" : "TOC003", + "name" : "The nesting level for a is more than 3. Consider restructuring content for easier navigation.", + "description" : "" + } ], + "TOC004" : [ { + "id" : "TOC004-", + "problemId" : "TOC004", + "name" : "Wrapper cannot have the 'accepts-web-file-names' attribute as it does not produce any content, so it cannot take redirects", + "description" : "" + } ], + "TOC005" : [ { + "id" : "TOC005-", + "problemId" : "TOC005", + "name" : "Wrapper cannot have the 'help-id' attribute as it does not produce any content, so there is nothing to link from the UI", + "description" : "" + } ], + "TOC006" : [ { + "id" : "TOC006-", + "problemId" : "TOC006", + "name" : " ID is duplicated", + "description" : "" + } ], + "TOC007" : [ { + "id" : "TOC007-", + "problemId" : "TOC007", + "name" : "The 'toc-title' attribute is redundant as it matches the topic title", + "description" : "" + } ], + "TOC010" : [ { + "id" : "TOC010-", + "problemId" : "TOC010", + "name" : " cannot have both the 'accepts-web-file-names' and the 'accepts-web-file-names-ref' attribute. Consider moving all references to redirection-rules.xml", + "description" : "" + } ], + "TOC011" : [ { + "id" : "TOC011-", + "problemId" : "TOC011", + "name" : "The 'accepts-web-file-names' attribute references the same topic in several TOC elements", + "description" : "" + } ], + "TOC012" : [ { + "id" : "TOC012-", + "problemId" : "TOC012", + "name" : "The 'custom-title' attribute on a is deprecated. Move it to the topic itself.", + "description" : "" + } ], + "TOC013" : [ { + "id" : "TOC013-", + "problemId" : "TOC013", + "name" : "The value of the 'show-structure-depth' attribute on a must be a valid number", + "description" : "" + } ], + "TOC016" : [ { + "id" : "TOC016-", + "problemId" : "TOC016", + "name" : " should have one of following attributes: 'topic', 'toc-title', or 'ref'", + "description" : "" + } ], + "TOC017" : [ { + "id" : "TOC017-", + "problemId" : "TOC017", + "name" : "Invalid value of attribute \"for\"", + "description" : "" + } ], + "TOC018" : [ { + "id" : "TOC018-", + "problemId" : "TOC018", + "name" : "Target for external redirect conflicts with topic reference", + "description" : "" + } ], + "TOC019" : [ { + "id" : "TOC019-", + "problemId" : "TOC019", + "name" : "Target for external redirect requires web file names", + "description" : "" + } ], + "TOC020" : [ { + "id" : "TOC020-", + "problemId" : "TOC020", + "name" : "Target for external redirect must be a hidden TOC element", + "description" : "" + } ], + "VIS001" : [ { + "id" : "VIS001-", + "problemId" : "VIS001", + "name" : "Image or video file cannot be found", + "description" : "" + } ], + "VIS002" : [ { + "id" : "VIS002-", + "problemId" : "VIS002", + "name" : "GIF animations and image thumbnails can only be block elements, do not place them inside a paragraph", + "description" : "" + } ], + "VIS003" : [ { + "id" : "VIS003-", + "problemId" : "VIS003", + "name" : "Logo aspect ratio H:W must be between 0.24 and 1.20", + "description" : "" + } ], + "VIS004" : [ { + "id" : "VIS004-", + "problemId" : "VIS004", + "name" : "Image or video must have the 'src' attribute", + "description" : "" + } ], + "VIS005" : [ { + "id" : "VIS005-", + "problemId" : "VIS005", + "name" : "Origin module for image cannot be found in the project", + "description" : "" + } ], + "VIS006" : [ { + "id" : "VIS006-", + "problemId" : "VIS006", + "name" : "Image or video 'width' and 'height' attributes must be positive integers", + "description" : "" + } ], + "VIS007" : [ { + "id" : "VIS007-", + "problemId" : "VIS007", + "name" : "Unable to count frames in a GIF image", + "description" : "" + } ], + "VIS008" : [ { + "id" : "VIS008-", + "problemId" : "VIS008", + "name" : "GIF animations cannot be rendered without a border, do not use 'border-effect=\"none\"'", + "description" : "" + } ], + "VIS009" : [ { + "id" : "VIS009-", + "problemId" : "VIS009", + "name" : "Unknown image 'border-effect' value", + "description" : "" + } ], + "VIS010" : [ { + "id" : "VIS010-", + "problemId" : "VIS010", + "name" : "Unknown image style", + "description" : "" + } ], + "VIS011" : [ { + "id" : "VIS011-", + "problemId" : "VIS011", + "name" : "Dark version of image or video file cannot be found", + "description" : "" + } ], + "VIS012" : [ { + "id" : "VIS012-", + "problemId" : "VIS012", + "name" : "Image file type is not supported, must be PNG or SVG", + "description" : "" + } ], + "VIS013" : [ { + "id" : "VIS013-", + "problemId" : "VIS013", + "name" : "Video file type is not supported, must be MP4", + "description" : "" + } ], + "VIS014" : [ { + "id" : "VIS014-", + "problemId" : "VIS014", + "name" : "Referenced file is outside the documentation project", + "description" : "" + } ], + "VIS015" : [ { + "id" : "VIS015-", + "problemId" : "VIS015", + "name" : "Image file type is not supported", + "description" : "" + } ] + } +} \ No newline at end of file diff --git a/documentation/_build/webHelpCTW2-all.zip b/documentation/_build/webHelpCTW2-all.zip new file mode 100644 index 00000000..c1d53376 Binary files /dev/null and b/documentation/_build/webHelpCTW2-all.zip differ diff --git a/documentation/_site_rebuild_20260317/HelpTOC.json b/documentation/_site_rebuild_20260317/HelpTOC.json new file mode 100644 index 00000000..d59909da --- /dev/null +++ b/documentation/_site_rebuild_20260317/HelpTOC.json @@ -0,0 +1 @@ +{"entities":{"pages":{"Introduction":{"id":"Introduction","title":"Introduction","url":"introduction.html","level":0,"tabIndex":0},"API-Overview":{"id":"API-Overview","title":"API Overview","url":"api-overview.html","level":0,"pages":["Authentication","Error-Handling"],"tabIndex":1},"Authentication":{"id":"Authentication","title":"Authentication","url":"authentication.html","level":1,"parentId":"API-Overview","tabIndex":0},"Error-Handling":{"id":"Error-Handling","title":"Error Handling","url":"error-handling.html","level":1,"parentId":"API-Overview","tabIndex":1},"API-Reference":{"id":"API-Reference","title":"API Reference","url":"api-reference.html","level":0,"pages":["Tag_Authentication","Tag_Security","Tag_Users","Tag_Search","Tag_Orders","Tag_Order_Items","Tag_Bookings","Tag_Departments","Tag_Products","Tag_Categories","Tag_Invoices","Tag_Vehicles","Tag_Notifications","Tag_Statistics","Tag_Modules","Tag_Attachments","Tag_Forms","Tag_Worker","Tag_Plate_Scans","Tag_Config","Tag_Branding","Tag_Roles","Tag_Self_Serve","Tag_Goals","Tag_Subusers","Tag_Bird"],"tabIndex":2},"Tag_Authentication":{"id":"Tag_Authentication","title":"Authentication","url":"tag-authentication.html","level":1,"parentId":"API-Reference","pages":["Tag_Authentication_Page_1"],"tabIndex":0},"Tag_Authentication_Page_1":{"id":"Tag_Authentication_Page_1","title":"Authentication - Page 1 of 1","url":"tag-authentication-page-1.html","level":2,"parentId":"Tag_Authentication","pages":["disable2fa","enable2fa","setup2fa","verify2fa","employeeLogin","customerLogin","logout","passkeyChallenge","passkeyVerify","requestPasswordReset","setPasswordUsingResetToken","validatePasswordResetToken","getRecaptchaConfig","registerCustomerByCvr","getSession","suIntimidate"],"tabIndex":0},"disable2fa":{"id":"disable2fa","title":"Disable 2FA","url":"disable2fa.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":0},"enable2fa":{"id":"enable2fa","title":"Enable 2FA","url":"enable2fa.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":1},"setup2fa":{"id":"setup2fa","title":"Generate 2FA secret","url":"setup2fa.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":2},"verify2fa":{"id":"verify2fa","title":"Verify 2FA code during login","url":"verify2fa.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":3},"employeeLogin":{"id":"employeeLogin","title":"Employee login","url":"employeelogin.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":4},"customerLogin":{"id":"customerLogin","title":"Customer login","url":"customerlogin.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":5},"logout":{"id":"logout","title":"Logout","url":"logout.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":6},"passkeyChallenge":{"id":"passkeyChallenge","title":"Initiate passkey authentication challenge","url":"passkeychallenge.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":7},"passkeyVerify":{"id":"passkeyVerify","title":"Verify passkey authentication and start session","url":"passkeyverify.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":8},"requestPasswordReset":{"id":"requestPasswordReset","title":"Request a customer password reset email","url":"requestpasswordreset.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":9},"setPasswordUsingResetToken":{"id":"setPasswordUsingResetToken","title":"Set a customer password using a reset key","url":"setpasswordusingresettoken.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":10},"validatePasswordResetToken":{"id":"validatePasswordResetToken","title":"Validate a customer password reset key","url":"validatepasswordresettoken.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":11},"getRecaptchaConfig":{"id":"getRecaptchaConfig","title":"Get reCAPTCHA configuration","url":"getrecaptchaconfig.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":12},"registerCustomerByCvr":{"id":"registerCustomerByCvr","title":"Register new customer by CVR","url":"registercustomerbycvr.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":13},"getSession":{"id":"getSession","title":"Get current session","url":"getsession.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":14},"suIntimidate":{"id":"suIntimidate","title":"Intimidate a user","url":"suintimidate.html","level":3,"parentId":"Tag_Authentication_Page_1","tabIndex":15},"Tag_Security":{"id":"Tag_Security","title":"Security","url":"tag-security.html","level":1,"parentId":"API-Reference","pages":["Tag_Security_Page_1"],"tabIndex":1},"Tag_Security_Page_1":{"id":"Tag_Security_Page_1","title":"Security - Page 1 of 1","url":"tag-security-page-1.html","level":2,"parentId":"Tag_Security","pages":["listPasskeys","createPasskey","deletePasskey","renamePasskey"],"tabIndex":0},"listPasskeys":{"id":"listPasskeys","title":"List passkeys for the authenticated user","url":"listpasskeys.html","level":3,"parentId":"Tag_Security_Page_1","tabIndex":0},"createPasskey":{"id":"createPasskey","title":"Create/add a passkey for the authenticated user","url":"createpasskey.html","level":3,"parentId":"Tag_Security_Page_1","tabIndex":1},"deletePasskey":{"id":"deletePasskey","title":"Delete a passkey","url":"deletepasskey.html","level":3,"parentId":"Tag_Security_Page_1","tabIndex":2},"renamePasskey":{"id":"renamePasskey","title":"Rename a passkey","url":"renamepasskey.html","level":3,"parentId":"Tag_Security_Page_1","tabIndex":3},"Tag_Users":{"id":"Tag_Users","title":"Users","url":"tag-users.html","level":1,"parentId":"API-Reference","pages":["Tag_Users_Page_1","Tag_Users_Page_2"],"tabIndex":2},"Tag_Users_Page_1":{"id":"Tag_Users_Page_1","title":"Users - Page 1 of 2","url":"tag-users-page-1.html","level":2,"parentId":"Tag_Users","pages":["updateUserNotifications","getCustomerCode","addCustomerCode","getUserIdFromCustomerNumber","getCustomerName","deleteCustomerAttribute","getCustomerAttributes","addCustomerAttribute","deleteCustomerDefaultDepartment","getCustomerDefaultDepartment","addCustomerDefaultDepartment","deleteCustomerNote","getCustomerNotes","addCustomerNote","deleteCustomerFixedPricing","getCustomerFixedPricing","addCustomerFixedPricing","listCustomers","searchCustomers","validateCustomerNumber"],"tabIndex":0},"updateUserNotifications":{"id":"updateUserNotifications","title":"Update user notification settings","url":"updateusernotifications.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":0},"getCustomerCode":{"id":"getCustomerCode","title":"Get customer code","url":"getcustomercode.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":1},"addCustomerCode":{"id":"addCustomerCode","title":"Add customer code","url":"addcustomercode.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":2},"getUserIdFromCustomerNumber":{"id":"getUserIdFromCustomerNumber","title":"Get user ID from customer number","url":"getuseridfromcustomernumber.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":3},"getCustomerName":{"id":"getCustomerName","title":"Get customer name","url":"getcustomername.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":4},"deleteCustomerAttribute":{"id":"deleteCustomerAttribute","title":"Delete customer attribute","url":"deletecustomerattribute.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":5},"getCustomerAttributes":{"id":"getCustomerAttributes","title":"Get customer attributes","url":"getcustomerattributes.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":6},"addCustomerAttribute":{"id":"addCustomerAttribute","title":"Add customer attribute","url":"addcustomerattribute.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":7},"deleteCustomerDefaultDepartment":{"id":"deleteCustomerDefaultDepartment","title":"Delete customer default department","url":"deletecustomerdefaultdepartment.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":8},"getCustomerDefaultDepartment":{"id":"getCustomerDefaultDepartment","title":"Get customer default department","url":"getcustomerdefaultdepartment.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":9},"addCustomerDefaultDepartment":{"id":"addCustomerDefaultDepartment","title":"Add customer default department","url":"addcustomerdefaultdepartment.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":10},"deleteCustomerNote":{"id":"deleteCustomerNote","title":"Delete customer note","url":"deletecustomernote.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":11},"getCustomerNotes":{"id":"getCustomerNotes","title":"Get customer notes","url":"getcustomernotes.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":12},"addCustomerNote":{"id":"addCustomerNote","title":"Add customer note","url":"addcustomernote.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":13},"deleteCustomerFixedPricing":{"id":"deleteCustomerFixedPricing","title":"Delete customer fixed pricing","url":"deletecustomerfixedpricing.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":14},"getCustomerFixedPricing":{"id":"getCustomerFixedPricing","title":"Get customer fixed pricing","url":"getcustomerfixedpricing.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":15},"addCustomerFixedPricing":{"id":"addCustomerFixedPricing","title":"Add customer fixed pricing","url":"addcustomerfixedpricing.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":16},"listCustomers":{"id":"listCustomers","title":"List customers","url":"listcustomers.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":17},"searchCustomers":{"id":"searchCustomers","title":"Search customers","url":"searchcustomers.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":18},"validateCustomerNumber":{"id":"validateCustomerNumber","title":"Validate customer number","url":"validatecustomernumber.html","level":3,"parentId":"Tag_Users_Page_1","tabIndex":19},"Tag_Users_Page_2":{"id":"Tag_Users_Page_2","title":"Users - Page 2 of 2","url":"tag-users-page-2.html","level":2,"parentId":"Tag_Users","pages":["listPermissions","getSuperuserUser","getUserDiscounts","setUserDiscount","getUserKeys","setUserKey","setUserPassword","getUserPermissions","listUsers","createUser","updateUser","getCustomer"],"tabIndex":1},"listPermissions":{"id":"listPermissions","title":"List permissions","url":"listpermissions.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":0},"getSuperuserUser":{"id":"getSuperuserUser","title":"Get user by ID (superuser)","url":"getsuperuseruser.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":1},"getUserDiscounts":{"id":"getUserDiscounts","title":"Get user discounts","url":"getuserdiscounts.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":2},"setUserDiscount":{"id":"setUserDiscount","title":"Set user discount","url":"setuserdiscount.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":3},"getUserKeys":{"id":"getUserKeys","title":"Get user keys","url":"getuserkeys.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":4},"setUserKey":{"id":"setUserKey","title":"Set user key","url":"setuserkey.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":5},"setUserPassword":{"id":"setUserPassword","title":"Set user password","url":"setuserpassword.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":6},"getUserPermissions":{"id":"getUserPermissions","title":"Get user permissions","url":"getuserpermissions.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":7},"listUsers":{"id":"listUsers","title":"List users","url":"listusers.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":8},"createUser":{"id":"createUser","title":"Create new user","url":"createuser.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":9},"updateUser":{"id":"updateUser","title":"Update user","url":"updateuser.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":10},"getCustomer":{"id":"getCustomer","title":"Get customer details","url":"getcustomer.html","level":3,"parentId":"Tag_Users_Page_2","tabIndex":11},"Tag_Search":{"id":"Tag_Search","title":"Search","url":"tag-search.html","level":1,"parentId":"API-Reference","pages":["Tag_Search_Page_1"],"tabIndex":3},"Tag_Search_Page_1":{"id":"Tag_Search_Page_1","title":"Search - Page 1 of 1","url":"tag-search-page-1.html","level":2,"parentId":"Tag_Search","pages":["systemWideSearchGet","systemWideSearchPost","clearSystemSearchCache","rebuildSystemSearchCache"],"tabIndex":0},"systemWideSearchGet":{"id":"systemWideSearchGet","title":"System-wide search","url":"systemwidesearchget.html","level":3,"parentId":"Tag_Search_Page_1","tabIndex":0},"systemWideSearchPost":{"id":"systemWideSearchPost","title":"System-wide search","url":"systemwidesearchpost.html","level":3,"parentId":"Tag_Search_Page_1","tabIndex":1},"clearSystemSearchCache":{"id":"clearSystemSearchCache","title":"Clear system search caches","url":"clearsystemsearchcache.html","level":3,"parentId":"Tag_Search_Page_1","tabIndex":2},"rebuildSystemSearchCache":{"id":"rebuildSystemSearchCache","title":"Queue system search cache rebuild","url":"rebuildsystemsearchcache.html","level":3,"parentId":"Tag_Search_Page_1","tabIndex":3},"Tag_Orders":{"id":"Tag_Orders","title":"Orders","url":"tag-orders.html","level":1,"parentId":"API-Reference","pages":["Tag_Orders_Page_1"],"tabIndex":4},"Tag_Orders_Page_1":{"id":"Tag_Orders_Page_1","title":"Orders - Page 1 of 1","url":"tag-orders-page-1.html","level":2,"parentId":"Tag_Orders","pages":["getOrder","updateOrder","generateWashCertificate","deleteOrder","listOrders","createOrder","updateOrders","markOrderCompleted","simulateStripePayment","deleteStripePaymentIntent","getStripePaymentIntent","createStripePaymentIntent","captureStripePaymentIntent","getUserOrder","getUserOrders"],"tabIndex":0},"getOrder":{"id":"getOrder","title":"Get order details","url":"getorder.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":0},"updateOrder":{"id":"updateOrder","title":"Update order","url":"updateorder.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":1},"generateWashCertificate":{"id":"generateWashCertificate","title":"Generate wash certificate","url":"generatewashcertificate.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":2},"deleteOrder":{"id":"deleteOrder","title":"Delete order","url":"deleteorder.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":3},"listOrders":{"id":"listOrders","title":"List orders","url":"listorders.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":4},"createOrder":{"id":"createOrder","title":"Create new order","url":"createorder.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":5},"updateOrders":{"id":"updateOrders","title":"Update order (alias)","url":"updateorders.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":6},"markOrderCompleted":{"id":"markOrderCompleted","title":"Mark order as completed","url":"markordercompleted.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":7},"simulateStripePayment":{"id":"simulateStripePayment","title":"Simulate Stripe payment","url":"simulatestripepayment.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":8},"deleteStripePaymentIntent":{"id":"deleteStripePaymentIntent","title":"Delete Stripe payment intent","url":"deletestripepaymentintent.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":9},"getStripePaymentIntent":{"id":"getStripePaymentIntent","title":"Get Stripe payment intent","url":"getstripepaymentintent.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":10},"createStripePaymentIntent":{"id":"createStripePaymentIntent","title":"Create Stripe payment intent","url":"createstripepaymentintent.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":11},"captureStripePaymentIntent":{"id":"captureStripePaymentIntent","title":"Capture Stripe payment intent","url":"capturestripepaymentintent.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":12},"getUserOrder":{"id":"getUserOrder","title":"Get user\u0027s specific order","url":"getuserorder.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":13},"getUserOrders":{"id":"getUserOrders","title":"Get current user\u0027s orders","url":"getuserorders.html","level":3,"parentId":"Tag_Orders_Page_1","tabIndex":14},"Tag_Order_Items":{"id":"Tag_Order_Items","title":"Order Items","url":"tag-order-items.html","level":1,"parentId":"API-Reference","pages":["Tag_Order_Items_Page_1"],"tabIndex":5},"Tag_Order_Items_Page_1":{"id":"Tag_Order_Items_Page_1","title":"Order Items - Page 1 of 1","url":"tag-order-items-page-1.html","level":2,"parentId":"Tag_Order_Items","pages":["deleteOrderItem","listOrderItems","addOrderItem","updateOrderItem"],"tabIndex":0},"deleteOrderItem":{"id":"deleteOrderItem","title":"Delete order item","url":"deleteorderitem.html","level":3,"parentId":"Tag_Order_Items_Page_1","tabIndex":0},"listOrderItems":{"id":"listOrderItems","title":"List order items","url":"listorderitems.html","level":3,"parentId":"Tag_Order_Items_Page_1","tabIndex":1},"addOrderItem":{"id":"addOrderItem","title":"Add item to order","url":"addorderitem.html","level":3,"parentId":"Tag_Order_Items_Page_1","tabIndex":2},"updateOrderItem":{"id":"updateOrderItem","title":"Update order item","url":"updateorderitem.html","level":3,"parentId":"Tag_Order_Items_Page_1","tabIndex":3},"Tag_Bookings":{"id":"Tag_Bookings","title":"Bookings","url":"tag-bookings.html","level":1,"parentId":"API-Reference","pages":["Tag_Bookings_Page_1"],"tabIndex":6},"Tag_Bookings_Page_1":{"id":"Tag_Bookings_Page_1","title":"Bookings - Page 1 of 1","url":"tag-bookings-page-1.html","level":2,"parentId":"Tag_Bookings","pages":["completeWashWithoutWashCertificate","adminDeleteBooking","getDepartmentBookingCount","syncBooking","listBookings","updateBooking","downloadBookingPdf","listOrderBookings","completeOrderBooking","syncAllBookings","getUserBookings","deleteOwnBooking","downloadOwnWashCertificate"],"tabIndex":0},"completeWashWithoutWashCertificate":{"id":"completeWashWithoutWashCertificate","title":"Complete wash without wash certificate","url":"completewashwithoutwashcertificate.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":0},"adminDeleteBooking":{"id":"adminDeleteBooking","title":"Delete booking (admin)","url":"admindeletebooking.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":1},"getDepartmentBookingCount":{"id":"getDepartmentBookingCount","title":"Get department unfulfilled bookings count","url":"getdepartmentbookingcount.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":2},"syncBooking":{"id":"syncBooking","title":"Sync booking from external system","url":"syncbooking.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":3},"listBookings":{"id":"listBookings","title":"List bookings","url":"listbookings.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":4},"updateBooking":{"id":"updateBooking","title":"Update booking","url":"updatebooking.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":5},"downloadBookingPdf":{"id":"downloadBookingPdf","title":"Download booking PDF","url":"downloadbookingpdf.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":6},"listOrderBookings":{"id":"listOrderBookings","title":"List order bookings","url":"listorderbookings.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":7},"completeOrderBooking":{"id":"completeOrderBooking","title":"Complete order booking","url":"completeorderbooking.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":8},"syncAllBookings":{"id":"syncAllBookings","title":"Sync all bookings from external system","url":"syncallbookings.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":9},"getUserBookings":{"id":"getUserBookings","title":"Get user bookings","url":"getuserbookings.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":10},"deleteOwnBooking":{"id":"deleteOwnBooking","title":"Delete own booking","url":"deleteownbooking.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":11},"downloadOwnWashCertificate":{"id":"downloadOwnWashCertificate","title":"Get download link for own wash certificate","url":"downloadownwashcertificate.html","level":3,"parentId":"Tag_Bookings_Page_1","tabIndex":12},"Tag_Departments":{"id":"Tag_Departments","title":"Departments","url":"tag-departments.html","level":1,"parentId":"API-Reference","pages":["Tag_Departments_Page_1","Tag_Departments_Page_2"],"tabIndex":7},"Tag_Departments_Page_1":{"id":"Tag_Departments_Page_1","title":"Departments - Page 1 of 2","url":"tag-departments-page-1.html","level":2,"parentId":"Tag_Departments","pages":["deleteDepartmentGate","listDepartmentGates","createDepartmentGate","updateDepartmentGate","listDepartmentLanes","createDepartmentLane","updateDepartmentLane","getDepartmentLaneDynamicImage","deleteDepartmentRelay","listDepartmentRelays","createDepartmentRelay","updateDepartmentRelay","listDepartments","createDepartment","updateDepartment","removeDepartmentCategory","getDepartmentCategories","addDepartmentCategory","listDailyReports","addDailyReport"],"tabIndex":0},"deleteDepartmentGate":{"id":"deleteDepartmentGate","title":"Delete department gate","url":"deletedepartmentgate.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":0},"listDepartmentGates":{"id":"listDepartmentGates","title":"List department gates","url":"listdepartmentgates.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":1},"createDepartmentGate":{"id":"createDepartmentGate","title":"Create department gate","url":"createdepartmentgate.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":2},"updateDepartmentGate":{"id":"updateDepartmentGate","title":"Update department gate","url":"updatedepartmentgate.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":3},"listDepartmentLanes":{"id":"listDepartmentLanes","title":"List department lanes","url":"listdepartmentlanes.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":4},"createDepartmentLane":{"id":"createDepartmentLane","title":"Create department lane","url":"createdepartmentlane.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":5},"updateDepartmentLane":{"id":"updateDepartmentLane","title":"Update department lane","url":"updatedepartmentlane.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":6},"getDepartmentLaneDynamicImage":{"id":"getDepartmentLaneDynamicImage","title":"Generate dynamic image for a department lane","url":"getdepartmentlanedynamicimage.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":7},"deleteDepartmentRelay":{"id":"deleteDepartmentRelay","title":"Delete department relay","url":"deletedepartmentrelay.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":8},"listDepartmentRelays":{"id":"listDepartmentRelays","title":"List department relays","url":"listdepartmentrelays.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":9},"createDepartmentRelay":{"id":"createDepartmentRelay","title":"Create department relay","url":"createdepartmentrelay.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":10},"updateDepartmentRelay":{"id":"updateDepartmentRelay","title":"Update department relay","url":"updatedepartmentrelay.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":11},"listDepartments":{"id":"listDepartments","title":"List departments","url":"listdepartments.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":12},"createDepartment":{"id":"createDepartment","title":"Create department","url":"createdepartment.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":13},"updateDepartment":{"id":"updateDepartment","title":"Update department","url":"updatedepartment.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":14},"removeDepartmentCategory":{"id":"removeDepartmentCategory","title":"Remove category from department","url":"removedepartmentcategory.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":15},"getDepartmentCategories":{"id":"getDepartmentCategories","title":"Get department categories","url":"getdepartmentcategories.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":16},"addDepartmentCategory":{"id":"addDepartmentCategory","title":"Add category to department","url":"adddepartmentcategory.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":17},"listDailyReports":{"id":"listDailyReports","title":"List daily reports","url":"listdailyreports.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":18},"addDailyReport":{"id":"addDailyReport","title":"Add daily report","url":"adddailyreport.html","level":3,"parentId":"Tag_Departments_Page_1","tabIndex":19},"Tag_Departments_Page_2":{"id":"Tag_Departments_Page_2","title":"Departments - Page 2 of 2","url":"tag-departments-page-2.html","level":2,"parentId":"Tag_Departments","pages":["editDailyReport","getDailyReportBookingsCount","getDailyReport","getDailyReportProductCount","getDailyReportTransactionCount","getDepartmentRecommendedOrder","getDepartmentSelfServeEnabled","updateDepartmentSelfServeEnabled","getDepartmentWeatherTimeline","listGuestDepartments","listSuperuserDepartments","getDepartmentPrices","setDepartmentPrice","getDepartmentVariables","setDepartmentVariable"],"tabIndex":1},"editDailyReport":{"id":"editDailyReport","title":"Edit daily report","url":"editdailyreport.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":0},"getDailyReportBookingsCount":{"id":"getDailyReportBookingsCount","title":"Get bookings count for daily reports","url":"getdailyreportbookingscount.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":1},"getDailyReport":{"id":"getDailyReport","title":"Get daily report","url":"getdailyreport.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":2},"getDailyReportProductCount":{"id":"getDailyReportProductCount","title":"Get product count for daily reports","url":"getdailyreportproductcount.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":3},"getDailyReportTransactionCount":{"id":"getDailyReportTransactionCount","title":"Get transaction count for daily reports","url":"getdailyreporttransactioncount.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":4},"getDepartmentRecommendedOrder":{"id":"getDepartmentRecommendedOrder","title":"Get recommended order for department","url":"getdepartmentrecommendedorder.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":5},"getDepartmentSelfServeEnabled":{"id":"getDepartmentSelfServeEnabled","title":"Get department self-serve status","url":"getdepartmentselfserveenabled.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":6},"updateDepartmentSelfServeEnabled":{"id":"updateDepartmentSelfServeEnabled","title":"Update department self-serve status","url":"updatedepartmentselfserveenabled.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":7},"getDepartmentWeatherTimeline":{"id":"getDepartmentWeatherTimeline","title":"Get department weather timeline","url":"getdepartmentweathertimeline.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":8},"listGuestDepartments":{"id":"listGuestDepartments","title":"List public departments","url":"listguestdepartments.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":9},"listSuperuserDepartments":{"id":"listSuperuserDepartments","title":"List departments (superuser)","url":"listsuperuserdepartments.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":10},"getDepartmentPrices":{"id":"getDepartmentPrices","title":"Get department prices","url":"getdepartmentprices.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":11},"setDepartmentPrice":{"id":"setDepartmentPrice","title":"Set department price","url":"setdepartmentprice.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":12},"getDepartmentVariables":{"id":"getDepartmentVariables","title":"Get department variables","url":"getdepartmentvariables.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":13},"setDepartmentVariable":{"id":"setDepartmentVariable","title":"Set department variable","url":"setdepartmentvariable.html","level":3,"parentId":"Tag_Departments_Page_2","tabIndex":14},"Tag_Products":{"id":"Tag_Products","title":"Products","url":"tag-products.html","level":1,"parentId":"API-Reference","pages":["Tag_Products_Page_1"],"tabIndex":8},"Tag_Products_Page_1":{"id":"Tag_Products_Page_1","title":"Products - Page 1 of 1","url":"tag-products-page-1.html","level":2,"parentId":"Tag_Products","pages":["listProducts","createProduct","updateProduct"],"tabIndex":0},"listProducts":{"id":"listProducts","title":"List products","url":"listproducts.html","level":3,"parentId":"Tag_Products_Page_1","tabIndex":0},"createProduct":{"id":"createProduct","title":"Create product","url":"createproduct.html","level":3,"parentId":"Tag_Products_Page_1","tabIndex":1},"updateProduct":{"id":"updateProduct","title":"Update product","url":"updateproduct.html","level":3,"parentId":"Tag_Products_Page_1","tabIndex":2},"Tag_Categories":{"id":"Tag_Categories","title":"Categories","url":"tag-categories.html","level":1,"parentId":"API-Reference","pages":["Tag_Categories_Page_1"],"tabIndex":9},"Tag_Categories_Page_1":{"id":"Tag_Categories_Page_1","title":"Categories - Page 1 of 1","url":"tag-categories-page-1.html","level":2,"parentId":"Tag_Categories","pages":["listCategories","createCategory","updateCategory"],"tabIndex":0},"listCategories":{"id":"listCategories","title":"List categories","url":"listcategories.html","level":3,"parentId":"Tag_Categories_Page_1","tabIndex":0},"createCategory":{"id":"createCategory","title":"Create category","url":"createcategory.html","level":3,"parentId":"Tag_Categories_Page_1","tabIndex":1},"updateCategory":{"id":"updateCategory","title":"Update category","url":"updatecategory.html","level":3,"parentId":"Tag_Categories_Page_1","tabIndex":2},"Tag_Invoices":{"id":"Tag_Invoices","title":"Invoices","url":"tag-invoices.html","level":1,"parentId":"API-Reference","pages":["Tag_Invoices_Page_1","Tag_Invoices_Page_2"],"tabIndex":10},"Tag_Invoices_Page_1":{"id":"Tag_Invoices_Page_1","title":"Invoices - Page 1 of 2","url":"tag-invoices-page-1.html","level":2,"parentId":"Tag_Invoices","pages":["listCollectedInvoices","createCollectedInvoice","updateCollectedInvoice","compareCollectedInvoiceEconomic","compareCollectedInvoiceEconomicV2","compareCollectedInvoiceEconomicV2Bulk","getCollectedInvoiceEconomicV2Details","getCollectedInvoiceEconomicV2RevenueStatistics","getReadyToInvoice","listDraftInvoices","closeDraftInvoice","getInvoicePdf","getCustomerPricingHistoryV2","getInvoicingPeriods","getInvoicingFixedPricingDistribution","getInvoicingPeriodDistributionV2All","getInvoicingPeriodDistributionV2BookedDepartment75","getInvoicingPeriodDistributionV2CustomerPrices","getInvoicingPeriodDistributionV2FixedPricing","getInvoicingPeriodDistributionV2WashSubscriptions"],"tabIndex":0},"listCollectedInvoices":{"id":"listCollectedInvoices","title":"List collected invoices","url":"listcollectedinvoices.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":0},"createCollectedInvoice":{"id":"createCollectedInvoice","title":"Create collected invoice","url":"createcollectedinvoice.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":1},"updateCollectedInvoice":{"id":"updateCollectedInvoice","title":"Update collected invoice","url":"updatecollectedinvoice.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":2},"compareCollectedInvoiceEconomic":{"id":"compareCollectedInvoiceEconomic","title":"Compare collected invoice totals with E-conomic","url":"comparecollectedinvoiceeconomic.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":3},"compareCollectedInvoiceEconomicV2":{"id":"compareCollectedInvoiceEconomicV2","title":"Compare internal invoice with draft/booked (V2)","url":"comparecollectedinvoiceeconomicv2.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":4},"compareCollectedInvoiceEconomicV2Bulk":{"id":"compareCollectedInvoiceEconomicV2Bulk","title":"Bulk compare collected invoices against draft/booked (V2)","url":"comparecollectedinvoiceeconomicv2bulk.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":5},"getCollectedInvoiceEconomicV2Details":{"id":"getCollectedInvoiceEconomicV2Details","title":"Get deep V2 e-conomic invoice details","url":"getcollectedinvoiceeconomicv2details.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":6},"getCollectedInvoiceEconomicV2RevenueStatistics":{"id":"getCollectedInvoiceEconomicV2RevenueStatistics","title":"Get overall booked revenue statistics from e-conomic (V2)","url":"getcollectedinvoiceeconomicv2revenuestatistics.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":7},"getReadyToInvoice":{"id":"getReadyToInvoice","title":"Get invoices ready to process","url":"getreadytoinvoice.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":8},"listDraftInvoices":{"id":"listDraftInvoices","title":"List draft invoices","url":"listdraftinvoices.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":9},"closeDraftInvoice":{"id":"closeDraftInvoice","title":"Close draft invoice","url":"closedraftinvoice.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":10},"getInvoicePdf":{"id":"getInvoicePdf","title":"Get invoice PDF","url":"getinvoicepdf.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":11},"getCustomerPricingHistoryV2":{"id":"getCustomerPricingHistoryV2","title":"Get customer versioned pricing/subscription/discount timeline","url":"getcustomerpricinghistoryv2.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":12},"getInvoicingPeriods":{"id":"getInvoicingPeriods","title":"Get invoicing periods","url":"getinvoicingperiods.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":13},"getInvoicingFixedPricingDistribution":{"id":"getInvoicingFixedPricingDistribution","title":"Get fixed pricing distribution","url":"getinvoicingfixedpricingdistribution.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":14},"getInvoicingPeriodDistributionV2All":{"id":"getInvoicingPeriodDistributionV2All","title":"Get version-aware historical distribution (all)","url":"getinvoicingperioddistributionv2all.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":15},"getInvoicingPeriodDistributionV2BookedDepartment75":{"id":"getInvoicingPeriodDistributionV2BookedDepartment75","title":"Get booked e-conomic department 75 redistribution","url":"getinvoicingperioddistributionv2bookeddepartment75.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":16},"getInvoicingPeriodDistributionV2CustomerPrices":{"id":"getInvoicingPeriodDistributionV2CustomerPrices","title":"Get version-aware historical customer-price discount distribution","url":"getinvoicingperioddistributionv2customerprices.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":17},"getInvoicingPeriodDistributionV2FixedPricing":{"id":"getInvoicingPeriodDistributionV2FixedPricing","title":"Get version-aware historical fixed pricing distribution","url":"getinvoicingperioddistributionv2fixedpricing.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":18},"getInvoicingPeriodDistributionV2WashSubscriptions":{"id":"getInvoicingPeriodDistributionV2WashSubscriptions","title":"Get version-aware historical wash subscription distribution","url":"getinvoicingperioddistributionv2washsubscriptions.html","level":3,"parentId":"Tag_Invoices_Page_1","tabIndex":19},"Tag_Invoices_Page_2":{"id":"Tag_Invoices_Page_2","title":"Invoices - Page 2 of 2","url":"tag-invoices-page-2.html","level":2,"parentId":"Tag_Invoices","pages":["getInvoicingWashSubscriptionsDistribution","getUserInvoices"],"tabIndex":1},"getInvoicingWashSubscriptionsDistribution":{"id":"getInvoicingWashSubscriptionsDistribution","title":"Get wash subscriptions distribution","url":"getinvoicingwashsubscriptionsdistribution.html","level":3,"parentId":"Tag_Invoices_Page_2","tabIndex":0},"getUserInvoices":{"id":"getUserInvoices","title":"Get user invoices","url":"getuserinvoices.html","level":3,"parentId":"Tag_Invoices_Page_2","tabIndex":1},"Tag_Vehicles":{"id":"Tag_Vehicles","title":"Vehicles","url":"tag-vehicles.html","level":1,"parentId":"API-Reference","pages":["Tag_Vehicles_Page_1"],"tabIndex":11},"Tag_Vehicles_Page_1":{"id":"Tag_Vehicles_Page_1","title":"Vehicles - Page 1 of 1","url":"tag-vehicles-page-1.html","level":2,"parentId":"Tag_Vehicles","pages":["getVehicleCustomerSuggestions","getUnknownCustomerVehicles","getUsersWithVehicleSubscriptions","deleteVehicle","listVehicles","addVehicle","editVehicle","getAvailableVehicleAddons","toggleVehicleAddon","searchVehicles","setVehicleAutoStartOnLpr","setVehicleTypeId","getVehicleStatus"],"tabIndex":0},"getVehicleCustomerSuggestions":{"id":"getVehicleCustomerSuggestions","title":"Get vehicle customer suggestions","url":"getvehiclecustomersuggestions.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":0},"getUnknownCustomerVehicles":{"id":"getUnknownCustomerVehicles","title":"Get unknown customer vehicles in department","url":"getunknowncustomervehicles.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":1},"getUsersWithVehicleSubscriptions":{"id":"getUsersWithVehicleSubscriptions","title":"Get users with vehicle subscriptions","url":"getuserswithvehiclesubscriptions.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":2},"deleteVehicle":{"id":"deleteVehicle","title":"Delete vehicle","url":"deletevehicle.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":3},"listVehicles":{"id":"listVehicles","title":"List vehicles","url":"listvehicles.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":4},"addVehicle":{"id":"addVehicle","title":"Add vehicle","url":"addvehicle.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":5},"editVehicle":{"id":"editVehicle","title":"Edit vehicle","url":"editvehicle.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":6},"getAvailableVehicleAddons":{"id":"getAvailableVehicleAddons","title":"Get available vehicle addons","url":"getavailablevehicleaddons.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":7},"toggleVehicleAddon":{"id":"toggleVehicleAddon","title":"Toggle vehicle addon","url":"togglevehicleaddon.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":8},"searchVehicles":{"id":"searchVehicles","title":"Search vehicles","url":"searchvehicles.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":9},"setVehicleAutoStartOnLpr":{"id":"setVehicleAutoStartOnLpr","title":"Set auto start on LPR","url":"setvehicleautostartonlpr.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":10},"setVehicleTypeId":{"id":"setVehicleTypeId","title":"Set vehicle type ID","url":"setvehicletypeid.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":11},"getVehicleStatus":{"id":"getVehicleStatus","title":"Get vehicle status","url":"getvehiclestatus.html","level":3,"parentId":"Tag_Vehicles_Page_1","tabIndex":12},"Tag_Notifications":{"id":"Tag_Notifications","title":"Notifications","url":"tag-notifications.html","level":1,"parentId":"API-Reference","pages":["Tag_Notifications_Page_1"],"tabIndex":12},"Tag_Notifications_Page_1":{"id":"Tag_Notifications_Page_1","title":"Notifications - Page 1 of 1","url":"tag-notifications-page-1.html","level":2,"parentId":"Tag_Notifications","pages":["deleteNotification","listNotifications","createNotification"],"tabIndex":0},"deleteNotification":{"id":"deleteNotification","title":"Delete notification","url":"deletenotification.html","level":3,"parentId":"Tag_Notifications_Page_1","tabIndex":0},"listNotifications":{"id":"listNotifications","title":"List notifications","url":"listnotifications.html","level":3,"parentId":"Tag_Notifications_Page_1","tabIndex":1},"createNotification":{"id":"createNotification","title":"Create notification","url":"createnotification.html","level":3,"parentId":"Tag_Notifications_Page_1","tabIndex":2},"Tag_Statistics":{"id":"Tag_Statistics","title":"Statistics","url":"tag-statistics.html","level":1,"parentId":"API-Reference","pages":["Tag_Statistics_Page_1"],"tabIndex":13},"Tag_Statistics_Page_1":{"id":"Tag_Statistics_Page_1","title":"Statistics - Page 1 of 1","url":"tag-statistics-page-1.html","level":2,"parentId":"Tag_Statistics","pages":["getNewBookingsStats","getEconomicTotals","getDepartmentDraftInvoiceTotals","getDepartmentSentInvoiceTotals","getTotalIncomeTodayByDepartments","getLastMonthIncome","getThisMonthIncome","getThisYearIncome","getTodayIncome","getYesterdayIncome","getNewOrdersStats"],"tabIndex":0},"getNewBookingsStats":{"id":"getNewBookingsStats","title":"Get new bookings statistics","url":"getnewbookingsstats.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":0},"getEconomicTotals":{"id":"getEconomicTotals","title":"Get total economic statistics","url":"geteconomictotals.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":1},"getDepartmentDraftInvoiceTotals":{"id":"getDepartmentDraftInvoiceTotals","title":"Get department draft invoice totals","url":"getdepartmentdraftinvoicetotals.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":2},"getDepartmentSentInvoiceTotals":{"id":"getDepartmentSentInvoiceTotals","title":"Get department sent invoice totals","url":"getdepartmentsentinvoicetotals.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":3},"getTotalIncomeTodayByDepartments":{"id":"getTotalIncomeTodayByDepartments","title":"Get total income today by departments","url":"gettotalincometodaybydepartments.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":4},"getLastMonthIncome":{"id":"getLastMonthIncome","title":"Get last month\u0027s income","url":"getlastmonthincome.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":5},"getThisMonthIncome":{"id":"getThisMonthIncome","title":"Get this month\u0027s income","url":"getthismonthincome.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":6},"getThisYearIncome":{"id":"getThisYearIncome","title":"Get this year\u0027s income","url":"getthisyearincome.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":7},"getTodayIncome":{"id":"getTodayIncome","title":"Get today\u0027s income","url":"gettodayincome.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":8},"getYesterdayIncome":{"id":"getYesterdayIncome","title":"Get yesterday\u0027s income","url":"getyesterdayincome.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":9},"getNewOrdersStats":{"id":"getNewOrdersStats","title":"Get new orders statistics","url":"getnewordersstats.html","level":3,"parentId":"Tag_Statistics_Page_1","tabIndex":10},"Tag_Modules":{"id":"Tag_Modules","title":"Modules","url":"tag-modules.html","level":1,"parentId":"API-Reference","pages":["modules_module_action_logs","modules_module_backup","modules_module_cvr","modules_module_e_conomic","modules_module_entra","modules_module_fxratesapi","modules_module_motorapi","modules_module_self_serve","modules_module_stripe","modules_module_virkdata","modules_module_wash_certificates","modules_module_weatherapi","modules_module_xlvask"],"tabIndex":14},"modules_module_action_logs":{"id":"modules_module_action_logs","level":2,"parentId":"Tag_Modules","pages":["modules_module_action_logs_page_1"],"tabIndex":0},"modules_module_action_logs_page_1":{"id":"modules_module_action_logs_page_1","level":3,"parentId":"modules_module_action_logs","pages":["listModuleActionLogs"],"tabIndex":0},"listModuleActionLogs":{"id":"listModuleActionLogs","title":"List module action logs","url":"listmoduleactionlogs.html","level":4,"parentId":"modules_module_action_logs_page_1","tabIndex":0},"modules_module_backup":{"id":"modules_module_backup","level":2,"parentId":"Tag_Modules","pages":["modules_module_backup_page_1"],"tabIndex":1},"modules_module_backup_page_1":{"id":"modules_module_backup_page_1","level":3,"parentId":"modules_module_backup","pages":["createBackupModule","listBackupModules"],"tabIndex":0},"createBackupModule":{"id":"createBackupModule","title":"Create backup module","url":"createbackupmodule.html","level":4,"parentId":"modules_module_backup_page_1","tabIndex":0},"listBackupModules":{"id":"listBackupModules","title":"List backup modules","url":"listbackupmodules.html","level":4,"parentId":"modules_module_backup_page_1","tabIndex":1},"modules_module_cvr":{"id":"modules_module_cvr","level":2,"parentId":"Tag_Modules","pages":["modules_module_cvr_page_1"],"tabIndex":2},"modules_module_cvr_page_1":{"id":"modules_module_cvr_page_1","level":3,"parentId":"modules_module_cvr","pages":["lookupCvr","searchCvr"],"tabIndex":0},"lookupCvr":{"id":"lookupCvr","title":"Lookup CVR information","url":"lookupcvr.html","level":4,"parentId":"modules_module_cvr_page_1","tabIndex":0},"searchCvr":{"id":"searchCvr","title":"Search CVR","url":"searchcvr.html","level":4,"parentId":"modules_module_cvr_page_1","tabIndex":1},"modules_module_e_conomic":{"id":"modules_module_e_conomic","level":2,"parentId":"Tag_Modules","pages":["modules_module_e_conomic_page_1"],"tabIndex":3},"modules_module_e_conomic_page_1":{"id":"modules_module_e_conomic_page_1","level":3,"parentId":"modules_module_e_conomic","pages":["checkEconomicCustomerExists","createEconomicCustomer","exportDraftInvoiceToEconomic","exportInvoiceToEconomic","getEconomicCustomer","getEconomicDepartments","getEconomicLayouts","getEconomicPaymentTerms","getEconomicProducts","importEconomicCustomers"],"tabIndex":0},"checkEconomicCustomerExists":{"id":"checkEconomicCustomerExists","title":"Check if customer exists in e-conomic","url":"checkeconomiccustomerexists.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":0},"createEconomicCustomer":{"id":"createEconomicCustomer","title":"Create e-conomic customer","url":"createeconomiccustomer.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":1},"exportDraftInvoiceToEconomic":{"id":"exportDraftInvoiceToEconomic","title":"Export draft invoice to e-conomic","url":"exportdraftinvoicetoeconomic.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":2},"exportInvoiceToEconomic":{"id":"exportInvoiceToEconomic","title":"Export invoice to e-conomic","url":"exportinvoicetoeconomic.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":3},"getEconomicCustomer":{"id":"getEconomicCustomer","title":"Get e-conomic customer details","url":"geteconomiccustomer.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":4},"getEconomicDepartments":{"id":"getEconomicDepartments","title":"Get e-conomic departments","url":"geteconomicdepartments.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":5},"getEconomicLayouts":{"id":"getEconomicLayouts","title":"Get e-conomic layouts","url":"geteconomiclayouts.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":6},"getEconomicPaymentTerms":{"id":"getEconomicPaymentTerms","title":"Get e-conomic payment terms","url":"geteconomicpaymentterms.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":7},"getEconomicProducts":{"id":"getEconomicProducts","title":"Get e-conomic products","url":"geteconomicproducts.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":8},"importEconomicCustomers":{"id":"importEconomicCustomers","title":"Import e-conomic customers","url":"importeconomiccustomers.html","level":4,"parentId":"modules_module_e_conomic_page_1","tabIndex":9},"modules_module_entra":{"id":"modules_module_entra","level":2,"parentId":"Tag_Modules","pages":["modules_module_entra_page_1"],"tabIndex":4},"modules_module_entra_page_1":{"id":"modules_module_entra_page_1","level":3,"parentId":"modules_module_entra","pages":["listEntraUsers"],"tabIndex":0},"listEntraUsers":{"id":"listEntraUsers","title":"List Microsoft Entra users","url":"listentrausers.html","level":4,"parentId":"modules_module_entra_page_1","tabIndex":0},"modules_module_fxratesapi":{"id":"modules_module_fxratesapi","level":2,"parentId":"Tag_Modules","pages":["modules_module_fxratesapi_page_1"],"tabIndex":5},"modules_module_fxratesapi_page_1":{"id":"modules_module_fxratesapi_page_1","level":3,"parentId":"modules_module_fxratesapi","pages":["getAllExchangeRates","getExchangeRate"],"tabIndex":0},"getAllExchangeRates":{"id":"getAllExchangeRates","title":"Get all exchange rates","url":"getallexchangerates.html","level":4,"parentId":"modules_module_fxratesapi_page_1","tabIndex":0},"getExchangeRate":{"id":"getExchangeRate","title":"Get exchange rate","url":"getexchangerate.html","level":4,"parentId":"modules_module_fxratesapi_page_1","tabIndex":1},"modules_module_motorapi":{"id":"modules_module_motorapi","level":2,"parentId":"Tag_Modules","pages":["modules_module_motorapi_page_1"],"tabIndex":6},"modules_module_motorapi_page_1":{"id":"modules_module_motorapi_page_1","level":3,"parentId":"modules_module_motorapi","pages":["motorApiLookup"],"tabIndex":0},"motorApiLookup":{"id":"motorApiLookup","title":"Lookup vehicle via MotorAPI","url":"motorapilookup.html","level":4,"parentId":"modules_module_motorapi_page_1","tabIndex":0},"modules_module_self_serve":{"id":"modules_module_self_serve","level":2,"parentId":"Tag_Modules","pages":["modules_module_self_serve_page_1"],"tabIndex":7},"modules_module_self_serve_page_1":{"id":"modules_module_self_serve_page_1","level":3,"parentId":"modules_module_self_serve","pages":["forceDisableSelfServeLaneMachine","forceEnableSelfServeLaneMachine","getSelfServeLaneStatus","enableSelfServeLaneMachineRelay","sendSelfServeLaneCommand","setSelfServeLaneAllowedServices"],"tabIndex":0},"forceDisableSelfServeLaneMachine":{"id":"forceDisableSelfServeLaneMachine","title":"Force disable MACHINE relay but keep lane as in-wash (superusers only)","url":"forcedisableselfservelanemachine.html","level":4,"parentId":"modules_module_self_serve_page_1","tabIndex":0},"forceEnableSelfServeLaneMachine":{"id":"forceEnableSelfServeLaneMachine","title":"Force enable MACHINE relay and mark lane as in-wash (superusers only)","url":"forceenableselfservelanemachine.html","level":4,"parentId":"modules_module_self_serve_page_1","tabIndex":1},"getSelfServeLaneStatus":{"id":"getSelfServeLaneStatus","title":"Get self-serve lane status","url":"getselfservelanestatus.html","level":4,"parentId":"modules_module_self_serve_page_1","tabIndex":2},"enableSelfServeLaneMachineRelay":{"id":"enableSelfServeLaneMachineRelay","title":"Manually enable MACHINE relay for a lane","url":"enableselfservelanemachinerelay.html","level":4,"parentId":"modules_module_self_serve_page_1","tabIndex":3},"sendSelfServeLaneCommand":{"id":"sendSelfServeLaneCommand","title":"Send self-serve lane command","url":"sendselfservelanecommand.html","level":4,"parentId":"modules_module_self_serve_page_1","tabIndex":4},"setSelfServeLaneAllowedServices":{"id":"setSelfServeLaneAllowedServices","title":"Set allowed services for a lane based on shown tasks","url":"setselfservelaneallowedservices.html","level":4,"parentId":"modules_module_self_serve_page_1","tabIndex":5},"modules_module_stripe":{"id":"modules_module_stripe","level":2,"parentId":"Tag_Modules","pages":["modules_module_stripe_page_1"],"tabIndex":8},"modules_module_stripe_page_1":{"id":"modules_module_stripe_page_1","level":3,"parentId":"modules_module_stripe","pages":["createStripeInvoice","getDepartmentTerminalLocation","getDepartmentTerminalReaders","listStripeCustomers","listStripePrices","listStripeProducts","listStripeTerminalLocations","listStripeTerminalReaders","setDepartmentTerminalLocation"],"tabIndex":0},"createStripeInvoice":{"id":"createStripeInvoice","title":"Create Stripe invoice","url":"createstripeinvoice.html","level":4,"parentId":"modules_module_stripe_page_1","tabIndex":0},"getDepartmentTerminalLocation":{"id":"getDepartmentTerminalLocation","title":"Get department terminal location","url":"getdepartmentterminallocation.html","level":4,"parentId":"modules_module_stripe_page_1","tabIndex":1},"getDepartmentTerminalReaders":{"id":"getDepartmentTerminalReaders","title":"Get department terminal readers","url":"getdepartmentterminalreaders.html","level":4,"parentId":"modules_module_stripe_page_1","tabIndex":2},"listStripeCustomers":{"id":"listStripeCustomers","title":"List Stripe customers","url":"liststripecustomers.html","level":4,"parentId":"modules_module_stripe_page_1","tabIndex":3},"listStripePrices":{"id":"listStripePrices","title":"List Stripe prices","url":"liststripeprices.html","level":4,"parentId":"modules_module_stripe_page_1","tabIndex":4},"listStripeProducts":{"id":"listStripeProducts","title":"List Stripe products","url":"liststripeproducts.html","level":4,"parentId":"modules_module_stripe_page_1","tabIndex":5},"listStripeTerminalLocations":{"id":"listStripeTerminalLocations","title":"List Stripe terminal locations","url":"liststripeterminallocations.html","level":4,"parentId":"modules_module_stripe_page_1","tabIndex":6},"listStripeTerminalReaders":{"id":"listStripeTerminalReaders","title":"List Stripe terminal readers","url":"liststripeterminalreaders.html","level":4,"parentId":"modules_module_stripe_page_1","tabIndex":7},"setDepartmentTerminalLocation":{"id":"setDepartmentTerminalLocation","title":"Set department terminal location","url":"setdepartmentterminallocation.html","level":4,"parentId":"modules_module_stripe_page_1","tabIndex":8},"modules_module_virkdata":{"id":"modules_module_virkdata","level":2,"parentId":"Tag_Modules","pages":["modules_module_virkdata_page_1"],"tabIndex":9},"modules_module_virkdata_page_1":{"id":"modules_module_virkdata_page_1","level":3,"parentId":"modules_module_virkdata","pages":["virkdataSearch"],"tabIndex":0},"virkdataSearch":{"id":"virkdataSearch","title":"Search VirkData","url":"virkdatasearch.html","level":4,"parentId":"modules_module_virkdata_page_1","tabIndex":0},"modules_module_wash_certificates":{"id":"modules_module_wash_certificates","level":2,"parentId":"Tag_Modules","pages":["modules_module_wash_certificates_page_1"],"tabIndex":10},"modules_module_wash_certificates_page_1":{"id":"modules_module_wash_certificates_page_1","level":3,"parentId":"modules_module_wash_certificates","pages":["listWashCertificates"],"tabIndex":0},"listWashCertificates":{"id":"listWashCertificates","title":"List wash certificates","url":"listwashcertificates.html","level":4,"parentId":"modules_module_wash_certificates_page_1","tabIndex":0},"modules_module_weatherapi":{"id":"modules_module_weatherapi","level":2,"parentId":"Tag_Modules","pages":["modules_module_weatherapi_page_1"],"tabIndex":11},"modules_module_weatherapi_page_1":{"id":"modules_module_weatherapi_page_1","level":3,"parentId":"modules_module_weatherapi","pages":["weatherApiCurrent","weatherApiForecast","weatherApiSearch"],"tabIndex":0},"weatherApiCurrent":{"id":"weatherApiCurrent","title":"Get current weather","url":"weatherapicurrent.html","level":4,"parentId":"modules_module_weatherapi_page_1","tabIndex":0},"weatherApiForecast":{"id":"weatherApiForecast","title":"Get weather forecast","url":"weatherapiforecast.html","level":4,"parentId":"modules_module_weatherapi_page_1","tabIndex":1},"weatherApiSearch":{"id":"weatherApiSearch","title":"Search weather locations","url":"weatherapisearch.html","level":4,"parentId":"modules_module_weatherapi_page_1","tabIndex":2},"modules_module_xlvask":{"id":"modules_module_xlvask","level":2,"parentId":"Tag_Modules","pages":["modules_module_xlvask_page_1"],"tabIndex":12},"modules_module_xlvask_page_1":{"id":"modules_module_xlvask_page_1","level":3,"parentId":"modules_module_xlvask","pages":["getXlvaskUsageLogs","getXlvaskUsageOrders","getXlvaskUsageOrdersFastLink","listXlvaskCustomers","listXlvaskVehicles"],"tabIndex":0},"getXlvaskUsageLogs":{"id":"getXlvaskUsageLogs","title":"Get XLVask usage logs","url":"getxlvaskusagelogs.html","level":4,"parentId":"modules_module_xlvask_page_1","tabIndex":0},"getXlvaskUsageOrders":{"id":"getXlvaskUsageOrders","title":"Get XLVask usage orders","url":"getxlvaskusageorders.html","level":4,"parentId":"modules_module_xlvask_page_1","tabIndex":1},"getXlvaskUsageOrdersFastLink":{"id":"getXlvaskUsageOrdersFastLink","title":"Get XLVask usage orders fast link","url":"getxlvaskusageordersfastlink.html","level":4,"parentId":"modules_module_xlvask_page_1","tabIndex":2},"listXlvaskCustomers":{"id":"listXlvaskCustomers","title":"List XLVask customers","url":"listxlvaskcustomers.html","level":4,"parentId":"modules_module_xlvask_page_1","tabIndex":3},"listXlvaskVehicles":{"id":"listXlvaskVehicles","title":"List XLVask vehicles","url":"listxlvaskvehicles.html","level":4,"parentId":"modules_module_xlvask_page_1","tabIndex":4},"Tag_Attachments":{"id":"Tag_Attachments","title":"Attachments","url":"tag-attachments.html","level":1,"parentId":"API-Reference","pages":["Tag_Attachments_Page_1"],"tabIndex":15},"Tag_Attachments_Page_1":{"id":"Tag_Attachments_Page_1","title":"Attachments - Page 1 of 1","url":"tag-attachments-page-1.html","level":2,"parentId":"Tag_Attachments","pages":["uploadAttachment","listOrderAttachments","downloadOrderAttachment","uploadOrderAttachment"],"tabIndex":0},"uploadAttachment":{"id":"uploadAttachment","title":"Upload attachment","url":"uploadattachment.html","level":3,"parentId":"Tag_Attachments_Page_1","tabIndex":0},"listOrderAttachments":{"id":"listOrderAttachments","title":"List order attachments","url":"listorderattachments.html","level":3,"parentId":"Tag_Attachments_Page_1","tabIndex":1},"downloadOrderAttachment":{"id":"downloadOrderAttachment","title":"Download order attachment","url":"downloadorderattachment.html","level":3,"parentId":"Tag_Attachments_Page_1","tabIndex":2},"uploadOrderAttachment":{"id":"uploadOrderAttachment","title":"Upload order attachment","url":"uploadorderattachment.html","level":3,"parentId":"Tag_Attachments_Page_1","tabIndex":3},"Tag_Forms":{"id":"Tag_Forms","title":"Forms","url":"tag-forms.html","level":1,"parentId":"API-Reference","pages":["Tag_Forms_Page_1"],"tabIndex":16},"Tag_Forms_Page_1":{"id":"Tag_Forms_Page_1","title":"Forms - Page 1 of 1","url":"tag-forms-page-1.html","level":2,"parentId":"Tag_Forms","pages":["getForm","submitForm"],"tabIndex":0},"getForm":{"id":"getForm","title":"Get form","url":"getform.html","level":3,"parentId":"Tag_Forms_Page_1","tabIndex":0},"submitForm":{"id":"submitForm","title":"Submit form","url":"submitform.html","level":3,"parentId":"Tag_Forms_Page_1","tabIndex":1},"Tag_Worker":{"id":"Tag_Worker","title":"Worker","url":"tag-worker.html","level":1,"parentId":"API-Reference","pages":["Tag_Worker_Page_1"],"tabIndex":17},"Tag_Worker_Page_1":{"id":"Tag_Worker_Page_1","title":"Worker - Page 1 of 1","url":"tag-worker-page-1.html","level":2,"parentId":"Tag_Worker","pages":["debugWorker","disableWorkerDebug","enableWorkerDebug","getWorkerLicensePlates","getWorkerStatus","updateWorkerVersion","getWorkerVersion"],"tabIndex":0},"debugWorker":{"id":"debugWorker","title":"Debug worker","url":"debugworker.html","level":3,"parentId":"Tag_Worker_Page_1","tabIndex":0},"disableWorkerDebug":{"id":"disableWorkerDebug","title":"Disable worker debug","url":"disableworkerdebug.html","level":3,"parentId":"Tag_Worker_Page_1","tabIndex":1},"enableWorkerDebug":{"id":"enableWorkerDebug","title":"Enable worker debug","url":"enableworkerdebug.html","level":3,"parentId":"Tag_Worker_Page_1","tabIndex":2},"getWorkerLicensePlates":{"id":"getWorkerLicensePlates","title":"Get unique license plates","url":"getworkerlicenseplates.html","level":3,"parentId":"Tag_Worker_Page_1","tabIndex":3},"getWorkerStatus":{"id":"getWorkerStatus","title":"Get worker status","url":"getworkerstatus.html","level":3,"parentId":"Tag_Worker_Page_1","tabIndex":4},"updateWorkerVersion":{"id":"updateWorkerVersion","title":"Update worker version","url":"updateworkerversion.html","level":3,"parentId":"Tag_Worker_Page_1","tabIndex":5},"getWorkerVersion":{"id":"getWorkerVersion","title":"Get worker version","url":"getworkerversion.html","level":3,"parentId":"Tag_Worker_Page_1","tabIndex":6},"Tag_Plate_Scans":{"id":"Tag_Plate_Scans","title":"Plate Scans","url":"tag-plate-scans.html","level":1,"parentId":"API-Reference","pages":["Tag_Plate_Scans_Page_1"],"tabIndex":18},"Tag_Plate_Scans_Page_1":{"id":"Tag_Plate_Scans_Page_1","title":"Plate Scans - Page 1 of 1","url":"tag-plate-scans-page-1.html","level":2,"parentId":"Tag_Plate_Scans","pages":["listDepartmentPlateScanners","listPlateScanners","addPlateScanner","updatePlateScanner","listPlateScans","recordPlateScan","recordDepartmentPlateScan","getPlateScanPostResults","addButtonPress","addButtonPressPost"],"tabIndex":0},"listDepartmentPlateScanners":{"id":"listDepartmentPlateScanners","title":"List department plate scanners","url":"listdepartmentplatescanners.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":0},"listPlateScanners":{"id":"listPlateScanners","title":"List plate scanners","url":"listplatescanners.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":1},"addPlateScanner":{"id":"addPlateScanner","title":"Add plate scanner","url":"addplatescanner.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":2},"updatePlateScanner":{"id":"updatePlateScanner","title":"Update plate scanner","url":"updateplatescanner.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":3},"listPlateScans":{"id":"listPlateScans","title":"List plate scans","url":"listplatescans.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":4},"recordPlateScan":{"id":"recordPlateScan","title":"Record plate scan","url":"recordplatescan.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":5},"recordDepartmentPlateScan":{"id":"recordDepartmentPlateScan","title":"Record plate scan for department","url":"recorddepartmentplatescan.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":6},"getPlateScanPostResults":{"id":"getPlateScanPostResults","title":"Get post-scan results","url":"getplatescanpostresults.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":7},"addButtonPress":{"id":"addButtonPress","title":"Record machine start button press webhook","url":"addbuttonpress.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":8},"addButtonPressPost":{"id":"addButtonPressPost","title":"Record machine start button press webhook","url":"addbuttonpresspost.html","level":3,"parentId":"Tag_Plate_Scans_Page_1","tabIndex":9},"Tag_Config":{"id":"Tag_Config","title":"Config","url":"tag-config.html","level":1,"parentId":"API-Reference","pages":["config_module_backups","config_module_bird","config_module_e_conomic","config_module_email","config_module_entra","config_module_fxratesapi","config_module_gatewayapi","config_module_licenseplaterecognizer","config_module_limble","config_module_motorapi","config_module_ocrspace","config_module_openai","config_module_recaptcha","config_module_self_serve","config_module_shelly","config_module_stripe","config_module_virkdata","config_module_weatherapi","config_module_xlvask"],"tabIndex":19},"config_module_backups":{"id":"config_module_backups","level":2,"parentId":"Tag_Config","pages":["config_module_backups_page_1"],"tabIndex":0},"config_module_backups_page_1":{"id":"config_module_backups_page_1","level":3,"parentId":"config_module_backups","pages":["getBackupsConfig","updateBackupsConfig"],"tabIndex":0},"getBackupsConfig":{"id":"getBackupsConfig","title":"Get backups config","url":"getbackupsconfig.html","level":4,"parentId":"config_module_backups_page_1","tabIndex":0},"updateBackupsConfig":{"id":"updateBackupsConfig","title":"Update backups config","url":"updatebackupsconfig.html","level":4,"parentId":"config_module_backups_page_1","tabIndex":1},"config_module_bird":{"id":"config_module_bird","level":2,"parentId":"Tag_Config","pages":["config_module_bird_page_1"],"tabIndex":1},"config_module_bird_page_1":{"id":"config_module_bird_page_1","level":3,"parentId":"config_module_bird","pages":["getBirdConfig","updateBirdConfig"],"tabIndex":0},"getBirdConfig":{"id":"getBirdConfig","title":"Get Bird config","url":"getbirdconfig.html","level":4,"parentId":"config_module_bird_page_1","tabIndex":0},"updateBirdConfig":{"id":"updateBirdConfig","title":"Update Bird config","url":"updatebirdconfig.html","level":4,"parentId":"config_module_bird_page_1","tabIndex":1},"config_module_e_conomic":{"id":"config_module_e_conomic","level":2,"parentId":"Tag_Config","pages":["config_module_e_conomic_page_1"],"tabIndex":2},"config_module_e_conomic_page_1":{"id":"config_module_e_conomic_page_1","level":3,"parentId":"config_module_e_conomic","pages":["getEconomicConfig","updateEconomicConfig"],"tabIndex":0},"getEconomicConfig":{"id":"getEconomicConfig","title":"Get e-conomic config","url":"geteconomicconfig.html","level":4,"parentId":"config_module_e_conomic_page_1","tabIndex":0},"updateEconomicConfig":{"id":"updateEconomicConfig","title":"Update e-conomic config","url":"updateeconomicconfig.html","level":4,"parentId":"config_module_e_conomic_page_1","tabIndex":1},"config_module_email":{"id":"config_module_email","level":2,"parentId":"Tag_Config","pages":["config_module_email_page_1"],"tabIndex":3},"config_module_email_page_1":{"id":"config_module_email_page_1","level":3,"parentId":"config_module_email","pages":["getEmailConfig","testEmailConfig","updateEmailConfig"],"tabIndex":0},"getEmailConfig":{"id":"getEmailConfig","title":"Get email config","url":"getemailconfig.html","level":4,"parentId":"config_module_email_page_1","tabIndex":0},"testEmailConfig":{"id":"testEmailConfig","title":"Test email config","url":"testemailconfig.html","level":4,"parentId":"config_module_email_page_1","tabIndex":1},"updateEmailConfig":{"id":"updateEmailConfig","title":"Update email config","url":"updateemailconfig.html","level":4,"parentId":"config_module_email_page_1","tabIndex":2},"config_module_entra":{"id":"config_module_entra","level":2,"parentId":"Tag_Config","pages":["config_module_entra_page_1"],"tabIndex":4},"config_module_entra_page_1":{"id":"config_module_entra_page_1","level":3,"parentId":"config_module_entra","pages":["getEntraConfig","updateEntraConfig"],"tabIndex":0},"getEntraConfig":{"id":"getEntraConfig","title":"Get Entra config","url":"getentraconfig.html","level":4,"parentId":"config_module_entra_page_1","tabIndex":0},"updateEntraConfig":{"id":"updateEntraConfig","title":"Update Entra config","url":"updateentraconfig.html","level":4,"parentId":"config_module_entra_page_1","tabIndex":1},"config_module_fxratesapi":{"id":"config_module_fxratesapi","level":2,"parentId":"Tag_Config","pages":["config_module_fxratesapi_page_1"],"tabIndex":5},"config_module_fxratesapi_page_1":{"id":"config_module_fxratesapi_page_1","level":3,"parentId":"config_module_fxratesapi","pages":["getFxRatesApiConfig","updateFxRatesApiConfig"],"tabIndex":0},"getFxRatesApiConfig":{"id":"getFxRatesApiConfig","title":"Get FXRatesAPI config","url":"getfxratesapiconfig.html","level":4,"parentId":"config_module_fxratesapi_page_1","tabIndex":0},"updateFxRatesApiConfig":{"id":"updateFxRatesApiConfig","title":"Update FXRatesAPI config","url":"updatefxratesapiconfig.html","level":4,"parentId":"config_module_fxratesapi_page_1","tabIndex":1},"config_module_gatewayapi":{"id":"config_module_gatewayapi","level":2,"parentId":"Tag_Config","pages":["config_module_gatewayapi_page_1"],"tabIndex":6},"config_module_gatewayapi_page_1":{"id":"config_module_gatewayapi_page_1","level":3,"parentId":"config_module_gatewayapi","pages":["getGatewayApiConfig","updateGatewayApiConfig"],"tabIndex":0},"getGatewayApiConfig":{"id":"getGatewayApiConfig","title":"Get GatewayAPI config","url":"getgatewayapiconfig.html","level":4,"parentId":"config_module_gatewayapi_page_1","tabIndex":0},"updateGatewayApiConfig":{"id":"updateGatewayApiConfig","title":"Update GatewayAPI config","url":"updategatewayapiconfig.html","level":4,"parentId":"config_module_gatewayapi_page_1","tabIndex":1},"config_module_licenseplaterecognizer":{"id":"config_module_licenseplaterecognizer","level":2,"parentId":"Tag_Config","pages":["config_module_licenseplaterecognizer_page_1"],"tabIndex":7},"config_module_licenseplaterecognizer_page_1":{"id":"config_module_licenseplaterecognizer_page_1","level":3,"parentId":"config_module_licenseplaterecognizer","pages":["getLicensePlateRecognizerConfig","updateLicensePlateRecognizerConfig"],"tabIndex":0},"getLicensePlateRecognizerConfig":{"id":"getLicensePlateRecognizerConfig","title":"Get LicensePlateRecognizer config","url":"getlicenseplaterecognizerconfig.html","level":4,"parentId":"config_module_licenseplaterecognizer_page_1","tabIndex":0},"updateLicensePlateRecognizerConfig":{"id":"updateLicensePlateRecognizerConfig","title":"Update LicensePlateRecognizer config","url":"updatelicenseplaterecognizerconfig.html","level":4,"parentId":"config_module_licenseplaterecognizer_page_1","tabIndex":1},"config_module_limble":{"id":"config_module_limble","level":2,"parentId":"Tag_Config","pages":["config_module_limble_page_1"],"tabIndex":8},"config_module_limble_page_1":{"id":"config_module_limble_page_1","level":3,"parentId":"config_module_limble","pages":["getLimbleConfig","updateLimbleConfig"],"tabIndex":0},"getLimbleConfig":{"id":"getLimbleConfig","title":"Get Limble config","url":"getlimbleconfig.html","level":4,"parentId":"config_module_limble_page_1","tabIndex":0},"updateLimbleConfig":{"id":"updateLimbleConfig","title":"Update Limble config","url":"updatelimbleconfig.html","level":4,"parentId":"config_module_limble_page_1","tabIndex":1},"config_module_motorapi":{"id":"config_module_motorapi","level":2,"parentId":"Tag_Config","pages":["config_module_motorapi_page_1"],"tabIndex":9},"config_module_motorapi_page_1":{"id":"config_module_motorapi_page_1","level":3,"parentId":"config_module_motorapi","pages":["getMotorApiConfig","updateMotorApiConfig"],"tabIndex":0},"getMotorApiConfig":{"id":"getMotorApiConfig","title":"Get MotorAPI config","url":"getmotorapiconfig.html","level":4,"parentId":"config_module_motorapi_page_1","tabIndex":0},"updateMotorApiConfig":{"id":"updateMotorApiConfig","title":"Update MotorAPI config","url":"updatemotorapiconfig.html","level":4,"parentId":"config_module_motorapi_page_1","tabIndex":1},"config_module_ocrspace":{"id":"config_module_ocrspace","level":2,"parentId":"Tag_Config","pages":["config_module_ocrspace_page_1"],"tabIndex":10},"config_module_ocrspace_page_1":{"id":"config_module_ocrspace_page_1","level":3,"parentId":"config_module_ocrspace","pages":["getOcrSpaceConfig","updateOcrSpaceConfig"],"tabIndex":0},"getOcrSpaceConfig":{"id":"getOcrSpaceConfig","title":"Get OcrSpace config","url":"getocrspaceconfig.html","level":4,"parentId":"config_module_ocrspace_page_1","tabIndex":0},"updateOcrSpaceConfig":{"id":"updateOcrSpaceConfig","title":"Update OcrSpace config","url":"updateocrspaceconfig.html","level":4,"parentId":"config_module_ocrspace_page_1","tabIndex":1},"config_module_openai":{"id":"config_module_openai","level":2,"parentId":"Tag_Config","pages":["config_module_openai_page_1"],"tabIndex":11},"config_module_openai_page_1":{"id":"config_module_openai_page_1","level":3,"parentId":"config_module_openai","pages":["getOpenAiConfig","updateOpenAiConfig"],"tabIndex":0},"getOpenAiConfig":{"id":"getOpenAiConfig","title":"Get OpenAI config","url":"getopenaiconfig.html","level":4,"parentId":"config_module_openai_page_1","tabIndex":0},"updateOpenAiConfig":{"id":"updateOpenAiConfig","title":"Update OpenAI config","url":"updateopenaiconfig.html","level":4,"parentId":"config_module_openai_page_1","tabIndex":1},"config_module_recaptcha":{"id":"config_module_recaptcha","level":2,"parentId":"Tag_Config","pages":["config_module_recaptcha_page_1"],"tabIndex":12},"config_module_recaptcha_page_1":{"id":"config_module_recaptcha_page_1","level":3,"parentId":"config_module_recaptcha","pages":["getRecaptchaModuleConfig","updateRecaptchaConfig"],"tabIndex":0},"getRecaptchaModuleConfig":{"id":"getRecaptchaModuleConfig","title":"Get reCAPTCHA config","url":"getrecaptchamoduleconfig.html","level":4,"parentId":"config_module_recaptcha_page_1","tabIndex":0},"updateRecaptchaConfig":{"id":"updateRecaptchaConfig","title":"Update reCAPTCHA config","url":"updaterecaptchaconfig.html","level":4,"parentId":"config_module_recaptcha_page_1","tabIndex":1},"config_module_self_serve":{"id":"config_module_self_serve","level":2,"parentId":"Tag_Config","pages":["config_module_self_serve_page_1"],"tabIndex":13},"config_module_self_serve_page_1":{"id":"config_module_self_serve_page_1","level":3,"parentId":"config_module_self_serve","pages":["getSelfServeConfig","updateSelfServeConfig"],"tabIndex":0},"getSelfServeConfig":{"id":"getSelfServeConfig","title":"Get Self-Serve config","url":"getselfserveconfig.html","level":4,"parentId":"config_module_self_serve_page_1","tabIndex":0},"updateSelfServeConfig":{"id":"updateSelfServeConfig","title":"Update Self-Serve config","url":"updateselfserveconfig.html","level":4,"parentId":"config_module_self_serve_page_1","tabIndex":1},"config_module_shelly":{"id":"config_module_shelly","level":2,"parentId":"Tag_Config","pages":["config_module_shelly_page_1"],"tabIndex":14},"config_module_shelly_page_1":{"id":"config_module_shelly_page_1","level":3,"parentId":"config_module_shelly","pages":["getShellyConfig","updateShellyConfig"],"tabIndex":0},"getShellyConfig":{"id":"getShellyConfig","title":"Get Shelly config","url":"getshellyconfig.html","level":4,"parentId":"config_module_shelly_page_1","tabIndex":0},"updateShellyConfig":{"id":"updateShellyConfig","title":"Update Shelly config","url":"updateshellyconfig.html","level":4,"parentId":"config_module_shelly_page_1","tabIndex":1},"config_module_stripe":{"id":"config_module_stripe","level":2,"parentId":"Tag_Config","pages":["config_module_stripe_page_1"],"tabIndex":15},"config_module_stripe_page_1":{"id":"config_module_stripe_page_1","level":3,"parentId":"config_module_stripe","pages":["getStripeConfig","updateStripeConfig"],"tabIndex":0},"getStripeConfig":{"id":"getStripeConfig","title":"Get Stripe config","url":"getstripeconfig.html","level":4,"parentId":"config_module_stripe_page_1","tabIndex":0},"updateStripeConfig":{"id":"updateStripeConfig","title":"Update Stripe config","url":"updatestripeconfig.html","level":4,"parentId":"config_module_stripe_page_1","tabIndex":1},"config_module_virkdata":{"id":"config_module_virkdata","level":2,"parentId":"Tag_Config","pages":["config_module_virkdata_page_1"],"tabIndex":16},"config_module_virkdata_page_1":{"id":"config_module_virkdata_page_1","level":3,"parentId":"config_module_virkdata","pages":["getVirkdataConfig","updateVirkdataConfig"],"tabIndex":0},"getVirkdataConfig":{"id":"getVirkdataConfig","title":"Get Virkdata config","url":"getvirkdataconfig.html","level":4,"parentId":"config_module_virkdata_page_1","tabIndex":0},"updateVirkdataConfig":{"id":"updateVirkdataConfig","title":"Update Virkdata config","url":"updatevirkdataconfig.html","level":4,"parentId":"config_module_virkdata_page_1","tabIndex":1},"config_module_weatherapi":{"id":"config_module_weatherapi","level":2,"parentId":"Tag_Config","pages":["config_module_weatherapi_page_1"],"tabIndex":17},"config_module_weatherapi_page_1":{"id":"config_module_weatherapi_page_1","level":3,"parentId":"config_module_weatherapi","pages":["getWeatherApiConfig","updateWeatherApiConfig"],"tabIndex":0},"getWeatherApiConfig":{"id":"getWeatherApiConfig","title":"Get WeatherAPI config","url":"getweatherapiconfig.html","level":4,"parentId":"config_module_weatherapi_page_1","tabIndex":0},"updateWeatherApiConfig":{"id":"updateWeatherApiConfig","title":"Update WeatherAPI config","url":"updateweatherapiconfig.html","level":4,"parentId":"config_module_weatherapi_page_1","tabIndex":1},"config_module_xlvask":{"id":"config_module_xlvask","level":2,"parentId":"Tag_Config","pages":["config_module_xlvask_page_1"],"tabIndex":18},"config_module_xlvask_page_1":{"id":"config_module_xlvask_page_1","level":3,"parentId":"config_module_xlvask","pages":["getXlvaskConfig","updateXlvaskConfig"],"tabIndex":0},"getXlvaskConfig":{"id":"getXlvaskConfig","title":"Get XLVask config","url":"getxlvaskconfig.html","level":4,"parentId":"config_module_xlvask_page_1","tabIndex":0},"updateXlvaskConfig":{"id":"updateXlvaskConfig","title":"Update XLVask config","url":"updatexlvaskconfig.html","level":4,"parentId":"config_module_xlvask_page_1","tabIndex":1},"Tag_Branding":{"id":"Tag_Branding","title":"Branding","url":"tag-branding.html","level":1,"parentId":"API-Reference","pages":["Tag_Branding_Page_1"],"tabIndex":20},"Tag_Branding_Page_1":{"id":"Tag_Branding_Page_1","title":"Branding - Page 1 of 1","url":"tag-branding-page-1.html","level":2,"parentId":"Tag_Branding","pages":["listBrandingOptions","addBrandingOption","editBrandingOption"],"tabIndex":0},"listBrandingOptions":{"id":"listBrandingOptions","title":"List branding options","url":"listbrandingoptions.html","level":3,"parentId":"Tag_Branding_Page_1","tabIndex":0},"addBrandingOption":{"id":"addBrandingOption","title":"Add branding option","url":"addbrandingoption.html","level":3,"parentId":"Tag_Branding_Page_1","tabIndex":1},"editBrandingOption":{"id":"editBrandingOption","title":"Edit branding option","url":"editbrandingoption.html","level":3,"parentId":"Tag_Branding_Page_1","tabIndex":2},"Tag_Roles":{"id":"Tag_Roles","title":"Roles","url":"tag-roles.html","level":1,"parentId":"API-Reference","pages":["Tag_Roles_Page_1"],"tabIndex":21},"Tag_Roles_Page_1":{"id":"Tag_Roles_Page_1","title":"Roles - Page 1 of 1","url":"tag-roles-page-1.html","level":2,"parentId":"Tag_Roles","pages":["listRoles","addRole","editRole","cloneRole","removeRolePermission","addRolePermission"],"tabIndex":0},"listRoles":{"id":"listRoles","title":"List roles","url":"listroles.html","level":3,"parentId":"Tag_Roles_Page_1","tabIndex":0},"addRole":{"id":"addRole","title":"Add role","url":"addrole.html","level":3,"parentId":"Tag_Roles_Page_1","tabIndex":1},"editRole":{"id":"editRole","title":"Edit role","url":"editrole.html","level":3,"parentId":"Tag_Roles_Page_1","tabIndex":2},"cloneRole":{"id":"cloneRole","title":"Clone role","url":"clonerole.html","level":3,"parentId":"Tag_Roles_Page_1","tabIndex":3},"removeRolePermission":{"id":"removeRolePermission","title":"Remove permission from role","url":"removerolepermission.html","level":3,"parentId":"Tag_Roles_Page_1","tabIndex":4},"addRolePermission":{"id":"addRolePermission","title":"Add permission to role","url":"addrolepermission.html","level":3,"parentId":"Tag_Roles_Page_1","tabIndex":5},"Tag_Self_Serve":{"id":"Tag_Self_Serve","title":"Self-Serve","url":"tag-self-serve.html","level":1,"parentId":"API-Reference","pages":["Tag_Self_Serve_Page_1","Tag_Self_Serve_Page_2"],"tabIndex":22},"Tag_Self_Serve_Page_1":{"id":"Tag_Self_Serve_Page_1","title":"Self-Serve - Page 1 of 2","url":"tag-self-serve-page-1.html","level":2,"parentId":"Tag_Self_Serve","pages":["deleteSelfserveConditionRule","listSelfserveConditionRules","addSelfserveConditionRule","updateSelfserveConditionRule","deleteSelfserveCondition","listSelfserveConditions","addSelfserveCondition","updateSelfserveCondition","deleteSelfserveMachineType","listSelfserveMachineTypes","addSelfserveMachineType","updateSelfserveMachineType","deleteSelfserveQuestion","listSelfserveQuestions","addSelfserveQuestion","updateSelfserveQuestion","deleteSelfserveTask","listSelfserveTasks","addSelfserveTask","updateSelfserveTask"],"tabIndex":0},"deleteSelfserveConditionRule":{"id":"deleteSelfserveConditionRule","title":"Delete self-serve condition rule","url":"deleteselfserveconditionrule.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":0},"listSelfserveConditionRules":{"id":"listSelfserveConditionRules","title":"List self-serve condition rules","url":"listselfserveconditionrules.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":1},"addSelfserveConditionRule":{"id":"addSelfserveConditionRule","title":"Add self-serve condition rule","url":"addselfserveconditionrule.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":2},"updateSelfserveConditionRule":{"id":"updateSelfserveConditionRule","title":"Update self-serve condition rule","url":"updateselfserveconditionrule.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":3},"deleteSelfserveCondition":{"id":"deleteSelfserveCondition","title":"Delete self-serve condition","url":"deleteselfservecondition.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":4},"listSelfserveConditions":{"id":"listSelfserveConditions","title":"List self-serve conditions","url":"listselfserveconditions.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":5},"addSelfserveCondition":{"id":"addSelfserveCondition","title":"Add self-serve condition","url":"addselfservecondition.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":6},"updateSelfserveCondition":{"id":"updateSelfserveCondition","title":"Update self-serve condition","url":"updateselfservecondition.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":7},"deleteSelfserveMachineType":{"id":"deleteSelfserveMachineType","title":"Delete reusable self-serve machine type","url":"deleteselfservemachinetype.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":8},"listSelfserveMachineTypes":{"id":"listSelfserveMachineTypes","title":"List reusable self-serve machine types","url":"listselfservemachinetypes.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":9},"addSelfserveMachineType":{"id":"addSelfserveMachineType","title":"Add reusable self-serve machine type","url":"addselfservemachinetype.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":10},"updateSelfserveMachineType":{"id":"updateSelfserveMachineType","title":"Update reusable self-serve machine type","url":"updateselfservemachinetype.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":11},"deleteSelfserveQuestion":{"id":"deleteSelfserveQuestion","title":"Delete self-serve question","url":"deleteselfservequestion.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":12},"listSelfserveQuestions":{"id":"listSelfserveQuestions","title":"List self-serve questions","url":"listselfservequestions.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":13},"addSelfserveQuestion":{"id":"addSelfserveQuestion","title":"Add self-serve question","url":"addselfservequestion.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":14},"updateSelfserveQuestion":{"id":"updateSelfserveQuestion","title":"Update self-serve question","url":"updateselfservequestion.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":15},"deleteSelfserveTask":{"id":"deleteSelfserveTask","title":"Delete self-serve task","url":"deleteselfservetask.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":16},"listSelfserveTasks":{"id":"listSelfserveTasks","title":"List self-serve tasks","url":"listselfservetasks.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":17},"addSelfserveTask":{"id":"addSelfserveTask","title":"Add self-serve task","url":"addselfservetask.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":18},"updateSelfserveTask":{"id":"updateSelfserveTask","title":"Update self-serve task","url":"updateselfservetask.html","level":3,"parentId":"Tag_Self_Serve_Page_1","tabIndex":19},"Tag_Self_Serve_Page_2":{"id":"Tag_Self_Serve_Page_2","title":"Self-Serve - Page 2 of 2","url":"tag-self-serve-page-2.html","level":2,"parentId":"Tag_Self_Serve","pages":["deleteSelfserveTaskAttachment","listSelfserveTaskAttachments","downloadSelfserveTaskAttachment","uploadSelfserveTaskAttachment","getSelfserveVehicleAllowed","deleteSelfserveVehicleCondition","listSelfserveVehicleConditions","addSelfserveVehicleCondition","updateSelfserveVehicleCondition","getSelfserveWashSummary"],"tabIndex":1},"deleteSelfserveTaskAttachment":{"id":"deleteSelfserveTaskAttachment","title":"Delete task attachment","url":"deleteselfservetaskattachment.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":0},"listSelfserveTaskAttachments":{"id":"listSelfserveTaskAttachments","title":"List task attachments","url":"listselfservetaskattachments.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":1},"downloadSelfserveTaskAttachment":{"id":"downloadSelfserveTaskAttachment","title":"Download task attachment","url":"downloadselfservetaskattachment.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":2},"uploadSelfserveTaskAttachment":{"id":"uploadSelfserveTaskAttachment","title":"Upload task attachment","url":"uploadselfservetaskattachment.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":3},"getSelfserveVehicleAllowed":{"id":"getSelfserveVehicleAllowed","title":"Check whether self-serve is allowed for a vehicle on a lane","url":"getselfservevehicleallowed.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":4},"deleteSelfserveVehicleCondition":{"id":"deleteSelfserveVehicleCondition","title":"Delete vehicle condition","url":"deleteselfservevehiclecondition.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":5},"listSelfserveVehicleConditions":{"id":"listSelfserveVehicleConditions","title":"List vehicle conditions","url":"listselfservevehicleconditions.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":6},"addSelfserveVehicleCondition":{"id":"addSelfserveVehicleCondition","title":"Add vehicle condition","url":"addselfservevehiclecondition.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":7},"updateSelfserveVehicleCondition":{"id":"updateSelfserveVehicleCondition","title":"Update vehicle condition","url":"updateselfservevehiclecondition.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":8},"getSelfserveWashSummary":{"id":"getSelfserveWashSummary","title":"Get self-serve wash summary","url":"getselfservewashsummary.html","level":3,"parentId":"Tag_Self_Serve_Page_2","tabIndex":9},"Tag_Goals":{"id":"Tag_Goals","title":"Goals","url":"tag-goals.html","level":1,"parentId":"API-Reference","pages":["Tag_Goals_Page_1"],"tabIndex":23},"Tag_Goals_Page_1":{"id":"Tag_Goals_Page_1","title":"Goals - Page 1 of 1","url":"tag-goals-page-1.html","level":2,"parentId":"Tag_Goals","pages":["listDepartmentGoals","createDepartmentGoal","updateDepartmentGoal","deleteDepartmentGoal","sendDepartmentGoalProgressAlertTest"],"tabIndex":0},"listDepartmentGoals":{"id":"listDepartmentGoals","title":"List or get department goals","url":"listdepartmentgoals.html","level":3,"parentId":"Tag_Goals_Page_1","tabIndex":0},"createDepartmentGoal":{"id":"createDepartmentGoal","title":"Create department goal","url":"createdepartmentgoal.html","level":3,"parentId":"Tag_Goals_Page_1","tabIndex":1},"updateDepartmentGoal":{"id":"updateDepartmentGoal","title":"Update department goal","url":"updatedepartmentgoal.html","level":3,"parentId":"Tag_Goals_Page_1","tabIndex":2},"deleteDepartmentGoal":{"id":"deleteDepartmentGoal","title":"Delete department goal","url":"deletedepartmentgoal.html","level":3,"parentId":"Tag_Goals_Page_1","tabIndex":3},"sendDepartmentGoalProgressAlertTest":{"id":"sendDepartmentGoalProgressAlertTest","title":"Send a test progress alert for a department goal","url":"senddepartmentgoalprogressalerttest.html","level":3,"parentId":"Tag_Goals_Page_1","tabIndex":4},"Tag_Subusers":{"id":"Tag_Subusers","title":"Subusers","url":"tag-subusers.html","level":1,"parentId":"API-Reference","pages":["Tag_Subusers_Page_1"],"tabIndex":24},"Tag_Subusers_Page_1":{"id":"Tag_Subusers_Page_1","title":"Subusers - Page 1 of 1","url":"tag-subusers-page-1.html","level":2,"parentId":"Tag_Subusers","pages":["listSubusers","subuserPasswordAuth","listSubuserGrants","createSubuserGrant","deleteSubuserGrant","updateSubuserGrant","getCurrentSubuser","createSubuser","listSubuserPermissionNodes","validateSubuserSetupToken","completeSubuserSetup","getSubuser"],"tabIndex":0},"listSubusers":{"id":"listSubusers","title":"List subusers visible to the authenticated user","url":"listsubusers.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":0},"subuserPasswordAuth":{"id":"subuserPasswordAuth","title":"Authenticate subuser with password","url":"subuserpasswordauth.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":1},"listSubuserGrants":{"id":"listSubuserGrants","title":"List subuser grants","url":"listsubusergrants.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":2},"createSubuserGrant":{"id":"createSubuserGrant","title":"Create subuser grant","url":"createsubusergrant.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":3},"deleteSubuserGrant":{"id":"deleteSubuserGrant","title":"Delete subuser grant","url":"deletesubusergrant.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":4},"updateSubuserGrant":{"id":"updateSubuserGrant","title":"Update subuser grant","url":"updatesubusergrant.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":5},"getCurrentSubuser":{"id":"getCurrentSubuser","title":"Get current subuser profile","url":"getcurrentsubuser.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":6},"createSubuser":{"id":"createSubuser","title":"Create a subuser registration","url":"createsubuser.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":7},"listSubuserPermissionNodes":{"id":"listSubuserPermissionNodes","title":"List available subuser permission nodes","url":"listsubuserpermissionnodes.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":8},"validateSubuserSetupToken":{"id":"validateSubuserSetupToken","title":"Validate setup token","url":"validatesubusersetuptoken.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":9},"completeSubuserSetup":{"id":"completeSubuserSetup","title":"Complete subuser setup","url":"completesubusersetup.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":10},"getSubuser":{"id":"getSubuser","title":"Get a subuser by ID (visible by grant)","url":"getsubuser.html","level":3,"parentId":"Tag_Subusers_Page_1","tabIndex":11},"Tag_Bird":{"id":"Tag_Bird","title":"Bird","url":"tag-bird.html","level":1,"parentId":"API-Reference","pages":["Tag_Bird_Page_1"],"tabIndex":25},"Tag_Bird_Page_1":{"id":"Tag_Bird_Page_1","title":"Bird - Page 1 of 1","url":"tag-bird-page-1.html","level":2,"parentId":"Tag_Bird","pages":["birdListNumbers","birdDeleteNumber","birdGetNumber","birdListVoiceCalls","birdCreateVoiceCall","birdTestOutboundVoiceCall","birdGetVoiceCall","birdHangupVoiceCall","birdSayOnVoiceCall","birdListFlashCalls","birdCreateFlashCall","birdEndFlashCallByNumbers","birdGetFlashCall","birdEndFlashCall"],"tabIndex":0},"birdListNumbers":{"id":"birdListNumbers","title":"List your numbers","url":"birdlistnumbers.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":0},"birdDeleteNumber":{"id":"birdDeleteNumber","title":"Delete/release a number by ID","url":"birddeletenumber.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":1},"birdGetNumber":{"id":"birdGetNumber","title":"Get a number by ID","url":"birdgetnumber.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":2},"birdListVoiceCalls":{"id":"birdListVoiceCalls","title":"List voice calls","url":"birdlistvoicecalls.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":3},"birdCreateVoiceCall":{"id":"birdCreateVoiceCall","title":"Create/place a voice call via Bird","url":"birdcreatevoicecall.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":4},"birdTestOutboundVoiceCall":{"id":"birdTestOutboundVoiceCall","title":"Place a test outbound call and hang up when accepted","url":"birdtestoutboundvoicecall.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":5},"birdGetVoiceCall":{"id":"birdGetVoiceCall","title":"Get a voice call by ID","url":"birdgetvoicecall.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":6},"birdHangupVoiceCall":{"id":"birdHangupVoiceCall","title":"Hang up a voice call by ID","url":"birdhangupvoicecall.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":7},"birdSayOnVoiceCall":{"id":"birdSayOnVoiceCall","title":"Say a message on an active voice call and hang up afterwards","url":"birdsayonvoicecall.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":8},"birdListFlashCalls":{"id":"birdListFlashCalls","title":"List flash calls","url":"birdlistflashcalls.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":9},"birdCreateFlashCall":{"id":"birdCreateFlashCall","title":"Create/place a flash call via Bird","url":"birdcreateflashcall.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":10},"birdEndFlashCallByNumbers":{"id":"birdEndFlashCallByNumbers","title":"Complete/end a flash call using from/to numbers","url":"birdendflashcallbynumbers.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":11},"birdGetFlashCall":{"id":"birdGetFlashCall","title":"Get a flash call by ID","url":"birdgetflashcall.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":12},"birdEndFlashCall":{"id":"birdEndFlashCall","title":"Complete/end a flash call by ID","url":"birdendflashcall.html","level":3,"parentId":"Tag_Bird_Page_1","tabIndex":13},"Vehicles_PUT":{"id":"Vehicles_PUT","level":0,"tabIndex":3},"Customers_PUT":{"id":"Customers_PUT","level":0,"tabIndex":4},"Vehicles_GET":{"id":"Vehicles_GET","level":0,"tabIndex":5},"Customers_id_DELETE":{"id":"Customers_id_DELETE","level":0,"tabIndex":6},"Config_Module_GatewayAPI":{"id":"Config_Module_GatewayAPI","title":"GatewayAPI","url":"config-module-gatewayapi.html","level":0,"tabIndex":7},"Config_Module_reCAPTCHA":{"id":"Config_Module_reCAPTCHA","title":"reCAPTCHA","url":"config-module-recaptcha.html","level":0,"tabIndex":8},"API_Reference":{"id":"API_Reference","level":0,"tabIndex":9},"Vehicles_id_DELETE":{"id":"Vehicles_id_DELETE","level":0,"tabIndex":10},"Customers_POST":{"id":"Customers_POST","level":0,"tabIndex":11},"Vehicles_POST":{"id":"Vehicles_POST","level":0,"tabIndex":12},"UsageLog_GET":{"id":"UsageLog_GET","level":0,"tabIndex":13},"Customers_GET":{"id":"Customers_GET","level":0,"tabIndex":14},"User_Bookings_Delete_POST":{"id":"User_Bookings_Delete_POST","level":0,"tabIndex":15},"Department_Timebookings_Entries_Public_POST":{"id":"Department_Timebookings_Entries_Public_POST","level":0,"tabIndex":16},"Bookings_Download_PDF_GET":{"id":"Bookings_Download_PDF_GET","level":0,"tabIndex":17},"User_Bookings_Washcertificate_Download_POST":{"id":"User_Bookings_Washcertificate_Download_POST","level":0,"tabIndex":18},"User_Bookings_GET":{"id":"User_Bookings_GET","level":0,"tabIndex":19},"Admin_Bookings_Sync_POST":{"id":"Admin_Bookings_Sync_POST","level":0,"tabIndex":20},"Department_Timebookings_Entries_Public_GET":{"id":"Department_Timebookings_Entries_Public_GET","level":0,"tabIndex":21},"Admin_Bookings_Delete_POST":{"id":"Admin_Bookings_Delete_POST","level":0,"tabIndex":22},"Department_Timebookings_Types_Public_GET":{"id":"Department_Timebookings_Types_Public_GET","level":0,"tabIndex":23},"Bookings_PUT":{"id":"Bookings_PUT","level":0,"tabIndex":24},"Admin_Bookings_CompleteWithoutWashCertificate_POST":{"id":"Admin_Bookings_CompleteWithoutWashCertificate_POST","level":0,"tabIndex":25},"Department_Timebookings_OpeningHours_Public_GET":{"id":"Department_Timebookings_OpeningHours_Public_GET","level":0,"tabIndex":26},"Superuser_Bookings_Sync_All_POST":{"id":"Superuser_Bookings_Sync_All_POST","level":0,"tabIndex":27},"Admin_Bookings_Department_Count_GET":{"id":"Admin_Bookings_Department_Count_GET","level":0,"tabIndex":28},"Bookings_GET":{"id":"Bookings_GET","level":0,"tabIndex":29},"Modules_Module_CVR_Page_1":{"id":"Modules_Module_CVR_Page_1","title":"CVR - Page 1 of 1","url":"modules-module-cvr-page-1.html","level":0,"tabIndex":30},"Modules_Module_Wash_Certificates_Page_1":{"id":"Modules_Module_Wash_Certificates_Page_1","title":"Wash Certificates - Page 1 of 1","url":"modules-module-wash-certificates-page-1.html","level":0,"tabIndex":31},"Modules_Module_Entra_Page_1":{"id":"Modules_Module_Entra_Page_1","title":"Entra - Page 1 of 1","url":"modules-module-entra-page-1.html","level":0,"tabIndex":32},"Modules_Module_XLVask":{"id":"Modules_Module_XLVask","title":"XLVask","url":"modules-module-xlvask.html","level":0,"tabIndex":33},"Modules_Module_VirkData":{"id":"Modules_Module_VirkData","title":"VirkData","url":"modules-module-virkdata.html","level":0,"tabIndex":34},"Modules_Module_XLVask_Page_1":{"id":"Modules_Module_XLVask_Page_1","title":"XLVask - Page 1 of 1","url":"modules-module-xlvask-page-1.html","level":0,"tabIndex":35},"Config_Module_OcrSpace_Page_1":{"id":"Config_Module_OcrSpace_Page_1","title":"OcrSpace - Page 1 of 1","url":"config-module-ocrspace-page-1.html","level":0,"tabIndex":36},"Modules_Module_Stripe_Page_1":{"id":"Modules_Module_Stripe_Page_1","title":"Stripe - Page 1 of 1","url":"modules-module-stripe-page-1.html","level":0,"tabIndex":37},"Config_Module_XLVask_Page_1":{"id":"Config_Module_XLVask_Page_1","title":"XLVask - Page 1 of 1","url":"config-module-xlvask-page-1.html","level":0,"tabIndex":38},"Modules_Module_WeatherAPI_Page_1":{"id":"Modules_Module_WeatherAPI_Page_1","title":"WeatherAPI - Page 1 of 1","url":"modules-module-weatherapi-page-1.html","level":0,"tabIndex":39},"Config_Module_FXRatesAPI":{"id":"Config_Module_FXRatesAPI","title":"FXRatesAPI","url":"config-module-fxratesapi.html","level":0,"tabIndex":40},"Modules_Module_Entra":{"id":"Modules_Module_Entra","title":"Entra","url":"modules-module-entra.html","level":0,"tabIndex":41},"Config_Module_WeatherAPI_Page_1":{"id":"Config_Module_WeatherAPI_Page_1","title":"WeatherAPI - Page 1 of 1","url":"config-module-weatherapi-page-1.html","level":0,"tabIndex":42},"Modules_Module_Action_Logs_Page_1":{"id":"Modules_Module_Action_Logs_Page_1","title":"Action Logs - Page 1 of 1","url":"modules-module-action-logs-page-1.html","level":0,"tabIndex":43},"Modules_Module_MotorAPI":{"id":"Modules_Module_MotorAPI","title":"MotorAPI","url":"modules-module-motorapi.html","level":0,"tabIndex":44},"Modules_Module_Self_Serve_Page_1":{"id":"Modules_Module_Self_Serve_Page_1","title":"Self-Serve - Page 1 of 1","url":"modules-module-self-serve-page-1.html","level":0,"tabIndex":45},"Modules_Module_Stripe":{"id":"Modules_Module_Stripe","title":"Stripe","url":"modules-module-stripe.html","level":0,"tabIndex":46},"Modules_Module_FXRatesAPI":{"id":"Modules_Module_FXRatesAPI","title":"FXRatesAPI","url":"modules-module-fxratesapi.html","level":0,"tabIndex":47},"Config_Module_LicensePlateRecognizer":{"id":"Config_Module_LicensePlateRecognizer","title":"LicensePlateRecognizer","url":"config-module-licenseplaterecognizer.html","level":0,"tabIndex":48},"Modules_Module_Backup_Page_1":{"id":"Modules_Module_Backup_Page_1","title":"Backup - Page 1 of 1","url":"modules-module-backup-page-1.html","level":0,"tabIndex":49},"Config_Module_reCAPTCHA_Page_1":{"id":"Config_Module_reCAPTCHA_Page_1","title":"reCAPTCHA - Page 1 of 1","url":"config-module-recaptcha-page-1.html","level":0,"tabIndex":50},"Modules_Module_Wash_Certificates":{"id":"Modules_Module_Wash_Certificates","title":"Wash Certificates","url":"modules-module-wash-certificates.html","level":0,"tabIndex":51},"Config_Module_Shelly":{"id":"Config_Module_Shelly","title":"Shelly","url":"config-module-shelly.html","level":0,"tabIndex":52},"Modules_Module_e_conomic":{"id":"Modules_Module_e_conomic","title":"e-conomic","url":"modules-module-e-conomic.html","level":0,"tabIndex":53},"Config_Module_WeatherAPI":{"id":"Config_Module_WeatherAPI","title":"WeatherAPI","url":"config-module-weatherapi.html","level":0,"tabIndex":54},"Modules_Module_MotorAPI_Page_1":{"id":"Modules_Module_MotorAPI_Page_1","title":"MotorAPI - Page 1 of 1","url":"modules-module-motorapi-page-1.html","level":0,"tabIndex":55},"Config_Module_Email_Page_1":{"id":"Config_Module_Email_Page_1","title":"Email - Page 1 of 1","url":"config-module-email-page-1.html","level":0,"tabIndex":56},"Modules_Module_VirkData_Page_1":{"id":"Modules_Module_VirkData_Page_1","title":"VirkData - Page 1 of 1","url":"modules-module-virkdata-page-1.html","level":0,"tabIndex":57},"Config_Module_Email":{"id":"Config_Module_Email","title":"Email","url":"config-module-email.html","level":0,"tabIndex":58},"Modules_Module_FXRatesAPI_Page_1":{"id":"Modules_Module_FXRatesAPI_Page_1","title":"FXRatesAPI - Page 1 of 1","url":"modules-module-fxratesapi-page-1.html","level":0,"tabIndex":59},"Config_Module_Backups_Page_1":{"id":"Config_Module_Backups_Page_1","title":"Backups - Page 1 of 1","url":"config-module-backups-page-1.html","level":0,"tabIndex":60},"Modules_Module_WeatherAPI":{"id":"Modules_Module_WeatherAPI","title":"WeatherAPI","url":"modules-module-weatherapi.html","level":0,"tabIndex":61},"Config_Module_Self_Serve":{"id":"Config_Module_Self_Serve","title":"Self-Serve","url":"config-module-self-serve.html","level":0,"tabIndex":62},"Config_Module_OcrSpace":{"id":"Config_Module_OcrSpace","title":"OcrSpace","url":"config-module-ocrspace.html","level":0,"tabIndex":63},"Modules_Module_Self_Serve":{"id":"Modules_Module_Self_Serve","title":"Self-Serve","url":"modules-module-self-serve.html","level":0,"tabIndex":64},"Config_Module_Bird":{"id":"Config_Module_Bird","title":"Bird","url":"config-module-bird.html","level":0,"tabIndex":65},"Config_Module_Limble_Page_1":{"id":"Config_Module_Limble_Page_1","title":"Limble - Page 1 of 1","url":"config-module-limble-page-1.html","level":0,"tabIndex":66},"Config_Module_MotorAPI":{"id":"Config_Module_MotorAPI","title":"MotorAPI","url":"config-module-motorapi.html","level":0,"tabIndex":67},"Modules_Module_e_conomic_Page_1":{"id":"Modules_Module_e_conomic_Page_1","title":"e-conomic - Page 1 of 1","url":"modules-module-e-conomic-page-1.html","level":0,"tabIndex":68},"Config_Module_MotorAPI_Page_1":{"id":"Config_Module_MotorAPI_Page_1","title":"MotorAPI - Page 1 of 1","url":"config-module-motorapi-page-1.html","level":0,"tabIndex":69},"Modules_Module_Action_Logs":{"id":"Modules_Module_Action_Logs","title":"Action Logs","url":"modules-module-action-logs.html","level":0,"tabIndex":70},"Config_Module_LicensePlateRecognizer_Page_1":{"id":"Config_Module_LicensePlateRecognizer_Page_1","title":"LicensePlateRecognizer - Page 1 of 1","url":"config-module-licenseplaterecognizer-page-1.html","level":0,"tabIndex":71},"Modules_Module_Backup":{"id":"Modules_Module_Backup","title":"Backup","url":"modules-module-backup.html","level":0,"tabIndex":72},"Config_Module_Bird_Page_1":{"id":"Config_Module_Bird_Page_1","title":"Bird - Page 1 of 1","url":"config-module-bird-page-1.html","level":0,"tabIndex":73},"Modules_Module_CVR":{"id":"Modules_Module_CVR","title":"CVR","url":"modules-module-cvr.html","level":0,"tabIndex":74},"Config_Module_OpenAI":{"id":"Config_Module_OpenAI","title":"OpenAI","url":"config-module-openai.html","level":0,"tabIndex":75},"Config_Module_OpenAI_Page_1":{"id":"Config_Module_OpenAI_Page_1","title":"OpenAI - Page 1 of 1","url":"config-module-openai-page-1.html","level":0,"tabIndex":76},"Config_Module_Stripe":{"id":"Config_Module_Stripe","title":"Stripe","url":"config-module-stripe.html","level":0,"tabIndex":77},"Config_Module_FXRatesAPI_Page_1":{"id":"Config_Module_FXRatesAPI_Page_1","title":"FXRatesAPI - Page 1 of 1","url":"config-module-fxratesapi-page-1.html","level":0,"tabIndex":78},"Config_Module_Stripe_Page_1":{"id":"Config_Module_Stripe_Page_1","title":"Stripe - Page 1 of 1","url":"config-module-stripe-page-1.html","level":0,"tabIndex":79},"Config_Module_Backups":{"id":"Config_Module_Backups","title":"Backups","url":"config-module-backups.html","level":0,"tabIndex":80},"Config_Module_e_conomic":{"id":"Config_Module_e_conomic","title":"e-conomic","url":"config-module-e-conomic.html","level":0,"tabIndex":81},"Config_Module_Limble":{"id":"Config_Module_Limble","title":"Limble","url":"config-module-limble.html","level":0,"tabIndex":82},"Config_Module_Shelly_Page_1":{"id":"Config_Module_Shelly_Page_1","title":"Shelly - Page 1 of 1","url":"config-module-shelly-page-1.html","level":0,"tabIndex":83},"Config_Module_XLVask":{"id":"Config_Module_XLVask","title":"XLVask","url":"config-module-xlvask.html","level":0,"tabIndex":84},"Config_Module_GatewayAPI_Page_1":{"id":"Config_Module_GatewayAPI_Page_1","title":"GatewayAPI - Page 1 of 1","url":"config-module-gatewayapi-page-1.html","level":0,"tabIndex":85},"Config_Module_Entra_Page_1":{"id":"Config_Module_Entra_Page_1","title":"Entra - Page 1 of 1","url":"config-module-entra-page-1.html","level":0,"tabIndex":86},"Config_Module_Self_Serve_Page_1":{"id":"Config_Module_Self_Serve_Page_1","title":"Self-Serve - Page 1 of 1","url":"config-module-self-serve-page-1.html","level":0,"tabIndex":87},"Config_Module_Entra":{"id":"Config_Module_Entra","title":"Entra","url":"config-module-entra.html","level":0,"tabIndex":88},"Config_Module_VirkData_Page_1":{"id":"Config_Module_VirkData_Page_1","title":"VirkData - Page 1 of 1","url":"config-module-virkdata-page-1.html","level":0,"tabIndex":89},"Config_Module_e_conomic_Page_1":{"id":"Config_Module_e_conomic_Page_1","title":"e-conomic - Page 1 of 1","url":"config-module-e-conomic-page-1.html","level":0,"tabIndex":90},"Config_Module_VirkData":{"id":"Config_Module_VirkData","title":"VirkData","url":"config-module-virkdata.html","level":0,"tabIndex":91}}},"topLevelIds":["Introduction","API-Overview","API-Reference","Vehicles_PUT","Customers_PUT","Vehicles_GET","Customers_id_DELETE","Config_Module_GatewayAPI","Config_Module_reCAPTCHA","API_Reference","Vehicles_id_DELETE","Customers_POST","Vehicles_POST","UsageLog_GET","Customers_GET","User_Bookings_Delete_POST","Department_Timebookings_Entries_Public_POST","Bookings_Download_PDF_GET","User_Bookings_Washcertificate_Download_POST","User_Bookings_GET","Admin_Bookings_Sync_POST","Department_Timebookings_Entries_Public_GET","Admin_Bookings_Delete_POST","Department_Timebookings_Types_Public_GET","Bookings_PUT","Admin_Bookings_CompleteWithoutWashCertificate_POST","Department_Timebookings_OpeningHours_Public_GET","Superuser_Bookings_Sync_All_POST","Admin_Bookings_Department_Count_GET","Bookings_GET","Modules_Module_CVR_Page_1","Modules_Module_Wash_Certificates_Page_1","Modules_Module_Entra_Page_1","Modules_Module_XLVask","Modules_Module_VirkData","Modules_Module_XLVask_Page_1","Config_Module_OcrSpace_Page_1","Modules_Module_Stripe_Page_1","Config_Module_XLVask_Page_1","Modules_Module_WeatherAPI_Page_1","Config_Module_FXRatesAPI","Modules_Module_Entra","Config_Module_WeatherAPI_Page_1","Modules_Module_Action_Logs_Page_1","Modules_Module_MotorAPI","Modules_Module_Self_Serve_Page_1","Modules_Module_Stripe","Modules_Module_FXRatesAPI","Config_Module_LicensePlateRecognizer","Modules_Module_Backup_Page_1","Config_Module_reCAPTCHA_Page_1","Modules_Module_Wash_Certificates","Config_Module_Shelly","Modules_Module_e_conomic","Config_Module_WeatherAPI","Modules_Module_MotorAPI_Page_1","Config_Module_Email_Page_1","Modules_Module_VirkData_Page_1","Config_Module_Email","Modules_Module_FXRatesAPI_Page_1","Config_Module_Backups_Page_1","Modules_Module_WeatherAPI","Config_Module_Self_Serve","Config_Module_OcrSpace","Modules_Module_Self_Serve","Config_Module_Bird","Config_Module_Limble_Page_1","Config_Module_MotorAPI","Modules_Module_e_conomic_Page_1","Config_Module_MotorAPI_Page_1","Modules_Module_Action_Logs","Config_Module_LicensePlateRecognizer_Page_1","Modules_Module_Backup","Config_Module_Bird_Page_1","Modules_Module_CVR","Config_Module_OpenAI","Config_Module_OpenAI_Page_1","Config_Module_Stripe","Config_Module_FXRatesAPI_Page_1","Config_Module_Stripe_Page_1","Config_Module_Backups","Config_Module_e_conomic","Config_Module_Limble","Config_Module_Shelly_Page_1","Config_Module_XLVask","Config_Module_GatewayAPI_Page_1","Config_Module_Entra_Page_1","Config_Module_Self_Serve_Page_1","Config_Module_Entra","Config_Module_VirkData_Page_1","Config_Module_e_conomic_Page_1","Config_Module_VirkData"]} \ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/Map.jhm b/documentation/_site_rebuild_20260317/Map.jhm new file mode 100644 index 00000000..d5b71b10 --- /dev/null +++ b/documentation/_site_rebuild_20260317/Map.jhm @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addbrandingoption.html b/documentation/_site_rebuild_20260317/addbrandingoption.html new file mode 100644 index 00000000..308b6ffe --- /dev/null +++ b/documentation/_site_rebuild_20260317/addbrandingoption.html @@ -0,0 +1,38 @@ + +Add branding option | Copenhagen Truck Wash API \ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addbuttonpress.html b/documentation/_site_rebuild_20260317/addbuttonpress.html new file mode 100644 index 00000000..145cdabc --- /dev/null +++ b/documentation/_site_rebuild_20260317/addbuttonpress.html @@ -0,0 +1,20 @@ + +Record machine start button press webhook | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Record machine start button press webhook

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /relay/button/press/post

Operation

Operation ID: addButtonPress

Record machine start button press webhook

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

token

query

no

string

lane_id

query

no

integer

reg

query

no

string

Responses

Status

Description

Content Types

201

Button press recorded and linked to a self-serve wash session

application/json

404

Schema for response 201 (application/json):

+{ + "$ref": "#/components/schemas/MachineButtonPressWebhookResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addbuttonpresspost.html b/documentation/_site_rebuild_20260317/addbuttonpresspost.html new file mode 100644 index 00000000..33e975dc --- /dev/null +++ b/documentation/_site_rebuild_20260317/addbuttonpresspost.html @@ -0,0 +1,35 @@ + +Record machine start button press webhook | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Record machine start button press webhook

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /relay/button/press/post

Operation

Operation ID: addButtonPressPost

Record machine start button press webhook

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{ + "properties": { + "lane_id": { + "type": "integer" + }, + "reg": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

201

Button press recorded and linked to a self-serve wash session

application/json

404

Schema for response 201 (application/json):

+{ + "$ref": "#/components/schemas/MachineButtonPressWebhookResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addcustomerattribute.html b/documentation/_site_rebuild_20260317/addcustomerattribute.html new file mode 100644 index 00000000..665094d9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/addcustomerattribute.html @@ -0,0 +1,20 @@ + +Add customer attribute | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add customer attribute

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /customer/attributes

Operation

Operation ID: addCustomerAttribute

Add a custom attribute to a customer

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

201

Customer attribute added successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addcustomercode.html b/documentation/_site_rebuild_20260317/addcustomercode.html new file mode 100644 index 00000000..b5bf34e6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/addcustomercode.html @@ -0,0 +1,33 @@ + +Add customer code | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add customer code

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /admin/customer/code

Operation

Operation ID: addCustomerCode

Add customer code

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "code": { + "type": "string" + }, + "customer_number": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addcustomerdefaultdepartment.html b/documentation/_site_rebuild_20260317/addcustomerdefaultdepartment.html new file mode 100644 index 00000000..baa01b3f --- /dev/null +++ b/documentation/_site_rebuild_20260317/addcustomerdefaultdepartment.html @@ -0,0 +1,33 @@ + +Add customer default department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add customer default department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /customer/department/default

Operation

Operation ID: addCustomerDefaultDepartment

Add customer default department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "customer_number": { + "type": "integer" + }, + "department": { + "type": "integer" + } + }, + "required": [ + "department" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addcustomerfixedpricing.html b/documentation/_site_rebuild_20260317/addcustomerfixedpricing.html new file mode 100644 index 00000000..b7570f45 --- /dev/null +++ b/documentation/_site_rebuild_20260317/addcustomerfixedpricing.html @@ -0,0 +1,38 @@ + +Add customer fixed pricing | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add customer fixed pricing

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /customer/pricing/fixed

Operation

Operation ID: addCustomerFixedPricing

Add customer fixed pricing

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "customer_number": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "price": { + "type": "integer" + } + }, + "required": [ + "customer_number", + "price", + "description" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addcustomernote.html b/documentation/_site_rebuild_20260317/addcustomernote.html new file mode 100644 index 00000000..a184351c --- /dev/null +++ b/documentation/_site_rebuild_20260317/addcustomernote.html @@ -0,0 +1,20 @@ + +Add customer note | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add customer note

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /customer/notes

Operation

Operation ID: addCustomerNote

Add a note to a customer

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

201

Customer note added successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/adddailyreport.html b/documentation/_site_rebuild_20260317/adddailyreport.html new file mode 100644 index 00000000..d2ec0dc9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/adddailyreport.html @@ -0,0 +1,38 @@ + +Add daily report | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add daily report

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /departments/daily-reports

Operation

Operation ID: addDailyReport

Add daily report

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "date": { + "type": "string" + }, + "department_id": { + "type": "integer" + }, + "report": { + "type": "string" + } + }, + "required": [ + "department_id", + "date", + "report" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/adddepartmentcategory.html b/documentation/_site_rebuild_20260317/adddepartmentcategory.html new file mode 100644 index 00000000..e28190ad --- /dev/null +++ b/documentation/_site_rebuild_20260317/adddepartmentcategory.html @@ -0,0 +1,30 @@ + +Add category to department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add category to department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /departments/categories

Operation

Operation ID: addDepartmentCategory

Associate a product category with a department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "category_id": { + "type": "integer" + }, + "department_id": { + "type": "integer" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

201

Category added to department successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addorderitem.html b/documentation/_site_rebuild_20260317/addorderitem.html new file mode 100644 index 00000000..d8ca9264 --- /dev/null +++ b/documentation/_site_rebuild_20260317/addorderitem.html @@ -0,0 +1,22 @@ + +Add item to order | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add item to order

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /order/items

Operation

Operation ID: addOrderItem

Add a new item to an existing order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/OrderItemCreate" +} +

Responses

Status

Description

Content Types

201

Order item added successfully

application/json

400

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addplatescanner.html b/documentation/_site_rebuild_20260317/addplatescanner.html new file mode 100644 index 00000000..54430ebb --- /dev/null +++ b/documentation/_site_rebuild_20260317/addplatescanner.html @@ -0,0 +1,38 @@ + +Add plate scanner | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add plate scanner

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /numberplatescanners

Operation

Operation ID: addPlateScanner

Add plate scanner

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "department_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string" + } + }, + "required": [ + "department_id", + "name", + "notes" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

201

Plate scanner added successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addrole.html b/documentation/_site_rebuild_20260317/addrole.html new file mode 100644 index 00000000..9b0821ce --- /dev/null +++ b/documentation/_site_rebuild_20260317/addrole.html @@ -0,0 +1,30 @@ + +Add role | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add role

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /roles

Operation

Operation ID: addRole

Add role

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addrolepermission.html b/documentation/_site_rebuild_20260317/addrolepermission.html new file mode 100644 index 00000000..2d0ab52b --- /dev/null +++ b/documentation/_site_rebuild_20260317/addrolepermission.html @@ -0,0 +1,34 @@ + +Add permission to role | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add permission to role

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /roles/permissions

Operation

Operation ID: addRolePermission

Add permission to role

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "permission": { + "type": "string" + }, + "role_id": { + "type": "integer" + } + }, + "required": [ + "role_id", + "permission" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addselfservecondition.html b/documentation/_site_rebuild_20260317/addselfservecondition.html new file mode 100644 index 00000000..8b92d25c --- /dev/null +++ b/documentation/_site_rebuild_20260317/addselfservecondition.html @@ -0,0 +1,56 @@ + +Add self-serve condition | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add self-serve condition

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/selfserve/conditions

Operation

Operation ID: addSelfserveCondition

Add a new self-serve condition. Either provide a reusable machine_type_id or a legacy department/lane/product scope.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "default": 0, + "type": "integer" + }, + "description": { + "type": "string" + }, + "lane": { + "default": 0, + "type": "integer" + }, + "machine_type_id": { + "nullable": true, + "type": "integer" + }, + "name": { + "type": "string" + }, + "product": { + "default": 0, + "type": "integer" + } + }, + "required": [ + "name", + "description" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully added condition

application/json

400

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveCondition" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addselfserveconditionrule.html b/documentation/_site_rebuild_20260317/addselfserveconditionrule.html new file mode 100644 index 00000000..908e5a61 --- /dev/null +++ b/documentation/_site_rebuild_20260317/addselfserveconditionrule.html @@ -0,0 +1,52 @@ + +Add self-serve condition rule | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add self-serve condition rule

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/selfserve/condition/rules

Operation

Operation ID: addSelfserveConditionRule

Add a new self-serve condition rule.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "condition_id": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "object_type": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "condition_id", + "type", + "object_type", + "object_id", + "name", + "description" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully added condition rule

application/json

400

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveConditionRule" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addselfservemachinetype.html b/documentation/_site_rebuild_20260317/addselfservemachinetype.html new file mode 100644 index 00000000..84c85588 --- /dev/null +++ b/documentation/_site_rebuild_20260317/addselfservemachinetype.html @@ -0,0 +1,36 @@ + +Add reusable self-serve machine type | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add reusable self-serve machine type

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/selfserve/machine-types

Operation

Operation ID: addSelfserveMachineType

Add reusable self-serve machine type

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "description": { + "nullable": true, + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully added machine type

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SelfserveMachineType" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addselfservequestion.html b/documentation/_site_rebuild_20260317/addselfservequestion.html new file mode 100644 index 00000000..bc80811a --- /dev/null +++ b/documentation/_site_rebuild_20260317/addselfservequestion.html @@ -0,0 +1,56 @@ + +Add self-serve question | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add self-serve question

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/selfserve/questions

Operation

Operation ID: addSelfserveQuestion

Add a new self-serve question. Questions are typically shared across departments and lanes by omitting department, lane, and product, which default to 0.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "default": 0, + "type": "integer" + }, + "description": { + "type": "string" + }, + "lane": { + "default": 0, + "type": "integer" + }, + "order_priority": { + "default": 0, + "type": "integer" + }, + "product": { + "default": 0, + "type": "integer" + }, + "question": { + "type": "string" + } + }, + "required": [ + "question", + "description" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully added question

application/json

400

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveQuestion" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addselfservetask.html b/documentation/_site_rebuild_20260317/addselfservetask.html new file mode 100644 index 00000000..569a6189 --- /dev/null +++ b/documentation/_site_rebuild_20260317/addselfservetask.html @@ -0,0 +1,80 @@ + +Add self-serve task | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add self-serve task

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/selfserve/tasks

Operation

Operation ID: addSelfserveTask

Add a new self-serve task. Either provide a reusable machine_type_id or a legacy department/lane/product scope.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "buttons": { + "default": [], + "description": "Optional dynamic image button IDs enabled by this task.", + "items": { + "type": "integer" + }, + "type": "array" + }, + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "default": 0, + "type": "integer" + }, + "description": { + "type": "string" + }, + "dynamic_images_vehicle_type": { + "description": "Optional vehicle type selection override for the machine UI. Integer >= 0 or null.", + "nullable": true, + "type": "integer" + }, + "lane": { + "default": 0, + "type": "integer" + }, + "machine_type_id": { + "nullable": true, + "type": "integer" + }, + "order_priority": { + "default": 0, + "type": "integer" + }, + "product": { + "default": 0, + "type": "integer" + }, + "services": { + "description": "Optional services enabled by this task. Items must be valid service enum names.", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + }, + "type": "array" + }, + "task": { + "type": "string" + } + }, + "required": [ + "task", + "description" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully added task

application/json

400

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveTask" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addselfservevehiclecondition.html b/documentation/_site_rebuild_20260317/addselfservevehiclecondition.html new file mode 100644 index 00000000..18dd6545 --- /dev/null +++ b/documentation/_site_rebuild_20260317/addselfservevehiclecondition.html @@ -0,0 +1,51 @@ + +Add vehicle condition | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add vehicle condition

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/selfserve/vehicle/conditions

Operation

Operation ID: addSelfserveVehicleCondition

Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "customer_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "question": { + "type": "integer" + }, + "reg": { + "type": "string" + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "department", + "lane", + "reg", + "question" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully added vehicle condition

application/json

400

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/addvehicle.html b/documentation/_site_rebuild_20260317/addvehicle.html new file mode 100644 index 00000000..b382d68b --- /dev/null +++ b/documentation/_site_rebuild_20260317/addvehicle.html @@ -0,0 +1,51 @@ + +Add vehicle | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Add vehicle

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /vehicles

Operation

Operation ID: addVehicle

Create a new vehicle for a customer. Permissions: - Own scope: `add_vehicle` (linked to subuser node `VEHICLES_ADD`). - Broader scope: `add_vehicle_other`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "customer_id": { + "description": "Optional explicit target customer. Defaults to the effective customer context.", + "type": "integer" + }, + "reference": { + "maxLength": 255, + "nullable": true, + "type": "string" + }, + "reg": { + "description": "Vehicle registration number", + "maxLength": 12, + "minLength": 2, + "type": "string" + }, + "type": { + "description": "Product ID representing the vehicle wash type", + "type": "integer" + }, + "wash_subscription": { + "type": "boolean" + } + }, + "required": [ + "reg", + "type", + "wash_subscription" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Vehicle created

application/json

400

403

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/admindeletebooking.html b/documentation/_site_rebuild_20260317/admindeletebooking.html new file mode 100644 index 00000000..8ae841b4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/admindeletebooking.html @@ -0,0 +1,30 @@ + +Delete booking (admin) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete booking (admin)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /admin/bookings/delete

Operation

Operation ID: adminDeleteBooking

Delete booking (admin)

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/api-object-digest.json b/documentation/_site_rebuild_20260317/api-object-digest.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/documentation/_site_rebuild_20260317/api-object-digest.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/api-overview.html b/documentation/_site_rebuild_20260317/api-overview.html new file mode 100644 index 00000000..4b7ff565 --- /dev/null +++ b/documentation/_site_rebuild_20260317/api-overview.html @@ -0,0 +1,20 @@ + +API Overview | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

API Overview

This section provides a high-level overview of how to interact with the API based on openapi.yaml.

Base URLs

The API currently defines the following servers:

+https://api.truckwash.dk +https://api.truckwash.io +http://localhost/api +

Common Headers

Header

Description

Authorization

Use Bearer authentication for protected endpoints: Authorization: Bearer <JWT>.

Accept

Use application/json.

Content-Type

Use application/json for request bodies.

X-Customer-Number

Required for many customer-scoped requests when authenticated as a subuser.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/api-reference.html b/documentation/_site_rebuild_20260317/api-reference.html new file mode 100644 index 00000000..8a6255c9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/api-reference.html @@ -0,0 +1,16 @@ + +API Reference | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/authentication.html b/documentation/_site_rebuild_20260317/authentication.html new file mode 100644 index 00000000..a5506554 --- /dev/null +++ b/documentation/_site_rebuild_20260317/authentication.html @@ -0,0 +1,20 @@ + +Authentication | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Authentication

The API uses the BearerAuth security scheme (HTTP Bearer, JWT).

Bearer JWT Header

Get a token from /auth/login or /auth/employee/login, then send:

+ Authorization: Bearer YOUR_API_TOKEN +

Subuser Customer Targeting

When authenticated as a subuser, include a target customer header for customer-scoped endpoints:

+ X-Customer-Number: 123456 +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdcreateflashcall.html b/documentation/_site_rebuild_20260317/birdcreateflashcall.html new file mode 100644 index 00000000..eb674276 --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdcreateflashcall.html @@ -0,0 +1,26 @@ + +Create/place a flash call via Bird | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create/place a flash call via Bird

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /bird/voice/flash-calls

Operation

Operation ID: birdCreateFlashCall

Create/place a flash call via Bird

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

Request Body

Required: yes.

Content type: application/json

+{ + "additionalProperties": true, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Flash call created

application/json

Schema for response 200 (application/json):

+{ + "additionalProperties": true, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdcreatevoicecall.html b/documentation/_site_rebuild_20260317/birdcreatevoicecall.html new file mode 100644 index 00000000..a51b5555 --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdcreatevoicecall.html @@ -0,0 +1,37 @@ + +Create/place a voice call via Bird | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create/place a voice call via Bird

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /bird/voice/calls

Operation

Operation ID: birdCreateVoiceCall

Create/place a voice call via Bird

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

Request Body

Required: yes.

Content type: application/json

+{ + "additionalProperties": true, + "properties": { + "from": { + "description": "E.164 phone number of the caller (sender)", + "example": "+4599988877", + "type": "string" + }, + "to": { + "description": "E.164 phone number of the callee", + "example": "+4511122233", + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Call created

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birddeletenumber.html b/documentation/_site_rebuild_20260317/birddeletenumber.html new file mode 100644 index 00000000..72fa7728 --- /dev/null +++ b/documentation/_site_rebuild_20260317/birddeletenumber.html @@ -0,0 +1,21 @@ + +Delete/release a number by ID | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete/release a number by ID

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /bird/numbers/{id}

Operation

Operation ID: birdDeleteNumber

Delete/release a number by ID

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (optional if configured)

id

path

yes

string

Responses

Status

Description

Content Types

200

Number deletion/release accepted

application/json

Schema for response 200 (application/json):

+{ + "additionalProperties": true, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdendflashcall.html b/documentation/_site_rebuild_20260317/birdendflashcall.html new file mode 100644 index 00000000..ef28b583 --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdendflashcall.html @@ -0,0 +1,26 @@ + +Complete/end a flash call by ID | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Complete/end a flash call by ID

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /bird/voice/flash-calls/{id}

Operation

Operation ID: birdEndFlashCall

Posts a completion/update payload to the flash call resource to finalize verification.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

id

path

yes

string

Request Body

Required: no.

Content type: application/json

+{ + "additionalProperties": true, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Flash call completed

application/json

Schema for response 200 (application/json):

+{ + "additionalProperties": true, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdendflashcallbynumbers.html b/documentation/_site_rebuild_20260317/birdendflashcallbynumbers.html new file mode 100644 index 00000000..7edf6e7b --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdendflashcallbynumbers.html @@ -0,0 +1,38 @@ + +Complete/end a flash call using from/to numbers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Complete/end a flash call using from/to numbers

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /bird/voice/flash-calls/end

Operation

Operation ID: birdEndFlashCallByNumbers

Ends an ongoing flash call by specifying the originating and destination numbers.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

Request Body

Required: yes.

Content type: application/json

+{ + "additionalProperties": true, + "properties": { + "from": { + "description": "E.164 formatted caller number", + "example": "+4599988877", + "type": "string" + }, + "to": { + "description": "E.164 formatted callee number", + "example": "+4511122233", + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Flash call completed (by numbers)

application/json

Schema for response 200 (application/json):

+{ + "additionalProperties": true, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdgetflashcall.html b/documentation/_site_rebuild_20260317/birdgetflashcall.html new file mode 100644 index 00000000..53ae7f62 --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdgetflashcall.html @@ -0,0 +1,21 @@ + +Get a flash call by ID | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get a flash call by ID

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /bird/voice/flash-calls/{id}

Operation

Operation ID: birdGetFlashCall

Get a flash call by ID

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

id

path

yes

string

Responses

Status

Description

Content Types

200

Flash call details

application/json

Schema for response 200 (application/json):

+{ + "additionalProperties": true, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdgetnumber.html b/documentation/_site_rebuild_20260317/birdgetnumber.html new file mode 100644 index 00000000..cdc33c81 --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdgetnumber.html @@ -0,0 +1,20 @@ + +Get a number by ID | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get a number by ID

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /bird/numbers/{id}

Operation

Operation ID: birdGetNumber

Get a number by ID

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (optional if configured)

id

path

yes

string

Responses

Status

Description

Content Types

200

Number details

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BirdNumberSingleResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdgetvoicecall.html b/documentation/_site_rebuild_20260317/birdgetvoicecall.html new file mode 100644 index 00000000..2d217598 --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdgetvoicecall.html @@ -0,0 +1,20 @@ + +Get a voice call by ID | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get a voice call by ID

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /bird/voice/calls/{id}

Operation

Operation ID: birdGetVoiceCall

Get a voice call by ID

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

id

path

yes

string

Responses

Status

Description

Content Types

200

Call details

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdhangupvoicecall.html b/documentation/_site_rebuild_20260317/birdhangupvoicecall.html new file mode 100644 index 00000000..4bf6fb5b --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdhangupvoicecall.html @@ -0,0 +1,20 @@ + +Hang up a voice call by ID | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Hang up a voice call by ID

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /bird/voice/calls/{id}/hangup

Operation

Operation ID: birdHangupVoiceCall

Hang up a voice call by ID

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

id

path

yes

string

Responses

Status

Description

Content Types

200

Hangup requested

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdlistflashcalls.html b/documentation/_site_rebuild_20260317/birdlistflashcalls.html new file mode 100644 index 00000000..a4d9750f --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdlistflashcalls.html @@ -0,0 +1,21 @@ + +List flash calls | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List flash calls

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /bird/voice/flash-calls

Operation

Operation ID: birdListFlashCalls

List flash calls

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

page

query

no

integer

Responses

Status

Description

Content Types

200

A list of flash calls

application/json

Schema for response 200 (application/json):

+{ + "additionalProperties": true, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdlistnumbers.html b/documentation/_site_rebuild_20260317/birdlistnumbers.html new file mode 100644 index 00000000..4f05c044 --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdlistnumbers.html @@ -0,0 +1,20 @@ + +List your numbers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List your numbers

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /bird/numbers

Operation

Operation ID: birdListNumbers

List your numbers

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (optional if configured)

page

query

no

integer

limit

query

no

integer

Responses

Status

Description

Content Types

200

A list of numbers

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BirdNumberListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdlistvoicecalls.html b/documentation/_site_rebuild_20260317/birdlistvoicecalls.html new file mode 100644 index 00000000..7e0bfbd9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdlistvoicecalls.html @@ -0,0 +1,20 @@ + +List voice calls | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List voice calls

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /bird/voice/calls

Operation

Operation ID: birdListVoiceCalls

List voice calls

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

page

query

no

integer

Responses

Status

Description

Content Types

200

A list of calls

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BirdVoiceCallListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdsayonvoicecall.html b/documentation/_site_rebuild_20260317/birdsayonvoicecall.html new file mode 100644 index 00000000..ff1cf1fa --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdsayonvoicecall.html @@ -0,0 +1,59 @@ + +Say a message on an active voice call and hang up afterwards | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Say a message on an active voice call and hang up afterwards

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /bird/voice/calls/{id}/say

Operation

Operation ID: birdSayOnVoiceCall

Say a message on an active voice call and hang up afterwards

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

id

path

yes

string

Call identifier

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "hangup": { + "description": "Whether to hang up the call after the message finishes playing (defaults to true)", + "example": true, + "type": "boolean" + }, + "locale": { + "description": "The locale to use for the TTS voice (e.g. en-US)", + "example": "en-US", + "type": "string" + }, + "loop": { + "description": "Number of times to loop the message", + "example": 1, + "type": "integer" + }, + "text": { + "description": "The text message to play via TTS", + "example": "The gate will open shortly.", + "type": "string" + }, + "timeout": { + "description": "Timeout in seconds for the TTS action", + "example": 1, + "type": "integer" + }, + "voice": { + "description": "The voice identifier to use", + "example": "male", + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

TTS action requested

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/birdtestoutboundvoicecall.html b/documentation/_site_rebuild_20260317/birdtestoutboundvoicecall.html new file mode 100644 index 00000000..e6d9b84a --- /dev/null +++ b/documentation/_site_rebuild_20260317/birdtestoutboundvoicecall.html @@ -0,0 +1,48 @@ + +Place a test outbound call and hang up when accepted | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Place a test outbound call and hang up when accepted

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /bird/voice/calls/test-outbound

Operation

Operation ID: birdTestOutboundVoiceCall

Calls +45 42 33 11 28 and hangs up when the call reaches accepted/ongoing state.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

workspaceId

query

no

string

Bird Workspace identifier (falls back to module configuration if omitted)

channelId

query

no

string

Bird Channel identifier (falls back to module configuration if omitted)

Request Body

Required: no.

Content type: application/json

+{ + "additionalProperties": true, + "properties": { + "from": { + "description": "Caller E.164 number to use for the test call", + "example": "+4599988877", + "type": "string" + }, + "hangupCause": { + "description": "Optional hangup cause passed through to Bird", + "type": "string" + }, + "maxPollSeconds": { + "description": "Max time to wait before timing out", + "example": 30, + "minimum": 5, + "type": "integer" + }, + "pollIntervalSeconds": { + "description": "Poll interval while waiting for accepted status", + "example": 2, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Test call created and either hung up or timed out

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BirdTestOutboundCallResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/capturestripepaymentintent.html b/documentation/_site_rebuild_20260317/capturestripepaymentintent.html new file mode 100644 index 00000000..ff7be88e --- /dev/null +++ b/documentation/_site_rebuild_20260317/capturestripepaymentintent.html @@ -0,0 +1,30 @@ + +Capture Stripe payment intent | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Capture Stripe payment intent

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /orders/module/stripe/payment_intent/capture

Operation

Operation ID: captureStripePaymentIntent

Capture Stripe payment intent

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/checkeconomiccustomerexists.html b/documentation/_site_rebuild_20260317/checkeconomiccustomerexists.html new file mode 100644 index 00000000..d8b7434c --- /dev/null +++ b/documentation/_site_rebuild_20260317/checkeconomiccustomerexists.html @@ -0,0 +1,18 @@ + +Check if customer exists in e-conomic | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Check if customer exists in e-conomic

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /economic/doesCustomerExist

Operation

Operation ID: checkEconomicCustomerExists

Check if customer exists in e-conomic

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

cvr

query

yes

string

Responses

Status

Description

Content Types

200

Customer check completed

application/json

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/clearsystemsearchcache.html b/documentation/_site_rebuild_20260317/clearsystemsearchcache.html new file mode 100644 index 00000000..586515ab --- /dev/null +++ b/documentation/_site_rebuild_20260317/clearsystemsearchcache.html @@ -0,0 +1,20 @@ + +Clear system search caches | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Clear system search caches

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /superuser/search/system/cache

Operation

Operation ID: clearSystemSearchCache

Clears both query-result cache and intent-parser cache namespaces for system-wide search.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Cache cleared successfully

application/json

401

403

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SystemSearchCacheClearResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/clonerole.html b/documentation/_site_rebuild_20260317/clonerole.html new file mode 100644 index 00000000..cc7d93bd --- /dev/null +++ b/documentation/_site_rebuild_20260317/clonerole.html @@ -0,0 +1,34 @@ + +Clone role | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Clone role

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /roles/clone

Operation

Operation ID: cloneRole

Clone role

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "name": { + "type": "string" + }, + "role_id": { + "type": "integer" + } + }, + "required": [ + "role_id", + "name" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/closedraftinvoice.html b/documentation/_site_rebuild_20260317/closedraftinvoice.html new file mode 100644 index 00000000..88edbd47 --- /dev/null +++ b/documentation/_site_rebuild_20260317/closedraftinvoice.html @@ -0,0 +1,27 @@ + +Close draft invoice | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Close draft invoice

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /invoices/draft/close

Operation

Operation ID: closeDraftInvoice

Close a draft invoice

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Draft invoice closed successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/comparecollectedinvoiceeconomic.html b/documentation/_site_rebuild_20260317/comparecollectedinvoiceeconomic.html new file mode 100644 index 00000000..b80bb03d --- /dev/null +++ b/documentation/_site_rebuild_20260317/comparecollectedinvoiceeconomic.html @@ -0,0 +1,20 @@ + +Compare collected invoice totals with E-conomic | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Compare collected invoice totals with E-conomic

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /collected-invoices/economic/compare

Operation

Operation ID: compareCollectedInvoiceEconomic

Compares a collected invoice in the system with its corresponding invoice in E-conomic. Returns totals from both sources, their difference, and any warnings detected during comparison.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

collected_invoice_id

query

yes

integer

The internal collected invoice ID to compare

Responses

Status

Description

Content Types

200

Comparison completed successfully

application/json

400

401

403

404

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicCompareResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/comparecollectedinvoiceeconomicv2.html b/documentation/_site_rebuild_20260317/comparecollectedinvoiceeconomicv2.html new file mode 100644 index 00000000..36d4052b --- /dev/null +++ b/documentation/_site_rebuild_20260317/comparecollectedinvoiceeconomicv2.html @@ -0,0 +1,20 @@ + +Compare internal invoice with draft/booked (V2) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Compare internal invoice with draft/booked (V2)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /collected-invoices/economic/v2/compare

Operation

Operation ID: compareCollectedInvoiceEconomicV2

Compare internal invoice with draft/booked (V2)

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

collected_invoice_id

query

yes

integer

Responses

Status

Description

Content Types

200

Comparison completed

application/json

400

401

403

404

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2CompareResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/comparecollectedinvoiceeconomicv2bulk.html b/documentation/_site_rebuild_20260317/comparecollectedinvoiceeconomicv2bulk.html new file mode 100644 index 00000000..1ddcb683 --- /dev/null +++ b/documentation/_site_rebuild_20260317/comparecollectedinvoiceeconomicv2bulk.html @@ -0,0 +1,38 @@ + +Bulk compare collected invoices against draft/booked (V2) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Bulk compare collected invoices against draft/booked (V2)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /collected-invoices/economic/v2/compare/bulk

Operation

Operation ID: compareCollectedInvoiceEconomicV2Bulk

Bulk compare collected invoices against draft/booked (V2)

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "collected_invoice_ids": { + "items": { + "minimum": 1, + "type": "integer" + }, + "maxItems": 200, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "collected_invoice_ids" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Bulk comparison completed

application/json

400

401

403

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2CompareBulkResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/completeorderbooking.html b/documentation/_site_rebuild_20260317/completeorderbooking.html new file mode 100644 index 00000000..a70e98f6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/completeorderbooking.html @@ -0,0 +1,33 @@ + +Complete order booking | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Complete order booking

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /order-bookings/complete

Operation

Operation ID: completeOrderBooking

Complete order booking

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + }, + "safety_seal": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/completesubusersetup.html b/documentation/_site_rebuild_20260317/completesubusersetup.html new file mode 100644 index 00000000..f4e96ac0 --- /dev/null +++ b/documentation/_site_rebuild_20260317/completesubusersetup.html @@ -0,0 +1,64 @@ + +Complete subuser setup | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Complete subuser setup

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /subusers/setup

Operation

Operation ID: completeSubuserSetup

Completes subuser setup by setting a password and basic profile fields. Accepts optional `username` and `email`.

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "email": { + "format": "email", + "maxLength": 255, + "minLength": 3, + "type": "string" + }, + "name": { + "maxLength": 255, + "minLength": 3, + "type": "string" + }, + "password": { + "description": "Must include at least one uppercase letter, one lowercase letter, and one number", + "format": "password", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\\\d).+$", + "type": "string" + }, + "token": { + "description": "One-time setup token", + "type": "string" + }, + "username": { + "maxLength": 255, + "minLength": 3, + "type": "string" + } + }, + "required": [ + "token", + "password", + "name" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Setup completed

application/json

400

500

Schema for response 200 (application/json):

+{ + "properties": { + "message": { + "example": "Password set successfully", + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/completewashwithoutwashcertificate.html b/documentation/_site_rebuild_20260317/completewashwithoutwashcertificate.html new file mode 100644 index 00000000..d26b6152 --- /dev/null +++ b/documentation/_site_rebuild_20260317/completewashwithoutwashcertificate.html @@ -0,0 +1,30 @@ + +Complete wash without wash certificate | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Complete wash without wash certificate

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /admin/bookings/completeWashWithoutWashCertificate

Operation

Operation ID: completeWashWithoutWashCertificate

Complete wash without wash certificate

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-backups-page-1.html b/documentation/_site_rebuild_20260317/config-module-backups-page-1.html new file mode 100644 index 00000000..e6d3735f --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-backups-page-1.html @@ -0,0 +1,16 @@ + +Backups - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-backups.html b/documentation/_site_rebuild_20260317/config-module-backups.html new file mode 100644 index 00000000..f9001265 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-backups.html @@ -0,0 +1,16 @@ + +Backups | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-bird-page-1.html b/documentation/_site_rebuild_20260317/config-module-bird-page-1.html new file mode 100644 index 00000000..8caa9823 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-bird-page-1.html @@ -0,0 +1,16 @@ + +Bird - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Bird - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-bird.html b/documentation/_site_rebuild_20260317/config-module-bird.html new file mode 100644 index 00000000..481d5c8f --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-bird.html @@ -0,0 +1,16 @@ + +Bird | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-e-conomic-page-1.html b/documentation/_site_rebuild_20260317/config-module-e-conomic-page-1.html new file mode 100644 index 00000000..7bb2a1b9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-e-conomic-page-1.html @@ -0,0 +1,16 @@ + +e-conomic - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-e-conomic.html b/documentation/_site_rebuild_20260317/config-module-e-conomic.html new file mode 100644 index 00000000..9feedfcf --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-e-conomic.html @@ -0,0 +1,16 @@ + +e-conomic | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

e-conomic

e-conomic accounting integration configuration.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-email-page-1.html b/documentation/_site_rebuild_20260317/config-module-email-page-1.html new file mode 100644 index 00000000..5a9b0fc8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-email-page-1.html @@ -0,0 +1,16 @@ + +Email - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-email.html b/documentation/_site_rebuild_20260317/config-module-email.html new file mode 100644 index 00000000..cc8625f8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-email.html @@ -0,0 +1,16 @@ + +Email | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-entra-page-1.html b/documentation/_site_rebuild_20260317/config-module-entra-page-1.html new file mode 100644 index 00000000..eecbd3f8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-entra-page-1.html @@ -0,0 +1,16 @@ + +Entra - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-entra.html b/documentation/_site_rebuild_20260317/config-module-entra.html new file mode 100644 index 00000000..356c28f3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-entra.html @@ -0,0 +1,16 @@ + +Entra | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-fxratesapi-page-1.html b/documentation/_site_rebuild_20260317/config-module-fxratesapi-page-1.html new file mode 100644 index 00000000..38d1742b --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-fxratesapi-page-1.html @@ -0,0 +1,16 @@ + +FXRatesAPI - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-fxratesapi.html b/documentation/_site_rebuild_20260317/config-module-fxratesapi.html new file mode 100644 index 00000000..14670655 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-fxratesapi.html @@ -0,0 +1,16 @@ + +FXRatesAPI | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-gatewayapi-page-1.html b/documentation/_site_rebuild_20260317/config-module-gatewayapi-page-1.html new file mode 100644 index 00000000..f48498b8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-gatewayapi-page-1.html @@ -0,0 +1,16 @@ + +GatewayAPI - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-gatewayapi.html b/documentation/_site_rebuild_20260317/config-module-gatewayapi.html new file mode 100644 index 00000000..c6d40f45 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-gatewayapi.html @@ -0,0 +1,16 @@ + +GatewayAPI | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-licenseplaterecognizer-page-1.html b/documentation/_site_rebuild_20260317/config-module-licenseplaterecognizer-page-1.html new file mode 100644 index 00000000..511ab0e8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-licenseplaterecognizer-page-1.html @@ -0,0 +1,16 @@ + +LicensePlateRecognizer - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

LicensePlateRecognizer - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-licenseplaterecognizer.html b/documentation/_site_rebuild_20260317/config-module-licenseplaterecognizer.html new file mode 100644 index 00000000..15d76da6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-licenseplaterecognizer.html @@ -0,0 +1,16 @@ + +LicensePlateRecognizer | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-limble-page-1.html b/documentation/_site_rebuild_20260317/config-module-limble-page-1.html new file mode 100644 index 00000000..82cb159f --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-limble-page-1.html @@ -0,0 +1,16 @@ + +Limble - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Limble - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-limble.html b/documentation/_site_rebuild_20260317/config-module-limble.html new file mode 100644 index 00000000..8f513878 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-limble.html @@ -0,0 +1,16 @@ + +Limble | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-motorapi-page-1.html b/documentation/_site_rebuild_20260317/config-module-motorapi-page-1.html new file mode 100644 index 00000000..1e617d65 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-motorapi-page-1.html @@ -0,0 +1,16 @@ + +MotorAPI - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-motorapi.html b/documentation/_site_rebuild_20260317/config-module-motorapi.html new file mode 100644 index 00000000..1535ee30 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-motorapi.html @@ -0,0 +1,16 @@ + +MotorAPI | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-ocrspace-page-1.html b/documentation/_site_rebuild_20260317/config-module-ocrspace-page-1.html new file mode 100644 index 00000000..ce03d44b --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-ocrspace-page-1.html @@ -0,0 +1,16 @@ + +OcrSpace - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-ocrspace.html b/documentation/_site_rebuild_20260317/config-module-ocrspace.html new file mode 100644 index 00000000..31649da9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-ocrspace.html @@ -0,0 +1,16 @@ + +OcrSpace | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-openai-page-1.html b/documentation/_site_rebuild_20260317/config-module-openai-page-1.html new file mode 100644 index 00000000..afb7e66a --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-openai-page-1.html @@ -0,0 +1,16 @@ + +OpenAI - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

OpenAI - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-openai.html b/documentation/_site_rebuild_20260317/config-module-openai.html new file mode 100644 index 00000000..a4512c9e --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-openai.html @@ -0,0 +1,16 @@ + +OpenAI | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-recaptcha-page-1.html b/documentation/_site_rebuild_20260317/config-module-recaptcha-page-1.html new file mode 100644 index 00000000..2048bff8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-recaptcha-page-1.html @@ -0,0 +1,16 @@ + +reCAPTCHA - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-recaptcha.html b/documentation/_site_rebuild_20260317/config-module-recaptcha.html new file mode 100644 index 00000000..3f2b91a5 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-recaptcha.html @@ -0,0 +1,16 @@ + +reCAPTCHA | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-self-serve-page-1.html b/documentation/_site_rebuild_20260317/config-module-self-serve-page-1.html new file mode 100644 index 00000000..e28631fb --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-self-serve-page-1.html @@ -0,0 +1,16 @@ + +Self-Serve - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-self-serve.html b/documentation/_site_rebuild_20260317/config-module-self-serve.html new file mode 100644 index 00000000..ac2596fb --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-self-serve.html @@ -0,0 +1,16 @@ + +Self-Serve | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-shelly-page-1.html b/documentation/_site_rebuild_20260317/config-module-shelly-page-1.html new file mode 100644 index 00000000..c90de826 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-shelly-page-1.html @@ -0,0 +1,16 @@ + +Shelly - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Shelly - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-shelly.html b/documentation/_site_rebuild_20260317/config-module-shelly.html new file mode 100644 index 00000000..17fe79d8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-shelly.html @@ -0,0 +1,16 @@ + +Shelly | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-stripe-page-1.html b/documentation/_site_rebuild_20260317/config-module-stripe-page-1.html new file mode 100644 index 00000000..211f7390 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-stripe-page-1.html @@ -0,0 +1,16 @@ + +Stripe - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-stripe.html b/documentation/_site_rebuild_20260317/config-module-stripe.html new file mode 100644 index 00000000..e0ebefdb --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-stripe.html @@ -0,0 +1,16 @@ + +Stripe | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-virkdata-page-1.html b/documentation/_site_rebuild_20260317/config-module-virkdata-page-1.html new file mode 100644 index 00000000..529905dc --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-virkdata-page-1.html @@ -0,0 +1,16 @@ + +VirkData - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-virkdata.html b/documentation/_site_rebuild_20260317/config-module-virkdata.html new file mode 100644 index 00000000..e241e8cf --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-virkdata.html @@ -0,0 +1,16 @@ + +VirkData | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-weatherapi-page-1.html b/documentation/_site_rebuild_20260317/config-module-weatherapi-page-1.html new file mode 100644 index 00000000..14e832f2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-weatherapi-page-1.html @@ -0,0 +1,16 @@ + +WeatherAPI - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-weatherapi.html b/documentation/_site_rebuild_20260317/config-module-weatherapi.html new file mode 100644 index 00000000..ba31f1e8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-weatherapi.html @@ -0,0 +1,16 @@ + +WeatherAPI | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-xlvask-page-1.html b/documentation/_site_rebuild_20260317/config-module-xlvask-page-1.html new file mode 100644 index 00000000..5ea2554c --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-xlvask-page-1.html @@ -0,0 +1,16 @@ + +XLVask - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config-module-xlvask.html b/documentation/_site_rebuild_20260317/config-module-xlvask.html new file mode 100644 index 00000000..d5f90abf --- /dev/null +++ b/documentation/_site_rebuild_20260317/config-module-xlvask.html @@ -0,0 +1,16 @@ + +XLVask | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/config.json b/documentation/_site_rebuild_20260317/config.json new file mode 100644 index 00000000..e48c6aa7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/config.json @@ -0,0 +1 @@ +{"primary-color":"#307FFF","productWebUrl":"./introduction.html","productId":"ctw","keymaps":{},"productName":"Copenhagen Truck Wash API","productVersion":"","stage":"release","downloadTitle":"Get Copenhagen Truck Wash API","searchMaxHits":75,"color-preset":"contrast"} \ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createbackupmodule.html b/documentation/_site_rebuild_20260317/createbackupmodule.html new file mode 100644 index 00000000..faaed287 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createbackupmodule.html @@ -0,0 +1,34 @@ + +Create backup module | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create backup module

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /modules/backup/backups

Operation

Operation ID: createBackupModule

Create backup module

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createcategory.html b/documentation/_site_rebuild_20260317/createcategory.html new file mode 100644 index 00000000..07054b95 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createcategory.html @@ -0,0 +1,22 @@ + +Create category | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create category

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /categories

Operation

Operation ID: createCategory

Create a new product category

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/CategoryCreate" +} +

Responses

Status

Description

Content Types

201

Category created successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createcollectedinvoice.html b/documentation/_site_rebuild_20260317/createcollectedinvoice.html new file mode 100644 index 00000000..e933b394 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createcollectedinvoice.html @@ -0,0 +1,20 @@ + +Create collected invoice | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create collected invoice

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /collected-invoices

Operation

Operation ID: createCollectedInvoice

Create a new collected invoice

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

201

Collected invoice created successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createdepartment.html b/documentation/_site_rebuild_20260317/createdepartment.html new file mode 100644 index 00000000..81e54c10 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createdepartment.html @@ -0,0 +1,22 @@ + +Create department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /departments

Operation

Operation ID: createDepartment

Create a new department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentCreate" +} +

Responses

Status

Description

Content Types

201

Department created successfully

application/json

400

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createdepartmentgate.html b/documentation/_site_rebuild_20260317/createdepartmentgate.html new file mode 100644 index 00000000..6f27655c --- /dev/null +++ b/documentation/_site_rebuild_20260317/createdepartmentgate.html @@ -0,0 +1,24 @@ + +Create department gate | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create department gate

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/gates

Operation

Operation ID: createDepartmentGate

Create department gate

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentGateCreate" +} +

Responses

Status

Description

Content Types

201

Department gate created successfully

application/json

Schema for response 201 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentGate" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createdepartmentgoal.html b/documentation/_site_rebuild_20260317/createdepartmentgoal.html new file mode 100644 index 00000000..524b8e34 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createdepartmentgoal.html @@ -0,0 +1,24 @@ + +Create department goal | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create department goal

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /goals/department

Operation

Operation ID: createDepartmentGoal

Create a new department goal. Access control: - The provided `departments` must be a subset of the user&#x27;s departments unless the user has `superuser`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentGoalCreate" +} +

Responses

Status

Description

Content Types

201

Department goal created successfully

application/json

400

401

403

Schema for response 201 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentGoal" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createdepartmentlane.html b/documentation/_site_rebuild_20260317/createdepartmentlane.html new file mode 100644 index 00000000..eee112fe --- /dev/null +++ b/documentation/_site_rebuild_20260317/createdepartmentlane.html @@ -0,0 +1,22 @@ + +Create department lane | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create department lane

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/lanes

Operation

Operation ID: createDepartmentLane

Create a new department lane

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentLaneCreate" +} +

Responses

Status

Description

Content Types

201

Department lane created successfully

application/json

400

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createdepartmentrelay.html b/documentation/_site_rebuild_20260317/createdepartmentrelay.html new file mode 100644 index 00000000..42373cb9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createdepartmentrelay.html @@ -0,0 +1,24 @@ + +Create department relay | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create department relay

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/relays

Operation

Operation ID: createDepartmentRelay

Create department relay

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentRelayCreate" +} +

Responses

Status

Description

Content Types

201

Department relay created successfully

application/json

Schema for response 201 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentRelay" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createeconomiccustomer.html b/documentation/_site_rebuild_20260317/createeconomiccustomer.html new file mode 100644 index 00000000..815b9def --- /dev/null +++ b/documentation/_site_rebuild_20260317/createeconomiccustomer.html @@ -0,0 +1,46 @@ + +Create e-conomic customer | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create e-conomic customer

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /modules/economic/customer

Operation

Operation ID: createEconomicCustomer

Create e-conomic customer

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "customer_number": { + "type": "integer" + }, + "cvr": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "phone": { + "type": "integer" + } + }, + "required": [ + "customer_number", + "cvr", + "email", + "phone", + "name" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createnotification.html b/documentation/_site_rebuild_20260317/createnotification.html new file mode 100644 index 00000000..50befa79 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createnotification.html @@ -0,0 +1,22 @@ + +Create notification | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create notification

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /notifications

Operation

Operation ID: createNotification

Create a new notification

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/NotificationCreate" +} +

Responses

Status

Description

Content Types

201

Notification created successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createorder.html b/documentation/_site_rebuild_20260317/createorder.html new file mode 100644 index 00000000..ba495ecb --- /dev/null +++ b/documentation/_site_rebuild_20260317/createorder.html @@ -0,0 +1,24 @@ + +Create new order | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create new order

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /orders

Operation

Operation ID: createOrder

Create a new wash order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/OrderCreate" +} +

Responses

Status

Description

Content Types

201

Order created successfully

application/json

400

401

Schema for response 201 (application/json):

+{ + "$ref": "#/components/schemas/Order" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createpasskey.html b/documentation/_site_rebuild_20260317/createpasskey.html new file mode 100644 index 00000000..019cc7f4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createpasskey.html @@ -0,0 +1,33 @@ + +Create/add a passkey for the authenticated user | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create/add a passkey for the authenticated user

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /account/security/passkeys

Operation

Operation ID: createPasskey

Create/add a passkey for the authenticated user

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/PasskeyCreateRequest" +} +

Responses

Status

Description

Content Types

200

Passkey created

application/json

400

Invalid session or request

application/json

Schema for response 200 (application/json):

+{ + "properties": { + "id": { + "type": "integer" + } + }, + "type": "object" +} +

Schema for response 400 (application/json):

+{ + "$ref": "#/components/schemas/Error" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createproduct.html b/documentation/_site_rebuild_20260317/createproduct.html new file mode 100644 index 00000000..415260ab --- /dev/null +++ b/documentation/_site_rebuild_20260317/createproduct.html @@ -0,0 +1,22 @@ + +Create product | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create product

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /products

Operation

Operation ID: createProduct

Create a new product

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/ProductCreate" +} +

Responses

Status

Description

Content Types

201

Product created successfully

application/json

400

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createstripeinvoice.html b/documentation/_site_rebuild_20260317/createstripeinvoice.html new file mode 100644 index 00000000..71ecbf79 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createstripeinvoice.html @@ -0,0 +1,20 @@ + +Create Stripe invoice | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create Stripe invoice

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /modules/stripe/invoice

Operation

Operation ID: createStripeInvoice

Create an invoice in Stripe

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

201

Stripe invoice created successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createstripepaymentintent.html b/documentation/_site_rebuild_20260317/createstripepaymentintent.html new file mode 100644 index 00000000..0fa4c9fa --- /dev/null +++ b/documentation/_site_rebuild_20260317/createstripepaymentintent.html @@ -0,0 +1,37 @@ + +Create Stripe payment intent | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create Stripe payment intent

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /orders/module/stripe/payment_intent

Operation

Operation ID: createStripePaymentIntent

Create Stripe payment intent

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + }, + "reader": { + "type": "string" + }, + "tax_percentage": { + "type": "integer" + } + }, + "required": [ + "id", + "reader" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createsubuser.html b/documentation/_site_rebuild_20260317/createsubuser.html new file mode 100644 index 00000000..4906ead7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createsubuser.html @@ -0,0 +1,57 @@ + +Create a subuser registration | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create a subuser registration

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /subusers/me

Operation

Operation ID: createSubuser

Creates a subuser (driver) account using a company&#x27;s CVR and a phone number. Validates the CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled sends a setup link by SMS for the user to complete registration.

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "cvr": { + "description": "Danish CVR (8 digits)", + "example": 12345678, + "type": "integer" + }, + "phone": { + "description": "Phone number (4–15 digits, no leading +)", + "example": 12345678, + "type": "integer" + }, + "phone_country_code": { + "description": "Phone country code (1–3 digits)", + "example": 45, + "type": "integer" + } + }, + "required": [ + "cvr", + "phone_country_code", + "phone" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Subuser created (or pending setup) and company identified

application/json

400

404

500

Schema for response 200 (application/json):

+{ + "properties": { + "customer_number": { + "description": "Matched e-conomic customer number", + "example": 1000, + "type": "integer" + }, + "cvr": { + "example": 12345678, + "type": "integer" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createsubusergrant.html b/documentation/_site_rebuild_20260317/createsubusergrant.html new file mode 100644 index 00000000..ffb893a1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createsubusergrant.html @@ -0,0 +1,29 @@ + +Create subuser grant | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create subuser grant

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /subusers/grants

Operation

Operation ID: createSubuserGrant

Create subuser grant

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/SubuserGrantCreateRequest" +} +

Responses

Status

Description

Content Types

200

Grant created

application/json

400

401

500

Schema for response 200 (application/json):

+{ + "properties": { + "grant": { + "$ref": "#/components/schemas/SubuserGrant" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/createuser.html b/documentation/_site_rebuild_20260317/createuser.html new file mode 100644 index 00000000..4cdc1f22 --- /dev/null +++ b/documentation/_site_rebuild_20260317/createuser.html @@ -0,0 +1,24 @@ + +Create new user | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Create new user

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /users

Operation

Operation ID: createUser

Create a new user account

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/UserCreate" +} +

Responses

Status

Description

Content Types

201

User created successfully

application/json

400

401

Schema for response 201 (application/json):

+{ + "$ref": "#/components/schemas/User" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/current.help.version b/documentation/_site_rebuild_20260317/current.help.version new file mode 100644 index 00000000..e69de29b diff --git a/documentation/_site_rebuild_20260317/customerlogin.html b/documentation/_site_rebuild_20260317/customerlogin.html new file mode 100644 index 00000000..cf285b51 --- /dev/null +++ b/documentation/_site_rebuild_20260317/customerlogin.html @@ -0,0 +1,78 @@ + +Customer login | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Customer login

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/login

Operation

Operation ID: customerLogin

Authenticate a customer using customer number and password

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "customer_number": { + "description": "Customer's e-conomic customer number", + "example": 12345, + "type": "integer" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + }, + "password": { + "description": "Customer password", + "format": "password", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "customer_number", + "password", + "g_recaptcha_response" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Login successful

application/json

400

401

Schema for response 200 (application/json):

+{ + "oneOf": [ + { + "properties": { + "token": { + "description": "Bearer authentication token", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + { + "properties": { + "2fa_required": { + "example": true, + "type": "boolean" + }, + "2fa_token": { + "description": "Temporary 2FA verification token", + "example": "557a3e7b1a2b...", + "type": "string" + } + }, + "required": [ + "2fa_required", + "2fa_token" + ], + "type": "object" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/debugworker.html b/documentation/_site_rebuild_20260317/debugworker.html new file mode 100644 index 00000000..964920e6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/debugworker.html @@ -0,0 +1,18 @@ + +Debug worker | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Debug worker

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /worker/debug

Operation

Operation ID: debugWorker

Execute debug commands on the worker (often restricted)

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Debug information retrieved successfully

application/json

403

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletecustomerattribute.html b/documentation/_site_rebuild_20260317/deletecustomerattribute.html new file mode 100644 index 00000000..9becd466 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletecustomerattribute.html @@ -0,0 +1,20 @@ + +Delete customer attribute | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete customer attribute

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /customer/attributes

Operation

Operation ID: deleteCustomerAttribute

Remove a custom attribute from a customer

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Customer attribute deleted successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletecustomerdefaultdepartment.html b/documentation/_site_rebuild_20260317/deletecustomerdefaultdepartment.html new file mode 100644 index 00000000..d7337468 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletecustomerdefaultdepartment.html @@ -0,0 +1,18 @@ + +Delete customer default department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete customer default department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /customer/department/default

Operation

Operation ID: deleteCustomerDefaultDepartment

Delete customer default department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

no

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletecustomerfixedpricing.html b/documentation/_site_rebuild_20260317/deletecustomerfixedpricing.html new file mode 100644 index 00000000..34558229 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletecustomerfixedpricing.html @@ -0,0 +1,18 @@ + +Delete customer fixed pricing | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete customer fixed pricing

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /customer/pricing/fixed

Operation

Operation ID: deleteCustomerFixedPricing

Delete customer fixed pricing

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletecustomernote.html b/documentation/_site_rebuild_20260317/deletecustomernote.html new file mode 100644 index 00000000..961acd73 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletecustomernote.html @@ -0,0 +1,20 @@ + +Delete customer note | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete customer note

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /customer/notes

Operation

Operation ID: deleteCustomerNote

Remove a note from a customer

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Customer note deleted successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletedepartmentgate.html b/documentation/_site_rebuild_20260317/deletedepartmentgate.html new file mode 100644 index 00000000..2a0bc6c4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletedepartmentgate.html @@ -0,0 +1,18 @@ + +Delete department gate | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete department gate

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /department/gates

Operation

Operation ID: deleteDepartmentGate

Delete department gate

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Department gate deleted

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletedepartmentgoal.html b/documentation/_site_rebuild_20260317/deletedepartmentgoal.html new file mode 100644 index 00000000..0de36a5c --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletedepartmentgoal.html @@ -0,0 +1,18 @@ + +Delete department goal | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete department goal

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /goals/department/progress-alert/test

Operation

Operation ID: deleteDepartmentGoal

Delete a department goal by `id`. Access control: - The creator (`created_by`) may delete regardless of department membership. - Otherwise the user must satisfy the subset rule or have `superuser`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

ID of the goal to delete

Responses

Status

Description

Content Types

200

Department goal deleted successfully

application/json

400

401

403

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletedepartmentrelay.html b/documentation/_site_rebuild_20260317/deletedepartmentrelay.html new file mode 100644 index 00000000..a15d51ad --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletedepartmentrelay.html @@ -0,0 +1,18 @@ + +Delete department relay | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete department relay

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /department/relays

Operation

Operation ID: deleteDepartmentRelay

Delete department relay

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Department relay deleted

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletenotification.html b/documentation/_site_rebuild_20260317/deletenotification.html new file mode 100644 index 00000000..c13ba4f8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletenotification.html @@ -0,0 +1,18 @@ + +Delete notification | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete notification

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /notifications

Operation

Operation ID: deleteNotification

Delete a notification

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Notification deleted successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteorder.html b/documentation/_site_rebuild_20260317/deleteorder.html new file mode 100644 index 00000000..8a6ea01c --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteorder.html @@ -0,0 +1,18 @@ + +Delete order | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete order

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /orders

Operation

Operation ID: deleteOrder

Delete an existing order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Order deleted successfully

application/json

401

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteorderitem.html b/documentation/_site_rebuild_20260317/deleteorderitem.html new file mode 100644 index 00000000..518894f4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteorderitem.html @@ -0,0 +1,18 @@ + +Delete order item | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete order item

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /order/items

Operation

Operation ID: deleteOrderItem

Remove an item from an order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Order item deleted successfully

application/json

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteownbooking.html b/documentation/_site_rebuild_20260317/deleteownbooking.html new file mode 100644 index 00000000..da4002e8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteownbooking.html @@ -0,0 +1,30 @@ + +Delete own booking | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete own booking

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /user/bookings/delete

Operation

Operation ID: deleteOwnBooking

Delete own booking

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletepasskey.html b/documentation/_site_rebuild_20260317/deletepasskey.html new file mode 100644 index 00000000..2f82b552 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletepasskey.html @@ -0,0 +1,22 @@ + +Delete a passkey | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete a passkey

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /account/security/passkeys/{id}

Operation

Operation ID: deletePasskey

Delete a passkey

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

path

yes

integer

Responses

Status

Description

Content Types

200

Passkey deleted

application/json

404

Not found

application/json

Schema for response 200 (application/json):

+{} +

Schema for response 404 (application/json):

+{ + "$ref": "#/components/schemas/Error" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteselfservecondition.html b/documentation/_site_rebuild_20260317/deleteselfservecondition.html new file mode 100644 index 00000000..6fcd27c1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteselfservecondition.html @@ -0,0 +1,21 @@ + +Delete self-serve condition | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete self-serve condition

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /department/selfserve/conditions

Operation

Operation ID: deleteSelfserveCondition

Delete a self-serve condition.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Condition ID

Responses

Status

Description

Content Types

200

Successfully deleted condition

application/json

400

404

Schema for response 200 (application/json):

+{ + "example": "Condition deleted", + "type": "string" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteselfserveconditionrule.html b/documentation/_site_rebuild_20260317/deleteselfserveconditionrule.html new file mode 100644 index 00000000..cb2cfa86 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteselfserveconditionrule.html @@ -0,0 +1,21 @@ + +Delete self-serve condition rule | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete self-serve condition rule

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /department/selfserve/condition/rules

Operation

Operation ID: deleteSelfserveConditionRule

Delete a self-serve condition rule.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Rule ID

Responses

Status

Description

Content Types

200

Successfully deleted condition rule

application/json

400

404

Schema for response 200 (application/json):

+{ + "example": "Rule deleted", + "type": "string" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteselfservemachinetype.html b/documentation/_site_rebuild_20260317/deleteselfservemachinetype.html new file mode 100644 index 00000000..fdf2704b --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteselfservemachinetype.html @@ -0,0 +1,21 @@ + +Delete reusable self-serve machine type | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete reusable self-serve machine type

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /department/selfserve/machine-types

Operation

Operation ID: deleteSelfserveMachineType

Delete reusable self-serve machine type

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Successfully deleted machine type

application/json

404

Schema for response 200 (application/json):

+{ + "example": "Machine type deleted", + "type": "string" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteselfservequestion.html b/documentation/_site_rebuild_20260317/deleteselfservequestion.html new file mode 100644 index 00000000..98477d31 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteselfservequestion.html @@ -0,0 +1,21 @@ + +Delete self-serve question | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete self-serve question

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /department/selfserve/questions

Operation

Operation ID: deleteSelfserveQuestion

Delete a self-serve question by ID.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Question ID

Responses

Status

Description

Content Types

200

Successfully deleted question

application/json

400

404

Schema for response 200 (application/json):

+{ + "example": "Question deleted", + "type": "string" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteselfservetask.html b/documentation/_site_rebuild_20260317/deleteselfservetask.html new file mode 100644 index 00000000..4ef7a450 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteselfservetask.html @@ -0,0 +1,21 @@ + +Delete self-serve task | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete self-serve task

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /department/selfserve/tasks

Operation

Operation ID: deleteSelfserveTask

Delete a self-serve task by ID.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Task ID

Responses

Status

Description

Content Types

200

Successfully deleted task

application/json

400

404

Schema for response 200 (application/json):

+{ + "example": "Task deleted", + "type": "string" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteselfservetaskattachment.html b/documentation/_site_rebuild_20260317/deleteselfservetaskattachment.html new file mode 100644 index 00000000..04b76374 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteselfservetaskattachment.html @@ -0,0 +1,26 @@ + +Delete task attachment | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete task attachment

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /department/selfserve/tasks/attachments

Operation

Operation ID: deleteSelfserveTaskAttachment

Remove an attachment from a specific self-serve task.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

task_id

query

yes

integer

Task ID

attachment_id

query

yes

integer

Attachment ID

Responses

Status

Description

Content Types

200

Attachment deleted successfully

application/json

400

404

Schema for response 200 (application/json):

+{ + "properties": { + "message": { + "example": "Attachment deleted successfully", + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deleteselfservevehiclecondition.html b/documentation/_site_rebuild_20260317/deleteselfservevehiclecondition.html new file mode 100644 index 00000000..b2f4881f --- /dev/null +++ b/documentation/_site_rebuild_20260317/deleteselfservevehiclecondition.html @@ -0,0 +1,34 @@ + +Delete vehicle condition | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete vehicle condition

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /department/selfserve/vehicle/conditions

Operation

Operation ID: deleteSelfserveVehicleCondition

Delete a vehicle condition. Customers can only delete conditions for their own vehicles.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Condition ID

Responses

Status

Description

Content Types

200

Successfully deleted vehicle condition

application/json

400

404

Schema for response 200 (application/json):

+{ + "properties": { + "message": { + "example": "Condition deleted", + "type": "string" + }, + "selfserve": { + "allOf": [ + { + "$ref": "#/components/schemas/SelfserveWashSummary" + } + ], + "nullable": true + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletestripepaymentintent.html b/documentation/_site_rebuild_20260317/deletestripepaymentintent.html new file mode 100644 index 00000000..42b53915 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletestripepaymentintent.html @@ -0,0 +1,18 @@ + +Delete Stripe payment intent | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete Stripe payment intent

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /orders/module/stripe/payment_intent

Operation

Operation ID: deleteStripePaymentIntent

Delete Stripe payment intent

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletesubusergrant.html b/documentation/_site_rebuild_20260317/deletesubusergrant.html new file mode 100644 index 00000000..8760a905 --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletesubusergrant.html @@ -0,0 +1,26 @@ + +Delete subuser grant | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete subuser grant

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /subusers/grants/{id}

Operation

Operation ID: deleteSubuserGrant

Delete subuser grant

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

path

yes

integer

Responses

Status

Description

Content Types

200

Grant deleted

application/json

401

404

500

Schema for response 200 (application/json):

+{ + "properties": { + "message": { + "example": "Grant deleted", + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/deletevehicle.html b/documentation/_site_rebuild_20260317/deletevehicle.html new file mode 100644 index 00000000..7219078d --- /dev/null +++ b/documentation/_site_rebuild_20260317/deletevehicle.html @@ -0,0 +1,18 @@ + +Delete vehicle | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Delete vehicle

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /vehicles

Operation

Operation ID: deleteVehicle

Delete an existing vehicle. Permissions: - Own scope: `delete_vehicle` (linked to subuser node `VEHICLES_DELETE`). - Broader scope: `delete_vehicle_other`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

id

query

yes

integer

Responses

Status

Description

Content Types

200

Vehicle deleted

application/json

403

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/disable2fa.html b/documentation/_site_rebuild_20260317/disable2fa.html new file mode 100644 index 00000000..91fc6a5f --- /dev/null +++ b/documentation/_site_rebuild_20260317/disable2fa.html @@ -0,0 +1,31 @@ + +Disable 2FA | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Disable 2FA

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/2fa/disable

Operation

Operation ID: disable2fa

Verify a code and disable 2FA for the authenticated user/subuser

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "code": { + "description": "The 6-digit TOTP code", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

2FA disabled successfully

application/json

400

401

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/disableworkerdebug.html b/documentation/_site_rebuild_20260317/disableworkerdebug.html new file mode 100644 index 00000000..a0d4df07 --- /dev/null +++ b/documentation/_site_rebuild_20260317/disableworkerdebug.html @@ -0,0 +1,18 @@ + +Disable worker debug | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Disable worker debug

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /worker/debug/off

Operation

Operation ID: disableWorkerDebug

Disable worker debug

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Worker debug disabled

application/json

403

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/downloadbookingpdf.html b/documentation/_site_rebuild_20260317/downloadbookingpdf.html new file mode 100644 index 00000000..08b603c2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/downloadbookingpdf.html @@ -0,0 +1,18 @@ + +Download booking PDF | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Download booking PDF

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /bookings/download_pdf

Operation

Operation ID: downloadBookingPdf

Download booking PDF

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/downloadorderattachment.html b/documentation/_site_rebuild_20260317/downloadorderattachment.html new file mode 100644 index 00000000..6338456c --- /dev/null +++ b/documentation/_site_rebuild_20260317/downloadorderattachment.html @@ -0,0 +1,21 @@ + +Download order attachment | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Download order attachment

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /orders/attachments/download

Operation

Operation ID: downloadOrderAttachment

Download a specific order attachment

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Attachment downloaded successfully

application/octet-stream

Schema for response 200 (application/octet-stream):

+{ + "format": "binary", + "type": "string" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/downloadownwashcertificate.html b/documentation/_site_rebuild_20260317/downloadownwashcertificate.html new file mode 100644 index 00000000..5e0cd4de --- /dev/null +++ b/documentation/_site_rebuild_20260317/downloadownwashcertificate.html @@ -0,0 +1,30 @@ + +Get download link for own wash certificate | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get download link for own wash certificate

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /user/bookings/washcertificate/download

Operation

Operation ID: downloadOwnWashCertificate

Get download link for own wash certificate

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/downloadselfservetaskattachment.html b/documentation/_site_rebuild_20260317/downloadselfservetaskattachment.html new file mode 100644 index 00000000..3dbc2859 --- /dev/null +++ b/documentation/_site_rebuild_20260317/downloadselfservetaskattachment.html @@ -0,0 +1,26 @@ + +Download task attachment | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Download task attachment

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/tasks/attachments/download

Operation

Operation ID: downloadSelfserveTaskAttachment

Generate a download link for a specific self-serve task attachment.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

task_id

query

yes

integer

Task ID

attachment_id

query

yes

integer

Attachment ID

Responses

Status

Description

Content Types

200

Successfully generated download link

application/json

400

404

Schema for response 200 (application/json):

+{ + "properties": { + "download_link": { + "format": "uri", + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/editbrandingoption.html b/documentation/_site_rebuild_20260317/editbrandingoption.html new file mode 100644 index 00000000..3c2948fa --- /dev/null +++ b/documentation/_site_rebuild_20260317/editbrandingoption.html @@ -0,0 +1,39 @@ + +Edit branding option | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Edit branding option

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /branding

Operation

Operation ID: editBrandingOption

Update an existing branding option

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "cvr": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Branding option updated successfully

application/json

403

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/editdailyreport.html b/documentation/_site_rebuild_20260317/editdailyreport.html new file mode 100644 index 00000000..30b082c8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/editdailyreport.html @@ -0,0 +1,34 @@ + +Edit daily report | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Edit daily report

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /departments/daily-reports

Operation

Operation ID: editDailyReport

Edit daily report

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + }, + "report": { + "type": "string" + } + }, + "required": [ + "id", + "report" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/editrole.html b/documentation/_site_rebuild_20260317/editrole.html new file mode 100644 index 00000000..853a8597 --- /dev/null +++ b/documentation/_site_rebuild_20260317/editrole.html @@ -0,0 +1,34 @@ + +Edit role | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Edit role

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /roles

Operation

Operation ID: editRole

Edit role

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/editvehicle.html b/documentation/_site_rebuild_20260317/editvehicle.html new file mode 100644 index 00000000..fb127546 --- /dev/null +++ b/documentation/_site_rebuild_20260317/editvehicle.html @@ -0,0 +1,46 @@ + +Edit vehicle | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Edit vehicle

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /vehicles

Operation

Operation ID: editVehicle

Update fields on an existing vehicle. Permissions: - Own scope: `edit_vehicle` (linked to subuser node `VEHICLES_EDIT`). - Broader scope: `edit_vehicle_other`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + }, + "reference": { + "maxLength": 255, + "nullable": true, + "type": "string" + }, + "reg": { + "maxLength": 12, + "minLength": 2, + "type": "string" + }, + "type": { + "type": "integer" + }, + "wash_subscription": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Vehicle updated

application/json

400

403

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/employeelogin.html b/documentation/_site_rebuild_20260317/employeelogin.html new file mode 100644 index 00000000..6b547fa7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/employeelogin.html @@ -0,0 +1,75 @@ + +Employee login | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Employee login

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/employee/login

Operation

Operation ID: employeeLogin

Authenticate an employee using user ID and password

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + }, + "password": { + "description": "Employee password", + "format": "password", + "type": "string" + }, + "user_id": { + "description": "Employee user ID", + "example": 1, + "type": "integer" + } + }, + "required": [ + "user_id", + "password", + "g_recaptcha_response" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Login successful

application/json

400

401

Schema for response 200 (application/json):

+{ + "oneOf": [ + { + "properties": { + "token": { + "description": "Bearer authentication token", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + { + "properties": { + "2fa_required": { + "example": true, + "type": "boolean" + }, + "2fa_token": { + "description": "Temporary 2FA verification token", + "type": "string" + } + }, + "required": [ + "2fa_required", + "2fa_token" + ], + "type": "object" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/enable2fa.html b/documentation/_site_rebuild_20260317/enable2fa.html new file mode 100644 index 00000000..20dd3a8d --- /dev/null +++ b/documentation/_site_rebuild_20260317/enable2fa.html @@ -0,0 +1,31 @@ + +Enable 2FA | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Enable 2FA

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/2fa/enable

Operation

Operation ID: enable2fa

Verify a code and enable 2FA for the authenticated user/subuser

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "code": { + "description": "The 6-digit TOTP code", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

2FA enabled successfully

application/json

400

401

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/enableselfservelanemachinerelay.html b/documentation/_site_rebuild_20260317/enableselfservelanemachinerelay.html new file mode 100644 index 00000000..4e762ce9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/enableselfservelanemachinerelay.html @@ -0,0 +1,55 @@ + +Manually enable MACHINE relay for a lane | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Manually enable MACHINE relay for a lane

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /modules/self-serve/lane/relay/machine/enable

Operation

Operation ID: enableSelfServeLaneMachineRelay

Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically enabled; an explicit call to this endpoint is required.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "duration": { + "description": "Optional number of seconds after which the relay should automatically turn off", + "type": "integer" + }, + "lane_id": { + "type": "integer" + } + }, + "required": [ + "lane_id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

MACHINE relay enabled

application/json

401

403

Not allowed to enable MACHINE relay (no matching task currently shown)

application/json

Schema for response 200 (application/json):

+{ + "properties": { + "duration": { + "nullable": true, + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "lane_id": { + "type": "integer" + }, + "relay": { + "type": "string" + } + }, + "type": "object" +} +

Schema for response 403 (application/json):

+{ + "$ref": "#/components/schemas/Error" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/enableworkerdebug.html b/documentation/_site_rebuild_20260317/enableworkerdebug.html new file mode 100644 index 00000000..3a049556 --- /dev/null +++ b/documentation/_site_rebuild_20260317/enableworkerdebug.html @@ -0,0 +1,18 @@ + +Enable worker debug | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Enable worker debug

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /worker/debug/on

Operation

Operation ID: enableWorkerDebug

Enable worker debug

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Worker debug enabled

application/json

403

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/error-handling.html b/documentation/_site_rebuild_20260317/error-handling.html new file mode 100644 index 00000000..f9acfa21 --- /dev/null +++ b/documentation/_site_rebuild_20260317/error-handling.html @@ -0,0 +1,21 @@ + +Error Handling | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Error Handling

This section describes error responses defined in openapi.yaml.

Error Schema

The shared Error schema is:

+{ + "error": "Invalid API token", + "code": 401 +} +

HTTP Status Codes

Common error status codes used across endpoints:

Code

Meaning

400

Bad Request

401

Unauthorized

403

Forbidden

404

Not Found

409

Conflict

422

Unprocessable Entity

500

Internal Server Error

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/exportdraftinvoicetoeconomic.html b/documentation/_site_rebuild_20260317/exportdraftinvoicetoeconomic.html new file mode 100644 index 00000000..7cc64f57 --- /dev/null +++ b/documentation/_site_rebuild_20260317/exportdraftinvoicetoeconomic.html @@ -0,0 +1,20 @@ + +Export draft invoice to e-conomic | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Export draft invoice to e-conomic

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /economic/invoice/draft/export

Operation

Operation ID: exportDraftInvoiceToEconomic

Export a draft invoice to e-conomic

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Draft invoice exported successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/exportinvoicetoeconomic.html b/documentation/_site_rebuild_20260317/exportinvoicetoeconomic.html new file mode 100644 index 00000000..93fb00fb --- /dev/null +++ b/documentation/_site_rebuild_20260317/exportinvoicetoeconomic.html @@ -0,0 +1,20 @@ + +Export invoice to e-conomic | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Export invoice to e-conomic

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /economic/invoice/export

Operation

Operation ID: exportInvoiceToEconomic

Export a booked invoice to e-conomic

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Invoice exported successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/forcedisableselfservelanemachine.html b/documentation/_site_rebuild_20260317/forcedisableselfservelanemachine.html new file mode 100644 index 00000000..8dff9c0f --- /dev/null +++ b/documentation/_site_rebuild_20260317/forcedisableselfservelanemachine.html @@ -0,0 +1,59 @@ + +Force disable MACHINE relay but keep lane as in-wash (superusers only) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Force disable MACHINE relay but keep lane as in-wash (superusers only)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /modules/self-serve/lane/force/machine/disable

Operation

Operation ID: forceDisableSelfServeLaneMachine

Superuser/emergency endpoint. Turns off the MACHINE relay while ensuring the lane remains in an IN_WASH state (simulating a started wash without machine assistance).

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "lane_id": { + "type": "integer" + }, + "license_plate": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "lane_id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

MACHINE relay force-disabled and lane ensured in-wash

application/json

401

403

Schema for response 200 (application/json):

+{ + "properties": { + "forced": { + "type": "boolean" + }, + "lane_id": { + "type": "integer" + }, + "machine": { + "enum": [ + "DISABLED" + ], + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "wash_start_time": { + "type": "integer" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/forceenableselfservelanemachine.html b/documentation/_site_rebuild_20260317/forceenableselfservelanemachine.html new file mode 100644 index 00000000..af654768 --- /dev/null +++ b/documentation/_site_rebuild_20260317/forceenableselfservelanemachine.html @@ -0,0 +1,69 @@ + +Force enable MACHINE relay and mark lane as in-wash (superusers only) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Force enable MACHINE relay and mark lane as in-wash (superusers only)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /modules/self-serve/lane/force/machine/enable

Operation

Operation ID: forceEnableSelfServeLaneMachine

Superuser/emergency endpoint. Bypasses the allowed services gating and directly turns on the MACHINE relay. Also ensures the lane is marked as OCCUPIED and IN_WASH with a wash start timestamp if not already set.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "duration": { + "description": "Optional number of seconds after which the relay should automatically turn off", + "nullable": true, + "type": "integer" + }, + "lane_id": { + "type": "integer" + }, + "license_plate": { + "description": "Optional license plate to associate with the lane", + "nullable": true, + "type": "string" + } + }, + "required": [ + "lane_id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

MACHINE relay force-enabled and lane marked in-wash

application/json

401

403

Schema for response 200 (application/json):

+{ + "properties": { + "duration": { + "nullable": true, + "type": "integer" + }, + "forced": { + "type": "boolean" + }, + "lane_id": { + "type": "integer" + }, + "machine": { + "enum": [ + "ENABLED" + ], + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "wash_start_time": { + "type": "integer" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/generatewashcertificate.html b/documentation/_site_rebuild_20260317/generatewashcertificate.html new file mode 100644 index 00000000..801f027b --- /dev/null +++ b/documentation/_site_rebuild_20260317/generatewashcertificate.html @@ -0,0 +1,27 @@ + +Generate wash certificate | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Generate wash certificate

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /order/wash-certificate

Operation

Operation ID: generateWashCertificate

Generate a wash certificate for an order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "order_id": { + "type": "integer" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Wash certificate generated successfully

application/json

400

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getallexchangerates.html b/documentation/_site_rebuild_20260317/getallexchangerates.html new file mode 100644 index 00000000..fc85c566 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getallexchangerates.html @@ -0,0 +1,18 @@ + +Get all exchange rates | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get all exchange rates

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/fxratesapi/rates

Operation

Operation ID: getAllExchangeRates

Get all available exchange rates

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Exchange rates retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getavailablevehicleaddons.html b/documentation/_site_rebuild_20260317/getavailablevehicleaddons.html new file mode 100644 index 00000000..b0b7b202 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getavailablevehicleaddons.html @@ -0,0 +1,18 @@ + +Get available vehicle addons | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get available vehicle addons

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /vehicles/addons/available

Operation

Operation ID: getAvailableVehicleAddons

Get list of available addons for a vehicle. Permissions: - Own scope: `list_vehicle_addon_own` (linked to subuser node `VEHICLES_LIST`). - Broader scope: `list_vehicles_addon_other`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

id

query

yes

integer

Responses

Status

Description

Content Types

200

Available addons retrieved successfully

application/json

403

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getbackupsconfig.html b/documentation/_site_rebuild_20260317/getbackupsconfig.html new file mode 100644 index 00000000..44b215d2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getbackupsconfig.html @@ -0,0 +1,20 @@ + +Get backups config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get backups config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /backups/config

Operation

Operation ID: getBackupsConfig

Get backups config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Backups configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BackupsConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getbirdconfig.html b/documentation/_site_rebuild_20260317/getbirdconfig.html new file mode 100644 index 00000000..01f99970 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getbirdconfig.html @@ -0,0 +1,20 @@ + +Get Bird config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get Bird config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /bird/config

Operation

Operation ID: getBirdConfig

Get Bird config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Bird configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/BirdConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcollectedinvoiceeconomicv2details.html b/documentation/_site_rebuild_20260317/getcollectedinvoiceeconomicv2details.html new file mode 100644 index 00000000..fe48991e --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcollectedinvoiceeconomicv2details.html @@ -0,0 +1,20 @@ + +Get deep V2 e-conomic invoice details | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get deep V2 e-conomic invoice details

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /collected-invoices/economic/v2/details

Operation

Operation ID: getCollectedInvoiceEconomicV2Details

Returns normalized internal lines and best-effort fetched draft/booked e-conomic lines for a collected invoice, including department distributions and warnings.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

collected_invoice_id

query

yes

integer

Responses

Status

Description

Content Types

200

Details resolved successfully

application/json

400

401

403

404

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcollectedinvoiceeconomicv2revenuestatistics.html b/documentation/_site_rebuild_20260317/getcollectedinvoiceeconomicv2revenuestatistics.html new file mode 100644 index 00000000..6746b7fb --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcollectedinvoiceeconomicv2revenuestatistics.html @@ -0,0 +1,20 @@ + +Get overall booked revenue statistics from e-conomic (V2) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get overall booked revenue statistics from e-conomic (V2)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /collected-invoices/economic/v2/revenue-statistics

Operation

Operation ID: getCollectedInvoiceEconomicV2RevenueStatistics

Aggregates booked e-conomic revenue across invoices and lines, with optional filters for date range, customer(s), department(s), currency, and barred-customer status.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

dateFrom

query

no

string

Start date (inclusive), defaults to first day of current month.

dateTo

query

no

string

End date (inclusive), defaults to today.

customer_numbers

query

no

string

Comma-separated customer numbers to include.

department_numbers

query

no

string

Comma-separated department numbers to include.

currency

query

no

string

Restrict to a specific invoice currency.

barred

query

no

string

Filter by e-conomic customer barred status.

max_pages

query

no

integer

Safety cap for paginated e-conomic reads.

Responses

Status

Description

Content Types

200

Revenue statistics resolved successfully

application/json

400

401

403

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2RevenueStatisticsResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcurrentsubuser.html b/documentation/_site_rebuild_20260317/getcurrentsubuser.html new file mode 100644 index 00000000..6664920b --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcurrentsubuser.html @@ -0,0 +1,20 @@ + +Get current subuser profile | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get current subuser profile

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /subusers/me

Operation

Operation ID: getCurrentSubuser

Returns the authenticated subuser (driver) profile and their enabled grants grouped by `billing_customer_number`. Notes: - This endpoint is available only to authenticated subuser sessions. - It does not require the `X-Customer-Number` header; all enabled, non-deleted grants for the subuser are included in the response.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Current subuser details

application/json

401

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SubuserSelf" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcustomer.html b/documentation/_site_rebuild_20260317/getcustomer.html new file mode 100644 index 00000000..be4b7095 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcustomer.html @@ -0,0 +1,20 @@ + +Get customer details | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get customer details

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /users/customer

Operation

Operation ID: getCustomer

Get details about a specific customer

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

no

integer

Responses

Status

Description

Content Types

200

Customer retrieved successfully

application/json

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/User" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcustomerattributes.html b/documentation/_site_rebuild_20260317/getcustomerattributes.html new file mode 100644 index 00000000..c7595c45 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcustomerattributes.html @@ -0,0 +1,18 @@ + +Get customer attributes | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get customer attributes

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /customer/attributes

Operation

Operation ID: getCustomerAttributes

Get custom attributes for a customer

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_id

query

no

integer

Responses

Status

Description

Content Types

200

Customer attributes retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcustomercode.html b/documentation/_site_rebuild_20260317/getcustomercode.html new file mode 100644 index 00000000..1bc79b0c --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcustomercode.html @@ -0,0 +1,18 @@ + +Get customer code | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get customer code

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /admin/customer/code

Operation

Operation ID: getCustomerCode

Get customer code

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

no

integer

user_id

query

no

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcustomerdefaultdepartment.html b/documentation/_site_rebuild_20260317/getcustomerdefaultdepartment.html new file mode 100644 index 00000000..c99b2b4b --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcustomerdefaultdepartment.html @@ -0,0 +1,18 @@ + +Get customer default department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get customer default department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /customer/department/default

Operation

Operation ID: getCustomerDefaultDepartment

Get customer default department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

no

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcustomerfixedpricing.html b/documentation/_site_rebuild_20260317/getcustomerfixedpricing.html new file mode 100644 index 00000000..b5daa31c --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcustomerfixedpricing.html @@ -0,0 +1,18 @@ + +Get customer fixed pricing | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get customer fixed pricing

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /customer/pricing/fixed

Operation

Operation ID: getCustomerFixedPricing

Get customer fixed pricing

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcustomername.html b/documentation/_site_rebuild_20260317/getcustomername.html new file mode 100644 index 00000000..3326605f --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcustomername.html @@ -0,0 +1,25 @@ + +Get customer name | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get customer name

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /admin/customer/name

Operation

Operation ID: getCustomerName

Get the full name of a customer

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

user_id

query

no

integer

Responses

Status

Description

Content Types

200

Customer name retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcustomernotes.html b/documentation/_site_rebuild_20260317/getcustomernotes.html new file mode 100644 index 00000000..dc832eb9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcustomernotes.html @@ -0,0 +1,18 @@ + +Get customer notes | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get customer notes

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /customer/notes

Operation

Operation ID: getCustomerNotes

Get notes for a customer

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_id

query

no

integer

Responses

Status

Description

Content Types

200

Customer notes retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getcustomerpricinghistoryv2.html b/documentation/_site_rebuild_20260317/getcustomerpricinghistoryv2.html new file mode 100644 index 00000000..dba856a6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getcustomerpricinghistoryv2.html @@ -0,0 +1,20 @@ + +Get customer versioned pricing/subscription/discount timeline | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get customer versioned pricing/subscription/discount timeline

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/customers/pricing-history

Operation

Operation ID: getCustomerPricingHistoryV2

Get customer versioned pricing/subscription/discount timeline

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

yes

integer

dateFrom

query

yes

string

dateTo

query

yes

string

Responses

Status

Description

Content Types

200

Customer timeline resolved

application/json

400

401

403

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/CustomerPricingHistoryResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdailyreport.html b/documentation/_site_rebuild_20260317/getdailyreport.html new file mode 100644 index 00000000..7e0315c1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdailyreport.html @@ -0,0 +1,18 @@ + +Get daily report | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get daily report

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments/daily-reports/get

Operation

Operation ID: getDailyReport

Get daily report

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

department_id

query

yes

integer

date

query

yes

string

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdailyreportbookingscount.html b/documentation/_site_rebuild_20260317/getdailyreportbookingscount.html new file mode 100644 index 00000000..d9bc559d --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdailyreportbookingscount.html @@ -0,0 +1,18 @@ + +Get bookings count for daily reports | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get bookings count for daily reports

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments/daily-reports/bookings-count

Operation

Operation ID: getDailyReportBookingsCount

Get bookings count for daily reports

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

date

query

yes

string

department_id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdailyreportproductcount.html b/documentation/_site_rebuild_20260317/getdailyreportproductcount.html new file mode 100644 index 00000000..38a40cda --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdailyreportproductcount.html @@ -0,0 +1,18 @@ + +Get product count for daily reports | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get product count for daily reports

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments/daily-reports/product-count

Operation

Operation ID: getDailyReportProductCount

Get product count for daily reports

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

date

query

yes

string

date_to

query

no

string

department_id

query

yes

integer

product_id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdailyreporttransactioncount.html b/documentation/_site_rebuild_20260317/getdailyreporttransactioncount.html new file mode 100644 index 00000000..aaa6ade1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdailyreporttransactioncount.html @@ -0,0 +1,18 @@ + +Get transaction count for daily reports | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get transaction count for daily reports

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments/daily-reports/transaction-count

Operation

Operation ID: getDailyReportTransactionCount

Get transaction count for daily reports

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

date

query

yes

string

date_to

query

no

string

department_id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentbookingcount.html b/documentation/_site_rebuild_20260317/getdepartmentbookingcount.html new file mode 100644 index 00000000..d1da9273 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentbookingcount.html @@ -0,0 +1,18 @@ + +Get department unfulfilled bookings count | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department unfulfilled bookings count

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /admin/bookings/department/count

Operation

Operation ID: getDepartmentBookingCount

Get department unfulfilled bookings count

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

department_id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentcategories.html b/documentation/_site_rebuild_20260317/getdepartmentcategories.html new file mode 100644 index 00000000..4e9d6ea2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentcategories.html @@ -0,0 +1,18 @@ + +Get department categories | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department categories

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments/categories

Operation

Operation ID: getDepartmentCategories

Get product categories available in a department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

department_id

query

yes

integer

Responses

Status

Description

Content Types

200

Department categories retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentdraftinvoicetotals.html b/documentation/_site_rebuild_20260317/getdepartmentdraftinvoicetotals.html new file mode 100644 index 00000000..000a5531 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentdraftinvoicetotals.html @@ -0,0 +1,18 @@ + +Get department draft invoice totals | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department draft invoice totals

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/economic/totals/department_draft_invoice_totals

Operation

Operation ID: getDepartmentDraftInvoiceTotals

Get department draft invoice totals

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentlanedynamicimage.html b/documentation/_site_rebuild_20260317/getdepartmentlanedynamicimage.html new file mode 100644 index 00000000..c97c95a3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentlanedynamicimage.html @@ -0,0 +1,21 @@ + +Generate dynamic image for a department lane | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Generate dynamic image for a department lane

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/lanes/dynamic-image

Operation

Operation ID: getDepartmentLaneDynamicImage

Returns a composed machine UI image for the specified department lane. You can optionally highlight button indices, set the current step indicator, and toggle only-current-step mode.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

department

query

yes

integer

Department ID

lane

query

yes

integer

Lane ID

buttons

query

no

oneOf

Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.

current_step

query

no

integer

Current step indicator (non-negative integer)

only_current_step

query

no

boolean

If true, only draw the current step highlight

vehicle_type

query

no

integer

Vehicle type selection override (nullable non-negative integer)

Responses

Status

Description

Content Types

200

Dynamic image rendered successfully

image/png

400

401

403

404

Schema for response 200 (image/png):

+{ + "format": "binary", + "type": "string" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentprices.html b/documentation/_site_rebuild_20260317/getdepartmentprices.html new file mode 100644 index 00000000..b274b7b9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentprices.html @@ -0,0 +1,18 @@ + +Get department prices | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department prices

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/department/prices

Operation

Operation ID: getDepartmentPrices

Get department prices

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

department_id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentrecommendedorder.html b/documentation/_site_rebuild_20260317/getdepartmentrecommendedorder.html new file mode 100644 index 00000000..5594ab9c --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentrecommendedorder.html @@ -0,0 +1,18 @@ + +Get recommended order for department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get recommended order for department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments/order/recommended

Operation

Operation ID: getDepartmentRecommendedOrder

Get recommended order for department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

department_id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentselfserveenabled.html b/documentation/_site_rebuild_20260317/getdepartmentselfserveenabled.html new file mode 100644 index 00000000..bc0b97b2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentselfserveenabled.html @@ -0,0 +1,25 @@ + +Get department self-serve status | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department self-serve status

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments/self-serve/enabled

Operation

Operation ID: getDepartmentSelfServeEnabled

Check if self-serve is enabled for a specific department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Department ID

Responses

Status

Description

Content Types

200

Successfully retrieved status

application/json

404

Schema for response 200 (application/json):

+{ + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentsentinvoicetotals.html b/documentation/_site_rebuild_20260317/getdepartmentsentinvoicetotals.html new file mode 100644 index 00000000..53a0fadc --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentsentinvoicetotals.html @@ -0,0 +1,18 @@ + +Get department sent invoice totals | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department sent invoice totals

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/economic/totals/department_sent_invoice_totals

Operation

Operation ID: getDepartmentSentInvoiceTotals

Get department sent invoice totals

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentterminallocation.html b/documentation/_site_rebuild_20260317/getdepartmentterminallocation.html new file mode 100644 index 00000000..162804dc --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentterminallocation.html @@ -0,0 +1,18 @@ + +Get department terminal location | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department terminal location

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/stripe/department/terminal/location

Operation

Operation ID: getDepartmentTerminalLocation

Get department terminal location

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentterminalreaders.html b/documentation/_site_rebuild_20260317/getdepartmentterminalreaders.html new file mode 100644 index 00000000..250124b4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentterminalreaders.html @@ -0,0 +1,18 @@ + +Get department terminal readers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department terminal readers

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/stripe/department/terminal/readers

Operation

Operation ID: getDepartmentTerminalReaders

Get department terminal readers

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentvariables.html b/documentation/_site_rebuild_20260317/getdepartmentvariables.html new file mode 100644 index 00000000..b19253b0 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentvariables.html @@ -0,0 +1,18 @@ + +Get department variables | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department variables

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/department/variables

Operation

Operation ID: getDepartmentVariables

Get department variables

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

department_id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getdepartmentweathertimeline.html b/documentation/_site_rebuild_20260317/getdepartmentweathertimeline.html new file mode 100644 index 00000000..12153b09 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getdepartmentweathertimeline.html @@ -0,0 +1,36 @@ + +Get department weather timeline | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get department weather timeline

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments/weather

Operation

Operation ID: getDepartmentWeatherTimeline

Returns hourly weather, washes, hours and productivity status for a department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Department ID

Responses

Status

Description

Content Types

200

Department weather timeline retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentWeatherTimelineResponse", + "allOf": [ + { + "$ref": "#/components/schemas/SuccessResponse" + }, + { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/DepartmentWeatherTimelineEntry" + }, + "type": "array" + } + }, + "type": "object" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/geteconomicconfig.html b/documentation/_site_rebuild_20260317/geteconomicconfig.html new file mode 100644 index 00000000..3fda2945 --- /dev/null +++ b/documentation/_site_rebuild_20260317/geteconomicconfig.html @@ -0,0 +1,20 @@ + +Get e-conomic config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get e-conomic config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /economic/config

Operation

Operation ID: getEconomicConfig

Get e-conomic config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

e-conomic configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/EconomicConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/geteconomiccustomer.html b/documentation/_site_rebuild_20260317/geteconomiccustomer.html new file mode 100644 index 00000000..53241ba8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/geteconomiccustomer.html @@ -0,0 +1,18 @@ + +Get e-conomic customer details | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get e-conomic customer details

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/economic/customer

Operation

Operation ID: getEconomicCustomer

Get e-conomic customer details

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/geteconomicdepartments.html b/documentation/_site_rebuild_20260317/geteconomicdepartments.html new file mode 100644 index 00000000..f37b072b --- /dev/null +++ b/documentation/_site_rebuild_20260317/geteconomicdepartments.html @@ -0,0 +1,18 @@ + +Get e-conomic departments | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get e-conomic departments

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /economic/departments

Operation

Operation ID: getEconomicDepartments

Get e-conomic departments

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/geteconomiclayouts.html b/documentation/_site_rebuild_20260317/geteconomiclayouts.html new file mode 100644 index 00000000..6204e008 --- /dev/null +++ b/documentation/_site_rebuild_20260317/geteconomiclayouts.html @@ -0,0 +1,18 @@ + +Get e-conomic layouts | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get e-conomic layouts

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /economic/layouts

Operation

Operation ID: getEconomicLayouts

Get available invoice layouts from e-conomic

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Layouts retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/geteconomicpaymentterms.html b/documentation/_site_rebuild_20260317/geteconomicpaymentterms.html new file mode 100644 index 00000000..28e8a276 --- /dev/null +++ b/documentation/_site_rebuild_20260317/geteconomicpaymentterms.html @@ -0,0 +1,18 @@ + +Get e-conomic payment terms | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get e-conomic payment terms

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /economic/payment-terms

Operation

Operation ID: getEconomicPaymentTerms

Get available payment terms from e-conomic

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Payment terms retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/geteconomicproducts.html b/documentation/_site_rebuild_20260317/geteconomicproducts.html new file mode 100644 index 00000000..349d7a65 --- /dev/null +++ b/documentation/_site_rebuild_20260317/geteconomicproducts.html @@ -0,0 +1,18 @@ + +Get e-conomic products | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get e-conomic products

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /economic/products

Operation

Operation ID: getEconomicProducts

Get e-conomic products

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/geteconomictotals.html b/documentation/_site_rebuild_20260317/geteconomictotals.html new file mode 100644 index 00000000..ceae5004 --- /dev/null +++ b/documentation/_site_rebuild_20260317/geteconomictotals.html @@ -0,0 +1,18 @@ + +Get total economic statistics | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get total economic statistics

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/economic/totals

Operation

Operation ID: getEconomicTotals

Get total economic statistics

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getemailconfig.html b/documentation/_site_rebuild_20260317/getemailconfig.html new file mode 100644 index 00000000..74113afc --- /dev/null +++ b/documentation/_site_rebuild_20260317/getemailconfig.html @@ -0,0 +1,20 @@ + +Get email config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get email config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /email/config

Operation

Operation ID: getEmailConfig

Get email config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Email configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/EmailConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getentraconfig.html b/documentation/_site_rebuild_20260317/getentraconfig.html new file mode 100644 index 00000000..dc27428f --- /dev/null +++ b/documentation/_site_rebuild_20260317/getentraconfig.html @@ -0,0 +1,20 @@ + +Get Entra config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get Entra config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /entra/config

Operation

Operation ID: getEntraConfig

Get Entra config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Entra configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/EntraConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getexchangerate.html b/documentation/_site_rebuild_20260317/getexchangerate.html new file mode 100644 index 00000000..085cbe57 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getexchangerate.html @@ -0,0 +1,18 @@ + +Get exchange rate | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get exchange rate

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/fxratesapi/rate

Operation

Operation ID: getExchangeRate

Get current exchange rate

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

from

query

yes

string

to

query

yes

string

Responses

Status

Description

Content Types

200

Exchange rate retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getform.html b/documentation/_site_rebuild_20260317/getform.html new file mode 100644 index 00000000..a872bb12 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getform.html @@ -0,0 +1,18 @@ + +Get form | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get form

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /form

Operation

Operation ID: getForm

Retrieve a form definition

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

Responses

Status

Description

Content Types

200

Form retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getfxratesapiconfig.html b/documentation/_site_rebuild_20260317/getfxratesapiconfig.html new file mode 100644 index 00000000..ee34929a --- /dev/null +++ b/documentation/_site_rebuild_20260317/getfxratesapiconfig.html @@ -0,0 +1,20 @@ + +Get FXRatesAPI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get FXRatesAPI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /fxratesapi/config

Operation

Operation ID: getFxRatesApiConfig

Get FXRatesAPI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

FXRatesAPI configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/FxRatesApiConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getgatewayapiconfig.html b/documentation/_site_rebuild_20260317/getgatewayapiconfig.html new file mode 100644 index 00000000..3584d3b3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getgatewayapiconfig.html @@ -0,0 +1,20 @@ + +Get GatewayAPI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get GatewayAPI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /gatewayapi/config

Operation

Operation ID: getGatewayApiConfig

Get GatewayAPI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

GatewayAPI configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/GatewayApiConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getinvoicepdf.html b/documentation/_site_rebuild_20260317/getinvoicepdf.html new file mode 100644 index 00000000..04c2bc25 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getinvoicepdf.html @@ -0,0 +1,21 @@ + +Get invoice PDF | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get invoice PDF

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /invoices/pdf

Operation

Operation ID: getInvoicePdf

Download an invoice as PDF

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

PDF retrieved successfully

application/pdf

Schema for response 200 (application/pdf):

+{ + "format": "binary", + "type": "string" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getinvoicingfixedpricingdistribution.html b/documentation/_site_rebuild_20260317/getinvoicingfixedpricingdistribution.html new file mode 100644 index 00000000..7c4face8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getinvoicingfixedpricingdistribution.html @@ -0,0 +1,20 @@ + +Get fixed pricing distribution | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get fixed pricing distribution

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/invoicing/period/distribution/fixed-pricing

Operation

Operation ID: getInvoicingFixedPricingDistribution

Get invoicing distribution for fixed pricing items

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

dateFrom

query

yes

string

dateTo

query

yes

string

Responses

Status

Description

Content Types

200

Fixed pricing distribution retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2all.html b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2all.html new file mode 100644 index 00000000..40db707e --- /dev/null +++ b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2all.html @@ -0,0 +1,20 @@ + +Get version-aware historical distribution (all) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get version-aware historical distribution (all)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/invoicing/period/distribution/v2/all

Operation

Operation ID: getInvoicingPeriodDistributionV2All

Get version-aware historical distribution (all)

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

dateFrom

query

yes

string

dateTo

query

yes

string

Responses

Status

Description

Content Types

200

Version-aware historical distribution (all categories)

application/json

400

401

403

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/InvoicingDistributionV2AllResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2bookeddepartment75.html b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2bookeddepartment75.html new file mode 100644 index 00000000..38a442eb --- /dev/null +++ b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2bookeddepartment75.html @@ -0,0 +1,20 @@ + +Get booked e-conomic department 75 redistribution | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get booked e-conomic department 75 redistribution

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/invoicing/period/distribution/v2/booked-department-75

Operation

Operation ID: getInvoicingPeriodDistributionV2BookedDepartment75

Get booked e-conomic department 75 redistribution

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

dateFrom

query

yes

string

dateTo

query

yes

string

Responses

Status

Description

Content Types

200

Actual booked e-conomic department 75 net amounts redistributed to internal departments

application/json

400

401

403

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/InvoicingDistributionV2BookedDepartment75Response" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2customerprices.html b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2customerprices.html new file mode 100644 index 00000000..2a55cc32 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2customerprices.html @@ -0,0 +1,20 @@ + +Get version-aware historical customer-price discount distribution | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get version-aware historical customer-price discount distribution

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/invoicing/period/distribution/v2/customer-prices

Operation

Operation ID: getInvoicingPeriodDistributionV2CustomerPrices

Get version-aware historical customer-price discount distribution

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

dateFrom

query

yes

string

dateTo

query

yes

string

Responses

Status

Description

Content Types

200

Version-aware customer-price discount distribution

application/json

400

401

403

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/InvoicingDistributionV2CustomerPricesResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2fixedpricing.html b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2fixedpricing.html new file mode 100644 index 00000000..42199b66 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2fixedpricing.html @@ -0,0 +1,20 @@ + +Get version-aware historical fixed pricing distribution | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get version-aware historical fixed pricing distribution

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/invoicing/period/distribution/v2/fixed-pricing

Operation

Operation ID: getInvoicingPeriodDistributionV2FixedPricing

Get version-aware historical fixed pricing distribution

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

dateFrom

query

yes

string

dateTo

query

yes

string

Responses

Status

Description

Content Types

200

Version-aware fixed pricing distribution

application/json

400

401

403

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/InvoicingDistributionV2FixedPricingResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2washsubscriptions.html b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2washsubscriptions.html new file mode 100644 index 00000000..85e18028 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getinvoicingperioddistributionv2washsubscriptions.html @@ -0,0 +1,20 @@ + +Get version-aware historical wash subscription distribution | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get version-aware historical wash subscription distribution

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/invoicing/period/distribution/v2/wash-subscriptions

Operation

Operation ID: getInvoicingPeriodDistributionV2WashSubscriptions

Get version-aware historical wash subscription distribution

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

dateFrom

query

yes

string

dateTo

query

yes

string

Responses

Status

Description

Content Types

200

Version-aware wash subscription distribution

application/json

400

401

403

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getinvoicingperiods.html b/documentation/_site_rebuild_20260317/getinvoicingperiods.html new file mode 100644 index 00000000..32f0a979 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getinvoicingperiods.html @@ -0,0 +1,18 @@ + +Get invoicing periods | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get invoicing periods

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/invoicing/period

Operation

Operation ID: getInvoicingPeriods

Retrieve invoicing periods for superusers

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

dateFrom

query

yes

string

dateTo

query

yes

string

Responses

Status

Description

Content Types

200

Invoicing periods retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getinvoicingwashsubscriptionsdistribution.html b/documentation/_site_rebuild_20260317/getinvoicingwashsubscriptionsdistribution.html new file mode 100644 index 00000000..9864b346 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getinvoicingwashsubscriptionsdistribution.html @@ -0,0 +1,20 @@ + +Get wash subscriptions distribution | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get wash subscriptions distribution

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/invoicing/period/distribution/wash-subscriptions

Operation

Operation ID: getInvoicingWashSubscriptionsDistribution

Get invoicing distribution for wash subscriptions

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

dateFrom

query

yes

string

dateTo

query

yes

string

Responses

Status

Description

Content Types

200

Wash subscriptions distribution retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/InvoicingWashSubscriptionsDistributionResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getlastmonthincome.html b/documentation/_site_rebuild_20260317/getlastmonthincome.html new file mode 100644 index 00000000..c6324a4c --- /dev/null +++ b/documentation/_site_rebuild_20260317/getlastmonthincome.html @@ -0,0 +1,18 @@ + +Get last month's income | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get last month's income

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/income/last-month

Operation

Operation ID: getLastMonthIncome

Get income statistics for the previous month

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Last month&#x27;s income statistics retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getlicenseplaterecognizerconfig.html b/documentation/_site_rebuild_20260317/getlicenseplaterecognizerconfig.html new file mode 100644 index 00000000..aa10ba2b --- /dev/null +++ b/documentation/_site_rebuild_20260317/getlicenseplaterecognizerconfig.html @@ -0,0 +1,20 @@ + +Get LicensePlateRecognizer config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get LicensePlateRecognizer config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /licenseplaterecognizer/config

Operation

Operation ID: getLicensePlateRecognizerConfig

Get LicensePlateRecognizer config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

LicensePlateRecognizer configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/LicensePlateRecognizerConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getlimbleconfig.html b/documentation/_site_rebuild_20260317/getlimbleconfig.html new file mode 100644 index 00000000..f20c0983 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getlimbleconfig.html @@ -0,0 +1,20 @@ + +Get Limble config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get Limble config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /limble/config

Operation

Operation ID: getLimbleConfig

Get Limble config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Limble configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/LimbleConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getmotorapiconfig.html b/documentation/_site_rebuild_20260317/getmotorapiconfig.html new file mode 100644 index 00000000..51879ec1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getmotorapiconfig.html @@ -0,0 +1,20 @@ + +Get MotorAPI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get MotorAPI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /motorapi/config

Operation

Operation ID: getMotorApiConfig

Get MotorAPI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

MotorAPI configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/MotorApiConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getnewbookingsstats.html b/documentation/_site_rebuild_20260317/getnewbookingsstats.html new file mode 100644 index 00000000..4e625bc4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getnewbookingsstats.html @@ -0,0 +1,18 @@ + +Get new bookings statistics | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get new bookings statistics

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/bookings/new

Operation

Operation ID: getNewBookingsStats

Get statistics for new bookings

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

New bookings statistics retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getnewordersstats.html b/documentation/_site_rebuild_20260317/getnewordersstats.html new file mode 100644 index 00000000..747a35d9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getnewordersstats.html @@ -0,0 +1,18 @@ + +Get new orders statistics | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get new orders statistics

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/orders/new

Operation

Operation ID: getNewOrdersStats

Get statistics for new orders

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

New orders statistics retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getocrspaceconfig.html b/documentation/_site_rebuild_20260317/getocrspaceconfig.html new file mode 100644 index 00000000..d80cdf49 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getocrspaceconfig.html @@ -0,0 +1,20 @@ + +Get OcrSpace config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get OcrSpace config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /ocrspace/config

Operation

Operation ID: getOcrSpaceConfig

Get OcrSpace config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

OcrSpace configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/OcrSpaceConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getopenaiconfig.html b/documentation/_site_rebuild_20260317/getopenaiconfig.html new file mode 100644 index 00000000..57986956 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getopenaiconfig.html @@ -0,0 +1,20 @@ + +Get OpenAI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get OpenAI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /openai/config

Operation

Operation ID: getOpenAiConfig

Get OpenAI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

OpenAI configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/OpenAiConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getorder.html b/documentation/_site_rebuild_20260317/getorder.html new file mode 100644 index 00000000..5a6abeb1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getorder.html @@ -0,0 +1,20 @@ + +Get order details | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get order details

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /order

Operation

Operation ID: getOrder

Get detailed information about a specific order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Order retrieved successfully

application/json

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/Order" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getplatescanpostresults.html b/documentation/_site_rebuild_20260317/getplatescanpostresults.html new file mode 100644 index 00000000..c12e3c4b --- /dev/null +++ b/documentation/_site_rebuild_20260317/getplatescanpostresults.html @@ -0,0 +1,18 @@ + +Get post-scan results | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get post-scan results

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /numberplatescans/post

Operation

Operation ID: getPlateScanPostResults

Get post-scan results

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Post-scan results retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getreadytoinvoice.html b/documentation/_site_rebuild_20260317/getreadytoinvoice.html new file mode 100644 index 00000000..23063abe --- /dev/null +++ b/documentation/_site_rebuild_20260317/getreadytoinvoice.html @@ -0,0 +1,18 @@ + +Get invoices ready to process | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get invoices ready to process

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /collected-invoices/ready-to-invoice

Operation

Operation ID: getReadyToInvoice

Get collected invoices that are ready to be processed

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Ready invoices retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getrecaptchaconfig.html b/documentation/_site_rebuild_20260317/getrecaptchaconfig.html new file mode 100644 index 00000000..b42097c0 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getrecaptchaconfig.html @@ -0,0 +1,46 @@ + +Get reCAPTCHA configuration | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get reCAPTCHA configuration

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /auth/reCAPTCHA/public

Operation

Operation ID: getRecaptchaConfig

Retrieve public reCAPTCHA configuration for login forms

Authentication

No authentication required.

Responses

Status

Description

Content Types

200

reCAPTCHA configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "properties": { + "rate_limit": { + "properties": { + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "reset": { + "type": "integer" + }, + "warning": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "recaptcha": { + "type": "object" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getrecaptchamoduleconfig.html b/documentation/_site_rebuild_20260317/getrecaptchamoduleconfig.html new file mode 100644 index 00000000..b103f4a4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getrecaptchamoduleconfig.html @@ -0,0 +1,20 @@ + +Get reCAPTCHA config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get reCAPTCHA config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /reCAPTCHA/config

Operation

Operation ID: getRecaptchaModuleConfig

Get reCAPTCHA config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

reCAPTCHA configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/RecaptchaConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getselfserveconfig.html b/documentation/_site_rebuild_20260317/getselfserveconfig.html new file mode 100644 index 00000000..354fa983 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getselfserveconfig.html @@ -0,0 +1,20 @@ + +Get Self-Serve config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get Self-Serve config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /selfserve/config

Operation

Operation ID: getSelfServeConfig

Get Self-Serve config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Self-serve configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SelfServeConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getselfservelanestatus.html b/documentation/_site_rebuild_20260317/getselfservelanestatus.html new file mode 100644 index 00000000..073aa985 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getselfservelanestatus.html @@ -0,0 +1,20 @@ + +Get self-serve lane status | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get self-serve lane status

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/self-serve/lane/status

Operation

Operation ID: getSelfServeLaneStatus

Retrieve the current status of a self-serve lane

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

lane_id

query

yes

integer

Responses

Status

Description

Content Types

200

Lane status retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SelfServeLaneStatus" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getselfservevehicleallowed.html b/documentation/_site_rebuild_20260317/getselfservevehicleallowed.html new file mode 100644 index 00000000..18e71a61 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getselfservevehicleallowed.html @@ -0,0 +1,20 @@ + +Check whether self-serve is allowed for a vehicle on a lane | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Check whether self-serve is allowed for a vehicle on a lane

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/vehicle/allowed

Operation

Operation ID: getSelfserveVehicleAllowed

Check whether self-serve is allowed for a vehicle on a lane

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

lane_id

query

yes

integer

reg

query

yes

string

Responses

Status

Description

Content Types

200

Successfully evaluated self-serve eligibility

application/json

403

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SelfserveVehicleAllowedResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getselfservewashsummary.html b/documentation/_site_rebuild_20260317/getselfservewashsummary.html new file mode 100644 index 00000000..b8811e8d --- /dev/null +++ b/documentation/_site_rebuild_20260317/getselfservewashsummary.html @@ -0,0 +1,20 @@ + +Get self-serve wash summary | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get self-serve wash summary

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/washes/summary

Operation

Operation ID: getSelfserveWashSummary

Get self-serve wash summary

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

session_id

query

no

integer

lane_id

query

no

integer

reg

query

no

string

Responses

Status

Description

Content Types

200

Successfully retrieved self-serve wash summary

application/json

403

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SelfserveWashSummary" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getsession.html b/documentation/_site_rebuild_20260317/getsession.html new file mode 100644 index 00000000..6ca997cc --- /dev/null +++ b/documentation/_site_rebuild_20260317/getsession.html @@ -0,0 +1,33 @@ + +Get current session | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get current session

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /auth/session

Operation

Operation ID: getSession

Retrieve information about the current authenticated user session

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Session information retrieved successfully

application/json

400

401

Schema for response 200 (application/json):

+{ + "allOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "properties": { + "two_factor_enabled": { + "description": "Indicates if 2FA is enabled for this account", + "type": "boolean" + } + }, + "type": "object" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getshellyconfig.html b/documentation/_site_rebuild_20260317/getshellyconfig.html new file mode 100644 index 00000000..6cda9b18 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getshellyconfig.html @@ -0,0 +1,20 @@ + +Get Shelly config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get Shelly config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /shelly/config

Operation

Operation ID: getShellyConfig

Get Shelly config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Shelly configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ShellyConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getstripeconfig.html b/documentation/_site_rebuild_20260317/getstripeconfig.html new file mode 100644 index 00000000..61457133 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getstripeconfig.html @@ -0,0 +1,20 @@ + +Get Stripe config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get Stripe config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /stripe/config

Operation

Operation ID: getStripeConfig

Get Stripe config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Stripe configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/StripeConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getstripepaymentintent.html b/documentation/_site_rebuild_20260317/getstripepaymentintent.html new file mode 100644 index 00000000..6fbe59f1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getstripepaymentintent.html @@ -0,0 +1,18 @@ + +Get Stripe payment intent | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get Stripe payment intent

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /orders/module/stripe/payment_intent

Operation

Operation ID: getStripePaymentIntent

Get Stripe payment intent

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getsubuser.html b/documentation/_site_rebuild_20260317/getsubuser.html new file mode 100644 index 00000000..9511e717 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getsubuser.html @@ -0,0 +1,68 @@ + +Get a subuser by ID (visible by grant) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get a subuser by ID (visible by grant)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /subusers/{id}

Operation

Operation ID: getSubuser

Returns the subuser if the authenticated user has at least one enabled, non-deleted grant for their customer number to this subuser. Otherwise returns 404.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

path

yes

integer

Responses

Status

Description

Content Types

200

Subuser details

application/json

401

404

500

Schema for response 200 (application/json):

+{ + "properties": { + "created_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "email": { + "format": "email", + "nullable": true, + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "nullable": true, + "type": "string" + }, + "permissions": { + "description": "Aggregated permission keys granted for the caller's customer", + "items": { + "type": "string" + }, + "type": "array" + }, + "phone": { + "nullable": true, + "type": "integer" + }, + "phone_country_code": { + "nullable": true, + "type": "integer" + }, + "suspended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getsuperuseruser.html b/documentation/_site_rebuild_20260317/getsuperuseruser.html new file mode 100644 index 00000000..ac948dc0 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getsuperuseruser.html @@ -0,0 +1,20 @@ + +Get user by ID (superuser) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get user by ID (superuser)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/user

Operation

Operation ID: getSuperuserUser

Get detailed user information by user ID

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

user_id

query

no

integer

Responses

Status

Description

Content Types

200

User retrieved successfully

application/json

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/User" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getthismonthincome.html b/documentation/_site_rebuild_20260317/getthismonthincome.html new file mode 100644 index 00000000..749767ea --- /dev/null +++ b/documentation/_site_rebuild_20260317/getthismonthincome.html @@ -0,0 +1,18 @@ + +Get this month's income | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get this month's income

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/income/this-month

Operation

Operation ID: getThisMonthIncome

Get income statistics for the current month

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

This month&#x27;s income statistics retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getthisyearincome.html b/documentation/_site_rebuild_20260317/getthisyearincome.html new file mode 100644 index 00000000..5323aa67 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getthisyearincome.html @@ -0,0 +1,18 @@ + +Get this year's income | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get this year's income

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/income/this-year

Operation

Operation ID: getThisYearIncome

Get income statistics for the current year

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

This year&#x27;s income statistics retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/gettodayincome.html b/documentation/_site_rebuild_20260317/gettodayincome.html new file mode 100644 index 00000000..fbfba4dd --- /dev/null +++ b/documentation/_site_rebuild_20260317/gettodayincome.html @@ -0,0 +1,18 @@ + +Get today's income | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get today's income

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/income/today

Operation

Operation ID: getTodayIncome

Get income statistics for today

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Today&#x27;s income statistics retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/gettotalincometodaybydepartments.html b/documentation/_site_rebuild_20260317/gettotalincometodaybydepartments.html new file mode 100644 index 00000000..04c00afa --- /dev/null +++ b/documentation/_site_rebuild_20260317/gettotalincometodaybydepartments.html @@ -0,0 +1,18 @@ + +Get total income today by departments | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get total income today by departments

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/income/departments

Operation

Operation ID: getTotalIncomeTodayByDepartments

Get total income today by departments

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getunknowncustomervehicles.html b/documentation/_site_rebuild_20260317/getunknowncustomervehicles.html new file mode 100644 index 00000000..7fc49a5a --- /dev/null +++ b/documentation/_site_rebuild_20260317/getunknowncustomervehicles.html @@ -0,0 +1,18 @@ + +Get unknown customer vehicles in department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get unknown customer vehicles in department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/vehicles/unknown-customer

Operation

Operation ID: getUnknownCustomerVehicles

Get unknown customer vehicles in department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getuserbookings.html b/documentation/_site_rebuild_20260317/getuserbookings.html new file mode 100644 index 00000000..fe0a06fb --- /dev/null +++ b/documentation/_site_rebuild_20260317/getuserbookings.html @@ -0,0 +1,23 @@ + +Get user bookings | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get user bookings

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /user/bookings

Operation

Operation ID: getUserBookings

Retrieve bookings for the authenticated user

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

User bookings retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Booking" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getuserdiscounts.html b/documentation/_site_rebuild_20260317/getuserdiscounts.html new file mode 100644 index 00000000..99cd2a37 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getuserdiscounts.html @@ -0,0 +1,18 @@ + +Get user discounts | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get user discounts

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/user/discounts

Operation

Operation ID: getUserDiscounts

Get user discounts

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

user_id

query

yes

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getuseridfromcustomernumber.html b/documentation/_site_rebuild_20260317/getuseridfromcustomernumber.html new file mode 100644 index 00000000..771e2d21 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getuseridfromcustomernumber.html @@ -0,0 +1,25 @@ + +Get user ID from customer number | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get user ID from customer number

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /admin/customer/getUserId

Operation

Operation ID: getUserIdFromCustomerNumber

Convert e-conomic customer number to internal user ID

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

yes

integer

Responses

Status

Description

Content Types

200

User ID retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "properties": { + "user_id": { + "type": "integer" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getuserinvoices.html b/documentation/_site_rebuild_20260317/getuserinvoices.html new file mode 100644 index 00000000..357c5ae3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getuserinvoices.html @@ -0,0 +1,18 @@ + +Get user invoices | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get user invoices

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /user/invoices

Operation

Operation ID: getUserInvoices

Retrieve invoices for the authenticated user

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

User invoices retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getuserkeys.html b/documentation/_site_rebuild_20260317/getuserkeys.html new file mode 100644 index 00000000..baf34618 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getuserkeys.html @@ -0,0 +1,18 @@ + +Get user keys | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get user keys

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/user/keys

Operation

Operation ID: getUserKeys

Get user keys

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

user_id

query

yes

integer

key

query

no

string

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getuserorder.html b/documentation/_site_rebuild_20260317/getuserorder.html new file mode 100644 index 00000000..40c6c3fb --- /dev/null +++ b/documentation/_site_rebuild_20260317/getuserorder.html @@ -0,0 +1,20 @@ + +Get user's specific order | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get user's specific order

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /user/order

Operation

Operation ID: getUserOrder

Get details of a specific order for the authenticated user

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Order retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/Order" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getuserorders.html b/documentation/_site_rebuild_20260317/getuserorders.html new file mode 100644 index 00000000..dbadf122 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getuserorders.html @@ -0,0 +1,23 @@ + +Get current user's orders | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get current user's orders

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /user/orders

Operation

Operation ID: getUserOrders

Retrieve orders for the authenticated user

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Orders retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Order" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getuserpermissions.html b/documentation/_site_rebuild_20260317/getuserpermissions.html new file mode 100644 index 00000000..039acf78 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getuserpermissions.html @@ -0,0 +1,18 @@ + +Get user permissions | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get user permissions

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /user/permissions

Operation

Operation ID: getUserPermissions

Get user permissions

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getuserswithvehiclesubscriptions.html b/documentation/_site_rebuild_20260317/getuserswithvehiclesubscriptions.html new file mode 100644 index 00000000..7316ea5f --- /dev/null +++ b/documentation/_site_rebuild_20260317/getuserswithvehiclesubscriptions.html @@ -0,0 +1,18 @@ + +Get users with vehicle subscriptions | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get users with vehicle subscriptions

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/users-with-vehicle-subscriptions

Operation

Operation ID: getUsersWithVehicleSubscriptions

Get users with vehicle subscriptions

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getvehiclecustomersuggestions.html b/documentation/_site_rebuild_20260317/getvehiclecustomersuggestions.html new file mode 100644 index 00000000..c6c04a94 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getvehiclecustomersuggestions.html @@ -0,0 +1,18 @@ + +Get vehicle customer suggestions | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get vehicle customer suggestions

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/vehicle/customer-suggestions

Operation

Operation ID: getVehicleCustomerSuggestions

Get vehicle customer suggestions

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

reg

query

yes

string

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getvehiclestatus.html b/documentation/_site_rebuild_20260317/getvehiclestatus.html new file mode 100644 index 00000000..3e5414a1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getvehiclestatus.html @@ -0,0 +1,18 @@ + +Get vehicle status | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get vehicle status

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /vehicles/status

Operation

Operation ID: getVehicleStatus

Get vehicle status

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

reg

query

yes

string

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getvirkdataconfig.html b/documentation/_site_rebuild_20260317/getvirkdataconfig.html new file mode 100644 index 00000000..5a846a96 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getvirkdataconfig.html @@ -0,0 +1,20 @@ + +Get Virkdata config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get Virkdata config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /virkdata/config

Operation

Operation ID: getVirkdataConfig

Get Virkdata config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Virkdata configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/VirkdataConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getweatherapiconfig.html b/documentation/_site_rebuild_20260317/getweatherapiconfig.html new file mode 100644 index 00000000..02109b19 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getweatherapiconfig.html @@ -0,0 +1,20 @@ + +Get WeatherAPI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get WeatherAPI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /weatherapi/config

Operation

Operation ID: getWeatherApiConfig

Get WeatherAPI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

WeatherAPI configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/WeatherApiConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getworkerlicenseplates.html b/documentation/_site_rebuild_20260317/getworkerlicenseplates.html new file mode 100644 index 00000000..3b82a498 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getworkerlicenseplates.html @@ -0,0 +1,18 @@ + +Get unique license plates | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get unique license plates

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /worker/licenseplates

Operation

Operation ID: getWorkerLicensePlates

Fetch all unique license plates from various database tables

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

License plates retrieved successfully

application/json

403

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getworkerstatus.html b/documentation/_site_rebuild_20260317/getworkerstatus.html new file mode 100644 index 00000000..0c5abaaa --- /dev/null +++ b/documentation/_site_rebuild_20260317/getworkerstatus.html @@ -0,0 +1,18 @@ + +Get worker status | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get worker status

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /worker/status

Operation

Operation ID: getWorkerStatus

Get detailed status of the system worker

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Worker status retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getworkerversion.html b/documentation/_site_rebuild_20260317/getworkerversion.html new file mode 100644 index 00000000..42d4f1c0 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getworkerversion.html @@ -0,0 +1,18 @@ + +Get worker version | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get worker version

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /worker/version

Operation

Operation ID: getWorkerVersion

Get the current version of the system worker

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Worker version retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getxlvaskconfig.html b/documentation/_site_rebuild_20260317/getxlvaskconfig.html new file mode 100644 index 00000000..e8d0dc7c --- /dev/null +++ b/documentation/_site_rebuild_20260317/getxlvaskconfig.html @@ -0,0 +1,20 @@ + +Get XLVask config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get XLVask config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /xlvask/config

Operation

Operation ID: getXlvaskConfig

Get XLVask config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

XLVask configuration retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/XlvaskConfigListResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getxlvaskusagelogs.html b/documentation/_site_rebuild_20260317/getxlvaskusagelogs.html new file mode 100644 index 00000000..8674186f --- /dev/null +++ b/documentation/_site_rebuild_20260317/getxlvaskusagelogs.html @@ -0,0 +1,18 @@ + +Get XLVask usage logs | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get XLVask usage logs

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/xlvask/usageLog

Operation

Operation ID: getXlvaskUsageLogs

Retrieve usage logs from XLVask system

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Usage logs retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getxlvaskusageorders.html b/documentation/_site_rebuild_20260317/getxlvaskusageorders.html new file mode 100644 index 00000000..de4cdf10 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getxlvaskusageorders.html @@ -0,0 +1,18 @@ + +Get XLVask usage orders | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get XLVask usage orders

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/xlvask/services/usage/orders

Operation

Operation ID: getXlvaskUsageOrders

Get XLVask usage orders

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getxlvaskusageordersfastlink.html b/documentation/_site_rebuild_20260317/getxlvaskusageordersfastlink.html new file mode 100644 index 00000000..64ba9a49 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getxlvaskusageordersfastlink.html @@ -0,0 +1,18 @@ + +Get XLVask usage orders fast link | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get XLVask usage orders fast link

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/xlvask/services/usage/orders/fast-link

Operation

Operation ID: getXlvaskUsageOrdersFastLink

Get XLVask usage orders fast link

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/getyesterdayincome.html b/documentation/_site_rebuild_20260317/getyesterdayincome.html new file mode 100644 index 00000000..c6b2a2e8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/getyesterdayincome.html @@ -0,0 +1,18 @@ + +Get yesterday's income | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get yesterday's income

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /statistics/income/yesterday

Operation

Operation ID: getYesterdayIncome

Get income statistics for yesterday

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Yesterday&#x27;s income statistics retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/importeconomiccustomers.html b/documentation/_site_rebuild_20260317/importeconomiccustomers.html new file mode 100644 index 00000000..34c0f989 --- /dev/null +++ b/documentation/_site_rebuild_20260317/importeconomiccustomers.html @@ -0,0 +1,20 @@ + +Import e-conomic customers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Import e-conomic customers

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /economic/customers/import

Operation

Operation ID: importEconomicCustomers

Import customers from e-conomic

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Customers imported successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/index.html b/documentation/_site_rebuild_20260317/index.html new file mode 100644 index 00000000..a56f03b7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/index.html @@ -0,0 +1,11 @@ + + + + +You will be redirected shortly + +

Redirecting…

+Click here if you are not redirected. + + + diff --git a/documentation/_site_rebuild_20260317/introduction.html b/documentation/_site_rebuild_20260317/introduction.html new file mode 100644 index 00000000..58f47b9c --- /dev/null +++ b/documentation/_site_rebuild_20260317/introduction.html @@ -0,0 +1,16 @@ + +Introduction | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Introduction

Welcome to the Copenhagen Truck Wash API documentation.

This API provides access to the Copenhagen Truck Wash system, allowing you to manage wash bookings, vehicle data, and customer information.

Overview

The API is built on REST principles and returns JSON-encoded responses.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listbackupmodules.html b/documentation/_site_rebuild_20260317/listbackupmodules.html new file mode 100644 index 00000000..eab5b451 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listbackupmodules.html @@ -0,0 +1,18 @@ + +List backup modules | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List backup modules

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/backup/backups

Operation

Operation ID: listBackupModules

List backup modules

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listbookings.html b/documentation/_site_rebuild_20260317/listbookings.html new file mode 100644 index 00000000..b58aa859 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listbookings.html @@ -0,0 +1,23 @@ + +List bookings | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List bookings

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /bookings

Operation

Operation ID: listBookings

Retrieve a list of bookings

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Bookings retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Booking" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listbrandingoptions.html b/documentation/_site_rebuild_20260317/listbrandingoptions.html new file mode 100644 index 00000000..d1757d4e --- /dev/null +++ b/documentation/_site_rebuild_20260317/listbrandingoptions.html @@ -0,0 +1,18 @@ + +List branding options | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List branding options

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /branding

Operation

Operation ID: listBrandingOptions

Retrieve a list of branding options or a specific branding option if ID is provided

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

no

object

Responses

Status

Description

Content Types

200

Branding options retrieved successfully

application/json

400

403

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listcategories.html b/documentation/_site_rebuild_20260317/listcategories.html new file mode 100644 index 00000000..535367c8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listcategories.html @@ -0,0 +1,23 @@ + +List categories | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List categories

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /categories

Operation

Operation ID: listCategories

Retrieve a list of product categories

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Categories retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listcollectedinvoices.html b/documentation/_site_rebuild_20260317/listcollectedinvoices.html new file mode 100644 index 00000000..613ed867 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listcollectedinvoices.html @@ -0,0 +1,18 @@ + +List collected invoices | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List collected invoices

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /collected-invoices

Operation

Operation ID: listCollectedInvoices

Get list of collected invoices

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Collected invoices retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listcustomers.html b/documentation/_site_rebuild_20260317/listcustomers.html new file mode 100644 index 00000000..fadb85cb --- /dev/null +++ b/documentation/_site_rebuild_20260317/listcustomers.html @@ -0,0 +1,18 @@ + +List customers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List customers

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /customers

Operation

Operation ID: listCustomers

List customers

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

barred

query

no

string

Optional e-conomic barred customer filter.

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listdailyreports.html b/documentation/_site_rebuild_20260317/listdailyreports.html new file mode 100644 index 00000000..30fc7d01 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listdailyreports.html @@ -0,0 +1,18 @@ + +List daily reports | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List daily reports

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments/daily-reports

Operation

Operation ID: listDailyReports

List daily reports

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listdepartmentgates.html b/documentation/_site_rebuild_20260317/listdepartmentgates.html new file mode 100644 index 00000000..9ccbe785 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listdepartmentgates.html @@ -0,0 +1,30 @@ + +List department gates | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List department gates

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/gates

Operation

Operation ID: listDepartmentGates

Retrieve department gates, optionally filtered by id

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

no

object

Responses

Status

Description

Content Types

200

Department gates retrieved successfully

application/json

401

Schema for response 200 (application/json):

+{ + "oneOf": [ + { + "$ref": "#/components/schemas/DepartmentGate" + }, + { + "items": { + "$ref": "#/components/schemas/DepartmentGate" + }, + "type": "array" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listdepartmentgoals.html b/documentation/_site_rebuild_20260317/listdepartmentgoals.html new file mode 100644 index 00000000..eb9b786c --- /dev/null +++ b/documentation/_site_rebuild_20260317/listdepartmentgoals.html @@ -0,0 +1,23 @@ + +List or get department goals | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List or get department goals

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /goals/department

Operation

Operation ID: listDepartmentGoals

Retrieve a list of department goals or a single goal when `id` is provided. Access control: - A user may only access goals where the goal&#x27;s `departments` set is a subset of the user&#x27;s departments. - Users with the `superuser` permission may access all goals.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

When provided, returns the single goal with this id (if accessible)

no

object

Responses

Status

Description

Content Types

200

Goals retrieved successfully

application/json

400

401

403

404

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/DepartmentGoal" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listdepartmentlanes.html b/documentation/_site_rebuild_20260317/listdepartmentlanes.html new file mode 100644 index 00000000..c3ccf5d7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listdepartmentlanes.html @@ -0,0 +1,23 @@ + +List department lanes | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List department lanes

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/lanes

Operation

Operation ID: listDepartmentLanes

Retrieve a list of all department lanes

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Department lanes retrieved successfully

application/json

401

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/DepartmentLane" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listdepartmentplatescanners.html b/documentation/_site_rebuild_20260317/listdepartmentplatescanners.html new file mode 100644 index 00000000..1f887fc8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listdepartmentplatescanners.html @@ -0,0 +1,18 @@ + +List department plate scanners | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List department plate scanners

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/numberplatescanners

Operation

Operation ID: listDepartmentPlateScanners

List department plate scanners

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Responses

Status

Description

Content Types

200

Department plate scanners retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listdepartmentrelays.html b/documentation/_site_rebuild_20260317/listdepartmentrelays.html new file mode 100644 index 00000000..1a67efee --- /dev/null +++ b/documentation/_site_rebuild_20260317/listdepartmentrelays.html @@ -0,0 +1,30 @@ + +List department relays | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List department relays

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/relays

Operation

Operation ID: listDepartmentRelays

Retrieve department relays, optionally filtered by id

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

no

object

Responses

Status

Description

Content Types

200

Department relays retrieved successfully

application/json

401

Schema for response 200 (application/json):

+{ + "oneOf": [ + { + "$ref": "#/components/schemas/DepartmentRelay" + }, + { + "items": { + "$ref": "#/components/schemas/DepartmentRelay" + }, + "type": "array" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listdepartments.html b/documentation/_site_rebuild_20260317/listdepartments.html new file mode 100644 index 00000000..1ae2ee22 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listdepartments.html @@ -0,0 +1,23 @@ + +List departments | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List departments

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /departments

Operation

Operation ID: listDepartments

Retrieve a list of all visible departments

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

Filter by specific department ID

no

object

Responses

Status

Description

Content Types

200

Departments retrieved successfully

application/json

401

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Department" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listdraftinvoices.html b/documentation/_site_rebuild_20260317/listdraftinvoices.html new file mode 100644 index 00000000..c17f4f95 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listdraftinvoices.html @@ -0,0 +1,18 @@ + +List draft invoices | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List draft invoices

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /invoices/draft

Operation

Operation ID: listDraftInvoices

Retrieve a list of draft invoices

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Draft invoices retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listentrausers.html b/documentation/_site_rebuild_20260317/listentrausers.html new file mode 100644 index 00000000..5f7306ce --- /dev/null +++ b/documentation/_site_rebuild_20260317/listentrausers.html @@ -0,0 +1,18 @@ + +List Microsoft Entra users | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List Microsoft Entra users

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/entra/users

Operation

Operation ID: listEntraUsers

Get list of users from Microsoft Entra (Azure AD)

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Entra users retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listguestdepartments.html b/documentation/_site_rebuild_20260317/listguestdepartments.html new file mode 100644 index 00000000..06a15ba1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listguestdepartments.html @@ -0,0 +1,23 @@ + +List public departments | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List public departments

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /guest/departments

Operation

Operation ID: listGuestDepartments

Get list of departments without authentication

Authentication

No authentication required.

Parameters

Name

In

Required

Type

Description

include_lanes

query

no

boolean

Whether to include lane status and self-serve information

Responses

Status

Description

Content Types

200

Departments retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/DepartmentGuest" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listmoduleactionlogs.html b/documentation/_site_rebuild_20260317/listmoduleactionlogs.html new file mode 100644 index 00000000..e53b9721 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listmoduleactionlogs.html @@ -0,0 +1,23 @@ + +List module action logs | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List module action logs

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/action-logs

Operation

Operation ID: listModuleActionLogs

Retrieve a paginated list of module action logs with searching and filtering

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Module action logs retrieved successfully

application/json

401

403

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/ModuleActionLog" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listnotifications.html b/documentation/_site_rebuild_20260317/listnotifications.html new file mode 100644 index 00000000..514456d2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listnotifications.html @@ -0,0 +1,23 @@ + +List notifications | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List notifications

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /notifications

Operation

Operation ID: listNotifications

Get list of notifications

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Notifications retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Notification" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listorderattachments.html b/documentation/_site_rebuild_20260317/listorderattachments.html new file mode 100644 index 00000000..07d4cfaa --- /dev/null +++ b/documentation/_site_rebuild_20260317/listorderattachments.html @@ -0,0 +1,18 @@ + +List order attachments | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List order attachments

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /orders/attachments

Operation

Operation ID: listOrderAttachments

Get attachments for an order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

order_id

query

yes

integer

Responses

Status

Description

Content Types

200

Order attachments retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listorderbookings.html b/documentation/_site_rebuild_20260317/listorderbookings.html new file mode 100644 index 00000000..1abe46dd --- /dev/null +++ b/documentation/_site_rebuild_20260317/listorderbookings.html @@ -0,0 +1,18 @@ + +List order bookings | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List order bookings

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /order-bookings

Operation

Operation ID: listOrderBookings

List order bookings

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listorderitems.html b/documentation/_site_rebuild_20260317/listorderitems.html new file mode 100644 index 00000000..d958b8e1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listorderitems.html @@ -0,0 +1,23 @@ + +List order items | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List order items

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /order/items

Operation

Operation ID: listOrderItems

Get all items for a specific order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

order_id

query

yes

integer

Responses

Status

Description

Content Types

200

Order items retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/OrderItem" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listorders.html b/documentation/_site_rebuild_20260317/listorders.html new file mode 100644 index 00000000..345b110c --- /dev/null +++ b/documentation/_site_rebuild_20260317/listorders.html @@ -0,0 +1,23 @@ + +List orders | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List orders

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /orders

Operation

Operation ID: listOrders

Retrieve a paginated list of orders

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

show_wash_subscription

query

no

string

Responses

Status

Description

Content Types

200

Orders retrieved successfully

application/json

401

403

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Order" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listpasskeys.html b/documentation/_site_rebuild_20260317/listpasskeys.html new file mode 100644 index 00000000..34745d14 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listpasskeys.html @@ -0,0 +1,27 @@ + +List passkeys for the authenticated user | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List passkeys for the authenticated user

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /account/security/passkeys

Operation

Operation ID: listPasskeys

List passkeys for the authenticated user

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

A list of passkeys

application/json

400

Invalid session or request

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Passkey" + }, + "type": "array" +} +

Schema for response 400 (application/json):

+{ + "$ref": "#/components/schemas/Error" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listpermissions.html b/documentation/_site_rebuild_20260317/listpermissions.html new file mode 100644 index 00000000..df7871ab --- /dev/null +++ b/documentation/_site_rebuild_20260317/listpermissions.html @@ -0,0 +1,23 @@ + +List permissions | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List permissions

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /permissions

Operation

Operation ID: listPermissions

Get list of all available permissions

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Permissions retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Permission" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listplatescanners.html b/documentation/_site_rebuild_20260317/listplatescanners.html new file mode 100644 index 00000000..37adf877 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listplatescanners.html @@ -0,0 +1,18 @@ + +List plate scanners | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List plate scanners

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /numberplatescanners

Operation

Operation ID: listPlateScanners

Get a list of all number plate scanners

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Plate scanners retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listplatescans.html b/documentation/_site_rebuild_20260317/listplatescans.html new file mode 100644 index 00000000..0df93fb7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listplatescans.html @@ -0,0 +1,18 @@ + +List plate scans | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List plate scans

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /numberplatescans

Operation

Operation ID: listPlateScans

Get a list of license plate scans

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Plate scans retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listproducts.html b/documentation/_site_rebuild_20260317/listproducts.html new file mode 100644 index 00000000..183fbb02 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listproducts.html @@ -0,0 +1,23 @@ + +List products | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List products

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /products

Operation

Operation ID: listProducts

Retrieve a list of products with optional filters for customer pricing and department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_id

query

no

integer

Customer ID for custom pricing

department_id

query

no

integer

Department ID for department-specific pricing

category

query

no

integer

Filter by category ID

id

query

no

integer

Get specific product by ID

final_price

query

no

boolean

Whether to return final prices including discounts

no

object

Responses

Status

Description

Content Types

200

Products retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/Product" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listroles.html b/documentation/_site_rebuild_20260317/listroles.html new file mode 100644 index 00000000..e9c21d2d --- /dev/null +++ b/documentation/_site_rebuild_20260317/listroles.html @@ -0,0 +1,18 @@ + +List roles | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List roles

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /roles

Operation

Operation ID: listRoles

List roles

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listselfserveconditionrules.html b/documentation/_site_rebuild_20260317/listselfserveconditionrules.html new file mode 100644 index 00000000..7fad9ed6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listselfserveconditionrules.html @@ -0,0 +1,23 @@ + +List self-serve condition rules | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List self-serve condition rules

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/condition/rules

Operation

Operation ID: listSelfserveConditionRules

Retrieve a list of self-serve condition rules.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

Filter by rule ID

condition_id

query

no

integer

Filter by condition ID

type

query

no

string

Filter by rule type

object_type

query

no

string

Filter by object type

object_id

query

no

integer

Filter by object ID

no

object

Responses

Status

Description

Content Types

200

Successfully retrieved condition rules

application/json

400

404

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveConditionRule" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listselfserveconditions.html b/documentation/_site_rebuild_20260317/listselfserveconditions.html new file mode 100644 index 00000000..c2c5b23a --- /dev/null +++ b/documentation/_site_rebuild_20260317/listselfserveconditions.html @@ -0,0 +1,23 @@ + +List self-serve conditions | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List self-serve conditions

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/conditions

Operation

Operation ID: listSelfserveConditions

Retrieve a list of self-serve conditions for a department, lane, or product.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

Filter by condition ID

department

query

no

integer

Filter by department ID

lane

query

no

integer

Filter by lane ID

product

query

no

integer

Filter by product ID

condition_id

query

no

integer

Filter by condition ID

machine_type_id

query

no

integer

Filter by reusable machine type ID

no

object

Responses

Status

Description

Content Types

200

Successfully retrieved conditions

application/json

400

404

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveCondition" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listselfservemachinetypes.html b/documentation/_site_rebuild_20260317/listselfservemachinetypes.html new file mode 100644 index 00000000..4ae248cd --- /dev/null +++ b/documentation/_site_rebuild_20260317/listselfservemachinetypes.html @@ -0,0 +1,30 @@ + +List reusable self-serve machine types | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List reusable self-serve machine types

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/machine-types

Operation

Operation ID: listSelfserveMachineTypes

List reusable self-serve machine types

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

no

object

Responses

Status

Description

Content Types

200

Successfully retrieved machine types

application/json

404

Schema for response 200 (application/json):

+{ + "oneOf": [ + { + "$ref": "#/components/schemas/SelfserveMachineType" + }, + { + "items": { + "$ref": "#/components/schemas/SelfserveMachineType" + }, + "type": "array" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listselfservequestions.html b/documentation/_site_rebuild_20260317/listselfservequestions.html new file mode 100644 index 00000000..227c7b82 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listselfservequestions.html @@ -0,0 +1,23 @@ + +List self-serve questions | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List self-serve questions

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/questions

Operation

Operation ID: listSelfserveQuestions

Retrieve a list of self-serve questions for a department, lane, or product.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

Filter by question ID

department

query

no

integer

Filter by department ID

lane

query

no

integer

Filter by lane ID

product

query

no

integer

Filter by product ID

no

object

Responses

Status

Description

Content Types

200

Successfully retrieved questions

application/json

400

404

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveQuestion" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listselfservetaskattachments.html b/documentation/_site_rebuild_20260317/listselfservetaskattachments.html new file mode 100644 index 00000000..9e393e57 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listselfservetaskattachments.html @@ -0,0 +1,18 @@ + +List task attachments | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List task attachments

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/tasks/attachments

Operation

Operation ID: listSelfserveTaskAttachments

Retrieve a list of attachments for a specific self-serve task.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Task ID

Responses

Status

Description

Content Types

200

Successfully retrieved task attachments

application/json

400

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listselfservetasks.html b/documentation/_site_rebuild_20260317/listselfservetasks.html new file mode 100644 index 00000000..ee0cae72 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listselfservetasks.html @@ -0,0 +1,23 @@ + +List self-serve tasks | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List self-serve tasks

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/tasks

Operation

Operation ID: listSelfserveTasks

Retrieve a list of self-serve tasks for a department, lane, product, or condition_id.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

Filter by task ID

department

query

no

integer

Filter by department ID

lane

query

no

integer

Filter by lane ID

product

query

no

integer

Filter by product ID

condition_id

query

no

integer

Filter by condition ID

no

object

Responses

Status

Description

Content Types

200

Successfully retrieved tasks

application/json

400

404

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveTask" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listselfservevehicleconditions.html b/documentation/_site_rebuild_20260317/listselfservevehicleconditions.html new file mode 100644 index 00000000..731ec357 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listselfservevehicleconditions.html @@ -0,0 +1,23 @@ + +List vehicle conditions | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List vehicle conditions

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /department/selfserve/vehicle/conditions

Operation

Operation ID: listSelfserveVehicleConditions

Retrieve a list of vehicle conditions for a department, lane, reg, or question. Customers will only see their own vehicle conditions.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

no

integer

Filter by condition ID

department

query

no

integer

Filter by department ID

lane

query

no

integer

Filter by lane ID

reg

query

no

string

Filter by vehicle registration number

question

query

no

integer

Filter by question ID

customer_id

query

no

integer

Filter by customer ID

no

object

Responses

Status

Description

Content Types

200

Successfully retrieved vehicle conditions

application/json

400

404

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveVehicleCondition" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/liststripecustomers.html b/documentation/_site_rebuild_20260317/liststripecustomers.html new file mode 100644 index 00000000..d2157a85 --- /dev/null +++ b/documentation/_site_rebuild_20260317/liststripecustomers.html @@ -0,0 +1,18 @@ + +List Stripe customers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List Stripe customers

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/stripe/customers

Operation

Operation ID: listStripeCustomers

Get list of Stripe customers

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Stripe customers retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/liststripeprices.html b/documentation/_site_rebuild_20260317/liststripeprices.html new file mode 100644 index 00000000..b4af8595 --- /dev/null +++ b/documentation/_site_rebuild_20260317/liststripeprices.html @@ -0,0 +1,18 @@ + +List Stripe prices | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List Stripe prices

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/stripe/prices

Operation

Operation ID: listStripePrices

Get list of Stripe prices

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Stripe prices retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/liststripeproducts.html b/documentation/_site_rebuild_20260317/liststripeproducts.html new file mode 100644 index 00000000..722c5cf1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/liststripeproducts.html @@ -0,0 +1,18 @@ + +List Stripe products | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List Stripe products

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/stripe/products

Operation

Operation ID: listStripeProducts

Get list of Stripe products

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Stripe products retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/liststripeterminallocations.html b/documentation/_site_rebuild_20260317/liststripeterminallocations.html new file mode 100644 index 00000000..00a4599c --- /dev/null +++ b/documentation/_site_rebuild_20260317/liststripeterminallocations.html @@ -0,0 +1,18 @@ + +List Stripe terminal locations | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List Stripe terminal locations

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/stripe/terminal/locations

Operation

Operation ID: listStripeTerminalLocations

List Stripe terminal locations

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/liststripeterminalreaders.html b/documentation/_site_rebuild_20260317/liststripeterminalreaders.html new file mode 100644 index 00000000..0e67ce6f --- /dev/null +++ b/documentation/_site_rebuild_20260317/liststripeterminalreaders.html @@ -0,0 +1,18 @@ + +List Stripe terminal readers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List Stripe terminal readers

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/stripe/terminal/readers

Operation

Operation ID: listStripeTerminalReaders

List Stripe terminal readers

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listsubusergrants.html b/documentation/_site_rebuild_20260317/listsubusergrants.html new file mode 100644 index 00000000..b4b4511d --- /dev/null +++ b/documentation/_site_rebuild_20260317/listsubusergrants.html @@ -0,0 +1,28 @@ + +List subuser grants | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List subuser grants

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /subusers/grants

Operation

Operation ID: listSubuserGrants

Returns subuser grant records filtered by `customer_number` and/or `subuser_id`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

customer_number

query

no

integer

e-conomic customer number to filter by

subuser_id

query

no

integer

Subuser ID to filter by

Responses

Status

Description

Content Types

200

Grants fetched

application/json

400

401

500

Schema for response 200 (application/json):

+{ + "properties": { + "grants": { + "items": { + "$ref": "#/components/schemas/SubuserGrant" + }, + "type": "array" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listsubuserpermissionnodes.html b/documentation/_site_rebuild_20260317/listsubuserpermissionnodes.html new file mode 100644 index 00000000..aadb2248 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listsubuserpermissionnodes.html @@ -0,0 +1,28 @@ + +List available subuser permission nodes | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List available subuser permission nodes

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /subusers/permission-nodes

Operation

Operation ID: listSubuserPermissionNodes

Returns grouped permission nodes available for subuser grants.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Permission nodes fetched

application/json

401

500

Schema for response 200 (application/json):

+{ + "properties": { + "permission_nodes": { + "items": { + "$ref": "#/components/schemas/PermissionNodeGroup" + }, + "type": "array" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listsubusers.html b/documentation/_site_rebuild_20260317/listsubusers.html new file mode 100644 index 00000000..10ea5d29 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listsubusers.html @@ -0,0 +1,75 @@ + +List subusers visible to the authenticated user | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List subusers visible to the authenticated user

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /subusers

Operation

Operation ID: listSubusers

Returns a paginated list of subusers (drivers) that have enabled grants tied to the authenticated user&#x27;s customer number. Only subusers with at least one enabled, non-deleted grant for the caller&#x27;s customer are returned.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

page

query

no

integer

limit

query

no

integer

search

query

no

string

include_non_enabled

query

no

boolean

Include subusers that only have non-enabled grants (default false)

Responses

Status

Description

Content Types

200

List of visible subusers

application/json

401

500

Schema for response 200 (application/json):

+{ + "items": { + "properties": { + "created_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "email": { + "format": "email", + "nullable": true, + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "nullable": true, + "type": "string" + }, + "permissions": { + "description": "Aggregated permission keys granted for the caller's customer", + "items": { + "type": "string" + }, + "type": "array" + }, + "phone": { + "nullable": true, + "type": "integer" + }, + "phone_country_code": { + "nullable": true, + "type": "integer" + }, + "suspended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "two_factor_enabled": { + "description": "Indicates if 2FA is enabled for this account", + "type": "boolean" + }, + "updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listsuperuserdepartments.html b/documentation/_site_rebuild_20260317/listsuperuserdepartments.html new file mode 100644 index 00000000..69e3472e --- /dev/null +++ b/documentation/_site_rebuild_20260317/listsuperuserdepartments.html @@ -0,0 +1,18 @@ + +List departments (superuser) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List departments (superuser)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /superuser/department

Operation

Operation ID: listSuperuserDepartments

List departments (superuser)

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listusers.html b/documentation/_site_rebuild_20260317/listusers.html new file mode 100644 index 00000000..8642c8d6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listusers.html @@ -0,0 +1,23 @@ + +List users | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List users

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /users

Operation

Operation ID: listUsers

Retrieve a paginated list of users

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Responses

Status

Description

Content Types

200

Users retrieved successfully

application/json

400

401

Schema for response 200 (application/json):

+{ + "items": { + "$ref": "#/components/schemas/User" + }, + "type": "array" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listvehicles.html b/documentation/_site_rebuild_20260317/listvehicles.html new file mode 100644 index 00000000..a4efe4cc --- /dev/null +++ b/documentation/_site_rebuild_20260317/listvehicles.html @@ -0,0 +1,30 @@ + +List vehicles | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List vehicles

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /vehicles

Operation

Operation ID: listVehicles

List vehicles or fetch a specific vehicle when `id` is provided. - When `id` is present, returns a single vehicle object (404 if not found). - Otherwise returns a paginated list of vehicles. Permissions: - Own scope: `list_own_vehicles` (linked to subuser node `VEHICLES_LIST`). - Broader scope: `list_vehicles_other`. Subusers may specify header `X-Customer-Number` to target a specific customer. If the broader permission is missing, the list will automatically be restricted to the effective customer context.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

id

query

no

integer

reg

query

no

string

customer_id

query

no

integer

Responses

Status

Description

Content Types

200

Vehicle(s) retrieved successfully

application/json

403

404

Schema for response 200 (application/json):

+{ + "oneOf": [ + { + "$ref": "#/components/schemas/Vehicle" + }, + { + "items": { + "$ref": "#/components/schemas/Vehicle" + }, + "type": "array" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listwashcertificates.html b/documentation/_site_rebuild_20260317/listwashcertificates.html new file mode 100644 index 00000000..16035dba --- /dev/null +++ b/documentation/_site_rebuild_20260317/listwashcertificates.html @@ -0,0 +1,18 @@ + +List wash certificates | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List wash certificates

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/washcertificates

Operation

Operation ID: listWashCertificates

List wash certificates

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listxlvaskcustomers.html b/documentation/_site_rebuild_20260317/listxlvaskcustomers.html new file mode 100644 index 00000000..b27ac4d8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/listxlvaskcustomers.html @@ -0,0 +1,18 @@ + +List XLVask customers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List XLVask customers

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/xlvask/customers

Operation

Operation ID: listXlvaskCustomers

Get list of customers from XLVask

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

XLVask customers retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/listxlvaskvehicles.html b/documentation/_site_rebuild_20260317/listxlvaskvehicles.html new file mode 100644 index 00000000..d7d3234b --- /dev/null +++ b/documentation/_site_rebuild_20260317/listxlvaskvehicles.html @@ -0,0 +1,18 @@ + +List XLVask vehicles | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

List XLVask vehicles

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/xlvask/vehicles

Operation

Operation ID: listXlvaskVehicles

Get list of vehicles from XLVask

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

XLVask vehicles retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/logout.html b/documentation/_site_rebuild_20260317/logout.html new file mode 100644 index 00000000..18160664 --- /dev/null +++ b/documentation/_site_rebuild_20260317/logout.html @@ -0,0 +1,26 @@ + +Logout | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Logout

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /auth/logout

Operation

Operation ID: logout

Invalidate the current authentication token

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Responses

Status

Description

Content Types

200

Logout successful

application/json

401

Schema for response 200 (application/json):

+{ + "properties": { + "message": { + "example": "Logged out", + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/lookupcvr.html b/documentation/_site_rebuild_20260317/lookupcvr.html new file mode 100644 index 00000000..ee883da4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/lookupcvr.html @@ -0,0 +1,18 @@ + +Lookup CVR information | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Lookup CVR information

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /cvr/lookup

Operation

Operation ID: lookupCvr

Get detailed information for a CVR number

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

cvr

query

yes

string

Responses

Status

Description

Content Types

200

CVR information retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/markordercompleted.html b/documentation/_site_rebuild_20260317/markordercompleted.html new file mode 100644 index 00000000..458d30d9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/markordercompleted.html @@ -0,0 +1,30 @@ + +Mark order as completed | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Mark order as completed

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /orders/mark_as_completed

Operation

Operation ID: markOrderCompleted

Mark an order as completed

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Order marked as completed successfully

application/json

400

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-action-logs-page-1.html b/documentation/_site_rebuild_20260317/modules-module-action-logs-page-1.html new file mode 100644 index 00000000..cd4eb0e2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-action-logs-page-1.html @@ -0,0 +1,16 @@ + +Action Logs - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-action-logs.html b/documentation/_site_rebuild_20260317/modules-module-action-logs.html new file mode 100644 index 00000000..560a1377 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-action-logs.html @@ -0,0 +1,16 @@ + +Action Logs | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-backup-page-1.html b/documentation/_site_rebuild_20260317/modules-module-backup-page-1.html new file mode 100644 index 00000000..94bd95df --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-backup-page-1.html @@ -0,0 +1,16 @@ + +Backup - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-backup.html b/documentation/_site_rebuild_20260317/modules-module-backup.html new file mode 100644 index 00000000..479731c6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-backup.html @@ -0,0 +1,16 @@ + +Backup | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-cvr-page-1.html b/documentation/_site_rebuild_20260317/modules-module-cvr-page-1.html new file mode 100644 index 00000000..91d79158 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-cvr-page-1.html @@ -0,0 +1,16 @@ + +CVR - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-cvr.html b/documentation/_site_rebuild_20260317/modules-module-cvr.html new file mode 100644 index 00000000..5cb67425 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-cvr.html @@ -0,0 +1,16 @@ + +CVR | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-e-conomic-page-1.html b/documentation/_site_rebuild_20260317/modules-module-e-conomic-page-1.html new file mode 100644 index 00000000..6af702fc --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-e-conomic-page-1.html @@ -0,0 +1,16 @@ + +e-conomic - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-e-conomic.html b/documentation/_site_rebuild_20260317/modules-module-e-conomic.html new file mode 100644 index 00000000..4fdae646 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-e-conomic.html @@ -0,0 +1,16 @@ + +e-conomic | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

e-conomic

Accounting and invoicing integration with e-conomic.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-entra-page-1.html b/documentation/_site_rebuild_20260317/modules-module-entra-page-1.html new file mode 100644 index 00000000..9213660e --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-entra-page-1.html @@ -0,0 +1,16 @@ + +Entra - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-entra.html b/documentation/_site_rebuild_20260317/modules-module-entra.html new file mode 100644 index 00000000..f00df5de --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-entra.html @@ -0,0 +1,16 @@ + +Entra | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-fxratesapi-page-1.html b/documentation/_site_rebuild_20260317/modules-module-fxratesapi-page-1.html new file mode 100644 index 00000000..c47295df --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-fxratesapi-page-1.html @@ -0,0 +1,16 @@ + +FXRatesAPI - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-fxratesapi.html b/documentation/_site_rebuild_20260317/modules-module-fxratesapi.html new file mode 100644 index 00000000..74906509 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-fxratesapi.html @@ -0,0 +1,16 @@ + +FXRatesAPI | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-motorapi-page-1.html b/documentation/_site_rebuild_20260317/modules-module-motorapi-page-1.html new file mode 100644 index 00000000..296f3063 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-motorapi-page-1.html @@ -0,0 +1,16 @@ + +MotorAPI - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-motorapi.html b/documentation/_site_rebuild_20260317/modules-module-motorapi.html new file mode 100644 index 00000000..9093ab95 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-motorapi.html @@ -0,0 +1,16 @@ + +MotorAPI | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-self-serve-page-1.html b/documentation/_site_rebuild_20260317/modules-module-self-serve-page-1.html new file mode 100644 index 00000000..e87c3930 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-self-serve-page-1.html @@ -0,0 +1,16 @@ + +Self-Serve - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Self-Serve - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-self-serve.html b/documentation/_site_rebuild_20260317/modules-module-self-serve.html new file mode 100644 index 00000000..03a57f1b --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-self-serve.html @@ -0,0 +1,16 @@ + +Self-Serve | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Self-Serve

Self-serve lane control and machine command endpoints.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-stripe-page-1.html b/documentation/_site_rebuild_20260317/modules-module-stripe-page-1.html new file mode 100644 index 00000000..8c3cc0c3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-stripe-page-1.html @@ -0,0 +1,16 @@ + +Stripe - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-stripe.html b/documentation/_site_rebuild_20260317/modules-module-stripe.html new file mode 100644 index 00000000..8ccd9e93 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-stripe.html @@ -0,0 +1,16 @@ + +Stripe | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-virkdata-page-1.html b/documentation/_site_rebuild_20260317/modules-module-virkdata-page-1.html new file mode 100644 index 00000000..53fa1cbe --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-virkdata-page-1.html @@ -0,0 +1,16 @@ + +VirkData - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-virkdata.html b/documentation/_site_rebuild_20260317/modules-module-virkdata.html new file mode 100644 index 00000000..529783b6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-virkdata.html @@ -0,0 +1,16 @@ + +VirkData | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-wash-certificates-page-1.html b/documentation/_site_rebuild_20260317/modules-module-wash-certificates-page-1.html new file mode 100644 index 00000000..153afd8e --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-wash-certificates-page-1.html @@ -0,0 +1,16 @@ + +Wash Certificates - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-wash-certificates.html b/documentation/_site_rebuild_20260317/modules-module-wash-certificates.html new file mode 100644 index 00000000..bde99709 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-wash-certificates.html @@ -0,0 +1,16 @@ + +Wash Certificates | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-weatherapi-page-1.html b/documentation/_site_rebuild_20260317/modules-module-weatherapi-page-1.html new file mode 100644 index 00000000..94e9349f --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-weatherapi-page-1.html @@ -0,0 +1,16 @@ + +WeatherAPI - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-weatherapi.html b/documentation/_site_rebuild_20260317/modules-module-weatherapi.html new file mode 100644 index 00000000..74a5c69c --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-weatherapi.html @@ -0,0 +1,16 @@ + +WeatherAPI | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-xlvask-page-1.html b/documentation/_site_rebuild_20260317/modules-module-xlvask-page-1.html new file mode 100644 index 00000000..1ad99025 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-xlvask-page-1.html @@ -0,0 +1,16 @@ + +XLVask - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/modules-module-xlvask.html b/documentation/_site_rebuild_20260317/modules-module-xlvask.html new file mode 100644 index 00000000..0f63bc31 --- /dev/null +++ b/documentation/_site_rebuild_20260317/modules-module-xlvask.html @@ -0,0 +1,16 @@ + +XLVask | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/motorapilookup.html b/documentation/_site_rebuild_20260317/motorapilookup.html new file mode 100644 index 00000000..86ca3e84 --- /dev/null +++ b/documentation/_site_rebuild_20260317/motorapilookup.html @@ -0,0 +1,18 @@ + +Lookup vehicle via MotorAPI | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Lookup vehicle via MotorAPI

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/motorapi/lookup

Operation

Operation ID: motorApiLookup

Look up vehicle information using license plate

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

plate

query

yes

string

Responses

Status

Description

Content Types

200

Vehicle information retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/passkeychallenge.html b/documentation/_site_rebuild_20260317/passkeychallenge.html new file mode 100644 index 00000000..d08305ee --- /dev/null +++ b/documentation/_site_rebuild_20260317/passkeychallenge.html @@ -0,0 +1,92 @@ + +Initiate passkey authentication challenge | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Initiate passkey authentication challenge

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/passkey/challenge

Operation

Operation ID: passkeyChallenge

Generates a WebAuthn PublicKeyCredentialRequestOptions payload. If customer_number is provided, allowCredentials will be populated with existing passkeys for that account. Otherwise, a challenge is issued for discoverable credentials.

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "customer_number": { + "description": "Optional customer's e-conomic customer number", + "example": 12345, + "type": "integer" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + } + }, + "required": [ + "g_recaptcha_response" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Challenge generated

application/json

400

Schema for response 200 (application/json):

+{ + "properties": { + "challenge_token": { + "description": "Temporary token binding the challenge to the login attempt", + "type": "string" + }, + "publicKey": { + "properties": { + "allowCredentials": { + "items": { + "properties": { + "id": { + "description": "Base64URL-encoded credential ID", + "type": "string" + }, + "transports": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "example": "public-key", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "challenge": { + "description": "Base64URL-encoded challenge", + "type": "string" + }, + "rpId": { + "description": "Relying party ID (truckwash.io or localhost)", + "example": "truckwash.io", + "type": "string" + }, + "timeout": { + "description": "Timeout in milliseconds", + "type": "integer" + }, + "userVerification": { + "enum": [ + "required", + "preferred", + "discouraged" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/passkeyverify.html b/documentation/_site_rebuild_20260317/passkeyverify.html new file mode 100644 index 00000000..c7e0773a --- /dev/null +++ b/documentation/_site_rebuild_20260317/passkeyverify.html @@ -0,0 +1,118 @@ + +Verify passkey authentication and start session | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Verify passkey authentication and start session

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/passkey/verify

Operation

Operation ID: passkeyVerify

Verifies the WebAuthn assertion and challenge token. Returns a session token on success.

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "challenge_token": { + "description": "The token returned by the challenge endpoint", + "type": "string" + }, + "credential": { + "description": "The WebAuthn PublicKeyCredential object (assertion)", + "properties": { + "clientExtensionResults": { + "type": "object" + }, + "id": { + "description": "The credential ID (base64url)", + "type": "string" + }, + "rawId": { + "description": "The raw credential ID (base64url)", + "type": "string" + }, + "response": { + "properties": { + "authenticatorData": { + "description": "Base64URL-encoded authenticator data", + "type": "string" + }, + "clientDataJSON": { + "description": "Base64URL-encoded client data", + "type": "string" + }, + "signature": { + "description": "Base64URL-encoded signature", + "type": "string" + }, + "userHandle": { + "description": "Base64URL-encoded user handle", + "nullable": true, + "type": "string" + } + }, + "required": [ + "clientDataJSON", + "authenticatorData", + "signature" + ], + "type": "object" + }, + "type": { + "example": "public-key", + "type": "string" + } + }, + "required": [ + "id", + "rawId", + "type", + "response" + ], + "type": "object" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + } + }, + "required": [ + "challenge_token", + "credential", + "g_recaptcha_response" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Verification successful, session started

application/json

400

401

Schema for response 200 (application/json):

+{ + "oneOf": [ + { + "properties": { + "token": { + "description": "Bearer token for customer", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + { + "properties": { + "session": { + "description": "Session token for subuser", + "type": "string" + } + }, + "required": [ + "session" + ], + "type": "object" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/rebuildsystemsearchcache.html b/documentation/_site_rebuild_20260317/rebuildsystemsearchcache.html new file mode 100644 index 00000000..8902c4a5 --- /dev/null +++ b/documentation/_site_rebuild_20260317/rebuildsystemsearchcache.html @@ -0,0 +1,24 @@ + +Queue system search cache rebuild | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Queue system search cache rebuild

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /superuser/search/system/cache/rebuild

Operation

Operation ID: rebuildSystemSearchCache

Queues a cache rebuild request and clears active query/intent cache namespaces immediately.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{ + "$ref": "#/components/schemas/SystemSearchCacheRebuildRequest" +} +

Responses

Status

Description

Content Types

200

Cache rebuild queued successfully

application/json

401

403

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SystemSearchCacheRebuildResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/recorddepartmentplatescan.html b/documentation/_site_rebuild_20260317/recorddepartmentplatescan.html new file mode 100644 index 00000000..7e08615e --- /dev/null +++ b/documentation/_site_rebuild_20260317/recorddepartmentplatescan.html @@ -0,0 +1,34 @@ + +Record plate scan for department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Record plate scan for department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /numberplatescans/department

Operation

Operation ID: recordDepartmentPlateScan

Record plate scan for department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "department_id": { + "type": "integer" + }, + "plate": { + "type": "string" + } + }, + "required": [ + "plate", + "department_id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

201

Plate scan recorded successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/recordplatescan.html b/documentation/_site_rebuild_20260317/recordplatescan.html new file mode 100644 index 00000000..10fa5c36 --- /dev/null +++ b/documentation/_site_rebuild_20260317/recordplatescan.html @@ -0,0 +1,34 @@ + +Record plate scan | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Record plate scan

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /numberplatescans

Operation

Operation ID: recordPlateScan

Record a new license plate scan

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "lane_id": { + "type": "integer" + }, + "plate": { + "type": "string" + } + }, + "required": [ + "plate", + "lane_id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

201

Plate scan recorded successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/registercustomerbycvr.html b/documentation/_site_rebuild_20260317/registercustomerbycvr.html new file mode 100644 index 00000000..8e79b18f --- /dev/null +++ b/documentation/_site_rebuild_20260317/registercustomerbycvr.html @@ -0,0 +1,79 @@ + +Register new customer by CVR | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Register new customer by CVR

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/register/cvr

Operation

Operation ID: registerCustomerByCvr

Register a new customer account using Danish CVR number

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "companyPhone": { + "description": "Company phone number", + "example": 21754690, + "maximum": 9999999999, + "minimum": 10000000, + "type": "integer" + }, + "contactEmail": { + "description": "Contact email", + "example": "contact@company.dk", + "format": "email", + "maxLength": 255, + "minLength": 5, + "type": "string" + }, + "contactName": { + "description": "Contact person name", + "example": "Mikkel", + "type": "string" + }, + "contactPhone": { + "description": "Contact phone number", + "example": 21754690, + "maximum": 9999999999, + "minimum": 10000000, + "type": "integer" + }, + "cvr": { + "description": "Danish CVR number", + "example": "44794780", + "maxLength": 20, + "minLength": 8, + "type": "string" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + }, + "invoiceEmail": { + "description": "Email for invoices", + "example": "invoice@company.dk", + "format": "email", + "maxLength": 255, + "minLength": 5, + "type": "string" + } + }, + "required": [ + "cvr", + "companyPhone", + "invoiceEmail", + "contactEmail", + "contactPhone", + "contactName", + "g_recaptcha_response" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

201

Customer registered successfully

application/json

400

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/removedepartmentcategory.html b/documentation/_site_rebuild_20260317/removedepartmentcategory.html new file mode 100644 index 00000000..a99df114 --- /dev/null +++ b/documentation/_site_rebuild_20260317/removedepartmentcategory.html @@ -0,0 +1,20 @@ + +Remove category from department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Remove category from department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /departments/categories

Operation

Operation ID: removeDepartmentCategory

Remove category from department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/removerolepermission.html b/documentation/_site_rebuild_20260317/removerolepermission.html new file mode 100644 index 00000000..6d423d01 --- /dev/null +++ b/documentation/_site_rebuild_20260317/removerolepermission.html @@ -0,0 +1,18 @@ + +Remove permission from role | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Remove permission from role

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

DELETE /roles/permissions

Operation

Operation ID: removeRolePermission

Remove permission from role

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

role_id

query

yes

integer

permission

query

yes

string

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/renamepasskey.html b/documentation/_site_rebuild_20260317/renamepasskey.html new file mode 100644 index 00000000..b4eb7b3d --- /dev/null +++ b/documentation/_site_rebuild_20260317/renamepasskey.html @@ -0,0 +1,26 @@ + +Rename a passkey | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Rename a passkey

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PATCH /account/security/passkeys/{id}

Operation

Operation ID: renamePasskey

Rename a passkey

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

path

yes

integer

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/PasskeyRenameRequest" +} +

Responses

Status

Description

Content Types

200

Passkey renamed

application/json

404

Not found

application/json

Schema for response 200 (application/json):

+{} +

Schema for response 404 (application/json):

+{ + "$ref": "#/components/schemas/Error" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/requestpasswordreset.html b/documentation/_site_rebuild_20260317/requestpasswordreset.html new file mode 100644 index 00000000..5174010c --- /dev/null +++ b/documentation/_site_rebuild_20260317/requestpasswordreset.html @@ -0,0 +1,44 @@ + +Request a customer password reset email | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Request a customer password reset email

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/password-reset/request

Operation

Operation ID: requestPasswordReset

Send an email with a password reset token to the customer&#x27;s email address

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "customer_number": { + "description": "The customer number", + "example": 123456, + "type": "integer" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + } + }, + "required": [ + "customer_number", + "g_recaptcha_response" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Request processed

application/json

400

Schema for response 200 (application/json):

+{ + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/searchcustomers.html b/documentation/_site_rebuild_20260317/searchcustomers.html new file mode 100644 index 00000000..d4f3b6e6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/searchcustomers.html @@ -0,0 +1,27 @@ + +Search customers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Search customers

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /customers/search

Operation

Operation ID: searchCustomers

Search for customers using various criteria

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "query": { + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Customers found successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/searchcvr.html b/documentation/_site_rebuild_20260317/searchcvr.html new file mode 100644 index 00000000..0bb033a5 --- /dev/null +++ b/documentation/_site_rebuild_20260317/searchcvr.html @@ -0,0 +1,18 @@ + +Search CVR | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Search CVR

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /cvr/search

Operation

Operation ID: searchCvr

Search for companies by name or CVR

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

query

query

yes

string

Responses

Status

Description

Content Types

200

Search results retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/searchvehicles.html b/documentation/_site_rebuild_20260317/searchvehicles.html new file mode 100644 index 00000000..6d0ec6a7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/searchvehicles.html @@ -0,0 +1,18 @@ + +Search vehicles | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Search vehicles

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /vehicles/search

Operation

Operation ID: searchVehicles

Search vehicles

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

search

query

yes

string

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/senddepartmentgoalprogressalerttest.html b/documentation/_site_rebuild_20260317/senddepartmentgoalprogressalerttest.html new file mode 100644 index 00000000..e938ce93 --- /dev/null +++ b/documentation/_site_rebuild_20260317/senddepartmentgoalprogressalerttest.html @@ -0,0 +1,112 @@ + +Send a test progress alert for a department goal | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Send a test progress alert for a department goal

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /goals/department/progress-alert/test

Operation

Operation ID: sendDepartmentGoalProgressAlertTest

Sends a progress alert for a department goal to the destination defined in the goal&#x27;s criteria. Permission required: `goals_department_progress_alert_test`. Access control: - The caller must be a superuser or belong to all departments targeted by the goal. Behavior: - Looks up the goal by `id`. - Rebuilds the criteria from stored JSON and attaches the goal&#x27;s departments. - Renders the alert using the server-side renderer (respecting progress type/style/format and destination limits). - Sends the alert to Slack, Email, or SMS depending on `progress_alert_destination`, unless overridden.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "department_id": { + "description": "Department id to use that department's Slack webhook when destination is SLACK", + "example": 3, + "type": "integer" + }, + "email_to": { + "description": "Email recipient when destination is EMAIL", + "example": "tester@example.com", + "format": "email", + "type": "string" + }, + "id": { + "description": "The department goal id", + "example": 42, + "type": "integer" + }, + "overrideDestination": { + "description": "Override the destination for this test", + "enum": [ + "SLACK", + "EMAIL", + "SMS", + "NONE" + ], + "example": "SLACK", + "type": "string" + }, + "slack_webhook": { + "description": "Slack webhook URL when destination is SLACK", + "example": "https://hooks.slack.com/services/T000/B000/XXX", + "type": "string" + }, + "sms_to": { + "description": "One or more MSISDN recipients when destination is SMS", + "oneOf": [ + { + "description": "Comma or semicolon separated list", + "example": "+4512345678, +4598765432", + "type": "string" + }, + { + "example": [ + "+4512345678", + "+4598765432" + ], + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "subject": { + "description": "Optional email subject when destination is EMAIL", + "example": "Dept Goal Progress Test", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Alert sent successfully

application/json

400

401

403

404

500

Schema for response 200 (application/json):

+{ + "properties": { + "destination": { + "description": "Final destination used", + "enum": [ + "SLACK", + "EMAIL", + "SMS", + "NONE" + ], + "type": "string" + }, + "id": { + "description": "Goal id", + "type": "integer" + }, + "message_preview": { + "description": "Rendered message preview", + "type": "string" + }, + "provider_response": { + "description": "Provider-specific response or status message" + }, + "target": { + "description": "The target used for delivery (email address, phone numbers, department id, or webhook)" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/sendselfservelanecommand.html b/documentation/_site_rebuild_20260317/sendselfservelanecommand.html new file mode 100644 index 00000000..1c961638 --- /dev/null +++ b/documentation/_site_rebuild_20260317/sendselfservelanecommand.html @@ -0,0 +1,47 @@ + +Send self-serve lane command | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Send self-serve lane command

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /modules/self-serve/lane/command

Operation

Operation ID: sendSelfServeLaneCommand

Send a command (e.g., start, stop, reset) to a self-serve lane

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "command": { + "enum": [ + "START", + "STOP", + "RESET", + "RESERVE", + "RELEASE" + ], + "type": "string" + }, + "lane_id": { + "type": "integer" + }, + "license_plate": { + "description": "Required for START command", + "type": "string" + } + }, + "required": [ + "lane_id", + "command" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Command sent successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SelfServeLaneStatus" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setdepartmentprice.html b/documentation/_site_rebuild_20260317/setdepartmentprice.html new file mode 100644 index 00000000..cec82721 --- /dev/null +++ b/documentation/_site_rebuild_20260317/setdepartmentprice.html @@ -0,0 +1,38 @@ + +Set department price | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set department price

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /superuser/department/prices

Operation

Operation ID: setDepartmentPrice

Set department price

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "department_id": { + "type": "integer" + }, + "price": { + "type": "integer" + }, + "product_id": { + "type": "integer" + } + }, + "required": [ + "department_id", + "product_id", + "price" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setdepartmentterminallocation.html b/documentation/_site_rebuild_20260317/setdepartmentterminallocation.html new file mode 100644 index 00000000..deef89c8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/setdepartmentterminallocation.html @@ -0,0 +1,34 @@ + +Set department terminal location | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set department terminal location

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /modules/stripe/department/terminal/location

Operation

Operation ID: setDepartmentTerminalLocation

Set department terminal location

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + }, + "location": { + "type": "string" + } + }, + "required": [ + "id", + "location" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setdepartmentvariable.html b/documentation/_site_rebuild_20260317/setdepartmentvariable.html new file mode 100644 index 00000000..f7847908 --- /dev/null +++ b/documentation/_site_rebuild_20260317/setdepartmentvariable.html @@ -0,0 +1,38 @@ + +Set department variable | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set department variable

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /superuser/department/variables

Operation

Operation ID: setDepartmentVariable

Set department variable

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "department_id": { + "type": "integer" + }, + "value": { + "type": "string" + }, + "variable": { + "type": "string" + } + }, + "required": [ + "department_id", + "variable", + "value" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setpasswordusingresettoken.html b/documentation/_site_rebuild_20260317/setpasswordusingresettoken.html new file mode 100644 index 00000000..28a7759e --- /dev/null +++ b/documentation/_site_rebuild_20260317/setpasswordusingresettoken.html @@ -0,0 +1,52 @@ + +Set a customer password using a reset key | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set a customer password using a reset key

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/password-reset/set

Operation

Operation ID: setPasswordUsingResetToken

Update the customer password using a valid reset token

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + }, + "password": { + "description": "The new password", + "type": "string" + }, + "token": { + "description": "The password reset token", + "type": "string" + } + }, + "required": [ + "token", + "password", + "g_recaptcha_response" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Password updated successfully

application/json

400

404

Invalid or expired token

application/json

Schema for response 200 (application/json):

+{ + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" +} +

Schema for response 404 (application/json):

+{ + "$ref": "#/components/schemas/Error" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setselfservelaneallowedservices.html b/documentation/_site_rebuild_20260317/setselfservelaneallowedservices.html new file mode 100644 index 00000000..a950fe57 --- /dev/null +++ b/documentation/_site_rebuild_20260317/setselfservelaneallowedservices.html @@ -0,0 +1,50 @@ + +Set allowed services for a lane based on shown tasks | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set allowed services for a lane based on shown tasks

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /modules/self-serve/lane/services/allowed

Operation

Operation ID: setSelfServeLaneAllowedServices

Updates the set of services that are allowed to be manually activated for a given self-serve lane, derived from the tasks currently shown to the user after answering the self-serve questions. This endpoint does not activate anything by itself; it only sets what is allowed to be activated.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "lane_id": { + "type": "integer" + }, + "task_ids": { + "description": "List of task IDs that are currently shown to the user", + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "lane_id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Allowed services updated

application/json

401

403

Schema for response 200 (application/json):

+{ + "properties": { + "allowed_services": { + "items": { + "type": "string" + }, + "type": "array" + }, + "lane_id": { + "type": "integer" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setup2fa.html b/documentation/_site_rebuild_20260317/setup2fa.html new file mode 100644 index 00000000..3e8169d5 --- /dev/null +++ b/documentation/_site_rebuild_20260317/setup2fa.html @@ -0,0 +1,32 @@ + +Generate 2FA secret | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Generate 2FA secret

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/2fa/setup

Operation

Operation ID: setup2fa

Generate a new TOTP secret for the authenticated user/subuser

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

2FA secret generated successfully

application/json

401

Schema for response 200 (application/json):

+{ + "properties": { + "qr_code_url": { + "description": "An otpauth URL for generating a QR code", + "type": "string" + }, + "secret": { + "description": "The base32 encoded TOTP secret", + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setuserdiscount.html b/documentation/_site_rebuild_20260317/setuserdiscount.html new file mode 100644 index 00000000..e0912b29 --- /dev/null +++ b/documentation/_site_rebuild_20260317/setuserdiscount.html @@ -0,0 +1,41 @@ + +Set user discount | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set user discount

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /superuser/user/discounts

Operation

Operation ID: setUserDiscount

Set user discount

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "discount": { + "type": "integer" + }, + "is_category": { + "type": "boolean" + }, + "object_id": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + }, + "required": [ + "discount", + "object_id", + "is_category" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setuserkey.html b/documentation/_site_rebuild_20260317/setuserkey.html new file mode 100644 index 00000000..317d957d --- /dev/null +++ b/documentation/_site_rebuild_20260317/setuserkey.html @@ -0,0 +1,37 @@ + +Set user key | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set user key

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /superuser/user/keys

Operation

Operation ID: setUserKey

Set user key

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "key": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setuserpassword.html b/documentation/_site_rebuild_20260317/setuserpassword.html new file mode 100644 index 00000000..2cb24430 --- /dev/null +++ b/documentation/_site_rebuild_20260317/setuserpassword.html @@ -0,0 +1,33 @@ + +Set user password | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set user password

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /superuser/user/password

Operation

Operation ID: setUserPassword

Set user password

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "password": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + }, + "required": [ + "password" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setvehicleautostartonlpr.html b/documentation/_site_rebuild_20260317/setvehicleautostartonlpr.html new file mode 100644 index 00000000..8024aec1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/setvehicleautostartonlpr.html @@ -0,0 +1,34 @@ + +Set auto start on LPR | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set auto start on LPR

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /vehicles/set-auto-start-on-lpr

Operation

Operation ID: setVehicleAutoStartOnLpr

Enable or disable automatic start on LPR for a vehicle in XL Vask. Permissions: - Own scope: `set_auto_start_on_lpr` (linked to subuser node `VEHICLES_EDIT`). - Broader scope: `set_auto_start_on_lpr_other`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "active": { + "type": "boolean" + }, + "id": { + "type": "integer" + } + }, + "required": [ + "id", + "active" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

403

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/setvehicletypeid.html b/documentation/_site_rebuild_20260317/setvehicletypeid.html new file mode 100644 index 00000000..dc4cebd7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/setvehicletypeid.html @@ -0,0 +1,36 @@ + +Set vehicle type ID | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Set vehicle type ID

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /vehicles/set-vehicle-type-id

Operation

Operation ID: setVehicleTypeId

Set or change the XL Vask `vehicleTypeId` for a vehicle. Permissions: - Own scope: `set_vehicle_type_id` (linked to subuser node `VEHICLES_EDIT`). - Broader scope: `set_vehicle_type_id_other`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + }, + "vehicleTypeId": { + "maxLength": 50, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "vehicleTypeId" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

400

403

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/simulatestripepayment.html b/documentation/_site_rebuild_20260317/simulatestripepayment.html new file mode 100644 index 00000000..82afb330 --- /dev/null +++ b/documentation/_site_rebuild_20260317/simulatestripepayment.html @@ -0,0 +1,30 @@ + +Simulate Stripe payment | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Simulate Stripe payment

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /orders/module/stripe/debug/simulate_payment

Operation

Operation ID: simulateStripePayment

Simulate Stripe payment

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/submitform.html b/documentation/_site_rebuild_20260317/submitform.html new file mode 100644 index 00000000..d6187f02 --- /dev/null +++ b/documentation/_site_rebuild_20260317/submitform.html @@ -0,0 +1,40 @@ + +Submit form | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Submit form

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /form

Operation

Operation ID: submitForm

Submit a form

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "data": { + "description": "Form submission data", + "type": "object" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token (required if not authenticated)", + "type": "string" + }, + "id": { + "description": "Form identifier", + "type": "string" + } + }, + "required": [ + "id", + "data" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

201

Form submitted successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/subuserpasswordauth.html b/documentation/_site_rebuild_20260317/subuserpasswordauth.html new file mode 100644 index 00000000..c92f60f4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/subuserpasswordauth.html @@ -0,0 +1,128 @@ + +Authenticate subuser with password | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Authenticate subuser with password

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /subusers/auth/password

Operation

Operation ID: subuserPasswordAuth

Authenticates a subuser (driver) using a password together with one of the supported identifiers: `phone_country_code` + `phone`, `subuser_id`, or `username`. On success, returns a newly generated session token for the subuser.

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "oneOf": [ + { + "properties": { + "password": { + "format": "password", + "maxLength": 255, + "minLength": 8, + "type": "string" + }, + "phone": { + "description": "Phone number (4–15 digits, no leading +)", + "example": 12345678, + "maximum": 999999999999999, + "minimum": 1000, + "type": "integer" + }, + "phone_country_code": { + "description": "Phone country code (1–3 digits)", + "example": 45, + "maximum": 999, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "phone_country_code", + "phone", + "password" + ], + "type": "object" + }, + { + "properties": { + "password": { + "format": "password", + "maxLength": 255, + "minLength": 8, + "type": "string" + }, + "subuser_id": { + "description": "Subuser ID", + "example": 42, + "type": "integer" + } + }, + "required": [ + "subuser_id", + "password" + ], + "type": "object" + }, + { + "properties": { + "password": { + "format": "password", + "maxLength": 255, + "minLength": 8, + "type": "string" + }, + "username": { + "example": "jdoe", + "maxLength": 255, + "minLength": 3, + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "type": "object" + } + ] +} +

Responses

Status

Description

Content Types

200

Authentication successful

application/json

400

404

500

Schema for response 200 (application/json):

+{ + "oneOf": [ + { + "properties": { + "session": { + "description": "Newly generated subuser session token", + "example": "2f7a8c0e-9b1d-4c6a-91a9-1a2b3c4d5e6f", + "type": "string" + } + }, + "required": [ + "session" + ], + "type": "object" + }, + { + "properties": { + "2fa_required": { + "example": true, + "type": "boolean" + }, + "2fa_token": { + "description": "Temporary 2FA verification token", + "example": "557a3e7b1a2b...", + "type": "string" + } + }, + "required": [ + "2fa_required", + "2fa_token" + ], + "type": "object" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/suintimidate.html b/documentation/_site_rebuild_20260317/suintimidate.html new file mode 100644 index 00000000..1c1ba6a3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/suintimidate.html @@ -0,0 +1,37 @@ + +Intimidate a user | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Intimidate a user

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /su/intimidate

Operation

Operation ID: suIntimidate

Create an authentication token for another user (Superuser only)

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "user_id": { + "type": "integer" + } + }, + "required": [ + "user_id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{ + "properties": { + "token": { + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/syncallbookings.html b/documentation/_site_rebuild_20260317/syncallbookings.html new file mode 100644 index 00000000..ad971bc6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/syncallbookings.html @@ -0,0 +1,20 @@ + +Sync all bookings from external system | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Sync all bookings from external system

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /superuser/bookings/sync/all

Operation

Operation ID: syncAllBookings

Sync all bookings from external system

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/syncbooking.html b/documentation/_site_rebuild_20260317/syncbooking.html new file mode 100644 index 00000000..ed079619 --- /dev/null +++ b/documentation/_site_rebuild_20260317/syncbooking.html @@ -0,0 +1,20 @@ + +Sync booking from external system | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Sync booking from external system

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /admin/bookings/sync

Operation

Operation ID: syncBooking

Sync booking from external system

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/systemwidesearchget.html b/documentation/_site_rebuild_20260317/systemwidesearchget.html new file mode 100644 index 00000000..f186e022 --- /dev/null +++ b/documentation/_site_rebuild_20260317/systemwidesearchget.html @@ -0,0 +1,20 @@ + +System-wide search | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

System-wide search

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /search/system

Operation

Operation ID: systemWideSearchGet

Search across all supported entities with permission-aware filtering and optional intent parsing debug metadata. Customer records and customer-related entities are matched against a local e-conomic customer index (name/address/email/CVR) that is refreshed by cron. Intent parsing is invoked adaptively when lexical confidence is low or when the query looks intent-driven. Results are ordered by relevance, with recent records preferred when relevance is comparable.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

query

query

yes

string

Free-text query to search for. Supports natural-language intent fallback and domain synonyms such as `rabat` -> `discount`.

include_types

query

no

array<SystemSearchEntityType>

Comma-separated list of entity types to include. Defaults to all allowed types.

exclude_types

query

no

array<SystemSearchEntityType>

Comma-separated list of entity types to exclude.

include_associations

query

no

boolean

Include associated objects when matching a primary entity such as a customer.

debug_intent

query

no

boolean

Include intent parser diagnostics in `meta.intent_parser`.

limit

query

no

integer

offset

query

no

integer

Responses

Status

Description

Content Types

200

Search results returned successfully

application/json

400

401

403

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SystemSearchResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/systemwidesearchpost.html b/documentation/_site_rebuild_20260317/systemwidesearchpost.html new file mode 100644 index 00000000..ead63ebc --- /dev/null +++ b/documentation/_site_rebuild_20260317/systemwidesearchpost.html @@ -0,0 +1,24 @@ + +System-wide search | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

System-wide search

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /search/system

Operation

Operation ID: systemWideSearchPost

Search across all supported entities using JSON request payload. Customer-related matches include local e-conomic customer index fields. Intent parsing may run adaptively for intent-driven natural-language queries. Results are ordered by relevance, with recent records preferred when relevance is comparable.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/SystemSearchRequest" +} +

Responses

Status

Description

Content Types

200

Search results returned successfully

application/json

400

401

403

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SystemSearchResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-attachments-page-1.html b/documentation/_site_rebuild_20260317/tag-attachments-page-1.html new file mode 100644 index 00000000..d4d0e910 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-attachments-page-1.html @@ -0,0 +1,16 @@ + +Attachments - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-attachments.html b/documentation/_site_rebuild_20260317/tag-attachments.html new file mode 100644 index 00000000..325d1f1f --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-attachments.html @@ -0,0 +1,16 @@ + +Attachments | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-authentication-page-1.html b/documentation/_site_rebuild_20260317/tag-authentication-page-1.html new file mode 100644 index 00000000..8791ceaf --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-authentication-page-1.html @@ -0,0 +1,16 @@ + +Authentication - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-authentication.html b/documentation/_site_rebuild_20260317/tag-authentication.html new file mode 100644 index 00000000..6cf1d155 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-authentication.html @@ -0,0 +1,16 @@ + +Authentication | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-bird-page-1.html b/documentation/_site_rebuild_20260317/tag-bird-page-1.html new file mode 100644 index 00000000..83d6c49d --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-bird-page-1.html @@ -0,0 +1,16 @@ + +Bird - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Bird - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-bird.html b/documentation/_site_rebuild_20260317/tag-bird.html new file mode 100644 index 00000000..cdc12ac4 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-bird.html @@ -0,0 +1,16 @@ + +Bird | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-bookings-page-1.html b/documentation/_site_rebuild_20260317/tag-bookings-page-1.html new file mode 100644 index 00000000..3c42d5e7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-bookings-page-1.html @@ -0,0 +1,16 @@ + +Bookings - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-bookings.html b/documentation/_site_rebuild_20260317/tag-bookings.html new file mode 100644 index 00000000..63b4c11e --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-bookings.html @@ -0,0 +1,16 @@ + +Bookings | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-branding-page-1.html b/documentation/_site_rebuild_20260317/tag-branding-page-1.html new file mode 100644 index 00000000..41e78f4b --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-branding-page-1.html @@ -0,0 +1,16 @@ + +Branding - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-branding.html b/documentation/_site_rebuild_20260317/tag-branding.html new file mode 100644 index 00000000..a64f650e --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-branding.html @@ -0,0 +1,16 @@ + +Branding | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-categories-page-1.html b/documentation/_site_rebuild_20260317/tag-categories-page-1.html new file mode 100644 index 00000000..57aab718 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-categories-page-1.html @@ -0,0 +1,16 @@ + +Categories - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-categories.html b/documentation/_site_rebuild_20260317/tag-categories.html new file mode 100644 index 00000000..b45feb2c --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-categories.html @@ -0,0 +1,16 @@ + +Categories | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-config.html b/documentation/_site_rebuild_20260317/tag-config.html new file mode 100644 index 00000000..d88cc031 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-config.html @@ -0,0 +1,16 @@ + +Config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Config

Configuration endpoints grouped by module name.

Configuration Module Catalog

Module

Description

Backups

Backup configuration for backup module behavior.

Bird

Bird communication integration configuration.

e-conomic

e-conomic accounting integration configuration.

Email

Email provider and SMTP/MailerSend configuration.

Entra

Microsoft Entra identity integration configuration.

FXRatesAPI

FXRatesAPI exchange-rate integration configuration.

GatewayAPI

GatewayAPI integration configuration.

LicensePlateRecognizer

License plate recognizer integration configuration.

Limble

Limble integration configuration.

MotorAPI

MotorAPI vehicle lookup integration configuration.

OcrSpace

OCR Space integration configuration.

OpenAI

OpenAI integration configuration.

reCAPTCHA

reCAPTCHA protection configuration.

Self-Serve

Self-serve module runtime configuration.

Shelly

Shelly integration configuration.

Stripe

Stripe integration configuration.

VirkData

VirkData integration configuration.

WeatherAPI

WeatherAPI integration configuration.

XLVask

XLVask integration configuration.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-departments-page-1.html b/documentation/_site_rebuild_20260317/tag-departments-page-1.html new file mode 100644 index 00000000..c1bde4d7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-departments-page-1.html @@ -0,0 +1,16 @@ + +Departments - Page 1 of 2 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-departments-page-2.html b/documentation/_site_rebuild_20260317/tag-departments-page-2.html new file mode 100644 index 00000000..f7232839 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-departments-page-2.html @@ -0,0 +1,16 @@ + +Departments - Page 2 of 2 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-departments.html b/documentation/_site_rebuild_20260317/tag-departments.html new file mode 100644 index 00000000..06d640a3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-departments.html @@ -0,0 +1,16 @@ + +Departments | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-forms-page-1.html b/documentation/_site_rebuild_20260317/tag-forms-page-1.html new file mode 100644 index 00000000..203188dc --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-forms-page-1.html @@ -0,0 +1,16 @@ + +Forms - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Forms - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-forms.html b/documentation/_site_rebuild_20260317/tag-forms.html new file mode 100644 index 00000000..365c05fb --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-forms.html @@ -0,0 +1,16 @@ + +Forms | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-goals-page-1.html b/documentation/_site_rebuild_20260317/tag-goals-page-1.html new file mode 100644 index 00000000..522abeb2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-goals-page-1.html @@ -0,0 +1,16 @@ + +Goals - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-goals.html b/documentation/_site_rebuild_20260317/tag-goals.html new file mode 100644 index 00000000..8aae5ab7 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-goals.html @@ -0,0 +1,16 @@ + +Goals | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-invoices-page-1.html b/documentation/_site_rebuild_20260317/tag-invoices-page-1.html new file mode 100644 index 00000000..7e2f8ea8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-invoices-page-1.html @@ -0,0 +1,16 @@ + +Invoices - Page 1 of 2 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-invoices-page-2.html b/documentation/_site_rebuild_20260317/tag-invoices-page-2.html new file mode 100644 index 00000000..e2549fec --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-invoices-page-2.html @@ -0,0 +1,16 @@ + +Invoices - Page 2 of 2 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-invoices.html b/documentation/_site_rebuild_20260317/tag-invoices.html new file mode 100644 index 00000000..d88ad1c0 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-invoices.html @@ -0,0 +1,16 @@ + +Invoices | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-modules.html b/documentation/_site_rebuild_20260317/tag-modules.html new file mode 100644 index 00000000..7bd62d23 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-modules.html @@ -0,0 +1,16 @@ + +Modules | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Modules

Module integrations sorted by module name.

Module Catalog

Module

Description

Action Logs

Audit and operational logs for integration module activity.

Backup

Backup integrations and backup module orchestration.

CVR

Danish company registry (CVR) lookup and search integration.

e-conomic

Accounting and invoicing integration with e-conomic.

Entra

Microsoft Entra directory integration endpoints.

FXRatesAPI

Currency exchange-rate lookup integration.

MotorAPI

Vehicle lookup integration via MotorAPI.

Self-Serve

Self-serve lane control and machine command endpoints.

Stripe

Stripe payments, invoices, terminals, products, and customers.

VirkData

VirkData company information integration.

Wash Certificates

Wash certificate retrieval and listing integration.

WeatherAPI

Weather provider integration for current, forecast, and search.

XLVask

XLVask synchronization, usage logs, vehicles, and customers.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-notifications-page-1.html b/documentation/_site_rebuild_20260317/tag-notifications-page-1.html new file mode 100644 index 00000000..3f2f7340 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-notifications-page-1.html @@ -0,0 +1,16 @@ + +Notifications - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-notifications.html b/documentation/_site_rebuild_20260317/tag-notifications.html new file mode 100644 index 00000000..06780524 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-notifications.html @@ -0,0 +1,16 @@ + +Notifications | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-order-items-page-1.html b/documentation/_site_rebuild_20260317/tag-order-items-page-1.html new file mode 100644 index 00000000..3e5040c1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-order-items-page-1.html @@ -0,0 +1,16 @@ + +Order Items - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-order-items.html b/documentation/_site_rebuild_20260317/tag-order-items.html new file mode 100644 index 00000000..20eb3673 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-order-items.html @@ -0,0 +1,16 @@ + +Order Items | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-orders-page-1.html b/documentation/_site_rebuild_20260317/tag-orders-page-1.html new file mode 100644 index 00000000..28556784 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-orders-page-1.html @@ -0,0 +1,16 @@ + +Orders - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-orders.html b/documentation/_site_rebuild_20260317/tag-orders.html new file mode 100644 index 00000000..00f01795 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-orders.html @@ -0,0 +1,16 @@ + +Orders | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-plate-scans-page-1.html b/documentation/_site_rebuild_20260317/tag-plate-scans-page-1.html new file mode 100644 index 00000000..69f9baaf --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-plate-scans-page-1.html @@ -0,0 +1,16 @@ + +Plate Scans - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-plate-scans.html b/documentation/_site_rebuild_20260317/tag-plate-scans.html new file mode 100644 index 00000000..bc29ffbd --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-plate-scans.html @@ -0,0 +1,16 @@ + +Plate Scans | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-products-page-1.html b/documentation/_site_rebuild_20260317/tag-products-page-1.html new file mode 100644 index 00000000..55cfb6b5 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-products-page-1.html @@ -0,0 +1,16 @@ + +Products - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Products - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-products.html b/documentation/_site_rebuild_20260317/tag-products.html new file mode 100644 index 00000000..2190783c --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-products.html @@ -0,0 +1,16 @@ + +Products | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-roles-page-1.html b/documentation/_site_rebuild_20260317/tag-roles-page-1.html new file mode 100644 index 00000000..e122bf81 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-roles-page-1.html @@ -0,0 +1,16 @@ + +Roles - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Roles - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-roles.html b/documentation/_site_rebuild_20260317/tag-roles.html new file mode 100644 index 00000000..338f20d9 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-roles.html @@ -0,0 +1,16 @@ + +Roles | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-search-page-1.html b/documentation/_site_rebuild_20260317/tag-search-page-1.html new file mode 100644 index 00000000..7e3ec142 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-search-page-1.html @@ -0,0 +1,16 @@ + +Search - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-search.html b/documentation/_site_rebuild_20260317/tag-search.html new file mode 100644 index 00000000..b3c05dd6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-search.html @@ -0,0 +1,16 @@ + +Search | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-security-page-1.html b/documentation/_site_rebuild_20260317/tag-security-page-1.html new file mode 100644 index 00000000..5172c3b6 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-security-page-1.html @@ -0,0 +1,16 @@ + +Security - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-security.html b/documentation/_site_rebuild_20260317/tag-security.html new file mode 100644 index 00000000..bf8b68df --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-security.html @@ -0,0 +1,16 @@ + +Security | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-self-serve-page-1.html b/documentation/_site_rebuild_20260317/tag-self-serve-page-1.html new file mode 100644 index 00000000..265849db --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-self-serve-page-1.html @@ -0,0 +1,16 @@ + +Self-Serve - Page 1 of 2 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-self-serve-page-2.html b/documentation/_site_rebuild_20260317/tag-self-serve-page-2.html new file mode 100644 index 00000000..6989ccbe --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-self-serve-page-2.html @@ -0,0 +1,16 @@ + +Self-Serve - Page 2 of 2 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-self-serve.html b/documentation/_site_rebuild_20260317/tag-self-serve.html new file mode 100644 index 00000000..7582cd83 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-self-serve.html @@ -0,0 +1,16 @@ + +Self-Serve | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-statistics-page-1.html b/documentation/_site_rebuild_20260317/tag-statistics-page-1.html new file mode 100644 index 00000000..e05b327f --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-statistics-page-1.html @@ -0,0 +1,16 @@ + +Statistics - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-statistics.html b/documentation/_site_rebuild_20260317/tag-statistics.html new file mode 100644 index 00000000..bedb9dfd --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-statistics.html @@ -0,0 +1,16 @@ + +Statistics | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-subusers-page-1.html b/documentation/_site_rebuild_20260317/tag-subusers-page-1.html new file mode 100644 index 00000000..fecc94a3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-subusers-page-1.html @@ -0,0 +1,16 @@ + +Subusers - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-subusers.html b/documentation/_site_rebuild_20260317/tag-subusers.html new file mode 100644 index 00000000..5a8df7e3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-subusers.html @@ -0,0 +1,16 @@ + +Subusers | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-users-page-1.html b/documentation/_site_rebuild_20260317/tag-users-page-1.html new file mode 100644 index 00000000..b8918c46 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-users-page-1.html @@ -0,0 +1,16 @@ + +Users - Page 1 of 2 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-users-page-2.html b/documentation/_site_rebuild_20260317/tag-users-page-2.html new file mode 100644 index 00000000..aa5c08e2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-users-page-2.html @@ -0,0 +1,16 @@ + +Users - Page 2 of 2 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-users.html b/documentation/_site_rebuild_20260317/tag-users.html new file mode 100644 index 00000000..7894393c --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-users.html @@ -0,0 +1,16 @@ + +Users | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-vehicles-page-1.html b/documentation/_site_rebuild_20260317/tag-vehicles-page-1.html new file mode 100644 index 00000000..8550e3e1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-vehicles-page-1.html @@ -0,0 +1,16 @@ + +Vehicles - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-vehicles.html b/documentation/_site_rebuild_20260317/tag-vehicles.html new file mode 100644 index 00000000..fcbabf3b --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-vehicles.html @@ -0,0 +1,16 @@ + +Vehicles | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-worker-page-1.html b/documentation/_site_rebuild_20260317/tag-worker-page-1.html new file mode 100644 index 00000000..a11445fc --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-worker-page-1.html @@ -0,0 +1,16 @@ + +Worker - Page 1 of 1 | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Worker - Page 1 of 1

This page groups endpoint topics for this object type.

17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/tag-worker.html b/documentation/_site_rebuild_20260317/tag-worker.html new file mode 100644 index 00000000..b506354f --- /dev/null +++ b/documentation/_site_rebuild_20260317/tag-worker.html @@ -0,0 +1,16 @@ + +Worker | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/testemailconfig.html b/documentation/_site_rebuild_20260317/testemailconfig.html new file mode 100644 index 00000000..91c96147 --- /dev/null +++ b/documentation/_site_rebuild_20260317/testemailconfig.html @@ -0,0 +1,22 @@ + +Test email config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Test email config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /email/config/test

Operation

Operation ID: testEmailConfig

Test email config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Email configuration test completed

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigTestResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/togglevehicleaddon.html b/documentation/_site_rebuild_20260317/togglevehicleaddon.html new file mode 100644 index 00000000..eafe8ae0 --- /dev/null +++ b/documentation/_site_rebuild_20260317/togglevehicleaddon.html @@ -0,0 +1,34 @@ + +Toggle vehicle addon | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Toggle vehicle addon

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /vehicles/addons/toggle

Operation

Operation ID: toggleVehicleAddon

Enable or disable a vehicle addon for a vehicle. Permissions: - Own scope: `toggle_vehicle_addon_own` (linked to subuser node `VEHICLES_EDIT`). - Broader scope: `toggle_vehicle_addon_other`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

no

object

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "addon_id": { + "type": "integer" + }, + "vehicle_id": { + "type": "integer" + } + }, + "required": [ + "vehicle_id", + "addon_id" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Vehicle addon toggled successfully

application/json

403

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatebackupsconfig.html b/documentation/_site_rebuild_20260317/updatebackupsconfig.html new file mode 100644 index 00000000..3ea83bad --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatebackupsconfig.html @@ -0,0 +1,22 @@ + +Update backups config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update backups config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /backups/config

Operation

Operation ID: updateBackupsConfig

Update backups config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Backups configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatebirdconfig.html b/documentation/_site_rebuild_20260317/updatebirdconfig.html new file mode 100644 index 00000000..f35ecc51 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatebirdconfig.html @@ -0,0 +1,22 @@ + +Update Bird config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update Bird config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /bird/config

Operation

Operation ID: updateBirdConfig

Update Bird config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Bird configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatebooking.html b/documentation/_site_rebuild_20260317/updatebooking.html new file mode 100644 index 00000000..55be6730 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatebooking.html @@ -0,0 +1,22 @@ + +Update booking | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update booking

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /bookings

Operation

Operation ID: updateBooking

Update an existing booking

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/BookingUpdate" +} +

Responses

Status

Description

Content Types

200

Booking updated successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatecategory.html b/documentation/_site_rebuild_20260317/updatecategory.html new file mode 100644 index 00000000..65128b84 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatecategory.html @@ -0,0 +1,22 @@ + +Update category | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update category

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /categories

Operation

Operation ID: updateCategory

Update an existing category

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/CategoryUpdate" +} +

Responses

Status

Description

Content Types

200

Category updated successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatecollectedinvoice.html b/documentation/_site_rebuild_20260317/updatecollectedinvoice.html new file mode 100644 index 00000000..851258d0 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatecollectedinvoice.html @@ -0,0 +1,20 @@ + +Update collected invoice | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update collected invoice

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /collected-invoices

Operation

Operation ID: updateCollectedInvoice

Update a collected invoice

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Collected invoice updated successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatedepartment.html b/documentation/_site_rebuild_20260317/updatedepartment.html new file mode 100644 index 00000000..e1ec3150 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatedepartment.html @@ -0,0 +1,22 @@ + +Update department | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update department

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /departments

Operation

Operation ID: updateDepartment

Update an existing department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentUpdate" +} +

Responses

Status

Description

Content Types

200

Department updated successfully

application/json

400

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatedepartmentgate.html b/documentation/_site_rebuild_20260317/updatedepartmentgate.html new file mode 100644 index 00000000..4d49c713 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatedepartmentgate.html @@ -0,0 +1,24 @@ + +Update department gate | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update department gate

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /department/gates

Operation

Operation ID: updateDepartmentGate

Update department gate

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentGateUpdate" +} +

Responses

Status

Description

Content Types

200

Department gate updated successfully

application/json

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentGate" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatedepartmentgoal.html b/documentation/_site_rebuild_20260317/updatedepartmentgoal.html new file mode 100644 index 00000000..9b1ee45e --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatedepartmentgoal.html @@ -0,0 +1,24 @@ + +Update department goal | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update department goal

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /goals/department

Operation

Operation ID: updateDepartmentGoal

Update an existing department goal by `id`. Access control: - The creator (`created_by`) may update regardless of department membership. - Otherwise the user must satisfy the same subset rule as for read access, and any new `departments` provided must also be a subset unless the user has `superuser`.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentGoalUpdate" +} +

Responses

Status

Description

Content Types

200

Department goal updated successfully

application/json

400

401

403

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentGoal" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatedepartmentlane.html b/documentation/_site_rebuild_20260317/updatedepartmentlane.html new file mode 100644 index 00000000..fdaeca52 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatedepartmentlane.html @@ -0,0 +1,22 @@ + +Update department lane | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update department lane

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /department/lanes

Operation

Operation ID: updateDepartmentLane

Update an existing department lane

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentLaneUpdate" +} +

Responses

Status

Description

Content Types

200

Department lane updated successfully

application/json

400

404

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatedepartmentrelay.html b/documentation/_site_rebuild_20260317/updatedepartmentrelay.html new file mode 100644 index 00000000..e264f2e1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatedepartmentrelay.html @@ -0,0 +1,24 @@ + +Update department relay | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update department relay

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /department/relays

Operation

Operation ID: updateDepartmentRelay

Update department relay

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/DepartmentRelayUpdate" +} +

Responses

Status

Description

Content Types

200

Department relay updated successfully

application/json

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentRelay" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatedepartmentselfserveenabled.html b/documentation/_site_rebuild_20260317/updatedepartmentselfserveenabled.html new file mode 100644 index 00000000..add0275d --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatedepartmentselfserveenabled.html @@ -0,0 +1,25 @@ + +Update department self-serve status | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update department self-serve status

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /departments/self-serve/enabled

Operation

Operation ID: updateDepartmentSelfServeEnabled

Enable or disable self-serve for a specific department

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Department ID

enabled

query

yes

string

Enabled status (true/false)

Responses

Status

Description

Content Types

200

Status updated successfully

application/json

404

Schema for response 200 (application/json):

+{ + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateeconomicconfig.html b/documentation/_site_rebuild_20260317/updateeconomicconfig.html new file mode 100644 index 00000000..554e3526 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateeconomicconfig.html @@ -0,0 +1,22 @@ + +Update e-conomic config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update e-conomic config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /economic/config

Operation

Operation ID: updateEconomicConfig

Update e-conomic config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

e-conomic configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateemailconfig.html b/documentation/_site_rebuild_20260317/updateemailconfig.html new file mode 100644 index 00000000..6fe11d35 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateemailconfig.html @@ -0,0 +1,22 @@ + +Update email config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update email config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /email/config

Operation

Operation ID: updateEmailConfig

Update email config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Email configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateentraconfig.html b/documentation/_site_rebuild_20260317/updateentraconfig.html new file mode 100644 index 00000000..a7e70d11 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateentraconfig.html @@ -0,0 +1,22 @@ + +Update Entra config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update Entra config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /entra/config

Operation

Operation ID: updateEntraConfig

Update Entra config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Entra configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatefxratesapiconfig.html b/documentation/_site_rebuild_20260317/updatefxratesapiconfig.html new file mode 100644 index 00000000..d276adcd --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatefxratesapiconfig.html @@ -0,0 +1,22 @@ + +Update FXRatesAPI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update FXRatesAPI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /fxratesapi/config

Operation

Operation ID: updateFxRatesApiConfig

Update FXRatesAPI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

FXRatesAPI configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updategatewayapiconfig.html b/documentation/_site_rebuild_20260317/updategatewayapiconfig.html new file mode 100644 index 00000000..af474b6a --- /dev/null +++ b/documentation/_site_rebuild_20260317/updategatewayapiconfig.html @@ -0,0 +1,22 @@ + +Update GatewayAPI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update GatewayAPI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /gatewayapi/config

Operation

Operation ID: updateGatewayApiConfig

Update GatewayAPI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

GatewayAPI configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatelicenseplaterecognizerconfig.html b/documentation/_site_rebuild_20260317/updatelicenseplaterecognizerconfig.html new file mode 100644 index 00000000..337a90ca --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatelicenseplaterecognizerconfig.html @@ -0,0 +1,22 @@ + +Update LicensePlateRecognizer config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update LicensePlateRecognizer config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /licenseplaterecognizer/config

Operation

Operation ID: updateLicensePlateRecognizerConfig

Update LicensePlateRecognizer config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

LicensePlateRecognizer configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatelimbleconfig.html b/documentation/_site_rebuild_20260317/updatelimbleconfig.html new file mode 100644 index 00000000..111d003f --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatelimbleconfig.html @@ -0,0 +1,22 @@ + +Update Limble config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update Limble config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /limble/config

Operation

Operation ID: updateLimbleConfig

Update Limble config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Limble configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatemotorapiconfig.html b/documentation/_site_rebuild_20260317/updatemotorapiconfig.html new file mode 100644 index 00000000..eb18452b --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatemotorapiconfig.html @@ -0,0 +1,22 @@ + +Update MotorAPI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update MotorAPI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /motorapi/config

Operation

Operation ID: updateMotorApiConfig

Update MotorAPI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

MotorAPI configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateocrspaceconfig.html b/documentation/_site_rebuild_20260317/updateocrspaceconfig.html new file mode 100644 index 00000000..8c0e7852 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateocrspaceconfig.html @@ -0,0 +1,22 @@ + +Update OcrSpace config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update OcrSpace config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /ocrspace/config

Operation

Operation ID: updateOcrSpaceConfig

Update OcrSpace config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

OcrSpace configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateopenaiconfig.html b/documentation/_site_rebuild_20260317/updateopenaiconfig.html new file mode 100644 index 00000000..30080c8a --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateopenaiconfig.html @@ -0,0 +1,22 @@ + +Update OpenAI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update OpenAI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /openai/config

Operation

Operation ID: updateOpenAiConfig

Update OpenAI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

OpenAI configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateorder.html b/documentation/_site_rebuild_20260317/updateorder.html new file mode 100644 index 00000000..7d4c05af --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateorder.html @@ -0,0 +1,22 @@ + +Update order | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update order

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /order

Operation

Operation ID: updateOrder

Update an existing order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/OrderUpdate" +} +

Responses

Status

Description

Content Types

200

Order updated successfully

application/json

400

401

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateorderitem.html b/documentation/_site_rebuild_20260317/updateorderitem.html new file mode 100644 index 00000000..aca85da5 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateorderitem.html @@ -0,0 +1,22 @@ + +Update order item | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update order item

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /order/items

Operation

Operation ID: updateOrderItem

Update an existing order item

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/OrderItemUpdate" +} +

Responses

Status

Description

Content Types

200

Order item updated successfully

application/json

400

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateorders.html b/documentation/_site_rebuild_20260317/updateorders.html new file mode 100644 index 00000000..240d64fe --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateorders.html @@ -0,0 +1,22 @@ + +Update order (alias) | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update order (alias)

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /orders

Operation

Operation ID: updateOrders

Update an existing order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/OrderUpdate" +} +

Responses

Status

Description

Content Types

200

Order updated successfully

application/json

400

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateplatescanner.html b/documentation/_site_rebuild_20260317/updateplatescanner.html new file mode 100644 index 00000000..99edf496 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateplatescanner.html @@ -0,0 +1,42 @@ + +Update plate scanner | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update plate scanner

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /numberplatescanners

Operation

Operation ID: updatePlateScanner

Update plate scanner

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "department_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string" + } + }, + "required": [ + "id", + "department_id", + "name", + "notes" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Plate scanner updated successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateproduct.html b/documentation/_site_rebuild_20260317/updateproduct.html new file mode 100644 index 00000000..566aec29 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateproduct.html @@ -0,0 +1,22 @@ + +Update product | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update product

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /products

Operation

Operation ID: updateProduct

Update an existing product

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/ProductUpdate" +} +

Responses

Status

Description

Content Types

200

Product updated successfully

application/json

400

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updaterecaptchaconfig.html b/documentation/_site_rebuild_20260317/updaterecaptchaconfig.html new file mode 100644 index 00000000..20810df3 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updaterecaptchaconfig.html @@ -0,0 +1,22 @@ + +Update reCAPTCHA config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update reCAPTCHA config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /reCAPTCHA/config

Operation

Operation ID: updateRecaptchaConfig

Update reCAPTCHA config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

reCAPTCHA configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateselfservecondition.html b/documentation/_site_rebuild_20260317/updateselfservecondition.html new file mode 100644 index 00000000..7e355639 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateselfservecondition.html @@ -0,0 +1,49 @@ + +Update self-serve condition | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update self-serve condition

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /department/selfserve/conditions

Operation

Operation ID: updateSelfserveCondition

Update an existing self-serve condition.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Condition ID

Request Body

Required: no.

Content type: application/json

+{ + "properties": { + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "lane": { + "type": "integer" + }, + "machine_type_id": { + "nullable": true, + "type": "integer" + }, + "name": { + "type": "string" + }, + "product": { + "type": "integer" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully updated condition

application/json

400

404

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveCondition" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateselfserveconditionrule.html b/documentation/_site_rebuild_20260317/updateselfserveconditionrule.html new file mode 100644 index 00000000..dde91999 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateselfserveconditionrule.html @@ -0,0 +1,44 @@ + +Update self-serve condition rule | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update self-serve condition rule

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /department/selfserve/condition/rules

Operation

Operation ID: updateSelfserveConditionRule

Update an existing self-serve condition rule.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Rule ID

Request Body

Required: no.

Content type: application/json

+{ + "properties": { + "condition_id": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "object_type": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully updated condition rule

application/json

400

404

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveConditionRule" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateselfserveconfig.html b/documentation/_site_rebuild_20260317/updateselfserveconfig.html new file mode 100644 index 00000000..e741073c --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateselfserveconfig.html @@ -0,0 +1,24 @@ + +Update Self-Serve config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update Self-Serve config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /selfserve/config

Operation

Operation ID: updateSelfServeConfig

Update Self-Serve config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/SelfServeConfig" +} +

Responses

Status

Description

Content Types

200

Self-serve configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateselfservemachinetype.html b/documentation/_site_rebuild_20260317/updateselfservemachinetype.html new file mode 100644 index 00000000..9374aaa8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateselfservemachinetype.html @@ -0,0 +1,33 @@ + +Update reusable self-serve machine type | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update reusable self-serve machine type

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /department/selfserve/machine-types

Operation

Operation ID: updateSelfserveMachineType

Update reusable self-serve machine type

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Request Body

Required: no.

Content type: application/json

+{ + "properties": { + "description": { + "nullable": true, + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully updated machine type

application/json

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/SelfserveMachineType" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateselfservequestion.html b/documentation/_site_rebuild_20260317/updateselfservequestion.html new file mode 100644 index 00000000..608d5c1b --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateselfservequestion.html @@ -0,0 +1,48 @@ + +Update self-serve question | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update self-serve question

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /department/selfserve/questions

Operation

Operation ID: updateSelfserveQuestion

Update an existing self-serve question.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Question ID

Request Body

Required: no.

Content type: application/json

+{ + "properties": { + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "lane": { + "type": "integer" + }, + "order_priority": { + "type": "integer" + }, + "product": { + "type": "integer" + }, + "question": { + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully updated question

application/json

400

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveQuestion" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateselfservetask.html b/documentation/_site_rebuild_20260317/updateselfservetask.html new file mode 100644 index 00000000..6ef176b5 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateselfservetask.html @@ -0,0 +1,73 @@ + +Update self-serve task | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update self-serve task

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /department/selfserve/tasks

Operation

Operation ID: updateSelfserveTask

Update an existing self-serve task.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Task ID

Request Body

Required: no.

Content type: application/json

+{ + "properties": { + "buttons": { + "description": "Button IDs enabled by this task. Set to null to clear all buttons.", + "items": { + "type": "integer" + }, + "nullable": true, + "type": "array" + }, + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "dynamic_images_vehicle_type": { + "description": "Vehicle type selection override. Set to null to clear.", + "nullable": true, + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "machine_type_id": { + "nullable": true, + "type": "integer" + }, + "order_priority": { + "type": "integer" + }, + "product": { + "type": "integer" + }, + "services": { + "description": "Services enabled by this task. Set to null to clear all services.", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + }, + "nullable": true, + "type": "array" + }, + "task": { + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully updated task

application/json

400

404

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveTask" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateselfservevehiclecondition.html b/documentation/_site_rebuild_20260317/updateselfservevehiclecondition.html new file mode 100644 index 00000000..c03d6f54 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateselfservevehiclecondition.html @@ -0,0 +1,45 @@ + +Update vehicle condition | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update vehicle condition

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /department/selfserve/vehicle/conditions

Operation

Operation ID: updateSelfserveVehicleCondition

Update an existing vehicle condition. Customers can only update conditions for their own vehicles.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

query

yes

integer

Condition ID

Request Body

Required: no.

Content type: application/json

+{ + "properties": { + "customer_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "question": { + "type": "integer" + }, + "reg": { + "type": "string" + }, + "value": { + "type": "boolean" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Successfully updated vehicle condition

application/json

400

404

500

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateshellyconfig.html b/documentation/_site_rebuild_20260317/updateshellyconfig.html new file mode 100644 index 00000000..fc750b95 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateshellyconfig.html @@ -0,0 +1,22 @@ + +Update Shelly config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update Shelly config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /shelly/config

Operation

Operation ID: updateShellyConfig

Update Shelly config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Shelly configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatestripeconfig.html b/documentation/_site_rebuild_20260317/updatestripeconfig.html new file mode 100644 index 00000000..a4bf6653 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatestripeconfig.html @@ -0,0 +1,22 @@ + +Update Stripe config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update Stripe config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /stripe/config

Operation

Operation ID: updateStripeConfig

Update Stripe config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Stripe configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatesubusergrant.html b/documentation/_site_rebuild_20260317/updatesubusergrant.html new file mode 100644 index 00000000..b4e46f3f --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatesubusergrant.html @@ -0,0 +1,29 @@ + +Update subuser grant | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update subuser grant

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PATCH /subusers/grants/{id}

Operation

Operation ID: updateSubuserGrant

Update subuser grant

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

id

path

yes

integer

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/SubuserGrantUpdateRequest" +} +

Responses

Status

Description

Content Types

200

Grant updated

application/json

400

401

404

500

Schema for response 200 (application/json):

+{ + "properties": { + "grant": { + "$ref": "#/components/schemas/SubuserGrant" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateuser.html b/documentation/_site_rebuild_20260317/updateuser.html new file mode 100644 index 00000000..237aa6c8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateuser.html @@ -0,0 +1,22 @@ + +Update user | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update user

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /users

Operation

Operation ID: updateUser

Update an existing user

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "$ref": "#/components/schemas/UserUpdate" +} +

Responses

Status

Description

Content Types

200

User updated successfully

application/json

400

401

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateusernotifications.html b/documentation/_site_rebuild_20260317/updateusernotifications.html new file mode 100644 index 00000000..44a60e99 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateusernotifications.html @@ -0,0 +1,33 @@ + +Update user notification settings | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update user notification settings

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

PUT /account/notifications

Operation

Operation ID: updateUserNotifications

Update user notification settings

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "email_notifications_enabled": { + "type": "boolean" + }, + "sms_notifications_enabled": { + "type": "boolean" + }, + "wash_certificate_email": { + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

200

Success

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatevirkdataconfig.html b/documentation/_site_rebuild_20260317/updatevirkdataconfig.html new file mode 100644 index 00000000..c34986b1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatevirkdataconfig.html @@ -0,0 +1,22 @@ + +Update Virkdata config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update Virkdata config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /virkdata/config

Operation

Operation ID: updateVirkdataConfig

Update Virkdata config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

Virkdata configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateweatherapiconfig.html b/documentation/_site_rebuild_20260317/updateweatherapiconfig.html new file mode 100644 index 00000000..6c420bcc --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateweatherapiconfig.html @@ -0,0 +1,22 @@ + +Update WeatherAPI config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update WeatherAPI config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /weatherapi/config

Operation

Operation ID: updateWeatherApiConfig

Update WeatherAPI config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

WeatherAPI configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updateworkerversion.html b/documentation/_site_rebuild_20260317/updateworkerversion.html new file mode 100644 index 00000000..b83883e1 --- /dev/null +++ b/documentation/_site_rebuild_20260317/updateworkerversion.html @@ -0,0 +1,18 @@ + +Update worker version | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update worker version

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /worker/update-version

Operation

Operation ID: updateWorkerVersion

Set the target version for the worker update

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

version

query

yes

string

Responses

Status

Description

Content Types

200

Version update target set successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/updatexlvaskconfig.html b/documentation/_site_rebuild_20260317/updatexlvaskconfig.html new file mode 100644 index 00000000..cf6603aa --- /dev/null +++ b/documentation/_site_rebuild_20260317/updatexlvaskconfig.html @@ -0,0 +1,22 @@ + +Update XLVask config | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Update XLVask config

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /xlvask/config

Operation

Operation ID: updateXlvaskConfig

Update XLVask config

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: no.

Content type: application/json

+{} +

Responses

Status

Description

Content Types

200

XLVask configuration updated successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/uploadattachment.html b/documentation/_site_rebuild_20260317/uploadattachment.html new file mode 100644 index 00000000..aa1a2fcd --- /dev/null +++ b/documentation/_site_rebuild_20260317/uploadattachment.html @@ -0,0 +1,28 @@ + +Upload attachment | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Upload attachment

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /attachments/upload

Operation

Operation ID: uploadAttachment

Upload a file attachment

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: multipart/form-data

+{ + "properties": { + "file": { + "format": "binary", + "type": "string" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

201

Attachment uploaded successfully

application/json

400

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/uploadorderattachment.html b/documentation/_site_rebuild_20260317/uploadorderattachment.html new file mode 100644 index 00000000..dc4e921e --- /dev/null +++ b/documentation/_site_rebuild_20260317/uploadorderattachment.html @@ -0,0 +1,31 @@ + +Upload order attachment | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Upload order attachment

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /orders/attachments/upload

Operation

Operation ID: uploadOrderAttachment

Upload an attachment to an order

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: multipart/form-data

+{ + "properties": { + "file": { + "format": "binary", + "type": "string" + }, + "order_id": { + "type": "integer" + } + }, + "type": "object" +} +

Responses

Status

Description

Content Types

201

Order attachment uploaded successfully

application/json

Schema for response 201 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/uploadselfservetaskattachment.html b/documentation/_site_rebuild_20260317/uploadselfservetaskattachment.html new file mode 100644 index 00000000..62292783 --- /dev/null +++ b/documentation/_site_rebuild_20260317/uploadselfservetaskattachment.html @@ -0,0 +1,40 @@ + +Upload task attachment | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Upload task attachment

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /department/selfserve/tasks/attachments/upload

Operation

Operation ID: uploadSelfserveTaskAttachment

Upload a new attachment to a specific self-serve task using base64 encoding.

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "base64_file": { + "description": "Base64 encoded file content", + "type": "string" + }, + "file_name": { + "description": "Name of the file including extension", + "type": "string" + }, + "task_id": { + "type": "integer" + } + }, + "required": [ + "task_id", + "base64_file", + "file_name" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Attachment uploaded successfully

application/json

400

404

500

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/validatecustomernumber.html b/documentation/_site_rebuild_20260317/validatecustomernumber.html new file mode 100644 index 00000000..896e92bc --- /dev/null +++ b/documentation/_site_rebuild_20260317/validatecustomernumber.html @@ -0,0 +1,30 @@ + +Validate customer number | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Validate customer number

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /guest/validation/customer-number

Operation

Operation ID: validateCustomerNumber

Check if a customer number is valid and exists

Authentication

No authentication required.

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "customer_number": { + "type": "integer" + } + }, + "required": [ + "customer_number" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Customer number validation successful

application/json

400

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/validatepasswordresettoken.html b/documentation/_site_rebuild_20260317/validatepasswordresettoken.html new file mode 100644 index 00000000..e681c00d --- /dev/null +++ b/documentation/_site_rebuild_20260317/validatepasswordresettoken.html @@ -0,0 +1,32 @@ + +Validate a customer password reset key | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Validate a customer password reset key

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /auth/password-reset/validate

Operation

Operation ID: validatePasswordResetToken

Check if a password reset token is valid and hasn&#x27;t expired

Authentication

No authentication required.

Parameters

Name

In

Required

Type

Description

token

query

yes

string

The password reset token

Responses

Status

Description

Content Types

200

Token is valid

application/json

404

Invalid or expired token

application/json

Schema for response 200 (application/json):

+{ + "properties": { + "customer_id": { + "type": "integer" + }, + "valid": { + "type": "boolean" + } + }, + "type": "object" +} +

Schema for response 404 (application/json):

+{ + "$ref": "#/components/schemas/Error" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/validatesubusersetuptoken.html b/documentation/_site_rebuild_20260317/validatesubusersetuptoken.html new file mode 100644 index 00000000..ac1db6e8 --- /dev/null +++ b/documentation/_site_rebuild_20260317/validatesubusersetuptoken.html @@ -0,0 +1,30 @@ + +Validate setup token | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Validate setup token

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /subusers/setup

Operation

Operation ID: validateSubuserSetupToken

Validates a subuser setup token generated during registration.

Authentication

No authentication required.

Parameters

Name

In

Required

Type

Description

token

query

yes

string

One-time setup token received via SMS

Responses

Status

Description

Content Types

200

Token is valid

application/json

400

500

Schema for response 200 (application/json):

+{ + "properties": { + "message": { + "example": "Token is valid", + "type": "string" + }, + "subuser_id": { + "example": 42, + "type": "integer" + } + }, + "type": "object" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/verify2fa.html b/documentation/_site_rebuild_20260317/verify2fa.html new file mode 100644 index 00000000..0de320cc --- /dev/null +++ b/documentation/_site_rebuild_20260317/verify2fa.html @@ -0,0 +1,63 @@ + +Verify 2FA code during login | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Verify 2FA code during login

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

POST /auth/2fa/verify

Operation

Operation ID: verify2fa

Complete the login process by verifying the 2FA code

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Request Body

Required: yes.

Content type: application/json

+{ + "properties": { + "2fa_token": { + "description": "The temporary 2FA verification token", + "type": "string" + }, + "code": { + "description": "The 6-digit TOTP code", + "type": "string" + } + }, + "required": [ + "2fa_token", + "code" + ], + "type": "object" +} +

Responses

Status

Description

Content Types

200

Login successful

application/json

400

401

Schema for response 200 (application/json):

+{ + "oneOf": [ + { + "properties": { + "token": { + "description": "Bearer authentication token (for users/employees)", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + { + "properties": { + "session": { + "description": "Session token (for subusers)", + "type": "string" + } + }, + "required": [ + "session" + ], + "type": "object" + } + ] +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/virkdatasearch.html b/documentation/_site_rebuild_20260317/virkdatasearch.html new file mode 100644 index 00000000..971824d0 --- /dev/null +++ b/documentation/_site_rebuild_20260317/virkdatasearch.html @@ -0,0 +1,18 @@ + +Search VirkData | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Search VirkData

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/virkdata/search

Operation

Operation ID: virkdataSearch

Search for company information in VirkData

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

search

query

yes

string

Responses

Status

Description

Content Types

200

Company information retrieved successfully

application/json

Schema for response 200 (application/json):

+{} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/weatherapicurrent.html b/documentation/_site_rebuild_20260317/weatherapicurrent.html new file mode 100644 index 00000000..2b46cc99 --- /dev/null +++ b/documentation/_site_rebuild_20260317/weatherapicurrent.html @@ -0,0 +1,20 @@ + +Get current weather | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get current weather

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/weatherapi/current

Operation

Operation ID: weatherApiCurrent

Get current weather data from WeatherAPI for a location query

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

q

query

yes

string

Location query (e.g. city, postal code, or latitude,longitude)

Responses

Status

Description

Content Types

200

Current weather retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/WeatherApiObjectResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/weatherapiforecast.html b/documentation/_site_rebuild_20260317/weatherapiforecast.html new file mode 100644 index 00000000..675b1baf --- /dev/null +++ b/documentation/_site_rebuild_20260317/weatherapiforecast.html @@ -0,0 +1,20 @@ + +Get weather forecast | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Get weather forecast

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/weatherapi/forecast

Operation

Operation ID: weatherApiForecast

Get forecast weather data from WeatherAPI

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

q

query

yes

string

Location query (e.g. city, postal code, or latitude,longitude)

days

query

no

integer

Number of forecast days

Responses

Status

Description

Content Types

200

Forecast weather retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/WeatherApiObjectResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/_site_rebuild_20260317/weatherapisearch.html b/documentation/_site_rebuild_20260317/weatherapisearch.html new file mode 100644 index 00000000..f9328fb2 --- /dev/null +++ b/documentation/_site_rebuild_20260317/weatherapisearch.html @@ -0,0 +1,20 @@ + +Search weather locations | Copenhagen Truck Wash API

Copenhagen Truck Wash API Help

Search weather locations

This endpoint documentation is generated directly from openapi.yaml.

Endpoint

GET /modules/weatherapi/search

Operation

Operation ID: weatherApiSearch

Search location suggestions from WeatherAPI

Authentication

Security requirements:

Scheme

Scopes

BearerAuth

-

Parameters

Name

In

Required

Type

Description

q

query

yes

string

Search text

Responses

Status

Description

Content Types

200

Location search results retrieved successfully

application/json

Schema for response 200 (application/json):

+{ + "$ref": "#/components/schemas/WeatherApiObjectResponse" +} +
17 March 2026
\ No newline at end of file diff --git a/documentation/c.list b/documentation/c.list new file mode 100644 index 00000000..4628b018 --- /dev/null +++ b/documentation/c.list @@ -0,0 +1,7 @@ + + + + + + diff --git a/documentation/ctw.tree b/documentation/ctw.tree new file mode 100644 index 00000000..ebf53a1a --- /dev/null +++ b/documentation/ctw.tree @@ -0,0 +1,608 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/documentation/generated/api-reference.toc.xml b/documentation/generated/api-reference.toc.xml new file mode 100644 index 00000000..f27dd0de --- /dev/null +++ b/documentation/generated/api-reference.toc.xml @@ -0,0 +1,592 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/documentation/generated/openapi.json b/documentation/generated/openapi.json new file mode 100644 index 00000000..2e65bb0f --- /dev/null +++ b/documentation/generated/openapi.json @@ -0,0 +1,21288 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Copenhagen Truck Wash API", + "description": "This API provides access to the Copenhagen Truck Wash system, managing orders, bookings, \ndepartments, products, customers, and various integrations including e-conomic, Stripe, \nXLVask, and more.\n\n## Authentication\nMost endpoints require authentication using a Bearer token obtained from the `/auth/login` \nor `/auth/employee/login` endpoints.\n\n## Permissions\nMany endpoints require specific permissions that are assigned to user groups/roles.\n\n## Subusers and customer targeting\nWhen authenticated as a subuser, most customer-scoped endpoints require an explicit target\ncustomer context. Provide the header `X-Customer-Number: ` to target a\nspecific customer. If omitted, the API attempts to infer the customer from the authenticated\nuser context when possible. Classic user sessions ignore this header.\n", + "version": "1.0.0", + "contact": { + "name": "Copenhagen Truck Wash", + "email": "support@truckwash.dk" + } + }, + "servers": [ + { + "url": "https://api.truckwash.dk", + "description": "Production server (.dk)" + }, + { + "url": "https://api.truckwash.io", + "description": "Production server (.io)" + }, + { + "url": "http://localhost/api", + "description": "Local development server" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + { + "name": "Authentication", + "description": "User and employee authentication endpoints" + }, + { + "name": "Security", + "description": "Account security and passkey management endpoints" + }, + { + "name": "Users", + "description": "User management and customer operations" + }, + { + "name": "Search", + "description": "System-wide search endpoints" + }, + { + "name": "Orders", + "description": "Order creation, management, and retrieval" + }, + { + "name": "Order Items", + "description": "Managing items within orders" + }, + { + "name": "Bookings", + "description": "Booking management for wash services" + }, + { + "name": "Departments", + "description": "Department and location management" + }, + { + "name": "Products", + "description": "Product catalog and pricing" + }, + { + "name": "Categories", + "description": "Product category management" + }, + { + "name": "Invoices", + "description": "Invoice generation and management" + }, + { + "name": "Payments", + "description": "Payment processing and collection" + }, + { + "name": "Vehicles", + "description": "Vehicle registration and management" + }, + { + "name": "Notifications", + "description": "System notifications and alerts" + }, + { + "name": "Statistics", + "description": "Business analytics and reporting" + }, + { + "name": "Modules", + "description": "Third-party integrations and modules" + }, + { + "name": "Attachments", + "description": "File upload and attachment management" + }, + { + "name": "Forms", + "description": "Form submissions and management" + }, + { + "name": "Worker", + "description": "System worker status and maintenance" + }, + { + "name": "Plate Scans", + "description": "License plate scanning operations" + }, + { + "name": "Config", + "description": "Module configuration management" + }, + { + "name": "Branding", + "description": "Branding options management" + }, + { + "name": "Roles", + "description": "Role and permission management" + }, + { + "name": "Self-Serve", + "description": "Self-serve lane operations and questions" + }, + { + "name": "Goals", + "description": "Department goals management" + }, + { + "name": "Subusers", + "description": "Subuser registration and setup" + }, + { + "name": "Bird", + "description": "Voice Calls via Bird" + } + ], + "paths": { + "/bird/voice/calls": { + "post": { + "tags": [ + "Bird" + ], + "summary": "Create/place a voice call via Bird", + "operationId": "birdCreateVoiceCall", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "properties": { + "to": { + "type": "string", + "description": "E.164 phone number of the callee", + "example": "+4511122233" + }, + "from": { + "type": "string", + "description": "E.164 phone number of the caller (sender)", + "example": "+4599988877" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Call created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" + } + } + } + } + } + }, + "get": { + "tags": [ + "Bird" + ], + "summary": "List voice calls", + "operationId": "birdListVoiceCalls", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "A list of calls", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BirdVoiceCallListResponse" + } + } + } + } + } + } + }, + "/bird/voice/calls/{id}": { + "get": { + "tags": [ + "Bird" + ], + "summary": "Get a voice call by ID", + "operationId": "birdGetVoiceCall", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Call details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" + } + } + } + } + } + } + }, + "/bird/voice/calls/{id}/hangup": { + "post": { + "tags": [ + "Bird" + ], + "summary": "Hang up a voice call by ID", + "operationId": "birdHangupVoiceCall", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Hangup requested", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" + } + } + } + } + } + } + }, + "/bird/voice/calls/{id}/say": { + "post": { + "tags": [ + "Bird" + ], + "summary": "Say a message on an active voice call and hang up afterwards", + "operationId": "birdSayOnVoiceCall", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Call identifier" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "text" + ], + "properties": { + "text": { + "type": "string", + "description": "The text message to play via TTS", + "example": "The gate will open shortly." + }, + "locale": { + "type": "string", + "description": "The locale to use for the TTS voice (e.g. en-US)", + "example": "en-US" + }, + "voice": { + "type": "string", + "description": "The voice identifier to use", + "example": "male" + }, + "loop": { + "type": "integer", + "description": "Number of times to loop the message", + "example": 1 + }, + "timeout": { + "type": "integer", + "description": "Timeout in seconds for the TTS action", + "example": 1 + }, + "hangup": { + "type": "boolean", + "description": "Whether to hang up the call after the message finishes playing (defaults to true)", + "example": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "TTS action requested", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" + } + } + } + } + } + } + }, + "/bird/voice/calls/test-outbound": { + "post": { + "tags": [ + "Bird" + ], + "summary": "Place a test outbound call and hang up when accepted", + "operationId": "birdTestOutboundVoiceCall", + "description": "Calls +45 42 33 11 28 and hangs up when the call reaches accepted/ongoing state.", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "properties": { + "from": { + "type": "string", + "description": "Caller E.164 number to use for the test call", + "example": "+4599988877" + }, + "pollIntervalSeconds": { + "type": "integer", + "minimum": 1, + "description": "Poll interval while waiting for accepted status", + "example": 2 + }, + "maxPollSeconds": { + "type": "integer", + "minimum": 5, + "description": "Max time to wait before timing out", + "example": 30 + }, + "hangupCause": { + "type": "string", + "description": "Optional hangup cause passed through to Bird" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Test call created and either hung up or timed out", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BirdTestOutboundCallResponse" + } + } + } + } + } + } + }, + "/bird/numbers": { + "get": { + "tags": [ + "Bird" + ], + "summary": "List your numbers", + "operationId": "birdListNumbers", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "required": false, + "schema": { + "type": "string" + }, + "description": "Bird Workspace identifier (optional if configured)" + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "A list of numbers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BirdNumberListResponse" + } + } + } + } + } + } + }, + "/bird/numbers/{id}": { + "get": { + "tags": [ + "Bird" + ], + "summary": "Get a number by ID", + "operationId": "birdGetNumber", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "required": false, + "schema": { + "type": "string" + }, + "description": "Bird Workspace identifier (optional if configured)" + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Number details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BirdNumberSingleResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Bird" + ], + "summary": "Delete/release a number by ID", + "operationId": "birdDeleteNumber", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "required": false, + "schema": { + "type": "string" + }, + "description": "Bird Workspace identifier (optional if configured)" + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Number deletion/release accepted", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "/bird/voice/flash-calls": { + "post": { + "tags": [ + "Bird" + ], + "summary": "Create/place a flash call via Bird", + "operationId": "birdCreateFlashCall", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "Flash call created", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "get": { + "tags": [ + "Bird" + ], + "summary": "List flash calls", + "operationId": "birdListFlashCalls", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "A list of flash calls", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "/bird/voice/flash-calls/{id}": { + "get": { + "tags": [ + "Bird" + ], + "summary": "Get a flash call by ID", + "operationId": "birdGetFlashCall", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Flash call details", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "post": { + "tags": [ + "Bird" + ], + "summary": "Complete/end a flash call by ID", + "description": "Posts a completion/update payload to the flash call resource to finalize verification.", + "operationId": "birdEndFlashCall", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "Flash call completed", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "/bird/voice/flash-calls/end": { + "post": { + "tags": [ + "Bird" + ], + "summary": "Complete/end a flash call using from/to numbers", + "description": "Ends an ongoing flash call by specifying the originating and destination numbers.", + "operationId": "birdEndFlashCallByNumbers", + "parameters": [ + { + "in": "query", + "name": "workspaceId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Workspace identifier (falls back to module configuration if omitted)" + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "required": false, + "description": "Bird Channel identifier (falls back to module configuration if omitted)" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "properties": { + "from": { + "type": "string", + "description": "E.164 formatted caller number", + "example": "+4599988877" + }, + "to": { + "type": "string", + "description": "E.164 formatted callee number", + "example": "+4511122233" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Flash call completed (by numbers)", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "/subusers": { + "get": { + "tags": [ + "Subusers" + ], + "summary": "List subusers visible to the authenticated user", + "description": "Returns a paginated list of subusers (drivers) that have enabled grants tied to the\nauthenticated user's customer number. Only subusers with at least one enabled, non-deleted\ngrant for the caller's customer are returned.\n", + "operationId": "listSubusers", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "include_non_enabled", + "in": "query", + "required": false, + "description": "Include subusers that only have non-enabled grants (default false)", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "List of visible subusers", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "phone_country_code": { + "type": "integer", + "nullable": true + }, + "phone": { + "type": "integer", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "suspended_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "two_factor_enabled": { + "type": "boolean", + "description": "Indicates if 2FA is enabled for this account" + }, + "permissions": { + "type": "array", + "description": "Aggregated permission keys granted for the caller's customer", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/subusers/me": { + "get": { + "tags": [ + "Subusers" + ], + "summary": "Get current subuser profile", + "description": "Returns the authenticated subuser (driver) profile and their enabled grants grouped by\n`billing_customer_number`.\n\nNotes:\n- This endpoint is available only to authenticated subuser sessions.\n- It does not require the `X-Customer-Number` header; all enabled, non-deleted grants for the\n subuser are included in the response.\n", + "operationId": "getCurrentSubuser", + "responses": { + "200": { + "description": "Current subuser details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubuserSelf" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "post": { + "tags": [ + "Subusers" + ], + "summary": "Create a subuser registration", + "description": "Creates a subuser (driver) account using a company's CVR and a phone number. Validates the\nCVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled\nsends a setup link by SMS for the user to complete registration.\n", + "operationId": "createSubuser", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "cvr", + "phone_country_code", + "phone" + ], + "properties": { + "cvr": { + "type": "integer", + "description": "Danish CVR (8 digits)", + "example": 12345678 + }, + "phone_country_code": { + "type": "integer", + "description": "Phone country code (1–3 digits)", + "example": 45 + }, + "phone": { + "type": "integer", + "description": "Phone number (4–15 digits, no leading +)", + "example": 12345678 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Subuser created (or pending setup) and company identified", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cvr": { + "type": "integer", + "example": 12345678 + }, + "customer_number": { + "type": "integer", + "description": "Matched e-conomic customer number", + "example": 1000 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/subusers/{id}": { + "get": { + "tags": [ + "Subusers" + ], + "summary": "Get a subuser by ID (visible by grant)", + "description": "Returns the subuser if the authenticated user has at least one enabled, non-deleted grant\nfor their customer number to this subuser. Otherwise returns 404.\n", + "operationId": "getSubuser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Subuser details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "phone_country_code": { + "type": "integer", + "nullable": true + }, + "phone": { + "type": "integer", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "suspended_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "permissions": { + "type": "array", + "description": "Aggregated permission keys granted for the caller's customer", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/subusers/setup": { + "get": { + "tags": [ + "Subusers" + ], + "summary": "Validate setup token", + "description": "Validates a subuser setup token generated during registration.", + "operationId": "validateSubuserSetupToken", + "security": [], + "parameters": [ + { + "name": "token", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "One-time setup token received via SMS" + } + ], + "responses": { + "200": { + "description": "Token is valid", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Token is valid" + }, + "subuser_id": { + "type": "integer", + "example": 42 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "post": { + "tags": [ + "Subusers" + ], + "summary": "Complete subuser setup", + "description": "Completes subuser setup by setting a password and basic profile fields. Accepts optional\n`username` and `email`.\n", + "operationId": "completeSubuserSetup", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "token", + "password", + "name" + ], + "properties": { + "token": { + "type": "string", + "description": "One-time setup token" + }, + "password": { + "type": "string", + "format": "password", + "minLength": 8, + "description": "Must include at least one uppercase letter, one lowercase letter, and one number", + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\\\d).+$" + }, + "name": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "username": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "email": { + "type": "string", + "format": "email", + "minLength": 3, + "maxLength": 255 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Setup completed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Password set successfully" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/subusers/auth/password": { + "post": { + "tags": [ + "Subusers" + ], + "summary": "Authenticate subuser with password", + "description": "Authenticates a subuser (driver) using a password together with one of the supported\nidentifiers: `phone_country_code` + `phone`, `subuser_id`, or `username`.\n\nOn success, returns a newly generated session token for the subuser.\n", + "operationId": "subuserPasswordAuth", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "required": [ + "phone_country_code", + "phone", + "password" + ], + "properties": { + "phone_country_code": { + "type": "integer", + "description": "Phone country code (1–3 digits)", + "minimum": 1, + "maximum": 999, + "example": 45 + }, + "phone": { + "type": "integer", + "description": "Phone number (4–15 digits, no leading +)", + "minimum": 1000, + "maximum": 999999999999999, + "example": 12345678 + }, + "password": { + "type": "string", + "format": "password", + "minLength": 8, + "maxLength": 255 + } + } + }, + { + "type": "object", + "required": [ + "subuser_id", + "password" + ], + "properties": { + "subuser_id": { + "type": "integer", + "description": "Subuser ID", + "example": 42 + }, + "password": { + "type": "string", + "format": "password", + "minLength": 8, + "maxLength": 255 + } + } + }, + { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string", + "minLength": 3, + "maxLength": 255, + "example": "jdoe" + }, + "password": { + "type": "string", + "format": "password", + "minLength": 8, + "maxLength": 255 + } + } + } + ] + }, + "examples": { + "withPhone": { + "summary": "Authenticate with phone", + "value": { + "phone_country_code": 45, + "phone": 12345678, + "password": "MySecureP@ssw0rd" + } + }, + "withSubuserId": { + "summary": "Authenticate with subuser_id", + "value": { + "subuser_id": 42, + "password": "MySecureP@ssw0rd" + } + }, + "withUsername": { + "summary": "Authenticate with username", + "value": { + "username": "jdoe", + "password": "MySecureP@ssw0rd" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Authentication successful", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "required": [ + "session" + ], + "properties": { + "session": { + "type": "string", + "description": "Newly generated subuser session token", + "example": "2f7a8c0e-9b1d-4c6a-91a9-1a2b3c4d5e6f" + } + } + }, + { + "type": "object", + "required": [ + "2fa_required", + "2fa_token" + ], + "properties": { + "2fa_required": { + "type": "boolean", + "example": true + }, + "2fa_token": { + "type": "string", + "description": "Temporary 2FA verification token", + "example": "557a3e7b1a2b..." + } + } + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/subusers/grants": { + "get": { + "tags": [ + "Subusers" + ], + "summary": "List subuser grants", + "description": "Returns subuser grant records filtered by `customer_number` and/or `subuser_id`.", + "operationId": "listSubuserGrants", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "customer_number", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "e-conomic customer number to filter by" + }, + { + "name": "subuser_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Subuser ID to filter by" + } + ], + "responses": { + "200": { + "description": "Grants fetched", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubuserGrant" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "post": { + "tags": [ + "Subusers" + ], + "summary": "Create subuser grant", + "operationId": "createSubuserGrant", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubuserGrantCreateRequest" + }, + "examples": { + "default": { + "value": { + "customer_number": 1000, + "subuser_id": 42, + "enabled": true, + "note": "Grant for bookings access", + "permissions": [ + "BOOKINGS_LIST", + "BOOKINGS_ADD", + "BOOKINGS_EDIT" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Grant created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grant": { + "$ref": "#/components/schemas/SubuserGrant" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/subusers/grants/{id}": { + "patch": { + "tags": [ + "Subusers" + ], + "summary": "Update subuser grant", + "operationId": "updateSubuserGrant", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubuserGrantUpdateRequest" + }, + "examples": { + "enableOnly": { + "value": { + "enabled": true + } + }, + "updatePermissions": { + "value": { + "permissions": [ + "VEHICLES_LIST", + "SELFSERVE_ADD" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Grant updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grant": { + "$ref": "#/components/schemas/SubuserGrant" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "delete": { + "tags": [ + "Subusers" + ], + "summary": "Delete subuser grant", + "operationId": "deleteSubuserGrant", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Grant deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Grant deleted" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/subusers/permission-nodes": { + "get": { + "tags": [ + "Subusers" + ], + "summary": "List available subuser permission nodes", + "description": "Returns grouped permission nodes available for subuser grants.", + "operationId": "listSubuserPermissionNodes", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Permission nodes fetched", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permission_nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionNodeGroup" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/auth/login": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Customer login", + "description": "Authenticate a customer using customer number and password", + "operationId": "customerLogin", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "customer_number", + "password", + "g_recaptcha_response" + ], + "properties": { + "customer_number": { + "type": "integer", + "description": "Customer's e-conomic customer number", + "example": 12345 + }, + "password": { + "type": "string", + "format": "password", + "description": "Customer password", + "minLength": 1 + }, + "g_recaptcha_response": { + "type": "string", + "description": "reCAPTCHA verification token" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "Bearer authentication token", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + } + }, + { + "type": "object", + "required": [ + "2fa_required", + "2fa_token" + ], + "properties": { + "2fa_required": { + "type": "boolean", + "example": true + }, + "2fa_token": { + "type": "string", + "description": "Temporary 2FA verification token", + "example": "557a3e7b1a2b..." + } + } + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/auth/employee/login": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Employee login", + "description": "Authenticate an employee using user ID and password", + "operationId": "employeeLogin", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "user_id", + "password", + "g_recaptcha_response" + ], + "properties": { + "user_id": { + "type": "integer", + "description": "Employee user ID", + "example": 1 + }, + "password": { + "type": "string", + "format": "password", + "description": "Employee password" + }, + "g_recaptcha_response": { + "type": "string", + "description": "reCAPTCHA verification token" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "Bearer authentication token" + } + } + }, + { + "type": "object", + "required": [ + "2fa_required", + "2fa_token" + ], + "properties": { + "2fa_required": { + "type": "boolean", + "example": true + }, + "2fa_token": { + "type": "string", + "description": "Temporary 2FA verification token" + } + } + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/auth/passkey/challenge": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Initiate passkey authentication challenge", + "description": "Generates a WebAuthn PublicKeyCredentialRequestOptions payload. If customer_number is provided, allowCredentials will be populated with existing passkeys for that account. Otherwise, a challenge is issued for discoverable credentials.", + "operationId": "passkeyChallenge", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "g_recaptcha_response" + ], + "properties": { + "customer_number": { + "type": "integer", + "description": "Optional customer's e-conomic customer number", + "example": 12345 + }, + "g_recaptcha_response": { + "type": "string", + "description": "reCAPTCHA verification token" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Challenge generated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "challenge_token": { + "type": "string", + "description": "Temporary token binding the challenge to the login attempt" + }, + "publicKey": { + "type": "object", + "properties": { + "challenge": { + "type": "string", + "description": "Base64URL-encoded challenge" + }, + "rpId": { + "type": "string", + "description": "Relying party ID (truckwash.io or localhost)", + "example": "truckwash.io" + }, + "timeout": { + "type": "integer", + "description": "Timeout in milliseconds" + }, + "userVerification": { + "type": "string", + "enum": [ + "required", + "preferred", + "discouraged" + ] + }, + "allowCredentials": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "public-key" + }, + "id": { + "type": "string", + "description": "Base64URL-encoded credential ID" + }, + "transports": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/auth/passkey/verify": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Verify passkey authentication and start session", + "description": "Verifies the WebAuthn assertion and challenge token. Returns a session token on success.", + "operationId": "passkeyVerify", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "challenge_token", + "credential", + "g_recaptcha_response" + ], + "properties": { + "challenge_token": { + "type": "string", + "description": "The token returned by the challenge endpoint" + }, + "g_recaptcha_response": { + "type": "string", + "description": "reCAPTCHA verification token" + }, + "credential": { + "type": "object", + "description": "The WebAuthn PublicKeyCredential object (assertion)", + "required": [ + "id", + "rawId", + "type", + "response" + ], + "properties": { + "id": { + "type": "string", + "description": "The credential ID (base64url)" + }, + "rawId": { + "type": "string", + "description": "The raw credential ID (base64url)" + }, + "type": { + "type": "string", + "example": "public-key" + }, + "clientExtensionResults": { + "type": "object" + }, + "response": { + "type": "object", + "required": [ + "clientDataJSON", + "authenticatorData", + "signature" + ], + "properties": { + "clientDataJSON": { + "type": "string", + "description": "Base64URL-encoded client data" + }, + "authenticatorData": { + "type": "string", + "description": "Base64URL-encoded authenticator data" + }, + "signature": { + "type": "string", + "description": "Base64URL-encoded signature" + }, + "userHandle": { + "type": "string", + "nullable": true, + "description": "Base64URL-encoded user handle" + } + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Verification successful, session started", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "Bearer token for customer" + } + } + }, + { + "type": "object", + "required": [ + "session" + ], + "properties": { + "session": { + "type": "string", + "description": "Session token for subuser" + } + } + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/auth/logout": { + "get": { + "tags": [ + "Authentication" + ], + "summary": "Logout", + "description": "Invalidate the current authentication token", + "operationId": "logout", + "responses": { + "200": { + "description": "Logout successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Logged out" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/auth/session": { + "get": { + "tags": [ + "Authentication" + ], + "summary": "Get current session", + "description": "Retrieve information about the current authenticated user session", + "operationId": "getSession", + "responses": { + "200": { + "description": "Session information retrieved successfully", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "type": "object", + "properties": { + "two_factor_enabled": { + "type": "boolean", + "description": "Indicates if 2FA is enabled for this account" + } + } + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/auth/2fa/setup": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Generate 2FA secret", + "description": "Generate a new TOTP secret for the authenticated user/subuser", + "operationId": "setup2fa", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "2FA secret generated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The base32 encoded TOTP secret" + }, + "qr_code_url": { + "type": "string", + "description": "An otpauth URL for generating a QR code" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/auth/2fa/enable": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Enable 2FA", + "description": "Verify a code and enable 2FA for the authenticated user/subuser", + "operationId": "enable2fa", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code" + ], + "properties": { + "code": { + "type": "string", + "description": "The 6-digit TOTP code" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "2FA enabled successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/auth/2fa/disable": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Disable 2FA", + "description": "Verify a code and disable 2FA for the authenticated user/subuser", + "operationId": "disable2fa", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code" + ], + "properties": { + "code": { + "type": "string", + "description": "The 6-digit TOTP code" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "2FA disabled successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/auth/2fa/verify": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Verify 2FA code during login", + "description": "Complete the login process by verifying the 2FA code", + "operationId": "verify2fa", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "2fa_token", + "code" + ], + "properties": { + "2fa_token": { + "type": "string", + "description": "The temporary 2FA verification token" + }, + "code": { + "type": "string", + "description": "The 6-digit TOTP code" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "Bearer authentication token (for users/employees)" + } + } + }, + { + "type": "object", + "required": [ + "session" + ], + "properties": { + "session": { + "type": "string", + "description": "Session token (for subusers)" + } + } + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/auth/reCAPTCHA/public": { + "get": { + "tags": [ + "Authentication" + ], + "summary": "Get reCAPTCHA configuration", + "description": "Retrieve public reCAPTCHA configuration for login forms", + "operationId": "getRecaptchaConfig", + "security": [], + "responses": { + "200": { + "description": "reCAPTCHA configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rate_limit": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "reset": { + "type": "integer" + }, + "warning": { + "type": "string", + "nullable": true + } + } + }, + "recaptcha": { + "type": "object" + } + } + } + } + } + } + } + } + }, + "/auth/register/cvr": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Register new customer by CVR", + "description": "Register a new customer account using Danish CVR number", + "operationId": "registerCustomerByCvr", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "cvr", + "companyPhone", + "invoiceEmail", + "contactEmail", + "contactPhone", + "contactName", + "g_recaptcha_response" + ], + "properties": { + "cvr": { + "type": "string", + "description": "Danish CVR number", + "minLength": 8, + "maxLength": 20, + "example": "44794780" + }, + "companyPhone": { + "type": "integer", + "description": "Company phone number", + "minimum": 10000000, + "maximum": 9999999999, + "example": 21754690 + }, + "invoiceEmail": { + "type": "string", + "format": "email", + "description": "Email for invoices", + "minLength": 5, + "maxLength": 255, + "example": "invoice@company.dk" + }, + "contactEmail": { + "type": "string", + "format": "email", + "description": "Contact email", + "minLength": 5, + "maxLength": 255, + "example": "contact@company.dk" + }, + "contactPhone": { + "type": "integer", + "description": "Contact phone number", + "minimum": 10000000, + "maximum": 9999999999, + "example": 21754690 + }, + "contactName": { + "type": "string", + "description": "Contact person name", + "example": "Mikkel" + }, + "g_recaptcha_response": { + "type": "string", + "description": "reCAPTCHA verification token" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Customer registered successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/auth/password-reset/request": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Request a customer password reset email", + "description": "Send an email with a password reset token to the customer's email address", + "operationId": "requestPasswordReset", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "customer_number", + "g_recaptcha_response" + ], + "properties": { + "customer_number": { + "type": "integer", + "description": "The customer number", + "example": 123456 + }, + "g_recaptcha_response": { + "type": "string", + "description": "reCAPTCHA verification token" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Request processed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/auth/password-reset/validate": { + "get": { + "tags": [ + "Authentication" + ], + "summary": "Validate a customer password reset key", + "description": "Check if a password reset token is valid and hasn't expired", + "operationId": "validatePasswordResetToken", + "security": [], + "parameters": [ + { + "name": "token", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The password reset token" + } + ], + "responses": { + "200": { + "description": "Token is valid", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "customer_id": { + "type": "integer" + } + } + } + } + } + }, + "404": { + "description": "Invalid or expired token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/password-reset/set": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Set a customer password using a reset key", + "description": "Update the customer password using a valid reset token", + "operationId": "setPasswordUsingResetToken", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "token", + "password", + "g_recaptcha_response" + ], + "properties": { + "token": { + "type": "string", + "description": "The password reset token" + }, + "password": { + "type": "string", + "description": "The new password" + }, + "g_recaptcha_response": { + "type": "string", + "description": "reCAPTCHA verification token" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Password updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "description": "Invalid or expired token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/su/intimidate": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Intimidate a user", + "description": "Create an authentication token for another user (Superuser only)", + "operationId": "suIntimidate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/users": { + "get": { + "tags": [ + "Users" + ], + "summary": "List users", + "description": "Retrieve a paginated list of users", + "operationId": "listUsers", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + } + ], + "responses": { + "200": { + "description": "Users retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Create new user", + "description": "Create a new user account", + "operationId": "createUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserCreate" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "put": { + "tags": [ + "Users" + ], + "summary": "Update user", + "description": "Update an existing user", + "operationId": "updateUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "User updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/users/customer": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get customer details", + "description": "Get details about a specific customer", + "operationId": "getCustomer", + "parameters": [ + { + "name": "customer_number", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Customer retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/superuser/user": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get user by ID (superuser)", + "description": "Get detailed user information by user ID", + "operationId": "getSuperuserUser", + "parameters": [ + { + "name": "user_id", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "User retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/admin/customer/code": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get customer code", + "operationId": "getCustomerCode", + "parameters": [ + { + "name": "customer_number", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "user_id", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Add customer code", + "operationId": "addCustomerCode", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "customer_number": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "code": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/customer/department/default": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get customer default department", + "operationId": "getCustomerDefaultDepartment", + "parameters": [ + { + "name": "customer_number", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Add customer default department", + "operationId": "addCustomerDefaultDepartment", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "department" + ], + "properties": { + "customer_number": { + "type": "integer" + }, + "department": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "delete": { + "tags": [ + "Users" + ], + "summary": "Delete customer default department", + "operationId": "deleteCustomerDefaultDepartment", + "parameters": [ + { + "name": "customer_number", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/customer/pricing/fixed": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get customer fixed pricing", + "operationId": "getCustomerFixedPricing", + "parameters": [ + { + "name": "customer_number", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Add customer fixed pricing", + "operationId": "addCustomerFixedPricing", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "customer_number", + "price", + "description" + ], + "properties": { + "customer_number": { + "type": "integer" + }, + "price": { + "type": "integer" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "delete": { + "tags": [ + "Users" + ], + "summary": "Delete customer fixed pricing", + "operationId": "deleteCustomerFixedPricing", + "parameters": [ + { + "name": "customer_number", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/account/notifications": { + "put": { + "tags": [ + "Users" + ], + "summary": "Update user notification settings", + "operationId": "updateUserNotifications", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "wash_certificate_email": { + "type": "string" + }, + "sms_notifications_enabled": { + "type": "boolean" + }, + "email_notifications_enabled": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/user/permissions": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get user permissions", + "operationId": "getUserPermissions", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/customers": { + "get": { + "tags": [ + "Users" + ], + "summary": "List customers", + "operationId": "listCustomers", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + }, + { + "name": "barred", + "in": "query", + "required": false, + "description": "Optional e-conomic barred customer filter.", + "schema": { + "type": "string", + "enum": [ + "true", + "false", + "barred", + "active", + "1", + "0" + ] + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/superuser/user/discounts": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get user discounts", + "operationId": "getUserDiscounts", + "parameters": [ + { + "name": "user_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Set user discount", + "operationId": "setUserDiscount", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "discount", + "object_id", + "is_category" + ], + "properties": { + "user_id": { + "type": "integer" + }, + "discount": { + "type": "integer" + }, + "object_id": { + "type": "string" + }, + "is_category": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/superuser/user/keys": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get user keys", + "operationId": "getUserKeys", + "parameters": [ + { + "name": "user_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "key", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Set user key", + "operationId": "setUserKey", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "user_id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/superuser/user/password": { + "post": { + "tags": [ + "Users" + ], + "summary": "Set user password", + "operationId": "setUserPassword", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "user_id": { + "type": "integer" + }, + "password": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/admin/customer/getUserId": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get user ID from customer number", + "description": "Convert e-conomic customer number to internal user ID", + "operationId": "getUserIdFromCustomerNumber", + "parameters": [ + { + "name": "customer_number", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "User ID retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user_id": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "/admin/customer/name": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get customer name", + "description": "Get the full name of a customer", + "operationId": "getCustomerName", + "parameters": [ + { + "name": "user_id", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Customer name retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/orders": { + "get": { + "tags": [ + "Orders" + ], + "summary": "List orders", + "description": "Retrieve a paginated list of orders", + "operationId": "listOrders", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + }, + { + "name": "show_wash_subscription", + "in": "query", + "schema": { + "type": "string", + "enum": [ + true, + false + ] + } + } + ], + "responses": { + "200": { + "description": "Orders retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Orders" + ], + "summary": "Create new order", + "description": "Create a new wash order", + "operationId": "createOrder", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Order created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "delete": { + "tags": [ + "Orders" + ], + "summary": "Delete order", + "description": "Delete an existing order", + "operationId": "deleteOrder", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Order deleted successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "put": { + "tags": [ + "Orders" + ], + "summary": "Update order (alias)", + "description": "Update an existing order", + "operationId": "updateOrders", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Order updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/order": { + "get": { + "tags": [ + "Orders" + ], + "summary": "Get order details", + "description": "Get detailed information about a specific order", + "operationId": "getOrder", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Order retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "put": { + "tags": [ + "Orders" + ], + "summary": "Update order", + "description": "Update an existing order", + "operationId": "updateOrder", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Order updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/user/orders": { + "get": { + "tags": [ + "Orders" + ], + "summary": "Get current user's orders", + "description": "Retrieve orders for the authenticated user", + "operationId": "getUserOrders", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + } + ], + "responses": { + "200": { + "description": "Orders retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + } + } + } + } + } + } + }, + "/user/order": { + "get": { + "tags": [ + "Orders" + ], + "summary": "Get user's specific order", + "description": "Get details of a specific order for the authenticated user", + "operationId": "getUserOrder", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Order retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + } + } + } + }, + "/orders/mark_as_completed": { + "post": { + "tags": [ + "Orders" + ], + "summary": "Mark order as completed", + "description": "Mark an order as completed", + "operationId": "markOrderCompleted", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Order marked as completed successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/order/wash-certificate": { + "post": { + "tags": [ + "Orders" + ], + "summary": "Generate wash certificate", + "description": "Generate a wash certificate for an order", + "operationId": "generateWashCertificate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "order_id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Wash certificate generated successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/order/items": { + "get": { + "tags": [ + "Order Items" + ], + "summary": "List order items", + "description": "Get all items for a specific order", + "operationId": "listOrderItems", + "parameters": [ + { + "name": "order_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Order items retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderItem" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Order Items" + ], + "summary": "Add item to order", + "description": "Add a new item to an existing order", + "operationId": "addOrderItem", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderItemCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Order item added successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "put": { + "tags": [ + "Order Items" + ], + "summary": "Update order item", + "description": "Update an existing order item", + "operationId": "updateOrderItem", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderItemUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Order item updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "delete": { + "tags": [ + "Order Items" + ], + "summary": "Delete order item", + "description": "Remove an item from an order", + "operationId": "deleteOrderItem", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Order item deleted successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/departments": { + "get": { + "tags": [ + "Departments" + ], + "summary": "List departments", + "description": "Retrieve a list of all visible departments", + "operationId": "listDepartments", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Filter by specific department ID" + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + } + ], + "responses": { + "200": { + "description": "Departments retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Department" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "tags": [ + "Departments" + ], + "summary": "Create department", + "description": "Create a new department", + "operationId": "createDepartment", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Department created successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "put": { + "tags": [ + "Departments" + ], + "summary": "Update department", + "description": "Update an existing department", + "operationId": "updateDepartment", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Department updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/departments/categories": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get department categories", + "description": "Get product categories available in a department", + "operationId": "getDepartmentCategories", + "parameters": [ + { + "name": "department_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Department categories retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Departments" + ], + "summary": "Add category to department", + "description": "Associate a product category with a department", + "operationId": "addDepartmentCategory", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "department_id": { + "type": "integer" + }, + "category_id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Category added to department successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "delete": { + "tags": [ + "Departments" + ], + "summary": "Remove category from department", + "operationId": "removeDepartmentCategory", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/departments/self-serve/enabled": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get department self-serve status", + "description": "Check if self-serve is enabled for a specific department", + "operationId": "getDepartmentSelfServeEnabled", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Department ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "put": { + "tags": [ + "Departments" + ], + "summary": "Update department self-serve status", + "description": "Enable or disable self-serve for a specific department", + "operationId": "updateDepartmentSelfServeEnabled", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Department ID", + "schema": { + "type": "integer" + } + }, + { + "name": "enabled", + "in": "query", + "required": true, + "description": "Enabled status (true/false)", + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + } + ], + "responses": { + "200": { + "description": "Status updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/departments/order/recommended": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get recommended order for department", + "operationId": "getDepartmentRecommendedOrder", + "parameters": [ + { + "name": "department_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/department/lanes": { + "get": { + "tags": [ + "Departments" + ], + "summary": "List department lanes", + "description": "Retrieve a list of all department lanes", + "operationId": "listDepartmentLanes", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + } + ], + "responses": { + "200": { + "description": "Department lanes retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentLane" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "tags": [ + "Departments" + ], + "summary": "Create department lane", + "description": "Create a new department lane", + "operationId": "createDepartmentLane", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentLaneCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Department lane created successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "put": { + "tags": [ + "Departments" + ], + "summary": "Update department lane", + "description": "Update an existing department lane", + "operationId": "updateDepartmentLane", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentLaneUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Department lane updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/gates": { + "get": { + "tags": [ + "Departments" + ], + "summary": "List department gates", + "description": "Retrieve department gates, optionally filtered by id", + "operationId": "listDepartmentGates", + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + } + ], + "responses": { + "200": { + "description": "Department gates retrieved successfully", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DepartmentGate" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentGate" + } + } + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "tags": [ + "Departments" + ], + "summary": "Create department gate", + "operationId": "createDepartmentGate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentGateCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Department gate created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentGate" + } + } + } + } + } + }, + "put": { + "tags": [ + "Departments" + ], + "summary": "Update department gate", + "operationId": "updateDepartmentGate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentGateUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Department gate updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentGate" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "tags": [ + "Departments" + ], + "summary": "Delete department gate", + "operationId": "deleteDepartmentGate", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Department gate deleted", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/department/relays": { + "get": { + "tags": [ + "Departments" + ], + "summary": "List department relays", + "description": "Retrieve department relays, optionally filtered by id", + "operationId": "listDepartmentRelays", + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + } + ], + "responses": { + "200": { + "description": "Department relays retrieved successfully", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DepartmentRelay" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentRelay" + } + } + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "tags": [ + "Departments" + ], + "summary": "Create department relay", + "operationId": "createDepartmentRelay", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentRelayCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Department relay created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentRelay" + } + } + } + } + } + }, + "put": { + "tags": [ + "Departments" + ], + "summary": "Update department relay", + "operationId": "updateDepartmentRelay", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentRelayUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Department relay updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentRelay" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "tags": [ + "Departments" + ], + "summary": "Delete department relay", + "operationId": "deleteDepartmentRelay", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Department relay deleted", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/department/lanes/dynamic-image": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Generate dynamic image for a department lane", + "description": "Returns a composed machine UI image for the specified department lane.\nYou can optionally highlight button indices, set the current step indicator, and toggle only-current-step mode.\n", + "operationId": "getDepartmentLaneDynamicImage", + "parameters": [ + { + "name": "department", + "in": "query", + "required": true, + "description": "Department ID", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "lane", + "in": "query", + "required": true, + "description": "Lane ID", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "buttons", + "in": "query", + "required": false, + "description": "Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.", + "schema": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + } + }, + { + "name": "current_step", + "in": "query", + "required": false, + "description": "Current step indicator (non-negative integer)", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "only_current_step", + "in": "query", + "required": false, + "description": "If true, only draw the current step highlight", + "schema": { + "type": "boolean" + } + }, + { + "name": "vehicle_type", + "in": "query", + "required": false, + "description": "Vehicle type selection override (nullable non-negative integer)", + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Dynamic image rendered successfully", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/guest/validation/customer-number": { + "post": { + "tags": [ + "Users" + ], + "summary": "Validate customer number", + "description": "Check if a customer number is valid and exists", + "operationId": "validateCustomerNumber", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "customer_number" + ], + "properties": { + "customer_number": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Customer number validation successful", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/guest/departments": { + "get": { + "tags": [ + "Departments" + ], + "summary": "List public departments", + "description": "Get list of departments without authentication", + "operationId": "listGuestDepartments", + "security": [], + "parameters": [ + { + "name": "include_lanes", + "in": "query", + "description": "Whether to include lane status and self-serve information", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Departments retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentGuest" + } + } + } + } + } + } + } + }, + "/department/selfserve/machine-types": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "List reusable self-serve machine types", + "operationId": "listSelfserveMachineTypes", + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved machine types", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SelfserveMachineType" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfserveMachineType" + } + } + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "tags": [ + "Self-Serve" + ], + "summary": "Add reusable self-serve machine type", + "operationId": "addSelfserveMachineType", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully added machine type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfserveMachineType" + } + } + } + } + } + }, + "put": { + "tags": [ + "Self-Serve" + ], + "summary": "Update reusable self-serve machine type", + "operationId": "updateSelfserveMachineType", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully updated machine type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfserveMachineType" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "tags": [ + "Self-Serve" + ], + "summary": "Delete reusable self-serve machine type", + "operationId": "deleteSelfserveMachineType", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted machine type", + "content": { + "application/json": { + "schema": { + "type": "string", + "example": "Machine type deleted" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/selfserve/questions": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "List self-serve questions", + "description": "Retrieve a list of self-serve questions for a department, lane, or product.", + "operationId": "listSelfserveQuestions", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter by question ID", + "schema": { + "type": "integer" + } + }, + { + "name": "department", + "in": "query", + "description": "Filter by department ID", + "schema": { + "type": "integer" + } + }, + { + "name": "lane", + "in": "query", + "description": "Filter by lane ID", + "schema": { + "type": "integer" + } + }, + { + "name": "product", + "in": "query", + "description": "Filter by product ID", + "schema": { + "type": "integer" + } + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + }, + { + "$ref": "#/components/parameters/FiltersParam" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved questions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveQuestion" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "tags": [ + "Self-Serve" + ], + "summary": "Add self-serve question", + "description": "Add a new self-serve question. Questions are typically shared across departments and lanes by omitting department, lane, and product, which default to 0.", + "operationId": "addSelfserveQuestion", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "question", + "description" + ], + "properties": { + "department": { + "type": "integer", + "default": 0 + }, + "lane": { + "type": "integer", + "default": 0 + }, + "product": { + "type": "integer", + "default": 0 + }, + "question": { + "type": "string" + }, + "description": { + "type": "string" + }, + "condition_id": { + "type": "integer", + "nullable": true + }, + "order_priority": { + "type": "integer", + "default": 0 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully added question", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveQuestion" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "put": { + "tags": [ + "Self-Serve" + ], + "summary": "Update self-serve question", + "description": "Update an existing self-serve question.", + "operationId": "updateSelfserveQuestion", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Question ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "department": { + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "product": { + "type": "integer" + }, + "question": { + "type": "string" + }, + "description": { + "type": "string" + }, + "condition_id": { + "type": "integer", + "nullable": true + }, + "order_priority": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully updated question", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveQuestion" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "tags": [ + "Self-Serve" + ], + "summary": "Delete self-serve question", + "description": "Delete a self-serve question by ID.", + "operationId": "deleteSelfserveQuestion", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Question ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted question", + "content": { + "application/json": { + "schema": { + "type": "string", + "example": "Question deleted" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/selfserve/conditions": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "List self-serve conditions", + "description": "Retrieve a list of self-serve conditions for a department, lane, or product.", + "operationId": "listSelfserveConditions", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter by condition ID", + "schema": { + "type": "integer" + } + }, + { + "name": "department", + "in": "query", + "description": "Filter by department ID", + "schema": { + "type": "integer" + } + }, + { + "name": "lane", + "in": "query", + "description": "Filter by lane ID", + "schema": { + "type": "integer" + } + }, + { + "name": "product", + "in": "query", + "description": "Filter by product ID", + "schema": { + "type": "integer" + } + }, + { + "name": "condition_id", + "in": "query", + "description": "Filter by condition ID", + "schema": { + "type": "integer" + } + }, + { + "name": "machine_type_id", + "in": "query", + "description": "Filter by reusable machine type ID", + "schema": { + "type": "integer" + } + }, + { + "name": "machine_type_id", + "in": "query", + "description": "Filter by reusable machine type ID", + "schema": { + "type": "integer" + } + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + }, + { + "$ref": "#/components/parameters/FiltersParam" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved conditions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveCondition" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "tags": [ + "Self-Serve" + ], + "summary": "Add self-serve condition", + "description": "Add a new self-serve condition. Either provide a reusable machine_type_id or a legacy department/lane/product scope.", + "operationId": "addSelfserveCondition", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "description" + ], + "properties": { + "department": { + "type": "integer", + "default": 0 + }, + "lane": { + "type": "integer", + "default": 0 + }, + "product": { + "type": "integer", + "default": 0 + }, + "machine_type_id": { + "type": "integer", + "nullable": true + }, + "condition_id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully added condition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveCondition" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "put": { + "tags": [ + "Self-Serve" + ], + "summary": "Update self-serve condition", + "description": "Update an existing self-serve condition.", + "operationId": "updateSelfserveCondition", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Condition ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "department": { + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "product": { + "type": "integer" + }, + "machine_type_id": { + "type": "integer", + "nullable": true + }, + "condition_id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully updated condition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveCondition" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "delete": { + "tags": [ + "Self-Serve" + ], + "summary": "Delete self-serve condition", + "description": "Delete a self-serve condition.", + "operationId": "deleteSelfserveCondition", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Condition ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted condition", + "content": { + "application/json": { + "schema": { + "type": "string", + "example": "Condition deleted" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/selfserve/condition/rules": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "List self-serve condition rules", + "description": "Retrieve a list of self-serve condition rules.", + "operationId": "listSelfserveConditionRules", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter by rule ID", + "schema": { + "type": "integer" + } + }, + { + "name": "condition_id", + "in": "query", + "description": "Filter by condition ID", + "schema": { + "type": "integer" + } + }, + { + "name": "type", + "in": "query", + "description": "Filter by rule type", + "schema": { + "type": "string" + } + }, + { + "name": "object_type", + "in": "query", + "description": "Filter by object type", + "schema": { + "type": "string" + } + }, + { + "name": "object_id", + "in": "query", + "description": "Filter by object ID", + "schema": { + "type": "integer" + } + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + }, + { + "$ref": "#/components/parameters/FiltersParam" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved condition rules", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveConditionRule" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "tags": [ + "Self-Serve" + ], + "summary": "Add self-serve condition rule", + "description": "Add a new self-serve condition rule.", + "operationId": "addSelfserveConditionRule", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "condition_id", + "type", + "object_type", + "object_id", + "name", + "description" + ], + "properties": { + "condition_id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "object_type": { + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully added condition rule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveConditionRule" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "put": { + "tags": [ + "Self-Serve" + ], + "summary": "Update self-serve condition rule", + "description": "Update an existing self-serve condition rule.", + "operationId": "updateSelfserveConditionRule", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Rule ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "condition_id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "object_type": { + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully updated condition rule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveConditionRule" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "delete": { + "tags": [ + "Self-Serve" + ], + "summary": "Delete self-serve condition rule", + "description": "Delete a self-serve condition rule.", + "operationId": "deleteSelfserveConditionRule", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Rule ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted condition rule", + "content": { + "application/json": { + "schema": { + "type": "string", + "example": "Rule deleted" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/selfserve/vehicle/conditions": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "List vehicle conditions", + "description": "Retrieve a list of vehicle conditions for a department, lane, reg, or question. Customers will only see their own vehicle conditions.", + "operationId": "listSelfserveVehicleConditions", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter by condition ID", + "schema": { + "type": "integer" + } + }, + { + "name": "department", + "in": "query", + "description": "Filter by department ID", + "schema": { + "type": "integer" + } + }, + { + "name": "lane", + "in": "query", + "description": "Filter by lane ID", + "schema": { + "type": "integer" + } + }, + { + "name": "reg", + "in": "query", + "description": "Filter by vehicle registration number", + "schema": { + "type": "string" + } + }, + { + "name": "question", + "in": "query", + "description": "Filter by question ID", + "schema": { + "type": "integer" + } + }, + { + "name": "customer_id", + "in": "query", + "description": "Filter by customer ID", + "schema": { + "type": "integer" + } + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + }, + { + "$ref": "#/components/parameters/FiltersParam" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved vehicle conditions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveVehicleCondition" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "tags": [ + "Self-Serve" + ], + "summary": "Add vehicle condition", + "description": "Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.", + "operationId": "addSelfserveVehicleCondition", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "department", + "lane", + "reg", + "question" + ], + "properties": { + "department": { + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "reg": { + "type": "string" + }, + "question": { + "type": "integer" + }, + "value": { + "type": "boolean" + }, + "customer_id": { + "type": "integer", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully added vehicle condition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "put": { + "tags": [ + "Self-Serve" + ], + "summary": "Update vehicle condition", + "description": "Update an existing vehicle condition. Customers can only update conditions for their own vehicles.", + "operationId": "updateSelfserveVehicleCondition", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Condition ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "department": { + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "reg": { + "type": "string" + }, + "question": { + "type": "integer" + }, + "value": { + "type": "boolean" + }, + "customer_id": { + "type": "integer", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully updated vehicle condition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "delete": { + "tags": [ + "Self-Serve" + ], + "summary": "Delete vehicle condition", + "description": "Delete a vehicle condition. Customers can only delete conditions for their own vehicles.", + "operationId": "deleteSelfserveVehicleCondition", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Condition ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted vehicle condition", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Condition deleted" + }, + "selfserve": { + "allOf": [ + { + "$ref": "#/components/schemas/SelfserveWashSummary" + } + ], + "nullable": true + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/selfserve/vehicle/allowed": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "Check whether self-serve is allowed for a vehicle on a lane", + "operationId": "getSelfserveVehicleAllowed", + "parameters": [ + { + "name": "lane_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "reg", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully evaluated self-serve eligibility", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfserveVehicleAllowedResponse" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/selfserve/washes/summary": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "Get self-serve wash summary", + "operationId": "getSelfserveWashSummary", + "parameters": [ + { + "name": "session_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "lane_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "reg", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved self-serve wash summary", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfserveWashSummary" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/selfserve/tasks": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "List self-serve tasks", + "description": "Retrieve a list of self-serve tasks for a department, lane, product, or condition_id.", + "operationId": "listSelfserveTasks", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter by task ID", + "schema": { + "type": "integer" + } + }, + { + "name": "department", + "in": "query", + "description": "Filter by department ID", + "schema": { + "type": "integer" + } + }, + { + "name": "lane", + "in": "query", + "description": "Filter by lane ID", + "schema": { + "type": "integer" + } + }, + { + "name": "product", + "in": "query", + "description": "Filter by product ID", + "schema": { + "type": "integer" + } + }, + { + "name": "condition_id", + "in": "query", + "description": "Filter by condition ID", + "schema": { + "type": "integer" + } + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + }, + { + "$ref": "#/components/parameters/FiltersParam" + } + ], + "responses": { + "200": { + "description": "Successfully retrieved tasks", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveTask" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "tags": [ + "Self-Serve" + ], + "summary": "Add self-serve task", + "description": "Add a new self-serve task. Either provide a reusable machine_type_id or a legacy department/lane/product scope.", + "operationId": "addSelfserveTask", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "task", + "description" + ], + "properties": { + "department": { + "type": "integer", + "default": 0 + }, + "lane": { + "type": "integer", + "default": 0 + }, + "product": { + "type": "integer", + "default": 0 + }, + "machine_type_id": { + "type": "integer", + "nullable": true + }, + "condition_id": { + "type": "integer", + "nullable": true + }, + "task": { + "type": "string" + }, + "description": { + "type": "string" + }, + "order_priority": { + "type": "integer", + "default": 0 + }, + "services": { + "type": "array", + "description": "Optional services enabled by this task. Items must be valid service enum names.", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + } + }, + "buttons": { + "type": "array", + "description": "Optional dynamic image button IDs enabled by this task.", + "items": { + "type": "integer" + }, + "default": [] + }, + "dynamic_images_vehicle_type": { + "type": "integer", + "nullable": true, + "description": "Optional vehicle type selection override for the machine UI. Integer >= 0 or null." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully added task", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveTask" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "put": { + "tags": [ + "Self-Serve" + ], + "summary": "Update self-serve task", + "description": "Update an existing self-serve task.", + "operationId": "updateSelfserveTask", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Task ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "department": { + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "product": { + "type": "integer" + }, + "machine_type_id": { + "type": "integer", + "nullable": true + }, + "condition_id": { + "type": "integer", + "nullable": true + }, + "task": { + "type": "string" + }, + "description": { + "type": "string" + }, + "order_priority": { + "type": "integer" + }, + "services": { + "type": "array", + "nullable": true, + "description": "Services enabled by this task. Set to null to clear all services.", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + } + }, + "buttons": { + "type": "array", + "nullable": true, + "description": "Button IDs enabled by this task. Set to null to clear all buttons.", + "items": { + "type": "integer" + } + }, + "dynamic_images_vehicle_type": { + "type": "integer", + "nullable": true, + "description": "Vehicle type selection override. Set to null to clear." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully updated task", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentSelfserveTask" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "tags": [ + "Self-Serve" + ], + "summary": "Delete self-serve task", + "description": "Delete a self-serve task by ID.", + "operationId": "deleteSelfserveTask", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Task ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted task", + "content": { + "application/json": { + "schema": { + "type": "string", + "example": "Task deleted" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/selfserve/tasks/attachments": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "List task attachments", + "description": "Retrieve a list of attachments for a specific self-serve task.", + "operationId": "listSelfserveTaskAttachments", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "description": "Task ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved task attachments", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "tags": [ + "Self-Serve" + ], + "summary": "Delete task attachment", + "description": "Remove an attachment from a specific self-serve task.", + "operationId": "deleteSelfserveTaskAttachment", + "parameters": [ + { + "name": "task_id", + "in": "query", + "required": true, + "description": "Task ID", + "schema": { + "type": "integer" + } + }, + { + "name": "attachment_id", + "in": "query", + "required": true, + "description": "Attachment ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Attachment deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Attachment deleted successfully" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/selfserve/tasks/attachments/upload": { + "post": { + "tags": [ + "Self-Serve" + ], + "summary": "Upload task attachment", + "description": "Upload a new attachment to a specific self-serve task using base64 encoding.", + "operationId": "uploadSelfserveTaskAttachment", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "task_id", + "base64_file", + "file_name" + ], + "properties": { + "task_id": { + "type": "integer" + }, + "base64_file": { + "type": "string", + "description": "Base64 encoded file content" + }, + "file_name": { + "type": "string", + "description": "Name of the file including extension" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Attachment uploaded successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/department/selfserve/tasks/attachments/download": { + "get": { + "tags": [ + "Self-Serve" + ], + "summary": "Download task attachment", + "description": "Generate a download link for a specific self-serve task attachment.", + "operationId": "downloadSelfserveTaskAttachment", + "parameters": [ + { + "name": "task_id", + "in": "query", + "required": true, + "description": "Task ID", + "schema": { + "type": "integer" + } + }, + { + "name": "attachment_id", + "in": "query", + "required": true, + "description": "Attachment ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully generated download link", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "download_link": { + "type": "string", + "format": "uri" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/products": { + "get": { + "tags": [ + "Products" + ], + "summary": "List products", + "description": "Retrieve a list of products with optional filters for customer pricing and department", + "operationId": "listProducts", + "parameters": [ + { + "name": "customer_id", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Customer ID for custom pricing" + }, + { + "name": "department_id", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Department ID for department-specific pricing" + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Filter by category ID" + }, + { + "name": "id", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Get specific product by ID" + }, + { + "name": "final_price", + "in": "query", + "schema": { + "type": "boolean" + }, + "description": "Whether to return final prices including discounts" + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + } + ], + "responses": { + "200": { + "description": "Products retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Product" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Products" + ], + "summary": "Create product", + "description": "Create a new product", + "operationId": "createProduct", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Product created successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "put": { + "tags": [ + "Products" + ], + "summary": "Update product", + "description": "Update an existing product", + "operationId": "updateProduct", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Product updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/categories": { + "get": { + "tags": [ + "Categories" + ], + "summary": "List categories", + "description": "Retrieve a list of product categories", + "operationId": "listCategories", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + } + ], + "responses": { + "200": { + "description": "Categories retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Category" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Categories" + ], + "summary": "Create category", + "description": "Create a new product category", + "operationId": "createCategory", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Category created successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "put": { + "tags": [ + "Categories" + ], + "summary": "Update category", + "description": "Update an existing category", + "operationId": "updateCategory", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Category updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/bookings": { + "get": { + "tags": [ + "Bookings" + ], + "summary": "List bookings", + "description": "Retrieve a list of bookings", + "operationId": "listBookings", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + } + ], + "responses": { + "200": { + "description": "Bookings retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Booking" + } + } + } + } + } + } + }, + "put": { + "tags": [ + "Bookings" + ], + "summary": "Update booking", + "description": "Update an existing booking", + "operationId": "updateBooking", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Booking updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/user/bookings": { + "get": { + "tags": [ + "Bookings" + ], + "summary": "Get user bookings", + "description": "Retrieve bookings for the authenticated user", + "operationId": "getUserBookings", + "responses": { + "200": { + "description": "User bookings retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Booking" + } + } + } + } + } + } + } + }, + "/order-bookings": { + "get": { + "tags": [ + "Bookings" + ], + "summary": "List order bookings", + "operationId": "listOrderBookings", + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/goals/department": { + "get": { + "tags": [ + "Goals" + ], + "summary": "List or get department goals", + "description": "Retrieve a list of department goals or a single goal when `id` is provided.\n\nAccess control:\n- A user may only access goals where the goal's `departments` set is a subset of the user's departments.\n- Users with the `superuser` permission may access all goals.\n", + "operationId": "listDepartmentGoals", + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "When provided, returns the single goal with this id (if accessible)" + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + }, + { + "$ref": "#/components/parameters/FiltersParam" + } + ], + "responses": { + "200": { + "description": "Goals retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentGoal" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "tags": [ + "Goals" + ], + "summary": "Create department goal", + "description": "Create a new department goal.\n\nAccess control:\n- The provided `departments` must be a subset of the user's departments unless the user has `superuser`.\n", + "operationId": "createDepartmentGoal", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentGoalCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Department goal created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentGoal" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "put": { + "tags": [ + "Goals" + ], + "summary": "Update department goal", + "description": "Update an existing department goal by `id`.\n\nAccess control:\n- The creator (`created_by`) may update regardless of department membership.\n- Otherwise the user must satisfy the same subset rule as for read access, and any new `departments` provided must also be a subset unless the user has `superuser`.\n", + "operationId": "updateDepartmentGoal", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentGoalUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Department goal updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepartmentGoal" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/goals/department/progress-alert/test": { + "post": { + "tags": [ + "Goals" + ], + "summary": "Send a test progress alert for a department goal", + "description": "Sends a progress alert for a department goal to the destination defined in the goal's criteria.\n\nPermission required: `goals_department_progress_alert_test`.\n\nAccess control:\n- The caller must be a superuser or belong to all departments targeted by the goal.\n\nBehavior:\n- Looks up the goal by `id`.\n- Rebuilds the criteria from stored JSON and attaches the goal's departments.\n- Renders the alert using the server-side renderer (respecting progress type/style/format and destination limits).\n- Sends the alert to Slack, Email, or SMS depending on `progress_alert_destination`, unless overridden.\n", + "operationId": "sendDepartmentGoalProgressAlertTest", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "description": "The department goal id", + "example": 42 + }, + "overrideDestination": { + "type": "string", + "description": "Override the destination for this test", + "enum": [ + "SLACK", + "EMAIL", + "SMS", + "NONE" + ], + "example": "SLACK" + }, + "email_to": { + "type": "string", + "format": "email", + "description": "Email recipient when destination is EMAIL", + "example": "tester@example.com" + }, + "subject": { + "type": "string", + "description": "Optional email subject when destination is EMAIL", + "example": "Dept Goal Progress Test" + }, + "sms_to": { + "description": "One or more MSISDN recipients when destination is SMS", + "oneOf": [ + { + "type": "string", + "description": "Comma or semicolon separated list", + "example": "+4512345678, +4598765432" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "+4512345678", + "+4598765432" + ] + } + ] + }, + "slack_webhook": { + "type": "string", + "description": "Slack webhook URL when destination is SLACK", + "example": "https://hooks.slack.com/services/T000/B000/XXX" + }, + "department_id": { + "type": "integer", + "description": "Department id to use that department's Slack webhook when destination is SLACK", + "example": 3 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Alert sent successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Goal id" + }, + "destination": { + "type": "string", + "description": "Final destination used", + "enum": [ + "SLACK", + "EMAIL", + "SMS", + "NONE" + ] + }, + "target": { + "description": "The target used for delivery (email address, phone numbers, department id, or webhook)" + }, + "message_preview": { + "type": "string", + "description": "Rendered message preview" + }, + "provider_response": { + "description": "Provider-specific response or status message" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "delete": { + "tags": [ + "Goals" + ], + "summary": "Delete department goal", + "description": "Delete a department goal by `id`.\n\nAccess control:\n- The creator (`created_by`) may delete regardless of department membership.\n- Otherwise the user must satisfy the subset rule or have `superuser`.\n", + "operationId": "deleteDepartmentGoal", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the goal to delete" + } + ], + "responses": { + "200": { + "description": "Department goal deleted successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "/order-bookings": { + "post": { + "tags": [ + "Bookings" + ], + "summary": "Create order booking", + "operationId": "createOrderBooking", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "department", + "reg_1", + "datetime", + "items" + ], + "properties": { + "customer_number": { + "type": "integer" + }, + "department": { + "type": "integer" + }, + "reg_1": { + "type": "string" + }, + "reg_2": { + "type": "string" + }, + "reg_3": { + "type": "string" + }, + "datetime": { + "type": "string", + "format": "date-time" + }, + "note": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "po": { + "type": "string" + }, + "pickup": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "quantity" + ], + "properties": { + "id": { + "type": "integer" + }, + "quantity": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + }, + "put": { + "tags": [ + "Bookings" + ], + "summary": "Update order booking", + "operationId": "updateOrderBooking", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "customer_number": { + "type": "integer" + }, + "department": { + "type": "integer" + }, + "reg_1": { + "type": "string" + }, + "reg_2": { + "type": "string" + }, + "reg_3": { + "type": "string" + }, + "datetime": { + "type": "string", + "format": "date-time" + }, + "note": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "po": { + "type": "string" + }, + "pickup": { + "type": "boolean" + }, + "order_id": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "quantity" + ], + "properties": { + "id": { + "type": "integer" + }, + "quantity": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "Bookings" + ], + "summary": "Delete order booking", + "operationId": "deleteOrderBooking", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + } + }, + "/order-bookings/complete": { + "post": { + "tags": [ + "Bookings" + ], + "summary": "Complete order booking", + "operationId": "completeOrderBooking", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "safety_seal": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/admin/bookings/sync": { + "post": { + "tags": [ + "Bookings" + ], + "summary": "Sync booking from external system", + "operationId": "syncBooking", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/admin/bookings/department/count": { + "get": { + "tags": [ + "Bookings" + ], + "summary": "Get department unfulfilled bookings count", + "operationId": "getDepartmentBookingCount", + "parameters": [ + { + "name": "department_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/user/bookings/washcertificate/download": { + "post": { + "tags": [ + "Bookings" + ], + "summary": "Get download link for own wash certificate", + "operationId": "downloadOwnWashCertificate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/bookings/download_pdf": { + "get": { + "tags": [ + "Bookings" + ], + "summary": "Download booking PDF", + "operationId": "downloadBookingPdf", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/admin/bookings/delete": { + "post": { + "tags": [ + "Bookings" + ], + "summary": "Delete booking (admin)", + "operationId": "adminDeleteBooking", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/superuser/bookings/sync/all": { + "post": { + "tags": [ + "Bookings" + ], + "summary": "Sync all bookings from external system", + "operationId": "syncAllBookings", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/admin/bookings/completeWashWithoutWashCertificate": { + "post": { + "tags": [ + "Bookings" + ], + "summary": "Complete wash without wash certificate", + "operationId": "completeWashWithoutWashCertificate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/user/bookings/delete": { + "post": { + "tags": [ + "Bookings" + ], + "summary": "Delete own booking", + "operationId": "deleteOwnBooking", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/invoices/draft": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "List draft invoices", + "description": "Retrieve a list of draft invoices", + "operationId": "listDraftInvoices", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + } + ], + "responses": { + "200": { + "description": "Draft invoices retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/invoices/draft/close": { + "post": { + "tags": [ + "Invoices" + ], + "summary": "Close draft invoice", + "description": "Close a draft invoice", + "operationId": "closeDraftInvoice", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Draft invoice closed successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/invoices/pdf": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get invoice PDF", + "description": "Download an invoice as PDF", + "operationId": "getInvoicePdf", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "PDF retrieved successfully", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/user/invoices": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get user invoices", + "description": "Retrieve invoices for the authenticated user", + "operationId": "getUserInvoices", + "responses": { + "200": { + "description": "User invoices retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/collected-invoices": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "List collected invoices", + "description": "Get list of collected invoices", + "operationId": "listCollectedInvoices", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + } + ], + "responses": { + "200": { + "description": "Collected invoices retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Invoices" + ], + "summary": "Create collected invoice", + "description": "Create a new collected invoice", + "operationId": "createCollectedInvoice", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "201": { + "description": "Collected invoice created successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "put": { + "tags": [ + "Invoices" + ], + "summary": "Update collected invoice", + "description": "Update a collected invoice", + "operationId": "updateCollectedInvoice", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Collected invoice updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/collected-invoices/ready-to-invoice": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get invoices ready to process", + "description": "Get collected invoices that are ready to be processed", + "operationId": "getReadyToInvoice", + "responses": { + "200": { + "description": "Ready invoices retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/collected-invoices/economic/compare": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Compare collected invoice totals with E-conomic", + "description": "Compares a collected invoice in the system with its corresponding invoice in E-conomic.\nReturns totals from both sources, their difference, and any warnings detected during comparison.\n", + "operationId": "compareCollectedInvoiceEconomic", + "parameters": [ + { + "name": "collected_invoice_id", + "in": "query", + "required": true, + "description": "The internal collected invoice ID to compare", + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Comparison completed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectedInvoiceEconomicCompareResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/collected-invoices/economic/v2/details": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get deep V2 e-conomic invoice details", + "description": "Returns normalized internal lines and best-effort fetched draft/booked e-conomic lines\nfor a collected invoice, including department distributions and warnings.\n", + "operationId": "getCollectedInvoiceEconomicV2Details", + "parameters": [ + { + "name": "collected_invoice_id", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Details resolved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/collected-invoices/economic/v2/compare": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Compare internal invoice with draft/booked (V2)", + "operationId": "compareCollectedInvoiceEconomicV2", + "parameters": [ + { + "name": "collected_invoice_id", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Comparison completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2CompareResponse" + }, + "examples": { + "exactMatch": { + "summary": "Exact match between internal and draft/booked", + "value": { + "collected_invoice_id": 123, + "warnings": [], + "comparison": { + "totals": { + "internal_net_total": 694 + }, + "targets": { + "draft": { + "target": "draft", + "status": "exact_match", + "overall_match": true + }, + "booked": { + "target": "booked", + "status": "exact_match", + "overall_match": true + } + } + } + } + }, + "partialMismatch": { + "summary": "Partial mismatch with line and department differences", + "value": { + "collected_invoice_id": 123, + "warnings": [ + "Non-billable line count differs" + ], + "comparison": { + "totals": { + "internal_net_total": 694 + }, + "targets": { + "draft": { + "target": "draft", + "status": "partial_mismatch", + "overall_match": false, + "mismatch_reasons": [ + "quantity_mismatch", + "department_total_mismatch" + ] + } + } + } + } + }, + "missingBooked": { + "summary": "Missing booked target", + "value": { + "collected_invoice_id": 123, + "comparison": { + "totals": { + "internal_net_total": 694 + }, + "targets": { + "booked": { + "target": "booked", + "status": "missing_target", + "overall_match": false + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/collected-invoices/economic/v2/compare/bulk": { + "post": { + "tags": [ + "Invoices" + ], + "summary": "Bulk compare collected invoices against draft/booked (V2)", + "operationId": "compareCollectedInvoiceEconomicV2Bulk", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "collected_invoice_ids" + ], + "properties": { + "collected_invoice_ids": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Bulk comparison completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2CompareBulkResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/collected-invoices/economic/v2/revenue-statistics": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get overall booked revenue statistics from e-conomic (V2)", + "description": "Aggregates booked e-conomic revenue across invoices and lines, with optional filters\nfor date range, customer(s), department(s), currency, and barred-customer status.\n", + "operationId": "getCollectedInvoiceEconomicV2RevenueStatistics", + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "required": false, + "description": "Start date (inclusive), defaults to first day of current month.", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": false, + "description": "End date (inclusive), defaults to today.", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "customer_numbers", + "in": "query", + "required": false, + "description": "Comma-separated customer numbers to include.", + "schema": { + "type": "string", + "example": "42493959,42493960" + } + }, + { + "name": "department_numbers", + "in": "query", + "required": false, + "description": "Comma-separated department numbers to include.", + "schema": { + "type": "string", + "example": "75,10" + } + }, + { + "name": "currency", + "in": "query", + "required": false, + "description": "Restrict to a specific invoice currency.", + "schema": { + "type": "string", + "example": "DKK" + } + }, + { + "name": "barred", + "in": "query", + "required": false, + "description": "Filter by e-conomic customer barred status.", + "schema": { + "type": "string", + "enum": [ + "all", + "barred", + "active" + ], + "default": "all" + } + }, + { + "name": "max_pages", + "in": "query", + "required": false, + "description": "Safety cap for paginated e-conomic reads.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "Revenue statistics resolved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2RevenueStatisticsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/superuser/invoicing/period": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get invoicing periods", + "description": "Retrieve invoicing periods for superusers", + "operationId": "getInvoicingPeriods", + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Invoicing periods retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/superuser/invoicing/period/distribution/fixed-pricing": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get fixed pricing distribution", + "description": "Get invoicing distribution for fixed pricing items", + "operationId": "getInvoicingFixedPricingDistribution", + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Fixed pricing distribution retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionResponse" + } + } + } + } + } + } + }, + "/superuser/invoicing/period/distribution/wash-subscriptions": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get wash subscriptions distribution", + "description": "Get invoicing distribution for wash subscriptions", + "operationId": "getInvoicingWashSubscriptionsDistribution", + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Wash subscriptions distribution retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoicingWashSubscriptionsDistributionResponse" + } + } + } + } + } + } + }, + "/superuser/invoicing/period/distribution/v2/all": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get version-aware historical distribution (all)", + "operationId": "getInvoicingPeriodDistributionV2All", + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Version-aware historical distribution (all categories)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoicingDistributionV2AllResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/superuser/invoicing/period/distribution/v2/fixed-pricing": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get version-aware historical fixed pricing distribution", + "operationId": "getInvoicingPeriodDistributionV2FixedPricing", + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Version-aware fixed pricing distribution", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoicingDistributionV2FixedPricingResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/superuser/invoicing/period/distribution/v2/wash-subscriptions": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get version-aware historical wash subscription distribution", + "operationId": "getInvoicingPeriodDistributionV2WashSubscriptions", + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Version-aware wash subscription distribution", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/superuser/invoicing/period/distribution/v2/customer-prices": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get version-aware historical customer-price discount distribution", + "operationId": "getInvoicingPeriodDistributionV2CustomerPrices", + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Version-aware customer-price discount distribution", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoicingDistributionV2CustomerPricesResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/superuser/invoicing/period/distribution/v2/booked-department-75": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get booked e-conomic department 75 redistribution", + "operationId": "getInvoicingPeriodDistributionV2BookedDepartment75", + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Actual booked e-conomic department 75 net amounts redistributed to internal departments", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoicingDistributionV2BookedDepartment75Response" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/superuser/customers/pricing-history": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get customer versioned pricing/subscription/discount timeline", + "operationId": "getCustomerPricingHistoryV2", + "parameters": [ + { + "name": "customer_number", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "dateFrom", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Customer timeline resolved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerPricingHistoryResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/vehicles": { + "get": { + "tags": [ + "Vehicles" + ], + "summary": "List vehicles", + "description": "List vehicles or fetch a specific vehicle when `id` is provided.\n\n- When `id` is present, returns a single vehicle object (404 if not found).\n- Otherwise returns a paginated list of vehicles.\n\nPermissions:\n- Own scope: `list_own_vehicles` (linked to subuser node `VEHICLES_LIST`).\n- Broader scope: `list_vehicles_other`.\n\nSubusers may specify header `X-Customer-Number` to target a specific customer. If the broader\npermission is missing, the list will automatically be restricted to the effective customer context.\n", + "operationId": "listVehicles", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/XCustomerNumber" + }, + { + "name": "id", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "reg", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "customer_id", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Vehicle(s) retrieved successfully", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Vehicle" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Vehicle" + } + } + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "tags": [ + "Vehicles" + ], + "summary": "Add vehicle", + "operationId": "addVehicle", + "description": "Create a new vehicle for a customer.\n\nPermissions:\n- Own scope: `add_vehicle` (linked to subuser node `VEHICLES_ADD`).\n- Broader scope: `add_vehicle_other`.\n", + "parameters": [ + { + "$ref": "#/components/parameters/XCustomerNumber" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "reg", + "type", + "wash_subscription" + ], + "properties": { + "reg": { + "type": "string", + "minLength": 2, + "maxLength": 12, + "description": "Vehicle registration number" + }, + "type": { + "type": "integer", + "description": "Product ID representing the vehicle wash type" + }, + "wash_subscription": { + "type": "boolean" + }, + "reference": { + "type": "string", + "maxLength": 255, + "nullable": true + }, + "customer_id": { + "type": "integer", + "description": "Optional explicit target customer. Defaults to the effective customer context." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Vehicle created", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "put": { + "tags": [ + "Vehicles" + ], + "summary": "Edit vehicle", + "operationId": "editVehicle", + "description": "Update fields on an existing vehicle.\n\nPermissions:\n- Own scope: `edit_vehicle` (linked to subuser node `VEHICLES_EDIT`).\n- Broader scope: `edit_vehicle_other`.\n", + "parameters": [ + { + "$ref": "#/components/parameters/XCustomerNumber" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "reg": { + "type": "string", + "minLength": 2, + "maxLength": 12 + }, + "type": { + "type": "integer" + }, + "wash_subscription": { + "type": "boolean" + }, + "reference": { + "type": "string", + "maxLength": 255, + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Vehicle updated", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "tags": [ + "Vehicles" + ], + "summary": "Delete vehicle", + "operationId": "deleteVehicle", + "description": "Delete an existing vehicle.\n\nPermissions:\n- Own scope: `delete_vehicle` (linked to subuser node `VEHICLES_DELETE`).\n- Broader scope: `delete_vehicle_other`.\n", + "parameters": [ + { + "$ref": "#/components/parameters/XCustomerNumber" + }, + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Vehicle deleted", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vehicles/addons/available": { + "get": { + "tags": [ + "Vehicles" + ], + "summary": "Get available vehicle addons", + "description": "Get list of available addons for a vehicle.\n\nPermissions:\n- Own scope: `list_vehicle_addon_own` (linked to subuser node `VEHICLES_LIST`).\n- Broader scope: `list_vehicles_addon_other`.\n", + "operationId": "getAvailableVehicleAddons", + "parameters": [ + { + "$ref": "#/components/parameters/XCustomerNumber" + }, + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Available addons retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vehicles/addons/toggle": { + "post": { + "tags": [ + "Vehicles" + ], + "summary": "Toggle vehicle addon", + "description": "Enable or disable a vehicle addon for a vehicle.\n\nPermissions:\n- Own scope: `toggle_vehicle_addon_own` (linked to subuser node `VEHICLES_EDIT`).\n- Broader scope: `toggle_vehicle_addon_other`.\n", + "operationId": "toggleVehicleAddon", + "parameters": [ + { + "$ref": "#/components/parameters/XCustomerNumber" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "vehicle_id", + "addon_id" + ], + "properties": { + "vehicle_id": { + "type": "integer" + }, + "addon_id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Vehicle addon toggled successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/department/vehicles/unknown-customer": { + "get": { + "tags": [ + "Vehicles" + ], + "summary": "Get unknown customer vehicles in department", + "operationId": "getUnknownCustomerVehicles", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/department/vehicle/customer-suggestions": { + "get": { + "tags": [ + "Vehicles" + ], + "summary": "Get vehicle customer suggestions", + "operationId": "getVehicleCustomerSuggestions", + "parameters": [ + { + "name": "reg", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/vehicles/set-auto-start-on-lpr": { + "post": { + "tags": [ + "Vehicles" + ], + "summary": "Set auto start on LPR", + "operationId": "setVehicleAutoStartOnLpr", + "description": "Enable or disable automatic start on LPR for a vehicle in XL Vask.\n\nPermissions:\n- Own scope: `set_auto_start_on_lpr` (linked to subuser node `VEHICLES_EDIT`).\n- Broader scope: `set_auto_start_on_lpr_other`.\n", + "parameters": [ + { + "$ref": "#/components/parameters/XCustomerNumber" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "active" + ], + "properties": { + "id": { + "type": "integer" + }, + "active": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vehicles/set-vehicle-type-id": { + "post": { + "tags": [ + "Vehicles" + ], + "summary": "Set vehicle type ID", + "operationId": "setVehicleTypeId", + "description": "Set or change the XL Vask `vehicleTypeId` for a vehicle.\n\nPermissions:\n- Own scope: `set_vehicle_type_id` (linked to subuser node `VEHICLES_EDIT`).\n- Broader scope: `set_vehicle_type_id_other`.\n", + "parameters": [ + { + "$ref": "#/components/parameters/XCustomerNumber" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "vehicleTypeId" + ], + "properties": { + "id": { + "type": "integer" + }, + "vehicleTypeId": { + "type": "string", + "minLength": 1, + "maxLength": 50 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/superuser/users-with-vehicle-subscriptions": { + "get": { + "tags": [ + "Vehicles" + ], + "summary": "Get users with vehicle subscriptions", + "operationId": "getUsersWithVehicleSubscriptions", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/vehicles/status": { + "get": { + "tags": [ + "Vehicles" + ], + "summary": "Get vehicle status", + "operationId": "getVehicleStatus", + "parameters": [ + { + "name": "reg", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/vehicles/search": { + "get": { + "tags": [ + "Vehicles" + ], + "summary": "Search vehicles", + "operationId": "searchVehicles", + "parameters": [ + { + "name": "search", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/notifications": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "List notifications", + "description": "Get list of notifications", + "operationId": "listNotifications", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + } + ], + "responses": { + "200": { + "description": "Notifications retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Notification" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Notifications" + ], + "summary": "Create notification", + "description": "Create a new notification", + "operationId": "createNotification", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Notification created successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "delete": { + "tags": [ + "Notifications" + ], + "summary": "Delete notification", + "description": "Delete a notification", + "operationId": "deleteNotification", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Notification deleted successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/bookings/new": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get new bookings statistics", + "description": "Get statistics for new bookings", + "operationId": "getNewBookingsStats", + "responses": { + "200": { + "description": "New bookings statistics retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/orders/module/stripe/payment_intent": { + "get": { + "tags": [ + "Orders" + ], + "summary": "Get Stripe payment intent", + "operationId": "getStripePaymentIntent", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Orders" + ], + "summary": "Create Stripe payment intent", + "operationId": "createStripePaymentIntent", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "reader" + ], + "properties": { + "id": { + "type": "integer" + }, + "reader": { + "type": "string" + }, + "tax_percentage": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "delete": { + "tags": [ + "Orders" + ], + "summary": "Delete Stripe payment intent", + "operationId": "deleteStripePaymentIntent", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/orders/module/stripe/payment_intent/capture": { + "post": { + "tags": [ + "Orders" + ], + "summary": "Capture Stripe payment intent", + "operationId": "captureStripePaymentIntent", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/orders/module/stripe/debug/simulate_payment": { + "post": { + "tags": [ + "Orders" + ], + "summary": "Simulate Stripe payment", + "operationId": "simulateStripePayment", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/orders/new": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get new orders statistics", + "description": "Get statistics for new orders", + "operationId": "getNewOrdersStats", + "responses": { + "200": { + "description": "New orders statistics retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/income/today": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get today's income", + "description": "Get income statistics for today", + "operationId": "getTodayIncome", + "responses": { + "200": { + "description": "Today's income statistics retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/income/yesterday": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get yesterday's income", + "description": "Get income statistics for yesterday", + "operationId": "getYesterdayIncome", + "responses": { + "200": { + "description": "Yesterday's income statistics retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/income/this-month": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get this month's income", + "description": "Get income statistics for the current month", + "operationId": "getThisMonthIncome", + "responses": { + "200": { + "description": "This month's income statistics retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/income/last-month": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get last month's income", + "description": "Get income statistics for the previous month", + "operationId": "getLastMonthIncome", + "responses": { + "200": { + "description": "Last month's income statistics retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/income/this-year": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get this year's income", + "description": "Get income statistics for the current year", + "operationId": "getThisYearIncome", + "responses": { + "200": { + "description": "This year's income statistics retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/income/departments": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get total income today by departments", + "operationId": "getTotalIncomeTodayByDepartments", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/economic/totals": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get total economic statistics", + "operationId": "getEconomicTotals", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/economic/totals/department_sent_invoice_totals": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get department sent invoice totals", + "operationId": "getDepartmentSentInvoiceTotals", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/statistics/economic/totals/department_draft_invoice_totals": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get department draft invoice totals", + "operationId": "getDepartmentDraftInvoiceTotals", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/worker/version": { + "get": { + "tags": [ + "Worker" + ], + "summary": "Get worker version", + "description": "Get the current version of the system worker", + "operationId": "getWorkerVersion", + "responses": { + "200": { + "description": "Worker version retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/worker/update-version": { + "get": { + "tags": [ + "Worker" + ], + "summary": "Update worker version", + "description": "Set the target version for the worker update", + "operationId": "updateWorkerVersion", + "parameters": [ + { + "name": "version", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Version update target set successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/worker/status": { + "get": { + "tags": [ + "Worker" + ], + "summary": "Get worker status", + "description": "Get detailed status of the system worker", + "operationId": "getWorkerStatus", + "responses": { + "200": { + "description": "Worker status retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/worker/debug": { + "get": { + "tags": [ + "Worker" + ], + "summary": "Debug worker", + "description": "Execute debug commands on the worker (often restricted)", + "operationId": "debugWorker", + "responses": { + "200": { + "description": "Debug information retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/worker/debug/on": { + "get": { + "tags": [ + "Worker" + ], + "summary": "Enable worker debug", + "operationId": "enableWorkerDebug", + "responses": { + "200": { + "description": "Worker debug enabled", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/worker/debug/off": { + "get": { + "tags": [ + "Worker" + ], + "summary": "Disable worker debug", + "operationId": "disableWorkerDebug", + "responses": { + "200": { + "description": "Worker debug disabled", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/worker/licenseplates": { + "get": { + "tags": [ + "Worker" + ], + "summary": "Get unique license plates", + "description": "Fetch all unique license plates from various database tables", + "operationId": "getWorkerLicensePlates", + "responses": { + "200": { + "description": "License plates retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/economic/doesCustomerExist": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Check if customer exists in e-conomic", + "operationId": "checkEconomicCustomerExists", + "parameters": [ + { + "name": "cvr", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Customer check completed", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/cvr/lookup": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Lookup CVR information", + "description": "Get detailed information for a CVR number", + "operationId": "lookupCvr", + "parameters": [ + { + "name": "cvr", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "CVR information retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/cvr/search": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Search CVR", + "description": "Search for companies by name or CVR", + "operationId": "searchCvr", + "parameters": [ + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 2 + } + } + ], + "responses": { + "200": { + "description": "Search results retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/numberplatescans": { + "get": { + "tags": [ + "Plate Scans" + ], + "summary": "List plate scans", + "description": "Get a list of license plate scans", + "operationId": "listPlateScans", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + } + ], + "responses": { + "200": { + "description": "Plate scans retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Plate Scans" + ], + "summary": "Record plate scan", + "description": "Record a new license plate scan", + "operationId": "recordPlateScan", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "plate", + "lane_id" + ], + "properties": { + "plate": { + "type": "string" + }, + "lane_id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Plate scan recorded successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/numberplatescans/department": { + "post": { + "tags": [ + "Plate Scans" + ], + "summary": "Record plate scan for department", + "operationId": "recordDepartmentPlateScan", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "plate", + "department_id" + ], + "properties": { + "plate": { + "type": "string" + }, + "department_id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Plate scan recorded successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/numberplatescans/post": { + "get": { + "tags": [ + "Plate Scans" + ], + "summary": "Get post-scan results", + "operationId": "getPlateScanPostResults", + "responses": { + "200": { + "description": "Post-scan results retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/numberplatescanners": { + "get": { + "tags": [ + "Plate Scans" + ], + "summary": "List plate scanners", + "description": "Get a list of all number plate scanners", + "operationId": "listPlateScanners", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + } + ], + "responses": { + "200": { + "description": "Plate scanners retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Plate Scans" + ], + "summary": "Add plate scanner", + "operationId": "addPlateScanner", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "department_id", + "name", + "notes" + ], + "properties": { + "department_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Plate scanner added successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "put": { + "tags": [ + "Plate Scans" + ], + "summary": "Update plate scanner", + "operationId": "updatePlateScanner", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "department_id", + "name", + "notes" + ], + "properties": { + "id": { + "type": "integer" + }, + "department_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Plate scanner updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/department/numberplatescanners": { + "get": { + "tags": [ + "Plate Scans" + ], + "summary": "List department plate scanners", + "operationId": "listDepartmentPlateScanners", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Department plate scanners retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/relay/button/press/post": { + "get": { + "tags": [ + "Plate Scans" + ], + "summary": "Record machine start button press webhook", + "operationId": "addButtonPress", + "parameters": [ + { + "name": "token", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "lane_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "reg", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Button press recorded and linked to a self-serve wash session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MachineButtonPressWebhookResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "tags": [ + "Plate Scans" + ], + "summary": "Record machine start button press webhook", + "operationId": "addButtonPressPost", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "lane_id": { + "type": "integer" + }, + "reg": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Button press recorded and linked to a self-serve wash session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MachineButtonPressWebhookResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/economic/customers/import": { + "post": { + "tags": [ + "Modules" + ], + "summary": "Import e-conomic customers", + "description": "Import customers from e-conomic", + "operationId": "importEconomicCustomers", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Customers imported successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/economic/departments": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get e-conomic departments", + "operationId": "getEconomicDepartments", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/economic/products": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get e-conomic products", + "operationId": "getEconomicProducts", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/economic/customer": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get e-conomic customer details", + "operationId": "getEconomicCustomer", + "parameters": [ + { + "name": "customer_number", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Modules" + ], + "summary": "Create e-conomic customer", + "operationId": "createEconomicCustomer", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "customer_number", + "cvr", + "email", + "phone", + "name" + ], + "properties": { + "customer_number": { + "type": "integer" + }, + "cvr": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/economic/layouts": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get e-conomic layouts", + "description": "Get available invoice layouts from e-conomic", + "operationId": "getEconomicLayouts", + "responses": { + "200": { + "description": "Layouts retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/economic/payment-terms": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get e-conomic payment terms", + "description": "Get available payment terms from e-conomic", + "operationId": "getEconomicPaymentTerms", + "responses": { + "200": { + "description": "Payment terms retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/economic/invoice/draft/export": { + "post": { + "tags": [ + "Modules" + ], + "summary": "Export draft invoice to e-conomic", + "description": "Export a draft invoice to e-conomic", + "operationId": "exportDraftInvoiceToEconomic", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Draft invoice exported successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/economic/invoice/export": { + "post": { + "tags": [ + "Modules" + ], + "summary": "Export invoice to e-conomic", + "description": "Export a booked invoice to e-conomic", + "operationId": "exportInvoiceToEconomic", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Invoice exported successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/stripe/customers": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List Stripe customers", + "description": "Get list of Stripe customers", + "operationId": "listStripeCustomers", + "responses": { + "200": { + "description": "Stripe customers retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/stripe/products": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List Stripe products", + "description": "Get list of Stripe products", + "operationId": "listStripeProducts", + "responses": { + "200": { + "description": "Stripe products retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/stripe/prices": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List Stripe prices", + "description": "Get list of Stripe prices", + "operationId": "listStripePrices", + "responses": { + "200": { + "description": "Stripe prices retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/stripe/invoice": { + "post": { + "tags": [ + "Modules" + ], + "summary": "Create Stripe invoice", + "description": "Create an invoice in Stripe", + "operationId": "createStripeInvoice", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "201": { + "description": "Stripe invoice created successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/stripe/terminal/readers": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List Stripe terminal readers", + "operationId": "listStripeTerminalReaders", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/stripe/terminal/locations": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List Stripe terminal locations", + "operationId": "listStripeTerminalLocations", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/stripe/department/terminal/location": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get department terminal location", + "operationId": "getDepartmentTerminalLocation", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Modules" + ], + "summary": "Set department terminal location", + "operationId": "setDepartmentTerminalLocation", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "location" + ], + "properties": { + "id": { + "type": "integer" + }, + "location": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/stripe/department/terminal/readers": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get department terminal readers", + "operationId": "getDepartmentTerminalReaders", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/backup/backups": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List backup modules", + "operationId": "listBackupModules", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Modules" + ], + "summary": "Create backup module", + "operationId": "createBackupModule", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "description" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/xlvask/usageLog": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get XLVask usage logs", + "description": "Retrieve usage logs from XLVask system", + "operationId": "getXlvaskUsageLogs", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + } + ], + "responses": { + "200": { + "description": "Usage logs retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/xlvask/vehicles": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List XLVask vehicles", + "description": "Get list of vehicles from XLVask", + "operationId": "listXlvaskVehicles", + "responses": { + "200": { + "description": "XLVask vehicles retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/xlvask/customers": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List XLVask customers", + "description": "Get list of customers from XLVask", + "operationId": "listXlvaskCustomers", + "responses": { + "200": { + "description": "XLVask customers retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/action-logs": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List module action logs", + "description": "Retrieve a paginated list of module action logs with searching and filtering", + "operationId": "listModuleActionLogs", + "parameters": [ + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/LimitParam" + }, + { + "$ref": "#/components/parameters/SearchParam" + }, + { + "$ref": "#/components/parameters/FiltersParam" + } + ], + "responses": { + "200": { + "description": "Module action logs retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleActionLog" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/modules/self-serve/lane/status": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get self-serve lane status", + "description": "Retrieve the current status of a self-serve lane", + "operationId": "getSelfServeLaneStatus", + "parameters": [ + { + "name": "lane_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Lane status retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfServeLaneStatus" + } + } + } + } + } + } + }, + "/modules/self-serve/lane/command": { + "post": { + "tags": [ + "Modules" + ], + "summary": "Send self-serve lane command", + "description": "Send a command (e.g., start, stop, reset) to a self-serve lane", + "operationId": "sendSelfServeLaneCommand", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "lane_id", + "command" + ], + "properties": { + "lane_id": { + "type": "integer" + }, + "command": { + "type": "string", + "enum": [ + "START", + "STOP", + "RESET", + "RESERVE", + "RELEASE" + ] + }, + "license_plate": { + "type": "string", + "description": "Required for START command" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Command sent successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfServeLaneStatus" + } + } + } + } + } + } + }, + "/modules/self-serve/lane/services/allowed": { + "post": { + "tags": [ + "Modules" + ], + "summary": "Set allowed services for a lane based on shown tasks", + "description": "Updates the set of services that are allowed to be manually activated for a given self-serve lane,\nderived from the tasks currently shown to the user after answering the self-serve questions.\nThis endpoint does not activate anything by itself; it only sets what is allowed to be activated.\n", + "operationId": "setSelfServeLaneAllowedServices", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "lane_id" + ], + "properties": { + "lane_id": { + "type": "integer" + }, + "task_ids": { + "type": "array", + "description": "List of task IDs that are currently shown to the user", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Allowed services updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lane_id": { + "type": "integer" + }, + "allowed_services": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/modules/self-serve/lane/relay/machine/enable": { + "post": { + "tags": [ + "Modules" + ], + "summary": "Manually enable MACHINE relay for a lane", + "description": "Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services\ninclude `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically\nenabled; an explicit call to this endpoint is required.\n", + "operationId": "enableSelfServeLaneMachineRelay", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "lane_id" + ], + "properties": { + "lane_id": { + "type": "integer" + }, + "duration": { + "type": "integer", + "description": "Optional number of seconds after which the relay should automatically turn off" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "MACHINE relay enabled", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lane_id": { + "type": "integer" + }, + "relay": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "duration": { + "type": "integer", + "nullable": true + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Not allowed to enable MACHINE relay (no matching task currently shown)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/modules/self-serve/lane/force/machine/enable": { + "post": { + "tags": [ + "Modules" + ], + "summary": "Force enable MACHINE relay and mark lane as in-wash (superusers only)", + "description": "Superuser/emergency endpoint. Bypasses the allowed services gating and directly turns on the MACHINE relay.\nAlso ensures the lane is marked as OCCUPIED and IN_WASH with a wash start timestamp if not already set.\n", + "operationId": "forceEnableSelfServeLaneMachine", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "lane_id" + ], + "properties": { + "lane_id": { + "type": "integer" + }, + "duration": { + "type": "integer", + "nullable": true, + "description": "Optional number of seconds after which the relay should automatically turn off" + }, + "license_plate": { + "type": "string", + "nullable": true, + "description": "Optional license plate to associate with the lane" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "MACHINE relay force-enabled and lane marked in-wash", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lane_id": { + "type": "integer" + }, + "forced": { + "type": "boolean" + }, + "machine": { + "type": "string", + "enum": [ + "ENABLED" + ] + }, + "duration": { + "type": "integer", + "nullable": true + }, + "status": { + "type": "string" + }, + "state": { + "type": "string" + }, + "wash_start_time": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/modules/self-serve/lane/force/machine/disable": { + "post": { + "tags": [ + "Modules" + ], + "summary": "Force disable MACHINE relay but keep lane as in-wash (superusers only)", + "description": "Superuser/emergency endpoint. Turns off the MACHINE relay while ensuring the lane remains in an IN_WASH state\n(simulating a started wash without machine assistance).\n", + "operationId": "forceDisableSelfServeLaneMachine", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "lane_id" + ], + "properties": { + "lane_id": { + "type": "integer" + }, + "license_plate": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "MACHINE relay force-disabled and lane ensured in-wash", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lane_id": { + "type": "integer" + }, + "forced": { + "type": "boolean" + }, + "machine": { + "type": "string", + "enum": [ + "DISABLED" + ] + }, + "status": { + "type": "string" + }, + "state": { + "type": "string" + }, + "wash_start_time": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/modules/motorapi/lookup": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Lookup vehicle via MotorAPI", + "description": "Look up vehicle information using license plate", + "operationId": "motorApiLookup", + "parameters": [ + { + "name": "plate", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Vehicle information retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/virkdata/search": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Search VirkData", + "description": "Search for company information in VirkData", + "operationId": "virkdataSearch", + "parameters": [ + { + "name": "search", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Company information retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/fxratesapi/rate": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get exchange rate", + "description": "Get current exchange rate", + "operationId": "getExchangeRate", + "parameters": [ + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Exchange rate retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/fxratesapi/rates": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get all exchange rates", + "description": "Get all available exchange rates", + "operationId": "getAllExchangeRates", + "responses": { + "200": { + "description": "Exchange rates retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/weatherapi/current": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get current weather", + "description": "Get current weather data from WeatherAPI for a location query", + "operationId": "weatherApiCurrent", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Location query (e.g. city, postal code, or latitude,longitude)" + } + ], + "responses": { + "200": { + "description": "Current weather retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherApiObjectResponse" + } + } + } + } + } + } + }, + "/modules/weatherapi/forecast": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get weather forecast", + "description": "Get forecast weather data from WeatherAPI", + "operationId": "weatherApiForecast", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Location query (e.g. city, postal code, or latitude,longitude)" + }, + { + "name": "days", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 14 + }, + "description": "Number of forecast days" + } + ], + "responses": { + "200": { + "description": "Forecast weather retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherApiObjectResponse" + } + } + } + } + } + } + }, + "/modules/weatherapi/search": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Search weather locations", + "description": "Search location suggestions from WeatherAPI", + "operationId": "weatherApiSearch", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search text" + } + ], + "responses": { + "200": { + "description": "Location search results retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherApiObjectResponse" + } + } + } + } + } + } + }, + "/departments/weather": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get department weather timeline", + "description": "Returns hourly weather, washes, hours and productivity status for a department", + "operationId": "getDepartmentWeatherTimeline", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + }, + "description": "Department ID" + } + ], + "responses": { + "200": { + "description": "Department weather timeline retrieved successfully", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentWeatherTimelineEntry" + } + } + } + } + ], + "$ref": "#/components/schemas/DepartmentWeatherTimelineResponse" + } + } + } + } + } + } + }, + "/modules/entra/users": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List Microsoft Entra users", + "description": "Get list of users from Microsoft Entra (Azure AD)", + "operationId": "listEntraUsers", + "responses": { + "200": { + "description": "Entra users retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/attachments/upload": { + "post": { + "tags": [ + "Attachments" + ], + "summary": "Upload attachment", + "description": "Upload a file attachment", + "operationId": "uploadAttachment", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Attachment uploaded successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/orders/attachments": { + "get": { + "tags": [ + "Attachments" + ], + "summary": "List order attachments", + "description": "Get attachments for an order", + "operationId": "listOrderAttachments", + "parameters": [ + { + "name": "order_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Order attachments retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/orders/attachments/upload": { + "post": { + "tags": [ + "Attachments" + ], + "summary": "Upload order attachment", + "description": "Upload an attachment to an order", + "operationId": "uploadOrderAttachment", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "order_id": { + "type": "integer" + }, + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Order attachment uploaded successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/orders/attachments/download": { + "get": { + "tags": [ + "Attachments" + ], + "summary": "Download order attachment", + "description": "Download a specific order attachment", + "operationId": "downloadOrderAttachment", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Attachment downloaded successfully", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/form": { + "get": { + "tags": [ + "Forms" + ], + "summary": "Get form", + "description": "Retrieve a form definition", + "operationId": "getForm", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Form retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Forms" + ], + "summary": "Submit form", + "description": "Submit a form", + "operationId": "submitForm", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "data" + ], + "properties": { + "id": { + "type": "string", + "description": "Form identifier" + }, + "data": { + "type": "object", + "description": "Form submission data" + }, + "g_recaptcha_response": { + "type": "string", + "description": "reCAPTCHA verification token (required if not authenticated)" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Form submitted successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/permissions": { + "get": { + "tags": [ + "Users" + ], + "summary": "List permissions", + "description": "Get list of all available permissions", + "operationId": "listPermissions", + "responses": { + "200": { + "description": "Permissions retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission" + } + } + } + } + } + } + } + }, + "/customer/attributes": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get customer attributes", + "description": "Get custom attributes for a customer", + "operationId": "getCustomerAttributes", + "parameters": [ + { + "name": "customer_id", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Customer attributes retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Add customer attribute", + "description": "Add a custom attribute to a customer", + "operationId": "addCustomerAttribute", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "201": { + "description": "Customer attribute added successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "delete": { + "tags": [ + "Users" + ], + "summary": "Delete customer attribute", + "description": "Remove a custom attribute from a customer", + "operationId": "deleteCustomerAttribute", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Customer attribute deleted successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/customer/notes": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get customer notes", + "description": "Get notes for a customer", + "operationId": "getCustomerNotes", + "parameters": [ + { + "name": "customer_id", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Customer notes retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Add customer note", + "description": "Add a note to a customer", + "operationId": "addCustomerNote", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "201": { + "description": "Customer note added successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "delete": { + "tags": [ + "Users" + ], + "summary": "Delete customer note", + "description": "Remove a note from a customer", + "operationId": "deleteCustomerNote", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Customer note deleted successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/customers/search": { + "post": { + "tags": [ + "Users" + ], + "summary": "Search customers", + "description": "Search for customers using various criteria", + "operationId": "searchCustomers", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Customers found successfully", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/search/system": { + "get": { + "tags": [ + "Search" + ], + "summary": "System-wide search", + "description": "Search across all supported entities with permission-aware filtering and optional intent parsing debug metadata. Customer records and customer-related entities are matched against a local e-conomic customer index (name/address/email/CVR) that is refreshed by cron. Intent parsing is invoked adaptively when lexical confidence is low or when the query looks intent-driven. Results are ordered by relevance, with recent records preferred when relevance is comparable.", + "operationId": "systemWideSearchGet", + "parameters": [ + { + "in": "query", + "name": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Free-text query to search for. Supports natural-language intent fallback and domain synonyms such as `rabat` -> `discount`." + }, + { + "in": "query", + "name": "include_types", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchEntityType" + } + }, + "style": "form", + "explode": false, + "description": "Comma-separated list of entity types to include. Defaults to all allowed types." + }, + { + "in": "query", + "name": "exclude_types", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchEntityType" + } + }, + "style": "form", + "explode": false, + "description": "Comma-separated list of entity types to exclude." + }, + { + "in": "query", + "name": "include_associations", + "required": false, + "schema": { + "type": "boolean", + "default": true + }, + "description": "Include associated objects when matching a primary entity such as a customer." + }, + { + "in": "query", + "name": "debug_intent", + "required": false, + "schema": { + "type": "boolean", + "default": false + }, + "description": "Include intent parser diagnostics in `meta.intent_parser`." + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Search results returned successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemSearchResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Search" + ], + "summary": "System-wide search", + "description": "Search across all supported entities using JSON request payload. Customer-related matches include local e-conomic customer index fields. Intent parsing may run adaptively for intent-driven natural-language queries. Results are ordered by relevance, with recent records preferred when relevance is comparable.", + "operationId": "systemWideSearchPost", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemSearchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Search results returned successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemSearchResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/superuser/search/system/cache": { + "delete": { + "tags": [ + "Search" + ], + "summary": "Clear system search caches", + "description": "Clears both query-result cache and intent-parser cache namespaces for system-wide search.", + "operationId": "clearSystemSearchCache", + "responses": { + "200": { + "description": "Cache cleared successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemSearchCacheClearResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/superuser/search/system/cache/rebuild": { + "post": { + "tags": [ + "Search" + ], + "summary": "Queue system search cache rebuild", + "description": "Queues a cache rebuild request and clears active query/intent cache namespaces immediately.", + "operationId": "rebuildSystemSearchCache", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemSearchCacheRebuildRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Cache rebuild queued successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemSearchCacheRebuildResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/economic/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get e-conomic config", + "operationId": "getEconomicConfig", + "responses": { + "200": { + "description": "e-conomic configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EconomicConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update e-conomic config", + "operationId": "updateEconomicConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "e-conomic configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/reCAPTCHA/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get reCAPTCHA config", + "operationId": "getRecaptchaModuleConfig", + "responses": { + "200": { + "description": "reCAPTCHA configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecaptchaConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update reCAPTCHA config", + "operationId": "updateRecaptchaConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "reCAPTCHA configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/email/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get email config", + "operationId": "getEmailConfig", + "responses": { + "200": { + "description": "Email configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update email config", + "operationId": "updateEmailConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Email configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/email/config/test": { + "post": { + "tags": [ + "Config" + ], + "summary": "Test email config", + "operationId": "testEmailConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Email configuration test completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigTestResponse" + } + } + } + } + } + } + }, + "/backups/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get backups config", + "operationId": "getBackupsConfig", + "responses": { + "200": { + "description": "Backups configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupsConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update backups config", + "operationId": "updateBackupsConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Backups configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/bird/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get Bird config", + "operationId": "getBirdConfig", + "responses": { + "200": { + "description": "Bird configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BirdConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update Bird config", + "operationId": "updateBirdConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Bird configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/motorapi/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get MotorAPI config", + "operationId": "getMotorApiConfig", + "responses": { + "200": { + "description": "MotorAPI configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MotorApiConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update MotorAPI config", + "operationId": "updateMotorApiConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "MotorAPI configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/stripe/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get Stripe config", + "operationId": "getStripeConfig", + "responses": { + "200": { + "description": "Stripe configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripeConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update Stripe config", + "operationId": "updateStripeConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Stripe configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/fxratesapi/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get FXRatesAPI config", + "operationId": "getFxRatesApiConfig", + "responses": { + "200": { + "description": "FXRatesAPI configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FxRatesApiConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update FXRatesAPI config", + "operationId": "updateFxRatesApiConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "FXRatesAPI configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/weatherapi/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get WeatherAPI config", + "operationId": "getWeatherApiConfig", + "responses": { + "200": { + "description": "WeatherAPI configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherApiConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update WeatherAPI config", + "operationId": "updateWeatherApiConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "WeatherAPI configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/gatewayapi/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get GatewayAPI config", + "operationId": "getGatewayApiConfig", + "responses": { + "200": { + "description": "GatewayAPI configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayApiConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update GatewayAPI config", + "operationId": "updateGatewayApiConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "GatewayAPI configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/xlvask/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get XLVask config", + "operationId": "getXlvaskConfig", + "responses": { + "200": { + "description": "XLVask configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/XlvaskConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update XLVask config", + "operationId": "updateXlvaskConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "XLVask configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/entra/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get Entra config", + "operationId": "getEntraConfig", + "responses": { + "200": { + "description": "Entra configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntraConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update Entra config", + "operationId": "updateEntraConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Entra configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/limble/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get Limble config", + "operationId": "getLimbleConfig", + "responses": { + "200": { + "description": "Limble configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LimbleConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update Limble config", + "operationId": "updateLimbleConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Limble configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/ocrspace/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get OcrSpace config", + "operationId": "getOcrSpaceConfig", + "responses": { + "200": { + "description": "OcrSpace configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OcrSpaceConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update OcrSpace config", + "operationId": "updateOcrSpaceConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "OcrSpace configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/openai/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get OpenAI config", + "operationId": "getOpenAiConfig", + "responses": { + "200": { + "description": "OpenAI configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAiConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update OpenAI config", + "operationId": "updateOpenAiConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "OpenAI configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/licenseplaterecognizer/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get LicensePlateRecognizer config", + "operationId": "getLicensePlateRecognizerConfig", + "responses": { + "200": { + "description": "LicensePlateRecognizer configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicensePlateRecognizerConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update LicensePlateRecognizer config", + "operationId": "updateLicensePlateRecognizerConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "LicensePlateRecognizer configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/virkdata/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get Virkdata config", + "operationId": "getVirkdataConfig", + "responses": { + "200": { + "description": "Virkdata configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VirkdataConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update Virkdata config", + "operationId": "updateVirkdataConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Virkdata configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/shelly/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get Shelly config", + "operationId": "getShellyConfig", + "responses": { + "200": { + "description": "Shelly configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellyConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update Shelly config", + "operationId": "updateShellyConfig", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Shelly configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/selfserve/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get Self-Serve config", + "operationId": "getSelfServeConfig", + "responses": { + "200": { + "description": "Self-serve configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfServeConfigListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Update Self-Serve config", + "operationId": "updateSelfServeConfig", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfServeConfig" + } + } + } + }, + "responses": { + "200": { + "description": "Self-serve configuration updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" + } + } + } + } + } + } + }, + "/branding": { + "get": { + "tags": [ + "Branding" + ], + "summary": "List branding options", + "description": "Retrieve a list of branding options or a specific branding option if ID is provided", + "operationId": "listBrandingOptions", + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "$ref": "#/components/parameters/PageParam" + }, + { + "$ref": "#/components/parameters/PerPageParam" + } + ], + "responses": { + "200": { + "description": "Branding options retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Branding" + ], + "summary": "Add branding option", + "description": "Create a new branding option", + "operationId": "addBrandingOption", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "description", + "cvr" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "cvr": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Branding option added successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "put": { + "tags": [ + "Branding" + ], + "summary": "Edit branding option", + "description": "Update an existing branding option", + "operationId": "editBrandingOption", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "cvr": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Branding option updated successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/roles": { + "get": { + "tags": [ + "Roles" + ], + "summary": "List roles", + "operationId": "listRoles", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Roles" + ], + "summary": "Add role", + "operationId": "addRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "put": { + "tags": [ + "Roles" + ], + "summary": "Edit role", + "operationId": "editRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/roles/permissions": { + "post": { + "tags": [ + "Roles" + ], + "summary": "Add permission to role", + "operationId": "addRolePermission", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "role_id", + "permission" + ], + "properties": { + "role_id": { + "type": "integer" + }, + "permission": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "delete": { + "tags": [ + "Roles" + ], + "summary": "Remove permission from role", + "operationId": "removeRolePermission", + "parameters": [ + { + "name": "role_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "permission", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/roles/clone": { + "post": { + "tags": [ + "Roles" + ], + "summary": "Clone role", + "operationId": "cloneRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "role_id", + "name" + ], + "properties": { + "role_id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/washcertificates": { + "get": { + "tags": [ + "Modules" + ], + "summary": "List wash certificates", + "operationId": "listWashCertificates", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/xlvask/services/usage/orders": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get XLVask usage orders", + "operationId": "getXlvaskUsageOrders", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/modules/xlvask/services/usage/orders/fast-link": { + "get": { + "tags": [ + "Modules" + ], + "summary": "Get XLVask usage orders fast link", + "operationId": "getXlvaskUsageOrdersFastLink", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/superuser/department": { + "get": { + "tags": [ + "Departments" + ], + "summary": "List departments (superuser)", + "operationId": "listSuperuserDepartments", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/superuser/department/prices": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get department prices", + "operationId": "getDepartmentPrices", + "parameters": [ + { + "name": "department_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Departments" + ], + "summary": "Set department price", + "operationId": "setDepartmentPrice", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "department_id", + "product_id", + "price" + ], + "properties": { + "department_id": { + "type": "integer" + }, + "product_id": { + "type": "integer" + }, + "price": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/superuser/department/variables": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get department variables", + "operationId": "getDepartmentVariables", + "parameters": [ + { + "name": "department_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Departments" + ], + "summary": "Set department variable", + "operationId": "setDepartmentVariable", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "department_id", + "variable", + "value" + ], + "properties": { + "department_id": { + "type": "integer" + }, + "variable": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/departments/daily-reports": { + "get": { + "tags": [ + "Departments" + ], + "summary": "List daily reports", + "operationId": "listDailyReports", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "Departments" + ], + "summary": "Add daily report", + "operationId": "addDailyReport", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "department_id", + "date", + "report" + ], + "properties": { + "department_id": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "report": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "put": { + "tags": [ + "Departments" + ], + "summary": "Edit daily report", + "operationId": "editDailyReport", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "report" + ], + "properties": { + "id": { + "type": "integer" + }, + "report": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/departments/daily-reports/get": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get daily report", + "operationId": "getDailyReport", + "parameters": [ + { + "name": "department_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "date", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/departments/daily-reports/product-count": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get product count for daily reports", + "operationId": "getDailyReportProductCount", + "parameters": [ + { + "name": "date", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "date_to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "department_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "product_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/departments/daily-reports/transaction-count": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get transaction count for daily reports", + "operationId": "getDailyReportTransactionCount", + "parameters": [ + { + "name": "date", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "date_to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "department_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/departments/daily-reports/bookings-count": { + "get": { + "tags": [ + "Departments" + ], + "summary": "Get bookings count for daily reports", + "operationId": "getDailyReportBookingsCount", + "parameters": [ + { + "name": "date", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "department_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/account/security/passkeys": { + "get": { + "tags": [ + "Security" + ], + "summary": "List passkeys for the authenticated user", + "operationId": "listPasskeys", + "responses": { + "200": { + "description": "A list of passkeys", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Passkey" + } + } + } + } + }, + "400": { + "description": "Invalid session or request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "tags": [ + "Security" + ], + "summary": "Create/add a passkey for the authenticated user", + "operationId": "createPasskey", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Passkey created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Invalid session or request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/account/security/passkeys/{id}": { + "patch": { + "tags": [ + "Security" + ], + "summary": "Rename a passkey", + "operationId": "renamePasskey", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRenameRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Passkey renamed", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Security" + ], + "summary": "Delete a passkey", + "operationId": "deletePasskey", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Passkey deleted", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT token obtained from /auth/login or /auth/employee/login" + } + }, + "parameters": { + "PageParam": { + "name": "page", + "in": "query", + "description": "Page number for pagination", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + "PerPageParam": { + "name": "per_page", + "in": "query", + "description": "Number of items per page", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30 + } + }, + "SearchParam": { + "name": "search", + "in": "query", + "description": "Search query string", + "schema": { + "type": "string" + } + }, + "LimitParam": { + "name": "limit", + "in": "query", + "description": "Number of items per page", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + "FiltersParam": { + "name": "filters", + "in": "query", + "description": "Filters for the list (e.g., module:selfserve,status_code:200)", + "schema": { + "type": "string" + } + }, + "XCustomerNumber": { + "name": "X-Customer-Number", + "in": "header", + "required": false, + "description": "Target customer number for subuser requests. Ignored for classic user sessions.\nRequired on customer-scoped endpoints when authenticated as a subuser unless\nthe target customer can be inferred from context.\n", + "schema": { + "type": "integer" + } + } + }, + "responses": { + "BadRequest": { + "description": "Bad request - Invalid input parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Unauthorized": { + "description": "Unauthorized - Invalid or missing authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Forbidden": { + "description": "Forbidden - Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "NotFound": { + "description": "Not found - Resource does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "InternalServerError": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message" + }, + "code": { + "type": "integer", + "description": "HTTP status code" + } + } + }, + "SystemSearchEntityType": { + "type": "string", + "enum": [ + "objects", + "module_config", + "orders", + "order_items", + "customers", + "employees", + "users", + "subusers", + "customer_discounts", + "customer_fixed_prices", + "departments", + "permissions", + "roles", + "invoices", + "vehicles", + "bookings", + "bookings_new", + "branding", + "categories", + "currency_conversion_rates", + "customer_codes", + "customer_default_department", + "customer_notes", + "customer_vehicles_addons", + "department_categories", + "department_daily_reports", + "department_gates", + "department_goals", + "department_lanes", + "department_notification_sms", + "department_relays", + "department_selfserve_condition_rules", + "department_selfserve_conditions", + "department_selfserve_questions", + "department_selfserve_tasks", + "department_selfserve_vehicle_conditions", + "department_time_bookings_entries", + "department_time_bookings_opening_hours", + "department_time_bookings_types", + "department_variables", + "fxratesapi_conversion_rates", + "module_action_logs", + "motorapi_lookups", + "notifications", + "order_bookings", + "plate_scanners", + "plate_scans", + "product_options", + "products", + "stripe_module_customers", + "stripe_module_orders", + "stripe_payment_intents", + "subuser_grants", + "xlvask_customers", + "xlvask_potential_order_matches", + "xlvask_usage_log_wash_items", + "xlvask_usage_logs", + "xlvask_vehicle_types", + "xlvask_vehicles" + ] + }, + "SystemSearchRequest": { + "type": "object", + "required": [ + "query" + ], + "properties": { + "query": { + "type": "string", + "description": "Free-text query to search for. Customer lookups include local e-conomic index fields and lexical synonym expansion (for example `rabat` -> `discount`)." + }, + "include_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchEntityType" + }, + "description": "Limit search to these entity types." + }, + "exclude_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchEntityType" + }, + "description": "Exclude these entity types from search." + }, + "include_associations": { + "type": "boolean", + "default": true, + "description": "Include associated records for matched core entities." + }, + "debug_intent": { + "type": "boolean", + "default": false, + "description": "Include parser diagnostics in `meta.intent_parser`." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "offset": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + }, + "SystemSearchResult": { + "type": "object", + "properties": { + "entity_type": { + "$ref": "#/components/schemas/SystemSearchEntityType" + }, + "entity_id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "customer_number": { + "type": "integer", + "nullable": true + }, + "department_id": { + "type": "integer", + "nullable": true + }, + "score": { + "type": "integer" + }, + "association_reason": { + "type": "string", + "nullable": true + }, + "payload": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "entity_type", + "entity_id", + "title", + "score" + ] + }, + "SystemSearchIntentParserMeta": { + "type": "object", + "properties": { + "invoked": { + "type": "boolean" + }, + "source": { + "type": "string", + "enum": [ + "cache", + "openai", + "none" + ] + }, + "status": { + "type": "string" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "expanded_terms": { + "type": "array", + "items": { + "type": "string" + } + }, + "entity_hints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchEntityType" + } + }, + "fallback_reason": { + "type": "string", + "nullable": true + } + }, + "required": [ + "invoked", + "source", + "status", + "confidence", + "expanded_terms", + "entity_hints" + ] + }, + "SystemSearchMeta": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "allowed_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchEntityType" + } + }, + "cache": { + "type": "object", + "properties": { + "hit": { + "type": "boolean" + } + }, + "required": [ + "hit" + ] + }, + "intent_parser": { + "$ref": "#/components/schemas/SystemSearchIntentParserMeta" + } + }, + "required": [ + "query", + "limit", + "offset", + "total", + "allowed_types", + "cache" + ] + }, + "SystemSearchPayload": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchResult" + } + }, + "grouped_results": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchResult" + } + } + }, + "meta": { + "$ref": "#/components/schemas/SystemSearchMeta" + } + }, + "required": [ + "results", + "grouped_results", + "meta" + ] + }, + "SystemSearchResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$ref": "#/components/schemas/SystemSearchPayload" + }, + "meta": { + "type": "object", + "additionalProperties": true + }, + "includes": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "success", + "data", + "meta", + "includes" + ] + }, + "SystemSearchCacheClearResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "query_cache_cleared": { + "type": "boolean" + }, + "intent_cache_cleared": { + "type": "boolean" + } + }, + "required": [ + "message", + "query_cache_cleared", + "intent_cache_cleared" + ] + }, + "meta": { + "type": "object", + "additionalProperties": true + }, + "includes": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "success", + "data", + "meta", + "includes" + ] + }, + "SystemSearchCacheRebuildRequest": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "enum": [ + "all", + "types", + "dirty" + ], + "default": "all" + }, + "types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchEntityType" + } + } + } + }, + "SystemSearchCacheRebuildResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "request": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "enum": [ + "all", + "types", + "dirty" + ] + }, + "types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemSearchEntityType" + } + }, + "requested_at": { + "type": "integer" + } + }, + "required": [ + "scope", + "types", + "requested_at" + ] + }, + "query_cache_cleared": { + "type": "boolean" + }, + "intent_cache_cleared": { + "type": "boolean" + } + }, + "required": [ + "message", + "request", + "query_cache_cleared", + "intent_cache_cleared" + ] + }, + "meta": { + "type": "object", + "additionalProperties": true + }, + "includes": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "success", + "data", + "meta", + "includes" + ] + }, + "ModuleConfigValue": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "nullable": true + }, + "ModuleConfigEnvelopeBase": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "meta": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, + "includes": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ] + } + }, + "required": [ + "success", + "meta", + "includes" + ] + }, + "EconomicConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "economic" + ] + }, + "variable": { + "type": "string", + "enum": [ + "adminFeeMonthly", + "adminFeeOrder", + "feeProductId", + "invoiceLayoutNumber", + "paymentTermsNumber" + ] + }, + "type": { + "type": "string", + "enum": [ + "string", + "int" + ] + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "RecaptchaConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "reCAPTCHA" + ] + }, + "variable": { + "type": "string", + "enum": [ + "enabled", + "secret_key_v2", + "site_key_v2" + ] + }, + "type": { + "type": "string", + "enum": [ + "bool", + "string" + ] + }, + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "EmailConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "Email" + ] + }, + "variable": { + "type": "string", + "enum": [ + "enabled", + "mailersend_api_key", + "mailersend_enabled", + "smtp_encryption", + "smtp_from", + "smtp_from_name", + "smtp_host", + "smtp_password", + "smtp_port", + "smtp_reply_to", + "smtp_reply_to_name", + "smtp_username" + ] + }, + "type": { + "type": "string", + "enum": [ + "bool", + "string", + "int" + ] + }, + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "BackupsConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "Backups" + ] + }, + "variable": { + "type": "string", + "enum": [ + "enabled" + ] + }, + "type": { + "type": "string", + "enum": [ + "bool" + ] + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "BirdConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "bird" + ] + }, + "variable": { + "type": "string", + "enum": [ + "api_key", + "enabled", + "server_url", + "workplaceId", + "channelId" + ] + }, + "type": { + "type": "string", + "enum": [ + "string", + "bool" + ] + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "MotorApiConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "motorapi" + ] + }, + "variable": { + "type": "string", + "enum": [ + "daily_limit", + "enabled", + "secret_key" + ] + }, + "type": { + "type": "string", + "enum": [ + "int", + "bool", + "string" + ] + }, + "value": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "StripeConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "Stripe" + ] + }, + "variable": { + "type": "string", + "enum": [ + "economic_customer_number", + "enabled", + "publishable_key", + "secret_key" + ] + }, + "type": { + "type": "string", + "enum": [ + "int", + "bool", + "string" + ] + }, + "value": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "FxRatesApiConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "fxratesapi" + ] + }, + "variable": { + "type": "string", + "enum": [ + "daily_limit", + "enabled", + "secret_key" + ] + }, + "type": { + "type": "string", + "enum": [ + "int", + "bool", + "string" + ] + }, + "value": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "WeatherApiConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "weatherapi" + ] + }, + "variable": { + "type": "string", + "enum": [ + "enabled", + "secret_key" + ] + }, + "type": { + "type": "string", + "enum": [ + "bool", + "string" + ] + }, + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "GatewayApiConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "GatewayAPI" + ] + }, + "variable": { + "type": "string", + "enum": [ + "api_key", + "api_secret", + "api_token", + "enabled", + "sender" + ] + }, + "type": { + "type": "string", + "enum": [ + "string", + "bool" + ] + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "XlvaskConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "xlvask" + ] + }, + "variable": { + "type": "string", + "enum": [ + "enabled", + "password", + "synchronization_enabled", + "username" + ] + }, + "type": { + "type": "string", + "enum": [ + "bool", + "string" + ] + }, + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "EntraConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "Entra" + ] + }, + "variable": { + "type": "string", + "enum": [ + "enabled", + "entra_client_id", + "entra_client_secret", + "entra_tenant_id" + ] + }, + "type": { + "type": "string", + "enum": [ + "bool", + "string" + ] + }, + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "LimbleConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "limble" + ] + }, + "variable": { + "type": "string", + "enum": [ + "client_id", + "client_secret", + "enabled", + "webhooks_enabled" + ] + }, + "type": { + "type": "string", + "enum": [ + "string", + "bool" + ] + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "OcrSpaceConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "ocrSpace" + ] + }, + "variable": { + "type": "string", + "enum": [ + "api_key", + "enabled" + ] + }, + "type": { + "type": "string", + "enum": [ + "string", + "bool" + ] + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "OpenAiConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "openAI" + ] + }, + "variable": { + "type": "string", + "enum": [ + "api_key", + "enabled" + ] + }, + "type": { + "type": "string", + "enum": [ + "string", + "bool" + ] + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "LicensePlateRecognizerConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "licenseplaterecognizer" + ] + }, + "variable": { + "type": "string", + "enum": [ + "api_key", + "enabled" + ] + }, + "type": { + "type": "string", + "enum": [ + "string", + "bool" + ] + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "VirkdataConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "virkdata" + ] + }, + "variable": { + "type": "string", + "enum": [ + "enabled", + "monthly_limit", + "secret_key" + ] + }, + "type": { + "type": "string", + "enum": [ + "bool", + "int", + "string" + ] + }, + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "ShellyConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "shelly" + ] + }, + "variable": { + "type": "string", + "enum": [ + "enabled", + "secret_key", + "server_url" + ] + }, + "type": { + "type": "string", + "enum": [ + "bool", + "string" + ] + }, + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "SelfServeConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "selfserve" + ] + }, + "variable": { + "type": "string", + "enum": [ + "enabled", + "minute_product" + ] + }, + "type": { + "type": "string", + "enum": [ + "bool", + "int" + ] + }, + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + } + ] + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "EconomicConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EconomicConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "RecaptchaConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecaptchaConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "EmailConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "BackupsConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupsConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "BirdConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BirdConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "MotorApiConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MotorApiConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "StripeConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StripeConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "FxRatesApiConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FxRatesApiConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "WeatherApiConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherApiConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "GatewayApiConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GatewayApiConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "XlvaskConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/XlvaskConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "EntraConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntraConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "LimbleConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LimbleConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "OcrSpaceConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OcrSpaceConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "OpenAiConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OpenAiConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "LicensePlateRecognizerConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LicensePlateRecognizerConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "VirkdataConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VirkdataConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "ShellyConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShellyConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "SelfServeConfigListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfServeConfigEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "DepartmentWeatherStatus": { + "type": "string", + "enum": [ + "unknown", + "healthy", + "degraded", + "unhealthy" + ] + }, + "DepartmentWeatherCondition": { + "type": "string", + "enum": [ + "clear", + "mostly_clear", + "partly_cloudy", + "mostly_cloudy", + "overcast", + "rain", + "showers", + "thunderstorm", + "snow", + "fog" + ] + }, + "DepartmentWeatherTimelineEntry": { + "type": "object", + "properties": { + "time": { + "type": "string", + "example": "01:00" + }, + "weather": { + "$ref": "#/components/schemas/DepartmentWeatherCondition" + }, + "washes": { + "type": "integer", + "minimum": 0, + "example": 0 + }, + "hours": { + "type": "integer", + "minimum": 0, + "example": 10 + }, + "status": { + "$ref": "#/components/schemas/DepartmentWeatherStatus" + } + }, + "required": [ + "time", + "weather", + "washes", + "hours", + "status" + ] + }, + "WeatherApiObjectResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "data" + ] + } + ] + }, + "DepartmentWeatherTimelineResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ModuleConfigEnvelopeBase" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentWeatherTimelineEntry" + } + } + }, + "required": [ + "data" + ] + } + ] + }, + "ModuleConfigEntry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "Module name" + }, + "variable": { + "type": "string", + "description": "Configuration variable key" + }, + "type": { + "type": "string", + "description": "Stored value type in module_config" + }, + "value": { + "$ref": "#/components/schemas/ModuleConfigValue" + } + }, + "required": [ + "module", + "variable", + "type", + "value" + ] + }, + "ModuleConfigListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleConfigEntry" + } + }, + "meta": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, + "includes": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ] + } + }, + "required": [ + "success", + "data", + "meta", + "includes" + ] + }, + "ModuleConfigUpdateResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "boolean" + }, + "meta": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, + "includes": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ] + } + }, + "required": [ + "success", + "data", + "meta", + "includes" + ] + }, + "ModuleConfigTestResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "string" + }, + "meta": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, + "includes": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ] + } + }, + "required": [ + "success", + "data", + "meta", + "includes" + ] + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "User ID" + }, + "customer_number": { + "type": "integer", + "description": "e-conomic customer number" + }, + "display_name": { + "type": "string", + "description": "User's display name" + }, + "group_id": { + "type": "integer", + "description": "User group/role ID" + }, + "phone_country_code": { + "type": "integer", + "description": "Phone country code" + }, + "phone": { + "type": "integer", + "description": "Phone number" + }, + "email": { + "type": "string", + "format": "email", + "description": "Email address" + }, + "sms_notifications_enabled": { + "type": "boolean", + "description": "SMS notifications enabled" + }, + "email_notifications_enabled": { + "type": "boolean", + "description": "Email notifications enabled" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "SubuserGrant": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "billing_customer_number": { + "type": "integer", + "description": "e-conomic customer number" + }, + "subuser": { + "type": "integer", + "description": "Subuser ID" + }, + "enabled": { + "type": "boolean" + }, + "note": { + "type": "string", + "nullable": true + }, + "permissions": { + "type": "array", + "description": "List of permission node keys", + "items": { + "type": "string", + "example": "BOOKINGS_LIST" + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deleted_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "SubuserGrantCreateRequest": { + "type": "object", + "required": [ + "customer_number", + "subuser_id" + ], + "properties": { + "customer_number": { + "type": "integer", + "description": "e-conomic customer number" + }, + "subuser_id": { + "type": "integer", + "description": "Subuser ID to grant permissions for" + }, + "enabled": { + "type": "boolean", + "default": true + }, + "note": { + "type": "string", + "nullable": true, + "maxLength": 65535 + }, + "permissions": { + "type": "array", + "description": "Optional list of permission keys; defaults will be applied if omitted", + "items": { + "type": "string" + } + } + } + }, + "SubuserGrantUpdateRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "note": { + "type": "string", + "nullable": true, + "maxLength": 65535 + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "SubuserGrantSummary": { + "type": "object", + "description": "Summary of a subuser grant grouped by billing customer number", + "properties": { + "billing_customer_number": { + "type": "integer", + "description": "e-conomic customer number this grant applies to" + }, + "permissions": { + "type": "array", + "description": "List of permission node keys enabled for this customer", + "items": { + "type": "string" + } + } + } + }, + "SubuserSelf": { + "type": "object", + "description": "Authenticated subuser profile with enabled grants", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "name": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "phone_country_code": { + "type": "integer", + "nullable": true + }, + "phone": { + "type": "integer", + "nullable": true + }, + "grants": { + "type": "array", + "description": "Enabled, non-deleted grants for the subuser grouped by billing customer number", + "items": { + "$ref": "#/components/schemas/SubuserGrantSummary" + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "suspended_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "two_factor_enabled": { + "type": "boolean", + "description": "Indicates if 2FA is enabled for this account" + } + } + }, + "PermissionNode": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Permission node key" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "description": "Permission type (e.g., TOGGLE)" + }, + "default": { + "type": "boolean" + } + } + }, + "PermissionNodeGroup": { + "type": "object", + "properties": { + "group": { + "type": "string" + }, + "description": { + "type": "string" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionNode" + } + } + } + }, + "UserCreate": { + "type": "object", + "required": [ + "customer_number", + "password" + ], + "properties": { + "customer_number": { + "type": "integer" + }, + "password": { + "type": "string", + "format": "password" + }, + "display_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "email": { + "type": "string", + "format": "email" + }, + "phone": { + "type": "integer" + }, + "phone_country_code": { + "type": "integer" + } + } + }, + "UserUpdate": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "customer_number": { + "type": "integer" + }, + "display_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "email": { + "type": "string", + "format": "email" + }, + "phone": { + "type": "integer" + }, + "phone_country_code": { + "type": "integer" + } + } + }, + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Order ID" + }, + "customer_id": { + "type": "integer", + "description": "Customer number" + }, + "customer_name": { + "type": "string", + "description": "Customer name" + }, + "user_id": { + "type": "integer", + "description": "User ID" + }, + "cashier_id": { + "type": "integer", + "description": "Cashier user ID" + }, + "cashier_name": { + "type": "string", + "description": "Cashier name" + }, + "department_id": { + "type": "integer", + "description": "Department ID" + }, + "status": { + "type": "string", + "description": "Order status" + }, + "total_net_amount": { + "type": "number", + "format": "float", + "description": "Total order amount" + }, + "po": { + "type": "string", + "description": "Purchase order number", + "nullable": true + }, + "lane": { + "type": "string", + "description": "Lane information", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CollectedInvoiceEconomicCompareResponse": { + "type": "object", + "description": "Result of comparing a collected invoice with its E-conomic counterpart", + "properties": { + "collected_invoice_id": { + "type": "integer", + "description": "The internal collected invoice ID", + "example": 123 + }, + "draft_id": { + "type": "integer", + "nullable": true, + "description": "E-conomic draft invoice ID, if present", + "example": 456 + }, + "booked_id": { + "type": "integer", + "nullable": true, + "description": "E-conomic booked invoice ID, if present", + "example": 28368 + }, + "warnings": { + "type": "array", + "description": "List of warnings detected during comparison", + "items": { + "type": "string" + }, + "example": [ + "Total amount mismatch for draft invoice ID 456: E-Conomic total is 867.5, internal total is 694" + ] + }, + "draft_total": { + "type": "number", + "format": "float", + "nullable": true, + "description": "Total amount from the E-conomic draft (gross)", + "example": 867.5 + }, + "booked_total": { + "type": "number", + "format": "float", + "nullable": true, + "description": "Total amount from the E-conomic booked invoice (gross minus VAT if applicable)", + "example": 694 + }, + "difference": { + "type": "number", + "format": "float", + "nullable": true, + "description": "Selected e-conomic total (draft when available, otherwise booked) minus internal_total", + "example": 0 + }, + "internal_total": { + "type": "number", + "format": "float", + "description": "Internal total amount for the collected invoice", + "example": 694 + } + }, + "required": [ + "collected_invoice_id", + "internal_total" + ] + }, + "CollectedInvoiceEconomicV2DetailsResponse": { + "type": "object", + "properties": { + "collected_invoice_id": { + "type": "integer" + }, + "external_id": { + "type": "string" + }, + "order_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "economic": { + "type": "object", + "properties": { + "draft_id": { + "type": "integer", + "nullable": true + }, + "booked_id": { + "type": "integer", + "nullable": true + } + } + }, + "customer": { + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary" + }, + "internal": { + "type": "object", + "required": [ + "normalized" + ], + "properties": { + "normalized": { + "$ref": "#/components/schemas/EconomicV2NormalizedInvoice" + } + } + }, + "draft": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + }, + "raw": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "normalized": { + "allOf": [ + { + "$ref": "#/components/schemas/EconomicV2NormalizedInvoice" + } + ], + "nullable": true + } + } + }, + "booked": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + }, + "raw": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "normalized": { + "allOf": [ + { + "$ref": "#/components/schemas/EconomicV2NormalizedInvoice" + } + ], + "nullable": true + } + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "collected_invoice_id", + "order_ids", + "economic", + "customer", + "internal", + "draft", + "booked", + "warnings" + ] + }, + "CollectedInvoiceEconomicV2CustomerSummary": { + "type": "object", + "properties": { + "internal_customer_number": { + "type": "integer", + "nullable": true + }, + "draft_customer_number": { + "type": "integer", + "nullable": true + }, + "booked_customer_number": { + "type": "integer", + "nullable": true + }, + "exists": { + "type": "boolean" + }, + "name": { + "type": "string", + "nullable": true + }, + "barred": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "exists" + ] + }, + "CollectedInvoiceEconomicV2CompareResponse": { + "type": "object", + "properties": { + "collected_invoice_id": { + "type": "integer" + }, + "details": { + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse" + }, + "comparison": { + "$ref": "#/components/schemas/EconomicV2Comparison" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "collected_invoice_id", + "details", + "comparison", + "warnings" + ] + }, + "CollectedInvoiceEconomicV2CompareBulkResponse": { + "type": "object", + "properties": { + "requested": { + "type": "integer" + }, + "compared": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2CompareResponse" + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "collected_invoice_id": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + } + } + }, + "required": [ + "requested", + "compared", + "failed", + "results", + "errors" + ] + }, + "CollectedInvoiceEconomicV2RevenueStatisticsResponse": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "properties": { + "dateFrom": { + "type": "string", + "format": "date" + }, + "dateTo": { + "type": "string", + "format": "date" + }, + "customer_numbers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "department_numbers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "currency": { + "type": "string", + "nullable": true + }, + "barred": { + "type": "string", + "enum": [ + "all", + "barred", + "active" + ] + }, + "max_pages": { + "type": "integer" + } + } + }, + "summary": { + "$ref": "#/components/schemas/EconomicV2RevenueSummary" + }, + "customers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EconomicV2RevenueCustomerStat" + } + }, + "departments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EconomicV2RevenueDepartmentStat" + } + }, + "currencies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EconomicV2RevenueCurrencyStat" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "filters", + "summary", + "customers", + "departments", + "currencies", + "warnings" + ] + }, + "EconomicV2RevenueSummary": { + "type": "object", + "properties": { + "invoice_count": { + "type": "integer" + }, + "line_count": { + "type": "integer" + }, + "unique_customers": { + "type": "integer" + }, + "net_amount": { + "type": "number" + }, + "vat_amount": { + "type": "number" + }, + "gross_amount": { + "type": "number" + }, + "average_invoice_net_amount": { + "type": "number" + } + }, + "required": [ + "invoice_count", + "line_count", + "unique_customers", + "net_amount", + "vat_amount", + "gross_amount", + "average_invoice_net_amount" + ] + }, + "EconomicV2RevenueCustomerStat": { + "type": "object", + "properties": { + "customer_number": { + "type": "integer" + }, + "customer_name": { + "type": "string", + "nullable": true + }, + "barred": { + "type": "boolean", + "nullable": true + }, + "invoice_count": { + "type": "integer" + }, + "net_amount": { + "type": "number" + }, + "vat_amount": { + "type": "number" + }, + "gross_amount": { + "type": "number" + } + }, + "required": [ + "customer_number", + "invoice_count", + "net_amount", + "vat_amount", + "gross_amount" + ] + }, + "EconomicV2RevenueDepartmentStat": { + "type": "object", + "properties": { + "department_key": { + "type": "string" + }, + "department_number": { + "type": "integer", + "nullable": true + }, + "invoice_count": { + "type": "integer" + }, + "line_count": { + "type": "integer" + }, + "net_amount": { + "type": "number" + }, + "vat_amount": { + "type": "number" + }, + "gross_amount": { + "type": "number" + } + }, + "required": [ + "department_key", + "invoice_count", + "line_count", + "net_amount", + "vat_amount", + "gross_amount" + ] + }, + "EconomicV2RevenueCurrencyStat": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "invoice_count": { + "type": "integer" + }, + "net_amount": { + "type": "number" + }, + "vat_amount": { + "type": "number" + }, + "gross_amount": { + "type": "number" + } + }, + "required": [ + "currency", + "invoice_count", + "net_amount", + "vat_amount", + "gross_amount" + ] + }, + "EconomicV2NormalizedInvoice": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ + "internal", + "draft", + "booked" + ] + }, + "totals": { + "type": "object", + "properties": { + "net_total": { + "type": "number" + }, + "line_net_total": { + "type": "number" + }, + "line_count": { + "type": "integer" + }, + "billable_line_count": { + "type": "integer" + }, + "difference_from_line_sum": { + "type": "number", + "nullable": true + } + } + }, + "departments": { + "$ref": "#/components/schemas/EconomicV2DepartmentDistribution" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EconomicV2NormalizedLineItem" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "source", + "totals", + "departments", + "lines", + "warnings" + ] + }, + "EconomicV2NormalizedLineItem": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "source": { + "type": "string" + }, + "source_order_id": { + "type": "integer", + "nullable": true + }, + "source_line_id": { + "type": "integer", + "nullable": true + }, + "line_type": { + "type": "string", + "enum": [ + "product", + "discount", + "text" + ] + }, + "billable": { + "type": "boolean" + }, + "product_number": { + "type": "string", + "nullable": true + }, + "product_id": { + "type": "integer", + "nullable": true + }, + "description": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "quantity": { + "type": "number" + }, + "unit_net_price": { + "type": "number" + }, + "line_net_amount": { + "type": "number" + }, + "department_distribution": { + "$ref": "#/components/schemas/EconomicV2DepartmentDistribution" + }, + "match_key": { + "type": "string" + } + }, + "required": [ + "source", + "line_type", + "billable", + "description", + "reference", + "quantity", + "unit_net_price", + "line_net_amount", + "department_distribution", + "match_key" + ] + }, + "EconomicV2DepartmentDistribution": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "example": { + "75": 100 + } + }, + "EconomicV2Comparison": { + "type": "object", + "properties": { + "totals": { + "type": "object", + "properties": { + "internal_net_total": { + "type": "number" + } + } + }, + "targets": { + "type": "object", + "properties": { + "draft": { + "$ref": "#/components/schemas/EconomicV2TargetComparison" + }, + "booked": { + "$ref": "#/components/schemas/EconomicV2TargetComparison" + } + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "totals", + "targets", + "warnings" + ] + }, + "EconomicV2TargetComparison": { + "type": "object", + "properties": { + "target": { + "type": "string", + "enum": [ + "draft", + "booked" + ] + }, + "status": { + "type": "string", + "enum": [ + "exact_match", + "partial_mismatch", + "total_mismatch", + "missing_target" + ] + }, + "overall_match": { + "type": "boolean" + }, + "totals": { + "$ref": "#/components/schemas/EconomicV2TotalsComparison" + }, + "lines": { + "type": "object", + "properties": { + "summary": { + "type": "object", + "properties": { + "internal_billable_count": { + "type": "integer" + }, + "target_billable_count": { + "type": "integer" + }, + "mismatch_count": { + "type": "integer" + } + } + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EconomicV2LineDiffEntry" + } + } + } + }, + "departments": { + "type": "object", + "properties": { + "matches": { + "type": "boolean" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EconomicV2DepartmentDiffEntry" + } + } + } + }, + "mismatch_reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "target", + "status", + "overall_match", + "totals", + "lines", + "departments", + "mismatch_reasons", + "warnings" + ] + }, + "EconomicV2TotalsComparison": { + "type": "object", + "properties": { + "internal_net_total": { + "type": "number", + "nullable": true + }, + "target_net_total": { + "type": "number", + "nullable": true + }, + "difference": { + "type": "number", + "nullable": true + }, + "abs_difference": { + "type": "number", + "nullable": true + }, + "matches": { + "type": "boolean" + } + }, + "required": [ + "matches" + ] + }, + "EconomicV2LineDiffEntry": { + "type": "object", + "properties": { + "match_key": { + "type": "string" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "internal_line": { + "allOf": [ + { + "$ref": "#/components/schemas/EconomicV2NormalizedLineItem" + } + ], + "nullable": true + }, + "target_line": { + "allOf": [ + { + "$ref": "#/components/schemas/EconomicV2NormalizedLineItem" + } + ], + "nullable": true + } + }, + "required": [ + "match_key", + "reasons" + ] + }, + "EconomicV2DepartmentDiffEntry": { + "type": "object", + "properties": { + "department_key": { + "type": "string" + }, + "internal_amount": { + "type": "number" + }, + "target_amount": { + "type": "number" + }, + "difference": { + "type": "number" + }, + "matches": { + "type": "boolean" + } + }, + "required": [ + "department_key", + "internal_amount", + "target_amount", + "difference", + "matches" + ] + }, + "InvoicingDistributionV2Transaction": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "amount": { + "type": "number" + }, + "booked": { + "type": "boolean" + }, + "department_id": { + "type": "integer" + }, + "excluded": { + "type": "boolean" + } + }, + "required": [ + "id", + "date", + "amount", + "booked", + "department_id", + "excluded" + ] + }, + "InvoicingDistributionV2Customer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "nullable": true + }, + "customer_number": { + "type": "integer" + }, + "customer_name": { + "type": "string" + }, + "transactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoicingDistributionV2Transaction" + } + }, + "requires_action": { + "type": "boolean" + }, + "meta": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "customer_number", + "customer_name", + "transactions", + "requires_action", + "meta" + ] + }, + "InvoicingDistributionV2CategoryResponse": { + "type": "object", + "properties": { + "customers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoicingDistributionV2Customer" + } + }, + "collective_results": { + "type": "object", + "additionalProperties": true + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "customers", + "collective_results", + "warnings" + ] + }, + "InvoicingDistributionV2FixedPricingResponse": { + "$ref": "#/components/schemas/InvoicingDistributionV2CategoryResponse" + }, + "InvoicingDistributionV2WashSubscriptionsResponse": { + "$ref": "#/components/schemas/InvoicingDistributionV2CategoryResponse" + }, + "InvoicingDistributionV2CustomerPricesResponse": { + "$ref": "#/components/schemas/InvoicingDistributionV2CategoryResponse" + }, + "InvoicingDistributionV2BookedDepartment75Group": { + "type": "object", + "properties": { + "month": { + "type": "string", + "example": "2026-01" + }, + "source_category": { + "type": "string", + "enum": [ + "fixed_pricing", + "wash_subscriptions", + "unclassified" + ] + }, + "invoice_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "booked_net_amount": { + "type": "number" + }, + "department_distribution": { + "$ref": "#/components/schemas/EconomicV2DepartmentDistribution" + }, + "undistributed_net_amount": { + "type": "number" + } + }, + "required": [ + "month", + "source_category", + "invoice_ids", + "booked_net_amount", + "department_distribution", + "undistributed_net_amount" + ] + }, + "InvoicingDistributionV2BookedDepartment75Meta": { + "type": "object", + "properties": { + "booked_net_amount": { + "type": "number" + }, + "distributed_net_amount": { + "type": "number" + }, + "undistributed_net_amount": { + "type": "number" + }, + "department_distribution": { + "$ref": "#/components/schemas/EconomicV2DepartmentDistribution" + }, + "booked_groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoicingDistributionV2BookedDepartment75Group" + } + } + }, + "required": [ + "booked_net_amount", + "distributed_net_amount", + "undistributed_net_amount", + "department_distribution", + "booked_groups" + ] + }, + "InvoicingDistributionV2BookedDepartment75Customer": { + "allOf": [ + { + "$ref": "#/components/schemas/InvoicingDistributionV2Customer" + }, + { + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "booked_department_75": { + "$ref": "#/components/schemas/InvoicingDistributionV2BookedDepartment75Meta" + } + }, + "required": [ + "booked_department_75" + ] + } + } + } + ] + }, + "InvoicingDistributionV2BookedDepartment75CollectiveResults": { + "type": "object", + "properties": { + "booked_net_amount": { + "type": "number" + }, + "distributed_net_amount": { + "type": "number" + }, + "undistributed_net_amount": { + "type": "number" + }, + "department_distribution": { + "$ref": "#/components/schemas/EconomicV2DepartmentDistribution" + }, + "department_distribution_parsed": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + }, + "required": [ + "booked_net_amount", + "distributed_net_amount", + "undistributed_net_amount", + "department_distribution", + "department_distribution_parsed" + ] + }, + "InvoicingDistributionV2BookedDepartment75Response": { + "type": "object", + "properties": { + "customers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoicingDistributionV2BookedDepartment75Customer" + } + }, + "collective_results": { + "$ref": "#/components/schemas/InvoicingDistributionV2BookedDepartment75CollectiveResults" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "customers", + "collective_results", + "warnings" + ] + }, + "InvoicingDistributionV2AllResponse": { + "type": "object", + "properties": { + "fixed_pricing": { + "$ref": "#/components/schemas/InvoicingDistributionV2FixedPricingResponse" + }, + "wash_subscriptions": { + "$ref": "#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse" + }, + "customer_prices": { + "$ref": "#/components/schemas/InvoicingDistributionV2CustomerPricesResponse" + }, + "booked_department_75": { + "$ref": "#/components/schemas/InvoicingDistributionV2BookedDepartment75Response" + } + }, + "required": [ + "fixed_pricing", + "wash_subscriptions", + "customer_prices", + "booked_department_75" + ] + }, + "PricingHistoryVersionEntry": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string", + "enum": [ + "fixed_pricing", + "vehicle_subscription", + "discount_override" + ] + }, + "customer_number": { + "type": "integer" + }, + "effective_from": { + "type": "string", + "format": "date-time" + }, + "effective_to": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "source": { + "type": "string" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "inferred": { + "type": "boolean" + }, + "metadata_json": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": true + }, + { + "type": "array", + "items": {} + } + ], + "nullable": true + } + }, + "required": [ + "id", + "type", + "customer_number", + "effective_from", + "source", + "confidence", + "inferred" + ] + }, + "CustomerPricingHistoryResponse": { + "type": "object", + "properties": { + "customer_number": { + "type": "integer" + }, + "fixed_pricing": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "vehicle_subscriptions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "discount_overrides": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "timeline": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PricingHistoryVersionEntry" + } + } + }, + "required": [ + "customer_number", + "fixed_pricing", + "vehicle_subscriptions", + "discount_overrides", + "timeline" + ] + }, + "InvoicingWashSubscriptionsDistributionResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoicingWashSubscriptionsDistributionCustomer" + } + }, + "meta": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionMeta" + }, + "includes": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "success", + "data", + "meta", + "includes" + ] + }, + "InvoicingWashSubscriptionsDistributionCustomer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "nullable": true + }, + "customer_number": { + "type": "integer" + }, + "customer_name": { + "type": "string" + }, + "transactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionTransaction" + } + }, + "requires_action": { + "type": "boolean" + }, + "meta": { + "type": "object", + "properties": { + "subscription": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "subscription" + ] + } + }, + "required": [ + "customer_number", + "customer_name", + "transactions", + "requires_action", + "meta" + ] + }, + "InvoicingFixedPricingDistributionResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionCustomer" + } + }, + "meta": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionMeta" + }, + "includes": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionIncludes" + } + }, + "required": [ + "success", + "data", + "meta", + "includes" + ] + }, + "InvoicingFixedPricingDistributionCustomer": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "customer_number": { + "type": "integer" + }, + "customer_name": { + "type": "string" + }, + "transactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionTransaction" + } + }, + "requires_action": { + "type": "boolean" + }, + "meta": { + "type": "object", + "properties": { + "fixed_pricing": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionFixedPricing" + } + }, + "required": [ + "fixed_pricing" + ] + } + }, + "required": [ + "id", + "customer_number", + "customer_name", + "transactions", + "requires_action", + "meta" + ] + }, + "InvoicingFixedPricingDistributionTransaction": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "date": { + "type": "string", + "description": "Datetime in `YYYY-MM-DD HH:mm:ss` format.", + "example": "2026-02-02 10:43:41" + }, + "amount": { + "type": "number" + }, + "booked": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + } + }, + "required": [ + "id", + "date", + "amount", + "booked", + "excluded" + ] + }, + "InvoicingFixedPricingDistributionFixedPricing": { + "type": "object", + "properties": { + "customer_number": { + "type": "integer" + }, + "price": { + "type": "number" + }, + "description": { + "type": "string" + }, + "original_price": { + "type": "number" + }, + "department_totals": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray" + }, + "department_totals_relative": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray" + } + }, + "required": [ + "customer_number", + "price", + "description", + "original_price", + "department_totals", + "department_totals_relative" + ] + }, + "InvoicingFixedPricingDistributionMeta": { + "type": "object", + "properties": { + "date_from": { + "type": "string", + "description": "Datetime in `YYYY-MM-DD HH:mm:ss` format.", + "example": "2026-02-01 00:00:00" + }, + "date_to": { + "type": "string", + "description": "Datetime in `YYYY-MM-DD HH:mm:ss` format.", + "example": "2026-02-28 23:59:59" + } + }, + "required": [ + "date_from", + "date_to" + ] + }, + "InvoicingFixedPricingDistributionIncludes": { + "type": "object", + "properties": { + "debug_invoicing_period_customers_with_orders_in_date_range": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionExecutionTime" + }, + "debug_invoicing_period_process_customer_numbers": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionExecutionTime" + }, + "debug_invoicing_period_get_transactions_for_customers_in_date_range": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionExecutionTime" + }, + "debug_invoicing_period_calculate_transaction_totals": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionExecutionTime" + }, + "debug_invoicing_period_construct_customer_objects": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionExecutionTime" + }, + "collective_fixed_pricing_results": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionCollectiveResults" + } + }, + "additionalProperties": true + }, + "InvoicingFixedPricingDistributionExecutionTime": { + "type": "object", + "properties": { + "execution_time": { + "type": "number" + } + }, + "required": [ + "execution_time" + ] + }, + "InvoicingFixedPricingDistributionCollectiveResults": { + "type": "object", + "properties": { + "total_fixed_price": { + "type": "number" + }, + "total_original_price": { + "type": "number" + }, + "total_department_totals": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray" + }, + "total_department_totals_relative": { + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray" + }, + "total_department_totals_parsed": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "total_department_totals_relative_parsed": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + }, + "required": [ + "total_fixed_price", + "total_original_price", + "total_department_totals", + "total_department_totals_relative", + "total_department_totals_parsed", + "total_department_totals_relative_parsed" + ] + }, + "InvoicingFixedPricingDistributionNumberMapOrEmptyArray": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + { + "type": "array", + "maxItems": 0 + } + ] + }, + "SelfServeLaneStatus": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Lane ID" + }, + "status": { + "type": "string", + "description": "Current lane status (e.g., IDLE, OCCUPIED)" + }, + "mode": { + "type": "string", + "description": "Current lane mode (e.g., AUTOMATIC, MANUAL)" + }, + "state": { + "type": "string", + "description": "Current lane state (e.g., READY, WASHING)" + }, + "wash_start_time": { + "type": "integer", + "description": "Timestamp when the wash started (0 if not washing)", + "nullable": true + }, + "elapsed_wash_time": { + "type": "integer", + "description": "Elapsed wash time in seconds", + "nullable": true + }, + "license_plate": { + "type": "string", + "description": "License plate of the vehicle in the lane", + "nullable": true + }, + "customer_number": { + "type": "integer", + "description": "Customer number associated with the current lane use", + "nullable": true + } + } + }, + "SelfServeConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the self-serve module is enabled" + }, + "minute_product": { + "type": "integer", + "description": "The product ID used for minute-based billing" + } + } + }, + "SelfserveLaneService": { + "type": "string", + "description": "Allowed self-serve lane service name", + "enum": [ + "MACHINE" + ] + }, + "SelfserveMachineType": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "SelfserveVisibleQuestion": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "question": { + "type": "string" + }, + "description": { + "type": "string" + }, + "condition_id": { + "type": "integer", + "nullable": true + }, + "order_priority": { + "type": "integer" + }, + "answer": { + "type": "boolean", + "nullable": true + } + } + }, + "SelfserveTaskDecision": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "task": { + "type": "string" + }, + "description": { + "type": "string" + }, + "condition_id": { + "type": "integer", + "nullable": true + }, + "order_priority": { + "type": "integer" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + } + }, + "buttons": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "SelfserveWashSession": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "lane_id": { + "type": "integer" + }, + "department_id": { + "type": "integer" + }, + "machine_type_id": { + "type": "integer", + "nullable": true + }, + "customer_number": { + "type": "integer", + "nullable": true + }, + "vehicle_id": { + "type": "integer", + "nullable": true + }, + "vehicle_type_id": { + "type": "integer", + "nullable": true + }, + "reg": { + "type": "string" + }, + "status": { + "type": "string" + }, + "allowed": { + "type": "boolean" + }, + "machine_relay_enabled": { + "type": "boolean" + }, + "machine_relay_enabled_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "machine_start_triggered": { + "type": "boolean" + }, + "machine_start_triggered_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "order_id": { + "type": "integer", + "nullable": true + }, + "completed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "SelfserveWashQuestionAnswer": { + "type": "object", + "properties": { + "question_id": { + "type": "integer" + }, + "question": { + "type": "string" + }, + "answer": { + "type": "boolean" + }, + "answered_at": { + "type": "string", + "format": "date-time" + } + } + }, + "SelfserveWashTaskSnapshot": { + "type": "object", + "properties": { + "task_id": { + "type": "integer", + "nullable": true + }, + "task": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + } + }, + "buttons": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "SelfserveWashEvent": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "payload": { + "type": "object", + "additionalProperties": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "SelfserveVehicleAllowedResponse": { + "type": "object", + "properties": { + "lane": { + "$ref": "#/components/schemas/DepartmentLane" + }, + "machine_type": { + "allOf": [ + { + "$ref": "#/components/schemas/SelfserveMachineType" + } + ], + "nullable": true + }, + "vehicle": { + "type": "object", + "additionalProperties": true, + "nullable": true + }, + "reg": { + "type": "string" + }, + "customer_number": { + "type": "integer", + "nullable": true + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfserveVisibleQuestion" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfserveTaskDecision" + } + }, + "allowed_services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + } + }, + "machine_available": { + "type": "boolean" + }, + "all_visible_questions_answered": { + "type": "boolean" + }, + "allowed": { + "type": "boolean" + }, + "session": { + "allOf": [ + { + "$ref": "#/components/schemas/SelfserveWashSession" + } + ], + "nullable": true + } + } + }, + "SelfserveWashSummary": { + "type": "object", + "properties": { + "session": { + "$ref": "#/components/schemas/SelfserveWashSession" + }, + "lane": { + "allOf": [ + { + "$ref": "#/components/schemas/DepartmentLane" + } + ], + "nullable": true + }, + "machine_type": { + "allOf": [ + { + "$ref": "#/components/schemas/SelfserveMachineType" + } + ], + "nullable": true + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfserveWashQuestionAnswer" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfserveWashTaskSnapshot" + } + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfserveWashEvent" + } + } + } + }, + "DepartmentSelfserveVehicleConditionMutationResponse": { + "type": "object", + "properties": { + "condition": { + "$ref": "#/components/schemas/DepartmentSelfserveVehicleCondition" + }, + "selfserve": { + "$ref": "#/components/schemas/SelfserveWashSummary" + } + } + }, + "MachineButtonPressWebhookResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "scanner": { + "type": "string" + }, + "lane_id": { + "type": "integer" + }, + "selfserve": { + "$ref": "#/components/schemas/SelfserveWashSummary" + } + } + }, + "DepartmentSelfserveQuestion": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Question ID" + }, + "department": { + "type": "integer", + "description": "Department ID" + }, + "lane": { + "type": "integer", + "description": "Lane ID" + }, + "product": { + "type": "integer", + "description": "Product ID" + }, + "condition_id": { + "type": "integer", + "description": "Question condition object ID", + "nullable": true + }, + "question": { + "type": "string", + "description": "Question text" + }, + "description": { + "type": "string", + "description": "Question description" + }, + "order_priority": { + "type": "integer", + "description": "Display order priority (lower numbers shown first)" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "DepartmentSelfserveTask": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Task ID" + }, + "department": { + "type": "integer", + "description": "Department ID" + }, + "lane": { + "type": "integer", + "description": "Lane ID" + }, + "product": { + "type": "integer", + "description": "Product ID" + }, + "machine_type_id": { + "type": "integer", + "description": "Reusable machine type ID", + "nullable": true + }, + "condition_id": { + "type": "integer", + "description": "Condition ID (if conditional task)", + "nullable": true + }, + "task": { + "type": "string", + "description": "Task text" + }, + "description": { + "type": "string", + "description": "Task description" + }, + "order_priority": { + "type": "integer", + "description": "Display order priority (lower numbers shown first)" + }, + "services": { + "type": "array", + "description": "Services enabled by this task. Each item must be a valid service enum name.", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + }, + "default": [] + }, + "buttons": { + "type": "array", + "description": "Dynamic image button IDs enabled by this task.", + "items": { + "type": "integer" + }, + "default": [] + }, + "dynamic_images_vehicle_type": { + "type": "integer", + "nullable": true, + "description": "Optional vehicle type selection override for the machine UI." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "DepartmentSelfserveCondition": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Condition ID" + }, + "department": { + "type": "integer", + "description": "Department ID" + }, + "lane": { + "type": "integer", + "description": "Lane ID" + }, + "product": { + "type": "integer", + "description": "Product ID" + }, + "machine_type_id": { + "type": "integer", + "description": "Reusable machine type ID", + "nullable": true + }, + "condition_id": { + "type": "integer", + "description": "Optional condition ID", + "nullable": true + }, + "name": { + "type": "string", + "description": "Condition name" + }, + "description": { + "type": "string", + "description": "Condition description" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "DepartmentSelfserveConditionRule": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Rule ID" + }, + "condition_id": { + "type": "integer", + "description": "Condition object ID" + }, + "type": { + "type": "string", + "description": "Condition type (e.g., IS_TRUE, IS_FALSE)" + }, + "object_type": { + "type": "string", + "description": "The object type to which the condition applies (e.g., question, task, etc.)" + }, + "object_id": { + "type": "integer", + "description": "The object id to which the condition applies" + }, + "name": { + "type": "string", + "description": "Condition name" + }, + "description": { + "type": "string", + "description": "Condition description" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "DepartmentSelfserveVehicleCondition": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Vehicle condition ID" + }, + "department": { + "type": "integer", + "description": "Department ID" + }, + "lane": { + "type": "integer", + "description": "Lane ID" + }, + "customer_id": { + "type": "integer", + "description": "Customer ID", + "nullable": true + }, + "reg": { + "type": "string", + "description": "Vehicle registration number" + }, + "question": { + "type": "integer", + "description": "Question ID" + }, + "value": { + "type": "boolean", + "description": "Answer value" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "OrderCreate": { + "type": "object", + "required": [ + "customer_id", + "department_id" + ], + "properties": { + "customer_id": { + "type": "integer" + }, + "department_id": { + "type": "integer" + }, + "cashier_id": { + "type": "integer" + }, + "po": { + "type": "string" + }, + "lane": { + "type": "string" + } + } + }, + "OrderUpdate": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "customer_id": { + "type": "integer" + }, + "department_id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "po": { + "type": "string" + }, + "lane": { + "type": "string" + } + } + }, + "OrderItem": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "order_id": { + "type": "integer" + }, + "product_id": { + "type": "integer" + }, + "product_name": { + "type": "string" + }, + "quantity": { + "type": "integer" + }, + "unit_price": { + "type": "number", + "format": "float" + }, + "discount": { + "type": "number", + "format": "float" + }, + "total_price": { + "type": "number", + "format": "float" + } + } + }, + "OrderItemCreate": { + "type": "object", + "required": [ + "order_id", + "product_id", + "quantity" + ], + "properties": { + "order_id": { + "type": "integer" + }, + "product_id": { + "type": "integer" + }, + "quantity": { + "type": "integer" + }, + "discount": { + "type": "number", + "format": "float" + } + } + }, + "OrderItemUpdate": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "quantity": { + "type": "integer" + }, + "discount": { + "type": "number", + "format": "float" + } + } + }, + "Department": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "economic_department_id": { + "type": "integer" + }, + "visible": { + "type": "boolean" + }, + "dimension": { + "type": "integer" + }, + "branding": { + "type": "integer" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "latitude": { + "type": "number", + "format": "float" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "DepartmentGuest": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "latitude": { + "type": "number", + "format": "float" + }, + "address": { + "type": "string", + "description": "Department address (same as description)" + }, + "self_serve_enabled": { + "type": "boolean" + }, + "lanes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DepartmentLaneGuest" + } + } + } + }, + "DepartmentLaneGuest": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "products": { + "type": "array", + "items": { + "type": "integer" + } + }, + "machine_available": { + "type": "boolean" + } + } + }, + "DepartmentCreate": { + "type": "object", + "required": [ + "name", + "economic_department_id" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "economic_department_id": { + "type": "integer" + }, + "visible": { + "type": "boolean" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "latitude": { + "type": "number", + "format": "float" + } + } + }, + "DepartmentUpdate": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "visible": { + "type": "boolean" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "latitude": { + "type": "number", + "format": "float" + } + } + }, + "DepartmentLane": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "department": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "relay_in_id": { + "type": "string" + }, + "relay_out_id": { + "type": "string" + }, + "relay_machine_id": { + "type": "string" + }, + "dynamic_image_id": { + "type": "integer", + "nullable": true, + "minimum": 1 + }, + "machine_type_id": { + "type": "integer", + "nullable": true + }, + "status": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "DepartmentLaneCreate": { + "type": "object", + "required": [ + "department", + "name" + ], + "properties": { + "department": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "relay_in_id": { + "type": "string" + }, + "relay_out_id": { + "type": "string" + }, + "relay_machine_id": { + "type": "string" + }, + "dynamic_image_id": { + "type": "integer", + "nullable": true, + "minimum": 1 + }, + "machine_type_id": { + "type": "integer", + "nullable": true + } + } + }, + "DepartmentLaneUpdate": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "department": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "relay_in_id": { + "type": "string" + }, + "relay_out_id": { + "type": "string" + }, + "relay_machine_id": { + "type": "string" + }, + "dynamic_image_id": { + "type": "integer", + "nullable": true, + "minimum": 1 + }, + "machine_type_id": { + "type": "integer", + "nullable": true + } + } + }, + "DepartmentGate": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "department": { + "type": "integer" + }, + "is_entrance": { + "type": "boolean" + }, + "is_exit": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "config": { + "$ref": "#/components/schemas/DepartmentGateConfig" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "DepartmentGateConfig": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "example": "PHONE_CALL" + }, + "phone_number": { + "type": "string", + "nullable": true, + "example": 4512345678 + }, + "call_duration_threshold": { + "type": "integer", + "nullable": true, + "example": 10 + } + }, + "description": "Configuration for the department gate.\nIf type is 'PHONE_CALL', 'phone_number' and 'call_duration_threshold' are required.\n" + }, + "DepartmentGateCreate": { + "type": "object", + "required": [ + "department", + "is_entrance", + "is_exit", + "name", + "config" + ], + "properties": { + "department": { + "type": "integer" + }, + "is_entrance": { + "type": "boolean" + }, + "is_exit": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "config": { + "$ref": "#/components/schemas/DepartmentGateConfig" + } + } + }, + "DepartmentGateUpdate": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "is_entrance": { + "type": "boolean" + }, + "is_exit": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "config": { + "$ref": "#/components/schemas/DepartmentGateConfig" + } + } + }, + "DepartmentRelay": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "department": { + "type": "integer" + }, + "relay_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "SWITCH", + "TRIGGER" + ] + }, + "config": { + "$ref": "#/components/schemas/DepartmentRelayConfig" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "DepartmentRelayConfig": { + "type": "object", + "properties": { + "what_happens": { + "type": "string", + "nullable": true, + "example": "open_gate" + }, + "webhook_token": { + "type": "string", + "nullable": true, + "example": "secret_token" + } + }, + "description": "Configuration for the department relay.\nIf the relay 'type' is 'TRIGGER', 'what_happens' and 'webhook_token' are required.\n" + }, + "DepartmentRelayCreate": { + "type": "object", + "required": [ + "department", + "relay_id", + "name", + "type", + "config" + ], + "properties": { + "department": { + "type": "integer" + }, + "relay_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "SWITCH", + "TRIGGER" + ] + }, + "config": { + "$ref": "#/components/schemas/DepartmentRelayConfig" + } + } + }, + "DepartmentRelayUpdate": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "relay_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "SWITCH", + "TRIGGER" + ] + }, + "config": { + "$ref": "#/components/schemas/DepartmentRelayConfig" + } + } + }, + "Product": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "price": { + "type": "number", + "format": "float" + }, + "category_id": { + "type": "integer" + }, + "category_name": { + "type": "string" + }, + "visible": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ProductCreate": { + "type": "object", + "required": [ + "name", + "price", + "category_id" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "price": { + "type": "number", + "format": "float" + }, + "category_id": { + "type": "integer" + }, + "visible": { + "type": "boolean" + } + } + }, + "ProductUpdate": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "price": { + "type": "number", + "format": "float" + }, + "category_id": { + "type": "integer" + }, + "visible": { + "type": "boolean" + } + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CategoryCreate": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "CategoryUpdate": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "ModuleActionLog": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Log ID" + }, + "module": { + "type": "string", + "description": "The module name" + }, + "action": { + "type": "string", + "description": "The action name" + }, + "status_code": { + "type": "integer", + "description": "HTTP status code" + }, + "data": { + "type": "object", + "description": "The action data (JSON decoded)" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Log creation timestamp" + } + } + }, + "Booking": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "customer_id": { + "type": "integer" + }, + "department_id": { + "type": "integer" + }, + "booking_time": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "BookingUpdate": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "booking_time": { + "type": "string", + "format": "date-time" + } + } + }, + "Vehicle": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user_id": { + "type": "integer", + "description": "Internal user ID owning the customer account" + }, + "reg": { + "type": "string", + "description": "Vehicle registration number" + }, + "customer_id": { + "type": "integer" + }, + "customer_name": { + "type": "string" + }, + "type": { + "type": "integer", + "description": "Product ID representing the vehicle wash type" + }, + "reference": { + "type": "string", + "nullable": true, + "description": "Optional external reference/label" + }, + "wash_subscription": { + "type": "boolean" + }, + "barred": { + "type": "boolean", + "description": "True if the associated customer is barred" + }, + "addons": { + "type": "object", + "properties": { + "enabled": { + "type": "integer" + }, + "available": { + "type": "integer" + }, + "list": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "last_order_id": { + "type": "integer", + "nullable": true + }, + "xlvask": { + "type": "object", + "nullable": true, + "description": "XL Vask vehicle data when available" + }, + "vehicle_types": { + "type": "array", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "Notification": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "read": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "NotificationCreate": { + "type": "object", + "required": [ + "user_id", + "title", + "message" + ], + "properties": { + "user_id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "Permission": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Permission identifier" + }, + "description": { + "type": "string", + "description": "Human-readable description" + } + } + }, + "GoalsCriteria": { + "type": "object", + "description": "Goal evaluation criteria", + "properties": { + "type": { + "type": "string", + "description": "Criteria type", + "enum": [ + "PRODUCT", + "REVENUE", + "VISITS", + "NONE" + ], + "example": "PRODUCT" + }, + "target": { + "type": "number", + "description": "Target value for the goal", + "example": 100 + }, + "label": { + "type": "string", + "description": "Optional short label/title for this goal criteria (max 255 characters)", + "example": "Q1 Revenue Goal" + }, + "start": { + "type": "string", + "format": "date-time", + "description": "Start of the evaluation window (ISO 8601)" + }, + "end": { + "type": "string", + "format": "date-time", + "description": "End of the evaluation window (ISO 8601)" + }, + "users": { + "type": "array", + "description": "List of user customer numbers included in the criteria", + "items": { + "type": "integer" + } + }, + "departments": { + "type": "array", + "description": "List of department IDs included in the criteria", + "items": { + "type": "integer" + } + }, + "products": { + "type": "array", + "description": "List of product IDs included in the criteria", + "items": { + "type": "integer" + } + }, + "progress_alert_frequency": { + "type": "string", + "description": "Frequency of progress alerts for the goal.\nAccepted values: DAILY, WEEKLY, MONTHLY, CHANGED, NONE.\nThe canonical field name is snake_case `progress_alert_frequency`.\nFor backward-compatibility the API also accepts camelCase `progressAlertFrequency` on input.\n", + "enum": [ + "DAILY", + "WEEKLY", + "MONTHLY", + "CHANGED", + "NONE" + ], + "example": "DAILY" + }, + "progress_alert_destination": { + "type": "string", + "description": "Destination/channel where progress alerts should be delivered.\nAccepted values: SLACK, EMAIL, SMS, NONE.\nThe canonical field name is snake_case `progress_alert_destination`.\nFor backward-compatibility the API also accepts camelCase `progressAlertDestination` on input.\n", + "enum": [ + "SLACK", + "EMAIL", + "SMS", + "NONE" + ], + "example": "NONE" + }, + "progress_alert_progress_type": { + "type": "string", + "description": "What part of the progress should be included in alert messages.\nAccepted values: ALL, PERCENTAGE_ONLY, COUNT_ONLY, COUNT_AND_TARGET, NONE.\nThe canonical field name is snake_case `progress_alert_progress_type`.\nFor backward-compatibility the API also accepts camelCase `progressAlertProgressType` on input.\n", + "enum": [ + "ALL", + "PERCENTAGE_ONLY", + "COUNT_ONLY", + "COUNT_AND_TARGET", + "NONE" + ], + "example": "ALL" + }, + "progress_alert_style": { + "type": "string", + "description": "Presentation style of the alert.\nAccepted values: DEPARTMENT_COMPARE, COLLECTIVE, SINGLE_DEPARTMENT, NONE.\nThe canonical field name is snake_case `progress_alert_style`.\nFor backward-compatibility the API also accepts camelCase `progressAlertStyle` on input.\n", + "enum": [ + "DEPARTMENT_COMPARE", + "COLLECTIVE", + "SINGLE_DEPARTMENT", + "NONE" + ], + "example": "NONE" + }, + "progress_alert_format": { + "type": "string", + "nullable": true, + "description": "Optional custom template for the alert body. Supports tokens `{label}`, `{percent}`, `{count}`, `{target}`, `{timeframe}`, `{departments}`, `{prefix}`, `{body}`.\nMax length depends on destination: 160 characters for SMS; 1024 characters for EMAIL/SLACK/other.\nThe canonical field name is snake_case `progress_alert_format`.\nFor backward-compatibility the API also accepts camelCase `progressAlertFormat` on input.\n" + }, + "progress_alert_weekdays": { + "type": "array", + "description": "Weekdays on which progress alerts should be sent.\nUse one or more of: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY.\nThe canonical field name is snake_case `progress_alert_weekdays`.\nFor backward-compatibility the API also accepts camelCase `progressAlertWeekdays` on input.\n", + "items": { + "type": "string", + "enum": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ] + }, + "example": [ + "MONDAY", + "WEDNESDAY", + "FRIDAY" + ] + }, + "progress_alert_time_of_day": { + "type": "string", + "nullable": true, + "description": "Time of day (with timezone) when progress alerts should be sent.\nFormat: `HH:MMZ` or `HH:MM±HH:MM` (24-hour clock with UTC offset). Examples: `14:30Z`, `09:15+02:00`, `18:45-05:00`.\nThe canonical field name is snake_case `progress_alert_time_of_day`.\nFor backward-compatibility the API also accepts camelCase `progressAlertTimeOfDay` on input.\n", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d(?:Z|[+-](?:[01]\\d|2[0-3]):?[0-5]\\d)$", + "example": "14:30+02:00" + }, + "department_daily_targets": { + "type": "object", + "description": "Optional per-department custom daily targets. Keys are department IDs and values are non-negative numbers representing the target per operating day for that department.\nIf omitted, the daily target is split evenly across selected departments. The canonical field name is snake_case `department_daily_targets`.\nFor backward-compatibility the API also accepts camelCase `departmentDailyTargets` on input.\n", + "x-additionalPropertiesName": "department_id", + "additionalProperties": { + "type": "number", + "minimum": 0 + }, + "example": { + "12": 3, + "15": 5 + } + } + } + }, + "GoalProgressDetails": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "Current progress value" + }, + "target": { + "type": "number", + "description": "Target value for the period" + }, + "date_from": { + "type": "string", + "nullable": true, + "description": "Inclusive period start datetime (ISO-8601), null when no lower bound applies", + "example": "2026-01-01T00:00:00+00:00" + }, + "date_end": { + "type": "string", + "nullable": true, + "description": "Inclusive period end datetime (ISO-8601), null when no upper bound applies", + "example": "2026-02-26T23:59:59+00:00" + } + } + }, + "DepartmentGoalProgress": { + "type": "object", + "title": "Department progress details", + "description": "Goal progress details for a single department across multiple timeframes.\nTimeframes are clamped to the goal timeframe (never before goal start and never after goal end).\n", + "properties": { + "all": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "today": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "week": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "month": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "year": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "to_date": { + "$ref": "#/components/schemas/GoalProgressDetails" + } + } + }, + "DepartmentGoal": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "created_by": { + "type": "integer", + "description": "ID of the user who created the goal" + }, + "departments": { + "type": "array", + "items": { + "type": "integer" + } + }, + "criteria": { + "$ref": "#/components/schemas/GoalsCriteria" + }, + "progress": { + "type": "object", + "description": "Goal progress details for various timeframes.\n`year` starts at January 1 of the current year or the goal start, whichever is later.\n`to_date` starts at the goal start and ends at today (also clamped by goal end).\n", + "properties": { + "all": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "today": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "week": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "month": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "year": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "to_date": { + "$ref": "#/components/schemas/GoalProgressDetails" + }, + "departmental_distribution": { + "type": "object", + "description": "Progress details broken down by department. Keys are department IDs.\nIncludes `all`, `today`, `week`, `month`, `year`, and `to_date` timeframes.\n", + "x-additionalPropertiesName": "department_id", + "additionalProperties": { + "$ref": "#/components/schemas/DepartmentGoalProgress" + }, + "example": { + "12": { + "all": { + "count": 15, + "target": 100 + }, + "today": { + "count": 2, + "target": 5 + }, + "week": { + "count": 10, + "target": 35 + }, + "month": { + "count": 15, + "target": 100 + }, + "year": { + "count": 15, + "target": 100 + }, + "to_date": { + "count": 15, + "target": 100 + } + } + } + } + } + }, + "created_at": { + "type": "string", + "description": "Creation timestamp" + }, + "updated_at": { + "type": "string", + "description": "Update timestamp" + } + } + }, + "DepartmentGoalCreate": { + "type": "object", + "required": [ + "departments", + "criteria" + ], + "properties": { + "departments": { + "type": "array", + "items": { + "type": "integer" + } + }, + "criteria": { + "$ref": "#/components/schemas/GoalsCriteria" + } + } + }, + "DepartmentGoalUpdate": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "departments": { + "type": "array", + "items": { + "type": "integer" + } + }, + "criteria": { + "$ref": "#/components/schemas/GoalsCriteria" + } + } + }, + "Passkey": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "credential_id": { + "type": "string", + "description": "Base64URL-encoded credential ID" + }, + "name": { + "type": "string", + "nullable": true + }, + "algorithm": { + "type": "string", + "example": "ES256" + }, + "transports": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "usb", + "nfc", + "ble", + "internal" + ] + }, + "sign_count": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "PasskeyCreateRequest": { + "type": "object", + "required": [ + "credential_id", + "public_key", + "algorithm", + "transports" + ], + "properties": { + "credential_id": { + "type": "string", + "description": "Base64URL-encoded credential ID returned from WebAuthn" + }, + "public_key": { + "type": "string", + "description": "Base64URL-encoded public key (COSE or PEM as stored)" + }, + "algorithm": { + "type": "string", + "example": "ES256" + }, + "transports": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string", + "nullable": true + } + } + }, + "PasskeyRenameRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "BirdVoiceCall": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + }, + "channelId": { + "type": "string", + "example": "a2545e48-fe8c-5741-9bdc-42a081076bc9" + }, + "originator": { + "type": "object", + "properties": { + "number": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "pstn" + }, + "number": { + "type": "string", + "example": "+4532330288" + }, + "countryIsoCode": { + "type": "string", + "example": "DK" + } + } + } + } + }, + "receiver": { + "type": "object", + "properties": { + "contact": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "18229adf-af8c-404a-b036-ae193c22e33c" + }, + "identifierKey": { + "type": "string", + "example": "phonenumber" + }, + "identifierValue": { + "type": "string", + "example": "+4542331128" + } + } + }, + "number": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "pstn" + }, + "number": { + "type": "string", + "example": "+4542331128" + }, + "countryIsoCode": { + "type": "string", + "example": "DK" + } + } + } + } + }, + "from": { + "type": "string", + "example": "+4532330288" + }, + "to": { + "type": "string", + "example": "+4542331128" + }, + "direction": { + "type": "string", + "example": "outgoing" + }, + "status": { + "type": "string", + "example": "completed" + }, + "type": { + "type": "string", + "example": "pstn" + }, + "duration": { + "type": "integer", + "example": 3 + }, + "hangupCauseCode": { + "type": "integer", + "example": 16 + }, + "hangupSource": { + "type": "string", + "example": "callee" + }, + "sipInsights": { + "type": "object", + "properties": { + "hangupSipCode": { + "type": "string", + "example": "200" + } + } + }, + "qualityInsights": { + "type": "object", + "properties": { + "mos": { + "type": "string", + "example": "4.50" + }, + "pdd": { + "type": "string", + "example": "2.15" + } + } + }, + "price": { + "type": "object", + "properties": { + "currencyCode": { + "type": "string", + "example": "EUR" + }, + "amount": { + "type": "string", + "example": "0.0079" + } + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-02-27T13:45:36.216Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2026-02-27T13:45:46.532Z" + }, + "ringingAt": { + "type": "string", + "format": "date-time", + "example": "2026-02-27T13:45:38.367Z" + }, + "answeredAt": { + "type": "string", + "format": "date-time", + "example": "2026-02-27T13:45:43.359Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "example": "2026-02-27T13:45:46.368Z" + } + } + }, + "BirdVoiceCallListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "nextPageToken": { + "type": "string", + "example": "WzE3NzIxNTY5NTI0MDUsIjk5ZDU4M2VkLTQyMzAtNDExNy1hOTQ0LTllY2JjNzhmYWJlMSJd" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BirdVoiceCall" + } + } + } + }, + "meta": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + }, + "includes": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + } + } + }, + "BirdVoiceCallSingleResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "$ref": "#/components/schemas/BirdVoiceCall" + }, + "meta": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + }, + "includes": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + } + } + }, + "BirdTestOutboundCallResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "to": { + "type": "string", + "example": "+45 42 33 11 28" + }, + "to_e164": { + "type": "string", + "example": "+4542331128" + }, + "call_id": { + "type": "string", + "nullable": true, + "example": "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + }, + "final_status": { + "type": "string", + "nullable": true, + "example": "completed" + }, + "hangup_sent": { + "type": "boolean", + "example": true + }, + "created_call": { + "$ref": "#/components/schemas/BirdVoiceCall" + }, + "last_call_snapshot": { + "$ref": "#/components/schemas/BirdVoiceCall" + }, + "hangup_response": { + "$ref": "#/components/schemas/BirdVoiceCall" + } + } + }, + "meta": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + }, + "includes": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + } + } + }, + "BirdNumber": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "019c73dc-60f1-76c4-98b0-c4318706b938" + }, + "workspaceId": { + "type": "string", + "example": "3d5fae4f-9c2d-41aa-9840-28b18e6a94bc" + }, + "type": { + "type": "string", + "example": "national" + }, + "country": { + "type": "string", + "example": "DK" + }, + "number": { + "type": "string", + "example": "+4532330288" + }, + "status": { + "type": "string", + "example": "active" + }, + "capabilities": { + "type": "object", + "properties": { + "voice": { + "$ref": "#/components/schemas/BirdNumberCapability" + }, + "sms": { + "$ref": "#/components/schemas/BirdNumberCapability" + }, + "mms": { + "$ref": "#/components/schemas/BirdNumberCapability" + }, + "fax": { + "$ref": "#/components/schemas/BirdNumberCapability" + }, + "whatsapp": { + "$ref": "#/components/schemas/BirdNumberCapability" + } + } + }, + "monthlyRecurringPrice": { + "type": "object", + "properties": { + "currencyCode": { + "type": "string", + "example": "EUR" + }, + "amount": { + "type": "integer", + "example": 1000000 + }, + "exponent": { + "type": "integer", + "example": -6 + } + } + }, + "complianceRequirements": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "configurations": { + "type": "object", + "additionalProperties": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-02-19T03:05:48.529Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2026-02-19T03:08:17.753Z" + }, + "activatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": "2026-02-19T03:05:48.529Z" + }, + "deactivatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deactivatesAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "subscription": { + "type": "object", + "additionalProperties": true + }, + "endpointSubscription": { + "type": "object", + "additionalProperties": true + }, + "requirements": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "whatsApp": { + "type": "object", + "additionalProperties": true + }, + "endpoint": { + "type": "object", + "additionalProperties": true + } + } + }, + "BirdNumberCapability": { + "type": "object", + "properties": { + "inbound": { + "type": "boolean" + }, + "outbound": { + "type": "boolean" + } + } + }, + "BirdNumberListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BirdNumber" + } + } + } + }, + "meta": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + }, + "includes": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + } + } + }, + "BirdNumberSingleResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "$ref": "#/components/schemas/BirdNumber" + }, + "meta": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + }, + "includes": { + "oneOf": [ + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": true + } + ], + "example": [] + } + } + } + } + } +} diff --git a/documentation/topics/API-Overview.topic b/documentation/topics/API-Overview.topic new file mode 100644 index 00000000..f17dd329 --- /dev/null +++ b/documentation/topics/API-Overview.topic @@ -0,0 +1,43 @@ + + + + +

This section provides a high-level overview of how to interact with the API based on openapi.yaml.

+ + +

The API currently defines the following servers:

+ +https://api.truckwash.dk +https://api.truckwash.io +http://localhost/api + +
+ + + + + + + + + + + + + + + + + + + + + + + +
HeaderDescription
AuthorizationUse Bearer authentication for protected endpoints: Authorization: Bearer <JWT>.
AcceptUse application/json.
Content-TypeUse application/json for request bodies.
X-Customer-NumberRequired for many customer-scoped requests when authenticated as a subuser.
+
+
diff --git a/documentation/topics/API-Reference.topic b/documentation/topics/API-Reference.topic new file mode 100644 index 00000000..031d8423 --- /dev/null +++ b/documentation/topics/API-Reference.topic @@ -0,0 +1,10 @@ + + + + + +

Comprehensive API reference generated from the repository root openapi.yaml.

+
diff --git a/documentation/topics/Authentication.topic b/documentation/topics/Authentication.topic new file mode 100644 index 00000000..f659d698 --- /dev/null +++ b/documentation/topics/Authentication.topic @@ -0,0 +1,23 @@ + + + + +

The API uses the BearerAuth security scheme (HTTP Bearer, JWT).

+ + +

Get a token from /auth/login or /auth/employee/login, then send:

+ + Authorization: Bearer YOUR_API_TOKEN + +
+ + +

When authenticated as a subuser, include a target customer header for customer-scoped endpoints:

+ + X-Customer-Number: 123456 + +
+
diff --git a/documentation/topics/Error-Handling.topic b/documentation/topics/Error-Handling.topic new file mode 100644 index 00000000..91ad3145 --- /dev/null +++ b/documentation/topics/Error-Handling.topic @@ -0,0 +1,57 @@ + + + + +

This section describes error responses defined in openapi.yaml.

+ + +

The shared Error schema is:

+ +{ + "error": "Invalid API token", + "code": 401 +} + +
+ + +

Common error status codes used across endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeMeaning
400Bad Request
401Unauthorized
403Forbidden
404Not Found
409Conflict
422Unprocessable Entity
500Internal Server Error
+
+
diff --git a/documentation/topics/Introduction.topic b/documentation/topics/Introduction.topic new file mode 100644 index 00000000..6f34f88a --- /dev/null +++ b/documentation/topics/Introduction.topic @@ -0,0 +1,14 @@ + + + + +

Welcome to the %product% documentation.

+

This API provides access to the Copenhagen Truck Wash system, allowing you to manage wash bookings, vehicle data, and customer information.

+ + +

The API is built on REST principles and returns JSON-encoded responses.

+
+
diff --git a/documentation/topics/generated/Config_Module_Backups.topic b/documentation/topics/generated/Config_Module_Backups.topic new file mode 100644 index 00000000..6cdb29f2 --- /dev/null +++ b/documentation/topics/generated/Config_Module_Backups.topic @@ -0,0 +1,10 @@ + + + + + +

Backup configuration for backup module behavior.

+
diff --git a/documentation/topics/generated/Config_Module_Backups_Page_1.topic b/documentation/topics/generated/Config_Module_Backups_Page_1.topic new file mode 100644 index 00000000..15fce64b --- /dev/null +++ b/documentation/topics/generated/Config_Module_Backups_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_Bird.topic b/documentation/topics/generated/Config_Module_Bird.topic new file mode 100644 index 00000000..2d74eba3 --- /dev/null +++ b/documentation/topics/generated/Config_Module_Bird.topic @@ -0,0 +1,10 @@ + + + + + +

Bird communication integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_Bird_Page_1.topic b/documentation/topics/generated/Config_Module_Bird_Page_1.topic new file mode 100644 index 00000000..1f8835e9 --- /dev/null +++ b/documentation/topics/generated/Config_Module_Bird_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_Email.topic b/documentation/topics/generated/Config_Module_Email.topic new file mode 100644 index 00000000..c9ac2e95 --- /dev/null +++ b/documentation/topics/generated/Config_Module_Email.topic @@ -0,0 +1,10 @@ + + + + + +

Email provider and SMTP/MailerSend configuration.

+
diff --git a/documentation/topics/generated/Config_Module_Email_Page_1.topic b/documentation/topics/generated/Config_Module_Email_Page_1.topic new file mode 100644 index 00000000..9c66f5aa --- /dev/null +++ b/documentation/topics/generated/Config_Module_Email_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_Entra.topic b/documentation/topics/generated/Config_Module_Entra.topic new file mode 100644 index 00000000..3ff643aa --- /dev/null +++ b/documentation/topics/generated/Config_Module_Entra.topic @@ -0,0 +1,10 @@ + + + + + +

Microsoft Entra identity integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_Entra_Page_1.topic b/documentation/topics/generated/Config_Module_Entra_Page_1.topic new file mode 100644 index 00000000..11d7b350 --- /dev/null +++ b/documentation/topics/generated/Config_Module_Entra_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_FXRatesAPI.topic b/documentation/topics/generated/Config_Module_FXRatesAPI.topic new file mode 100644 index 00000000..61736f21 --- /dev/null +++ b/documentation/topics/generated/Config_Module_FXRatesAPI.topic @@ -0,0 +1,10 @@ + + + + + +

FXRatesAPI exchange-rate integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_FXRatesAPI_Page_1.topic b/documentation/topics/generated/Config_Module_FXRatesAPI_Page_1.topic new file mode 100644 index 00000000..83ce174b --- /dev/null +++ b/documentation/topics/generated/Config_Module_FXRatesAPI_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_GatewayAPI.topic b/documentation/topics/generated/Config_Module_GatewayAPI.topic new file mode 100644 index 00000000..6175891f --- /dev/null +++ b/documentation/topics/generated/Config_Module_GatewayAPI.topic @@ -0,0 +1,10 @@ + + + + + +

GatewayAPI integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_GatewayAPI_Page_1.topic b/documentation/topics/generated/Config_Module_GatewayAPI_Page_1.topic new file mode 100644 index 00000000..5cabd770 --- /dev/null +++ b/documentation/topics/generated/Config_Module_GatewayAPI_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_LicensePlateRecognizer.topic b/documentation/topics/generated/Config_Module_LicensePlateRecognizer.topic new file mode 100644 index 00000000..432e9222 --- /dev/null +++ b/documentation/topics/generated/Config_Module_LicensePlateRecognizer.topic @@ -0,0 +1,10 @@ + + + + + +

License plate recognizer integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_LicensePlateRecognizer_Page_1.topic b/documentation/topics/generated/Config_Module_LicensePlateRecognizer_Page_1.topic new file mode 100644 index 00000000..9820f185 --- /dev/null +++ b/documentation/topics/generated/Config_Module_LicensePlateRecognizer_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_Limble.topic b/documentation/topics/generated/Config_Module_Limble.topic new file mode 100644 index 00000000..9e521ea6 --- /dev/null +++ b/documentation/topics/generated/Config_Module_Limble.topic @@ -0,0 +1,10 @@ + + + + + +

Limble integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_Limble_Page_1.topic b/documentation/topics/generated/Config_Module_Limble_Page_1.topic new file mode 100644 index 00000000..4669155d --- /dev/null +++ b/documentation/topics/generated/Config_Module_Limble_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_MotorAPI.topic b/documentation/topics/generated/Config_Module_MotorAPI.topic new file mode 100644 index 00000000..44152ed6 --- /dev/null +++ b/documentation/topics/generated/Config_Module_MotorAPI.topic @@ -0,0 +1,10 @@ + + + + + +

MotorAPI vehicle lookup integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_MotorAPI_Page_1.topic b/documentation/topics/generated/Config_Module_MotorAPI_Page_1.topic new file mode 100644 index 00000000..796ba859 --- /dev/null +++ b/documentation/topics/generated/Config_Module_MotorAPI_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_OcrSpace.topic b/documentation/topics/generated/Config_Module_OcrSpace.topic new file mode 100644 index 00000000..9e5f5033 --- /dev/null +++ b/documentation/topics/generated/Config_Module_OcrSpace.topic @@ -0,0 +1,10 @@ + + + + + +

OCR Space integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_OcrSpace_Page_1.topic b/documentation/topics/generated/Config_Module_OcrSpace_Page_1.topic new file mode 100644 index 00000000..fc63c3ad --- /dev/null +++ b/documentation/topics/generated/Config_Module_OcrSpace_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_OpenAI.topic b/documentation/topics/generated/Config_Module_OpenAI.topic new file mode 100644 index 00000000..150b671c --- /dev/null +++ b/documentation/topics/generated/Config_Module_OpenAI.topic @@ -0,0 +1,10 @@ + + + + + +

OpenAI integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_OpenAI_Page_1.topic b/documentation/topics/generated/Config_Module_OpenAI_Page_1.topic new file mode 100644 index 00000000..7874197c --- /dev/null +++ b/documentation/topics/generated/Config_Module_OpenAI_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_Self_Serve.topic b/documentation/topics/generated/Config_Module_Self_Serve.topic new file mode 100644 index 00000000..e30226c2 --- /dev/null +++ b/documentation/topics/generated/Config_Module_Self_Serve.topic @@ -0,0 +1,10 @@ + + + + + +

Self-serve module runtime configuration.

+
diff --git a/documentation/topics/generated/Config_Module_Self_Serve_Page_1.topic b/documentation/topics/generated/Config_Module_Self_Serve_Page_1.topic new file mode 100644 index 00000000..a3b94b9c --- /dev/null +++ b/documentation/topics/generated/Config_Module_Self_Serve_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_Shelly.topic b/documentation/topics/generated/Config_Module_Shelly.topic new file mode 100644 index 00000000..094b8991 --- /dev/null +++ b/documentation/topics/generated/Config_Module_Shelly.topic @@ -0,0 +1,10 @@ + + + + + +

Shelly integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_Shelly_Page_1.topic b/documentation/topics/generated/Config_Module_Shelly_Page_1.topic new file mode 100644 index 00000000..1a33d16d --- /dev/null +++ b/documentation/topics/generated/Config_Module_Shelly_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_Stripe.topic b/documentation/topics/generated/Config_Module_Stripe.topic new file mode 100644 index 00000000..247f5d1e --- /dev/null +++ b/documentation/topics/generated/Config_Module_Stripe.topic @@ -0,0 +1,10 @@ + + + + + +

Stripe integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_Stripe_Page_1.topic b/documentation/topics/generated/Config_Module_Stripe_Page_1.topic new file mode 100644 index 00000000..6fe4e291 --- /dev/null +++ b/documentation/topics/generated/Config_Module_Stripe_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_VirkData.topic b/documentation/topics/generated/Config_Module_VirkData.topic new file mode 100644 index 00000000..e7d286a4 --- /dev/null +++ b/documentation/topics/generated/Config_Module_VirkData.topic @@ -0,0 +1,10 @@ + + + + + +

VirkData integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_VirkData_Page_1.topic b/documentation/topics/generated/Config_Module_VirkData_Page_1.topic new file mode 100644 index 00000000..84c9c735 --- /dev/null +++ b/documentation/topics/generated/Config_Module_VirkData_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_WeatherAPI.topic b/documentation/topics/generated/Config_Module_WeatherAPI.topic new file mode 100644 index 00000000..d34796e4 --- /dev/null +++ b/documentation/topics/generated/Config_Module_WeatherAPI.topic @@ -0,0 +1,10 @@ + + + + + +

WeatherAPI integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_WeatherAPI_Page_1.topic b/documentation/topics/generated/Config_Module_WeatherAPI_Page_1.topic new file mode 100644 index 00000000..4a6cc249 --- /dev/null +++ b/documentation/topics/generated/Config_Module_WeatherAPI_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_XLVask.topic b/documentation/topics/generated/Config_Module_XLVask.topic new file mode 100644 index 00000000..818b8ee2 --- /dev/null +++ b/documentation/topics/generated/Config_Module_XLVask.topic @@ -0,0 +1,10 @@ + + + + + +

XLVask integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_XLVask_Page_1.topic b/documentation/topics/generated/Config_Module_XLVask_Page_1.topic new file mode 100644 index 00000000..9676c848 --- /dev/null +++ b/documentation/topics/generated/Config_Module_XLVask_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_e_conomic.topic b/documentation/topics/generated/Config_Module_e_conomic.topic new file mode 100644 index 00000000..2a9c02f5 --- /dev/null +++ b/documentation/topics/generated/Config_Module_e_conomic.topic @@ -0,0 +1,10 @@ + + + + + +

e-conomic accounting integration configuration.

+
diff --git a/documentation/topics/generated/Config_Module_e_conomic_Page_1.topic b/documentation/topics/generated/Config_Module_e_conomic_Page_1.topic new file mode 100644 index 00000000..1d4877a2 --- /dev/null +++ b/documentation/topics/generated/Config_Module_e_conomic_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Config_Module_reCAPTCHA.topic b/documentation/topics/generated/Config_Module_reCAPTCHA.topic new file mode 100644 index 00000000..1c41d93c --- /dev/null +++ b/documentation/topics/generated/Config_Module_reCAPTCHA.topic @@ -0,0 +1,10 @@ + + + + + +

reCAPTCHA protection configuration.

+
diff --git a/documentation/topics/generated/Config_Module_reCAPTCHA_Page_1.topic b/documentation/topics/generated/Config_Module_reCAPTCHA_Page_1.topic new file mode 100644 index 00000000..49f21b35 --- /dev/null +++ b/documentation/topics/generated/Config_Module_reCAPTCHA_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_Action_Logs.topic b/documentation/topics/generated/Modules_Module_Action_Logs.topic new file mode 100644 index 00000000..c65a63c4 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Action_Logs.topic @@ -0,0 +1,10 @@ + + + + + +

Audit and operational logs for integration module activity.

+
diff --git a/documentation/topics/generated/Modules_Module_Action_Logs_Page_1.topic b/documentation/topics/generated/Modules_Module_Action_Logs_Page_1.topic new file mode 100644 index 00000000..df65851b --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Action_Logs_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_Backup.topic b/documentation/topics/generated/Modules_Module_Backup.topic new file mode 100644 index 00000000..1d11f061 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Backup.topic @@ -0,0 +1,10 @@ + + + + + +

Backup integrations and backup module orchestration.

+
diff --git a/documentation/topics/generated/Modules_Module_Backup_Page_1.topic b/documentation/topics/generated/Modules_Module_Backup_Page_1.topic new file mode 100644 index 00000000..3cfdb7eb --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Backup_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_CVR.topic b/documentation/topics/generated/Modules_Module_CVR.topic new file mode 100644 index 00000000..11345e8d --- /dev/null +++ b/documentation/topics/generated/Modules_Module_CVR.topic @@ -0,0 +1,10 @@ + + + + + +

Danish company registry (CVR) lookup and search integration.

+
diff --git a/documentation/topics/generated/Modules_Module_CVR_Page_1.topic b/documentation/topics/generated/Modules_Module_CVR_Page_1.topic new file mode 100644 index 00000000..197702af --- /dev/null +++ b/documentation/topics/generated/Modules_Module_CVR_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_Entra.topic b/documentation/topics/generated/Modules_Module_Entra.topic new file mode 100644 index 00000000..652317bc --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Entra.topic @@ -0,0 +1,10 @@ + + + + + +

Microsoft Entra directory integration endpoints.

+
diff --git a/documentation/topics/generated/Modules_Module_Entra_Page_1.topic b/documentation/topics/generated/Modules_Module_Entra_Page_1.topic new file mode 100644 index 00000000..3df53d43 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Entra_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_FXRatesAPI.topic b/documentation/topics/generated/Modules_Module_FXRatesAPI.topic new file mode 100644 index 00000000..09549b55 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_FXRatesAPI.topic @@ -0,0 +1,10 @@ + + + + + +

Currency exchange-rate lookup integration.

+
diff --git a/documentation/topics/generated/Modules_Module_FXRatesAPI_Page_1.topic b/documentation/topics/generated/Modules_Module_FXRatesAPI_Page_1.topic new file mode 100644 index 00000000..d998e8bb --- /dev/null +++ b/documentation/topics/generated/Modules_Module_FXRatesAPI_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_MotorAPI.topic b/documentation/topics/generated/Modules_Module_MotorAPI.topic new file mode 100644 index 00000000..e2cc59f2 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_MotorAPI.topic @@ -0,0 +1,10 @@ + + + + + +

Vehicle lookup integration via MotorAPI.

+
diff --git a/documentation/topics/generated/Modules_Module_MotorAPI_Page_1.topic b/documentation/topics/generated/Modules_Module_MotorAPI_Page_1.topic new file mode 100644 index 00000000..a5c0b1c4 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_MotorAPI_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_Self_Serve.topic b/documentation/topics/generated/Modules_Module_Self_Serve.topic new file mode 100644 index 00000000..11b3c713 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Self_Serve.topic @@ -0,0 +1,10 @@ + + + + + +

Self-serve lane control and machine command endpoints.

+
diff --git a/documentation/topics/generated/Modules_Module_Self_Serve_Page_1.topic b/documentation/topics/generated/Modules_Module_Self_Serve_Page_1.topic new file mode 100644 index 00000000..1cb84005 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Self_Serve_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_Stripe.topic b/documentation/topics/generated/Modules_Module_Stripe.topic new file mode 100644 index 00000000..bbb4a34e --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Stripe.topic @@ -0,0 +1,10 @@ + + + + + +

Stripe payments, invoices, terminals, products, and customers.

+
diff --git a/documentation/topics/generated/Modules_Module_Stripe_Page_1.topic b/documentation/topics/generated/Modules_Module_Stripe_Page_1.topic new file mode 100644 index 00000000..4c17ad17 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Stripe_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_VirkData.topic b/documentation/topics/generated/Modules_Module_VirkData.topic new file mode 100644 index 00000000..a3168363 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_VirkData.topic @@ -0,0 +1,10 @@ + + + + + +

VirkData company information integration.

+
diff --git a/documentation/topics/generated/Modules_Module_VirkData_Page_1.topic b/documentation/topics/generated/Modules_Module_VirkData_Page_1.topic new file mode 100644 index 00000000..4bb2b8b6 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_VirkData_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_Wash_Certificates.topic b/documentation/topics/generated/Modules_Module_Wash_Certificates.topic new file mode 100644 index 00000000..0a0d4f26 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Wash_Certificates.topic @@ -0,0 +1,10 @@ + + + + + +

Wash certificate retrieval and listing integration.

+
diff --git a/documentation/topics/generated/Modules_Module_Wash_Certificates_Page_1.topic b/documentation/topics/generated/Modules_Module_Wash_Certificates_Page_1.topic new file mode 100644 index 00000000..d5e1ee43 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_Wash_Certificates_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_WeatherAPI.topic b/documentation/topics/generated/Modules_Module_WeatherAPI.topic new file mode 100644 index 00000000..1e787fed --- /dev/null +++ b/documentation/topics/generated/Modules_Module_WeatherAPI.topic @@ -0,0 +1,10 @@ + + + + + +

Weather provider integration for current, forecast, and search.

+
diff --git a/documentation/topics/generated/Modules_Module_WeatherAPI_Page_1.topic b/documentation/topics/generated/Modules_Module_WeatherAPI_Page_1.topic new file mode 100644 index 00000000..240f8653 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_WeatherAPI_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_XLVask.topic b/documentation/topics/generated/Modules_Module_XLVask.topic new file mode 100644 index 00000000..7500733f --- /dev/null +++ b/documentation/topics/generated/Modules_Module_XLVask.topic @@ -0,0 +1,10 @@ + + + + + +

XLVask synchronization, usage logs, vehicles, and customers.

+
diff --git a/documentation/topics/generated/Modules_Module_XLVask_Page_1.topic b/documentation/topics/generated/Modules_Module_XLVask_Page_1.topic new file mode 100644 index 00000000..c059b69b --- /dev/null +++ b/documentation/topics/generated/Modules_Module_XLVask_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Modules_Module_e_conomic.topic b/documentation/topics/generated/Modules_Module_e_conomic.topic new file mode 100644 index 00000000..6fca0683 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_e_conomic.topic @@ -0,0 +1,10 @@ + + + + + +

Accounting and invoicing integration with e-conomic.

+
diff --git a/documentation/topics/generated/Modules_Module_e_conomic_Page_1.topic b/documentation/topics/generated/Modules_Module_e_conomic_Page_1.topic new file mode 100644 index 00000000..ee2428f1 --- /dev/null +++ b/documentation/topics/generated/Modules_Module_e_conomic_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Attachments.topic b/documentation/topics/generated/Tag_Attachments.topic new file mode 100644 index 00000000..35eff617 --- /dev/null +++ b/documentation/topics/generated/Tag_Attachments.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Attachments_Page_1.topic b/documentation/topics/generated/Tag_Attachments_Page_1.topic new file mode 100644 index 00000000..29ebeafc --- /dev/null +++ b/documentation/topics/generated/Tag_Attachments_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Authentication.topic b/documentation/topics/generated/Tag_Authentication.topic new file mode 100644 index 00000000..47780ef6 --- /dev/null +++ b/documentation/topics/generated/Tag_Authentication.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Authentication_Page_1.topic b/documentation/topics/generated/Tag_Authentication_Page_1.topic new file mode 100644 index 00000000..1f12cf12 --- /dev/null +++ b/documentation/topics/generated/Tag_Authentication_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Bird.topic b/documentation/topics/generated/Tag_Bird.topic new file mode 100644 index 00000000..61c3c4b2 --- /dev/null +++ b/documentation/topics/generated/Tag_Bird.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Bird_Page_1.topic b/documentation/topics/generated/Tag_Bird_Page_1.topic new file mode 100644 index 00000000..49ae2025 --- /dev/null +++ b/documentation/topics/generated/Tag_Bird_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Bookings.topic b/documentation/topics/generated/Tag_Bookings.topic new file mode 100644 index 00000000..0ededc9c --- /dev/null +++ b/documentation/topics/generated/Tag_Bookings.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Bookings_Page_1.topic b/documentation/topics/generated/Tag_Bookings_Page_1.topic new file mode 100644 index 00000000..b040929c --- /dev/null +++ b/documentation/topics/generated/Tag_Bookings_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Branding.topic b/documentation/topics/generated/Tag_Branding.topic new file mode 100644 index 00000000..078e1f09 --- /dev/null +++ b/documentation/topics/generated/Tag_Branding.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Branding_Page_1.topic b/documentation/topics/generated/Tag_Branding_Page_1.topic new file mode 100644 index 00000000..ae9148df --- /dev/null +++ b/documentation/topics/generated/Tag_Branding_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Categories.topic b/documentation/topics/generated/Tag_Categories.topic new file mode 100644 index 00000000..59c1fb0d --- /dev/null +++ b/documentation/topics/generated/Tag_Categories.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Categories_Page_1.topic b/documentation/topics/generated/Tag_Categories_Page_1.topic new file mode 100644 index 00000000..a7ee0f93 --- /dev/null +++ b/documentation/topics/generated/Tag_Categories_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Config.topic b/documentation/topics/generated/Tag_Config.topic new file mode 100644 index 00000000..48429663 --- /dev/null +++ b/documentation/topics/generated/Tag_Config.topic @@ -0,0 +1,34 @@ + + + + + +

Configuration endpoints grouped by module name.

+ + + + + + + + + + + + + + + + + + + + + + +
ModuleDescription
BackupsBackup configuration for backup module behavior.
BirdBird communication integration configuration.
e-conomice-conomic accounting integration configuration.
EmailEmail provider and SMTP/MailerSend configuration.
EntraMicrosoft Entra identity integration configuration.
FXRatesAPIFXRatesAPI exchange-rate integration configuration.
GatewayAPIGatewayAPI integration configuration.
LicensePlateRecognizerLicense plate recognizer integration configuration.
LimbleLimble integration configuration.
MotorAPIMotorAPI vehicle lookup integration configuration.
OcrSpaceOCR Space integration configuration.
OpenAIOpenAI integration configuration.
reCAPTCHAreCAPTCHA protection configuration.
Self-ServeSelf-serve module runtime configuration.
ShellyShelly integration configuration.
StripeStripe integration configuration.
VirkDataVirkData integration configuration.
WeatherAPIWeatherAPI integration configuration.
XLVaskXLVask integration configuration.
+
+
diff --git a/documentation/topics/generated/Tag_Departments.topic b/documentation/topics/generated/Tag_Departments.topic new file mode 100644 index 00000000..de3ad223 --- /dev/null +++ b/documentation/topics/generated/Tag_Departments.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Departments_Page_1.topic b/documentation/topics/generated/Tag_Departments_Page_1.topic new file mode 100644 index 00000000..7dd38095 --- /dev/null +++ b/documentation/topics/generated/Tag_Departments_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Departments_Page_2.topic b/documentation/topics/generated/Tag_Departments_Page_2.topic new file mode 100644 index 00000000..4ea69fab --- /dev/null +++ b/documentation/topics/generated/Tag_Departments_Page_2.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Forms.topic b/documentation/topics/generated/Tag_Forms.topic new file mode 100644 index 00000000..86d07f56 --- /dev/null +++ b/documentation/topics/generated/Tag_Forms.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Forms_Page_1.topic b/documentation/topics/generated/Tag_Forms_Page_1.topic new file mode 100644 index 00000000..c153c9db --- /dev/null +++ b/documentation/topics/generated/Tag_Forms_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Goals.topic b/documentation/topics/generated/Tag_Goals.topic new file mode 100644 index 00000000..1e959b33 --- /dev/null +++ b/documentation/topics/generated/Tag_Goals.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Goals_Page_1.topic b/documentation/topics/generated/Tag_Goals_Page_1.topic new file mode 100644 index 00000000..8280f391 --- /dev/null +++ b/documentation/topics/generated/Tag_Goals_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Invoices.topic b/documentation/topics/generated/Tag_Invoices.topic new file mode 100644 index 00000000..23322399 --- /dev/null +++ b/documentation/topics/generated/Tag_Invoices.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Invoices_Page_1.topic b/documentation/topics/generated/Tag_Invoices_Page_1.topic new file mode 100644 index 00000000..91d34a4e --- /dev/null +++ b/documentation/topics/generated/Tag_Invoices_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Invoices_Page_2.topic b/documentation/topics/generated/Tag_Invoices_Page_2.topic new file mode 100644 index 00000000..1f932a2a --- /dev/null +++ b/documentation/topics/generated/Tag_Invoices_Page_2.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Modules.topic b/documentation/topics/generated/Tag_Modules.topic new file mode 100644 index 00000000..cdb648c4 --- /dev/null +++ b/documentation/topics/generated/Tag_Modules.topic @@ -0,0 +1,28 @@ + + + + + +

Module integrations sorted by module name.

+ + + + + + + + + + + + + + + + +
ModuleDescription
Action LogsAudit and operational logs for integration module activity.
BackupBackup integrations and backup module orchestration.
CVRDanish company registry (CVR) lookup and search integration.
e-conomicAccounting and invoicing integration with e-conomic.
EntraMicrosoft Entra directory integration endpoints.
FXRatesAPICurrency exchange-rate lookup integration.
MotorAPIVehicle lookup integration via MotorAPI.
Self-ServeSelf-serve lane control and machine command endpoints.
StripeStripe payments, invoices, terminals, products, and customers.
VirkDataVirkData company information integration.
Wash CertificatesWash certificate retrieval and listing integration.
WeatherAPIWeather provider integration for current, forecast, and search.
XLVaskXLVask synchronization, usage logs, vehicles, and customers.
+
+
diff --git a/documentation/topics/generated/Tag_Notifications.topic b/documentation/topics/generated/Tag_Notifications.topic new file mode 100644 index 00000000..b1ab5182 --- /dev/null +++ b/documentation/topics/generated/Tag_Notifications.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Notifications_Page_1.topic b/documentation/topics/generated/Tag_Notifications_Page_1.topic new file mode 100644 index 00000000..6c052d36 --- /dev/null +++ b/documentation/topics/generated/Tag_Notifications_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Order_Items.topic b/documentation/topics/generated/Tag_Order_Items.topic new file mode 100644 index 00000000..4f24e04c --- /dev/null +++ b/documentation/topics/generated/Tag_Order_Items.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Order_Items_Page_1.topic b/documentation/topics/generated/Tag_Order_Items_Page_1.topic new file mode 100644 index 00000000..eb60d385 --- /dev/null +++ b/documentation/topics/generated/Tag_Order_Items_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Orders.topic b/documentation/topics/generated/Tag_Orders.topic new file mode 100644 index 00000000..4f1b16d5 --- /dev/null +++ b/documentation/topics/generated/Tag_Orders.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Orders_Page_1.topic b/documentation/topics/generated/Tag_Orders_Page_1.topic new file mode 100644 index 00000000..142dd43d --- /dev/null +++ b/documentation/topics/generated/Tag_Orders_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Plate_Scans.topic b/documentation/topics/generated/Tag_Plate_Scans.topic new file mode 100644 index 00000000..451e89cd --- /dev/null +++ b/documentation/topics/generated/Tag_Plate_Scans.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Plate_Scans_Page_1.topic b/documentation/topics/generated/Tag_Plate_Scans_Page_1.topic new file mode 100644 index 00000000..60bff3e2 --- /dev/null +++ b/documentation/topics/generated/Tag_Plate_Scans_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Products.topic b/documentation/topics/generated/Tag_Products.topic new file mode 100644 index 00000000..3206a17d --- /dev/null +++ b/documentation/topics/generated/Tag_Products.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Products_Page_1.topic b/documentation/topics/generated/Tag_Products_Page_1.topic new file mode 100644 index 00000000..47945ffe --- /dev/null +++ b/documentation/topics/generated/Tag_Products_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Roles.topic b/documentation/topics/generated/Tag_Roles.topic new file mode 100644 index 00000000..b3e01ccb --- /dev/null +++ b/documentation/topics/generated/Tag_Roles.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Roles_Page_1.topic b/documentation/topics/generated/Tag_Roles_Page_1.topic new file mode 100644 index 00000000..704c01ee --- /dev/null +++ b/documentation/topics/generated/Tag_Roles_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Search.topic b/documentation/topics/generated/Tag_Search.topic new file mode 100644 index 00000000..eeae238d --- /dev/null +++ b/documentation/topics/generated/Tag_Search.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Search_Page_1.topic b/documentation/topics/generated/Tag_Search_Page_1.topic new file mode 100644 index 00000000..92a85971 --- /dev/null +++ b/documentation/topics/generated/Tag_Search_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Security.topic b/documentation/topics/generated/Tag_Security.topic new file mode 100644 index 00000000..c95d3cf1 --- /dev/null +++ b/documentation/topics/generated/Tag_Security.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Security_Page_1.topic b/documentation/topics/generated/Tag_Security_Page_1.topic new file mode 100644 index 00000000..0efd088c --- /dev/null +++ b/documentation/topics/generated/Tag_Security_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Self_Serve.topic b/documentation/topics/generated/Tag_Self_Serve.topic new file mode 100644 index 00000000..f61a0c51 --- /dev/null +++ b/documentation/topics/generated/Tag_Self_Serve.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Self_Serve_Page_1.topic b/documentation/topics/generated/Tag_Self_Serve_Page_1.topic new file mode 100644 index 00000000..c6d948f8 --- /dev/null +++ b/documentation/topics/generated/Tag_Self_Serve_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Self_Serve_Page_2.topic b/documentation/topics/generated/Tag_Self_Serve_Page_2.topic new file mode 100644 index 00000000..c2d54e21 --- /dev/null +++ b/documentation/topics/generated/Tag_Self_Serve_Page_2.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Statistics.topic b/documentation/topics/generated/Tag_Statistics.topic new file mode 100644 index 00000000..b5014bec --- /dev/null +++ b/documentation/topics/generated/Tag_Statistics.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Statistics_Page_1.topic b/documentation/topics/generated/Tag_Statistics_Page_1.topic new file mode 100644 index 00000000..5d45e7fa --- /dev/null +++ b/documentation/topics/generated/Tag_Statistics_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Subusers.topic b/documentation/topics/generated/Tag_Subusers.topic new file mode 100644 index 00000000..0c760f8a --- /dev/null +++ b/documentation/topics/generated/Tag_Subusers.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Subusers_Page_1.topic b/documentation/topics/generated/Tag_Subusers_Page_1.topic new file mode 100644 index 00000000..8ff0a26c --- /dev/null +++ b/documentation/topics/generated/Tag_Subusers_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Users.topic b/documentation/topics/generated/Tag_Users.topic new file mode 100644 index 00000000..28f90011 --- /dev/null +++ b/documentation/topics/generated/Tag_Users.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Users_Page_1.topic b/documentation/topics/generated/Tag_Users_Page_1.topic new file mode 100644 index 00000000..3be43b0f --- /dev/null +++ b/documentation/topics/generated/Tag_Users_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Users_Page_2.topic b/documentation/topics/generated/Tag_Users_Page_2.topic new file mode 100644 index 00000000..fdf67db7 --- /dev/null +++ b/documentation/topics/generated/Tag_Users_Page_2.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Vehicles.topic b/documentation/topics/generated/Tag_Vehicles.topic new file mode 100644 index 00000000..100f8305 --- /dev/null +++ b/documentation/topics/generated/Tag_Vehicles.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Vehicles_Page_1.topic b/documentation/topics/generated/Tag_Vehicles_Page_1.topic new file mode 100644 index 00000000..c16c8674 --- /dev/null +++ b/documentation/topics/generated/Tag_Vehicles_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/Tag_Worker.topic b/documentation/topics/generated/Tag_Worker.topic new file mode 100644 index 00000000..fc994fdb --- /dev/null +++ b/documentation/topics/generated/Tag_Worker.topic @@ -0,0 +1,10 @@ + + + + + +

Endpoints in this section are generated from openapi.yaml.

+
diff --git a/documentation/topics/generated/Tag_Worker_Page_1.topic b/documentation/topics/generated/Tag_Worker_Page_1.topic new file mode 100644 index 00000000..a55f2054 --- /dev/null +++ b/documentation/topics/generated/Tag_Worker_Page_1.topic @@ -0,0 +1,10 @@ + + + + + +

This page groups endpoint topics for this object type.

+
diff --git a/documentation/topics/generated/addBrandingOption.topic b/documentation/topics/generated/addBrandingOption.topic new file mode 100644 index 00000000..ff33b57d --- /dev/null +++ b/documentation/topics/generated/addBrandingOption.topic @@ -0,0 +1,60 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /branding + + +

Operation ID: addBrandingOption

+

Create a new branding option

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "cvr": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "description", + "cvr" + ], + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
200Branding option added successfullyapplication/json
403
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addButtonPress.topic b/documentation/topics/generated/addButtonPress.topic new file mode 100644 index 00000000..48b2acb7 --- /dev/null +++ b/documentation/topics/generated/addButtonPress.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /relay/button/press/post + + +

Operation ID: addButtonPress

+

Record machine start button press webhook

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
tokenquerynostring
lane_idquerynointeger
regquerynostring
+
+ + + + + +
StatusDescriptionContent Types
201Button press recorded and linked to a self-serve wash sessionapplication/json
404
+

Schema for response 201 (application/json):

+ +{ + "$ref": "#/components/schemas/MachineButtonPressWebhookResponse" +} + +
+
diff --git a/documentation/topics/generated/addButtonPressPost.topic b/documentation/topics/generated/addButtonPressPost.topic new file mode 100644 index 00000000..d7317bfb --- /dev/null +++ b/documentation/topics/generated/addButtonPressPost.topic @@ -0,0 +1,57 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /relay/button/press/post + + +

Operation ID: addButtonPressPost

+

Record machine start button press webhook

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "properties": { + "lane_id": { + "type": "integer" + }, + "reg": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
201Button press recorded and linked to a self-serve wash sessionapplication/json
404
+

Schema for response 201 (application/json):

+ +{ + "$ref": "#/components/schemas/MachineButtonPressWebhookResponse" +} + +
+
diff --git a/documentation/topics/generated/addCustomerAttribute.topic b/documentation/topics/generated/addCustomerAttribute.topic new file mode 100644 index 00000000..d394a7b7 --- /dev/null +++ b/documentation/topics/generated/addCustomerAttribute.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /customer/attributes + + +

Operation ID: addCustomerAttribute

+

Add a custom attribute to a customer

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
201Customer attribute added successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addCustomerCode.topic b/documentation/topics/generated/addCustomerCode.topic new file mode 100644 index 00000000..831f803e --- /dev/null +++ b/documentation/topics/generated/addCustomerCode.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /admin/customer/code + + +

Operation ID: addCustomerCode

+

Add customer code

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "code": { + "type": "string" + }, + "customer_number": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + }, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addCustomerDefaultDepartment.topic b/documentation/topics/generated/addCustomerDefaultDepartment.topic new file mode 100644 index 00000000..d9c5c684 --- /dev/null +++ b/documentation/topics/generated/addCustomerDefaultDepartment.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /customer/department/default + + +

Operation ID: addCustomerDefaultDepartment

+

Add customer default department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "customer_number": { + "type": "integer" + }, + "department": { + "type": "integer" + } + }, + "required": [ + "department" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addCustomerFixedPricing.topic b/documentation/topics/generated/addCustomerFixedPricing.topic new file mode 100644 index 00000000..58909b0c --- /dev/null +++ b/documentation/topics/generated/addCustomerFixedPricing.topic @@ -0,0 +1,59 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /customer/pricing/fixed + + +

Operation ID: addCustomerFixedPricing

+

Add customer fixed pricing

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "customer_number": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "price": { + "type": "integer" + } + }, + "required": [ + "customer_number", + "price", + "description" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addCustomerNote.topic b/documentation/topics/generated/addCustomerNote.topic new file mode 100644 index 00000000..eeb8603b --- /dev/null +++ b/documentation/topics/generated/addCustomerNote.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /customer/notes + + +

Operation ID: addCustomerNote

+

Add a note to a customer

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
201Customer note added successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addDailyReport.topic b/documentation/topics/generated/addDailyReport.topic new file mode 100644 index 00000000..f4b6fcbf --- /dev/null +++ b/documentation/topics/generated/addDailyReport.topic @@ -0,0 +1,59 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /departments/daily-reports + + +

Operation ID: addDailyReport

+

Add daily report

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "date": { + "type": "string" + }, + "department_id": { + "type": "integer" + }, + "report": { + "type": "string" + } + }, + "required": [ + "department_id", + "date", + "report" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addDepartmentCategory.topic b/documentation/topics/generated/addDepartmentCategory.topic new file mode 100644 index 00000000..671afdcf --- /dev/null +++ b/documentation/topics/generated/addDepartmentCategory.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /departments/categories + + +

Operation ID: addDepartmentCategory

+

Associate a product category with a department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "category_id": { + "type": "integer" + }, + "department_id": { + "type": "integer" + } + }, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
201Category added to department successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addOrderItem.topic b/documentation/topics/generated/addOrderItem.topic new file mode 100644 index 00000000..5354b6a0 --- /dev/null +++ b/documentation/topics/generated/addOrderItem.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /order/items + + +

Operation ID: addOrderItem

+

Add a new item to an existing order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/OrderItemCreate" +} + +
+ + + + + +
StatusDescriptionContent Types
201Order item added successfullyapplication/json
400
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addPlateScanner.topic b/documentation/topics/generated/addPlateScanner.topic new file mode 100644 index 00000000..0cd2297c --- /dev/null +++ b/documentation/topics/generated/addPlateScanner.topic @@ -0,0 +1,59 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /numberplatescanners + + +

Operation ID: addPlateScanner

+

Add plate scanner

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "department_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string" + } + }, + "required": [ + "department_id", + "name", + "notes" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
201Plate scanner added successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addRole.topic b/documentation/topics/generated/addRole.topic new file mode 100644 index 00000000..0d318200 --- /dev/null +++ b/documentation/topics/generated/addRole.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /roles + + +

Operation ID: addRole

+

Add role

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addRolePermission.topic b/documentation/topics/generated/addRolePermission.topic new file mode 100644 index 00000000..80ab4f9a --- /dev/null +++ b/documentation/topics/generated/addRolePermission.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /roles/permissions + + +

Operation ID: addRolePermission

+

Add permission to role

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "permission": { + "type": "string" + }, + "role_id": { + "type": "integer" + } + }, + "required": [ + "role_id", + "permission" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/addSelfserveCondition.topic b/documentation/topics/generated/addSelfserveCondition.topic new file mode 100644 index 00000000..16c9b58f --- /dev/null +++ b/documentation/topics/generated/addSelfserveCondition.topic @@ -0,0 +1,79 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/selfserve/conditions + + +

Operation ID: addSelfserveCondition

+

Add a new self-serve condition. Either provide a reusable machine_type_id or a legacy department/lane/product scope.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "default": 0, + "type": "integer" + }, + "description": { + "type": "string" + }, + "lane": { + "default": 0, + "type": "integer" + }, + "machine_type_id": { + "nullable": true, + "type": "integer" + }, + "name": { + "type": "string" + }, + "product": { + "default": 0, + "type": "integer" + } + }, + "required": [ + "name", + "description" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Successfully added conditionapplication/json
400
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveCondition" +} + +
+
diff --git a/documentation/topics/generated/addSelfserveConditionRule.topic b/documentation/topics/generated/addSelfserveConditionRule.topic new file mode 100644 index 00000000..e2d33b32 --- /dev/null +++ b/documentation/topics/generated/addSelfserveConditionRule.topic @@ -0,0 +1,75 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/selfserve/condition/rules + + +

Operation ID: addSelfserveConditionRule

+

Add a new self-serve condition rule.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "condition_id": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "object_type": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "condition_id", + "type", + "object_type", + "object_id", + "name", + "description" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Successfully added condition ruleapplication/json
400
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveConditionRule" +} + +
+
diff --git a/documentation/topics/generated/addSelfserveMachineType.topic b/documentation/topics/generated/addSelfserveMachineType.topic new file mode 100644 index 00000000..8157adf2 --- /dev/null +++ b/documentation/topics/generated/addSelfserveMachineType.topic @@ -0,0 +1,57 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/selfserve/machine-types + + +

Operation ID: addSelfserveMachineType

+

Add reusable self-serve machine type

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "description": { + "nullable": true, + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successfully added machine typeapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SelfserveMachineType" +} + +
+
diff --git a/documentation/topics/generated/addSelfserveQuestion.topic b/documentation/topics/generated/addSelfserveQuestion.topic new file mode 100644 index 00000000..7a785461 --- /dev/null +++ b/documentation/topics/generated/addSelfserveQuestion.topic @@ -0,0 +1,79 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/selfserve/questions + + +

Operation ID: addSelfserveQuestion

+

Add a new self-serve question. Questions are typically shared across departments and lanes by omitting department, lane, and product, which default to 0.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "default": 0, + "type": "integer" + }, + "description": { + "type": "string" + }, + "lane": { + "default": 0, + "type": "integer" + }, + "order_priority": { + "default": 0, + "type": "integer" + }, + "product": { + "default": 0, + "type": "integer" + }, + "question": { + "type": "string" + } + }, + "required": [ + "question", + "description" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Successfully added questionapplication/json
400
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveQuestion" +} + +
+
diff --git a/documentation/topics/generated/addSelfserveTask.topic b/documentation/topics/generated/addSelfserveTask.topic new file mode 100644 index 00000000..8dcd2bcb --- /dev/null +++ b/documentation/topics/generated/addSelfserveTask.topic @@ -0,0 +1,103 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/selfserve/tasks + + +

Operation ID: addSelfserveTask

+

Add a new self-serve task. Either provide a reusable machine_type_id or a legacy department/lane/product scope.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "buttons": { + "default": [], + "description": "Optional dynamic image button IDs enabled by this task.", + "items": { + "type": "integer" + }, + "type": "array" + }, + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "default": 0, + "type": "integer" + }, + "description": { + "type": "string" + }, + "dynamic_images_vehicle_type": { + "description": "Optional vehicle type selection override for the machine UI. Integer >= 0 or null.", + "nullable": true, + "type": "integer" + }, + "lane": { + "default": 0, + "type": "integer" + }, + "machine_type_id": { + "nullable": true, + "type": "integer" + }, + "order_priority": { + "default": 0, + "type": "integer" + }, + "product": { + "default": 0, + "type": "integer" + }, + "services": { + "description": "Optional services enabled by this task. Items must be valid service enum names.", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + }, + "type": "array" + }, + "task": { + "type": "string" + } + }, + "required": [ + "task", + "description" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Successfully added taskapplication/json
400
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveTask" +} + +
+
diff --git a/documentation/topics/generated/addSelfserveVehicleCondition.topic b/documentation/topics/generated/addSelfserveVehicleCondition.topic new file mode 100644 index 00000000..bcc83594 --- /dev/null +++ b/documentation/topics/generated/addSelfserveVehicleCondition.topic @@ -0,0 +1,74 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/selfserve/vehicle/conditions + + +

Operation ID: addSelfserveVehicleCondition

+

Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "customer_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "question": { + "type": "integer" + }, + "reg": { + "type": "string" + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "department", + "lane", + "reg", + "question" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Successfully added vehicle conditionapplication/json
400
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse" +} + +
+
diff --git a/documentation/topics/generated/addVehicle.topic b/documentation/topics/generated/addVehicle.topic new file mode 100644 index 00000000..06648d95 --- /dev/null +++ b/documentation/topics/generated/addVehicle.topic @@ -0,0 +1,85 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /vehicles + + +

Operation ID: addVehicle

+

Create a new vehicle for a customer. + +Permissions: +- Own scope: `add_vehicle` (linked to subuser node `VEHICLES_ADD`). +- Broader scope: `add_vehicle_other`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "customer_id": { + "description": "Optional explicit target customer. Defaults to the effective customer context.", + "type": "integer" + }, + "reference": { + "maxLength": 255, + "nullable": true, + "type": "string" + }, + "reg": { + "description": "Vehicle registration number", + "maxLength": 12, + "minLength": 2, + "type": "string" + }, + "type": { + "description": "Product ID representing the vehicle wash type", + "type": "integer" + }, + "wash_subscription": { + "type": "boolean" + } + }, + "required": [ + "reg", + "type", + "wash_subscription" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Vehicle createdapplication/json
400
403
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/adminDeleteBooking.topic b/documentation/topics/generated/adminDeleteBooking.topic new file mode 100644 index 00000000..aac95d8d --- /dev/null +++ b/documentation/topics/generated/adminDeleteBooking.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /admin/bookings/delete + + +

Operation ID: adminDeleteBooking

+

Delete booking (admin)

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/birdCreateFlashCall.topic b/documentation/topics/generated/birdCreateFlashCall.topic new file mode 100644 index 00000000..ca92902d --- /dev/null +++ b/documentation/topics/generated/birdCreateFlashCall.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /bird/voice/flash-calls + + +

Operation ID: birdCreateFlashCall

+

Create/place a flash call via Bird

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "additionalProperties": true, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Flash call createdapplication/json
+

Schema for response 200 (application/json):

+ +{ + "additionalProperties": true, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/birdCreateVoiceCall.topic b/documentation/topics/generated/birdCreateVoiceCall.topic new file mode 100644 index 00000000..e6e1aa18 --- /dev/null +++ b/documentation/topics/generated/birdCreateVoiceCall.topic @@ -0,0 +1,65 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /bird/voice/calls + + +

Operation ID: birdCreateVoiceCall

+

Create/place a voice call via Bird

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "additionalProperties": true, + "properties": { + "from": { + "description": "E.164 phone number of the caller (sender)", + "example": "+4599988877", + "type": "string" + }, + "to": { + "description": "E.164 phone number of the callee", + "example": "+4511122233", + "type": "string" + } + }, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Call createdapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" +} + +
+
diff --git a/documentation/topics/generated/birdDeleteNumber.topic b/documentation/topics/generated/birdDeleteNumber.topic new file mode 100644 index 00000000..c2e46149 --- /dev/null +++ b/documentation/topics/generated/birdDeleteNumber.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /bird/numbers/{id} + + +

Operation ID: birdDeleteNumber

+

Delete/release a number by ID

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (optional if configured)
idpathyesstring
+
+ + + + +
StatusDescriptionContent Types
200Number deletion/release acceptedapplication/json
+

Schema for response 200 (application/json):

+ +{ + "additionalProperties": true, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/birdEndFlashCall.topic b/documentation/topics/generated/birdEndFlashCall.topic new file mode 100644 index 00000000..4065399c --- /dev/null +++ b/documentation/topics/generated/birdEndFlashCall.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /bird/voice/flash-calls/{id} + + +

Operation ID: birdEndFlashCall

+

Posts a completion/update payload to the flash call resource to finalize verification.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
idpathyesstring
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "additionalProperties": true, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Flash call completedapplication/json
+

Schema for response 200 (application/json):

+ +{ + "additionalProperties": true, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/birdEndFlashCallByNumbers.topic b/documentation/topics/generated/birdEndFlashCallByNumbers.topic new file mode 100644 index 00000000..119da6d9 --- /dev/null +++ b/documentation/topics/generated/birdEndFlashCallByNumbers.topic @@ -0,0 +1,66 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /bird/voice/flash-calls/end + + +

Operation ID: birdEndFlashCallByNumbers

+

Ends an ongoing flash call by specifying the originating and destination numbers.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "additionalProperties": true, + "properties": { + "from": { + "description": "E.164 formatted caller number", + "example": "+4599988877", + "type": "string" + }, + "to": { + "description": "E.164 formatted callee number", + "example": "+4511122233", + "type": "string" + } + }, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Flash call completed (by numbers)application/json
+

Schema for response 200 (application/json):

+ +{ + "additionalProperties": true, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/birdGetFlashCall.topic b/documentation/topics/generated/birdGetFlashCall.topic new file mode 100644 index 00000000..caf22d68 --- /dev/null +++ b/documentation/topics/generated/birdGetFlashCall.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /bird/voice/flash-calls/{id} + + +

Operation ID: birdGetFlashCall

+

Get a flash call by ID

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
idpathyesstring
+
+ + + + +
StatusDescriptionContent Types
200Flash call detailsapplication/json
+

Schema for response 200 (application/json):

+ +{ + "additionalProperties": true, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/birdGetNumber.topic b/documentation/topics/generated/birdGetNumber.topic new file mode 100644 index 00000000..4bea5863 --- /dev/null +++ b/documentation/topics/generated/birdGetNumber.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /bird/numbers/{id} + + +

Operation ID: birdGetNumber

+

Get a number by ID

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (optional if configured)
idpathyesstring
+
+ + + + +
StatusDescriptionContent Types
200Number detailsapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BirdNumberSingleResponse" +} + +
+
diff --git a/documentation/topics/generated/birdGetVoiceCall.topic b/documentation/topics/generated/birdGetVoiceCall.topic new file mode 100644 index 00000000..68f5d626 --- /dev/null +++ b/documentation/topics/generated/birdGetVoiceCall.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /bird/voice/calls/{id} + + +

Operation ID: birdGetVoiceCall

+

Get a voice call by ID

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
idpathyesstring
+
+ + + + +
StatusDescriptionContent Types
200Call detailsapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" +} + +
+
diff --git a/documentation/topics/generated/birdHangupVoiceCall.topic b/documentation/topics/generated/birdHangupVoiceCall.topic new file mode 100644 index 00000000..419d7eb2 --- /dev/null +++ b/documentation/topics/generated/birdHangupVoiceCall.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /bird/voice/calls/{id}/hangup + + +

Operation ID: birdHangupVoiceCall

+

Hang up a voice call by ID

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
idpathyesstring
+
+ + + + +
StatusDescriptionContent Types
200Hangup requestedapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" +} + +
+
diff --git a/documentation/topics/generated/birdListFlashCalls.topic b/documentation/topics/generated/birdListFlashCalls.topic new file mode 100644 index 00000000..277bf6b5 --- /dev/null +++ b/documentation/topics/generated/birdListFlashCalls.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /bird/voice/flash-calls + + +

Operation ID: birdListFlashCalls

+

List flash calls

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
pagequerynointeger
+
+ + + + +
StatusDescriptionContent Types
200A list of flash callsapplication/json
+

Schema for response 200 (application/json):

+ +{ + "additionalProperties": true, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/birdListNumbers.topic b/documentation/topics/generated/birdListNumbers.topic new file mode 100644 index 00000000..811b45aa --- /dev/null +++ b/documentation/topics/generated/birdListNumbers.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /bird/numbers + + +

Operation ID: birdListNumbers

+

List your numbers

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (optional if configured)
pagequerynointeger
limitquerynointeger
+
+ + + + +
StatusDescriptionContent Types
200A list of numbersapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BirdNumberListResponse" +} + +
+
diff --git a/documentation/topics/generated/birdListVoiceCalls.topic b/documentation/topics/generated/birdListVoiceCalls.topic new file mode 100644 index 00000000..2b6ca35a --- /dev/null +++ b/documentation/topics/generated/birdListVoiceCalls.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /bird/voice/calls + + +

Operation ID: birdListVoiceCalls

+

List voice calls

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
pagequerynointeger
+
+ + + + +
StatusDescriptionContent Types
200A list of callsapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BirdVoiceCallListResponse" +} + +
+
diff --git a/documentation/topics/generated/birdSayOnVoiceCall.topic b/documentation/topics/generated/birdSayOnVoiceCall.topic new file mode 100644 index 00000000..381abe3e --- /dev/null +++ b/documentation/topics/generated/birdSayOnVoiceCall.topic @@ -0,0 +1,88 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /bird/voice/calls/{id}/say + + +

Operation ID: birdSayOnVoiceCall

+

Say a message on an active voice call and hang up afterwards

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
idpathyesstringCall identifier
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "hangup": { + "description": "Whether to hang up the call after the message finishes playing (defaults to true)", + "example": true, + "type": "boolean" + }, + "locale": { + "description": "The locale to use for the TTS voice (e.g. en-US)", + "example": "en-US", + "type": "string" + }, + "loop": { + "description": "Number of times to loop the message", + "example": 1, + "type": "integer" + }, + "text": { + "description": "The text message to play via TTS", + "example": "The gate will open shortly.", + "type": "string" + }, + "timeout": { + "description": "Timeout in seconds for the TTS action", + "example": 1, + "type": "integer" + }, + "voice": { + "description": "The voice identifier to use", + "example": "male", + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200TTS action requestedapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BirdVoiceCallSingleResponse" +} + +
+
diff --git a/documentation/topics/generated/birdTestOutboundVoiceCall.topic b/documentation/topics/generated/birdTestOutboundVoiceCall.topic new file mode 100644 index 00000000..20c7e7df --- /dev/null +++ b/documentation/topics/generated/birdTestOutboundVoiceCall.topic @@ -0,0 +1,76 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /bird/voice/calls/test-outbound + + +

Operation ID: birdTestOutboundVoiceCall

+

Calls +45 42 33 11 28 and hangs up when the call reaches accepted/ongoing state.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
workspaceIdquerynostringBird Workspace identifier (falls back to module configuration if omitted)
channelIdquerynostringBird Channel identifier (falls back to module configuration if omitted)
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "additionalProperties": true, + "properties": { + "from": { + "description": "Caller E.164 number to use for the test call", + "example": "+4599988877", + "type": "string" + }, + "hangupCause": { + "description": "Optional hangup cause passed through to Bird", + "type": "string" + }, + "maxPollSeconds": { + "description": "Max time to wait before timing out", + "example": 30, + "minimum": 5, + "type": "integer" + }, + "pollIntervalSeconds": { + "description": "Poll interval while waiting for accepted status", + "example": 2, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Test call created and either hung up or timed outapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BirdTestOutboundCallResponse" +} + +
+
diff --git a/documentation/topics/generated/captureStripePaymentIntent.topic b/documentation/topics/generated/captureStripePaymentIntent.topic new file mode 100644 index 00000000..98029a55 --- /dev/null +++ b/documentation/topics/generated/captureStripePaymentIntent.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /orders/module/stripe/payment_intent/capture + + +

Operation ID: captureStripePaymentIntent

+

Capture Stripe payment intent

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/checkEconomicCustomerExists.topic b/documentation/topics/generated/checkEconomicCustomerExists.topic new file mode 100644 index 00000000..cde2df37 --- /dev/null +++ b/documentation/topics/generated/checkEconomicCustomerExists.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /economic/doesCustomerExist + + +

Operation ID: checkEconomicCustomerExists

+

Check if customer exists in e-conomic

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
cvrqueryyesstring
+
+ + + + + +
StatusDescriptionContent Types
200Customer check completedapplication/json
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/clearSystemSearchCache.topic b/documentation/topics/generated/clearSystemSearchCache.topic new file mode 100644 index 00000000..9c9c72b8 --- /dev/null +++ b/documentation/topics/generated/clearSystemSearchCache.topic @@ -0,0 +1,38 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /superuser/search/system/cache + + +

Operation ID: clearSystemSearchCache

+

Clears both query-result cache and intent-parser cache namespaces for system-wide search.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
StatusDescriptionContent Types
200Cache cleared successfullyapplication/json
401
403
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SystemSearchCacheClearResponse" +} + +
+
diff --git a/documentation/topics/generated/cloneRole.topic b/documentation/topics/generated/cloneRole.topic new file mode 100644 index 00000000..28e7f369 --- /dev/null +++ b/documentation/topics/generated/cloneRole.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /roles/clone + + +

Operation ID: cloneRole

+

Clone role

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "name": { + "type": "string" + }, + "role_id": { + "type": "integer" + } + }, + "required": [ + "role_id", + "name" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/closeDraftInvoice.topic b/documentation/topics/generated/closeDraftInvoice.topic new file mode 100644 index 00000000..c48f3b07 --- /dev/null +++ b/documentation/topics/generated/closeDraftInvoice.topic @@ -0,0 +1,48 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /invoices/draft/close + + +

Operation ID: closeDraftInvoice

+

Close a draft invoice

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + } + }, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Draft invoice closed successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/compareCollectedInvoiceEconomic.topic b/documentation/topics/generated/compareCollectedInvoiceEconomic.topic new file mode 100644 index 00000000..5c599827 --- /dev/null +++ b/documentation/topics/generated/compareCollectedInvoiceEconomic.topic @@ -0,0 +1,49 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /collected-invoices/economic/compare + + +

Operation ID: compareCollectedInvoiceEconomic

+

Compares a collected invoice in the system with its corresponding invoice in E-conomic. +Returns totals from both sources, their difference, and any warnings detected during comparison. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
collected_invoice_idqueryyesintegerThe internal collected invoice ID to compare
+
+ + + + + + + + + +
StatusDescriptionContent Types
200Comparison completed successfullyapplication/json
400
401
403
404
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicCompareResponse" +} + +
+
diff --git a/documentation/topics/generated/compareCollectedInvoiceEconomicV2.topic b/documentation/topics/generated/compareCollectedInvoiceEconomicV2.topic new file mode 100644 index 00000000..85317628 --- /dev/null +++ b/documentation/topics/generated/compareCollectedInvoiceEconomicV2.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /collected-invoices/economic/v2/compare + + +

Operation ID: compareCollectedInvoiceEconomicV2

+

Compare internal invoice with draft/booked (V2)

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
collected_invoice_idqueryyesinteger
+
+ + + + + + + + + +
StatusDescriptionContent Types
200Comparison completedapplication/json
400
401
403
404
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2CompareResponse" +} + +
+
diff --git a/documentation/topics/generated/compareCollectedInvoiceEconomicV2Bulk.topic b/documentation/topics/generated/compareCollectedInvoiceEconomicV2Bulk.topic new file mode 100644 index 00000000..006c95d9 --- /dev/null +++ b/documentation/topics/generated/compareCollectedInvoiceEconomicV2Bulk.topic @@ -0,0 +1,63 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /collected-invoices/economic/v2/compare/bulk + + +

Operation ID: compareCollectedInvoiceEconomicV2Bulk

+

Bulk compare collected invoices against draft/booked (V2)

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "collected_invoice_ids": { + "items": { + "minimum": 1, + "type": "integer" + }, + "maxItems": 200, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "collected_invoice_ids" + ], + "type": "object" +} + +
+ + + + + + + + +
StatusDescriptionContent Types
200Bulk comparison completedapplication/json
400
401
403
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2CompareBulkResponse" +} + +
+
diff --git a/documentation/topics/generated/completeOrderBooking.topic b/documentation/topics/generated/completeOrderBooking.topic new file mode 100644 index 00000000..120f2773 --- /dev/null +++ b/documentation/topics/generated/completeOrderBooking.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /order-bookings/complete + + +

Operation ID: completeOrderBooking

+

Complete order booking

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + }, + "safety_seal": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/completeSubuserSetup.topic b/documentation/topics/generated/completeSubuserSetup.topic new file mode 100644 index 00000000..c02cbf38 --- /dev/null +++ b/documentation/topics/generated/completeSubuserSetup.topic @@ -0,0 +1,83 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /subusers/setup + + +

Operation ID: completeSubuserSetup

+

Completes subuser setup by setting a password and basic profile fields. Accepts optional +`username` and `email`. +

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "email": { + "format": "email", + "maxLength": 255, + "minLength": 3, + "type": "string" + }, + "name": { + "maxLength": 255, + "minLength": 3, + "type": "string" + }, + "password": { + "description": "Must include at least one uppercase letter, one lowercase letter, and one number", + "format": "password", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\\\d).+$", + "type": "string" + }, + "token": { + "description": "One-time setup token", + "type": "string" + }, + "username": { + "maxLength": 255, + "minLength": 3, + "type": "string" + } + }, + "required": [ + "token", + "password", + "name" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Setup completedapplication/json
400
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "message": { + "example": "Password set successfully", + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/completeWashWithoutWashCertificate.topic b/documentation/topics/generated/completeWashWithoutWashCertificate.topic new file mode 100644 index 00000000..b3ab8de1 --- /dev/null +++ b/documentation/topics/generated/completeWashWithoutWashCertificate.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /admin/bookings/completeWashWithoutWashCertificate + + +

Operation ID: completeWashWithoutWashCertificate

+

Complete wash without wash certificate

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createBackupModule.topic b/documentation/topics/generated/createBackupModule.topic new file mode 100644 index 00000000..762bd287 --- /dev/null +++ b/documentation/topics/generated/createBackupModule.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /modules/backup/backups + + +

Operation ID: createBackupModule

+

Create backup module

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createCategory.topic b/documentation/topics/generated/createCategory.topic new file mode 100644 index 00000000..adfc0ded --- /dev/null +++ b/documentation/topics/generated/createCategory.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /categories + + +

Operation ID: createCategory

+

Create a new product category

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/CategoryCreate" +} + +
+ + + + +
StatusDescriptionContent Types
201Category created successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createCollectedInvoice.topic b/documentation/topics/generated/createCollectedInvoice.topic new file mode 100644 index 00000000..ee6dd4ba --- /dev/null +++ b/documentation/topics/generated/createCollectedInvoice.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /collected-invoices + + +

Operation ID: createCollectedInvoice

+

Create a new collected invoice

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
201Collected invoice created successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createDepartment.topic b/documentation/topics/generated/createDepartment.topic new file mode 100644 index 00000000..308861b5 --- /dev/null +++ b/documentation/topics/generated/createDepartment.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /departments + + +

Operation ID: createDepartment

+

Create a new department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentCreate" +} + +
+ + + + + +
StatusDescriptionContent Types
201Department created successfullyapplication/json
400
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createDepartmentGate.topic b/documentation/topics/generated/createDepartmentGate.topic new file mode 100644 index 00000000..d071b5b0 --- /dev/null +++ b/documentation/topics/generated/createDepartmentGate.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/gates + + +

Operation ID: createDepartmentGate

+

Create department gate

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentGateCreate" +} + +
+ + + + +
StatusDescriptionContent Types
201Department gate created successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentGate" +} + +
+
diff --git a/documentation/topics/generated/createDepartmentGoal.topic b/documentation/topics/generated/createDepartmentGoal.topic new file mode 100644 index 00000000..ff1fe33d --- /dev/null +++ b/documentation/topics/generated/createDepartmentGoal.topic @@ -0,0 +1,52 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /goals/department + + +

Operation ID: createDepartmentGoal

+

Create a new department goal. + +Access control: +- The provided `departments` must be a subset of the user's departments unless the user has `superuser`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentGoalCreate" +} + +
+ + + + + + + +
StatusDescriptionContent Types
201Department goal created successfullyapplication/json
400
401
403
+

Schema for response 201 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentGoal" +} + +
+
diff --git a/documentation/topics/generated/createDepartmentLane.topic b/documentation/topics/generated/createDepartmentLane.topic new file mode 100644 index 00000000..5710a15a --- /dev/null +++ b/documentation/topics/generated/createDepartmentLane.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/lanes + + +

Operation ID: createDepartmentLane

+

Create a new department lane

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentLaneCreate" +} + +
+ + + + + +
StatusDescriptionContent Types
201Department lane created successfullyapplication/json
400
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createDepartmentRelay.topic b/documentation/topics/generated/createDepartmentRelay.topic new file mode 100644 index 00000000..c671868a --- /dev/null +++ b/documentation/topics/generated/createDepartmentRelay.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/relays + + +

Operation ID: createDepartmentRelay

+

Create department relay

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentRelayCreate" +} + +
+ + + + +
StatusDescriptionContent Types
201Department relay created successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentRelay" +} + +
+
diff --git a/documentation/topics/generated/createEconomicCustomer.topic b/documentation/topics/generated/createEconomicCustomer.topic new file mode 100644 index 00000000..6655427d --- /dev/null +++ b/documentation/topics/generated/createEconomicCustomer.topic @@ -0,0 +1,67 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /modules/economic/customer + + +

Operation ID: createEconomicCustomer

+

Create e-conomic customer

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "customer_number": { + "type": "integer" + }, + "cvr": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "phone": { + "type": "integer" + } + }, + "required": [ + "customer_number", + "cvr", + "email", + "phone", + "name" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createNotification.topic b/documentation/topics/generated/createNotification.topic new file mode 100644 index 00000000..da2149df --- /dev/null +++ b/documentation/topics/generated/createNotification.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /notifications + + +

Operation ID: createNotification

+

Create a new notification

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/NotificationCreate" +} + +
+ + + + +
StatusDescriptionContent Types
201Notification created successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createOrder.topic b/documentation/topics/generated/createOrder.topic new file mode 100644 index 00000000..d3de5140 --- /dev/null +++ b/documentation/topics/generated/createOrder.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /orders + + +

Operation ID: createOrder

+

Create a new wash order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/OrderCreate" +} + +
+ + + + + + +
StatusDescriptionContent Types
201Order created successfullyapplication/json
400
401
+

Schema for response 201 (application/json):

+ +{ + "$ref": "#/components/schemas/Order" +} + +
+
diff --git a/documentation/topics/generated/createPasskey.topic b/documentation/topics/generated/createPasskey.topic new file mode 100644 index 00000000..60c412aa --- /dev/null +++ b/documentation/topics/generated/createPasskey.topic @@ -0,0 +1,57 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /account/security/passkeys + + +

Operation ID: createPasskey

+

Create/add a passkey for the authenticated user

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/PasskeyCreateRequest" +} + +
+ + + + + +
StatusDescriptionContent Types
200Passkey createdapplication/json
400Invalid session or requestapplication/json
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "id": { + "type": "integer" + } + }, + "type": "object" +} + +

Schema for response 400 (application/json):

+ +{ + "$ref": "#/components/schemas/Error" +} + +
+
diff --git a/documentation/topics/generated/createProduct.topic b/documentation/topics/generated/createProduct.topic new file mode 100644 index 00000000..ff66a254 --- /dev/null +++ b/documentation/topics/generated/createProduct.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /products + + +

Operation ID: createProduct

+

Create a new product

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/ProductCreate" +} + +
+ + + + + +
StatusDescriptionContent Types
201Product created successfullyapplication/json
400
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createStripeInvoice.topic b/documentation/topics/generated/createStripeInvoice.topic new file mode 100644 index 00000000..d1dde192 --- /dev/null +++ b/documentation/topics/generated/createStripeInvoice.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /modules/stripe/invoice + + +

Operation ID: createStripeInvoice

+

Create an invoice in Stripe

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
201Stripe invoice created successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createStripePaymentIntent.topic b/documentation/topics/generated/createStripePaymentIntent.topic new file mode 100644 index 00000000..397e461a --- /dev/null +++ b/documentation/topics/generated/createStripePaymentIntent.topic @@ -0,0 +1,58 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /orders/module/stripe/payment_intent + + +

Operation ID: createStripePaymentIntent

+

Create Stripe payment intent

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + }, + "reader": { + "type": "string" + }, + "tax_percentage": { + "type": "integer" + } + }, + "required": [ + "id", + "reader" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/createSubuser.topic b/documentation/topics/generated/createSubuser.topic new file mode 100644 index 00000000..df6fd5a6 --- /dev/null +++ b/documentation/topics/generated/createSubuser.topic @@ -0,0 +1,78 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /subusers/me + + +

Operation ID: createSubuser

+

Creates a subuser (driver) account using a company's CVR and a phone number. Validates the +CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled +sends a setup link by SMS for the user to complete registration. +

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "cvr": { + "description": "Danish CVR (8 digits)", + "example": 12345678, + "type": "integer" + }, + "phone": { + "description": "Phone number (4–15 digits, no leading +)", + "example": 12345678, + "type": "integer" + }, + "phone_country_code": { + "description": "Phone country code (1–3 digits)", + "example": 45, + "type": "integer" + } + }, + "required": [ + "cvr", + "phone_country_code", + "phone" + ], + "type": "object" +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Subuser created (or pending setup) and company identifiedapplication/json
400
404
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "customer_number": { + "description": "Matched e-conomic customer number", + "example": 1000, + "type": "integer" + }, + "cvr": { + "example": 12345678, + "type": "integer" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/createSubuserGrant.topic b/documentation/topics/generated/createSubuserGrant.topic new file mode 100644 index 00000000..95eedbaf --- /dev/null +++ b/documentation/topics/generated/createSubuserGrant.topic @@ -0,0 +1,53 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /subusers/grants + + +

Operation ID: createSubuserGrant

+

Create subuser grant

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/SubuserGrantCreateRequest" +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Grant createdapplication/json
400
401
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "grant": { + "$ref": "#/components/schemas/SubuserGrant" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/createUser.topic b/documentation/topics/generated/createUser.topic new file mode 100644 index 00000000..f0a386b4 --- /dev/null +++ b/documentation/topics/generated/createUser.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /users + + +

Operation ID: createUser

+

Create a new user account

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/UserCreate" +} + +
+ + + + + + +
StatusDescriptionContent Types
201User created successfullyapplication/json
400
401
+

Schema for response 201 (application/json):

+ +{ + "$ref": "#/components/schemas/User" +} + +
+
diff --git a/documentation/topics/generated/customerLogin.topic b/documentation/topics/generated/customerLogin.topic new file mode 100644 index 00000000..46a27e5c --- /dev/null +++ b/documentation/topics/generated/customerLogin.topic @@ -0,0 +1,95 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/login + + +

Operation ID: customerLogin

+

Authenticate a customer using customer number and password

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "customer_number": { + "description": "Customer's e-conomic customer number", + "example": 12345, + "type": "integer" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + }, + "password": { + "description": "Customer password", + "format": "password", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "customer_number", + "password", + "g_recaptcha_response" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Login successfulapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{ + "oneOf": [ + { + "properties": { + "token": { + "description": "Bearer authentication token", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + { + "properties": { + "2fa_required": { + "example": true, + "type": "boolean" + }, + "2fa_token": { + "description": "Temporary 2FA verification token", + "example": "557a3e7b1a2b...", + "type": "string" + } + }, + "required": [ + "2fa_required", + "2fa_token" + ], + "type": "object" + } + ] +} + +
+
diff --git a/documentation/topics/generated/debugWorker.topic b/documentation/topics/generated/debugWorker.topic new file mode 100644 index 00000000..6c2a8334 --- /dev/null +++ b/documentation/topics/generated/debugWorker.topic @@ -0,0 +1,35 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /worker/debug + + +

Operation ID: debugWorker

+

Execute debug commands on the worker (often restricted)

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
StatusDescriptionContent Types
200Debug information retrieved successfullyapplication/json
403
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteCustomerAttribute.topic b/documentation/topics/generated/deleteCustomerAttribute.topic new file mode 100644 index 00000000..f1b3f327 --- /dev/null +++ b/documentation/topics/generated/deleteCustomerAttribute.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /customer/attributes + + +

Operation ID: deleteCustomerAttribute

+

Remove a custom attribute from a customer

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Customer attribute deleted successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteCustomerDefaultDepartment.topic b/documentation/topics/generated/deleteCustomerDefaultDepartment.topic new file mode 100644 index 00000000..e44f3dc9 --- /dev/null +++ b/documentation/topics/generated/deleteCustomerDefaultDepartment.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /customer/department/default + + +

Operation ID: deleteCustomerDefaultDepartment

+

Delete customer default department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
customer_numberquerynointeger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteCustomerFixedPricing.topic b/documentation/topics/generated/deleteCustomerFixedPricing.topic new file mode 100644 index 00000000..38fffadf --- /dev/null +++ b/documentation/topics/generated/deleteCustomerFixedPricing.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /customer/pricing/fixed + + +

Operation ID: deleteCustomerFixedPricing

+

Delete customer fixed pricing

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
customer_numberqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteCustomerNote.topic b/documentation/topics/generated/deleteCustomerNote.topic new file mode 100644 index 00000000..980869e6 --- /dev/null +++ b/documentation/topics/generated/deleteCustomerNote.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /customer/notes + + +

Operation ID: deleteCustomerNote

+

Remove a note from a customer

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Customer note deleted successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteDepartmentGate.topic b/documentation/topics/generated/deleteDepartmentGate.topic new file mode 100644 index 00000000..63cf3ad9 --- /dev/null +++ b/documentation/topics/generated/deleteDepartmentGate.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /department/gates + + +

Operation ID: deleteDepartmentGate

+

Delete department gate

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Department gate deletedapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteDepartmentGoal.topic b/documentation/topics/generated/deleteDepartmentGoal.topic new file mode 100644 index 00000000..9f692677 --- /dev/null +++ b/documentation/topics/generated/deleteDepartmentGoal.topic @@ -0,0 +1,49 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /goals/department/progress-alert/test + + +

Operation ID: deleteDepartmentGoal

+

Delete a department goal by `id`. + +Access control: +- The creator (`created_by`) may delete regardless of department membership. +- Otherwise the user must satisfy the subset rule or have `superuser`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerID of the goal to delete
+
+ + + + + + + + +
StatusDescriptionContent Types
200Department goal deleted successfullyapplication/json
400
401
403
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteDepartmentRelay.topic b/documentation/topics/generated/deleteDepartmentRelay.topic new file mode 100644 index 00000000..45dc0996 --- /dev/null +++ b/documentation/topics/generated/deleteDepartmentRelay.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /department/relays + + +

Operation ID: deleteDepartmentRelay

+

Delete department relay

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Department relay deletedapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteNotification.topic b/documentation/topics/generated/deleteNotification.topic new file mode 100644 index 00000000..552a3600 --- /dev/null +++ b/documentation/topics/generated/deleteNotification.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /notifications + + +

Operation ID: deleteNotification

+

Delete a notification

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Notification deleted successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteOrder.topic b/documentation/topics/generated/deleteOrder.topic new file mode 100644 index 00000000..46c1e866 --- /dev/null +++ b/documentation/topics/generated/deleteOrder.topic @@ -0,0 +1,42 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /orders + + +

Operation ID: deleteOrder

+

Delete an existing order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + + + +
StatusDescriptionContent Types
200Order deleted successfullyapplication/json
401
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteOrderItem.topic b/documentation/topics/generated/deleteOrderItem.topic new file mode 100644 index 00000000..3e85f0e9 --- /dev/null +++ b/documentation/topics/generated/deleteOrderItem.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /order/items + + +

Operation ID: deleteOrderItem

+

Remove an item from an order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + + +
StatusDescriptionContent Types
200Order item deleted successfullyapplication/json
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteOwnBooking.topic b/documentation/topics/generated/deleteOwnBooking.topic new file mode 100644 index 00000000..b875df74 --- /dev/null +++ b/documentation/topics/generated/deleteOwnBooking.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /user/bookings/delete + + +

Operation ID: deleteOwnBooking

+

Delete own booking

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deletePasskey.topic b/documentation/topics/generated/deletePasskey.topic new file mode 100644 index 00000000..ccc2418e --- /dev/null +++ b/documentation/topics/generated/deletePasskey.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /account/security/passkeys/{id} + + +

Operation ID: deletePasskey

+

Delete a passkey

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idpathyesinteger
+
+ + + + + +
StatusDescriptionContent Types
200Passkey deletedapplication/json
404Not foundapplication/json
+

Schema for response 200 (application/json):

+ +{} + +

Schema for response 404 (application/json):

+ +{ + "$ref": "#/components/schemas/Error" +} + +
+
diff --git a/documentation/topics/generated/deleteSelfserveCondition.topic b/documentation/topics/generated/deleteSelfserveCondition.topic new file mode 100644 index 00000000..316dccc2 --- /dev/null +++ b/documentation/topics/generated/deleteSelfserveCondition.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /department/selfserve/conditions + + +

Operation ID: deleteSelfserveCondition

+

Delete a self-serve condition.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerCondition ID
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully deleted conditionapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "example": "Condition deleted", + "type": "string" +} + +
+
diff --git a/documentation/topics/generated/deleteSelfserveConditionRule.topic b/documentation/topics/generated/deleteSelfserveConditionRule.topic new file mode 100644 index 00000000..67e38bd1 --- /dev/null +++ b/documentation/topics/generated/deleteSelfserveConditionRule.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /department/selfserve/condition/rules + + +

Operation ID: deleteSelfserveConditionRule

+

Delete a self-serve condition rule.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerRule ID
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully deleted condition ruleapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "example": "Rule deleted", + "type": "string" +} + +
+
diff --git a/documentation/topics/generated/deleteSelfserveMachineType.topic b/documentation/topics/generated/deleteSelfserveMachineType.topic new file mode 100644 index 00000000..d9249f81 --- /dev/null +++ b/documentation/topics/generated/deleteSelfserveMachineType.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /department/selfserve/machine-types + + +

Operation ID: deleteSelfserveMachineType

+

Delete reusable self-serve machine type

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + + +
StatusDescriptionContent Types
200Successfully deleted machine typeapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "example": "Machine type deleted", + "type": "string" +} + +
+
diff --git a/documentation/topics/generated/deleteSelfserveQuestion.topic b/documentation/topics/generated/deleteSelfserveQuestion.topic new file mode 100644 index 00000000..a820faf4 --- /dev/null +++ b/documentation/topics/generated/deleteSelfserveQuestion.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /department/selfserve/questions + + +

Operation ID: deleteSelfserveQuestion

+

Delete a self-serve question by ID.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerQuestion ID
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully deleted questionapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "example": "Question deleted", + "type": "string" +} + +
+
diff --git a/documentation/topics/generated/deleteSelfserveTask.topic b/documentation/topics/generated/deleteSelfserveTask.topic new file mode 100644 index 00000000..2d49d53b --- /dev/null +++ b/documentation/topics/generated/deleteSelfserveTask.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /department/selfserve/tasks + + +

Operation ID: deleteSelfserveTask

+

Delete a self-serve task by ID.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerTask ID
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully deleted taskapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "example": "Task deleted", + "type": "string" +} + +
+
diff --git a/documentation/topics/generated/deleteSelfserveTaskAttachment.topic b/documentation/topics/generated/deleteSelfserveTaskAttachment.topic new file mode 100644 index 00000000..1b0b9cf5 --- /dev/null +++ b/documentation/topics/generated/deleteSelfserveTaskAttachment.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /department/selfserve/tasks/attachments + + +

Operation ID: deleteSelfserveTaskAttachment

+

Remove an attachment from a specific self-serve task.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
task_idqueryyesintegerTask ID
attachment_idqueryyesintegerAttachment ID
+
+ + + + + + +
StatusDescriptionContent Types
200Attachment deleted successfullyapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "message": { + "example": "Attachment deleted successfully", + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/deleteSelfserveVehicleCondition.topic b/documentation/topics/generated/deleteSelfserveVehicleCondition.topic new file mode 100644 index 00000000..f893940e --- /dev/null +++ b/documentation/topics/generated/deleteSelfserveVehicleCondition.topic @@ -0,0 +1,58 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /department/selfserve/vehicle/conditions + + +

Operation ID: deleteSelfserveVehicleCondition

+

Delete a vehicle condition. Customers can only delete conditions for their own vehicles.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerCondition ID
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully deleted vehicle conditionapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "message": { + "example": "Condition deleted", + "type": "string" + }, + "selfserve": { + "allOf": [ + { + "$ref": "#/components/schemas/SelfserveWashSummary" + } + ], + "nullable": true + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/deleteStripePaymentIntent.topic b/documentation/topics/generated/deleteStripePaymentIntent.topic new file mode 100644 index 00000000..9d23714a --- /dev/null +++ b/documentation/topics/generated/deleteStripePaymentIntent.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /orders/module/stripe/payment_intent + + +

Operation ID: deleteStripePaymentIntent

+

Delete Stripe payment intent

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/deleteSubuserGrant.topic b/documentation/topics/generated/deleteSubuserGrant.topic new file mode 100644 index 00000000..626a1df2 --- /dev/null +++ b/documentation/topics/generated/deleteSubuserGrant.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /subusers/grants/{id} + + +

Operation ID: deleteSubuserGrant

+

Delete subuser grant

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idpathyesinteger
+
+ + + + + + + +
StatusDescriptionContent Types
200Grant deletedapplication/json
401
404
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "message": { + "example": "Grant deleted", + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/deleteVehicle.topic b/documentation/topics/generated/deleteVehicle.topic new file mode 100644 index 00000000..e6a88c01 --- /dev/null +++ b/documentation/topics/generated/deleteVehicle.topic @@ -0,0 +1,48 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /vehicles + + +

Operation ID: deleteVehicle

+

Delete an existing vehicle. + +Permissions: +- Own scope: `delete_vehicle` (linked to subuser node `VEHICLES_DELETE`). +- Broader scope: `delete_vehicle_other`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
noobject
idqueryyesinteger
+
+ + + + + + +
StatusDescriptionContent Types
200Vehicle deletedapplication/json
403
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/disable2fa.topic b/documentation/topics/generated/disable2fa.topic new file mode 100644 index 00000000..12348e2f --- /dev/null +++ b/documentation/topics/generated/disable2fa.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/2fa/disable + + +

Operation ID: disable2fa

+

Verify a code and disable 2FA for the authenticated user/subuser

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "code": { + "description": "The 6-digit TOTP code", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
2002FA disabled successfullyapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/disableWorkerDebug.topic b/documentation/topics/generated/disableWorkerDebug.topic new file mode 100644 index 00000000..46c74ffa --- /dev/null +++ b/documentation/topics/generated/disableWorkerDebug.topic @@ -0,0 +1,35 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /worker/debug/off + + +

Operation ID: disableWorkerDebug

+

Disable worker debug

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
StatusDescriptionContent Types
200Worker debug disabledapplication/json
403
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/downloadBookingPdf.topic b/documentation/topics/generated/downloadBookingPdf.topic new file mode 100644 index 00000000..7339c910 --- /dev/null +++ b/documentation/topics/generated/downloadBookingPdf.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /bookings/download_pdf + + +

Operation ID: downloadBookingPdf

+

Download booking PDF

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/downloadOrderAttachment.topic b/documentation/topics/generated/downloadOrderAttachment.topic new file mode 100644 index 00000000..1ddf54f2 --- /dev/null +++ b/documentation/topics/generated/downloadOrderAttachment.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /orders/attachments/download + + +

Operation ID: downloadOrderAttachment

+

Download a specific order attachment

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Attachment downloaded successfullyapplication/octet-stream
+

Schema for response 200 (application/octet-stream):

+ +{ + "format": "binary", + "type": "string" +} + +
+
diff --git a/documentation/topics/generated/downloadOwnWashCertificate.topic b/documentation/topics/generated/downloadOwnWashCertificate.topic new file mode 100644 index 00000000..39905d0d --- /dev/null +++ b/documentation/topics/generated/downloadOwnWashCertificate.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /user/bookings/washcertificate/download + + +

Operation ID: downloadOwnWashCertificate

+

Get download link for own wash certificate

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/downloadSelfserveTaskAttachment.topic b/documentation/topics/generated/downloadSelfserveTaskAttachment.topic new file mode 100644 index 00000000..9511f613 --- /dev/null +++ b/documentation/topics/generated/downloadSelfserveTaskAttachment.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/tasks/attachments/download + + +

Operation ID: downloadSelfserveTaskAttachment

+

Generate a download link for a specific self-serve task attachment.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
task_idqueryyesintegerTask ID
attachment_idqueryyesintegerAttachment ID
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully generated download linkapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "download_link": { + "format": "uri", + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/editBrandingOption.topic b/documentation/topics/generated/editBrandingOption.topic new file mode 100644 index 00000000..04c8ac11 --- /dev/null +++ b/documentation/topics/generated/editBrandingOption.topic @@ -0,0 +1,61 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /branding + + +

Operation ID: editBrandingOption

+

Update an existing branding option

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "cvr": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
200Branding option updated successfullyapplication/json
403
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/editDailyReport.topic b/documentation/topics/generated/editDailyReport.topic new file mode 100644 index 00000000..7c667823 --- /dev/null +++ b/documentation/topics/generated/editDailyReport.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /departments/daily-reports + + +

Operation ID: editDailyReport

+

Edit daily report

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + }, + "report": { + "type": "string" + } + }, + "required": [ + "id", + "report" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/editRole.topic b/documentation/topics/generated/editRole.topic new file mode 100644 index 00000000..7f63dc49 --- /dev/null +++ b/documentation/topics/generated/editRole.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /roles + + +

Operation ID: editRole

+

Edit role

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/editVehicle.topic b/documentation/topics/generated/editVehicle.topic new file mode 100644 index 00000000..bdd5afc9 --- /dev/null +++ b/documentation/topics/generated/editVehicle.topic @@ -0,0 +1,81 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /vehicles + + +

Operation ID: editVehicle

+

Update fields on an existing vehicle. + +Permissions: +- Own scope: `edit_vehicle` (linked to subuser node `VEHICLES_EDIT`). +- Broader scope: `edit_vehicle_other`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + }, + "reference": { + "maxLength": 255, + "nullable": true, + "type": "string" + }, + "reg": { + "maxLength": 12, + "minLength": 2, + "type": "string" + }, + "type": { + "type": "integer" + }, + "wash_subscription": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Vehicle updatedapplication/json
400
403
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/employeeLogin.topic b/documentation/topics/generated/employeeLogin.topic new file mode 100644 index 00000000..1520e2fd --- /dev/null +++ b/documentation/topics/generated/employeeLogin.topic @@ -0,0 +1,92 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/employee/login + + +

Operation ID: employeeLogin

+

Authenticate an employee using user ID and password

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + }, + "password": { + "description": "Employee password", + "format": "password", + "type": "string" + }, + "user_id": { + "description": "Employee user ID", + "example": 1, + "type": "integer" + } + }, + "required": [ + "user_id", + "password", + "g_recaptcha_response" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Login successfulapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{ + "oneOf": [ + { + "properties": { + "token": { + "description": "Bearer authentication token", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + { + "properties": { + "2fa_required": { + "example": true, + "type": "boolean" + }, + "2fa_token": { + "description": "Temporary 2FA verification token", + "type": "string" + } + }, + "required": [ + "2fa_required", + "2fa_token" + ], + "type": "object" + } + ] +} + +
+
diff --git a/documentation/topics/generated/enable2fa.topic b/documentation/topics/generated/enable2fa.topic new file mode 100644 index 00000000..7f9b9b18 --- /dev/null +++ b/documentation/topics/generated/enable2fa.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/2fa/enable + + +

Operation ID: enable2fa

+

Verify a code and enable 2FA for the authenticated user/subuser

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "code": { + "description": "The 6-digit TOTP code", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
2002FA enabled successfullyapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/enableSelfServeLaneMachineRelay.topic b/documentation/topics/generated/enableSelfServeLaneMachineRelay.topic new file mode 100644 index 00000000..a68cabb8 --- /dev/null +++ b/documentation/topics/generated/enableSelfServeLaneMachineRelay.topic @@ -0,0 +1,83 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /modules/self-serve/lane/relay/machine/enable + + +

Operation ID: enableSelfServeLaneMachineRelay

+

Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services +include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically +enabled; an explicit call to this endpoint is required. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "duration": { + "description": "Optional number of seconds after which the relay should automatically turn off", + "type": "integer" + }, + "lane_id": { + "type": "integer" + } + }, + "required": [ + "lane_id" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200MACHINE relay enabledapplication/json
401
403Not allowed to enable MACHINE relay (no matching task currently shown)application/json
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "duration": { + "nullable": true, + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "lane_id": { + "type": "integer" + }, + "relay": { + "type": "string" + } + }, + "type": "object" +} + +

Schema for response 403 (application/json):

+ +{ + "$ref": "#/components/schemas/Error" +} + +
+
diff --git a/documentation/topics/generated/enableWorkerDebug.topic b/documentation/topics/generated/enableWorkerDebug.topic new file mode 100644 index 00000000..88ebd8d6 --- /dev/null +++ b/documentation/topics/generated/enableWorkerDebug.topic @@ -0,0 +1,35 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /worker/debug/on + + +

Operation ID: enableWorkerDebug

+

Enable worker debug

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
StatusDescriptionContent Types
200Worker debug enabledapplication/json
403
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/exportDraftInvoiceToEconomic.topic b/documentation/topics/generated/exportDraftInvoiceToEconomic.topic new file mode 100644 index 00000000..3522dbb5 --- /dev/null +++ b/documentation/topics/generated/exportDraftInvoiceToEconomic.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /economic/invoice/draft/export + + +

Operation ID: exportDraftInvoiceToEconomic

+

Export a draft invoice to e-conomic

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Draft invoice exported successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/exportInvoiceToEconomic.topic b/documentation/topics/generated/exportInvoiceToEconomic.topic new file mode 100644 index 00000000..a82deef6 --- /dev/null +++ b/documentation/topics/generated/exportInvoiceToEconomic.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /economic/invoice/export + + +

Operation ID: exportInvoiceToEconomic

+

Export a booked invoice to e-conomic

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Invoice exported successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/forceDisableSelfServeLaneMachine.topic b/documentation/topics/generated/forceDisableSelfServeLaneMachine.topic new file mode 100644 index 00000000..7e1fa2cc --- /dev/null +++ b/documentation/topics/generated/forceDisableSelfServeLaneMachine.topic @@ -0,0 +1,84 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /modules/self-serve/lane/force/machine/disable + + +

Operation ID: forceDisableSelfServeLaneMachine

+

Superuser/emergency endpoint. Turns off the MACHINE relay while ensuring the lane remains in an IN_WASH state +(simulating a started wash without machine assistance). +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "lane_id": { + "type": "integer" + }, + "license_plate": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "lane_id" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200MACHINE relay force-disabled and lane ensured in-washapplication/json
401
403
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "forced": { + "type": "boolean" + }, + "lane_id": { + "type": "integer" + }, + "machine": { + "enum": [ + "DISABLED" + ], + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "wash_start_time": { + "type": "integer" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/forceEnableSelfServeLaneMachine.topic b/documentation/topics/generated/forceEnableSelfServeLaneMachine.topic new file mode 100644 index 00000000..8e0fa83d --- /dev/null +++ b/documentation/topics/generated/forceEnableSelfServeLaneMachine.topic @@ -0,0 +1,94 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /modules/self-serve/lane/force/machine/enable + + +

Operation ID: forceEnableSelfServeLaneMachine

+

Superuser/emergency endpoint. Bypasses the allowed services gating and directly turns on the MACHINE relay. +Also ensures the lane is marked as OCCUPIED and IN_WASH with a wash start timestamp if not already set. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "duration": { + "description": "Optional number of seconds after which the relay should automatically turn off", + "nullable": true, + "type": "integer" + }, + "lane_id": { + "type": "integer" + }, + "license_plate": { + "description": "Optional license plate to associate with the lane", + "nullable": true, + "type": "string" + } + }, + "required": [ + "lane_id" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200MACHINE relay force-enabled and lane marked in-washapplication/json
401
403
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "duration": { + "nullable": true, + "type": "integer" + }, + "forced": { + "type": "boolean" + }, + "lane_id": { + "type": "integer" + }, + "machine": { + "enum": [ + "ENABLED" + ], + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "wash_start_time": { + "type": "integer" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/generateWashCertificate.topic b/documentation/topics/generated/generateWashCertificate.topic new file mode 100644 index 00000000..2d926baf --- /dev/null +++ b/documentation/topics/generated/generateWashCertificate.topic @@ -0,0 +1,49 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /order/wash-certificate + + +

Operation ID: generateWashCertificate

+

Generate a wash certificate for an order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "order_id": { + "type": "integer" + } + }, + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
200Wash certificate generated successfullyapplication/json
400
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getAllExchangeRates.topic b/documentation/topics/generated/getAllExchangeRates.topic new file mode 100644 index 00000000..72046d22 --- /dev/null +++ b/documentation/topics/generated/getAllExchangeRates.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/fxratesapi/rates + + +

Operation ID: getAllExchangeRates

+

Get all available exchange rates

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Exchange rates retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getAvailableVehicleAddons.topic b/documentation/topics/generated/getAvailableVehicleAddons.topic new file mode 100644 index 00000000..8e78a2c1 --- /dev/null +++ b/documentation/topics/generated/getAvailableVehicleAddons.topic @@ -0,0 +1,48 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /vehicles/addons/available + + +

Operation ID: getAvailableVehicleAddons

+

Get list of available addons for a vehicle. + +Permissions: +- Own scope: `list_vehicle_addon_own` (linked to subuser node `VEHICLES_LIST`). +- Broader scope: `list_vehicles_addon_other`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
noobject
idqueryyesinteger
+
+ + + + + + +
StatusDescriptionContent Types
200Available addons retrieved successfullyapplication/json
403
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getBackupsConfig.topic b/documentation/topics/generated/getBackupsConfig.topic new file mode 100644 index 00000000..4bf7609d --- /dev/null +++ b/documentation/topics/generated/getBackupsConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /backups/config + + +

Operation ID: getBackupsConfig

+

Get backups config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Backups configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BackupsConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getBirdConfig.topic b/documentation/topics/generated/getBirdConfig.topic new file mode 100644 index 00000000..f76ca4be --- /dev/null +++ b/documentation/topics/generated/getBirdConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /bird/config + + +

Operation ID: getBirdConfig

+

Get Bird config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Bird configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/BirdConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getCollectedInvoiceEconomicV2Details.topic b/documentation/topics/generated/getCollectedInvoiceEconomicV2Details.topic new file mode 100644 index 00000000..b0a7256f --- /dev/null +++ b/documentation/topics/generated/getCollectedInvoiceEconomicV2Details.topic @@ -0,0 +1,49 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /collected-invoices/economic/v2/details + + +

Operation ID: getCollectedInvoiceEconomicV2Details

+

Returns normalized internal lines and best-effort fetched draft/booked e-conomic lines +for a collected invoice, including department distributions and warnings. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
collected_invoice_idqueryyesinteger
+
+ + + + + + + + + +
StatusDescriptionContent Types
200Details resolved successfullyapplication/json
400
401
403
404
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse" +} + +
+
diff --git a/documentation/topics/generated/getCollectedInvoiceEconomicV2RevenueStatistics.topic b/documentation/topics/generated/getCollectedInvoiceEconomicV2RevenueStatistics.topic new file mode 100644 index 00000000..c4f78f25 --- /dev/null +++ b/documentation/topics/generated/getCollectedInvoiceEconomicV2RevenueStatistics.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /collected-invoices/economic/v2/revenue-statistics + + +

Operation ID: getCollectedInvoiceEconomicV2RevenueStatistics

+

Aggregates booked e-conomic revenue across invoices and lines, with optional filters +for date range, customer(s), department(s), currency, and barred-customer status. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + + + + +
NameInRequiredTypeDescription
dateFromquerynostringStart date (inclusive), defaults to first day of current month.
dateToquerynostringEnd date (inclusive), defaults to today.
customer_numbersquerynostringComma-separated customer numbers to include.
department_numbersquerynostringComma-separated department numbers to include.
currencyquerynostringRestrict to a specific invoice currency.
barredquerynostringFilter by e-conomic customer barred status.
max_pagesquerynointegerSafety cap for paginated e-conomic reads.
+
+ + + + + + + + +
StatusDescriptionContent Types
200Revenue statistics resolved successfullyapplication/json
400
401
403
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/CollectedInvoiceEconomicV2RevenueStatisticsResponse" +} + +
+
diff --git a/documentation/topics/generated/getCurrentSubuser.topic b/documentation/topics/generated/getCurrentSubuser.topic new file mode 100644 index 00000000..6a7e2caf --- /dev/null +++ b/documentation/topics/generated/getCurrentSubuser.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /subusers/me + + +

Operation ID: getCurrentSubuser

+

Returns the authenticated subuser (driver) profile and their enabled grants grouped by +`billing_customer_number`. + +Notes: +- This endpoint is available only to authenticated subuser sessions. +- It does not require the `X-Customer-Number` header; all enabled, non-deleted grants for the + subuser are included in the response. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
StatusDescriptionContent Types
200Current subuser detailsapplication/json
401
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SubuserSelf" +} + +
+
diff --git a/documentation/topics/generated/getCustomer.topic b/documentation/topics/generated/getCustomer.topic new file mode 100644 index 00000000..fa9d1bc3 --- /dev/null +++ b/documentation/topics/generated/getCustomer.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /users/customer + + +

Operation ID: getCustomer

+

Get details about a specific customer

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
customer_numberquerynointeger
+
+ + + + + +
StatusDescriptionContent Types
200Customer retrieved successfullyapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/User" +} + +
+
diff --git a/documentation/topics/generated/getCustomerAttributes.topic b/documentation/topics/generated/getCustomerAttributes.topic new file mode 100644 index 00000000..d5136c8d --- /dev/null +++ b/documentation/topics/generated/getCustomerAttributes.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /customer/attributes + + +

Operation ID: getCustomerAttributes

+

Get custom attributes for a customer

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
customer_idquerynointeger
+
+ + + + +
StatusDescriptionContent Types
200Customer attributes retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getCustomerCode.topic b/documentation/topics/generated/getCustomerCode.topic new file mode 100644 index 00000000..eae0dbcc --- /dev/null +++ b/documentation/topics/generated/getCustomerCode.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /admin/customer/code + + +

Operation ID: getCustomerCode

+

Get customer code

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
customer_numberquerynointeger
user_idquerynointeger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getCustomerDefaultDepartment.topic b/documentation/topics/generated/getCustomerDefaultDepartment.topic new file mode 100644 index 00000000..7f7ceeab --- /dev/null +++ b/documentation/topics/generated/getCustomerDefaultDepartment.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /customer/department/default + + +

Operation ID: getCustomerDefaultDepartment

+

Get customer default department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
customer_numberquerynointeger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getCustomerFixedPricing.topic b/documentation/topics/generated/getCustomerFixedPricing.topic new file mode 100644 index 00000000..bdca8d1f --- /dev/null +++ b/documentation/topics/generated/getCustomerFixedPricing.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /customer/pricing/fixed + + +

Operation ID: getCustomerFixedPricing

+

Get customer fixed pricing

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
customer_numberqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getCustomerName.topic b/documentation/topics/generated/getCustomerName.topic new file mode 100644 index 00000000..d0992370 --- /dev/null +++ b/documentation/topics/generated/getCustomerName.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /admin/customer/name + + +

Operation ID: getCustomerName

+

Get the full name of a customer

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
user_idquerynointeger
+
+ + + + +
StatusDescriptionContent Types
200Customer name retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/getCustomerNotes.topic b/documentation/topics/generated/getCustomerNotes.topic new file mode 100644 index 00000000..2f598c57 --- /dev/null +++ b/documentation/topics/generated/getCustomerNotes.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /customer/notes + + +

Operation ID: getCustomerNotes

+

Get notes for a customer

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
customer_idquerynointeger
+
+ + + + +
StatusDescriptionContent Types
200Customer notes retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getCustomerPricingHistoryV2.topic b/documentation/topics/generated/getCustomerPricingHistoryV2.topic new file mode 100644 index 00000000..d0014788 --- /dev/null +++ b/documentation/topics/generated/getCustomerPricingHistoryV2.topic @@ -0,0 +1,48 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/customers/pricing-history + + +

Operation ID: getCustomerPricingHistoryV2

+

Get customer versioned pricing/subscription/discount timeline

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
customer_numberqueryyesinteger
dateFromqueryyesstring
dateToqueryyesstring
+
+ + + + + + + + +
StatusDescriptionContent Types
200Customer timeline resolvedapplication/json
400
401
403
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/CustomerPricingHistoryResponse" +} + +
+
diff --git a/documentation/topics/generated/getDailyReport.topic b/documentation/topics/generated/getDailyReport.topic new file mode 100644 index 00000000..f2c8b069 --- /dev/null +++ b/documentation/topics/generated/getDailyReport.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments/daily-reports/get + + +

Operation ID: getDailyReport

+

Get daily report

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
department_idqueryyesinteger
datequeryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDailyReportBookingsCount.topic b/documentation/topics/generated/getDailyReportBookingsCount.topic new file mode 100644 index 00000000..24f7efbe --- /dev/null +++ b/documentation/topics/generated/getDailyReportBookingsCount.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments/daily-reports/bookings-count + + +

Operation ID: getDailyReportBookingsCount

+

Get bookings count for daily reports

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
datequeryyesstring
department_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDailyReportProductCount.topic b/documentation/topics/generated/getDailyReportProductCount.topic new file mode 100644 index 00000000..1e351f70 --- /dev/null +++ b/documentation/topics/generated/getDailyReportProductCount.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments/daily-reports/product-count + + +

Operation ID: getDailyReportProductCount

+

Get product count for daily reports

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + +
NameInRequiredTypeDescription
datequeryyesstring
date_toquerynostring
department_idqueryyesinteger
product_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDailyReportTransactionCount.topic b/documentation/topics/generated/getDailyReportTransactionCount.topic new file mode 100644 index 00000000..b5a82e6c --- /dev/null +++ b/documentation/topics/generated/getDailyReportTransactionCount.topic @@ -0,0 +1,42 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments/daily-reports/transaction-count + + +

Operation ID: getDailyReportTransactionCount

+

Get transaction count for daily reports

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
datequeryyesstring
date_toquerynostring
department_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentBookingCount.topic b/documentation/topics/generated/getDepartmentBookingCount.topic new file mode 100644 index 00000000..9945e040 --- /dev/null +++ b/documentation/topics/generated/getDepartmentBookingCount.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /admin/bookings/department/count + + +

Operation ID: getDepartmentBookingCount

+

Get department unfulfilled bookings count

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
department_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentCategories.topic b/documentation/topics/generated/getDepartmentCategories.topic new file mode 100644 index 00000000..d44d0212 --- /dev/null +++ b/documentation/topics/generated/getDepartmentCategories.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments/categories + + +

Operation ID: getDepartmentCategories

+

Get product categories available in a department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
department_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Department categories retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentDraftInvoiceTotals.topic b/documentation/topics/generated/getDepartmentDraftInvoiceTotals.topic new file mode 100644 index 00000000..afc30be4 --- /dev/null +++ b/documentation/topics/generated/getDepartmentDraftInvoiceTotals.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/economic/totals/department_draft_invoice_totals + + +

Operation ID: getDepartmentDraftInvoiceTotals

+

Get department draft invoice totals

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentLaneDynamicImage.topic b/documentation/topics/generated/getDepartmentLaneDynamicImage.topic new file mode 100644 index 00000000..3a790cd6 --- /dev/null +++ b/documentation/topics/generated/getDepartmentLaneDynamicImage.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/lanes/dynamic-image + + +

Operation ID: getDepartmentLaneDynamicImage

+

Returns a composed machine UI image for the specified department lane. +You can optionally highlight button indices, set the current step indicator, and toggle only-current-step mode. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + + + +
NameInRequiredTypeDescription
departmentqueryyesintegerDepartment ID
lanequeryyesintegerLane ID
buttonsquerynooneOfHighlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
current_stepquerynointegerCurrent step indicator (non-negative integer)
only_current_stepquerynobooleanIf true, only draw the current step highlight
vehicle_typequerynointegerVehicle type selection override (nullable non-negative integer)
+
+ + + + + + + + +
StatusDescriptionContent Types
200Dynamic image rendered successfullyimage/png
400
401
403
404
+

Schema for response 200 (image/png):

+ +{ + "format": "binary", + "type": "string" +} + +
+
diff --git a/documentation/topics/generated/getDepartmentPrices.topic b/documentation/topics/generated/getDepartmentPrices.topic new file mode 100644 index 00000000..4ff4518d --- /dev/null +++ b/documentation/topics/generated/getDepartmentPrices.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/department/prices + + +

Operation ID: getDepartmentPrices

+

Get department prices

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
department_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentRecommendedOrder.topic b/documentation/topics/generated/getDepartmentRecommendedOrder.topic new file mode 100644 index 00000000..fd285da4 --- /dev/null +++ b/documentation/topics/generated/getDepartmentRecommendedOrder.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments/order/recommended + + +

Operation ID: getDepartmentRecommendedOrder

+

Get recommended order for department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
department_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentSelfServeEnabled.topic b/documentation/topics/generated/getDepartmentSelfServeEnabled.topic new file mode 100644 index 00000000..6856913b --- /dev/null +++ b/documentation/topics/generated/getDepartmentSelfServeEnabled.topic @@ -0,0 +1,48 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments/self-serve/enabled + + +

Operation ID: getDepartmentSelfServeEnabled

+

Check if self-serve is enabled for a specific department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerDepartment ID
+
+ + + + + +
StatusDescriptionContent Types
200Successfully retrieved statusapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/getDepartmentSentInvoiceTotals.topic b/documentation/topics/generated/getDepartmentSentInvoiceTotals.topic new file mode 100644 index 00000000..23b85dbe --- /dev/null +++ b/documentation/topics/generated/getDepartmentSentInvoiceTotals.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/economic/totals/department_sent_invoice_totals + + +

Operation ID: getDepartmentSentInvoiceTotals

+

Get department sent invoice totals

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentTerminalLocation.topic b/documentation/topics/generated/getDepartmentTerminalLocation.topic new file mode 100644 index 00000000..3f66c089 --- /dev/null +++ b/documentation/topics/generated/getDepartmentTerminalLocation.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/stripe/department/terminal/location + + +

Operation ID: getDepartmentTerminalLocation

+

Get department terminal location

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentTerminalReaders.topic b/documentation/topics/generated/getDepartmentTerminalReaders.topic new file mode 100644 index 00000000..bc3270f6 --- /dev/null +++ b/documentation/topics/generated/getDepartmentTerminalReaders.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/stripe/department/terminal/readers + + +

Operation ID: getDepartmentTerminalReaders

+

Get department terminal readers

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentVariables.topic b/documentation/topics/generated/getDepartmentVariables.topic new file mode 100644 index 00000000..b248dfa6 --- /dev/null +++ b/documentation/topics/generated/getDepartmentVariables.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/department/variables + + +

Operation ID: getDepartmentVariables

+

Get department variables

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
department_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getDepartmentWeatherTimeline.topic b/documentation/topics/generated/getDepartmentWeatherTimeline.topic new file mode 100644 index 00000000..50804a70 --- /dev/null +++ b/documentation/topics/generated/getDepartmentWeatherTimeline.topic @@ -0,0 +1,58 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments/weather + + +

Operation ID: getDepartmentWeatherTimeline

+

Returns hourly weather, washes, hours and productivity status for a department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerDepartment ID
+
+ + + + +
StatusDescriptionContent Types
200Department weather timeline retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentWeatherTimelineResponse", + "allOf": [ + { + "$ref": "#/components/schemas/SuccessResponse" + }, + { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/DepartmentWeatherTimelineEntry" + }, + "type": "array" + } + }, + "type": "object" + } + ] +} + +
+
diff --git a/documentation/topics/generated/getEconomicConfig.topic b/documentation/topics/generated/getEconomicConfig.topic new file mode 100644 index 00000000..91d0c421 --- /dev/null +++ b/documentation/topics/generated/getEconomicConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /economic/config + + +

Operation ID: getEconomicConfig

+

Get e-conomic config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200e-conomic configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/EconomicConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getEconomicCustomer.topic b/documentation/topics/generated/getEconomicCustomer.topic new file mode 100644 index 00000000..07f1c011 --- /dev/null +++ b/documentation/topics/generated/getEconomicCustomer.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/economic/customer + + +

Operation ID: getEconomicCustomer

+

Get e-conomic customer details

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
customer_numberqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getEconomicDepartments.topic b/documentation/topics/generated/getEconomicDepartments.topic new file mode 100644 index 00000000..f1d605d1 --- /dev/null +++ b/documentation/topics/generated/getEconomicDepartments.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /economic/departments + + +

Operation ID: getEconomicDepartments

+

Get e-conomic departments

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getEconomicLayouts.topic b/documentation/topics/generated/getEconomicLayouts.topic new file mode 100644 index 00000000..03d23b59 --- /dev/null +++ b/documentation/topics/generated/getEconomicLayouts.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /economic/layouts + + +

Operation ID: getEconomicLayouts

+

Get available invoice layouts from e-conomic

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Layouts retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getEconomicPaymentTerms.topic b/documentation/topics/generated/getEconomicPaymentTerms.topic new file mode 100644 index 00000000..4d50377a --- /dev/null +++ b/documentation/topics/generated/getEconomicPaymentTerms.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /economic/payment-terms + + +

Operation ID: getEconomicPaymentTerms

+

Get available payment terms from e-conomic

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Payment terms retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getEconomicProducts.topic b/documentation/topics/generated/getEconomicProducts.topic new file mode 100644 index 00000000..4c17e4d7 --- /dev/null +++ b/documentation/topics/generated/getEconomicProducts.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /economic/products + + +

Operation ID: getEconomicProducts

+

Get e-conomic products

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getEconomicTotals.topic b/documentation/topics/generated/getEconomicTotals.topic new file mode 100644 index 00000000..81a83532 --- /dev/null +++ b/documentation/topics/generated/getEconomicTotals.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/economic/totals + + +

Operation ID: getEconomicTotals

+

Get total economic statistics

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getEmailConfig.topic b/documentation/topics/generated/getEmailConfig.topic new file mode 100644 index 00000000..83cc35d5 --- /dev/null +++ b/documentation/topics/generated/getEmailConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /email/config + + +

Operation ID: getEmailConfig

+

Get email config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Email configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/EmailConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getEntraConfig.topic b/documentation/topics/generated/getEntraConfig.topic new file mode 100644 index 00000000..465b5161 --- /dev/null +++ b/documentation/topics/generated/getEntraConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /entra/config + + +

Operation ID: getEntraConfig

+

Get Entra config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Entra configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/EntraConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getExchangeRate.topic b/documentation/topics/generated/getExchangeRate.topic new file mode 100644 index 00000000..b3c78b63 --- /dev/null +++ b/documentation/topics/generated/getExchangeRate.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/fxratesapi/rate + + +

Operation ID: getExchangeRate

+

Get current exchange rate

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
fromqueryyesstring
toqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Exchange rate retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getForm.topic b/documentation/topics/generated/getForm.topic new file mode 100644 index 00000000..06e54fda --- /dev/null +++ b/documentation/topics/generated/getForm.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /form + + +

Operation ID: getForm

+

Retrieve a form definition

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idquerynointeger
+
+ + + + +
StatusDescriptionContent Types
200Form retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getFxRatesApiConfig.topic b/documentation/topics/generated/getFxRatesApiConfig.topic new file mode 100644 index 00000000..5cc241b3 --- /dev/null +++ b/documentation/topics/generated/getFxRatesApiConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /fxratesapi/config + + +

Operation ID: getFxRatesApiConfig

+

Get FXRatesAPI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200FXRatesAPI configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/FxRatesApiConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getGatewayApiConfig.topic b/documentation/topics/generated/getGatewayApiConfig.topic new file mode 100644 index 00000000..e8fa3178 --- /dev/null +++ b/documentation/topics/generated/getGatewayApiConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /gatewayapi/config + + +

Operation ID: getGatewayApiConfig

+

Get GatewayAPI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200GatewayAPI configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/GatewayApiConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getInvoicePdf.topic b/documentation/topics/generated/getInvoicePdf.topic new file mode 100644 index 00000000..bdda6b09 --- /dev/null +++ b/documentation/topics/generated/getInvoicePdf.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /invoices/pdf + + +

Operation ID: getInvoicePdf

+

Download an invoice as PDF

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200PDF retrieved successfullyapplication/pdf
+

Schema for response 200 (application/pdf):

+ +{ + "format": "binary", + "type": "string" +} + +
+
diff --git a/documentation/topics/generated/getInvoicingFixedPricingDistribution.topic b/documentation/topics/generated/getInvoicingFixedPricingDistribution.topic new file mode 100644 index 00000000..fa944bc4 --- /dev/null +++ b/documentation/topics/generated/getInvoicingFixedPricingDistribution.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/invoicing/period/distribution/fixed-pricing + + +

Operation ID: getInvoicingFixedPricingDistribution

+

Get invoicing distribution for fixed pricing items

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
dateFromqueryyesstring
dateToqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Fixed pricing distribution retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/InvoicingFixedPricingDistributionResponse" +} + +
+
diff --git a/documentation/topics/generated/getInvoicingPeriodDistributionV2All.topic b/documentation/topics/generated/getInvoicingPeriodDistributionV2All.topic new file mode 100644 index 00000000..6eaa418d --- /dev/null +++ b/documentation/topics/generated/getInvoicingPeriodDistributionV2All.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/invoicing/period/distribution/v2/all + + +

Operation ID: getInvoicingPeriodDistributionV2All

+

Get version-aware historical distribution (all)

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
dateFromqueryyesstring
dateToqueryyesstring
+
+ + + + + + + + +
StatusDescriptionContent Types
200Version-aware historical distribution (all categories)application/json
400
401
403
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/InvoicingDistributionV2AllResponse" +} + +
+
diff --git a/documentation/topics/generated/getInvoicingPeriodDistributionV2BookedDepartment75.topic b/documentation/topics/generated/getInvoicingPeriodDistributionV2BookedDepartment75.topic new file mode 100644 index 00000000..ad4c4adb --- /dev/null +++ b/documentation/topics/generated/getInvoicingPeriodDistributionV2BookedDepartment75.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/invoicing/period/distribution/v2/booked-department-75 + + +

Operation ID: getInvoicingPeriodDistributionV2BookedDepartment75

+

Get booked e-conomic department 75 redistribution

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
dateFromqueryyesstring
dateToqueryyesstring
+
+ + + + + + + + +
StatusDescriptionContent Types
200Actual booked e-conomic department 75 net amounts redistributed to internal departmentsapplication/json
400
401
403
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/InvoicingDistributionV2BookedDepartment75Response" +} + +
+
diff --git a/documentation/topics/generated/getInvoicingPeriodDistributionV2CustomerPrices.topic b/documentation/topics/generated/getInvoicingPeriodDistributionV2CustomerPrices.topic new file mode 100644 index 00000000..856a831a --- /dev/null +++ b/documentation/topics/generated/getInvoicingPeriodDistributionV2CustomerPrices.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/invoicing/period/distribution/v2/customer-prices + + +

Operation ID: getInvoicingPeriodDistributionV2CustomerPrices

+

Get version-aware historical customer-price discount distribution

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
dateFromqueryyesstring
dateToqueryyesstring
+
+ + + + + + + + +
StatusDescriptionContent Types
200Version-aware customer-price discount distributionapplication/json
400
401
403
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/InvoicingDistributionV2CustomerPricesResponse" +} + +
+
diff --git a/documentation/topics/generated/getInvoicingPeriodDistributionV2FixedPricing.topic b/documentation/topics/generated/getInvoicingPeriodDistributionV2FixedPricing.topic new file mode 100644 index 00000000..31130af1 --- /dev/null +++ b/documentation/topics/generated/getInvoicingPeriodDistributionV2FixedPricing.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/invoicing/period/distribution/v2/fixed-pricing + + +

Operation ID: getInvoicingPeriodDistributionV2FixedPricing

+

Get version-aware historical fixed pricing distribution

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
dateFromqueryyesstring
dateToqueryyesstring
+
+ + + + + + + + +
StatusDescriptionContent Types
200Version-aware fixed pricing distributionapplication/json
400
401
403
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/InvoicingDistributionV2FixedPricingResponse" +} + +
+
diff --git a/documentation/topics/generated/getInvoicingPeriodDistributionV2WashSubscriptions.topic b/documentation/topics/generated/getInvoicingPeriodDistributionV2WashSubscriptions.topic new file mode 100644 index 00000000..16801f72 --- /dev/null +++ b/documentation/topics/generated/getInvoicingPeriodDistributionV2WashSubscriptions.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/invoicing/period/distribution/v2/wash-subscriptions + + +

Operation ID: getInvoicingPeriodDistributionV2WashSubscriptions

+

Get version-aware historical wash subscription distribution

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
dateFromqueryyesstring
dateToqueryyesstring
+
+ + + + + + + + +
StatusDescriptionContent Types
200Version-aware wash subscription distributionapplication/json
400
401
403
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse" +} + +
+
diff --git a/documentation/topics/generated/getInvoicingPeriods.topic b/documentation/topics/generated/getInvoicingPeriods.topic new file mode 100644 index 00000000..9d3eb1d8 --- /dev/null +++ b/documentation/topics/generated/getInvoicingPeriods.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/invoicing/period + + +

Operation ID: getInvoicingPeriods

+

Retrieve invoicing periods for superusers

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
dateFromqueryyesstring
dateToqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Invoicing periods retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getInvoicingWashSubscriptionsDistribution.topic b/documentation/topics/generated/getInvoicingWashSubscriptionsDistribution.topic new file mode 100644 index 00000000..20b9befb --- /dev/null +++ b/documentation/topics/generated/getInvoicingWashSubscriptionsDistribution.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/invoicing/period/distribution/wash-subscriptions + + +

Operation ID: getInvoicingWashSubscriptionsDistribution

+

Get invoicing distribution for wash subscriptions

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
dateFromqueryyesstring
dateToqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Wash subscriptions distribution retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/InvoicingWashSubscriptionsDistributionResponse" +} + +
+
diff --git a/documentation/topics/generated/getLastMonthIncome.topic b/documentation/topics/generated/getLastMonthIncome.topic new file mode 100644 index 00000000..5fc006d3 --- /dev/null +++ b/documentation/topics/generated/getLastMonthIncome.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/income/last-month + + +

Operation ID: getLastMonthIncome

+

Get income statistics for the previous month

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Last month's income statistics retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getLicensePlateRecognizerConfig.topic b/documentation/topics/generated/getLicensePlateRecognizerConfig.topic new file mode 100644 index 00000000..3b42a368 --- /dev/null +++ b/documentation/topics/generated/getLicensePlateRecognizerConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /licenseplaterecognizer/config + + +

Operation ID: getLicensePlateRecognizerConfig

+

Get LicensePlateRecognizer config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200LicensePlateRecognizer configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/LicensePlateRecognizerConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getLimbleConfig.topic b/documentation/topics/generated/getLimbleConfig.topic new file mode 100644 index 00000000..26241254 --- /dev/null +++ b/documentation/topics/generated/getLimbleConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /limble/config + + +

Operation ID: getLimbleConfig

+

Get Limble config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Limble configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/LimbleConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getMotorApiConfig.topic b/documentation/topics/generated/getMotorApiConfig.topic new file mode 100644 index 00000000..835f35be --- /dev/null +++ b/documentation/topics/generated/getMotorApiConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /motorapi/config + + +

Operation ID: getMotorApiConfig

+

Get MotorAPI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200MotorAPI configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/MotorApiConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getNewBookingsStats.topic b/documentation/topics/generated/getNewBookingsStats.topic new file mode 100644 index 00000000..55f4ddbd --- /dev/null +++ b/documentation/topics/generated/getNewBookingsStats.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/bookings/new + + +

Operation ID: getNewBookingsStats

+

Get statistics for new bookings

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200New bookings statistics retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getNewOrdersStats.topic b/documentation/topics/generated/getNewOrdersStats.topic new file mode 100644 index 00000000..4c362342 --- /dev/null +++ b/documentation/topics/generated/getNewOrdersStats.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/orders/new + + +

Operation ID: getNewOrdersStats

+

Get statistics for new orders

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200New orders statistics retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getOcrSpaceConfig.topic b/documentation/topics/generated/getOcrSpaceConfig.topic new file mode 100644 index 00000000..c473eaac --- /dev/null +++ b/documentation/topics/generated/getOcrSpaceConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /ocrspace/config + + +

Operation ID: getOcrSpaceConfig

+

Get OcrSpace config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200OcrSpace configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/OcrSpaceConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getOpenAiConfig.topic b/documentation/topics/generated/getOpenAiConfig.topic new file mode 100644 index 00000000..23931ec3 --- /dev/null +++ b/documentation/topics/generated/getOpenAiConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /openai/config + + +

Operation ID: getOpenAiConfig

+

Get OpenAI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200OpenAI configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/OpenAiConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getOrder.topic b/documentation/topics/generated/getOrder.topic new file mode 100644 index 00000000..efeba16c --- /dev/null +++ b/documentation/topics/generated/getOrder.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /order + + +

Operation ID: getOrder

+

Get detailed information about a specific order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + + +
StatusDescriptionContent Types
200Order retrieved successfullyapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/Order" +} + +
+
diff --git a/documentation/topics/generated/getPlateScanPostResults.topic b/documentation/topics/generated/getPlateScanPostResults.topic new file mode 100644 index 00000000..18dfbecd --- /dev/null +++ b/documentation/topics/generated/getPlateScanPostResults.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /numberplatescans/post + + +

Operation ID: getPlateScanPostResults

+

Get post-scan results

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Post-scan results retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getReadyToInvoice.topic b/documentation/topics/generated/getReadyToInvoice.topic new file mode 100644 index 00000000..9f7f6942 --- /dev/null +++ b/documentation/topics/generated/getReadyToInvoice.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /collected-invoices/ready-to-invoice + + +

Operation ID: getReadyToInvoice

+

Get collected invoices that are ready to be processed

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Ready invoices retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getRecaptchaConfig.topic b/documentation/topics/generated/getRecaptchaConfig.topic new file mode 100644 index 00000000..60ced51c --- /dev/null +++ b/documentation/topics/generated/getRecaptchaConfig.topic @@ -0,0 +1,56 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /auth/reCAPTCHA/public + + +

Operation ID: getRecaptchaConfig

+

Retrieve public reCAPTCHA configuration for login forms

+
+

No authentication required.

+ + + + +
StatusDescriptionContent Types
200reCAPTCHA configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "rate_limit": { + "properties": { + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "reset": { + "type": "integer" + }, + "warning": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "recaptcha": { + "type": "object" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/getRecaptchaModuleConfig.topic b/documentation/topics/generated/getRecaptchaModuleConfig.topic new file mode 100644 index 00000000..73b5c7a5 --- /dev/null +++ b/documentation/topics/generated/getRecaptchaModuleConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /reCAPTCHA/config + + +

Operation ID: getRecaptchaModuleConfig

+

Get reCAPTCHA config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200reCAPTCHA configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/RecaptchaConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getSelfServeConfig.topic b/documentation/topics/generated/getSelfServeConfig.topic new file mode 100644 index 00000000..66304ea9 --- /dev/null +++ b/documentation/topics/generated/getSelfServeConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /selfserve/config + + +

Operation ID: getSelfServeConfig

+

Get Self-Serve config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Self-serve configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SelfServeConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getSelfServeLaneStatus.topic b/documentation/topics/generated/getSelfServeLaneStatus.topic new file mode 100644 index 00000000..968c972c --- /dev/null +++ b/documentation/topics/generated/getSelfServeLaneStatus.topic @@ -0,0 +1,42 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/self-serve/lane/status + + +

Operation ID: getSelfServeLaneStatus

+

Retrieve the current status of a self-serve lane

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
lane_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Lane status retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SelfServeLaneStatus" +} + +
+
diff --git a/documentation/topics/generated/getSelfserveVehicleAllowed.topic b/documentation/topics/generated/getSelfserveVehicleAllowed.topic new file mode 100644 index 00000000..283258b6 --- /dev/null +++ b/documentation/topics/generated/getSelfserveVehicleAllowed.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/vehicle/allowed + + +

Operation ID: getSelfserveVehicleAllowed

+

Check whether self-serve is allowed for a vehicle on a lane

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
lane_idqueryyesinteger
regqueryyesstring
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully evaluated self-serve eligibilityapplication/json
403
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SelfserveVehicleAllowedResponse" +} + +
+
diff --git a/documentation/topics/generated/getSelfserveWashSummary.topic b/documentation/topics/generated/getSelfserveWashSummary.topic new file mode 100644 index 00000000..de956fcb --- /dev/null +++ b/documentation/topics/generated/getSelfserveWashSummary.topic @@ -0,0 +1,46 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/washes/summary + + +

Operation ID: getSelfserveWashSummary

+

Get self-serve wash summary

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
NameInRequiredTypeDescription
session_idquerynointeger
lane_idquerynointeger
regquerynostring
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully retrieved self-serve wash summaryapplication/json
403
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SelfserveWashSummary" +} + +
+
diff --git a/documentation/topics/generated/getSession.topic b/documentation/topics/generated/getSession.topic new file mode 100644 index 00000000..1bc5e8c2 --- /dev/null +++ b/documentation/topics/generated/getSession.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /auth/session + + +

Operation ID: getSession

+

Retrieve information about the current authenticated user session

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
StatusDescriptionContent Types
200Session information retrieved successfullyapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{ + "allOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "properties": { + "two_factor_enabled": { + "description": "Indicates if 2FA is enabled for this account", + "type": "boolean" + } + }, + "type": "object" + } + ] +} + +
+
diff --git a/documentation/topics/generated/getShellyConfig.topic b/documentation/topics/generated/getShellyConfig.topic new file mode 100644 index 00000000..386797d0 --- /dev/null +++ b/documentation/topics/generated/getShellyConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /shelly/config + + +

Operation ID: getShellyConfig

+

Get Shelly config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Shelly configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ShellyConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getStripeConfig.topic b/documentation/topics/generated/getStripeConfig.topic new file mode 100644 index 00000000..47a15db9 --- /dev/null +++ b/documentation/topics/generated/getStripeConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /stripe/config + + +

Operation ID: getStripeConfig

+

Get Stripe config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Stripe configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/StripeConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getStripePaymentIntent.topic b/documentation/topics/generated/getStripePaymentIntent.topic new file mode 100644 index 00000000..6309b7d9 --- /dev/null +++ b/documentation/topics/generated/getStripePaymentIntent.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /orders/module/stripe/payment_intent + + +

Operation ID: getStripePaymentIntent

+

Get Stripe payment intent

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getSubuser.topic b/documentation/topics/generated/getSubuser.topic new file mode 100644 index 00000000..093d1fe4 --- /dev/null +++ b/documentation/topics/generated/getSubuser.topic @@ -0,0 +1,95 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /subusers/{id} + + +

Operation ID: getSubuser

+

Returns the subuser if the authenticated user has at least one enabled, non-deleted grant +for their customer number to this subuser. Otherwise returns 404. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idpathyesinteger
+
+ + + + + + + +
StatusDescriptionContent Types
200Subuser detailsapplication/json
401
404
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "created_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "email": { + "format": "email", + "nullable": true, + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "nullable": true, + "type": "string" + }, + "permissions": { + "description": "Aggregated permission keys granted for the caller's customer", + "items": { + "type": "string" + }, + "type": "array" + }, + "phone": { + "nullable": true, + "type": "integer" + }, + "phone_country_code": { + "nullable": true, + "type": "integer" + }, + "suspended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/getSuperuserUser.topic b/documentation/topics/generated/getSuperuserUser.topic new file mode 100644 index 00000000..e3d67dd4 --- /dev/null +++ b/documentation/topics/generated/getSuperuserUser.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/user + + +

Operation ID: getSuperuserUser

+

Get detailed user information by user ID

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
user_idquerynointeger
+
+ + + + + +
StatusDescriptionContent Types
200User retrieved successfullyapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/User" +} + +
+
diff --git a/documentation/topics/generated/getThisMonthIncome.topic b/documentation/topics/generated/getThisMonthIncome.topic new file mode 100644 index 00000000..f4f99efd --- /dev/null +++ b/documentation/topics/generated/getThisMonthIncome.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/income/this-month + + +

Operation ID: getThisMonthIncome

+

Get income statistics for the current month

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200This month's income statistics retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getThisYearIncome.topic b/documentation/topics/generated/getThisYearIncome.topic new file mode 100644 index 00000000..1993d349 --- /dev/null +++ b/documentation/topics/generated/getThisYearIncome.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/income/this-year + + +

Operation ID: getThisYearIncome

+

Get income statistics for the current year

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200This year's income statistics retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getTodayIncome.topic b/documentation/topics/generated/getTodayIncome.topic new file mode 100644 index 00000000..e5a12612 --- /dev/null +++ b/documentation/topics/generated/getTodayIncome.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/income/today + + +

Operation ID: getTodayIncome

+

Get income statistics for today

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Today's income statistics retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getTotalIncomeTodayByDepartments.topic b/documentation/topics/generated/getTotalIncomeTodayByDepartments.topic new file mode 100644 index 00000000..3196326d --- /dev/null +++ b/documentation/topics/generated/getTotalIncomeTodayByDepartments.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/income/departments + + +

Operation ID: getTotalIncomeTodayByDepartments

+

Get total income today by departments

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getUnknownCustomerVehicles.topic b/documentation/topics/generated/getUnknownCustomerVehicles.topic new file mode 100644 index 00000000..58f4731f --- /dev/null +++ b/documentation/topics/generated/getUnknownCustomerVehicles.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/vehicles/unknown-customer + + +

Operation ID: getUnknownCustomerVehicles

+

Get unknown customer vehicles in department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getUserBookings.topic b/documentation/topics/generated/getUserBookings.topic new file mode 100644 index 00000000..d18236e9 --- /dev/null +++ b/documentation/topics/generated/getUserBookings.topic @@ -0,0 +1,39 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /user/bookings + + +

Operation ID: getUserBookings

+

Retrieve bookings for the authenticated user

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200User bookings retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Booking" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/getUserDiscounts.topic b/documentation/topics/generated/getUserDiscounts.topic new file mode 100644 index 00000000..1d72997e --- /dev/null +++ b/documentation/topics/generated/getUserDiscounts.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/user/discounts + + +

Operation ID: getUserDiscounts

+

Get user discounts

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
user_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getUserIdFromCustomerNumber.topic b/documentation/topics/generated/getUserIdFromCustomerNumber.topic new file mode 100644 index 00000000..d9e26c8c --- /dev/null +++ b/documentation/topics/generated/getUserIdFromCustomerNumber.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /admin/customer/getUserId + + +

Operation ID: getUserIdFromCustomerNumber

+

Convert e-conomic customer number to internal user ID

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
customer_numberqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200User ID retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "user_id": { + "type": "integer" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/getUserInvoices.topic b/documentation/topics/generated/getUserInvoices.topic new file mode 100644 index 00000000..b6ea5eed --- /dev/null +++ b/documentation/topics/generated/getUserInvoices.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /user/invoices + + +

Operation ID: getUserInvoices

+

Retrieve invoices for the authenticated user

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200User invoices retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getUserKeys.topic b/documentation/topics/generated/getUserKeys.topic new file mode 100644 index 00000000..71cb0fea --- /dev/null +++ b/documentation/topics/generated/getUserKeys.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/user/keys + + +

Operation ID: getUserKeys

+

Get user keys

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
user_idqueryyesinteger
keyquerynostring
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getUserOrder.topic b/documentation/topics/generated/getUserOrder.topic new file mode 100644 index 00000000..494b020e --- /dev/null +++ b/documentation/topics/generated/getUserOrder.topic @@ -0,0 +1,42 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /user/order + + +

Operation ID: getUserOrder

+

Get details of a specific order for the authenticated user

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Order retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/Order" +} + +
+
diff --git a/documentation/topics/generated/getUserOrders.topic b/documentation/topics/generated/getUserOrders.topic new file mode 100644 index 00000000..1c9ea8e7 --- /dev/null +++ b/documentation/topics/generated/getUserOrders.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /user/orders + + +

Operation ID: getUserOrders

+

Retrieve orders for the authenticated user

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + +
StatusDescriptionContent Types
200Orders retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Order" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/getUserPermissions.topic b/documentation/topics/generated/getUserPermissions.topic new file mode 100644 index 00000000..753dfebb --- /dev/null +++ b/documentation/topics/generated/getUserPermissions.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /user/permissions + + +

Operation ID: getUserPermissions

+

Get user permissions

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getUsersWithVehicleSubscriptions.topic b/documentation/topics/generated/getUsersWithVehicleSubscriptions.topic new file mode 100644 index 00000000..f557f925 --- /dev/null +++ b/documentation/topics/generated/getUsersWithVehicleSubscriptions.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/users-with-vehicle-subscriptions + + +

Operation ID: getUsersWithVehicleSubscriptions

+

Get users with vehicle subscriptions

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getVehicleCustomerSuggestions.topic b/documentation/topics/generated/getVehicleCustomerSuggestions.topic new file mode 100644 index 00000000..8dae93e2 --- /dev/null +++ b/documentation/topics/generated/getVehicleCustomerSuggestions.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/vehicle/customer-suggestions + + +

Operation ID: getVehicleCustomerSuggestions

+

Get vehicle customer suggestions

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
regqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getVehicleStatus.topic b/documentation/topics/generated/getVehicleStatus.topic new file mode 100644 index 00000000..c932d02e --- /dev/null +++ b/documentation/topics/generated/getVehicleStatus.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /vehicles/status + + +

Operation ID: getVehicleStatus

+

Get vehicle status

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
regqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getVirkdataConfig.topic b/documentation/topics/generated/getVirkdataConfig.topic new file mode 100644 index 00000000..d1e0fb82 --- /dev/null +++ b/documentation/topics/generated/getVirkdataConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /virkdata/config + + +

Operation ID: getVirkdataConfig

+

Get Virkdata config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Virkdata configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/VirkdataConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getWeatherApiConfig.topic b/documentation/topics/generated/getWeatherApiConfig.topic new file mode 100644 index 00000000..38f7fba0 --- /dev/null +++ b/documentation/topics/generated/getWeatherApiConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /weatherapi/config + + +

Operation ID: getWeatherApiConfig

+

Get WeatherAPI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200WeatherAPI configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/WeatherApiConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getWorkerLicensePlates.topic b/documentation/topics/generated/getWorkerLicensePlates.topic new file mode 100644 index 00000000..952574b4 --- /dev/null +++ b/documentation/topics/generated/getWorkerLicensePlates.topic @@ -0,0 +1,35 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /worker/licenseplates + + +

Operation ID: getWorkerLicensePlates

+

Fetch all unique license plates from various database tables

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
StatusDescriptionContent Types
200License plates retrieved successfullyapplication/json
403
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getWorkerStatus.topic b/documentation/topics/generated/getWorkerStatus.topic new file mode 100644 index 00000000..4f3c8835 --- /dev/null +++ b/documentation/topics/generated/getWorkerStatus.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /worker/status + + +

Operation ID: getWorkerStatus

+

Get detailed status of the system worker

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Worker status retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getWorkerVersion.topic b/documentation/topics/generated/getWorkerVersion.topic new file mode 100644 index 00000000..3c96cd9e --- /dev/null +++ b/documentation/topics/generated/getWorkerVersion.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /worker/version + + +

Operation ID: getWorkerVersion

+

Get the current version of the system worker

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Worker version retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getXlvaskConfig.topic b/documentation/topics/generated/getXlvaskConfig.topic new file mode 100644 index 00000000..0974cd95 --- /dev/null +++ b/documentation/topics/generated/getXlvaskConfig.topic @@ -0,0 +1,36 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /xlvask/config + + +

Operation ID: getXlvaskConfig

+

Get XLVask config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200XLVask configuration retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/XlvaskConfigListResponse" +} + +
+
diff --git a/documentation/topics/generated/getXlvaskUsageLogs.topic b/documentation/topics/generated/getXlvaskUsageLogs.topic new file mode 100644 index 00000000..d209606d --- /dev/null +++ b/documentation/topics/generated/getXlvaskUsageLogs.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/xlvask/usageLog + + +

Operation ID: getXlvaskUsageLogs

+

Retrieve usage logs from XLVask system

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + +
StatusDescriptionContent Types
200Usage logs retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getXlvaskUsageOrders.topic b/documentation/topics/generated/getXlvaskUsageOrders.topic new file mode 100644 index 00000000..c1058eb0 --- /dev/null +++ b/documentation/topics/generated/getXlvaskUsageOrders.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/xlvask/services/usage/orders + + +

Operation ID: getXlvaskUsageOrders

+

Get XLVask usage orders

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getXlvaskUsageOrdersFastLink.topic b/documentation/topics/generated/getXlvaskUsageOrdersFastLink.topic new file mode 100644 index 00000000..a9642271 --- /dev/null +++ b/documentation/topics/generated/getXlvaskUsageOrdersFastLink.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/xlvask/services/usage/orders/fast-link + + +

Operation ID: getXlvaskUsageOrdersFastLink

+

Get XLVask usage orders fast link

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/getYesterdayIncome.topic b/documentation/topics/generated/getYesterdayIncome.topic new file mode 100644 index 00000000..8ade3e31 --- /dev/null +++ b/documentation/topics/generated/getYesterdayIncome.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /statistics/income/yesterday + + +

Operation ID: getYesterdayIncome

+

Get income statistics for yesterday

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Yesterday's income statistics retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/importEconomicCustomers.topic b/documentation/topics/generated/importEconomicCustomers.topic new file mode 100644 index 00000000..dccc1df1 --- /dev/null +++ b/documentation/topics/generated/importEconomicCustomers.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /economic/customers/import + + +

Operation ID: importEconomicCustomers

+

Import customers from e-conomic

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Customers imported successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listBackupModules.topic b/documentation/topics/generated/listBackupModules.topic new file mode 100644 index 00000000..1d1c402d --- /dev/null +++ b/documentation/topics/generated/listBackupModules.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/backup/backups + + +

Operation ID: listBackupModules

+

List backup modules

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listBookings.topic b/documentation/topics/generated/listBookings.topic new file mode 100644 index 00000000..f6911f5e --- /dev/null +++ b/documentation/topics/generated/listBookings.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /bookings + + +

Operation ID: listBookings

+

Retrieve a list of bookings

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + +
StatusDescriptionContent Types
200Bookings retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Booking" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listBrandingOptions.topic b/documentation/topics/generated/listBrandingOptions.topic new file mode 100644 index 00000000..59aa76ea --- /dev/null +++ b/documentation/topics/generated/listBrandingOptions.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /branding + + +

Operation ID: listBrandingOptions

+

Retrieve a list of branding options or a specific branding option if ID is provided

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
idquerynointeger
noobject
+
+ + + + + + +
StatusDescriptionContent Types
200Branding options retrieved successfullyapplication/json
400
403
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listCategories.topic b/documentation/topics/generated/listCategories.topic new file mode 100644 index 00000000..c5f76474 --- /dev/null +++ b/documentation/topics/generated/listCategories.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /categories + + +

Operation ID: listCategories

+

Retrieve a list of product categories

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + +
StatusDescriptionContent Types
200Categories retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listCollectedInvoices.topic b/documentation/topics/generated/listCollectedInvoices.topic new file mode 100644 index 00000000..52c60d81 --- /dev/null +++ b/documentation/topics/generated/listCollectedInvoices.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /collected-invoices + + +

Operation ID: listCollectedInvoices

+

Get list of collected invoices

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + +
StatusDescriptionContent Types
200Collected invoices retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listCustomers.topic b/documentation/topics/generated/listCustomers.topic new file mode 100644 index 00000000..94cc025b --- /dev/null +++ b/documentation/topics/generated/listCustomers.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /customers + + +

Operation ID: listCustomers

+

List customers

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
noobject
barredquerynostringOptional e-conomic barred customer filter.
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listDailyReports.topic b/documentation/topics/generated/listDailyReports.topic new file mode 100644 index 00000000..5907829f --- /dev/null +++ b/documentation/topics/generated/listDailyReports.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments/daily-reports + + +

Operation ID: listDailyReports

+

List daily reports

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listDepartmentGates.topic b/documentation/topics/generated/listDepartmentGates.topic new file mode 100644 index 00000000..e23d189f --- /dev/null +++ b/documentation/topics/generated/listDepartmentGates.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/gates + + +

Operation ID: listDepartmentGates

+

Retrieve department gates, optionally filtered by id

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
idquerynointeger
noobject
+
+ + + + + +
StatusDescriptionContent Types
200Department gates retrieved successfullyapplication/json
401
+

Schema for response 200 (application/json):

+ +{ + "oneOf": [ + { + "$ref": "#/components/schemas/DepartmentGate" + }, + { + "items": { + "$ref": "#/components/schemas/DepartmentGate" + }, + "type": "array" + } + ] +} + +
+
diff --git a/documentation/topics/generated/listDepartmentGoals.topic b/documentation/topics/generated/listDepartmentGoals.topic new file mode 100644 index 00000000..9647ad40 --- /dev/null +++ b/documentation/topics/generated/listDepartmentGoals.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /goals/department + + +

Operation ID: listDepartmentGoals

+

Retrieve a list of department goals or a single goal when `id` is provided. + +Access control: +- A user may only access goals where the goal's `departments` set is a subset of the user's departments. +- Users with the `superuser` permission may access all goals. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
idquerynointegerWhen provided, returns the single goal with this id (if accessible)
noobject
+
+ + + + + + + + +
StatusDescriptionContent Types
200Goals retrieved successfullyapplication/json
400
401
403
404
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/DepartmentGoal" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listDepartmentLanes.topic b/documentation/topics/generated/listDepartmentLanes.topic new file mode 100644 index 00000000..d395500e --- /dev/null +++ b/documentation/topics/generated/listDepartmentLanes.topic @@ -0,0 +1,46 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/lanes + + +

Operation ID: listDepartmentLanes

+

Retrieve a list of all department lanes

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + + +
StatusDescriptionContent Types
200Department lanes retrieved successfullyapplication/json
401
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/DepartmentLane" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listDepartmentPlateScanners.topic b/documentation/topics/generated/listDepartmentPlateScanners.topic new file mode 100644 index 00000000..af8ba260 --- /dev/null +++ b/documentation/topics/generated/listDepartmentPlateScanners.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/numberplatescanners + + +

Operation ID: listDepartmentPlateScanners

+

List department plate scanners

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Department plate scanners retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listDepartmentRelays.topic b/documentation/topics/generated/listDepartmentRelays.topic new file mode 100644 index 00000000..106cd3c7 --- /dev/null +++ b/documentation/topics/generated/listDepartmentRelays.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/relays + + +

Operation ID: listDepartmentRelays

+

Retrieve department relays, optionally filtered by id

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
idquerynointeger
noobject
+
+ + + + + +
StatusDescriptionContent Types
200Department relays retrieved successfullyapplication/json
401
+

Schema for response 200 (application/json):

+ +{ + "oneOf": [ + { + "$ref": "#/components/schemas/DepartmentRelay" + }, + { + "items": { + "$ref": "#/components/schemas/DepartmentRelay" + }, + "type": "array" + } + ] +} + +
+
diff --git a/documentation/topics/generated/listDepartments.topic b/documentation/topics/generated/listDepartments.topic new file mode 100644 index 00000000..2506f9a3 --- /dev/null +++ b/documentation/topics/generated/listDepartments.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /departments + + +

Operation ID: listDepartments

+

Retrieve a list of all visible departments

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
idquerynointegerFilter by specific department ID
noobject
+
+ + + + + +
StatusDescriptionContent Types
200Departments retrieved successfullyapplication/json
401
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Department" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listDraftInvoices.topic b/documentation/topics/generated/listDraftInvoices.topic new file mode 100644 index 00000000..51bf5a55 --- /dev/null +++ b/documentation/topics/generated/listDraftInvoices.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /invoices/draft + + +

Operation ID: listDraftInvoices

+

Retrieve a list of draft invoices

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + +
StatusDescriptionContent Types
200Draft invoices retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listEntraUsers.topic b/documentation/topics/generated/listEntraUsers.topic new file mode 100644 index 00000000..e759fb4c --- /dev/null +++ b/documentation/topics/generated/listEntraUsers.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/entra/users + + +

Operation ID: listEntraUsers

+

Get list of users from Microsoft Entra (Azure AD)

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Entra users retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listGuestDepartments.topic b/documentation/topics/generated/listGuestDepartments.topic new file mode 100644 index 00000000..83298010 --- /dev/null +++ b/documentation/topics/generated/listGuestDepartments.topic @@ -0,0 +1,39 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /guest/departments + + +

Operation ID: listGuestDepartments

+

Get list of departments without authentication

+
+

No authentication required.

+ + + + +
NameInRequiredTypeDescription
include_lanesquerynobooleanWhether to include lane status and self-serve information
+
+ + + + +
StatusDescriptionContent Types
200Departments retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/DepartmentGuest" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listModuleActionLogs.topic b/documentation/topics/generated/listModuleActionLogs.topic new file mode 100644 index 00000000..3f5d3e35 --- /dev/null +++ b/documentation/topics/generated/listModuleActionLogs.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/action-logs + + +

Operation ID: listModuleActionLogs

+

Retrieve a paginated list of module action logs with searching and filtering

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + + + +
StatusDescriptionContent Types
200Module action logs retrieved successfullyapplication/json
401
403
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/ModuleActionLog" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listNotifications.topic b/documentation/topics/generated/listNotifications.topic new file mode 100644 index 00000000..638c4d74 --- /dev/null +++ b/documentation/topics/generated/listNotifications.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /notifications + + +

Operation ID: listNotifications

+

Get list of notifications

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + +
StatusDescriptionContent Types
200Notifications retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Notification" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listOrderAttachments.topic b/documentation/topics/generated/listOrderAttachments.topic new file mode 100644 index 00000000..234675b6 --- /dev/null +++ b/documentation/topics/generated/listOrderAttachments.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /orders/attachments + + +

Operation ID: listOrderAttachments

+

Get attachments for an order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
order_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Order attachments retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listOrderBookings.topic b/documentation/topics/generated/listOrderBookings.topic new file mode 100644 index 00000000..f05efa67 --- /dev/null +++ b/documentation/topics/generated/listOrderBookings.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /order-bookings + + +

Operation ID: listOrderBookings

+

List order bookings

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idquerynointeger
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listOrderItems.topic b/documentation/topics/generated/listOrderItems.topic new file mode 100644 index 00000000..e229272b --- /dev/null +++ b/documentation/topics/generated/listOrderItems.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /order/items + + +

Operation ID: listOrderItems

+

Get all items for a specific order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
order_idqueryyesinteger
+
+ + + + +
StatusDescriptionContent Types
200Order items retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/OrderItem" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listOrders.topic b/documentation/topics/generated/listOrders.topic new file mode 100644 index 00000000..720210e9 --- /dev/null +++ b/documentation/topics/generated/listOrders.topic @@ -0,0 +1,48 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /orders + + +

Operation ID: listOrders

+

Retrieve a paginated list of orders

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
noobject
show_wash_subscriptionquerynostring
+
+ + + + + + +
StatusDescriptionContent Types
200Orders retrieved successfullyapplication/json
401
403
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Order" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listPasskeys.topic b/documentation/topics/generated/listPasskeys.topic new file mode 100644 index 00000000..84f00fcd --- /dev/null +++ b/documentation/topics/generated/listPasskeys.topic @@ -0,0 +1,46 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /account/security/passkeys + + +

Operation ID: listPasskeys

+

List passkeys for the authenticated user

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
StatusDescriptionContent Types
200A list of passkeysapplication/json
400Invalid session or requestapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Passkey" + }, + "type": "array" +} + +

Schema for response 400 (application/json):

+ +{ + "$ref": "#/components/schemas/Error" +} + +
+
diff --git a/documentation/topics/generated/listPermissions.topic b/documentation/topics/generated/listPermissions.topic new file mode 100644 index 00000000..ba1be68c --- /dev/null +++ b/documentation/topics/generated/listPermissions.topic @@ -0,0 +1,39 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /permissions + + +

Operation ID: listPermissions

+

Get list of all available permissions

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Permissions retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Permission" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listPlateScanners.topic b/documentation/topics/generated/listPlateScanners.topic new file mode 100644 index 00000000..a2d11180 --- /dev/null +++ b/documentation/topics/generated/listPlateScanners.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /numberplatescanners + + +

Operation ID: listPlateScanners

+

Get a list of all number plate scanners

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + +
StatusDescriptionContent Types
200Plate scanners retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listPlateScans.topic b/documentation/topics/generated/listPlateScans.topic new file mode 100644 index 00000000..2b080830 --- /dev/null +++ b/documentation/topics/generated/listPlateScans.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /numberplatescans + + +

Operation ID: listPlateScans

+

Get a list of license plate scans

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + +
StatusDescriptionContent Types
200Plate scans retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listProducts.topic b/documentation/topics/generated/listProducts.topic new file mode 100644 index 00000000..a788c167 --- /dev/null +++ b/documentation/topics/generated/listProducts.topic @@ -0,0 +1,50 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /products + + +

Operation ID: listProducts

+

Retrieve a list of products with optional filters for customer pricing and department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + + + +
NameInRequiredTypeDescription
customer_idquerynointegerCustomer ID for custom pricing
department_idquerynointegerDepartment ID for department-specific pricing
categoryquerynointegerFilter by category ID
idquerynointegerGet specific product by ID
final_pricequerynobooleanWhether to return final prices including discounts
noobject
+
+ + + + +
StatusDescriptionContent Types
200Products retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/Product" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listRoles.topic b/documentation/topics/generated/listRoles.topic new file mode 100644 index 00000000..70c84ccb --- /dev/null +++ b/documentation/topics/generated/listRoles.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /roles + + +

Operation ID: listRoles

+

List roles

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listSelfserveConditionRules.topic b/documentation/topics/generated/listSelfserveConditionRules.topic new file mode 100644 index 00000000..d85593fd --- /dev/null +++ b/documentation/topics/generated/listSelfserveConditionRules.topic @@ -0,0 +1,52 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/condition/rules + + +

Operation ID: listSelfserveConditionRules

+

Retrieve a list of self-serve condition rules.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + + + +
NameInRequiredTypeDescription
idquerynointegerFilter by rule ID
condition_idquerynointegerFilter by condition ID
typequerynostringFilter by rule type
object_typequerynostringFilter by object type
object_idquerynointegerFilter by object ID
noobject
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully retrieved condition rulesapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveConditionRule" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listSelfserveConditions.topic b/documentation/topics/generated/listSelfserveConditions.topic new file mode 100644 index 00000000..83e79281 --- /dev/null +++ b/documentation/topics/generated/listSelfserveConditions.topic @@ -0,0 +1,53 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/conditions + + +

Operation ID: listSelfserveConditions

+

Retrieve a list of self-serve conditions for a department, lane, or product.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + + + + +
NameInRequiredTypeDescription
idquerynointegerFilter by condition ID
departmentquerynointegerFilter by department ID
lanequerynointegerFilter by lane ID
productquerynointegerFilter by product ID
condition_idquerynointegerFilter by condition ID
machine_type_idquerynointegerFilter by reusable machine type ID
noobject
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully retrieved conditionsapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveCondition" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listSelfserveMachineTypes.topic b/documentation/topics/generated/listSelfserveMachineTypes.topic new file mode 100644 index 00000000..fbd6355f --- /dev/null +++ b/documentation/topics/generated/listSelfserveMachineTypes.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/machine-types + + +

Operation ID: listSelfserveMachineTypes

+

List reusable self-serve machine types

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
idquerynointeger
noobject
+
+ + + + + +
StatusDescriptionContent Types
200Successfully retrieved machine typesapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "oneOf": [ + { + "$ref": "#/components/schemas/SelfserveMachineType" + }, + { + "items": { + "$ref": "#/components/schemas/SelfserveMachineType" + }, + "type": "array" + } + ] +} + +
+
diff --git a/documentation/topics/generated/listSelfserveQuestions.topic b/documentation/topics/generated/listSelfserveQuestions.topic new file mode 100644 index 00000000..14b481b3 --- /dev/null +++ b/documentation/topics/generated/listSelfserveQuestions.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/questions + + +

Operation ID: listSelfserveQuestions

+

Retrieve a list of self-serve questions for a department, lane, or product.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + + +
NameInRequiredTypeDescription
idquerynointegerFilter by question ID
departmentquerynointegerFilter by department ID
lanequerynointegerFilter by lane ID
productquerynointegerFilter by product ID
noobject
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully retrieved questionsapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveQuestion" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listSelfserveTaskAttachments.topic b/documentation/topics/generated/listSelfserveTaskAttachments.topic new file mode 100644 index 00000000..afee7249 --- /dev/null +++ b/documentation/topics/generated/listSelfserveTaskAttachments.topic @@ -0,0 +1,42 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/tasks/attachments + + +

Operation ID: listSelfserveTaskAttachments

+

Retrieve a list of attachments for a specific self-serve task.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerTask ID
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully retrieved task attachmentsapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listSelfserveTasks.topic b/documentation/topics/generated/listSelfserveTasks.topic new file mode 100644 index 00000000..1ada7084 --- /dev/null +++ b/documentation/topics/generated/listSelfserveTasks.topic @@ -0,0 +1,52 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/tasks + + +

Operation ID: listSelfserveTasks

+

Retrieve a list of self-serve tasks for a department, lane, product, or condition_id.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + + + +
NameInRequiredTypeDescription
idquerynointegerFilter by task ID
departmentquerynointegerFilter by department ID
lanequerynointegerFilter by lane ID
productquerynointegerFilter by product ID
condition_idquerynointegerFilter by condition ID
noobject
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully retrieved tasksapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveTask" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listSelfserveVehicleConditions.topic b/documentation/topics/generated/listSelfserveVehicleConditions.topic new file mode 100644 index 00000000..92dd1695 --- /dev/null +++ b/documentation/topics/generated/listSelfserveVehicleConditions.topic @@ -0,0 +1,53 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /department/selfserve/vehicle/conditions + + +

Operation ID: listSelfserveVehicleConditions

+

Retrieve a list of vehicle conditions for a department, lane, reg, or question. Customers will only see their own vehicle conditions.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + + + + +
NameInRequiredTypeDescription
idquerynointegerFilter by condition ID
departmentquerynointegerFilter by department ID
lanequerynointegerFilter by lane ID
regquerynostringFilter by vehicle registration number
questionquerynointegerFilter by question ID
customer_idquerynointegerFilter by customer ID
noobject
+
+ + + + + + +
StatusDescriptionContent Types
200Successfully retrieved vehicle conditionsapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/DepartmentSelfserveVehicleCondition" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listStripeCustomers.topic b/documentation/topics/generated/listStripeCustomers.topic new file mode 100644 index 00000000..c204122e --- /dev/null +++ b/documentation/topics/generated/listStripeCustomers.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/stripe/customers + + +

Operation ID: listStripeCustomers

+

Get list of Stripe customers

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Stripe customers retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listStripePrices.topic b/documentation/topics/generated/listStripePrices.topic new file mode 100644 index 00000000..bf16bde8 --- /dev/null +++ b/documentation/topics/generated/listStripePrices.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/stripe/prices + + +

Operation ID: listStripePrices

+

Get list of Stripe prices

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Stripe prices retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listStripeProducts.topic b/documentation/topics/generated/listStripeProducts.topic new file mode 100644 index 00000000..0c56775a --- /dev/null +++ b/documentation/topics/generated/listStripeProducts.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/stripe/products + + +

Operation ID: listStripeProducts

+

Get list of Stripe products

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Stripe products retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listStripeTerminalLocations.topic b/documentation/topics/generated/listStripeTerminalLocations.topic new file mode 100644 index 00000000..a3507439 --- /dev/null +++ b/documentation/topics/generated/listStripeTerminalLocations.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/stripe/terminal/locations + + +

Operation ID: listStripeTerminalLocations

+

List Stripe terminal locations

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listStripeTerminalReaders.topic b/documentation/topics/generated/listStripeTerminalReaders.topic new file mode 100644 index 00000000..56306084 --- /dev/null +++ b/documentation/topics/generated/listStripeTerminalReaders.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/stripe/terminal/readers + + +

Operation ID: listStripeTerminalReaders

+

List Stripe terminal readers

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listSubuserGrants.topic b/documentation/topics/generated/listSubuserGrants.topic new file mode 100644 index 00000000..63aaea65 --- /dev/null +++ b/documentation/topics/generated/listSubuserGrants.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /subusers/grants + + +

Operation ID: listSubuserGrants

+

Returns subuser grant records filtered by `customer_number` and/or `subuser_id`.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
customer_numberquerynointegere-conomic customer number to filter by
subuser_idquerynointegerSubuser ID to filter by
+
+ + + + + + + +
StatusDescriptionContent Types
200Grants fetchedapplication/json
400
401
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "grants": { + "items": { + "$ref": "#/components/schemas/SubuserGrant" + }, + "type": "array" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/listSubuserPermissionNodes.topic b/documentation/topics/generated/listSubuserPermissionNodes.topic new file mode 100644 index 00000000..c0373797 --- /dev/null +++ b/documentation/topics/generated/listSubuserPermissionNodes.topic @@ -0,0 +1,46 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /subusers/permission-nodes + + +

Operation ID: listSubuserPermissionNodes

+

Returns grouped permission nodes available for subuser grants.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + +
StatusDescriptionContent Types
200Permission nodes fetchedapplication/json
401
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "permission_nodes": { + "items": { + "$ref": "#/components/schemas/PermissionNodeGroup" + }, + "type": "array" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/listSubusers.topic b/documentation/topics/generated/listSubusers.topic new file mode 100644 index 00000000..e28e61c0 --- /dev/null +++ b/documentation/topics/generated/listSubusers.topic @@ -0,0 +1,105 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /subusers + + +

Operation ID: listSubusers

+

Returns a paginated list of subusers (drivers) that have enabled grants tied to the +authenticated user's customer number. Only subusers with at least one enabled, non-deleted +grant for the caller's customer are returned. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + +
NameInRequiredTypeDescription
pagequerynointeger
limitquerynointeger
searchquerynostring
include_non_enabledquerynobooleanInclude subusers that only have non-enabled grants (default false)
+
+ + + + + + +
StatusDescriptionContent Types
200List of visible subusersapplication/json
401
500
+

Schema for response 200 (application/json):

+ +{ + "items": { + "properties": { + "created_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "email": { + "format": "email", + "nullable": true, + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "nullable": true, + "type": "string" + }, + "permissions": { + "description": "Aggregated permission keys granted for the caller's customer", + "items": { + "type": "string" + }, + "type": "array" + }, + "phone": { + "nullable": true, + "type": "integer" + }, + "phone_country_code": { + "nullable": true, + "type": "integer" + }, + "suspended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "two_factor_enabled": { + "description": "Indicates if 2FA is enabled for this account", + "type": "boolean" + }, + "updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listSuperuserDepartments.topic b/documentation/topics/generated/listSuperuserDepartments.topic new file mode 100644 index 00000000..9b828ca3 --- /dev/null +++ b/documentation/topics/generated/listSuperuserDepartments.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /superuser/department + + +

Operation ID: listSuperuserDepartments

+

List departments (superuser)

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listUsers.topic b/documentation/topics/generated/listUsers.topic new file mode 100644 index 00000000..c5124b7d --- /dev/null +++ b/documentation/topics/generated/listUsers.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /users + + +

Operation ID: listUsers

+

Retrieve a paginated list of users

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ + + + + + +
StatusDescriptionContent Types
200Users retrieved successfullyapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{ + "items": { + "$ref": "#/components/schemas/User" + }, + "type": "array" +} + +
+
diff --git a/documentation/topics/generated/listVehicles.topic b/documentation/topics/generated/listVehicles.topic new file mode 100644 index 00000000..f2c16dc5 --- /dev/null +++ b/documentation/topics/generated/listVehicles.topic @@ -0,0 +1,68 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /vehicles + + +

Operation ID: listVehicles

+

List vehicles or fetch a specific vehicle when `id` is provided. + +- When `id` is present, returns a single vehicle object (404 if not found). +- Otherwise returns a paginated list of vehicles. + +Permissions: +- Own scope: `list_own_vehicles` (linked to subuser node `VEHICLES_LIST`). +- Broader scope: `list_vehicles_other`. + +Subusers may specify header `X-Customer-Number` to target a specific customer. If the broader +permission is missing, the list will automatically be restricted to the effective customer context. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + +
NameInRequiredTypeDescription
noobject
idquerynointeger
regquerynostring
customer_idquerynointeger
+
+ + + + + + +
StatusDescriptionContent Types
200Vehicle(s) retrieved successfullyapplication/json
403
404
+

Schema for response 200 (application/json):

+ +{ + "oneOf": [ + { + "$ref": "#/components/schemas/Vehicle" + }, + { + "items": { + "$ref": "#/components/schemas/Vehicle" + }, + "type": "array" + } + ] +} + +
+
diff --git a/documentation/topics/generated/listWashCertificates.topic b/documentation/topics/generated/listWashCertificates.topic new file mode 100644 index 00000000..ffd2ad25 --- /dev/null +++ b/documentation/topics/generated/listWashCertificates.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/washcertificates + + +

Operation ID: listWashCertificates

+

List wash certificates

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listXlvaskCustomers.topic b/documentation/topics/generated/listXlvaskCustomers.topic new file mode 100644 index 00000000..b1a66023 --- /dev/null +++ b/documentation/topics/generated/listXlvaskCustomers.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/xlvask/customers + + +

Operation ID: listXlvaskCustomers

+

Get list of customers from XLVask

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200XLVask customers retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/listXlvaskVehicles.topic b/documentation/topics/generated/listXlvaskVehicles.topic new file mode 100644 index 00000000..a25ef811 --- /dev/null +++ b/documentation/topics/generated/listXlvaskVehicles.topic @@ -0,0 +1,34 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/xlvask/vehicles + + +

Operation ID: listXlvaskVehicles

+

Get list of vehicles from XLVask

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
StatusDescriptionContent Types
200XLVask vehicles retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/logout.topic b/documentation/topics/generated/logout.topic new file mode 100644 index 00000000..6d01dac1 --- /dev/null +++ b/documentation/topics/generated/logout.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /auth/logout + + +

Operation ID: logout

+

Invalidate the current authentication token

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
StatusDescriptionContent Types
200Logout successfulapplication/json
401
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "message": { + "example": "Logged out", + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/lookupCvr.topic b/documentation/topics/generated/lookupCvr.topic new file mode 100644 index 00000000..34e4c859 --- /dev/null +++ b/documentation/topics/generated/lookupCvr.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /cvr/lookup + + +

Operation ID: lookupCvr

+

Get detailed information for a CVR number

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
cvrqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200CVR information retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/markOrderCompleted.topic b/documentation/topics/generated/markOrderCompleted.topic new file mode 100644 index 00000000..52716e43 --- /dev/null +++ b/documentation/topics/generated/markOrderCompleted.topic @@ -0,0 +1,52 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /orders/mark_as_completed + + +

Operation ID: markOrderCompleted

+

Mark an order as completed

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
200Order marked as completed successfullyapplication/json
400
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/motorApiLookup.topic b/documentation/topics/generated/motorApiLookup.topic new file mode 100644 index 00000000..389886d1 --- /dev/null +++ b/documentation/topics/generated/motorApiLookup.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/motorapi/lookup + + +

Operation ID: motorApiLookup

+

Look up vehicle information using license plate

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
platequeryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Vehicle information retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/passkeyChallenge.topic b/documentation/topics/generated/passkeyChallenge.topic new file mode 100644 index 00000000..00b56dd8 --- /dev/null +++ b/documentation/topics/generated/passkeyChallenge.topic @@ -0,0 +1,108 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/passkey/challenge + + +

Operation ID: passkeyChallenge

+

Generates a WebAuthn PublicKeyCredentialRequestOptions payload. If customer_number is provided, allowCredentials will be populated with existing passkeys for that account. Otherwise, a challenge is issued for discoverable credentials.

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "customer_number": { + "description": "Optional customer's e-conomic customer number", + "example": 12345, + "type": "integer" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + } + }, + "required": [ + "g_recaptcha_response" + ], + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
200Challenge generatedapplication/json
400
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "challenge_token": { + "description": "Temporary token binding the challenge to the login attempt", + "type": "string" + }, + "publicKey": { + "properties": { + "allowCredentials": { + "items": { + "properties": { + "id": { + "description": "Base64URL-encoded credential ID", + "type": "string" + }, + "transports": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "example": "public-key", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "challenge": { + "description": "Base64URL-encoded challenge", + "type": "string" + }, + "rpId": { + "description": "Relying party ID (truckwash.io or localhost)", + "example": "truckwash.io", + "type": "string" + }, + "timeout": { + "description": "Timeout in milliseconds", + "type": "integer" + }, + "userVerification": { + "enum": [ + "required", + "preferred", + "discouraged" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/passkeyVerify.topic b/documentation/topics/generated/passkeyVerify.topic new file mode 100644 index 00000000..3dc1f1b0 --- /dev/null +++ b/documentation/topics/generated/passkeyVerify.topic @@ -0,0 +1,135 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/passkey/verify + + +

Operation ID: passkeyVerify

+

Verifies the WebAuthn assertion and challenge token. Returns a session token on success.

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "challenge_token": { + "description": "The token returned by the challenge endpoint", + "type": "string" + }, + "credential": { + "description": "The WebAuthn PublicKeyCredential object (assertion)", + "properties": { + "clientExtensionResults": { + "type": "object" + }, + "id": { + "description": "The credential ID (base64url)", + "type": "string" + }, + "rawId": { + "description": "The raw credential ID (base64url)", + "type": "string" + }, + "response": { + "properties": { + "authenticatorData": { + "description": "Base64URL-encoded authenticator data", + "type": "string" + }, + "clientDataJSON": { + "description": "Base64URL-encoded client data", + "type": "string" + }, + "signature": { + "description": "Base64URL-encoded signature", + "type": "string" + }, + "userHandle": { + "description": "Base64URL-encoded user handle", + "nullable": true, + "type": "string" + } + }, + "required": [ + "clientDataJSON", + "authenticatorData", + "signature" + ], + "type": "object" + }, + "type": { + "example": "public-key", + "type": "string" + } + }, + "required": [ + "id", + "rawId", + "type", + "response" + ], + "type": "object" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + } + }, + "required": [ + "challenge_token", + "credential", + "g_recaptcha_response" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Verification successful, session startedapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{ + "oneOf": [ + { + "properties": { + "token": { + "description": "Bearer token for customer", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + { + "properties": { + "session": { + "description": "Session token for subuser", + "type": "string" + } + }, + "required": [ + "session" + ], + "type": "object" + } + ] +} + +
+
diff --git a/documentation/topics/generated/rebuildSystemSearchCache.topic b/documentation/topics/generated/rebuildSystemSearchCache.topic new file mode 100644 index 00000000..0830a16d --- /dev/null +++ b/documentation/topics/generated/rebuildSystemSearchCache.topic @@ -0,0 +1,47 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /superuser/search/system/cache/rebuild + + +

Operation ID: rebuildSystemSearchCache

+

Queues a cache rebuild request and clears active query/intent cache namespaces immediately.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/SystemSearchCacheRebuildRequest" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Cache rebuild queued successfullyapplication/json
401
403
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SystemSearchCacheRebuildResponse" +} + +
+
diff --git a/documentation/topics/generated/recordDepartmentPlateScan.topic b/documentation/topics/generated/recordDepartmentPlateScan.topic new file mode 100644 index 00000000..50bfaa73 --- /dev/null +++ b/documentation/topics/generated/recordDepartmentPlateScan.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /numberplatescans/department + + +

Operation ID: recordDepartmentPlateScan

+

Record plate scan for department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "department_id": { + "type": "integer" + }, + "plate": { + "type": "string" + } + }, + "required": [ + "plate", + "department_id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
201Plate scan recorded successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/recordPlateScan.topic b/documentation/topics/generated/recordPlateScan.topic new file mode 100644 index 00000000..139a6bc4 --- /dev/null +++ b/documentation/topics/generated/recordPlateScan.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /numberplatescans + + +

Operation ID: recordPlateScan

+

Record a new license plate scan

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "lane_id": { + "type": "integer" + }, + "plate": { + "type": "string" + } + }, + "required": [ + "plate", + "lane_id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
201Plate scan recorded successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/registerCustomerByCvr.topic b/documentation/topics/generated/registerCustomerByCvr.topic new file mode 100644 index 00000000..b3aa005e --- /dev/null +++ b/documentation/topics/generated/registerCustomerByCvr.topic @@ -0,0 +1,95 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/register/cvr + + +

Operation ID: registerCustomerByCvr

+

Register a new customer account using Danish CVR number

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "companyPhone": { + "description": "Company phone number", + "example": 21754690, + "maximum": 9999999999, + "minimum": 10000000, + "type": "integer" + }, + "contactEmail": { + "description": "Contact email", + "example": "contact@company.dk", + "format": "email", + "maxLength": 255, + "minLength": 5, + "type": "string" + }, + "contactName": { + "description": "Contact person name", + "example": "Mikkel", + "type": "string" + }, + "contactPhone": { + "description": "Contact phone number", + "example": 21754690, + "maximum": 9999999999, + "minimum": 10000000, + "type": "integer" + }, + "cvr": { + "description": "Danish CVR number", + "example": "44794780", + "maxLength": 20, + "minLength": 8, + "type": "string" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + }, + "invoiceEmail": { + "description": "Email for invoices", + "example": "invoice@company.dk", + "format": "email", + "maxLength": 255, + "minLength": 5, + "type": "string" + } + }, + "required": [ + "cvr", + "companyPhone", + "invoiceEmail", + "contactEmail", + "contactPhone", + "contactName", + "g_recaptcha_response" + ], + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
201Customer registered successfullyapplication/json
400
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/removeDepartmentCategory.topic b/documentation/topics/generated/removeDepartmentCategory.topic new file mode 100644 index 00000000..754d69cf --- /dev/null +++ b/documentation/topics/generated/removeDepartmentCategory.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /departments/categories + + +

Operation ID: removeDepartmentCategory

+

Remove category from department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/removeRolePermission.topic b/documentation/topics/generated/removeRolePermission.topic new file mode 100644 index 00000000..70c0db73 --- /dev/null +++ b/documentation/topics/generated/removeRolePermission.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + DELETE /roles/permissions + + +

Operation ID: removeRolePermission

+

Remove permission from role

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
role_idqueryyesinteger
permissionqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/renamePasskey.topic b/documentation/topics/generated/renamePasskey.topic new file mode 100644 index 00000000..a1386ad1 --- /dev/null +++ b/documentation/topics/generated/renamePasskey.topic @@ -0,0 +1,56 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PATCH /account/security/passkeys/{id} + + +

Operation ID: renamePasskey

+

Rename a passkey

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idpathyesinteger
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/PasskeyRenameRequest" +} + +
+ + + + + +
StatusDescriptionContent Types
200Passkey renamedapplication/json
404Not foundapplication/json
+

Schema for response 200 (application/json):

+ +{} + +

Schema for response 404 (application/json):

+ +{ + "$ref": "#/components/schemas/Error" +} + +
+
diff --git a/documentation/topics/generated/requestPasswordReset.topic b/documentation/topics/generated/requestPasswordReset.topic new file mode 100644 index 00000000..b1183759 --- /dev/null +++ b/documentation/topics/generated/requestPasswordReset.topic @@ -0,0 +1,60 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/password-reset/request + + +

Operation ID: requestPasswordReset

+

Send an email with a password reset token to the customer's email address

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "customer_number": { + "description": "The customer number", + "example": 123456, + "type": "integer" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + } + }, + "required": [ + "customer_number", + "g_recaptcha_response" + ], + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
200Request processedapplication/json
400
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/searchCustomers.topic b/documentation/topics/generated/searchCustomers.topic new file mode 100644 index 00000000..4667fa02 --- /dev/null +++ b/documentation/topics/generated/searchCustomers.topic @@ -0,0 +1,48 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /customers/search + + +

Operation ID: searchCustomers

+

Search for customers using various criteria

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "query": { + "type": "string" + } + }, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Customers found successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/searchCvr.topic b/documentation/topics/generated/searchCvr.topic new file mode 100644 index 00000000..2fc1ecd3 --- /dev/null +++ b/documentation/topics/generated/searchCvr.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /cvr/search + + +

Operation ID: searchCvr

+

Search for companies by name or CVR

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
queryqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Search results retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/searchVehicles.topic b/documentation/topics/generated/searchVehicles.topic new file mode 100644 index 00000000..f361fddf --- /dev/null +++ b/documentation/topics/generated/searchVehicles.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /vehicles/search + + +

Operation ID: searchVehicles

+

Search vehicles

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
searchqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/sendDepartmentGoalProgressAlertTest.topic b/documentation/topics/generated/sendDepartmentGoalProgressAlertTest.topic new file mode 100644 index 00000000..888e92fc --- /dev/null +++ b/documentation/topics/generated/sendDepartmentGoalProgressAlertTest.topic @@ -0,0 +1,150 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /goals/department/progress-alert/test + + +

Operation ID: sendDepartmentGoalProgressAlertTest

+

Sends a progress alert for a department goal to the destination defined in the goal's criteria. + +Permission required: `goals_department_progress_alert_test`. + +Access control: +- The caller must be a superuser or belong to all departments targeted by the goal. + +Behavior: +- Looks up the goal by `id`. +- Rebuilds the criteria from stored JSON and attaches the goal's departments. +- Renders the alert using the server-side renderer (respecting progress type/style/format and destination limits). +- Sends the alert to Slack, Email, or SMS depending on `progress_alert_destination`, unless overridden. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "department_id": { + "description": "Department id to use that department's Slack webhook when destination is SLACK", + "example": 3, + "type": "integer" + }, + "email_to": { + "description": "Email recipient when destination is EMAIL", + "example": "tester@example.com", + "format": "email", + "type": "string" + }, + "id": { + "description": "The department goal id", + "example": 42, + "type": "integer" + }, + "overrideDestination": { + "description": "Override the destination for this test", + "enum": [ + "SLACK", + "EMAIL", + "SMS", + "NONE" + ], + "example": "SLACK", + "type": "string" + }, + "slack_webhook": { + "description": "Slack webhook URL when destination is SLACK", + "example": "https://hooks.slack.com/services/T000/B000/XXX", + "type": "string" + }, + "sms_to": { + "description": "One or more MSISDN recipients when destination is SMS", + "oneOf": [ + { + "description": "Comma or semicolon separated list", + "example": "+4512345678, +4598765432", + "type": "string" + }, + { + "example": [ + "+4512345678", + "+4598765432" + ], + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "subject": { + "description": "Optional email subject when destination is EMAIL", + "example": "Dept Goal Progress Test", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + + + + + + +
StatusDescriptionContent Types
200Alert sent successfullyapplication/json
400
401
403
404
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "destination": { + "description": "Final destination used", + "enum": [ + "SLACK", + "EMAIL", + "SMS", + "NONE" + ], + "type": "string" + }, + "id": { + "description": "Goal id", + "type": "integer" + }, + "message_preview": { + "description": "Rendered message preview", + "type": "string" + }, + "provider_response": { + "description": "Provider-specific response or status message" + }, + "target": { + "description": "The target used for delivery (email address, phone numbers, department id, or webhook)" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/sendSelfServeLaneCommand.topic b/documentation/topics/generated/sendSelfServeLaneCommand.topic new file mode 100644 index 00000000..3c626870 --- /dev/null +++ b/documentation/topics/generated/sendSelfServeLaneCommand.topic @@ -0,0 +1,68 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /modules/self-serve/lane/command + + +

Operation ID: sendSelfServeLaneCommand

+

Send a command (e.g., start, stop, reset) to a self-serve lane

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "command": { + "enum": [ + "START", + "STOP", + "RESET", + "RESERVE", + "RELEASE" + ], + "type": "string" + }, + "lane_id": { + "type": "integer" + }, + "license_plate": { + "description": "Required for START command", + "type": "string" + } + }, + "required": [ + "lane_id", + "command" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Command sent successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SelfServeLaneStatus" +} + +
+
diff --git a/documentation/topics/generated/setDepartmentPrice.topic b/documentation/topics/generated/setDepartmentPrice.topic new file mode 100644 index 00000000..b1f4531b --- /dev/null +++ b/documentation/topics/generated/setDepartmentPrice.topic @@ -0,0 +1,59 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /superuser/department/prices + + +

Operation ID: setDepartmentPrice

+

Set department price

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "department_id": { + "type": "integer" + }, + "price": { + "type": "integer" + }, + "product_id": { + "type": "integer" + } + }, + "required": [ + "department_id", + "product_id", + "price" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/setDepartmentTerminalLocation.topic b/documentation/topics/generated/setDepartmentTerminalLocation.topic new file mode 100644 index 00000000..f99d4537 --- /dev/null +++ b/documentation/topics/generated/setDepartmentTerminalLocation.topic @@ -0,0 +1,55 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /modules/stripe/department/terminal/location + + +

Operation ID: setDepartmentTerminalLocation

+

Set department terminal location

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + }, + "location": { + "type": "string" + } + }, + "required": [ + "id", + "location" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/setDepartmentVariable.topic b/documentation/topics/generated/setDepartmentVariable.topic new file mode 100644 index 00000000..13de77ff --- /dev/null +++ b/documentation/topics/generated/setDepartmentVariable.topic @@ -0,0 +1,59 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /superuser/department/variables + + +

Operation ID: setDepartmentVariable

+

Set department variable

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "department_id": { + "type": "integer" + }, + "value": { + "type": "string" + }, + "variable": { + "type": "string" + } + }, + "required": [ + "department_id", + "variable", + "value" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/setPasswordUsingResetToken.topic b/documentation/topics/generated/setPasswordUsingResetToken.topic new file mode 100644 index 00000000..af38e54d --- /dev/null +++ b/documentation/topics/generated/setPasswordUsingResetToken.topic @@ -0,0 +1,71 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/password-reset/set + + +

Operation ID: setPasswordUsingResetToken

+

Update the customer password using a valid reset token

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "g_recaptcha_response": { + "description": "reCAPTCHA verification token", + "type": "string" + }, + "password": { + "description": "The new password", + "type": "string" + }, + "token": { + "description": "The password reset token", + "type": "string" + } + }, + "required": [ + "token", + "password", + "g_recaptcha_response" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Password updated successfullyapplication/json
400
404Invalid or expired tokenapplication/json
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" +} + +

Schema for response 404 (application/json):

+ +{ + "$ref": "#/components/schemas/Error" +} + +
+
diff --git a/documentation/topics/generated/setSelfServeLaneAllowedServices.topic b/documentation/topics/generated/setSelfServeLaneAllowedServices.topic new file mode 100644 index 00000000..798db1fa --- /dev/null +++ b/documentation/topics/generated/setSelfServeLaneAllowedServices.topic @@ -0,0 +1,76 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /modules/self-serve/lane/services/allowed + + +

Operation ID: setSelfServeLaneAllowedServices

+

Updates the set of services that are allowed to be manually activated for a given self-serve lane, +derived from the tasks currently shown to the user after answering the self-serve questions. +This endpoint does not activate anything by itself; it only sets what is allowed to be activated. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "lane_id": { + "type": "integer" + }, + "task_ids": { + "description": "List of task IDs that are currently shown to the user", + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "lane_id" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Allowed services updatedapplication/json
401
403
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "allowed_services": { + "items": { + "type": "string" + }, + "type": "array" + }, + "lane_id": { + "type": "integer" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/setUserDiscount.topic b/documentation/topics/generated/setUserDiscount.topic new file mode 100644 index 00000000..ded9b046 --- /dev/null +++ b/documentation/topics/generated/setUserDiscount.topic @@ -0,0 +1,62 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /superuser/user/discounts + + +

Operation ID: setUserDiscount

+

Set user discount

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "discount": { + "type": "integer" + }, + "is_category": { + "type": "boolean" + }, + "object_id": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + }, + "required": [ + "discount", + "object_id", + "is_category" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/setUserKey.topic b/documentation/topics/generated/setUserKey.topic new file mode 100644 index 00000000..342a371b --- /dev/null +++ b/documentation/topics/generated/setUserKey.topic @@ -0,0 +1,58 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /superuser/user/keys + + +

Operation ID: setUserKey

+

Set user key

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "key": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/setUserPassword.topic b/documentation/topics/generated/setUserPassword.topic new file mode 100644 index 00000000..4f9f62c4 --- /dev/null +++ b/documentation/topics/generated/setUserPassword.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /superuser/user/password + + +

Operation ID: setUserPassword

+

Set user password

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "password": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + }, + "required": [ + "password" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/setVehicleAutoStartOnLpr.topic b/documentation/topics/generated/setVehicleAutoStartOnLpr.topic new file mode 100644 index 00000000..4afcfb9b --- /dev/null +++ b/documentation/topics/generated/setVehicleAutoStartOnLpr.topic @@ -0,0 +1,68 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /vehicles/set-auto-start-on-lpr + + +

Operation ID: setVehicleAutoStartOnLpr

+

Enable or disable automatic start on LPR for a vehicle in XL Vask. + +Permissions: +- Own scope: `set_auto_start_on_lpr` (linked to subuser node `VEHICLES_EDIT`). +- Broader scope: `set_auto_start_on_lpr_other`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "active": { + "type": "boolean" + }, + "id": { + "type": "integer" + } + }, + "required": [ + "id", + "active" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Successapplication/json
403
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/setVehicleTypeId.topic b/documentation/topics/generated/setVehicleTypeId.topic new file mode 100644 index 00000000..86ca158e --- /dev/null +++ b/documentation/topics/generated/setVehicleTypeId.topic @@ -0,0 +1,71 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /vehicles/set-vehicle-type-id + + +

Operation ID: setVehicleTypeId

+

Set or change the XL Vask `vehicleTypeId` for a vehicle. + +Permissions: +- Own scope: `set_vehicle_type_id` (linked to subuser node `VEHICLES_EDIT`). +- Broader scope: `set_vehicle_type_id_other`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + }, + "vehicleTypeId": { + "maxLength": 50, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "vehicleTypeId" + ], + "type": "object" +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Successapplication/json
400
403
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/setup2fa.topic b/documentation/topics/generated/setup2fa.topic new file mode 100644 index 00000000..58697c4b --- /dev/null +++ b/documentation/topics/generated/setup2fa.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/2fa/setup + + +

Operation ID: setup2fa

+

Generate a new TOTP secret for the authenticated user/subuser

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + + +
StatusDescriptionContent Types
2002FA secret generated successfullyapplication/json
401
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "qr_code_url": { + "description": "An otpauth URL for generating a QR code", + "type": "string" + }, + "secret": { + "description": "The base32 encoded TOTP secret", + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/simulateStripePayment.topic b/documentation/topics/generated/simulateStripePayment.topic new file mode 100644 index 00000000..d7f50971 --- /dev/null +++ b/documentation/topics/generated/simulateStripePayment.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /orders/module/stripe/debug/simulate_payment + + +

Operation ID: simulateStripePayment

+

Simulate Stripe payment

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "id": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/suIntimidate.topic b/documentation/topics/generated/suIntimidate.topic new file mode 100644 index 00000000..26f82583 --- /dev/null +++ b/documentation/topics/generated/suIntimidate.topic @@ -0,0 +1,58 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /su/intimidate + + +

Operation ID: suIntimidate

+

Create an authentication token for another user (Superuser only)

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "user_id": { + "type": "integer" + } + }, + "required": [ + "user_id" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "token": { + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/submitForm.topic b/documentation/topics/generated/submitForm.topic new file mode 100644 index 00000000..30942ded --- /dev/null +++ b/documentation/topics/generated/submitForm.topic @@ -0,0 +1,61 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /form + + +

Operation ID: submitForm

+

Submit a form

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "data": { + "description": "Form submission data", + "type": "object" + }, + "g_recaptcha_response": { + "description": "reCAPTCHA verification token (required if not authenticated)", + "type": "string" + }, + "id": { + "description": "Form identifier", + "type": "string" + } + }, + "required": [ + "id", + "data" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
201Form submitted successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/subuserPasswordAuth.topic b/documentation/topics/generated/subuserPasswordAuth.topic new file mode 100644 index 00000000..7f077187 --- /dev/null +++ b/documentation/topics/generated/subuserPasswordAuth.topic @@ -0,0 +1,150 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /subusers/auth/password + + +

Operation ID: subuserPasswordAuth

+

Authenticates a subuser (driver) using a password together with one of the supported +identifiers: `phone_country_code` + `phone`, `subuser_id`, or `username`. + +On success, returns a newly generated session token for the subuser. +

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "oneOf": [ + { + "properties": { + "password": { + "format": "password", + "maxLength": 255, + "minLength": 8, + "type": "string" + }, + "phone": { + "description": "Phone number (4–15 digits, no leading +)", + "example": 12345678, + "maximum": 999999999999999, + "minimum": 1000, + "type": "integer" + }, + "phone_country_code": { + "description": "Phone country code (1–3 digits)", + "example": 45, + "maximum": 999, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "phone_country_code", + "phone", + "password" + ], + "type": "object" + }, + { + "properties": { + "password": { + "format": "password", + "maxLength": 255, + "minLength": 8, + "type": "string" + }, + "subuser_id": { + "description": "Subuser ID", + "example": 42, + "type": "integer" + } + }, + "required": [ + "subuser_id", + "password" + ], + "type": "object" + }, + { + "properties": { + "password": { + "format": "password", + "maxLength": 255, + "minLength": 8, + "type": "string" + }, + "username": { + "example": "jdoe", + "maxLength": 255, + "minLength": 3, + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "type": "object" + } + ] +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Authentication successfulapplication/json
400
404
500
+

Schema for response 200 (application/json):

+ +{ + "oneOf": [ + { + "properties": { + "session": { + "description": "Newly generated subuser session token", + "example": "2f7a8c0e-9b1d-4c6a-91a9-1a2b3c4d5e6f", + "type": "string" + } + }, + "required": [ + "session" + ], + "type": "object" + }, + { + "properties": { + "2fa_required": { + "example": true, + "type": "boolean" + }, + "2fa_token": { + "description": "Temporary 2FA verification token", + "example": "557a3e7b1a2b...", + "type": "string" + } + }, + "required": [ + "2fa_required", + "2fa_token" + ], + "type": "object" + } + ] +} + +
+
diff --git a/documentation/topics/generated/syncAllBookings.topic b/documentation/topics/generated/syncAllBookings.topic new file mode 100644 index 00000000..123f12f2 --- /dev/null +++ b/documentation/topics/generated/syncAllBookings.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /superuser/bookings/sync/all + + +

Operation ID: syncAllBookings

+

Sync all bookings from external system

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/syncBooking.topic b/documentation/topics/generated/syncBooking.topic new file mode 100644 index 00000000..102f3d8c --- /dev/null +++ b/documentation/topics/generated/syncBooking.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /admin/bookings/sync + + +

Operation ID: syncBooking

+

Sync booking from external system

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/systemWideSearchGet.topic b/documentation/topics/generated/systemWideSearchGet.topic new file mode 100644 index 00000000..69950f0f --- /dev/null +++ b/documentation/topics/generated/systemWideSearchGet.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /search/system + + +

Operation ID: systemWideSearchGet

+

Search across all supported entities with permission-aware filtering and optional intent parsing debug metadata. Customer records and customer-related entities are matched against a local e-conomic customer index (name/address/email/CVR) that is refreshed by cron. Intent parsing is invoked adaptively when lexical confidence is low or when the query looks intent-driven. Results are ordered by relevance, with recent records preferred when relevance is comparable.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + + + + + + +
NameInRequiredTypeDescription
queryqueryyesstringFree-text query to search for. Supports natural-language intent fallback and domain synonyms such as `rabat` -> `discount`.
include_typesquerynoarray<SystemSearchEntityType>Comma-separated list of entity types to include. Defaults to all allowed types.
exclude_typesquerynoarray<SystemSearchEntityType>Comma-separated list of entity types to exclude.
include_associationsquerynobooleanInclude associated objects when matching a primary entity such as a customer.
debug_intentquerynobooleanInclude intent parser diagnostics in `meta.intent_parser`.
limitquerynointeger
offsetquerynointeger
+
+ + + + + + + +
StatusDescriptionContent Types
200Search results returned successfullyapplication/json
400
401
403
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SystemSearchResponse" +} + +
+
diff --git a/documentation/topics/generated/systemWideSearchPost.topic b/documentation/topics/generated/systemWideSearchPost.topic new file mode 100644 index 00000000..56a1839b --- /dev/null +++ b/documentation/topics/generated/systemWideSearchPost.topic @@ -0,0 +1,48 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /search/system + + +

Operation ID: systemWideSearchPost

+

Search across all supported entities using JSON request payload. Customer-related matches include local e-conomic customer index fields. Intent parsing may run adaptively for intent-driven natural-language queries. Results are ordered by relevance, with recent records preferred when relevance is comparable.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/SystemSearchRequest" +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Search results returned successfullyapplication/json
400
401
403
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SystemSearchResponse" +} + +
+
diff --git a/documentation/topics/generated/testEmailConfig.topic b/documentation/topics/generated/testEmailConfig.topic new file mode 100644 index 00000000..64065c9c --- /dev/null +++ b/documentation/topics/generated/testEmailConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /email/config/test + + +

Operation ID: testEmailConfig

+

Test email config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Email configuration test completedapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigTestResponse" +} + +
+
diff --git a/documentation/topics/generated/toggleVehicleAddon.topic b/documentation/topics/generated/toggleVehicleAddon.topic new file mode 100644 index 00000000..982c23f0 --- /dev/null +++ b/documentation/topics/generated/toggleVehicleAddon.topic @@ -0,0 +1,68 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /vehicles/addons/toggle + + +

Operation ID: toggleVehicleAddon

+

Enable or disable a vehicle addon for a vehicle. + +Permissions: +- Own scope: `toggle_vehicle_addon_own` (linked to subuser node `VEHICLES_EDIT`). +- Broader scope: `toggle_vehicle_addon_other`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
noobject
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "addon_id": { + "type": "integer" + }, + "vehicle_id": { + "type": "integer" + } + }, + "required": [ + "vehicle_id", + "addon_id" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Vehicle addon toggled successfullyapplication/json
403
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateBackupsConfig.topic b/documentation/topics/generated/updateBackupsConfig.topic new file mode 100644 index 00000000..2cd320ed --- /dev/null +++ b/documentation/topics/generated/updateBackupsConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /backups/config + + +

Operation ID: updateBackupsConfig

+

Update backups config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Backups configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateBirdConfig.topic b/documentation/topics/generated/updateBirdConfig.topic new file mode 100644 index 00000000..816ac0b2 --- /dev/null +++ b/documentation/topics/generated/updateBirdConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /bird/config + + +

Operation ID: updateBirdConfig

+

Update Bird config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Bird configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateBooking.topic b/documentation/topics/generated/updateBooking.topic new file mode 100644 index 00000000..9de44726 --- /dev/null +++ b/documentation/topics/generated/updateBooking.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /bookings + + +

Operation ID: updateBooking

+

Update an existing booking

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/BookingUpdate" +} + +
+ + + + +
StatusDescriptionContent Types
200Booking updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateCategory.topic b/documentation/topics/generated/updateCategory.topic new file mode 100644 index 00000000..3a0e932c --- /dev/null +++ b/documentation/topics/generated/updateCategory.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /categories + + +

Operation ID: updateCategory

+

Update an existing category

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/CategoryUpdate" +} + +
+ + + + +
StatusDescriptionContent Types
200Category updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateCollectedInvoice.topic b/documentation/topics/generated/updateCollectedInvoice.topic new file mode 100644 index 00000000..80743c56 --- /dev/null +++ b/documentation/topics/generated/updateCollectedInvoice.topic @@ -0,0 +1,41 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /collected-invoices + + +

Operation ID: updateCollectedInvoice

+

Update a collected invoice

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Collected invoice updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateDepartment.topic b/documentation/topics/generated/updateDepartment.topic new file mode 100644 index 00000000..9de3913e --- /dev/null +++ b/documentation/topics/generated/updateDepartment.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /departments + + +

Operation ID: updateDepartment

+

Update an existing department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentUpdate" +} + +
+ + + + + +
StatusDescriptionContent Types
200Department updated successfullyapplication/json
400
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateDepartmentGate.topic b/documentation/topics/generated/updateDepartmentGate.topic new file mode 100644 index 00000000..ade7718b --- /dev/null +++ b/documentation/topics/generated/updateDepartmentGate.topic @@ -0,0 +1,46 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /department/gates + + +

Operation ID: updateDepartmentGate

+

Update department gate

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentGateUpdate" +} + +
+ + + + + +
StatusDescriptionContent Types
200Department gate updated successfullyapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentGate" +} + +
+
diff --git a/documentation/topics/generated/updateDepartmentGoal.topic b/documentation/topics/generated/updateDepartmentGoal.topic new file mode 100644 index 00000000..42efe6b3 --- /dev/null +++ b/documentation/topics/generated/updateDepartmentGoal.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /goals/department + + +

Operation ID: updateDepartmentGoal

+

Update an existing department goal by `id`. + +Access control: +- The creator (`created_by`) may update regardless of department membership. +- Otherwise the user must satisfy the same subset rule as for read access, and any new `departments` provided must also be a subset unless the user has `superuser`. +

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentGoalUpdate" +} + +
+ + + + + + + + +
StatusDescriptionContent Types
200Department goal updated successfullyapplication/json
400
401
403
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentGoal" +} + +
+
diff --git a/documentation/topics/generated/updateDepartmentLane.topic b/documentation/topics/generated/updateDepartmentLane.topic new file mode 100644 index 00000000..2e4e2c0c --- /dev/null +++ b/documentation/topics/generated/updateDepartmentLane.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /department/lanes + + +

Operation ID: updateDepartmentLane

+

Update an existing department lane

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentLaneUpdate" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Department lane updated successfullyapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateDepartmentRelay.topic b/documentation/topics/generated/updateDepartmentRelay.topic new file mode 100644 index 00000000..b75f722e --- /dev/null +++ b/documentation/topics/generated/updateDepartmentRelay.topic @@ -0,0 +1,46 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /department/relays + + +

Operation ID: updateDepartmentRelay

+

Update department relay

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/DepartmentRelayUpdate" +} + +
+ + + + + +
StatusDescriptionContent Types
200Department relay updated successfullyapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentRelay" +} + +
+
diff --git a/documentation/topics/generated/updateDepartmentSelfServeEnabled.topic b/documentation/topics/generated/updateDepartmentSelfServeEnabled.topic new file mode 100644 index 00000000..20b8c2fc --- /dev/null +++ b/documentation/topics/generated/updateDepartmentSelfServeEnabled.topic @@ -0,0 +1,49 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /departments/self-serve/enabled + + +

Operation ID: updateDepartmentSelfServeEnabled

+

Enable or disable self-serve for a specific department

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
idqueryyesintegerDepartment ID
enabledqueryyesstringEnabled status (true/false)
+
+ + + + + +
StatusDescriptionContent Types
200Status updated successfullyapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/updateEconomicConfig.topic b/documentation/topics/generated/updateEconomicConfig.topic new file mode 100644 index 00000000..e372fe65 --- /dev/null +++ b/documentation/topics/generated/updateEconomicConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /economic/config + + +

Operation ID: updateEconomicConfig

+

Update e-conomic config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200e-conomic configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateEmailConfig.topic b/documentation/topics/generated/updateEmailConfig.topic new file mode 100644 index 00000000..f5486a1b --- /dev/null +++ b/documentation/topics/generated/updateEmailConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /email/config + + +

Operation ID: updateEmailConfig

+

Update email config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Email configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateEntraConfig.topic b/documentation/topics/generated/updateEntraConfig.topic new file mode 100644 index 00000000..e46bf962 --- /dev/null +++ b/documentation/topics/generated/updateEntraConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /entra/config + + +

Operation ID: updateEntraConfig

+

Update Entra config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Entra configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateFxRatesApiConfig.topic b/documentation/topics/generated/updateFxRatesApiConfig.topic new file mode 100644 index 00000000..4396260a --- /dev/null +++ b/documentation/topics/generated/updateFxRatesApiConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /fxratesapi/config + + +

Operation ID: updateFxRatesApiConfig

+

Update FXRatesAPI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200FXRatesAPI configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateGatewayApiConfig.topic b/documentation/topics/generated/updateGatewayApiConfig.topic new file mode 100644 index 00000000..487d53d6 --- /dev/null +++ b/documentation/topics/generated/updateGatewayApiConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /gatewayapi/config + + +

Operation ID: updateGatewayApiConfig

+

Update GatewayAPI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200GatewayAPI configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateLicensePlateRecognizerConfig.topic b/documentation/topics/generated/updateLicensePlateRecognizerConfig.topic new file mode 100644 index 00000000..97613111 --- /dev/null +++ b/documentation/topics/generated/updateLicensePlateRecognizerConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /licenseplaterecognizer/config + + +

Operation ID: updateLicensePlateRecognizerConfig

+

Update LicensePlateRecognizer config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200LicensePlateRecognizer configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateLimbleConfig.topic b/documentation/topics/generated/updateLimbleConfig.topic new file mode 100644 index 00000000..cfe9ccad --- /dev/null +++ b/documentation/topics/generated/updateLimbleConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /limble/config + + +

Operation ID: updateLimbleConfig

+

Update Limble config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Limble configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateMotorApiConfig.topic b/documentation/topics/generated/updateMotorApiConfig.topic new file mode 100644 index 00000000..cd258360 --- /dev/null +++ b/documentation/topics/generated/updateMotorApiConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /motorapi/config + + +

Operation ID: updateMotorApiConfig

+

Update MotorAPI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200MotorAPI configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateOcrSpaceConfig.topic b/documentation/topics/generated/updateOcrSpaceConfig.topic new file mode 100644 index 00000000..b3da1a1b --- /dev/null +++ b/documentation/topics/generated/updateOcrSpaceConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /ocrspace/config + + +

Operation ID: updateOcrSpaceConfig

+

Update OcrSpace config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200OcrSpace configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateOpenAiConfig.topic b/documentation/topics/generated/updateOpenAiConfig.topic new file mode 100644 index 00000000..48cc2254 --- /dev/null +++ b/documentation/topics/generated/updateOpenAiConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /openai/config + + +

Operation ID: updateOpenAiConfig

+

Update OpenAI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200OpenAI configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateOrder.topic b/documentation/topics/generated/updateOrder.topic new file mode 100644 index 00000000..a6eb0cc7 --- /dev/null +++ b/documentation/topics/generated/updateOrder.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /order + + +

Operation ID: updateOrder

+

Update an existing order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/OrderUpdate" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Order updated successfullyapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateOrderItem.topic b/documentation/topics/generated/updateOrderItem.topic new file mode 100644 index 00000000..e16c73b9 --- /dev/null +++ b/documentation/topics/generated/updateOrderItem.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /order/items + + +

Operation ID: updateOrderItem

+

Update an existing order item

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/OrderItemUpdate" +} + +
+ + + + + +
StatusDescriptionContent Types
200Order item updated successfullyapplication/json
400
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateOrders.topic b/documentation/topics/generated/updateOrders.topic new file mode 100644 index 00000000..cfdb0ccc --- /dev/null +++ b/documentation/topics/generated/updateOrders.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /orders + + +

Operation ID: updateOrders

+

Update an existing order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/OrderUpdate" +} + +
+ + + + + +
StatusDescriptionContent Types
200Order updated successfullyapplication/json
400
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updatePlateScanner.topic b/documentation/topics/generated/updatePlateScanner.topic new file mode 100644 index 00000000..d657599f --- /dev/null +++ b/documentation/topics/generated/updatePlateScanner.topic @@ -0,0 +1,63 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /numberplatescanners + + +

Operation ID: updatePlateScanner

+

Update plate scanner

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "department_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string" + } + }, + "required": [ + "id", + "department_id", + "name", + "notes" + ], + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Plate scanner updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateProduct.topic b/documentation/topics/generated/updateProduct.topic new file mode 100644 index 00000000..96008a94 --- /dev/null +++ b/documentation/topics/generated/updateProduct.topic @@ -0,0 +1,44 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /products + + +

Operation ID: updateProduct

+

Update an existing product

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/ProductUpdate" +} + +
+ + + + + +
StatusDescriptionContent Types
200Product updated successfullyapplication/json
400
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateRecaptchaConfig.topic b/documentation/topics/generated/updateRecaptchaConfig.topic new file mode 100644 index 00000000..84f42f4b --- /dev/null +++ b/documentation/topics/generated/updateRecaptchaConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /reCAPTCHA/config + + +

Operation ID: updateRecaptchaConfig

+

Update reCAPTCHA config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200reCAPTCHA configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateSelfServeConfig.topic b/documentation/topics/generated/updateSelfServeConfig.topic new file mode 100644 index 00000000..fb06441b --- /dev/null +++ b/documentation/topics/generated/updateSelfServeConfig.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /selfserve/config + + +

Operation ID: updateSelfServeConfig

+

Update Self-Serve config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/SelfServeConfig" +} + +
+ + + + +
StatusDescriptionContent Types
200Self-serve configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateSelfserveCondition.topic b/documentation/topics/generated/updateSelfserveCondition.topic new file mode 100644 index 00000000..9e46b30f --- /dev/null +++ b/documentation/topics/generated/updateSelfserveCondition.topic @@ -0,0 +1,79 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /department/selfserve/conditions + + +

Operation ID: updateSelfserveCondition

+

Update an existing self-serve condition.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerCondition ID
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "properties": { + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "lane": { + "type": "integer" + }, + "machine_type_id": { + "nullable": true, + "type": "integer" + }, + "name": { + "type": "string" + }, + "product": { + "type": "integer" + } + }, + "type": "object" +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Successfully updated conditionapplication/json
400
404
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveCondition" +} + +
+
diff --git a/documentation/topics/generated/updateSelfserveConditionRule.topic b/documentation/topics/generated/updateSelfserveConditionRule.topic new file mode 100644 index 00000000..a3ee753c --- /dev/null +++ b/documentation/topics/generated/updateSelfserveConditionRule.topic @@ -0,0 +1,74 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /department/selfserve/condition/rules + + +

Operation ID: updateSelfserveConditionRule

+

Update an existing self-serve condition rule.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerRule ID
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "properties": { + "condition_id": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "object_type": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object" +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Successfully updated condition ruleapplication/json
400
404
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveConditionRule" +} + +
+
diff --git a/documentation/topics/generated/updateSelfserveMachineType.topic b/documentation/topics/generated/updateSelfserveMachineType.topic new file mode 100644 index 00000000..e560c05e --- /dev/null +++ b/documentation/topics/generated/updateSelfserveMachineType.topic @@ -0,0 +1,61 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /department/selfserve/machine-types + + +

Operation ID: updateSelfserveMachineType

+

Update reusable self-serve machine type

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesinteger
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "properties": { + "description": { + "nullable": true, + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
200Successfully updated machine typeapplication/json
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/SelfserveMachineType" +} + +
+
diff --git a/documentation/topics/generated/updateSelfserveQuestion.topic b/documentation/topics/generated/updateSelfserveQuestion.topic new file mode 100644 index 00000000..08f3db6b --- /dev/null +++ b/documentation/topics/generated/updateSelfserveQuestion.topic @@ -0,0 +1,77 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /department/selfserve/questions + + +

Operation ID: updateSelfserveQuestion

+

Update an existing self-serve question.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerQuestion ID
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "properties": { + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "lane": { + "type": "integer" + }, + "order_priority": { + "type": "integer" + }, + "product": { + "type": "integer" + }, + "question": { + "type": "string" + } + }, + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Successfully updated questionapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveQuestion" +} + +
+
diff --git a/documentation/topics/generated/updateSelfserveTask.topic b/documentation/topics/generated/updateSelfserveTask.topic new file mode 100644 index 00000000..00e2e388 --- /dev/null +++ b/documentation/topics/generated/updateSelfserveTask.topic @@ -0,0 +1,102 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /department/selfserve/tasks + + +

Operation ID: updateSelfserveTask

+

Update an existing self-serve task.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerTask ID
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "properties": { + "buttons": { + "description": "Button IDs enabled by this task. Set to null to clear all buttons.", + "items": { + "type": "integer" + }, + "nullable": true, + "type": "array" + }, + "condition_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "dynamic_images_vehicle_type": { + "description": "Vehicle type selection override. Set to null to clear.", + "nullable": true, + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "machine_type_id": { + "nullable": true, + "type": "integer" + }, + "order_priority": { + "type": "integer" + }, + "product": { + "type": "integer" + }, + "services": { + "description": "Services enabled by this task. Set to null to clear all services.", + "items": { + "$ref": "#/components/schemas/SelfserveLaneService" + }, + "nullable": true, + "type": "array" + }, + "task": { + "type": "string" + } + }, + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Successfully updated taskapplication/json
400
404
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveTask" +} + +
+
diff --git a/documentation/topics/generated/updateSelfserveVehicleCondition.topic b/documentation/topics/generated/updateSelfserveVehicleCondition.topic new file mode 100644 index 00000000..d5e80962 --- /dev/null +++ b/documentation/topics/generated/updateSelfserveVehicleCondition.topic @@ -0,0 +1,75 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /department/selfserve/vehicle/conditions + + +

Operation ID: updateSelfserveVehicleCondition

+

Update an existing vehicle condition. Customers can only update conditions for their own vehicles.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idqueryyesintegerCondition ID
+
+ +

Required: no.

+

Content type: application/json

+ +{ + "properties": { + "customer_id": { + "nullable": true, + "type": "integer" + }, + "department": { + "type": "integer" + }, + "lane": { + "type": "integer" + }, + "question": { + "type": "integer" + }, + "reg": { + "type": "string" + }, + "value": { + "type": "boolean" + } + }, + "type": "object" +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Successfully updated vehicle conditionapplication/json
400
404
500
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse" +} + +
+
diff --git a/documentation/topics/generated/updateShellyConfig.topic b/documentation/topics/generated/updateShellyConfig.topic new file mode 100644 index 00000000..e20ba94b --- /dev/null +++ b/documentation/topics/generated/updateShellyConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /shelly/config + + +

Operation ID: updateShellyConfig

+

Update Shelly config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Shelly configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateStripeConfig.topic b/documentation/topics/generated/updateStripeConfig.topic new file mode 100644 index 00000000..84bf336c --- /dev/null +++ b/documentation/topics/generated/updateStripeConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /stripe/config + + +

Operation ID: updateStripeConfig

+

Update Stripe config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Stripe configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateSubuserGrant.topic b/documentation/topics/generated/updateSubuserGrant.topic new file mode 100644 index 00000000..e15ed304 --- /dev/null +++ b/documentation/topics/generated/updateSubuserGrant.topic @@ -0,0 +1,60 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PATCH /subusers/grants/{id} + + +

Operation ID: updateSubuserGrant

+

Update subuser grant

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
idpathyesinteger
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/SubuserGrantUpdateRequest" +} + +
+ + + + + + + + +
StatusDescriptionContent Types
200Grant updatedapplication/json
400
401
404
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "grant": { + "$ref": "#/components/schemas/SubuserGrant" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/updateUser.topic b/documentation/topics/generated/updateUser.topic new file mode 100644 index 00000000..eb81c52e --- /dev/null +++ b/documentation/topics/generated/updateUser.topic @@ -0,0 +1,45 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /users + + +

Operation ID: updateUser

+

Update an existing user

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "$ref": "#/components/schemas/UserUpdate" +} + +
+ + + + + + +
StatusDescriptionContent Types
200User updated successfullyapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateUserNotifications.topic b/documentation/topics/generated/updateUserNotifications.topic new file mode 100644 index 00000000..eace59ee --- /dev/null +++ b/documentation/topics/generated/updateUserNotifications.topic @@ -0,0 +1,54 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + PUT /account/notifications + + +

Operation ID: updateUserNotifications

+

Update user notification settings

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "email_notifications_enabled": { + "type": "boolean" + }, + "sms_notifications_enabled": { + "type": "boolean" + }, + "wash_certificate_email": { + "type": "string" + } + }, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
200Successapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateVirkdataConfig.topic b/documentation/topics/generated/updateVirkdataConfig.topic new file mode 100644 index 00000000..bc51b099 --- /dev/null +++ b/documentation/topics/generated/updateVirkdataConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /virkdata/config + + +

Operation ID: updateVirkdataConfig

+

Update Virkdata config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200Virkdata configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateWeatherApiConfig.topic b/documentation/topics/generated/updateWeatherApiConfig.topic new file mode 100644 index 00000000..cff5109a --- /dev/null +++ b/documentation/topics/generated/updateWeatherApiConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /weatherapi/config + + +

Operation ID: updateWeatherApiConfig

+

Update WeatherAPI config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200WeatherAPI configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/updateWorkerVersion.topic b/documentation/topics/generated/updateWorkerVersion.topic new file mode 100644 index 00000000..ba8afc67 --- /dev/null +++ b/documentation/topics/generated/updateWorkerVersion.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /worker/update-version + + +

Operation ID: updateWorkerVersion

+

Set the target version for the worker update

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
versionqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Version update target set successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/updateXlvaskConfig.topic b/documentation/topics/generated/updateXlvaskConfig.topic new file mode 100644 index 00000000..acbafd4f --- /dev/null +++ b/documentation/topics/generated/updateXlvaskConfig.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /xlvask/config + + +

Operation ID: updateXlvaskConfig

+

Update XLVask config

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: no.

+

Content type: application/json

+ +{} + +
+ + + + +
StatusDescriptionContent Types
200XLVask configuration updated successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/ModuleConfigUpdateResponse" +} + +
+
diff --git a/documentation/topics/generated/uploadAttachment.topic b/documentation/topics/generated/uploadAttachment.topic new file mode 100644 index 00000000..e4b1b5ec --- /dev/null +++ b/documentation/topics/generated/uploadAttachment.topic @@ -0,0 +1,50 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /attachments/upload + + +

Operation ID: uploadAttachment

+

Upload a file attachment

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: multipart/form-data

+ +{ + "properties": { + "file": { + "format": "binary", + "type": "string" + } + }, + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
201Attachment uploaded successfullyapplication/json
400
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/uploadOrderAttachment.topic b/documentation/topics/generated/uploadOrderAttachment.topic new file mode 100644 index 00000000..50b2a2ff --- /dev/null +++ b/documentation/topics/generated/uploadOrderAttachment.topic @@ -0,0 +1,52 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /orders/attachments/upload + + +

Operation ID: uploadOrderAttachment

+

Upload an attachment to an order

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: multipart/form-data

+ +{ + "properties": { + "file": { + "format": "binary", + "type": "string" + }, + "order_id": { + "type": "integer" + } + }, + "type": "object" +} + +
+ + + + +
StatusDescriptionContent Types
201Order attachment uploaded successfullyapplication/json
+

Schema for response 201 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/uploadSelfserveTaskAttachment.topic b/documentation/topics/generated/uploadSelfserveTaskAttachment.topic new file mode 100644 index 00000000..ee777214 --- /dev/null +++ b/documentation/topics/generated/uploadSelfserveTaskAttachment.topic @@ -0,0 +1,64 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /department/selfserve/tasks/attachments/upload + + +

Operation ID: uploadSelfserveTaskAttachment

+

Upload a new attachment to a specific self-serve task using base64 encoding.

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "base64_file": { + "description": "Base64 encoded file content", + "type": "string" + }, + "file_name": { + "description": "Name of the file including extension", + "type": "string" + }, + "task_id": { + "type": "integer" + } + }, + "required": [ + "task_id", + "base64_file", + "file_name" + ], + "type": "object" +} + +
+ + + + + + + +
StatusDescriptionContent Types
200Attachment uploaded successfullyapplication/json
400
404
500
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/validateCustomerNumber.topic b/documentation/topics/generated/validateCustomerNumber.topic new file mode 100644 index 00000000..a825baf8 --- /dev/null +++ b/documentation/topics/generated/validateCustomerNumber.topic @@ -0,0 +1,46 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /guest/validation/customer-number + + +

Operation ID: validateCustomerNumber

+

Check if a customer number is valid and exists

+
+

No authentication required.

+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "customer_number": { + "type": "integer" + } + }, + "required": [ + "customer_number" + ], + "type": "object" +} + +
+ + + + + +
StatusDescriptionContent Types
200Customer number validation successfulapplication/json
400
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/validatePasswordResetToken.topic b/documentation/topics/generated/validatePasswordResetToken.topic new file mode 100644 index 00000000..90db1ee0 --- /dev/null +++ b/documentation/topics/generated/validatePasswordResetToken.topic @@ -0,0 +1,51 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /auth/password-reset/validate + + +

Operation ID: validatePasswordResetToken

+

Check if a password reset token is valid and hasn't expired

+
+

No authentication required.

+ + + + +
NameInRequiredTypeDescription
tokenqueryyesstringThe password reset token
+
+ + + + + +
StatusDescriptionContent Types
200Token is validapplication/json
404Invalid or expired tokenapplication/json
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "customer_id": { + "type": "integer" + }, + "valid": { + "type": "boolean" + } + }, + "type": "object" +} + +

Schema for response 404 (application/json):

+ +{ + "$ref": "#/components/schemas/Error" +} + +
+
diff --git a/documentation/topics/generated/validateSubuserSetupToken.topic b/documentation/topics/generated/validateSubuserSetupToken.topic new file mode 100644 index 00000000..20186b97 --- /dev/null +++ b/documentation/topics/generated/validateSubuserSetupToken.topic @@ -0,0 +1,48 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /subusers/setup + + +

Operation ID: validateSubuserSetupToken

+

Validates a subuser setup token generated during registration.

+
+

No authentication required.

+ + + + +
NameInRequiredTypeDescription
tokenqueryyesstringOne-time setup token received via SMS
+
+ + + + + + +
StatusDescriptionContent Types
200Token is validapplication/json
400
500
+

Schema for response 200 (application/json):

+ +{ + "properties": { + "message": { + "example": "Token is valid", + "type": "string" + }, + "subuser_id": { + "example": 42, + "type": "integer" + } + }, + "type": "object" +} + +
+
diff --git a/documentation/topics/generated/verify2fa.topic b/documentation/topics/generated/verify2fa.topic new file mode 100644 index 00000000..084ea531 --- /dev/null +++ b/documentation/topics/generated/verify2fa.topic @@ -0,0 +1,86 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + POST /auth/2fa/verify + + +

Operation ID: verify2fa

+

Complete the login process by verifying the 2FA code

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ +

Required: yes.

+

Content type: application/json

+ +{ + "properties": { + "2fa_token": { + "description": "The temporary 2FA verification token", + "type": "string" + }, + "code": { + "description": "The 6-digit TOTP code", + "type": "string" + } + }, + "required": [ + "2fa_token", + "code" + ], + "type": "object" +} + +
+ + + + + + +
StatusDescriptionContent Types
200Login successfulapplication/json
400
401
+

Schema for response 200 (application/json):

+ +{ + "oneOf": [ + { + "properties": { + "token": { + "description": "Bearer authentication token (for users/employees)", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + { + "properties": { + "session": { + "description": "Session token (for subusers)", + "type": "string" + } + }, + "required": [ + "session" + ], + "type": "object" + } + ] +} + +
+
diff --git a/documentation/topics/generated/virkdataSearch.topic b/documentation/topics/generated/virkdataSearch.topic new file mode 100644 index 00000000..c3a914eb --- /dev/null +++ b/documentation/topics/generated/virkdataSearch.topic @@ -0,0 +1,40 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/virkdata/search + + +

Operation ID: virkdataSearch

+

Search for company information in VirkData

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
searchqueryyesstring
+
+ + + + +
StatusDescriptionContent Types
200Company information retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{} + +
+
diff --git a/documentation/topics/generated/weatherApiCurrent.topic b/documentation/topics/generated/weatherApiCurrent.topic new file mode 100644 index 00000000..1229f42b --- /dev/null +++ b/documentation/topics/generated/weatherApiCurrent.topic @@ -0,0 +1,42 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/weatherapi/current + + +

Operation ID: weatherApiCurrent

+

Get current weather data from WeatherAPI for a location query

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
qqueryyesstringLocation query (e.g. city, postal code, or latitude,longitude)
+
+ + + + +
StatusDescriptionContent Types
200Current weather retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/WeatherApiObjectResponse" +} + +
+
diff --git a/documentation/topics/generated/weatherApiForecast.topic b/documentation/topics/generated/weatherApiForecast.topic new file mode 100644 index 00000000..4aadd8ce --- /dev/null +++ b/documentation/topics/generated/weatherApiForecast.topic @@ -0,0 +1,43 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/weatherapi/forecast + + +

Operation ID: weatherApiForecast

+

Get forecast weather data from WeatherAPI

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + + +
NameInRequiredTypeDescription
qqueryyesstringLocation query (e.g. city, postal code, or latitude,longitude)
daysquerynointegerNumber of forecast days
+
+ + + + +
StatusDescriptionContent Types
200Forecast weather retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/WeatherApiObjectResponse" +} + +
+
diff --git a/documentation/topics/generated/weatherApiSearch.topic b/documentation/topics/generated/weatherApiSearch.topic new file mode 100644 index 00000000..18eee5cd --- /dev/null +++ b/documentation/topics/generated/weatherApiSearch.topic @@ -0,0 +1,42 @@ + + + + + +

This endpoint documentation is generated directly from openapi.yaml.

+ + GET /modules/weatherapi/search + + +

Operation ID: weatherApiSearch

+

Search location suggestions from WeatherAPI

+
+ +

Security requirements:

+ + + +
SchemeScopes
BearerAuth-
+
+ + + + +
NameInRequiredTypeDescription
qqueryyesstringSearch text
+
+ + + + +
StatusDescriptionContent Types
200Location search results retrieved successfullyapplication/json
+

Schema for response 200 (application/json):

+ +{ + "$ref": "#/components/schemas/WeatherApiObjectResponse" +} + +
+
diff --git a/documentation/v.list b/documentation/v.list new file mode 100644 index 00000000..7346665d --- /dev/null +++ b/documentation/v.list @@ -0,0 +1,5 @@ + + + + + diff --git a/documentation/writerside.cfg b/documentation/writerside.cfg new file mode 100644 index 00000000..ecc3c7ae --- /dev/null +++ b/documentation/writerside.cfg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/nginx-example.conf b/nginx-example.conf index 6707e45e..e0ae4061 100644 --- a/nginx-example.conf +++ b/nginx-example.conf @@ -38,14 +38,14 @@ http { location / { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; add_header Access-Control-Allow-Credentials true; # If OPTIONS method is needed for preflight if ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; return 204; # No Content } @@ -65,4 +65,4 @@ http { location ~* \.(cgi|shtml|phtml)$ { } } -} \ No newline at end of file +} diff --git a/nginx.conf b/nginx.conf index 1904efbc..ce4bf628 100644 --- a/nginx.conf +++ b/nginx.conf @@ -52,14 +52,14 @@ http { location / { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; add_header Access-Control-Allow-Credentials true; # If OPTIONS method is needed for preflight if ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; return 204; # No Content } @@ -80,4 +80,4 @@ http { # Additional SSL options or configurations can be placed here, if necessary. } } -} \ No newline at end of file +} diff --git a/openapi.yaml b/openapi.yaml index cb100a03..9db79e35 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -27,7 +27,7 @@ servers: description: Production server (.dk) - url: https://api.truckwash.io description: Production server (.io) - - url: http://localhost + - url: http://localhost/api description: Local development server security: @@ -36,8 +36,12 @@ security: tags: - name: Authentication description: User and employee authentication endpoints + - name: Security + description: Account security and passkey management endpoints - name: Users description: User management and customer operations + - name: Search + description: System-wide search endpoints - name: Orders description: Order creation, management, and retrieval - name: Order Items @@ -68,10 +72,14 @@ tags: description: Form submissions and management - name: Worker description: System worker status and maintenance + - name: Error Reports + description: Authenticated application error reporting - name: Plate Scans description: License plate scanning operations - name: Config description: Module configuration management + - name: Release Manager + description: Release channel, deployment, and operation management - name: Branding description: Branding options management - name: Roles @@ -86,6 +94,160 @@ tags: description: Voice Calls via Bird paths: + /error-reports: + post: + tags: + - Error Reports + summary: Submit an authenticated user error report + operationId: submitErrorReport + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportSubmissionRequest' + responses: + '201': + description: Error report submitted + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /superuser/error-reports: + get: + tags: + - Error Reports + summary: List error reports for superusers + operationId: listSuperuserErrorReports + security: + - BearerAuth: [] + parameters: + - name: status + in: query + required: false + schema: + type: string + enum: [open, resolved, all] + default: open + - name: q + in: query + required: false + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Error reports retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportListResponse' + '403': + description: Missing superuser error report permission + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /superuser/error-reports/{id}: + get: + tags: + - Error Reports + summary: Get an error report detail + operationId: getSuperuserErrorReport + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Error report retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportResponse' + '404': + description: Error report not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /superuser/error-reports/{id}/status: + patch: + tags: + - Error Reports + summary: Mark an error report open or resolved + operationId: updateSuperuserErrorReportStatus + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportStatusUpdateRequest' + responses: + '200': + description: Error report status updated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Missing superuser error report resolve permission + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # Bird Voice Calls /bird/voice/calls: post: @@ -98,12 +260,14 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) requestBody: @@ -111,17 +275,7 @@ paths: content: application/json: schema: - type: object - additionalProperties: true - properties: - to: - type: string - description: E.164 phone number of the callee - example: "+4511122233" - from: - type: string - description: E.164 phone number of the caller (sender) - example: "+4599988877" + $ref: '#/components/schemas/BirdVoiceCallCreateRequest' responses: '200': description: Call created @@ -138,19 +292,73 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) - in: query - name: page + name: limit schema: type: integer - required: false + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: status + schema: + type: string + - in: query + name: type + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: direction + schema: + type: string + - in: query + name: id + schema: + type: string + format: uuid + - in: query + name: tag + schema: + oneOf: + - type: string + - type: array + items: + type: string responses: '200': description: A list of calls @@ -158,6 +366,93 @@ paths: application/json: schema: { $ref: '#/components/schemas/BirdVoiceCallListResponse' } + /bird/voice/calls/log: + get: + tags: + - Bird + summary: List workspace call log entries + operationId: birdListVoiceCallsLog + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: channelId + schema: + oneOf: + - type: string + - type: array + items: + type: string + format: uuid + - in: query + name: status + schema: + type: string + - in: query + name: type + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: direction + schema: + type: string + - in: query + name: id + schema: + type: string + format: uuid + - in: query + name: tag + schema: + oneOf: + - type: string + - type: array + items: + type: string + responses: + '200': + description: Workspace call log entries + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallsLogResponse' } + /bird/voice/calls/{id}: get: tags: @@ -169,12 +464,14 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) - in: path @@ -182,12 +479,125 @@ paths: required: true schema: type: string + format: uuid responses: '200': description: Call details content: application/json: schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + patch: + tags: + - Bird + summary: Update a voice call by ID + operationId: birdUpdateVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallUpdateRequest' + responses: + '200': + description: Call update accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + + /bird/voice/calls/{id}/answer: + post: + tags: + - Bird + summary: Answer an incoming voice call by ID + operationId: birdAnswerVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallAnswerRequest' + responses: + '200': + description: Answer command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/ringing: + post: + tags: + - Bird + summary: Mark voice call as ringing by ID + operationId: birdRingingVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRingingRequest' + responses: + '200': + description: Ringing command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } /bird/voice/calls/{id}/hangup: post: @@ -200,90 +610,412 @@ paths: name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) - in: path name: id required: true schema: type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallHangupRequest' responses: '200': description: Hangup requested content: application/json: - schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/playback: + post: + tags: + - Bird + summary: Playback media on a voice call by ID + operationId: birdPlaybackVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallPlaybackRequest' + responses: + '200': + description: Playback command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } /bird/voice/calls/{id}/say: post: tags: - Bird - summary: Say a message on an active voice call and hang up afterwards + summary: Say a message on an active voice call and optionally hang up operationId: birdSayOnVoiceCall parameters: - in: query name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) - in: path name: id required: true schema: type: string - description: Call identifier + format: uuid requestBody: required: true content: application/json: schema: - type: object - required: - - text - properties: - text: - type: string - description: The text message to play via TTS - example: "The gate will open shortly." - locale: - type: string - description: The locale to use for the TTS voice (e.g. en-US) - example: "en-US" - voice: - type: string - description: The voice identifier to use - example: "male" - loop: - type: integer - description: Number of times to loop the message - example: 1 - timeout: - type: integer - description: Timeout in seconds for the TTS action - example: 1 - hangup: - type: boolean - description: Whether to hang up the call after the message finishes playing (defaults to true) - example: true + $ref: '#/components/schemas/BirdVoiceCallSayRequest' responses: '200': - description: TTS action requested + description: Say command accepted content: application/json: - schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/gather: + post: + tags: + - Bird + summary: Gather input from a voice call by ID + operationId: birdGatherVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallGatherRequest' + responses: + '200': + description: Gather command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/bridge: + post: + tags: + - Bird + summary: Bridge a voice call by ID + operationId: birdBridgeVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallBridgeRequest' + responses: + '200': + description: Bridge command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallBridgeResponse' } + + /bird/voice/calls/{id}/record: + post: + tags: + - Bird + summary: Record call audio by ID + operationId: birdRecordVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordRequest' + responses: + '200': + description: Record command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/recordings: + post: + tags: + - Bird + summary: Create a call recording session + operationId: birdCreateVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordingCreateRequest' + responses: + '200': + description: Recording session created + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + get: + tags: + - Bird + summary: List call recordings for a voice call + operationId: birdListVoiceCallRecordings + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + responses: + '200': + description: List of call recordings + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingListResponse' } + + /bird/voice/calls/{id}/recordings/{recordingId}: + get: + tags: + - Bird + summary: Get a single call recording + operationId: birdGetVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: recordingId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Call recording details + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + patch: + tags: + - Bird + summary: Update call recording state + operationId: birdUpdateVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: recordingId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordingUpdateRequest' + responses: + '200': + description: Recording update accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + + /bird/voice/calls/{id}/insights: + get: + tags: + - Bird + summary: Get voice call insights + operationId: birdGetVoiceCallInsights + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Voice call insights + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallInsightsResponse' } /bird/voice/calls/test-outbound: post: @@ -297,12 +1029,14 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) requestBody: @@ -310,26 +1044,7 @@ paths: content: application/json: schema: - type: object - additionalProperties: true - properties: - from: - type: string - description: Caller E.164 number to use for the test call - example: "+4599988877" - pollIntervalSeconds: - type: integer - minimum: 1 - description: Poll interval while waiting for accepted status - example: 2 - maxPollSeconds: - type: integer - minimum: 5 - description: Max time to wait before timing out - example: 30 - hangupCause: - type: string - description: Optional hangup cause passed through to Bird + $ref: '#/components/schemas/BirdTestOutboundCallRequest' responses: '200': description: Test call created and either hung up or timed out @@ -337,6 +1052,78 @@ paths: application/json: schema: { $ref: '#/components/schemas/BirdTestOutboundCallResponse' } + /bird/voice/calls/webhook/inbound: + post: + tags: + - Bird + summary: Process inbound Bird voice call lifecycle + operationId: birdInboundVoiceCallWebhook + description: > + Stateful inbound-call webhook that owns department and gate selection for phone-controlled + gates. The preferred Bird Flow Builder integration is the native-flow mode: + send top-level `callId`, `channelId`, and `workspaceId` to fetch IVR prompt data, + then submit the selected DTMF digits as `keys` in a follow-up request. In this mode + the webhook returns a plain `200 OK` JSON body with `prompt`, `stage`, and `gather` + settings that Bird native voice steps can consume directly. For backward compatibility, + the webhook also supports the older `{ payload, request, waitConditions }` contract and + returns a raw `202 Accepted` Bird `callCommand` gather envelope that resumes on + `call_command_gather_finished`. The Bird Flow itself must answer the inbound call before + invoking this HTTP step; the backend answer attempt is only a best-effort fallback. + Department options are generated from `department_gates` records with `config.type=PHONE_CALL`, + ordered by `departments.order_priority`, and support multi-digit DTMF selections such as `10#`. + After a department is chosen, the webhook returns compact gate options, for example `Press 1 for exit` + when exit is the only available phone-controlled gate for that department. When a valid gate is confirmed, + the webhook opens the gate through the corresponding `department_gates` phone-call record and returns + a `200 OK` completion result. + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (fallbacks to payload/state/module configuration) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (fallbacks to payload/state/module configuration) + - in: query + name: callId + schema: + type: string + required: false + description: Bird Call identifier (fallbacks to payload fields) + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdInboundCallWebhookRequest' + responses: + '200': + description: Native-flow gather data or gate action result for this webhook invocation + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/BirdInboundCallWebhookFlowGatherResponse' + - $ref: '#/components/schemas/BirdInboundCallWebhookActionResultResponse' + '202': + description: Gather command accepted and returned to Bird + content: + application/json: + schema: + $ref: '#/components/schemas/BirdInboundCallWebhookGatherAcceptedResponse' + '400': + description: Malformed Bird webhook payload + content: + application/json: + schema: + $ref: '#/components/schemas/BirdInboundCallWebhookTransportErrorResponse' + # Bird Numbers /bird/numbers: get: @@ -350,6 +1137,7 @@ paths: required: false schema: type: string + format: uuid description: Bird Workspace identifier (optional if configured) - in: query name: page @@ -381,6 +1169,7 @@ paths: required: false schema: type: string + format: uuid description: Bird Workspace identifier (optional if configured) - in: path name: id @@ -405,6 +1194,7 @@ paths: required: false schema: type: string + format: uuid description: Bird Workspace identifier (optional if configured) - in: path name: id @@ -432,12 +1222,14 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) requestBody: @@ -445,16 +1237,14 @@ paths: content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallCreateRequest' responses: '200': description: Flash call created content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallSingleResponse' get: tags: - Bird @@ -465,27 +1255,64 @@ paths: name: workspaceId schema: type: string + format: uuid required: false description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false description: Bird Channel identifier (falls back to module configuration if omitted) - in: query - name: page + name: limit schema: type: integer - required: false + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: status + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: id + schema: + type: string + format: uuid responses: '200': description: A list of flash calls content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallListResponse' /bird/voice/flash-calls/{id}: get: @@ -498,111 +1325,132 @@ paths: name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) - in: path name: id required: true schema: type: string + format: uuid responses: '200': description: Flash call details content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallSingleResponse' post: tags: - Bird summary: Complete/end a flash call by ID - description: Posts a completion/update payload to the flash call resource to finalize verification. operationId: birdEndFlashCall parameters: - in: query name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) - in: path name: id required: true schema: type: string + format: uuid requestBody: - required: false + required: true content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallEndRequest' responses: '200': description: Flash call completed content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallSingleResponse' + + /bird/voice/flash-calls/hangup: + post: + tags: + - Bird + summary: Hang up flash calls using payload criteria + operationId: birdHangupFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupRequest' + responses: + '200': + description: Flash call hangup accepted + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupResponse' /bird/voice/flash-calls/end: post: tags: - Bird - summary: Complete/end a flash call using from/to numbers - description: Ends an ongoing flash call by specifying the originating and destination numbers. + summary: Compatibility alias for flash hangup endpoint + description: Deprecated alias for `/bird/voice/flash-calls/hangup`. + deprecated: true operationId: birdEndFlashCallByNumbers parameters: - in: query name: workspaceId schema: type: string + format: uuid required: false - description: Bird Workspace identifier (falls back to module configuration if omitted) - in: query name: channelId schema: type: string + format: uuid required: false - description: Bird Channel identifier (falls back to module configuration if omitted) requestBody: required: true content: application/json: schema: - type: object - additionalProperties: true - properties: - from: - type: string - description: E.164 formatted caller number - example: "+4599988877" - to: - type: string - description: E.164 formatted callee number - example: "+4511122233" + $ref: '#/components/schemas/BirdFlashCallHangupRequest' responses: '200': - description: Flash call completed (by numbers) + description: Flash call hangup accepted (alias) content: application/json: schema: - type: object - additionalProperties: true + $ref: '#/components/schemas/BirdFlashCallHangupResponse' # Subusers (public registration + setup) /subusers: @@ -1505,6 +2353,19 @@ 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 + default_distribution_department_id: + type: integer + additionalProperties: false + additionalProperties: true '400': $ref: '#/components/responses/BadRequest' '401': @@ -1517,6 +2378,11 @@ paths: summary: Generate 2FA secret description: Generate a new TOTP secret for the authenticated user/subuser operationId: setup2fa + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: 2FA secret generated successfully @@ -1555,6 +2421,9 @@ paths: responses: '200': description: 2FA enabled successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' '401': @@ -1581,6 +2450,9 @@ paths: responses: '200': description: 2FA disabled successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' '401': @@ -1729,6 +2601,9 @@ paths: responses: '201': description: Customer registered successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' @@ -1799,6 +2674,10 @@ paths: type: integer '404': description: Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /auth/password-reset/set: post: @@ -1842,6 +2721,10 @@ paths: $ref: '#/components/responses/BadRequest' '404': description: Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /su/intimidate: post: @@ -1932,6 +2815,9 @@ paths: responses: '200': description: User updated successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' '401': @@ -1997,6 +2883,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Users @@ -2015,6 +2904,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /customer/department/default: get: @@ -2029,6 +2921,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Users @@ -2047,6 +2942,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} delete: tags: - Users @@ -2059,6 +2957,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /customer/pricing/fixed: get: @@ -2074,6 +2975,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Users @@ -2093,6 +2997,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} delete: tags: - Users @@ -2106,6 +3013,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /account/notifications: put: @@ -2126,6 +3036,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /user/permissions: get: @@ -2136,6 +3049,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /customers: get: @@ -2147,9 +3063,19 @@ paths: - $ref: '#/components/parameters/PageParam' - $ref: '#/components/parameters/PerPageParam' - $ref: '#/components/parameters/SearchParam' + - name: barred + in: query + required: false + description: Optional e-conomic barred customer filter. + schema: + type: string + enum: ['true', 'false', 'barred', 'active', '1', '0'] responses: '200': description: Success + content: + application/json: + schema: {} /superuser/user/discounts: get: @@ -2165,6 +3091,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Users @@ -2185,6 +3114,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /superuser/user/keys: get: @@ -2203,6 +3135,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Users @@ -2222,6 +3157,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /superuser/user/password: post: @@ -2242,6 +3180,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /admin/customer/getUserId: get: @@ -2358,6 +3299,9 @@ paths: responses: '200': description: Order deleted successfully + content: + application/json: + schema: {} '401': $ref: '#/components/responses/Unauthorized' '404': @@ -2377,6 +3321,9 @@ paths: responses: '200': description: Order updated successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' @@ -2417,6 +3364,9 @@ paths: responses: '200': description: Order updated successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' '401': @@ -2484,6 +3434,9 @@ paths: responses: '200': description: Order marked as completed successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' @@ -2506,6 +3459,9 @@ paths: responses: '200': description: Wash certificate generated successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' @@ -2547,6 +3503,9 @@ paths: responses: '201': description: Order item added successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' put: @@ -2564,6 +3523,9 @@ paths: responses: '200': description: Order item updated successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' delete: @@ -2581,6 +3543,9 @@ paths: responses: '200': description: Order item deleted successfully + content: + application/json: + schema: {} '404': $ref: '#/components/responses/NotFound' @@ -2590,7 +3555,7 @@ paths: tags: - Departments summary: List departments - description: Retrieve a list of all visible departments + description: Retrieve visible, active departments by default. Superuser department access may filter archived departments with `filters=archived:1`. operationId: listDepartments parameters: - name: id @@ -2601,6 +3566,11 @@ paths: - $ref: '#/components/parameters/PageParam' - $ref: '#/components/parameters/PerPageParam' - $ref: '#/components/parameters/SearchParam' + - name: filters + in: query + schema: + type: string + description: Comma-separated field filters. `archived:1` is only honored for users with superuser department access. responses: '200': description: Departments retrieved successfully @@ -2627,6 +3597,9 @@ paths: responses: '201': description: Department created successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' put: @@ -2644,6 +3617,9 @@ paths: responses: '200': description: Department updated successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' @@ -2663,6 +3639,9 @@ paths: responses: '200': description: Department categories retrieved successfully + content: + application/json: + schema: {} post: tags: - Departments @@ -2683,13 +3662,25 @@ paths: responses: '201': description: Category added to department successfully + content: + application/json: + schema: {} delete: tags: - Departments summary: Remove category from department operationId: removeDepartmentCategory + requestBody: + required: false + content: + application/json: + schema: {} responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} /departments/self-serve/enabled: get: @@ -2762,7 +3753,11 @@ paths: required: true schema: {type: integer} responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} /department/lanes: get: @@ -2801,6 +3796,9 @@ paths: responses: '201': description: Department lane created successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' put: @@ -2818,6 +3816,9 @@ paths: responses: '200': description: Department lane updated successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' '404': @@ -2906,6 +3907,9 @@ paths: responses: '200': description: Department gate deleted + content: + application/json: + schema: {} /department/relays: get: @@ -2990,6 +3994,9 @@ paths: responses: '200': description: Department relay deleted + content: + application/json: + schema: {} /department/lanes/dynamic-image: get: @@ -3015,16 +4022,25 @@ paths: schema: type: integer minimum: 1 + - name: dynamic_image_id + in: query + required: false + description: Dynamic image ID to preview instead of the lane's saved image + schema: + type: integer + minimum: 1 - name: buttons in: query required: false - description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params. + description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params. schema: oneOf: - type: string - type: array items: - type: integer + oneOf: + - type: integer + - type: string - name: current_step in: query required: false @@ -3084,6 +4100,9 @@ paths: responses: '200': description: Customer number validation successful + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' @@ -3111,6 +4130,113 @@ paths: items: $ref: '#/components/schemas/DepartmentGuest' + /department/selfserve/machine-types: + get: + tags: + - Self-Serve + summary: List reusable self-serve machine types + operationId: listSelfserveMachineTypes + parameters: + - name: id + in: query + required: true + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Successfully retrieved machine types + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/SelfserveMachineType' + - type: array + items: + $ref: '#/components/schemas/SelfserveMachineType' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Self-Serve + summary: Add reusable self-serve machine type + operationId: addSelfserveMachineType + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + nullable: true + responses: + '200': + description: Successfully added machine type + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveMachineType' + put: + tags: + - Self-Serve + summary: Update reusable self-serve machine type + operationId: updateSelfserveMachineType + parameters: + - name: id + in: query + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + nullable: true + responses: + '200': + description: Successfully updated machine type + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveMachineType' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Self-Serve + summary: Delete reusable self-serve machine type + operationId: deleteSelfserveMachineType + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Successfully deleted machine type + content: + application/json: + schema: + type: string + example: Machine type deleted + '404': + $ref: '#/components/responses/NotFound' + /department/selfserve/questions: get: tags: @@ -3161,7 +4287,7 @@ paths: tags: - Self-Serve summary: Add self-serve question - description: Add a new self-serve question. + description: Add a new self-serve question. Questions are typically shared across departments and lanes by omitting department, lane, and product, which default to 0. operationId: addSelfserveQuestion requestBody: required: true @@ -3170,18 +4296,18 @@ paths: schema: type: object required: - - department - - lane - - product - question - description properties: department: type: integer + default: 0 lane: type: integer + default: 0 product: type: integer + default: 0 question: type: string description: @@ -3309,6 +4435,16 @@ paths: description: Filter by condition ID schema: type: integer + - name: machine_type_id + in: query + description: Filter by reusable machine type ID + schema: + type: integer + - name: machine_type_id + in: query + description: Filter by reusable machine type ID + schema: + type: integer - $ref: '#/components/parameters/PageParam' - $ref: '#/components/parameters/PerPageParam' - $ref: '#/components/parameters/SearchParam' @@ -3331,7 +4467,7 @@ paths: tags: - Self-Serve summary: Add self-serve condition - description: Add a new self-serve condition. + description: Add a new self-serve condition. Either provide a reusable machine_type_id or a legacy department/lane/product scope. operationId: addSelfserveCondition requestBody: required: true @@ -3340,18 +4476,21 @@ paths: schema: type: object required: - - department - - lane - - product - name - description properties: department: type: integer + default: 0 lane: type: integer + default: 0 product: type: integer + default: 0 + machine_type_id: + type: integer + nullable: true condition_id: type: integer nullable: true @@ -3396,6 +4535,9 @@ paths: type: integer product: type: integer + machine_type_id: + type: integer + nullable: true condition_id: type: integer nullable: true @@ -3702,7 +4844,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + $ref: '#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse' '400': $ref: '#/components/responses/BadRequest' '500': @@ -3746,7 +4888,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + $ref: '#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse' '400': $ref: '#/components/responses/BadRequest' '404': @@ -3773,13 +4915,111 @@ paths: content: application/json: schema: - type: string - example: Condition deleted + type: object + properties: + message: + type: string + example: Condition deleted + selfserve: + allOf: + - $ref: '#/components/schemas/SelfserveWashSummary' + nullable: true '400': $ref: '#/components/responses/BadRequest' '404': $ref: '#/components/responses/NotFound' + /department/selfserve/vehicle/allowed: + get: + tags: + - Self-Serve + summary: Check whether self-serve is allowed for a vehicle on a lane + operationId: getSelfserveVehicleAllowed + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + - name: reg + in: query + required: true + schema: + type: string + - name: vehicle_type_id + in: query + required: false + description: Optional vehicle type override used when no vehicle is found by registration plate. + schema: + type: integer + minimum: 0 + - name: vehicle_type + in: query + required: false + description: Backward-compatible alias of `vehicle_type_id`. + schema: + type: integer + minimum: 0 + responses: + '200': + description: Successfully evaluated self-serve eligibility + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveVehicleAllowedResponse' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/washes/summary: + get: + tags: + - Self-Serve + summary: Get self-serve wash summary + operationId: getSelfserveWashSummary + parameters: + - name: session_id + in: query + required: false + schema: + type: integer + - name: lane_id + in: query + required: false + schema: + type: integer + - name: reg + in: query + required: false + schema: + type: string + - name: vehicle_type_id + in: query + required: false + description: Optional vehicle type override used to refresh summary data for unknown or reassigned plates. + schema: + type: integer + minimum: 0 + - name: vehicle_type + in: query + required: false + description: Backward-compatible alias of `vehicle_type_id`. + schema: + type: integer + minimum: 0 + responses: + '200': + description: Successfully retrieved self-serve wash summary + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveWashSummary' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /department/selfserve/tasks: get: tags: @@ -3835,7 +5075,7 @@ paths: tags: - Self-Serve summary: Add self-serve task - description: Add a new self-serve task. + description: Add a new self-serve task. Either provide a reusable machine_type_id or a legacy department/lane/product scope. operationId: addSelfserveTask requestBody: required: true @@ -3844,18 +5084,21 @@ paths: schema: type: object required: - - department - - lane - - product - task - description properties: department: type: integer + default: 0 lane: type: integer + default: 0 product: type: integer + default: 0 + machine_type_id: + type: integer + nullable: true condition_id: type: integer nullable: true @@ -3918,6 +5161,9 @@ paths: type: integer product: type: integer + machine_type_id: + type: integer + nullable: true condition_id: type: integer nullable: true @@ -3998,6 +5244,9 @@ paths: responses: '200': description: Successfully retrieved task attachments + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' '404': @@ -4067,6 +5316,9 @@ paths: responses: '200': description: Attachment uploaded successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' '404': @@ -4170,6 +5422,9 @@ paths: responses: '201': description: Product created successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' put: @@ -4187,6 +5442,9 @@ paths: responses: '200': description: Product updated successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' @@ -4225,6 +5483,9 @@ paths: responses: '201': description: Category created successfully + content: + application/json: + schema: {} put: tags: - Categories @@ -4240,6 +5501,9 @@ paths: responses: '200': description: Category updated successfully + content: + application/json: + schema: {} # Bookings Endpoints /bookings: @@ -4276,6 +5540,9 @@ paths: responses: '200': description: Booking updated successfully + content: + application/json: + schema: {} /user/bookings: get: @@ -4309,6 +5576,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} # Goals Endpoints /goals/department: @@ -4512,6 +5782,9 @@ paths: responses: '200': description: Department goal deleted successfully + content: + application/json: + schema: {} '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } @@ -4575,7 +5848,7 @@ paths: reference: {type: string} po: {type: string} pickup: {type: boolean} - order_id: {type: integer} + order_id: {type: integer, nullable: true} items: type: array items: @@ -4620,6 +5893,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /admin/bookings/sync: post: @@ -4627,9 +5903,17 @@ paths: - Bookings summary: Sync booking from external system operationId: syncBooking + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Success + content: + application/json: + schema: {} /admin/bookings/department/count: get: @@ -4645,6 +5929,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /user/bookings/washcertificate/download: post: @@ -4664,6 +5951,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /bookings/download_pdf: get: @@ -4679,6 +5969,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /admin/bookings/delete: post: @@ -4698,6 +5991,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /superuser/bookings/sync/all: post: @@ -4705,9 +6001,17 @@ paths: - Bookings summary: Sync all bookings from external system operationId: syncAllBookings + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Success + content: + application/json: + schema: {} /admin/bookings/completeWashWithoutWashCertificate: post: @@ -4727,6 +6031,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /user/bookings/delete: post: @@ -4746,6 +6053,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} # Invoices Endpoints /invoices/draft: @@ -4761,6 +6071,9 @@ paths: responses: '200': description: Draft invoices retrieved successfully + content: + application/json: + schema: {} /invoices/draft/close: post: @@ -4781,6 +6094,9 @@ paths: responses: '200': description: Draft invoice closed successfully + content: + application/json: + schema: {} /invoices/pdf: get: @@ -4814,6 +6130,9 @@ paths: responses: '200': description: User invoices retrieved successfully + content: + application/json: + schema: {} /collected-invoices: get: @@ -4828,24 +6147,43 @@ paths: responses: '200': description: Collected invoices retrieved successfully + content: + application/json: + schema: {} post: tags: - Invoices summary: Create collected invoice description: Create a new collected invoice operationId: createCollectedInvoice + requestBody: + required: false + content: + application/json: + schema: {} responses: '201': description: Collected invoice created successfully + content: + application/json: + schema: {} put: tags: - Invoices summary: Update collected invoice description: Update a collected invoice operationId: updateCollectedInvoice + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Collected invoice updated successfully + content: + application/json: + schema: {} /collected-invoices/ready-to-invoice: get: @@ -4857,6 +6195,306 @@ paths: responses: '200': description: Ready invoices retrieved successfully + content: + application/json: + schema: {} + + /collected-invoices/economic: + post: + tags: + - Invoices + summary: Export collected invoice to e-conomic + description: | + Exports collected invoice to e-conomic. + Uses async queue when available, otherwise falls back to synchronous processing. + operationId: queueCollectedInvoiceEconomicTransfer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: integer + minimum: 1 + send_as_is: + type: boolean + default: false + responses: + '200': + description: Collected invoice export processed synchronously (fallback) + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse' + '202': + description: Collected invoice transfer queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/stripe/book: + post: + tags: + - Invoices + summary: Export Stripe collected invoice to e-conomic + description: | + Exports a Stripe-backed collected invoice to e-conomic. + Uses async queue when available, otherwise falls back to synchronous processing. + operationId: queueStripeCollectedInvoiceEconomicTransfer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: integer + minimum: 1 + responses: + '200': + description: Stripe collected invoice export processed synchronously (fallback) + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse' + '202': + description: Stripe collected invoice transfer queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue: + get: + tags: + - Invoices + summary: List collected-invoice e-conomic transfer queue jobs + operationId: listCollectedInvoiceEconomicQueueJobs + parameters: + - name: status + in: query + required: false + description: Comma-separated queue statuses to filter by. + style: form + explode: false + schema: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueStatus' + uniqueItems: true + example: [QUEUED, FAILED] + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Queue jobs retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueListResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/monitor: + get: + tags: + - Invoices + summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs + operationId: monitorCollectedInvoiceEconomicQueueJobs + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: Queue monitor state retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueMonitorResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/status: + get: + tags: + - Invoices + summary: Get collected-invoice e-conomic transfer queue job status + operationId: getCollectedInvoiceEconomicQueueJobStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/retry: + post: + tags: + - Invoices + summary: Retry failed collected-invoice queue job + operationId: retryCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/dismiss: + post: + tags: + - Invoices + summary: Clear one completed or failed collected-invoice queue job for the current user + operationId: dismissCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/dismiss-terminal: + post: + tags: + - Invoices + summary: Clear all visible completed or failed collected-invoice queue jobs for the current user + operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs + responses: + '200': + description: Terminal queue jobs cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/run: + post: + tags: + - Invoices + summary: Run one collected-invoice queue batch immediately + operationId: runCollectedInvoiceEconomicQueueBatch + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + limit: + type: integer + minimum: 1 + maximum: 10 + default: 10 + responses: + '200': + description: Queue batch processed + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRunResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } /collected-invoices/economic/compare: get: @@ -4893,6 +6531,215 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /collected-invoices/economic/v2/details: + get: + tags: + - Invoices + summary: Get deep V2 e-conomic invoice details + description: | + Returns normalized internal lines and best-effort fetched draft/booked e-conomic lines + for a collected invoice, including department distributions and warnings. + operationId: getCollectedInvoiceEconomicV2Details + parameters: + - name: collected_invoice_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Details resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/compare: + get: + tags: + - Invoices + summary: Compare internal invoice with draft/booked (V2) + operationId: compareCollectedInvoiceEconomicV2 + parameters: + - name: collected_invoice_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Comparison completed + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareResponse' + examples: + exactMatch: + summary: Exact match between internal and draft/booked + value: + collected_invoice_id: 123 + warnings: [] + comparison: + totals: + internal_net_total: 694 + targets: + draft: + target: draft + status: exact_match + overall_match: true + booked: + target: booked + status: exact_match + overall_match: true + partialMismatch: + summary: Partial mismatch with line and department differences + value: + collected_invoice_id: 123 + warnings: + - Non-billable line count differs + comparison: + totals: + internal_net_total: 694 + targets: + draft: + target: draft + status: partial_mismatch + overall_match: false + mismatch_reasons: + - quantity_mismatch + - department_total_mismatch + missingBooked: + summary: Missing booked target + value: + collected_invoice_id: 123 + comparison: + totals: + internal_net_total: 694 + targets: + booked: + target: booked + status: missing_target + overall_match: false + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/compare/bulk: + post: + tags: + - Invoices + summary: Bulk compare collected invoices against draft/booked (V2) + operationId: compareCollectedInvoiceEconomicV2Bulk + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [collected_invoice_ids] + properties: + collected_invoice_ids: + type: array + minItems: 1 + maxItems: 200 + items: + type: integer + minimum: 1 + responses: + '200': + description: Bulk comparison completed + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareBulkResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/revenue-statistics: + get: + tags: + - Invoices + summary: Get overall booked revenue statistics from e-conomic (V2) + description: | + Aggregates booked e-conomic revenue across invoices and lines, with optional filters + for date range, customer(s), department(s), currency, and barred-customer status. + operationId: getCollectedInvoiceEconomicV2RevenueStatistics + parameters: + - name: dateFrom + in: query + required: false + description: Start date (inclusive), defaults to first day of current month. + schema: + type: string + format: date + - name: dateTo + in: query + required: false + description: End date (inclusive), defaults to today. + schema: + type: string + format: date + - name: customer_numbers + in: query + required: false + description: Comma-separated customer numbers to include. + schema: + type: string + example: "42493959,42493960" + - name: department_numbers + in: query + required: false + description: Comma-separated department numbers to include. + schema: + type: string + example: "75,10" + - name: currency + in: query + required: false + description: Restrict to a specific invoice currency. + schema: + type: string + example: "DKK" + - name: barred + in: query + required: false + description: Filter by e-conomic customer barred status. + schema: + type: string + enum: [all, barred, active] + default: all + - name: max_pages + in: query + required: false + description: Safety cap for paginated e-conomic reads. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 10 + responses: + '200': + description: Revenue statistics resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2RevenueStatisticsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + /superuser/invoicing/period: get: tags: @@ -4912,6 +6759,9 @@ paths: responses: '200': description: Invoicing periods retrieved successfully + content: + application/json: + schema: {} /superuser/invoicing/period/distribution/fixed-pricing: get: @@ -4932,6 +6782,10 @@ paths: responses: '200': description: Fixed pricing distribution retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionResponse' /superuser/invoicing/period/distribution/wash-subscriptions: get: @@ -4952,6 +6806,178 @@ paths: responses: '200': description: Wash subscriptions distribution retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingWashSubscriptionsDistributionResponse' + + /superuser/invoicing/period/distribution/v2/all: + get: + tags: + - Invoices + summary: Get version-aware historical distribution (all) + operationId: getInvoicingPeriodDistributionV2All + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware historical distribution (all categories) + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2AllResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/fixed-pricing: + get: + tags: + - Invoices + summary: Get version-aware historical fixed pricing distribution + operationId: getInvoicingPeriodDistributionV2FixedPricing + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware fixed pricing distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2FixedPricingResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/wash-subscriptions: + get: + tags: + - Invoices + summary: Get version-aware historical wash subscription distribution + operationId: getInvoicingPeriodDistributionV2WashSubscriptions + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware wash subscription distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/customer-prices: + get: + tags: + - Invoices + summary: Get version-aware historical customer-price discount distribution + operationId: getInvoicingPeriodDistributionV2CustomerPrices + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware customer-price discount distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/booked-department-75: + get: + tags: + - Invoices + summary: Get booked e-conomic department 75 redistribution + operationId: getInvoicingPeriodDistributionV2BookedDepartment75 + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Actual booked e-conomic department 75 net amounts redistributed to internal departments + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/customers/pricing-history: + get: + tags: + - Invoices + summary: Get customer versioned pricing/subscription/discount timeline + operationId: getCustomerPricingHistoryV2 + parameters: + - name: customer_number + in: query + required: true + schema: + type: integer + minimum: 1 + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Customer timeline resolved + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerPricingHistoryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } # Vehicles Endpoints /vehicles: @@ -5037,7 +7063,11 @@ paths: type: integer description: Optional explicit target customer. Defaults to the effective customer context. responses: - '200': {description: Vehicle created} + '200': + description: Vehicle created + content: + application/json: + schema: {} '400': { $ref: '#/components/responses/BadRequest' } '403': { $ref: '#/components/responses/Forbidden' } put: @@ -5073,7 +7103,11 @@ paths: maxLength: 255 nullable: true responses: - '200': {description: Vehicle updated} + '200': + description: Vehicle updated + content: + application/json: + schema: {} '400': { $ref: '#/components/responses/BadRequest' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } @@ -5095,7 +7129,11 @@ paths: required: true schema: {type: integer} responses: - '200': {description: Vehicle deleted} + '200': + description: Vehicle deleted + content: + application/json: + schema: {} '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } @@ -5121,6 +7159,9 @@ paths: responses: '200': description: Available addons retrieved successfully + content: + application/json: + schema: {} '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } @@ -5153,6 +7194,9 @@ paths: responses: '200': description: Vehicle addon toggled successfully + content: + application/json: + schema: {} '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } @@ -5163,7 +7207,11 @@ paths: summary: Get unknown customer vehicles in department operationId: getUnknownCustomerVehicles responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} /department/vehicle/customer-suggestions: get: @@ -5177,7 +7225,11 @@ paths: required: true schema: {type: string} responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} /vehicles/set-auto-start-on-lpr: post: @@ -5204,7 +7256,11 @@ paths: id: {type: integer} active: {type: boolean} responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } @@ -5236,7 +7292,11 @@ paths: minLength: 1 maxLength: 50 responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} '400': { $ref: '#/components/responses/BadRequest' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } @@ -5248,7 +7308,11 @@ paths: summary: Get users with vehicle subscriptions operationId: getUsersWithVehicleSubscriptions responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} /vehicles/status: get: @@ -5262,7 +7326,11 @@ paths: required: true schema: {type: string} responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} /vehicles/search: get: @@ -5276,7 +7344,11 @@ paths: required: true schema: {type: string} responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} # Notifications Endpoints /notifications: @@ -5313,6 +7385,9 @@ paths: responses: '201': description: Notification created successfully + content: + application/json: + schema: {} delete: tags: - Notifications @@ -5328,6 +7403,9 @@ paths: responses: '200': description: Notification deleted successfully + content: + application/json: + schema: {} # Statistics Endpoints /statistics/bookings/new: @@ -5340,6 +7418,9 @@ paths: responses: '200': description: New bookings statistics retrieved successfully + content: + application/json: + schema: {} /orders/module/stripe/payment_intent: get: @@ -5355,6 +7436,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Orders @@ -5374,6 +7458,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} delete: tags: - Orders @@ -5387,6 +7474,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /orders/module/stripe/payment_intent/capture: post: @@ -5406,6 +7496,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /orders/module/stripe/debug/simulate_payment: post: @@ -5425,6 +7518,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /statistics/orders/new: get: @@ -5436,6 +7532,9 @@ paths: responses: '200': description: New orders statistics retrieved successfully + content: + application/json: + schema: {} /statistics/income/today: get: @@ -5447,6 +7546,9 @@ paths: responses: '200': description: Today's income statistics retrieved successfully + content: + application/json: + schema: {} /statistics/income/yesterday: get: @@ -5458,6 +7560,9 @@ paths: responses: '200': description: Yesterday's income statistics retrieved successfully + content: + application/json: + schema: {} /statistics/income/this-month: get: @@ -5469,6 +7574,9 @@ paths: responses: '200': description: This month's income statistics retrieved successfully + content: + application/json: + schema: {} /statistics/income/last-month: get: @@ -5480,6 +7588,9 @@ paths: responses: '200': description: Last month's income statistics retrieved successfully + content: + application/json: + schema: {} /statistics/income/this-year: get: @@ -5491,6 +7602,9 @@ paths: responses: '200': description: This year's income statistics retrieved successfully + content: + application/json: + schema: {} /statistics/income/departments: get: @@ -5499,7 +7613,11 @@ paths: summary: Get total income today by departments operationId: getTotalIncomeTodayByDepartments responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} /statistics/economic/totals: get: @@ -5508,7 +7626,11 @@ paths: summary: Get total economic statistics operationId: getEconomicTotals responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} /statistics/economic/totals/department_sent_invoice_totals: get: @@ -5517,7 +7639,11 @@ paths: summary: Get department sent invoice totals operationId: getDepartmentSentInvoiceTotals responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} /statistics/economic/totals/department_draft_invoice_totals: get: @@ -5526,7 +7652,11 @@ paths: summary: Get department draft invoice totals operationId: getDepartmentDraftInvoiceTotals responses: - '200': {description: Success} + '200': + description: Success + content: + application/json: + schema: {} # Worker Endpoints /worker/version: @@ -5539,6 +7669,9 @@ paths: responses: '200': description: Worker version retrieved successfully + content: + application/json: + schema: {} /worker/update-version: get: @@ -5556,6 +7689,9 @@ paths: responses: '200': description: Version update target set successfully + content: + application/json: + schema: {} /worker/status: get: @@ -5567,6 +7703,17 @@ paths: responses: '200': description: Worker status retrieved successfully + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + api_commit_sha: + type: string + description: Running API commit SHA, or unknown when unavailable. /worker/debug: get: @@ -5578,6 +7725,9 @@ paths: responses: '200': description: Debug information retrieved successfully + content: + application/json: + schema: {} '403': $ref: '#/components/responses/Forbidden' @@ -5590,6 +7740,9 @@ paths: responses: '200': description: Worker debug enabled + content: + application/json: + schema: {} '403': $ref: '#/components/responses/Forbidden' @@ -5602,6 +7755,9 @@ paths: responses: '200': description: Worker debug disabled + content: + application/json: + schema: {} '403': $ref: '#/components/responses/Forbidden' @@ -5615,6 +7771,9 @@ paths: responses: '200': description: License plates retrieved successfully + content: + application/json: + schema: {} '403': $ref: '#/components/responses/Forbidden' @@ -5633,6 +7792,9 @@ paths: responses: '200': description: Customer check completed + content: + application/json: + schema: {} '404': $ref: '#/components/responses/NotFound' @@ -5652,6 +7814,9 @@ paths: responses: '200': description: CVR information retrieved successfully + content: + application/json: + schema: {} /cvr/search: get: @@ -5670,6 +7835,9 @@ paths: responses: '200': description: Search results retrieved successfully + content: + application/json: + schema: {} # Plate Scans Endpoints /numberplatescans: @@ -5686,6 +7854,9 @@ paths: responses: '200': description: Plate scans retrieved successfully + content: + application/json: + schema: {} post: tags: - Plate Scans @@ -5709,6 +7880,9 @@ paths: responses: '201': description: Plate scan recorded successfully + content: + application/json: + schema: {} /numberplatescans/department: post: @@ -5733,6 +7907,9 @@ paths: responses: '201': description: Plate scan recorded successfully + content: + application/json: + schema: {} /numberplatescans/post: get: @@ -5743,6 +7920,9 @@ paths: responses: '200': description: Post-scan results retrieved successfully + content: + application/json: + schema: {} /numberplatescanners: get: @@ -5758,6 +7938,9 @@ paths: responses: '200': description: Plate scanners retrieved successfully + content: + application/json: + schema: {} post: tags: - Plate Scans @@ -5777,6 +7960,9 @@ paths: responses: '201': description: Plate scanner added successfully + content: + application/json: + schema: {} put: tags: - Plate Scans @@ -5797,6 +7983,9 @@ paths: responses: '200': description: Plate scanner updated successfully + content: + application/json: + schema: {} /department/numberplatescanners: get: @@ -5813,21 +8002,154 @@ paths: responses: '200': description: Department plate scanners retrieved successfully + content: + application/json: + schema: {} /relay/button/press/post: get: tags: - Plate Scans - summary: Add button press + summary: Record machine start button press webhook operationId: addButtonPress parameters: - name: token in: query - required: true + required: false schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: reg + in: query + required: false + schema: + type: string responses: '201': - description: Button press recorded + description: Button press recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Plate Scans + summary: Record machine start button press webhook + operationId: addButtonPressPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + reg: + type: string + responses: + '201': + description: Button press recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '404': + $ref: '#/components/responses/NotFound' + + /relay/machine/on/post: + get: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud webhook/query parameters for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignal + parameters: + - name: token + in: query + required: false + schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: relay_id + in: query + required: false + schema: + type: string + - name: event + in: query + required: false + schema: + type: string + enum: [input.toggle_on, switch.on] + - name: reg + in: query + required: false + schema: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + post: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud JSON webhook payloads for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignalPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + relay_id: + type: string + event: + type: string + enum: [input.toggle_on, switch.on] + component: + type: string + example: input:0 + state: + type: boolean + output: + type: boolean + reg: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' # Module - e-conomic Endpoints /economic/customers/import: @@ -5837,9 +8159,17 @@ paths: summary: Import e-conomic customers description: Import customers from e-conomic operationId: importEconomicCustomers + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Customers imported successfully + content: + application/json: + schema: {} /economic/departments: get: @@ -5850,6 +8180,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /economic/products: get: @@ -5860,6 +8193,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /modules/economic/customer: get: @@ -5875,6 +8211,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Modules @@ -5896,6 +8235,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /economic/layouts: get: @@ -5907,6 +8249,9 @@ paths: responses: '200': description: Layouts retrieved successfully + content: + application/json: + schema: {} /economic/payment-terms: get: @@ -5918,28 +8263,199 @@ paths: responses: '200': description: Payment terms retrieved successfully + content: + application/json: + schema: {} /economic/invoice/draft/export: post: tags: - Modules summary: Export draft invoice to e-conomic - description: Export a draft invoice to e-conomic - operationId: exportDraftInvoiceToEconomic + description: Exports draft invoice using queue processing when available, with synchronous fallback when queue dependencies are unavailable. + operationId: queueDraftInvoiceExportToEconomic + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [order_id] + properties: + order_id: + type: integer + minimum: 1 responses: '200': - description: Draft invoice exported successfully + description: Draft invoice export processed synchronously (fallback) + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse' + '202': + description: Draft invoice export queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /economic/invoice/draft/export/status: + get: + tags: + - Modules + summary: Get queued draft export job status + operationId: getDraftInvoiceExportQueueStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /economic/invoice/draft/export/retry: + post: + tags: + - Modules + summary: Retry failed draft export queue job + operationId: retryDraftInvoiceExportQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } /economic/invoice/export: post: tags: - Modules summary: Export invoice to e-conomic - description: Export a booked invoice to e-conomic - operationId: exportInvoiceToEconomic + description: Exports booked invoice using queue processing when available, with synchronous fallback when queue dependencies are unavailable. + operationId: queueInvoiceExportToEconomic + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [order_id] + properties: + order_id: + type: integer + minimum: 1 responses: '200': - description: Invoice exported successfully + description: Invoice export processed synchronously (fallback) + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse' + '202': + description: Invoice export queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /economic/invoice/export/status: + get: + tags: + - Modules + summary: Get queued invoice export job status + operationId: getInvoiceExportQueueStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /economic/invoice/export/retry: + post: + tags: + - Modules + summary: Retry failed invoice export queue job + operationId: retryInvoiceExportQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } # Module - Stripe Endpoints /modules/stripe/customers: @@ -5952,6 +8468,9 @@ paths: responses: '200': description: Stripe customers retrieved successfully + content: + application/json: + schema: {} /modules/stripe/products: get: @@ -5963,6 +8482,9 @@ paths: responses: '200': description: Stripe products retrieved successfully + content: + application/json: + schema: {} /modules/stripe/prices: get: @@ -5974,6 +8496,9 @@ paths: responses: '200': description: Stripe prices retrieved successfully + content: + application/json: + schema: {} /modules/stripe/invoice: post: @@ -5982,9 +8507,17 @@ paths: summary: Create Stripe invoice description: Create an invoice in Stripe operationId: createStripeInvoice + requestBody: + required: false + content: + application/json: + schema: {} responses: '201': description: Stripe invoice created successfully + content: + application/json: + schema: {} /modules/stripe/terminal/readers: get: @@ -5995,6 +8528,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /modules/stripe/terminal/locations: get: @@ -6005,6 +8541,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /modules/stripe/department/terminal/location: get: @@ -6020,6 +8559,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Modules @@ -6038,6 +8580,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /modules/stripe/department/terminal/readers: get: @@ -6053,6 +8598,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} # Module - Backup Endpoints /modules/backup/backups: @@ -6064,6 +8612,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Modules @@ -6082,6 +8633,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} # Module - XLVask Endpoints /modules/xlvask/usageLog: @@ -6097,6 +8651,9 @@ paths: responses: '200': description: Usage logs retrieved successfully + content: + application/json: + schema: {} /modules/xlvask/vehicles: get: @@ -6108,6 +8665,9 @@ paths: responses: '200': description: XLVask vehicles retrieved successfully + content: + application/json: + schema: {} /modules/xlvask/customers: get: @@ -6119,6 +8679,9 @@ paths: responses: '200': description: XLVask customers retrieved successfully + content: + application/json: + schema: {} /modules/action-logs: get: @@ -6168,12 +8731,244 @@ paths: schema: $ref: '#/components/schemas/SelfServeLaneStatus' + /modules/self-serve/lane/wash/in-progress: + get: + tags: + - Modules + summary: Get in-progress self-serve wash customer and vehicle details + description: | + Returns the current open self-serve wash session details for a lane (if any), + including resolved customer and vehicle details. + operationId: getSelfServeLaneWashInProgress + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: In-progress wash details resolved + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + in_progress: + type: boolean + session: + type: object + nullable: true + properties: + id: + type: integer + status: + type: string + reg: + type: string + customer_number: + type: integer + nullable: true + vehicle_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + included_minutes: + type: integer + nullable: true + machine_type_id: + type: integer + nullable: true + machine_relay_enabled: + type: boolean + machine_relay_enabled_at: + type: string + nullable: true + machine_start_triggered: + type: boolean + machine_start_triggered_at: + type: string + nullable: true + wash_started_at: + type: string + nullable: true + created_at: + type: string + updated_at: + type: string + nullable: true + customer: + type: object + nullable: true + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + nullable: true + display_name: + type: string + nullable: true + email: + type: string + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: string + nullable: true + vehicle: + type: object + nullable: true + properties: + id: + type: integer + customer_id: + type: integer + type: + type: integer + reg: + type: string + reference: + type: string + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/sessions: + get: + tags: + - Modules + summary: List self-serve wash sessions + description: Retrieve paginated self-serve wash sessions with search, filters, ordering, and active/open-only support. + operationId: listSelfServeSessions + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + - name: order + in: query + required: false + schema: + type: string + example: id:DESC + - name: open_only + in: query + required: false + schema: + type: boolean + responses: + '200': + description: Self-serve wash sessions retrieved successfully + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/SelfserveWashSession' + - type: object + properties: + elapsed_minutes: + type: integer + open: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/sessions/{id}: + get: + tags: + - Modules + summary: Get self-serve wash session detail + operationId: getSelfServeSessionDetail + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Self-serve wash session detail retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveWashSummary' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /modules/self-serve/lane/force/stop: + post: + tags: + - Modules + summary: Force stop a self-serve wash session + description: | + Clears the current self-serve wash session and lane runtime with RESET behavior only. + This administrative action does not signal relays or gates. When billing is requested, + only elapsed-minute billing is attempted before runtime is cleared. + operationId: forceStopSelfServeLane + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - bill + properties: + lane_id: + type: integer + session_id: + type: integer + nullable: true + bill: + type: boolean + reason: + type: string + nullable: true + responses: + '200': + description: Self-serve wash force stopped successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveForceStopResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + /modules/self-serve/lane/command: post: tags: - Modules summary: Send self-serve lane command - description: Send a command (e.g., start, stop, reset) to a self-serve lane + description: | + Send a command (e.g., start, stop, reset) to a self-serve lane. + Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here. + Operator callers require the base command permission plus the command-specific permission. Authenticated + customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve + lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target + department. operationId: sendSelfServeLaneCommand requestBody: required: true @@ -6189,7 +8984,7 @@ paths: type: integer command: type: string - enum: [START, STOP, RESET, RESERVE, RELEASE] + enum: [START, STOP, RESET, RESERVE, RELEASE, OPEN_PROPERTY_ACCESS_GATE, OPEN_PROPERTY_EXIT_GATE] license_plate: type: string description: Required for START command @@ -6200,6 +8995,20 @@ paths: application/json: schema: $ref: '#/components/schemas/SelfServeLaneStatus' + '400': + description: Command execution failed + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Failed to execute command: Failed to open property access gate.' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' /modules/self-serve/lane/services/allowed: post: @@ -6210,6 +9019,9 @@ paths: Updates the set of services that are allowed to be manually activated for a given self-serve lane, derived from the tasks currently shown to the user after answering the self-serve questions. This endpoint does not activate anything by itself; it only sets what is allowed to be activated. + Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with + `list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before + confirming a wash start. operationId: setSelfServeLaneAllowedServices requestBody: required: true @@ -6246,6 +9058,405 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /modules/self-serve/lane/gate/open: + post: + tags: + - Modules + summary: Open a self-serve lane gate + description: | + Opens either the ENTRANCE or EXIT gate relay for a self-serve lane. + Departments with `shelly_transport_mode=gateway` use the edge gateway/local edge agent path; + otherwise Shelly cloud remains the default. `transport=local` or `transport=gateway` is a + diagnostic local-only override, while `transport=cloud` forces Shelly cloud. + Failures return a sanitized gate-specific message. + operationId: openSelfServeLaneGate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - gate + properties: + lane_id: + type: integer + gate: + type: string + enum: [ENTRANCE, EXIT] + transport: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override + responses: + '200': + description: Lane gate opened + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + gate: + type: string + enum: [ENTRANCE, EXIT] + opened: + type: boolean + state: + type: string + transport: + type: string + nullable: true + '400': + description: Gate open failed + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Failed to open entrance gate. + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine/status: + get: + tags: + - Modules + summary: Get MACHINE relay status for a lane + description: | + Reads the current Shelly MACHINE relay status (`on`/`off`) for the given lane. + Transport follows the department `shelly_transport_mode`: `cloud` is the default, while + `gateway` uses the edge gateway/local edge agent with the binding fallback policy. + `transport=local` or `transport=gateway` forces local-only diagnostics; `transport=cloud` + forces Shelly cloud. + operationId: getSelfServeLaneMachineRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + - name: transport + in: query + required: false + schema: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override + responses: + '200': + description: MACHINE relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_program_picker/status: + get: + tags: + - Modules + summary: Get MACHINE_PROGRAM_PICKER relay status for a lane + description: | + Reads the current Shelly MACHINE_PROGRAM_PICKER relay status (`on`/`off`) for the given lane. + Transport follows the department `shelly_transport_mode`: `cloud` is the default, while + `gateway` uses the edge gateway/local edge agent with the binding fallback policy. + `transport=local` or `transport=gateway` forces local-only diagnostics; `transport=cloud` + forces Shelly cloud. + operationId: getSelfServeLaneMachineProgramPickerRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + - name: transport + in: query + required: false + schema: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override + responses: + '200': + description: MACHINE_PROGRAM_PICKER relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_program_picker/set: + post: + tags: + - Modules + summary: Set MACHINE_PROGRAM_PICKER relay status for a lane + description: | + Sets the Shelly MACHINE_PROGRAM_PICKER relay state for the lane to on or off and returns the latest status. + Transport follows the department `shelly_transport_mode`. `gateway` uses local edge agent + dispatch with binding fallback; `transport=local` or `transport=gateway` forces local-only + diagnostics, and `transport=cloud` forces Shelly cloud. + operationId: setSelfServeLaneMachineProgramPickerRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + transport: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override + responses: + '200': + description: MACHINE_PROGRAM_PICKER relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE_PROGRAM_PICKER] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + status: + type: object + description: Normalized Shelly switch status; includes `switch:0.output` + additionalProperties: true + transport: + type: string + nullable: true + binding: + type: object + description: Optional edge gateway relay binding diagnostics + additionalProperties: true + execution: + type: object + description: Optional edge gateway relay execution diagnostics + additionalProperties: true + raw: + type: object + description: Optional raw gateway/cloud transport payload + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_cleaner/status: + get: + tags: + - Modules + summary: Get MACHINE_CLEANER relay status for a lane + description: | + Reads the current Shelly MACHINE_CLEANER relay status (`on`/`off`) for the given lane. + Transport follows the department `shelly_transport_mode`: `cloud` is the default, while + `gateway` uses the edge gateway/local edge agent with the binding fallback policy. + `transport=local` or `transport=gateway` forces local-only diagnostics; `transport=cloud` + forces Shelly cloud. + operationId: getSelfServeLaneMachineCleanerRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + - name: transport + in: query + required: false + schema: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override + responses: + '200': + description: MACHINE_CLEANER relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_cleaner/set: + post: + tags: + - Modules + summary: Set MACHINE_CLEANER relay status for a lane + description: | + Sets the Shelly MACHINE_CLEANER relay state for the lane to on or off and returns the latest status. + Transport follows the department `shelly_transport_mode`. `gateway` uses local edge agent + dispatch with binding fallback; `transport=local` or `transport=gateway` forces local-only + diagnostics, and `transport=cloud` forces Shelly cloud. + operationId: setSelfServeLaneMachineCleanerRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + transport: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override + responses: + '200': + description: MACHINE_CLEANER relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE_CLEANER] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + status: + type: object + description: Normalized Shelly switch status; includes `switch:0.output` + additionalProperties: true + transport: + type: string + nullable: true + binding: + type: object + description: Optional edge gateway relay binding diagnostics + additionalProperties: true + execution: + type: object + description: Optional edge gateway relay execution diagnostics + additionalProperties: true + raw: + type: object + description: Optional raw gateway/cloud transport payload + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine/set: + post: + tags: + - Modules + summary: Set MACHINE relay status for a lane + description: | + Sets the Shelly MACHINE relay state for the lane to on or off and returns the latest status. + Transport follows the department `shelly_transport_mode`. `gateway` uses local edge agent + dispatch with binding fallback; `transport=local` or `transport=gateway` forces local-only + diagnostics, and `transport=cloud` forces Shelly cloud. + operationId: setSelfServeLaneMachineRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + transport: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override + responses: + '200': + description: MACHINE relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + status: + type: object + description: Normalized Shelly switch status; includes `switch:0.output` + additionalProperties: true + transport: + type: string + nullable: true + binding: + type: object + description: Optional edge gateway relay binding diagnostics + additionalProperties: true + execution: + type: object + description: Optional edge gateway relay execution diagnostics + additionalProperties: true + raw: + type: object + description: Optional raw gateway/cloud transport payload + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /modules/self-serve/lane/relay/machine/enable: post: tags: @@ -6254,7 +9465,11 @@ paths: description: | Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically - enabled; an explicit call to this endpoint is required. + enabled; an explicit call to this endpoint is required. Operator callers require + `modules_selfserve_lane_relay_enable_machine`; authenticated customers with + `list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash. + Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway` + forces local-only diagnostics, and `transport=cloud` forces Shelly cloud. operationId: enableSelfServeLaneMachineRelay requestBody: required: true @@ -6270,6 +9485,10 @@ paths: duration: type: integer description: Optional number of seconds after which the relay should automatically turn off + transport: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override responses: '200': description: MACHINE relay enabled @@ -6287,6 +9506,9 @@ paths: duration: type: integer nullable: true + transport: + type: string + nullable: true '401': $ref: '#/components/responses/Unauthorized' '403': @@ -6304,6 +9526,8 @@ paths: description: | Superuser/emergency endpoint. Bypasses the allowed services gating and directly turns on the MACHINE relay. Also ensures the lane is marked as OCCUPIED and IN_WASH with a wash start timestamp if not already set. + Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway` + forces local-only diagnostics, and `transport=cloud` forces Shelly cloud. operationId: forceEnableSelfServeLaneMachine requestBody: required: true @@ -6324,6 +9548,10 @@ paths: type: string nullable: true description: Optional license plate to associate with the lane + transport: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override responses: '200': description: MACHINE relay force-enabled and lane marked in-wash @@ -6348,6 +9576,9 @@ paths: type: string wash_start_time: type: integer + transport: + type: string + nullable: true '401': $ref: '#/components/responses/Unauthorized' '403': @@ -6361,6 +9592,8 @@ paths: description: | Superuser/emergency endpoint. Turns off the MACHINE relay while ensuring the lane remains in an IN_WASH state (simulating a started wash without machine assistance). + Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway` + forces local-only diagnostics, and `transport=cloud` forces Shelly cloud. operationId: forceDisableSelfServeLaneMachine requestBody: required: true @@ -6376,6 +9609,10 @@ paths: license_plate: type: string nullable: true + transport: + type: string + enum: [local, gateway, cloud] + description: Optional diagnostic Shelly transport override responses: '200': description: MACHINE relay force-disabled and lane ensured in-wash @@ -6397,6 +9634,9 @@ paths: type: string wash_start_time: type: integer + transport: + type: string + nullable: true '401': $ref: '#/components/responses/Unauthorized' '403': @@ -6419,6 +9659,9 @@ paths: responses: '200': description: Vehicle information retrieved successfully + content: + application/json: + schema: {} /modules/virkdata/search: get: @@ -6436,6 +9679,9 @@ paths: responses: '200': description: Company information retrieved successfully + content: + application/json: + schema: {} /modules/fxratesapi/rate: get: @@ -6458,6 +9704,9 @@ paths: responses: '200': description: Exchange rate retrieved successfully + content: + application/json: + schema: {} /modules/fxratesapi/rates: get: @@ -6469,6 +9718,365 @@ paths: responses: '200': description: Exchange rates retrieved successfully + content: + application/json: + schema: {} + + + /modules/weatherapi/current: + get: + tags: + - Modules + summary: Get current weather + description: Get current weather data from WeatherAPI for a location query + operationId: weatherApiCurrent + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Location query (e.g. city, postal code, or latitude,longitude) + responses: + '200': + description: Current weather retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiObjectResponse' + + /modules/weatherapi/forecast: + get: + tags: + - Modules + summary: Get weather forecast + description: Get forecast weather data from WeatherAPI + operationId: weatherApiForecast + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Location query (e.g. city, postal code, or latitude,longitude) + - name: days + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 14 + description: Number of forecast days + responses: + '200': + description: Forecast weather retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiObjectResponse' + + /modules/weatherapi/search: + get: + tags: + - Modules + summary: Search weather locations + description: Search location suggestions from WeatherAPI + operationId: weatherApiSearch + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Search text + responses: + '200': + description: Location search results retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiObjectResponse' + + /modules/workfeed/employees: + get: + tags: + - Modules + summary: List Workfeed employees + description: List employees from Workfeed (`GET /companies/{CompanyID}/employees`) + operationId: workfeedListEmployees + responses: + '200': + description: Workfeed employees retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedEmployeeListResponse' + + /modules/workfeed/employees/{id}: + get: + tags: + - Modules + summary: Get Workfeed employee + description: Retrieve a single Workfeed employee by identifier + operationId: workfeedGetEmployee + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workfeed employee identifier + responses: + '200': + description: Workfeed employee retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedEmployeeSingleResponse' + + /modules/workfeed/shifts: + get: + tags: + - Modules + summary: List Workfeed shifts + description: List Workfeed shifts (`GET /companies/{CompanyID}/shifts`) + operationId: workfeedListShifts + parameters: + - name: startFrom + in: query + required: true + schema: + type: string + format: date-time + description: Only return shifts starting on or after this timestamp (ISO 8601) + - name: startTo + in: query + required: true + schema: + type: string + format: date-time + description: Only return shifts starting before this timestamp (ISO 8601) + - name: employeeID + in: query + required: false + schema: + type: string + description: Filter shifts by employee ID + - name: released + in: query + required: false + schema: + type: boolean + description: Filter by released/published status + responses: + '200': + description: Workfeed shifts retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedShiftListResponse' + + /modules/workfeed/shifts/{id}: + get: + tags: + - Modules + summary: Get Workfeed shift + description: Retrieve a single Workfeed shift by identifier + operationId: workfeedGetShift + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workfeed shift identifier + responses: + '200': + description: Workfeed shift retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedShiftSingleResponse' + + /modules/workfeed/departments: + get: + tags: + - Modules + summary: List Workfeed departments + description: List departments from Workfeed (`GET /companies/{CompanyID}/departments`) + operationId: workfeedListDepartments + responses: + '200': + description: Workfeed departments retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedDepartmentListResponse' + + /departments/weather: + get: + tags: + - Departments + summary: Get department weather timeline + description: Returns hourly weather, washes, Workfeed employee-hours, and productivity status aggregated across selected departments (server local time). Default range is start of yesterday (`00:00`) to end of today (`23:00`). Use `date_from` and `date_to` (`YYYY-MM-DD`) together to override the range. If department coordinates are missing/invalid or WeatherAPI cannot resolve the location, weather data falls back silently and timeline slots default to `mostly_clear`. Slots return `unknown` status when they have no evaluable employee-hours or when one or more selected departments are missing department weather targets. + operationId: getDepartmentWeatherTimeline + parameters: + - name: id + in: query + required: false + schema: + type: array + items: + type: integer + minimum: 1 + minItems: 1 + uniqueItems: true + style: form + explode: true + description: Department ID list. Repeat `id` to select multiple departments (`?id=1&id=2`). + - name: ids + in: query + required: false + schema: + type: string + example: '1,2,3' + description: Optional CSV alternative for department IDs. Merged with `id` if both are provided. At least one of `id` or `ids` must be provided. + - name: date_from + in: query + required: false + schema: + type: string + format: date + example: '2026-03-23' + description: Optional range start date (`YYYY-MM-DD`). Must be used together with `date_to`. + - name: date_to + in: query + required: false + schema: + type: string + format: date + example: '2026-03-24' + description: Optional range end date (`YYYY-MM-DD`, inclusive). Must be used together with `date_from`. + responses: + '200': + description: Department weather timeline retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWeatherTimelineResponse' + example: + success: true + meta: [] + includes: [] + data: + - date: '2026-03-23' + time: '00:00' + current: false + weather: mostly_cloudy + washes: 1 + hours: 2.0 + status: degraded + - date: '2026-03-24' + time: '13:00' + current: true + weather: rain + washes: 0 + hours: 2.5 + status: unhealthy + - date: '2026-03-24' + time: '14:00' + current: false + weather: mostly_clear + washes: 0 + hours: 1.0 + status: unknown + + /departments/weather/targets: + get: + tags: + - Departments + summary: Get department weather status targets + description: Returns department-specific weather productivity thresholds used by `/departments/weather` to classify `healthy`, `degraded`, and `unhealthy` statuses. + operationId: getDepartmentWeatherTargets + parameters: + - name: id + in: query + required: false + schema: + type: array + items: + type: integer + minimum: 1 + minItems: 1 + uniqueItems: true + style: form + explode: true + description: Department ID list. Repeat `id` to select multiple departments (`?id=1&id=2`). + - name: ids + in: query + required: false + schema: + type: string + example: '1,2,3' + description: Optional CSV alternative for department IDs. Merged with `id` if both are provided. At least one of `id` or `ids` must be provided. + responses: + '200': + description: Department weather targets retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWeatherTargetsResponse' + example: + success: true + meta: [] + includes: [] + data: + - department_id: 1 + degraded_threshold: 1.0 + healthy_threshold: 1.3 + configured: true + - department_id: 2 + degraded_threshold: null + healthy_threshold: null + configured: false + put: + tags: + - Departments + summary: Upsert department weather status targets + description: Creates or updates the weather productivity thresholds for one department. `healthy_threshold` must be greater than or equal to `degraded_threshold`. + operationId: upsertDepartmentWeatherTarget + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWeatherTargetUpsertRequest' + example: + department_id: 1 + degraded_threshold: 1.0 + healthy_threshold: 1.3 + responses: + '200': + description: Department weather targets updated successfully + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/DepartmentWeatherTarget' + required: [data] + example: + success: true + meta: [] + includes: [] + data: + department_id: 1 + degraded_threshold: 1.0 + healthy_threshold: 1.3 + configured: true /modules/entra/users: get: @@ -6480,6 +10088,9 @@ paths: responses: '200': description: Entra users retrieved successfully + content: + application/json: + schema: {} # Attachments Endpoints /attachments/upload: @@ -6502,6 +10113,9 @@ paths: responses: '201': description: Attachment uploaded successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' @@ -6521,6 +10135,9 @@ paths: responses: '200': description: Order attachments retrieved successfully + content: + application/json: + schema: {} /orders/attachments/upload: post: @@ -6544,6 +10161,9 @@ paths: responses: '201': description: Order attachment uploaded successfully + content: + application/json: + schema: {} /orders/attachments/download: get: @@ -6583,6 +10203,9 @@ paths: responses: '200': description: Form retrieved successfully + content: + application/json: + schema: {} post: tags: - Forms @@ -6609,6 +10232,9 @@ paths: responses: '201': description: Form submitted successfully + content: + application/json: + schema: {} # Permissions Endpoints /permissions: @@ -6644,24 +10270,43 @@ paths: responses: '200': description: Customer attributes retrieved successfully + content: + application/json: + schema: {} post: tags: - Users summary: Add customer attribute description: Add a custom attribute to a customer operationId: addCustomerAttribute + requestBody: + required: false + content: + application/json: + schema: {} responses: '201': description: Customer attribute added successfully + content: + application/json: + schema: {} delete: tags: - Users summary: Delete customer attribute description: Remove a custom attribute from a customer operationId: deleteCustomerAttribute + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Customer attribute deleted successfully + content: + application/json: + schema: {} /customer/notes: get: @@ -6678,24 +10323,43 @@ paths: responses: '200': description: Customer notes retrieved successfully + content: + application/json: + schema: {} post: tags: - Users summary: Add customer note description: Add a note to a customer operationId: addCustomerNote + requestBody: + required: false + content: + application/json: + schema: {} responses: '201': description: Customer note added successfully + content: + application/json: + schema: {} delete: tags: - Users summary: Delete customer note description: Remove a note from a customer operationId: deleteCustomerNote + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Customer note deleted successfully + content: + application/json: + schema: {} /customers/search: post: @@ -6716,6 +10380,182 @@ paths: responses: '200': description: Customers found successfully + content: + application/json: + schema: {} + + /search/system: + get: + tags: + - Search + summary: System-wide search + description: Search across all supported entities with permission-aware filtering and optional intent parsing debug metadata. Customer records and customer-related entities are matched against a local e-conomic customer index (name/address/email/CVR) that is refreshed by cron. Intent parsing is invoked adaptively when lexical confidence is low or when the query looks intent-driven. Results are ordered by relevance, with recent records preferred when relevance is comparable. + operationId: systemWideSearchGet + parameters: + - in: query + name: query + required: true + schema: + type: string + description: Free-text query to search for. Supports natural-language intent fallback and domain synonyms such as `rabat` -> `discount`. + - in: query + name: include_types + required: false + schema: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + style: form + explode: false + description: Comma-separated list of entity types to include. Defaults to all allowed types. + - in: query + name: exclude_types + required: false + schema: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + style: form + explode: false + description: Comma-separated list of entity types to exclude. + - in: query + name: include_associations + required: false + schema: + type: boolean + default: true + description: Include associated objects when matching a primary entity such as a customer. + - in: query + name: debug_intent + required: false + schema: + type: boolean + default: false + description: Include intent parser diagnostics in `meta.intent_parser`. + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Search results returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: + - Search + summary: System-wide search + description: Search across all supported entities using JSON request payload. Customer-related matches include local e-conomic customer index fields. Intent parsing may run adaptively for intent-driven natural-language queries. Results are ordered by relevance, with recent records preferred when relevance is comparable. + operationId: systemWideSearchPost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchRequest' + responses: + '200': + description: Search results returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/search/system/cache: + delete: + tags: + - Search + summary: Clear system search caches + description: Clears both query-result cache and intent-parser cache namespaces for system-wide search. + operationId: clearSystemSearchCache + responses: + '200': + description: Cache cleared successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchCacheClearResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/search/system/cache/rebuild: + post: + tags: + - Search + summary: Queue system search cache rebuild + description: Queues a cache rebuild request and clears active query/intent cache namespaces immediately. + operationId: rebuildSystemSearchCache + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchCacheRebuildRequest' + responses: + '200': + description: Cache rebuild queued successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchCacheRebuildResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/system/status: + get: + tags: + - Superuser + summary: Aggregated system status snapshot + description: Returns a read-only snapshot of runtime health, dependency connectivity, module configuration/probe status, and active user session activity for the superuser dashboard. + operationId: getSuperuserSystemStatus + parameters: + - in: query + name: force + required: false + schema: + type: boolean + default: false + description: Bypass cached external module probes for this request. + responses: + '200': + description: System status snapshot returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserSystemStatusResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' # Configuration Endpoints /economic/config: @@ -6734,6 +10574,11 @@ paths: tags: [Config] summary: Update e-conomic config operationId: updateEconomicConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: e-conomic configuration updated successfully @@ -6746,7 +10591,7 @@ paths: get: tags: [Config] summary: Get reCAPTCHA config - operationId: getRecaptchaConfig + operationId: getRecaptchaModuleConfig responses: '200': description: reCAPTCHA configuration retrieved successfully @@ -6758,6 +10603,11 @@ paths: tags: [Config] summary: Update reCAPTCHA config operationId: updateRecaptchaConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: reCAPTCHA configuration updated successfully @@ -6782,6 +10632,11 @@ paths: tags: [Config] summary: Update email config operationId: updateEmailConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Email configuration updated successfully @@ -6795,6 +10650,11 @@ paths: tags: [Config] summary: Test email config operationId: testEmailConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Email configuration test completed @@ -6819,6 +10679,11 @@ paths: tags: [Config] summary: Update backups config operationId: updateBackupsConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Backups configuration updated successfully @@ -6843,6 +10708,11 @@ paths: tags: [Config] summary: Update Bird config operationId: updateBirdConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Bird configuration updated successfully @@ -6867,6 +10737,11 @@ paths: tags: [Config] summary: Update MotorAPI config operationId: updateMotorApiConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: MotorAPI configuration updated successfully @@ -6891,6 +10766,11 @@ paths: tags: [Config] summary: Update Stripe config operationId: updateStripeConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Stripe configuration updated successfully @@ -6915,6 +10795,11 @@ paths: tags: [Config] summary: Update FXRatesAPI config operationId: updateFxRatesApiConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: FXRatesAPI configuration updated successfully @@ -6923,6 +10808,89 @@ paths: schema: $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /weatherapi/config: + get: + tags: [Config] + summary: Get WeatherAPI config + operationId: getWeatherApiConfig + responses: + '200': + description: WeatherAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiConfigListResponse' + post: + tags: [Config] + summary: Update WeatherAPI config + operationId: updateWeatherApiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: WeatherAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /workfeed/config: + get: + tags: [Config] + summary: Get Workfeed config + operationId: getWorkfeedConfig + responses: + '200': + description: Workfeed configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedConfigListResponse' + examples: + default: + summary: Workfeed module configuration + value: + success: true + data: + - module: workfeed + variable: enabled + type: bool + value: true + - module: workfeed + variable: api_url + type: string + value: https://europe-west1-production-eu-327a3.cloudfunctions.net/api + - module: workfeed + variable: api_key + type: string + value: wf_live_xxxxxxxxxxxxxxxxx + - module: workfeed + variable: CompanyID + type: string + value: "123456" + meta: [] + includes: [] + post: + tags: [Config] + summary: Update Workfeed config + operationId: updateWorkfeedConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Workfeed configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + /gatewayapi/config: get: tags: [Config] @@ -6939,6 +10907,11 @@ paths: tags: [Config] summary: Update GatewayAPI config operationId: updateGatewayApiConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: GatewayAPI configuration updated successfully @@ -6963,6 +10936,11 @@ paths: tags: [Config] summary: Update XLVask config operationId: updateXlvaskConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: XLVask configuration updated successfully @@ -6987,6 +10965,11 @@ paths: tags: [Config] summary: Update Entra config operationId: updateEntraConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Entra configuration updated successfully @@ -7011,6 +10994,11 @@ paths: tags: [Config] summary: Update Limble config operationId: updateLimbleConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Limble configuration updated successfully @@ -7035,6 +11023,11 @@ paths: tags: [Config] summary: Update OcrSpace config operationId: updateOcrSpaceConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: OcrSpace configuration updated successfully @@ -7059,6 +11052,11 @@ paths: tags: [Config] summary: Update OpenAI config operationId: updateOpenAiConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: OpenAI configuration updated successfully @@ -7083,6 +11081,11 @@ paths: tags: [Config] summary: Update LicensePlateRecognizer config operationId: updateLicensePlateRecognizerConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: LicensePlateRecognizer configuration updated successfully @@ -7107,6 +11110,11 @@ paths: tags: [Config] summary: Update Virkdata config operationId: updateVirkdataConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Virkdata configuration updated successfully @@ -7131,6 +11139,11 @@ paths: tags: [Config] summary: Update Shelly config operationId: updateShellyConfig + requestBody: + required: false + content: + application/json: + schema: {} responses: '200': description: Shelly configuration updated successfully @@ -7187,6 +11200,9 @@ paths: responses: '200': description: Branding options retrieved successfully + content: + application/json: + schema: {} '400': $ref: '#/components/responses/BadRequest' '403': @@ -7208,9 +11224,23 @@ paths: name: {type: string} description: {type: string} cvr: {type: integer} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option added successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' put: @@ -7228,14 +11258,30 @@ paths: required: [id] properties: id: {type: integer} - name: {type: string} - description: {type: string} - cvr: {type: integer} + name: {type: string, nullable: true} + description: {type: string, nullable: true} + cvr: {type: integer, nullable: true} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /roles: get: @@ -7246,6 +11292,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Roles @@ -7263,6 +11312,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} put: tags: - Roles @@ -7281,6 +11333,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /roles/permissions: post: @@ -7301,6 +11356,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} delete: tags: - Roles @@ -7318,6 +11376,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /roles/clone: post: @@ -7338,6 +11399,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /modules/washcertificates: get: @@ -7348,6 +11412,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /modules/xlvask/services/usage/orders: get: @@ -7358,6 +11425,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /modules/xlvask/services/usage/orders/fast-link: get: @@ -7368,6 +11438,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /superuser/department: get: @@ -7378,6 +11451,39 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} + + /superuser/department/branding: + put: + tags: + - Departments + summary: Set department branding + description: Assign an existing branding option to a department, or clear the department branding by sending a null branding_id. + operationId: setDepartmentBranding + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, branding_id] + properties: + department_id: {type: integer} + branding_id: {type: integer, nullable: true} + responses: + '200': + description: Department branding updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /superuser/department/prices: get: @@ -7393,6 +11499,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Departments @@ -7412,6 +11521,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /superuser/department/variables: get: @@ -7427,6 +11539,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Departments @@ -7446,6 +11561,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /departments/daily-reports: get: @@ -7456,6 +11574,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} post: tags: - Departments @@ -7475,6 +11596,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} put: tags: - Departments @@ -7493,6 +11617,37 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} + + /departments/daily-reports/overview: + get: + tags: + - Departments + summary: Get daily report overview + operationId: getDailyReportOverview + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: date_to + in: query + required: false + schema: {type: string} + - name: department_ids + in: query + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportOverviewResponse' /departments/daily-reports/get: get: @@ -7512,6 +11667,130 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} + + /departments/daily-reports/complaints: + get: + tags: + - Departments + summary: List or fetch daily report customer complaints + operationId: listDailyReportComplaints + parameters: + - name: id + in: query + required: false + schema: {type: integer} + - name: page + in: query + required: false + schema: {type: integer} + - name: limit + in: query + required: false + schema: {type: integer} + - name: search + in: query + required: false + schema: {type: string} + - name: filters + in: query + required: false + schema: {type: string} + - name: order + in: query + required: false + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCollectionResponse' + post: + tags: + - Departments + summary: Create daily report customer complaint + operationId: createDailyReportComplaint + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCreateRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintResponse' + put: + tags: + - Departments + summary: Update daily report customer complaint + operationId: updateDailyReportComplaint + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintUpdateRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintResponse' + delete: + tags: + - Departments + summary: Delete daily report customer complaint + operationId: deleteDailyReportComplaint + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintDeleteResponse' + + /departments/daily-reports/complaints/customers: + get: + tags: + - Departments + summary: Search selectable customers for daily report complaints + operationId: searchDailyReportComplaintCustomers + parameters: + - name: search + in: query + required: true + schema: + type: string + minLength: 2 + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 20 + default: 10 + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResponse' /departments/daily-reports/product-count: get: @@ -7539,6 +11818,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} /departments/daily-reports/transaction-count: get: @@ -7562,6 +11844,38 @@ paths: responses: '200': description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportTransactionCountResponse' + + /departments/daily-reports/outside-hours-trend: + get: + tags: + - Departments + summary: Get outside-hours trend for daily reports + operationId: getDailyReportOutsideHoursTrend + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: date_to + in: query + required: true + schema: {type: string} + - name: department_ids + in: query + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendResponse' /departments/daily-reports/bookings-count: get: @@ -7581,6 +11895,9 @@ paths: responses: '200': description: Success + content: + application/json: + schema: {} # Account Security - Passkeys /account/security/passkeys: @@ -7600,6 +11917,10 @@ paths: $ref: '#/components/schemas/Passkey' '400': description: Invalid session or request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' post: tags: - Security @@ -7623,6 +11944,10 @@ paths: type: integer '400': description: Invalid session or request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /account/security/passkeys/{id}: patch: @@ -7645,8 +11970,15 @@ paths: responses: '200': description: Passkey renamed + content: + application/json: + schema: {} '404': description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' delete: tags: - Security @@ -7661,8 +11993,198 @@ paths: responses: '200': description: Passkey deleted + content: + application/json: + schema: {} '404': description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /superuser/releases/operations: + get: + tags: + - Release Manager + summary: List release operation runs + operationId: listReleaseOperations + parameters: + - in: query + name: channel_id + schema: + type: integer + - in: query + name: operation_type + schema: + type: string + - in: query + name: status + schema: + type: string + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + responses: + '200': + description: Release operation runs + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: array + items: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/releases/operations/{id}: + get: + tags: + - Release Manager + summary: Get release operation details + operationId: getReleaseOperation + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Release operation details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /superuser/releases/test-runs: + post: + tags: + - Release Manager + summary: Run Release Manager diagnostics + operationId: runReleaseTest + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '202': + description: Release test operation started + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + + /superuser/releases/channels/{id}/sync: + post: + tags: + - Release Manager + summary: Sync latest branch commits into a release channel + operationId: syncReleaseChannel + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '202': + description: Channel sync operation started + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + + /superuser/releases/issues/actions: + post: + tags: + - Release Manager + summary: Run a Release Manager issue action + operationId: runReleaseIssueAction + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + issue_key: + type: string + action_id: + type: string + inputs: + type: object + additionalProperties: true + confirm: + type: boolean + additionalProperties: true + responses: + '200': + description: Issue action result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' components: securitySchemes: @@ -7729,6 +12251,12 @@ components: application/json: schema: $ref: '#/components/schemas/Error' + Conflict: + description: Conflict - Request could not be completed due to current resource state + content: + application/json: + schema: + $ref: '#/components/schemas/Error' Unauthorized: description: Unauthorized - Invalid or missing authentication token content: @@ -7747,6 +12275,12 @@ components: application/json: schema: $ref: '#/components/schemas/Error' + ServiceUnavailable: + description: Service unavailable - Required async queue dependencies are unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/Error' InternalServerError: description: Internal server error content: @@ -7765,6 +12299,743 @@ components: type: integer description: HTTP status code + ErrorReportSubmissionRequest: + type: object + required: + - before_error + - expected + - actual + - data_collection_accepted + - screenshot + properties: + before_error: + type: string + maxLength: 4000 + description: What the user was doing before the error occurred + expected: + type: string + maxLength: 4000 + description: What the user expected would happen + actual: + type: string + maxLength: 4000 + description: What actually happened + data_collection_accepted: + type: boolean + description: Required acceptance of collecting screenshot and diagnostic error data + screenshot: + type: string + description: PNG, JPEG, or WebP data URI of the current app viewport + route_path: + type: string + nullable: true + page_url: + type: string + nullable: true + release_trace_id: + type: string + nullable: true + request_errors: + type: array + items: + type: object + additionalProperties: true + vue_errors: + type: array + items: + type: object + additionalProperties: true + context: + type: object + additionalProperties: true + + ErrorReportStatusUpdateRequest: + type: object + required: + - status + properties: + status: + type: string + enum: [open, resolved] + resolution_note: + type: string + nullable: true + maxLength: 2000 + + ErrorReportResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/ErrorReport' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + ErrorReportListResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/ErrorReport' + counts: + type: object + properties: + open: + type: integer + resolved: + type: integer + all: + type: integer + limit: + type: integer + offset: + type: integer + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + ErrorReport: + type: object + properties: + id: + type: integer + status: + type: string + enum: [open, resolved] + reporter: + type: object + additionalProperties: true + route_path: + type: string + nullable: true + page_url: + type: string + nullable: true + release_trace_id: + type: string + nullable: true + frontend_version: + type: string + nullable: true + api_version: + type: string + nullable: true + screenshot: + type: object + additionalProperties: true + answers: + type: object + properties: + before_error: + type: string + expected: + type: string + actual: + type: string + request_error_count: + type: integer + vue_error_count: + type: integer + request_errors: + type: array + items: + type: object + additionalProperties: true + vue_errors: + type: array + items: + type: object + additionalProperties: true + runtime_context: + type: object + additionalProperties: true + resolved_at: + type: string + nullable: true + resolved_by_user_id: + type: integer + nullable: true + created_at: + type: string + updated_at: + type: string + nullable: true + + SuperuserSystemStatusResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserSystemStatusPayload' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + SuperuserSystemStatusPayload: + type: object + properties: + overall_status: + $ref: '#/components/schemas/SuperuserSystemStatusEnum' + generated_at: + type: string + format: date-time + refresh_after_seconds: + type: integer + runtime: + type: object + properties: + cpu: + $ref: '#/components/schemas/SuperuserRuntimeMetric' + memory: + $ref: '#/components/schemas/SuperuserRuntimeMetric' + disk: + $ref: '#/components/schemas/SuperuserRuntimeMetric' + dependencies: + type: object + properties: + database: + $ref: '#/components/schemas/SuperuserDependencyStatus' + redis: + $ref: '#/components/schemas/SuperuserDependencyStatus' + minio: + $ref: '#/components/schemas/SuperuserMinioDependencyStatus' + modules: + type: array + items: + $ref: '#/components/schemas/SuperuserModuleStatus' + sessions: + $ref: '#/components/schemas/SuperuserSessionStatus' + warnings: + type: array + items: + type: string + required: + - overall_status + - generated_at + - refresh_after_seconds + - runtime + - dependencies + - modules + - sessions + - warnings + + SuperuserSystemStatusEnum: + type: string + enum: [ok, degraded, down] + + SuperuserModuleStatusEnum: + type: string + enum: [disabled, not_configured, configured, ok, degraded, down] + + SuperuserRuntimeMetric: + type: object + properties: + status: + $ref: '#/components/schemas/SuperuserSystemStatusEnum' + usage_percent: + type: number + format: float + nullable: true + used_bytes: + type: integer + nullable: true + free_bytes: + type: integer + nullable: true + total_bytes: + type: integer + nullable: true + path: + type: string + nullable: true + source: + type: string + nullable: true + checked_at: + type: string + format: date-time + + SuperuserDependencyStatus: + type: object + properties: + status: + $ref: '#/components/schemas/SuperuserSystemStatusEnum' + latency_ms: + type: number + format: float + nullable: true + database: + oneOf: + - type: integer + - type: string + nullable: true + server_version: + type: string + nullable: true + http_status: + type: integer + nullable: true + checked_at: + type: string + format: date-time + error: + type: string + nullable: true + + SuperuserMinioDependencyStatus: + type: object + properties: + status: + $ref: '#/components/schemas/SuperuserSystemStatusEnum' + latency_ms: + type: number + format: float + nullable: true + endpoint: + type: string + nullable: true + http_status: + type: integer + nullable: true + buckets: + type: array + items: + type: object + properties: + name: + type: string + status: + type: string + error: + type: string + nullable: true + checked_at: + type: string + format: date-time + error: + type: string + nullable: true + + SuperuserModuleStatus: + type: object + properties: + key: + type: string + enabled: + type: boolean + configured: + type: boolean + probe_supported: + type: boolean + status: + $ref: '#/components/schemas/SuperuserModuleStatusEnum' + status_reason: + type: string + nullable: true + checked_at: + type: string + format: date-time + required: + - key + - enabled + - configured + - probe_supported + - status + - checked_at + + SuperuserSessionStatus: + type: object + properties: + active_window_minutes: + type: integer + active_users: + type: integer + active_sessions: + type: integer + recent_sessions: + type: array + items: + type: object + properties: + session_kind: + type: string + principal_id: + type: integer + display_name: + type: string + context_label: + type: string + nullable: true + customer_number_context: + type: integer + nullable: true + device_type: + type: string + user_agent: + type: string + last_route: + type: string + first_seen_at: + type: string + format: date-time + nullable: true + last_seen_at: + type: string + format: date-time + nullable: true + active: + type: boolean + required: + - active_window_minutes + - active_users + - active_sessions + - recent_sessions + + SystemSearchEntityType: + type: string + enum: + - objects + - module_config + - orders + - order_items + - customers + - employees + - users + - subusers + - customer_discounts + - customer_fixed_prices + - departments + - permissions + - roles + - invoices + - vehicles + - bookings + - bookings_new + - branding + - categories + - currency_conversion_rates + - customer_codes + - customer_default_department + - customer_notes + - customer_vehicles_addons + - department_categories + - department_daily_reports + - department_gates + - department_goals + - department_lanes + - department_notification_sms + - department_relays + - department_selfserve_condition_rules + - department_selfserve_conditions + - department_selfserve_questions + - department_selfserve_tasks + - department_selfserve_vehicle_conditions + - department_time_bookings_entries + - department_time_bookings_opening_hours + - department_time_bookings_types + - department_variables + - fxratesapi_conversion_rates + - module_action_logs + - motorapi_lookups + - notifications + - order_bookings + - plate_scanners + - plate_scans + - product_options + - products + - stripe_module_customers + - stripe_module_orders + - stripe_payment_intents + - subuser_grants + - xlvask_customers + - xlvask_potential_order_matches + - xlvask_usage_log_wash_items + - xlvask_usage_logs + - xlvask_vehicle_types + - xlvask_vehicles + + SystemSearchRequest: + type: object + required: + - query + properties: + query: + type: string + description: Free-text query to search for. Customer lookups include local e-conomic index fields and lexical synonym expansion (for example `rabat` -> `discount`). + include_types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + description: Limit search to these entity types. + exclude_types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + description: Exclude these entity types from search. + include_associations: + type: boolean + default: true + description: Include associated records for matched core entities. + debug_intent: + type: boolean + default: false + description: Include parser diagnostics in `meta.intent_parser`. + limit: + type: integer + minimum: 1 + maximum: 200 + default: 50 + offset: + type: integer + minimum: 0 + default: 0 + + SystemSearchResult: + type: object + properties: + entity_type: + $ref: '#/components/schemas/SystemSearchEntityType' + entity_id: + type: string + title: + type: string + description: + type: string + customer_number: + type: integer + nullable: true + department_id: + type: integer + nullable: true + score: + type: integer + association_reason: + type: string + nullable: true + payload: + type: object + additionalProperties: true + required: + - entity_type + - entity_id + - title + - score + + SystemSearchIntentParserMeta: + type: object + properties: + invoked: + type: boolean + source: + type: string + enum: [cache, openai, none] + status: + type: string + confidence: + type: number + minimum: 0 + maximum: 1 + expanded_terms: + type: array + items: + type: string + entity_hints: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + fallback_reason: + type: string + nullable: true + required: + - invoked + - source + - status + - confidence + - expanded_terms + - entity_hints + + SystemSearchMeta: + type: object + properties: + query: + type: string + limit: + type: integer + offset: + type: integer + total: + type: integer + allowed_types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + cache: + type: object + properties: + hit: + type: boolean + required: [hit] + intent_parser: + $ref: '#/components/schemas/SystemSearchIntentParserMeta' + required: + - query + - limit + - offset + - total + - allowed_types + - cache + + SystemSearchPayload: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/SystemSearchResult' + grouped_results: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SystemSearchResult' + meta: + $ref: '#/components/schemas/SystemSearchMeta' + required: + - results + - grouped_results + - meta + + SystemSearchResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SystemSearchPayload' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + SystemSearchCacheClearResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + query_cache_cleared: + type: boolean + intent_cache_cleared: + type: boolean + required: + - message + - query_cache_cleared + - intent_cache_cleared + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + SystemSearchCacheRebuildRequest: + type: object + properties: + scope: + type: string + enum: [all, types, dirty] + default: all + types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + + SystemSearchCacheRebuildResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + request: + type: object + properties: + scope: + type: string + enum: [all, types, dirty] + types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + requested_at: + type: integer + required: + - scope + - types + - requested_at + query_cache_cleared: + type: boolean + intent_cache_cleared: + type: boolean + required: + - message + - request + - query_cache_cleared + - intent_cache_cleared + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + ModuleConfigValue: oneOf: - type: string @@ -7803,12 +13074,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, defaultDepartmentId] } type: { type: string, enum: [string, int] } value: oneOf: - type: string - type: integer + nullable: true required: [module, variable, type, value] RecaptchaConfigEntry: @@ -7898,6 +13170,69 @@ components: - type: string required: [module, variable, type, value] + + WeatherApiConfigEntry: + type: object + properties: + module: { type: string, enum: [weatherapi] } + variable: { type: string, enum: [enabled, secret_key] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + WorkfeedConfigEnabledEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [enabled] } + type: { type: string, enum: [bool] } + value: { type: boolean } + required: [module, variable, type, value] + + WorkfeedConfigApiUrlEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [api_url] } + type: { type: string, enum: [string] } + value: { type: string, example: "https://europe-west1-production-eu-327a3.cloudfunctions.net/api" } + required: [module, variable, type, value] + + WorkfeedConfigApiKeyEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [api_key] } + type: { type: string, enum: [string] } + value: { type: string, example: "wf_live_xxxxxxxxxxxxxxxxx" } + required: [module, variable, type, value] + + WorkfeedConfigCompanyIdEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [CompanyID] } + type: { type: string, enum: [string] } + value: { type: string, example: "123456" } + required: [module, variable, type, value] + + WorkfeedConfigEntry: + oneOf: + - $ref: '#/components/schemas/WorkfeedConfigEnabledEntry' + - $ref: '#/components/schemas/WorkfeedConfigApiUrlEntry' + - $ref: '#/components/schemas/WorkfeedConfigApiKeyEntry' + - $ref: '#/components/schemas/WorkfeedConfigCompanyIdEntry' + discriminator: + propertyName: variable + mapping: + enabled: '#/components/schemas/WorkfeedConfigEnabledEntry' + api_url: '#/components/schemas/WorkfeedConfigApiUrlEntry' + api_key: '#/components/schemas/WorkfeedConfigApiKeyEntry' + CompanyID: '#/components/schemas/WorkfeedConfigCompanyIdEntry' + GatewayApiConfigEntry: type: object properties: @@ -8011,7 +13346,7 @@ components: type: object properties: module: { type: string, enum: [selfserve] } - variable: { type: string, enum: [enabled, minute_product] } + variable: { type: string, enum: [enabled, minute_product, machine_wash_minutes_included] } type: { type: string, enum: [bool, int] } value: oneOf: @@ -8083,6 +13418,44 @@ components: data: { type: array, items: { $ref: '#/components/schemas/FxRatesApiConfigEntry' } } required: [data] + + WeatherApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WeatherApiConfigEntry' } } + required: [data] + + WorkfeedConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WorkfeedConfigEntry' } } + required: [data] + example: + success: true + data: + - module: workfeed + variable: enabled + type: bool + value: true + - module: workfeed + variable: api_url + type: string + value: https://europe-west1-production-eu-327a3.cloudfunctions.net/api + - module: workfeed + variable: api_key + type: string + value: wf_live_xxxxxxxxxxxxxxxxx + - module: workfeed + variable: CompanyID + type: string + value: "123456" + meta: [] + includes: [] + GatewayApiConfigListResponse: allOf: - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' @@ -8163,6 +13536,266 @@ components: data: { type: array, items: { $ref: '#/components/schemas/SelfServeConfigEntry' } } required: [data] + + DepartmentWeatherStatus: + type: string + description: Productivity health for the slot. `unknown` is returned when the slot has not started yet, has no employee-hours, or one or more selected departments are missing department weather targets. + enum: [unknown, healthy, degraded, unhealthy] + + DepartmentWeatherCondition: + type: string + enum: [clear, mostly_clear, partly_cloudy, mostly_cloudy, overcast, rain, showers, thunderstorm, snow, fog] + + DepartmentWeatherTarget: + type: object + properties: + department_id: + type: integer + minimum: 1 + degraded_threshold: + type: number + nullable: true + minimum: 0 + description: Minimum washes per hour ratio required for `degraded`. `null` when target is not configured. + healthy_threshold: + type: number + nullable: true + minimum: 0 + description: Minimum washes per hour ratio required for `healthy`. `null` when target is not configured. + configured: + type: boolean + description: Whether both weather status thresholds are configured and valid for the department. + required: [department_id, degraded_threshold, healthy_threshold, configured] + + DepartmentWeatherTargetUpsertRequest: + type: object + required: [department_id, degraded_threshold, healthy_threshold] + properties: + department_id: + type: integer + minimum: 1 + degraded_threshold: + type: number + minimum: 0 + healthy_threshold: + type: number + minimum: 0 + description: Must be greater than or equal to `degraded_threshold`. + + DepartmentWeatherTimelineEntry: + type: object + properties: + date: + type: string + format: date + description: Calendar date for the hourly slot (`YYYY-MM-DD`). + example: '2026-03-24' + time: + type: string + description: Hour label for the slot in 24-hour format (`HH:00`). + example: '01:00' + current: + type: boolean + description: True when this slot matches the current server hour. + example: false + weather: + $ref: '#/components/schemas/DepartmentWeatherCondition' + washes: + type: integer + minimum: 0 + example: 0 + hours: + type: number + format: float + minimum: 0 + example: 2.5 + description: Sum of Workfeed employee-hours in the department for this exact hour slot + status: + $ref: '#/components/schemas/DepartmentWeatherStatus' + required: [date, time, current, weather, washes, hours, status] + + WeatherApiObjectResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: object + additionalProperties: true + required: [data] + + WorkfeedDepartment: + type: object + properties: + id: { type: string, example: "PKHaOSgFA4uguOqmLfWv" } + name: { type: string, example: "API Testing Account 😎" } + isDeleted: { type: boolean, example: false } + createTime: { type: string, format: date-time, example: "2023-10-25T09:43:06.650Z" } + updateTime: { type: string, format: date-time, example: "2023-10-25T09:43:06.650Z" } + additionalProperties: true + + WorkfeedEmployee: + type: object + properties: + id: { type: string, example: "J9QAIiTG0nRC1OsC5YvHfLWdDHn1" } + firstname: { type: string, example: "API 2" } + lastname: { type: string, example: "Test 2" } + email: { type: string, nullable: true, example: "test2@example.com" } + phone: { type: string, nullable: true, example: "12345678" } + roleIDs: + type: array + items: { type: string } + example: ["hLbEKIPTlMh3ehotXl0w"] + departmentIDs: + type: array + items: { type: string } + example: ["PKHaOSgFA4uguOqmLfWv"] + primaryDepartmentID: { type: string, nullable: true } + street: { type: string, nullable: true, example: "" } + city: { type: string, nullable: true, example: "" } + zip: { type: string, nullable: true, example: "" } + accessLevel: { type: string, nullable: true, example: "employee" } + wage: { type: number, nullable: true } + minHours: { type: number, nullable: true } + maxHours: { type: number, nullable: true } + isDeleted: { type: boolean, example: false } + ssn: { type: string, nullable: true, example: "" } + imageURL: { type: string, nullable: true, format: uri } + createTime: { type: string, format: date-time, example: "2023-10-28T18:22:45.695Z" } + updateTime: { type: string, format: date-time, example: "2023-10-28T18:34:37.191Z" } + additionalProperties: true + + WorkfeedShiftComment: + type: object + properties: + message: { type: string, nullable: true, example: "Comments! 😍" } + creatorID: { type: string, nullable: true, example: "API" } + createdOn: { type: string, format: date-time, nullable: true, example: "2023-10-27T09:43:36.542Z" } + additionalProperties: true + + WorkfeedShiftCustomBreak: + type: object + properties: + creatorID: { type: string, nullable: true, example: "automatic" } + duration: { type: number, nullable: true, example: 1 } + createdOn: { type: string, format: date-time, nullable: true, example: "2023-10-27T09:10:30.336Z" } + additionalProperties: true + + WorkfeedShiftApproval: + type: object + properties: + approver: { type: string, nullable: true, example: "automatic" } + date: { type: string, format: date-time, nullable: true, example: "2023-12-27T09:10:30.336Z" } + originalStart: { type: string, format: date-time, nullable: true, example: "2023-12-07T09:10:30.336Z" } + originalEnd: { type: string, format: date-time, nullable: true, example: "2023-12-08T09:10:30.336Z" } + additionalProperties: true + + WorkfeedShift: + type: object + properties: + id: { type: string, example: "Trcu7MKFomu5y5zv1B8G" } + start: { type: string, format: date-time, example: "2023-10-23T08:00:00.000Z" } + end: { type: string, format: date-time, example: "2023-10-23T16:00:00.000Z" } + employeeID: { type: string, nullable: true } + roleID: { type: string, nullable: true, example: "hLbEKIPTlMh3ehotXl0w" } + departmentID: { type: string, nullable: true, example: "PKHaOSgFA4uguOqmLfWv" } + released: { type: boolean, example: false } + isForSale: { type: boolean, nullable: true, example: false } + comment: + allOf: + - $ref: '#/components/schemas/WorkfeedShiftComment' + nullable: true + customBreak: + allOf: + - $ref: '#/components/schemas/WorkfeedShiftCustomBreak' + nullable: true + overlappingLeaveID: { type: string, nullable: true } + approval: + allOf: + - $ref: '#/components/schemas/WorkfeedShiftApproval' + nullable: true + grossPay: { type: number, nullable: true } + tagIDs: + type: array + items: { type: string } + createTime: { type: string, format: date-time, example: "2023-10-27T09:10:30.336Z" } + updateTime: { type: string, format: date-time, example: "2023-10-27T09:10:30.422Z" } + additionalProperties: true + + WorkfeedEmployeeListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkfeedEmployee' + required: [data] + + WorkfeedEmployeeSingleResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/WorkfeedEmployee' + required: [data] + + WorkfeedShiftListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkfeedShift' + required: [data] + + WorkfeedShiftSingleResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/WorkfeedShift' + required: [data] + + WorkfeedDepartmentListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkfeedDepartment' + required: [data] + + DepartmentWeatherTimelineResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + description: Hourly contiguous slots from start of range (`00:00`) to end of range (`23:00`, inclusive). Defaults to yesterday+today (48 entries) when date_from/date_to are not provided. + items: + $ref: '#/components/schemas/DepartmentWeatherTimelineEntry' + required: [data] + + DepartmentWeatherTargetsResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/DepartmentWeatherTarget' + required: [data] + ModuleConfigEntry: type: object properties: @@ -8543,6 +14176,462 @@ components: type: string format: date-time + EconomicTransferQueueStatus: + type: string + enum: + - QUEUED + - PROCESSING + - COMPLETED + - FAILED + + EconomicTransferQueueJob: + type: object + properties: + id: + type: integer + transfer_type: + type: string + enum: + - ORDER_DRAFT_EXPORT + - ORDER_INVOICE_EXPORT + - COLLECTED_INVOICE_EXPORT + status: + $ref: '#/components/schemas/EconomicTransferQueueStatus' + progress_percent: + type: integer + minimum: 0 + maximum: 100 + progress_message: + type: string + nullable: true + attempts: + type: integer + minimum: 0 + max_attempts: + type: integer + minimum: 1 + error_message: + type: string + nullable: true + payload: + type: object + nullable: true + additionalProperties: true + result: + type: object + nullable: true + additionalProperties: true + details_summary: + type: object + nullable: true + additionalProperties: true + created_by: + type: integer + nullable: true + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + next_retry_at: + type: string + format: date-time + nullable: true + required: + - id + - transfer_type + - status + - progress_percent + - attempts + - max_attempts + + EconomicTransferQueueEnqueueResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job_id: + type: integer + minimum: 1 + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job_id + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferSynchronousFallbackResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + mode: + type: string + enum: [synchronous_fallback] + result: + type: object + additionalProperties: true + required: + - message + - mode + - result + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueStatusResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/EconomicTransferQueueJob' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueRetryResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueRunResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + processed: + type: integer + minimum: 0 + completed: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + jobs: + type: array + items: + type: integer + minimum: 1 + limit: + type: integer + minimum: 1 + maximum: 10 + transfer_type: + type: string + enum: + - COLLECTED_INVOICE_EXPORT + required: + - message + - processed + - completed + - failed + - jobs + - limit + - transfer_type + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueListResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + count: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + has_more: + type: boolean + required: + - items + - count + - total + - limit + - offset + - has_more + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueMonitorResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + jobs: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + counts: + type: object + properties: + queued: + type: integer + minimum: 0 + in_progress: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + completed: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + required: + - queued + - in_progress + - failed + - completed + - total + progress_percent: + type: integer + minimum: 0 + maximum: 100 + limit: + type: integer + minimum: 1 + maximum: 100 + required: + - jobs + - counts + - progress_percent + - limit + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissTerminalResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + dismissed_count: + type: integer + minimum: 0 + required: + - message + - dismissed_count + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + CollectedInvoiceEconomicCompareResponse: type: object description: Result of comparing a collected invoice with its E-conomic counterpart @@ -8584,23 +14673,1013 @@ components: type: number format: float nullable: true - description: draft_total minus booked_total when both are available + description: Selected e-conomic total (draft when available, otherwise booked) minus internal_total example: 0 internal_total: type: number format: float description: Internal total amount for the collected invoice example: 694 - order_ids: - type: array - description: List of order IDs included in the collected invoice - items: - type: integer - example: [38679, 39210] required: - collected_invoice_id - internal_total + + CollectedInvoiceEconomicV2DetailsResponse: + type: object + properties: + collected_invoice_id: + type: integer + external_id: + type: string + order_ids: + type: array + items: + type: integer + economic: + type: object + properties: + draft_id: + type: integer + nullable: true + booked_id: + type: integer + nullable: true + customer: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary' + internal: + type: object + required: [normalized] + properties: + normalized: + $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + draft: + type: object + properties: + exists: + type: boolean + raw: + type: object + nullable: true + additionalProperties: true + normalized: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + nullable: true + booked: + type: object + properties: + exists: + type: boolean + raw: + type: object + nullable: true + additionalProperties: true + normalized: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + nullable: true + warnings: + type: array + items: + type: string + required: + - collected_invoice_id - order_ids + - economic + - customer + - internal + - draft + - booked + - warnings + + CollectedInvoiceEconomicV2CustomerSummary: + type: object + properties: + internal_customer_number: + type: integer + nullable: true + draft_customer_number: + type: integer + nullable: true + booked_customer_number: + type: integer + nullable: true + exists: + type: boolean + name: + type: string + nullable: true + barred: + type: boolean + nullable: true + required: + - exists + + CollectedInvoiceEconomicV2CompareResponse: + type: object + properties: + collected_invoice_id: + type: integer + details: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse' + comparison: + $ref: '#/components/schemas/EconomicV2Comparison' + warnings: + type: array + items: + type: string + required: + - collected_invoice_id + - details + - comparison + - warnings + + CollectedInvoiceEconomicV2CompareBulkResponse: + type: object + properties: + requested: + type: integer + compared: + type: integer + failed: + type: integer + results: + type: array + items: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareResponse' + errors: + type: array + items: + type: object + properties: + collected_invoice_id: + type: integer + error: + type: string + required: + - requested + - compared + - failed + - results + - errors + + CollectedInvoiceEconomicV2RevenueStatisticsResponse: + type: object + properties: + filters: + type: object + properties: + dateFrom: + type: string + format: date + dateTo: + type: string + format: date + customer_numbers: + type: array + items: + type: integer + department_numbers: + type: array + items: + type: integer + currency: + type: string + nullable: true + barred: + type: string + enum: [all, barred, active] + max_pages: + type: integer + summary: + $ref: '#/components/schemas/EconomicV2RevenueSummary' + customers: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueCustomerStat' + departments: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueDepartmentStat' + currencies: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueCurrencyStat' + warnings: + type: array + items: + type: string + required: + - filters + - summary + - customers + - departments + - currencies + - warnings + + EconomicV2RevenueSummary: + type: object + properties: + invoice_count: + type: integer + line_count: + type: integer + unique_customers: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + average_invoice_net_amount: + type: number + required: + - invoice_count + - line_count + - unique_customers + - net_amount + - vat_amount + - gross_amount + - average_invoice_net_amount + + EconomicV2RevenueCustomerStat: + type: object + properties: + customer_number: + type: integer + customer_name: + type: string + nullable: true + barred: + type: boolean + nullable: true + invoice_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - customer_number + - invoice_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2RevenueDepartmentStat: + type: object + properties: + department_key: + type: string + department_number: + type: integer + nullable: true + invoice_count: + type: integer + line_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - department_key + - invoice_count + - line_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2RevenueCurrencyStat: + type: object + properties: + currency: + type: string + invoice_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - currency + - invoice_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2NormalizedInvoice: + type: object + properties: + source: + type: string + enum: [internal, draft, booked] + totals: + type: object + properties: + net_total: + type: number + line_net_total: + type: number + line_count: + type: integer + billable_line_count: + type: integer + difference_from_line_sum: + type: number + nullable: true + departments: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + lines: + type: array + items: + $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + warnings: + type: array + items: + type: string + required: + - source + - totals + - departments + - lines + - warnings + + EconomicV2NormalizedLineItem: + type: object + properties: + index: + type: integer + source: + type: string + source_order_id: + type: integer + nullable: true + source_line_id: + type: integer + nullable: true + line_type: + type: string + enum: [product, discount, text] + billable: + type: boolean + product_number: + type: string + nullable: true + product_id: + type: integer + nullable: true + description: + type: string + reference: + type: string + quantity: + type: number + unit_net_price: + type: number + line_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + match_key: + type: string + required: + - source + - line_type + - billable + - description + - reference + - quantity + - unit_net_price + - line_net_amount + - department_distribution + - match_key + + EconomicV2DepartmentDistribution: + type: object + additionalProperties: + type: number + example: + "75": 100 + + EconomicV2Comparison: + type: object + properties: + totals: + type: object + properties: + internal_net_total: + type: number + targets: + type: object + properties: + draft: + $ref: '#/components/schemas/EconomicV2TargetComparison' + booked: + $ref: '#/components/schemas/EconomicV2TargetComparison' + warnings: + type: array + items: + type: string + required: + - totals + - targets + - warnings + + EconomicV2TargetComparison: + type: object + properties: + target: + type: string + enum: [draft, booked] + status: + type: string + enum: [exact_match, partial_mismatch, total_mismatch, missing_target] + overall_match: + type: boolean + totals: + $ref: '#/components/schemas/EconomicV2TotalsComparison' + lines: + type: object + properties: + summary: + type: object + properties: + internal_billable_count: + type: integer + target_billable_count: + type: integer + mismatch_count: + type: integer + diff: + type: array + items: + $ref: '#/components/schemas/EconomicV2LineDiffEntry' + departments: + type: object + properties: + matches: + type: boolean + diff: + type: array + items: + $ref: '#/components/schemas/EconomicV2DepartmentDiffEntry' + mismatch_reasons: + type: array + items: + type: string + warnings: + type: array + items: + type: string + required: + - target + - status + - overall_match + - totals + - lines + - departments + - mismatch_reasons + - warnings + + EconomicV2TotalsComparison: + type: object + properties: + internal_net_total: + type: number + nullable: true + target_net_total: + type: number + nullable: true + difference: + type: number + nullable: true + abs_difference: + type: number + nullable: true + matches: + type: boolean + required: + - matches + + EconomicV2LineDiffEntry: + type: object + properties: + match_key: + type: string + reasons: + type: array + items: + type: string + internal_line: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + nullable: true + target_line: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + nullable: true + required: + - match_key + - reasons + + EconomicV2DepartmentDiffEntry: + type: object + properties: + department_key: + type: string + internal_amount: + type: number + target_amount: + type: number + difference: + type: number + matches: + type: boolean + required: + - department_key + - internal_amount + - target_amount + - difference + - matches + + InvoicingDistributionV2Transaction: + type: object + properties: + id: + type: integer + date: + type: string + format: date-time + amount: + type: number + booked: + type: boolean + department_id: + type: integer + excluded: + type: boolean + required: [id, date, amount, booked, department_id, excluded] + + InvoicingDistributionV2Customer: + type: object + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2Transaction' + requires_action: + type: boolean + meta: + type: object + additionalProperties: true + required: [customer_number, customer_name, transactions, requires_action, meta] + + InvoicingDistributionV2CategoryResponse: + type: object + properties: + customers: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2Customer' + collective_results: + type: object + additionalProperties: true + warnings: + type: array + items: + type: string + required: [customers, collective_results, warnings] + + InvoicingDistributionV2FixedPricingResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2WashSubscriptionsResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2CustomerPricesResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2BookedDepartment75Group: + type: object + properties: + month: + type: string + example: '2026-01' + source_category: + type: string + enum: [fixed_pricing, wash_subscriptions, unclassified] + invoice_ids: + type: array + items: + type: integer + booked_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + undistributed_net_amount: + type: number + required: + - month + - source_category + - invoice_ids + - booked_net_amount + - department_distribution + - undistributed_net_amount + + InvoicingDistributionV2BookedDepartment75Meta: + type: object + properties: + booked_net_amount: + type: number + distributed_net_amount: + type: number + undistributed_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + booked_groups: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Group' + required: + - booked_net_amount + - distributed_net_amount + - undistributed_net_amount + - department_distribution + - booked_groups + + InvoicingDistributionV2BookedDepartment75Customer: + allOf: + - $ref: '#/components/schemas/InvoicingDistributionV2Customer' + - type: object + properties: + meta: + type: object + properties: + booked_department_75: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Meta' + required: + - booked_department_75 + + InvoicingDistributionV2BookedDepartment75CollectiveResults: + type: object + properties: + booked_net_amount: + type: number + distributed_net_amount: + type: number + undistributed_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + department_distribution_parsed: + type: object + additionalProperties: + type: number + required: + - booked_net_amount + - distributed_net_amount + - undistributed_net_amount + - department_distribution + - department_distribution_parsed + + InvoicingDistributionV2BookedDepartment75Response: + type: object + properties: + customers: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Customer' + collective_results: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75CollectiveResults' + warnings: + type: array + items: + type: string + required: [customers, collective_results, warnings] + + InvoicingDistributionV2AllResponse: + type: object + properties: + fixed_pricing: + $ref: '#/components/schemas/InvoicingDistributionV2FixedPricingResponse' + wash_subscriptions: + $ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse' + customer_prices: + $ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse' + booked_department_75: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response' + required: [fixed_pricing, wash_subscriptions, customer_prices, booked_department_75] + + PricingHistoryVersionEntry: + type: object + properties: + id: + type: integer + type: + type: string + enum: [fixed_pricing, vehicle_subscription, discount_override] + customer_number: + type: integer + effective_from: + type: string + format: date-time + effective_to: + type: string + format: date-time + nullable: true + source: + type: string + confidence: + type: number + minimum: 0 + maximum: 1 + inferred: + type: boolean + metadata_json: + oneOf: + - type: string + - type: object + additionalProperties: true + - type: array + items: {} + nullable: true + required: + - id + - type + - customer_number + - effective_from + - source + - confidence + - inferred + + CustomerPricingHistoryResponse: + type: object + properties: + customer_number: + type: integer + fixed_pricing: + type: array + items: + type: object + additionalProperties: true + vehicle_subscriptions: + type: array + items: + type: object + additionalProperties: true + discount_overrides: + type: array + items: + type: object + additionalProperties: true + timeline: + type: array + items: + $ref: '#/components/schemas/PricingHistoryVersionEntry' + required: + - customer_number + - fixed_pricing + - vehicle_subscriptions + - discount_overrides + - timeline + + InvoicingWashSubscriptionsDistributionResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + $ref: '#/components/schemas/InvoicingWashSubscriptionsDistributionCustomer' + meta: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionMeta' + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + InvoicingWashSubscriptionsDistributionCustomer: + type: object + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionTransaction' + requires_action: + type: boolean + meta: + type: object + properties: + subscription: + type: object + additionalProperties: true + required: + - subscription + required: + - customer_number + - customer_name + - transactions + - requires_action + - meta + + InvoicingFixedPricingDistributionResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionCustomer' + meta: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionMeta' + includes: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionIncludes' + required: + - success + - data + - meta + - includes + + InvoicingFixedPricingDistributionCustomer: + type: object + properties: + id: + type: integer + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionTransaction' + requires_action: + type: boolean + meta: + type: object + properties: + fixed_pricing: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionFixedPricing' + required: + - fixed_pricing + required: + - id + - customer_number + - customer_name + - transactions + - requires_action + - meta + + InvoicingFixedPricingDistributionTransaction: + type: object + properties: + id: + type: integer + date: + type: string + description: Datetime in `YYYY-MM-DD HH:mm:ss` format. + example: '2026-02-02 10:43:41' + amount: + type: number + booked: + type: boolean + excluded: + type: boolean + required: + - id + - date + - amount + - booked + - excluded + + InvoicingFixedPricingDistributionFixedPricing: + type: object + properties: + customer_number: + type: integer + price: + type: number + description: + type: string + original_price: + type: number + department_totals: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + department_totals_relative: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + required: + - customer_number + - price + - description + - original_price + - department_totals + - department_totals_relative + + InvoicingFixedPricingDistributionMeta: + type: object + properties: + date_from: + type: string + description: Datetime in `YYYY-MM-DD HH:mm:ss` format. + example: '2026-02-01 00:00:00' + date_to: + type: string + description: Datetime in `YYYY-MM-DD HH:mm:ss` format. + example: '2026-02-28 23:59:59' + required: + - date_from + - date_to + + InvoicingFixedPricingDistributionIncludes: + type: object + properties: + debug_invoicing_period_customers_with_orders_in_date_range: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_process_customer_numbers: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_get_transactions_for_customers_in_date_range: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_calculate_transaction_totals: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_construct_customer_objects: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + collective_fixed_pricing_results: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionCollectiveResults' + additionalProperties: true + + InvoicingFixedPricingDistributionExecutionTime: + type: object + properties: + execution_time: + type: number + required: + - execution_time + + InvoicingFixedPricingDistributionCollectiveResults: + type: object + properties: + total_fixed_price: + type: number + total_original_price: + type: number + total_department_totals: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + total_department_totals_relative: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + total_department_totals_parsed: + type: object + additionalProperties: + type: number + total_department_totals_relative_parsed: + type: object + additionalProperties: + type: number + required: + - total_fixed_price + - total_original_price + - total_department_totals + - total_department_totals_relative + - total_department_totals_parsed + - total_department_totals_relative_parsed + + InvoicingFixedPricingDistributionNumberMapOrEmptyArray: + oneOf: + - type: object + additionalProperties: + type: number + - type: array + maxItems: 0 SelfServeLaneStatus: type: object @@ -8634,6 +15713,41 @@ components: description: Customer number associated with the current lane use nullable: true + SelfServeLaneMachineRelayStatus: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE, MACHINE_PROGRAM_PICKER, MACHINE_CLEANER] + relay_id: + type: string + online: + type: boolean + on: + type: boolean + status: + type: object + description: Normalized Shelly switch status; includes `switch:0.output` + additionalProperties: true + transport: + type: string + nullable: true + description: Diagnostic override used for the request, or null when department defaults applied + binding: + type: object + description: Optional edge gateway relay binding diagnostics + additionalProperties: true + execution: + type: object + description: Optional edge gateway relay execution diagnostics + additionalProperties: true + raw: + type: object + description: Optional raw gateway/cloud transport payload + additionalProperties: true + SelfServeConfig: type: object properties: @@ -8643,6 +15757,9 @@ components: minute_product: type: integer description: The product ID used for minute-based billing + machine_wash_minutes_included: + type: integer + description: Included machine wash minutes before minute-based billing starts SelfserveLaneService: type: string @@ -8650,6 +15767,310 @@ components: enum: - MACHINE + SelfserveMachineType: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + nullable: true + + SelfserveVisibleQuestion: + type: object + properties: + id: + type: integer + question: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + answer: + type: boolean + nullable: true + + SelfserveTaskDecision: + type: object + properties: + id: + type: integer + task: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: + type: integer + + SelfserveWashSession: + type: object + properties: + id: + type: integer + lane_id: + type: integer + department_id: + type: integer + machine_type_id: + type: integer + nullable: true + customer_number: + type: integer + nullable: true + vehicle_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + reg: + type: string + status: + type: string + enum: + - PENDING_QUESTIONS + - READY_FOR_MACHINE_START + - MACHINE_NOT_ALLOWED + - MACHINE_RELAY_ENABLED + - MACHINE_STARTED + - COMPLETED + - FORCE_STOPPED + allowed: + type: boolean + machine_relay_enabled: + type: boolean + machine_relay_enabled_at: + type: string + format: date-time + nullable: true + machine_start_triggered: + type: boolean + machine_start_triggered_at: + type: string + format: date-time + nullable: true + wash_started_at: + type: string + format: date-time + nullable: true + order_id: + type: integer + nullable: true + completed_at: + type: string + format: date-time + nullable: true + metadata: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + nullable: true + + SelfserveWashQuestionAnswer: + type: object + properties: + question_id: + type: integer + question: + type: string + answer: + type: boolean + nullable: true + answered_at: + type: string + format: date-time + nullable: true + + SelfserveWashTaskSnapshot: + type: object + properties: + task_id: + type: integer + nullable: true + task: + type: string + description: + type: string + nullable: true + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: + type: integer + + SelfserveWashEvent: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - SESSION_SYNCED + - MACHINE_RELAY_ENABLED + - MACHINE_START_TRIGGERED + - SESSION_COMPLETED + - SESSION_FORCE_STOPPED + payload: + type: object + additionalProperties: true + nullable: true + created_at: + type: string + format: date-time + + SelfserveVehicleAllowedResponse: + type: object + properties: + lane: + $ref: '#/components/schemas/DepartmentLane' + machine_type: + allOf: + - $ref: '#/components/schemas/SelfserveMachineType' + nullable: true + vehicle: + type: object + additionalProperties: true + nullable: true + reg: + type: string + customer_number: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + questions: + type: array + items: + $ref: '#/components/schemas/SelfserveVisibleQuestion' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveTaskDecision' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean + session: + allOf: + - $ref: '#/components/schemas/SelfserveWashSession' + nullable: true + + SelfserveWashSummary: + type: object + properties: + session: + $ref: '#/components/schemas/SelfserveWashSession' + lane: + allOf: + - $ref: '#/components/schemas/DepartmentLane' + nullable: true + machine_type: + allOf: + - $ref: '#/components/schemas/SelfserveMachineType' + nullable: true + questions: + type: array + items: + $ref: '#/components/schemas/SelfserveWashQuestionAnswer' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveWashTaskSnapshot' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean + events: + type: array + items: + $ref: '#/components/schemas/SelfserveWashEvent' + + SelfserveForceStopResponse: + type: object + properties: + lane_id: + type: integer + forced: + type: boolean + bill: + type: boolean + order_id: + type: integer + nullable: true + session: + allOf: + - $ref: '#/components/schemas/SelfserveWashSummary' + nullable: true + runtime_before_reset: + type: object + additionalProperties: true + + DepartmentSelfserveVehicleConditionMutationResponse: + type: object + properties: + condition: + $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + selfserve: + $ref: '#/components/schemas/SelfserveWashSummary' + + MachineButtonPressWebhookResponse: + type: object + properties: + message: + type: string + scanner: + type: string + lane_id: + type: integer + selfserve: + $ref: '#/components/schemas/SelfserveWashSummary' + DepartmentSelfserveQuestion: type: object properties: @@ -8700,6 +16121,10 @@ components: product: type: integer description: Product ID + machine_type_id: + type: integer + description: Reusable machine type ID + nullable: true condition_id: type: integer description: Condition ID (if conditional task) @@ -8751,6 +16176,10 @@ components: product: type: integer description: Product ID + machine_type_id: + type: integer + description: Reusable machine type ID + nullable: true condition_id: type: integer description: Optional condition ID @@ -8934,6 +16363,8 @@ components: type: integer visible: type: boolean + archived: + type: boolean dimension: type: integer branding: @@ -9004,6 +16435,8 @@ components: type: integer visible: type: boolean + archived: + type: boolean longitude: type: number format: float @@ -9024,6 +16457,8 @@ components: type: string visible: type: boolean + archived: + type: boolean longitude: type: number format: float @@ -9046,10 +16481,19 @@ components: type: string relay_machine_id: type: string + relay_machine_program_picker_id: + type: string + relay_machine_cleaner_id: + type: string dynamic_image_id: type: integer nullable: true minimum: 1 + machine_type_id: + type: integer + nullable: true + status: + type: string created_at: type: string format: date-time @@ -9073,10 +16517,17 @@ components: type: string relay_machine_id: type: string + relay_machine_program_picker_id: + type: string + relay_machine_cleaner_id: + type: string dynamic_image_id: type: integer nullable: true minimum: 1 + machine_type_id: + type: integer + nullable: true DepartmentLaneUpdate: type: object @@ -9095,10 +16546,17 @@ components: type: string relay_machine_id: type: string + relay_machine_program_picker_id: + type: string + relay_machine_cleaner_id: + type: string dynamic_image_id: type: integer nullable: true minimum: 1 + machine_type_id: + type: integer + nullable: true DepartmentGate: type: object @@ -9514,6 +16972,29 @@ components: type: number description: Target value for the goal example: 100 + target_duration: + type: string + nullable: true + description: | + Optional advanced target duration mode. + Accepted values: ENTIRE_DURATION, WEEKS, MONTHS, YEARS. + When omitted, legacy target behavior is preserved for backward compatibility. + The canonical field name is snake_case `target_duration`. + For backward-compatibility the API also accepts camelCase `targetDuration` on input. + enum: [ENTIRE_DURATION, WEEKS, MONTHS, YEARS] + example: WEEKS + target_duration_every: + type: integer + nullable: true + minimum: 1 + description: | + Optional cadence value used with `target_duration` WEEKS, MONTHS, or YEARS. + Example: with `target_duration=WEEKS` and `target_duration_every=2`, + the target applies every second week. + Ignored when `target_duration=ENTIRE_DURATION`. + The canonical field name is snake_case `target_duration_every`. + For backward-compatibility the API also accepts camelCase `targetDurationEvery` on input. + example: 1 label: type: string description: Optional short label/title for this goal criteria (max 255 characters) @@ -9804,34 +17285,26 @@ components: properties: id: type: string + format: uuid example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + workspaceId: + type: string + format: uuid + example: "3d5fae4f-9c2d-41aa-9840-28b18e6a94bc" channelId: type: string + format: uuid example: "a2545e48-fe8c-5741-9bdc-42a081076bc9" + callFlowId: + type: string + format: uuid + nullable: true originator: type: object - properties: - number: - type: object - properties: - type: { type: string, example: "pstn" } - number: { type: string, example: "+4532330288" } - countryIsoCode: { type: string, example: "DK" } + additionalProperties: true receiver: type: object - properties: - contact: - type: object - properties: - id: { type: string, example: "18229adf-af8c-404a-b036-ae193c22e33c" } - identifierKey: { type: string, example: "phonenumber" } - identifierValue: { type: string, example: "+4542331128" } - number: - type: object - properties: - type: { type: string, example: "pstn" } - number: { type: string, example: "+4542331128" } - countryIsoCode: { type: string, example: "DK" } + additionalProperties: true from: type: string example: "+4532330288" @@ -9852,29 +17325,139 @@ components: example: 3 hangupCauseCode: type: integer - example: 16 + nullable: true hangupSource: type: string - example: "callee" + nullable: true sipInsights: type: object - properties: - hangupSipCode: { type: string, example: "200" } + additionalProperties: true qualityInsights: type: object - properties: - mos: { type: string, example: "4.50" } - pdd: { type: string, example: "2.15" } + additionalProperties: true price: type: object + additionalProperties: true + createdAt: { type: string, format: date-time, nullable: true } + updatedAt: { type: string, format: date-time, nullable: true } + ringingAt: { type: string, format: date-time, nullable: true } + answeredAt: { type: string, format: date-time, nullable: true } + endedAt: { type: string, format: date-time, nullable: true } + + BirdVoiceCallCommandCondition: + type: object + properties: + variable: { type: string } + operator: { type: string } + value: { type: string } + + BirdVoiceCallCommandResult: + type: object + properties: + id: + type: string + format: uuid + callId: + type: string + format: uuid + callFlowId: + type: string + format: uuid + nullable: true + status: + type: string + command: + type: string + conditions: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCallCommandCondition' + + BirdVoiceCallBridgeResult: + allOf: + - $ref: '#/components/schemas/BirdVoiceCallCommandResult' + - type: object properties: - currencyCode: { type: string, example: "EUR" } - amount: { type: string, example: "0.0079" } - createdAt: { type: string, format: date-time, example: "2026-02-27T13:45:36.216Z" } - updatedAt: { type: string, format: date-time, example: "2026-02-27T13:45:46.532Z" } - ringingAt: { type: string, format: date-time, example: "2026-02-27T13:45:38.367Z" } - answeredAt: { type: string, format: date-time, example: "2026-02-27T13:45:43.359Z" } - endedAt: { type: string, format: date-time, example: "2026-02-27T13:45:46.368Z" } + bridgeCallId: + type: string + format: uuid + nullable: true + + BirdVoiceCallRecording: + type: object + properties: + id: + type: string + format: uuid + callId: + type: string + format: uuid + status: + type: string + example: ongoing + duration: + type: integer + nullable: true + stereo: + type: boolean + nullable: true + mediaUrl: + type: string + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + + BirdVoiceCallInsights: + type: object + description: Voice call insights payload as returned by Bird. + additionalProperties: true + + BirdFlashCall: + type: object + properties: + id: + type: string + format: uuid + workspaceId: + type: string + format: uuid + nullable: true + channelId: + type: string + format: uuid + nullable: true + from: + type: string + nullable: true + to: + type: string + nullable: true + receivedCli: + type: string + nullable: true + result: + type: string + nullable: true + status: + type: string + nullable: true + duration: + type: integer + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true BirdVoiceCallListResponse: type: object @@ -9885,6 +17468,7 @@ components: properties: nextPageToken: type: string + nullable: true example: "WzE3NzIxNTY5NTI0MDUsIjk5ZDU4M2VkLTQyMzAtNDExNy1hOTQ0LTllY2JjNzhmYWJlMSJd" results: type: array @@ -9924,6 +17508,183 @@ components: additionalProperties: true example: [] + BirdVoiceCallCommandResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallCommandResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallBridgeResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallBridgeResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallRecordingListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCallRecording' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallRecordingSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallRecording' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallInsightsResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallInsights' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallsLogResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdTestOutboundCallRequest: + type: object + additionalProperties: false + properties: + from: + type: string + description: Caller E.164 number to use for the test call + example: "+4599988877" + to: + type: string + description: Target E.164 number. Defaults to configured test number if omitted. + example: "+4542331128" + timeout: + type: integer + minimum: 1 + description: Backward-compatible alias mapped to ringTimeout + ringTimeout: + type: integer + minimum: 3 + maximum: 120 + pollIntervalSeconds: + type: integer + minimum: 1 + description: Poll interval while waiting for accepted status + example: 2 + maxPollSeconds: + type: integer + minimum: 5 + description: Max time to wait before timing out + example: 30 + hangupCause: + type: string + enum: [rejected, busy] + description: Optional hangup cause passed through to Bird + BirdTestOutboundCallResponse: type: object properties: @@ -9938,7 +17699,603 @@ components: hangup_sent: { type: boolean, example: true } created_call: { $ref: '#/components/schemas/BirdVoiceCall' } last_call_snapshot: { $ref: '#/components/schemas/BirdVoiceCall' } - hangup_response: { $ref: '#/components/schemas/BirdVoiceCall' } + hangup_response: { $ref: '#/components/schemas/BirdVoiceCallCommandResult' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdInboundCallWebhookRequest: + type: object + additionalProperties: true + properties: + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + workspaceId: + type: string + format: uuid + channelId: + type: string + format: uuid + payload: + type: object + additionalProperties: true + properties: + endKey: + type: string + example: "#" + retries: + type: integer + example: 3 + timeout: + type: integer + example: 30 + say: + type: object + additionalProperties: true + properties: + locale: + type: string + example: "en-US" + voice: + type: string + example: "female" + request: + type: object + additionalProperties: true + properties: + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + workspaceId: + type: string + format: uuid + channelId: + type: string + format: uuid + waitConditions: + type: object + additionalProperties: true + properties: + timeout: + type: string + example: "PT10M" + events: + type: array + items: + type: object + additionalProperties: true + properties: + action: + type: string + example: "continue" + name: + type: string + example: "call_command_gather_finished" + event: + type: object + additionalProperties: true + result: + type: object + additionalProperties: true + resumeData: + type: object + additionalProperties: true + dtmf: + type: string + description: DTMF value when present, for example `1`, `1#`, or `10#` + example: "10#" + digit: + type: string + description: Alternate DTMF field, also accepts values such as `10#` + example: "10#" + digits: + type: string + description: Alternate DTMF field + example: "10#" + keys: + type: string + description: Alternate DTMF field returned by gather results + example: "10#" + key: + type: string + example: "1" + input: + oneOf: + - type: string + - type: object + additionalProperties: true + conditions: + type: array + items: + type: object + additionalProperties: true + + BirdInboundCallWebhookResponse: + type: object + oneOf: + - $ref: '#/components/schemas/BirdInboundCallWebhookFlowGatherResponse' + - $ref: '#/components/schemas/BirdInboundCallWebhookActionResultResponse' + - $ref: '#/components/schemas/BirdInboundCallWebhookGatherAcceptedResponse' + - $ref: '#/components/schemas/BirdInboundCallWebhookTransportErrorResponse' + + BirdInboundCallWebhookFlowGatherResponse: + type: object + properties: + requestId: + type: string + example: "request-123" + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + status: + type: string + enum: [gather] + completed: + type: boolean + enum: [false] + stage: + type: string + enum: [department_select, gate_type_select] + prompt: + type: string + gather: + type: object + properties: + input: + type: string + enum: [dtmf] + maxNumKeys: + type: integer + endKey: + type: string + example: "#" + timeout: + type: integer + retries: + type: integer + say: + type: object + properties: + locale: + type: string + example: "en-US" + voice: + type: string + example: "female" + text: + type: string + selection: + type: object + properties: + departmentId: + type: integer + nullable: true + departmentName: + type: string + nullable: true + gateType: + type: string + nullable: true + enum: [entrance, exit] + gateId: + type: integer + nullable: true + invalidSelectionCount: + type: integer + resumed: + type: boolean + statusCode: + type: integer + enum: [200] + statusText: + type: string + enum: [OK] + + BirdInboundCallWebhookActionResultResponse: + type: object + properties: + requestId: + type: string + example: "request-123" + result: + type: object + properties: + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + status: + type: string + enum: [completed, failed, ignored] + action: + type: string + enum: [gate_opened, gate_open_failed, no_action, ignored] + message: + type: string + departmentId: + type: integer + nullable: true + gateType: + type: string + nullable: true + enum: [entrance, exit] + gateId: + type: integer + nullable: true + gateOpened: + type: boolean + resumeData: + type: object + additionalProperties: true + properties: + action: + type: string + example: "continue" + completed: + type: boolean + example: true + result: + type: string + example: "gate_opened" + gateOpened: + type: boolean + example: true + completedAt: + type: string + format: date-time + statusCode: + type: integer + enum: [200] + statusText: + type: string + enum: [OK] + + BirdInboundCallWebhookGatherAcceptedResponse: + type: object + properties: + event: + type: object + additionalProperties: true + requestId: + type: string + example: "request-123" + result: + type: object + properties: + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + command: + type: string + enum: [gather] + id: + type: string + status: + type: string + enum: [accepted] + resumeData: + type: object + additionalProperties: true + properties: + action: + type: string + example: "continue" + statusCode: + type: integer + enum: [202] + statusText: + type: string + enum: [Accepted] + suspendedAt: + type: string + format: date-time + resumedAt: + type: string + format: date-time + nullable: true + + BirdInboundCallWebhookTransportErrorResponse: + type: object + properties: + requestId: + type: string + statusCode: + type: integer + enum: [400, 500] + statusText: + type: string + enum: [Bad Request, Internal Server Error] + error: + type: object + properties: + message: + type: string + + BirdVoiceCallCreateRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + maxDuration: { type: integer, minimum: 1 } + sendKeys: { type: string } + record: { type: boolean } + recordStart: { type: string, enum: [record-from-answer, record-from-ringing] } + flowStart: { type: string, enum: [from-answer, from-ringing] } + stereo: { type: boolean } + callFlow: + type: array + items: + type: object + additionalProperties: true + scheduledFor: { type: string, format: date-time } + notification: + type: object + additionalProperties: false + properties: + url: { type: string } + amdSettings: + type: object + additionalProperties: true + tags: + type: array + items: { type: string } + + BirdVoiceCallUpdateRequest: + type: object + additionalProperties: false + properties: + status: + type: string + enum: [completed] + callFlow: + type: array + items: + type: object + additionalProperties: true + + BirdVoiceCallAnswerRequest: + type: object + additionalProperties: false + properties: {} + + BirdVoiceCallRingingRequest: + type: object + additionalProperties: false + properties: {} + + BirdVoiceCallHangupRequest: + type: object + additionalProperties: false + properties: + cause: + type: string + enum: [rejected, busy] + + BirdVoiceCallPlaybackRequest: + type: object + additionalProperties: false + required: [media] + properties: + media: + type: array + minItems: 1 + items: { type: string } + loop: { type: integer, minimum: 0 } + timeout: { type: integer, minimum: 0 } + pauseMilliseconds: { type: integer, minimum: 0, maximum: 30000 } + + BirdVoiceCallSayRequest: + type: object + additionalProperties: false + required: [text] + properties: + text: { type: string } + locale: { type: string } + voice: { type: string } + loop: { type: integer, minimum: 0 } + timeout: { type: integer, minimum: 0 } + hangup: { type: boolean } + + BirdVoiceCallGatherRequest: + type: object + additionalProperties: false + properties: + maxNumKeys: { type: integer, minimum: 1 } + endKey: { type: string, enum: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] } + timeout: { type: integer, minimum: 0 } + retries: { type: integer, minimum: 0 } + input: { type: string, enum: [dtmf, speech, 'dtmf speech'] } + speechLocale: { type: string } + playback: + $ref: '#/components/schemas/BirdVoiceCallPlaybackRequest' + say: + $ref: '#/components/schemas/BirdVoiceCallSayRequest' + + BirdVoiceCallBridgeRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + maxDuration: { type: integer, minimum: 1 } + ringTone: { type: string } + hangupAfterBridge: { type: boolean } + record: { type: boolean } + recordStart: { type: string, enum: [record-from-answer, record-from-ringing] } + recordStereo: { type: boolean } + callFlow: + type: array + items: + type: object + additionalProperties: true + notification: + type: object + additionalProperties: false + properties: + url: { type: string } + amdSettings: + type: object + additionalProperties: true + + BirdVoiceCallRecordRequest: + type: object + additionalProperties: false + properties: + endKey: { type: string, enum: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] } + maxLength: { type: integer, minimum: 1 } + timeout: { type: integer, minimum: 0 } + beep: { type: boolean } + transcribe: { type: boolean } + transcribeLocale: { type: string } + + BirdVoiceCallRecordingCreateRequest: + type: object + additionalProperties: false + properties: + maxLength: { type: integer, minimum: 1 } + stereo: { type: boolean } + + BirdVoiceCallRecordingUpdateRequest: + type: object + additionalProperties: false + required: [status] + properties: + status: + type: string + enum: [paused, ongoing, completed] + + BirdFlashCallCreateRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + + BirdFlashCallEndRequest: + type: object + additionalProperties: false + required: [result] + properties: + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + + BirdFlashCallHangupRequest: + oneOf: + - type: object + additionalProperties: false + required: [result] + properties: + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + - type: object + additionalProperties: false + required: [from, to] + properties: + from: { type: string } + to: { type: string } + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + + BirdFlashCallListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdFlashCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdFlashCallSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdFlashCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdFlashCallHangupResult: + type: object + properties: + id: + type: string + format: uuid + nullable: true + result: + type: string + nullable: true + receivedCli: + type: string + nullable: true + from: + type: string + nullable: true + to: + type: string + nullable: true + + BirdFlashCallHangupResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdFlashCallHangupResult' meta: oneOf: - type: array @@ -10070,3 +18427,265 @@ components: - type: object additionalProperties: true example: [] + + DepartmentDailyReportOutsideHoursBreakdown: + type: object + properties: + orders: { type: integer } + xlvask: { type: integer } + selfserve: { type: integer } + + DepartmentDailyReportOutsideHoursSummary: + type: object + properties: + department_ids: + type: array + items: { type: integer } + date: { type: string } + date_to: { type: string } + total: { type: integer } + by_source: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursBreakdown' + has_missing_opening_hours: { type: boolean } + missing_department_ids: + type: array + items: { type: integer } + + DepartmentDailyReportMetric: + type: object + properties: + state: { type: string } + value: + type: number + nullable: true + out_of: + type: number + nullable: true + message: + type: string + nullable: true + by_source: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursBreakdown' + has_missing_opening_hours: { type: boolean } + missing_department_ids: + type: array + items: { type: integer } + + DepartmentDailyReportComplaintCreateRequest: + type: object + required: [department_id, wash_date, category, description] + properties: + department_id: { type: integer } + customer_number: + type: integer + nullable: true + wash_date: + type: string + format: date + category: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCategory' + description: + type: string + minLength: 1 + maxLength: 4000 + + DepartmentDailyReportComplaintUpdateRequest: + type: object + required: [id] + properties: + id: { type: integer } + department_id: { type: integer } + customer_number: + type: integer + nullable: true + wash_date: + type: string + format: date + category: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCategory' + description: + type: string + minLength: 1 + maxLength: 4000 + + DepartmentDailyReportComplaintCategory: + type: string + enum: + - wash_quality + - wash_price + - damage_paint + - damage_mirrors + - damage_cables_electronics + - damage_plastic_parts + - damage_other + - service + - other + + DepartmentDailyReportComplaintCustomerSearchResult: + type: object + properties: + customer_number: + type: integer + customer_name: + type: string + nullable: true + + DepartmentDailyReportComplaint: + type: object + properties: + id: { type: integer } + department_id: { type: integer } + department_name: + type: string + nullable: true + customer_number: + type: integer + nullable: true + customer_name: + type: string + nullable: true + wash_date: + type: string + format: date + nullable: true + category: + allOf: + - $ref: '#/components/schemas/DepartmentDailyReportComplaintCategory' + nullable: true + description: { type: string } + created_by: { type: integer } + created_by_name: + type: string + nullable: true + created_at: + type: string + format: date-time + + DepartmentDailyReportComplaintResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/DepartmentDailyReportComplaint' + + DepartmentDailyReportComplaintCollectionResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + oneOf: + - $ref: '#/components/schemas/DepartmentDailyReportComplaint' + - type: array + items: + $ref: '#/components/schemas/DepartmentDailyReportComplaint' + + DepartmentDailyReportComplaintCustomerSearchResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: array + items: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResult' + + DepartmentDailyReportComplaintDeleteResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + message: { type: string } + + DepartmentDailyReportProductTile: + type: object + properties: + product_id: { type: integer } + slug: { type: string } + title: { type: string } + state: { type: string } + value: { type: integer } + out_of: { type: integer } + + DepartmentDailyReportOverviewPayload: + type: object + properties: + department_ids: + type: array + items: { type: integer } + date: { type: string } + date_to: { type: string } + metrics: + type: object + additionalProperties: + $ref: '#/components/schemas/DepartmentDailyReportMetric' + products: + type: array + items: + $ref: '#/components/schemas/DepartmentDailyReportProductTile' + + DepartmentDailyReportOverviewResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/DepartmentDailyReportOverviewPayload' + + DepartmentDailyReportTransactionCountPayload: + type: object + properties: + quantity: { type: integer } + products: { type: integer } + earnings: { type: integer } + washes: { type: integer } + water_usage: { type: integer } + date: { type: string } + date_to: { type: string } + department_id: { type: integer } + outside_hours: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursSummary' + + DepartmentDailyReportTransactionCountResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/DepartmentDailyReportTransactionCountPayload' + + DepartmentDailyReportOutsideHoursTrendPoint: + type: object + properties: + date: { type: string } + total: { type: integer } + by_source: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursBreakdown' + has_missing_opening_hours: { type: boolean } + missing_department_ids: + type: array + items: { type: integer } + + DepartmentDailyReportOutsideHoursTrendPayload: + type: object + properties: + department_ids: + type: array + items: { type: integer } + date: { type: string } + date_to: { type: string } + points: + type: array + items: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPoint' + has_missing_opening_hours: { type: boolean } + missing_department_ids: + type: array + items: { type: integer } + + DepartmentDailyReportOutsideHoursTrendResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload' + + diff --git a/scripts/.php-ci-test.lf.52582.sh b/scripts/.php-ci-test.lf.52582.sh new file mode 100644 index 00000000..63158f36 --- /dev/null +++ b/scripts/.php-ci-test.lf.52582.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env sh +set -eu + +suite="${1:-}" +case "$suite" in + unit|integration|api|legacy|all) + ;; + *) + echo "Usage: $0 " >&2 + exit 2 + ;; +esac + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)" +cd "$repo_root" + +compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml" +project_suffix="$(date +%s)-$$" +export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}" + +log_dir=".tmp/ci-logs/$suite" +mkdir -p "$log_dir" + +env_backup_dir=".tmp/php-ci-env-backup-$project_suffix" +mkdir -p "$env_backup_dir" +had_env=0 +had_env_staging=0 +if [ -f .env ]; then + cp .env "$env_backup_dir/env" + had_env=1 +fi +if [ -f .env.staging ]; then + cp .env.staging "$env_backup_dir/env.staging" + had_env_staging=1 +fi + +cp .github/ci.env .env +cp .github/ci.env.staging .env.staging + +collect_logs() { + status="$1" + if [ "$status" -eq 0 ]; then + return + fi + + mkdir -p "$log_dir" + docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true + docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true + docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true + docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true +} + +cleanup() { + status="$?" + collect_logs "$status" + docker compose $compose_files down -v >/dev/null 2>&1 || true + if [ "$had_env" -eq 1 ]; then + cp "$env_backup_dir/env" .env + else + rm -f .env + fi + if [ "$had_env_staging" -eq 1 ]; then + cp "$env_backup_dir/env.staging" .env.staging + else + rm -f .env.staging + fi + rm -rf "$env_backup_dir" + exit "$status" +} +trap cleanup EXIT INT TERM + +docker compose $compose_files up -d redis mysql-debug php1 + +docker compose $compose_files exec -T php1 sh -lc ' + set -eu + for i in $(seq 1 90); do + if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \ + -h "${CONFIG_DB_HOST:-mysql-debug}" \ + -P "${CONFIG_DB_PORT:-3306}" \ + -u "${CONFIG_DB_USER:-root}" \ + ping --silent >/dev/null 2>&1; then + exit 0 + fi + sleep 1 + done + echo "Timed out waiting for mysql-debug" >&2 + exit 1 +' + +tar \ + --exclude='./vendor' \ + --exclude='./.phpunit.cache' \ + --exclude='./build/logs' \ + -C services/nginx/app -cf - . \ + | docker compose $compose_files exec -T php1 tar -C /var/www/html -xf - + +docker compose $compose_files exec -T php1 sh -lc \ + 'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress' + +docker compose $compose_files exec -T php1 sh -lc \ + "cd /var/www/html && composer test:ci:$suite" diff --git a/scripts/clone-live-db-and-test.ps1 b/scripts/clone-live-db-and-test.ps1 new file mode 100644 index 00000000..da0463fe --- /dev/null +++ b/scripts/clone-live-db-and-test.ps1 @@ -0,0 +1,257 @@ +param( + [string]$TestCommand = "composer test:unit", + [ValidateSet("live", "debug")] + [string]$SourceTarget = "", + [string]$SourceHost = "", + [int]$SourcePort = 3306, + [string]$SourceDatabase = "", + [string]$SourceUser = "", + [string]$SourcePassword = "", + [string]$NetworkName = "api-test-net", + [string]$DbContainerName = "api-test-db", + [string]$RedisContainerName = "api-test-redis", + [string]$PhpImage = "api-php-test-runner", + [string]$CloneDatabase = "", + [string]$CloneRootPassword = "test_root_password", + [switch]$SkipBuild, + [switch]$ForceBuild, + [switch]$SkipTests, + [switch]$KeepContainers, + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Read-DotEnv { + param([string]$Path) + + $values = @{} + if (-not (Test-Path $Path)) { + return $values + } + + foreach ($line in Get-Content $Path) { + $trimmed = $line.Trim() + if ($trimmed -eq "" -or $trimmed.StartsWith("#")) { + continue + } + $idx = $trimmed.IndexOf("=") + if ($idx -lt 1) { + continue + } + $key = $trimmed.Substring(0, $idx).Trim() + $value = $trimmed.Substring($idx + 1).Trim() + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + $values[$key] = $value + } + return $values +} + +function Get-EnvOrDotEnv { + param( + [hashtable]$DotEnv, + [string]$Key + ) + + $envValue = [Environment]::GetEnvironmentVariable($Key) + if (-not [string]::IsNullOrWhiteSpace($envValue)) { + return $envValue + } + if ($DotEnv.ContainsKey($Key) -and -not [string]::IsNullOrWhiteSpace([string]$DotEnv[$Key])) { + return [string]$DotEnv[$Key] + } + return "" +} + +function Resolve-DbValueByTarget { + param( + [hashtable]$DotEnv, + [string]$Target, + [string]$Suffix + ) + + $resolvedTarget = $Target + if ([string]::IsNullOrWhiteSpace($resolvedTarget)) { + $resolvedTarget = Get-EnvOrDotEnv -DotEnv $DotEnv -Key "CONFIG_DB_TARGET" + } + if ([string]::IsNullOrWhiteSpace($resolvedTarget)) { + $resolvedTarget = "live" + } + $resolvedTarget = $resolvedTarget.ToLowerInvariant() + + $liveKey = "CONFIG_DB_$Suffix" + $debugKey = "CONFIG_DB_DEBUG_$Suffix" + if ($resolvedTarget -eq "debug") { + $debugValue = Get-EnvOrDotEnv -DotEnv $DotEnv -Key $debugKey + if (-not [string]::IsNullOrWhiteSpace($debugValue)) { + return $debugValue + } + } + + return Get-EnvOrDotEnv -DotEnv $DotEnv -Key $liveKey +} + +function Remove-ContainerIfExists { + param([string]$Name) + + $existingRaw = & docker ps -aq --filter "name=^${Name}$" + $existing = (($existingRaw | Out-String).Trim()) + if (-not [string]::IsNullOrWhiteSpace($existing)) { + & docker rm -f $Name | Out-Null + } +} + +$RootDir = Split-Path -Parent $PSScriptRoot +Set-Location $RootDir + +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + throw "Docker is required but was not found in PATH." +} + +$dotEnv = Read-DotEnv -Path (Join-Path $RootDir ".env") + +if ([string]::IsNullOrWhiteSpace($SourceTarget)) { + $SourceTarget = (Get-EnvOrDotEnv -DotEnv $dotEnv -Key "CONFIG_DB_TARGET").ToLowerInvariant() +} +if ([string]::IsNullOrWhiteSpace($SourceTarget)) { + $SourceTarget = "live" +} + +if ([string]::IsNullOrWhiteSpace($SourceHost)) { + $SourceHost = Resolve-DbValueByTarget -DotEnv $dotEnv -Target $SourceTarget -Suffix "HOST" +} +if ([string]::IsNullOrWhiteSpace($SourceDatabase)) { + $SourceDatabase = Resolve-DbValueByTarget -DotEnv $dotEnv -Target $SourceTarget -Suffix "DATABASE" +} +if ([string]::IsNullOrWhiteSpace($SourceUser)) { + $SourceUser = Resolve-DbValueByTarget -DotEnv $dotEnv -Target $SourceTarget -Suffix "USER" +} +if ([string]::IsNullOrWhiteSpace($SourcePassword)) { + $SourcePassword = Resolve-DbValueByTarget -DotEnv $dotEnv -Target $SourceTarget -Suffix "PASSWORD" +} +if ([string]::IsNullOrWhiteSpace($CloneDatabase)) { + $CloneDatabase = "${SourceDatabase}_test_clone" +} + +if ([string]::IsNullOrWhiteSpace($SourceHost) -or [string]::IsNullOrWhiteSpace($SourceDatabase) -or [string]::IsNullOrWhiteSpace($SourceUser) -or [string]::IsNullOrWhiteSpace($SourcePassword)) { + throw "Missing source DB credentials for target '$SourceTarget'. Set CONFIG_DB_TARGET and CONFIG_DB_* / CONFIG_DB_DEBUG_* in .env or pass script parameters." +} + +if (-not $Force) { + Write-Host "This will clone data from the '$SourceTarget' database target into a local Docker MySQL container." -ForegroundColor Yellow + $confirmation = Read-Host "Type CLONE to continue" + if ($confirmation -ne "CLONE") { + throw "Cancelled." + } +} + +if (-not $SkipBuild) { + & docker image inspect $PhpImage *> $null + $imageExists = ($LASTEXITCODE -eq 0) + if ($imageExists -and -not $ForceBuild) { + Write-Host "Using cached PHP test image ($PhpImage). Use -ForceBuild to rebuild." -ForegroundColor Cyan + } else { + Write-Host "Building PHP test image ($PhpImage)..." -ForegroundColor Cyan + & docker build -f services/php/Dockerfile -t $PhpImage . + } +} + +$networkExists = (& docker network ls --format "{{.Name}}" | Where-Object { $_ -eq $NetworkName }) +if (-not $networkExists) { + Write-Host "Creating docker network $NetworkName..." -ForegroundColor Cyan + & docker network create $NetworkName | Out-Null +} + +Write-Host "Resetting test containers..." -ForegroundColor Cyan +Remove-ContainerIfExists -Name $DbContainerName +Remove-ContainerIfExists -Name $RedisContainerName + +Write-Host "Starting cloned MySQL container ($DbContainerName)..." -ForegroundColor Cyan +& docker run -d ` + --name $DbContainerName ` + --network $NetworkName ` + -e "MYSQL_ROOT_PASSWORD=$CloneRootPassword" ` + -e "MYSQL_DATABASE=$CloneDatabase" ` + mysql:8.4 | Out-Null + +$maxAttempts = 60 +for ($i = 1; $i -le $maxAttempts; $i++) { + & docker exec -e "MYSQL_PWD=$CloneRootPassword" $DbContainerName sh -lc "mysqladmin -u root ping --silent" *> $null + if ($LASTEXITCODE -eq 0) { + break + } + if ($i -eq $maxAttempts) { + throw "Timed out waiting for $DbContainerName to be ready." + } + Start-Sleep -Seconds 2 +} + +Write-Host "Cloning '$SourceTarget' database $SourceDatabase from $SourceHost into $DbContainerName/$CloneDatabase..." -ForegroundColor Cyan +$dumpDir = Join-Path $RootDir ".tmp-db-clone" +$dumpFile = Join-Path $dumpDir "dump.sql" +if (-not (Test-Path $dumpDir)) { + New-Item -Path $dumpDir -ItemType Directory | Out-Null +} +if (Test-Path $dumpFile) { + Remove-Item -Path $dumpFile -Force +} + +$dumpMount = $dumpDir.Replace('\', '/') +$dumpCmd = "exec mysqldump --compress --single-transaction --quick --set-gtid-purged=OFF -h '$SourceHost' -P '$SourcePort' -u '$SourceUser' '$SourceDatabase' > /dump/dump.sql" +& docker run --rm ` + -e "MYSQL_PWD=$SourcePassword" ` + -v "${dumpMount}:/dump" ` + mysql:8.4 sh -lc $dumpCmd +if ($LASTEXITCODE -ne 0 -or -not (Test-Path $dumpFile)) { + throw "Database dump failed." +} + +$importCmd = "exec mysql -h '$DbContainerName' -u root '$CloneDatabase' < /dump/dump.sql" +& docker run --rm ` + --network $NetworkName ` + -e "MYSQL_PWD=$CloneRootPassword" ` + -v "${dumpMount}:/dump" ` + mysql:8.4 sh -lc $importCmd +if ($LASTEXITCODE -ne 0) { + throw "Database import failed." +} + +Remove-Item -Path $dumpFile -Force -ErrorAction SilentlyContinue + +Write-Host "Starting isolated Redis container ($RedisContainerName)..." -ForegroundColor Cyan +& docker run -d --name $RedisContainerName --network $NetworkName redis:7 | Out-Null + +if (-not $SkipTests) { + Write-Host "Running tests in a separate PHP container against cloned DB..." -ForegroundColor Cyan + $appPath = Join-Path $RootDir "services/nginx/app" + $phpIniPath = Join-Path $RootDir "services/php/php.ini" + + & docker run --rm ` + --network $NetworkName ` + --env-file (Join-Path $RootDir ".env") ` + -e "CONFIG_DB_HOST=$DbContainerName" ` + -e "CONFIG_DB_DATABASE=$CloneDatabase" ` + -e "CONFIG_DB_USER=root" ` + -e "CONFIG_DB_PASSWORD=$CloneRootPassword" ` + -e "REDIS_CONFIG_HOST=$RedisContainerName" ` + -e "AUTO_COMPOSER_INSTALL=false" ` + -v "${appPath}:/var/www/html" ` + -v "${phpIniPath}:/usr/local/etc/php/conf.d/zz-custom.ini:ro" ` + $PhpImage ` + sh -lc "cd /var/www/html && if [ ! -f vendor/autoload.php ]; then composer install --no-interaction --prefer-dist; fi && $TestCommand" +} + +if (-not $KeepContainers) { + Write-Host "Cleaning up test containers..." -ForegroundColor Cyan + Remove-ContainerIfExists -Name $RedisContainerName + Remove-ContainerIfExists -Name $DbContainerName +} else { + Write-Host "Keeping containers for inspection:" -ForegroundColor Yellow + Write-Host " DB: $DbContainerName" + Write-Host " Redis: $RedisContainerName" +} + +Write-Host "Done." -ForegroundColor Green diff --git a/scripts/clone-live-db-and-test.sh b/scripts/clone-live-db-and-test.sh new file mode 100644 index 00000000..394d34e9 --- /dev/null +++ b/scripts/clone-live-db-and-test.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +TEST_COMMAND="${TEST_COMMAND:-composer test:unit}" +SOURCE_TARGET="${SOURCE_TARGET:-}" +SOURCE_HOST="${SOURCE_HOST:-${CONFIG_DB_HOST:-}}" +SOURCE_PORT="${SOURCE_PORT:-${CONFIG_DB_PORT:-3306}}" +SOURCE_DATABASE="${SOURCE_DATABASE:-${CONFIG_DB_DATABASE:-}}" +SOURCE_USER="${SOURCE_USER:-${CONFIG_DB_USER:-}}" +SOURCE_PASSWORD="${SOURCE_PASSWORD:-${CONFIG_DB_PASSWORD:-}}" +NETWORK_NAME="${NETWORK_NAME:-api-test-net}" +DB_CONTAINER_NAME="${DB_CONTAINER_NAME:-api-test-db}" +REDIS_CONTAINER_NAME="${REDIS_CONTAINER_NAME:-api-test-redis}" +PHP_IMAGE="${PHP_IMAGE:-api-php-test-runner}" +CLONE_DATABASE="${CLONE_DATABASE:-}" +CLONE_ROOT_PASSWORD="${CLONE_ROOT_PASSWORD:-test_root_password}" +SKIP_BUILD="${SKIP_BUILD:-0}" +FORCE_BUILD="${FORCE_BUILD:-0}" +SKIP_TESTS="${SKIP_TESTS:-0}" +KEEP_CONTAINERS="${KEEP_CONTAINERS:-0}" +FORCE="${FORCE:-0}" + +if [[ -f ".env" ]]; then + # shellcheck disable=SC2046 + export $(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' .env | sed 's/\r$//') + SOURCE_TARGET="${SOURCE_TARGET:-${CONFIG_DB_TARGET:-}}" +fi + +SOURCE_TARGET="${SOURCE_TARGET:-live}" +SOURCE_TARGET="$(echo "${SOURCE_TARGET}" | tr '[:upper:]' '[:lower:]')" +if [[ "${SOURCE_TARGET}" != "live" && "${SOURCE_TARGET}" != "debug" ]]; then + SOURCE_TARGET="live" +fi + +if [[ "${SOURCE_TARGET}" == "debug" ]]; then + SOURCE_HOST="${SOURCE_HOST:-${CONFIG_DB_DEBUG_HOST:-${CONFIG_DB_HOST:-}}}" + SOURCE_DATABASE="${SOURCE_DATABASE:-${CONFIG_DB_DEBUG_DATABASE:-${CONFIG_DB_DATABASE:-}}}" + SOURCE_USER="${SOURCE_USER:-${CONFIG_DB_DEBUG_USER:-${CONFIG_DB_USER:-}}}" + SOURCE_PASSWORD="${SOURCE_PASSWORD:-${CONFIG_DB_DEBUG_PASSWORD:-${CONFIG_DB_PASSWORD:-}}}" +else + SOURCE_HOST="${SOURCE_HOST:-${CONFIG_DB_HOST:-}}" + SOURCE_DATABASE="${SOURCE_DATABASE:-${CONFIG_DB_DATABASE:-}}" + SOURCE_USER="${SOURCE_USER:-${CONFIG_DB_USER:-}}" + SOURCE_PASSWORD="${SOURCE_PASSWORD:-${CONFIG_DB_PASSWORD:-}}" +fi + +if [[ -z "${SOURCE_HOST}" || -z "${SOURCE_DATABASE}" || -z "${SOURCE_USER}" || -z "${SOURCE_PASSWORD}" ]]; then + echo "Missing source DB credentials for target '${SOURCE_TARGET}'. Set CONFIG_DB_TARGET and CONFIG_DB_* / CONFIG_DB_DEBUG_* in .env or env vars." + exit 1 +fi + +if [[ -z "${CLONE_DATABASE}" ]]; then + CLONE_DATABASE="${SOURCE_DATABASE}_test_clone" +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required but was not found in PATH." + exit 1 +fi + +if [[ "${FORCE}" != "1" ]]; then + echo "This will clone data from the '${SOURCE_TARGET}' database target into a local Docker MySQL container." + read -r -p "Type CLONE to continue: " confirmation + if [[ "${confirmation}" != "CLONE" ]]; then + echo "Cancelled." + exit 1 + fi +fi + +remove_container_if_exists() { + local name="$1" + if docker ps -aq --filter "name=^${name}$" | grep -q .; then + docker rm -f "${name}" >/dev/null + fi +} + +if [[ "${SKIP_BUILD}" != "1" ]]; then + if docker image inspect "${PHP_IMAGE}" >/dev/null 2>&1 && [[ "${FORCE_BUILD}" != "1" ]]; then + echo "Using cached PHP test image (${PHP_IMAGE}). Set FORCE_BUILD=1 to rebuild." + else + echo "Building PHP test image (${PHP_IMAGE})..." + docker build -f services/php/Dockerfile -t "${PHP_IMAGE}" . + fi +fi + +if ! docker network ls --format '{{.Name}}' | grep -qx "${NETWORK_NAME}"; then + echo "Creating docker network ${NETWORK_NAME}..." + docker network create "${NETWORK_NAME}" >/dev/null +fi + +echo "Resetting test containers..." +remove_container_if_exists "${DB_CONTAINER_NAME}" +remove_container_if_exists "${REDIS_CONTAINER_NAME}" + +echo "Starting cloned MySQL container (${DB_CONTAINER_NAME})..." +docker run -d \ + --name "${DB_CONTAINER_NAME}" \ + --network "${NETWORK_NAME}" \ + -e "MYSQL_ROOT_PASSWORD=${CLONE_ROOT_PASSWORD}" \ + -e "MYSQL_DATABASE=${CLONE_DATABASE}" \ + mysql:8.4 >/dev/null + +for i in $(seq 1 60); do + if docker exec -e "MYSQL_PWD=${CLONE_ROOT_PASSWORD}" "${DB_CONTAINER_NAME}" sh -lc "mysqladmin -u root ping --silent" >/dev/null 2>&1; then + break + fi + if [[ "$i" -eq 60 ]]; then + echo "Timed out waiting for ${DB_CONTAINER_NAME} to be ready." + exit 1 + fi + sleep 2 +done + +echo "Cloning '${SOURCE_TARGET}' database ${SOURCE_DATABASE} from ${SOURCE_HOST} into ${DB_CONTAINER_NAME}/${CLONE_DATABASE}..." +DUMP_DIR="${ROOT_DIR}/.tmp-db-clone" +DUMP_FILE="${DUMP_DIR}/dump.sql" +mkdir -p "${DUMP_DIR}" +rm -f "${DUMP_FILE}" + +docker run --rm \ + -e "MYSQL_PWD=${SOURCE_PASSWORD}" \ + -v "${DUMP_DIR}:/dump" \ + mysql:8.4 \ + sh -lc "exec mysqldump --compress --single-transaction --quick --set-gtid-purged=OFF -h '${SOURCE_HOST}' -P '${SOURCE_PORT}' -u '${SOURCE_USER}' '${SOURCE_DATABASE}' > /dump/dump.sql" + +if [[ ! -f "${DUMP_FILE}" ]]; then + echo "Database dump failed." + exit 1 +fi + +docker run --rm \ + --network "${NETWORK_NAME}" \ + -e "MYSQL_PWD=${CLONE_ROOT_PASSWORD}" \ + -v "${DUMP_DIR}:/dump" \ + mysql:8.4 \ + sh -lc "exec mysql -h '${DB_CONTAINER_NAME}' -u root '${CLONE_DATABASE}' < /dump/dump.sql" + +rm -f "${DUMP_FILE}" + +echo "Starting isolated Redis container (${REDIS_CONTAINER_NAME})..." +docker run -d --name "${REDIS_CONTAINER_NAME}" --network "${NETWORK_NAME}" redis:7 >/dev/null + +if [[ "${SKIP_TESTS}" != "1" ]]; then + echo "Running tests in a separate PHP container against cloned DB..." + docker run --rm \ + --network "${NETWORK_NAME}" \ + --env-file "${ROOT_DIR}/.env" \ + -e "CONFIG_DB_HOST=${DB_CONTAINER_NAME}" \ + -e "CONFIG_DB_DATABASE=${CLONE_DATABASE}" \ + -e "CONFIG_DB_USER=root" \ + -e "CONFIG_DB_PASSWORD=${CLONE_ROOT_PASSWORD}" \ + -e "REDIS_CONFIG_HOST=${REDIS_CONTAINER_NAME}" \ + -e "AUTO_COMPOSER_INSTALL=false" \ + -v "${ROOT_DIR}/services/nginx/app:/var/www/html" \ + -v "${ROOT_DIR}/services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro" \ + "${PHP_IMAGE}" \ + sh -lc "cd /var/www/html && if [ ! -f vendor/autoload.php ]; then composer install --no-interaction --prefer-dist; fi && ${TEST_COMMAND}" +fi + +if [[ "${KEEP_CONTAINERS}" != "1" ]]; then + echo "Cleaning up test containers..." + remove_container_if_exists "${REDIS_CONTAINER_NAME}" + remove_container_if_exists "${DB_CONTAINER_NAME}" +else + echo "Keeping containers for inspection:" + echo " DB: ${DB_CONTAINER_NAME}" + echo " Redis: ${REDIS_CONTAINER_NAME}" +fi + +echo "Done." diff --git a/scripts/clone-live-to-debug-db.ps1 b/scripts/clone-live-to-debug-db.ps1 new file mode 100644 index 00000000..65d4edd7 --- /dev/null +++ b/scripts/clone-live-to-debug-db.ps1 @@ -0,0 +1,131 @@ +param( + [string]$SourceHost = "", + [int]$SourcePort = 3306, + [string]$SourceDatabase = "", + [string]$SourceUser = "", + [string]$SourcePassword = "", + [string]$TargetService = "mysql-debug", + [string]$TargetDatabase = "", + [string]$TargetUser = "", + [string]$TargetPassword = "", + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Read-DotEnv { + param([string]$Path) + + $values = @{} + if (-not (Test-Path $Path)) { return $values } + + foreach ($line in Get-Content $Path) { + $trimmed = $line.Trim() + if ($trimmed -eq "" -or $trimmed.StartsWith("#")) { continue } + $idx = $trimmed.IndexOf("=") + if ($idx -lt 1) { continue } + $key = $trimmed.Substring(0, $idx).Trim() + $value = $trimmed.Substring($idx + 1).Trim() + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + $values[$key] = $value + } + return $values +} + +function Get-EnvOrDotEnv { + param( + [hashtable]$DotEnv, + [string]$Key + ) + $envValue = [Environment]::GetEnvironmentVariable($Key) + if (-not [string]::IsNullOrWhiteSpace($envValue)) { return $envValue } + if ($DotEnv.ContainsKey($Key) -and -not [string]::IsNullOrWhiteSpace([string]$DotEnv[$Key])) { + return [string]$DotEnv[$Key] + } + return "" +} + +$RootDir = Split-Path -Parent $PSScriptRoot +Set-Location $RootDir + +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + throw "Docker is required but was not found in PATH." +} + +$dotEnv = Read-DotEnv -Path (Join-Path $RootDir ".env") + +if ([string]::IsNullOrWhiteSpace($SourceHost)) { $SourceHost = Get-EnvOrDotEnv -DotEnv $dotEnv -Key "CONFIG_DB_HOST" } +if ([string]::IsNullOrWhiteSpace($SourceDatabase)) { $SourceDatabase = Get-EnvOrDotEnv -DotEnv $dotEnv -Key "CONFIG_DB_DATABASE" } +if ([string]::IsNullOrWhiteSpace($SourceUser)) { $SourceUser = Get-EnvOrDotEnv -DotEnv $dotEnv -Key "CONFIG_DB_USER" } +if ([string]::IsNullOrWhiteSpace($SourcePassword)) { $SourcePassword = Get-EnvOrDotEnv -DotEnv $dotEnv -Key "CONFIG_DB_PASSWORD" } + +if ([string]::IsNullOrWhiteSpace($TargetDatabase)) { $TargetDatabase = Get-EnvOrDotEnv -DotEnv $dotEnv -Key "CONFIG_DB_DEBUG_DATABASE" } +if ([string]::IsNullOrWhiteSpace($TargetUser)) { $TargetUser = Get-EnvOrDotEnv -DotEnv $dotEnv -Key "CONFIG_DB_DEBUG_USER" } +if ([string]::IsNullOrWhiteSpace($TargetPassword)) { $TargetPassword = Get-EnvOrDotEnv -DotEnv $dotEnv -Key "CONFIG_DB_DEBUG_PASSWORD" } + +if ([string]::IsNullOrWhiteSpace($SourceHost) -or [string]::IsNullOrWhiteSpace($SourceDatabase) -or [string]::IsNullOrWhiteSpace($SourceUser) -or [string]::IsNullOrWhiteSpace($SourcePassword)) { + throw "Missing live DB credentials. Expected CONFIG_DB_HOST/CONFIG_DB_DATABASE/CONFIG_DB_USER/CONFIG_DB_PASSWORD." +} +if ([string]::IsNullOrWhiteSpace($TargetDatabase) -or [string]::IsNullOrWhiteSpace($TargetUser) -or [string]::IsNullOrWhiteSpace($TargetPassword)) { + throw "Missing debug DB credentials. Expected CONFIG_DB_DEBUG_DATABASE/CONFIG_DB_DEBUG_USER/CONFIG_DB_DEBUG_PASSWORD." +} + +if (-not $Force) { + Write-Host "This will overwrite debug DB '$TargetDatabase' in service '$TargetService' with live DB '$SourceDatabase' from '$SourceHost'." -ForegroundColor Yellow + $confirmation = Read-Host "Type CLONE to continue" + if ($confirmation -ne "CLONE") { throw "Cancelled." } +} + +Write-Host "Starting debug DB service ($TargetService)..." -ForegroundColor Cyan +& docker compose up -d $TargetService + +Write-Host "Waiting for debug DB to accept connections..." -ForegroundColor Cyan +$maxAttempts = 60 +for ($i = 1; $i -le $maxAttempts; $i++) { + & docker compose exec -T $TargetService sh -lc "MYSQL_PWD='$TargetPassword' mysqladmin -u '$TargetUser' ping --silent" *> $null + if ($LASTEXITCODE -eq 0) { break } + if ($i -eq $maxAttempts) { throw "Timed out waiting for $TargetService to become ready." } + Start-Sleep -Seconds 2 +} + +$dumpDir = Join-Path $RootDir ".tmp-db-clone" +$dumpFile = Join-Path $dumpDir "live-to-debug.sql" +$resetFile = Join-Path $dumpDir "reset-debug.sql" +if (-not (Test-Path $dumpDir)) { New-Item -Path $dumpDir -ItemType Directory | Out-Null } +if (Test-Path $dumpFile) { Remove-Item -Path $dumpFile -Force } +if (Test-Path $resetFile) { Remove-Item -Path $resetFile -Force } + +Write-Host "Creating live dump..." -ForegroundColor Cyan +$dumpMount = $dumpDir.Replace('\', '/') +$dumpCmd = "exec mysqldump --compression-algorithms=zstd --single-transaction --quick --set-gtid-purged=OFF -h '$SourceHost' -P '$SourcePort' -u '$SourceUser' '$SourceDatabase' > /dump/live-to-debug.sql" +& docker run --rm -e "MYSQL_PWD=$SourcePassword" -v "${dumpMount}:/dump" mysql:8.4 sh -lc $dumpCmd +if ($LASTEXITCODE -ne 0 -or -not (Test-Path $dumpFile)) { throw "Database dump failed." } + +Write-Host "Resolving docker network for target service..." -ForegroundColor Cyan +$targetContainerId = (& docker compose ps -q $TargetService | Select-Object -First 1) +if ([string]::IsNullOrWhiteSpace($targetContainerId)) { throw "Could not resolve container id for service '$TargetService'." } +$targetNetwork = (& docker inspect $targetContainerId --format "{{range `$k,`$v := .NetworkSettings.Networks}}{{`$k}}{{end}}" | Out-String).Trim() +if ([string]::IsNullOrWhiteSpace($targetNetwork)) { throw "Could not resolve docker network for service '$TargetService'." } + +Write-Host "Resetting debug database '$TargetDatabase'..." -ForegroundColor Cyan +Set-Content -Path $resetFile -Value "DROP DATABASE IF EXISTS $TargetDatabase; CREATE DATABASE $TargetDatabase;" -NoNewline +$resetCmd = "MYSQL_PWD='$TargetPassword' mysql -h '$TargetService' -u '$TargetUser' < /dump/reset-debug.sql" +& docker run --rm ` + --network $targetNetwork ` + -v "${dumpMount}:/dump" ` + mysql:8.4 sh -lc $resetCmd +if ($LASTEXITCODE -ne 0) { throw "Failed to reset debug database." } + +Write-Host "Importing live dump into debug DB..." -ForegroundColor Cyan +& docker run --rm ` + --network $targetNetwork ` + -v "${dumpMount}:/dump" ` + mysql:8.4 sh -lc "MYSQL_PWD='$TargetPassword' mysql -h '$TargetService' -u '$TargetUser' '$TargetDatabase' < /dump/live-to-debug.sql" +if ($LASTEXITCODE -ne 0) { throw "Import into debug database failed." } + +Remove-Item -Path $dumpFile -Force -ErrorAction SilentlyContinue +Remove-Item -Path $resetFile -Force -ErrorAction SilentlyContinue +Write-Host "Done. Debug DB '$TargetDatabase' now mirrors live '$SourceDatabase'." -ForegroundColor Green diff --git a/scripts/clone-live-to-debug-db.sh b/scripts/clone-live-to-debug-db.sh new file mode 100644 index 00000000..2292b219 --- /dev/null +++ b/scripts/clone-live-to-debug-db.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +SOURCE_HOST="${SOURCE_HOST:-}" +SOURCE_PORT="${SOURCE_PORT:-3306}" +SOURCE_DATABASE="${SOURCE_DATABASE:-}" +SOURCE_USER="${SOURCE_USER:-}" +SOURCE_PASSWORD="${SOURCE_PASSWORD:-}" + +TARGET_SERVICE="${TARGET_SERVICE:-mysql-debug}" +TARGET_DATABASE="${TARGET_DATABASE:-}" +TARGET_USER="${TARGET_USER:-}" +TARGET_PASSWORD="${TARGET_PASSWORD:-}" + +FORCE="${FORCE:-0}" + +if [[ -f ".env" ]]; then + # shellcheck disable=SC2046 + export $(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' .env | sed 's/\r$//') +fi + +SOURCE_HOST="${SOURCE_HOST:-${CONFIG_DB_HOST:-}}" +SOURCE_DATABASE="${SOURCE_DATABASE:-${CONFIG_DB_DATABASE:-}}" +SOURCE_USER="${SOURCE_USER:-${CONFIG_DB_USER:-}}" +SOURCE_PASSWORD="${SOURCE_PASSWORD:-${CONFIG_DB_PASSWORD:-}}" + +TARGET_DATABASE="${TARGET_DATABASE:-${CONFIG_DB_DEBUG_DATABASE:-}}" +TARGET_USER="${TARGET_USER:-${CONFIG_DB_DEBUG_USER:-}}" +TARGET_PASSWORD="${TARGET_PASSWORD:-${CONFIG_DB_DEBUG_PASSWORD:-}}" + +if [[ -z "${SOURCE_HOST}" || -z "${SOURCE_DATABASE}" || -z "${SOURCE_USER}" || -z "${SOURCE_PASSWORD}" ]]; then + echo "Missing live DB credentials. Expected CONFIG_DB_HOST/CONFIG_DB_DATABASE/CONFIG_DB_USER/CONFIG_DB_PASSWORD." + exit 1 +fi +if [[ -z "${TARGET_DATABASE}" || -z "${TARGET_USER}" || -z "${TARGET_PASSWORD}" ]]; then + echo "Missing debug DB credentials. Expected CONFIG_DB_DEBUG_DATABASE/CONFIG_DB_DEBUG_USER/CONFIG_DB_DEBUG_PASSWORD." + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required but was not found in PATH." + exit 1 +fi + +if [[ "${FORCE}" != "1" ]]; then + echo "This will overwrite debug DB '${TARGET_DATABASE}' in service '${TARGET_SERVICE}' with live DB '${SOURCE_DATABASE}' from '${SOURCE_HOST}'." + read -r -p "Type CLONE to continue: " confirmation + if [[ "${confirmation}" != "CLONE" ]]; then + echo "Cancelled." + exit 1 + fi +fi + +echo "Starting debug DB service (${TARGET_SERVICE})..." +docker compose up -d "${TARGET_SERVICE}" + +echo "Waiting for debug DB to accept connections..." +for i in $(seq 1 60); do + if docker compose exec -T "${TARGET_SERVICE}" sh -lc "MYSQL_PWD='${TARGET_PASSWORD}' mysqladmin -u '${TARGET_USER}' ping --silent" >/dev/null 2>&1; then + break + fi + if [[ "$i" -eq 60 ]]; then + echo "Timed out waiting for ${TARGET_SERVICE} to become ready." + exit 1 + fi + sleep 2 +done + +DUMP_DIR="${ROOT_DIR}/.tmp-db-clone" +DUMP_FILE="${DUMP_DIR}/live-to-debug.sql" +mkdir -p "${DUMP_DIR}" +rm -f "${DUMP_FILE}" + +echo "Creating live dump..." +docker run --rm \ + -e "MYSQL_PWD=${SOURCE_PASSWORD}" \ + -v "${DUMP_DIR}:/dump" \ + mysql:8.4 \ + sh -lc "exec mysqldump --compression-algorithms=zstd --single-transaction --quick --set-gtid-purged=OFF -h '${SOURCE_HOST}' -P '${SOURCE_PORT}' -u '${SOURCE_USER}' '${SOURCE_DATABASE}' > /dump/live-to-debug.sql" + +if [[ ! -f "${DUMP_FILE}" ]]; then + echo "Database dump failed." + exit 1 +fi + +echo "Resetting debug database '${TARGET_DATABASE}'..." +docker compose exec -T "${TARGET_SERVICE}" sh -lc "MYSQL_PWD='${TARGET_PASSWORD}' mysql -u '${TARGET_USER}' -e \"DROP DATABASE IF EXISTS ${TARGET_DATABASE}; CREATE DATABASE ${TARGET_DATABASE};\"" + +echo "Importing live dump into debug DB..." +cat "${DUMP_FILE}" | docker compose exec -T "${TARGET_SERVICE}" sh -lc "MYSQL_PWD='${TARGET_PASSWORD}' mysql -u '${TARGET_USER}' '${TARGET_DATABASE}'" + +rm -f "${DUMP_FILE}" +echo "Done. Debug DB '${TARGET_DATABASE}' now mirrors live '${SOURCE_DATABASE}'." diff --git a/scripts/edge-gateway-e2e.mjs b/scripts/edge-gateway-e2e.mjs new file mode 100644 index 00000000..94595991 --- /dev/null +++ b/scripts/edge-gateway-e2e.mjs @@ -0,0 +1,912 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process"; +import { randomUUID } from "node:crypto"; +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"; + +import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs"; + +const execFile = promisify(execFileCallback); +const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "php2", "php3", "php4", "php5", "caddy"]; + +function composeArgs(projectName, args) { + return ["compose", "-p", projectName, ...args]; +} + +async function resolveRootDir(scriptPath) { + const cwd = process.cwd(); + + try { + await fs.access(path.join(cwd, "docker-compose.yml")); + return cwd; + } catch { + return path.resolve(path.dirname(scriptPath), ".."); + } +} + +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: "", code: 0 }; + } + + try { + const result = await execFile(command, args, { + cwd, + windowsHide: true, + encoding: "utf8", + }); + + return { stdout: result.stdout, stderr: result.stderr, code: 0 }; + } catch (error) { + if (!allowFailure) { + throw error; + } + + return { + stdout: error.stdout || "", + stderr: error.stderr || "", + code: typeof error.code === "number" ? error.code : 1, + }; + } +} + +function normalizeBaseUrl(url) { + return String(url || "").replace(/\/+$/, ""); +} + +function baseUrlWithHost(baseUrl, host, port = null) { + const url = new URL(normalizeBaseUrl(baseUrl)); + url.hostname = host; + if (port !== null) { + url.port = port; + } + return normalizeBaseUrl(url.toString()); +} + +function directCaddyBaseUrl(baseUrl) { + const url = new URL(normalizeBaseUrl(baseUrl)); + url.hostname = "caddy"; + url.port = ""; + if (url.pathname === "/api" || url.pathname === "/api/") { + url.pathname = "/"; + } + return normalizeBaseUrl(url.toString()); +} + +function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) { + const websocketUrl = new URL(String(rawUrl)); + const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl)); + + if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") { + websocketUrl.hostname = "edge-broker"; + websocketUrl.port = "4300"; + websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/"; + } + + return websocketUrl.toString(); +} + +async function readDefaultGatewayHost() { + if (process.platform === "win32") { + return null; + } + + try { + const routeTable = await fs.readFile("/proc/net/route", "utf8"); + const route = routeTable + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/)) + .find((fields) => fields[1] === "00000000" && /^[0-9A-Fa-f]{8}$/.test(fields[2] || "")); + + if (!route) { + return null; + } + + const gateway = route[2]; + const octets = [ + gateway.slice(6, 8), + gateway.slice(4, 6), + gateway.slice(2, 4), + gateway.slice(0, 2), + ].map((octet) => Number.parseInt(octet, 16)); + + if (octets.some((octet) => !Number.isInteger(octet)) || octets.every((octet) => octet === 0)) { + return null; + } + + return octets.join("."); + } catch { + return null; + } +} + +function composeNetworkName(composeProject) { + return `${composeProject}_default`; +} + +async function readCurrentContainerRef() { + if (process.platform === "win32") { + return null; + } + + const candidates = []; + const envHostname = String(process.env.HOSTNAME || "").trim(); + if (envHostname !== "") { + candidates.push(envHostname); + } + + try { + const hostname = (await fs.readFile("/etc/hostname", "utf8")).trim(); + if (hostname !== "") { + candidates.push(hostname); + } + } catch { + // Not running in a container with /etc/hostname available. + } + + try { + const cgroup = await fs.readFile("/proc/self/cgroup", "utf8"); + const matches = cgroup.match(/[0-9a-f]{64}/gi) || []; + candidates.push(...matches); + } catch { + // cgroup metadata is optional in local development. + } + + return candidates.find((candidate) => /^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,127}$/.test(candidate)) || null; +} + +async function connectCurrentContainerToComposeNetwork(rootDir, composeProject) { + const containerRef = await readCurrentContainerRef(); + if (!containerRef) { + return false; + } + + const inspection = await runCommand("docker", ["inspect", containerRef], { + cwd: rootDir, + allowFailure: true, + }); + if (inspection.code !== 0) { + return false; + } + + const networkName = composeNetworkName(composeProject); + const connection = await runCommand("docker", ["network", "connect", networkName, containerRef], { + cwd: rootDir, + allowFailure: true, + }); + const stderr = String(connection.stderr || ""); + if (connection.code === 0) { + process.stdout.write(`Attached runner container ${containerRef} to ${networkName}.\n`); + return true; + } + + if (/already exists|already connected/i.test(stderr)) { + return true; + } + + return false; +} + +async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) { + const containerRef = await readCurrentContainerRef(); + if (!containerRef) { + return; + } + + await runCommand("docker", ["network", "disconnect", composeNetworkName(composeProject), containerRef], { + cwd: rootDir, + allowFailure: true, + }); +} + +async function readComposeServiceHost(rootDir, composeProject, serviceName) { + const ps = await runCommand("docker", composeArgs(composeProject, ["ps", "-q", serviceName]), { + cwd: rootDir, + allowFailure: true, + }); + const containerId = String(ps.stdout || "").trim().split(/\s+/).find(Boolean); + const containerRefs = [ + ...(containerId ? [containerId] : []), + serviceName, + ]; + + for (const containerRef of [...new Set(containerRefs)]) { + const inspection = await runCommand("docker", [ + "inspect", + "-f", + "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + containerRef, + ], { + cwd: rootDir, + allowFailure: true, + }); + const host = String(inspection.stdout || "").trim().split(/\s+/).find((value) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value)); + + if (host) { + return host; + } + } + + return null; +} + +async function candidateApiBaseUrls(baseUrl, rootDir, composeProject, useComposeNetwork = false) { + const normalized = normalizeBaseUrl(baseUrl); + const candidates = [normalized]; + const url = new URL(normalized); + + if (["localhost", "127.0.0.1", "::1"].includes(url.hostname)) { + if (rootDir && composeProject) { + if (useComposeNetwork) { + candidates.push(directCaddyBaseUrl(normalized)); + candidates.push(baseUrlWithHost(normalized, "traefik", "")); + } + + const traefikHost = await readComposeServiceHost(rootDir, composeProject, "traefik"); + if (traefikHost) { + candidates.push(baseUrlWithHost(normalized, traefikHost, "")); + } + } + + const gatewayHost = await readDefaultGatewayHost(); + if (gatewayHost) { + candidates.push(baseUrlWithHost(normalized, gatewayHost)); + } + + candidates.push(baseUrlWithHost(normalized, "host.docker.internal")); + } + + return [...new Set(candidates)]; +} + +async function waitForCondition(predicate, { timeoutMs = 30_000, intervalMs = 500, message = "Timed out" } = {}) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (await predicate()) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(message); +} + +async function ensureComposeServices(rootDir, composeProject) { + await runCommand("docker", composeArgs(composeProject, ["up", "-d", ...COMPOSE_SERVICES]), { + cwd: rootDir, + stdio: "inherit", + }); +} + +async function waitForApiReady(baseUrl, rootDir, composeProject, useComposeNetwork = false, attempts = 60) { + const candidates = await candidateApiBaseUrls(baseUrl, rootDir, composeProject, useComposeNetwork); + let lastError = "API never responded"; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + for (const root of candidates) { + try { + const response = await fetch(`${root}/ping`, { + signal: AbortSignal.timeout(1000), + }); + if (response.ok) { + return root; + } + + lastError = `${root}/ping returned HTTP ${response.status}`; + } catch (error) { + lastError = `${root}/ping failed: ${error instanceof Error ? error.message : String(error)}`; + } + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + throw new Error(`API did not become ready at ${candidates.map((candidate) => `${candidate}/ping`).join(", ")}: ${lastError}`); +} + +function parseLastJsonLine(output) { + const lines = String(output || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + return JSON.parse(lines[index]); + } catch { + // Continue scanning backwards for the JSON payload. + } + } + + throw new Error(`Unable to parse JSON from command output:\n${output}`); +} + +async function runPhpFixture(rootDir, composeProject, action, payload = null) { + const encodedPayload = payload === null + ? "" + : ` ${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}`; + const command = `cd /var/www/html && CONFIG_DB_TARGET=debug php tests/Support/EdgeGatewayE2eFixture.php ${action}${encodedPayload}`; + + const result = await runCommand("docker", composeArgs(composeProject, [ + "exec", + "-T", + "php1", + "sh", + "-lc", + command, + ]), { + cwd: rootDir, + }); + + return parseLastJsonLine(result.stdout); +} + +async function apiRequest(baseUrl, method, endpoint, { token = null, body = null, headers = {} } = {}) { + const response = await fetch(`${normalizeBaseUrl(baseUrl)}${endpoint}`, { + method, + headers: { + ...(body === null ? {} : { "content-type": "application/json" }), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + body: body === null ? undefined : JSON.stringify(body), + }); + + const rawBody = await response.text(); + let json = null; + + if (rawBody !== "") { + try { + json = JSON.parse(rawBody); + } catch { + json = null; + } + } + + if (!response.ok) { + const message = + json?.data?.message || + json?.error || + rawBody || + `HTTP ${response.status}`; + throw new Error(`${method} ${endpoint} failed: ${message}`); + } + + return json; +} + +async function loadWebSocketImplementation() { + if (typeof WebSocket !== "undefined") { + return WebSocket; + } + + const module = await import("ws"); + return module.default; +} + +function onSocket(socket, eventName, handler) { + if (typeof socket.addEventListener === "function") { + socket.addEventListener(eventName, (event) => { + if (eventName === "message") { + handler(event.data); + return; + } + + handler(event); + }); + return; + } + + socket.on(eventName, handler); +} + +function collectSocketMessages(socket) { + const messages = []; + + onSocket(socket, "message", (payload) => { + const text = typeof payload === "string" + ? payload + : Buffer.isBuffer(payload) + ? payload.toString("utf8") + : typeof payload?.toString === "function" + ? payload.toString() + : ""; + + if (text === "") { + return; + } + + try { + messages.push(JSON.parse(text)); + } catch { + // Ignore non-JSON frames. + } + }); + + return messages; +} + +async function waitForSocketOpen(socket) { + if (socket.readyState === 1) { + return; + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for websocket open.")), 10_000); + const socketUrl = typeof socket.url === "string" && socket.url !== "" ? ` (${socket.url})` : ""; + + const onOpen = () => { + clearTimeout(timeout); + resolve(); + }; + + const onError = (error) => { + clearTimeout(timeout); + if (error instanceof Error) { + reject(error); + return; + } + + const readyState = typeof socket.readyState === "number" ? socket.readyState : "unknown"; + reject(new Error(`Websocket failed to open${socketUrl}; readyState=${readyState}.`)); + }; + + const onClose = (event) => { + clearTimeout(timeout); + const code = event && typeof event === "object" && "code" in event ? event.code : "unknown"; + const reason = event && typeof event === "object" && "reason" in event ? event.reason : ""; + reject(new Error(`Websocket closed before open${socketUrl}; code=${code} reason=${reason || "none"}.`)); + }; + + onSocket(socket, "open", onOpen); + onSocket(socket, "error", onError); + onSocket(socket, "close", onClose); + }); +} + +async function waitForSocketMessage(messages, predicate, options) { + await waitForCondition(() => messages.some(predicate), options); +} + +function buildSocketUrl(wsUrl, token) { + const url = new URL(String(wsUrl)); + url.searchParams.set("token", token); + return url.toString(); +} + +function closeSocket(socket) { + if (!socket || typeof socket.close !== "function") { + return; + } + + const readyState = typeof socket.readyState === "number" ? socket.readyState : null; + if (readyState !== null && readyState >= 2) { + return; + } + + socket.close(); +} + +function shouldCopyGatewayConfig() { + return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim()); +} + +function shouldSkipComposeUp() { + return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP || "").trim()); +} + +function collectMessages(rows) { + return Array.isArray(rows) + ? rows + .map((row) => (row && typeof row === "object" ? row.message : null)) + .filter((value) => typeof value === "string") + : []; +} + +function summarizeStreamMessages(messages, limit = 12) { + return messages + .slice(-limit) + .map((message) => { + if (!message || typeof message !== "object") { + return null; + } + + const summary = { + type: message.type || "unknown", + }; + + if (message.operationId !== undefined) { + summary.operationId = Number(message.operationId || 0); + } + + if (message.operation && typeof message.operation === "object") { + summary.operationStatus = message.operation.status || null; + } + + if (message.gateway && typeof message.gateway === "object") { + summary.gatewayStatus = message.gateway.status || null; + } + + return summary; + }) + .filter(Boolean); +} + +async function main() { + const scriptPath = fileURLToPath(import.meta.url); + const rootDir = await resolveRootDir(scriptPath); + const runId = randomUUID().slice(0, 8); + const containerName = `truckwash-edge-e2e-${runId}`; + const configDir = path.join(rootDir, ".tmp", "edge-gateway-e2e", runId); + const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME); + let baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL; + const composeProject = + process.env.EDGE_GATEWAY_E2E_COMPOSE_PROJECT + || path.basename(rootDir); + + let fixture = null; + let gatewayId = null; + let streamSocket = null; + let shellSocket = null; + let runnerNetworkAttached = false; + + try { + if (!shouldSkipComposeUp()) { + await ensureComposeServices(rootDir, composeProject); + } + runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject); + baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached); + process.stdout.write(`Using API base URL ${baseUrl}\n`); + + fixture = await runPhpFixture(rootDir, composeProject, "create"); + const authToken = String(fixture.auth_token || ""); + const departmentId = Number(fixture.department_id || 0); + + assert.ok(authToken !== "", "Fixture helper did not return an auth token."); + assert.ok(departmentId > 0, "Fixture helper did not return a department id."); + + const installTokenResponse = await apiRequest(baseUrl, "POST", "/edge-gateways/install-token", { + token: authToken, + body: { + department_id: departmentId, + label: `Edge Gateway E2E ${runId}`, + }, + }); + const installToken = String(installTokenResponse?.data?.token || ""); + assert.ok(installToken !== "", "Install token creation did not return a token."); + + await runCommand(process.execPath, [ + "scripts/test-gateway.mjs", + "start", + "--install-token", + installToken, + "--host-api-url", + baseUrl, + "--container-name", + containerName, + "--config-dir", + configDir, + "--heartbeat-seconds", + "3", + "--skip-compose-up", + ...(shouldCopyGatewayConfig() ? ["--copy-config"] : []), + ], { + cwd: rootDir, + stdio: "inherit", + }); + + await waitForCondition( + async () => { + try { + await fs.access(configFilePath); + return true; + } catch { + return false; + } + }, + { message: `Gateway config file was not created at ${configFilePath}` } + ); + + const config = JSON.parse(await fs.readFile(configFilePath, "utf8")); + gatewayId = Number(config.gatewayId || 0); + assert.ok(gatewayId > 0, "Gateway config did not include a gateway id."); + + await waitForCondition( + async () => { + const result = await runCommand("docker", [ + "exec", + containerName, + "test", + "-f", + "/opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt", + ], { + allowFailure: true, + }); + + return result.code === 0; + }, + { + timeoutMs: 60_000, + message: "Gateway never wrote the successful heartbeat marker.", + } + ); + + await waitForCondition( + async () => { + const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, { + token: authToken, + }); + + return detail?.data?.status === "ONLINE" + && Object.keys(detail?.data?.metadata?.system_metrics || {}).length > 0; + }, + { + timeoutMs: 60_000, + message: "Gateway detail never transitioned to ONLINE with fresh system metrics after install.", + } + ); + + await waitForCondition( + async () => { + const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, { + token: authToken, + }); + + return Boolean( + detail?.data?.channel_status?.broker?.connected + || detail?.data?.metadata?.broker_connected + ); + }, + { + timeoutMs: 90_000, + message: "Gateway never established a live broker connection after install.", + } + ); + + const WebSocketImpl = await loadWebSocketImplementation(); + const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, { + token: authToken, + body: { + scopes: ["overview", "tasks", "logs", "statistics"], + }, + }); + const streamWsUrl = buildSocketUrl( + resolveBrokerWebSocketUrl(String(streamSession?.data?.ws_url || ""), baseUrl), + String(streamSession?.data?.token || "") + ); + streamSocket = new WebSocketImpl(streamWsUrl); + const streamMessages = collectSocketMessages(streamSocket); + + await waitForSocketOpen(streamSocket); + await waitForSocketMessage( + streamMessages, + (message) => message?.type === "gateway.stream.ready", + { timeoutMs: 15_000, message: "Gateway stream never became ready." } + ); + + const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready"); + assert.equal( + Boolean(readyMessage?.connected), + true, + "Gateway stream became ready before the broker reported the gateway as connected." + ); + + const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, { + token: authToken, + body: { + type: "DISCOVERY", + request: { + inventory: [{ + device_id: `edge-e2e-${runId}`, + local_ip: "10.70.80.90", + model: "TruckWash Edge E2E", + channel_count: 1, + online: true, + capabilities: { + gateway_management_v2: true, + }, + metadata: { + hostname: `edge-e2e-${runId}`, + }, + }], + }, + }, + }); + const operationId = Number(operationResponse?.data?.operation?.id || 0); + assert.ok(operationId > 0, "Operation creation did not return an operation id."); + + try { + await waitForSocketMessage( + streamMessages, + (message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId, + { timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." } + ); + } catch (error) { + let operationSnapshot = null; + try { + const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, { + token: authToken, + }); + operationSnapshot = Array.isArray(operations?.data) + ? operations.data.find((item) => Number(item?.id || 0) === operationId) || null + : null; + } catch { + operationSnapshot = null; + } + + const diagnostic = [ + error instanceof Error ? error.message : String(error), + `Recent stream messages: ${JSON.stringify(summarizeStreamMessages(streamMessages))}`, + `Operation snapshot: ${JSON.stringify(operationSnapshot)}`, + ].join("\n"); + throw new Error(diagnostic); + } + + await waitForCondition( + async () => { + const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, { + token: authToken, + }); + + const operation = Array.isArray(operations?.data) + ? operations.data.find((item) => Number(item?.id || 0) === operationId) + : null; + + return operation?.status === "COMPLETED"; + }, + { timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." } + ); + + await waitForSocketMessage( + streamMessages, + (message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated", + { timeoutMs: 15_000, message: "Live gateway stream never emitted telemetry or statistics updates." } + ); + + await waitForCondition( + async () => { + const logs = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, { + token: authToken, + }); + + const timeline = Array.isArray(logs?.data?.entries) + ? logs.data.entries + : (Array.isArray(logs?.data?.timeline) ? logs.data.timeline : []); + + return timeline.some((entry) => { + const nestedEntry = entry?.entry && typeof entry.entry === "object" ? entry.entry : null; + const directOperationId = Number(nestedEntry?.operation_id || 0); + const contextualOperationId = Number(nestedEntry?.context?.operation_id || 0); + + return directOperationId === operationId || contextualOperationId === operationId; + }); + }, + { timeoutMs: 30_000, message: "Gateway logs page never reflected the live operation timeline." } + ); + + const statistics = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/statistics`, { + token: authToken, + }); + assert.ok( + Object.keys(statistics?.data?.system_metrics || {}).length > 0, + "Gateway statistics page did not expose system metrics after live telemetry." + ); + + const shellSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/shell-sessions`, { + token: authToken, + body: { + reason: "Edge gateway E2E shell validation", + cwd: "/opt/truckwash-edge-agent", + cols: 120, + rows: 40, + }, + }); + const shellWsUrl = buildSocketUrl( + resolveBrokerWebSocketUrl(String(shellSession?.data?.ws_url || ""), baseUrl), + String(shellSession?.data?.token || "") + ); + shellSocket = new WebSocketImpl(shellWsUrl); + const shellMessages = collectSocketMessages(shellSocket); + + await waitForSocketOpen(shellSocket); + await waitForSocketMessage( + shellMessages, + (message) => message?.type === "opened", + { timeoutMs: 20_000, message: "Browser shell never opened against the live gateway." } + ); + + shellSocket.send(JSON.stringify({ + type: "input", + data: "printf 'edge-e2e-shell\\n'; exit\n", + })); + + await waitForSocketMessage( + shellMessages, + (message) => message?.type === "output" && String(message?.data || "").includes("edge-e2e-shell"), + { timeoutMs: 20_000, message: "Browser shell never returned the expected command output." } + ); + + await waitForSocketMessage( + shellMessages, + (message) => message?.type === "closed", + { timeoutMs: 20_000, message: "Browser shell never closed cleanly." } + ); + + const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, { + token: authToken, + }); + const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions) + ? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || "")) + : []; + + assert.ok( + shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")), + "Gateway logs page did not persist the shell transcript." + ); + + const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []); + assert.ok( + timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"), + "Gateway logs page did not include the shell close audit event." + ); + + process.stdout.write("Edge gateway E2E smoke completed successfully.\n"); + } finally { + closeSocket(shellSocket); + closeSocket(streamSocket); + + await runCommand(process.execPath, [ + "scripts/test-gateway.mjs", + "stop", + "--container-name", + containerName, + ], { + cwd: rootDir, + allowFailure: true, + }).catch(() => {}); + + if (gatewayId !== null && fixture?.auth_token) { + await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, { + token: String(fixture.auth_token), + }).catch(() => {}); + } + + if (fixture !== null) { + await runPhpFixture(rootDir, composeProject, "cleanup", fixture).catch(() => {}); + } + + await fs.rm(configDir, { recursive: true, force: true }).catch(() => {}); + + if (runnerNetworkAttached) { + await disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject).catch(() => {}); + } + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/generate_writerside_openapi_docs.py b/scripts/generate_writerside_openapi_docs.py new file mode 100644 index 00000000..01795bbf --- /dev/null +++ b/scripts/generate_writerside_openapi_docs.py @@ -0,0 +1,839 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import html +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Tuple + +import yaml + + +HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head", "trace"] +AUTOGEN_NOTE = "AUTO-GENERATED, DO NOT EDIT" +TOC_START_MARKER = "" +TOC_END_MARKER = "" +OPS_PER_PAGE = 20 +MODULE_DESCRIPTIONS: Dict[str, Tuple[str, str]] = { + "action-logs": ("Action Logs", "Audit and operational logs for integration module activity."), + "backup": ("Backup", "Backup integrations and backup module orchestration."), + "cvr": ("CVR", "Danish company registry (CVR) lookup and search integration."), + "economic": ("e-conomic", "Accounting and invoicing integration with e-conomic."), + "entra": ("Entra", "Microsoft Entra directory integration endpoints."), + "fxratesapi": ("FXRatesAPI", "Currency exchange-rate lookup integration."), + "motorapi": ("MotorAPI", "Vehicle lookup integration via MotorAPI."), + "self-serve": ("Self-Serve", "Self-serve lane control and machine command endpoints."), + "stripe": ("Stripe", "Stripe payments, invoices, terminals, products, and customers."), + "virkdata": ("VirkData", "VirkData company information integration."), + "washcertificates": ("Wash Certificates", "Wash certificate retrieval and listing integration."), + "weatherapi": ("WeatherAPI", "Weather provider integration for current, forecast, and search."), + "xlvask": ("XLVask", "XLVask synchronization, usage logs, vehicles, and customers."), +} +CONFIG_DESCRIPTIONS: Dict[str, Tuple[str, str]] = { + "backups": ("Backups", "Backup configuration for backup module behavior."), + "bird": ("Bird", "Bird communication integration configuration."), + "economic": ("e-conomic", "e-conomic accounting integration configuration."), + "email": ("Email", "Email provider and SMTP/MailerSend configuration."), + "entra": ("Entra", "Microsoft Entra identity integration configuration."), + "fxratesapi": ("FXRatesAPI", "FXRatesAPI exchange-rate integration configuration."), + "gatewayapi": ("GatewayAPI", "GatewayAPI integration configuration."), + "licenseplaterecognizer": ("LicensePlateRecognizer", "License plate recognizer integration configuration."), + "limble": ("Limble", "Limble integration configuration."), + "motorapi": ("MotorAPI", "MotorAPI vehicle lookup integration configuration."), + "ocrspace": ("OcrSpace", "OCR Space integration configuration."), + "openai": ("OpenAI", "OpenAI integration configuration."), + "reCAPTCHA": ("reCAPTCHA", "reCAPTCHA protection configuration."), + "selfserve": ("Self-Serve", "Self-serve module runtime configuration."), + "shelly": ("Shelly", "Shelly integration configuration."), + "stripe": ("Stripe", "Stripe integration configuration."), + "virkdata": ("VirkData", "VirkData integration configuration."), + "weatherapi": ("WeatherAPI", "WeatherAPI integration configuration."), + "xlvask": ("XLVask", "XLVask integration configuration."), +} + + +@dataclass(frozen=True) +class Operation: + method: str + path: str + title: str + topic_id: str + topic_file: str + primary_tag: str + operation_id: str + description: str + parameters: List[dict] + request_body: dict + responses: dict + security: List[dict] + module_key: str + module_name: str + module_description: str + config_key: str + config_name: str + config_description: str + + +def slugify(value: str) -> str: + slug = re.sub(r"[^A-Za-z0-9_]+", "_", value.strip()) + slug = re.sub(r"_+", "_", slug).strip("_") + if not slug: + slug = "unnamed" + if not re.match(r"^[A-Za-z_]", slug): + slug = f"id_{slug}" + return slug + + +def safe_token(value: str) -> str: + return slugify(value).lower() + + +def build_operation_topic_id(operation_id: str, method: str, path: str) -> str: + base = slugify(operation_id) + if len(base) > 110: + digest = hashlib.sha1(f"{method}:{path}:{operation_id}".encode("utf-8")).hexdigest()[:8] + base = f"{base[:100]}_{digest}" + return base + + +def schema_type_name(schema: dict) -> str: + if not isinstance(schema, dict): + return "unknown" + if "$ref" in schema: + ref = str(schema["$ref"]) + return ref.rsplit("/", 1)[-1] + if "type" in schema: + t = str(schema["type"]) + if t == "array" and isinstance(schema.get("items"), dict): + return f"array<{schema_type_name(schema['items'])}>" + return t + if "oneOf" in schema: + return "oneOf" + if "anyOf" in schema: + return "anyOf" + if "allOf" in schema: + return "allOf" + return "object" + + +def xml_escape(value: str) -> str: + return html.escape(value or "", quote=True) + + +def pretty_json(value: dict) -> str: + return html.escape(json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True)) + + +def render_parameters_table(parameters: List[dict]) -> str: + if not parameters: + return "" + rows = [ + " ", + " ", + " ", + ] + for param in parameters: + name = xml_escape(str(param.get("name", ""))) + loc = xml_escape(str(param.get("in", ""))) + required = "yes" if param.get("required") else "no" + ptype = xml_escape(schema_type_name(param.get("schema", {}))) + desc = xml_escape(str(param.get("description", ""))) + rows.append( + f" " + ) + rows.append("
NameInRequiredTypeDescription
{name}{loc}{required}{ptype}{desc}
") + rows.append("
") + return "\n".join(rows) + "\n" + + +def render_request_body(request_body: dict) -> str: + if not isinstance(request_body, dict) or not request_body: + return "" + lines = [" "] + lines.append(f"

Required: {'yes' if request_body.get('required') else 'no'}.

") + content = request_body.get("content", {}) + if isinstance(content, dict) and content: + for content_type, media in content.items(): + lines.append(f"

Content type: {xml_escape(str(content_type))}

") + schema = media.get("schema") if isinstance(media, dict) else None + if isinstance(schema, dict): + lines.append(" ") + lines.append(pretty_json(schema)) + lines.append(" ") + lines.append("
") + return "\n".join(lines) + "\n" + + +def render_responses(responses: dict) -> str: + if not isinstance(responses, dict) or not responses: + return "" + lines = [ + " ", + " ", + " ", + ] + for status_code, response in responses.items(): + if not isinstance(response, dict): + continue + description = xml_escape(str(response.get("description", ""))) + content = response.get("content", {}) + if isinstance(content, dict): + content_types = ", ".join(sorted(str(k) for k in content.keys())) + else: + content_types = "" + lines.append( + f" " + ) + lines.append("
StatusDescriptionContent Types
{xml_escape(str(status_code))}{description}{xml_escape(content_types)}
") + + for status_code, response in responses.items(): + if not isinstance(response, dict): + continue + content = response.get("content", {}) + if not isinstance(content, dict): + continue + for content_type, media in content.items(): + schema = media.get("schema") if isinstance(media, dict) else None + if not isinstance(schema, dict): + continue + lines.append( + f"

Schema for response {xml_escape(str(status_code))} ({xml_escape(str(content_type))}):

" + ) + lines.append(" ") + lines.append(pretty_json(schema)) + lines.append(" ") + lines.append("
") + return "\n".join(lines) + "\n" + + +def render_security(security: List[dict]) -> str: + if not security: + return "

No authentication required.

\n" + lines = [ + " ", + "

Security requirements:

", + " ", + " ", + ] + for req in security: + if not isinstance(req, dict): + continue + for scheme, scopes in req.items(): + if isinstance(scopes, list): + scope_text = ", ".join(str(s) for s in scopes) if scopes else "-" + else: + scope_text = "-" + lines.append( + f" " + ) + lines.extend(["
SchemeScopes
{xml_escape(str(scheme))}{xml_escape(scope_text)}
", "
"]) + return "\n".join(lines) + "\n" + + +def render_operation_topic(operation: Operation) -> str: + title = html.escape(operation.title, quote=True) + endpoint = xml_escape(operation.path) + method = operation.method.upper() + topic_id = html.escape(operation.topic_id, quote=True) + description = xml_escape(operation.description) + operation_id = xml_escape(operation.operation_id) + + parameter_block = render_parameters_table(operation.parameters) + request_block = render_request_body(operation.request_body) + response_block = render_responses(operation.responses) + security_block = render_security(operation.security) + + return ( + '\n' + '\n' + '\n' + f"\n \n" + "

This endpoint documentation is generated directly from openapi.yaml.

\n" + " \n" + f" {method} {endpoint}\n" + " \n" + " \n" + f"

Operation ID: {operation_id}

\n" + f"

{description}

\n" + "
\n" + f"{security_block}" + f"{parameter_block}" + f"{request_block}" + f"{response_block}" + "
\n" + ) + + +def render_tag_topic(tag: str, topic_id: str) -> str: + title = html.escape(tag, quote=True) + safe_id = html.escape(topic_id, quote=True) + return ( + '\n' + '\n' + '\n' + f"\n \n" + "

Endpoints in this section are generated from openapi.yaml.

\n" + "
\n" + ) + + +def render_tag_page_topic(tag: str, topic_id: str, page_number: int, total_pages: int) -> str: + title = html.escape(f"{tag} - Page {page_number} of {total_pages}", quote=True) + safe_id = html.escape(topic_id, quote=True) + return ( + '\n' + '\n' + '\n' + f"\n \n" + "

This page groups endpoint topics for this object type.

\n" + "
\n" + ) + + +def render_module_topic(module_name: str, module_description: str, topic_id: str) -> str: + title = html.escape(module_name, quote=True) + safe_id = html.escape(topic_id, quote=True) + description = xml_escape(module_description) + return ( + '\n' + '\n' + '\n' + f"\n \n" + f"

{description}

\n" + "
\n" + ) + + +def render_modules_index_topic(topic_id: str, modules: List[Tuple[str, str]]) -> str: + safe_id = html.escape(topic_id, quote=True) + rows = [ + '', + "', + '', + "", + f" ", + "

Module integrations sorted by module name.

", + " ", + " ", + " ", + ] + for module_name, module_description in modules: + rows.append( + f" " + ) + rows.extend(["
ModuleDescription
{xml_escape(module_name)}{xml_escape(module_description)}
", "
", "
", ""]) + return "\n".join(rows) + + +def render_config_index_topic(topic_id: str, modules: List[Tuple[str, str]]) -> str: + safe_id = html.escape(topic_id, quote=True) + rows = [ + '', + "', + '', + "", + f" ", + "

Configuration endpoints grouped by module name.

", + " ", + " ", + " ", + ] + for module_name, module_description in modules: + rows.append( + f" " + ) + rows.extend(["
ModuleDescription
{xml_escape(module_name)}{xml_escape(module_description)}
", "
", "
", ""]) + return "\n".join(rows) + + +def infer_module(path: str) -> Tuple[str, str, str]: + segments = [segment for segment in path.split("/") if segment] + module_key = "misc" + if segments: + if segments[0] == "modules" and len(segments) > 1: + module_key = segments[1] + elif segments[0] in {"economic", "cvr"}: + module_key = segments[0] + + if module_key in MODULE_DESCRIPTIONS: + module_name, module_description = MODULE_DESCRIPTIONS[module_key] + else: + module_name = module_key.replace("-", " ").title() + module_description = "Integration module endpoints." + return module_key, module_name, module_description + + +def infer_config_module(path: str) -> Tuple[str, str, str]: + segments = [segment for segment in path.split("/") if segment] + config_key = segments[0] if segments else "misc" + if config_key in CONFIG_DESCRIPTIONS: + config_name, config_description = CONFIG_DESCRIPTIONS[config_key] + else: + config_name = config_key.replace("-", " ").title() + config_description = "Configuration endpoints for this module." + return config_key, config_name, config_description + + +def render_api_reference_topic() -> str: + return ( + '\n' + '\n' + '\n' + f"\n \n" + "

Comprehensive API reference generated from the repository root openapi.yaml.

\n" + "
\n" + ) + + +def write_if_changed(path: Path, content: str) -> bool: + if path.exists() and path.read_text(encoding="utf-8") == content: + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return True + + +def parse_openapi(openapi_path: Path) -> Tuple[List[str], List[Operation], Dict[str, List[Operation]]]: + doc = yaml.safe_load(openapi_path.read_text(encoding="utf-8")) + global_security = doc.get("security", []) + tags_section = doc.get("tags", []) + declared_tags = [] + for tag_item in tags_section: + if isinstance(tag_item, dict) and isinstance(tag_item.get("name"), str): + declared_tags.append(tag_item["name"]) + + declared_tag_set = set(declared_tags) + paths = doc.get("paths", {}) + if not isinstance(paths, dict): + raise ValueError("Invalid OpenAPI: top-level 'paths' must be an object.") + + operations: List[Operation] = [] + grouped: Dict[str, List[Operation]] = {} + seen_topic_ids: Dict[str, str] = {} + unknown_tags: List[str] = [] + + for api_path, path_item in paths.items(): + if not isinstance(path_item, dict): + continue + path_parameters = path_item.get("parameters", []) + for method in HTTP_METHODS: + operation = path_item.get(method) + if not isinstance(operation, dict): + continue + + operation_tags = operation.get("tags") + if isinstance(operation_tags, list) and operation_tags: + tags = [str(tag) for tag in operation_tags] + else: + tags = ["Misc"] + + for tag in tags: + if tag != "Misc" and tag not in declared_tag_set: + unknown_tags.append(f"{method.upper()} {api_path} => '{tag}'") + + primary_tag = tags[0] + operation_id = operation.get("operationId") + if not isinstance(operation_id, str) or not operation_id.strip(): + operation_id = f"{method}_{api_path}" + + summary = operation.get("summary") + if not isinstance(summary, str) or not summary.strip(): + summary = f"{method.upper()} {api_path}" + description = operation.get("description") + if not isinstance(description, str) or not description.strip(): + description = summary + + topic_id = build_operation_topic_id(operation_id, method, api_path) + existing = seen_topic_ids.get(topic_id) + operation_ref = f"{method.upper()} {api_path}" + if existing and existing != operation_ref: + raise ValueError( + f"Duplicate topic id '{topic_id}' generated for '{existing}' and '{operation_ref}'." + ) + seen_topic_ids[topic_id] = operation_ref + + topic_file = f"{topic_id}.topic" + operation_parameters = operation.get("parameters", []) + merged_parameters: List[dict] = [] + seen_params = set() + for param_source in [path_parameters, operation_parameters]: + if not isinstance(param_source, list): + continue + for param in param_source: + if not isinstance(param, dict): + continue + key = (str(param.get("in", "")), str(param.get("name", ""))) + if key in seen_params: + merged_parameters = [ + p + for p in merged_parameters + if (str(p.get("in", "")), str(p.get("name", ""))) != key + ] + seen_params.add(key) + merged_parameters.append(param) + + module_key, module_name, module_description = infer_module(api_path) + config_key, config_name, config_description = infer_config_module(api_path) + op = Operation( + method=method, + path=api_path, + title=summary, + topic_id=topic_id, + topic_file=topic_file, + primary_tag=primary_tag, + operation_id=operation_id, + description=description, + parameters=merged_parameters, + request_body=operation.get("requestBody", {}), + responses=operation.get("responses", {}), + security=operation.get("security", global_security), + module_key=module_key, + module_name=module_name, + module_description=module_description, + config_key=config_key, + config_name=config_name, + config_description=config_description, + ) + operations.append(op) + grouped.setdefault(primary_tag, []).append(op) + + if unknown_tags: + raise ValueError( + "Invalid tag mapping: operation tags not declared in top-level OpenAPI tags:\n" + + "\n".join(sorted(unknown_tags)) + ) + + for tag_ops in grouped.values(): + tag_ops.sort(key=lambda op: (op.path, op.method)) + + ordered_tags = [tag for tag in declared_tags if tag in grouped] + if "Misc" in grouped and "Misc" not in ordered_tags: + ordered_tags.append("Misc") + for extra in sorted(tag for tag in grouped if tag not in ordered_tags): + ordered_tags.append(extra) + + return ordered_tags, operations, grouped + + +def render_generated_toc(ordered_tags: List[str], grouped: Dict[str, List[Operation]]) -> str: + lines: List[str] = [] + lines.append(' ') + for tag in ordered_tags: + tag_topic_file = f"Tag_{slugify(tag)}.topic" + lines.append(f' ') + tag_ops = grouped.get(tag, []) + if tag == "Modules": + module_groups: Dict[str, List[Operation]] = {} + module_meta: Dict[str, Tuple[str, str]] = {} + for op in tag_ops: + module_groups.setdefault(op.module_key, []).append(op) + module_meta[op.module_key] = (op.module_name, op.module_description) + + for module_key in sorted(module_groups, key=lambda key: module_meta[key][0].lower()): + module_name, _ = module_meta[module_key] + module_slug = safe_token(module_name) + module_topic = f"modules_module_{module_slug}.topic" + lines.append(f' ') + module_ops = sorted(module_groups[module_key], key=lambda op: (op.module_name.lower(), op.title.lower(), op.path, op.method)) + total_pages = max(1, (len(module_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) + for page_index in range(total_pages): + start = page_index * OPS_PER_PAGE + end = start + OPS_PER_PAGE + page_ops = module_ops[start:end] + page_number = page_index + 1 + page_topic = f"modules_module_{module_slug}_page_{page_number}.topic" + lines.append(f' ') + for op in page_ops: + lines.append(f' ') + lines.append(" ") + lines.append(" ") + lines.append(" ") + continue + + if tag == "Config": + config_groups: Dict[str, List[Operation]] = {} + config_meta: Dict[str, Tuple[str, str]] = {} + for op in tag_ops: + config_groups.setdefault(op.config_key, []).append(op) + config_meta[op.config_key] = (op.config_name, op.config_description) + + for config_key in sorted(config_groups, key=lambda key: config_meta[key][0].lower()): + config_name, _ = config_meta[config_key] + config_slug = safe_token(config_name) + config_topic = f"config_module_{config_slug}.topic" + lines.append(f' ') + config_ops = sorted(config_groups[config_key], key=lambda op: (op.title.lower(), op.path, op.method)) + total_pages = max(1, (len(config_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) + for page_index in range(total_pages): + start = page_index * OPS_PER_PAGE + end = start + OPS_PER_PAGE + page_ops = config_ops[start:end] + page_number = page_index + 1 + page_topic = f"config_module_{config_slug}_page_{page_number}.topic" + lines.append(f' ') + for op in page_ops: + lines.append(f' ') + lines.append(" ") + lines.append(" ") + lines.append(" ") + continue + + total_pages = max(1, (len(tag_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) + for page_index in range(total_pages): + start = page_index * OPS_PER_PAGE + end = start + OPS_PER_PAGE + page_ops = tag_ops[start:end] + page_number = page_index + 1 + page_topic = f"Tag_{slugify(tag)}_Page_{page_number}.topic" + lines.append(f' ') + for op in page_ops: + lines.append(f' ') + lines.append(" ") + lines.append(" ") + lines.append(" ") + return "\n".join(lines) + "\n" + + +def apply_toc_block(ctw_tree_content: str, generated_toc: str) -> str: + block = f" {TOC_START_MARKER}\n{generated_toc.rstrip()}\n {TOC_END_MARKER}\n" + marker_pattern = re.compile( + rf"(?ms)^[ \t]*{re.escape(TOC_START_MARKER)}\r?\n.*?^[ \t]*{re.escape(TOC_END_MARKER)}\r?\n?" + ) + if marker_pattern.search(ctw_tree_content): + return marker_pattern.sub(block, ctw_tree_content, count=1) + + close_tag = "" + close_index = ctw_tree_content.rfind(close_tag) + if close_index == -1: + raise ValueError("Unable to update ctw.tree: missing .") + left = ctw_tree_content[:close_index].rstrip() + "\n\n" + right = ctw_tree_content[close_index:] + return left + block + right + + +def build_expected_outputs(root: Path) -> Dict[Path, str]: + documentation_dir = root / "documentation" + openapi_path = root / "openapi.yaml" + ctw_tree_path = documentation_dir / "ctw.tree" + api_reference_topic_path = documentation_dir / "topics" / "API-Reference.topic" + generated_dir = documentation_dir / "topics" / "generated" + generated_toc_path = documentation_dir / "generated" / "api-reference.toc.xml" + + openapi_doc = yaml.safe_load(openapi_path.read_text(encoding="utf-8")) + ordered_tags, operations, grouped = parse_openapi(openapi_path) + generated_toc = render_generated_toc(ordered_tags, grouped) + + expected: Dict[Path, str] = {} + expected[generated_toc_path] = generated_toc + expected[documentation_dir / "generated" / "openapi.json"] = json.dumps( + openapi_doc, indent=2, ensure_ascii=False, sort_keys=False + ) + "\n" + expected[api_reference_topic_path] = render_api_reference_topic() + + existing_tree = ctw_tree_path.read_text(encoding="utf-8") + expected[ctw_tree_path] = apply_toc_block(existing_tree, generated_toc) + + for tag in ordered_tags: + tag_slug = slugify(tag) + tag_topic_file = generated_dir / f"Tag_{tag_slug}.topic" + tag_ops = grouped[tag] + if tag == "Modules": + module_groups: Dict[str, List[Operation]] = {} + module_meta: Dict[str, Tuple[str, str]] = {} + for op in tag_ops: + module_groups.setdefault(op.module_key, []).append(op) + module_meta[op.module_key] = (op.module_name, op.module_description) + + module_list = [ + module_meta[module_key] + for module_key in sorted(module_groups, key=lambda key: module_meta[key][0].lower()) + ] + expected[tag_topic_file] = render_modules_index_topic(f"Tag_{tag_slug}", module_list) + + for module_key in sorted(module_groups, key=lambda key: module_meta[key][0].lower()): + module_name, module_description = module_meta[module_key] + module_slug = safe_token(module_name) + module_topic_file = generated_dir / f"modules_module_{module_slug}.topic" + expected[module_topic_file] = render_module_topic( + module_name=module_name, + module_description=module_description, + topic_id=f"modules_module_{module_slug}", + ) + + module_ops = sorted(module_groups[module_key], key=lambda op: (op.module_name.lower(), op.title.lower(), op.path, op.method)) + total_pages = max(1, (len(module_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) + for page_index in range(total_pages): + page_number = page_index + 1 + page_topic_file = generated_dir / f"modules_module_{module_slug}_page_{page_number}.topic" + expected[page_topic_file] = render_tag_page_topic( + tag=module_name, + topic_id=f"modules_module_{module_slug}_page_{page_number}", + page_number=page_number, + total_pages=total_pages, + ) + + for op in module_ops: + expected[generated_dir / op.topic_file] = render_operation_topic(op) + continue + + if tag == "Config": + config_groups: Dict[str, List[Operation]] = {} + config_meta: Dict[str, Tuple[str, str]] = {} + for op in tag_ops: + config_groups.setdefault(op.config_key, []).append(op) + config_meta[op.config_key] = (op.config_name, op.config_description) + + config_list = [ + config_meta[config_key] + for config_key in sorted(config_groups, key=lambda key: config_meta[key][0].lower()) + ] + expected[tag_topic_file] = render_config_index_topic(f"Tag_{tag_slug}", config_list) + + for config_key in sorted(config_groups, key=lambda key: config_meta[key][0].lower()): + config_name, config_description = config_meta[config_key] + config_slug = safe_token(config_name) + config_topic_file = generated_dir / f"config_module_{config_slug}.topic" + expected[config_topic_file] = render_module_topic( + module_name=config_name, + module_description=config_description, + topic_id=f"config_module_{config_slug}", + ) + + config_ops = sorted(config_groups[config_key], key=lambda op: (op.title.lower(), op.path, op.method)) + total_pages = max(1, (len(config_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) + for page_index in range(total_pages): + page_number = page_index + 1 + page_topic_file = generated_dir / f"config_module_{config_slug}_page_{page_number}.topic" + expected[page_topic_file] = render_tag_page_topic( + tag=config_name, + topic_id=f"config_module_{config_slug}_page_{page_number}", + page_number=page_number, + total_pages=total_pages, + ) + + for op in config_ops: + expected[generated_dir / op.topic_file] = render_operation_topic(op) + continue + + expected[tag_topic_file] = render_tag_topic(tag, f"Tag_{tag_slug}") + + total_pages = max(1, (len(tag_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) + for page_index in range(total_pages): + page_number = page_index + 1 + page_topic_file = generated_dir / f"Tag_{tag_slug}_Page_{page_number}.topic" + expected[page_topic_file] = render_tag_page_topic( + tag=tag, + topic_id=f"Tag_{tag_slug}_Page_{page_number}", + page_number=page_number, + total_pages=total_pages, + ) + + for op in tag_ops: + expected[generated_dir / op.topic_file] = render_operation_topic(op) + + operation_topic_names = {f"{op.topic_id}.topic" for op in operations} + operation_topics = [ + path for path in expected + if path.parent == generated_dir and path.name in operation_topic_names + ] + if len(operation_topics) != len(operations): + raise ValueError( + f"Coverage mismatch: expected {len(operations)} operation topics, built {len(operation_topics)} files." + ) + + return expected + + +def generate(root: Path) -> None: + expected = build_expected_outputs(root) + generated_dir = root / "documentation" / "topics" / "generated" + generated_dir.mkdir(parents=True, exist_ok=True) + + expected_generated_paths = { + path for path in expected if path.parent == generated_dir and path.suffix == ".topic" + } + for existing_file in generated_dir.glob("*.topic"): + if existing_file not in expected_generated_paths: + existing_file.unlink() + + changed_files = 0 + for path, content in expected.items(): + if write_if_changed(path, content): + changed_files += 1 + + print(f"Generated documentation artifacts. Updated files: {changed_files}") + + +def check(root: Path) -> None: + expected = build_expected_outputs(root) + generated_dir = root / "documentation" / "topics" / "generated" + + failures: List[str] = [] + for path, content in expected.items(): + if not path.exists(): + failures.append(f"Missing file: {path}") + continue + current = path.read_text(encoding="utf-8") + if current != content: + failures.append(f"Outdated file: {path}") + + if generated_dir.exists(): + expected_generated_paths = { + path for path in expected if path.parent == generated_dir and path.suffix == ".topic" + } + for existing_file in generated_dir.glob("*.topic"): + if existing_file not in expected_generated_paths: + failures.append(f"Stale generated topic: {existing_file}") + + if failures: + print("Documentation check failed:") + for failure in failures: + print(f"- {failure}") + print("Run: python scripts/generate_writerside_openapi_docs.py generate") + raise SystemExit(1) + + print("Documentation check passed.") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate Writerside API docs from openapi.yaml.") + parser.add_argument("command", choices=["generate", "check"], help="Generation mode") + args = parser.parse_args() + + root = Path(__file__).resolve().parents[1] + try: + if args.command == "generate": + generate(root) + else: + check(root) + except ValueError as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/php-ci-test.sh b/scripts/php-ci-test.sh new file mode 100644 index 00000000..63158f36 --- /dev/null +++ b/scripts/php-ci-test.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env sh +set -eu + +suite="${1:-}" +case "$suite" in + unit|integration|api|legacy|all) + ;; + *) + echo "Usage: $0 " >&2 + exit 2 + ;; +esac + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)" +cd "$repo_root" + +compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml" +project_suffix="$(date +%s)-$$" +export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}" + +log_dir=".tmp/ci-logs/$suite" +mkdir -p "$log_dir" + +env_backup_dir=".tmp/php-ci-env-backup-$project_suffix" +mkdir -p "$env_backup_dir" +had_env=0 +had_env_staging=0 +if [ -f .env ]; then + cp .env "$env_backup_dir/env" + had_env=1 +fi +if [ -f .env.staging ]; then + cp .env.staging "$env_backup_dir/env.staging" + had_env_staging=1 +fi + +cp .github/ci.env .env +cp .github/ci.env.staging .env.staging + +collect_logs() { + status="$1" + if [ "$status" -eq 0 ]; then + return + fi + + mkdir -p "$log_dir" + docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true + docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true + docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true + docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true +} + +cleanup() { + status="$?" + collect_logs "$status" + docker compose $compose_files down -v >/dev/null 2>&1 || true + if [ "$had_env" -eq 1 ]; then + cp "$env_backup_dir/env" .env + else + rm -f .env + fi + if [ "$had_env_staging" -eq 1 ]; then + cp "$env_backup_dir/env.staging" .env.staging + else + rm -f .env.staging + fi + rm -rf "$env_backup_dir" + exit "$status" +} +trap cleanup EXIT INT TERM + +docker compose $compose_files up -d redis mysql-debug php1 + +docker compose $compose_files exec -T php1 sh -lc ' + set -eu + for i in $(seq 1 90); do + if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \ + -h "${CONFIG_DB_HOST:-mysql-debug}" \ + -P "${CONFIG_DB_PORT:-3306}" \ + -u "${CONFIG_DB_USER:-root}" \ + ping --silent >/dev/null 2>&1; then + exit 0 + fi + sleep 1 + done + echo "Timed out waiting for mysql-debug" >&2 + exit 1 +' + +tar \ + --exclude='./vendor' \ + --exclude='./.phpunit.cache' \ + --exclude='./build/logs' \ + -C services/nginx/app -cf - . \ + | docker compose $compose_files exec -T php1 tar -C /var/www/html -xf - + +docker compose $compose_files exec -T php1 sh -lc \ + 'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress' + +docker compose $compose_files exec -T php1 sh -lc \ + "cd /var/www/html && composer test:ci:$suite" diff --git a/scripts/run.ps1 b/scripts/run.ps1 new file mode 100644 index 00000000..7a8f04ff --- /dev/null +++ b/scripts/run.ps1 @@ -0,0 +1,26 @@ +param( + [ValidateSet("start", "stop", "logs", "test")] + [string]$Action = "start", + [string]$Service = "php1" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RootDir = Split-Path -Parent $PSScriptRoot +Set-Location $RootDir + +switch ($Action) { + "start" { + docker compose up -d traefik redis mysql-debug php1 caddy + } + "stop" { + docker compose down + } + "logs" { + docker compose logs -f --tail=200 $Service + } + "test" { + docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit" + } +} diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 00000000..79209ee7 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +ACTION="${1:-start}" +SERVICE="${2:-php1}" + +case "$ACTION" in + start) + docker compose up -d traefik redis mysql-debug php1 caddy + ;; + stop) + docker compose down + ;; + logs) + docker compose logs -f --tail=200 "$SERVICE" + ;; + test) + docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit" + ;; + *) + echo "Usage: ./scripts/run.sh [start|stop|logs |test]" + exit 1 + ;; +esac diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 new file mode 100644 index 00000000..d3590d38 --- /dev/null +++ b/scripts/setup.ps1 @@ -0,0 +1,18 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RootDir = Split-Path -Parent $PSScriptRoot +Set-Location $RootDir + +if ((-not (Test-Path ".env")) -and (Test-Path ".env.example")) { + Copy-Item ".env.example" ".env" + Write-Host "Created .env from .env.example" +} + +$docker = Get-Command docker -ErrorAction SilentlyContinue +if (-not $docker) { + throw "Docker is required but was not found in PATH." +} + +docker compose up -d --build traefik redis mysql-debug php1 caddy +Write-Host "API stack started: http://localhost" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 00000000..c39b3571 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ ! -f ".env" && -f ".env.example" ]]; then + cp .env.example .env + echo "Created .env from .env.example" +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required but was not found in PATH." + exit 1 +fi + +docker compose up -d --build traefik redis mysql-debug php1 caddy +echo "API stack started: http://localhost" diff --git a/scripts/staging-edge-gateway-smoke.mjs b/scripts/staging-edge-gateway-smoke.mjs new file mode 100644 index 00000000..42e54527 --- /dev/null +++ b/scripts/staging-edge-gateway-smoke.mjs @@ -0,0 +1,160 @@ +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 const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [ + "/edge-agent/install-token/status", + "report_install_status", + 'begin_install_phase "VERIFY_TOKEN"', + 'begin_install_phase "WAIT_FOR_CLAIM"', + 'report_install_status "FAILED"', +]; + +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)}`, + }, + ]; +} + +export function validateInstallerScriptBody(body) { + const source = String(body || ""); + const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet)); + + if (missingSnippets.length) { + throw new Error(`Installer script is missing required status wiring: ${missingSnippets.join(", ")}`); + } + + return INSTALLER_SCRIPT_REQUIRED_SNIPPETS; +} + +function printUsage() { + process.stdout.write(`Usage: + node scripts/staging-edge-gateway-smoke.mjs --install-token [--base-url ] + +Options: + --install-token Real edge-gateway install token used to validate install.sh. + --base-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 || ""}` + ); + } + + if (check.name === "Installer script") { + result.verifiedSnippets = validateInstallerScriptBody(body); + } + } + + 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; + }); +} diff --git a/scripts/staging-edge-gateway-smoke.test.mjs b/scripts/staging-edge-gateway-smoke.test.mjs new file mode 100644 index 00000000..2d8299fa --- /dev/null +++ b/scripts/staging-edge-gateway-smoke.test.mjs @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_STAGING_BASE_URL, + INSTALLER_SCRIPT_REQUIRED_SNIPPETS, + buildChecks, + normalizeBaseUrl, + parseArgs, + validateInstallerScriptBody, +} 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", + ]); +}); + +test("validateInstallerScriptBody requires install-session reporting wiring", () => { + const script = ` + INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status" + report_install_status "FAILED" + begin_install_phase "VERIFY_TOKEN" "Verifying install token" + begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim" + `; + + assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS); +}); + +test("validateInstallerScriptBody rejects missing installer status hooks", () => { + assert.throws( + () => validateInstallerScriptBody("echo hello"), + /Installer script is missing required status wiring/ + ); +}); diff --git a/scripts/sync-ai-workflow.mjs b/scripts/sync-ai-workflow.mjs new file mode 100644 index 00000000..08c86e87 --- /dev/null +++ b/scripts/sync-ai-workflow.mjs @@ -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", + "---", + "", + "", + "", + ...lines, + ].join("\n"); +} + +function renderJunieGuidelines(lines) { + return [ + "", + "", + ...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 < ` ${line}`), + " EOF", + " )\"", + " gh issue create \\", + ` --title "${copilot.title_prefix} $TASK" \\`, + " --body \"$BODY\" \\", + ` --label "${copilot.issue_label}"`, + ].join("\n"); +} + +function renderWorkflowSnapshot(workflow, projectKey) { + return [ + ``, + "", + 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); +}); diff --git a/scripts/test-gateway.mjs b/scripts/test-gateway.mjs new file mode 100644 index 00000000..47bb3d3f --- /dev/null +++ b/scripts/test-gateway.mjs @@ -0,0 +1,547 @@ +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_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`; +export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`; +export const DEFAULT_STACK_SERVICE_NAME = "truckwash-edge-gateway-stack.service"; +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 composeArgs(projectName, args) { + return ["compose", "-p", projectName, ...args]; +} + +function usesWindowsPathSyntax(filePath) { + return /^[A-Za-z]:($|[\\/])/.test(filePath) || filePath.startsWith("\\\\") || filePath.includes("\\"); +} + +function pathForInputs(...filePaths) { + const hasWindowsPath = filePaths.some((filePath) => usesWindowsPathSyntax(String(filePath || ""))); + + return hasWindowsPath ? path.win32 : path; +} + +async function resolveRootDir(scriptPath) { + const cwd = process.cwd(); + + try { + await fs.access(path.join(cwd, "docker-compose.yml")); + return cwd; + } catch { + return path.resolve(path.dirname(scriptPath), ".."); + } +} + +function printUsage() { + process.stdout.write(`Usage: + node scripts/test-gateway.mjs start [--install-token ] [--container-name ] [--hostname ] + node scripts/test-gateway.mjs stop [--container-name ] + node scripts/test-gateway.mjs logs [--container-name ] [--tail ] + node scripts/test-gateway.mjs status [--container-name ] + +Options: + --install-token Claim a new edge gateway before starting the container. + --container-name Docker container name. Default: ${DEFAULT_CONTAINER_NAME} + --hostname Gateway hostname reported during claim and Docker run. + --host-api-url Host-reachable API URL for claim/health checks. Default: ${DEFAULT_HOST_API_URL} + --api-url Container-internal API URL. Default: ${DEFAULT_CONTAINER_API_URL} + --broker-url Container-internal broker URL. Default: ${DEFAULT_CONTAINER_BROKER_URL} + --config-dir Directory for generated config. Default: backend-php/.tmp/test-gateway + --image-tag Docker image tag. Default: ${DEFAULT_IMAGE_TAG} + --heartbeat-seconds Agent heartbeat interval. Default: ${DEFAULT_HEARTBEAT_INTERVAL_SECONDS} + --tail 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. + --copy-config Copy generated config into the container instead of bind mounting it. +`); +} + +export function resolveComposeProjectName(rootDir, env = process.env) { + const explicit = String(env.COMPOSE_PROJECT_NAME || "").trim(); + if (explicit !== "") { + return explicit; + } + + return pathForInputs(rootDir).basename(rootDir); +} + +export function resolveComposeNetworkName(rootDir, env = process.env) { + return `${resolveComposeProjectName(rootDir, env)}_default`; +} + +export function resolveConfigDirectory(rootDir, explicitDir = null) { + const pathModule = pathForInputs(rootDir, explicitDir); + + if (explicitDir) { + return pathModule.resolve(rootDir, explicitDir); + } + + return pathModule.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, + runtimeDir: DEFAULT_RUNTIME_DIR, + stateDatabasePath: DEFAULT_STATE_DATABASE_PATH, + agentPath: `${DEFAULT_INSTALL_DIR}/agent.php`, + lanWorkerPath: `${DEFAULT_INSTALL_DIR}/lan-worker.php`, + serviceUnitPath: `${DEFAULT_INSTALL_DIR}/truckwash-edge-agent.service`, + stackServiceUnitPath: `${DEFAULT_INSTALL_DIR}/${DEFAULT_STACK_SERVICE_NAME}`, + serviceName: String(existingConfig.serviceName || hostname || DEFAULT_CONTAINER_NAME), + stackServiceName: String(existingConfig.stackServiceName || DEFAULT_STACK_SERVICE_NAME), + composeFileName: String(existingConfig.composeFileName || "docker-compose.gateway.yml"), + composeProjectName: String(existingConfig.composeProjectName || "truckwash-edge-gateway"), + launcherScriptName: String(existingConfig.launcherScriptName || "gateway-launcher.sh"), + workerBaseUrl: String(existingConfig.workerBaseUrl || "http://lan-worker:8090"), + updateWindow: String(existingConfig.updateWindow || "02:00-04:00"), + runtimeMode: String(existingConfig.runtimeMode || "compose"), + restartMode: "spawn", + heartbeatIntervalSeconds, + commandPollTimeoutSeconds: Number(existingConfig.commandPollTimeoutSeconds || 20), + commandPollRetryDelayMs: Number(existingConfig.commandPollRetryDelayMs || 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, + copyConfig: 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 "--copy-config": + options.copyConfig = 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", composeArgs(resolveComposeProjectName(rootDir), ["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, + copyConfig, +}) { + const networkName = resolveComposeNetworkName(rootDir); + const mountedConfigDir = toDockerMountPath(configDir); + const containerConfigPath = copyConfig + ? `/tmp/${DEFAULT_CONFIG_FILE_NAME}` + : `/config/${DEFAULT_CONFIG_FILE_NAME}`; + const createArgs = [ + copyConfig ? "create" : "run", + ...(copyConfig ? [] : ["-d"]), + "--name", + containerName, + "--hostname", + hostname, + "--restart", + "unless-stopped", + "--network", + networkName, + ...(copyConfig ? [] : ["-v", `${mountedConfigDir}:/config`]), + imageTag, + "--config", + containerConfigPath, + ]; + + await removeContainer(containerName); + await runCommand("docker", createArgs, { + cwd: rootDir, + stdio: "inherit", + }); + + if (copyConfig) { + await runCommand("docker", [ + "cp", + path.join(configDir, DEFAULT_CONFIG_FILE_NAME), + `${containerName}:${containerConfigPath}`, + ], { + cwd: rootDir, + stdio: "inherit", + }); + + await runCommand("docker", ["start", containerName], { + 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, + runtimeDir: config.runtimeDir ?? null, + agentPath: config.agentPath ?? null, + serviceUnitPath: config.serviceUnitPath ?? null, + stackServiceUnitPath: config.stackServiceUnitPath ?? 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 = await resolveRootDir(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, + copyConfig: options.copyConfig, + }); + + 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 currentRealPath = await fs.realpath(currentFilePath).catch(() => currentFilePath); +const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : ""; +const invokedRealPath = invokedScript !== "" + ? await fs.realpath(invokedScript).catch(() => invokedScript) + : ""; +if (invokedRealPath === currentRealPath) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/test-gateway.ps1 b/scripts/test-gateway.ps1 new file mode 100644 index 00000000..ebddf878 --- /dev/null +++ b/scripts/test-gateway.ps1 @@ -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 diff --git a/scripts/test-gateway.sh b/scripts/test-gateway.sh new file mode 100644 index 00000000..a0f06518 --- /dev/null +++ b/scripts/test-gateway.sh @@ -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" "$@" diff --git a/scripts/test-gateway.test.mjs b/scripts/test-gateway.test.mjs new file mode 100644 index 00000000..1441a8f6 --- /dev/null +++ b/scripts/test-gateway.test.mjs @@ -0,0 +1,97 @@ +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("parseArgs accepts copy config mode", () => { + const options = parseArgs(["start", "--copy-config"]); + + assert.equal(options.copyConfig, 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); +}); diff --git a/services/caddy/Caddyfile-staging b/services/caddy/Caddyfile-staging new file mode 100644 index 00000000..4989804b --- /dev/null +++ b/services/caddy/Caddyfile-staging @@ -0,0 +1,27 @@ +{ + # Traefik terminates TLS; Caddy should serve plain HTTP internally + auto_https off +} + +:80 { + encode gzip + root * /var/www/html + + # CORS is handled at the edge by Traefik's headers middleware. + # Do not set or strip Access-Control-* headers here to avoid conflicts. + + # PHP handling via FastCGI to php-fpm pool + php_fastcgi php-staging:9000 + + try_files {path} {path}/ /index.php + file_server + + log { + output file /var/log/caddy/access.log { + roll_size 10MiB + roll_keep 5 + roll_keep_for 720h + } + format json + } +} diff --git a/services/caddy/logs/access.log b/services/caddy/logs/access.log deleted file mode 100644 index c1fe36e3..00000000 --- a/services/caddy/logs/access.log +++ /dev/null @@ -1,3331 +0,0 @@ -{"level":"info","ts":1772633436.3347545,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.007102549,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633436.7006695,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.479188956,"size":164,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633436.7183015,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.372354948,"size":164,"status":200,"resp_headers":{"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633441.2505348,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007334989,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633441.3609092,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003834443,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633441.7223766,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.469109095,"size":164,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"]}} -{"level":"info","ts":1772633441.7979734,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.434596773,"size":164,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["text/html; charset=UTF-8"]}} -{"level":"info","ts":1772633446.2342622,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.007483473,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633446.342698,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004434574,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633446.7978988,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.560990688,"size":164,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633446.8557699,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.51014985,"size":164,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633451.1916416,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.008222966,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633451.301096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006922111,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633451.5715532,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.376771673,"size":164,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633451.6389284,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.335357297,"size":164,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633456.1347315,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.008932399,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633456.2419968,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004871595,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633456.5224674,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.277709197,"size":164,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633456.5518763,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.414596935,"size":164,"status":200,"resp_headers":{"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633461.069195,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00711109,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633461.1743457,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002058242,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633461.500588,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.428455301,"size":164,"status":200,"resp_headers":{"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633461.636255,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.458922065,"size":164,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633464.9531577,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.010159955,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633465.694005,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.739021304,"size":164,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633466.2302976,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005153994,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633466.3472621,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.007831312,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633466.6576333,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.4241142,"size":164,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633466.8840947,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.534653883,"size":164,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633471.2842078,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007107018,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633471.3940537,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.003919355,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633471.7348902,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.338118207,"size":164,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633471.7461538,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.459307734,"size":164,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633476.2763963,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007649661,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633476.384288,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003794941,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633476.7042546,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.424990735,"size":164,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633476.7905838,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.403791374,"size":164,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633481.2369094,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005881705,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633481.3467903,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004735441,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633481.720631,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.3712451,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633482.8254776,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.586439718,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633486.1818657,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.008834647,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633486.2918818,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00305182,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772633487.2457054,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.951391575,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633487.622611,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.438077032,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633491.1178293,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.00720582,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633491.2328725,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.007176497,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633491.6093676,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.373700526,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633492.0112727,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.890855378,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633496.2305803,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006264515,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633496.3532224,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004519149,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633497.0270917,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.671247187,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633498.268637,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":2.034954444,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633501.2787771,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003711273,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633501.3943508,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003127398,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633501.9955168,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.598817435,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633502.3353086,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.054217101,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633506.2751453,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.007931799,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633506.3875034,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005020182,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633506.971844,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.581754652,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633507.4076774,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.129826636,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633511.2367394,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005267525,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633511.3484793,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004769407,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633511.7716458,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.421138454,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633512.4217753,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.181878839,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633516.1820035,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.004797171,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633516.2930424,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004681606,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633516.9637284,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.668380447,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633517.0247128,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.840076259,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633521.1183116,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006644684,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633521.2316537,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005462919,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633521.9049459,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.670737128,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633522.417705,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.297319836,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633524.950014,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.012134739,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633525.6694825,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.715873625,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633526.235385,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005716303,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633526.347175,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003450901,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633527.3354166,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":1.097722868,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633527.670913,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":1.321326803,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633531.2834566,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006811941,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633531.3966675,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.00374822,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633531.986424,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.587031936,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633532.2479322,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.96192142,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633536.27315,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005082268,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633536.389087,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005359708,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633536.9560444,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.564572223,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633537.1515446,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.875682301,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633541.2376497,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.007763724,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633541.3494859,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004107722,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633542.2101235,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.857900378,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633542.293862,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.053337951,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633546.1823046,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.007081836,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633546.294408,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004061084,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633546.888751,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.590890492,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633547.71383,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.529032451,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633551.1199427,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00735294,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633551.2312946,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003278175,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633552.1674898,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.045331631,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633552.2944562,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.060163166,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633556.233573,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00720556,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633556.348738,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003970784,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633556.98711,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.635256205,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633557.7604156,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.524500631,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633561.2993083,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.008696378,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633561.4018173,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.008035692,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633562.0754666,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.670818246,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633562.693193,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":1.391375211,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633566.272242,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003739702,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633566.3867836,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.002351671,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633566.7356374,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.346154625,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633567.2491174,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.974620468,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633571.2359695,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.005927865,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633571.349627,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004117503,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633571.8312814,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.47877862,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633572.9159405,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.677733543,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633576.1821127,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006532286,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633576.2968166,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006657032,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633577.0532079,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.75404666,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633577.1735961,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.989151342,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633581.1188054,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.007167068,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633581.2378972,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003942484,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633581.6483665,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.407132459,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633582.788066,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.66605324,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633584.9570749,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.011207878,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633585.5656857,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.605445308,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633586.27227,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006535343,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633586.4396281,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005314349,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633586.9893782,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.546542312,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633587.2867966,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.012079823,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633591.2904463,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007046576,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633591.4034429,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003733073,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633591.7317924,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.325760286,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633592.2284253,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.935355517,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633596.2824664,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.006682626,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633596.3953993,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.003902274,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633597.144113,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.746092386,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633597.681649,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.397059496,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633601.2440476,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006202124,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633601.355709,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.002811981,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633601.7037132,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.345329863,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633602.2546926,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.007810686,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772633606.1889427,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.005453547,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633606.3029296,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004268251,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633607.0655088,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.874025024,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633607.192767,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.887506974,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633611.1275527,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006063922,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633611.2435973,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006265753,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633611.597547,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.352060549,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633612.411236,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.281574297,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633616.2034726,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007658618,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633616.3194861,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004508672,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633616.8595576,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.537217696,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633617.2282164,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.021872179,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633621.2712653,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.006217159,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633621.3873415,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005207632,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633621.690151,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.300489941,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633622.07696,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.802974335,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633626.2744803,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.007552053,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633626.3874998,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004356108,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633626.734557,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.343925961,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633627.4175222,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.140157179,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633631.2374077,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004174957,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633631.3515208,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003419804,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633631.8906918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.537174102,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633632.1942723,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.954615087,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633636.1869826,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.006420743,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633636.3020496,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006585976,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633636.850137,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.544945642,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633637.3551655,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.165678338,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633641.1241353,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.005688313,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633641.2363548,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.002796678,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633642.0863225,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Authorization":[],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.847493722,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633642.24745,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.120793708,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633644.8248198,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.010925921,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633646.2018356,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003877882,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633646.3231745,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004112906,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633646.340871,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55280","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.374549111,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633647.2898855,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.084960959,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633647.5574584,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.23218128,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633651.271797,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.006628341,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633651.3904364,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007625058,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633652.0487282,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.655510167,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633652.1856427,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.910565049,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633656.2734342,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.007674422,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633656.3871143,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.005185218,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633656.6560035,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.265244654,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633657.0402517,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.763992902,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633661.2387636,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.007508905,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633661.3495934,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002672516,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633661.993027,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.64011992,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633663.4367025,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":2.195027563,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633666.184956,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.006294854,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633666.2971594,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003426347,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633666.9417346,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.641499958,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772633667.229228,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.041950749,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633671.1262126,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.008221723,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633671.2391486,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.007044837,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633671.807386,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.565733205,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633672.274307,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.145772263,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633676.2091942,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.009320497,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633676.3226786,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005286022,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633676.7001839,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.374631647,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633677.079715,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.867891907,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633681.266091,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.007475352,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633681.3822439,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.008092919,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633681.896196,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.51089148,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633682.2112615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.942370195,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633686.2635028,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.010075369,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633686.372165,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004331359,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633687.6792138,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.30404313,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633687.6973753,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":1.431538013,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633691.222067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.006519719,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633691.3339972,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003561622,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633692.2615185,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.9248356,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633692.266931,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.041737329,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633696.168745,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007373634,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633696.2798407,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003182807,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633697.1773486,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.005938329,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633697.4607785,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.17838839,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633701.1029243,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005934452,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633701.2140613,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.003069188,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633701.6935506,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.476528693,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633702.656089,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.550826335,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633704.7977555,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.00992067,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633705.559502,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.592460756,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633706.2166054,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006304444,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633706.34126,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004756791,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633706.7592332,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.414664688,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633707.6283724,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.408946495,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633711.278424,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007173926,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633711.3948147,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006872935,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633711.9777098,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.580534591,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633712.5614638,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.280322036,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633716.276874,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004735335,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633716.392172,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003817846,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633716.9019477,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.506490144,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633718.0673292,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.78769543,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633721.244721,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.006626979,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633721.356364,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003095525,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633721.7614558,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.402403296,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633722.9144194,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.667620661,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633726.1930118,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005909296,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633726.3060622,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003746345,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633726.8609939,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.55254519,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633727.1893785,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.994196495,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633731.1300325,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004736051,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633731.243589,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003570822,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633731.8477345,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.6016739,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633731.9641373,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.831740434,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633736.2135663,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.008254228,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633736.32753,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004045109,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633737.0432134,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.713210741,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633737.8245552,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.607293748,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633741.2800572,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.006691064,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633741.3956375,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005392928,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633742.0457463,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.646906783,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633742.170755,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.888275433,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772633746.281361,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007041952,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772633746.3931212,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003011621,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633746.9041102,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.508666756,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633747.1963015,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.91219821,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633751.2459395,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005913191,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633751.3593616,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003869432,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633751.8578804,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.495751695,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633752.601839,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.353671613,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633756.19038,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.003739916,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633756.3049939,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003552273,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633756.6373844,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.329660358,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633757.2726543,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.079610123,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633761.1460836,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00411874,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633761.241964,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003627749,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633761.6866603,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.442393318,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633762.2702563,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.121834721,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633764.8280106,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.01069603,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633765.7923534,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.819864232,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633766.2200155,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007101052,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633766.3501623,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.025466714,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633766.8565345,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.63356272,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633767.036891,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.68187548,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633771.2816408,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006116452,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633771.3989334,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005809175,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633772.412229,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.128378493,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633772.4467897,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.045450676,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633776.2835686,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.007014472,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633776.3962915,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.003980128,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633776.9918184,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.59277352,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633777.7384353,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.452084249,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633781.2483618,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.006828122,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633781.3615618,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004078267,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633782.0204885,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.656476714,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633782.3844683,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.133858522,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633786.1976023,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.00750726,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633786.328078,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002455965,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633786.6652887,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.334896355,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633787.2056756,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":1.00542727,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633791.1338155,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.006266881,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633791.2488217,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.006405264,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633791.9255495,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.674277,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633791.9872625,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.851392698,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633796.215509,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00759392,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633796.330189,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003151625,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633796.9764693,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.643422945,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633797.970259,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.751719404,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633801.2841763,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007512392,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633801.4000025,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004818426,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633802.789547,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.387142172,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633802.795168,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.508429668,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633806.2863903,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.008647947,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633806.3968756,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.003102546,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633806.9704432,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.570276356,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633807.3538651,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.06444471,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633811.2528648,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.008170409,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633811.3636851,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.003976322,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633811.9320924,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.565424782,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633812.2198956,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.964284044,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633816.1981685,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.006803082,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633816.311362,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004436918,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633816.7229974,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.408873971,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633817.2184265,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.017909297,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633821.137031,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.008692469,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633821.2509396,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.008222076,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633821.768678,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.514150907,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633822.025536,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.885910926,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633824.823527,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.010711114,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633826.2167778,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.01101117,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633826.3229742,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00235364,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633826.5117242,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"55296","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.541215334,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633826.9320118,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.606531503,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772633827.817053,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.594110936,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633831.275184,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.008580139,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633831.389693,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006750953,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633832.165659,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Gpc":["1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.773662141,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633832.2062,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.927966832,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772633836.2874024,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.006193094,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633836.4013646,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003734106,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633837.2556624,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.850007896,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633837.3145247,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.024752343,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633841.2597017,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005744118,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633841.376201,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006721543,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633841.8544204,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.476030881,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633842.144348,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.882072911,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633846.217066,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.010883682,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633846.3323314,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.009592324,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633846.6731305,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.338058419,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633847.0512495,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.831259517,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633851.1542454,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007328784,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633851.2675583,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004915163,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633851.8154764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.544261303,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633852.48496,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.327946172,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633856.2145019,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.007678642,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633856.329527,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007728356,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633856.8925421,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.559988403,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633857.2663717,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.048318127,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633861.2701726,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007116082,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633861.3841825,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.005561526,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633861.8481538,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.459392775,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633862.2225378,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.949941894,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633866.2829616,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.007961045,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633866.395755,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004890893,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633867.261063,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.975145089,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633867.6570063,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.258497215,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633871.3150764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.055049323,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633871.5719554,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.063426638,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633872.537431,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.942846766,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633873.4298391,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":2.103773494,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633876.2052495,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.011108473,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633876.3130293,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003579285,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633877.159702,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.844009865,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633878.0966067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.888377542,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633881.1366582,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004907344,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633881.2483015,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002447639,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633881.766823,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.515967093,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633882.0194082,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.879989557,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772633884.833924,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007944958,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633885.5181236,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.680652714,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633886.08406,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.007834727,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633886.1857796,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00541331,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633886.9710197,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.639961244,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633887.0987253,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.868546651,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633891.2709422,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.006810595,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633891.3869562,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005557673,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772633892.0383856,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.648252117,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633892.1990592,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.925879885,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633896.2879498,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.012079,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633896.3973176,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003989952,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633896.7140536,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.314200493,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633897.2318852,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.940882839,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633901.254487,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007731561,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633901.3666716,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004400916,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633902.052679,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.683380474,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633902.7286952,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.471289374,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633906.206434,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.00849123,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633906.3156812,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002552354,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633906.861123,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.542765241,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633907.1730719,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.963877527,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633911.144892,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.008469698,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633911.2591166,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.009242013,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633911.6573992,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.395433874,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633912.0491822,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.90162403,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633916.0878978,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007104333,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633916.1902254,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002733383,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633916.8477762,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.515247123,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633917.1963263,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.965033726,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633921.2728329,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006862467,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633921.38753,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.003818669,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633921.897867,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.50765446,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633922.9487054,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.673189955,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633926.288079,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.009787857,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633926.397417,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003283719,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633927.6714125,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.270372102,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633927.7333918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.441450934,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633931.2748303,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.025140966,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633931.4096167,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.010163108,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633931.903711,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.481572096,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633933.061517,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.746515656,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633936.204922,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007643499,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633936.3179383,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004122178,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633936.8835917,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.56274188,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633937.189749,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.981790015,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633941.1402895,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006793729,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633941.25449,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004787404,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633941.7286596,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.471094524,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633942.7449718,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.602238739,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633944.825449,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004720829,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633945.3744822,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.546187531,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633946.1409607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.054848069,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633946.1849911,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.003249237,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633946.6611264,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.324486426,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633947.1807134,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.878522081,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633951.277605,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.006664215,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633951.3960764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007647877,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633952.1101143,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.711144858,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633952.7124527,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.432727964,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633956.292024,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.009127348,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633956.403994,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.00442067,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633957.106132,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.699205619,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633958.0837438,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.78933016,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633961.2683895,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006576592,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633961.3723907,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003076043,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772633961.7322323,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.357185909,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633962.2675414,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.996634257,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633966.2102058,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.008231083,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633966.3260202,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007631611,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772633966.986893,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Sec-Gpc":["1"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.65830912,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633967.5145643,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.302019306,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633971.146726,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006198266,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633971.2609634,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004030333,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633971.6349638,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.37124017,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633972.104422,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.955457234,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633976.0839403,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007672867,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633976.1944113,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.00315123,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633976.5848033,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.387431348,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633977.140956,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.913830814,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633981.2840083,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006944234,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633981.3988633,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004539435,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772633982.3015175,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.014996042,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633982.5456173,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.143816816,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633986.3010874,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006634925,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633986.4281797,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.003705265,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772633987.0386355,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.608389307,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772633987.7491539,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.445515192,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772633991.2685635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006677598,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772633991.38137,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005221154,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633991.8340163,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.449812017,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772633992.2952733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.024296737,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772633996.211324,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.005791739,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772633996.3239424,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003779494,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772633997.2371216,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.023058115,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772633997.2896686,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Gpc":["1"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.963173213,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634001.1435578,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007807439,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634001.256631,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004601782,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634002.7026873,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.44325825,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634002.7712238,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.625052625,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634004.849267,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.011393492,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634005.4006293,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.548832493,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634006.0817716,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.006395913,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634006.202533,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00220447,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634006.7671409,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.562424771,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634007.21155,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.980137008,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634011.254594,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004637412,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634011.3702533,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003294843,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634011.7703645,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.397505402,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634012.2983851,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.040940274,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634016.2767386,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004161673,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634016.3922596,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.004043501,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634016.724236,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.329281821,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634017.3884733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.108442994,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634021.251832,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.003594667,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634021.4465816,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.05221593,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634022.0345387,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.582586756,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634022.3667424,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.112207631,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634026.2063274,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.00256343,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634026.3236308,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004499722,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634027.073395,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.746916331,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634027.747369,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.538228481,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634031.147812,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003443958,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634031.2643783,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004599913,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634031.8605185,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.593300996,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634032.1513183,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.001135448,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634036.0864813,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005973125,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634036.1994302,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002828464,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634036.6948385,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.49259927,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634037.163078,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.934608763,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772634041.2662146,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.014993169,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634041.3735886,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002905589,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634041.916842,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.540614708,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634042.2419732,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.972127169,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634046.2799764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005715508,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634046.3932583,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003079128,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634046.7270849,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.331017247,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634047.375795,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.093589037,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772634051.2577689,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.006409288,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634051.368514,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003752672,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634052.024899,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.65382945,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634052.9008226,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.640780433,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634056.2107487,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006022376,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634056.3260624,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.006345891,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634056.634077,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.305865681,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634057.022573,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.809162867,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634061.1522062,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006204415,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634061.2710776,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.00973635,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634061.9684997,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.694938804,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634062.1414328,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.987142878,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634064.8451953,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.007624069,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634066.0831554,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002390921,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634066.0855823,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.236908471,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634066.2041287,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007238897,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634066.899567,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.691743566,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634066.9645114,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.878219545,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634071.2588542,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005638129,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634071.372564,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00214165,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634071.8813534,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.506652935,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634072.3252504,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.063961416,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634076.2821221,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005736601,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634076.3943582,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003585375,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634076.9651835,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.568340544,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634077.9751763,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.690580505,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634081.257635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005215943,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634081.3712056,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003164187,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634082.326451,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.06620838,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634082.6428258,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.268899531,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634086.2110465,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.00546778,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634086.3245792,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006887516,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634087.5102043,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.296794066,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634087.5703328,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.243259099,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634091.1511772,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005826022,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634091.2649255,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004421463,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634091.9119287,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.643632278,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634092.8285391,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.675251033,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634096.0898144,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.008343041,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634096.19761,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003695098,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634096.6846988,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.484806891,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634097.0139563,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.921679571,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634101.2621708,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00571426,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634101.3793972,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.00350164,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634101.7797368,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.39808254,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634102.310357,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.045811114,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634106.2921708,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006277379,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634106.405033,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003444263,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634106.9547236,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.547407739,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634107.7479382,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.453369415,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634111.2699494,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.007816082,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634111.39884,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.007093975,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634112.1141908,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.712616661,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634112.8875134,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.614692282,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634116.2214959,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.009281387,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634116.3334742,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006670139,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634116.9250834,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.588515277,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772634117.3523126,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.127954826,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634121.1560261,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.009783984,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634121.263754,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003094873,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634121.8269176,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.560224304,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634122.1486228,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.990029427,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634124.849196,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.008429449,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634126.0828004,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003211194,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634126.197724,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.345765316,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634126.205125,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006729105,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634126.9541247,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.745669369,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634127.7350342,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.504710583,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772634131.2670033,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.008366894,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634131.3832738,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006409995,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634131.890305,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Authorization":[],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.504118849,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634132.5242045,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.254470763,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634136.2922757,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.010503896,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634136.4032974,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00501053,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634136.7787302,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.372763961,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634137.2848814,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.989327858,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634141.267348,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006954331,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634141.3818228,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005627021,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634141.9476879,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.56272563,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634142.30135,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.030561139,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634146.225535,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.010022639,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634146.3344517,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005982082,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634146.9644918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.627330904,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634147.2320561,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.003738672,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634151.161251,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006454964,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634151.2744603,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004535159,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634151.6876442,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.410528246,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634152.7102187,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.546910097,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634156.2564416,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.006284698,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634156.2590241,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.008879731,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634156.4925385,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.233151605,"size":210,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"]}} -{"level":"info","ts":1772634156.4925377,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.231221577,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634156.7660909,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.007366181,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634157.9123313,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.006609273,"size":210,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634159.981524,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004050122,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634160.4171307,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.433183878,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772634161.286778,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005254609,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634161.3802722,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00269881,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634161.6660469,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.376695408,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772634161.8349984,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.451558833,"size":210,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"]}} -{"level":"info","ts":1772634165.031647,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007414726,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634165.615951,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""]}},"bytes_read":0,"user_id":"","duration":0.58184153,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634166.5495083,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003947986,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634166.5509171,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005390689,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634166.8594282,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.307128101,"size":210,"status":200,"resp_headers":{"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634166.8597648,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.306348377,"size":210,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["text/html; charset=UTF-8"]}} -{"level":"info","ts":1772634170.005884,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.006869388,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634170.5055408,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""]}},"bytes_read":0,"user_id":"","duration":0.497313241,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634171.2623758,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.0042358,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634171.3805668,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.006007843,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634171.5790129,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.314029991,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634171.5790193,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.196178913,"size":210,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"]}} -{"level":"info","ts":1772634174.9692671,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.006935747,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634175.4481702,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.476281164,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634176.2186458,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.005892501,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634176.3330688,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006415985,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634176.5207458,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.298719724,"size":210,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634177.3833718,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.047228611,"size":210,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634179.912202,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.007435264,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634180.4768722,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.561976977,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634181.15944,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.006240689,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634181.2722127,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003800658,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634181.6070817,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.444784368,"size":210,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"]}} -{"level":"info","ts":1772634181.7507179,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.476025345,"size":210,"status":200,"resp_headers":{"Content-Type":["text/html; charset=UTF-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634184.8622446,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.010055075,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634185.4197316,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.554903937,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634186.095988,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.0057168,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634186.2153754,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00216556,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634186.6653988,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.56661575,"size":210,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634186.790359,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.572580812,"size":210,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["text/html; charset=UTF-8"]}} -{"level":"info","ts":1772634189.987801,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004760711,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634190.5979621,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.607703467,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634191.2736242,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004283349,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634191.3836367,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004783699,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634191.598168,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.211924006,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634191.895209,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.619005213,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634195.0293918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006241601,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634196.005845,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.973882819,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634196.2899761,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004557783,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634196.4044507,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002720823,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634196.9301407,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.52268537,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634197.292116,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.998600378,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634200.0271347,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.011861847,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634200.6578336,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.627482206,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634201.2672863,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006181281,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634201.3814135,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.00436601,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634202.0316052,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.64738289,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634202.4738986,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.204321232,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634204.9691505,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.006060176,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634205.553415,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.581726286,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634206.2185185,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.005259151,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634206.336385,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00260949,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634207.015613,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.676427155,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634207.7209125,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.499824214,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634209.9170437,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005729831,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634211.0020583,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.082949315,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634211.1617053,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006646708,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634211.2739863,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005561432,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634211.443473,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.166829035,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772634212.2885723,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.123846495,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634214.8518934,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004668052,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634215.574621,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.719652085,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634216.0986724,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.008724322,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634216.20715,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003741147,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634217.2069528,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.997210344,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634217.5141063,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.412134671,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634219.963228,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.008392302,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634220.8536382,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua-Mobile":["?0"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.888179357,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634221.2443042,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.007592644,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634221.3587337,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003691153,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634221.7169063,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.355507542,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634222.3230333,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.076428532,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634225.0139081,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004090149,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634226.2935793,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.005120165,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634226.3987734,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004241803,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634226.411462,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"39512","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.395261343,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634226.9519808,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.550221586,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634227.2720802,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.972268024,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634230.0084283,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.006762022,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634231.2643054,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003096891,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634231.3365242,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.326030783,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634231.378588,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.002730619,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634232.0900502,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.709572295,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634232.372045,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.104531197,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634234.9739523,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005710551,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634235.5283237,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.55186354,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634236.225384,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007525799,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634236.3348992,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004042067,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634237.103997,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.874686853,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634237.2255688,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.88819932,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634239.9140618,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007033711,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634240.7241642,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.808252884,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634241.1620255,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00765226,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634241.2716405,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.002894452,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634241.8209734,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.546754096,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634242.8078005,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.642344753,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772634244.8485165,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.009593264,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634245.5083618,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.657551811,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634246.1049876,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.008313495,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634246.2214797,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00604789,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634246.823025,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.599113018,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634246.9405243,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.831305103,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634249.962096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00711572,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634251.1525614,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.188498413,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634251.2443852,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007561905,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634251.3594623,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00447546,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634252.0769823,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.714805983,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634252.7588668,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.511803153,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634255.0105555,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006896527,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634255.840729,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.827786885,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634256.283593,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.008595744,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634256.4095967,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.016179886,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634257.006655,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.593080677,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634257.7684596,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.480913006,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634260.008266,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005334641,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634260.7265263,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.715791396,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634261.263998,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007752366,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634261.376081,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.003127796,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634261.9528537,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.573708278,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634262.296128,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.029605548,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634264.9765882,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.005698807,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634265.8776312,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.89860448,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634266.222281,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.007461549,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634266.3371196,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.008011944,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634267.1484284,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.922654708,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634267.4011917,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.061275196,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634269.9196467,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.012496675,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634270.6902182,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.768349913,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634271.1630216,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006241565,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634271.2740643,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002836682,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634271.8968515,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.620551787,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634272.0578604,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.892950828,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634274.8482091,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005526606,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634275.3273418,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.476421086,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634276.1010985,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007831851,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634276.211585,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003315641,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634276.8150616,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.59979674,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634277.5217369,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.418073423,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634279.9549515,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004326664,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634280.4067917,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.449619101,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634281.2453425,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005788089,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634281.3607907,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003080617,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634281.7310247,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.367964978,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634282.2506177,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.002974192,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634285.0126982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004672753,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634285.5101185,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.495431005,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634286.2794607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.002941481,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634286.3948715,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.002936964,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634286.6096249,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.212711401,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634286.993485,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.711590503,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634290.0069025,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002959549,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634290.4789665,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.470171227,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634291.2635422,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004225134,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634291.3794065,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004598822,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634291.5930047,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.21139385,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634291.973728,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.707250642,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634294.9730732,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.008891081,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634295.973384,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.998291618,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634296.2226377,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005649564,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634296.339263,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006270033,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634296.8818543,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.53813826,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772634297.228206,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.00233123,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634299.9178557,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.00475169,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634300.501256,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.581287934,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634301.1626225,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003565223,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634301.277575,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003536267,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634301.6875024,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.407791556,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634301.9228818,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.758183624,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634304.8701677,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.012743423,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634305.335243,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.462561858,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634306.1137483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007469747,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634306.2161832,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005340567,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634306.3959482,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.176799328,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634306.720327,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.603958413,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634309.9702063,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005836087,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772634310.434675,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.462328391,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634311.2473598,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004958432,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634311.366051,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005571916,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634311.9461718,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.577944813,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634312.3518481,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.101938526,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634315.0137131,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004945869,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634315.592673,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.577007748,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634316.2862597,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.007163368,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634316.4009101,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005744627,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634316.5989487,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.194666488,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634316.9801893,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.691370443,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634320.0101008,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004349839,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634320.3070834,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.295031043,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634321.266604,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.004494092,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634321.381368,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.003922094,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634321.9353058,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.551785607,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634321.9807746,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.711614698,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634324.9774432,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003240647,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634325.4532692,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.473484069,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634326.2281508,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.008294321,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634326.3390825,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004578038,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634326.8625038,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.520719162,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634327.0189364,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.788281121,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634329.9136264,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005978703,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634330.6192653,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?0"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.703153408,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634331.1645873,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.003823751,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634331.2768476,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002553808,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634331.8566277,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.57765151,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634332.0119867,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.844766161,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634334.8591871,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003046889,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772634335.3173454,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.456321508,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634336.1048193,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00763565,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634336.2170033,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005892559,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634336.5730352,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.353298319,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634337.5013888,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.393760889,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634339.9646041,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006638103,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634340.8196797,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.852493627,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634341.2433784,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.005458207,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634341.3590765,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003462313,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634341.7552354,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.393538131,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634342.2466207,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.001092924,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634345.016164,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004711437,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634345.4127696,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.394431887,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634346.2736816,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.009168357,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634346.392573,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.007495805,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634346.821513,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.425017272,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634347.1306503,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.854258863,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634349.9965408,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005360606,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634350.7644987,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.765819914,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634351.2480788,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.001979994,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634351.3637958,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.001929342,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634351.694245,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.327991901,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634352.1275015,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.877291463,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634354.9569607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.007878901,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634355.6084106,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.649068953,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634356.2116308,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.009755066,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634356.3422844,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.025515085,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634356.9050012,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.690434044,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634357.624588,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.272182832,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634359.8971963,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005281997,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634360.3369844,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.437619948,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634361.1483464,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005924416,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634361.260055,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004903193,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772634361.4522896,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.190003002,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772634361.966764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.815997632,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634364.841435,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007754601,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634365.3570557,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.513764134,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634366.107671,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006784982,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634366.1926472,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002613261,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634366.704936,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.509927063,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634367.049215,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.939153008,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634369.9661055,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.007019157,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634370.3582704,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.390034668,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634371.2512803,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004614625,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634371.3700454,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003358708,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634371.9671984,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.594876238,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634372.3026102,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.048730248,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634375.0255952,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004513843,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634375.4121075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.384371511,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634376.2955902,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Accept":["*/*"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.008733977,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634376.4098232,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.005742895,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634377.0147338,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.602336806,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634377.4828832,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.184894001,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634380.026553,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004167114,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634380.3189692,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.290453612,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634381.2753444,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003704836,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634381.3883398,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.001754641,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634381.744165,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.353667259,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634382.2509866,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.973047329,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634384.9805102,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004937041,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634385.333715,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.351200332,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634386.2350645,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004997266,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772634386.3588908,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005491635,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634386.8019614,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.440466109,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634387.1419919,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.904885946,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634389.9261127,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005315138,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634390.3214958,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.393359071,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634391.1800423,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007976991,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634391.3186874,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.009030683,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634391.8153045,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.632710399,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634392.0241022,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.702594188,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634394.8644607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005405719,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634395.2696474,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"]}},"bytes_read":0,"user_id":"","duration":0.403224944,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634396.1139288,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.0071348,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634396.2280152,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003792439,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634396.7587605,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.527602192,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634397.4841285,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":1.367129404,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634399.9639506,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004145574,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634400.3765361,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.410366466,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634401.2531297,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00549778,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634401.36625,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003023426,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634401.7490325,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.380364468,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634402.538799,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.283303005,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634405.0214107,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006510339,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634405.46503,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.441815395,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634406.2918522,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.007602169,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634406.4108987,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.009566386,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634407.228994,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.934055969,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634407.4828806,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.067810543,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634410.0206573,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007347059,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634410.6097803,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.586183354,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634411.2726505,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004382973,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634411.3867974,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003462918,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634411.5159147,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.127341964,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634412.0430486,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.768147985,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634414.972973,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.00321115,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634415.4471214,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.472079581,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634416.2283702,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003356772,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634416.3411694,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003417894,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634416.897201,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.553738534,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634417.1976213,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Cache-Control":["no-cache"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.966946572,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634419.9191213,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005099612,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634420.3400154,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.419168611,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634421.1722283,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003997263,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634421.284093,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003940826,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634421.7063856,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.419862511,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634422.5087214,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.33414423,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634424.8635938,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.008487844,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634425.3612542,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.495211147,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634426.1253626,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.009674106,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634426.2254963,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005358271,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634426.7805362,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.552287176,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634427.207712,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.079400364,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634429.9347873,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005757332,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634430.437305,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.500072706,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634431.2255352,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005377328,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634431.3419197,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003887389,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634431.7804687,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.552952599,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634431.9456615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.601259488,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634435.003956,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004120951,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634435.3078547,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.301561426,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634436.277139,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.006046282,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634436.3888566,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002742368,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634436.5622325,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.170648599,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634437.6998212,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.419937878,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634440.01384,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.007849957,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634440.7691276,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.752845851,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634441.2698317,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005108454,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634441.38353,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003315557,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634441.723642,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.336994064,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634442.2138104,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.941820047,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634444.980675,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006527102,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634445.7305024,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.747164272,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634446.2302406,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004533868,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634446.3452508,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003811864,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634446.9238071,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.575945373,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634447.248779,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.015437299,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634449.9247894,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003773398,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634450.4964173,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.569850885,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634451.1747186,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003696394,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634451.2906535,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004232761,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634451.6271462,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.333590125,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634451.9077165,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.7299903,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634454.871678,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.007645462,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634455.3342674,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.460074141,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634456.1135278,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005694771,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634456.2278259,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.006956054,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634456.9585092,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.84211038,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634457.326863,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.096450435,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634461.2257254,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003998645,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634461.3419003,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.002653384,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634461.6833465,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.339283589,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634462.6175032,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.389428273,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634466.2786953,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006008892,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634466.3992476,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.007475228,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634466.994229,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.592371706,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634467.1307938,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.849157484,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634471.274453,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.008458522,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634471.387625,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006154227,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634471.877293,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.487702281,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634472.351746,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":1.07447909,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634476.2314417,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004583605,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634476.344828,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.005360315,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634477.4021413,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.167870743,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634477.4144526,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.066837445,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634481.1801844,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.008569615,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634481.2932744,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006370435,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634481.4476218,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.151959812,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634481.845261,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.662536094,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634484.8740125,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.010392726,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634485.4317768,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Windows\""]}},"bytes_read":0,"user_id":"","duration":0.554810637,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634486.121857,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.011639352,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634486.2258337,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004752873,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634486.8522596,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.621476734,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634487.6413853,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.516074635,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634491.2236235,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004663008,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634491.3385437,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00370754,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634491.843732,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.502057566,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634492.1226468,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.89599916,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634496.264442,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.005830955,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634496.3771923,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003541065,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634497.303425,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.92313829,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634497.6634212,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.395875817,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634501.2523823,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006711588,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634501.368373,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007113854,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634502.188361,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.817571131,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634502.509017,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.254561811,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634506.210949,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006359896,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634506.3277657,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.011422314,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634507.066977,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.735486687,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772634507.09968,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.886644153,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634511.1600246,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.010271638,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634511.2626379,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003424029,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634511.5359898,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.271262175,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634512.2367885,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.073906361,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634516.0890596,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.006406234,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634516.203072,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007302414,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634516.7571554,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.550327489,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634517.026532,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.935008238,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634521.231825,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004371759,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634521.3481808,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.002914969,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634521.6351733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.284777892,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634522.309771,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.074961417,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634526.288684,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.008630496,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634526.4017045,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004211768,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634527.142007,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.735752934,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634527.7841806,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.492581142,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634531.2752457,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005262305,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634531.3948767,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006939759,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634532.1673553,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.89013571,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634532.348059,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.950283068,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634536.239819,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005562682,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634536.3588395,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.012837184,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634537.3886874,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.026294928,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634537.522965,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.280542476,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634541.2084472,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.019563,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634541.3027484,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002420183,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634541.6343844,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.328775699,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634542.0798483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.868392171,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634544.877312,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.009167385,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634545.5523076,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.671708794,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634546.1210995,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004240984,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634546.2356274,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002964974,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634546.684603,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.446670523,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634547.208575,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.084858801,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634551.231318,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004300733,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634551.3484535,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.0026737,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634552.0382075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.686945465,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634552.2954276,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.061858849,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634556.2794049,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004690206,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634556.391627,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.0035434,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634556.9084353,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.514525782,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634557.5373857,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.255648625,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634561.2689412,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004197297,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634561.3797143,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003014502,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634561.629822,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.247562297,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634562.1411846,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.869901354,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634566.2423642,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.005784039,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634566.3438482,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.008131029,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634567.4164906,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.171833641,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634567.4922802,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":1.145123009,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634571.173989,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005238039,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634571.288063,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004401692,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634571.6285799,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.337632798,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634572.0064604,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.83011803,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634576.1088476,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.00358613,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634576.2235289,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002884595,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634576.6505783,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.42452512,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634577.075243,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.964221821,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772634581.2355273,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004323305,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634581.3520837,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002424355,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772634581.668639,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.314081924,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634582.3948705,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.156923545,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634586.2864602,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004235978,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634586.4017105,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.005404447,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634586.741288,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.337199988,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634587.6031806,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.3140275,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634591.2771795,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004301245,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634591.4194756,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002402228,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634591.8392096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.417542067,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634592.0727365,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.79328649,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634596.2418177,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005253783,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634596.3498607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002543459,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634596.9551277,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.603295981,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634597.2162783,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.972536578,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634601.1869054,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.005484213,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634601.2979982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005302057,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634601.893745,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.592793595,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634602.2104814,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.0212485,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634604.8733625,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00551741,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634605.5338483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.657758789,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634606.1258163,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004688176,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634606.248874,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006167378,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634606.8639495,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.612809723,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772634607.197869,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.06989661,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634611.2382944,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004761834,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634611.3570774,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005425094,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634611.7642095,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.404685195,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634612.561526,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.320678959,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634616.2918682,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005726061,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634616.4050064,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006545649,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634617.0253263,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.618144857,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634617.4693117,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.17511101,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634621.2845638,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.005852316,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634621.3958507,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005532819,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634621.8877156,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.489014464,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634622.03443,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.747760161,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634626.2462635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.006166626,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634626.3583236,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.006510547,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634627.1953695,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.834916721,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634627.271616,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.023466103,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634631.1906915,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.00575235,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634631.3006108,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002237252,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634631.8408926,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.537984148,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634632.4543757,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.261325622,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634636.123884,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004344797,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634636.2334728,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002749896,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634636.8213606,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.585208709,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634637.2328827,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.106621041,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772634641.2049067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.006283663,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634641.3198483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002185473,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634641.8185325,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.496432862,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634642.32764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.120695052,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634646.274405,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005091738,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634646.3856306,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.00269835,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634646.8010898,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.413272515,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634647.2418873,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.965174299,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634651.2750149,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005180385,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772634651.3846478,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00316303,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634651.7940533,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.406910559,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634652.0478313,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.770604351,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634656.2423909,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.00682162,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634656.3487034,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.001952282,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634656.8735034,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.522806798,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634657.549661,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.305395438,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634661.187887,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00451764,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634661.2977283,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.003138929,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634661.7015972,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.401679828,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634662.6085985,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.418246292,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634664.8848228,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.01004328,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634665.63178,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Windows\""]}},"bytes_read":0,"user_id":"","duration":0.743975908,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634666.1230297,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004766789,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634666.2353861,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.002488836,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634666.7521667,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.514622608,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634666.9967284,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.871327192,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634671.2040896,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004279384,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634671.3209522,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005974836,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634671.853,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.529874704,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634672.051886,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.845122005,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634676.276787,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004926674,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634676.3878126,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003257201,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634676.938977,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.54904612,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634677.509709,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.230597642,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634681.2795148,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005439795,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634681.3881137,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002892801,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634681.7943964,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.404174741,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634682.0533867,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.771642412,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634686.2459486,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005481809,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634686.357593,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006450468,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634686.668552,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.308866985,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634687.4886937,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.240556396,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634691.1902504,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004423977,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634691.3011436,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00424598,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634691.5969448,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.292964053,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634692.1567612,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.963923857,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634696.127919,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.005040258,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634696.2435918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006967101,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634696.989615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.743486709,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634697.0673728,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.936991634,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634701.2148867,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.009698442,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634701.3227496,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005059157,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634702.2855859,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.068016583,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634702.5008738,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.175319273,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634706.2660928,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006182534,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634706.3782947,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004299563,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634707.105431,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.724580432,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634707.1345174,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.865557828,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634711.2616808,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004724165,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634711.3741224,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.005516995,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634712.105091,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.728437894,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634712.7036245,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.439112863,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634716.225922,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007335178,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634716.3372993,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005586229,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634716.933771,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.593180655,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634717.2882383,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.059806583,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634721.1719296,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006153844,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634721.281727,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005165262,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634721.9097984,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.735621127,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634722.0504622,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.766495525,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634724.865019,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007561211,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634725.5246065,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.656567055,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634726.1068902,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004901249,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634726.2183194,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004658931,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634726.9042222,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.68326123,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634727.1204846,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.010548651,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634731.2078462,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.002497819,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634731.3281457,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003891357,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634732.0070827,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.675905435,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634732.4430408,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.23322977,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634736.277455,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003896688,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634736.3948247,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005723115,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634736.661122,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.264050776,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634737.328866,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.0489203,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634741.2852015,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.006111716,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634741.3949218,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004477587,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634741.730475,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.333418658,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634742.0108252,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.723439202,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634746.252158,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.010863343,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634746.3588355,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002846677,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634746.7460349,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.384883445,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634747.4006884,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.145622268,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634751.1948266,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006358264,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634751.3116527,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006779142,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634751.8630743,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.54863988,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634752.0974889,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.900527338,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634756.133122,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004780458,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634756.240983,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002101973,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634756.62786,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.384405069,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634757.0470912,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.911895868,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634761.2104182,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005975621,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634761.326272,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003391074,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634762.0630848,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.734554237,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634763.0603085,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.847710377,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634766.2880483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005299808,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634766.3957882,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004594835,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634767.0372107,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.638659158,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634767.1192887,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.828912903,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634771.2826886,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.0074253,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634771.3951614,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004018419,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634771.5590796,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.161820296,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634771.9123244,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.627254831,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634776.251723,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006773467,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634776.358827,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002886728,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634776.682448,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.321704418,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634777.160004,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.906336469,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634781.198688,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.00701158,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634781.3082988,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006130853,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772634781.620461,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.310189337,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634781.8937075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.692475546,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634784.890872,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.009787123,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634785.4416432,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.548164613,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634786.1292655,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003850615,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634786.2426565,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002781518,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634786.8894637,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.644610565,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634787.9257367,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.793991581,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634791.2141557,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004902247,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634791.3539171,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002744711,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634792.1680298,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.811791024,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634792.3911304,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.174228308,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634796.287053,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004920419,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634796.3994987,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.00407895,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634796.5807118,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.178948673,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634796.974108,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.68436949,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634801.286082,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00471754,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634801.399606,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002532244,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634801.9553957,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.553363504,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634802.2018661,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.913036989,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634806.2512016,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005247742,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634806.3643093,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003052691,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634807.0516715,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.684545176,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634807.624649,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.370792841,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634811.1914635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004272588,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634811.3043437,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.002528915,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634811.8008397,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.494486728,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634812.594119,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.400426046,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634816.1276288,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007835247,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634816.2384105,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003729026,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634816.864736,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.623425986,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772634817.5788486,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.448084598,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634821.2122426,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.005177466,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634821.3302066,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003748689,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634821.9312608,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.598574063,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634822.2729774,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.058302908,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634826.2831688,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004361342,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634826.3978481,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002773589,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634827.232331,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.94696575,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634827.4699736,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.070114962,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634831.2874448,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007289517,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634831.3999562,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.002965497,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634831.7007687,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.298470949,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634832.2702036,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.980530195,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634836.2540464,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007279741,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634836.3657043,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004164421,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634836.842393,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.473808041,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634837.8206065,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.563211289,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634841.1987667,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.0041972,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634841.315317,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005901542,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634841.9843042,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.66673228,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634842.1184995,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.916747539,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634844.90473,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.009476322,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634846.0999677,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.191959973,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634846.1380615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005693819,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634846.2506857,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.003111231,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634846.639925,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.386831942,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634847.1216056,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.981653559,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634851.2066927,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004027594,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634851.319368,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002048445,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634852.0836546,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.874828488,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634852.1477025,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.826163813,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634856.2682912,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003056549,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634856.385875,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003147424,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634857.3774498,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.106400896,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634857.446758,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.058332791,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634861.2888703,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007919654,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634861.4045901,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00819766,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634862.307819,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.900519687,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634862.8943903,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.602356816,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634866.2608569,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007513965,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634866.375387,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.006029222,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634866.552436,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.174149938,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634866.9485795,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.685368397,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634871.2090745,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004389269,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634871.322155,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.002834107,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634871.7332397,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.408832393,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634872.183907,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.972631317,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634876.1468337,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003886778,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634876.259756,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002378991,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634876.8137772,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.551805739,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634877.2764852,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.127284537,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634881.2090511,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004095513,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634881.3285635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006744258,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634881.9083095,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.577023052,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634881.9949455,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.783354972,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634886.2722614,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005108684,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772634886.3822172,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002424872,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634886.9860826,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.601738634,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634887.267031,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.992635496,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634891.2806878,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.005894627,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634891.3925588,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.002224051,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634891.7047687,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.3099505,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634892.089816,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.807015574,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772634896.2520819,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00657798,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634896.365281,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004494502,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634896.5431159,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.175505545,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634896.9426022,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.688449335,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772634901.199574,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003802385,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634901.3130713,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003330453,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634901.8201,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.50516391,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634902.092413,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.890403802,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634904.9049282,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.009761366,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634905.4679334,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.560737995,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634906.1392462,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004986478,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634906.2536652,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004649119,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634906.7950187,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.53904888,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634906.8901691,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.748595976,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634911.0744352,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004868117,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634911.3237157,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002417143,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634911.709187,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.383164522,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634911.9725506,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.757235288,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634916.2724278,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.00550946,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634916.391557,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.00744793,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634916.6157882,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.221586509,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634917.0130908,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.738277707,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634921.2828493,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003401729,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634921.397222,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.002206619,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634922.1037767,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.818208183,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634922.1964707,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.797330868,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634926.2547014,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004901833,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634926.3666728,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002103307,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634927.0088673,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.640100837,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634927.2719848,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.015193535,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634931.2008188,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005146929,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634931.3127615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002330379,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634931.6821723,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.367512071,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772634931.9471483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.744123472,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634936.1332119,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004339084,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634936.2473192,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004229523,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634936.7912734,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.541540307,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634936.8728607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.737129397,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634941.068092,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004465158,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634941.182942,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004505849,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634941.5396862,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.2087063,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634941.9030027,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.686884632,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634946.2726538,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006231957,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634946.38661,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002899681,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634947.3736951,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.098960298,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634947.7175872,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.329051245,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634951.2876906,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.008825073,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772634951.3987699,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.003881371,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634952.0743902,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.672542261,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634952.4696214,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.178700582,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634956.2564473,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006370615,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634956.3722785,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007533456,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634957.2018363,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.943294321,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634957.7064474,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":1.332198573,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634961.2048497,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00481067,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634961.3215773,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.006310146,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634962.0670316,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.859888104,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634962.6325338,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.308559698,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634964.9011517,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.007772761,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634965.641039,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.737511069,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634966.2855144,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004665562,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634966.2888355,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.008300038,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634966.8420181,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.554012711,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634967.8967106,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.605737682,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634971.0804245,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.006435276,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634971.1931636,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005058212,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634971.412826,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.217146295,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634971.9321594,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.711838954,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634976.2738059,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.00747226,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634976.3879485,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004295386,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634976.5882173,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.197669769,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634977.0017061,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.72581583,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634981.2812445,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004127864,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772634981.3969219,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003716348,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772634982.1008239,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.701201459,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772634982.4157002,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.131818094,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772634986.254948,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007258479,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634986.3689353,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.005726505,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634987.5808227,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":1.209569842,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772634987.7376373,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.480381078,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634991.2043912,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005900581,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634991.3307,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004086552,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772634992.410216,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.07720154,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634992.708202,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.501789259,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772634996.1443727,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007306891,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772634996.2540047,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.003061816,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772634997.0766547,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.820795452,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772634997.1974256,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.050345392,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635001.0746002,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004577538,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635001.188789,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.00392782,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635002.0378258,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.702985597,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635002.8592224,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.638644464,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635006.2823558,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005440754,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635006.40028,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005844209,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635007.291724,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.006924169,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635007.3057415,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.903036929,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635011.297035,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004385899,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635011.4110827,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00283253,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635011.6962814,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.283222875,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635012.1735966,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.873576795,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635016.2703345,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004314715,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635016.3861,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004761339,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635016.7825677,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.393828548,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635017.2962468,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.023287166,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635021.2192416,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.004681449,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635021.33278,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003165084,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635021.723513,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.388629924,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635021.9931014,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.771601346,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635024.9261096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.009809579,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635025.6518219,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.723073263,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635026.1597064,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.006945367,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635026.2709334,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003516997,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635026.9488215,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.67540064,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635027.5828624,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.420269395,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635031.0911088,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005125723,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635031.203678,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002912779,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635031.9132364,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.578026819,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635032.0541768,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.831430553,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635036.2766855,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003683546,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635036.393754,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003909781,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635036.9718041,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.575705591,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635037.033629,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.754534164,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635041.2900991,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00407379,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635041.405317,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.003676107,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635041.557272,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.150008836,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635041.913119,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.620030559,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635046.2588518,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003347015,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635046.3744075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003018418,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635047.2937639,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.032033418,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635047.6043494,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.226981315,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635051.20853,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005090195,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635051.322084,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004947649,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635051.6729183,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.348406415,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635052.3920355,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.181450413,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635056.1406,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004410813,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635056.2546973,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00404131,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635056.8623428,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.605571753,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635057.14583,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.002761071,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635061.0755467,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.005746996,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635061.1861434,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002177079,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635061.7053568,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.517261928,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635062.1036608,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.878103018,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635066.2543068,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004697608,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635066.3712075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004587632,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635066.685612,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.311987354,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635066.9304416,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.673828243,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635071.2771702,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004258309,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635071.393084,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003732588,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635072.3048365,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.024981304,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635072.3139455,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.917664398,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635076.253946,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004562165,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635076.367816,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002731302,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635076.7789247,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.408383791,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635077.6105707,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.354286948,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635081.2090538,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006270413,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635081.3224173,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004357094,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635081.7154303,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.390827622,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635081.8574274,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.646375049,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635084.9159389,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.008621804,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635085.474811,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"]}},"bytes_read":0,"user_id":"","duration":0.556801328,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635086.15472,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.010103301,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635086.2672603,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00830169,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635086.8932817,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.623400255,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635087.216032,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.058414515,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635091.083082,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004117375,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635091.2051206,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.009398439,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635091.452915,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Authorization":[],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.244792659,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635091.841654,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.755866549,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635096.2559185,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.00413138,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635096.3744822,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.005785223,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635096.579762,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.202166872,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635096.9534698,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.69557463,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635101.277675,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004062042,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635101.3930135,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.002740616,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772635101.6872842,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.292103038,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635102.1387599,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.858794297,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635106.2536032,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003866351,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635106.3678832,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002559172,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635106.9547756,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.584594664,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635107.4804351,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.224329102,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635111.2073777,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004104886,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635111.3224814,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004457002,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635112.1169505,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.907048268,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635112.2609096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.936046811,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635116.150497,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.0066943,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635116.2603126,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.001780668,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635117.0860174,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.933684329,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635117.414309,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.151837665,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635121.084574,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.006057649,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635121.1967142,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003313046,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635121.5989845,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.399455149,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635122.0649087,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.977833956,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635126.2566633,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003659565,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635126.375562,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005663425,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635127.002636,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.624801656,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635127.6039903,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.344902514,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635131.2808475,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005405512,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635131.3993719,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007033591,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635132.194269,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.909830948,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635132.5197077,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.117854306,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635136.2608953,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.007289378,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635136.3714948,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002641371,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635136.8349738,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.460802674,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635137.1155317,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.852542481,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635141.2100286,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004649571,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635141.3242962,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004181592,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635141.5741096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.247573534,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635141.938704,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.726533628,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635144.9170434,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.010561459,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635145.6458728,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.726458141,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635146.3516564,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005340304,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635146.3527188,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006355866,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635146.6719365,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.317155481,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635147.0617497,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.707332238,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635151.085359,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005690085,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635151.1974628,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00268762,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635152.0991921,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.899549777,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635152.5325966,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"Authorization":[],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.301880698,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635156.264691,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004506714,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635156.3809798,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.002650219,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635157.2284358,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.845268743,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635157.295296,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.028154194,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635161.2935636,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004321189,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635161.4110546,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005369293,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635161.7923703,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.378473813,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635161.9715056,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.675296656,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635166.27693,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00530264,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635166.386479,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.002477568,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635166.7654827,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.375980367,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635167.1042292,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.825024198,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635171.25233,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004195523,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635171.3376422,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003026185,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635172.0467546,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.706285421,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635172.1900897,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.935196483,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635176.161066,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005422339,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635176.2761378,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004620158,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635176.7629514,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.484107764,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635177.4131413,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.249510432,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635181.0950987,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006242145,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635181.2054906,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003219191,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635181.7075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.499979632,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635182.0220091,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.925052388,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635186.2664456,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006680361,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635186.3872795,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.009697547,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635187.1590946,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.768850201,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635187.2565954,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.986787324,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635191.2864704,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003568743,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635191.409095,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003184326,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635191.724836,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.31326124,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635192.2054331,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.916987767,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635196.2662675,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.007371082,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635196.3786485,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004256466,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635197.4688199,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.199531626,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635197.8246255,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.442909629,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635201.2152731,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003870994,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635201.3326814,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00642583,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635202.1498075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.814877051,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635202.3430047,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.124883348,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635204.9240847,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.009046788,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635205.592765,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.666822611,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635206.1566017,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004266739,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635206.2713022,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.002876831,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635206.6253064,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.351292364,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635207.3157754,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.156563556,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635211.0906434,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004056857,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635211.2030873,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002274633,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635211.6146228,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.409173241,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635211.8485744,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.755811438,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635216.265713,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004258587,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635216.3819137,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003014834,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635217.1531827,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.885212087,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635217.2534676,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.869170971,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635221.288535,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003726876,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635221.40487,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003859776,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635221.8893259,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.482068964,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635222.1523905,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.861513348,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635226.2661464,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004923777,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635226.3812265,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004455045,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635226.5692472,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.185515604,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635227.0039244,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.735338408,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635231.220998,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006526827,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635231.335689,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.006104074,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635232.0676625,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.729572123,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635232.1209269,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.897917566,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635236.1592197,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.003632495,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635236.2734733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002811538,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635237.4764464,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.200751008,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635237.7008295,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.538845214,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635241.0944917,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.00443026,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635241.2092874,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004588321,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635241.948076,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.851325034,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635242.0564282,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.844712542,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635246.2699556,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006735983,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635246.3843896,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003717555,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635246.8905988,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.503187485,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635248.0243328,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.752294297,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635251.2892296,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003610466,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635251.4087894,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00691511,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635251.6205435,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.209159343,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635252.4002445,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.108221659,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635256.2719963,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.009119009,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635256.3824883,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004385327,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635257.2455997,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.860038924,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635257.4900084,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.215120334,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635261.2188005,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004173455,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635261.3339524,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004184943,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635261.7202022,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.38369833,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635262.4671779,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.245848995,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635264.9199834,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.009649724,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635265.5251439,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.602739913,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635266.1600637,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00581461,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635266.2717073,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002621889,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635266.8500397,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.576305215,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635267.165617,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.003179652,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635271.0929916,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004256811,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635271.2088246,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00458592,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635271.6448216,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.43358163,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635272.165626,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Authorization":[],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.070001511,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635276.243754,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007769201,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635276.3616672,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007045598,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635276.8494124,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.485547321,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635277.5585792,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.312472925,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635281.2783816,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005525509,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635281.3921044,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.00292874,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635281.8879213,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.493444813,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635282.1612053,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.880170362,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635286.2633235,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.006491781,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635286.37785,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005558015,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635287.052715,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.672575147,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635287.1266096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.861149495,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635291.217119,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004474231,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635291.329472,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003039333,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635291.707053,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.375147327,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635291.9859104,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.766192221,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635296.1604738,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.005762199,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635296.2744632,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006162076,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635297.0238948,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.74697623,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635297.0838213,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.921441018,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635301.093323,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003888956,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635301.2071478,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002641244,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635301.6012235,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.392148039,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635302.246093,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.149873526,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635306.2449267,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.0058283,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635306.3614206,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004815145,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635306.6820962,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.318002402,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635307.0639062,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.816855301,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635311.2820404,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006009223,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635311.395665,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004036299,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635311.68622,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.28785384,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635311.9622252,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.677725661,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635316.2645764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004117201,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635316.3780713,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002286408,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635316.7058377,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.325508912,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635317.122597,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.85539706,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635321.2208166,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004342001,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635321.3350616,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003282461,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635322.3898246,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.166280694,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635322.6896276,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.35233829,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635324.9317675,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.009532734,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635326.1137028,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.179881566,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635326.3416085,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003103329,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635326.343577,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005064311,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635326.7475529,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.401528068,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635327.2415888,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.897286462,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635331.0993779,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007741075,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635331.210307,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004518136,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635331.7924216,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.579456343,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635332.7039194,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.602098242,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635336.2442017,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004396401,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635336.3627098,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004515071,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635336.9539106,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.588925646,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635336.9780178,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.731664721,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635341.2845788,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.007420608,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635341.3973403,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004353516,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635342.0298767,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.630100454,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635342.0695298,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.782360647,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635346.2659266,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005581045,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635346.3793192,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003365048,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635346.7581835,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.375862593,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635347.0723276,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.803542401,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635351.2208743,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004665828,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635351.3353338,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003222749,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635351.6388836,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.300834083,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635352.4532783,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.229827668,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635356.1643252,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005243375,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635356.2776487,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.003480938,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635356.7698762,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.490091922,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635356.8828712,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.715886923,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635361.0978076,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003597678,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635361.213038,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003537188,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635361.8524141,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.637188513,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635362.0998542,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.999563631,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635366.2495022,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.002475648,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635366.3672676,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003082393,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635366.6987135,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.329596148,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635367.3318377,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.079986576,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635371.321077,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.0087155,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635371.4094455,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.005741293,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635371.6627805,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.250389546,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635372.0259223,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.701762065,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635376.277339,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004214058,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635376.3913264,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002876939,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635376.7257757,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.332516568,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635377.3373659,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.057553416,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635381.2341604,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004039837,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635381.3497107,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004966678,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635381.6627228,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.310903141,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635381.9791234,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.742498269,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635384.9372895,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007040281,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635385.9133914,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.972837442,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635386.1780305,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007016322,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635386.2890635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003209825,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635386.6621208,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.371276617,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635387.346447,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.165893309,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635391.1123977,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006125142,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635391.2330065,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003124799,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635391.8436153,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.607900852,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635392.116659,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.002413745,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635396.2532868,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.00792227,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635396.3988245,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00268046,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635396.8692913,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.46845047,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635397.334539,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.078438566,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635401.2879102,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004955433,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635401.4034734,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005756039,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635401.6250675,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.219555289,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635402.28552,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.995566032,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635406.27017,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005141867,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635406.384301,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006110672,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635406.776204,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.389637084,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635407.071842,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.799018846,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635411.2254233,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004677915,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635411.3383741,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.002528845,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635412.040863,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.700359568,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635412.201443,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.973533916,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635416.166589,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004418469,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635416.279993,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.00227171,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635416.6535509,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.371239705,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635417.1888833,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.020177093,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635421.1016395,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004680512,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635421.213827,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002165546,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772635421.5122952,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.296472016,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635421.912731,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.808293367,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635426.2532163,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005659038,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635426.3681796,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.0024933,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635427.001355,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.630951768,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635427.6786056,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.422808115,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635431.2869556,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004814193,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635431.4042625,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004839839,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635431.8467462,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.439802019,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635432.0146918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.723648602,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635436.2707636,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004206162,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635436.3847017,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002970856,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635436.7463343,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.359402268,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635437.2424326,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.968980762,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635441.2265742,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003620926,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635441.341393,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003853732,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635441.771913,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.427746473,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635441.9044354,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.675196181,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635444.9274075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007706288,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635445.5179648,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.587515889,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635446.1669712,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003734548,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635446.2810826,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.002594382,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635446.6577415,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.374546061,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635446.9729888,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.80365556,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635451.1050682,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00801545,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635451.2148805,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003342053,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635451.9041495,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.685960011,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635452.716645,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.608511983,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635456.257605,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007429703,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635456.371598,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002905645,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635457.0698736,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.69510545,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635457.9146082,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.654825043,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635461.2976425,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.009280945,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635461.4100065,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.008065942,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635462.1226568,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.710028859,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635462.6316895,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.331388131,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635466.2757893,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004624411,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635466.3915262,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004750725,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635466.7540534,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.359385371,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635467.0980365,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.819836082,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635471.233296,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.00515089,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635471.3470135,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003607375,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635471.654072,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.304936783,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635472.106898,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.870425941,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635476.179522,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.007333521,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635476.294671,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.008124439,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635476.9508731,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.653477674,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635477.649223,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":1.466971099,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635481.111476,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.00580194,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635481.2234483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003099747,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635481.881089,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.655682552,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635482.0821517,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.968685739,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635486.2240791,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004288498,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635486.3393254,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.001749106,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635486.6134205,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.271980049,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772635487.1000738,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.873199417,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635491.278345,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003196758,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635491.3923979,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002037725,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635491.9408677,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.546352189,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635492.4064093,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.1246238,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635496.2696717,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004268913,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635496.3845158,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003591611,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635496.9314551,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.659769804,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635497.0406506,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.654126512,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635501.2280843,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.002945227,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635501.343676,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.002625003,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635501.871955,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.525978147,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635502.7634938,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.53319812,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635504.9287791,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.010815912,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635505.8830807,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.951971022,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635506.1744547,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005283394,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635506.2876525,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003818075,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635506.596299,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.306726019,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635507.0829186,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.906102893,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635511.1092117,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004293902,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635511.2217522,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.0020098,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635511.7245889,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.5007997,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635511.9847903,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.872797804,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635516.230088,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007978891,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635516.3437738,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003474408,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635516.7957551,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.448139904,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635517.4848719,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.251311372,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635521.2778647,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003659152,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635521.393703,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002708067,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635521.6635528,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.267401678,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635522.3067837,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":1.026330164,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635526.270483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.003656276,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635526.3869894,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.00481288,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635526.7782242,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.388518382,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635527.056268,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.783594758,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635531.2322166,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005029513,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635531.3483202,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.006397759,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635531.6339529,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.28324351,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635532.0006142,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.765738856,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772635536.1749158,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004193212,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635536.2888362,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.002370744,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635536.9799469,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.802363397,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635537.1300566,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.839178552,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635541.1128345,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.0056304,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635541.2259789,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005737302,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635541.9076104,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.679222588,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635541.998108,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.882799598,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635546.2314992,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.008225873,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635546.3459482,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004398401,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635546.885895,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.537412467,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635547.2849228,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.05049703,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635551.2803583,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004451731,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635551.396605,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003665258,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635552.2189562,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.819649561,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635552.586198,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.302718008,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635556.271546,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003023505,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635556.3864934,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002815637,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635557.3230367,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.049091575,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635557.6528015,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.263894949,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635561.2343962,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005859995,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635561.3470662,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003969617,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635561.8104692,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.46056617,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635561.8970196,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.660511857,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635564.9394474,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.00913976,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635565.499872,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.557507105,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635566.1767368,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005532901,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635566.2910416,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004523997,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635566.8262477,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.532142768,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635567.6327562,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.452843553,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635571.1629994,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.036607567,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635571.2287433,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.008434533,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635571.775984,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.543910726,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635572.4166682,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.234776861,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635576.232727,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.005932489,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635576.3483484,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004884495,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635576.9771883,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.626010003,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635577.6698422,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.434500613,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635581.2838728,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003971156,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635581.3989685,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00268942,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635582.1543903,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.753163934,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635582.254753,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.968262173,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635586.276376,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004564731,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635586.3912485,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004697309,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635586.82881,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.434584426,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635586.996532,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.717693316,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635591.237703,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.007646272,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635591.352972,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.006013463,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635591.9230638,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.567396205,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635592.4388871,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.19659816,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635596.177507,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003820343,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635596.306216,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.001932569,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635596.5445685,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.236477129,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635597.0006156,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.820189852,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635601.113718,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004856156,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635601.2271001,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003451334,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635601.807795,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.578165031,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635602.3256059,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.209321388,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635606.233118,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.00627123,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635606.3537624,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006174188,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635606.9392574,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.58285371,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635607.082582,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.846551337,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635611.2937691,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.007900728,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635611.4075837,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004285571,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635612.0155938,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.605380623,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635612.358718,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.062192267,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635616.283264,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004320185,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635616.396849,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002639674,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635617.122246,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.723211944,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635617.416995,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":1.131277581,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635621.2493372,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.00901441,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635621.3553452,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.002951114,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635621.6503534,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.293015129,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772635622.1816716,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.929402405,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635624.9423537,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.009161716,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635625.552216,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.607337838,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635626.1773136,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004032746,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635626.2911308,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.002996242,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635627.5612013,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.38145983,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635627.5747788,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.281132999,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635631.1094298,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004446428,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635631.2228448,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003287229,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635631.7928987,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.567883147,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635631.929091,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.817515977,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635636.235901,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004598739,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635636.3511655,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003616974,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635637.1946893,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.956100691,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635637.4187236,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.065323227,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635641.2884753,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003980438,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635641.404982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00400401,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635642.001697,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.710472782,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635642.086022,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.678199875,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635646.2790272,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00386708,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635646.3948636,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002651556,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635646.9681022,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.570550559,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635647.5058417,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.225032546,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635651.243717,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006543634,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635651.3543928,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Pragma":["no-cache"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.00274076,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635651.8621328,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.505607333,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635652.9522467,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.706632853,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635656.1876242,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005719386,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635656.3004491,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003340747,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635656.920102,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.616869666,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635657.2026002,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.012703167,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635661.1227684,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004206935,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635661.2358804,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003258258,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635661.6837335,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.445676641,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635661.95575,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.83032192,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635666.1989064,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.00502263,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635666.315949,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002090967,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635666.8480887,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.530116491,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635667.5987113,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.397629711,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635671.2578483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.005259492,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635671.3738744,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004845518,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635671.8506978,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.474123167,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635671.9630713,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.703046426,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635676.2509565,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004929808,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635676.3692923,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006202272,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635676.9052567,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.533608093,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635677.6915653,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.438257122,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635681.2135315,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005296353,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772635681.3270502,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.002711996,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635681.9714148,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.642228832,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635682.225965,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.010239958,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635684.9337556,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00749086,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635686.1589346,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.222547597,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635686.1734061,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007547991,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635686.2769725,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.008290252,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635687.0131617,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.733720579,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635687.066702,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.890851223,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635691.0937326,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004614986,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635691.2075493,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003507319,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635691.8250515,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.728806018,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635692.107044,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.897627648,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635696.2030733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004179333,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635696.3211093,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005558582,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635696.8767912,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.553616233,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635696.9948502,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.789016145,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635701.2746713,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004661501,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635701.390891,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003756259,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635701.8541884,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.461238463,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635702.5239666,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.246615317,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635706.275135,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003908285,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635706.3891733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002256806,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635706.7732503,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.381812502,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635707.3905287,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Authorization":[],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.112994144,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635711.238102,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003261367,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635711.3535094,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002573754,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635711.7565322,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.400835161,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635712.113729,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.873451727,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635716.1880999,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.006701886,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635716.3030982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005901224,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635716.5359573,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.230296823,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635716.9660876,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.775987559,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635721.1248937,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005300761,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635721.2366502,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002758427,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635721.7973588,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.558445808,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635721.9616654,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.834587863,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635726.2049072,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004724976,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635726.3242586,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004528613,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635726.711298,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.384464592,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635727.0443766,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.836481193,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635731.2883642,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.013029694,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635731.3968685,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004728249,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635732.089492,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.689732882,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635732.5010324,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.209676127,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635736.2816935,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003984311,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635736.3980186,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005215345,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635736.7742808,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.373868336,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635736.9737206,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.689488047,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635741.2463887,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004450183,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635741.3572242,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.002048112,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635741.9340487,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.574592768,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635742.580329,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.331523701,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635744.9544663,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.009930141,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635745.4456148,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.488124731,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635746.1862059,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002299557,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635746.300576,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.001578166,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635746.6303399,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.327468382,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635746.8783002,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.689823874,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635751.1246212,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006139046,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635751.235773,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00428478,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635751.734124,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.495943824,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635752.0210173,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.893957423,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635756.2073257,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.005406542,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635756.3222833,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004177541,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635756.6492615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.324422757,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635756.935074,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.725342413,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635761.2802181,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.007301835,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635761.3896282,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.003138329,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635761.8694978,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.477872595,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635762.2189984,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.936479824,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635766.280103,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005820487,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635766.3915794,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003120593,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635767.1989348,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.805184574,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635767.3913665,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":1.109026145,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635771.254089,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003745087,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635771.359779,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004768132,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635771.7569108,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.394252814,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635771.938728,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.682535939,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635776.1926053,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.006378338,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635776.3032966,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002191181,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635776.6923015,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.387031345,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635777.1765664,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.982208812,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635781.1291938,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005335062,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635781.2397308,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002568388,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635781.5291965,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.286863708,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635782.1958485,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.064608197,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635786.2072334,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004982568,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635786.3246531,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.002432566,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635786.7441733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.416575603,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635787.016222,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.806493986,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635791.2767913,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005525404,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635791.3903413,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.001838393,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635791.8503718,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.458142392,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635792.8482533,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.569202899,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635796.279144,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004416904,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635796.4137204,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002377983,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635797.019279,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.60342783,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635797.267342,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.985923887,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635801.2465699,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005324309,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635801.3595116,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003700197,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635801.801298,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.438410294,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635802.7408903,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.492297267,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635804.9603932,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.012811775,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635805.654845,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.691249084,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635806.1933742,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006700278,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635806.3062313,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004626668,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635806.7559452,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.446904405,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635807.4103236,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.214806539,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635811.129071,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005101003,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635811.2410827,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002833941,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635811.8892736,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.645721179,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635812.102586,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.971528185,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635816.2158048,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.008923409,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635816.3253732,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004073718,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635816.958213,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.630095404,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635817.8907263,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.672609943,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635821.3335912,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.045399353,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635821.4084895,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006146545,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635822.035572,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.624901038,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635822.1931117,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.855141594,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635826.2817187,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.005224833,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635826.3981245,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004865593,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635827.1482918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.863996835,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635827.6558237,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.254913962,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635831.2524364,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.009425557,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635831.3641043,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005817054,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635832.0540242,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.687511372,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635832.0923204,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.837368753,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635836.1926227,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00456369,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635836.3031723,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004168117,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635837.2006807,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.894777059,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635837.2707691,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.075840698,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635841.1329222,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.008176648,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635841.2444277,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005290977,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635841.6496713,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.402902954,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635842.2540996,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.11832373,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635846.2180493,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.007331337,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635846.329743,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004278262,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635846.70523,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.372382243,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635847.322496,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.101481112,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635851.2913675,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00645172,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635851.4060528,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004970751,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635851.6745942,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.266009811,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635852.055525,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.761996434,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635856.2918897,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004102938,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635856.4079745,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.005027643,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635857.1343837,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.839849581,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635857.5512445,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.140430526,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635861.2561758,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004457715,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635861.3697853,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.007268387,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635862.3020363,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.043169153,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635862.935778,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.563972425,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635864.962001,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.009984452,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635865.6952088,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.731237237,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635866.2020984,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.004177066,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635866.3268278,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006648186,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635867.0000703,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.670708192,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635867.6575077,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.453216633,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635871.127586,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003986357,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635871.24271,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.002491971,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635871.8344383,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.588605795,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635871.9079084,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.777790565,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635876.2104013,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006663327,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635876.3301523,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.012752273,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635876.9830067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.64894138,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635877.8088999,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.596037268,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635881.2694237,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.006812571,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635881.3858867,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00572289,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635881.8804467,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.491880888,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635882.0358825,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.764326171,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635886.279885,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.00471629,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635886.3939095,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002902398,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635886.6678667,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.271799783,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635887.1187003,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.836258275,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635891.2502558,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004368686,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635891.3657124,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007918022,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635892.1676445,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.798984363,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635892.7243795,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.471395628,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635896.1992483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003662025,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635896.3095753,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002976828,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772635897.0355148,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.723738689,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635897.320982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.119233484,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635901.1399322,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005932525,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635901.2513905,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.005681451,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635901.4353075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.18066323,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635901.8083565,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.666188949,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635906.2171664,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.006319797,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635906.322169,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003784203,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635906.7403812,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.415389823,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635907.5332093,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.313396505,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635911.2676547,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005216147,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635911.3836775,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003122135,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635911.6652696,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.279377138,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635912.0909474,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.820306045,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635916.2783926,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004238743,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635916.39511,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005015512,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635917.1528473,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.754154435,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635917.3888304,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.10778715,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635921.2485378,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003992065,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635921.3586276,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003083711,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635921.6219141,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.261100035,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772635922.154719,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.903779318,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635924.9676359,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.012586501,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635926.1977396,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.003862554,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635926.3027067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.332509788,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635926.3086257,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.00310431,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635927.0769377,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.765661732,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635928.3508582,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":2.150521389,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635931.1367247,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004272855,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635931.245384,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.002383942,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635932.0985866,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.850655186,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635932.1272848,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.987918337,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635936.0687108,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.002961824,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635936.323197,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002674894,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635936.8794484,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Authorization":[],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.553801418,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635937.0143318,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.798501174,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635941.2728906,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.007173709,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635941.3816333,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.002394298,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635942.3794127,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.104496046,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635942.4548628,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.071396031,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635946.28515,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.007741801,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635946.3923995,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.003294629,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635947.1949055,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.8004724,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635947.3018613,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.013764097,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635951.2545764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006559552,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635951.3626227,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.003170967,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635951.8332021,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.467766843,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635952.2302456,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.973280612,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635956.2023795,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006554915,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635956.3095064,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002801801,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635956.567809,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.256112544,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635957.090723,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.88634574,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635961.1442943,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.010319173,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772635961.2468958,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.002504234,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635961.5982776,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.349140517,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635962.2072983,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.060695156,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635966.0706546,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003999256,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635966.1792629,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.001934097,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635966.848028,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.521395533,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635967.6057112,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.38724715,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635971.28982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006732644,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635971.3996885,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007788676,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772635972.081487,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.678662578,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772635972.1865761,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.894530499,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772635976.3020864,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004499892,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635976.4123607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003004116,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635976.891488,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.476947843,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635977.1091278,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.804525459,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635981.2747734,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005652675,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635981.3832796,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002815272,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635981.9895635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.604281593,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772635982.2154176,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.938046543,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635984.9736218,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.012689635,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635985.584296,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.608339135,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772635986.2324183,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005241845,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635986.3397875,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.007536654,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772635986.8149543,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.472469637,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772635987.3110907,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.076226962,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635991.1492965,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003868236,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635991.2637458,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005448188,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772635991.9236386,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.657620833,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772635992.6000056,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.448703827,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772635996.0830872,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004589043,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635996.2143888,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006030324,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772635996.8450081,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.493098717,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772635997.115748,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.895293064,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636001.2765293,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006104729,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636001.3877044,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.003983699,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636002.3817651,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.103140411,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636002.5661173,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.175915426,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636006.284393,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004317868,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636006.3967137,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003003638,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636006.8463738,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.447647574,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636007.2971036,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.010445513,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636011.2570152,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.00428192,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636011.3662663,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.00295674,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636012.1499298,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.8907355,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636012.201365,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.832699152,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636016.2073355,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.009086986,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636016.3161147,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003177375,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636016.6324887,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.314076316,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636017.5390146,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.329488932,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636021.1535306,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.012066568,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636021.2560394,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004072994,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636021.6797905,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.420922817,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636022.2088056,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.052477227,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636026.0776544,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004342017,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636026.186679,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003027705,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636026.4582038,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.269397861,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636026.9681787,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.745790451,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636031.276713,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003965325,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636031.3873153,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.001927138,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636031.8001962,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.410564601,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636032.0540426,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.77466074,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636036.2871552,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004017521,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772636036.4006927,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005162828,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636036.8127575,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.409868374,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636036.976717,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.686764972,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636041.2538235,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004008559,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636041.368408,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003667406,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636041.738501,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.367900855,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636042.501936,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.246069972,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636044.9651043,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007257634,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636045.7215586,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""]}},"bytes_read":0,"user_id":"","duration":0.75404669,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636046.2068634,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004974241,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636046.3174849,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003435383,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636046.9351351,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.614518844,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636047.51853,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.309290248,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636051.1413944,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004211166,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636051.2554855,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003213779,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636051.5970342,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.338529787,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636052.3012433,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.15788255,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636056.0766835,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003619263,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636056.1867485,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.002445348,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636056.8869352,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.664274195,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636057.5271046,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.194220394,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636061.273044,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.002898308,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636061.3913612,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003223109,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636061.5747998,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.180809683,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636061.946437,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.67146123,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636066.2851937,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004442476,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636066.400493,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003485473,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636067.02257,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.619157917,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636067.6768022,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.38901072,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636071.254945,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004469244,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636071.373915,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.007446394,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636071.5869558,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.210464168,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636071.9742568,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.716769731,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636076.205521,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.006639273,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636076.3171413,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003379751,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636076.9507735,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.63085032,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636077.5828333,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.374752269,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636081.1408136,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00431462,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636081.2551782,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00352386,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636081.7064018,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.448243156,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636082.6652858,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.521785034,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636086.0750334,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.00515529,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636086.188907,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004915712,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636086.6971004,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.504560176,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636087.550451,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.325460628,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636091.2554374,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004934696,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636091.3694923,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.002082314,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636091.8508587,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.479405764,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636092.6416025,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.384101863,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636096.2815127,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005290841,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636096.3972948,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004413672,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636096.591109,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.191367004,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636096.991508,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.707883228,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636101.2570555,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004635878,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636101.3724542,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004257142,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636101.8643112,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.489036872,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636102.1393535,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.879899028,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636104.9725068,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.009782532,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636105.7551095,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.780777724,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636106.2217894,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.010045336,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636106.3222415,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003071307,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636106.7841787,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.459322173,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636107.8555918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.631448569,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636111.147947,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006662937,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636111.257904,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.002151086,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636112.0786679,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.928854692,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636112.2122774,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.952298657,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636116.078398,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004206855,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636116.1932743,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004531094,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636116.7797647,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.583964947,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636117.3782785,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.153261508,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636121.243864,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005653804,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636121.3583808,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002656755,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636121.8327458,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.471955614,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636122.143066,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.896996739,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636126.2544186,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003124414,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636126.3692029,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002520242,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636126.8336256,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.462457993,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636127.0433607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.786205025,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636131.2274723,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005210407,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636131.340631,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003272164,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636131.8545213,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.511878176,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636132.5321817,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.302599784,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636136.1772635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.005807599,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636136.2921057,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004570798,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636136.8021622,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.507417182,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636137.365215,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.185517294,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636141.1167247,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006982952,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636141.2288797,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003774295,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636141.8176806,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.585932007,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636142.120383,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.001300788,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636146.047093,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004772604,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636146.1604116,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003256328,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636146.6461968,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.483733783,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636147.5620697,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.334050509,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636151.2588627,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004006503,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636151.37495,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002844002,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636151.6349292,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.25805678,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636152.0042799,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.742884653,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636156.284171,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005751969,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636156.398849,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003727387,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636156.9421892,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.540425827,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636157.5795615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.29320298,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636161.2607546,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.005043631,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636161.3752046,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004705808,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636161.864936,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.487388966,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636161.9828372,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.719630166,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636164.967259,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007978908,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636165.6028268,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.633203847,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636166.2112975,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.003994805,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636166.3250036,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002403534,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636166.7573888,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.430029624,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636167.0181813,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.804392558,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636171.157582,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004198176,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636171.2651446,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002476877,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636171.790682,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.523453753,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636171.9691734,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.809645683,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636176.1165814,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.006395232,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636176.2009008,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004787225,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636176.6773372,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.474058464,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636176.7930384,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.674574203,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636181.262885,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.006607586,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636181.3761349,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00247503,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636181.8493524,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.471175884,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636182.5007083,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.235557655,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636186.2836976,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00405712,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636186.3992515,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003446569,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636186.7613063,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.359574134,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636187.3191743,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.033031508,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636191.2582939,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00221352,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636191.3762267,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004146485,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636192.0948718,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.715370231,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636193.1769438,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.916439362,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636196.214117,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.00547365,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636196.3378625,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004112611,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636196.6426318,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.302177464,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636197.0860224,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.869756754,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636201.1543982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005758813,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636201.2659998,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002879587,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636201.8280308,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.559356645,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636202.0653822,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.908447219,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636206.0907006,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00723282,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636206.2008443,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.004379566,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636206.8110948,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.718236022,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772636207.046789,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.843639101,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636211.2616673,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003628897,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636211.3801818,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004071785,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636212.4501336,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.066889613,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636212.7636893,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.499587571,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636216.2853293,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004483658,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636216.4263487,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.015076775,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636217.9380367,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.485175208,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636218.1075206,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.819676002,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636221.2630384,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00537854,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636221.3771918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.00475437,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636221.9869757,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.60720022,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772636222.0088875,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.743764247,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636224.9678767,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.00797873,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636225.591027,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.620960868,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636226.2160573,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006536478,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636226.327418,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.003091924,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636226.9503655,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.62056045,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636226.9770253,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.758804211,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772636231.153852,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003936383,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636231.2660325,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00203418,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636232.068823,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.912976647,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636232.2208898,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.952765,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636236.087594,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004666891,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636236.2014782,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003578629,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636236.5918946,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.387613148,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636237.1825085,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.092617796,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636241.2648666,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004066946,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636241.380406,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002282714,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636241.9923081,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.609593966,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636242.7700512,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.502930563,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636246.292642,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.007717385,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636246.4033833,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002604582,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636247.032898,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.627094577,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636247.4239788,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":1.128845627,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636251.264838,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004051054,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636251.3810852,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.005040725,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636251.815316,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.431213396,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636252.0790133,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.81213013,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636256.2175763,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00423653,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636256.3320677,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003720073,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636257.22093,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.001127561,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636257.2315395,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.89704628,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636261.1579232,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.00575406,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636261.2699623,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002556676,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636261.8588333,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.586728783,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636262.2003849,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.040562368,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636266.0880866,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003644582,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636266.2062051,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.006436931,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636266.7797322,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.571198994,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636266.9077148,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.817177885,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772636271.2678294,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005147481,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636271.382577,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.002189776,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636272.0246074,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.639888069,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636272.2079754,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.93752543,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636276.2896833,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003916076,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636276.405525,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003715931,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636276.8394725,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.431333339,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636278.2650723,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.972694985,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636281.268902,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.006591102,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636281.3853486,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006655183,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636281.8701115,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.482502555,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636282.04117,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.770286729,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636284.9900508,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.010206619,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636285.7285285,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.735481075,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636286.2202096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005351247,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636286.3344562,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004410513,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636286.8850687,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.662468635,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636286.985333,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.648510573,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636291.161026,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.006099314,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636291.2761333,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005477459,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636292.036812,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.75820295,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636292.457657,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.294603694,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636296.097731,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.008040361,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636296.206745,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003527992,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636296.4914048,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.282478924,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636297.054875,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.953752991,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636301.2426405,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006972171,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636301.3595667,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005805063,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636301.9736972,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.611369114,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636302.1769044,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.930924834,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636306.2801733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.007705459,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636306.3939614,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004046849,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636306.7902036,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.393526206,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636307.3702583,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":1.085968934,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636311.262152,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005658267,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636311.3757331,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004125974,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636311.798825,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.420686158,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636312.0569859,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.792557349,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636316.214593,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.00263463,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636316.3295617,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.002531888,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636316.8404198,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.508681085,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636318.0409806,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":1.823529593,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636321.16592,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.012286794,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636321.2707376,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002395487,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636321.8663402,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.593420365,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636322.1341715,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.966076797,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636326.092503,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004762886,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636326.2063887,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003138941,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636327.1345363,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.925350187,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636327.1973052,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.102234226,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636331.2429414,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.004284154,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636331.3586366,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002022117,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636331.790844,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.429882488,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636332.060143,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.814970594,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772636336.2797604,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004211553,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636336.3976014,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004740845,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636336.9842322,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.702150875,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636337.2262332,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.826154841,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636341.264528,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004730785,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636341.379246,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004537098,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636342.092553,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.710253877,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636342.5473473,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.280290892,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772636344.9819527,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.009807045,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636345.7371922,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.752532095,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636346.2198384,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.004839095,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636346.3335369,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.002884521,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636346.7518098,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.415818863,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636347.4417121,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.219613268,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636351.1607735,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004381248,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636351.27612,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004607757,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636351.4237835,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.145295403,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636351.7794127,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.616364314,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636356.0968874,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006003865,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636356.2087514,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003386839,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636357.0233195,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.92453134,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636357.0881155,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.877008807,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636361.2453818,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004635159,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636361.3624737,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004044763,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636361.7668903,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.401903127,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636361.9532862,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.705160503,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636366.2835097,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004930937,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636366.3973134,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00354673,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636366.8293111,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.429715478,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636367.6814847,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.395435265,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636371.2680006,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.0033698,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636371.3838642,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005083518,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636372.0491018,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.778910831,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636372.375647,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.989268787,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636376.250435,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.00462548,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636376.337769,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.003462788,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636376.713842,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.373767846,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772636377.4194412,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.167116294,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636381.1642163,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004935512,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636381.2796538,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005024953,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636381.6921344,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.410057733,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636382.054943,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.88855592,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636386.0957432,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004742073,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636386.209551,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003953872,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636386.4719172,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.260271055,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636386.8297248,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.731513765,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636391.2510915,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007060294,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636391.3644462,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002996476,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636391.7320182,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.364092127,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636392.2599442,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.006021382,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636396.2834234,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004273061,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636396.4028893,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004182194,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636396.8422577,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.437264155,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636397.0151849,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.729297447,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636401.2662468,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00423895,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636401.4133794,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.00420579,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636401.9122047,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.496955794,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636401.9577343,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.688745463,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636404.984531,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006806679,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636405.6016781,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Authorization":[],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.613960329,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636406.2243664,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006490374,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636406.3371973,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004004318,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636406.5969527,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.257249868,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636408.103047,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.876242816,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636411.1650162,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006969477,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636411.275736,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003022461,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636411.6288147,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.351021164,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636412.1498458,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.982874734,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636416.0957918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.00384861,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636416.2091131,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.003198505,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636416.8501706,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.638825571,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636416.8782418,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.780074311,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636421.2504778,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004452949,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636421.3696938,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.005974074,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636421.849379,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.476894799,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636422.0410314,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.78839028,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636426.2913647,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003517771,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636426.4147975,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.003883548,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636427.0769718,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.659633468,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636427.3092191,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.015874996,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636431.2767274,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004296732,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636431.391105,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003102534,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636431.6743436,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.281347151,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636432.118821,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.83959974,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636436.2315023,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004208563,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636436.3465326,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004880993,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636436.978016,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.628997495,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636437.0502005,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.816186408,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636441.1640892,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003017365,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636441.278829,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002695718,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636442.0301154,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.749256699,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636442.1798189,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.013286517,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636446.0996237,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005907937,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636446.212214,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004009957,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636446.7164059,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.501822207,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636447.547751,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.445871207,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636451.2566721,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.008595938,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636451.3721561,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.005433318,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636452.0068052,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.631858164,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636452.3588428,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.09960201,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636456.2931957,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006920875,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636456.4092953,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006942276,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636456.6643033,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.252485648,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636457.0530715,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.757400906,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636461.2755525,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00693054,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636461.39196,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007179542,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636462.0319378,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.637085889,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636462.594729,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.317246706,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636464.995609,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.010258395,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636465.5985112,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.598978431,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636466.228835,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003951735,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636466.3423634,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002647391,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636466.9176881,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.573048305,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636467.5204625,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":1.289078699,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636471.1710222,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004391334,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636471.2855742,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.003643116,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636472.0143619,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.725501215,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636472.2418258,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.068546579,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636476.1089249,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.007592377,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636476.219165,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.002959766,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636476.4593432,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.238018502,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636476.8842285,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.772380746,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636481.2179167,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004217708,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636481.3366146,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004670386,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636481.761963,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.42270452,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636481.9231257,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.702641046,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636486.2625513,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004166731,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636486.3784018,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002675389,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636487.1463156,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.765138345,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636487.4021568,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.136997074,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636491.2520137,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.004277369,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636491.365079,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.002069743,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636491.881925,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.514767544,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636492.6088607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.35437613,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636496.2105994,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004389709,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636496.324219,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003001382,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636496.7137635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.387682201,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636496.9793446,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.76625593,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636501.153113,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004655169,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636501.2658532,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002609724,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636501.695798,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.427895824,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636502.5197198,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.363868505,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636506.0862212,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003904292,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636506.199184,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.002072978,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636506.7605016,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.55882679,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636507.2582893,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.169007128,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636511.2287304,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007718605,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636511.3444288,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004962513,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636512.3390985,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.991795572,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636512.4254954,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.193573401,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636516.276269,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003806058,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636516.3901863,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.00204559,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636516.8943126,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.501475488,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636517.795168,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.516491846,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636521.272608,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007868297,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636521.3867137,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006395874,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636522.2877972,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.01221426,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636522.3249726,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.935598239,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636524.9878764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.010501053,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636525.9276588,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""]}},"bytes_read":0,"user_id":"","duration":0.937382322,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636526.2269428,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00339249,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636526.3413393,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.002192954,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636526.932343,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.588945931,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636527.0266247,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.797659878,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636531.172825,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005573046,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636531.2859106,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003602851,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636531.7748487,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.486680302,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636532.0649037,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.889766288,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636536.1062243,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003840529,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636536.2198455,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002450849,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636536.7116647,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.489975259,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636536.8462574,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.73719689,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636541.232243,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.008750405,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636541.3481753,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.006078129,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636541.7359762,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.385360047,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636542.016261,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.781560023,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636546.2838213,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005412796,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636546.4024456,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.007359643,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636547.112651,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.826125211,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772636547.2661412,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.861170575,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636551.2764375,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.007543482,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636551.39037,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004698181,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636551.5676463,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.174634645,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636551.955252,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.676894162,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636556.234757,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005220535,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636556.3483596,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.003510817,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636556.7026696,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.351532411,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636557.1959348,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.958479599,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636561.174886,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00531792,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636561.2868361,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.00248725,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636561.525369,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Authorization":[],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.236310013,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636561.9452734,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.76813758,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636566.1053073,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003143699,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636566.2213018,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003760007,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636566.4795365,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.255177419,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636566.8781483,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.771001782,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636571.2308338,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004598867,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636571.3479965,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004574761,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636572.385488,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.152334437,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636572.6463256,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.295320403,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636576.3076794,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.010699518,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636576.3990922,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004889236,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636577.0800512,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.677523749,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636578.0182216,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.70716862,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636581.2753716,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007160161,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636581.3923593,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004113074,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636582.142361,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.746771288,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636583.0092053,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.730970847,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636584.9933136,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.009427902,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636585.6538188,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.65646955,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636586.2327955,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006213137,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636586.3467453,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.003950477,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636586.8626468,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.513175405,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636587.1429503,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.907997915,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636591.1750262,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004013776,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636591.288893,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002922873,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636591.7419665,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.451242648,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636592.2363296,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.05889915,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636596.111115,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004520853,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636596.2261186,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.001882441,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636596.6750493,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.446184457,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636596.9496176,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.836061825,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636601.242541,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.014074398,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636601.3699892,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003785035,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636601.834853,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.461884551,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636602.3718674,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.126474846,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636606.2854602,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004386855,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636606.4047825,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.007095196,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636606.9747663,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.567924561,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636607.0417655,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.753833481,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636611.2806942,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.007317142,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636611.3961408,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00515471,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636611.8007622,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.40205908,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636612.3853927,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.102628498,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636616.2375674,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.003862188,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636616.3535414,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004203792,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636616.7932513,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.437469228,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636617.4786816,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.238021486,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636621.1824615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005533748,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636621.2958465,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00321683,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636621.7389305,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.440223689,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636622.033179,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.848582154,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636626.1157835,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005822542,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636626.2293608,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003781854,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636626.708137,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.476357291,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636626.9930387,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.875272634,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636631.2374706,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.006515789,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636631.353278,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004242961,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636631.7540207,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.397671655,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636632.5986285,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.359127753,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636636.2913442,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00733945,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636636.4026043,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.002122917,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636637.3421507,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.937449252,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636637.5754359,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.282081949,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636641.279393,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004143746,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636641.3950963,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003988912,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636641.740266,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.342617696,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636642.2508137,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.968881287,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636645.001709,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.009441613,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636645.7332144,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.729032169,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636646.2491982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007951624,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636646.3690238,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006569973,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636646.8878856,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.516063268,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636647.2459402,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.994233924,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636651.1819654,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004530195,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636651.2955804,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.003048801,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636651.8684878,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.570335809,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636652.1129987,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.928608041,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636656.1182895,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006231651,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636656.2301695,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002905818,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636657.02229,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.790002425,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636657.7593157,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.638721533,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636661.2382786,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006700777,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636661.3572342,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.006254441,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636661.9954646,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.635780145,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636662.500233,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.25967885,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636666.2957747,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.006366207,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636666.4130719,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.006734999,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636667.2025578,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.904547981,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636667.55792,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.142215274,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636671.2881262,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004313838,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636671.4025295,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.00448358,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636672.1305451,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.839839662,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636672.1369853,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.731487797,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636676.2461782,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.005157255,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636676.3611338,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.004067002,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636677.104869,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.741020448,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636677.718944,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.470563633,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636681.181998,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003702925,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636681.2973855,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.00441368,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636681.924172,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.624551916,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636682.1597104,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.975354474,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636686.1174066,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.00748638,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636686.22776,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.00345861,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636686.8415709,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.611219964,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636687.0263522,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.906308279,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636691.2005005,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004930723,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636691.3175576,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.002349096,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636691.8150883,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Authorization":[],"Pragma":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.494553211,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636692.0296998,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.826655948,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636696.2714667,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002932083,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636696.3878896,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002095007,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636696.8140788,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.424139275,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636696.9540856,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.67978408,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636701.2760606,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Pragma":["no-cache"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.005081109,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636701.3901029,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00340153,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636702.0224602,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.629274836,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636702.3363838,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.057643528,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636705.0025501,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.010560865,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636705.6293879,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.624528677,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636706.2452488,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00463077,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636706.353476,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.003135596,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636706.854601,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.498709253,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636707.528536,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.281102848,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636711.1848123,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003730432,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636711.300769,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003902952,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636711.9912353,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.687449863,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636712.1635356,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.976368685,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636716.1247435,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006437156,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636716.2379076,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004207775,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636716.678325,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.437413011,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636717.9975572,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.870108368,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636721.2039623,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005690161,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636721.3201842,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.00303087,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636722.0034783,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.681322791,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636722.2381024,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.031731317,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636726.2774677,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.007527331,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636726.3926663,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004619313,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636727.107896,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.712162817,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636727.3551102,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.074627657,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636731.2788033,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.008064066,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636731.3899684,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.003996978,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636732.4518166,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.169782193,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636732.6276157,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.234945894,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636736.2477572,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.010131392,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636736.3587363,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.007843811,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636736.739053,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.377259943,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636737.1188245,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.868233763,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636741.1853693,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004086641,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636741.309983,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.013856572,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636741.9283566,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.608921848,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636742.576152,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.38805785,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636746.1242723,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007106242,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636746.236207,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00419078,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636746.640573,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.402042688,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636746.7981658,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.671686734,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636751.2033715,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004339827,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636751.326379,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.007449767,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636752.0412576,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.712176586,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636752.1455512,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.939935507,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636756.2772295,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006284813,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636756.3928041,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004773977,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636756.7141638,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.318773643,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636757.6793609,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.399707989,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636761.275244,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004293228,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636761.3908224,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.0031699,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636762.0032768,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.609786545,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636762.1350017,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.857135336,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636764.9935954,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.009485818,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636765.942365,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.946709361,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636766.3262744,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.008050465,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636766.355186,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004122602,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636766.775004,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.41690141,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636767.4089987,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.080544114,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636771.190864,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.01019059,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636771.307882,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.010777527,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636772.3464222,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.152107534,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636772.6485415,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":1.336225661,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636776.1385145,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007629991,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636776.2348635,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.002977601,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636776.4990907,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.261734356,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636776.967296,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.826414516,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636781.2098038,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.007275483,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636781.327702,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.008109472,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636782.1098247,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.779597713,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636782.6083517,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.396195596,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636786.2889879,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.010264287,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636786.4011135,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.006641696,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636786.8142867,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.411139137,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636787.1758015,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.884298718,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636791.2844918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004173027,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636791.3982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.002023006,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636792.4558835,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.169198702,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636792.7074943,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.30730345,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636796.2519991,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007051898,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636796.3637128,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003773004,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636796.7276683,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.361661832,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636797.2703984,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.015912153,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636801.1928918,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005626637,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636801.32615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005427069,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636802.0279553,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.833117956,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636802.2364917,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.908091591,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636806.124943,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005440793,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636806.2382615,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002820464,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636806.6160235,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.37504141,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636807.5492718,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.421770074,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636811.2145457,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007803283,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636811.3300853,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004658812,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636811.9663851,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.633312137,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636812.269244,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":1.051694106,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636816.2826807,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006217024,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636816.402084,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006179313,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636817.1453555,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.860071368,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636817.4769967,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.072853941,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636821.2860408,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.006091091,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636821.401288,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00554175,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636821.639975,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.236044096,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636822.0226495,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.734668874,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636824.9965048,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005898226,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636825.6724505,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.672611424,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636826.2481096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003586542,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636826.359869,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.001934845,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636826.4810922,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.119183704,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636827.0705864,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.820008946,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636831.198371,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007488681,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636831.3166566,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.008371158,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636832.6270244,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.308180729,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636832.8972905,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.696387372,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772636836.1316752,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004413327,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636836.245248,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.002962522,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636836.927438,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.792964214,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636837.1558676,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.908547679,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636841.213594,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004601537,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636841.3311708,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003803941,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636841.761281,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.428043035,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636841.932264,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.716201136,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636846.284223,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004903005,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636846.3974154,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003632703,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636846.8124592,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.41229732,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636847.3983927,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.111586343,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636851.2859614,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.006181447,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636851.3984802,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003208179,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636851.9275374,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.526539381,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636852.2011702,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.912548041,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636856.2478564,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003790136,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636856.3609393,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004040132,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636856.804946,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.441571458,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636857.539546,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.288861503,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636861.1944273,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.004418585,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636861.308319,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.003395658,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636861.6163359,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.3060277,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636862.0167155,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.820033352,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636866.1307154,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.00414384,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636866.243747,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002279154,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636866.8212829,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.575462239,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636866.9703157,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.837046211,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636871.2079902,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.005401078,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636871.325276,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003637474,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636872.0910103,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.76311394,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636872.4447834,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.234433462,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636876.2471886,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.005402692,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636876.3605611,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002348295,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636876.9286199,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.565902462,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772636877.1940956,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.944806463,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636881.2457385,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004832006,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636881.3617687,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.003841262,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636881.9589634,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.594073869,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636882.2821696,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.033541651,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636884.9669216,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007098357,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636885.7032056,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.733819333,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636886.2103665,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004995931,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636886.3203268,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00299621,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636886.6029449,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.280473119,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636887.021268,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.808273959,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636891.1551173,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003877369,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636891.2704382,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004339183,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636891.9431677,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.785149507,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636892.2806084,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.007057029,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636896.0925531,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004905712,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636896.2104452,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.007463211,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636896.79903,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Gpc":["1"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.586054113,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636896.938299,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.843141149,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636901.2117605,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005301902,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636901.3248582,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004428769,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636901.8560514,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.528883163,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636901.8885694,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.674616103,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636906.2756925,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003815405,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636906.3939545,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004457192,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636906.89761,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.500418294,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636907.0009227,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.722960403,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636911.2963915,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004884661,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636911.4114656,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00356267,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636912.193409,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.894058787,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636912.3871527,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.973610818,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636916.2644253,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.004327842,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636916.3840222,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006031538,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636916.865131,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.478625215,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636917.0163848,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.749381223,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636921.2102458,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005335694,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636921.3210278,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003041413,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636921.8194916,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.496561059,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636922.164949,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.952247391,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636926.140764,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005426235,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636926.2524633,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.002480736,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636926.802733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.547712344,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636927.0667868,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.923686411,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636931.2118826,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003682447,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636931.330049,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.007536938,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636931.9806585,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.64783597,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636932.2335541,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.019390186,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636936.2708416,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004530576,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636936.3903205,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.007089689,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772636936.7674801,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.375049481,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636937.1407316,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.867283353,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636941.2828777,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004151268,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636941.397086,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002955831,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636941.7553232,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.356053595,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772636942.289724,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.004450121,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636945.0060232,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.011605272,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636945.622072,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.613585902,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636946.4629884,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.005365757,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636946.466388,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.008713116,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636946.781594,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.312081026,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636947.481409,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.016349271,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636951.2041042,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.004788104,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636951.3152404,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.002282655,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636951.8245041,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.506872938,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636952.6266103,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.420258414,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636956.1413603,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004791319,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636956.2546499,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.003382326,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636956.9062731,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.648508547,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636957.5878234,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":1.443428637,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636961.0738387,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005122821,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636961.327919,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.003220653,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636961.6909351,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.360838475,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772636962.1684334,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.950449473,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636966.303149,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.008631918,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772636966.393328,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005691764,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636967.0178776,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.621582419,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636967.3338578,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":1.028197279,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636971.285097,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.007070218,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772636971.4008496,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004851346,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772636971.9317608,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.528128499,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636972.473439,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.185515141,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636976.2539067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003497191,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636976.3681912,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.002872234,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636976.8368256,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.466510097,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636977.5504076,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":1.293886047,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636981.20475,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.004797485,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636981.317058,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002359675,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636981.6266906,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.307592307,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772636982.0857012,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.87872783,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636986.1425261,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005027266,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636986.2558026,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.002621711,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772636986.5353448,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.277254165,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772636986.9516025,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.806332516,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636991.072607,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004425626,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636991.1877093,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.00268382,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772636991.7252264,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":0.394011159,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772636991.966578,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":0.750184662,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636996.273397,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.004165209,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636996.3886461,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.002554507,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772636997.1195126,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.728815208,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772636997.5185974,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.242709713,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637001.2842739,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004442902,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637001.4114377,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002947266,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637001.7863414,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.372868626,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637002.334267,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.04730219,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637005.0055032,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.011305336,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637005.5413597,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.534079824,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637006.2687936,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.007219132,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637006.3692367,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003642056,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637006.9007237,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.528902028,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637007.2806964,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.009408392,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637011.2026815,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.004417715,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637011.3155472,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002099611,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637011.6029563,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.285281233,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637012.6662292,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"]}},"bytes_read":0,"user_id":"","duration":1.460984407,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"]}} -{"level":"info","ts":1772637016.1404312,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004637273,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637016.2507858,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.001815214,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637016.7926712,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.539544009,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637016.963961,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.820723253,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637021.0724444,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.004437682,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637021.1849163,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.00223282,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772637021.4380906,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.251359502,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637022.0448802,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.823414016,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637026.276107,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004563815,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637026.3931258,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.004277342,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637027.0808444,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.684700417,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637027.1627066,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.88434883,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637031.286654,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.00414772,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637031.3992712,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002088543,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637031.7056067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.304139965,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637032.1326063,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.84336885,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637036.2562578,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003957282,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637036.3659012,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"]}},"bytes_read":0,"user_id":"","duration":0.001841314,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637036.6486638,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.280745113,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637037.122747,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.86439451,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637041.2053683,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004347929,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772637041.3177462,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.002494827,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637041.6268067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.306734796,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637042.387695,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.179333418,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637046.1444495,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005602879,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637046.259048,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.005150029,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637046.8678186,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.605846038,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637046.910859,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.764278251,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637051.0756962,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004616812,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772637051.1893616,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003639748,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637051.880876,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.542794774,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637051.9852536,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.761429301,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637056.2803154,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Access-Control-Request-Headers":["authorization"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.005647378,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637056.394535,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.002995895,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637056.927882,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.530782013,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637057.0218694,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.739427281,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637061.2900887,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004005692,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637061.407514,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005066815,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772637062.1662748,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.755979754,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637062.1960406,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.90350325,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637065.0188096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.008897933,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637065.5741286,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Gpc":["1"],"Authorization":[],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.553250983,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637066.263229,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005019499,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772637066.3740156,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.002295983,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637066.8353412,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.459393715,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637067.1349328,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.869672396,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637071.2121344,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.005601393,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637071.320967,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003670266,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772637072.0343013,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.710348055,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637073.0936208,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":1.878649389,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637076.1455748,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.003106472,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637076.2597287,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.002147709,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637076.68171,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.419844992,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637076.9546177,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.807013769,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637081.0827518,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007473547,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772637081.197347,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.007684594,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637081.4446323,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.244093036,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637081.9620447,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.732345418,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637086.2799737,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004976171,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637086.3989642,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004493381,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637086.946819,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.545130339,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637087.0287342,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.746827886,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637091.2936473,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004891817,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637091.406872,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.003198562,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637092.1727762,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Authorization":[],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.763510181,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637092.4347055,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":1.13867322,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637096.26826,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.009377299,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637096.3777792,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003779482,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637096.8615372,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.481299512,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637097.636378,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.365431205,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637101.2116506,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00387372,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"]}} -{"level":"info","ts":1772637101.3234768,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00232282,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637101.5771503,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.251464645,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637102.026975,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.812708813,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637106.1478627,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003971079,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637106.2645204,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003911838,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637106.8713772,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.721650341,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637107.5308504,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":1.263444498,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772637111.0830796,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004462695,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637111.1999679,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.006461289,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637111.8017232,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.59942731,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637111.8578606,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.77236295,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637116.263588,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.007692027,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637116.4555538,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.056350464,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637117.1503973,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.690793262,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637117.944963,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":1.658474226,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637121.282603,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004758339,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637121.3972034,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003381611,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637121.849836,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.450394712,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637122.4088514,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":1.123613147,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637125.0061443,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.009339281,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637125.4006333,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.392106118,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637126.2576835,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003614491,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637126.372567,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003042821,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637126.495971,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.120829889,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637126.9890509,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.729136952,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637131.213962,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.007432494,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772637131.3231144,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.001925729,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637131.7532606,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.42810443,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637132.024927,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.808840748,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637136.1585076,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.010973506,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637136.2633975,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Accept":["*/*"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.006152533,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637136.7717881,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.50575717,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637137.5940194,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"Authorization":[],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.431129757,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637141.0849056,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.00532215,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637141.1924663,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00282253,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637141.6182039,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.423286183,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637142.6940007,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.462116491,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637146.2590706,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.00434587,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637146.3728077,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.004395587,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637146.5702736,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.195134308,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637146.985733,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.724152948,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637151.2855513,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.007217091,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637151.4015048,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.007017789,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637152.0879822,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.799636118,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772637152.4637659,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.059699391,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637156.2576191,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003813585,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637156.3742263,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004147005,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637156.7755852,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.398485006,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637156.9079986,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.648390074,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637161.2104888,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003549543,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637161.3201306,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.00273576,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637161.6749442,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.352379007,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637162.2486057,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":1.035240557,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637166.1511693,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.00571266,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637166.2591922,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.002962318,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637166.55966,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Authorization":[],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.298455497,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637167.2442865,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.090943088,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637171.0827901,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.00428014,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637171.1948166,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.002759552,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637171.7558453,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[]}},"bytes_read":0,"user_id":"","duration":0.670737525,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637171.8603075,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.663355056,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637176.2607179,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.005911823,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637176.376857,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003995491,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637176.678725,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"Authorization":[],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.299348215,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637177.1317296,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.869009416,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637181.3088932,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.005659915,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772637181.3991175,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.003534356,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637181.7744067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.372866353,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637182.157524,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.846055274,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637185.0059433,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.005857318,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637185.614675,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.60591987,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637186.2628284,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003302311,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637186.3746095,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"]}},"bytes_read":0,"user_id":"","duration":0.003570845,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637186.7745867,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.397575019,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637187.0070908,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.741597504,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637191.2158859,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004174208,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637191.3255327,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.002984988,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637191.91795,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.589457962,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637191.9735858,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.755169047,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637196.1529543,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00696838,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637196.2627223,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002953949,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772637196.6671,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.40245124,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637197.7273526,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.572041971,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637201.080996,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003813647,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637201.2020388,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.002774605,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637201.5916502,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Platform":["\"Android\""],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.387268431,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637201.8551083,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.771998371,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637206.2656765,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.004751113,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637206.380048,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.005756254,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772637206.7871559,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.404468164,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637206.950543,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.68087317,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637211.2971847,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.015195172,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637211.4014728,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004391141,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637212.222638,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""]}},"bytes_read":0,"user_id":"","duration":0.818555601,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637212.4215326,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":1.120964724,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"]}} -{"level":"info","ts":1772637216.2621121,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004481677,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637216.3741412,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Pragma":["no-cache"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.002272755,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637216.8375504,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.461468136,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637217.0716763,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.80749384,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637221.2171783,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.008210526,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637221.3262153,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.002692015,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637221.6636095,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.335317146,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637222.0672352,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.847768096,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637226.1565874,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.008030846,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637226.2675157,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.004531279,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637226.8744156,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.71551819,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637226.9500985,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.680335235,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"]}} -{"level":"info","ts":1772637231.089519,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.004333828,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637231.198908,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Accept":["*/*"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002785217,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637231.4508002,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Pragma":["no-cache"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.249851107,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637231.860118,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.767986304,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637236.2698953,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Cache-Control":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.004750002,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"]}} -{"level":"info","ts":1772637236.382619,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004514978,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637237.0442657,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.65894573,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637237.0531628,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.780686064,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637241.2960105,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"Accept":["*/*"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.006076566,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637241.4049096,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.00330729,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637242.186129,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Origin":["http://localhost:5173"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.779143067,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637242.2070448,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.908403666,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637245.0266576,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.006558383,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637245.46862,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Gpc":["1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.440103739,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637246.2795544,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004922213,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637246.3822505,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.00221081,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637246.8715792,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"]}},"bytes_read":0,"user_id":"","duration":0.487132647,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637247.2310846,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.948919013,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"]}} -{"level":"info","ts":1772637251.2179222,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.004617672,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637251.3303711,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.003019771,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637251.7787414,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Gpc":["1"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-Proto":["http"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.446263102,"size":101,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637252.0432286,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.823228584,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637256.1565497,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.006576835,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637256.26804,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003459458,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637256.7106361,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.440320041,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637256.9674854,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"Pragma":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.808350256,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637261.0859287,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"]}},"bytes_read":0,"user_id":"","duration":0.003527012,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637261.2013397,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"]}},"bytes_read":0,"user_id":"","duration":0.004550485,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637261.5838451,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Fetch-Dest":["empty"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""]}},"bytes_read":0,"user_id":"","duration":0.380171448,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772637262.2551138,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Cache-Control":["no-cache"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":1.166731635,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637266.2646387,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.003213784,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637266.3819067,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Method":["GET"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Fetch-Site":["same-site"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002690392,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637266.8219497,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Cache-Control":["no-cache"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Accept":["application/json, text/plain, */*"],"Authorization":[],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"]}},"bytes_read":0,"user_id":"","duration":0.437788809,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637266.8855042,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Prefix":["/api"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"]}},"bytes_read":0,"user_id":"","duration":0.618769333,"size":299,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637271.2945762,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Site":["same-site"],"X-Forwarded-Prefix":["/api"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Method":["GET"],"Referer":["http://localhost:5173/"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Proto":["http"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"]}},"bytes_read":0,"user_id":"","duration":0.003416399,"size":0,"status":200,"resp_headers":{"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"]}} -{"level":"info","ts":1772637271.408971,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Real-Ip":["172.18.0.1"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Cache-Control":["no-cache"]}},"bytes_read":0,"user_id":"","duration":0.003668317,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637272.337746,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Dest":["empty"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":1.041114989,"size":299,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637272.517407,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"]}},"bytes_read":0,"user_id":"","duration":1.105854254,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637276.2712407,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"Accept":["*/*"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Pragma":["no-cache"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.004146595,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637276.3849568,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Referer":["http://localhost:5173/"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Headers":["authorization"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"X-Forwarded-Prefix":["/api"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.002974753,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637276.7564702,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Forwarded-Port":["80"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.369639775,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637277.03982,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"Sec-Fetch-Site":["same-site"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Proto":["http"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.765815152,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637281.2276168,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"Access-Control-Request-Headers":["authorization"],"X-Real-Ip":["172.18.0.1"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-For":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Method":["GET"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"]}},"bytes_read":0,"user_id":"","duration":0.007178836,"size":0,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637281.337954,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.003432813,"size":0,"status":200,"resp_headers":{"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"]}} -{"level":"info","ts":1772637282.1458998,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Authorization":[],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Mobile":["?1"],"Cache-Control":["no-cache"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"]}},"bytes_read":0,"user_id":"","duration":0.805362657,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Server":["Caddy"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637282.211224,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en;q=0.6"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["application/json, text/plain, */*"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"]}},"bytes_read":0,"user_id":"","duration":0.981306202,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637286.1758392,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Pragma":["no-cache"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.015114921,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637286.2795904,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Cache-Control":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"Access-Control-Request-Method":["GET"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003807734,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Server":["Caddy"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637286.7117078,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Referer":["http://localhost:5173/"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept":["application/json, text/plain, */*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Port":["80"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Gpc":["1"],"X-Forwarded-Host":["localhost"]}},"bytes_read":0,"user_id":"","duration":0.429250643,"size":101,"status":200,"resp_headers":{"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637287.753094,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"X-Forwarded-Proto":["http"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Host":["localhost"],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Origin":["http://localhost:5173"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"X-Forwarded-Port":["80"],"Accept":["application/json, text/plain, */*"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"Sec-Gpc":["1"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":1.573744115,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"]}} -{"level":"info","ts":1772637291.0984979,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["*/*"],"Pragma":["no-cache"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Forwarded-For":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"]}},"bytes_read":0,"user_id":"","duration":0.007051537,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"]}} -{"level":"info","ts":1772637291.210437,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Prefix":["/api"],"X-Forwarded-Proto":["http"],"Accept":["*/*"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Origin":["http://localhost:5173"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Port":["80"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Host":["localhost"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.003074251,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"]}} -{"level":"info","ts":1772637292.038979,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept":["application/json, text/plain, */*"],"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Server":["0b64317da32a"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Cache-Control":["no-cache"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"Accept-Language":["en-GB,en;q=0.6"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Authorization":[],"Referer":["http://localhost:5173/"],"Sec-Gpc":["1"],"X-Forwarded-Prefix":["/api"],"X-Real-Ip":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.825834096,"size":101,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637292.1001875,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept":["application/json, text/plain, */*"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua-Platform":["\"Android\""],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Sec-Fetch-Mode":["cors"],"Sec-Gpc":["1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"Sec-Ch-Ua-Mobile":["?1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Pragma":["no-cache"],"X-Forwarded-For":["172.18.0.1"]}},"bytes_read":0,"user_id":"","duration":0.998344704,"size":299,"status":200,"resp_headers":{"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Server":["Caddy"]}} -{"level":"info","ts":1772637296.2691376,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Access-Control-Request-Headers":["authorization"],"Access-Control-Request-Method":["GET"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"Accept-Encoding":["gzip, deflate, br, zstd"],"X-Forwarded-Port":["80"],"Origin":["http://localhost:5173"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["*/*"],"Cache-Control":["no-cache"],"X-Forwarded-For":["172.18.0.1"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Pragma":["no-cache"],"X-Forwarded-Prefix":["/api"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":0.005769027,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Headers":["*"],"Server":["Caddy"],"Content-Type":["application/json"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} -{"level":"info","ts":1772637296.3835495,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"OPTIONS","host":"localhost","uri":"/ping","headers":{"Origin":["http://localhost:5173"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"X-Real-Ip":["172.18.0.1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Host":["localhost"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept-Language":["en-GB,en-US;q=0.9,en;q=0.8"],"Referer":["http://localhost:5173/"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Port":["80"],"X-Forwarded-Prefix":["/api"],"Access-Control-Request-Headers":["authorization"],"Cache-Control":["no-cache"],"Accept":["*/*"],"Access-Control-Request-Method":["GET"]}},"bytes_read":0,"user_id":"","duration":0.00333115,"size":0,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Access-Control-Allow-Headers":["*"],"Content-Type":["application/json"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"]}} -{"level":"info","ts":1772637296.7289019,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"49136","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/ping","headers":{"Accept-Encoding":["gzip, deflate, br, zstd"],"Accept-Language":["en-GB,en;q=0.6"],"Cache-Control":["no-cache"],"Origin":["http://localhost:5173"],"X-Real-Ip":["172.18.0.1"],"Sec-Fetch-Mode":["cors"],"X-Forwarded-Port":["80"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Authorization":[],"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Dest":["empty"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Prefix":["/api"],"Pragma":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Ch-Ua-Mobile":["?1"],"Sec-Fetch-Site":["same-site"],"X-Forwarded-Proto":["http"],"X-Forwarded-Server":["0b64317da32a"],"Accept":["application/json, text/plain, */*"],"Sec-Gpc":["1"]}},"bytes_read":0,"user_id":"","duration":0.343511666,"size":101,"status":200,"resp_headers":{"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"],"Content-Type":["application/json; charset=utf-8"],"Server":["Caddy"],"X-Powered-By":["PHP/8.2.30"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"]}} -{"level":"info","ts":1772637297.3256814,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.3","remote_port":"37176","client_ip":"172.18.0.3","proto":"HTTP/1.1","method":"GET","host":"localhost","uri":"/bookings?filters=department:2,status:pending,date-date_from:2026-03-04,date-date_to:2026-03-04&limit=100&page=1","headers":{"Sec-Ch-Ua-Platform":["\"Android\""],"Sec-Fetch-Site":["same-site"],"Sec-Gpc":["1"],"X-Forwarded-Server":["0b64317da32a"],"Accept-Language":["en-GB,en;q=0.6"],"Authorization":[],"Sec-Ch-Ua-Mobile":["?1"],"X-Forwarded-Prefix":["/api"],"Origin":["http://localhost:5173"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Cache-Control":["no-cache"],"Referer":["http://localhost:5173/"],"Sec-Ch-Ua":["\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\""],"Sec-Fetch-Mode":["cors"],"X-Real-Ip":["172.18.0.1"],"User-Agent":["Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36"],"Accept":["application/json, text/plain, */*"],"X-Forwarded-For":["172.18.0.1"],"X-Forwarded-Host":["localhost"],"X-Forwarded-Port":["80"],"X-Forwarded-Proto":["http"],"Pragma":["no-cache"],"Sec-Fetch-Dest":["empty"]}},"bytes_read":0,"user_id":"","duration":1.054349983,"size":299,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":["application/json; charset=utf-8"],"X-Powered-By":["PHP/8.2.15"],"Access-Control-Allow-Origin":["*"],"Access-Control-Allow-Headers":["Content-Type, Authorization, X-Customer-Number"],"Access-Control-Allow-Methods":["GET, POST, PUT, DELETE, OPTIONS"]}} diff --git a/services/coolify/api/nginx.conf b/services/coolify/api/nginx.conf new file mode 100644 index 00000000..a7bdc1a4 --- /dev/null +++ b/services/coolify/api/nginx.conf @@ -0,0 +1,42 @@ +worker_processes auto; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /dev/stdout; + error_log /dev/stderr warn; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 64m; + + gzip on; + gzip_types application/json application/javascript application/xml text/css text/plain; + + server { + listen 80 default_server; + server_name _; + root /var/www/html; + index index.php; + + location / { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_read_timeout 60s; + fastcgi_param SCRIPT_FILENAME $document_root/index.php; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param X_REQUEST_ID $http_x_request_id; + } + + location ~ /\.(?!well-known) { + deny all; + } + } +} diff --git a/services/coolify/api/start.sh b/services/coolify/api/start.sh new file mode 100644 index 00000000..6a364078 --- /dev/null +++ b/services/coolify/api/start.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +mkdir -p /run/nginx +php-fpm -D +exec nginx -g "daemon off;" diff --git a/services/edge-agent/Dockerfile.test-gateway b/services/edge-agent/Dockerfile.test-gateway new file mode 100644 index 00000000..af691a1e --- /dev/null +++ b/services/edge-agent/Dockerfile.test-gateway @@ -0,0 +1,11 @@ +FROM php:8.2-cli + +WORKDIR /opt/truckwash-edge-agent + +RUN set -eux; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' + +COPY services/nginx/app/resources/edge-gateway-agent/ ./ + +ENTRYPOINT ["php", "/opt/truckwash-edge-agent/agent.php"] +CMD ["--config", "/config/test-gateway.json"] diff --git a/services/edge-agent/dist/agent.mjs b/services/edge-agent/dist/agent.mjs new file mode 100644 index 00000000..c85c2bcd --- /dev/null +++ b/services/edge-agent/dist/agent.mjs @@ -0,0 +1,2194 @@ +import { createHash } from "node:crypto"; +import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const DEFAULT_VERSION = "0.1.0"; +const DEFAULT_SHELL_COLS = 120; +const DEFAULT_SHELL_ROWS = 32; +const DEFAULT_CPU_SAMPLE_DELAY_MS = 150; +const DEFAULT_SHELL_EVENT_FLUSH_DELAY_MS = 40; +const DEFAULT_EDGE_AGENT_SERVICE_NAME = "truckwash-edge-agent.service"; +const DEFAULT_UPDATE_VERIFICATION_TIMEOUT_SECONDS = 45; +const DEFAULT_UPDATE_VERIFY_INTERVAL_MS = 500; +const DEFAULT_UPDATE_RESTART_GRACE_MS = 150; +const DEFAULT_BROKER_RECONNECT_DELAY_MS = 1500; +const DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS = 1200; +const UPDATE_VERIFY_COMMAND = "post-update-verify"; +const execFile = promisify(execFileCallback); + +function cloneJson(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +function replaceObjectContents(target, source) { + if (!target || typeof target !== "object" || Array.isArray(target) || !source || typeof source !== "object") { + return source; + } + + for (const key of Object.keys(target)) { + delete target[key]; + } + for (const [key, value] of Object.entries(source)) { + target[key] = value; + } + + return target; +} + +function formatUpdateTimestamp(date = new Date()) { + return date.toISOString(); +} + +function resolveUpdateVerificationTimeoutSeconds(config = {}, pendingUpdate = {}) { + const configuredTimeout = Number( + pendingUpdate.verificationTimeoutSeconds ?? + config.updateVerificationTimeoutSeconds ?? + DEFAULT_UPDATE_VERIFICATION_TIMEOUT_SECONDS + ); + return Number.isFinite(configuredTimeout) && configuredTimeout > 0 + ? Math.max(5, Math.round(configuredTimeout)) + : DEFAULT_UPDATE_VERIFICATION_TIMEOUT_SECONDS; +} + +function resolveUpdatePaths(configPath, config = {}) { + const installDir = path.resolve(String(config.installDir || path.dirname(configPath))); + return { + installDir, + configPath: path.resolve(configPath), + agentPath: path.resolve(String(config.agentPath || path.join(installDir, "agent.mjs"))), + packagePath: path.resolve(String(config.packagePath || path.join(installDir, "package.json"))), + updatesDir: path.resolve(String(config.updatesDir || path.join(installDir, ".updates"))), + serviceName: String(config.serviceName || DEFAULT_EDGE_AGENT_SERVICE_NAME), + }; +} + +function sanitizeUpdateSegment(value, fallback = "update") { + const normalized = String(value || "") + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized === "" ? fallback : normalized; +} + +function buildUpdateMetadata(config = {}) { + const lastUpdate = config.lastUpdate; + if (!lastUpdate || typeof lastUpdate !== "object" || Array.isArray(lastUpdate)) { + return null; + } + + return { + state: String(lastUpdate.state || "UNKNOWN"), + target_version: lastUpdate.targetVersion ?? null, + previous_version: lastUpdate.previousVersion ?? null, + restored_version: lastUpdate.restoredVersion ?? null, + error: lastUpdate.error ?? null, + completed_at: lastUpdate.completedAt ?? null, + }; +} + +async function pathExists(pathToCheck) { + try { + await fs.access(pathToCheck); + return true; + } catch { + return false; + } +} + +async function ensureDirectory(pathToEnsure) { + await fs.mkdir(pathToEnsure, { recursive: true }); +} + +async function writeBuffer(pathToWrite, buffer) { + await fs.writeFile(pathToWrite, buffer); +} + +async function backupFileIfPresent(sourcePath, destinationPath) { + if (!(await pathExists(sourcePath))) { + return false; + } + + await ensureDirectory(path.dirname(destinationPath)); + await fs.copyFile(sourcePath, destinationPath); + return true; +} + +async function restoreFileIfPresent(sourcePath, destinationPath) { + if (!(await pathExists(sourcePath))) { + return false; + } + + await ensureDirectory(path.dirname(destinationPath)); + await fs.copyFile(sourcePath, destinationPath); + return true; +} + +function createUpdateErrorMessage(error, fallback = "Edge agent update failed") { + return error instanceof Error ? error.message : String(error || fallback); +} + +function buildTransportHeartbeatState(brokerState = {}) { + const brokerConnected = Boolean(brokerState.connected); + return { + status: "ONLINE", + metadata: { + command_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING", + shell_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING", + broker_connected: brokerConnected, + broker_url: brokerState.url || null, + broker_last_error: brokerState.lastError || null, + broker_last_connected_at: brokerState.lastConnectedAt || null, + broker_last_disconnected_at: brokerState.lastDisconnectedAt || null, + broker_disconnect_reason: brokerState.disconnectReason || null, + broker_last_close_code: brokerState.lastCloseCode ?? null, + broker_last_close_clean: brokerState.lastCloseClean ?? null, + }, + }; +} + +function isShellAccessEnabled(config = {}) { + return config.enableShellAccess === true; +} + +function normalizeBrokerBaseUrl(value) { + const trimmed = String(value || "").trim().replace(/\/+$/, ""); + if (trimmed === "") { + return null; + } + if (trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) { + return trimmed; + } + if (trimmed.startsWith("https://")) { + return `wss://${trimmed.slice("https://".length)}`; + } + if (trimmed.startsWith("http://")) { + return `ws://${trimmed.slice("http://".length)}`; + } + return `ws://${trimmed}`; +} + +function buildBrokerSocketUrl(brokerUrl, gatewayId, token) { + const baseUrl = normalizeBrokerBaseUrl(brokerUrl); + if (!baseUrl || !gatewayId || !token) { + return null; + } + + const search = new URLSearchParams({ + gatewayId: String(gatewayId), + token: String(token), + }); + return `${baseUrl}/ws/agent?${search.toString()}`; +} + +function redactBrokerSocketUrl(value) { + try { + const parsed = new URL(String(value || "")); + for (const key of ["token", "agentToken", "agent_token"]) { + if (parsed.searchParams.has(key)) { + parsed.searchParams.set(key, "***"); + } + } + return parsed.toString(); + } catch { + return String(value || "").replace(/([?&](?:token|agentToken|agent_token)=)[^&]+/gi, "$1***"); + } +} + +function normalizeBrokerSocketError(error, socketUrl) { + const message = error instanceof Error && error.message + ? error.message + : error?.message + ? String(error.message) + : "Broker connection failed"; + const redactedUrl = redactBrokerSocketUrl(socketUrl); + return redactedUrl ? `${message} (${redactedUrl})` : message; +} + +async function readSocketMessageText(data) { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); + } + if (typeof Blob !== "undefined" && data instanceof Blob) { + return data.text(); + } + return String(data || ""); +} + +export async function loadConfig(configPath) { + const raw = await fs.readFile(configPath, "utf8"); + return JSON.parse(raw); +} + +export async function saveConfig(configPath, config) { + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); +} + +export function buildStatusReport(configPath, config) { + const heartbeatIntervalSeconds = Number(config.heartbeatIntervalSeconds || 15); + const claimed = Boolean(config.gatewayId && config.agentToken); + + return { + command: "status", + configPath, + claimed, + state: claimed ? "CLAIMED" : "PENDING_CLAIM", + gatewayId: config.gatewayId ?? null, + apiUrl: config.apiUrl ?? null, + brokerUrl: config.brokerUrl ?? null, + transport: config.brokerUrl ? "HYBRID" : "API_POLLING", + hostname: config.hostname || os.hostname(), + releaseChannel: config.releaseChannel || "stable", + installedVersion: config.installedVersion || DEFAULT_VERSION, + targetVersion: config.targetVersion || config.installedVersion || DEFAULT_VERSION, + heartbeatIntervalSeconds: Number.isFinite(heartbeatIntervalSeconds) && heartbeatIntervalSeconds > 0 + ? heartbeatIntervalSeconds + : 15, + hasInstallToken: Boolean(config.installToken), + }; +} + +export async function getAgentStatus(configPath) { + if (!configPath) { + throw new Error("Missing --config path"); + } + + const config = await loadConfig(configPath); + return buildStatusReport(configPath, config); +} + +function wait(delayMs) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, delayMs); + timer.unref?.(); + }); +} + +function clampPercentage(value) { + if (!Number.isFinite(value)) { + return null; + } + + return Math.max(0, Math.min(100, value)); +} + +function snapshotCpuTimes() { + return os.cpus().reduce( + (accumulator, cpu) => { + const total = Object.values(cpu.times).reduce((sum, current) => sum + current, 0); + return { + idle: accumulator.idle + cpu.times.idle, + total: accumulator.total + total, + }; + }, + { idle: 0, total: 0 } + ); +} + +function calculateCpuUsagePercent(previousSnapshot, currentSnapshot) { + if (!previousSnapshot || !currentSnapshot) { + return null; + } + + const totalDelta = currentSnapshot.total - previousSnapshot.total; + const idleDelta = currentSnapshot.idle - previousSnapshot.idle; + if (totalDelta <= 0) { + return null; + } + + return clampPercentage(((totalDelta - idleDelta) / totalDelta) * 100); +} + +async function sampleCpuUsage(previousSnapshot = null, sleepImpl = wait) { + if (previousSnapshot) { + const currentSnapshot = snapshotCpuTimes(); + return { + snapshot: currentSnapshot, + usagePct: calculateCpuUsagePercent(previousSnapshot, currentSnapshot), + }; + } + + const initialSnapshot = snapshotCpuTimes(); + await sleepImpl(DEFAULT_CPU_SAMPLE_DELAY_MS); + const sampledSnapshot = snapshotCpuTimes(); + return { + snapshot: sampledSnapshot, + usagePct: calculateCpuUsagePercent(initialSnapshot, sampledSnapshot), + }; +} + +function buildMemoryMetrics() { + const totalBytes = os.totalmem(); + const freeBytes = os.freemem(); + const usedBytes = Math.max(0, totalBytes - freeBytes); + + return { + memory_total_bytes: totalBytes, + memory_used_bytes: usedBytes, + memory_usage_pct: totalBytes > 0 ? clampPercentage((usedBytes / totalBytes) * 100) : null, + }; +} + +async function readDiskMetrics(execFileImpl = execFile) { + if (process.platform === "win32") { + return { + disk_total_bytes: null, + disk_used_bytes: null, + disk_usage_pct: null, + disk_mount: null, + }; + } + + try { + const { stdout } = await execFileImpl("df", ["-Pk", "/"], { + encoding: "utf8", + windowsHide: true, + }); + const lines = String(stdout) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const dataLine = lines.at(-1); + if (!dataLine) { + throw new Error("Missing disk usage output"); + } + + const segments = dataLine.split(/\s+/); + if (segments.length < 6) { + throw new Error("Unexpected disk usage output"); + } + + const totalBytes = Number(segments[1]) * 1024; + const usedBytes = Number(segments[2]) * 1024; + const usagePct = clampPercentage(Number.parseFloat(String(segments[4]).replace("%", ""))); + + return { + disk_total_bytes: Number.isFinite(totalBytes) ? totalBytes : null, + disk_used_bytes: Number.isFinite(usedBytes) ? usedBytes : null, + disk_usage_pct: usagePct, + disk_mount: segments.slice(5).join(" ") || null, + }; + } catch { + return { + disk_total_bytes: null, + disk_used_bytes: null, + disk_usage_pct: null, + disk_mount: null, + }; + } +} + +export async function collectSystemMetrics({ + previousCpuSnapshot = null, + execFileImpl = execFile, + sleepImpl = wait, + latencyMs = null, +} = {}) { + const [cpuMetric, diskMetrics] = await Promise.all([ + sampleCpuUsage(previousCpuSnapshot, sleepImpl), + readDiskMetrics(execFileImpl), + ]); + + return { + cpuSnapshot: cpuMetric.snapshot, + metrics: { + latency_ms: Number.isFinite(latencyMs) ? Math.max(0, Math.round(latencyMs)) : null, + cpu_usage_pct: cpuMetric.usagePct, + cpu_core_count: os.cpus().length, + load_average_1m: process.platform === "win32" ? null : os.loadavg()[0], + ...buildMemoryMetrics(), + ...diskMetrics, + }, + }; +} + +export async function apiRequest(baseUrl, path, method = "POST", body = {}, fetchImpl = fetch) { + const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}${path}`, { + method, + headers: { + "content-type": "application/json", + }, + body: method === "GET" ? undefined : JSON.stringify(body), + }); + + const json = await response.json(); + if (!response.ok) { + throw new Error(json?.data?.message || json?.message || `HTTP ${response.status}`); + } + + return json.data ?? json; +} + +export async function claimIfNeeded(config, configPath, fetchImpl = fetch) { + if (config.gatewayId && config.agentToken) { + return config; + } + + const claimed = await apiRequest(config.apiUrl, "/edge-agent/claim", "POST", { + token: config.installToken, + hostname: config.hostname || os.hostname(), + installed_version: config.installedVersion || DEFAULT_VERSION, + metadata: { + platform: process.platform, + arch: process.arch, + }, + }, fetchImpl); + + const nextConfig = { + ...config, + gatewayId: claimed.gateway.id, + agentToken: claimed.agent_token, + releaseChannel: claimed.release_channel || config.releaseChannel || "stable", + }; + if (claimed.broker_url || config.brokerUrl) { + nextConfig.brokerUrl = claimed.broker_url || config.brokerUrl; + } + await saveConfig(configPath, nextConfig); + return nextConfig; +} + +function makeHttpTimeoutError(timeoutMs) { + const error = new Error(`HTTP request timed out after ${timeoutMs}ms`); + error.code = "EDGE_AGENT_HTTP_TIMEOUT"; + return error; +} + +function isHttpTimeoutError(error) { + return error?.code === "EDGE_AGENT_HTTP_TIMEOUT"; +} + +function resolveShellyLocalHttpTimeoutMs(options = {}) { + const configured = Number(options.timeoutMs ?? process.env.EDGE_SHELLY_LOCAL_HTTP_TIMEOUT_MS); + if (Number.isFinite(configured) && configured > 0) { + return Math.max(50, Math.floor(configured)); + } + + return DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS; +} + +function resolveRelayToggleAfterSeconds(payload = {}) { + const configured = Number(payload.toggleAfter ?? payload.toggle_after ?? payload.timer); + if (!Number.isFinite(configured) || configured <= 0) { + return null; + } + + return Math.floor(configured); +} + +async function fetchJson(url, fetchImpl = fetch, options = {}) { + const timeoutMs = Number(options.timeoutMs || 0); + let timeout = null; + let controller = null; + const requestOptions = {}; + if (timeoutMs > 0 && typeof AbortController !== "undefined") { + controller = new AbortController(); + requestOptions.signal = controller.signal; + } + + let response; + try { + const fetchPromise = Promise.resolve().then(() => fetchImpl(url, requestOptions)); + response = timeoutMs > 0 + ? await Promise.race([ + fetchPromise, + new Promise((_, reject) => { + timeout = setTimeout(() => { + controller?.abort(); + reject(makeHttpTimeoutError(timeoutMs)); + }, timeoutMs); + }), + ]) + : await fetchPromise; + } finally { + if (timeout !== null) { + clearTimeout(timeout); + } + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); +} + +function expandCandidateIps(options = {}) { + if (Array.isArray(options.candidateIps) && options.candidateIps.length > 0) { + return options.candidateIps; + } + + if (typeof options.subnetPrefix === "string") { + const start = Number.isInteger(options.startHost) ? options.startHost : 1; + const end = Number.isInteger(options.endHost) ? options.endHost : 20; + const ips = []; + for (let host = start; host <= end; host += 1) { + ips.push(`${options.subnetPrefix}.${host}`); + } + return ips; + } + + return []; +} + +function normalizeShellyDeviceGeneration(value) { + if (Number.isInteger(value) && value > 0) { + return value; + } + + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.trunc(value); + } + + return inferShellyDeviceGenerationFromString(value); +} + +function inferShellyDeviceGenerationFromString(value) { + const normalized = String(value || "").trim(); + if (!normalized) { + return null; + } + + const explicit = normalized.match(/\bgen(?:eration)?\s*([1-9]\d*)\b/i); + if (explicit) { + return Number(explicit[1]); + } + + const upper = normalized.toUpperCase(); + const sSeries = upper.match(/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/); + if (sSeries) { + return Number(sSeries[1]); + } + + if (/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i.test(normalized) || /\bSP[A-Z0-9]+-[A-Z0-9-]+\b/.test(upper)) { + return 2; + } + + if (/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/.test(upper)) { + return 1; + } + + return null; +} + +function resolveShellyDeviceGeneration(identity = {}) { + for (const candidate of [ + identity.gen, + identity.generation, + identity.device_generation, + identity.capabilities?.generation, + ]) { + const generation = normalizeShellyDeviceGeneration(candidate); + if (generation !== null) { + return generation; + } + } + + for (const candidate of [ + identity.model, + identity.type, + identity.app, + identity.name, + identity.id, + identity.mac, + ]) { + const generation = inferShellyDeviceGenerationFromString(candidate); + if (generation !== null) { + return generation; + } + } + + return null; +} + +function resolveShellyCommandGeneration(payload = {}) { + return resolveShellyDeviceGeneration({ + gen: payload.gen ?? payload.generation ?? payload.deviceGeneration ?? payload.device_generation, + device_generation: payload.device_generation, + capabilities: payload.capabilities, + model: payload.model ?? payload.deviceModel ?? payload.device_model ?? payload.deviceType ?? payload.device_type, + type: payload.type ?? payload.deviceType ?? payload.device_type, + app: payload.app, + name: payload.name ?? payload.deviceName ?? payload.device_name, + id: payload.deviceId ?? payload.device_id ?? payload.relayId ?? payload.relay_id, + mac: payload.mac, + }); +} + +export async function discoverShellyDevices(options = {}, fetchImpl = fetch) { + const candidateIps = expandCandidateIps(options); + const discovered = []; + + await Promise.all(candidateIps.map(async (ip) => { + try { + const identity = await fetchJson(`http://${ip}/shelly`, fetchImpl, { + timeoutMs: resolveShellyLocalHttpTimeoutMs(options), + }); + discovered.push({ + id: identity.mac || identity.id || ip, + device_id: identity.mac || identity.id || ip, + local_ip: ip, + model: identity.model || identity.type || "Shelly", + channel_count: Number(identity.num_outputs || identity.num_switches || 1), + online: true, + capabilities: { + generation: resolveShellyDeviceGeneration(identity), + }, + metadata: identity, + }); + } catch { + // Ignore non-responsive candidates during opportunistic discovery. + } + })); + + return discovered; +} + +export async function getRelayStatus(payload, fetchImpl = fetch) { + const ip = payload.localIp || payload.local_ip || payload.ip; + const channel = Number.isInteger(payload.channel) ? payload.channel : 0; + const timeoutMs = resolveShellyLocalHttpTimeoutMs(payload); + if (!ip) { + throw new Error("Missing relay local IP"); + } + + const attempts = [ + async () => { + const rpc = await fetchJson(`http://${ip}/rpc/Switch.GetStatus?id=${channel}`, fetchImpl, { timeoutMs }); + return { + online: true, + on: Boolean(rpc.output), + raw: rpc, + }; + }, + async () => { + const legacy = await fetchJson(`http://${ip}/relay/${channel}`, fetchImpl, { timeoutMs }); + return { + online: true, + on: Boolean(legacy.ison ?? legacy.output), + raw: legacy, + }; + }, + ]; + + try { + return await Promise.any(attempts.map((attempt) => attempt())); + } catch (error) { + const errors = Array.isArray(error?.errors) ? error.errors : [error]; + const message = errors + .map((entry) => entry?.message || String(entry)) + .filter(Boolean) + .join("; "); + const relayStatusError = new Error(message ? `Unable to read relay status: ${message}` : "Unable to read relay status"); + relayStatusError.cause = error; + throw relayStatusError; + } +} + +export async function setRelayState(payload, fetchImpl = fetch) { + const ip = payload.localIp || payload.local_ip || payload.ip; + const channel = Number.isInteger(payload.channel) ? payload.channel : 0; + const on = Boolean(payload.on); + const timeoutMs = resolveShellyLocalHttpTimeoutMs(payload); + const toggleAfter = resolveRelayToggleAfterSeconds(payload); + const timerQuery = toggleAfter === null ? "" : `&toggle_after=${encodeURIComponent(String(toggleAfter))}`; + const legacyTimerQuery = toggleAfter === null ? "" : `&timer=${encodeURIComponent(String(toggleAfter))}`; + const deviceGeneration = resolveShellyCommandGeneration(payload); + if (!ip) { + throw new Error("Missing relay local IP"); + } + + const runRpcSwitch = async () => { + const rpc = await fetchJson(`http://${ip}/rpc/Switch.Set?id=${channel}&on=${on ? "true" : "false"}${timerQuery}`, fetchImpl, { timeoutMs }); + return { + online: true, + on: Boolean(rpc.output ?? on), + raw: rpc, + }; + }; + const runLegacySwitch = async () => { + const legacy = await fetchJson(`http://${ip}/relay/${channel}?turn=${on ? "on" : "off"}${legacyTimerQuery}`, fetchImpl, { timeoutMs }); + return { + online: true, + on: Boolean(legacy.ison ?? legacy.output ?? on), + raw: legacy, + }; + }; + + if (toggleAfter !== null) { + const attempts = deviceGeneration === 1 + ? [runLegacySwitch, runRpcSwitch] + : [runRpcSwitch, runLegacySwitch]; + + let lastError = null; + for (const attempt of attempts) { + try { + return await attempt(); + } catch (error) { + if (isHttpTimeoutError(error)) { + throw error; + } + lastError = error; + } + } + + throw lastError || new Error("Unable to switch relay"); + } + + try { + return await runRpcSwitch(); + } catch (error) { + if (isHttpTimeoutError(error)) { + throw error; + } + return await runLegacySwitch(); + } +} + +async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) { + if (!url) { + return null; + } + + const response = await fetchImpl(url); + if (!response.ok) { + throw new Error(`${label} download failed: HTTP ${response.status}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const sha256 = createHash("sha256").update(buffer).digest("hex"); + if (expectedSha256 && String(expectedSha256).toLowerCase() !== sha256.toLowerCase()) { + throw new Error(`${label} checksum mismatch`); + } + + return { + buffer, + sha256, + bytes: buffer.length, + }; +} + +async function installAgentDependencies(installDir, execFileImpl = execFile) { + await execFileImpl("npm", ["install", "--omit=dev"], { + cwd: installDir, + encoding: "utf8", + windowsHide: true, + }); +} + +async function runAgentStatusPreflight(agentPath, configPath, execFileImpl = execFile) { + await execFileImpl(process.execPath, [agentPath, "status", "--config", configPath], { + encoding: "utf8", + windowsHide: true, + }); +} + +function buildPendingUpdateState(payload, config, paths, backupDir) { + return { + targetVersion: String(payload.targetVersion || payload.target_version || config.targetVersion || config.installedVersion || DEFAULT_VERSION), + previousVersion: String(config.installedVersion || DEFAULT_VERSION), + releaseChannel: String(payload.releaseChannel || payload.release_channel || config.releaseChannel || "stable"), + requestedAt: formatUpdateTimestamp(), + backupDir, + installDir: paths.installDir, + serviceName: paths.serviceName, + restartMode: String(payload.restartMode || config.restartMode || (process.platform === "win32" ? "spawn" : "systemd")), + verificationTimeoutSeconds: resolveUpdateVerificationTimeoutSeconds(config), + }; +} + +function buildPreparedUpdatePayload({ + pendingUpdate, + agentArtifact, + packageArtifact, + paths, +}) { + return { + updated: true, + verification_pending: true, + target_version: pendingUpdate.targetVersion, + previous_version: pendingUpdate.previousVersion, + release_channel: pendingUpdate.releaseChannel, + restart_mode: pendingUpdate.restartMode, + verification_timeout_seconds: pendingUpdate.verificationTimeoutSeconds, + artifact_sha256: agentArtifact?.sha256 ?? null, + artifact_bytes: agentArtifact?.bytes ?? null, + package_sha256: packageArtifact?.sha256 ?? null, + package_bytes: packageArtifact?.bytes ?? null, + install_dir: paths.installDir, + }; +} + +async function restorePreparedUpdate(paths, pendingUpdate, originalConfig, { + execFileImpl = execFile, + liveConfig = null, + rollbackMessage = "Edge agent update failed", +} = {}) { + const backupDir = pendingUpdate?.backupDir; + if (!backupDir) { + if (originalConfig && pendingUpdate !== null) { + await saveConfig(paths.configPath, originalConfig); + if (liveConfig) { + replaceObjectContents(liveConfig, cloneJson(originalConfig)); + } + } + return { + rolledBack: false, + restoredVersion: originalConfig?.installedVersion || null, + }; + } + + await restoreFileIfPresent(path.join(backupDir, "agent.mjs"), paths.agentPath); + await restoreFileIfPresent(path.join(backupDir, "package.json"), paths.packagePath); + + try { + await installAgentDependencies(paths.installDir, execFileImpl); + } catch { + // Prefer preserving the restored files and rollback metadata over surfacing a secondary npm failure here. + } + + const nextConfig = { + ...(cloneJson(originalConfig) || {}), + targetVersion: pendingUpdate.previousVersion || originalConfig?.targetVersion || originalConfig?.installedVersion || DEFAULT_VERSION, + installedVersion: pendingUpdate.previousVersion || originalConfig?.installedVersion || DEFAULT_VERSION, + pendingUpdate: null, + lastUpdate: { + state: "ROLLED_BACK", + targetVersion: pendingUpdate.targetVersion ?? null, + previousVersion: pendingUpdate.previousVersion ?? null, + restoredVersion: pendingUpdate.previousVersion ?? null, + error: rollbackMessage, + completedAt: formatUpdateTimestamp(), + }, + }; + + await saveConfig(paths.configPath, nextConfig); + if (liveConfig) { + replaceObjectContents(liveConfig, cloneJson(nextConfig)); + } + + return { + rolledBack: true, + restoredVersion: nextConfig.installedVersion || null, + }; +} + +export async function rollbackPendingUpdate(configPath, { + config = null, + execFileImpl = execFile, + liveConfig = null, + reason = "Edge agent update verification failed", +} = {}) { + const currentConfig = cloneJson(config || (await loadConfig(configPath))); + const pendingUpdate = currentConfig.pendingUpdate; + if (!pendingUpdate || typeof pendingUpdate !== "object") { + return { + rolledBack: false, + restoredVersion: currentConfig.installedVersion || null, + }; + } + + const paths = resolveUpdatePaths(configPath, currentConfig); + const originalConfig = { + ...currentConfig, + pendingUpdate: null, + lastUpdate: currentConfig.lastUpdate ?? null, + }; + originalConfig.installedVersion = pendingUpdate.previousVersion || originalConfig.installedVersion || DEFAULT_VERSION; + originalConfig.targetVersion = pendingUpdate.previousVersion || originalConfig.targetVersion || originalConfig.installedVersion; + + return restorePreparedUpdate(paths, pendingUpdate, originalConfig, { + execFileImpl, + liveConfig, + rollbackMessage: reason, + }); +} + +export async function finalizePendingUpdateOnStartup(config, configPath, { + liveConfig = null, +} = {}) { + const pendingUpdate = config?.pendingUpdate; + if (!pendingUpdate || typeof pendingUpdate !== "object") { + return config; + } + + const nextConfig = { + ...cloneJson(config), + installedVersion: + pendingUpdate.targetVersion || + config.targetVersion || + config.installedVersion || + DEFAULT_VERSION, + targetVersion: + pendingUpdate.targetVersion || + config.targetVersion || + config.installedVersion || + DEFAULT_VERSION, + releaseChannel: pendingUpdate.releaseChannel || config.releaseChannel || "stable", + pendingUpdate: null, + lastUpdate: { + state: "COMPLETED", + targetVersion: pendingUpdate.targetVersion ?? null, + previousVersion: pendingUpdate.previousVersion ?? null, + restoredVersion: null, + error: null, + completedAt: formatUpdateTimestamp(), + }, + }; + + await saveConfig(configPath, nextConfig); + if (liveConfig) { + replaceObjectContents(liveConfig, cloneJson(nextConfig)); + return liveConfig; + } + + return nextConfig; +} + +function buildUpdateRestartPlan(payload, configPath, config = {}) { + const paths = resolveUpdatePaths(configPath, config); + return { + configPath: paths.configPath, + agentPath: paths.agentPath, + serviceName: String( + payload.serviceName || + payload.service_name || + config.serviceName || + DEFAULT_EDGE_AGENT_SERVICE_NAME + ), + restartMode: String( + payload.restartMode || + payload.restart_mode || + config.restartMode || + (process.platform === "win32" ? "spawn" : "systemd") + ), + restartGraceMs: DEFAULT_UPDATE_RESTART_GRACE_MS, + }; +} + +function buildUninstallPlan(payload = {}, configPath, config = {}) { + const paths = resolveUpdatePaths(configPath, config); + return { + configPath: paths.configPath, + serviceName: String(payload.serviceName || config.serviceName || paths.serviceName || DEFAULT_EDGE_AGENT_SERVICE_NAME), + restartMode: String(config.restartMode || (process.platform === "win32" ? "spawn" : "systemd")), + cleanupDelayMs: DEFAULT_UPDATE_RESTART_GRACE_MS, + }; +} + +function escapeShellArgument(value) { + return `'${String(value || "").replace(/'/g, `'\\''`)}'`; +} + +async function persistUninstallState(configPath, config, { liveConfig = null } = {}) { + const currentConfig = cloneJson(config || (await loadConfig(configPath))); + const nextConfig = { + ...currentConfig, + gatewayId: null, + agentToken: null, + installToken: null, + brokerUrl: null, + pendingUpdate: null, + lastUninstall: { + state: "SCHEDULED", + completedAt: formatUpdateTimestamp(), + }, + }; + + await saveConfig(configPath, nextConfig); + if (liveConfig) { + replaceObjectContents(liveConfig, cloneJson(nextConfig)); + } + + return nextConfig; +} + +async function scheduleAgentUninstall(uninstallPlan, { + spawnImpl = spawnCallback, + waitImpl = wait, + exitProcessImpl = (code) => process.exit(code), +} = {}) { + if (uninstallPlan.restartMode === "systemd" && process.platform !== "win32") { + const serviceName = escapeShellArgument(uninstallPlan.serviceName || DEFAULT_EDGE_AGENT_SERVICE_NAME); + const uninstallProcess = spawnImpl( + "/bin/sh", + [ + "-lc", + `sleep 1; systemctl disable --now ${serviceName} >/dev/null 2>&1 || true; systemctl reset-failed ${serviceName} >/dev/null 2>&1 || true`, + ], + { + detached: true, + stdio: "ignore", + windowsHide: true, + } + ); + uninstallProcess.unref?.(); + } + + await waitImpl(uninstallPlan.cleanupDelayMs ?? DEFAULT_UPDATE_RESTART_GRACE_MS); + exitProcessImpl(0); +} + +async function startDetachedUpdateVerifier(configPath, config, { + spawnImpl = spawnCallback, +} = {}) { + const paths = resolveUpdatePaths(configPath, config); + const verifier = spawnImpl( + process.execPath, + [paths.agentPath, UPDATE_VERIFY_COMMAND, "--config", paths.configPath], + { + detached: true, + stdio: "ignore", + windowsHide: true, + } + ); + verifier.unref?.(); + return true; +} + +async function restartAgentAfterUpdate(restartPlan, { + spawnImpl = spawnCallback, + waitImpl = wait, + exitProcessImpl = (code) => process.exit(code), +} = {}) { + if (restartPlan.restartMode === "systemd" && process.platform !== "win32") { + const restartProcess = spawnImpl("systemctl", ["restart", restartPlan.serviceName], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + restartProcess.unref?.(); + } else { + const nextProcess = spawnImpl(process.execPath, [restartPlan.agentPath, "--config", restartPlan.configPath], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + nextProcess.unref?.(); + } + + await waitImpl(restartPlan.restartGraceMs ?? DEFAULT_UPDATE_RESTART_GRACE_MS); + exitProcessImpl(0); +} + +async function resumeAgentAfterRollback(configPath, config, { + spawnImpl = spawnCallback, +} = {}) { + const restartPlan = buildUpdateRestartPlan({}, configPath, config); + if (restartPlan.restartMode === "systemd" && process.platform !== "win32") { + const restartProcess = spawnImpl("systemctl", ["restart", restartPlan.serviceName], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + restartProcess.unref?.(); + return; + } + + const nextProcess = spawnImpl(process.execPath, [restartPlan.agentPath, "--config", restartPlan.configPath], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + nextProcess.unref?.(); +} + +export async function verifyPendingUpdate(configPath, { + waitImpl = wait, + execFileImpl = execFile, + spawnImpl = spawnCallback, + verifyIntervalMs = DEFAULT_UPDATE_VERIFY_INTERVAL_MS, + timeoutMs = null, +} = {}) { + const config = await loadConfig(configPath); + const pendingUpdate = config.pendingUpdate; + if (!pendingUpdate || typeof pendingUpdate !== "object") { + return { + verified: false, + skipped: true, + reason: "No pending update verification state", + }; + } + + const effectiveTimeoutMs = + Number.isFinite(timeoutMs) && timeoutMs !== null + ? Math.max(1, Number(timeoutMs)) + : resolveUpdateVerificationTimeoutSeconds(config, pendingUpdate) * 1000; + const deadline = Date.now() + effectiveTimeoutMs; + while (Date.now() < deadline) { + const latestConfig = await loadConfig(configPath).catch(() => null); + if (latestConfig && !latestConfig.pendingUpdate) { + return { + verified: true, + state: latestConfig.lastUpdate?.state || null, + }; + } + + await waitImpl(Math.max(25, verifyIntervalMs)); + } + + const rollbackReason = `Edge agent update to ${pendingUpdate.targetVersion || "unknown"} did not pass startup verification`; + const rollbackResult = await rollbackPendingUpdate(configPath, { + config, + execFileImpl, + reason: rollbackReason, + }); + await resumeAgentAfterRollback(configPath, await loadConfig(configPath), { + spawnImpl, + }); + + return { + verified: false, + rolledBack: rollbackResult.rolledBack, + restoredVersion: rollbackResult.restoredVersion || null, + reason: rollbackReason, + }; +} + +export async function runUpdate(payload, fetchImpl = fetch, deps = {}) { + if (!payload.artifactUrl) { + return { + updated: false, + skipped: true, + reason: "No artifact URL provided", + }; + } + + const configPath = deps.configPath; + if (!configPath) { + throw new Error("Missing config path for edge agent update"); + } + + const liveConfig = deps.liveConfig && typeof deps.liveConfig === "object" ? deps.liveConfig : null; + const currentConfig = cloneJson(deps.config || liveConfig || (await loadConfig(configPath))); + const originalConfig = cloneJson(currentConfig); + const paths = resolveUpdatePaths(configPath, currentConfig); + const safeTargetVersion = sanitizeUpdateSegment( + payload.targetVersion || payload.target_version || currentConfig.targetVersion || currentConfig.installedVersion + ); + const backupDir = path.join(paths.updatesDir, `${Date.now()}-${safeTargetVersion}`); + + await ensureDirectory(paths.installDir); + await ensureDirectory(paths.updatesDir); + + const agentArtifact = await fetchArtifactBuffer( + payload.artifactUrl, + payload.sha256 || payload.artifactSha256 || payload.artifact_sha256, + "Agent artifact", + fetchImpl + ); + const packageArtifact = await fetchArtifactBuffer( + payload.packageUrl || payload.package_url, + payload.packageSha256 || payload.package_sha256, + "Package manifest", + fetchImpl + ); + + let pendingUpdate = null; + try { + await ensureDirectory(backupDir); + await backupFileIfPresent(paths.agentPath, path.join(backupDir, "agent.mjs")); + await backupFileIfPresent(paths.packagePath, path.join(backupDir, "package.json")); + + if (packageArtifact) { + await writeBuffer(paths.packagePath, packageArtifact.buffer); + } + await writeBuffer(paths.agentPath, agentArtifact.buffer); + + await installAgentDependencies(paths.installDir, deps.execFileImpl || execFile); + await runAgentStatusPreflight(paths.agentPath, paths.configPath, deps.execFileImpl || execFile); + + pendingUpdate = buildPendingUpdateState(payload, currentConfig, paths, backupDir); + const nextConfig = { + ...currentConfig, + targetVersion: pendingUpdate.targetVersion, + pendingUpdate, + lastUpdate: null, + }; + await saveConfig(paths.configPath, nextConfig); + if (liveConfig) { + replaceObjectContents(liveConfig, cloneJson(nextConfig)); + } + + return { + __agentCommandEnvelope: true, + payload: buildPreparedUpdatePayload({ + pendingUpdate, + agentArtifact, + packageArtifact, + paths, + }), + followUp: { + type: "RUN_UPDATE", + restartPlan: buildUpdateRestartPlan(pendingUpdate, paths.configPath, nextConfig), + }, + }; + } catch (error) { + await restorePreparedUpdate(paths, pendingUpdate || buildPendingUpdateState(payload, currentConfig, paths, backupDir), originalConfig, { + execFileImpl: deps.execFileImpl || execFile, + liveConfig, + rollbackMessage: createUpdateErrorMessage(error), + }); + throw error; + } +} + +export async function runUninstall(payload = {}, deps = {}) { + const configPath = deps.configPath; + if (!configPath) { + throw new Error("Missing config path for edge agent uninstall"); + } + + const liveConfig = deps.liveConfig && typeof deps.liveConfig === "object" ? deps.liveConfig : null; + const currentConfig = cloneJson(deps.config || liveConfig || (await loadConfig(configPath))); + + return { + __agentCommandEnvelope: true, + payload: { + uninstall_scheduled: true, + scheduled_at: formatUpdateTimestamp(), + service_name: String(payload.serviceName || currentConfig.serviceName || DEFAULT_EDGE_AGENT_SERVICE_NAME), + }, + followUp: { + type: "UNINSTALL_AGENT", + uninstallPlan: buildUninstallPlan(payload, configPath, currentConfig), + }, + }; +} + +function defaultShellCommand() { + if (process.platform === "win32") { + return { command: process.env.ComSpec || "cmd.exe", args: [] }; + } + return { command: process.env.SHELL || "/bin/sh", args: [] }; +} + +function normalizeShellSize(value, fallback) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : fallback; +} + +async function createDefaultPtyProcess(options = {}) { + const nodePtyModule = await import("node-pty"); + const spawnPty = + nodePtyModule.spawn ?? + nodePtyModule.default?.spawn ?? + nodePtyModule.default; + + if (typeof spawnPty !== "function") { + throw new Error("node-pty spawn is unavailable"); + } + + return spawnPty(options.command, options.args || [], { + name: options.env?.TERM || "xterm-256color", + cols: normalizeShellSize(options.cols, DEFAULT_SHELL_COLS), + rows: normalizeShellSize(options.rows, DEFAULT_SHELL_ROWS), + cwd: options.cwd || process.cwd(), + env: options.env || process.env, + }); +} + +export function createShellBridge(sendMessage, { createPtyProcess = createDefaultPtyProcess } = {}) { + const sessions = new Map(); + + const open = async (payload = {}) => { + const sessionId = String(payload.sessionId || ""); + if (sessionId === "") { + return; + } + + const existingSession = sessions.get(sessionId); + if (existingSession) { + existingSession.pty.kill(); + sessions.delete(sessionId); + } + + const shell = payload.shellCommand + ? { command: payload.shellCommand, args: payload.shellArgs || [] } + : defaultShellCommand(); + + try { + const pty = await Promise.resolve(createPtyProcess({ + command: shell.command, + args: shell.args, + cwd: payload.cwd || process.cwd(), + cols: normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS), + rows: normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS), + env: { + ...process.env, + TERM: payload.term || process.env.TERM || "xterm-256color", + }, + })); + + const sessionRecord = { + pty, + dataSubscription: null, + exitSubscription: null, + }; + + sessionRecord.dataSubscription = pty.onData((data) => { + sendMessage({ type: "SHELL_OUTPUT", sessionId, data: String(data || "") }); + }); + + sessionRecord.exitSubscription = pty.onExit(({ exitCode }) => { + sessions.delete(sessionId); + sessionRecord.dataSubscription?.dispose?.(); + sessionRecord.exitSubscription?.dispose?.(); + sendMessage({ type: "SHELL_EXIT", sessionId, code: Number.isFinite(exitCode) ? exitCode : 0 }); + }); + + sessions.set(sessionId, sessionRecord); + sendMessage({ type: "SHELL_OPENED", sessionId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendMessage({ + type: "SHELL_OUTPUT", + sessionId, + data: `Failed to start root shell: ${message}\r\n`, + }); + sendMessage({ type: "SHELL_EXIT", sessionId, code: 1, reason: "shell_spawn_failed", message }); + } + }; + + const input = (payload = {}) => { + const sessionRecord = sessions.get(String(payload.sessionId || "")); + if (!sessionRecord) { + return; + } + sessionRecord.pty.write(String(payload.data || "")); + }; + + const resize = (payload = {}) => { + const sessionRecord = sessions.get(String(payload.sessionId || "")); + if (!sessionRecord) { + return; + } + + sessionRecord.pty.resize( + normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS), + normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS) + ); + }; + + const close = (payload = {}) => { + const sessionId = String(payload.sessionId || ""); + const sessionRecord = sessions.get(sessionId); + if (!sessionRecord) { + return; + } + + sessionRecord.pty.kill(); + }; + + const dispose = () => { + for (const sessionId of sessions.keys()) { + close({ sessionId }); + } + }; + + return { open, input, resize, close, dispose }; +} + +export async function handleAgentCommand(command, deps = {}) { + const fetchImpl = deps.fetchImpl || fetch; + switch (command.commandType) { + case "DISCOVER_SHELLY": + return { inventory: await discoverShellyDevices(command.payload || {}, fetchImpl) }; + case "GET_RELAY_STATUS": + return await getRelayStatus(command.payload || {}, fetchImpl); + case "SET_RELAY_STATE": + return await setRelayState(command.payload || {}, fetchImpl); + case "RUN_UPDATE": + return await runUpdate(command.payload || {}, fetchImpl, deps); + case "UNINSTALL_AGENT": + return await runUninstall(command.payload || {}, deps); + case "RESTART_AGENT": + return { restarted: true }; + case "REBOOT_HOST": + return { rebooted: true }; + default: + throw new Error(`Unsupported agent command: ${command.commandType}`); + } +} + +export function buildHeartbeatPayload(config, extra = {}) { + const lastUpdateMetadata = buildUpdateMetadata(config); + const payload = { + hostname: os.hostname(), + installed_version: config.installedVersion || DEFAULT_VERSION, + target_version: config.targetVersion || config.installedVersion || DEFAULT_VERSION, + status: extra.status || "ONLINE", + metadata: { + release_channel: config.releaseChannel || "stable", + ...extra.metadata, + }, + }; + + if (lastUpdateMetadata) { + payload.metadata.last_update = lastUpdateMetadata; + } + + if (Object.prototype.hasOwnProperty.call(extra, "discovery_status")) { + payload.discovery_status = extra.discovery_status; + } + + if (Array.isArray(extra.inventory)) { + payload.inventory = extra.inventory; + } + + return payload; +} + +export async function sendHeartbeat(config, fetchImpl = fetch, extra = {}) { + if (!config.gatewayId || !config.agentToken) { + throw new Error("Gateway claim must complete before heartbeat"); + } + return apiRequest( + config.apiUrl, + `/edge-agent/gateways/${config.gatewayId}/heartbeat`, + "POST", + { + agent_token: config.agentToken, + ...buildHeartbeatPayload(config, extra), + }, + fetchImpl + ); +} + +async function sendTimedHeartbeat(config, fetchImpl = fetch, extra = {}) { + const startedAt = Date.now(); + const data = await sendHeartbeat(config, fetchImpl, extra); + + return { + data, + latencyMs: Math.max(0, Date.now() - startedAt), + }; +} + +export async function pollCommandJob(config, fetchImpl = fetch, waitSeconds = 20) { + if (!config.gatewayId || !config.agentToken) { + throw new Error("Gateway claim must complete before polling commands"); + } + + return apiRequest( + config.apiUrl, + `/edge-agent/gateways/${config.gatewayId}/commands/poll`, + "POST", + { + agent_token: config.agentToken, + wait_seconds: waitSeconds, + }, + fetchImpl + ); +} + +export async function submitCommandJobResult(config, jobId, { ok, payload = {}, error = null } = {}, fetchImpl = fetch) { + if (!config.gatewayId || !config.agentToken) { + throw new Error("Gateway claim must complete before submitting command results"); + } + + return apiRequest( + config.apiUrl, + `/edge-agent/gateways/${config.gatewayId}/commands/${jobId}/result`, + "POST", + { + agent_token: config.agentToken, + ok: Boolean(ok), + payload, + error, + }, + fetchImpl + ); +} + +export async function pollShellActionJob(config, fetchImpl = fetch, waitSeconds = 20) { + if (!config.gatewayId || !config.agentToken) { + throw new Error("Gateway claim must complete before polling shell actions"); + } + + return apiRequest( + config.apiUrl, + `/edge-agent/gateways/${config.gatewayId}/shell-actions/poll`, + "POST", + { + agent_token: config.agentToken, + wait_seconds: waitSeconds, + }, + fetchImpl + ); +} + +export async function submitShellActionJobResult(config, jobId, { ok, error = null } = {}, fetchImpl = fetch) { + if (!config.gatewayId || !config.agentToken) { + throw new Error("Gateway claim must complete before submitting shell action results"); + } + + return apiRequest( + config.apiUrl, + `/edge-agent/gateways/${config.gatewayId}/shell-actions/${jobId}/result`, + "POST", + { + agent_token: config.agentToken, + ok: Boolean(ok), + error, + }, + fetchImpl + ); +} + +export async function submitShellSessionEvents(config, sessionId, events = [], fetchImpl = fetch) { + if (!config.gatewayId || !config.agentToken) { + throw new Error("Gateway claim must complete before submitting shell events"); + } + + return apiRequest( + config.apiUrl, + `/edge-agent/gateways/${config.gatewayId}/shell-sessions/${sessionId}/events`, + "POST", + { + agent_token: config.agentToken, + events, + }, + fetchImpl + ); +} + +async function executeAgentCommandEnvelope(config, command, fetchImpl = fetch, deps = {}) { + const commandType = command?.commandType || command?.command_type; + const payload = command?.payload || {}; + + if (!commandType) { + return null; + } + + let followUp = null; + const result = await handleAgentCommand( + { commandType, payload }, + { + ...deps, + fetchImpl, + config, + configPath: payload.configPath || config.configPath || null, + liveConfig: config, + } + ); + + return { + commandType, + payload, + followUp: result && result.__agentCommandEnvelope === true ? (followUp = result.followUp || null, followUp) : null, + responsePayload: result && result.__agentCommandEnvelope === true ? result.payload || {} : result, + }; +} + +async function runAgentCommandFollowUp( + config, + execution, + followUpErrorMessage = "Edge agent restart failed after update", + deps = {} +) { + if (!execution || !execution.followUp?.type) { + return; + } + + if (execution.commandType === "RUN_UPDATE" && execution.followUp.type === "RUN_UPDATE") { + try { + await startDetachedUpdateVerifier(config.configPath || execution.payload?.configPath || null, config, { + spawnImpl: deps.spawnImpl, + }); + await restartAgentAfterUpdate(execution.followUp.restartPlan, { + spawnImpl: deps.spawnImpl, + waitImpl: deps.waitImpl, + exitProcessImpl: deps.exitProcessImpl, + }); + } catch (followUpError) { + await rollbackPendingUpdate(config.configPath || execution.payload?.configPath || null, { + config, + liveConfig: config, + reason: createUpdateErrorMessage(followUpError, followUpErrorMessage), + }).catch(() => {}); + } + return; + } + + if (execution.commandType === "UNINSTALL_AGENT" && execution.followUp.type === "UNINSTALL_AGENT") { + await persistUninstallState(config.configPath || execution.followUp.uninstallPlan?.configPath, config, { + liveConfig: config, + }); + await scheduleAgentUninstall(execution.followUp.uninstallPlan, { + spawnImpl: deps.spawnImpl, + waitImpl: deps.waitImpl, + exitProcessImpl: deps.exitProcessImpl, + }); + } +} + +export async function processPolledCommand(config, command, fetchImpl = fetch, deps = {}) { + const jobId = command?.id; + if (!jobId) { + return null; + } + + try { + const execution = await executeAgentCommandEnvelope(config, command, fetchImpl, deps); + if (!execution) { + return null; + } + + await submitCommandJobResult(config, jobId, { + ok: true, + payload: execution.responsePayload, + }, fetchImpl); + await runAgentCommandFollowUp(config, execution, "Edge agent restart failed after update", deps); + + return { ok: true, payload: execution.responsePayload }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await submitCommandJobResult(config, jobId, { + ok: false, + error: message, + }, fetchImpl); + return { ok: false, error: message }; + } +} + +function normalizeShellEvent(message) { + const sessionId = message?.sessionId ?? message?.session_id ?? null; + if (!sessionId) { + return null; + } + + if (message.type === "SHELL_OPENED") { + return { + sessionId: String(sessionId), + event: { + type: "OPENED", + payload: {}, + }, + }; + } + + if (message.type === "SHELL_OUTPUT") { + return { + sessionId: String(sessionId), + event: { + type: "OUTPUT", + payload: { + data: String(message.data || ""), + }, + }, + }; + } + + if (message.type === "SHELL_EXIT") { + const reason = String(message.reason || "agent_exit"); + return { + sessionId: String(sessionId), + event: { + type: "CLOSED", + payload: { + code: Number.isFinite(message.code) ? message.code : 0, + reason, + message: message.message ? String(message.message) : null, + }, + }, + }; + } + + return null; +} + +function createShellEventPublisher(config, fetchImpl = fetch) { + let queue = []; + let flushTimer = null; + let flushPromise = Promise.resolve(); + + const flush = () => { + const pending = queue; + queue = []; + + if (pending.length === 0) { + return flushPromise; + } + + const grouped = new Map(); + for (const item of pending) { + const normalized = normalizeShellEvent(item); + if (!normalized) { + continue; + } + + const events = grouped.get(normalized.sessionId) || []; + events.push(normalized.event); + grouped.set(normalized.sessionId, events); + } + + flushPromise = flushPromise + .catch(() => {}) + .then(async () => { + for (const [sessionId, events] of grouped.entries()) { + await submitShellSessionEvents(config, sessionId, events, fetchImpl); + } + }); + + return flushPromise; + }; + + const scheduleFlush = () => { + if (flushTimer !== null) { + return; + } + + flushTimer = setTimeout(() => { + flushTimer = null; + flush().catch(() => {}); + }, DEFAULT_SHELL_EVENT_FLUSH_DELAY_MS); + flushTimer.unref?.(); + }; + + return { + publish(message) { + queue.push(message); + if (message?.type === "SHELL_OPENED" || message?.type === "SHELL_EXIT") { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + flush().catch(() => {}); + return; + } + + scheduleFlush(); + }, + async drain() { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + await flush().catch(() => {}); + await flushPromise.catch(() => {}); + }, + }; +} + +export async function processPolledShellAction(config, action, shell, fetchImpl = fetch) { + const jobId = action?.id; + const actionType = action?.actionType || action?.action_type; + const payload = action?.payload || {}; + + if (!jobId || !actionType) { + return null; + } + + try { + if (!isShellAccessEnabled(config)) { + throw new Error("Shell access is disabled by local configuration"); + } + + if (actionType === "OPEN") { + await shell.open(payload); + } else if (actionType === "INPUT") { + shell.input(payload); + } else if (actionType === "RESIZE") { + shell.resize(payload); + } else if (actionType === "CLOSE") { + shell.close(payload); + } else { + throw new Error(`Unsupported shell action: ${actionType}`); + } + + await submitShellActionJobResult(config, jobId, { + ok: true, + }, fetchImpl); + return { ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await submitShellActionJobResult(config, jobId, { + ok: false, + error: message, + }, fetchImpl); + return { ok: false, error: message }; + } +} + +function createBrokerBridge({ + config, + shell, + fetchImpl = fetch, + reconnectDelayMs = DEFAULT_BROKER_RECONNECT_DELAY_MS, +} = {}) { + const state = { + url: config?.brokerUrl || null, + connected: false, + lastError: null, + disconnectReason: null, + lastConnectedAt: null, + lastDisconnectedAt: null, + lastCloseCode: null, + lastCloseClean: null, + }; + + let stopped = false; + let reconnectTimer = null; + let socket = null; + + const clearReconnectTimer = () => { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + + const send = (message) => { + if (!socket || socket.readyState !== WebSocket.OPEN) { + return false; + } + + socket.send(JSON.stringify(message)); + return true; + }; + + const scheduleReconnect = () => { + if (stopped || reconnectTimer || !state.url) { + return; + } + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, Math.max(250, reconnectDelayMs)); + }; + + const connect = () => { + if (stopped) { + return; + } + + const socketUrl = buildBrokerSocketUrl(state.url, config?.gatewayId, config?.agentToken); + if (!socketUrl) { + return; + } + + try { + socket = new WebSocket(socketUrl); + } catch (error) { + state.connected = false; + state.lastError = error instanceof Error ? error.message : String(error); + state.lastDisconnectedAt = formatUpdateTimestamp(); + scheduleReconnect(); + return; + } + + socket.onopen = () => { + state.connected = true; + state.lastError = null; + state.disconnectReason = null; + state.lastCloseCode = null; + state.lastCloseClean = null; + state.lastConnectedAt = formatUpdateTimestamp(); + }; + + socket.onmessage = async (event) => { + try { + const raw = await readSocketMessageText(event.data); + const message = JSON.parse(raw); + + if (message.type === "COMMAND") { + try { + const execution = await executeAgentCommandEnvelope(config, message, fetchImpl); + if (!execution) { + return; + } + + send({ + type: "COMMAND_RESULT", + commandId: message.commandId, + ok: true, + payload: execution.responsePayload, + }); + await runAgentCommandFollowUp(config, execution); + } catch (error) { + send({ + type: "COMMAND_RESULT", + commandId: message.commandId, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + return; + } + + if (message.type === "OPEN_ROOT_SHELL") { + if (!isShellAccessEnabled(config)) { + throw new Error("Shell access is disabled by local configuration"); + } + await shell.open(message.payload || {}); + return; + } + if (message.type === "SHELL_INPUT") { + if (!isShellAccessEnabled(config)) { + throw new Error("Shell access is disabled by local configuration"); + } + shell.input(message.payload || {}); + return; + } + if (message.type === "RESIZE_ROOT_SHELL") { + if (!isShellAccessEnabled(config)) { + throw new Error("Shell access is disabled by local configuration"); + } + shell.resize(message.payload || {}); + return; + } + if (message.type === "CLOSE_ROOT_SHELL") { + if (!isShellAccessEnabled(config)) { + throw new Error("Shell access is disabled by local configuration"); + } + shell.close(message.payload || {}); + } + } catch { + // Ignore malformed or unsupported broker messages so polling remains authoritative. + } + }; + + socket.onerror = (error) => { + state.lastError = normalizeBrokerSocketError(error, socketUrl); + }; + + socket.onclose = (event) => { + socket = null; + state.connected = false; + state.disconnectReason = event.reason || "broker_disconnected"; + state.lastCloseCode = Number.isFinite(Number(event.code)) ? Number(event.code) : null; + state.lastCloseClean = typeof event.wasClean === "boolean" ? event.wasClean : null; + if (state.lastCloseCode && state.lastCloseCode !== 1000 && !state.lastError) { + state.lastError = `Broker websocket closed with code ${state.lastCloseCode}`; + } + state.lastDisconnectedAt = formatUpdateTimestamp(); + if (!stopped) { + scheduleReconnect(); + } + }; + }; + + return { + state, + start() { + connect(); + }, + stop() { + stopped = true; + clearReconnectTimer(); + if (socket && socket.readyState <= WebSocket.OPEN) { + socket.close(); + } + socket = null; + state.connected = false; + }, + send, + }; +} + +export async function startAgent({ + configPath, + fetchImpl = fetch, + collectMetricsImpl = collectSystemMetrics, + createShellBridgeImpl = createShellBridge, +} = {}) { + if (!configPath) { + throw new Error("Missing --config path"); + } + + let config = await loadConfig(configPath); + config.configPath = configPath; + config = await claimIfNeeded(config, configPath, fetchImpl); + config.configPath = configPath; + config = await finalizePendingUpdateOnStartup(config, configPath, { + liveConfig: config, + }); + config.configPath = configPath; + const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000; + const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20); + const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000); + const brokerReconnectDelayMs = Number(config.brokerReconnectDelayMs || DEFAULT_BROKER_RECONNECT_DELAY_MS); + const shellActionPollTimeoutSeconds = Number(config.shellActionPollTimeoutSeconds || 20); + const shellActionPollRetryDelayMs = Number(config.shellActionPollRetryDelayMs || 1000); + + let stopped = false; + let cpuSnapshot = null; + let lastHeartbeatLatencyMs = null; + let brokerBridge = null; + const shellEventPublisher = createShellEventPublisher(config, fetchImpl); + const shell = createShellBridgeImpl((message) => { + brokerBridge?.send(message); + shellEventPublisher.publish(message); + }); + brokerBridge = createBrokerBridge({ + config, + shell, + fetchImpl, + reconnectDelayMs: brokerReconnectDelayMs, + }); + brokerBridge.start(); + + const sendTransportHeartbeat = async (extra = {}) => { + const transportState = buildTransportHeartbeatState(brokerBridge?.state || { url: config.brokerUrl || null }); + const collectedMetrics = await collectMetricsImpl({ + previousCpuSnapshot: cpuSnapshot, + latencyMs: lastHeartbeatLatencyMs, + }); + cpuSnapshot = collectedMetrics.cpuSnapshot ?? cpuSnapshot; + + const extraMetadata = + extra.metadata && typeof extra.metadata === "object" && !Array.isArray(extra.metadata) + ? extra.metadata + : {}; + const { system_metrics: extraSystemMetrics, ...extraMetadataFields } = extraMetadata; + const metricPayload = { + ...(collectedMetrics.metrics || {}), + ...(extraSystemMetrics && typeof extraSystemMetrics === "object" ? extraSystemMetrics : {}), + }; + + const heartbeatPayload = { + ...transportState, + ...extra, + metadata: { + ...(transportState.metadata || {}), + ...extraMetadataFields, + system_metrics: metricPayload, + }, + }; + + const response = await sendTimedHeartbeat(config, fetchImpl, heartbeatPayload); + lastHeartbeatLatencyMs = response.latencyMs; + return response.data; + }; + + const runCommandPollLoop = async () => { + while (!stopped) { + try { + const command = await pollCommandJob(config, fetchImpl, commandPollTimeoutSeconds); + if (stopped) { + break; + } + + if (!command) { + continue; + } + + await processPolledCommand(config, command, fetchImpl); + } catch { + if (stopped) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, commandPollRetryDelayMs)); + } + } + }; + + 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(() => {}); + }, intervalMs); + timer.unref?.(); + + const stop = async () => { + stopped = true; + clearInterval(timer); + brokerBridge?.stop(); + await shellEventPublisher.drain(); + shell.dispose(); + await Promise.allSettled([commandPollPromise, shellActionPollPromise]); + }; + + return { + brokerBridge, + commandPollPromise, + shellActionPollPromise, + timer, + config, + stop, + }; +} + +export function parseCliArgs(argv = process.argv.slice(2)) { + let command = "start"; + let configPath = null; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "status" && command === "start") { + command = "status"; + continue; + } + if (arg === UPDATE_VERIFY_COMMAND && command === "start") { + command = UPDATE_VERIFY_COMMAND; + continue; + } + if (arg === "--config") { + configPath = argv[index + 1] || null; + index += 1; + continue; + } + } + + return { command, configPath }; +} + +export async function runCli(argv = process.argv.slice(2)) { + const { command, configPath } = parseCliArgs(argv); + if (command === "status") { + const report = await getAgentStatus(configPath); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report; + } + if (command === UPDATE_VERIFY_COMMAND) { + return verifyPendingUpdate(configPath); + } + + return startAgent({ configPath }); +} + +const currentModulePath = fileURLToPath(import.meta.url); +const invokedModulePath = process.argv[1] ? path.resolve(process.argv[1]) : null; + +if (invokedModulePath && currentModulePath === invokedModulePath) { + runCli().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/services/edge-agent/dist/package.json b/services/edge-agent/dist/package.json new file mode 100644 index 00000000..bc9f7659 --- /dev/null +++ b/services/edge-agent/dist/package.json @@ -0,0 +1,8 @@ +{ + "name": "truckwash-edge-agent", + "private": true, + "type": "module", + "dependencies": { + "node-pty": "^1.1.0" + } +} diff --git a/services/edge-agent/node_modules/.package-lock.json b/services/edge-agent/node_modules/.package-lock.json new file mode 100644 index 00000000..15209251 --- /dev/null +++ b/services/edge-agent/node_modules/.package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "truckwash-edge-agent", + "lockfileVersion": 3, + "requires": true, + "packages": { + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + } + } +} diff --git a/services/edge-agent/node_modules/node-addon-api/LICENSE.md b/services/edge-agent/node_modules/node-addon-api/LICENSE.md new file mode 100644 index 00000000..819d91a5 --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2017 [Node.js API collaborators](https://github.com/nodejs/node-addon-api#collaborators) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/services/edge-agent/node_modules/node-addon-api/README.md b/services/edge-agent/node_modules/node-addon-api/README.md new file mode 100644 index 00000000..e90eb7c9 --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/README.md @@ -0,0 +1,319 @@ +NOTE: The default branch has been renamed! +master is now named main + +If you have a local clone, you can update it by running: + +```shell +git branch -m master main +git fetch origin +git branch -u origin/main main +``` + +# **node-addon-api module** +This module contains **header-only C++ wrapper classes** which simplify +the use of the C based [Node-API](https://nodejs.org/dist/latest/docs/api/n-api.html) +provided by Node.js when using C++. It provides a C++ object model +and exception handling semantics with low overhead. + +There are three options for implementing addons: Node-API, nan, or direct +use of internal V8, libuv, and Node.js libraries. Unless there is a need for +direct access to functionality that is not exposed by Node-API as outlined +in [C/C++ addons](https://nodejs.org/dist/latest/docs/api/addons.html) +in Node.js core, use Node-API. Refer to +[C/C++ addons with Node-API](https://nodejs.org/dist/latest/docs/api/n-api.html) +for more information on Node-API. + +Node-API is an ABI stable C interface provided by Node.js for building native +addons. It is independent of the underlying JavaScript runtime (e.g. V8 or ChakraCore) +and is maintained as part of Node.js itself. It is intended to insulate +native addons from changes in the underlying JavaScript engine and allow +modules compiled for one version to run on later versions of Node.js without +recompilation. + +The `node-addon-api` module, which is not part of Node.js, preserves the benefits +of the Node-API as it consists only of inline code that depends only on the stable API +provided by Node-API. As such, modules built against one version of Node.js +using node-addon-api should run without having to be rebuilt with newer versions +of Node.js. + +It is important to remember that *other* Node.js interfaces such as +`libuv` (included in a project via `#include `) are not ABI-stable across +Node.js major versions. Thus, an addon must use Node-API and/or `node-addon-api` +exclusively and build against a version of Node.js that includes an +implementation of Node-API (meaning an active LTS version of Node.js) in +order to benefit from ABI stability across Node.js major versions. Node.js +provides an [ABI stability guide][] containing a detailed explanation of ABI +stability in general, and the Node-API ABI stability guarantee in particular. + +As new APIs are added to Node-API, node-addon-api must be updated to provide +wrappers for those new APIs. For this reason, node-addon-api provides +methods that allow callers to obtain the underlying Node-API handles so +direct calls to Node-API and the use of the objects/methods provided by +node-addon-api can be used together. For example, in order to be able +to use an API for which the node-addon-api does not yet provide a wrapper. + +APIs exposed by node-addon-api are generally used to create and +manipulate JavaScript values. Concepts and operations generally map +to ideas specified in the **ECMA262 Language Specification**. + +The [Node-API Resource](https://nodejs.github.io/node-addon-examples/) offers an +excellent orientation and tips for developers just getting started with Node-API +and node-addon-api. + +- **[Setup](#setup)** +- **[API Documentation](#api)** +- **[Examples](#examples)** +- **[Tests](#tests)** +- **[More resource and info about native Addons](#resources)** +- **[Badges](#badges)** +- **[Code of Conduct](CODE_OF_CONDUCT.md)** +- **[Contributors](#contributors)** +- **[License](#license)** + +## **Current version: 7.1.1** + +(See [CHANGELOG.md](CHANGELOG.md) for complete Changelog) + +[![NPM](https://nodei.co/npm/node-addon-api.png?downloads=true&downloadRank=true)](https://nodei.co/npm/node-addon-api/) [![NPM](https://nodei.co/npm-dl/node-addon-api.png?months=6&height=1)](https://nodei.co/npm/node-addon-api/) + + + +node-addon-api is based on [Node-API](https://nodejs.org/api/n-api.html) and supports using different Node-API versions. +This allows addons built with it to run with Node.js versions which support the targeted Node-API version. +**However** the node-addon-api support model is to support only the active LTS Node.js versions. This means that +every year there will be a new major which drops support for the Node.js LTS version which has gone out of service. + +The oldest Node.js version supported by the current version of node-addon-api is Node.js 16.x. + +## Setup + - [Installation and usage](doc/setup.md) + - [node-gyp](doc/node-gyp.md) + - [cmake-js](doc/cmake-js.md) + - [Conversion tool](doc/conversion-tool.md) + - [Checker tool](doc/checker-tool.md) + - [Generator](doc/generator.md) + - [Prebuild tools](doc/prebuild_tools.md) + + + +### **API Documentation** + +The following is the documentation for node-addon-api. + + - [Full Class Hierarchy](doc/hierarchy.md) + - [Addon Structure](doc/addon.md) + - Data Types: + - [Env](doc/env.md) + - [CallbackInfo](doc/callbackinfo.md) + - [Reference](doc/reference.md) + - [Value](doc/value.md) + - [Name](doc/name.md) + - [Symbol](doc/symbol.md) + - [String](doc/string.md) + - [Number](doc/number.md) + - [Date](doc/date.md) + - [BigInt](doc/bigint.md) + - [Boolean](doc/boolean.md) + - [External](doc/external.md) + - [Object](doc/object.md) + - [Array](doc/array.md) + - [ObjectReference](doc/object_reference.md) + - [PropertyDescriptor](doc/property_descriptor.md) + - [Function](doc/function.md) + - [FunctionReference](doc/function_reference.md) + - [ObjectWrap](doc/object_wrap.md) + - [ClassPropertyDescriptor](doc/class_property_descriptor.md) + - [Buffer](doc/buffer.md) + - [ArrayBuffer](doc/array_buffer.md) + - [TypedArray](doc/typed_array.md) + - [TypedArrayOf](doc/typed_array_of.md) + - [DataView](doc/dataview.md) + - [Error Handling](doc/error_handling.md) + - [Error](doc/error.md) + - [TypeError](doc/type_error.md) + - [RangeError](doc/range_error.md) + - [SyntaxError](doc/syntax_error.md) + - [Object Lifetime Management](doc/object_lifetime_management.md) + - [HandleScope](doc/handle_scope.md) + - [EscapableHandleScope](doc/escapable_handle_scope.md) + - [Memory Management](doc/memory_management.md) + - [Async Operations](doc/async_operations.md) + - [AsyncWorker](doc/async_worker.md) + - [AsyncContext](doc/async_context.md) + - [AsyncWorker Variants](doc/async_worker_variants.md) + - [Thread-safe Functions](doc/threadsafe.md) + - [ThreadSafeFunction](doc/threadsafe_function.md) + - [TypedThreadSafeFunction](doc/typed_threadsafe_function.md) + - [Promises](doc/promises.md) + - [Version management](doc/version_management.md) + + + +### **Examples** + +Are you new to **node-addon-api**? Take a look at our **[examples](https://github.com/nodejs/node-addon-examples)** + +- **[Hello World](https://github.com/nodejs/node-addon-examples/tree/main/src/1-getting-started/1_hello_world)** +- **[Pass arguments to a function](https://github.com/nodejs/node-addon-examples/tree/main/src/1-getting-started/2_function_arguments/node-addon-api)** +- **[Callbacks](https://github.com/nodejs/node-addon-examples/tree/main/src/1-getting-started/3_callbacks/node-addon-api)** +- **[Object factory](https://github.com/nodejs/node-addon-examples/tree/main/src/1-getting-started/4_object_factory/node-addon-api)** +- **[Function factory](https://github.com/nodejs/node-addon-examples/tree/main/src/1-getting-started/5_function_factory/node-addon-api)** +- **[Wrapping C++ Object](https://github.com/nodejs/node-addon-examples/tree/main/src/1-getting-started/6_object_wrap/node-addon-api)** +- **[Factory of wrapped object](https://github.com/nodejs/node-addon-examples/tree/main/src/1-getting-started/7_factory_wrap/node-addon-api)** +- **[Passing wrapped object around](https://github.com/nodejs/node-addon-examples/tree/main/src/2-js-to-native-conversion/8_passing_wrapped/node-addon-api)** + + + +### **Tests** + +To run the **node-addon-api** tests do: + +``` +npm install +npm test +``` + +To avoid testing the deprecated portions of the API run +``` +npm install +npm test --disable-deprecated +``` + +To run the tests targeting a specific version of Node-API run +``` +npm install +export NAPI_VERSION=X +npm test --NAPI_VERSION=X +``` + +where X is the version of Node-API you want to target. + +To run a specific unit test, filter conditions are available + +**Example:** + compile and run only tests on objectwrap.cc and objectwrap.js + ``` + npm run unit --filter=objectwrap + ``` + +Multiple unit tests cane be selected with wildcards + +**Example:** +compile and run all test files ending with "reference" -> function_reference.cc, object_reference.cc, reference.cc + ``` + npm run unit --filter=*reference + ``` + +Multiple filter conditions can be joined to broaden the test selection + +**Example:** + compile and run all tests under folders threadsafe_function and typed_threadsafe_function and also the objectwrap.cc file + npm run unit --filter='*function objectwrap' + +### **Debug** + +To run the **node-addon-api** tests with `--debug` option: + +``` +npm run-script dev +``` + +If you want a faster build, you might use the following option: + +``` +npm run-script dev:incremental +``` + +Take a look and get inspired by our **[test suite](https://github.com/nodejs/node-addon-api/tree/HEAD/test)** + +### **Benchmarks** + +You can run the available benchmarks using the following command: + +``` +npm run-script benchmark +``` + +See [benchmark/README.md](benchmark/README.md) for more details about running and adding benchmarks. + + + +### **More resource and info about native Addons** +- **[C++ Addons](https://nodejs.org/dist/latest/docs/api/addons.html)** +- **[Node-API](https://nodejs.org/dist/latest/docs/api/n-api.html)** +- **[Node-API - Next Generation Node API for Native Modules](https://youtu.be/-Oniup60Afs)** +- **[How We Migrated Realm JavaScript From NAN to Node-API](https://developer.mongodb.com/article/realm-javascript-nan-to-n-api)** + +As node-addon-api's core mission is to expose the plain C Node-API as C++ +wrappers, tools that facilitate n-api/node-addon-api providing more +convenient patterns for developing a Node.js add-on with n-api/node-addon-api +can be published to NPM as standalone packages. It is also recommended to tag +such packages with `node-addon-api` to provide more visibility to the community. + +Quick links to NPM searches: [keywords:node-addon-api](https://www.npmjs.com/search?q=keywords%3Anode-addon-api). + + + +### **Other bindings** + +- **[napi-rs](https://napi.rs)** - (`Rust`) + + + +### **Badges** + +The use of badges is recommended to indicate the minimum version of Node-API +required for the module. This helps to determine which Node.js major versions are +supported. Addon maintainers can consult the [Node-API support matrix][] to determine +which Node.js versions provide a given Node-API version. The following badges are +available: + +![Node-API v1 Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20v1%20Badge.svg) +![Node-API v2 Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20v2%20Badge.svg) +![Node-API v3 Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20v3%20Badge.svg) +![Node-API v4 Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20v4%20Badge.svg) +![Node-API v5 Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20v5%20Badge.svg) +![Node-API v6 Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20v6%20Badge.svg) +![Node-API v7 Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20v7%20Badge.svg) +![Node-API v8 Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20v8%20Badge.svg) +![Node-API v9 Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20v9%20Badge.svg) +![Node-API Experimental Version Badge](https://github.com/nodejs/abi-stable-node/blob/doc/assets/Node-API%20Experimental%20Version%20Badge.svg) + +## **Contributing** + +We love contributions from the community to **node-addon-api**! +See [CONTRIBUTING.md](CONTRIBUTING.md) for more details on our philosophy around extending this module. + + + +## Team members + +### Active +| Name | GitHub Link | +| ------------------- | ----------------------------------------------------- | +| Anna Henningsen | [addaleax](https://github.com/addaleax) | +| Chengzhong Wu | [legendecas](https://github.com/legendecas) | +| Jack Xia | [JckXia](https://github.com/JckXia) | +| Kevin Eady | [KevinEady](https://github.com/KevinEady) | +| Michael Dawson | [mhdawson](https://github.com/mhdawson) | +| Nicola Del Gobbo | [NickNaso](https://github.com/NickNaso) | +| Vladimir Morozov | [vmoroz](https://github.com/vmoroz) | + +### Emeritus +| Name | GitHub Link | +| ------------------- | ----------------------------------------------------- | +| Arunesh Chandra | [aruneshchandra](https://github.com/aruneshchandra) | +| Benjamin Byholm | [kkoopa](https://github.com/kkoopa) | +| Gabriel Schulhof | [gabrielschulhof](https://github.com/gabrielschulhof) | +| Hitesh Kanwathirtha | [digitalinfinity](https://github.com/digitalinfinity) | +| Jason Ginchereau | [jasongin](https://github.com/jasongin) | +| Jim Schlight | [jschlight](https://github.com/jschlight) | +| Sampson Gao | [sampsongao](https://github.com/sampsongao) | +| Taylor Woll | [boingoing](https://github.com/boingoing) | + + + +Licensed under [MIT](./LICENSE.md) + +[ABI stability guide]: https://nodejs.org/en/docs/guides/abi-stability/ +[Node-API support matrix]: https://nodejs.org/dist/latest/docs/api/n-api.html#n_api_n_api_version_matrix diff --git a/services/edge-agent/node_modules/node-addon-api/common.gypi b/services/edge-agent/node_modules/node-addon-api/common.gypi new file mode 100644 index 00000000..06c0176b --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/common.gypi @@ -0,0 +1,20 @@ +{ + 'variables': { + 'NAPI_VERSION%': " +inline PropertyDescriptor PropertyDescriptor::Accessor( + const char* utf8name, + Getter getter, + napi_property_attributes attributes, + void* /*data*/) { + using CbData = details::CallbackData; + // TODO: Delete when the function is destroyed + auto callbackData = new CbData({getter, nullptr}); + + return PropertyDescriptor({utf8name, + nullptr, + nullptr, + CbData::Wrapper, + nullptr, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + const std::string& utf8name, + Getter getter, + napi_property_attributes attributes, + void* data) { + return Accessor(utf8name.c_str(), getter, attributes, data); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + napi_value name, + Getter getter, + napi_property_attributes attributes, + void* /*data*/) { + using CbData = details::CallbackData; + // TODO: Delete when the function is destroyed + auto callbackData = new CbData({getter, nullptr}); + + return PropertyDescriptor({nullptr, + name, + nullptr, + CbData::Wrapper, + nullptr, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + Name name, Getter getter, napi_property_attributes attributes, void* data) { + napi_value nameValue = name; + return PropertyDescriptor::Accessor(nameValue, getter, attributes, data); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + const char* utf8name, + Getter getter, + Setter setter, + napi_property_attributes attributes, + void* /*data*/) { + using CbData = details::AccessorCallbackData; + // TODO: Delete when the function is destroyed + auto callbackData = new CbData({getter, setter, nullptr}); + + return PropertyDescriptor({utf8name, + nullptr, + nullptr, + CbData::GetterWrapper, + CbData::SetterWrapper, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + const std::string& utf8name, + Getter getter, + Setter setter, + napi_property_attributes attributes, + void* data) { + return Accessor(utf8name.c_str(), getter, setter, attributes, data); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + napi_value name, + Getter getter, + Setter setter, + napi_property_attributes attributes, + void* /*data*/) { + using CbData = details::AccessorCallbackData; + // TODO: Delete when the function is destroyed + auto callbackData = new CbData({getter, setter, nullptr}); + + return PropertyDescriptor({nullptr, + name, + nullptr, + CbData::GetterWrapper, + CbData::SetterWrapper, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + Name name, + Getter getter, + Setter setter, + napi_property_attributes attributes, + void* data) { + napi_value nameValue = name; + return PropertyDescriptor::Accessor( + nameValue, getter, setter, attributes, data); +} + +template +inline PropertyDescriptor PropertyDescriptor::Function( + const char* utf8name, + Callable cb, + napi_property_attributes attributes, + void* /*data*/) { + using ReturnType = decltype(cb(CallbackInfo(nullptr, nullptr))); + using CbData = details::CallbackData; + // TODO: Delete when the function is destroyed + auto callbackData = new CbData({cb, nullptr}); + + return PropertyDescriptor({utf8name, + nullptr, + CbData::Wrapper, + nullptr, + nullptr, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Function( + const std::string& utf8name, + Callable cb, + napi_property_attributes attributes, + void* data) { + return Function(utf8name.c_str(), cb, attributes, data); +} + +template +inline PropertyDescriptor PropertyDescriptor::Function( + napi_value name, + Callable cb, + napi_property_attributes attributes, + void* /*data*/) { + using ReturnType = decltype(cb(CallbackInfo(nullptr, nullptr))); + using CbData = details::CallbackData; + // TODO: Delete when the function is destroyed + auto callbackData = new CbData({cb, nullptr}); + + return PropertyDescriptor({nullptr, + name, + CbData::Wrapper, + nullptr, + nullptr, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Function( + Name name, Callable cb, napi_property_attributes attributes, void* data) { + napi_value nameValue = name; + return PropertyDescriptor::Function(nameValue, cb, attributes, data); +} + +#endif // !SRC_NAPI_INL_DEPRECATED_H_ diff --git a/services/edge-agent/node_modules/node-addon-api/napi-inl.h b/services/edge-agent/node_modules/node-addon-api/napi-inl.h new file mode 100644 index 00000000..a5ae7af7 --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/napi-inl.h @@ -0,0 +1,6607 @@ +#ifndef SRC_NAPI_INL_H_ +#define SRC_NAPI_INL_H_ + +//////////////////////////////////////////////////////////////////////////////// +// Node-API C++ Wrapper Classes +// +// Inline header-only implementations for "Node-API" ABI-stable C APIs for +// Node.js. +//////////////////////////////////////////////////////////////////////////////// + +// Note: Do not include this file directly! Include "napi.h" instead. + +#include +#include +#if NAPI_HAS_THREADS +#include +#endif // NAPI_HAS_THREADS +#include +#include + +namespace Napi { + +#ifdef NAPI_CPP_CUSTOM_NAMESPACE +namespace NAPI_CPP_CUSTOM_NAMESPACE { +#endif + +// Helpers to handle functions exposed from C++ and internal constants. +namespace details { + +// New napi_status constants not yet available in all supported versions of +// Node.js releases. Only necessary when they are used in napi.h and napi-inl.h. +constexpr int napi_no_external_buffers_allowed = 22; + +template +inline void default_finalizer(napi_env /*env*/, void* data, void* /*hint*/) { + delete static_cast(data); +} + +// Attach a data item to an object and delete it when the object gets +// garbage-collected. +// TODO: Replace this code with `napi_add_finalizer()` whenever it becomes +// available on all supported versions of Node.js. +template > +inline napi_status AttachData(napi_env env, + napi_value obj, + FreeType* data, + void* hint = nullptr) { + napi_status status; +#if (NAPI_VERSION < 5) + napi_value symbol, external; + status = napi_create_symbol(env, nullptr, &symbol); + if (status == napi_ok) { + status = napi_create_external(env, data, finalizer, hint, &external); + if (status == napi_ok) { + napi_property_descriptor desc = {nullptr, + symbol, + nullptr, + nullptr, + nullptr, + external, + napi_default, + nullptr}; + status = napi_define_properties(env, obj, 1, &desc); + } + } +#else // NAPI_VERSION >= 5 + status = napi_add_finalizer(env, obj, data, finalizer, hint, nullptr); +#endif + return status; +} + +// For use in JS to C++ callback wrappers to catch any Napi::Error exceptions +// and rethrow them as JavaScript exceptions before returning from the callback. +template +inline napi_value WrapCallback(Callable callback) { +#ifdef NAPI_CPP_EXCEPTIONS + try { + return callback(); + } catch (const Error& e) { + e.ThrowAsJavaScriptException(); + return nullptr; + } +#else // NAPI_CPP_EXCEPTIONS + // When C++ exceptions are disabled, errors are immediately thrown as JS + // exceptions, so there is no need to catch and rethrow them here. + return callback(); +#endif // NAPI_CPP_EXCEPTIONS +} + +// For use in JS to C++ void callback wrappers to catch any Napi::Error +// exceptions and rethrow them as JavaScript exceptions before returning from +// the callback. +template +inline void WrapVoidCallback(Callable callback) { +#ifdef NAPI_CPP_EXCEPTIONS + try { + callback(); + } catch (const Error& e) { + e.ThrowAsJavaScriptException(); + } +#else // NAPI_CPP_EXCEPTIONS + // When C++ exceptions are disabled, errors are immediately thrown as JS + // exceptions, so there is no need to catch and rethrow them here. + callback(); +#endif // NAPI_CPP_EXCEPTIONS +} + +template +struct CallbackData { + static inline napi_value Wrapper(napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + CallbackData* callbackData = + static_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + return callbackData->callback(callbackInfo); + }); + } + + Callable callback; + void* data; +}; + +template +struct CallbackData { + static inline napi_value Wrapper(napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + CallbackData* callbackData = + static_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + callbackData->callback(callbackInfo); + return nullptr; + }); + } + + Callable callback; + void* data; +}; + +template +napi_value TemplatedVoidCallback(napi_env env, + napi_callback_info info) NAPI_NOEXCEPT { + return details::WrapCallback([&] { + CallbackInfo cbInfo(env, info); + Callback(cbInfo); + return nullptr; + }); +} + +template +napi_value TemplatedCallback(napi_env env, + napi_callback_info info) NAPI_NOEXCEPT { + return details::WrapCallback([&] { + CallbackInfo cbInfo(env, info); + return Callback(cbInfo); + }); +} + +template +napi_value TemplatedInstanceCallback(napi_env env, + napi_callback_info info) NAPI_NOEXCEPT { + return details::WrapCallback([&] { + CallbackInfo cbInfo(env, info); + T* instance = T::Unwrap(cbInfo.This().As()); + return instance ? (instance->*UnwrapCallback)(cbInfo) : Napi::Value(); + }); +} + +template +napi_value TemplatedInstanceVoidCallback(napi_env env, napi_callback_info info) + NAPI_NOEXCEPT { + return details::WrapCallback([&] { + CallbackInfo cbInfo(env, info); + T* instance = T::Unwrap(cbInfo.This().As()); + if (instance) (instance->*UnwrapCallback)(cbInfo); + return nullptr; + }); +} + +template +struct FinalizeData { + static inline void Wrapper(napi_env env, + void* data, + void* finalizeHint) NAPI_NOEXCEPT { + WrapVoidCallback([&] { + FinalizeData* finalizeData = static_cast(finalizeHint); + finalizeData->callback(Env(env), static_cast(data)); + delete finalizeData; + }); + } + + static inline void WrapperWithHint(napi_env env, + void* data, + void* finalizeHint) NAPI_NOEXCEPT { + WrapVoidCallback([&] { + FinalizeData* finalizeData = static_cast(finalizeHint); + finalizeData->callback( + Env(env), static_cast(data), finalizeData->hint); + delete finalizeData; + }); + } + + Finalizer callback; + Hint* hint; +}; + +#if (NAPI_VERSION > 3 && NAPI_HAS_THREADS) +template , + typename FinalizerDataType = void> +struct ThreadSafeFinalize { + static inline void Wrapper(napi_env env, + void* rawFinalizeData, + void* /* rawContext */) { + if (rawFinalizeData == nullptr) return; + + ThreadSafeFinalize* finalizeData = + static_cast(rawFinalizeData); + finalizeData->callback(Env(env)); + delete finalizeData; + } + + static inline void FinalizeWrapperWithData(napi_env env, + void* rawFinalizeData, + void* /* rawContext */) { + if (rawFinalizeData == nullptr) return; + + ThreadSafeFinalize* finalizeData = + static_cast(rawFinalizeData); + finalizeData->callback(Env(env), finalizeData->data); + delete finalizeData; + } + + static inline void FinalizeWrapperWithContext(napi_env env, + void* rawFinalizeData, + void* rawContext) { + if (rawFinalizeData == nullptr) return; + + ThreadSafeFinalize* finalizeData = + static_cast(rawFinalizeData); + finalizeData->callback(Env(env), static_cast(rawContext)); + delete finalizeData; + } + + static inline void FinalizeFinalizeWrapperWithDataAndContext( + napi_env env, void* rawFinalizeData, void* rawContext) { + if (rawFinalizeData == nullptr) return; + + ThreadSafeFinalize* finalizeData = + static_cast(rawFinalizeData); + finalizeData->callback( + Env(env), finalizeData->data, static_cast(rawContext)); + delete finalizeData; + } + + FinalizerDataType* data; + Finalizer callback; +}; + +template +inline typename std::enable_if(nullptr)>::type +CallJsWrapper(napi_env env, napi_value jsCallback, void* context, void* data) { + details::WrapVoidCallback([&]() { + call(env, + Function(env, jsCallback), + static_cast(context), + static_cast(data)); + }); +} + +template +inline typename std::enable_if(nullptr)>::type +CallJsWrapper(napi_env env, + napi_value jsCallback, + void* /*context*/, + void* /*data*/) { + details::WrapVoidCallback([&]() { + if (jsCallback != nullptr) { + Function(env, jsCallback).Call(0, nullptr); + } + }); +} + +#if NAPI_VERSION > 4 + +template +napi_value DefaultCallbackWrapper(napi_env /*env*/, std::nullptr_t /*cb*/) { + return nullptr; +} + +template +napi_value DefaultCallbackWrapper(napi_env /*env*/, Napi::Function cb) { + return cb; +} + +#else +template +napi_value DefaultCallbackWrapper(napi_env env, Napi::Function cb) { + if (cb.IsEmpty()) { + return TSFN::EmptyFunctionFactory(env); + } + return cb; +} +#endif // NAPI_VERSION > 4 +#endif // NAPI_VERSION > 3 && NAPI_HAS_THREADS + +template +struct AccessorCallbackData { + static inline napi_value GetterWrapper(napi_env env, + napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + AccessorCallbackData* callbackData = + static_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + return callbackData->getterCallback(callbackInfo); + }); + } + + static inline napi_value SetterWrapper(napi_env env, + napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + AccessorCallbackData* callbackData = + static_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + callbackData->setterCallback(callbackInfo); + return nullptr; + }); + } + + Getter getterCallback; + Setter setterCallback; + void* data; +}; + +} // namespace details + +#ifndef NODE_ADDON_API_DISABLE_DEPRECATED +#include "napi-inl.deprecated.h" +#endif // !NODE_ADDON_API_DISABLE_DEPRECATED + +//////////////////////////////////////////////////////////////////////////////// +// Module registration +//////////////////////////////////////////////////////////////////////////////// + +// Register an add-on based on an initializer function. +#define NODE_API_MODULE(modname, regfunc) \ + static napi_value __napi_##regfunc(napi_env env, napi_value exports) { \ + return Napi::RegisterModule(env, exports, regfunc); \ + } \ + NAPI_MODULE(modname, __napi_##regfunc) + +// Register an add-on based on a subclass of `Addon` with a custom Node.js +// module name. +#define NODE_API_NAMED_ADDON(modname, classname) \ + static napi_value __napi_##classname(napi_env env, napi_value exports) { \ + return Napi::RegisterModule(env, exports, &classname::Init); \ + } \ + NAPI_MODULE(modname, __napi_##classname) + +// Register an add-on based on a subclass of `Addon` with the Node.js module +// name given by node-gyp from the `target_name` in binding.gyp. +#define NODE_API_ADDON(classname) \ + NODE_API_NAMED_ADDON(NODE_GYP_MODULE_NAME, classname) + +// Adapt the NAPI_MODULE registration function: +// - Wrap the arguments in NAPI wrappers. +// - Catch any NAPI errors and rethrow as JS exceptions. +inline napi_value RegisterModule(napi_env env, + napi_value exports, + ModuleRegisterCallback registerCallback) { + return details::WrapCallback([&] { + return napi_value( + registerCallback(Napi::Env(env), Napi::Object(env, exports))); + }); +} + +//////////////////////////////////////////////////////////////////////////////// +// Maybe class +//////////////////////////////////////////////////////////////////////////////// + +template +bool Maybe::IsNothing() const { + return !_has_value; +} + +template +bool Maybe::IsJust() const { + return _has_value; +} + +template +void Maybe::Check() const { + NAPI_CHECK(IsJust(), "Napi::Maybe::Check", "Maybe value is Nothing."); +} + +template +T Maybe::Unwrap() const { + NAPI_CHECK(IsJust(), "Napi::Maybe::Unwrap", "Maybe value is Nothing."); + return _value; +} + +template +T Maybe::UnwrapOr(const T& default_value) const { + return _has_value ? _value : default_value; +} + +template +bool Maybe::UnwrapTo(T* out) const { + if (IsJust()) { + *out = _value; + return true; + }; + return false; +} + +template +bool Maybe::operator==(const Maybe& other) const { + return (IsJust() == other.IsJust()) && + (!IsJust() || Unwrap() == other.Unwrap()); +} + +template +bool Maybe::operator!=(const Maybe& other) const { + return !operator==(other); +} + +template +Maybe::Maybe() : _has_value(false) {} + +template +Maybe::Maybe(const T& t) : _has_value(true), _value(t) {} + +template +inline Maybe Nothing() { + return Maybe(); +} + +template +inline Maybe Just(const T& t) { + return Maybe(t); +} + +//////////////////////////////////////////////////////////////////////////////// +// Env class +//////////////////////////////////////////////////////////////////////////////// + +inline Env::Env(napi_env env) : _env(env) {} + +inline Env::operator napi_env() const { + return _env; +} + +inline Object Env::Global() const { + napi_value value; + napi_status status = napi_get_global(*this, &value); + NAPI_THROW_IF_FAILED(*this, status, Object()); + return Object(*this, value); +} + +inline Value Env::Undefined() const { + napi_value value; + napi_status status = napi_get_undefined(*this, &value); + NAPI_THROW_IF_FAILED(*this, status, Value()); + return Value(*this, value); +} + +inline Value Env::Null() const { + napi_value value; + napi_status status = napi_get_null(*this, &value); + NAPI_THROW_IF_FAILED(*this, status, Value()); + return Value(*this, value); +} + +inline bool Env::IsExceptionPending() const { + bool result; + napi_status status = napi_is_exception_pending(_env, &result); + if (status != napi_ok) + result = false; // Checking for a pending exception shouldn't throw. + return result; +} + +inline Error Env::GetAndClearPendingException() const { + napi_value value; + napi_status status = napi_get_and_clear_last_exception(_env, &value); + if (status != napi_ok) { + // Don't throw another exception when failing to get the exception! + return Error(); + } + return Error(_env, value); +} + +inline MaybeOrValue Env::RunScript(const char* utf8script) const { + String script = String::New(_env, utf8script); + return RunScript(script); +} + +inline MaybeOrValue Env::RunScript(const std::string& utf8script) const { + return RunScript(utf8script.c_str()); +} + +inline MaybeOrValue Env::RunScript(String script) const { + napi_value result; + napi_status status = napi_run_script(_env, script, &result); + NAPI_RETURN_OR_THROW_IF_FAILED( + _env, status, Napi::Value(_env, result), Napi::Value); +} + +#if NAPI_VERSION > 2 +template +void Env::CleanupHook::Wrapper(void* data) NAPI_NOEXCEPT { + auto* cleanupData = + static_cast::CleanupData*>( + data); + cleanupData->hook(); + delete cleanupData; +} + +template +void Env::CleanupHook::WrapperWithArg(void* data) NAPI_NOEXCEPT { + auto* cleanupData = + static_cast::CleanupData*>( + data); + cleanupData->hook(static_cast(cleanupData->arg)); + delete cleanupData; +} +#endif // NAPI_VERSION > 2 + +#if NAPI_VERSION > 5 +template fini> +inline void Env::SetInstanceData(T* data) const { + napi_status status = napi_set_instance_data( + _env, + data, + [](napi_env env, void* data, void*) { fini(env, static_cast(data)); }, + nullptr); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +template fini> +inline void Env::SetInstanceData(DataType* data, HintType* hint) const { + napi_status status = napi_set_instance_data( + _env, + data, + [](napi_env env, void* data, void* hint) { + fini(env, static_cast(data), static_cast(hint)); + }, + hint); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +template +inline T* Env::GetInstanceData() const { + void* data = nullptr; + + napi_status status = napi_get_instance_data(_env, &data); + NAPI_THROW_IF_FAILED(_env, status, nullptr); + + return static_cast(data); +} + +template +void Env::DefaultFini(Env, T* data) { + delete data; +} + +template +void Env::DefaultFiniWithHint(Env, DataType* data, HintType*) { + delete data; +} +#endif // NAPI_VERSION > 5 + +#if NAPI_VERSION > 8 +inline const char* Env::GetModuleFileName() const { + const char* result; + napi_status status = node_api_get_module_file_name(_env, &result); + NAPI_THROW_IF_FAILED(*this, status, nullptr); + return result; +} +#endif // NAPI_VERSION > 8 +//////////////////////////////////////////////////////////////////////////////// +// Value class +//////////////////////////////////////////////////////////////////////////////// + +inline Value::Value() : _env(nullptr), _value(nullptr) {} + +inline Value::Value(napi_env env, napi_value value) + : _env(env), _value(value) {} + +inline Value::operator napi_value() const { + return _value; +} + +inline bool Value::operator==(const Value& other) const { + return StrictEquals(other); +} + +inline bool Value::operator!=(const Value& other) const { + return !this->operator==(other); +} + +inline bool Value::StrictEquals(const Value& other) const { + bool result; + napi_status status = napi_strict_equals(_env, *this, other, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} + +inline Napi::Env Value::Env() const { + return Napi::Env(_env); +} + +inline bool Value::IsEmpty() const { + return _value == nullptr; +} + +inline napi_valuetype Value::Type() const { + if (IsEmpty()) { + return napi_undefined; + } + + napi_valuetype type; + napi_status status = napi_typeof(_env, _value, &type); + NAPI_THROW_IF_FAILED(_env, status, napi_undefined); + return type; +} + +inline bool Value::IsUndefined() const { + return Type() == napi_undefined; +} + +inline bool Value::IsNull() const { + return Type() == napi_null; +} + +inline bool Value::IsBoolean() const { + return Type() == napi_boolean; +} + +inline bool Value::IsNumber() const { + return Type() == napi_number; +} + +#if NAPI_VERSION > 5 +inline bool Value::IsBigInt() const { + return Type() == napi_bigint; +} +#endif // NAPI_VERSION > 5 + +#if (NAPI_VERSION > 4) +inline bool Value::IsDate() const { + if (IsEmpty()) { + return false; + } + + bool result; + napi_status status = napi_is_date(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} +#endif + +inline bool Value::IsString() const { + return Type() == napi_string; +} + +inline bool Value::IsSymbol() const { + return Type() == napi_symbol; +} + +inline bool Value::IsArray() const { + if (IsEmpty()) { + return false; + } + + bool result; + napi_status status = napi_is_array(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} + +inline bool Value::IsArrayBuffer() const { + if (IsEmpty()) { + return false; + } + + bool result; + napi_status status = napi_is_arraybuffer(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} + +inline bool Value::IsTypedArray() const { + if (IsEmpty()) { + return false; + } + + bool result; + napi_status status = napi_is_typedarray(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} + +inline bool Value::IsObject() const { + return Type() == napi_object || IsFunction(); +} + +inline bool Value::IsFunction() const { + return Type() == napi_function; +} + +inline bool Value::IsPromise() const { + if (IsEmpty()) { + return false; + } + + bool result; + napi_status status = napi_is_promise(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} + +inline bool Value::IsDataView() const { + if (IsEmpty()) { + return false; + } + + bool result; + napi_status status = napi_is_dataview(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} + +inline bool Value::IsBuffer() const { + if (IsEmpty()) { + return false; + } + + bool result; + napi_status status = napi_is_buffer(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} + +inline bool Value::IsExternal() const { + return Type() == napi_external; +} + +template +inline T Value::As() const { +#ifdef NODE_ADDON_API_ENABLE_TYPE_CHECK_ON_AS + T::CheckCast(_env, _value); +#endif + return T(_env, _value); +} + +inline MaybeOrValue Value::ToBoolean() const { + napi_value result; + napi_status status = napi_coerce_to_bool(_env, _value, &result); + NAPI_RETURN_OR_THROW_IF_FAILED( + _env, status, Napi::Boolean(_env, result), Napi::Boolean); +} + +inline MaybeOrValue Value::ToNumber() const { + napi_value result; + napi_status status = napi_coerce_to_number(_env, _value, &result); + NAPI_RETURN_OR_THROW_IF_FAILED( + _env, status, Napi::Number(_env, result), Napi::Number); +} + +inline MaybeOrValue Value::ToString() const { + napi_value result; + napi_status status = napi_coerce_to_string(_env, _value, &result); + NAPI_RETURN_OR_THROW_IF_FAILED( + _env, status, Napi::String(_env, result), Napi::String); +} + +inline MaybeOrValue Value::ToObject() const { + napi_value result; + napi_status status = napi_coerce_to_object(_env, _value, &result); + NAPI_RETURN_OR_THROW_IF_FAILED( + _env, status, Napi::Object(_env, result), Napi::Object); +} + +//////////////////////////////////////////////////////////////////////////////// +// Boolean class +//////////////////////////////////////////////////////////////////////////////// + +inline Boolean Boolean::New(napi_env env, bool val) { + napi_value value; + napi_status status = napi_get_boolean(env, val, &value); + NAPI_THROW_IF_FAILED(env, status, Boolean()); + return Boolean(env, value); +} + +inline void Boolean::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Boolean::CheckCast", "empty value"); + + napi_valuetype type; + napi_status status = napi_typeof(env, value, &type); + NAPI_CHECK(status == napi_ok, "Boolean::CheckCast", "napi_typeof failed"); + NAPI_CHECK( + type == napi_boolean, "Boolean::CheckCast", "value is not napi_boolean"); +} + +inline Boolean::Boolean() : Napi::Value() {} + +inline Boolean::Boolean(napi_env env, napi_value value) + : Napi::Value(env, value) {} + +inline Boolean::operator bool() const { + return Value(); +} + +inline bool Boolean::Value() const { + bool result; + napi_status status = napi_get_value_bool(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} + +//////////////////////////////////////////////////////////////////////////////// +// Number class +//////////////////////////////////////////////////////////////////////////////// + +inline Number Number::New(napi_env env, double val) { + napi_value value; + napi_status status = napi_create_double(env, val, &value); + NAPI_THROW_IF_FAILED(env, status, Number()); + return Number(env, value); +} + +inline void Number::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Number::CheckCast", "empty value"); + + napi_valuetype type; + napi_status status = napi_typeof(env, value, &type); + NAPI_CHECK(status == napi_ok, "Number::CheckCast", "napi_typeof failed"); + NAPI_CHECK( + type == napi_number, "Number::CheckCast", "value is not napi_number"); +} + +inline Number::Number() : Value() {} + +inline Number::Number(napi_env env, napi_value value) : Value(env, value) {} + +inline Number::operator int32_t() const { + return Int32Value(); +} + +inline Number::operator uint32_t() const { + return Uint32Value(); +} + +inline Number::operator int64_t() const { + return Int64Value(); +} + +inline Number::operator float() const { + return FloatValue(); +} + +inline Number::operator double() const { + return DoubleValue(); +} + +inline int32_t Number::Int32Value() const { + int32_t result; + napi_status status = napi_get_value_int32(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} + +inline uint32_t Number::Uint32Value() const { + uint32_t result; + napi_status status = napi_get_value_uint32(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} + +inline int64_t Number::Int64Value() const { + int64_t result; + napi_status status = napi_get_value_int64(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} + +inline float Number::FloatValue() const { + return static_cast(DoubleValue()); +} + +inline double Number::DoubleValue() const { + double result; + napi_status status = napi_get_value_double(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} + +#if NAPI_VERSION > 5 +//////////////////////////////////////////////////////////////////////////////// +// BigInt Class +//////////////////////////////////////////////////////////////////////////////// + +inline BigInt BigInt::New(napi_env env, int64_t val) { + napi_value value; + napi_status status = napi_create_bigint_int64(env, val, &value); + NAPI_THROW_IF_FAILED(env, status, BigInt()); + return BigInt(env, value); +} + +inline BigInt BigInt::New(napi_env env, uint64_t val) { + napi_value value; + napi_status status = napi_create_bigint_uint64(env, val, &value); + NAPI_THROW_IF_FAILED(env, status, BigInt()); + return BigInt(env, value); +} + +inline BigInt BigInt::New(napi_env env, + int sign_bit, + size_t word_count, + const uint64_t* words) { + napi_value value; + napi_status status = + napi_create_bigint_words(env, sign_bit, word_count, words, &value); + NAPI_THROW_IF_FAILED(env, status, BigInt()); + return BigInt(env, value); +} + +inline void BigInt::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "BigInt::CheckCast", "empty value"); + + napi_valuetype type; + napi_status status = napi_typeof(env, value, &type); + NAPI_CHECK(status == napi_ok, "BigInt::CheckCast", "napi_typeof failed"); + NAPI_CHECK( + type == napi_bigint, "BigInt::CheckCast", "value is not napi_bigint"); +} + +inline BigInt::BigInt() : Value() {} + +inline BigInt::BigInt(napi_env env, napi_value value) : Value(env, value) {} + +inline int64_t BigInt::Int64Value(bool* lossless) const { + int64_t result; + napi_status status = + napi_get_value_bigint_int64(_env, _value, &result, lossless); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} + +inline uint64_t BigInt::Uint64Value(bool* lossless) const { + uint64_t result; + napi_status status = + napi_get_value_bigint_uint64(_env, _value, &result, lossless); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} + +inline size_t BigInt::WordCount() const { + size_t word_count; + napi_status status = + napi_get_value_bigint_words(_env, _value, nullptr, &word_count, nullptr); + NAPI_THROW_IF_FAILED(_env, status, 0); + return word_count; +} + +inline void BigInt::ToWords(int* sign_bit, + size_t* word_count, + uint64_t* words) { + napi_status status = + napi_get_value_bigint_words(_env, _value, sign_bit, word_count, words); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} +#endif // NAPI_VERSION > 5 + +#if (NAPI_VERSION > 4) +//////////////////////////////////////////////////////////////////////////////// +// Date Class +//////////////////////////////////////////////////////////////////////////////// + +inline Date Date::New(napi_env env, double val) { + napi_value value; + napi_status status = napi_create_date(env, val, &value); + NAPI_THROW_IF_FAILED(env, status, Date()); + return Date(env, value); +} + +inline void Date::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Date::CheckCast", "empty value"); + + bool result; + napi_status status = napi_is_date(env, value, &result); + NAPI_CHECK(status == napi_ok, "Date::CheckCast", "napi_is_date failed"); + NAPI_CHECK(result, "Date::CheckCast", "value is not date"); +} + +inline Date::Date() : Value() {} + +inline Date::Date(napi_env env, napi_value value) : Value(env, value) {} + +inline Date::operator double() const { + return ValueOf(); +} + +inline double Date::ValueOf() const { + double result; + napi_status status = napi_get_date_value(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} +#endif + +//////////////////////////////////////////////////////////////////////////////// +// Name class +//////////////////////////////////////////////////////////////////////////////// +inline void Name::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Name::CheckCast", "empty value"); + + napi_valuetype type; + napi_status status = napi_typeof(env, value, &type); + NAPI_CHECK(status == napi_ok, "Name::CheckCast", "napi_typeof failed"); + NAPI_CHECK(type == napi_string || type == napi_symbol, + "Name::CheckCast", + "value is not napi_string or napi_symbol"); +} + +inline Name::Name() : Value() {} + +inline Name::Name(napi_env env, napi_value value) : Value(env, value) {} + +//////////////////////////////////////////////////////////////////////////////// +// String class +//////////////////////////////////////////////////////////////////////////////// + +inline String String::New(napi_env env, const std::string& val) { + return String::New(env, val.c_str(), val.size()); +} + +inline String String::New(napi_env env, const std::u16string& val) { + return String::New(env, val.c_str(), val.size()); +} + +inline String String::New(napi_env env, const char* val) { + // TODO(@gabrielschulhof) Remove if-statement when core's error handling is + // available in all supported versions. + if (val == nullptr) { + // Throw an error that looks like it came from core. + NAPI_THROW_IF_FAILED(env, napi_invalid_arg, String()); + } + napi_value value; + napi_status status = + napi_create_string_utf8(env, val, std::strlen(val), &value); + NAPI_THROW_IF_FAILED(env, status, String()); + return String(env, value); +} + +inline String String::New(napi_env env, const char16_t* val) { + napi_value value; + // TODO(@gabrielschulhof) Remove if-statement when core's error handling is + // available in all supported versions. + if (val == nullptr) { + // Throw an error that looks like it came from core. + NAPI_THROW_IF_FAILED(env, napi_invalid_arg, String()); + } + napi_status status = + napi_create_string_utf16(env, val, std::u16string(val).size(), &value); + NAPI_THROW_IF_FAILED(env, status, String()); + return String(env, value); +} + +inline String String::New(napi_env env, const char* val, size_t length) { + napi_value value; + napi_status status = napi_create_string_utf8(env, val, length, &value); + NAPI_THROW_IF_FAILED(env, status, String()); + return String(env, value); +} + +inline String String::New(napi_env env, const char16_t* val, size_t length) { + napi_value value; + napi_status status = napi_create_string_utf16(env, val, length, &value); + NAPI_THROW_IF_FAILED(env, status, String()); + return String(env, value); +} + +inline void String::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "String::CheckCast", "empty value"); + + napi_valuetype type; + napi_status status = napi_typeof(env, value, &type); + NAPI_CHECK(status == napi_ok, "String::CheckCast", "napi_typeof failed"); + NAPI_CHECK( + type == napi_string, "String::CheckCast", "value is not napi_string"); +} + +inline String::String() : Name() {} + +inline String::String(napi_env env, napi_value value) : Name(env, value) {} + +inline String::operator std::string() const { + return Utf8Value(); +} + +inline String::operator std::u16string() const { + return Utf16Value(); +} + +inline std::string String::Utf8Value() const { + size_t length; + napi_status status = + napi_get_value_string_utf8(_env, _value, nullptr, 0, &length); + NAPI_THROW_IF_FAILED(_env, status, ""); + + std::string value; + value.reserve(length + 1); + value.resize(length); + status = napi_get_value_string_utf8( + _env, _value, &value[0], value.capacity(), nullptr); + NAPI_THROW_IF_FAILED(_env, status, ""); + return value; +} + +inline std::u16string String::Utf16Value() const { + size_t length; + napi_status status = + napi_get_value_string_utf16(_env, _value, nullptr, 0, &length); + NAPI_THROW_IF_FAILED(_env, status, NAPI_WIDE_TEXT("")); + + std::u16string value; + value.reserve(length + 1); + value.resize(length); + status = napi_get_value_string_utf16( + _env, _value, &value[0], value.capacity(), nullptr); + NAPI_THROW_IF_FAILED(_env, status, NAPI_WIDE_TEXT("")); + return value; +} + +//////////////////////////////////////////////////////////////////////////////// +// Symbol class +//////////////////////////////////////////////////////////////////////////////// + +inline Symbol Symbol::New(napi_env env, const char* description) { + napi_value descriptionValue = description != nullptr + ? String::New(env, description) + : static_cast(nullptr); + return Symbol::New(env, descriptionValue); +} + +inline Symbol Symbol::New(napi_env env, const std::string& description) { + napi_value descriptionValue = String::New(env, description); + return Symbol::New(env, descriptionValue); +} + +inline Symbol Symbol::New(napi_env env, String description) { + napi_value descriptionValue = description; + return Symbol::New(env, descriptionValue); +} + +inline Symbol Symbol::New(napi_env env, napi_value description) { + napi_value value; + napi_status status = napi_create_symbol(env, description, &value); + NAPI_THROW_IF_FAILED(env, status, Symbol()); + return Symbol(env, value); +} + +inline MaybeOrValue Symbol::WellKnown(napi_env env, + const std::string& name) { +#if defined(NODE_ADDON_API_ENABLE_MAYBE) + Value symbol_obj; + Value symbol_value; + if (Napi::Env(env).Global().Get("Symbol").UnwrapTo(&symbol_obj) && + symbol_obj.As().Get(name).UnwrapTo(&symbol_value)) { + return Just(symbol_value.As()); + } + return Nothing(); +#else + return Napi::Env(env) + .Global() + .Get("Symbol") + .As() + .Get(name) + .As(); +#endif +} + +inline MaybeOrValue Symbol::For(napi_env env, + const std::string& description) { + napi_value descriptionValue = String::New(env, description); + return Symbol::For(env, descriptionValue); +} + +inline MaybeOrValue Symbol::For(napi_env env, const char* description) { + napi_value descriptionValue = String::New(env, description); + return Symbol::For(env, descriptionValue); +} + +inline MaybeOrValue Symbol::For(napi_env env, String description) { + return Symbol::For(env, static_cast(description)); +} + +inline MaybeOrValue Symbol::For(napi_env env, napi_value description) { +#if defined(NODE_ADDON_API_ENABLE_MAYBE) + Value symbol_obj; + Value symbol_for_value; + Value symbol_value; + if (Napi::Env(env).Global().Get("Symbol").UnwrapTo(&symbol_obj) && + symbol_obj.As().Get("for").UnwrapTo(&symbol_for_value) && + symbol_for_value.As() + .Call(symbol_obj, {description}) + .UnwrapTo(&symbol_value)) { + return Just(symbol_value.As()); + } + return Nothing(); +#else + Object symbol_obj = Napi::Env(env).Global().Get("Symbol").As(); + return symbol_obj.Get("for") + .As() + .Call(symbol_obj, {description}) + .As(); +#endif +} + +inline void Symbol::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Symbol::CheckCast", "empty value"); + + napi_valuetype type; + napi_status status = napi_typeof(env, value, &type); + NAPI_CHECK(status == napi_ok, "Symbol::CheckCast", "napi_typeof failed"); + NAPI_CHECK( + type == napi_symbol, "Symbol::CheckCast", "value is not napi_symbol"); +} + +inline Symbol::Symbol() : Name() {} + +inline Symbol::Symbol(napi_env env, napi_value value) : Name(env, value) {} + +//////////////////////////////////////////////////////////////////////////////// +// Automagic value creation +//////////////////////////////////////////////////////////////////////////////// + +namespace details { +template +struct vf_number { + static Number From(napi_env env, T value) { + return Number::New(env, static_cast(value)); + } +}; + +template <> +struct vf_number { + static Boolean From(napi_env env, bool value) { + return Boolean::New(env, value); + } +}; + +struct vf_utf8_charp { + static String From(napi_env env, const char* value) { + return String::New(env, value); + } +}; + +struct vf_utf16_charp { + static String From(napi_env env, const char16_t* value) { + return String::New(env, value); + } +}; +struct vf_utf8_string { + static String From(napi_env env, const std::string& value) { + return String::New(env, value); + } +}; + +struct vf_utf16_string { + static String From(napi_env env, const std::u16string& value) { + return String::New(env, value); + } +}; + +template +struct vf_fallback { + static Value From(napi_env env, const T& value) { return Value(env, value); } +}; + +template +struct disjunction : std::false_type {}; +template +struct disjunction : B {}; +template +struct disjunction + : std::conditional>::type {}; + +template +struct can_make_string + : disjunction::type, + typename std::is_convertible::type, + typename std::is_convertible::type, + typename std::is_convertible::type> {}; +} // namespace details + +template +Value Value::From(napi_env env, const T& value) { + using Helper = typename std::conditional< + std::is_integral::value || std::is_floating_point::value, + details::vf_number, + typename std::conditional::value, + String, + details::vf_fallback>::type>::type; + return Helper::From(env, value); +} + +template +String String::From(napi_env env, const T& value) { + struct Dummy {}; + using Helper = typename std::conditional< + std::is_convertible::value, + details::vf_utf8_charp, + typename std::conditional< + std::is_convertible::value, + details::vf_utf16_charp, + typename std::conditional< + std::is_convertible::value, + details::vf_utf8_string, + typename std::conditional< + std::is_convertible::value, + details::vf_utf16_string, + Dummy>::type>::type>::type>::type; + return Helper::From(env, value); +} + +//////////////////////////////////////////////////////////////////////////////// +// TypeTaggable class +//////////////////////////////////////////////////////////////////////////////// + +inline TypeTaggable::TypeTaggable() : Value() {} + +inline TypeTaggable::TypeTaggable(napi_env _env, napi_value _value) + : Value(_env, _value) {} + +#if NAPI_VERSION >= 8 + +inline void TypeTaggable::TypeTag(const napi_type_tag* type_tag) const { + napi_status status = napi_type_tag_object(_env, _value, type_tag); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline bool TypeTaggable::CheckTypeTag(const napi_type_tag* type_tag) const { + bool result; + napi_status status = + napi_check_object_type_tag(_env, _value, type_tag, &result); + NAPI_THROW_IF_FAILED(_env, status, false); + return result; +} + +#endif // NAPI_VERSION >= 8 + +//////////////////////////////////////////////////////////////////////////////// +// Object class +//////////////////////////////////////////////////////////////////////////////// + +template +inline Object::PropertyLValue::operator Value() const { + MaybeOrValue val = Object(_env, _object).Get(_key); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + return val.Unwrap(); +#else + return val; +#endif +} + +template +template +inline Object::PropertyLValue& Object::PropertyLValue::operator=( + ValueType value) { +#ifdef NODE_ADDON_API_ENABLE_MAYBE + MaybeOrValue result = +#endif + Object(_env, _object).Set(_key, value); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + result.Unwrap(); +#endif + return *this; +} + +template +inline Object::PropertyLValue::PropertyLValue(Object object, Key key) + : _env(object.Env()), _object(object), _key(key) {} + +inline Object Object::New(napi_env env) { + napi_value value; + napi_status status = napi_create_object(env, &value); + NAPI_THROW_IF_FAILED(env, status, Object()); + return Object(env, value); +} + +inline void Object::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Object::CheckCast", "empty value"); + + napi_valuetype type; + napi_status status = napi_typeof(env, value, &type); + NAPI_CHECK(status == napi_ok, "Object::CheckCast", "napi_typeof failed"); + NAPI_CHECK( + type == napi_object, "Object::CheckCast", "value is not napi_object"); +} + +inline Object::Object() : TypeTaggable() {} + +inline Object::Object(napi_env env, napi_value value) + : TypeTaggable(env, value) {} + +inline Object::PropertyLValue Object::operator[]( + const char* utf8name) { + return PropertyLValue(*this, utf8name); +} + +inline Object::PropertyLValue Object::operator[]( + const std::string& utf8name) { + return PropertyLValue(*this, utf8name); +} + +inline Object::PropertyLValue Object::operator[](uint32_t index) { + return PropertyLValue(*this, index); +} + +inline Object::PropertyLValue Object::operator[](Value index) const { + return PropertyLValue(*this, index); +} + +inline MaybeOrValue Object::operator[](const char* utf8name) const { + return Get(utf8name); +} + +inline MaybeOrValue Object::operator[]( + const std::string& utf8name) const { + return Get(utf8name); +} + +inline MaybeOrValue Object::operator[](uint32_t index) const { + return Get(index); +} + +inline MaybeOrValue Object::Has(napi_value key) const { + bool result; + napi_status status = napi_has_property(_env, _value, key, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +inline MaybeOrValue Object::Has(Value key) const { + bool result; + napi_status status = napi_has_property(_env, _value, key, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +inline MaybeOrValue Object::Has(const char* utf8name) const { + bool result; + napi_status status = napi_has_named_property(_env, _value, utf8name, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +inline MaybeOrValue Object::Has(const std::string& utf8name) const { + return Has(utf8name.c_str()); +} + +inline MaybeOrValue Object::HasOwnProperty(napi_value key) const { + bool result; + napi_status status = napi_has_own_property(_env, _value, key, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +inline MaybeOrValue Object::HasOwnProperty(Value key) const { + bool result; + napi_status status = napi_has_own_property(_env, _value, key, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +inline MaybeOrValue Object::HasOwnProperty(const char* utf8name) const { + napi_value key; + napi_status status = + napi_create_string_utf8(_env, utf8name, std::strlen(utf8name), &key); + NAPI_MAYBE_THROW_IF_FAILED(_env, status, bool); + return HasOwnProperty(key); +} + +inline MaybeOrValue Object::HasOwnProperty( + const std::string& utf8name) const { + return HasOwnProperty(utf8name.c_str()); +} + +inline MaybeOrValue Object::Get(napi_value key) const { + napi_value result; + napi_status status = napi_get_property(_env, _value, key, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, Value(_env, result), Value); +} + +inline MaybeOrValue Object::Get(Value key) const { + napi_value result; + napi_status status = napi_get_property(_env, _value, key, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, Value(_env, result), Value); +} + +inline MaybeOrValue Object::Get(const char* utf8name) const { + napi_value result; + napi_status status = napi_get_named_property(_env, _value, utf8name, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, Value(_env, result), Value); +} + +inline MaybeOrValue Object::Get(const std::string& utf8name) const { + return Get(utf8name.c_str()); +} + +template +inline MaybeOrValue Object::Set(napi_value key, + const ValueType& value) const { + napi_status status = + napi_set_property(_env, _value, key, Value::From(_env, value)); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, status == napi_ok, bool); +} + +template +inline MaybeOrValue Object::Set(Value key, const ValueType& value) const { + napi_status status = + napi_set_property(_env, _value, key, Value::From(_env, value)); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, status == napi_ok, bool); +} + +template +inline MaybeOrValue Object::Set(const char* utf8name, + const ValueType& value) const { + napi_status status = + napi_set_named_property(_env, _value, utf8name, Value::From(_env, value)); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, status == napi_ok, bool); +} + +template +inline MaybeOrValue Object::Set(const std::string& utf8name, + const ValueType& value) const { + return Set(utf8name.c_str(), value); +} + +inline MaybeOrValue Object::Delete(napi_value key) const { + bool result; + napi_status status = napi_delete_property(_env, _value, key, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +inline MaybeOrValue Object::Delete(Value key) const { + bool result; + napi_status status = napi_delete_property(_env, _value, key, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +inline MaybeOrValue Object::Delete(const char* utf8name) const { + return Delete(String::New(_env, utf8name)); +} + +inline MaybeOrValue Object::Delete(const std::string& utf8name) const { + return Delete(String::New(_env, utf8name)); +} + +inline MaybeOrValue Object::Has(uint32_t index) const { + bool result; + napi_status status = napi_has_element(_env, _value, index, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +inline MaybeOrValue Object::Get(uint32_t index) const { + napi_value value; + napi_status status = napi_get_element(_env, _value, index, &value); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, Value(_env, value), Value); +} + +template +inline MaybeOrValue Object::Set(uint32_t index, + const ValueType& value) const { + napi_status status = + napi_set_element(_env, _value, index, Value::From(_env, value)); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, status == napi_ok, bool); +} + +inline MaybeOrValue Object::Delete(uint32_t index) const { + bool result; + napi_status status = napi_delete_element(_env, _value, index, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +inline MaybeOrValue Object::GetPropertyNames() const { + napi_value result; + napi_status status = napi_get_property_names(_env, _value, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, Array(_env, result), Array); +} + +inline MaybeOrValue Object::DefineProperty( + const PropertyDescriptor& property) const { + napi_status status = napi_define_properties( + _env, + _value, + 1, + reinterpret_cast(&property)); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, status == napi_ok, bool); +} + +inline MaybeOrValue Object::DefineProperties( + const std::initializer_list& properties) const { + napi_status status = napi_define_properties( + _env, + _value, + properties.size(), + reinterpret_cast(properties.begin())); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, status == napi_ok, bool); +} + +inline MaybeOrValue Object::DefineProperties( + const std::vector& properties) const { + napi_status status = napi_define_properties( + _env, + _value, + properties.size(), + reinterpret_cast(properties.data())); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, status == napi_ok, bool); +} + +inline MaybeOrValue Object::InstanceOf( + const Function& constructor) const { + bool result; + napi_status status = napi_instanceof(_env, _value, constructor, &result); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, result, bool); +} + +template +inline void Object::AddFinalizer(Finalizer finalizeCallback, T* data) const { + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), nullptr}); + napi_status status = + details::AttachData::Wrapper>( + _env, *this, data, finalizeData); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED_VOID(_env, status); + } +} + +template +inline void Object::AddFinalizer(Finalizer finalizeCallback, + T* data, + Hint* finalizeHint) const { + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), finalizeHint}); + napi_status status = details:: + AttachData::WrapperWithHint>( + _env, *this, data, finalizeData); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED_VOID(_env, status); + } +} + +#ifdef NAPI_CPP_EXCEPTIONS +inline Object::const_iterator::const_iterator(const Object* object, + const Type type) { + _object = object; + _keys = object->GetPropertyNames(); + _index = type == Type::BEGIN ? 0 : _keys.Length(); +} + +inline Object::const_iterator Napi::Object::begin() const { + const_iterator it(this, Object::const_iterator::Type::BEGIN); + return it; +} + +inline Object::const_iterator Napi::Object::end() const { + const_iterator it(this, Object::const_iterator::Type::END); + return it; +} + +inline Object::const_iterator& Object::const_iterator::operator++() { + ++_index; + return *this; +} + +inline bool Object::const_iterator::operator==( + const const_iterator& other) const { + return _index == other._index; +} + +inline bool Object::const_iterator::operator!=( + const const_iterator& other) const { + return _index != other._index; +} + +inline const std::pair> +Object::const_iterator::operator*() const { + const Value key = _keys[_index]; + const PropertyLValue value = (*_object)[key]; + return {key, value}; +} + +inline Object::iterator::iterator(Object* object, const Type type) { + _object = object; + _keys = object->GetPropertyNames(); + _index = type == Type::BEGIN ? 0 : _keys.Length(); +} + +inline Object::iterator Napi::Object::begin() { + iterator it(this, Object::iterator::Type::BEGIN); + return it; +} + +inline Object::iterator Napi::Object::end() { + iterator it(this, Object::iterator::Type::END); + return it; +} + +inline Object::iterator& Object::iterator::operator++() { + ++_index; + return *this; +} + +inline bool Object::iterator::operator==(const iterator& other) const { + return _index == other._index; +} + +inline bool Object::iterator::operator!=(const iterator& other) const { + return _index != other._index; +} + +inline std::pair> +Object::iterator::operator*() { + Value key = _keys[_index]; + PropertyLValue value = (*_object)[key]; + return {key, value}; +} +#endif // NAPI_CPP_EXCEPTIONS + +#if NAPI_VERSION >= 8 +inline MaybeOrValue Object::Freeze() const { + napi_status status = napi_object_freeze(_env, _value); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, status == napi_ok, bool); +} + +inline MaybeOrValue Object::Seal() const { + napi_status status = napi_object_seal(_env, _value); + NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, status == napi_ok, bool); +} +#endif // NAPI_VERSION >= 8 + +//////////////////////////////////////////////////////////////////////////////// +// External class +//////////////////////////////////////////////////////////////////////////////// + +template +inline External External::New(napi_env env, T* data) { + napi_value value; + napi_status status = + napi_create_external(env, data, nullptr, nullptr, &value); + NAPI_THROW_IF_FAILED(env, status, External()); + return External(env, value); +} + +template +template +inline External External::New(napi_env env, + T* data, + Finalizer finalizeCallback) { + napi_value value; + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), nullptr}); + napi_status status = + napi_create_external(env, + data, + details::FinalizeData::Wrapper, + finalizeData, + &value); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED(env, status, External()); + } + return External(env, value); +} + +template +template +inline External External::New(napi_env env, + T* data, + Finalizer finalizeCallback, + Hint* finalizeHint) { + napi_value value; + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), finalizeHint}); + napi_status status = napi_create_external( + env, + data, + details::FinalizeData::WrapperWithHint, + finalizeData, + &value); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED(env, status, External()); + } + return External(env, value); +} + +template +inline void External::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "External::CheckCast", "empty value"); + + napi_valuetype type; + napi_status status = napi_typeof(env, value, &type); + NAPI_CHECK(status == napi_ok, "External::CheckCast", "napi_typeof failed"); + NAPI_CHECK(type == napi_external, + "External::CheckCast", + "value is not napi_external"); +} + +template +inline External::External() : TypeTaggable() {} + +template +inline External::External(napi_env env, napi_value value) + : TypeTaggable(env, value) {} + +template +inline T* External::Data() const { + void* data; + napi_status status = napi_get_value_external(_env, _value, &data); + NAPI_THROW_IF_FAILED(_env, status, nullptr); + return reinterpret_cast(data); +} + +//////////////////////////////////////////////////////////////////////////////// +// Array class +//////////////////////////////////////////////////////////////////////////////// + +inline Array Array::New(napi_env env) { + napi_value value; + napi_status status = napi_create_array(env, &value); + NAPI_THROW_IF_FAILED(env, status, Array()); + return Array(env, value); +} + +inline Array Array::New(napi_env env, size_t length) { + napi_value value; + napi_status status = napi_create_array_with_length(env, length, &value); + NAPI_THROW_IF_FAILED(env, status, Array()); + return Array(env, value); +} + +inline void Array::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Array::CheckCast", "empty value"); + + bool result; + napi_status status = napi_is_array(env, value, &result); + NAPI_CHECK(status == napi_ok, "Array::CheckCast", "napi_is_array failed"); + NAPI_CHECK(result, "Array::CheckCast", "value is not array"); +} + +inline Array::Array() : Object() {} + +inline Array::Array(napi_env env, napi_value value) : Object(env, value) {} + +inline uint32_t Array::Length() const { + uint32_t result; + napi_status status = napi_get_array_length(_env, _value, &result); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} + +//////////////////////////////////////////////////////////////////////////////// +// ArrayBuffer class +//////////////////////////////////////////////////////////////////////////////// + +inline ArrayBuffer ArrayBuffer::New(napi_env env, size_t byteLength) { + napi_value value; + void* data; + napi_status status = napi_create_arraybuffer(env, byteLength, &data, &value); + NAPI_THROW_IF_FAILED(env, status, ArrayBuffer()); + + return ArrayBuffer(env, value); +} + +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED +inline ArrayBuffer ArrayBuffer::New(napi_env env, + void* externalData, + size_t byteLength) { + napi_value value; + napi_status status = napi_create_external_arraybuffer( + env, externalData, byteLength, nullptr, nullptr, &value); + NAPI_THROW_IF_FAILED(env, status, ArrayBuffer()); + + return ArrayBuffer(env, value); +} + +template +inline ArrayBuffer ArrayBuffer::New(napi_env env, + void* externalData, + size_t byteLength, + Finalizer finalizeCallback) { + napi_value value; + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), nullptr}); + napi_status status = napi_create_external_arraybuffer( + env, + externalData, + byteLength, + details::FinalizeData::Wrapper, + finalizeData, + &value); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED(env, status, ArrayBuffer()); + } + + return ArrayBuffer(env, value); +} + +template +inline ArrayBuffer ArrayBuffer::New(napi_env env, + void* externalData, + size_t byteLength, + Finalizer finalizeCallback, + Hint* finalizeHint) { + napi_value value; + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), finalizeHint}); + napi_status status = napi_create_external_arraybuffer( + env, + externalData, + byteLength, + details::FinalizeData::WrapperWithHint, + finalizeData, + &value); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED(env, status, ArrayBuffer()); + } + + return ArrayBuffer(env, value); +} +#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + +inline void ArrayBuffer::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "ArrayBuffer::CheckCast", "empty value"); + + bool result; + napi_status status = napi_is_arraybuffer(env, value, &result); + NAPI_CHECK(status == napi_ok, + "ArrayBuffer::CheckCast", + "napi_is_arraybuffer failed"); + NAPI_CHECK(result, "ArrayBuffer::CheckCast", "value is not arraybuffer"); +} + +inline ArrayBuffer::ArrayBuffer() : Object() {} + +inline ArrayBuffer::ArrayBuffer(napi_env env, napi_value value) + : Object(env, value) {} + +inline void* ArrayBuffer::Data() { + void* data; + napi_status status = napi_get_arraybuffer_info(_env, _value, &data, nullptr); + NAPI_THROW_IF_FAILED(_env, status, nullptr); + return data; +} + +inline size_t ArrayBuffer::ByteLength() { + size_t length; + napi_status status = + napi_get_arraybuffer_info(_env, _value, nullptr, &length); + NAPI_THROW_IF_FAILED(_env, status, 0); + return length; +} + +#if NAPI_VERSION >= 7 +inline bool ArrayBuffer::IsDetached() const { + bool detached; + napi_status status = napi_is_detached_arraybuffer(_env, _value, &detached); + NAPI_THROW_IF_FAILED(_env, status, false); + return detached; +} + +inline void ArrayBuffer::Detach() { + napi_status status = napi_detach_arraybuffer(_env, _value); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} +#endif // NAPI_VERSION >= 7 + +//////////////////////////////////////////////////////////////////////////////// +// DataView class +//////////////////////////////////////////////////////////////////////////////// +inline DataView DataView::New(napi_env env, Napi::ArrayBuffer arrayBuffer) { + return New(env, arrayBuffer, 0, arrayBuffer.ByteLength()); +} + +inline DataView DataView::New(napi_env env, + Napi::ArrayBuffer arrayBuffer, + size_t byteOffset) { + if (byteOffset > arrayBuffer.ByteLength()) { + NAPI_THROW(RangeError::New( + env, "Start offset is outside the bounds of the buffer"), + DataView()); + } + return New( + env, arrayBuffer, byteOffset, arrayBuffer.ByteLength() - byteOffset); +} + +inline DataView DataView::New(napi_env env, + Napi::ArrayBuffer arrayBuffer, + size_t byteOffset, + size_t byteLength) { + if (byteOffset + byteLength > arrayBuffer.ByteLength()) { + NAPI_THROW(RangeError::New(env, "Invalid DataView length"), DataView()); + } + napi_value value; + napi_status status = + napi_create_dataview(env, byteLength, arrayBuffer, byteOffset, &value); + NAPI_THROW_IF_FAILED(env, status, DataView()); + return DataView(env, value); +} + +inline void DataView::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "DataView::CheckCast", "empty value"); + + bool result; + napi_status status = napi_is_dataview(env, value, &result); + NAPI_CHECK( + status == napi_ok, "DataView::CheckCast", "napi_is_dataview failed"); + NAPI_CHECK(result, "DataView::CheckCast", "value is not dataview"); +} + +inline DataView::DataView() : Object() {} + +inline DataView::DataView(napi_env env, napi_value value) : Object(env, value) { + napi_status status = napi_get_dataview_info(_env, + _value /* dataView */, + &_length /* byteLength */, + &_data /* data */, + nullptr /* arrayBuffer */, + nullptr /* byteOffset */); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline Napi::ArrayBuffer DataView::ArrayBuffer() const { + napi_value arrayBuffer; + napi_status status = napi_get_dataview_info(_env, + _value /* dataView */, + nullptr /* byteLength */, + nullptr /* data */, + &arrayBuffer /* arrayBuffer */, + nullptr /* byteOffset */); + NAPI_THROW_IF_FAILED(_env, status, Napi::ArrayBuffer()); + return Napi::ArrayBuffer(_env, arrayBuffer); +} + +inline size_t DataView::ByteOffset() const { + size_t byteOffset; + napi_status status = napi_get_dataview_info(_env, + _value /* dataView */, + nullptr /* byteLength */, + nullptr /* data */, + nullptr /* arrayBuffer */, + &byteOffset /* byteOffset */); + NAPI_THROW_IF_FAILED(_env, status, 0); + return byteOffset; +} + +inline size_t DataView::ByteLength() const { + return _length; +} + +inline void* DataView::Data() const { + return _data; +} + +inline float DataView::GetFloat32(size_t byteOffset) const { + return ReadData(byteOffset); +} + +inline double DataView::GetFloat64(size_t byteOffset) const { + return ReadData(byteOffset); +} + +inline int8_t DataView::GetInt8(size_t byteOffset) const { + return ReadData(byteOffset); +} + +inline int16_t DataView::GetInt16(size_t byteOffset) const { + return ReadData(byteOffset); +} + +inline int32_t DataView::GetInt32(size_t byteOffset) const { + return ReadData(byteOffset); +} + +inline uint8_t DataView::GetUint8(size_t byteOffset) const { + return ReadData(byteOffset); +} + +inline uint16_t DataView::GetUint16(size_t byteOffset) const { + return ReadData(byteOffset); +} + +inline uint32_t DataView::GetUint32(size_t byteOffset) const { + return ReadData(byteOffset); +} + +inline void DataView::SetFloat32(size_t byteOffset, float value) const { + WriteData(byteOffset, value); +} + +inline void DataView::SetFloat64(size_t byteOffset, double value) const { + WriteData(byteOffset, value); +} + +inline void DataView::SetInt8(size_t byteOffset, int8_t value) const { + WriteData(byteOffset, value); +} + +inline void DataView::SetInt16(size_t byteOffset, int16_t value) const { + WriteData(byteOffset, value); +} + +inline void DataView::SetInt32(size_t byteOffset, int32_t value) const { + WriteData(byteOffset, value); +} + +inline void DataView::SetUint8(size_t byteOffset, uint8_t value) const { + WriteData(byteOffset, value); +} + +inline void DataView::SetUint16(size_t byteOffset, uint16_t value) const { + WriteData(byteOffset, value); +} + +inline void DataView::SetUint32(size_t byteOffset, uint32_t value) const { + WriteData(byteOffset, value); +} + +template +inline T DataView::ReadData(size_t byteOffset) const { + if (byteOffset + sizeof(T) > _length || + byteOffset + sizeof(T) < byteOffset) { // overflow + NAPI_THROW( + RangeError::New(_env, "Offset is outside the bounds of the DataView"), + 0); + } + + return *reinterpret_cast(static_cast(_data) + byteOffset); +} + +template +inline void DataView::WriteData(size_t byteOffset, T value) const { + if (byteOffset + sizeof(T) > _length || + byteOffset + sizeof(T) < byteOffset) { // overflow + NAPI_THROW_VOID( + RangeError::New(_env, "Offset is outside the bounds of the DataView")); + } + + *reinterpret_cast(static_cast(_data) + byteOffset) = value; +} + +//////////////////////////////////////////////////////////////////////////////// +// TypedArray class +//////////////////////////////////////////////////////////////////////////////// +inline void TypedArray::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "TypedArray::CheckCast", "empty value"); + + bool result; + napi_status status = napi_is_typedarray(env, value, &result); + NAPI_CHECK( + status == napi_ok, "TypedArray::CheckCast", "napi_is_typedarray failed"); + NAPI_CHECK(result, "TypedArray::CheckCast", "value is not typedarray"); +} + +inline TypedArray::TypedArray() + : Object(), _type(napi_typedarray_type::napi_int8_array), _length(0) {} + +inline TypedArray::TypedArray(napi_env env, napi_value value) + : Object(env, value), + _type(napi_typedarray_type::napi_int8_array), + _length(0) { + if (value != nullptr) { + napi_status status = + napi_get_typedarray_info(_env, + _value, + &const_cast(this)->_type, + &const_cast(this)->_length, + nullptr, + nullptr, + nullptr); + NAPI_THROW_IF_FAILED_VOID(_env, status); + } +} + +inline TypedArray::TypedArray(napi_env env, + napi_value value, + napi_typedarray_type type, + size_t length) + : Object(env, value), _type(type), _length(length) {} + +inline napi_typedarray_type TypedArray::TypedArrayType() const { + return _type; +} + +inline uint8_t TypedArray::ElementSize() const { + switch (_type) { + case napi_int8_array: + case napi_uint8_array: + case napi_uint8_clamped_array: + return 1; + case napi_int16_array: + case napi_uint16_array: + return 2; + case napi_int32_array: + case napi_uint32_array: + case napi_float32_array: + return 4; + case napi_float64_array: +#if (NAPI_VERSION > 5) + case napi_bigint64_array: + case napi_biguint64_array: +#endif // (NAPI_VERSION > 5) + return 8; + default: + return 0; + } +} + +inline size_t TypedArray::ElementLength() const { + return _length; +} + +inline size_t TypedArray::ByteOffset() const { + size_t byteOffset; + napi_status status = napi_get_typedarray_info( + _env, _value, nullptr, nullptr, nullptr, nullptr, &byteOffset); + NAPI_THROW_IF_FAILED(_env, status, 0); + return byteOffset; +} + +inline size_t TypedArray::ByteLength() const { + return ElementSize() * ElementLength(); +} + +inline Napi::ArrayBuffer TypedArray::ArrayBuffer() const { + napi_value arrayBuffer; + napi_status status = napi_get_typedarray_info( + _env, _value, nullptr, nullptr, nullptr, &arrayBuffer, nullptr); + NAPI_THROW_IF_FAILED(_env, status, Napi::ArrayBuffer()); + return Napi::ArrayBuffer(_env, arrayBuffer); +} + +//////////////////////////////////////////////////////////////////////////////// +// TypedArrayOf class +//////////////////////////////////////////////////////////////////////////////// +template +inline void TypedArrayOf::CheckCast(napi_env env, napi_value value) { + TypedArray::CheckCast(env, value); + napi_typedarray_type type; + napi_status status = napi_get_typedarray_info( + env, value, &type, nullptr, nullptr, nullptr, nullptr); + NAPI_CHECK(status == napi_ok, + "TypedArrayOf::CheckCast", + "napi_is_typedarray failed"); + + NAPI_CHECK( + (type == TypedArrayTypeForPrimitiveType() || + (type == napi_uint8_clamped_array && std::is_same::value)), + "TypedArrayOf::CheckCast", + "Array type must match the template parameter. (Uint8 arrays may " + "optionally have the \"clamped\" array type.)"); +} + +template +inline TypedArrayOf TypedArrayOf::New(napi_env env, + size_t elementLength, + napi_typedarray_type type) { + Napi::ArrayBuffer arrayBuffer = + Napi::ArrayBuffer::New(env, elementLength * sizeof(T)); + return New(env, elementLength, arrayBuffer, 0, type); +} + +template +inline TypedArrayOf TypedArrayOf::New(napi_env env, + size_t elementLength, + Napi::ArrayBuffer arrayBuffer, + size_t bufferOffset, + napi_typedarray_type type) { + napi_value value; + napi_status status = napi_create_typedarray( + env, type, elementLength, arrayBuffer, bufferOffset, &value); + NAPI_THROW_IF_FAILED(env, status, TypedArrayOf()); + + return TypedArrayOf( + env, + value, + type, + elementLength, + reinterpret_cast(reinterpret_cast(arrayBuffer.Data()) + + bufferOffset)); +} + +template +inline TypedArrayOf::TypedArrayOf() : TypedArray(), _data(nullptr) {} + +template +inline TypedArrayOf::TypedArrayOf(napi_env env, napi_value value) + : TypedArray(env, value), _data(nullptr) { + napi_status status = napi_ok; + if (value != nullptr) { + void* data = nullptr; + status = napi_get_typedarray_info( + _env, _value, &_type, &_length, &data, nullptr, nullptr); + _data = static_cast(data); + } else { + _type = TypedArrayTypeForPrimitiveType(); + _length = 0; + } + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +template +inline TypedArrayOf::TypedArrayOf(napi_env env, + napi_value value, + napi_typedarray_type type, + size_t length, + T* data) + : TypedArray(env, value, type, length), _data(data) { + if (!(type == TypedArrayTypeForPrimitiveType() || + (type == napi_uint8_clamped_array && + std::is_same::value))) { + NAPI_THROW_VOID(TypeError::New( + env, + "Array type must match the template parameter. " + "(Uint8 arrays may optionally have the \"clamped\" array type.)")); + } +} + +template +inline T& TypedArrayOf::operator[](size_t index) { + return _data[index]; +} + +template +inline const T& TypedArrayOf::operator[](size_t index) const { + return _data[index]; +} + +template +inline T* TypedArrayOf::Data() { + return _data; +} + +template +inline const T* TypedArrayOf::Data() const { + return _data; +} + +//////////////////////////////////////////////////////////////////////////////// +// Function class +//////////////////////////////////////////////////////////////////////////////// + +template +inline napi_status CreateFunction(napi_env env, + const char* utf8name, + napi_callback cb, + CbData* data, + napi_value* result) { + napi_status status = + napi_create_function(env, utf8name, NAPI_AUTO_LENGTH, cb, data, result); + if (status == napi_ok) { + status = Napi::details::AttachData(env, *result, data); + } + + return status; +} + +template +inline Function Function::New(napi_env env, const char* utf8name, void* data) { + napi_value result = nullptr; + napi_status status = napi_create_function(env, + utf8name, + NAPI_AUTO_LENGTH, + details::TemplatedVoidCallback, + data, + &result); + NAPI_THROW_IF_FAILED(env, status, Function()); + return Function(env, result); +} + +template +inline Function Function::New(napi_env env, const char* utf8name, void* data) { + napi_value result = nullptr; + napi_status status = napi_create_function(env, + utf8name, + NAPI_AUTO_LENGTH, + details::TemplatedCallback, + data, + &result); + NAPI_THROW_IF_FAILED(env, status, Function()); + return Function(env, result); +} + +template +inline Function Function::New(napi_env env, + const std::string& utf8name, + void* data) { + return Function::New(env, utf8name.c_str(), data); +} + +template +inline Function Function::New(napi_env env, + const std::string& utf8name, + void* data) { + return Function::New(env, utf8name.c_str(), data); +} + +template +inline Function Function::New(napi_env env, + Callable cb, + const char* utf8name, + void* data) { + using ReturnType = decltype(cb(CallbackInfo(nullptr, nullptr))); + using CbData = details::CallbackData; + auto callbackData = new CbData{std::move(cb), data}; + + napi_value value; + napi_status status = + CreateFunction(env, utf8name, CbData::Wrapper, callbackData, &value); + if (status != napi_ok) { + delete callbackData; + NAPI_THROW_IF_FAILED(env, status, Function()); + } + + return Function(env, value); +} + +template +inline Function Function::New(napi_env env, + Callable cb, + const std::string& utf8name, + void* data) { + return New(env, cb, utf8name.c_str(), data); +} + +inline void Function::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Function::CheckCast", "empty value"); + + napi_valuetype type; + napi_status status = napi_typeof(env, value, &type); + NAPI_CHECK(status == napi_ok, "Function::CheckCast", "napi_typeof failed"); + NAPI_CHECK(type == napi_function, + "Function::CheckCast", + "value is not napi_function"); +} + +inline Function::Function() : Object() {} + +inline Function::Function(napi_env env, napi_value value) + : Object(env, value) {} + +inline MaybeOrValue Function::operator()( + const std::initializer_list& args) const { + return Call(Env().Undefined(), args); +} + +inline MaybeOrValue Function::Call( + const std::initializer_list& args) const { + return Call(Env().Undefined(), args); +} + +inline MaybeOrValue Function::Call( + const std::vector& args) const { + return Call(Env().Undefined(), args); +} + +inline MaybeOrValue Function::Call( + const std::vector& args) const { + return Call(Env().Undefined(), args); +} + +inline MaybeOrValue Function::Call(size_t argc, + const napi_value* args) const { + return Call(Env().Undefined(), argc, args); +} + +inline MaybeOrValue Function::Call( + napi_value recv, const std::initializer_list& args) const { + return Call(recv, args.size(), args.begin()); +} + +inline MaybeOrValue Function::Call( + napi_value recv, const std::vector& args) const { + return Call(recv, args.size(), args.data()); +} + +inline MaybeOrValue Function::Call( + napi_value recv, const std::vector& args) const { + const size_t argc = args.size(); + const size_t stackArgsCount = 6; + napi_value stackArgs[stackArgsCount]; + std::vector heapArgs; + napi_value* argv; + if (argc <= stackArgsCount) { + argv = stackArgs; + } else { + heapArgs.resize(argc); + argv = heapArgs.data(); + } + + for (size_t index = 0; index < argc; index++) { + argv[index] = static_cast(args[index]); + } + + return Call(recv, argc, argv); +} + +inline MaybeOrValue Function::Call(napi_value recv, + size_t argc, + const napi_value* args) const { + napi_value result; + napi_status status = + napi_call_function(_env, recv, _value, argc, args, &result); + NAPI_RETURN_OR_THROW_IF_FAILED( + _env, status, Napi::Value(_env, result), Napi::Value); +} + +inline MaybeOrValue Function::MakeCallback( + napi_value recv, + const std::initializer_list& args, + napi_async_context context) const { + return MakeCallback(recv, args.size(), args.begin(), context); +} + +inline MaybeOrValue Function::MakeCallback( + napi_value recv, + const std::vector& args, + napi_async_context context) const { + return MakeCallback(recv, args.size(), args.data(), context); +} + +inline MaybeOrValue Function::MakeCallback( + napi_value recv, + size_t argc, + const napi_value* args, + napi_async_context context) const { + napi_value result; + napi_status status = + napi_make_callback(_env, context, recv, _value, argc, args, &result); + NAPI_RETURN_OR_THROW_IF_FAILED( + _env, status, Napi::Value(_env, result), Napi::Value); +} + +inline MaybeOrValue Function::New( + const std::initializer_list& args) const { + return New(args.size(), args.begin()); +} + +inline MaybeOrValue Function::New( + const std::vector& args) const { + return New(args.size(), args.data()); +} + +inline MaybeOrValue Function::New(size_t argc, + const napi_value* args) const { + napi_value result; + napi_status status = napi_new_instance(_env, _value, argc, args, &result); + NAPI_RETURN_OR_THROW_IF_FAILED( + _env, status, Napi::Object(_env, result), Napi::Object); +} + +//////////////////////////////////////////////////////////////////////////////// +// Promise class +//////////////////////////////////////////////////////////////////////////////// + +inline Promise::Deferred Promise::Deferred::New(napi_env env) { + return Promise::Deferred(env); +} + +inline Promise::Deferred::Deferred(napi_env env) : _env(env) { + napi_status status = napi_create_promise(_env, &_deferred, &_promise); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline Promise Promise::Deferred::Promise() const { + return Napi::Promise(_env, _promise); +} + +inline Napi::Env Promise::Deferred::Env() const { + return Napi::Env(_env); +} + +inline void Promise::Deferred::Resolve(napi_value value) const { + napi_status status = napi_resolve_deferred(_env, _deferred, value); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline void Promise::Deferred::Reject(napi_value value) const { + napi_status status = napi_reject_deferred(_env, _deferred, value); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline void Promise::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Promise::CheckCast", "empty value"); + + bool result; + napi_status status = napi_is_promise(env, value, &result); + NAPI_CHECK(status == napi_ok, "Promise::CheckCast", "napi_is_promise failed"); + NAPI_CHECK(result, "Promise::CheckCast", "value is not promise"); +} + +inline Promise::Promise(napi_env env, napi_value value) : Object(env, value) {} + +//////////////////////////////////////////////////////////////////////////////// +// Buffer class +//////////////////////////////////////////////////////////////////////////////// + +template +inline Buffer Buffer::New(napi_env env, size_t length) { + napi_value value; + void* data; + napi_status status = + napi_create_buffer(env, length * sizeof(T), &data, &value); + NAPI_THROW_IF_FAILED(env, status, Buffer()); + return Buffer(env, value); +} + +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED +template +inline Buffer Buffer::New(napi_env env, T* data, size_t length) { + napi_value value; + napi_status status = napi_create_external_buffer( + env, length * sizeof(T), data, nullptr, nullptr, &value); + NAPI_THROW_IF_FAILED(env, status, Buffer()); + return Buffer(env, value); +} + +template +template +inline Buffer Buffer::New(napi_env env, + T* data, + size_t length, + Finalizer finalizeCallback) { + napi_value value; + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), nullptr}); + napi_status status = + napi_create_external_buffer(env, + length * sizeof(T), + data, + details::FinalizeData::Wrapper, + finalizeData, + &value); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED(env, status, Buffer()); + } + return Buffer(env, value); +} + +template +template +inline Buffer Buffer::New(napi_env env, + T* data, + size_t length, + Finalizer finalizeCallback, + Hint* finalizeHint) { + napi_value value; + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), finalizeHint}); + napi_status status = napi_create_external_buffer( + env, + length * sizeof(T), + data, + details::FinalizeData::WrapperWithHint, + finalizeData, + &value); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED(env, status, Buffer()); + } + return Buffer(env, value); +} +#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + +template +inline Buffer Buffer::NewOrCopy(napi_env env, T* data, size_t length) { +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + napi_value value; + napi_status status = napi_create_external_buffer( + env, length * sizeof(T), data, nullptr, nullptr, &value); + if (status == details::napi_no_external_buffers_allowed) { +#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + // If we can't create an external buffer, we'll just copy the data. + return Buffer::Copy(env, data, length); +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + } + NAPI_THROW_IF_FAILED(env, status, Buffer()); + return Buffer(env, value); +#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED +} + +template +template +inline Buffer Buffer::NewOrCopy(napi_env env, + T* data, + size_t length, + Finalizer finalizeCallback) { + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), nullptr}); +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + napi_value value; + napi_status status = + napi_create_external_buffer(env, + length * sizeof(T), + data, + details::FinalizeData::Wrapper, + finalizeData, + &value); + if (status == details::napi_no_external_buffers_allowed) { +#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + // If we can't create an external buffer, we'll just copy the data. + Buffer ret = Buffer::Copy(env, data, length); + details::FinalizeData::Wrapper(env, data, finalizeData); + return ret; +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + } + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED(env, status, Buffer()); + } + return Buffer(env, value); +#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED +} + +template +template +inline Buffer Buffer::NewOrCopy(napi_env env, + T* data, + size_t length, + Finalizer finalizeCallback, + Hint* finalizeHint) { + details::FinalizeData* finalizeData = + new details::FinalizeData( + {std::move(finalizeCallback), finalizeHint}); +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + napi_value value; + napi_status status = napi_create_external_buffer( + env, + length * sizeof(T), + data, + details::FinalizeData::WrapperWithHint, + finalizeData, + &value); + if (status == details::napi_no_external_buffers_allowed) { +#endif + // If we can't create an external buffer, we'll just copy the data. + Buffer ret = Buffer::Copy(env, data, length); + details::FinalizeData::WrapperWithHint( + env, data, finalizeData); + return ret; +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + } + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED(env, status, Buffer()); + } + return Buffer(env, value); +#endif +} + +template +inline Buffer Buffer::Copy(napi_env env, const T* data, size_t length) { + napi_value value; + napi_status status = + napi_create_buffer_copy(env, length * sizeof(T), data, nullptr, &value); + NAPI_THROW_IF_FAILED(env, status, Buffer()); + return Buffer(env, value); +} + +template +inline void Buffer::CheckCast(napi_env env, napi_value value) { + NAPI_CHECK(value != nullptr, "Buffer::CheckCast", "empty value"); + + bool result; + napi_status status = napi_is_buffer(env, value, &result); + NAPI_CHECK(status == napi_ok, "Buffer::CheckCast", "napi_is_buffer failed"); + NAPI_CHECK(result, "Buffer::CheckCast", "value is not buffer"); +} + +template +inline Buffer::Buffer() : Uint8Array() {} + +template +inline Buffer::Buffer(napi_env env, napi_value value) + : Uint8Array(env, value) {} + +template +inline size_t Buffer::Length() const { + return ByteLength() / sizeof(T); +} + +template +inline T* Buffer::Data() const { + return reinterpret_cast(const_cast(Uint8Array::Data())); +} + +//////////////////////////////////////////////////////////////////////////////// +// Error class +//////////////////////////////////////////////////////////////////////////////// + +inline Error Error::New(napi_env env) { + napi_status status; + napi_value error = nullptr; + bool is_exception_pending; + napi_extended_error_info last_error_info_copy; + + { + // We must retrieve the last error info before doing anything else because + // doing anything else will replace the last error info. + const napi_extended_error_info* last_error_info; + status = napi_get_last_error_info(env, &last_error_info); + NAPI_FATAL_IF_FAILED(status, "Error::New", "napi_get_last_error_info"); + + // All fields of the `napi_extended_error_info` structure gets reset in + // subsequent Node-API function calls on the same `env`. This includes a + // call to `napi_is_exception_pending()`. So here it is necessary to make a + // copy of the information as the `error_code` field is used later on. + memcpy(&last_error_info_copy, + last_error_info, + sizeof(napi_extended_error_info)); + } + + status = napi_is_exception_pending(env, &is_exception_pending); + NAPI_FATAL_IF_FAILED(status, "Error::New", "napi_is_exception_pending"); + + // A pending exception takes precedence over any internal error status. + if (is_exception_pending) { + status = napi_get_and_clear_last_exception(env, &error); + NAPI_FATAL_IF_FAILED( + status, "Error::New", "napi_get_and_clear_last_exception"); + } else { + const char* error_message = last_error_info_copy.error_message != nullptr + ? last_error_info_copy.error_message + : "Error in native callback"; + + napi_value message; + status = napi_create_string_utf8( + env, error_message, std::strlen(error_message), &message); + NAPI_FATAL_IF_FAILED(status, "Error::New", "napi_create_string_utf8"); + + switch (last_error_info_copy.error_code) { + case napi_object_expected: + case napi_string_expected: + case napi_boolean_expected: + case napi_number_expected: + status = napi_create_type_error(env, nullptr, message, &error); + break; + default: + status = napi_create_error(env, nullptr, message, &error); + break; + } + NAPI_FATAL_IF_FAILED(status, "Error::New", "napi_create_error"); + } + + return Error(env, error); +} + +inline Error Error::New(napi_env env, const char* message) { + return Error::New( + env, message, std::strlen(message), napi_create_error); +} + +inline Error Error::New(napi_env env, const std::string& message) { + return Error::New( + env, message.c_str(), message.size(), napi_create_error); +} + +inline NAPI_NO_RETURN void Error::Fatal(const char* location, + const char* message) { + napi_fatal_error(location, NAPI_AUTO_LENGTH, message, NAPI_AUTO_LENGTH); +} + +inline Error::Error() : ObjectReference() {} + +inline Error::Error(napi_env env, napi_value value) + : ObjectReference(env, nullptr) { + if (value != nullptr) { + // Attempting to create a reference on the error object. + // If it's not a Object/Function/Symbol, this call will return an error + // status. + napi_status status = napi_create_reference(env, value, 1, &_ref); + + if (status != napi_ok) { + napi_value wrappedErrorObj; + + // Create an error object + status = napi_create_object(env, &wrappedErrorObj); + NAPI_FATAL_IF_FAILED(status, "Error::Error", "napi_create_object"); + + // property flag that we attach to show the error object is wrapped + napi_property_descriptor wrapObjFlag = { + ERROR_WRAP_VALUE(), // Unique GUID identifier since Symbol isn't a + // viable option + nullptr, + nullptr, + nullptr, + nullptr, + Value::From(env, value), + napi_enumerable, + nullptr}; + + status = napi_define_properties(env, wrappedErrorObj, 1, &wrapObjFlag); +#ifdef NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS + if (status == napi_pending_exception) { + // Test if the pending exception was reported because the environment is + // shutting down. We assume that a status of napi_pending_exception + // coupled with the absence of an actual pending exception means that + // the environment is shutting down. If so, we replace the + // napi_pending_exception status with napi_ok. + bool is_exception_pending = false; + status = napi_is_exception_pending(env, &is_exception_pending); + if (status == napi_ok && !is_exception_pending) { + status = napi_ok; + } else { + status = napi_pending_exception; + } + } +#endif // NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS + NAPI_FATAL_IF_FAILED(status, "Error::Error", "napi_define_properties"); + + // Create a reference on the newly wrapped object + status = napi_create_reference(env, wrappedErrorObj, 1, &_ref); + } + + // Avoid infinite recursion in the failure case. + NAPI_FATAL_IF_FAILED(status, "Error::Error", "napi_create_reference"); + } +} + +inline Object Error::Value() const { + if (_ref == nullptr) { + return Object(_env, nullptr); + } + + napi_value refValue; + napi_status status = napi_get_reference_value(_env, _ref, &refValue); + NAPI_THROW_IF_FAILED(_env, status, Object()); + + napi_valuetype type; + status = napi_typeof(_env, refValue, &type); + NAPI_THROW_IF_FAILED(_env, status, Object()); + + // If refValue isn't a symbol, then we proceed to whether the refValue has the + // wrapped error flag + if (type != napi_symbol) { + // We are checking if the object is wrapped + bool isWrappedObject = false; + + status = napi_has_property(_env, + refValue, + String::From(_env, ERROR_WRAP_VALUE()), + &isWrappedObject); + + // Don't care about status + if (isWrappedObject) { + napi_value unwrappedValue; + status = napi_get_property(_env, + refValue, + String::From(_env, ERROR_WRAP_VALUE()), + &unwrappedValue); + NAPI_THROW_IF_FAILED(_env, status, Object()); + + return Object(_env, unwrappedValue); + } + } + + return Object(_env, refValue); +} + +inline Error::Error(Error&& other) : ObjectReference(std::move(other)) {} + +inline Error& Error::operator=(Error&& other) { + static_cast*>(this)->operator=(std::move(other)); + return *this; +} + +inline Error::Error(const Error& other) : ObjectReference(other) {} + +inline Error& Error::operator=(const Error& other) { + Reset(); + + _env = other.Env(); + HandleScope scope(_env); + + napi_value value = other.Value(); + if (value != nullptr) { + napi_status status = napi_create_reference(_env, value, 1, &_ref); + NAPI_THROW_IF_FAILED(_env, status, *this); + } + + return *this; +} + +inline const std::string& Error::Message() const NAPI_NOEXCEPT { + if (_message.size() == 0 && _env != nullptr) { +#ifdef NAPI_CPP_EXCEPTIONS + try { + _message = Get("message").As(); + } catch (...) { + // Catch all errors here, to include e.g. a std::bad_alloc from + // the std::string::operator=, because this method may not throw. + } +#else // NAPI_CPP_EXCEPTIONS +#if defined(NODE_ADDON_API_ENABLE_MAYBE) + Napi::Value message_val; + if (Get("message").UnwrapTo(&message_val)) { + _message = message_val.As(); + } +#else + _message = Get("message").As(); +#endif +#endif // NAPI_CPP_EXCEPTIONS + } + return _message; +} + +// we created an object on the &_ref +inline void Error::ThrowAsJavaScriptException() const { + HandleScope scope(_env); + if (!IsEmpty()) { +#ifdef NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS + bool pendingException = false; + + // check if there is already a pending exception. If so don't try to throw a + // new one as that is not allowed/possible + napi_status status = napi_is_exception_pending(_env, &pendingException); + + if ((status != napi_ok) || + ((status == napi_ok) && (pendingException == false))) { + // We intentionally don't use `NAPI_THROW_*` macros here to ensure + // that there is no possible recursion as `ThrowAsJavaScriptException` + // is part of `NAPI_THROW_*` macro definition for noexcept. + + status = napi_throw(_env, Value()); + + if (status == napi_pending_exception) { + // The environment must be terminating as we checked earlier and there + // was no pending exception. In this case continuing will result + // in a fatal error and there is nothing the author has done incorrectly + // in their code that is worth flagging through a fatal error + return; + } + } else { + status = napi_pending_exception; + } +#else + // We intentionally don't use `NAPI_THROW_*` macros here to ensure + // that there is no possible recursion as `ThrowAsJavaScriptException` + // is part of `NAPI_THROW_*` macro definition for noexcept. + + napi_status status = napi_throw(_env, Value()); +#endif + +#ifdef NAPI_CPP_EXCEPTIONS + if (status != napi_ok) { + throw Error::New(_env); + } +#else // NAPI_CPP_EXCEPTIONS + NAPI_FATAL_IF_FAILED( + status, "Error::ThrowAsJavaScriptException", "napi_throw"); +#endif // NAPI_CPP_EXCEPTIONS + } +} + +#ifdef NAPI_CPP_EXCEPTIONS + +inline const char* Error::what() const NAPI_NOEXCEPT { + return Message().c_str(); +} + +#endif // NAPI_CPP_EXCEPTIONS + +inline const char* Error::ERROR_WRAP_VALUE() NAPI_NOEXCEPT { + return "4bda9e7e-4913-4dbc-95de-891cbf66598e-errorVal"; +} + +template +inline TError Error::New(napi_env env, + const char* message, + size_t length, + create_error_fn create_error) { + napi_value str; + napi_status status = napi_create_string_utf8(env, message, length, &str); + NAPI_THROW_IF_FAILED(env, status, TError()); + + napi_value error; + status = create_error(env, nullptr, str, &error); + NAPI_THROW_IF_FAILED(env, status, TError()); + + return TError(env, error); +} + +inline TypeError TypeError::New(napi_env env, const char* message) { + return Error::New( + env, message, std::strlen(message), napi_create_type_error); +} + +inline TypeError TypeError::New(napi_env env, const std::string& message) { + return Error::New( + env, message.c_str(), message.size(), napi_create_type_error); +} + +inline TypeError::TypeError() : Error() {} + +inline TypeError::TypeError(napi_env env, napi_value value) + : Error(env, value) {} + +inline RangeError RangeError::New(napi_env env, const char* message) { + return Error::New( + env, message, std::strlen(message), napi_create_range_error); +} + +inline RangeError RangeError::New(napi_env env, const std::string& message) { + return Error::New( + env, message.c_str(), message.size(), napi_create_range_error); +} + +inline RangeError::RangeError() : Error() {} + +inline RangeError::RangeError(napi_env env, napi_value value) + : Error(env, value) {} + +#if NAPI_VERSION > 8 +inline SyntaxError SyntaxError::New(napi_env env, const char* message) { + return Error::New( + env, message, std::strlen(message), node_api_create_syntax_error); +} + +inline SyntaxError SyntaxError::New(napi_env env, const std::string& message) { + return Error::New( + env, message.c_str(), message.size(), node_api_create_syntax_error); +} + +inline SyntaxError::SyntaxError() : Error() {} + +inline SyntaxError::SyntaxError(napi_env env, napi_value value) + : Error(env, value) {} +#endif // NAPI_VERSION > 8 + +//////////////////////////////////////////////////////////////////////////////// +// Reference class +//////////////////////////////////////////////////////////////////////////////// + +template +inline Reference Reference::New(const T& value, + uint32_t initialRefcount) { + napi_env env = value.Env(); + napi_value val = value; + + if (val == nullptr) { + return Reference(env, nullptr); + } + + napi_ref ref; + napi_status status = napi_create_reference(env, value, initialRefcount, &ref); + NAPI_THROW_IF_FAILED(env, status, Reference()); + + return Reference(env, ref); +} + +template +inline Reference::Reference() + : _env(nullptr), _ref(nullptr), _suppressDestruct(false) {} + +template +inline Reference::Reference(napi_env env, napi_ref ref) + : _env(env), _ref(ref), _suppressDestruct(false) {} + +template +inline Reference::~Reference() { + if (_ref != nullptr) { + if (!_suppressDestruct) { + napi_delete_reference(_env, _ref); + } + + _ref = nullptr; + } +} + +template +inline Reference::Reference(Reference&& other) + : _env(other._env), + _ref(other._ref), + _suppressDestruct(other._suppressDestruct) { + other._env = nullptr; + other._ref = nullptr; + other._suppressDestruct = false; +} + +template +inline Reference& Reference::operator=(Reference&& other) { + Reset(); + _env = other._env; + _ref = other._ref; + _suppressDestruct = other._suppressDestruct; + other._env = nullptr; + other._ref = nullptr; + other._suppressDestruct = false; + return *this; +} + +template +inline Reference::Reference(const Reference& other) + : _env(other._env), _ref(nullptr), _suppressDestruct(false) { + HandleScope scope(_env); + + napi_value value = other.Value(); + if (value != nullptr) { + // Copying is a limited scenario (currently only used for Error object) and + // always creates a strong reference to the given value even if the incoming + // reference is weak. + napi_status status = napi_create_reference(_env, value, 1, &_ref); + NAPI_FATAL_IF_FAILED( + status, "Reference::Reference", "napi_create_reference"); + } +} + +template +inline Reference::operator napi_ref() const { + return _ref; +} + +template +inline bool Reference::operator==(const Reference& other) const { + HandleScope scope(_env); + return this->Value().StrictEquals(other.Value()); +} + +template +inline bool Reference::operator!=(const Reference& other) const { + return !this->operator==(other); +} + +template +inline Napi::Env Reference::Env() const { + return Napi::Env(_env); +} + +template +inline bool Reference::IsEmpty() const { + return _ref == nullptr; +} + +template +inline T Reference::Value() const { + if (_ref == nullptr) { + return T(_env, nullptr); + } + + napi_value value; + napi_status status = napi_get_reference_value(_env, _ref, &value); + NAPI_THROW_IF_FAILED(_env, status, T()); + return T(_env, value); +} + +template +inline uint32_t Reference::Ref() const { + uint32_t result; + napi_status status = napi_reference_ref(_env, _ref, &result); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} + +template +inline uint32_t Reference::Unref() const { + uint32_t result; + napi_status status = napi_reference_unref(_env, _ref, &result); + NAPI_THROW_IF_FAILED(_env, status, 0); + return result; +} + +template +inline void Reference::Reset() { + if (_ref != nullptr) { + napi_status status = napi_delete_reference(_env, _ref); + NAPI_THROW_IF_FAILED_VOID(_env, status); + _ref = nullptr; + } +} + +template +inline void Reference::Reset(const T& value, uint32_t refcount) { + Reset(); + _env = value.Env(); + + napi_value val = value; + if (val != nullptr) { + napi_status status = napi_create_reference(_env, value, refcount, &_ref); + NAPI_THROW_IF_FAILED_VOID(_env, status); + } +} + +template +inline void Reference::SuppressDestruct() { + _suppressDestruct = true; +} + +template +inline Reference Weak(T value) { + return Reference::New(value, 0); +} + +inline ObjectReference Weak(Object value) { + return Reference::New(value, 0); +} + +inline FunctionReference Weak(Function value) { + return Reference::New(value, 0); +} + +template +inline Reference Persistent(T value) { + return Reference::New(value, 1); +} + +inline ObjectReference Persistent(Object value) { + return Reference::New(value, 1); +} + +inline FunctionReference Persistent(Function value) { + return Reference::New(value, 1); +} + +//////////////////////////////////////////////////////////////////////////////// +// ObjectReference class +//////////////////////////////////////////////////////////////////////////////// + +inline ObjectReference::ObjectReference() : Reference() {} + +inline ObjectReference::ObjectReference(napi_env env, napi_ref ref) + : Reference(env, ref) {} + +inline ObjectReference::ObjectReference(Reference&& other) + : Reference(std::move(other)) {} + +inline ObjectReference& ObjectReference::operator=(Reference&& other) { + static_cast*>(this)->operator=(std::move(other)); + return *this; +} + +inline ObjectReference::ObjectReference(ObjectReference&& other) + : Reference(std::move(other)) {} + +inline ObjectReference& ObjectReference::operator=(ObjectReference&& other) { + static_cast*>(this)->operator=(std::move(other)); + return *this; +} + +inline ObjectReference::ObjectReference(const ObjectReference& other) + : Reference(other) {} + +inline MaybeOrValue ObjectReference::Get( + const char* utf8name) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().Get(utf8name); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue ObjectReference::Get( + const std::string& utf8name) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().Get(utf8name); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue ObjectReference::Set(const char* utf8name, + napi_value value) const { + HandleScope scope(_env); + return Value().Set(utf8name, value); +} + +inline MaybeOrValue ObjectReference::Set(const char* utf8name, + Napi::Value value) const { + HandleScope scope(_env); + return Value().Set(utf8name, value); +} + +inline MaybeOrValue ObjectReference::Set(const char* utf8name, + const char* utf8value) const { + HandleScope scope(_env); + return Value().Set(utf8name, utf8value); +} + +inline MaybeOrValue ObjectReference::Set(const char* utf8name, + bool boolValue) const { + HandleScope scope(_env); + return Value().Set(utf8name, boolValue); +} + +inline MaybeOrValue ObjectReference::Set(const char* utf8name, + double numberValue) const { + HandleScope scope(_env); + return Value().Set(utf8name, numberValue); +} + +inline MaybeOrValue ObjectReference::Set(const std::string& utf8name, + napi_value value) const { + HandleScope scope(_env); + return Value().Set(utf8name, value); +} + +inline MaybeOrValue ObjectReference::Set(const std::string& utf8name, + Napi::Value value) const { + HandleScope scope(_env); + return Value().Set(utf8name, value); +} + +inline MaybeOrValue ObjectReference::Set(const std::string& utf8name, + std::string& utf8value) const { + HandleScope scope(_env); + return Value().Set(utf8name, utf8value); +} + +inline MaybeOrValue ObjectReference::Set(const std::string& utf8name, + bool boolValue) const { + HandleScope scope(_env); + return Value().Set(utf8name, boolValue); +} + +inline MaybeOrValue ObjectReference::Set(const std::string& utf8name, + double numberValue) const { + HandleScope scope(_env); + return Value().Set(utf8name, numberValue); +} + +inline MaybeOrValue ObjectReference::Get(uint32_t index) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().Get(index); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue ObjectReference::Set(uint32_t index, + napi_value value) const { + HandleScope scope(_env); + return Value().Set(index, value); +} + +inline MaybeOrValue ObjectReference::Set(uint32_t index, + Napi::Value value) const { + HandleScope scope(_env); + return Value().Set(index, value); +} + +inline MaybeOrValue ObjectReference::Set(uint32_t index, + const char* utf8value) const { + HandleScope scope(_env); + return Value().Set(index, utf8value); +} + +inline MaybeOrValue ObjectReference::Set( + uint32_t index, const std::string& utf8value) const { + HandleScope scope(_env); + return Value().Set(index, utf8value); +} + +inline MaybeOrValue ObjectReference::Set(uint32_t index, + bool boolValue) const { + HandleScope scope(_env); + return Value().Set(index, boolValue); +} + +inline MaybeOrValue ObjectReference::Set(uint32_t index, + double numberValue) const { + HandleScope scope(_env); + return Value().Set(index, numberValue); +} + +//////////////////////////////////////////////////////////////////////////////// +// FunctionReference class +//////////////////////////////////////////////////////////////////////////////// + +inline FunctionReference::FunctionReference() : Reference() {} + +inline FunctionReference::FunctionReference(napi_env env, napi_ref ref) + : Reference(env, ref) {} + +inline FunctionReference::FunctionReference(Reference&& other) + : Reference(std::move(other)) {} + +inline FunctionReference& FunctionReference::operator=( + Reference&& other) { + static_cast*>(this)->operator=(std::move(other)); + return *this; +} + +inline FunctionReference::FunctionReference(FunctionReference&& other) + : Reference(std::move(other)) {} + +inline FunctionReference& FunctionReference::operator=( + FunctionReference&& other) { + static_cast*>(this)->operator=(std::move(other)); + return *this; +} + +inline MaybeOrValue FunctionReference::operator()( + const std::initializer_list& args) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value()(args); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue FunctionReference::Call( + const std::initializer_list& args) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().Call(args); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue FunctionReference::Call( + const std::vector& args) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().Call(args); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue FunctionReference::Call( + napi_value recv, const std::initializer_list& args) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().Call(recv, args); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue FunctionReference::Call( + napi_value recv, const std::vector& args) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().Call(recv, args); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue FunctionReference::Call( + napi_value recv, size_t argc, const napi_value* args) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().Call(recv, argc, args); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue FunctionReference::MakeCallback( + napi_value recv, + const std::initializer_list& args, + napi_async_context context) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().MakeCallback(recv, args, context); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue FunctionReference::MakeCallback( + napi_value recv, + const std::vector& args, + napi_async_context context) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().MakeCallback(recv, args, context); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue FunctionReference::MakeCallback( + napi_value recv, + size_t argc, + const napi_value* args, + napi_async_context context) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = + Value().MakeCallback(recv, argc, args, context); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap())); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Value(); + } + return scope.Escape(result); +#endif +} + +inline MaybeOrValue FunctionReference::New( + const std::initializer_list& args) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().New(args); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap()).As()); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Object(); + } + return scope.Escape(result).As(); +#endif +} + +inline MaybeOrValue FunctionReference::New( + const std::vector& args) const { + EscapableHandleScope scope(_env); + MaybeOrValue result = Value().New(args); +#ifdef NODE_ADDON_API_ENABLE_MAYBE + if (result.IsJust()) { + return Just(scope.Escape(result.Unwrap()).As()); + } + return result; +#else + if (scope.Env().IsExceptionPending()) { + return Object(); + } + return scope.Escape(result).As(); +#endif +} + +//////////////////////////////////////////////////////////////////////////////// +// CallbackInfo class +//////////////////////////////////////////////////////////////////////////////// + +inline CallbackInfo::CallbackInfo(napi_env env, napi_callback_info info) + : _env(env), + _info(info), + _this(nullptr), + _dynamicArgs(nullptr), + _data(nullptr) { + _argc = _staticArgCount; + _argv = _staticArgs; + napi_status status = + napi_get_cb_info(env, info, &_argc, _argv, &_this, &_data); + NAPI_THROW_IF_FAILED_VOID(_env, status); + + if (_argc > _staticArgCount) { + // Use either a fixed-size array (on the stack) or a dynamically-allocated + // array (on the heap) depending on the number of args. + _dynamicArgs = new napi_value[_argc]; + _argv = _dynamicArgs; + + status = napi_get_cb_info(env, info, &_argc, _argv, nullptr, nullptr); + NAPI_THROW_IF_FAILED_VOID(_env, status); + } +} + +inline CallbackInfo::~CallbackInfo() { + if (_dynamicArgs != nullptr) { + delete[] _dynamicArgs; + } +} + +inline CallbackInfo::operator napi_callback_info() const { + return _info; +} + +inline Value CallbackInfo::NewTarget() const { + napi_value newTarget; + napi_status status = napi_get_new_target(_env, _info, &newTarget); + NAPI_THROW_IF_FAILED(_env, status, Value()); + return Value(_env, newTarget); +} + +inline bool CallbackInfo::IsConstructCall() const { + return !NewTarget().IsEmpty(); +} + +inline Napi::Env CallbackInfo::Env() const { + return Napi::Env(_env); +} + +inline size_t CallbackInfo::Length() const { + return _argc; +} + +inline const Value CallbackInfo::operator[](size_t index) const { + return index < _argc ? Value(_env, _argv[index]) : Env().Undefined(); +} + +inline Value CallbackInfo::This() const { + if (_this == nullptr) { + return Env().Undefined(); + } + return Object(_env, _this); +} + +inline void* CallbackInfo::Data() const { + return _data; +} + +inline void CallbackInfo::SetData(void* data) { + _data = data; +} + +//////////////////////////////////////////////////////////////////////////////// +// PropertyDescriptor class +//////////////////////////////////////////////////////////////////////////////// + +template +PropertyDescriptor PropertyDescriptor::Accessor( + const char* utf8name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + + desc.utf8name = utf8name; + desc.getter = details::TemplatedCallback; + desc.attributes = attributes; + desc.data = data; + + return desc; +} + +template +PropertyDescriptor PropertyDescriptor::Accessor( + const std::string& utf8name, + napi_property_attributes attributes, + void* data) { + return Accessor(utf8name.c_str(), attributes, data); +} + +template +PropertyDescriptor PropertyDescriptor::Accessor( + Name name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + + desc.name = name; + desc.getter = details::TemplatedCallback; + desc.attributes = attributes; + desc.data = data; + + return desc; +} + +template +PropertyDescriptor PropertyDescriptor::Accessor( + const char* utf8name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + + desc.utf8name = utf8name; + desc.getter = details::TemplatedCallback; + desc.setter = details::TemplatedVoidCallback; + desc.attributes = attributes; + desc.data = data; + + return desc; +} + +template +PropertyDescriptor PropertyDescriptor::Accessor( + const std::string& utf8name, + napi_property_attributes attributes, + void* data) { + return Accessor(utf8name.c_str(), attributes, data); +} + +template +PropertyDescriptor PropertyDescriptor::Accessor( + Name name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + + desc.name = name; + desc.getter = details::TemplatedCallback; + desc.setter = details::TemplatedVoidCallback; + desc.attributes = attributes; + desc.data = data; + + return desc; +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + Napi::Env env, + Napi::Object object, + const char* utf8name, + Getter getter, + napi_property_attributes attributes, + void* data) { + using CbData = details::CallbackData; + auto callbackData = new CbData({getter, data}); + + napi_status status = AttachData(env, object, callbackData); + if (status != napi_ok) { + delete callbackData; + NAPI_THROW_IF_FAILED(env, status, napi_property_descriptor()); + } + + return PropertyDescriptor({utf8name, + nullptr, + nullptr, + CbData::Wrapper, + nullptr, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + Napi::Env env, + Napi::Object object, + const std::string& utf8name, + Getter getter, + napi_property_attributes attributes, + void* data) { + return Accessor(env, object, utf8name.c_str(), getter, attributes, data); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + Napi::Env env, + Napi::Object object, + Name name, + Getter getter, + napi_property_attributes attributes, + void* data) { + using CbData = details::CallbackData; + auto callbackData = new CbData({getter, data}); + + napi_status status = AttachData(env, object, callbackData); + if (status != napi_ok) { + delete callbackData; + NAPI_THROW_IF_FAILED(env, status, napi_property_descriptor()); + } + + return PropertyDescriptor({nullptr, + name, + nullptr, + CbData::Wrapper, + nullptr, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + Napi::Env env, + Napi::Object object, + const char* utf8name, + Getter getter, + Setter setter, + napi_property_attributes attributes, + void* data) { + using CbData = details::AccessorCallbackData; + auto callbackData = new CbData({getter, setter, data}); + + napi_status status = AttachData(env, object, callbackData); + if (status != napi_ok) { + delete callbackData; + NAPI_THROW_IF_FAILED(env, status, napi_property_descriptor()); + } + + return PropertyDescriptor({utf8name, + nullptr, + nullptr, + CbData::GetterWrapper, + CbData::SetterWrapper, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + Napi::Env env, + Napi::Object object, + const std::string& utf8name, + Getter getter, + Setter setter, + napi_property_attributes attributes, + void* data) { + return Accessor( + env, object, utf8name.c_str(), getter, setter, attributes, data); +} + +template +inline PropertyDescriptor PropertyDescriptor::Accessor( + Napi::Env env, + Napi::Object object, + Name name, + Getter getter, + Setter setter, + napi_property_attributes attributes, + void* data) { + using CbData = details::AccessorCallbackData; + auto callbackData = new CbData({getter, setter, data}); + + napi_status status = AttachData(env, object, callbackData); + if (status != napi_ok) { + delete callbackData; + NAPI_THROW_IF_FAILED(env, status, napi_property_descriptor()); + } + + return PropertyDescriptor({nullptr, + name, + nullptr, + CbData::GetterWrapper, + CbData::SetterWrapper, + nullptr, + attributes, + callbackData}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Function( + Napi::Env env, + Napi::Object /*object*/, + const char* utf8name, + Callable cb, + napi_property_attributes attributes, + void* data) { + return PropertyDescriptor({utf8name, + nullptr, + nullptr, + nullptr, + nullptr, + Napi::Function::New(env, cb, utf8name, data), + attributes, + nullptr}); +} + +template +inline PropertyDescriptor PropertyDescriptor::Function( + Napi::Env env, + Napi::Object object, + const std::string& utf8name, + Callable cb, + napi_property_attributes attributes, + void* data) { + return Function(env, object, utf8name.c_str(), cb, attributes, data); +} + +template +inline PropertyDescriptor PropertyDescriptor::Function( + Napi::Env env, + Napi::Object /*object*/, + Name name, + Callable cb, + napi_property_attributes attributes, + void* data) { + return PropertyDescriptor({nullptr, + name, + nullptr, + nullptr, + nullptr, + Napi::Function::New(env, cb, nullptr, data), + attributes, + nullptr}); +} + +inline PropertyDescriptor PropertyDescriptor::Value( + const char* utf8name, + napi_value value, + napi_property_attributes attributes) { + return PropertyDescriptor({utf8name, + nullptr, + nullptr, + nullptr, + nullptr, + value, + attributes, + nullptr}); +} + +inline PropertyDescriptor PropertyDescriptor::Value( + const std::string& utf8name, + napi_value value, + napi_property_attributes attributes) { + return Value(utf8name.c_str(), value, attributes); +} + +inline PropertyDescriptor PropertyDescriptor::Value( + napi_value name, napi_value value, napi_property_attributes attributes) { + return PropertyDescriptor( + {nullptr, name, nullptr, nullptr, nullptr, value, attributes, nullptr}); +} + +inline PropertyDescriptor PropertyDescriptor::Value( + Name name, Napi::Value value, napi_property_attributes attributes) { + napi_value nameValue = name; + napi_value valueValue = value; + return PropertyDescriptor::Value(nameValue, valueValue, attributes); +} + +inline PropertyDescriptor::PropertyDescriptor(napi_property_descriptor desc) + : _desc(desc) {} + +inline PropertyDescriptor::operator napi_property_descriptor&() { + return _desc; +} + +inline PropertyDescriptor::operator const napi_property_descriptor&() const { + return _desc; +} + +//////////////////////////////////////////////////////////////////////////////// +// InstanceWrap class +//////////////////////////////////////////////////////////////////////////////// + +template +inline void InstanceWrap::AttachPropData( + napi_env env, napi_value value, const napi_property_descriptor* prop) { + napi_status status; + if (!(prop->attributes & napi_static)) { + if (prop->method == T::InstanceVoidMethodCallbackWrapper) { + status = Napi::details::AttachData( + env, value, static_cast(prop->data)); + NAPI_THROW_IF_FAILED_VOID(env, status); + } else if (prop->method == T::InstanceMethodCallbackWrapper) { + status = Napi::details::AttachData( + env, value, static_cast(prop->data)); + NAPI_THROW_IF_FAILED_VOID(env, status); + } else if (prop->getter == T::InstanceGetterCallbackWrapper || + prop->setter == T::InstanceSetterCallbackWrapper) { + status = Napi::details::AttachData( + env, value, static_cast(prop->data)); + NAPI_THROW_IF_FAILED_VOID(env, status); + } + } +} + +template +inline ClassPropertyDescriptor InstanceWrap::InstanceMethod( + const char* utf8name, + InstanceVoidMethodCallback method, + napi_property_attributes attributes, + void* data) { + InstanceVoidMethodCallbackData* callbackData = + new InstanceVoidMethodCallbackData({method, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.method = T::InstanceVoidMethodCallbackWrapper; + desc.data = callbackData; + desc.attributes = attributes; + return desc; +} + +template +inline ClassPropertyDescriptor InstanceWrap::InstanceMethod( + const char* utf8name, + InstanceMethodCallback method, + napi_property_attributes attributes, + void* data) { + InstanceMethodCallbackData* callbackData = + new InstanceMethodCallbackData({method, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.method = T::InstanceMethodCallbackWrapper; + desc.data = callbackData; + desc.attributes = attributes; + return desc; +} + +template +inline ClassPropertyDescriptor InstanceWrap::InstanceMethod( + Symbol name, + InstanceVoidMethodCallback method, + napi_property_attributes attributes, + void* data) { + InstanceVoidMethodCallbackData* callbackData = + new InstanceVoidMethodCallbackData({method, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.method = T::InstanceVoidMethodCallbackWrapper; + desc.data = callbackData; + desc.attributes = attributes; + return desc; +} + +template +inline ClassPropertyDescriptor InstanceWrap::InstanceMethod( + Symbol name, + InstanceMethodCallback method, + napi_property_attributes attributes, + void* data) { + InstanceMethodCallbackData* callbackData = + new InstanceMethodCallbackData({method, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.method = T::InstanceMethodCallbackWrapper; + desc.data = callbackData; + desc.attributes = attributes; + return desc; +} + +template +template ::InstanceVoidMethodCallback method> +inline ClassPropertyDescriptor InstanceWrap::InstanceMethod( + const char* utf8name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.method = details::TemplatedInstanceVoidCallback; + desc.data = data; + desc.attributes = attributes; + return desc; +} + +template +template ::InstanceMethodCallback method> +inline ClassPropertyDescriptor InstanceWrap::InstanceMethod( + const char* utf8name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.method = details::TemplatedInstanceCallback; + desc.data = data; + desc.attributes = attributes; + return desc; +} + +template +template ::InstanceVoidMethodCallback method> +inline ClassPropertyDescriptor InstanceWrap::InstanceMethod( + Symbol name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.method = details::TemplatedInstanceVoidCallback; + desc.data = data; + desc.attributes = attributes; + return desc; +} + +template +template ::InstanceMethodCallback method> +inline ClassPropertyDescriptor InstanceWrap::InstanceMethod( + Symbol name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.method = details::TemplatedInstanceCallback; + desc.data = data; + desc.attributes = attributes; + return desc; +} + +template +inline ClassPropertyDescriptor InstanceWrap::InstanceAccessor( + const char* utf8name, + InstanceGetterCallback getter, + InstanceSetterCallback setter, + napi_property_attributes attributes, + void* data) { + InstanceAccessorCallbackData* callbackData = + new InstanceAccessorCallbackData({getter, setter, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.getter = getter != nullptr ? T::InstanceGetterCallbackWrapper : nullptr; + desc.setter = setter != nullptr ? T::InstanceSetterCallbackWrapper : nullptr; + desc.data = callbackData; + desc.attributes = attributes; + return desc; +} + +template +inline ClassPropertyDescriptor InstanceWrap::InstanceAccessor( + Symbol name, + InstanceGetterCallback getter, + InstanceSetterCallback setter, + napi_property_attributes attributes, + void* data) { + InstanceAccessorCallbackData* callbackData = + new InstanceAccessorCallbackData({getter, setter, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.getter = getter != nullptr ? T::InstanceGetterCallbackWrapper : nullptr; + desc.setter = setter != nullptr ? T::InstanceSetterCallbackWrapper : nullptr; + desc.data = callbackData; + desc.attributes = attributes; + return desc; +} + +template +template ::InstanceGetterCallback getter, + typename InstanceWrap::InstanceSetterCallback setter> +inline ClassPropertyDescriptor InstanceWrap::InstanceAccessor( + const char* utf8name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.getter = details::TemplatedInstanceCallback; + desc.setter = This::WrapSetter(This::SetterTag()); + desc.data = data; + desc.attributes = attributes; + return desc; +} + +template +template ::InstanceGetterCallback getter, + typename InstanceWrap::InstanceSetterCallback setter> +inline ClassPropertyDescriptor InstanceWrap::InstanceAccessor( + Symbol name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.getter = details::TemplatedInstanceCallback; + desc.setter = This::WrapSetter(This::SetterTag()); + desc.data = data; + desc.attributes = attributes; + return desc; +} + +template +inline ClassPropertyDescriptor InstanceWrap::InstanceValue( + const char* utf8name, + Napi::Value value, + napi_property_attributes attributes) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.value = value; + desc.attributes = attributes; + return desc; +} + +template +inline ClassPropertyDescriptor InstanceWrap::InstanceValue( + Symbol name, Napi::Value value, napi_property_attributes attributes) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.value = value; + desc.attributes = attributes; + return desc; +} + +template +inline napi_value InstanceWrap::InstanceVoidMethodCallbackWrapper( + napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + InstanceVoidMethodCallbackData* callbackData = + reinterpret_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + T* instance = T::Unwrap(callbackInfo.This().As()); + auto cb = callbackData->callback; + if (instance) (instance->*cb)(callbackInfo); + return nullptr; + }); +} + +template +inline napi_value InstanceWrap::InstanceMethodCallbackWrapper( + napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + InstanceMethodCallbackData* callbackData = + reinterpret_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + T* instance = T::Unwrap(callbackInfo.This().As()); + auto cb = callbackData->callback; + return instance ? (instance->*cb)(callbackInfo) : Napi::Value(); + }); +} + +template +inline napi_value InstanceWrap::InstanceGetterCallbackWrapper( + napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + InstanceAccessorCallbackData* callbackData = + reinterpret_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + T* instance = T::Unwrap(callbackInfo.This().As()); + auto cb = callbackData->getterCallback; + return instance ? (instance->*cb)(callbackInfo) : Napi::Value(); + }); +} + +template +inline napi_value InstanceWrap::InstanceSetterCallbackWrapper( + napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + InstanceAccessorCallbackData* callbackData = + reinterpret_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + T* instance = T::Unwrap(callbackInfo.This().As()); + auto cb = callbackData->setterCallback; + if (instance) (instance->*cb)(callbackInfo, callbackInfo[0]); + return nullptr; + }); +} + +template +template ::InstanceSetterCallback method> +inline napi_value InstanceWrap::WrappedMethod( + napi_env env, napi_callback_info info) NAPI_NOEXCEPT { + return details::WrapCallback([&] { + const CallbackInfo cbInfo(env, info); + T* instance = T::Unwrap(cbInfo.This().As()); + if (instance) (instance->*method)(cbInfo, cbInfo[0]); + return nullptr; + }); +} + +//////////////////////////////////////////////////////////////////////////////// +// ObjectWrap class +//////////////////////////////////////////////////////////////////////////////// + +template +inline ObjectWrap::ObjectWrap(const Napi::CallbackInfo& callbackInfo) { + napi_env env = callbackInfo.Env(); + napi_value wrapper = callbackInfo.This(); + napi_status status; + napi_ref ref; + T* instance = static_cast(this); + status = napi_wrap(env, wrapper, instance, FinalizeCallback, nullptr, &ref); + NAPI_THROW_IF_FAILED_VOID(env, status); + + Reference* instanceRef = instance; + *instanceRef = Reference(env, ref); +} + +template +inline ObjectWrap::~ObjectWrap() { + // If the JS object still exists at this point, remove the finalizer added + // through `napi_wrap()`. + if (!IsEmpty()) { + Object object = Value(); + // It is not valid to call `napi_remove_wrap()` with an empty `object`. + // This happens e.g. during garbage collection. + if (!object.IsEmpty() && _construction_failed) { + napi_remove_wrap(Env(), object, nullptr); + } + } +} + +template +inline T* ObjectWrap::Unwrap(Object wrapper) { + void* unwrapped; + napi_status status = napi_unwrap(wrapper.Env(), wrapper, &unwrapped); + NAPI_THROW_IF_FAILED(wrapper.Env(), status, nullptr); + return static_cast(unwrapped); +} + +template +inline Function ObjectWrap::DefineClass( + Napi::Env env, + const char* utf8name, + const size_t props_count, + const napi_property_descriptor* descriptors, + void* data) { + napi_status status; + std::vector props(props_count); + + // We copy the descriptors to a local array because before defining the class + // we must replace static method property descriptors with value property + // descriptors such that the value is a function-valued `napi_value` created + // with `CreateFunction()`. + // + // This replacement could be made for instance methods as well, but V8 aborts + // if we do that, because it expects methods defined on the prototype template + // to have `FunctionTemplate`s. + for (size_t index = 0; index < props_count; index++) { + props[index] = descriptors[index]; + napi_property_descriptor* prop = &props[index]; + if (prop->method == T::StaticMethodCallbackWrapper) { + status = + CreateFunction(env, + utf8name, + prop->method, + static_cast(prop->data), + &(prop->value)); + NAPI_THROW_IF_FAILED(env, status, Function()); + prop->method = nullptr; + prop->data = nullptr; + } else if (prop->method == T::StaticVoidMethodCallbackWrapper) { + status = + CreateFunction(env, + utf8name, + prop->method, + static_cast(prop->data), + &(prop->value)); + NAPI_THROW_IF_FAILED(env, status, Function()); + prop->method = nullptr; + prop->data = nullptr; + } + } + + napi_value value; + status = napi_define_class(env, + utf8name, + NAPI_AUTO_LENGTH, + T::ConstructorCallbackWrapper, + data, + props_count, + props.data(), + &value); + NAPI_THROW_IF_FAILED(env, status, Function()); + + // After defining the class we iterate once more over the property descriptors + // and attach the data associated with accessors and instance methods to the + // newly created JavaScript class. + for (size_t idx = 0; idx < props_count; idx++) { + const napi_property_descriptor* prop = &props[idx]; + + if (prop->getter == T::StaticGetterCallbackWrapper || + prop->setter == T::StaticSetterCallbackWrapper) { + status = Napi::details::AttachData( + env, value, static_cast(prop->data)); + NAPI_THROW_IF_FAILED(env, status, Function()); + } else { + // InstanceWrap::AttachPropData is responsible for attaching the data + // of instance methods and accessors. + T::AttachPropData(env, value, prop); + } + } + + return Function(env, value); +} + +template +inline Function ObjectWrap::DefineClass( + Napi::Env env, + const char* utf8name, + const std::initializer_list>& properties, + void* data) { + return DefineClass( + env, + utf8name, + properties.size(), + reinterpret_cast(properties.begin()), + data); +} + +template +inline Function ObjectWrap::DefineClass( + Napi::Env env, + const char* utf8name, + const std::vector>& properties, + void* data) { + return DefineClass( + env, + utf8name, + properties.size(), + reinterpret_cast(properties.data()), + data); +} + +template +inline ClassPropertyDescriptor ObjectWrap::StaticMethod( + const char* utf8name, + StaticVoidMethodCallback method, + napi_property_attributes attributes, + void* data) { + StaticVoidMethodCallbackData* callbackData = + new StaticVoidMethodCallbackData({method, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.method = T::StaticVoidMethodCallbackWrapper; + desc.data = callbackData; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +inline ClassPropertyDescriptor ObjectWrap::StaticMethod( + const char* utf8name, + StaticMethodCallback method, + napi_property_attributes attributes, + void* data) { + StaticMethodCallbackData* callbackData = + new StaticMethodCallbackData({method, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.method = T::StaticMethodCallbackWrapper; + desc.data = callbackData; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +inline ClassPropertyDescriptor ObjectWrap::StaticMethod( + Symbol name, + StaticVoidMethodCallback method, + napi_property_attributes attributes, + void* data) { + StaticVoidMethodCallbackData* callbackData = + new StaticVoidMethodCallbackData({method, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.method = T::StaticVoidMethodCallbackWrapper; + desc.data = callbackData; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +inline ClassPropertyDescriptor ObjectWrap::StaticMethod( + Symbol name, + StaticMethodCallback method, + napi_property_attributes attributes, + void* data) { + StaticMethodCallbackData* callbackData = + new StaticMethodCallbackData({method, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.method = T::StaticMethodCallbackWrapper; + desc.data = callbackData; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +template ::StaticVoidMethodCallback method> +inline ClassPropertyDescriptor ObjectWrap::StaticMethod( + const char* utf8name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.method = details::TemplatedVoidCallback; + desc.data = data; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +template ::StaticVoidMethodCallback method> +inline ClassPropertyDescriptor ObjectWrap::StaticMethod( + Symbol name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.method = details::TemplatedVoidCallback; + desc.data = data; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +template ::StaticMethodCallback method> +inline ClassPropertyDescriptor ObjectWrap::StaticMethod( + const char* utf8name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.method = details::TemplatedCallback; + desc.data = data; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +template ::StaticMethodCallback method> +inline ClassPropertyDescriptor ObjectWrap::StaticMethod( + Symbol name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.method = details::TemplatedCallback; + desc.data = data; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +inline ClassPropertyDescriptor ObjectWrap::StaticAccessor( + const char* utf8name, + StaticGetterCallback getter, + StaticSetterCallback setter, + napi_property_attributes attributes, + void* data) { + StaticAccessorCallbackData* callbackData = + new StaticAccessorCallbackData({getter, setter, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.getter = getter != nullptr ? T::StaticGetterCallbackWrapper : nullptr; + desc.setter = setter != nullptr ? T::StaticSetterCallbackWrapper : nullptr; + desc.data = callbackData; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +inline ClassPropertyDescriptor ObjectWrap::StaticAccessor( + Symbol name, + StaticGetterCallback getter, + StaticSetterCallback setter, + napi_property_attributes attributes, + void* data) { + StaticAccessorCallbackData* callbackData = + new StaticAccessorCallbackData({getter, setter, data}); + + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.getter = getter != nullptr ? T::StaticGetterCallbackWrapper : nullptr; + desc.setter = setter != nullptr ? T::StaticSetterCallbackWrapper : nullptr; + desc.data = callbackData; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +template ::StaticGetterCallback getter, + typename ObjectWrap::StaticSetterCallback setter> +inline ClassPropertyDescriptor ObjectWrap::StaticAccessor( + const char* utf8name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.getter = details::TemplatedCallback; + desc.setter = This::WrapStaticSetter(This::StaticSetterTag()); + desc.data = data; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +template ::StaticGetterCallback getter, + typename ObjectWrap::StaticSetterCallback setter> +inline ClassPropertyDescriptor ObjectWrap::StaticAccessor( + Symbol name, napi_property_attributes attributes, void* data) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.getter = details::TemplatedCallback; + desc.setter = This::WrapStaticSetter(This::StaticSetterTag()); + desc.data = data; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +inline ClassPropertyDescriptor ObjectWrap::StaticValue( + const char* utf8name, + Napi::Value value, + napi_property_attributes attributes) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.utf8name = utf8name; + desc.value = value; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +inline ClassPropertyDescriptor ObjectWrap::StaticValue( + Symbol name, Napi::Value value, napi_property_attributes attributes) { + napi_property_descriptor desc = napi_property_descriptor(); + desc.name = name; + desc.value = value; + desc.attributes = + static_cast(attributes | napi_static); + return desc; +} + +template +inline Value ObjectWrap::OnCalledAsFunction( + const Napi::CallbackInfo& callbackInfo) { + NAPI_THROW( + TypeError::New(callbackInfo.Env(), + "Class constructors cannot be invoked without 'new'"), + Napi::Value()); +} + +template +inline void ObjectWrap::Finalize(Napi::Env /*env*/) {} + +template +inline napi_value ObjectWrap::ConstructorCallbackWrapper( + napi_env env, napi_callback_info info) { + napi_value new_target; + napi_status status = napi_get_new_target(env, info, &new_target); + if (status != napi_ok) return nullptr; + + bool isConstructCall = (new_target != nullptr); + if (!isConstructCall) { + return details::WrapCallback( + [&] { return T::OnCalledAsFunction(CallbackInfo(env, info)); }); + } + + napi_value wrapper = details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + T* instance = new T(callbackInfo); +#ifdef NAPI_CPP_EXCEPTIONS + instance->_construction_failed = false; +#else + if (callbackInfo.Env().IsExceptionPending()) { + // We need to clear the exception so that removing the wrap might work. + Error e = callbackInfo.Env().GetAndClearPendingException(); + delete instance; + e.ThrowAsJavaScriptException(); + } else { + instance->_construction_failed = false; + } +#endif // NAPI_CPP_EXCEPTIONS + return callbackInfo.This(); + }); + + return wrapper; +} + +template +inline napi_value ObjectWrap::StaticVoidMethodCallbackWrapper( + napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + StaticVoidMethodCallbackData* callbackData = + reinterpret_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + callbackData->callback(callbackInfo); + return nullptr; + }); +} + +template +inline napi_value ObjectWrap::StaticMethodCallbackWrapper( + napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + StaticMethodCallbackData* callbackData = + reinterpret_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + return callbackData->callback(callbackInfo); + }); +} + +template +inline napi_value ObjectWrap::StaticGetterCallbackWrapper( + napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + StaticAccessorCallbackData* callbackData = + reinterpret_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + return callbackData->getterCallback(callbackInfo); + }); +} + +template +inline napi_value ObjectWrap::StaticSetterCallbackWrapper( + napi_env env, napi_callback_info info) { + return details::WrapCallback([&] { + CallbackInfo callbackInfo(env, info); + StaticAccessorCallbackData* callbackData = + reinterpret_cast(callbackInfo.Data()); + callbackInfo.SetData(callbackData->data); + callbackData->setterCallback(callbackInfo, callbackInfo[0]); + return nullptr; + }); +} + +template +inline void ObjectWrap::FinalizeCallback(napi_env env, + void* data, + void* /*hint*/) { + HandleScope scope(env); + T* instance = static_cast(data); + instance->Finalize(Napi::Env(env)); + delete instance; +} + +template +template ::StaticSetterCallback method> +inline napi_value ObjectWrap::WrappedMethod( + napi_env env, napi_callback_info info) NAPI_NOEXCEPT { + return details::WrapCallback([&] { + const CallbackInfo cbInfo(env, info); + method(cbInfo, cbInfo[0]); + return nullptr; + }); +} + +//////////////////////////////////////////////////////////////////////////////// +// HandleScope class +//////////////////////////////////////////////////////////////////////////////// + +inline HandleScope::HandleScope(napi_env env, napi_handle_scope scope) + : _env(env), _scope(scope) {} + +inline HandleScope::HandleScope(Napi::Env env) : _env(env) { + napi_status status = napi_open_handle_scope(_env, &_scope); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline HandleScope::~HandleScope() { + napi_status status = napi_close_handle_scope(_env, _scope); + NAPI_FATAL_IF_FAILED( + status, "HandleScope::~HandleScope", "napi_close_handle_scope"); +} + +inline HandleScope::operator napi_handle_scope() const { + return _scope; +} + +inline Napi::Env HandleScope::Env() const { + return Napi::Env(_env); +} + +//////////////////////////////////////////////////////////////////////////////// +// EscapableHandleScope class +//////////////////////////////////////////////////////////////////////////////// + +inline EscapableHandleScope::EscapableHandleScope( + napi_env env, napi_escapable_handle_scope scope) + : _env(env), _scope(scope) {} + +inline EscapableHandleScope::EscapableHandleScope(Napi::Env env) : _env(env) { + napi_status status = napi_open_escapable_handle_scope(_env, &_scope); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline EscapableHandleScope::~EscapableHandleScope() { + napi_status status = napi_close_escapable_handle_scope(_env, _scope); + NAPI_FATAL_IF_FAILED(status, + "EscapableHandleScope::~EscapableHandleScope", + "napi_close_escapable_handle_scope"); +} + +inline EscapableHandleScope::operator napi_escapable_handle_scope() const { + return _scope; +} + +inline Napi::Env EscapableHandleScope::Env() const { + return Napi::Env(_env); +} + +inline Value EscapableHandleScope::Escape(napi_value escapee) { + napi_value result; + napi_status status = napi_escape_handle(_env, _scope, escapee, &result); + NAPI_THROW_IF_FAILED(_env, status, Value()); + return Value(_env, result); +} + +#if (NAPI_VERSION > 2) +//////////////////////////////////////////////////////////////////////////////// +// CallbackScope class +//////////////////////////////////////////////////////////////////////////////// + +inline CallbackScope::CallbackScope(napi_env env, napi_callback_scope scope) + : _env(env), _scope(scope) {} + +inline CallbackScope::CallbackScope(napi_env env, napi_async_context context) + : _env(env) { + napi_status status = + napi_open_callback_scope(_env, Object::New(env), context, &_scope); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline CallbackScope::~CallbackScope() { + napi_status status = napi_close_callback_scope(_env, _scope); + NAPI_FATAL_IF_FAILED( + status, "CallbackScope::~CallbackScope", "napi_close_callback_scope"); +} + +inline CallbackScope::operator napi_callback_scope() const { + return _scope; +} + +inline Napi::Env CallbackScope::Env() const { + return Napi::Env(_env); +} +#endif + +//////////////////////////////////////////////////////////////////////////////// +// AsyncContext class +//////////////////////////////////////////////////////////////////////////////// + +inline AsyncContext::AsyncContext(napi_env env, const char* resource_name) + : AsyncContext(env, resource_name, Object::New(env)) {} + +inline AsyncContext::AsyncContext(napi_env env, + const char* resource_name, + const Object& resource) + : _env(env), _context(nullptr) { + napi_value resource_id; + napi_status status = napi_create_string_utf8( + _env, resource_name, NAPI_AUTO_LENGTH, &resource_id); + NAPI_THROW_IF_FAILED_VOID(_env, status); + + status = napi_async_init(_env, resource, resource_id, &_context); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline AsyncContext::~AsyncContext() { + if (_context != nullptr) { + napi_async_destroy(_env, _context); + _context = nullptr; + } +} + +inline AsyncContext::AsyncContext(AsyncContext&& other) { + _env = other._env; + other._env = nullptr; + _context = other._context; + other._context = nullptr; +} + +inline AsyncContext& AsyncContext::operator=(AsyncContext&& other) { + _env = other._env; + other._env = nullptr; + _context = other._context; + other._context = nullptr; + return *this; +} + +inline AsyncContext::operator napi_async_context() const { + return _context; +} + +inline Napi::Env AsyncContext::Env() const { + return Napi::Env(_env); +} + +//////////////////////////////////////////////////////////////////////////////// +// AsyncWorker class +//////////////////////////////////////////////////////////////////////////////// + +#if NAPI_HAS_THREADS + +inline AsyncWorker::AsyncWorker(const Function& callback) + : AsyncWorker(callback, "generic") {} + +inline AsyncWorker::AsyncWorker(const Function& callback, + const char* resource_name) + : AsyncWorker(callback, resource_name, Object::New(callback.Env())) {} + +inline AsyncWorker::AsyncWorker(const Function& callback, + const char* resource_name, + const Object& resource) + : AsyncWorker( + Object::New(callback.Env()), callback, resource_name, resource) {} + +inline AsyncWorker::AsyncWorker(const Object& receiver, + const Function& callback) + : AsyncWorker(receiver, callback, "generic") {} + +inline AsyncWorker::AsyncWorker(const Object& receiver, + const Function& callback, + const char* resource_name) + : AsyncWorker( + receiver, callback, resource_name, Object::New(callback.Env())) {} + +inline AsyncWorker::AsyncWorker(const Object& receiver, + const Function& callback, + const char* resource_name, + const Object& resource) + : _env(callback.Env()), + _receiver(Napi::Persistent(receiver)), + _callback(Napi::Persistent(callback)), + _suppress_destruct(false) { + napi_value resource_id; + napi_status status = napi_create_string_latin1( + _env, resource_name, NAPI_AUTO_LENGTH, &resource_id); + NAPI_THROW_IF_FAILED_VOID(_env, status); + + status = napi_create_async_work(_env, + resource, + resource_id, + OnAsyncWorkExecute, + OnAsyncWorkComplete, + this, + &_work); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline AsyncWorker::AsyncWorker(Napi::Env env) : AsyncWorker(env, "generic") {} + +inline AsyncWorker::AsyncWorker(Napi::Env env, const char* resource_name) + : AsyncWorker(env, resource_name, Object::New(env)) {} + +inline AsyncWorker::AsyncWorker(Napi::Env env, + const char* resource_name, + const Object& resource) + : _env(env), _receiver(), _callback(), _suppress_destruct(false) { + napi_value resource_id; + napi_status status = napi_create_string_latin1( + _env, resource_name, NAPI_AUTO_LENGTH, &resource_id); + NAPI_THROW_IF_FAILED_VOID(_env, status); + + status = napi_create_async_work(_env, + resource, + resource_id, + OnAsyncWorkExecute, + OnAsyncWorkComplete, + this, + &_work); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline AsyncWorker::~AsyncWorker() { + if (_work != nullptr) { + napi_delete_async_work(_env, _work); + _work = nullptr; + } +} + +inline void AsyncWorker::Destroy() { + delete this; +} + +inline AsyncWorker::operator napi_async_work() const { + return _work; +} + +inline Napi::Env AsyncWorker::Env() const { + return Napi::Env(_env); +} + +inline void AsyncWorker::Queue() { + napi_status status = napi_queue_async_work(_env, _work); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline void AsyncWorker::Cancel() { + napi_status status = napi_cancel_async_work(_env, _work); + NAPI_THROW_IF_FAILED_VOID(_env, status); +} + +inline ObjectReference& AsyncWorker::Receiver() { + return _receiver; +} + +inline FunctionReference& AsyncWorker::Callback() { + return _callback; +} + +inline void AsyncWorker::SuppressDestruct() { + _suppress_destruct = true; +} + +inline void AsyncWorker::OnOK() { + if (!_callback.IsEmpty()) { + _callback.Call(_receiver.Value(), GetResult(_callback.Env())); + } +} + +inline void AsyncWorker::OnError(const Error& e) { + if (!_callback.IsEmpty()) { + _callback.Call(_receiver.Value(), + std::initializer_list{e.Value()}); + } +} + +inline void AsyncWorker::SetError(const std::string& error) { + _error = error; +} + +inline std::vector AsyncWorker::GetResult(Napi::Env /*env*/) { + return {}; +} +// The OnAsyncWorkExecute method receives an napi_env argument. However, do NOT +// use it within this method, as it does not run on the JavaScript thread and +// must not run any method that would cause JavaScript to run. In practice, +// this means that almost any use of napi_env will be incorrect. +inline void AsyncWorker::OnAsyncWorkExecute(napi_env env, void* asyncworker) { + AsyncWorker* self = static_cast(asyncworker); + self->OnExecute(env); +} +// The OnExecute method receives an napi_env argument. However, do NOT +// use it within this method, as it does not run on the JavaScript thread and +// must not run any method that would cause JavaScript to run. In practice, +// this means that almost any use of napi_env will be incorrect. +inline void AsyncWorker::OnExecute(Napi::Env /*DO_NOT_USE*/) { +#ifdef NAPI_CPP_EXCEPTIONS + try { + Execute(); + } catch (const std::exception& e) { + SetError(e.what()); + } +#else // NAPI_CPP_EXCEPTIONS + Execute(); +#endif // NAPI_CPP_EXCEPTIONS +} + +inline void AsyncWorker::OnAsyncWorkComplete(napi_env env, + napi_status status, + void* asyncworker) { + AsyncWorker* self = static_cast(asyncworker); + self->OnWorkComplete(env, status); +} +inline void AsyncWorker::OnWorkComplete(Napi::Env /*env*/, napi_status status) { + if (status != napi_cancelled) { + HandleScope scope(_env); + details::WrapCallback([&] { + if (_error.size() == 0) { + OnOK(); + } else { + OnError(Error::New(_env, _error)); + } + return nullptr; + }); + } + if (!_suppress_destruct) { + Destroy(); + } +} + +#endif // NAPI_HAS_THREADS + +#if (NAPI_VERSION > 3 && NAPI_HAS_THREADS) +//////////////////////////////////////////////////////////////////////////////// +// TypedThreadSafeFunction class +//////////////////////////////////////////////////////////////////////////////// + +// Starting with NAPI 5, the JavaScript function `func` parameter of +// `napi_create_threadsafe_function` is optional. +#if NAPI_VERSION > 4 +// static, with Callback [missing] Resource [missing] Finalizer [missing] +template +template +inline TypedThreadSafeFunction +TypedThreadSafeFunction::New( + napi_env env, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context) { + TypedThreadSafeFunction tsfn; + + napi_status status = + napi_create_threadsafe_function(env, + nullptr, + nullptr, + String::From(env, resourceName), + maxQueueSize, + initialThreadCount, + nullptr, + nullptr, + context, + CallJsInternal, + &tsfn._tsfn); + if (status != napi_ok) { + NAPI_THROW_IF_FAILED( + env, status, TypedThreadSafeFunction()); + } + + return tsfn; +} + +// static, with Callback [missing] Resource [passed] Finalizer [missing] +template +template +inline TypedThreadSafeFunction +TypedThreadSafeFunction::New( + napi_env env, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context) { + TypedThreadSafeFunction tsfn; + + napi_status status = + napi_create_threadsafe_function(env, + nullptr, + resource, + String::From(env, resourceName), + maxQueueSize, + initialThreadCount, + nullptr, + nullptr, + context, + CallJsInternal, + &tsfn._tsfn); + if (status != napi_ok) { + NAPI_THROW_IF_FAILED( + env, status, TypedThreadSafeFunction()); + } + + return tsfn; +} + +// static, with Callback [missing] Resource [missing] Finalizer [passed] +template +template +inline TypedThreadSafeFunction +TypedThreadSafeFunction::New( + napi_env env, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data) { + TypedThreadSafeFunction tsfn; + + auto* finalizeData = new details:: + ThreadSafeFinalize( + {data, finalizeCallback}); + napi_status status = napi_create_threadsafe_function( + env, + nullptr, + nullptr, + String::From(env, resourceName), + maxQueueSize, + initialThreadCount, + finalizeData, + details::ThreadSafeFinalize:: + FinalizeFinalizeWrapperWithDataAndContext, + context, + CallJsInternal, + &tsfn._tsfn); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED( + env, status, TypedThreadSafeFunction()); + } + + return tsfn; +} + +// static, with Callback [missing] Resource [passed] Finalizer [passed] +template +template +inline TypedThreadSafeFunction +TypedThreadSafeFunction::New( + napi_env env, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data) { + TypedThreadSafeFunction tsfn; + + auto* finalizeData = new details:: + ThreadSafeFinalize( + {data, finalizeCallback}); + napi_status status = napi_create_threadsafe_function( + env, + nullptr, + resource, + String::From(env, resourceName), + maxQueueSize, + initialThreadCount, + finalizeData, + details::ThreadSafeFinalize:: + FinalizeFinalizeWrapperWithDataAndContext, + context, + CallJsInternal, + &tsfn._tsfn); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED( + env, status, TypedThreadSafeFunction()); + } + + return tsfn; +} +#endif + +// static, with Callback [passed] Resource [missing] Finalizer [missing] +template +template +inline TypedThreadSafeFunction +TypedThreadSafeFunction::New( + napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context) { + TypedThreadSafeFunction tsfn; + + napi_status status = + napi_create_threadsafe_function(env, + callback, + nullptr, + String::From(env, resourceName), + maxQueueSize, + initialThreadCount, + nullptr, + nullptr, + context, + CallJsInternal, + &tsfn._tsfn); + if (status != napi_ok) { + NAPI_THROW_IF_FAILED( + env, status, TypedThreadSafeFunction()); + } + + return tsfn; +} + +// static, with Callback [passed] Resource [passed] Finalizer [missing] +template +template +inline TypedThreadSafeFunction +TypedThreadSafeFunction::New( + napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context) { + TypedThreadSafeFunction tsfn; + + napi_status status = + napi_create_threadsafe_function(env, + callback, + resource, + String::From(env, resourceName), + maxQueueSize, + initialThreadCount, + nullptr, + nullptr, + context, + CallJsInternal, + &tsfn._tsfn); + if (status != napi_ok) { + NAPI_THROW_IF_FAILED( + env, status, TypedThreadSafeFunction()); + } + + return tsfn; +} + +// static, with Callback [passed] Resource [missing] Finalizer [passed] +template +template +inline TypedThreadSafeFunction +TypedThreadSafeFunction::New( + napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data) { + TypedThreadSafeFunction tsfn; + + auto* finalizeData = new details:: + ThreadSafeFinalize( + {data, finalizeCallback}); + napi_status status = napi_create_threadsafe_function( + env, + callback, + nullptr, + String::From(env, resourceName), + maxQueueSize, + initialThreadCount, + finalizeData, + details::ThreadSafeFinalize:: + FinalizeFinalizeWrapperWithDataAndContext, + context, + CallJsInternal, + &tsfn._tsfn); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED( + env, status, TypedThreadSafeFunction()); + } + + return tsfn; +} + +// static, with: Callback [passed] Resource [passed] Finalizer [passed] +template +template +inline TypedThreadSafeFunction +TypedThreadSafeFunction::New( + napi_env env, + CallbackType callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data) { + TypedThreadSafeFunction tsfn; + + auto* finalizeData = new details:: + ThreadSafeFinalize( + {data, finalizeCallback}); + napi_status status = napi_create_threadsafe_function( + env, + details::DefaultCallbackWrapper< + CallbackType, + TypedThreadSafeFunction>(env, + callback), + resource, + String::From(env, resourceName), + maxQueueSize, + initialThreadCount, + finalizeData, + details::ThreadSafeFinalize:: + FinalizeFinalizeWrapperWithDataAndContext, + context, + CallJsInternal, + &tsfn._tsfn); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED( + env, status, TypedThreadSafeFunction()); + } + + return tsfn; +} + +template +inline TypedThreadSafeFunction:: + TypedThreadSafeFunction() + : _tsfn() {} + +template +inline TypedThreadSafeFunction:: + TypedThreadSafeFunction(napi_threadsafe_function tsfn) + : _tsfn(tsfn) {} + +template +inline TypedThreadSafeFunction:: +operator napi_threadsafe_function() const { + return _tsfn; +} + +template +inline napi_status +TypedThreadSafeFunction::BlockingCall( + DataType* data) const { + return napi_call_threadsafe_function(_tsfn, data, napi_tsfn_blocking); +} + +template +inline napi_status +TypedThreadSafeFunction::NonBlockingCall( + DataType* data) const { + return napi_call_threadsafe_function(_tsfn, data, napi_tsfn_nonblocking); +} + +template +inline void TypedThreadSafeFunction::Ref( + napi_env env) const { + if (_tsfn != nullptr) { + napi_status status = napi_ref_threadsafe_function(env, _tsfn); + NAPI_THROW_IF_FAILED_VOID(env, status); + } +} + +template +inline void TypedThreadSafeFunction::Unref( + napi_env env) const { + if (_tsfn != nullptr) { + napi_status status = napi_unref_threadsafe_function(env, _tsfn); + NAPI_THROW_IF_FAILED_VOID(env, status); + } +} + +template +inline napi_status +TypedThreadSafeFunction::Acquire() const { + return napi_acquire_threadsafe_function(_tsfn); +} + +template +inline napi_status +TypedThreadSafeFunction::Release() const { + return napi_release_threadsafe_function(_tsfn, napi_tsfn_release); +} + +template +inline napi_status +TypedThreadSafeFunction::Abort() const { + return napi_release_threadsafe_function(_tsfn, napi_tsfn_abort); +} + +template +inline ContextType* +TypedThreadSafeFunction::GetContext() const { + void* context; + napi_status status = napi_get_threadsafe_function_context(_tsfn, &context); + NAPI_FATAL_IF_FAILED(status, + "TypedThreadSafeFunction::GetContext", + "napi_get_threadsafe_function_context"); + return static_cast(context); +} + +// static +template +void TypedThreadSafeFunction::CallJsInternal( + napi_env env, napi_value jsCallback, void* context, void* data) { + details::CallJsWrapper( + env, jsCallback, context, data); +} + +#if NAPI_VERSION == 4 +// static +template +Napi::Function +TypedThreadSafeFunction::EmptyFunctionFactory( + Napi::Env env) { + return Napi::Function::New(env, [](const CallbackInfo& cb) {}); +} + +// static +template +Napi::Function +TypedThreadSafeFunction::FunctionOrEmpty( + Napi::Env env, Napi::Function& callback) { + if (callback.IsEmpty()) { + return EmptyFunctionFactory(env); + } + return callback; +} + +#else +// static +template +std::nullptr_t +TypedThreadSafeFunction::EmptyFunctionFactory( + Napi::Env /*env*/) { + return nullptr; +} + +// static +template +Napi::Function +TypedThreadSafeFunction::FunctionOrEmpty( + Napi::Env /*env*/, Napi::Function& callback) { + return callback; +} + +#endif + +//////////////////////////////////////////////////////////////////////////////// +// ThreadSafeFunction class +//////////////////////////////////////////////////////////////////////////////// + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount) { + return New( + env, callback, Object(), resourceName, maxQueueSize, initialThreadCount); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context) { + return New(env, + callback, + Object(), + resourceName, + maxQueueSize, + initialThreadCount, + context); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + Finalizer finalizeCallback) { + return New(env, + callback, + Object(), + resourceName, + maxQueueSize, + initialThreadCount, + finalizeCallback); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + Finalizer finalizeCallback, + FinalizerDataType* data) { + return New(env, + callback, + Object(), + resourceName, + maxQueueSize, + initialThreadCount, + finalizeCallback, + data); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback) { + return New(env, + callback, + Object(), + resourceName, + maxQueueSize, + initialThreadCount, + context, + finalizeCallback); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data) { + return New(env, + callback, + Object(), + resourceName, + maxQueueSize, + initialThreadCount, + context, + finalizeCallback, + data); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount) { + return New(env, + callback, + resource, + resourceName, + maxQueueSize, + initialThreadCount, + static_cast(nullptr) /* context */); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context) { + return New(env, + callback, + resource, + resourceName, + maxQueueSize, + initialThreadCount, + context, + [](Env, ContextType*) {} /* empty finalizer */); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + Finalizer finalizeCallback) { + return New(env, + callback, + resource, + resourceName, + maxQueueSize, + initialThreadCount, + static_cast(nullptr) /* context */, + finalizeCallback, + static_cast(nullptr) /* data */, + details::ThreadSafeFinalize::Wrapper); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + Finalizer finalizeCallback, + FinalizerDataType* data) { + return New(env, + callback, + resource, + resourceName, + maxQueueSize, + initialThreadCount, + static_cast(nullptr) /* context */, + finalizeCallback, + data, + details::ThreadSafeFinalize:: + FinalizeWrapperWithData); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback) { + return New( + env, + callback, + resource, + resourceName, + maxQueueSize, + initialThreadCount, + context, + finalizeCallback, + static_cast(nullptr) /* data */, + details::ThreadSafeFinalize::FinalizeWrapperWithContext); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data) { + return New( + env, + callback, + resource, + resourceName, + maxQueueSize, + initialThreadCount, + context, + finalizeCallback, + data, + details::ThreadSafeFinalize:: + FinalizeFinalizeWrapperWithDataAndContext); +} + +inline ThreadSafeFunction::ThreadSafeFunction() : _tsfn() {} + +inline ThreadSafeFunction::ThreadSafeFunction(napi_threadsafe_function tsfn) + : _tsfn(tsfn) {} + +inline ThreadSafeFunction::operator napi_threadsafe_function() const { + return _tsfn; +} + +inline napi_status ThreadSafeFunction::BlockingCall() const { + return CallInternal(nullptr, napi_tsfn_blocking); +} + +template <> +inline napi_status ThreadSafeFunction::BlockingCall(void* data) const { + return napi_call_threadsafe_function(_tsfn, data, napi_tsfn_blocking); +} + +template +inline napi_status ThreadSafeFunction::BlockingCall(Callback callback) const { + return CallInternal(new CallbackWrapper(callback), napi_tsfn_blocking); +} + +template +inline napi_status ThreadSafeFunction::BlockingCall(DataType* data, + Callback callback) const { + auto wrapper = [data, callback](Env env, Function jsCallback) { + callback(env, jsCallback, data); + }; + return CallInternal(new CallbackWrapper(wrapper), napi_tsfn_blocking); +} + +inline napi_status ThreadSafeFunction::NonBlockingCall() const { + return CallInternal(nullptr, napi_tsfn_nonblocking); +} + +template <> +inline napi_status ThreadSafeFunction::NonBlockingCall(void* data) const { + return napi_call_threadsafe_function(_tsfn, data, napi_tsfn_nonblocking); +} + +template +inline napi_status ThreadSafeFunction::NonBlockingCall( + Callback callback) const { + return CallInternal(new CallbackWrapper(callback), napi_tsfn_nonblocking); +} + +template +inline napi_status ThreadSafeFunction::NonBlockingCall( + DataType* data, Callback callback) const { + auto wrapper = [data, callback](Env env, Function jsCallback) { + callback(env, jsCallback, data); + }; + return CallInternal(new CallbackWrapper(wrapper), napi_tsfn_nonblocking); +} + +inline void ThreadSafeFunction::Ref(napi_env env) const { + if (_tsfn != nullptr) { + napi_status status = napi_ref_threadsafe_function(env, _tsfn); + NAPI_THROW_IF_FAILED_VOID(env, status); + } +} + +inline void ThreadSafeFunction::Unref(napi_env env) const { + if (_tsfn != nullptr) { + napi_status status = napi_unref_threadsafe_function(env, _tsfn); + NAPI_THROW_IF_FAILED_VOID(env, status); + } +} + +inline napi_status ThreadSafeFunction::Acquire() const { + return napi_acquire_threadsafe_function(_tsfn); +} + +inline napi_status ThreadSafeFunction::Release() const { + return napi_release_threadsafe_function(_tsfn, napi_tsfn_release); +} + +inline napi_status ThreadSafeFunction::Abort() const { + return napi_release_threadsafe_function(_tsfn, napi_tsfn_abort); +} + +inline ThreadSafeFunction::ConvertibleContext ThreadSafeFunction::GetContext() + const { + void* context; + napi_status status = napi_get_threadsafe_function_context(_tsfn, &context); + NAPI_FATAL_IF_FAILED(status, + "ThreadSafeFunction::GetContext", + "napi_get_threadsafe_function_context"); + return ConvertibleContext({context}); +} + +// static +template +inline ThreadSafeFunction ThreadSafeFunction::New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data, + napi_finalize wrapper) { + static_assert(details::can_make_string::value || + std::is_convertible::value, + "Resource name should be convertible to the string type"); + + ThreadSafeFunction tsfn; + auto* finalizeData = new details:: + ThreadSafeFinalize( + {data, finalizeCallback}); + napi_status status = + napi_create_threadsafe_function(env, + callback, + resource, + Value::From(env, resourceName), + maxQueueSize, + initialThreadCount, + finalizeData, + wrapper, + context, + CallJS, + &tsfn._tsfn); + if (status != napi_ok) { + delete finalizeData; + NAPI_THROW_IF_FAILED(env, status, ThreadSafeFunction()); + } + + return tsfn; +} + +inline napi_status ThreadSafeFunction::CallInternal( + CallbackWrapper* callbackWrapper, + napi_threadsafe_function_call_mode mode) const { + napi_status status = + napi_call_threadsafe_function(_tsfn, callbackWrapper, mode); + if (status != napi_ok && callbackWrapper != nullptr) { + delete callbackWrapper; + } + + return status; +} + +// static +inline void ThreadSafeFunction::CallJS(napi_env env, + napi_value jsCallback, + void* /* context */, + void* data) { + if (env == nullptr && jsCallback == nullptr) { + return; + } + + details::WrapVoidCallback([&]() { + if (data != nullptr) { + auto* callbackWrapper = static_cast(data); + (*callbackWrapper)(env, Function(env, jsCallback)); + delete callbackWrapper; + } else if (jsCallback != nullptr) { + Function(env, jsCallback).Call({}); + } + }); +} + +//////////////////////////////////////////////////////////////////////////////// +// Async Progress Worker Base class +//////////////////////////////////////////////////////////////////////////////// +template +inline AsyncProgressWorkerBase::AsyncProgressWorkerBase( + const Object& receiver, + const Function& callback, + const char* resource_name, + const Object& resource, + size_t queue_size) + : AsyncWorker(receiver, callback, resource_name, resource) { + // Fill all possible arguments to work around ambiguous + // ThreadSafeFunction::New signatures. + _tsfn = ThreadSafeFunction::New(callback.Env(), + callback, + resource, + resource_name, + queue_size, + /** initialThreadCount */ 1, + /** context */ this, + OnThreadSafeFunctionFinalize, + /** finalizeData */ this); +} + +#if NAPI_VERSION > 4 +template +inline AsyncProgressWorkerBase::AsyncProgressWorkerBase( + Napi::Env env, + const char* resource_name, + const Object& resource, + size_t queue_size) + : AsyncWorker(env, resource_name, resource) { + // TODO: Once the changes to make the callback optional for threadsafe + // functions are available on all versions we can remove the dummy Function + // here. + Function callback; + // Fill all possible arguments to work around ambiguous + // ThreadSafeFunction::New signatures. + _tsfn = ThreadSafeFunction::New(env, + callback, + resource, + resource_name, + queue_size, + /** initialThreadCount */ 1, + /** context */ this, + OnThreadSafeFunctionFinalize, + /** finalizeData */ this); +} +#endif + +template +inline AsyncProgressWorkerBase::~AsyncProgressWorkerBase() { + // Abort pending tsfn call. + // Don't send progress events after we've already completed. + // It's ok to call ThreadSafeFunction::Abort and ThreadSafeFunction::Release + // duplicated. + _tsfn.Abort(); +} + +template +inline void AsyncProgressWorkerBase::OnAsyncWorkProgress( + Napi::Env /* env */, Napi::Function /* jsCallback */, void* data) { + ThreadSafeData* tsd = static_cast(data); + tsd->asyncprogressworker()->OnWorkProgress(tsd->data()); + delete tsd; +} + +template +inline napi_status AsyncProgressWorkerBase::NonBlockingCall( + DataType* data) { + auto tsd = new AsyncProgressWorkerBase::ThreadSafeData(this, data); + auto ret = _tsfn.NonBlockingCall(tsd, OnAsyncWorkProgress); + if (ret != napi_ok) { + delete tsd; + } + return ret; +} + +template +inline void AsyncProgressWorkerBase::OnWorkComplete( + Napi::Env /* env */, napi_status status) { + _work_completed = true; + _complete_status = status; + _tsfn.Release(); +} + +template +inline void AsyncProgressWorkerBase::OnThreadSafeFunctionFinalize( + Napi::Env env, void* /* data */, AsyncProgressWorkerBase* context) { + if (context->_work_completed) { + context->AsyncWorker::OnWorkComplete(env, context->_complete_status); + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Async Progress Worker class +//////////////////////////////////////////////////////////////////////////////// +template +inline AsyncProgressWorker::AsyncProgressWorker(const Function& callback) + : AsyncProgressWorker(callback, "generic") {} + +template +inline AsyncProgressWorker::AsyncProgressWorker(const Function& callback, + const char* resource_name) + : AsyncProgressWorker( + callback, resource_name, Object::New(callback.Env())) {} + +template +inline AsyncProgressWorker::AsyncProgressWorker(const Function& callback, + const char* resource_name, + const Object& resource) + : AsyncProgressWorker( + Object::New(callback.Env()), callback, resource_name, resource) {} + +template +inline AsyncProgressWorker::AsyncProgressWorker(const Object& receiver, + const Function& callback) + : AsyncProgressWorker(receiver, callback, "generic") {} + +template +inline AsyncProgressWorker::AsyncProgressWorker(const Object& receiver, + const Function& callback, + const char* resource_name) + : AsyncProgressWorker( + receiver, callback, resource_name, Object::New(callback.Env())) {} + +template +inline AsyncProgressWorker::AsyncProgressWorker(const Object& receiver, + const Function& callback, + const char* resource_name, + const Object& resource) + : AsyncProgressWorkerBase(receiver, callback, resource_name, resource), + _asyncdata(nullptr), + _asyncsize(0), + _signaled(false) {} + +#if NAPI_VERSION > 4 +template +inline AsyncProgressWorker::AsyncProgressWorker(Napi::Env env) + : AsyncProgressWorker(env, "generic") {} + +template +inline AsyncProgressWorker::AsyncProgressWorker(Napi::Env env, + const char* resource_name) + : AsyncProgressWorker(env, resource_name, Object::New(env)) {} + +template +inline AsyncProgressWorker::AsyncProgressWorker(Napi::Env env, + const char* resource_name, + const Object& resource) + : AsyncProgressWorkerBase(env, resource_name, resource), + _asyncdata(nullptr), + _asyncsize(0) {} +#endif + +template +inline AsyncProgressWorker::~AsyncProgressWorker() { + { + std::lock_guard lock(this->_mutex); + _asyncdata = nullptr; + _asyncsize = 0; + } +} + +template +inline void AsyncProgressWorker::Execute() { + ExecutionProgress progress(this); + Execute(progress); +} + +template +inline void AsyncProgressWorker::OnWorkProgress(void*) { + T* data; + size_t size; + bool signaled; + { + std::lock_guard lock(this->_mutex); + data = this->_asyncdata; + size = this->_asyncsize; + signaled = this->_signaled; + this->_asyncdata = nullptr; + this->_asyncsize = 0; + this->_signaled = false; + } + + /** + * The callback of ThreadSafeFunction is not been invoked immediately on the + * callback of uv_async_t (uv io poll), rather the callback of TSFN is + * invoked on the right next uv idle callback. There are chances that during + * the deferring the signal of uv_async_t is been sent again, i.e. potential + * not coalesced two calls of the TSFN callback. + */ + if (data == nullptr && !signaled) { + return; + } + + this->OnProgress(data, size); + delete[] data; +} + +template +inline void AsyncProgressWorker::SendProgress_(const T* data, size_t count) { + T* new_data = new T[count]; + std::copy(data, data + count, new_data); + + T* old_data; + { + std::lock_guard lock(this->_mutex); + old_data = _asyncdata; + _asyncdata = new_data; + _asyncsize = count; + _signaled = false; + } + this->NonBlockingCall(nullptr); + + delete[] old_data; +} + +template +inline void AsyncProgressWorker::Signal() { + { + std::lock_guard lock(this->_mutex); + _signaled = true; + } + this->NonBlockingCall(static_cast(nullptr)); +} + +template +inline void AsyncProgressWorker::ExecutionProgress::Signal() const { + this->_worker->Signal(); +} + +template +inline void AsyncProgressWorker::ExecutionProgress::Send( + const T* data, size_t count) const { + _worker->SendProgress_(data, count); +} + +//////////////////////////////////////////////////////////////////////////////// +// Async Progress Queue Worker class +//////////////////////////////////////////////////////////////////////////////// +template +inline AsyncProgressQueueWorker::AsyncProgressQueueWorker( + const Function& callback) + : AsyncProgressQueueWorker(callback, "generic") {} + +template +inline AsyncProgressQueueWorker::AsyncProgressQueueWorker( + const Function& callback, const char* resource_name) + : AsyncProgressQueueWorker( + callback, resource_name, Object::New(callback.Env())) {} + +template +inline AsyncProgressQueueWorker::AsyncProgressQueueWorker( + const Function& callback, const char* resource_name, const Object& resource) + : AsyncProgressQueueWorker( + Object::New(callback.Env()), callback, resource_name, resource) {} + +template +inline AsyncProgressQueueWorker::AsyncProgressQueueWorker( + const Object& receiver, const Function& callback) + : AsyncProgressQueueWorker(receiver, callback, "generic") {} + +template +inline AsyncProgressQueueWorker::AsyncProgressQueueWorker( + const Object& receiver, const Function& callback, const char* resource_name) + : AsyncProgressQueueWorker( + receiver, callback, resource_name, Object::New(callback.Env())) {} + +template +inline AsyncProgressQueueWorker::AsyncProgressQueueWorker( + const Object& receiver, + const Function& callback, + const char* resource_name, + const Object& resource) + : AsyncProgressWorkerBase>( + receiver, + callback, + resource_name, + resource, + /** unlimited queue size */ 0) {} + +#if NAPI_VERSION > 4 +template +inline AsyncProgressQueueWorker::AsyncProgressQueueWorker(Napi::Env env) + : AsyncProgressQueueWorker(env, "generic") {} + +template +inline AsyncProgressQueueWorker::AsyncProgressQueueWorker( + Napi::Env env, const char* resource_name) + : AsyncProgressQueueWorker(env, resource_name, Object::New(env)) {} + +template +inline AsyncProgressQueueWorker::AsyncProgressQueueWorker( + Napi::Env env, const char* resource_name, const Object& resource) + : AsyncProgressWorkerBase>( + env, resource_name, resource, /** unlimited queue size */ 0) {} +#endif + +template +inline void AsyncProgressQueueWorker::Execute() { + ExecutionProgress progress(this); + Execute(progress); +} + +template +inline void AsyncProgressQueueWorker::OnWorkProgress( + std::pair* datapair) { + if (datapair == nullptr) { + return; + } + + T* data = datapair->first; + size_t size = datapair->second; + + this->OnProgress(data, size); + delete datapair; + delete[] data; +} + +template +inline void AsyncProgressQueueWorker::SendProgress_(const T* data, + size_t count) { + T* new_data = new T[count]; + std::copy(data, data + count, new_data); + + auto pair = new std::pair(new_data, count); + this->NonBlockingCall(pair); +} + +template +inline void AsyncProgressQueueWorker::Signal() const { + this->SendProgress_(static_cast(nullptr), 0); +} + +template +inline void AsyncProgressQueueWorker::OnWorkComplete(Napi::Env env, + napi_status status) { + // Draining queued items in TSFN. + AsyncProgressWorkerBase>::OnWorkComplete(env, status); +} + +template +inline void AsyncProgressQueueWorker::ExecutionProgress::Signal() const { + _worker->SendProgress_(static_cast(nullptr), 0); +} + +template +inline void AsyncProgressQueueWorker::ExecutionProgress::Send( + const T* data, size_t count) const { + _worker->SendProgress_(data, count); +} +#endif // NAPI_VERSION > 3 && NAPI_HAS_THREADS + +//////////////////////////////////////////////////////////////////////////////// +// Memory Management class +//////////////////////////////////////////////////////////////////////////////// + +inline int64_t MemoryManagement::AdjustExternalMemory(Env env, + int64_t change_in_bytes) { + int64_t result; + napi_status status = + napi_adjust_external_memory(env, change_in_bytes, &result); + NAPI_THROW_IF_FAILED(env, status, 0); + return result; +} + +//////////////////////////////////////////////////////////////////////////////// +// Version Management class +//////////////////////////////////////////////////////////////////////////////// + +inline uint32_t VersionManagement::GetNapiVersion(Env env) { + uint32_t result; + napi_status status = napi_get_version(env, &result); + NAPI_THROW_IF_FAILED(env, status, 0); + return result; +} + +inline const napi_node_version* VersionManagement::GetNodeVersion(Env env) { + const napi_node_version* result; + napi_status status = napi_get_node_version(env, &result); + NAPI_THROW_IF_FAILED(env, status, 0); + return result; +} + +#if NAPI_VERSION > 5 +//////////////////////////////////////////////////////////////////////////////// +// Addon class +//////////////////////////////////////////////////////////////////////////////// + +template +inline Object Addon::Init(Env env, Object exports) { + T* addon = new T(env, exports); + env.SetInstanceData(addon); + return addon->entry_point_; +} + +template +inline T* Addon::Unwrap(Object wrapper) { + return wrapper.Env().GetInstanceData(); +} + +template +inline void Addon::DefineAddon( + Object exports, const std::initializer_list& props) { + DefineProperties(exports, props); + entry_point_ = exports; +} + +template +inline Napi::Object Addon::DefineProperties( + Object object, const std::initializer_list& props) { + const napi_property_descriptor* properties = + reinterpret_cast(props.begin()); + size_t size = props.size(); + napi_status status = + napi_define_properties(object.Env(), object, size, properties); + NAPI_THROW_IF_FAILED(object.Env(), status, object); + for (size_t idx = 0; idx < size; idx++) + T::AttachPropData(object.Env(), object, &properties[idx]); + return object; +} +#endif // NAPI_VERSION > 5 + +#if NAPI_VERSION > 2 +template +Env::CleanupHook Env::AddCleanupHook(Hook hook, Arg* arg) { + return CleanupHook(*this, hook, arg); +} + +template +Env::CleanupHook Env::AddCleanupHook(Hook hook) { + return CleanupHook(*this, hook); +} + +template +Env::CleanupHook::CleanupHook() { + data = nullptr; +} + +template +Env::CleanupHook::CleanupHook(Napi::Env env, Hook hook) + : wrapper(Env::CleanupHook::Wrapper) { + data = new CleanupData{std::move(hook), nullptr}; + napi_status status = napi_add_env_cleanup_hook(env, wrapper, data); + if (status != napi_ok) { + delete data; + data = nullptr; + } +} + +template +Env::CleanupHook::CleanupHook(Napi::Env env, Hook hook, Arg* arg) + : wrapper(Env::CleanupHook::WrapperWithArg) { + data = new CleanupData{std::move(hook), arg}; + napi_status status = napi_add_env_cleanup_hook(env, wrapper, data); + if (status != napi_ok) { + delete data; + data = nullptr; + } +} + +template +bool Env::CleanupHook::Remove(Env env) { + napi_status status = napi_remove_env_cleanup_hook(env, wrapper, data); + delete data; + data = nullptr; + return status == napi_ok; +} + +template +bool Env::CleanupHook::IsEmpty() const { + return data == nullptr; +} +#endif // NAPI_VERSION > 2 + +#ifdef NAPI_CPP_CUSTOM_NAMESPACE +} // namespace NAPI_CPP_CUSTOM_NAMESPACE +#endif + +} // namespace Napi + +#endif // SRC_NAPI_INL_H_ diff --git a/services/edge-agent/node_modules/node-addon-api/napi.h b/services/edge-agent/node_modules/node-addon-api/napi.h new file mode 100644 index 00000000..9f20cb88 --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/napi.h @@ -0,0 +1,3201 @@ +#ifndef SRC_NAPI_H_ +#define SRC_NAPI_H_ + +#ifndef NAPI_HAS_THREADS +#if !defined(__wasm__) || (defined(__EMSCRIPTEN_PTHREADS__) || \ + (defined(__wasi__) && defined(_REENTRANT))) +#define NAPI_HAS_THREADS 1 +#else +#define NAPI_HAS_THREADS 0 +#endif +#endif + +#include +#include +#include +#include +#if NAPI_HAS_THREADS +#include +#endif // NAPI_HAS_THREADS +#include +#include + +// VS2015 RTM has bugs with constexpr, so require min of VS2015 Update 3 (known +// good version) +#if !defined(_MSC_VER) || _MSC_FULL_VER >= 190024210 +#define NAPI_HAS_CONSTEXPR 1 +#endif + +// VS2013 does not support char16_t literal strings, so we'll work around it +// using wchar_t strings and casting them. This is safe as long as the character +// sizes are the same. +#if defined(_MSC_VER) && _MSC_VER <= 1800 +static_assert(sizeof(char16_t) == sizeof(wchar_t), + "Size mismatch between char16_t and wchar_t"); +#define NAPI_WIDE_TEXT(x) reinterpret_cast(L##x) +#else +#define NAPI_WIDE_TEXT(x) u##x +#endif + +// If C++ exceptions are not explicitly enabled or disabled, enable them +// if exceptions were enabled in the compiler settings. +#if !defined(NAPI_CPP_EXCEPTIONS) && !defined(NAPI_DISABLE_CPP_EXCEPTIONS) +#if defined(_CPPUNWIND) || defined(__EXCEPTIONS) +#define NAPI_CPP_EXCEPTIONS +#else +#error Exception support not detected. \ + Define either NAPI_CPP_EXCEPTIONS or NAPI_DISABLE_CPP_EXCEPTIONS. +#endif +#endif + +// If C++ NAPI_CPP_EXCEPTIONS are enabled, NODE_ADDON_API_ENABLE_MAYBE should +// not be set +#if defined(NAPI_CPP_EXCEPTIONS) && defined(NODE_ADDON_API_ENABLE_MAYBE) +#error NODE_ADDON_API_ENABLE_MAYBE should not be set when \ + NAPI_CPP_EXCEPTIONS is defined. +#endif + +#ifdef _NOEXCEPT +#define NAPI_NOEXCEPT _NOEXCEPT +#else +#define NAPI_NOEXCEPT noexcept +#endif + +#ifdef NAPI_CPP_EXCEPTIONS + +// When C++ exceptions are enabled, Errors are thrown directly. There is no need +// to return anything after the throw statements. The variadic parameter is an +// optional return value that is ignored. +// We need _VOID versions of the macros to avoid warnings resulting from +// leaving the NAPI_THROW_* `...` argument empty. + +#define NAPI_THROW(e, ...) throw e +#define NAPI_THROW_VOID(e) throw e + +#define NAPI_THROW_IF_FAILED(env, status, ...) \ + if ((status) != napi_ok) throw Napi::Error::New(env); + +#define NAPI_THROW_IF_FAILED_VOID(env, status) \ + if ((status) != napi_ok) throw Napi::Error::New(env); + +#else // NAPI_CPP_EXCEPTIONS + +// When C++ exceptions are disabled, Errors are thrown as JavaScript exceptions, +// which are pending until the callback returns to JS. The variadic parameter +// is an optional return value; usually it is an empty result. +// We need _VOID versions of the macros to avoid warnings resulting from +// leaving the NAPI_THROW_* `...` argument empty. + +#define NAPI_THROW(e, ...) \ + do { \ + (e).ThrowAsJavaScriptException(); \ + return __VA_ARGS__; \ + } while (0) + +#define NAPI_THROW_VOID(e) \ + do { \ + (e).ThrowAsJavaScriptException(); \ + return; \ + } while (0) + +#define NAPI_THROW_IF_FAILED(env, status, ...) \ + if ((status) != napi_ok) { \ + Napi::Error::New(env).ThrowAsJavaScriptException(); \ + return __VA_ARGS__; \ + } + +#define NAPI_THROW_IF_FAILED_VOID(env, status) \ + if ((status) != napi_ok) { \ + Napi::Error::New(env).ThrowAsJavaScriptException(); \ + return; \ + } + +#endif // NAPI_CPP_EXCEPTIONS + +#ifdef NODE_ADDON_API_ENABLE_MAYBE +#define NAPI_MAYBE_THROW_IF_FAILED(env, status, type) \ + NAPI_THROW_IF_FAILED(env, status, Napi::Nothing()) + +#define NAPI_RETURN_OR_THROW_IF_FAILED(env, status, result, type) \ + NAPI_MAYBE_THROW_IF_FAILED(env, status, type); \ + return Napi::Just(result); +#else +#define NAPI_MAYBE_THROW_IF_FAILED(env, status, type) \ + NAPI_THROW_IF_FAILED(env, status, type()) + +#define NAPI_RETURN_OR_THROW_IF_FAILED(env, status, result, type) \ + NAPI_MAYBE_THROW_IF_FAILED(env, status, type); \ + return result; +#endif + +#define NAPI_DISALLOW_ASSIGN(CLASS) void operator=(const CLASS&) = delete; +#define NAPI_DISALLOW_COPY(CLASS) CLASS(const CLASS&) = delete; + +#define NAPI_DISALLOW_ASSIGN_COPY(CLASS) \ + NAPI_DISALLOW_ASSIGN(CLASS) \ + NAPI_DISALLOW_COPY(CLASS) + +#define NAPI_CHECK(condition, location, message) \ + do { \ + if (!(condition)) { \ + Napi::Error::Fatal((location), (message)); \ + } \ + } while (0) + +#define NAPI_FATAL_IF_FAILED(status, location, message) \ + NAPI_CHECK((status) == napi_ok, location, message) + +//////////////////////////////////////////////////////////////////////////////// +/// Node-API C++ Wrapper Classes +/// +/// These classes wrap the "Node-API" ABI-stable C APIs for Node.js, providing a +/// C++ object model and C++ exception-handling semantics with low overhead. +/// The wrappers are all header-only so that they do not affect the ABI. +//////////////////////////////////////////////////////////////////////////////// +namespace Napi { + +#ifdef NAPI_CPP_CUSTOM_NAMESPACE +// NAPI_CPP_CUSTOM_NAMESPACE can be #define'd per-addon to avoid symbol +// conflicts between different instances of node-addon-api + +// First dummy definition of the namespace to make sure that Napi::(name) still +// refers to the right things inside this file. +namespace NAPI_CPP_CUSTOM_NAMESPACE {} +using namespace NAPI_CPP_CUSTOM_NAMESPACE; + +namespace NAPI_CPP_CUSTOM_NAMESPACE { +#endif + +// Forward declarations +class Env; +class Value; +class Boolean; +class Number; +#if NAPI_VERSION > 5 +class BigInt; +#endif // NAPI_VERSION > 5 +#if (NAPI_VERSION > 4) +class Date; +#endif +class String; +class Object; +class Array; +class ArrayBuffer; +class Function; +class Error; +class PropertyDescriptor; +class CallbackInfo; +class TypedArray; +template +class TypedArrayOf; + +using Int8Array = + TypedArrayOf; ///< Typed-array of signed 8-bit integers +using Uint8Array = + TypedArrayOf; ///< Typed-array of unsigned 8-bit integers +using Int16Array = + TypedArrayOf; ///< Typed-array of signed 16-bit integers +using Uint16Array = + TypedArrayOf; ///< Typed-array of unsigned 16-bit integers +using Int32Array = + TypedArrayOf; ///< Typed-array of signed 32-bit integers +using Uint32Array = + TypedArrayOf; ///< Typed-array of unsigned 32-bit integers +using Float32Array = + TypedArrayOf; ///< Typed-array of 32-bit floating-point values +using Float64Array = + TypedArrayOf; ///< Typed-array of 64-bit floating-point values +#if NAPI_VERSION > 5 +using BigInt64Array = + TypedArrayOf; ///< Typed array of signed 64-bit integers +using BigUint64Array = + TypedArrayOf; ///< Typed array of unsigned 64-bit integers +#endif // NAPI_VERSION > 5 + +/// Defines the signature of a Node-API C++ module's registration callback +/// (init) function. +using ModuleRegisterCallback = Object (*)(Env env, Object exports); + +class MemoryManagement; + +/// A simple Maybe type, representing an object which may or may not have a +/// value. +/// +/// If an API method returns a Maybe<>, the API method can potentially fail +/// either because an exception is thrown, or because an exception is pending, +/// e.g. because a previous API call threw an exception that hasn't been +/// caught yet. In that case, a "Nothing" value is returned. +template +class Maybe { + public: + bool IsNothing() const; + bool IsJust() const; + + /// Short-hand for Unwrap(), which doesn't return a value. Could be used + /// where the actual value of the Maybe is not needed like Object::Set. + /// If this Maybe is nothing (empty), node-addon-api will crash the + /// process. + void Check() const; + + /// Return the value of type T contained in the Maybe. If this Maybe is + /// nothing (empty), node-addon-api will crash the process. + T Unwrap() const; + + /// Return the value of type T contained in the Maybe, or using a default + /// value if this Maybe is nothing (empty). + T UnwrapOr(const T& default_value) const; + + /// Converts this Maybe to a value of type T in the out. If this Maybe is + /// nothing (empty), `false` is returned and `out` is left untouched. + bool UnwrapTo(T* out) const; + + bool operator==(const Maybe& other) const; + bool operator!=(const Maybe& other) const; + + private: + Maybe(); + explicit Maybe(const T& t); + + bool _has_value; + T _value; + + template + friend Maybe Nothing(); + template + friend Maybe Just(const U& u); +}; + +template +inline Maybe Nothing(); + +template +inline Maybe Just(const T& t); + +#if defined(NODE_ADDON_API_ENABLE_MAYBE) +template +using MaybeOrValue = Maybe; +#else +template +using MaybeOrValue = T; +#endif + +/// Environment for Node-API values and operations. +/// +/// All Node-API values and operations must be associated with an environment. +/// An environment instance is always provided to callback functions; that +/// environment must then be used for any creation of Node-API values or other +/// Node-API operations within the callback. (Many methods infer the +/// environment from the `this` instance that the method is called on.) +/// +/// In the future, multiple environments per process may be supported, +/// although current implementations only support one environment per process. +/// +/// In the V8 JavaScript engine, a Node-API environment approximately +/// corresponds to an Isolate. +class Env { + private: + napi_env _env; +#if NAPI_VERSION > 5 + template + static void DefaultFini(Env, T* data); + template + static void DefaultFiniWithHint(Env, DataType* data, HintType* hint); +#endif // NAPI_VERSION > 5 + public: + Env(napi_env env); + + operator napi_env() const; + + Object Global() const; + Value Undefined() const; + Value Null() const; + + bool IsExceptionPending() const; + Error GetAndClearPendingException() const; + + MaybeOrValue RunScript(const char* utf8script) const; + MaybeOrValue RunScript(const std::string& utf8script) const; + MaybeOrValue RunScript(String script) const; + +#if NAPI_VERSION > 2 + template + class CleanupHook; + + template + CleanupHook AddCleanupHook(Hook hook); + + template + CleanupHook AddCleanupHook(Hook hook, Arg* arg); +#endif // NAPI_VERSION > 2 + +#if NAPI_VERSION > 5 + template + T* GetInstanceData() const; + + template + using Finalizer = void (*)(Env, T*); + template fini = Env::DefaultFini> + void SetInstanceData(T* data) const; + + template + using FinalizerWithHint = void (*)(Env, DataType*, HintType*); + template fini = + Env::DefaultFiniWithHint> + void SetInstanceData(DataType* data, HintType* hint) const; +#endif // NAPI_VERSION > 5 + +#if NAPI_VERSION > 2 + template + class CleanupHook { + public: + CleanupHook(); + CleanupHook(Env env, Hook hook, Arg* arg); + CleanupHook(Env env, Hook hook); + bool Remove(Env env); + bool IsEmpty() const; + + private: + static inline void Wrapper(void* data) NAPI_NOEXCEPT; + static inline void WrapperWithArg(void* data) NAPI_NOEXCEPT; + + void (*wrapper)(void* arg); + struct CleanupData { + Hook hook; + Arg* arg; + } * data; + }; +#endif // NAPI_VERSION > 2 + +#if NAPI_VERSION > 8 + const char* GetModuleFileName() const; +#endif // NAPI_VERSION > 8 +}; + +/// A JavaScript value of unknown type. +/// +/// For type-specific operations, convert to one of the Value subclasses using a +/// `To*` or `As()` method. The `To*` methods do type coercion; the `As()` +/// method does not. +/// +/// Napi::Value value = ... +/// if (!value.IsString()) throw Napi::TypeError::New(env, "Invalid +/// arg..."); Napi::String str = value.As(); // Cast to a +/// string value +/// +/// Napi::Value anotherValue = ... +/// bool isTruthy = anotherValue.ToBoolean(); // Coerce to a boolean value +class Value { + public: + Value(); ///< Creates a new _empty_ Value instance. + Value(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + /// Creates a JS value from a C++ primitive. + /// + /// `value` may be any of: + /// - bool + /// - Any integer type + /// - Any floating point type + /// - const char* (encoded using UTF-8, null-terminated) + /// - const char16_t* (encoded using UTF-16-LE, null-terminated) + /// - std::string (encoded using UTF-8) + /// - std::u16string + /// - napi::Value + /// - napi_value + template + static Value From(napi_env env, const T& value); + + /// Converts to a Node-API value primitive. + /// + /// If the instance is _empty_, this returns `nullptr`. + operator napi_value() const; + + /// Tests if this value strictly equals another value. + bool operator==(const Value& other) const; + + /// Tests if this value does not strictly equal another value. + bool operator!=(const Value& other) const; + + /// Tests if this value strictly equals another value. + bool StrictEquals(const Value& other) const; + + /// Gets the environment the value is associated with. + Napi::Env Env() const; + + /// Checks if the value is empty (uninitialized). + /// + /// An empty value is invalid, and most attempts to perform an operation on an + /// empty value will result in an exception. Note an empty value is distinct + /// from JavaScript `null` or `undefined`, which are valid values. + /// + /// When C++ exceptions are disabled at compile time, a method with a `Value` + /// return type may return an empty value to indicate a pending exception. So + /// when not using C++ exceptions, callers should check whether the value is + /// empty before attempting to use it. + bool IsEmpty() const; + + napi_valuetype Type() const; ///< Gets the type of the value. + + bool IsUndefined() + const; ///< Tests if a value is an undefined JavaScript value. + bool IsNull() const; ///< Tests if a value is a null JavaScript value. + bool IsBoolean() const; ///< Tests if a value is a JavaScript boolean. + bool IsNumber() const; ///< Tests if a value is a JavaScript number. +#if NAPI_VERSION > 5 + bool IsBigInt() const; ///< Tests if a value is a JavaScript bigint. +#endif // NAPI_VERSION > 5 +#if (NAPI_VERSION > 4) + bool IsDate() const; ///< Tests if a value is a JavaScript date. +#endif + bool IsString() const; ///< Tests if a value is a JavaScript string. + bool IsSymbol() const; ///< Tests if a value is a JavaScript symbol. + bool IsArray() const; ///< Tests if a value is a JavaScript array. + bool IsArrayBuffer() + const; ///< Tests if a value is a JavaScript array buffer. + bool IsTypedArray() const; ///< Tests if a value is a JavaScript typed array. + bool IsObject() const; ///< Tests if a value is a JavaScript object. + bool IsFunction() const; ///< Tests if a value is a JavaScript function. + bool IsPromise() const; ///< Tests if a value is a JavaScript promise. + bool IsDataView() const; ///< Tests if a value is a JavaScript data view. + bool IsBuffer() const; ///< Tests if a value is a Node buffer. + bool IsExternal() const; ///< Tests if a value is a pointer to external data. + + /// Casts to another type of `Napi::Value`, when the actual type is known or + /// assumed. + /// + /// This conversion does NOT coerce the type. Calling any methods + /// inappropriate for the actual value type will throw `Napi::Error`. + /// + /// If `NODE_ADDON_API_ENABLE_TYPE_CHECK_ON_AS` is defined, this method + /// asserts that the actual type is the expected type. + template + T As() const; + + MaybeOrValue ToBoolean() + const; ///< Coerces a value to a JavaScript boolean. + MaybeOrValue ToNumber() + const; ///< Coerces a value to a JavaScript number. + MaybeOrValue ToString() + const; ///< Coerces a value to a JavaScript string. + MaybeOrValue ToObject() + const; ///< Coerces a value to a JavaScript object. + + protected: + /// !cond INTERNAL + napi_env _env; + napi_value _value; + /// !endcond +}; + +/// A JavaScript boolean value. +class Boolean : public Value { + public: + static Boolean New(napi_env env, ///< Node-API environment + bool value ///< Boolean value + ); + + static void CheckCast(napi_env env, napi_value value); + + Boolean(); ///< Creates a new _empty_ Boolean instance. + Boolean(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + operator bool() const; ///< Converts a Boolean value to a boolean primitive. + bool Value() const; ///< Converts a Boolean value to a boolean primitive. +}; + +/// A JavaScript number value. +class Number : public Value { + public: + static Number New(napi_env env, ///< Node-API environment + double value ///< Number value + ); + + static void CheckCast(napi_env env, napi_value value); + + Number(); ///< Creates a new _empty_ Number instance. + Number(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + operator int32_t() + const; ///< Converts a Number value to a 32-bit signed integer value. + operator uint32_t() + const; ///< Converts a Number value to a 32-bit unsigned integer value. + operator int64_t() + const; ///< Converts a Number value to a 64-bit signed integer value. + operator float() + const; ///< Converts a Number value to a 32-bit floating-point value. + operator double() + const; ///< Converts a Number value to a 64-bit floating-point value. + + int32_t Int32Value() + const; ///< Converts a Number value to a 32-bit signed integer value. + uint32_t Uint32Value() + const; ///< Converts a Number value to a 32-bit unsigned integer value. + int64_t Int64Value() + const; ///< Converts a Number value to a 64-bit signed integer value. + float FloatValue() + const; ///< Converts a Number value to a 32-bit floating-point value. + double DoubleValue() + const; ///< Converts a Number value to a 64-bit floating-point value. +}; + +#if NAPI_VERSION > 5 +/// A JavaScript bigint value. +class BigInt : public Value { + public: + static BigInt New(napi_env env, ///< Node-API environment + int64_t value ///< Number value + ); + static BigInt New(napi_env env, ///< Node-API environment + uint64_t value ///< Number value + ); + + /// Creates a new BigInt object using a specified sign bit and a + /// specified list of digits/words. + /// The resulting number is calculated as: + /// (-1)^sign_bit * (words[0] * (2^64)^0 + words[1] * (2^64)^1 + ...) + static BigInt New(napi_env env, ///< Node-API environment + int sign_bit, ///< Sign bit. 1 if negative. + size_t word_count, ///< Number of words in array + const uint64_t* words ///< Array of words + ); + + static void CheckCast(napi_env env, napi_value value); + + BigInt(); ///< Creates a new _empty_ BigInt instance. + BigInt(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + int64_t Int64Value(bool* lossless) + const; ///< Converts a BigInt value to a 64-bit signed integer value. + uint64_t Uint64Value(bool* lossless) + const; ///< Converts a BigInt value to a 64-bit unsigned integer value. + + size_t WordCount() const; ///< The number of 64-bit words needed to store + ///< the result of ToWords(). + + /// Writes the contents of this BigInt to a specified memory location. + /// `sign_bit` must be provided and will be set to 1 if this BigInt is + /// negative. + /// `*word_count` has to be initialized to the length of the `words` array. + /// Upon return, it will be set to the actual number of words that would + /// be needed to store this BigInt (i.e. the return value of `WordCount()`). + void ToWords(int* sign_bit, size_t* word_count, uint64_t* words); +}; +#endif // NAPI_VERSION > 5 + +#if (NAPI_VERSION > 4) +/// A JavaScript date value. +class Date : public Value { + public: + /// Creates a new Date value from a double primitive. + static Date New(napi_env env, ///< Node-API environment + double value ///< Number value + ); + + static void CheckCast(napi_env env, napi_value value); + + Date(); ///< Creates a new _empty_ Date instance. + Date(napi_env env, napi_value value); ///< Wraps a Node-API value primitive. + operator double() const; ///< Converts a Date value to double primitive + + double ValueOf() const; ///< Converts a Date value to a double primitive. +}; +#endif + +/// A JavaScript string or symbol value (that can be used as a property name). +class Name : public Value { + public: + static void CheckCast(napi_env env, napi_value value); + + Name(); ///< Creates a new _empty_ Name instance. + Name(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. +}; + +/// A JavaScript string value. +class String : public Name { + public: + /// Creates a new String value from a UTF-8 encoded C++ string. + static String New(napi_env env, ///< Node-API environment + const std::string& value ///< UTF-8 encoded C++ string + ); + + /// Creates a new String value from a UTF-16 encoded C++ string. + static String New(napi_env env, ///< Node-API environment + const std::u16string& value ///< UTF-16 encoded C++ string + ); + + /// Creates a new String value from a UTF-8 encoded C string. + static String New( + napi_env env, ///< Node-API environment + const char* value ///< UTF-8 encoded null-terminated C string + ); + + /// Creates a new String value from a UTF-16 encoded C string. + static String New( + napi_env env, ///< Node-API environment + const char16_t* value ///< UTF-16 encoded null-terminated C string + ); + + /// Creates a new String value from a UTF-8 encoded C string with specified + /// length. + static String New(napi_env env, ///< Node-API environment + const char* value, ///< UTF-8 encoded C string (not + ///< necessarily null-terminated) + size_t length ///< length of the string in bytes + ); + + /// Creates a new String value from a UTF-16 encoded C string with specified + /// length. + static String New( + napi_env env, ///< Node-API environment + const char16_t* value, ///< UTF-16 encoded C string (not necessarily + ///< null-terminated) + size_t length ///< Length of the string in 2-byte code units + ); + + /// Creates a new String based on the original object's type. + /// + /// `value` may be any of: + /// - const char* (encoded using UTF-8, null-terminated) + /// - const char16_t* (encoded using UTF-16-LE, null-terminated) + /// - std::string (encoded using UTF-8) + /// - std::u16string + template + static String From(napi_env env, const T& value); + + static void CheckCast(napi_env env, napi_value value); + + String(); ///< Creates a new _empty_ String instance. + String(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + operator std::string() + const; ///< Converts a String value to a UTF-8 encoded C++ string. + operator std::u16string() + const; ///< Converts a String value to a UTF-16 encoded C++ string. + std::string Utf8Value() + const; ///< Converts a String value to a UTF-8 encoded C++ string. + std::u16string Utf16Value() + const; ///< Converts a String value to a UTF-16 encoded C++ string. +}; + +/// A JavaScript symbol value. +class Symbol : public Name { + public: + /// Creates a new Symbol value with an optional description. + static Symbol New( + napi_env env, ///< Node-API environment + const char* description = + nullptr ///< Optional UTF-8 encoded null-terminated C string + /// describing the symbol + ); + + /// Creates a new Symbol value with a description. + static Symbol New( + napi_env env, ///< Node-API environment + const std::string& + description ///< UTF-8 encoded C++ string describing the symbol + ); + + /// Creates a new Symbol value with a description. + static Symbol New(napi_env env, ///< Node-API environment + String description ///< String value describing the symbol + ); + + /// Creates a new Symbol value with a description. + static Symbol New( + napi_env env, ///< Node-API environment + napi_value description ///< String value describing the symbol + ); + + /// Get a public Symbol (e.g. Symbol.iterator). + static MaybeOrValue WellKnown(napi_env, const std::string& name); + + // Create a symbol in the global registry, UTF-8 Encoded cpp string + static MaybeOrValue For(napi_env env, const std::string& description); + + // Create a symbol in the global registry, C style string (null terminated) + static MaybeOrValue For(napi_env env, const char* description); + + // Create a symbol in the global registry, String value describing the symbol + static MaybeOrValue For(napi_env env, String description); + + // Create a symbol in the global registry, napi_value describing the symbol + static MaybeOrValue For(napi_env env, napi_value description); + + static void CheckCast(napi_env env, napi_value value); + + Symbol(); ///< Creates a new _empty_ Symbol instance. + Symbol(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. +}; + +class TypeTaggable : public Value { + public: +#if NAPI_VERSION >= 8 + void TypeTag(const napi_type_tag* type_tag) const; + bool CheckTypeTag(const napi_type_tag* type_tag) const; +#endif // NAPI_VERSION >= 8 + protected: + TypeTaggable(); + TypeTaggable(napi_env env, napi_value value); +}; + +/// A JavaScript object value. +class Object : public TypeTaggable { + public: + /// Enables property and element assignments using indexing syntax. + /// + /// This is a convenient helper to get and set object properties. As + /// getting and setting object properties may throw with JavaScript + /// exceptions, it is notable that these operations may fail. + /// When NODE_ADDON_API_ENABLE_MAYBE is defined, the process will abort + /// on JavaScript exceptions. + /// + /// Example: + /// + /// Napi::Value propertyValue = object1['A']; + /// object2['A'] = propertyValue; + /// Napi::Value elementValue = array[0]; + /// array[1] = elementValue; + template + class PropertyLValue { + public: + /// Converts an L-value to a value. + operator Value() const; + + /// Assigns a value to the property. The type of value can be + /// anything supported by `Object::Set`. + template + PropertyLValue& operator=(ValueType value); + + private: + PropertyLValue() = delete; + PropertyLValue(Object object, Key key); + napi_env _env; + napi_value _object; + Key _key; + + friend class Napi::Object; + }; + + /// Creates a new Object value. + static Object New(napi_env env ///< Node-API environment + ); + + static void CheckCast(napi_env env, napi_value value); + + Object(); ///< Creates a new _empty_ Object instance. + Object(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + /// Gets or sets a named property. + PropertyLValue operator[]( + const char* utf8name ///< UTF-8 encoded null-terminated property name + ); + + /// Gets or sets a named property. + PropertyLValue operator[]( + const std::string& utf8name ///< UTF-8 encoded property name + ); + + /// Gets or sets an indexed property or array element. + PropertyLValue operator[]( + uint32_t index /// Property / element index + ); + + /// Gets or sets an indexed property or array element. + PropertyLValue operator[](Value index /// Property / element index + ) const; + + /// Gets a named property. + MaybeOrValue operator[]( + const char* utf8name ///< UTF-8 encoded null-terminated property name + ) const; + + /// Gets a named property. + MaybeOrValue operator[]( + const std::string& utf8name ///< UTF-8 encoded property name + ) const; + + /// Gets an indexed property or array element. + MaybeOrValue operator[](uint32_t index ///< Property / element index + ) const; + + /// Checks whether a property is present. + MaybeOrValue Has(napi_value key ///< Property key primitive + ) const; + + /// Checks whether a property is present. + MaybeOrValue Has(Value key ///< Property key + ) const; + + /// Checks whether a named property is present. + MaybeOrValue Has( + const char* utf8name ///< UTF-8 encoded null-terminated property name + ) const; + + /// Checks whether a named property is present. + MaybeOrValue Has( + const std::string& utf8name ///< UTF-8 encoded property name + ) const; + + /// Checks whether a own property is present. + MaybeOrValue HasOwnProperty(napi_value key ///< Property key primitive + ) const; + + /// Checks whether a own property is present. + MaybeOrValue HasOwnProperty(Value key ///< Property key + ) const; + + /// Checks whether a own property is present. + MaybeOrValue HasOwnProperty( + const char* utf8name ///< UTF-8 encoded null-terminated property name + ) const; + + /// Checks whether a own property is present. + MaybeOrValue HasOwnProperty( + const std::string& utf8name ///< UTF-8 encoded property name + ) const; + + /// Gets a property. + MaybeOrValue Get(napi_value key ///< Property key primitive + ) const; + + /// Gets a property. + MaybeOrValue Get(Value key ///< Property key + ) const; + + /// Gets a named property. + MaybeOrValue Get( + const char* utf8name ///< UTF-8 encoded null-terminated property name + ) const; + + /// Gets a named property. + MaybeOrValue Get( + const std::string& utf8name ///< UTF-8 encoded property name + ) const; + + /// Sets a property. + template + MaybeOrValue Set(napi_value key, ///< Property key primitive + const ValueType& value ///< Property value primitive + ) const; + + /// Sets a property. + template + MaybeOrValue Set(Value key, ///< Property key + const ValueType& value ///< Property value + ) const; + + /// Sets a named property. + template + MaybeOrValue Set( + const char* utf8name, ///< UTF-8 encoded null-terminated property name + const ValueType& value) const; + + /// Sets a named property. + template + MaybeOrValue Set( + const std::string& utf8name, ///< UTF-8 encoded property name + const ValueType& value ///< Property value primitive + ) const; + + /// Delete property. + MaybeOrValue Delete(napi_value key ///< Property key primitive + ) const; + + /// Delete property. + MaybeOrValue Delete(Value key ///< Property key + ) const; + + /// Delete property. + MaybeOrValue Delete( + const char* utf8name ///< UTF-8 encoded null-terminated property name + ) const; + + /// Delete property. + MaybeOrValue Delete( + const std::string& utf8name ///< UTF-8 encoded property name + ) const; + + /// Checks whether an indexed property is present. + MaybeOrValue Has(uint32_t index ///< Property / element index + ) const; + + /// Gets an indexed property or array element. + MaybeOrValue Get(uint32_t index ///< Property / element index + ) const; + + /// Sets an indexed property or array element. + template + MaybeOrValue Set(uint32_t index, ///< Property / element index + const ValueType& value ///< Property value primitive + ) const; + + /// Deletes an indexed property or array element. + MaybeOrValue Delete(uint32_t index ///< Property / element index + ) const; + + /// This operation can fail in case of Proxy.[[OwnPropertyKeys]] and + /// Proxy.[[GetOwnProperty]] calling into JavaScript. See: + /// - + /// https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-ownpropertykeys + /// - + /// https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-getownproperty-p + MaybeOrValue GetPropertyNames() const; ///< Get all property names + + /// Defines a property on the object. + /// + /// This operation can fail in case of Proxy.[[DefineOwnProperty]] calling + /// into JavaScript. See + /// https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-defineownproperty-p-desc + MaybeOrValue DefineProperty( + const PropertyDescriptor& + property ///< Descriptor for the property to be defined + ) const; + + /// Defines properties on the object. + /// + /// This operation can fail in case of Proxy.[[DefineOwnProperty]] calling + /// into JavaScript. See + /// https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-defineownproperty-p-desc + MaybeOrValue DefineProperties( + const std::initializer_list& properties + ///< List of descriptors for the properties to be defined + ) const; + + /// Defines properties on the object. + /// + /// This operation can fail in case of Proxy.[[DefineOwnProperty]] calling + /// into JavaScript. See + /// https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-defineownproperty-p-desc + MaybeOrValue DefineProperties( + const std::vector& properties + ///< Vector of descriptors for the properties to be defined + ) const; + + /// Checks if an object is an instance created by a constructor function. + /// + /// This is equivalent to the JavaScript `instanceof` operator. + /// + /// This operation can fail in case of Proxy.[[GetPrototypeOf]] calling into + /// JavaScript. + /// See + /// https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-getprototypeof + MaybeOrValue InstanceOf( + const Function& constructor ///< Constructor function + ) const; + + template + inline void AddFinalizer(Finalizer finalizeCallback, T* data) const; + + template + inline void AddFinalizer(Finalizer finalizeCallback, + T* data, + Hint* finalizeHint) const; + +#ifdef NAPI_CPP_EXCEPTIONS + class const_iterator; + + inline const_iterator begin() const; + + inline const_iterator end() const; + + class iterator; + + inline iterator begin(); + + inline iterator end(); +#endif // NAPI_CPP_EXCEPTIONS + +#if NAPI_VERSION >= 8 + /// This operation can fail in case of Proxy.[[GetPrototypeOf]] calling into + /// JavaScript. + /// See + /// https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-getprototypeof + MaybeOrValue Freeze() const; + /// This operation can fail in case of Proxy.[[GetPrototypeOf]] calling into + /// JavaScript. + /// See + /// https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-getprototypeof + MaybeOrValue Seal() const; +#endif // NAPI_VERSION >= 8 +}; + +template +class External : public TypeTaggable { + public: + static External New(napi_env env, T* data); + + // Finalizer must implement `void operator()(Env env, T* data)`. + template + static External New(napi_env env, T* data, Finalizer finalizeCallback); + // Finalizer must implement `void operator()(Env env, T* data, Hint* hint)`. + template + static External New(napi_env env, + T* data, + Finalizer finalizeCallback, + Hint* finalizeHint); + + static void CheckCast(napi_env env, napi_value value); + + External(); + External(napi_env env, napi_value value); + + T* Data() const; +}; + +class Array : public Object { + public: + static Array New(napi_env env); + static Array New(napi_env env, size_t length); + + static void CheckCast(napi_env env, napi_value value); + + Array(); + Array(napi_env env, napi_value value); + + uint32_t Length() const; +}; + +#ifdef NAPI_CPP_EXCEPTIONS +class Object::const_iterator { + private: + enum class Type { BEGIN, END }; + + inline const_iterator(const Object* object, const Type type); + + public: + inline const_iterator& operator++(); + + inline bool operator==(const const_iterator& other) const; + + inline bool operator!=(const const_iterator& other) const; + + inline const std::pair> operator*() + const; + + private: + const Napi::Object* _object; + Array _keys; + uint32_t _index; + + friend class Object; +}; + +class Object::iterator { + private: + enum class Type { BEGIN, END }; + + inline iterator(Object* object, const Type type); + + public: + inline iterator& operator++(); + + inline bool operator==(const iterator& other) const; + + inline bool operator!=(const iterator& other) const; + + inline std::pair> operator*(); + + private: + Napi::Object* _object; + Array _keys; + uint32_t _index; + + friend class Object; +}; +#endif // NAPI_CPP_EXCEPTIONS + +/// A JavaScript array buffer value. +class ArrayBuffer : public Object { + public: + /// Creates a new ArrayBuffer instance over a new automatically-allocated + /// buffer. + static ArrayBuffer New( + napi_env env, ///< Node-API environment + size_t byteLength ///< Length of the buffer to be allocated, in bytes + ); + +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + /// Creates a new ArrayBuffer instance, using an external buffer with + /// specified byte length. + static ArrayBuffer New( + napi_env env, ///< Node-API environment + void* externalData, ///< Pointer to the external buffer to be used by + ///< the array + size_t byteLength ///< Length of the external buffer to be used by the + ///< array, in bytes + ); + + /// Creates a new ArrayBuffer instance, using an external buffer with + /// specified byte length. + template + static ArrayBuffer New( + napi_env env, ///< Node-API environment + void* externalData, ///< Pointer to the external buffer to be used by + ///< the array + size_t byteLength, ///< Length of the external buffer to be used by the + ///< array, + /// in bytes + Finalizer finalizeCallback ///< Function to be called when the array + ///< buffer is destroyed; + /// must implement `void operator()(Env env, + /// void* externalData)` + ); + + /// Creates a new ArrayBuffer instance, using an external buffer with + /// specified byte length. + template + static ArrayBuffer New( + napi_env env, ///< Node-API environment + void* externalData, ///< Pointer to the external buffer to be used by + ///< the array + size_t byteLength, ///< Length of the external buffer to be used by the + ///< array, + /// in bytes + Finalizer finalizeCallback, ///< Function to be called when the array + ///< buffer is destroyed; + /// must implement `void operator()(Env + /// env, void* externalData, Hint* hint)` + Hint* finalizeHint ///< Hint (second parameter) to be passed to the + ///< finalize callback + ); +#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + + static void CheckCast(napi_env env, napi_value value); + + ArrayBuffer(); ///< Creates a new _empty_ ArrayBuffer instance. + ArrayBuffer(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + void* Data(); ///< Gets a pointer to the data buffer. + size_t ByteLength(); ///< Gets the length of the array buffer in bytes. + +#if NAPI_VERSION >= 7 + bool IsDetached() const; + void Detach(); +#endif // NAPI_VERSION >= 7 +}; + +/// A JavaScript typed-array value with unknown array type. +/// +/// For type-specific operations, cast to a `TypedArrayOf` instance using the +/// `As()` method: +/// +/// Napi::TypedArray array = ... +/// if (t.TypedArrayType() == napi_int32_array) { +/// Napi::Int32Array int32Array = t.As(); +/// } +class TypedArray : public Object { + public: + static void CheckCast(napi_env env, napi_value value); + + TypedArray(); ///< Creates a new _empty_ TypedArray instance. + TypedArray(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + napi_typedarray_type TypedArrayType() + const; ///< Gets the type of this typed-array. + Napi::ArrayBuffer ArrayBuffer() const; ///< Gets the backing array buffer. + + uint8_t ElementSize() + const; ///< Gets the size in bytes of one element in the array. + size_t ElementLength() const; ///< Gets the number of elements in the array. + size_t ByteOffset() + const; ///< Gets the offset into the buffer where the array starts. + size_t ByteLength() const; ///< Gets the length of the array in bytes. + + protected: + /// !cond INTERNAL + napi_typedarray_type _type; + size_t _length; + + TypedArray(napi_env env, + napi_value value, + napi_typedarray_type type, + size_t length); + + template + static +#if defined(NAPI_HAS_CONSTEXPR) + constexpr +#endif + napi_typedarray_type + TypedArrayTypeForPrimitiveType() { + return std::is_same::value ? napi_int8_array + : std::is_same::value ? napi_uint8_array + : std::is_same::value ? napi_int16_array + : std::is_same::value ? napi_uint16_array + : std::is_same::value ? napi_int32_array + : std::is_same::value ? napi_uint32_array + : std::is_same::value ? napi_float32_array + : std::is_same::value ? napi_float64_array +#if NAPI_VERSION > 5 + : std::is_same::value ? napi_bigint64_array + : std::is_same::value ? napi_biguint64_array +#endif // NAPI_VERSION > 5 + : napi_int8_array; + } + /// !endcond +}; + +/// A JavaScript typed-array value with known array type. +/// +/// Note while it is possible to create and access Uint8 "clamped" arrays using +/// this class, the _clamping_ behavior is only applied in JavaScript. +template +class TypedArrayOf : public TypedArray { + public: + /// Creates a new TypedArray instance over a new automatically-allocated array + /// buffer. + /// + /// The array type parameter can normally be omitted (because it is inferred + /// from the template parameter T), except when creating a "clamped" array: + /// + /// Uint8Array::New(env, length, napi_uint8_clamped_array) + static TypedArrayOf New( + napi_env env, ///< Node-API environment + size_t elementLength, ///< Length of the created array, as a number of + ///< elements +#if defined(NAPI_HAS_CONSTEXPR) + napi_typedarray_type type = + TypedArray::TypedArrayTypeForPrimitiveType() +#else + napi_typedarray_type type +#endif + ///< Type of array, if different from the default array type for the + ///< template parameter T. + ); + + /// Creates a new TypedArray instance over a provided array buffer. + /// + /// The array type parameter can normally be omitted (because it is inferred + /// from the template parameter T), except when creating a "clamped" array: + /// + /// Uint8Array::New(env, length, buffer, 0, napi_uint8_clamped_array) + static TypedArrayOf New( + napi_env env, ///< Node-API environment + size_t elementLength, ///< Length of the created array, as a number of + ///< elements + Napi::ArrayBuffer arrayBuffer, ///< Backing array buffer instance to use + size_t bufferOffset, ///< Offset into the array buffer where the + ///< typed-array starts +#if defined(NAPI_HAS_CONSTEXPR) + napi_typedarray_type type = + TypedArray::TypedArrayTypeForPrimitiveType() +#else + napi_typedarray_type type +#endif + ///< Type of array, if different from the default array type for the + ///< template parameter T. + ); + + static void CheckCast(napi_env env, napi_value value); + + TypedArrayOf(); ///< Creates a new _empty_ TypedArrayOf instance. + TypedArrayOf(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + T& operator[](size_t index); ///< Gets or sets an element in the array. + const T& operator[](size_t index) const; ///< Gets an element in the array. + + /// Gets a pointer to the array's backing buffer. + /// + /// This is not necessarily the same as the `ArrayBuffer::Data()` pointer, + /// because the typed-array may have a non-zero `ByteOffset()` into the + /// `ArrayBuffer`. + T* Data(); + + /// Gets a pointer to the array's backing buffer. + /// + /// This is not necessarily the same as the `ArrayBuffer::Data()` pointer, + /// because the typed-array may have a non-zero `ByteOffset()` into the + /// `ArrayBuffer`. + const T* Data() const; + + private: + T* _data; + + TypedArrayOf(napi_env env, + napi_value value, + napi_typedarray_type type, + size_t length, + T* data); +}; + +/// The DataView provides a low-level interface for reading/writing multiple +/// number types in an ArrayBuffer irrespective of the platform's endianness. +class DataView : public Object { + public: + static DataView New(napi_env env, Napi::ArrayBuffer arrayBuffer); + static DataView New(napi_env env, + Napi::ArrayBuffer arrayBuffer, + size_t byteOffset); + static DataView New(napi_env env, + Napi::ArrayBuffer arrayBuffer, + size_t byteOffset, + size_t byteLength); + + static void CheckCast(napi_env env, napi_value value); + + DataView(); ///< Creates a new _empty_ DataView instance. + DataView(napi_env env, + napi_value value); ///< Wraps a Node-API value primitive. + + Napi::ArrayBuffer ArrayBuffer() const; ///< Gets the backing array buffer. + size_t ByteOffset() + const; ///< Gets the offset into the buffer where the array starts. + size_t ByteLength() const; ///< Gets the length of the array in bytes. + + void* Data() const; + + float GetFloat32(size_t byteOffset) const; + double GetFloat64(size_t byteOffset) const; + int8_t GetInt8(size_t byteOffset) const; + int16_t GetInt16(size_t byteOffset) const; + int32_t GetInt32(size_t byteOffset) const; + uint8_t GetUint8(size_t byteOffset) const; + uint16_t GetUint16(size_t byteOffset) const; + uint32_t GetUint32(size_t byteOffset) const; + + void SetFloat32(size_t byteOffset, float value) const; + void SetFloat64(size_t byteOffset, double value) const; + void SetInt8(size_t byteOffset, int8_t value) const; + void SetInt16(size_t byteOffset, int16_t value) const; + void SetInt32(size_t byteOffset, int32_t value) const; + void SetUint8(size_t byteOffset, uint8_t value) const; + void SetUint16(size_t byteOffset, uint16_t value) const; + void SetUint32(size_t byteOffset, uint32_t value) const; + + private: + template + T ReadData(size_t byteOffset) const; + + template + void WriteData(size_t byteOffset, T value) const; + + void* _data; + size_t _length; +}; + +class Function : public Object { + public: + using VoidCallback = void (*)(const CallbackInfo& info); + using Callback = Value (*)(const CallbackInfo& info); + + template + static Function New(napi_env env, + const char* utf8name = nullptr, + void* data = nullptr); + + template + static Function New(napi_env env, + const char* utf8name = nullptr, + void* data = nullptr); + + template + static Function New(napi_env env, + const std::string& utf8name, + void* data = nullptr); + + template + static Function New(napi_env env, + const std::string& utf8name, + void* data = nullptr); + + /// Callable must implement operator() accepting a const CallbackInfo& + /// and return either void or Value. + template + static Function New(napi_env env, + Callable cb, + const char* utf8name = nullptr, + void* data = nullptr); + /// Callable must implement operator() accepting a const CallbackInfo& + /// and return either void or Value. + template + static Function New(napi_env env, + Callable cb, + const std::string& utf8name, + void* data = nullptr); + + static void CheckCast(napi_env env, napi_value value); + + Function(); + Function(napi_env env, napi_value value); + + MaybeOrValue operator()( + const std::initializer_list& args) const; + + MaybeOrValue Call(const std::initializer_list& args) const; + MaybeOrValue Call(const std::vector& args) const; + MaybeOrValue Call(const std::vector& args) const; + MaybeOrValue Call(size_t argc, const napi_value* args) const; + MaybeOrValue Call(napi_value recv, + const std::initializer_list& args) const; + MaybeOrValue Call(napi_value recv, + const std::vector& args) const; + MaybeOrValue Call(napi_value recv, + const std::vector& args) const; + MaybeOrValue Call(napi_value recv, + size_t argc, + const napi_value* args) const; + + MaybeOrValue MakeCallback( + napi_value recv, + const std::initializer_list& args, + napi_async_context context = nullptr) const; + MaybeOrValue MakeCallback(napi_value recv, + const std::vector& args, + napi_async_context context = nullptr) const; + MaybeOrValue MakeCallback(napi_value recv, + size_t argc, + const napi_value* args, + napi_async_context context = nullptr) const; + + MaybeOrValue New(const std::initializer_list& args) const; + MaybeOrValue New(const std::vector& args) const; + MaybeOrValue New(size_t argc, const napi_value* args) const; +}; + +class Promise : public Object { + public: + class Deferred { + public: + static Deferred New(napi_env env); + Deferred(napi_env env); + + Napi::Promise Promise() const; + Napi::Env Env() const; + + void Resolve(napi_value value) const; + void Reject(napi_value value) const; + + private: + napi_env _env; + napi_deferred _deferred; + napi_value _promise; + }; + + static void CheckCast(napi_env env, napi_value value); + + Promise(napi_env env, napi_value value); +}; + +template +class Buffer : public Uint8Array { + public: + static Buffer New(napi_env env, size_t length); +#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + static Buffer New(napi_env env, T* data, size_t length); + + // Finalizer must implement `void operator()(Env env, T* data)`. + template + static Buffer New(napi_env env, + T* data, + size_t length, + Finalizer finalizeCallback); + // Finalizer must implement `void operator()(Env env, T* data, Hint* hint)`. + template + static Buffer New(napi_env env, + T* data, + size_t length, + Finalizer finalizeCallback, + Hint* finalizeHint); +#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED + + static Buffer NewOrCopy(napi_env env, T* data, size_t length); + // Finalizer must implement `void operator()(Env env, T* data)`. + template + static Buffer NewOrCopy(napi_env env, + T* data, + size_t length, + Finalizer finalizeCallback); + // Finalizer must implement `void operator()(Env env, T* data, Hint* hint)`. + template + static Buffer NewOrCopy(napi_env env, + T* data, + size_t length, + Finalizer finalizeCallback, + Hint* finalizeHint); + + static Buffer Copy(napi_env env, const T* data, size_t length); + + static void CheckCast(napi_env env, napi_value value); + + Buffer(); + Buffer(napi_env env, napi_value value); + size_t Length() const; + T* Data() const; + + private: +}; + +/// Holds a counted reference to a value; initially a weak reference unless +/// otherwise specified, may be changed to/from a strong reference by adjusting +/// the refcount. +/// +/// The referenced value is not immediately destroyed when the reference count +/// is zero; it is merely then eligible for garbage-collection if there are no +/// other references to the value. +template +class Reference { + public: + static Reference New(const T& value, uint32_t initialRefcount = 0); + + Reference(); + Reference(napi_env env, napi_ref ref); + ~Reference(); + + // A reference can be moved but cannot be copied. + Reference(Reference&& other); + Reference& operator=(Reference&& other); + NAPI_DISALLOW_ASSIGN(Reference) + + operator napi_ref() const; + bool operator==(const Reference& other) const; + bool operator!=(const Reference& other) const; + + Napi::Env Env() const; + bool IsEmpty() const; + + // Note when getting the value of a Reference it is usually correct to do so + // within a HandleScope so that the value handle gets cleaned up efficiently. + T Value() const; + + uint32_t Ref() const; + uint32_t Unref() const; + void Reset(); + void Reset(const T& value, uint32_t refcount = 0); + + // Call this on a reference that is declared as static data, to prevent its + // destructor from running at program shutdown time, which would attempt to + // reset the reference when the environment is no longer valid. Avoid using + // this if at all possible. If you do need to use static data, MAKE SURE to + // warn your users that your addon is NOT threadsafe. + void SuppressDestruct(); + + protected: + Reference(const Reference&); + + /// !cond INTERNAL + napi_env _env; + napi_ref _ref; + /// !endcond + + private: + bool _suppressDestruct; +}; + +class ObjectReference : public Reference { + public: + ObjectReference(); + ObjectReference(napi_env env, napi_ref ref); + + // A reference can be moved but cannot be copied. + ObjectReference(Reference&& other); + ObjectReference& operator=(Reference&& other); + ObjectReference(ObjectReference&& other); + ObjectReference& operator=(ObjectReference&& other); + NAPI_DISALLOW_ASSIGN(ObjectReference) + + MaybeOrValue Get(const char* utf8name) const; + MaybeOrValue Get(const std::string& utf8name) const; + MaybeOrValue Set(const char* utf8name, napi_value value) const; + MaybeOrValue Set(const char* utf8name, Napi::Value value) const; + MaybeOrValue Set(const char* utf8name, const char* utf8value) const; + MaybeOrValue Set(const char* utf8name, bool boolValue) const; + MaybeOrValue Set(const char* utf8name, double numberValue) const; + MaybeOrValue Set(const std::string& utf8name, napi_value value) const; + MaybeOrValue Set(const std::string& utf8name, Napi::Value value) const; + MaybeOrValue Set(const std::string& utf8name, + std::string& utf8value) const; + MaybeOrValue Set(const std::string& utf8name, bool boolValue) const; + MaybeOrValue Set(const std::string& utf8name, double numberValue) const; + + MaybeOrValue Get(uint32_t index) const; + MaybeOrValue Set(uint32_t index, const napi_value value) const; + MaybeOrValue Set(uint32_t index, const Napi::Value value) const; + MaybeOrValue Set(uint32_t index, const char* utf8value) const; + MaybeOrValue Set(uint32_t index, const std::string& utf8value) const; + MaybeOrValue Set(uint32_t index, bool boolValue) const; + MaybeOrValue Set(uint32_t index, double numberValue) const; + + protected: + ObjectReference(const ObjectReference&); +}; + +class FunctionReference : public Reference { + public: + FunctionReference(); + FunctionReference(napi_env env, napi_ref ref); + + // A reference can be moved but cannot be copied. + FunctionReference(Reference&& other); + FunctionReference& operator=(Reference&& other); + FunctionReference(FunctionReference&& other); + FunctionReference& operator=(FunctionReference&& other); + NAPI_DISALLOW_ASSIGN_COPY(FunctionReference) + + MaybeOrValue operator()( + const std::initializer_list& args) const; + + MaybeOrValue Call( + const std::initializer_list& args) const; + MaybeOrValue Call(const std::vector& args) const; + MaybeOrValue Call( + napi_value recv, const std::initializer_list& args) const; + MaybeOrValue Call(napi_value recv, + const std::vector& args) const; + MaybeOrValue Call(napi_value recv, + size_t argc, + const napi_value* args) const; + + MaybeOrValue MakeCallback( + napi_value recv, + const std::initializer_list& args, + napi_async_context context = nullptr) const; + MaybeOrValue MakeCallback( + napi_value recv, + const std::vector& args, + napi_async_context context = nullptr) const; + MaybeOrValue MakeCallback( + napi_value recv, + size_t argc, + const napi_value* args, + napi_async_context context = nullptr) const; + + MaybeOrValue New(const std::initializer_list& args) const; + MaybeOrValue New(const std::vector& args) const; +}; + +// Shortcuts to creating a new reference with inferred type and refcount = 0. +template +Reference Weak(T value); +ObjectReference Weak(Object value); +FunctionReference Weak(Function value); + +// Shortcuts to creating a new reference with inferred type and refcount = 1. +template +Reference Persistent(T value); +ObjectReference Persistent(Object value); +FunctionReference Persistent(Function value); + +/// A persistent reference to a JavaScript error object. Use of this class +/// depends somewhat on whether C++ exceptions are enabled at compile time. +/// +/// ### Handling Errors With C++ Exceptions +/// +/// If C++ exceptions are enabled, then the `Error` class extends +/// `std::exception` and enables integrated error-handling for C++ exceptions +/// and JavaScript exceptions. +/// +/// If a Node-API call fails without executing any JavaScript code (for +/// example due to an invalid argument), then the Node-API wrapper +/// automatically converts and throws the error as a C++ exception of type +/// `Napi::Error`. Or if a JavaScript function called by C++ code via Node-API +/// throws a JavaScript exception, then the Node-API wrapper automatically +/// converts and throws it as a C++ exception of type `Napi::Error`. +/// +/// If a C++ exception of type `Napi::Error` escapes from a Node-API C++ +/// callback, then the Node-API wrapper automatically converts and throws it +/// as a JavaScript exception. Therefore, catching a C++ exception of type +/// `Napi::Error` prevents a JavaScript exception from being thrown. +/// +/// #### Example 1A - Throwing a C++ exception: +/// +/// Napi::Env env = ... +/// throw Napi::Error::New(env, "Example exception"); +/// +/// Following C++ statements will not be executed. The exception will bubble +/// up as a C++ exception of type `Napi::Error`, until it is either caught +/// while still in C++, or else automatically propataged as a JavaScript +/// exception when the callback returns to JavaScript. +/// +/// #### Example 2A - Propagating a Node-API C++ exception: +/// +/// Napi::Function jsFunctionThatThrows = someObj.As(); +/// Napi::Value result = jsFunctionThatThrows({ arg1, arg2 }); +/// +/// Following C++ statements will not be executed. The exception will bubble +/// up as a C++ exception of type `Napi::Error`, until it is either caught +/// while still in C++, or else automatically propagated as a JavaScript +/// exception when the callback returns to JavaScript. +/// +/// #### Example 3A - Handling a Node-API C++ exception: +/// +/// Napi::Function jsFunctionThatThrows = someObj.As(); +/// Napi::Value result; +/// try { +/// result = jsFunctionThatThrows({ arg1, arg2 }); +/// } catch (const Napi::Error& e) { +/// cerr << "Caught JavaScript exception: " + e.what(); +/// } +/// +/// Since the exception was caught here, it will not be propagated as a +/// JavaScript exception. +/// +/// ### Handling Errors Without C++ Exceptions +/// +/// If C++ exceptions are disabled (by defining `NAPI_DISABLE_CPP_EXCEPTIONS`) +/// then this class does not extend `std::exception`, and APIs in the `Napi` +/// namespace do not throw C++ exceptions when they fail. Instead, they raise +/// _pending_ JavaScript exceptions and return _empty_ `Value`s. Calling code +/// should check `Value::IsEmpty()` before attempting to use a returned value, +/// and may use methods on the `Env` class to check for, get, and clear a +/// pending JavaScript exception. If the pending exception is not cleared, it +/// will be thrown when the native callback returns to JavaScript. +/// +/// #### Example 1B - Throwing a JS exception +/// +/// Napi::Env env = ... +/// Napi::Error::New(env, "Example +/// exception").ThrowAsJavaScriptException(); return; +/// +/// After throwing a JS exception, the code should generally return +/// immediately from the native callback, after performing any necessary +/// cleanup. +/// +/// #### Example 2B - Propagating a Node-API JS exception: +/// +/// Napi::Function jsFunctionThatThrows = someObj.As(); +/// Napi::Value result = jsFunctionThatThrows({ arg1, arg2 }); +/// if (result.IsEmpty()) return; +/// +/// An empty value result from a Node-API call indicates an error occurred, +/// and a JavaScript exception is pending. To let the exception propagate, the +/// code should generally return immediately from the native callback, after +/// performing any necessary cleanup. +/// +/// #### Example 3B - Handling a Node-API JS exception: +/// +/// Napi::Function jsFunctionThatThrows = someObj.As(); +/// Napi::Value result = jsFunctionThatThrows({ arg1, arg2 }); +/// if (result.IsEmpty()) { +/// Napi::Error e = env.GetAndClearPendingException(); +/// cerr << "Caught JavaScript exception: " + e.Message(); +/// } +/// +/// Since the exception was cleared here, it will not be propagated as a +/// JavaScript exception after the native callback returns. +class Error : public ObjectReference +#ifdef NAPI_CPP_EXCEPTIONS + , + public std::exception +#endif // NAPI_CPP_EXCEPTIONS +{ + public: + static Error New(napi_env env); + static Error New(napi_env env, const char* message); + static Error New(napi_env env, const std::string& message); + + static NAPI_NO_RETURN void Fatal(const char* location, const char* message); + + Error(); + Error(napi_env env, napi_value value); + + // An error can be moved or copied. + Error(Error&& other); + Error& operator=(Error&& other); + Error(const Error&); + Error& operator=(const Error&); + + const std::string& Message() const NAPI_NOEXCEPT; + void ThrowAsJavaScriptException() const; + + Object Value() const; + +#ifdef NAPI_CPP_EXCEPTIONS + const char* what() const NAPI_NOEXCEPT override; +#endif // NAPI_CPP_EXCEPTIONS + + protected: + /// !cond INTERNAL + using create_error_fn = napi_status (*)(napi_env envb, + napi_value code, + napi_value msg, + napi_value* result); + + template + static TError New(napi_env env, + const char* message, + size_t length, + create_error_fn create_error); + /// !endcond + + private: + static inline const char* ERROR_WRAP_VALUE() NAPI_NOEXCEPT; + mutable std::string _message; +}; + +class TypeError : public Error { + public: + static TypeError New(napi_env env, const char* message); + static TypeError New(napi_env env, const std::string& message); + + TypeError(); + TypeError(napi_env env, napi_value value); +}; + +class RangeError : public Error { + public: + static RangeError New(napi_env env, const char* message); + static RangeError New(napi_env env, const std::string& message); + + RangeError(); + RangeError(napi_env env, napi_value value); +}; + +#if NAPI_VERSION > 8 +class SyntaxError : public Error { + public: + static SyntaxError New(napi_env env, const char* message); + static SyntaxError New(napi_env env, const std::string& message); + + SyntaxError(); + SyntaxError(napi_env env, napi_value value); +}; +#endif // NAPI_VERSION > 8 + +class CallbackInfo { + public: + CallbackInfo(napi_env env, napi_callback_info info); + ~CallbackInfo(); + + // Disallow copying to prevent multiple free of _dynamicArgs + NAPI_DISALLOW_ASSIGN_COPY(CallbackInfo) + + Napi::Env Env() const; + Value NewTarget() const; + bool IsConstructCall() const; + size_t Length() const; + const Value operator[](size_t index) const; + Value This() const; + void* Data() const; + void SetData(void* data); + explicit operator napi_callback_info() const; + + private: + const size_t _staticArgCount = 6; + napi_env _env; + napi_callback_info _info; + napi_value _this; + size_t _argc; + napi_value* _argv; + napi_value _staticArgs[6]; + napi_value* _dynamicArgs; + void* _data; +}; + +class PropertyDescriptor { + public: + using GetterCallback = Napi::Value (*)(const Napi::CallbackInfo& info); + using SetterCallback = void (*)(const Napi::CallbackInfo& info); + +#ifndef NODE_ADDON_API_DISABLE_DEPRECATED + template + static PropertyDescriptor Accessor( + const char* utf8name, + Getter getter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + const std::string& utf8name, + Getter getter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + napi_value name, + Getter getter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + Name name, + Getter getter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + const char* utf8name, + Getter getter, + Setter setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + const std::string& utf8name, + Getter getter, + Setter setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + napi_value name, + Getter getter, + Setter setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + Name name, + Getter getter, + Setter setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Function( + const char* utf8name, + Callable cb, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Function( + const std::string& utf8name, + Callable cb, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Function( + napi_value name, + Callable cb, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Function( + Name name, + Callable cb, + napi_property_attributes attributes = napi_default, + void* data = nullptr); +#endif // !NODE_ADDON_API_DISABLE_DEPRECATED + + template + static PropertyDescriptor Accessor( + const char* utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + + template + static PropertyDescriptor Accessor( + const std::string& utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + + template + static PropertyDescriptor Accessor( + Name name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + + template + static PropertyDescriptor Accessor( + const char* utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + + template + static PropertyDescriptor Accessor( + const std::string& utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + + template + static PropertyDescriptor Accessor( + Name name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + + template + static PropertyDescriptor Accessor( + Napi::Env env, + Napi::Object object, + const char* utf8name, + Getter getter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + Napi::Env env, + Napi::Object object, + const std::string& utf8name, + Getter getter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + Napi::Env env, + Napi::Object object, + Name name, + Getter getter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + Napi::Env env, + Napi::Object object, + const char* utf8name, + Getter getter, + Setter setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + Napi::Env env, + Napi::Object object, + const std::string& utf8name, + Getter getter, + Setter setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Accessor( + Napi::Env env, + Napi::Object object, + Name name, + Getter getter, + Setter setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Function( + Napi::Env env, + Napi::Object object, + const char* utf8name, + Callable cb, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Function( + Napi::Env env, + Napi::Object object, + const std::string& utf8name, + Callable cb, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor Function( + Napi::Env env, + Napi::Object object, + Name name, + Callable cb, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor Value( + const char* utf8name, + napi_value value, + napi_property_attributes attributes = napi_default); + static PropertyDescriptor Value( + const std::string& utf8name, + napi_value value, + napi_property_attributes attributes = napi_default); + static PropertyDescriptor Value( + napi_value name, + napi_value value, + napi_property_attributes attributes = napi_default); + static PropertyDescriptor Value( + Name name, + Napi::Value value, + napi_property_attributes attributes = napi_default); + + PropertyDescriptor(napi_property_descriptor desc); + + operator napi_property_descriptor&(); + operator const napi_property_descriptor&() const; + + private: + napi_property_descriptor _desc; +}; + +/// Property descriptor for use with `ObjectWrap::DefineClass()`. +/// +/// This is different from the standalone `PropertyDescriptor` because it is +/// specific to each `ObjectWrap` subclass. This prevents using descriptors +/// from a different class when defining a new class (preventing the callbacks +/// from having incorrect `this` pointers). +template +class ClassPropertyDescriptor { + public: + ClassPropertyDescriptor(napi_property_descriptor desc) : _desc(desc) {} + + operator napi_property_descriptor&() { return _desc; } + operator const napi_property_descriptor&() const { return _desc; } + + private: + napi_property_descriptor _desc; +}; + +template +struct MethodCallbackData { + TCallback callback; + void* data; +}; + +template +struct AccessorCallbackData { + TGetterCallback getterCallback; + TSetterCallback setterCallback; + void* data; +}; + +template +class InstanceWrap { + public: + using InstanceVoidMethodCallback = void (T::*)(const CallbackInfo& info); + using InstanceMethodCallback = Napi::Value (T::*)(const CallbackInfo& info); + using InstanceGetterCallback = Napi::Value (T::*)(const CallbackInfo& info); + using InstanceSetterCallback = void (T::*)(const CallbackInfo& info, + const Napi::Value& value); + + using PropertyDescriptor = ClassPropertyDescriptor; + + static PropertyDescriptor InstanceMethod( + const char* utf8name, + InstanceVoidMethodCallback method, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor InstanceMethod( + const char* utf8name, + InstanceMethodCallback method, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor InstanceMethod( + Symbol name, + InstanceVoidMethodCallback method, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor InstanceMethod( + Symbol name, + InstanceMethodCallback method, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor InstanceMethod( + const char* utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor InstanceMethod( + const char* utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor InstanceMethod( + Symbol name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor InstanceMethod( + Symbol name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor InstanceAccessor( + const char* utf8name, + InstanceGetterCallback getter, + InstanceSetterCallback setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor InstanceAccessor( + Symbol name, + InstanceGetterCallback getter, + InstanceSetterCallback setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor InstanceAccessor( + const char* utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor InstanceAccessor( + Symbol name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor InstanceValue( + const char* utf8name, + Napi::Value value, + napi_property_attributes attributes = napi_default); + static PropertyDescriptor InstanceValue( + Symbol name, + Napi::Value value, + napi_property_attributes attributes = napi_default); + + protected: + static void AttachPropData(napi_env env, + napi_value value, + const napi_property_descriptor* prop); + + private: + using This = InstanceWrap; + + using InstanceVoidMethodCallbackData = + MethodCallbackData; + using InstanceMethodCallbackData = + MethodCallbackData; + using InstanceAccessorCallbackData = + AccessorCallbackData; + + static napi_value InstanceVoidMethodCallbackWrapper(napi_env env, + napi_callback_info info); + static napi_value InstanceMethodCallbackWrapper(napi_env env, + napi_callback_info info); + static napi_value InstanceGetterCallbackWrapper(napi_env env, + napi_callback_info info); + static napi_value InstanceSetterCallbackWrapper(napi_env env, + napi_callback_info info); + + template + static napi_value WrappedMethod(napi_env env, + napi_callback_info info) NAPI_NOEXCEPT; + + template + struct SetterTag {}; + + template + static napi_callback WrapSetter(SetterTag) NAPI_NOEXCEPT { + return &This::WrappedMethod; + } + static napi_callback WrapSetter(SetterTag) NAPI_NOEXCEPT { + return nullptr; + } +}; + +/// Base class to be extended by C++ classes exposed to JavaScript; each C++ +/// class instance gets "wrapped" by a JavaScript object that is managed by this +/// class. +/// +/// At initialization time, the `DefineClass()` method must be used to +/// hook up the accessor and method callbacks. It takes a list of +/// property descriptors, which can be constructed via the various +/// static methods on the base class. +/// +/// #### Example: +/// +/// class Example: public Napi::ObjectWrap { +/// public: +/// static void Initialize(Napi::Env& env, Napi::Object& target) { +/// Napi::Function constructor = DefineClass(env, "Example", { +/// InstanceAccessor<&Example::GetSomething, +/// &Example::SetSomething>("value"), +/// InstanceMethod<&Example::DoSomething>("doSomething"), +/// }); +/// target.Set("Example", constructor); +/// } +/// +/// Example(const Napi::CallbackInfo& info); // Constructor +/// Napi::Value GetSomething(const Napi::CallbackInfo& info); +/// void SetSomething(const Napi::CallbackInfo& info, const Napi::Value& +/// value); Napi::Value DoSomething(const Napi::CallbackInfo& info); +/// } +template +class ObjectWrap : public InstanceWrap, public Reference { + public: + ObjectWrap(const CallbackInfo& callbackInfo); + virtual ~ObjectWrap(); + + static T* Unwrap(Object wrapper); + + // Methods exposed to JavaScript must conform to one of these callback + // signatures. + using StaticVoidMethodCallback = void (*)(const CallbackInfo& info); + using StaticMethodCallback = Napi::Value (*)(const CallbackInfo& info); + using StaticGetterCallback = Napi::Value (*)(const CallbackInfo& info); + using StaticSetterCallback = void (*)(const CallbackInfo& info, + const Napi::Value& value); + + using PropertyDescriptor = ClassPropertyDescriptor; + + static Function DefineClass( + Napi::Env env, + const char* utf8name, + const std::initializer_list& properties, + void* data = nullptr); + static Function DefineClass(Napi::Env env, + const char* utf8name, + const std::vector& properties, + void* data = nullptr); + static PropertyDescriptor StaticMethod( + const char* utf8name, + StaticVoidMethodCallback method, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor StaticMethod( + const char* utf8name, + StaticMethodCallback method, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor StaticMethod( + Symbol name, + StaticVoidMethodCallback method, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor StaticMethod( + Symbol name, + StaticMethodCallback method, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor StaticMethod( + const char* utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor StaticMethod( + Symbol name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor StaticMethod( + const char* utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor StaticMethod( + Symbol name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor StaticAccessor( + const char* utf8name, + StaticGetterCallback getter, + StaticSetterCallback setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor StaticAccessor( + Symbol name, + StaticGetterCallback getter, + StaticSetterCallback setter, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor StaticAccessor( + const char* utf8name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + template + static PropertyDescriptor StaticAccessor( + Symbol name, + napi_property_attributes attributes = napi_default, + void* data = nullptr); + static PropertyDescriptor StaticValue( + const char* utf8name, + Napi::Value value, + napi_property_attributes attributes = napi_default); + static PropertyDescriptor StaticValue( + Symbol name, + Napi::Value value, + napi_property_attributes attributes = napi_default); + static Napi::Value OnCalledAsFunction(const Napi::CallbackInfo& callbackInfo); + virtual void Finalize(Napi::Env env); + + private: + using This = ObjectWrap; + + static napi_value ConstructorCallbackWrapper(napi_env env, + napi_callback_info info); + static napi_value StaticVoidMethodCallbackWrapper(napi_env env, + napi_callback_info info); + static napi_value StaticMethodCallbackWrapper(napi_env env, + napi_callback_info info); + static napi_value StaticGetterCallbackWrapper(napi_env env, + napi_callback_info info); + static napi_value StaticSetterCallbackWrapper(napi_env env, + napi_callback_info info); + static void FinalizeCallback(napi_env env, void* data, void* hint); + static Function DefineClass(Napi::Env env, + const char* utf8name, + const size_t props_count, + const napi_property_descriptor* props, + void* data = nullptr); + + using StaticVoidMethodCallbackData = + MethodCallbackData; + using StaticMethodCallbackData = MethodCallbackData; + + using StaticAccessorCallbackData = + AccessorCallbackData; + + template + static napi_value WrappedMethod(napi_env env, + napi_callback_info info) NAPI_NOEXCEPT; + + template + struct StaticSetterTag {}; + + template + static napi_callback WrapStaticSetter(StaticSetterTag) NAPI_NOEXCEPT { + return &This::WrappedMethod; + } + static napi_callback WrapStaticSetter(StaticSetterTag) + NAPI_NOEXCEPT { + return nullptr; + } + + bool _construction_failed = true; +}; + +class HandleScope { + public: + HandleScope(napi_env env, napi_handle_scope scope); + explicit HandleScope(Napi::Env env); + ~HandleScope(); + + // Disallow copying to prevent double close of napi_handle_scope + NAPI_DISALLOW_ASSIGN_COPY(HandleScope) + + operator napi_handle_scope() const; + + Napi::Env Env() const; + + private: + napi_env _env; + napi_handle_scope _scope; +}; + +class EscapableHandleScope { + public: + EscapableHandleScope(napi_env env, napi_escapable_handle_scope scope); + explicit EscapableHandleScope(Napi::Env env); + ~EscapableHandleScope(); + + // Disallow copying to prevent double close of napi_escapable_handle_scope + NAPI_DISALLOW_ASSIGN_COPY(EscapableHandleScope) + + operator napi_escapable_handle_scope() const; + + Napi::Env Env() const; + Value Escape(napi_value escapee); + + private: + napi_env _env; + napi_escapable_handle_scope _scope; +}; + +#if (NAPI_VERSION > 2) +class CallbackScope { + public: + CallbackScope(napi_env env, napi_callback_scope scope); + CallbackScope(napi_env env, napi_async_context context); + virtual ~CallbackScope(); + + // Disallow copying to prevent double close of napi_callback_scope + NAPI_DISALLOW_ASSIGN_COPY(CallbackScope) + + operator napi_callback_scope() const; + + Napi::Env Env() const; + + private: + napi_env _env; + napi_callback_scope _scope; +}; +#endif + +class AsyncContext { + public: + explicit AsyncContext(napi_env env, const char* resource_name); + explicit AsyncContext(napi_env env, + const char* resource_name, + const Object& resource); + virtual ~AsyncContext(); + + AsyncContext(AsyncContext&& other); + AsyncContext& operator=(AsyncContext&& other); + NAPI_DISALLOW_ASSIGN_COPY(AsyncContext) + + operator napi_async_context() const; + + Napi::Env Env() const; + + private: + napi_env _env; + napi_async_context _context; +}; + +#if NAPI_HAS_THREADS +class AsyncWorker { + public: + virtual ~AsyncWorker(); + + NAPI_DISALLOW_ASSIGN_COPY(AsyncWorker) + + operator napi_async_work() const; + + Napi::Env Env() const; + + void Queue(); + void Cancel(); + void SuppressDestruct(); + + ObjectReference& Receiver(); + FunctionReference& Callback(); + + virtual void OnExecute(Napi::Env env); + virtual void OnWorkComplete(Napi::Env env, napi_status status); + + protected: + explicit AsyncWorker(const Function& callback); + explicit AsyncWorker(const Function& callback, const char* resource_name); + explicit AsyncWorker(const Function& callback, + const char* resource_name, + const Object& resource); + explicit AsyncWorker(const Object& receiver, const Function& callback); + explicit AsyncWorker(const Object& receiver, + const Function& callback, + const char* resource_name); + explicit AsyncWorker(const Object& receiver, + const Function& callback, + const char* resource_name, + const Object& resource); + + explicit AsyncWorker(Napi::Env env); + explicit AsyncWorker(Napi::Env env, const char* resource_name); + explicit AsyncWorker(Napi::Env env, + const char* resource_name, + const Object& resource); + + virtual void Execute() = 0; + virtual void OnOK(); + virtual void OnError(const Error& e); + virtual void Destroy(); + virtual std::vector GetResult(Napi::Env env); + + void SetError(const std::string& error); + + private: + static inline void OnAsyncWorkExecute(napi_env env, void* asyncworker); + static inline void OnAsyncWorkComplete(napi_env env, + napi_status status, + void* asyncworker); + + napi_env _env; + napi_async_work _work; + ObjectReference _receiver; + FunctionReference _callback; + std::string _error; + bool _suppress_destruct; +}; +#endif // NAPI_HAS_THREADS + +#if (NAPI_VERSION > 3 && NAPI_HAS_THREADS) +class ThreadSafeFunction { + public: + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + Finalizer finalizeCallback); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + Finalizer finalizeCallback, + FinalizerDataType* data); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + Finalizer finalizeCallback); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + Finalizer finalizeCallback, + FinalizerDataType* data); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback); + + // This API may only be called from the main thread. + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data); + + ThreadSafeFunction(); + ThreadSafeFunction(napi_threadsafe_function tsFunctionValue); + + operator napi_threadsafe_function() const; + + // This API may be called from any thread. + napi_status BlockingCall() const; + + // This API may be called from any thread. + template + napi_status BlockingCall(Callback callback) const; + + // This API may be called from any thread. + template + napi_status BlockingCall(DataType* data, Callback callback) const; + + // This API may be called from any thread. + napi_status NonBlockingCall() const; + + // This API may be called from any thread. + template + napi_status NonBlockingCall(Callback callback) const; + + // This API may be called from any thread. + template + napi_status NonBlockingCall(DataType* data, Callback callback) const; + + // This API may only be called from the main thread. + void Ref(napi_env env) const; + + // This API may only be called from the main thread. + void Unref(napi_env env) const; + + // This API may be called from any thread. + napi_status Acquire() const; + + // This API may be called from any thread. + napi_status Release() const; + + // This API may be called from any thread. + napi_status Abort() const; + + struct ConvertibleContext { + template + operator T*() { + return static_cast(context); + } + void* context; + }; + + // This API may be called from any thread. + ConvertibleContext GetContext() const; + + private: + using CallbackWrapper = std::function; + + template + static ThreadSafeFunction New(napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data, + napi_finalize wrapper); + + napi_status CallInternal(CallbackWrapper* callbackWrapper, + napi_threadsafe_function_call_mode mode) const; + + static void CallJS(napi_env env, + napi_value jsCallback, + void* context, + void* data); + + napi_threadsafe_function _tsfn; +}; + +// A TypedThreadSafeFunction by default has no context (nullptr) and can +// accept any type (void) to its CallJs. +template +class TypedThreadSafeFunction { + public: + // This API may only be called from the main thread. + // Helper function that returns nullptr if running Node-API 5+, otherwise a + // non-empty, no-op Function. This provides the ability to specify at + // compile-time a callback parameter to `New` that safely does no action + // when targeting _any_ Node-API version. +#if NAPI_VERSION > 4 + static std::nullptr_t EmptyFunctionFactory(Napi::Env env); +#else + static Napi::Function EmptyFunctionFactory(Napi::Env env); +#endif + static Napi::Function FunctionOrEmpty(Napi::Env env, + Napi::Function& callback); + +#if NAPI_VERSION > 4 + // This API may only be called from the main thread. + // Creates a new threadsafe function with: + // Callback [missing] Resource [missing] Finalizer [missing] + template + static TypedThreadSafeFunction New( + napi_env env, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context = nullptr); + + // This API may only be called from the main thread. + // Creates a new threadsafe function with: + // Callback [missing] Resource [passed] Finalizer [missing] + template + static TypedThreadSafeFunction New( + napi_env env, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context = nullptr); + + // This API may only be called from the main thread. + // Creates a new threadsafe function with: + // Callback [missing] Resource [missing] Finalizer [passed] + template + static TypedThreadSafeFunction New( + napi_env env, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data = nullptr); + + // This API may only be called from the main thread. + // Creates a new threadsafe function with: + // Callback [missing] Resource [passed] Finalizer [passed] + template + static TypedThreadSafeFunction New( + napi_env env, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data = nullptr); +#endif + + // This API may only be called from the main thread. + // Creates a new threadsafe function with: + // Callback [passed] Resource [missing] Finalizer [missing] + template + static TypedThreadSafeFunction New( + napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context = nullptr); + + // This API may only be called from the main thread. + // Creates a new threadsafe function with: + // Callback [passed] Resource [passed] Finalizer [missing] + template + static TypedThreadSafeFunction New( + napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context = nullptr); + + // This API may only be called from the main thread. + // Creates a new threadsafe function with: + // Callback [passed] Resource [missing] Finalizer [passed] + template + static TypedThreadSafeFunction New( + napi_env env, + const Function& callback, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data = nullptr); + + // This API may only be called from the main thread. + // Creates a new threadsafe function with: + // Callback [passed] Resource [passed] Finalizer [passed] + template + static TypedThreadSafeFunction New( + napi_env env, + CallbackType callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data = nullptr); + + TypedThreadSafeFunction(); + TypedThreadSafeFunction(napi_threadsafe_function tsFunctionValue); + + operator napi_threadsafe_function() const; + + // This API may be called from any thread. + napi_status BlockingCall(DataType* data = nullptr) const; + + // This API may be called from any thread. + napi_status NonBlockingCall(DataType* data = nullptr) const; + + // This API may only be called from the main thread. + void Ref(napi_env env) const; + + // This API may only be called from the main thread. + void Unref(napi_env env) const; + + // This API may be called from any thread. + napi_status Acquire() const; + + // This API may be called from any thread. + napi_status Release() const; + + // This API may be called from any thread. + napi_status Abort() const; + + // This API may be called from any thread. + ContextType* GetContext() const; + + private: + template + static TypedThreadSafeFunction New( + napi_env env, + const Function& callback, + const Object& resource, + ResourceString resourceName, + size_t maxQueueSize, + size_t initialThreadCount, + ContextType* context, + Finalizer finalizeCallback, + FinalizerDataType* data, + napi_finalize wrapper); + + static void CallJsInternal(napi_env env, + napi_value jsCallback, + void* context, + void* data); + + protected: + napi_threadsafe_function _tsfn; +}; +template +class AsyncProgressWorkerBase : public AsyncWorker { + public: + virtual void OnWorkProgress(DataType* data) = 0; + class ThreadSafeData { + public: + ThreadSafeData(AsyncProgressWorkerBase* asyncprogressworker, DataType* data) + : _asyncprogressworker(asyncprogressworker), _data(data) {} + + AsyncProgressWorkerBase* asyncprogressworker() { + return _asyncprogressworker; + }; + DataType* data() { return _data; }; + + private: + AsyncProgressWorkerBase* _asyncprogressworker; + DataType* _data; + }; + void OnWorkComplete(Napi::Env env, napi_status status) override; + + protected: + explicit AsyncProgressWorkerBase(const Object& receiver, + const Function& callback, + const char* resource_name, + const Object& resource, + size_t queue_size = 1); + virtual ~AsyncProgressWorkerBase(); + +// Optional callback of Napi::ThreadSafeFunction only available after +// NAPI_VERSION 4. Refs: https://github.com/nodejs/node/pull/27791 +#if NAPI_VERSION > 4 + explicit AsyncProgressWorkerBase(Napi::Env env, + const char* resource_name, + const Object& resource, + size_t queue_size = 1); +#endif + + static inline void OnAsyncWorkProgress(Napi::Env env, + Napi::Function jsCallback, + void* data); + + napi_status NonBlockingCall(DataType* data); + + private: + ThreadSafeFunction _tsfn; + bool _work_completed = false; + napi_status _complete_status; + static inline void OnThreadSafeFunctionFinalize( + Napi::Env env, void* data, AsyncProgressWorkerBase* context); +}; + +template +class AsyncProgressWorker : public AsyncProgressWorkerBase { + public: + virtual ~AsyncProgressWorker(); + + class ExecutionProgress { + friend class AsyncProgressWorker; + + public: + void Signal() const; + void Send(const T* data, size_t count) const; + + private: + explicit ExecutionProgress(AsyncProgressWorker* worker) : _worker(worker) {} + AsyncProgressWorker* const _worker; + }; + + void OnWorkProgress(void*) override; + + protected: + explicit AsyncProgressWorker(const Function& callback); + explicit AsyncProgressWorker(const Function& callback, + const char* resource_name); + explicit AsyncProgressWorker(const Function& callback, + const char* resource_name, + const Object& resource); + explicit AsyncProgressWorker(const Object& receiver, + const Function& callback); + explicit AsyncProgressWorker(const Object& receiver, + const Function& callback, + const char* resource_name); + explicit AsyncProgressWorker(const Object& receiver, + const Function& callback, + const char* resource_name, + const Object& resource); + +// Optional callback of Napi::ThreadSafeFunction only available after +// NAPI_VERSION 4. Refs: https://github.com/nodejs/node/pull/27791 +#if NAPI_VERSION > 4 + explicit AsyncProgressWorker(Napi::Env env); + explicit AsyncProgressWorker(Napi::Env env, const char* resource_name); + explicit AsyncProgressWorker(Napi::Env env, + const char* resource_name, + const Object& resource); +#endif + virtual void Execute(const ExecutionProgress& progress) = 0; + virtual void OnProgress(const T* data, size_t count) = 0; + + private: + void Execute() override; + void Signal(); + void SendProgress_(const T* data, size_t count); + + std::mutex _mutex; + T* _asyncdata; + size_t _asyncsize; + bool _signaled; +}; + +template +class AsyncProgressQueueWorker + : public AsyncProgressWorkerBase> { + public: + virtual ~AsyncProgressQueueWorker(){}; + + class ExecutionProgress { + friend class AsyncProgressQueueWorker; + + public: + void Signal() const; + void Send(const T* data, size_t count) const; + + private: + explicit ExecutionProgress(AsyncProgressQueueWorker* worker) + : _worker(worker) {} + AsyncProgressQueueWorker* const _worker; + }; + + void OnWorkComplete(Napi::Env env, napi_status status) override; + void OnWorkProgress(std::pair*) override; + + protected: + explicit AsyncProgressQueueWorker(const Function& callback); + explicit AsyncProgressQueueWorker(const Function& callback, + const char* resource_name); + explicit AsyncProgressQueueWorker(const Function& callback, + const char* resource_name, + const Object& resource); + explicit AsyncProgressQueueWorker(const Object& receiver, + const Function& callback); + explicit AsyncProgressQueueWorker(const Object& receiver, + const Function& callback, + const char* resource_name); + explicit AsyncProgressQueueWorker(const Object& receiver, + const Function& callback, + const char* resource_name, + const Object& resource); + +// Optional callback of Napi::ThreadSafeFunction only available after +// NAPI_VERSION 4. Refs: https://github.com/nodejs/node/pull/27791 +#if NAPI_VERSION > 4 + explicit AsyncProgressQueueWorker(Napi::Env env); + explicit AsyncProgressQueueWorker(Napi::Env env, const char* resource_name); + explicit AsyncProgressQueueWorker(Napi::Env env, + const char* resource_name, + const Object& resource); +#endif + virtual void Execute(const ExecutionProgress& progress) = 0; + virtual void OnProgress(const T* data, size_t count) = 0; + + private: + void Execute() override; + void Signal() const; + void SendProgress_(const T* data, size_t count); +}; +#endif // NAPI_VERSION > 3 && NAPI_HAS_THREADS + +// Memory management. +class MemoryManagement { + public: + static int64_t AdjustExternalMemory(Env env, int64_t change_in_bytes); +}; + +// Version management +class VersionManagement { + public: + static uint32_t GetNapiVersion(Env env); + static const napi_node_version* GetNodeVersion(Env env); +}; + +#if NAPI_VERSION > 5 +template +class Addon : public InstanceWrap { + public: + static inline Object Init(Env env, Object exports); + static T* Unwrap(Object wrapper); + + protected: + using AddonProp = ClassPropertyDescriptor; + void DefineAddon(Object exports, + const std::initializer_list& props); + Napi::Object DefineProperties(Object object, + const std::initializer_list& props); + + private: + Object entry_point_; +}; +#endif // NAPI_VERSION > 5 + +#ifdef NAPI_CPP_CUSTOM_NAMESPACE +} // namespace NAPI_CPP_CUSTOM_NAMESPACE +#endif + +} // namespace Napi + +// Inline implementations of all the above class methods are included here. +#include "napi-inl.h" + +#endif // SRC_NAPI_H_ diff --git a/services/edge-agent/node_modules/node-addon-api/node_addon_api.gyp b/services/edge-agent/node_modules/node-addon-api/node_addon_api.gyp new file mode 100644 index 00000000..29905ed4 --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/node_addon_api.gyp @@ -0,0 +1,32 @@ +{ + 'targets': [ + { + 'target_name': 'node_addon_api', + 'type': 'none', + 'sources': [ 'napi.h', 'napi-inl.h' ], + 'direct_dependent_settings': { + 'include_dirs': [ '.' ], + 'includes': ['noexcept.gypi'], + } + }, + { + 'target_name': 'node_addon_api_except', + 'type': 'none', + 'sources': [ 'napi.h', 'napi-inl.h' ], + 'direct_dependent_settings': { + 'include_dirs': [ '.' ], + 'includes': ['except.gypi'], + } + }, + { + 'target_name': 'node_addon_api_maybe', + 'type': 'none', + 'sources': [ 'napi.h', 'napi-inl.h' ], + 'direct_dependent_settings': { + 'include_dirs': [ '.' ], + 'includes': ['noexcept.gypi'], + 'defines': ['NODE_ADDON_API_ENABLE_MAYBE'] + } + }, + ] +} diff --git a/services/edge-agent/node_modules/node-addon-api/node_api.gyp b/services/edge-agent/node_modules/node-addon-api/node_api.gyp new file mode 100644 index 00000000..4ff0ae7d --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/node_api.gyp @@ -0,0 +1,9 @@ +{ + 'targets': [ + { + 'target_name': 'nothing', + 'type': 'static_library', + 'sources': [ 'nothing.c' ] + } + ] +} diff --git a/services/edge-agent/node_modules/node-addon-api/noexcept.gypi b/services/edge-agent/node_modules/node-addon-api/noexcept.gypi new file mode 100644 index 00000000..404a05f3 --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/noexcept.gypi @@ -0,0 +1,26 @@ +{ + 'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ], + 'cflags': [ '-fno-exceptions' ], + 'cflags_cc': [ '-fno-exceptions' ], + 'conditions': [ + ["OS=='win'", { + # _HAS_EXCEPTIONS is already defined and set to 0 in common.gypi + #"defines": [ + # "_HAS_EXCEPTIONS=0" + #], + "msvs_settings": { + "VCCLCompilerTool": { + 'ExceptionHandling': 0, + 'EnablePREfast': 'true', + }, + }, + }], + ["OS=='mac'", { + 'xcode_settings': { + 'CLANG_CXX_LIBRARY': 'libc++', + 'MACOSX_DEPLOYMENT_TARGET': '10.7', + 'GCC_ENABLE_CPP_EXCEPTIONS': 'NO', + }, + }], + ], +} diff --git a/services/edge-agent/node_modules/node-addon-api/nothing.c b/services/edge-agent/node_modules/node-addon-api/nothing.c new file mode 100644 index 00000000..e69de29b diff --git a/services/edge-agent/node_modules/node-addon-api/package-support.json b/services/edge-agent/node_modules/node-addon-api/package-support.json new file mode 100644 index 00000000..10d3607a --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/package-support.json @@ -0,0 +1,21 @@ +{ + "versions": [ + { + "version": "*", + "target": { + "node": "active" + }, + "response": { + "type": "time-permitting", + "paid": false, + "contact": { + "name": "node-addon-api team", + "url": "https://github.com/nodejs/node-addon-api/issues" + } + }, + "backing": [ { "project": "https://github.com/nodejs" }, + { "foundation": "https://openjsf.org/" } + ] + } + ] +} diff --git a/services/edge-agent/node_modules/node-addon-api/package.json b/services/edge-agent/node_modules/node-addon-api/package.json new file mode 100644 index 00000000..d772ddc9 --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/package.json @@ -0,0 +1,480 @@ +{ + "bugs": { + "url": "https://github.com/nodejs/node-addon-api/issues" + }, + "contributors": [ + { + "name": "Abhishek Kumar Singh", + "url": "https://github.com/abhi11210646" + }, + { + "name": "Alba Mendez", + "url": "https://github.com/jmendeth" + }, + { + "name": "Alexander Floh", + "url": "https://github.com/alexanderfloh" + }, + { + "name": "Ammar Faizi", + "url": "https://github.com/ammarfaizi2" + }, + { + "name": "András Timár, Dr", + "url": "https://github.com/timarandras" + }, + { + "name": "Andrew Petersen", + "url": "https://github.com/kirbysayshi" + }, + { + "name": "Anisha Rohra", + "url": "https://github.com/anisha-rohra" + }, + { + "name": "Anna Henningsen", + "url": "https://github.com/addaleax" + }, + { + "name": "Arnaud Botella", + "url": "https://github.com/BotellaA" + }, + { + "name": "Arunesh Chandra", + "url": "https://github.com/aruneshchandra" + }, + { + "name": "Azlan Mukhtar", + "url": "https://github.com/azlan" + }, + { + "name": "Ben Berman", + "url": "https://github.com/rivertam" + }, + { + "name": "Benjamin Byholm", + "url": "https://github.com/kkoopa" + }, + { + "name": "Bill Gallafent", + "url": "https://github.com/gallafent" + }, + { + "name": "blagoev", + "url": "https://github.com/blagoev" + }, + { + "name": "Bruce A. MacNaughton", + "url": "https://github.com/bmacnaughton" + }, + { + "name": "Cory Mickelson", + "url": "https://github.com/corymickelson" + }, + { + "name": "Daniel Bevenius", + "url": "https://github.com/danbev" + }, + { + "name": "Dante Calderón", + "url": "https://github.com/dantehemerson" + }, + { + "name": "Darshan Sen", + "url": "https://github.com/RaisinTen" + }, + { + "name": "David Halls", + "url": "https://github.com/davedoesdev" + }, + { + "name": "Deepak Rajamohan", + "url": "https://github.com/deepakrkris" + }, + { + "name": "Dmitry Ashkadov", + "url": "https://github.com/dmitryash" + }, + { + "name": "Dongjin Na", + "url": "https://github.com/nadongguri" + }, + { + "name": "Doni Rubiagatra", + "url": "https://github.com/rubiagatra" + }, + { + "name": "Eric Bickle", + "url": "https://github.com/ebickle" + }, + { + "name": "extremeheat", + "url": "https://github.com/extremeheat" + }, + { + "name": "Feng Yu", + "url": "https://github.com/F3n67u" + }, + { + "name": "Ferdinand Holzer", + "url": "https://github.com/fholzer" + }, + { + "name": "Gabriel Schulhof", + "url": "https://github.com/gabrielschulhof" + }, + { + "name": "Guenter Sandner", + "url": "https://github.com/gms1" + }, + { + "name": "Gus Caplan", + "url": "https://github.com/devsnek" + }, + { + "name": "Helio Frota", + "url": "https://github.com/helio-frota" + }, + { + "name": "Hitesh Kanwathirtha", + "url": "https://github.com/digitalinfinity" + }, + { + "name": "ikokostya", + "url": "https://github.com/ikokostya" + }, + { + "name": "Jack Xia", + "url": "https://github.com/JckXia" + }, + { + "name": "Jake Barnes", + "url": "https://github.com/DuBistKomisch" + }, + { + "name": "Jake Yoon", + "url": "https://github.com/yjaeseok" + }, + { + "name": "Jason Ginchereau", + "url": "https://github.com/jasongin" + }, + { + "name": "Jenny", + "url": "https://github.com/egg-bread" + }, + { + "name": "Jeroen Janssen", + "url": "https://github.com/japj" + }, + { + "name": "Jim Schlight", + "url": "https://github.com/jschlight" + }, + { + "name": "Jinho Bang", + "url": "https://github.com/romandev" + }, + { + "name": "José Expósito", + "url": "https://github.com/JoseExposito" + }, + { + "name": "joshgarde", + "url": "https://github.com/joshgarde" + }, + { + "name": "Julian Mesa", + "url": "https://github.com/julianmesa-gitkraken" + }, + { + "name": "Kasumi Hanazuki", + "url": "https://github.com/hanazuki" + }, + { + "name": "Kelvin", + "url": "https://github.com/kelvinhammond" + }, + { + "name": "Kevin Eady", + "url": "https://github.com/KevinEady" + }, + { + "name": "Kévin VOYER", + "url": "https://github.com/kecsou" + }, + { + "name": "kidneysolo", + "url": "https://github.com/kidneysolo" + }, + { + "name": "Koki Nishihara", + "url": "https://github.com/Nishikoh" + }, + { + "name": "Konstantin Tarkus", + "url": "https://github.com/koistya" + }, + { + "name": "Kyle Farnung", + "url": "https://github.com/kfarnung" + }, + { + "name": "Kyle Kovacs", + "url": "https://github.com/nullromo" + }, + { + "name": "legendecas", + "url": "https://github.com/legendecas" + }, + { + "name": "LongYinan", + "url": "https://github.com/Brooooooklyn" + }, + { + "name": "Lovell Fuller", + "url": "https://github.com/lovell" + }, + { + "name": "Luciano Martorella", + "url": "https://github.com/lmartorella" + }, + { + "name": "mastergberry", + "url": "https://github.com/mastergberry" + }, + { + "name": "Mathias Küsel", + "url": "https://github.com/mathiask88" + }, + { + "name": "Mathias Stearn", + "url": "https://github.com/RedBeard0531" + }, + { + "name": "Matteo Collina", + "url": "https://github.com/mcollina" + }, + { + "name": "Michael Dawson", + "url": "https://github.com/mhdawson" + }, + { + "name": "Michael Price", + "url": "https://github.com/mikepricedev" + }, + { + "name": "Michele Campus", + "url": "https://github.com/kYroL01" + }, + { + "name": "Mikhail Cheshkov", + "url": "https://github.com/mcheshkov" + }, + { + "name": "nempoBu4", + "url": "https://github.com/nempoBu4" + }, + { + "name": "Nicola Del Gobbo", + "url": "https://github.com/NickNaso" + }, + { + "name": "Nick Soggin", + "url": "https://github.com/iSkore" + }, + { + "name": "Nikolai Vavilov", + "url": "https://github.com/seishun" + }, + { + "name": "Nurbol Alpysbayev", + "url": "https://github.com/anurbol" + }, + { + "name": "pacop", + "url": "https://github.com/pacop" + }, + { + "name": "Peter Šándor", + "url": "https://github.com/petersandor" + }, + { + "name": "Philipp Renoth", + "url": "https://github.com/DaAitch" + }, + { + "name": "rgerd", + "url": "https://github.com/rgerd" + }, + { + "name": "Richard Lau", + "url": "https://github.com/richardlau" + }, + { + "name": "Rolf Timmermans", + "url": "https://github.com/rolftimmermans" + }, + { + "name": "Ross Weir", + "url": "https://github.com/ross-weir" + }, + { + "name": "Ryuichi Okumura", + "url": "https://github.com/okuryu" + }, + { + "name": "Saint Gabriel", + "url": "https://github.com/chineduG" + }, + { + "name": "Sampson Gao", + "url": "https://github.com/sampsongao" + }, + { + "name": "Sam Roberts", + "url": "https://github.com/sam-github" + }, + { + "name": "strager", + "url": "https://github.com/strager" + }, + { + "name": "Taylor Woll", + "url": "https://github.com/boingoing" + }, + { + "name": "Thomas Gentilhomme", + "url": "https://github.com/fraxken" + }, + { + "name": "Tim Rach", + "url": "https://github.com/timrach" + }, + { + "name": "Tobias Nießen", + "url": "https://github.com/tniessen" + }, + { + "name": "todoroff", + "url": "https://github.com/todoroff" + }, + { + "name": "Toyo Li", + "url": "https://github.com/toyobayashi" + }, + { + "name": "Tux3", + "url": "https://github.com/tux3" + }, + { + "name": "Vlad Velmisov", + "url": "https://github.com/Velmisov" + }, + { + "name": "Vladimir Morozov", + "url": "https://github.com/vmoroz" + + }, + { + "name": "WenheLI", + "url": "https://github.com/WenheLI" + }, + { + "name": "Xuguang Mei", + "url": "https://github.com/meixg" + }, + { + "name": "Yohei Kishimoto", + "url": "https://github.com/morokosi" + }, + { + "name": "Yulong Wang", + "url": "https://github.com/fs-eire" + }, + { + "name": "Ziqiu Zhao", + "url": "https://github.com/ZzqiZQute" + }, + { + "name": "Feng Yu", + "url": "https://github.com/F3n67u" + }, + { + "name": "wanlu wang", + "url": "https://github.com/wanlu" + }, + { + "name": "Caleb Hearon", + "url": "https://github.com/chearon" + }, + { + "name": "Marx", + "url": "https://github.com/MarxJiao" + }, + { + "name": "Ömer AKGÜL", + "url": "https://github.com/tuhalf" + } + ], + "description": "Node.js API (Node-API)", + "devDependencies": { + "benchmark": "^2.1.4", + "bindings": "^1.5.0", + "clang-format": "^1.4.0", + "eslint": "^7.32.0", + "eslint-config-semistandard": "^16.0.0", + "eslint-config-standard": "^16.0.3", + "eslint-plugin-import": "^2.24.2", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^5.1.0", + "fs-extra": "^11.1.1", + "path": "^0.12.7", + "pre-commit": "^1.2.2", + "safe-buffer": "^5.1.1" + }, + "directories": {}, + "gypfile": false, + "homepage": "https://github.com/nodejs/node-addon-api", + "keywords": [ + "n-api", + "napi", + "addon", + "native", + "bindings", + "c", + "c++", + "nan", + "node-addon-api" + ], + "license": "MIT", + "main": "index.js", + "name": "node-addon-api", + "readme": "README.md", + "repository": { + "type": "git", + "url": "git://github.com/nodejs/node-addon-api.git" + }, + "files": [ + "*.{c,h,gyp,gypi}", + "package-support.json", + "tools/" + ], + "scripts": { + "prebenchmark": "node-gyp rebuild -C benchmark", + "benchmark": "node benchmark", + "pretest": "node-gyp rebuild -C test", + "test": "node test", + "test:debug": "node-gyp rebuild -C test --debug && NODE_API_BUILD_CONFIG=Debug node ./test/index.js", + "predev": "node-gyp rebuild -C test --debug", + "dev": "node test", + "predev:incremental": "node-gyp configure build -C test --debug", + "dev:incremental": "node test", + "doc": "doxygen doc/Doxyfile", + "lint": "node tools/eslint-format && node tools/clang-format", + "lint:fix": "node tools/clang-format --fix && node tools/eslint-format --fix" + }, + "pre-commit": "lint", + "version": "7.1.1", + "support": true +} diff --git a/services/edge-agent/node_modules/node-addon-api/tools/README.md b/services/edge-agent/node_modules/node-addon-api/tools/README.md new file mode 100644 index 00000000..6b80e94f --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/tools/README.md @@ -0,0 +1,73 @@ +# Tools + +## clang-format + +The clang-format checking tools is designed to check changed lines of code compared to given git-refs. + +## Migration Script + +The migration tool is designed to reduce repetitive work in the migration process. However, the script is not aiming to convert every thing for you. There are usually some small fixes and major reconstruction required. + +### How To Use + +To run the conversion script, first make sure you have the latest `node-addon-api` in your `node_modules` directory. +``` +npm install node-addon-api +``` + +Then run the script passing your project directory +``` +node ./node_modules/node-addon-api/tools/conversion.js ./ +``` + +After finish, recompile and debug things that are missed by the script. + + +### Quick Fixes +Here is the list of things that can be fixed easily. + 1. Change your methods' return value to void if it doesn't return value to JavaScript. + 2. Use `.` to access attribute or to invoke member function in Napi::Object instead of `->`. + 3. `Napi::New(env, value);` to `Napi::[Type]::New(env, value); + + +### Major Reconstructions +The implementation of `Napi::ObjectWrap` is significantly different from NAN's. `Napi::ObjectWrap` takes a pointer to the wrapped object and creates a reference to the wrapped object inside ObjectWrap constructor. `Napi::ObjectWrap` also associates wrapped object's instance methods to Javascript module instead of static methods like NAN. + +So if you use Nan::ObjectWrap in your module, you will need to execute the following steps. + + 1. Convert your [ClassName]::New function to a constructor function that takes a `Napi::CallbackInfo`. Declare it as +``` +[ClassName](const Napi::CallbackInfo& info); +``` +and define it as +``` +[ClassName]::[ClassName](const Napi::CallbackInfo& info) : Napi::ObjectWrap<[ClassName]>(info){ + ... +} +``` +This way, the `Napi::ObjectWrap` constructor will be invoked after the object has been instantiated and `Napi::ObjectWrap` can use the `this` pointer to create a reference to the wrapped object. + + 2. Move your original constructor code into the new constructor. Delete your original constructor. + 3. In your class initialization function, associate native methods in the following way. +``` +Napi::FunctionReference constructor; + +void [ClassName]::Init(Napi::Env env, Napi::Object exports, Napi::Object module) { + Napi::HandleScope scope(env); + Napi::Function ctor = DefineClass(env, "Canvas", { + InstanceMethod<&[ClassName]::Func1>("Func1"), + InstanceMethod<&[ClassName]::Func2>("Func2"), + InstanceAccessor<&[ClassName]::ValueGetter>("Value"), + StaticMethod<&[ClassName]::StaticMethod>("MethodName"), + InstanceValue("Value", Napi::[Type]::New(env, value)), + }); + + constructor = Napi::Persistent(ctor); + constructor .SuppressDestruct(); + exports.Set("[ClassName]", ctor); +} +``` + 4. In function where you need to Unwrap the ObjectWrap in NAN like `[ClassName]* native = Nan::ObjectWrap::Unwrap<[ClassName]>(info.This());`, use `this` pointer directly as the unwrapped object as each ObjectWrap instance is associated with a unique object instance. + + +If you still find issues after following this guide, please leave us an issue describing your problem and we will try to resolve it. diff --git a/services/edge-agent/node_modules/node-addon-api/tools/check-napi.js b/services/edge-agent/node_modules/node-addon-api/tools/check-napi.js new file mode 100644 index 00000000..9199af33 --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/tools/check-napi.js @@ -0,0 +1,99 @@ +'use strict'; +// Descend into a directory structure and, for each file matching *.node, output +// based on the imports found in the file whether it's an N-API module or not. + +const fs = require('fs'); +const path = require('path'); + +// Read the output of the command, break it into lines, and use the reducer to +// decide whether the file is an N-API module or not. +function checkFile (file, command, argv, reducer) { + const child = require('child_process').spawn(command, argv, { + stdio: ['inherit', 'pipe', 'inherit'] + }); + let leftover = ''; + let isNapi; + child.stdout.on('data', (chunk) => { + if (isNapi === undefined) { + chunk = (leftover + chunk.toString()).split(/[\r\n]+/); + leftover = chunk.pop(); + isNapi = chunk.reduce(reducer, isNapi); + if (isNapi !== undefined) { + child.kill(); + } + } + }); + child.on('close', (code, signal) => { + if ((code === null && signal !== null) || (code !== 0)) { + console.log( + command + ' exited with code: ' + code + ' and signal: ' + signal); + } else { + // Green if it's a N-API module, red otherwise. + console.log( + '\x1b[' + (isNapi ? '42' : '41') + 'm' + + (isNapi ? ' N-API' : 'Not N-API') + + '\x1b[0m: ' + file); + } + }); +} + +// Use nm -a to list symbols. +function checkFileUNIX (file) { + checkFile(file, 'nm', ['-a', file], (soFar, line) => { + if (soFar === undefined) { + line = line.match(/([0-9a-f]*)? ([a-zA-Z]) (.*$)/); + if (line[2] === 'U') { + if (/^napi/.test(line[3])) { + soFar = true; + } + } + } + return soFar; + }); +} + +// Use dumpbin /imports to list symbols. +function checkFileWin32 (file) { + checkFile(file, 'dumpbin', ['/imports', file], (soFar, line) => { + if (soFar === undefined) { + line = line.match(/([0-9a-f]*)? +([a-zA-Z0-9]) (.*$)/); + if (line && /^napi/.test(line[line.length - 1])) { + soFar = true; + } + } + return soFar; + }); +} + +// Descend into a directory structure and pass each file ending in '.node' to +// one of the above checks, depending on the OS. +function recurse (top) { + fs.readdir(top, (error, items) => { + if (error) { + throw new Error('error reading directory ' + top + ': ' + error); + } + items.forEach((item) => { + item = path.join(top, item); + fs.stat(item, ((item) => (error, stats) => { + if (error) { + throw new Error('error about ' + item + ': ' + error); + } + if (stats.isDirectory()) { + recurse(item); + } else if (/[.]node$/.test(item) && + // Explicitly ignore files called 'nothing.node' because they are + // artefacts of node-addon-api having identified a version of + // Node.js that ships with a correct implementation of N-API. + path.basename(item) !== 'nothing.node') { + process.platform === 'win32' + ? checkFileWin32(item) + : checkFileUNIX(item); + } + })(item)); + }); + }); +} + +// Start with the directory given on the command line or the current directory +// if nothing was given. +recurse(process.argv.length > 3 ? process.argv[2] : '.'); diff --git a/services/edge-agent/node_modules/node-addon-api/tools/clang-format.js b/services/edge-agent/node_modules/node-addon-api/tools/clang-format.js new file mode 100644 index 00000000..e4bb4f52 --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/tools/clang-format.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +const spawn = require('child_process').spawnSync; +const path = require('path'); + +const filesToCheck = ['*.h', '*.cc']; +const FORMAT_START = process.env.FORMAT_START || 'main'; + +function main (args) { + let fix = false; + while (args.length > 0) { + switch (args[0]) { + case '-f': + case '--fix': + fix = true; + break; + default: + } + args.shift(); + } + + const clangFormatPath = path.dirname(require.resolve('clang-format')); + const binary = process.platform === 'win32' + ? 'node_modules\\.bin\\clang-format.cmd' + : 'node_modules/.bin/clang-format'; + const options = ['--binary=' + binary, '--style=file']; + if (fix) { + options.push(FORMAT_START); + } else { + options.push('--diff', FORMAT_START); + } + + const gitClangFormatPath = path.join(clangFormatPath, 'bin/git-clang-format'); + const result = spawn( + 'python', + [gitClangFormatPath, ...options, '--', ...filesToCheck], + { encoding: 'utf-8' } + ); + + if (result.stderr) { + console.error('Error running git-clang-format:', result.stderr); + return 2; + } + + const clangFormatOutput = result.stdout.trim(); + // Bail fast if in fix mode. + if (fix) { + console.log(clangFormatOutput); + return 0; + } + // Detect if there is any complains from clang-format + if ( + clangFormatOutput !== '' && + clangFormatOutput !== 'no modified files to format' && + clangFormatOutput !== 'clang-format did not modify any files' + ) { + console.error(clangFormatOutput); + const fixCmd = 'npm run lint:fix'; + console.error(` + ERROR: please run "${fixCmd}" to format changes in your commit + Note that when running the command locally, please keep your local + main branch and working branch up to date with nodejs/node-addon-api + to exclude un-related complains. + Or you can run "env FORMAT_START=upstream/main ${fixCmd}".`); + return 1; + } +} + +if (require.main === module) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/services/edge-agent/node_modules/node-addon-api/tools/conversion.js b/services/edge-agent/node_modules/node-addon-api/tools/conversion.js new file mode 100644 index 00000000..f89245ac --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/tools/conversion.js @@ -0,0 +1,301 @@ +#! /usr/bin/env node + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const args = process.argv.slice(2); +const dir = args[0]; +if (!dir) { + console.log('Usage: node ' + path.basename(__filename) + ' '); + process.exit(1); +} + +const NodeApiVersion = require('../package.json').version; + +const disable = args[1]; +let ConfigFileOperations; +if (disable !== '--disable' && dir !== '--disable') { + ConfigFileOperations = { + 'package.json': [ + [/([ ]*)"dependencies": {/g, '$1"dependencies": {\n$1 "node-addon-api": "' + NodeApiVersion + '",'], + [/[ ]*"nan": *"[^"]+"(,|)[\n\r]/g, ''] + ], + 'binding.gyp': [ + [/([ ]*)'include_dirs': \[/g, '$1\'include_dirs\': [\n$1 \'\s+(\w+)\s*=\s*Nan::New\([\w\d:]+\);(?:\w+->Reset\(\1\))?\s+\1->SetClassName\(Nan::String::New\("(\w+)"\)\);/g, 'Napi::Function $1 = DefineClass(env, "$2", {'], + [/Local\s+(\w+)\s*=\s*Nan::New\([\w\d:]+\);\s+(\w+)\.Reset\((\1)\);\s+\1->SetClassName\((Nan::String::New|Nan::New<(v8::)*String>)\("(.+?)"\)\);/g, 'Napi::Function $1 = DefineClass(env, "$6", {'], + [/Local\s+(\w+)\s*=\s*Nan::New\([\w\d:]+\);(?:\w+->Reset\(\1\))?\s+\1->SetClassName\(Nan::String::New\("(\w+)"\)\);/g, 'Napi::Function $1 = DefineClass(env, "$2", {'], + [/Nan::New\(([\w\d:]+)\)->GetFunction\(\)/g, 'Napi::Function::New(env, $1)'], + [/Nan::New\(([\w\d:]+)\)->GetFunction()/g, 'Napi::Function::New(env, $1);'], + [/Nan::New\(([\w\d:]+)\)/g, 'Napi::Function::New(env, $1)'], + [/Nan::New\(([\w\d:]+)\)/g, 'Napi::Function::New(env, $1)'], + + // FunctionTemplate to FunctionReference + [/Nan::Persistent<(v8::)*FunctionTemplate>/g, 'Napi::FunctionReference'], + [/Nan::Persistent<(v8::)*Function>/g, 'Napi::FunctionReference'], + [/v8::Local/g, 'Napi::FunctionReference'], + [/Local/g, 'Napi::FunctionReference'], + [/v8::FunctionTemplate/g, 'Napi::FunctionReference'], + [/FunctionTemplate/g, 'Napi::FunctionReference'], + + [/([ ]*)Nan::SetPrototypeMethod\(\w+, "(\w+)", (\w+)\);/g, '$1InstanceMethod("$2", &$3),'], + [/([ ]*)(?:\w+\.Reset\(\w+\);\s+)?\(target\)\.Set\("(\w+)",\s*Nan::GetFunction\((\w+)\)\);/gm, + '});\n\n' + + '$1constructor = Napi::Persistent($3);\n' + + '$1constructor.SuppressDestruct();\n' + + '$1target.Set("$2", $3);'], + + // TODO: Other attribute combinations + [/static_cast\(ReadOnly\s*\|\s*DontDelete\)/gm, + 'static_cast(napi_enumerable | napi_configurable)'], + + [/([\w\d:<>]+?)::Cast\((.+?)\)/g, '$2.As<$1>()'], + + [/\*Nan::Utf8String\(([^)]+)\)/g, '$1->As().Utf8Value().c_str()'], + [/Nan::Utf8String +(\w+)\(([^)]+)\)/g, 'std::string $1 = $2.As()'], + [/Nan::Utf8String/g, 'std::string'], + + [/v8::String::Utf8Value (.+?)\((.+?)\)/g, 'Napi::String $1(env, $2)'], + [/String::Utf8Value (.+?)\((.+?)\)/g, 'Napi::String $1(env, $2)'], + [/\.length\(\)/g, '.Length()'], + + [/Nan::MakeCallback\(([^,]+),[\s\\]+([^,]+),/gm, '$2.MakeCallback($1,'], + + [/class\s+(\w+)\s*:\s*public\s+Nan::ObjectWrap/g, 'class $1 : public Napi::ObjectWrap<$1>'], + [/(\w+)\(([^)]*)\)\s*:\s*Nan::ObjectWrap\(\)\s*(,)?/gm, '$1($2) : Napi::ObjectWrap<$1>()$3'], + + // HandleOKCallback to OnOK + [/HandleOKCallback/g, 'OnOK'], + // HandleErrorCallback to OnError + [/HandleErrorCallback/g, 'OnError'], + + // ex. .As() to .As() + [/\.As\(\)/g, '.As()'], + [/\.As<(Value|Boolean|String|Number|Object|Array|Symbol|External|Function)>\(\)/g, '.As()'], + + // ex. Nan::New(info[0]) to Napi::Number::New(info[0]) + [/Nan::New<(v8::)*Integer>\((.+?)\)/g, 'Napi::Number::New(env, $2)'], + [/Nan::New\(([0-9.]+)\)/g, 'Napi::Number::New(env, $1)'], + [/Nan::New<(v8::)*String>\("(.+?)"\)/g, 'Napi::String::New(env, "$2")'], + [/Nan::New\("(.+?)"\)/g, 'Napi::String::New(env, "$1")'], + [/Nan::New<(v8::)*(.+?)>\(\)/g, 'Napi::$2::New(env)'], + [/Nan::New<(.+?)>\(\)/g, 'Napi::$1::New(env)'], + [/Nan::New<(v8::)*(.+?)>\(/g, 'Napi::$2::New(env, '], + [/Nan::New<(.+?)>\(/g, 'Napi::$1::New(env, '], + [/Nan::NewBuffer\(/g, 'Napi::Buffer::New(env, '], + // TODO: Properly handle this + [/Nan::New\(/g, 'Napi::New(env, '], + + [/\.IsInt32\(\)/g, '.IsNumber()'], + [/->IsInt32\(\)/g, '.IsNumber()'], + + [/(.+?)->BooleanValue\(\)/g, '$1.As().Value()'], + [/(.+?)->Int32Value\(\)/g, '$1.As().Int32Value()'], + [/(.+?)->Uint32Value\(\)/g, '$1.As().Uint32Value()'], + [/(.+?)->IntegerValue\(\)/g, '$1.As().Int64Value()'], + [/(.+?)->NumberValue\(\)/g, '$1.As().DoubleValue()'], + + // ex. Nan::To(info[0]) to info[0].Value() + [/Nan::To\((.+?)\)/g, '$2.To()'], + [/Nan::To<(Boolean|String|Number|Object|Array|Symbol|Function)>\((.+?)\)/g, '$2.To()'], + // ex. Nan::To(info[0]) to info[0].As().Value() + [/Nan::To\((.+?)\)/g, '$1.As().Value()'], + // ex. Nan::To(info[0]) to info[0].As().Int32Value() + [/Nan::To\((.+?)\)/g, '$1.As().Int32Value()'], + // ex. Nan::To(info[0]) to info[0].As().Int32Value() + [/Nan::To\((.+?)\)/g, '$1.As().Int32Value()'], + // ex. Nan::To(info[0]) to info[0].As().Uint32Value() + [/Nan::To\((.+?)\)/g, '$1.As().Uint32Value()'], + // ex. Nan::To(info[0]) to info[0].As().Int64Value() + [/Nan::To\((.+?)\)/g, '$1.As().Int64Value()'], + // ex. Nan::To(info[0]) to info[0].As().FloatValue() + [/Nan::To\((.+?)\)/g, '$1.As().FloatValue()'], + // ex. Nan::To(info[0]) to info[0].As().DoubleValue() + [/Nan::To\((.+?)\)/g, '$1.As().DoubleValue()'], + + [/Nan::New\((\w+)\)->HasInstance\((\w+)\)/g, '$2.InstanceOf($1.Value())'], + + [/Nan::Has\(([^,]+),\s*/gm, '($1).Has('], + [/\.Has\([\s|\\]*Nan::New<(v8::)*String>\(([^)]+)\)\)/gm, '.Has($1)'], + [/\.Has\([\s|\\]*Nan::New\(([^)]+)\)\)/gm, '.Has($1)'], + + [/Nan::Get\(([^,]+),\s*/gm, '($1).Get('], + [/\.Get\([\s|\\]*Nan::New<(v8::)*String>\(([^)]+)\)\)/gm, '.Get($1)'], + [/\.Get\([\s|\\]*Nan::New\(([^)]+)\)\)/gm, '.Get($1)'], + + [/Nan::Set\(([^,]+),\s*/gm, '($1).Set('], + [/\.Set\([\s|\\]*Nan::New<(v8::)*String>\(([^)]+)\)\s*,/gm, '.Set($1,'], + [/\.Set\([\s|\\]*Nan::New\(([^)]+)\)\s*,/gm, '.Set($1,'], + + // ex. node::Buffer::HasInstance(info[0]) to info[0].IsBuffer() + [/node::Buffer::HasInstance\((.+?)\)/g, '$1.IsBuffer()'], + // ex. node::Buffer::Length(info[0]) to info[0].Length() + [/node::Buffer::Length\((.+?)\)/g, '$1.As>().Length()'], + // ex. node::Buffer::Data(info[0]) to info[0].Data() + [/node::Buffer::Data\((.+?)\)/g, '$1.As>().Data()'], + [/Nan::CopyBuffer\(/g, 'Napi::Buffer::Copy(env, '], + + // Nan::AsyncQueueWorker(worker) + [/Nan::AsyncQueueWorker\((.+)\);/g, '$1.Queue();'], + [/Nan::(Undefined|Null|True|False)\(\)/g, 'env.$1()'], + + // Nan::ThrowError(error) to Napi::Error::New(env, error).ThrowAsJavaScriptException() + [/([ ]*)return Nan::Throw(\w*?)Error\((.+?)\);/g, '$1Napi::$2Error::New(env, $3).ThrowAsJavaScriptException();\n$1return env.Null();'], + [/Nan::Throw(\w*?)Error\((.+?)\);\n(\s*)return;/g, 'Napi::$1Error::New(env, $2).ThrowAsJavaScriptException();\n$3return env.Null();'], + [/Nan::Throw(\w*?)Error\((.+?)\);/g, 'Napi::$1Error::New(env, $2).ThrowAsJavaScriptException();\n'], + // Nan::RangeError(error) to Napi::RangeError::New(env, error) + [/Nan::(\w*?)Error\((.+)\)/g, 'Napi::$1Error::New(env, $2)'], + + [/Nan::Set\((.+?),\n* *(.+?),\n* *(.+?),\n* *(.+?)\)/g, '$1.Set($2, $3, $4)'], + + [/Nan::(Escapable)?HandleScope\s+(\w+)\s*;/g, 'Napi::$1HandleScope $2(env);'], + [/Nan::(Escapable)?HandleScope/g, 'Napi::$1HandleScope'], + [/Nan::ForceSet\(([^,]+), ?/g, '$1->DefineProperty('], + [/\.ForceSet\(Napi::String::New\(env, "(\w+)"\),\s*?/g, '.DefineProperty("$1", '], + // [ /Nan::GetPropertyNames\(([^,]+)\)/, '$1->GetPropertyNames()' ], + [/Nan::Equals\(([^,]+),/g, '$1.StrictEquals('], + + [/(.+)->Set\(/g, '$1.Set('], + + [/Nan::Callback/g, 'Napi::FunctionReference'], + + [/Nan::Persistent/g, 'Napi::ObjectReference'], + [/Nan::ADDON_REGISTER_FUNCTION_ARGS_TYPE target/g, 'Napi::Env& env, Napi::Object& target'], + + [/(\w+)\*\s+(\w+)\s*=\s*Nan::ObjectWrap::Unwrap<\w+>\(info\.This\(\)\);/g, '$1* $2 = this;'], + [/Nan::ObjectWrap::Unwrap<(\w+)>\((.*)\);/g, '$2.Unwrap<$1>();'], + + [/Nan::NAN_METHOD_RETURN_TYPE/g, 'void'], + [/NAN_INLINE/g, 'inline'], + + [/Nan::NAN_METHOD_ARGS_TYPE/g, 'const Napi::CallbackInfo&'], + [/NAN_METHOD\(([\w\d:]+?)\)/g, 'Napi::Value $1(const Napi::CallbackInfo& info)'], + [/static\s*NAN_GETTER\(([\w\d:]+?)\)/g, 'Napi::Value $1(const Napi::CallbackInfo& info)'], + [/NAN_GETTER\(([\w\d:]+?)\)/g, 'Napi::Value $1(const Napi::CallbackInfo& info)'], + [/static\s*NAN_SETTER\(([\w\d:]+?)\)/g, 'void $1(const Napi::CallbackInfo& info, const Napi::Value& value)'], + [/NAN_SETTER\(([\w\d:]+?)\)/g, 'void $1(const Napi::CallbackInfo& info, const Napi::Value& value)'], + [/void Init\((v8::)*Local<(v8::)*Object> exports\)/g, 'Napi::Object Init(Napi::Env env, Napi::Object exports)'], + [/NAN_MODULE_INIT\(([\w\d:]+?)\);/g, 'Napi::Object $1(Napi::Env env, Napi::Object exports);'], + [/NAN_MODULE_INIT\(([\w\d:]+?)\)/g, 'Napi::Object $1(Napi::Env env, Napi::Object exports)'], + + [/::(Init(?:ialize)?)\(target\)/g, '::$1(env, target, module)'], + [/constructor_template/g, 'constructor'], + + [/Nan::FunctionCallbackInfo<(v8::)?Value>[ ]*& [ ]*info\)[ ]*{\n*([ ]*)/gm, 'Napi::CallbackInfo& info) {\n$2Napi::Env env = info.Env();\n$2'], + [/Nan::FunctionCallbackInfo<(v8::)*Value>\s*&\s*info\);/g, 'Napi::CallbackInfo& info);'], + [/Nan::FunctionCallbackInfo<(v8::)*Value>\s*&/g, 'Napi::CallbackInfo&'], + + [/Buffer::HasInstance\(([^)]+)\)/g, '$1.IsBuffer()'], + + [/info\[(\d+)\]->/g, 'info[$1].'], + [/info\[([\w\d]+)\]->/g, 'info[$1].'], + [/info\.This\(\)->/g, 'info.This().'], + [/->Is(Object|String|Int32|Number)\(\)/g, '.Is$1()'], + [/info.GetReturnValue\(\).SetUndefined\(\)/g, 'return env.Undefined()'], + [/info\.GetReturnValue\(\)\.Set\(((\n|.)+?)\);/g, 'return $1;'], + + // ex. Local to Napi::Value + [/v8::Local/g, 'Napi::$1'], + [/Local<(Value|Boolean|String|Number|Object|Array|Symbol|External|Function)>/g, 'Napi::$1'], + + // Declare an env in helper functions that take a Napi::Value + [/(\w+)\(Napi::Value (\w+)(,\s*[^()]+)?\)\s*{\n*([ ]*)/gm, '$1(Napi::Value $2$3) {\n$4Napi::Env env = $2.Env();\n$4'], + + // delete #include and/or + [/#include +(<|")(?:node|nan).h("|>)/g, '#include $1napi.h$2\n#include $1uv.h$2'], + // NODE_MODULE to NODE_API_MODULE + [/NODE_MODULE/g, 'NODE_API_MODULE'], + [/Nan::/g, 'Napi::'], + [/nan.h/g, 'napi.h'], + + // delete .FromJust() + [/\.FromJust\(\)/g, ''], + // delete .ToLocalCheck() + [/\.ToLocalChecked\(\)/g, ''], + [/^.*->SetInternalFieldCount\(.*$/gm, ''], + + // replace using node; and/or using v8; to using Napi; + [/using (node|v8);/g, 'using Napi;'], + [/using namespace (node|Nan|v8);/g, 'using namespace Napi;'], + // delete using v8::Local; + [/using v8::Local;\n/g, ''], + // replace using v8::XXX; with using Napi::XXX + [/using v8::([A-Za-z]+);/g, 'using Napi::$1;'] + +]; + +const paths = listFiles(dir); +paths.forEach(function (dirEntry) { + const filename = dirEntry.split('\\').pop().split('/').pop(); + + // Check whether the file is a source file or a config file + // then execute function accordingly + const sourcePattern = /.+\.h|.+\.cc|.+\.cpp/; + if (sourcePattern.test(filename)) { + convertFile(dirEntry, SourceFileOperations); + } else if (ConfigFileOperations[filename] != null) { + convertFile(dirEntry, ConfigFileOperations[filename]); + } +}); + +function listFiles (dir, filelist) { + const files = fs.readdirSync(dir); + filelist = filelist || []; + files.forEach(function (file) { + if (file === 'node_modules') { + return; + } + + if (fs.statSync(path.join(dir, file)).isDirectory()) { + filelist = listFiles(path.join(dir, file), filelist); + } else { + filelist.push(path.join(dir, file)); + } + }); + return filelist; +} + +function convert (content, operations) { + for (let i = 0; i < operations.length; i++) { + const operation = operations[i]; + content = content.replace(operation[0], operation[1]); + } + return content; +} + +function convertFile (fileName, operations) { + fs.readFile(fileName, 'utf-8', function (err, file) { + if (err) throw err; + + file = convert(file, operations); + + fs.writeFile(fileName, file, function (err) { + if (err) throw err; + }); + }); +} diff --git a/services/edge-agent/node_modules/node-addon-api/tools/eslint-format.js b/services/edge-agent/node_modules/node-addon-api/tools/eslint-format.js new file mode 100644 index 00000000..6923ab7b --- /dev/null +++ b/services/edge-agent/node_modules/node-addon-api/tools/eslint-format.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +const spawn = require('child_process').spawnSync; + +const filesToCheck = '*.js'; +const FORMAT_START = process.env.FORMAT_START || 'main'; +const IS_WIN = process.platform === 'win32'; +const ESLINT_PATH = IS_WIN ? 'node_modules\\.bin\\eslint.cmd' : 'node_modules/.bin/eslint'; + +function main (args) { + let fix = false; + while (args.length > 0) { + switch (args[0]) { + case '-f': + case '--fix': + fix = true; + break; + default: + } + args.shift(); + } + + // Check js files that change on unstaged file + const fileUnStaged = spawn( + 'git', + ['diff', '--name-only', '--diff-filter=d', FORMAT_START, filesToCheck], + { + encoding: 'utf-8' + } + ); + + // Check js files that change on staged file + const fileStaged = spawn( + 'git', + ['diff', '--name-only', '--cached', '--diff-filter=d', FORMAT_START, filesToCheck], + { + encoding: 'utf-8' + } + ); + + const options = [ + ...fileStaged.stdout.split('\n').filter((f) => f !== ''), + ...fileUnStaged.stdout.split('\n').filter((f) => f !== '') + ]; + + if (fix) { + options.push('--fix'); + } + + const result = spawn(ESLINT_PATH, [...options], { + encoding: 'utf-8' + }); + + if (result.error && result.error.errno === 'ENOENT') { + console.error('Eslint not found! Eslint is supposed to be found at ', ESLINT_PATH); + return 2; + } + + if (result.status === 1) { + console.error('Eslint error:', result.stdout); + const fixCmd = 'npm run lint:fix'; + console.error(`ERROR: please run "${fixCmd}" to format changes in your commit + Note that when running the command locally, please keep your local + main branch and working branch up to date with nodejs/node-addon-api + to exclude un-related complains. + Or you can run "env FORMAT_START=upstream/main ${fixCmd}". + Also fix JS files by yourself if necessary.`); + return 1; + } + + if (result.stderr) { + console.error('Error running eslint:', result.stderr); + return 2; + } +} + +if (require.main === module) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/services/edge-agent/node_modules/node-pty/LICENSE b/services/edge-agent/node_modules/node-pty/LICENSE new file mode 100644 index 00000000..22f780da --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/LICENSE @@ -0,0 +1,69 @@ +Copyright (c) 2012-2015, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + + +The MIT License (MIT) + +Copyright (c) 2016, Daniel Imms (http://www.growingwiththeweb.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +MIT License + +Copyright (c) 2018 - present Microsoft Corporation + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/services/edge-agent/node_modules/node-pty/README.md b/services/edge-agent/node_modules/node-pty/README.md new file mode 100644 index 00000000..dce014de --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/README.md @@ -0,0 +1,165 @@ +# node-pty + +[![Build Status](https://dev.azure.com/vscode/node-pty/_apis/build/status/Microsoft.node-pty?branchName=main)](https://dev.azure.com/vscode/node-pty/_build/latest?definitionId=11&branchName=main) + +`forkpty(3)` bindings for node.js. This allows you to fork processes with pseudoterminal file descriptors. It returns a terminal object which allows reads and writes. + +This is useful for: + +- Writing a terminal emulator (eg. via [xterm.js](https://github.com/sourcelair/xterm.js)). +- Getting certain programs to *think* you're a terminal, such as when you need a program to send you control sequences. + +`node-pty` supports Linux, macOS and Windows. Windows support is possible by utilizing the [Windows conpty API](https://blogs.msdn.microsoft.com/commandline/2018/08/02/windows-command-line-introducing-the-windows-pseudo-console-conpty/) on Windows 1809+ and the [winpty](https://github.com/rprichard/winpty) library in older version. + +## API + +The full API for node-pty is contained within the [TypeScript declaration file](https://github.com/microsoft/node-pty/blob/main/typings/node-pty.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API. + +## Example Usage + +```js +import * as os from 'node:os'; +import * as pty from 'node-pty'; + +const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; + +const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env +}); + +ptyProcess.onData((data) => { + process.stdout.write(data); +}); + +ptyProcess.write('ls\r'); +ptyProcess.resize(100, 40); +ptyProcess.write('ls\r'); +``` + +## Real-world Uses + +`node-pty` powers many different terminal emulators, including: + +- [Microsoft Visual Studio Code](https://code.visualstudio.com) +- [Hyper](https://hyper.is/) +- [Upterm](https://github.com/railsware/upterm) +- [Script Runner](https://github.com/ioquatix/script-runner) for Atom. +- [Theia](https://github.com/theia-ide/theia) +- [FreeMAN](https://github.com/matthew-matvei/freeman) file manager +- [terminus](https://atom.io/packages/terminus) - An Atom plugin for providing terminals inside your Atom workspace. +- [x-terminal](https://atom.io/packages/x-terminal) - Also an Atom plugin that provides terminals inside your Atom workspace. +- [Termination](https://atom.io/packages/termination) - Also an Atom plugin that provides terminals inside your Atom workspace. +- [atom-xterm](https://atom.io/packages/atom-xterm) - Also an Atom plugin that provides terminals inside your Atom workspace. +- [electerm](https://github.com/electerm/electerm) Terminal/SSH/SFTP client(Linux, macOS, Windows). +- [Extraterm](http://extraterm.org/) +- [Wetty](https://github.com/krishnasrinivas/wetty) Browser based Terminal over HTTP and HTTPS +- [nomad](https://github.com/lukebarnard1/nomad-term) +- [DockerStacks](https://github.com/sfx101/docker-stacks) Local LAMP/LEMP stack using Docker +- [TeleType](https://github.com/akshaykmr/TeleType): cli tool that allows you to share your terminal online conveniently. Show off mad cli-fu, help a colleague, teach, or troubleshoot. +- [mesos-term](https://github.com/criteo/mesos-term): A web terminal for Apache Mesos. It allows to execute commands within containers. +- [Commas](https://github.com/CyanSalt/commas): A hackable terminal and command runner. +- [ENiGMA½ BBS Software](https://github.com/NuSkooler/enigma-bbs): A modern BBS software with a nostalgic flair! +- [Tinkerun](https://github.com/tinkerun/tinkerun): A new way of running Tinker. +- [Tess](https://tessapp.dev): Hackable, simple and rapid terminal for the new era of technology 👍 +- [NxShell](https://nxshell.github.io/): An easy to use new terminal for Windows/Linux/MacOS platform. +- [OpenSumi](https://github.com/opensumi/core): A framework helps you quickly build Cloud or Desktop IDE products. +- [Enjoy Git](https://github.com/huangcs427/enjoy-git-release): A modern Git client featuring an intuitive user interface, built with Electron, Vue 3, and TypeScript. + +Do you use node-pty in your application as well? Please open a [Pull Request](https://github.com/Tyriar/node-pty/pulls) to include it here. We would love to have it in our list. + +## Building + +```bash +# Install dependencies and build C++ +npm install +# Compile TypeScript -> JavaScript +npm run build +``` + +## Dependencies + +Node.JS 16 or Electron 19 is required to use `node-pty`. What version of node is supported is currently mostly bound to [whatever version Visual Studio Code is using](https://github.com/microsoft/node-pty/issues/557#issuecomment-1332193541). + +### Linux (apt) + +```sh +sudo apt install -y make python build-essential +``` + +### macOS + +Xcode is needed to compile the sources, this can be installed from the App Store. + +### Windows + +`npm install` requires some tools to be present in the system like Python and C++ compiler. Windows users can easily install them by running the following command in PowerShell as administrator. For more information see https://github.com/felixrieseberg/windows-build-tools: + +```sh +npm install --global --production windows-build-tools +``` + +The following are also needed: + +- [Windows SDK](https://developer.microsoft.com/en-us/windows/downloads/windows-10-sdk) - only the "Desktop C++ Apps" components are needed to be installed +- Spectre-mitigated libraries - In order to avoid the build error "MSB8040: Spectre-mitigated libraries are required for this project", open the Visual Studio Installer, press the Modify button, navigate to the "Individual components" tab, search "Spectre", and install an option like "MSVC v143 - VS 2022 C++ x64/x86 Spectre-mitigated libs (Latest)" (the exact option to install will depend on your version of Visual Studio as well as your operating system architecture) + +## Debugging + +[The wiki](https://github.com/Microsoft/node-pty/wiki/Debugging) contains instructions for debugging node-pty. + +## Security + +All processes launched from node-pty will launch at the same permission level of the parent process. Take care particularly when using node-pty inside a server that's accessible on the internet. We recommend launching the pty inside a container to protect your host machine. + +## Thread Safety + +Note that node-pty is not thread safe so running it across multiple worker threads in node.js could cause issues. + +## Flow Control + +Automatic flow control can be enabled by either providing `handleFlowControl = true` in the constructor options or setting it later on: + +```js +const PAUSE = '\x13'; // XOFF +const RESUME = '\x11'; // XON + +const ptyProcess = pty.spawn(shell, [], {handleFlowControl: true}); + +// flow control in action +ptyProcess.write(PAUSE); // pty will block and pause the child program +... +ptyProcess.write(RESUME); // pty will enter flow mode and resume the child program + +// temporarily disable/re-enable flow control +ptyProcess.handleFlowControl = false; +... +ptyProcess.handleFlowControl = true; +``` + +By default `PAUSE` and `RESUME` are XON/XOFF control codes (as shown above). To avoid conflicts in environments that use these control codes for different purposes the messages can be customized as `flowControlPause: string` and `flowControlResume: string` in the constructor options. `PAUSE` and `RESUME` are not passed to the underlying pseudoterminal if flow control is enabled. + +## Troubleshooting + +### Powershell gives error 8009001d + +> Internal Windows PowerShell error. Loading managed Windows PowerShell failed with error 8009001d. + +This happens when PowerShell is launched with no `SystemRoot` environment variable present. + +### ConnectNamedPipe failed: Windows error 232 + +This error can occur due to anti-virus software intercepting winpty from creating a pty. To workaround this you can exclude this file from your anti-virus scanning `node-pty\build\Release\winpty-agent.exe` + +## pty.js + +This project is forked from [chjj/pty.js](https://github.com/chjj/pty.js) with the primary goals being to provide better support for later Node.js versions and Windows. + +## License + +Copyright (c) 2012-2015, Christopher Jeffrey (MIT License).
+Copyright (c) 2016, Daniel Imms (MIT License).
+Copyright (c) 2018, Microsoft Corporation (MIT License). diff --git a/services/edge-agent/node_modules/node-pty/binding.gyp b/services/edge-agent/node_modules/node-pty/binding.gyp new file mode 100644 index 00000000..5f63978b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/binding.gyp @@ -0,0 +1,111 @@ +{ + 'target_defaults': { + 'dependencies': [ + " on the +# command-line to override the default. + +.SECONDEXPANSION : + +.PHONY : default +default : all + +PREFIX := /usr/local +UNIX_ADAPTER_EXE := winpty.exe +MINGW_ENABLE_CXX11_FLAG := -std=c++11 +USE_PCH := 1 + +COMMON_CXXFLAGS := +UNIX_CXXFLAGS := +MINGW_CXXFLAGS := +MINGW_LDFLAGS := +UNIX_LDFLAGS := + +# Include config.mk but complain if it hasn't been created yet. +ifeq "$(wildcard config.mk)" "" + $(error config.mk does not exist. Please run ./configure) +endif +include config.mk + +COMMON_CXXFLAGS += \ + -MMD -Wall \ + -DUNICODE \ + -D_UNICODE \ + -D_WIN32_WINNT=0x0501 \ + -Ibuild/gen + +UNIX_CXXFLAGS += \ + $(COMMON_CXXFLAGS) + +MINGW_CXXFLAGS += \ + $(COMMON_CXXFLAGS) \ + -O2 \ + $(MINGW_ENABLE_CXX11_FLAG) + +MINGW_LDFLAGS += -static -static-libgcc -static-libstdc++ +UNIX_LDFLAGS += $(UNIX_LDFLAGS_STATIC) + +ifeq "$(USE_PCH)" "1" +MINGW_CXXFLAGS += -include build/mingw/PrecompiledHeader.h +PCH_DEP := build/mingw/PrecompiledHeader.h.gch +else +PCH_DEP := +endif + +build/gen/GenVersion.h : VERSION.txt $(COMMIT_HASH_DEP) | $$(@D)/.mkdir + $(info Updating build/gen/GenVersion.h) + @echo "const char GenVersion_Version[] = \"$(shell cat VERSION.txt | tr -d '\r\n')\";" > build/gen/GenVersion.h + @echo "const char GenVersion_Commit[] = \"$(COMMIT_HASH)\";" >> build/gen/GenVersion.h + +build/mingw/PrecompiledHeader.h : src/shared/PrecompiledHeader.h | $$(@D)/.mkdir + $(info Copying $< to $@) + @cp $< $@ + +build/mingw/PrecompiledHeader.h.gch : build/mingw/PrecompiledHeader.h | $$(@D)/.mkdir + $(info Compiling $<) + @$(MINGW_CXX) $(MINGW_CXXFLAGS) -c -o $@ $< + +-include build/mingw/PrecompiledHeader.h.d + +define def_unix_target +build/$1/%.o : src/%.cc | $$$$(@D)/.mkdir + $$(info Compiling $$<) + @$$(UNIX_CXX) $$(UNIX_CXXFLAGS) $2 -I src/include -c -o $$@ $$< +endef + +define def_mingw_target +build/$1/%.o : src/%.cc $$(PCH_DEP) | $$$$(@D)/.mkdir + $$(info Compiling $$<) + @$$(MINGW_CXX) $$(MINGW_CXXFLAGS) $2 -I src/include -c -o $$@ $$< +endef + +include src/subdir.mk + +.PHONY : all +all : $(ALL_TARGETS) + +.PHONY : tests +tests : $(TEST_PROGRAMS) + +.PHONY : install-bin +install-bin : all + mkdir -p $(PREFIX)/bin + install -m 755 -p -s build/$(UNIX_ADAPTER_EXE) $(PREFIX)/bin + install -m 755 -p -s build/winpty.dll $(PREFIX)/bin + install -m 755 -p -s build/winpty-agent.exe $(PREFIX)/bin + +.PHONY : install-debugserver +install-debugserver : all + mkdir -p $(PREFIX)/bin + install -m 755 -p -s build/winpty-debugserver.exe $(PREFIX)/bin + +.PHONY : install-lib +install-lib : all + mkdir -p $(PREFIX)/lib + install -m 644 -p build/winpty.lib $(PREFIX)/lib + +.PHONY : install-doc +install-doc : + mkdir -p $(PREFIX)/share/doc/winpty + install -m 644 -p LICENSE $(PREFIX)/share/doc/winpty + install -m 644 -p README.md $(PREFIX)/share/doc/winpty + install -m 644 -p RELEASES.md $(PREFIX)/share/doc/winpty + +.PHONY : install-include +install-include : + mkdir -p $(PREFIX)/include/winpty + install -m 644 -p src/include/winpty.h $(PREFIX)/include/winpty + install -m 644 -p src/include/winpty_constants.h $(PREFIX)/include/winpty + +.PHONY : install +install : \ + install-bin \ + install-debugserver \ + install-lib \ + install-doc \ + install-include + +.PHONY : clean +clean : + rm -fr build + +.PHONY : clean-msvc +clean-msvc : + rm -fr src/Default src/Release src/.vs src/gen + rm -f src/*.vcxproj src/*.vcxproj.filters src/*.sln src/*.sdf + +.PHONY : distclean +distclean : clean + rm -f config.mk + +.PRECIOUS : %.mkdir +%.mkdir : + $(info Creating directory $(dir $@)) + @mkdir -p $(dir $@) + @touch $@ + +src/%.h : + @echo "Missing header file $@ (stale dependency file?)" diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/README.md b/services/edge-agent/node_modules/node-pty/deps/winpty/README.md new file mode 100644 index 00000000..a6520fc3 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/README.md @@ -0,0 +1,151 @@ +# winpty + +[![Build Status](https://tea-ci.org/api/badges/rprichard/winpty/status.svg)](https://tea-ci.org/rprichard/winpty) + +winpty is a Windows software package providing an interface similar to a Unix +pty-master for communicating with Windows console programs. The package +consists of a library (libwinpty) and a tool for Cygwin and MSYS for running +Windows console programs in a Cygwin/MSYS pty. + +The software works by starting the `winpty-agent.exe` process with a new, +hidden console window, which bridges between the console API and terminal +input/output escape codes. It polls the hidden console's screen buffer for +changes and generates a corresponding stream of output. + +The Unix adapter allows running Windows console programs (e.g. CMD, PowerShell, +IronPython, etc.) under `mintty` or Cygwin's `sshd` with +properly-functioning input (e.g. arrow and function keys) and output (e.g. line +buffering). The library could be also useful for writing a non-Cygwin SSH +server. + +## Supported Windows versions + +winpty runs on Windows XP through Windows 10, including server versions. It +can be compiled into either 32-bit or 64-bit binaries. + +## Cygwin/MSYS adapter (`winpty.exe`) + +### Prerequisites + +You need the following to build winpty: + +* A Cygwin or MSYS installation +* GNU make +* A MinGW g++ toolchain capable of compiling C++11 code to build `winpty.dll` + and `winpty-agent.exe` +* A g++ toolchain targeting Cygwin or MSYS to build `winpty.exe` + +Winpty requires two g++ toolchains as it is split into two parts. The +`winpty.dll` and `winpty-agent.exe` binaries interface with the native +Windows command prompt window so they are compiled with the native MinGW +toolchain. The `winpty.exe` binary interfaces with the MSYS/Cygwin terminal so +it is compiled with the MSYS/Cygwin toolchain. + +MinGW appears to be split into two distributions -- MinGW (creates 32-bit +binaries) and MinGW-w64 (creates both 32-bit and 64-bit binaries). Either +one is generally acceptable. + +#### Cygwin packages + +The default g++ compiler for Cygwin targets Cygwin itself, but Cygwin also +packages MinGW-w64 compilers. As of this writing, the necessary packages are: + +* Either `mingw64-i686-gcc-g++` or `mingw64-x86_64-gcc-g++`. Select the + appropriate compiler for your CPU architecture. +* `gcc-g++` +* `make` + +As of this writing (2016-01-23), only the MinGW-w64 compiler is acceptable. +The MinGW compiler (e.g. from the `mingw-gcc-g++` package) is no longer +maintained and is too buggy. + +#### MSYS packages + +For the original MSYS, use the `mingw-get` tool (MinGW Installation Manager), +and select at least these components: + +* `mingw-developer-toolkit` +* `mingw32-base` +* `mingw32-gcc-g++` +* `msys-base` +* `msys-system-builder` + +When running `./configure`, make sure that `mingw32-g++` is in your +`PATH`. It will be in the `C:\MinGW\bin` directory. + +#### MSYS2 packages + +For MSYS2, use `pacman` and install at least these packages: + +* `msys/gcc` +* `mingw32/mingw-w64-i686-gcc` or `mingw64/mingw-w64-x86_64-gcc`. Select + the appropriate compiler for your CPU architecture. +* `make` + +MSYS2 provides three start menu shortcuts for starting MSYS2: + +* MinGW-w64 Win32 Shell +* MinGW-w64 Win64 Shell +* MSYS2 Shell + +To build winpty, use the MinGW-w64 {Win32,Win64} shortcut of the architecture +matching MSYS2. These shortcuts will put the g++ compiler from the +`{mingw32,mingw64}/mingw-w64-{i686,x86_64}-gcc` packages into the `PATH`. + +Alternatively, instead of installing `mingw32/mingw-w64-i686-gcc` or +`mingw64/mingw-w64-x86_64-gcc`, install the `mingw-w64-cross-gcc` and +`mingw-w64-cross-crt-git` packages. These packages install cross-compilers +into `/opt/bin`, and then any of the three shortcuts will work. + +### Building the Unix adapter + +In the project directory, run `./configure`, then `make`, then `make install`. +By default, winpty is installed into `/usr/local`. Pass `PREFIX=` to +`make install` to override this default. + +### Using the Unix adapter + +To run a Windows console program in `mintty` or Cygwin `sshd`, prepend +`winpty` to the command-line: + + $ winpty powershell + Windows PowerShell + Copyright (C) 2009 Microsoft Corporation. All rights reserved. + + PS C:\rprichard\proj\winpty> 10 + 20 + 30 + PS C:\rprichard\proj\winpty> exit + +## Embedding winpty / MSVC compilation + +See `src/include/winpty.h` for the prototypes of functions exported by +`winpty.dll`. + +Only the `winpty.exe` binary uses Cygwin; all the other binaries work without +it and can be compiled with either MinGW or MSVC. To compile using MSVC, +download gyp and run `gyp -I configurations.gypi` in the `src` subdirectory. +This will generate a `winpty.sln` and associated project files. See the +`src/winpty.gyp` and `src/configurations.gypi` files for notes on dealing with +MSVC versions and different architectures. + +Compiling winpty with MSVC currently requires MSVC 2013 or newer. + +## Debugging winpty + +winpty comes with a tool for collecting timestamped debugging output. To use +it: + +1. Run `winpty-debugserver.exe` on the same computer as winpty. +2. Set the `WINPTY_DEBUG` environment variable to `trace` for the + `winpty.exe` process and/or the process using `libwinpty.dll`. + +winpty also recognizes a `WINPTY_SHOW_CONSOLE` environment variable. Set it +to 1 to prevent winpty from hiding the console window. + +## Copyright + +This project is distributed under the MIT license (see the `LICENSE` file in +the project root). + +By submitting a pull request for this project, you agree to license your +contribution under the MIT license to this project. diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/RELEASES.md b/services/edge-agent/node_modules/node-pty/deps/winpty/RELEASES.md new file mode 100644 index 00000000..768cdf90 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/RELEASES.md @@ -0,0 +1,280 @@ +# Next Version + +Input handling changes: + + * Improve Ctrl-C handling with programs that use unprocessed input. (e.g. + Ctrl-C now cancels input with PowerShell on Windows 10.) + [#116](https://github.com/rprichard/winpty/issues/116) + * Fix a theoretical issue with input event ordering. + [#117](https://github.com/rprichard/winpty/issues/117) + * Ctrl/Shift+{Arrow,Home,End} keys now work with IntelliJ. + [#118](https://github.com/rprichard/winpty/issues/118) + +# Version 0.4.3 (2017-05-17) + +Input handling changes: + + * winpty sets `ENHANCED_KEY` for arrow and navigation keys. This fixes an + issue with the Ruby REPL. + [#99](https://github.com/rprichard/winpty/issues/99) + * AltGr keys are handled better now. + [#109](https://github.com/rprichard/winpty/issues/109) + * In `ENABLE_VIRTUAL_TERMINAL_INPUT` mode, when typing Home/End with a + modifier (e.g. Ctrl), winpty now generates an H/F escape sequence like + `^[[1;5F` rather than a 1/4 escape like `^[[4;5~`. + [#114](https://github.com/rprichard/winpty/issues/114) + +Resizing and scraping fixes: + + * winpty now synthesizes a `WINDOW_BUFFER_SIZE_EVENT` event after resizing + the console to better propagate window size changes to console programs. + In particular, this affects WSL and Cygwin. + [#110](https://github.com/rprichard/winpty/issues/110) + * Better handling of resizing for certain full-screen programs, like + WSL less. + [#112](https://github.com/rprichard/winpty/issues/112) + * Hide the cursor if it's currently outside the console window. This change + fixes an issue with Far Manager. + [#113](https://github.com/rprichard/winpty/issues/113) + * winpty now avoids using console fonts smaller than 5px high to improve + half-vs-full-width character handling. See + https://github.com/Microsoft/vscode/issues/19665. + [b4db322010](https://github.com/rprichard/winpty/commit/b4db322010d2d897e6c496fefc4f0ecc9b84c2f3) + +Cygwin/MSYS adapter fix: + + * The way the `winpty` Cygwin/MSYS2 adapter searches for the program to + launch changed. It now resolves symlinks and searches the PATH explicitly. + [#81](https://github.com/rprichard/winpty/issues/81) + [#98](https://github.com/rprichard/winpty/issues/98) + +This release does not include binaries for the old MSYS1 project anymore. +MSYS2 will continue to be supported. See +https://github.com/rprichard/winpty/issues/97. + +# Version 0.4.2 (2017-01-18) + +This release improves WSL support (i.e. Bash-on-Windows): + + * winpty generates more correct input escape sequences for WSL programs that + enable an alternate input mode using DECCKM. This bug affected arrow keys + and Home/End in WSL programs such as `vim`, `mc`, and `less`. + [#90](https://github.com/rprichard/winpty/issues/90) + * winpty now recognizes the `COMMON_LVB_REVERSE_VIDEO` and + `COMMON_LVB_UNDERSCORE` text attributes. The Windows console uses these + attributes to implement the SGR.4(Underline) and SGR.7(Negative) modes in + its VT handling. This change affects WSL pager status bars, man pages, etc. + +The build system no longer has a "version suffix" mechanism, so passing +`VERSION_SUFFIX=` to make or `-D VERSION_SUFFIX=` to gyp now +has no effect. AFAIK, the mechanism was never used publicly. +[67a34b6c03](https://github.com/rprichard/winpty/commit/67a34b6c03557a5c2e0a2bdd502c2210921d8f3e) + +# Version 0.4.1 (2017-01-03) + +Bug fixes: + + * This version fixes a bug where the `winpty-agent.exe` process could read + past the end of a buffer. + [#94](https://github.com/rprichard/winpty/issues/94) + +# Version 0.4.0 (2016-06-28) + +The winpty library has a new API that should be easier for embedding. +[880c00c69e](https://github.com/rprichard/winpty/commit/880c00c69eeca73643ddb576f02c5badbec81f56) + +User-visible changes: + + * winpty now automatically puts the terminal into mouse mode when it detects + that the console has left QuickEdit mode. The `--mouse` option still forces + the terminal into mouse mode. In principle, an option could be added to + suppress terminal mode, but hopefully it won't be necessary. There is a + script in the `misc` subdirectory, `misc/ConinMode.ps1`, that can change + the QuickEdit mode from the command-line. + * winpty now passes keyboard escapes to `bash.exe` in the Windows Subsystem + for Linux. + [#82](https://github.com/rprichard/winpty/issues/82) + +Bug fixes: + + * By default, `winpty.dll` avoids calling `SetProcessWindowStation` within + the calling process. + [#58](https://github.com/rprichard/winpty/issues/58) + * Fixed an uninitialized memory bug that could have crashed winpty. + [#80](https://github.com/rprichard/winpty/issues/80) + * winpty now works better with very large and very small terminal windows. + It resizes the console font according to the number of columns. + [#61](https://github.com/rprichard/winpty/issues/61) + * winpty no longer uses Mark to freeze the console on Windows 10. The Mark + command could interfere with the cursor position, corrupting the data in + the screen buffer. + [#79](https://github.com/rprichard/winpty/issues/79) + +# Version 0.3.0 (2016-05-20) + +User-visible changes: + + * The UNIX adapter is renamed from `console.exe` to `winpty.exe` to be + consistent with MSYS2. The name `winpty.exe` is less likely to conflict + with another program and is easier to search for online (e.g. for someone + unfamiliar with winpty). + * The UNIX adapter now clears the `TERM` variable. + [#43](https://github.com/rprichard/winpty/issues/43) + * An escape character appearing in a console screen buffer cell is converted + to a '?'. + [#47](https://github.com/rprichard/winpty/issues/47) + +Bug fixes: + + * A major bug affecting XP users was fixed. + [#67](https://github.com/rprichard/winpty/issues/67) + * Fixed an incompatibility with ConEmu where winpty hung if ConEmu's + "Process 'start'" feature was enabled. + [#70](https://github.com/rprichard/winpty/issues/70) + * Fixed a bug where `cmd.exe` sometimes printed the message, + `Not enough storage is available to process this command.`. + [#74](https://github.com/rprichard/winpty/issues/74) + +Many changes internally: + + * The codebase is switched from C++03 to C++11 and uses exceptions internally. + No exceptions are thrown across the C APIs defined in `winpty.h`. + * This version drops support for the original MinGW compiler packaged with + Cygwin (`i686-pc-mingw32-g++`). The MinGW-w64 compiler is still supported, + as is the MinGW distributed at mingw.org. Compiling with MSVC now requires + MSVC 2013 or newer. Windows XP is still supported. + [ec3eae8df5](https://github.com/rprichard/winpty/commit/ec3eae8df5bbbb36d7628d168b0815638d122f37) + * Pipe security is improved. winpty works harder to produce unique pipe names + and includes a random component in the name. winpty secures pipes with a + DACL that prevents arbitrary users from connecting to its pipes. winpty now + passes `PIPE_REJECT_REMOTE_CLIENTS` on Vista and up, and it verifies that + the pipe client PID is correct, again on Vista and up. When connecting to a + named pipe, winpty uses the `SECURITY_IDENTIFICATION` flag to restrict + impersonation. Previous versions *should* still be secure. + * `winpty-debugserver.exe` now has an `--everyone` flag that allows capturing + debug output from other users. + * The code now compiles cleanly with MSVC's "Security Development Lifecycle" + (`/SDL`) checks enabled. + +# Version 0.2.2 (2016-02-25) + +Minor bug fixes and enhancements: + + * Fix a bug that generated spurious mouse input records when an incomplete + mouse escape sequence was seen. + * Fix a buffer overflow bug in `winpty-debugserver.exe` affecting messages of + exactly 4096 bytes. + * For MSVC builds, add a `src/configurations.gypi` file that can be included + on the gyp command-line to enable 32-bit and 64-bit builds. + * `winpty-agent --show-input` mode: Flush stdout after each line. + * Makefile builds: generate a `build/winpty.lib` import library to accompany + `build/winpty.dll`. + +# Version 0.2.1 (2015-12-19) + + * The main project source was moved into a `src` directory for better code + organization and to fix + [#51](https://github.com/rprichard/winpty/issues/51). + * winpty recognizes many more escape sequences, including: + * putty/rxvt's F1-F4 keys + [#40](https://github.com/rprichard/winpty/issues/40) + * the Linux virtual console's F1-F5 keys + * the "application numpad" keys (e.g. enabled with DECPAM) + * Fixed handling of Shift-Alt-O and Alt-[. + * Added support for mouse input. The UNIX adapter has a `--mouse` argument + that puts the terminal into mouse mode, but the agent recognizes mouse + input even without the argument. The agent recognizes double-clicks using + Windows' double-click interval setting (i.e. GetDoubleClickTime). + [#57](https://github.com/rprichard/winpty/issues/57) + +Changes to debugging interfaces: + + * The `WINPTY_DEBUG` variable is now a comma-separated list. The old + behavior (i.e. tracing) is enabled with `WINPTY_DEBUG=trace`. + * The UNIX adapter program now has a `--showkey` argument that dumps input + bytes. + * The `winpty-agent.exe` program has a `--show-input` argument that dumps + `INPUT_RECORD` records. (It omits mouse events unless `--with-mouse` is + also specified.) The agent also responds to `WINPTY_DEBUG=trace,input`, + which logs input bytes and synthesized console events, and it responds to + `WINPTY_DEBUG=trace,dump_input_map`, which dumps the internal table of + escape sequences. + +# Version 0.2.0 (2015-11-13) + +No changes to the API, but many small changes to the implementation. The big +changes include: + + * Support for 64-bit Cygwin and MSYS2 + * Support for Windows 10 + * Better Unicode support (especially East Asian languages) + +Details: + + * The `configure` script recognizes 64-bit Cygwin and MSYS2 environments and + selects the appropriate compiler. + * winpty works much better with the upgraded console in Windows 10. The + `conhost.exe` hang can still occur, but only with certain programs, and + is much less likely to occur. With the new console, use Mark instead of + SelectAll, for better performance. + [#31](https://github.com/rprichard/winpty/issues/31) + [#30](https://github.com/rprichard/winpty/issues/30) + [#53](https://github.com/rprichard/winpty/issues/53) + * The UNIX adapter now calls `setlocale(LC_ALL, "")` to set the locale. + * Improved Unicode support. When a console is started with an East Asian code + page, winpty now chooses an East Asian font rather than Consolas / Lucida + Console. Selecting the right font helps synchronize character widths + between the console and terminal. (It's not perfect, though.) + [#41](https://github.com/rprichard/winpty/issues/41) + * winpty now more-or-less works with programs that change the screen buffer + or resize the original screen buffer. If the screen buffer height changes, + winpty switches to a "direct mode", where it makes no effort to track + scrolling. In direct mode, it merely syncs snapshots of the console to the + terminal. Caveats: + * Changing the screen buffer (i.e. `SetConsoleActiveScreenBuffer`) + breaks winpty on Windows 7. This problem can eventually be mitigated, + but never completely fixed, due to Windows 7 bugginess. + * Resizing the original screen buffer can hang `conhost.exe` on Windows 10. + Enabling the legacy console is a workaround. + * If a program changes the screen buffer and then exits, relying on the OS + to restore the original screen buffer, that restoration probably will not + happen with winpty. winpty's behavior can probably be improved here. + * Improved color handling: + * DkGray-on-Black text was previously hiddenly completely. Now it is + output as DkGray, with a fallback to LtGray on terminals that don't + recognize the intense colors. + [#39](https://github.com/rprichard/winpty/issues/39). + * The console is always initialized to LtGray-on-Black, regardless of the + user setting, which matches the console color heuristic, which translates + LtGray-on-Black to "reset SGR parameters." + * Shift-Tab is recognized correctly now. + [#19](https://github.com/rprichard/winpty/issues/19) + * Add a `--version` argument to `winpty-agent.exe` and the UNIX adapter. The + argument reports the nominal version (i.e. the `VERSION.txt`) file, with a + "VERSION_SUFFIX" appended (defaulted to `-dev`), and a git commit hash, if + the `git` command successfully reports a hash during the build. The `git` + command is invoked by either `make` or `gyp`. + * The agent now combines `ReadConsoleOutputW` calls when it polls the console + buffer for changes, which may slightly reduce its CPU overhead. + [#44](https://github.com/rprichard/winpty/issues/44). + * A `gyp` file is added to help compile with MSVC. + * The code can now be compiled as C++11 code, though it isn't by default. + [bde8922e08](https://github.com/rprichard/winpty/commit/bde8922e08c3638e01ecc7b581b676c314163e3c) + * If winpty can't create a new window station, it charges ahead rather than + aborting. This situation might happen if winpty were started from an SSH + session. + * Debugging improvements: + * `WINPTYDBG` is renamed to `WINPTY_DEBUG`, and a new `WINPTY_SHOW_CONSOLE` + variable keeps the underlying console visible. + * A `winpty-debugserver.exe` program is built and shipped by default. It + collects the trace output enabled with `WINPTY_DEBUG`. + * The `Makefile` build of winpty now compiles `winpty-agent.exe` and + `winpty.dll` with -O2. + +# Version 0.1.1 (2012-07-28) + +Minor bugfix release. + +# Version 0.1 (2012-04-17) + +Initial release. diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/VERSION.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/VERSION.txt new file mode 100644 index 00000000..5d47ff8c --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/VERSION.txt @@ -0,0 +1 @@ +0.4.4-dev diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/configure b/services/edge-agent/node_modules/node-pty/deps/winpty/configure new file mode 100644 index 00000000..6d37d65b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/configure @@ -0,0 +1,167 @@ +#!/bin/bash +# +# Copyright (c) 2011-2015 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +# +# findTool(desc, commandList) +# +# Searches commandLine for the first command in the PATH and returns it. +# Prints an error and aborts the script if no match is found. +# +FINDTOOL_OUT="" +function findTool { + DESC=$1 + OPTIONS=$2 + for CMD in ${OPTIONS}; do + if (which $CMD &>/dev/null) then + echo "Found $DESC: $CMD" + FINDTOOL_OUT="$CMD" + return + fi + done + echo "Error: could not find $DESC. One of these should be in your PATH:" + for CMD in ${OPTIONS}; do + echo " * $CMD" + done + exit 1 +} + +IS_CYGWIN=0 +IS_MSYS1=0 +IS_MSYS2=0 + +# Link parts of the Cygwin binary statically to aid in redistribution? The +# binary still links dynamically against the main DLL. The MinGW binaries are +# also statically linked and therefore depend only on Windows DLLs. I started +# linking the Cygwin/MSYS binary statically, because G++ 4.7 changed the +# Windows C++ ABI. +UNIX_LDFLAGS_STATIC='-static -static-libgcc -static-libstdc++' + +# Detect the environment -- Cygwin or MSYS. +case $(uname -s) in + CYGWIN*) + echo 'uname -s identifies a Cygwin environment.' + IS_CYGWIN=1 + case $(uname -m) in + i686) + echo 'uname -m identifies an i686 environment.' + UNIX_CXX=i686-pc-cygwin-g++ + MINGW_CXX=i686-w64-mingw32-g++ + ;; + x86_64) + echo 'uname -m identifies an x86_64 environment.' + UNIX_CXX=x86_64-pc-cygwin-g++ + MINGW_CXX=x86_64-w64-mingw32-g++ + ;; + *) + echo 'Error: uname -m did not match either i686 or x86_64.' + exit 1 + ;; + esac + ;; + MSYS*|MINGW*) + # MSYS2 notes: + # - MSYS2 offers two shortcuts to open an environment: + # - MinGW-w64 Win32 Shell. This env reports a `uname -s` of + # MINGW32_NT-6.1 on 32-bit Win7. The MinGW-w64 compiler + # (i686-w64-mingw32-g++.exe) is in the PATH. + # - MSYS2 Shell. `uname -s` instead reports MSYS_NT-6.1. + # The i686-w64-mingw32-g++ compiler is not in the PATH. + # - MSYS2 appears to use MinGW-w64, not the older mingw.org. + # MSYS notes: + # - `uname -s` is always MINGW32_NT-6.1 on Win7. + echo 'uname -s identifies an MSYS/MSYS2 environment.' + case $(uname -m) in + i686) + echo 'uname -m identifies an i686 environment.' + UNIX_CXX=i686-pc-msys-g++ + if echo "$(uname -r)" | grep '^1[.]' > /dev/null; then + # The MSYS-targeting compiler for the original 32-bit-only + # MSYS does not recognize the -static-libstdc++ flag, and + # it does not work with -static, because it tries to link + # statically with the core MSYS library and fails. + # + # Distinguish between the two using the major version + # number of `uname -r`: + # + # MSYS uname -r: 1.0.18(0.48/3/2) + # MSYS2 uname -r: 2.0.0(0.284/5/3) + # + # This is suboptimal because MSYS2 is not actually the + # second version of MSYS--it's a brand-new fork of Cygwin. + # + IS_MSYS1=1 + UNIX_LDFLAGS_STATIC= + MINGW_CXX=mingw32-g++ + else + IS_MSYS2=1 + MINGW_CXX=i686-w64-mingw32-g++.exe + fi + ;; + x86_64) + echo 'uname -m identifies an x86_64 environment.' + IS_MSYS2=1 + UNIX_CXX=x86_64-pc-msys-g++ + MINGW_CXX=x86_64-w64-mingw32-g++ + ;; + *) + echo 'Error: uname -m did not match either i686 or x86_64.' + exit 1 + ;; + esac + ;; + *) + echo 'Error: uname -s did not match either CYGWIN* or MINGW*.' + exit 1 + ;; +esac + +# Search the PATH and pick the first match. +findTool "Cygwin/MSYS G++ compiler" "$UNIX_CXX" +UNIX_CXX=$FINDTOOL_OUT +findTool "MinGW G++ compiler" "$MINGW_CXX" +MINGW_CXX=$FINDTOOL_OUT + +# Write config files. +echo Writing config.mk +echo UNIX_CXX=$UNIX_CXX > config.mk +echo UNIX_LDFLAGS_STATIC=$UNIX_LDFLAGS_STATIC >> config.mk +echo MINGW_CXX=$MINGW_CXX >> config.mk + +if test $IS_MSYS1 = 1; then + echo UNIX_CXXFLAGS += -DWINPTY_TARGET_MSYS1 >> config.mk + # The MSYS1 MinGW compiler has a bug that prevents inclusion of algorithm + # and math.h in normal C++11 mode. The workaround is to enable the gnu++11 + # mode instead. The bug was fixed on 2015-07-31, but as of 2016-02-26, the + # fix apparently hasn't been released. See + # http://ehc.ac/p/mingw/bugs/2250/. + echo MINGW_ENABLE_CXX11_FLAG := -std=gnu++11 >> config.mk +fi + +if test -d .git -a -f .git/HEAD -a -f .git/index && git rev-parse HEAD >&/dev/null; then + echo "Commit info: git" + echo 'COMMIT_HASH = $(shell git rev-parse HEAD)' >> config.mk + echo 'COMMIT_HASH_DEP := config.mk .git/HEAD .git/index' >> config.mk +else + echo "Commit info: none" + echo 'COMMIT_HASH := none' >> config.mk + echo 'COMMIT_HASH_DEP := config.mk' >> config.mk +fi diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/BufferResizeTests.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/BufferResizeTests.cc new file mode 100644 index 00000000..a5bb0748 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/BufferResizeTests.cc @@ -0,0 +1,90 @@ +#include +#include + +#include "TestUtil.cc" + +void dumpInfoToTrace() { + CONSOLE_SCREEN_BUFFER_INFO info; + assert(GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &info)); + trace("win=(%d,%d,%d,%d)", + (int)info.srWindow.Left, + (int)info.srWindow.Top, + (int)info.srWindow.Right, + (int)info.srWindow.Bottom); + trace("buf=(%d,%d)", + (int)info.dwSize.X, + (int)info.dwSize.Y); + trace("cur=(%d,%d)", + (int)info.dwCursorPosition.X, + (int)info.dwCursorPosition.Y); +} + +int main(int argc, char *argv[]) { + if (argc == 1) { + startChildProcess(L"CHILD"); + return 0; + } + + setWindowPos(0, 0, 1, 1); + + if (false) { + // Reducing the buffer height can move the window up. + setBufferSize(80, 25); + setWindowPos(0, 20, 80, 5); + Sleep(2000); + setBufferSize(80, 10); + } + + if (false) { + // Reducing the buffer height moves the window up and the buffer + // contents up too. + setBufferSize(80, 25); + setWindowPos(0, 20, 80, 5); + setCursorPos(0, 20); + printf("TEST1\nTEST2\nTEST3\nTEST4\n"); + fflush(stdout); + Sleep(2000); + setBufferSize(80, 10); + } + + if (false) { + // Reducing the buffer width can move the window left. + setBufferSize(80, 25); + setWindowPos(40, 0, 40, 25); + Sleep(2000); + setBufferSize(60, 25); + } + + if (false) { + // Sometimes the buffer contents are shifted up; sometimes they're + // shifted down. It seems to depend on the cursor position? + + // setBufferSize(80, 25); + // setWindowPos(0, 20, 80, 5); + // setCursorPos(0, 20); + // printf("TESTa\nTESTb\nTESTc\nTESTd\nTESTe"); + // fflush(stdout); + // setCursorPos(0, 0); + // printf("TEST1\nTEST2\nTEST3\nTEST4\nTEST5"); + // fflush(stdout); + // setCursorPos(0, 24); + // Sleep(5000); + // setBufferSize(80, 24); + + setBufferSize(80, 20); + setWindowPos(0, 10, 80, 10); + setCursorPos(0, 18); + + printf("TEST1\nTEST2"); + fflush(stdout); + setCursorPos(0, 18); + + Sleep(2000); + setBufferSize(80, 18); + } + + dumpInfoToTrace(); + Sleep(30000); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ChangeScreenBuffer.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ChangeScreenBuffer.cc new file mode 100644 index 00000000..701a2cb4 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ChangeScreenBuffer.cc @@ -0,0 +1,53 @@ +// A test program for CreateConsoleScreenBuffer / SetConsoleActiveScreenBuffer +// + +#include +#include +#include +#include +#include + +#include "TestUtil.cc" + +int main() +{ + HANDLE origBuffer = GetStdHandle(STD_OUTPUT_HANDLE); + HANDLE childBuffer = CreateConsoleScreenBuffer( + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, CONSOLE_TEXTMODE_BUFFER, NULL); + + SetConsoleActiveScreenBuffer(childBuffer); + + while (true) { + char buf[1024]; + CONSOLE_SCREEN_BUFFER_INFO info; + + assert(GetConsoleScreenBufferInfo(origBuffer, &info)); + trace("child.size=(%d,%d)", (int)info.dwSize.X, (int)info.dwSize.Y); + trace("child.cursor=(%d,%d)", (int)info.dwCursorPosition.X, (int)info.dwCursorPosition.Y); + trace("child.window=(%d,%d,%d,%d)", + (int)info.srWindow.Left, (int)info.srWindow.Top, + (int)info.srWindow.Right, (int)info.srWindow.Bottom); + trace("child.maxSize=(%d,%d)", (int)info.dwMaximumWindowSize.X, (int)info.dwMaximumWindowSize.Y); + + int ch = getch(); + sprintf(buf, "%02x\n", ch); + DWORD actual = 0; + WriteFile(childBuffer, buf, strlen(buf), &actual, NULL); + if (ch == 0x1b/*ESC*/ || ch == 0x03/*CTRL-C*/) + break; + + if (ch == 'b') { + setBufferSize(origBuffer, 40, 25); + } else if (ch == 'w') { + setWindowPos(origBuffer, 1, 1, 38, 23); + } else if (ch == 'c') { + setCursorPos(origBuffer, 10, 10); + } + } + + SetConsoleActiveScreenBuffer(origBuffer); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ClearConsole.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ClearConsole.cc new file mode 100644 index 00000000..f95f8c84 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ClearConsole.cc @@ -0,0 +1,72 @@ +/* + * Demonstrates that console clearing sets each cell's character to SP, not + * NUL, and it sets the attribute of each cell to the current text attribute. + * + * This confirms the MSDN instruction in the "Clearing the Screen" article. + * https://msdn.microsoft.com/en-us/library/windows/desktop/ms682022(v=vs.85).aspx + * It advises using GetConsoleScreenBufferInfo to get the current text + * attribute, then FillConsoleOutputCharacter and FillConsoleOutputAttribute to + * write to the console buffer. + */ + +#include + +#include +#include +#include + +#include "TestUtil.cc" + +int main(int argc, char *argv[]) { + if (argc == 1) { + startChildProcess(L"CHILD"); + return 0; + } + + const HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + + SetConsoleTextAttribute(conout, 0x24); + system("cls"); + + setWindowPos(0, 0, 1, 1); + setBufferSize(80, 25); + setWindowPos(0, 0, 80, 25); + + CHAR_INFO buf; + COORD bufSize = { 1, 1 }; + COORD bufCoord = { 0, 0 }; + SMALL_RECT rect = { 5, 5, 5, 5 }; + BOOL ret; + DWORD actual; + COORD writeCoord = { 5, 5 }; + + // After cls, each cell's character is a space, and its attributes are the + // default text attributes. + ret = ReadConsoleOutputW(conout, &buf, bufSize, bufCoord, &rect); + assert(ret && buf.Char.UnicodeChar == L' ' && buf.Attributes == 0x24); + + // Nevertheless, it is possible to change a cell to NUL. + ret = FillConsoleOutputCharacterW(conout, L'\0', 1, writeCoord, &actual); + assert(ret && actual == 1); + ret = ReadConsoleOutputW(conout, &buf, bufSize, bufCoord, &rect); + assert(ret && buf.Char.UnicodeChar == L'\0' && buf.Attributes == 0x24); + + // As well as a 0 attribute. (As one would expect, the cell is + // black-on-black.) + ret = FillConsoleOutputAttribute(conout, 0, 1, writeCoord, &actual); + assert(ret && actual == 1); + ret = ReadConsoleOutputW(conout, &buf, bufSize, bufCoord, &rect); + assert(ret && buf.Char.UnicodeChar == L'\0' && buf.Attributes == 0); + ret = FillConsoleOutputCharacterW(conout, L'X', 1, writeCoord, &actual); + assert(ret && actual == 1); + ret = ReadConsoleOutputW(conout, &buf, bufSize, bufCoord, &rect); + assert(ret && buf.Char.UnicodeChar == L'X' && buf.Attributes == 0); + + // The 'X' is invisible. + countDown(3); + + ret = FillConsoleOutputAttribute(conout, 0x42, 1, writeCoord, &actual); + assert(ret && actual == 1); + + countDown(5); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ConinMode.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ConinMode.cc new file mode 100644 index 00000000..1e1428d8 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ConinMode.cc @@ -0,0 +1,117 @@ +#include + +#include +#include +#include + +#include +#include + +static HANDLE getConin() { + HANDLE conin = GetStdHandle(STD_INPUT_HANDLE); + if (conin == INVALID_HANDLE_VALUE) { + fprintf(stderr, "error: cannot get stdin\n"); + exit(1); + } + return conin; +} + +static DWORD getConsoleMode() { + DWORD mode = 0; + if (!GetConsoleMode(getConin(), &mode)) { + fprintf(stderr, "error: GetConsoleMode failed (is stdin a console?)\n"); + exit(1); + } + return mode; +} + +static void setConsoleMode(DWORD mode) { + if (!SetConsoleMode(getConin(), mode)) { + fprintf(stderr, "error: SetConsoleMode failed (is stdin a console?)\n"); + exit(1); + } +} + +static long parseInt(const std::string &s) { + errno = 0; + char *endptr = nullptr; + long result = strtol(s.c_str(), &endptr, 0); + if (errno != 0 || !endptr || *endptr != '\0') { + fprintf(stderr, "error: could not parse integral argument '%s'\n", s.c_str()); + exit(1); + } + return result; +} + +static void usage() { + printf("Usage: ConinMode [verb] [options]\n"); + printf("Verbs:\n"); + printf(" [info] Dumps info about mode flags.\n"); + printf(" get Prints the mode DWORD.\n"); + printf(" set VALUE Sets the mode to VALUE, which can be decimal, hex, or octal.\n"); + printf(" set VALUE MASK\n"); + printf(" Same as `set VALUE`, but only alters the bits in MASK.\n"); + exit(1); +} + +struct { + const char *name; + DWORD value; +} kInputFlags[] = { + "ENABLE_PROCESSED_INPUT", ENABLE_PROCESSED_INPUT, // 0x0001 + "ENABLE_LINE_INPUT", ENABLE_LINE_INPUT, // 0x0002 + "ENABLE_ECHO_INPUT", ENABLE_ECHO_INPUT, // 0x0004 + "ENABLE_WINDOW_INPUT", ENABLE_WINDOW_INPUT, // 0x0008 + "ENABLE_MOUSE_INPUT", ENABLE_MOUSE_INPUT, // 0x0010 + "ENABLE_INSERT_MODE", ENABLE_INSERT_MODE, // 0x0020 + "ENABLE_QUICK_EDIT_MODE", ENABLE_QUICK_EDIT_MODE, // 0x0040 + "ENABLE_EXTENDED_FLAGS", ENABLE_EXTENDED_FLAGS, // 0x0080 + "ENABLE_VIRTUAL_TERMINAL_INPUT", 0x0200/*ENABLE_VIRTUAL_TERMINAL_INPUT*/, // 0x0200 +}; + +int main(int argc, char *argv[]) { + std::vector args; + for (size_t i = 1; i < argc; ++i) { + args.push_back(argv[i]); + } + + if (args.empty() || args.size() == 1 && args[0] == "info") { + DWORD mode = getConsoleMode(); + printf("mode: 0x%lx\n", mode); + for (const auto &flag : kInputFlags) { + printf("%-29s 0x%04lx %s\n", flag.name, flag.value, flag.value & mode ? "ON" : "off"); + mode &= ~flag.value; + } + for (int i = 0; i < 32; ++i) { + if (mode & (1u << i)) { + printf("Unrecognized flag: %04x\n", (1u << i)); + } + } + return 0; + } + + const auto verb = args[0]; + + if (verb == "set") { + if (args.size() == 2) { + const DWORD newMode = parseInt(args[1]); + setConsoleMode(newMode); + } else if (args.size() == 3) { + const DWORD mode = parseInt(args[1]); + const DWORD mask = parseInt(args[2]); + const int newMode = (getConsoleMode() & ~mask) | (mode & mask); + setConsoleMode(newMode); + } else { + usage(); + } + } else if (verb == "get") { + if (args.size() != 1) { + usage(); + } + printf("0x%lx\n", getConsoleMode()); + } else { + usage(); + } + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ConinMode.ps1 b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ConinMode.ps1 new file mode 100644 index 00000000..ecfe8f03 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ConinMode.ps1 @@ -0,0 +1,116 @@ +# +# PowerShell script for controlling the console QuickEdit and InsertMode flags. +# +# Turn QuickEdit off to interact with mouse-driven console programs. +# +# Usage: +# +# powershell .\ConinMode.ps1 [Options] +# +# Options: +# -QuickEdit [on/off] +# -InsertMode [on/off] +# -Mode [integer] +# + +param ( + [ValidateSet("on", "off")][string] $QuickEdit, + [ValidateSet("on", "off")][string] $InsertMode, + [int] $Mode +) + +$signature = @' +[DllImport("kernel32.dll", SetLastError = true)] +public static extern IntPtr GetStdHandle(int nStdHandle); + +[DllImport("kernel32.dll", SetLastError = true)] +public static extern uint GetConsoleMode( + IntPtr hConsoleHandle, + out uint lpMode); + +[DllImport("kernel32.dll", SetLastError = true)] +public static extern uint SetConsoleMode( + IntPtr hConsoleHandle, + uint dwMode); + +public const int STD_INPUT_HANDLE = -10; +public const int ENABLE_INSERT_MODE = 0x0020; +public const int ENABLE_QUICK_EDIT_MODE = 0x0040; +public const int ENABLE_EXTENDED_FLAGS = 0x0080; +'@ + +$WinAPI = Add-Type -MemberDefinition $signature ` + -Name WinAPI -Namespace ConinModeScript ` + -PassThru + +function GetConIn { + $ret = $WinAPI::GetStdHandle($WinAPI::STD_INPUT_HANDLE) + if ($ret -eq -1) { + throw "error: cannot get stdin" + } + return $ret +} + +function GetConsoleMode { + $conin = GetConIn + $mode = 0 + $ret = $WinAPI::GetConsoleMode($conin, [ref]$mode) + if ($ret -eq 0) { + throw "GetConsoleMode failed (is stdin a console?)" + } + return $mode +} + +function SetConsoleMode($mode) { + $conin = GetConIn + $ret = $WinAPI::SetConsoleMode($conin, $mode) + if ($ret -eq 0) { + throw "SetConsoleMode failed (is stdin a console?)" + } +} + +$oldMode = GetConsoleMode +$newMode = $oldMode +$doingSomething = $false + +if ($PSBoundParameters.ContainsKey("Mode")) { + $newMode = $Mode + $doingSomething = $true +} + +if ($QuickEdit + $InsertMode -ne "") { + if (!($newMode -band $WinAPI::ENABLE_EXTENDED_FLAGS)) { + # We can't enable an extended flag without overwriting the existing + # QuickEdit/InsertMode flags. AFAICT, there is no way to query their + # existing values, so at least we can choose sensible defaults. + $newMode = $newMode -bor $WinAPI::ENABLE_EXTENDED_FLAGS + $newMode = $newMode -bor $WinAPI::ENABLE_QUICK_EDIT_MODE + $newMode = $newMode -bor $WinAPI::ENABLE_INSERT_MODE + $doingSomething = $true + } +} + +if ($QuickEdit -eq "on") { + $newMode = $newMode -bor $WinAPI::ENABLE_QUICK_EDIT_MODE + $doingSomething = $true +} elseif ($QuickEdit -eq "off") { + $newMode = $newMode -band (-bnot $WinAPI::ENABLE_QUICK_EDIT_MODE) + $doingSomething = $true +} + +if ($InsertMode -eq "on") { + $newMode = $newMode -bor $WinAPI::ENABLE_INSERT_MODE + $doingSomething = $true +} elseif ($InsertMode -eq "off") { + $newMode = $newMode -band (-bnot $WinAPI::ENABLE_INSERT_MODE) + $doingSomething = $true +} + +if ($doingSomething) { + echo "old mode: $oldMode" + SetConsoleMode $newMode + $newMode = GetConsoleMode + echo "new mode: $newMode" +} else { + echo "mode: $oldMode" +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ConoutMode.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ConoutMode.cc new file mode 100644 index 00000000..100e0c7b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ConoutMode.cc @@ -0,0 +1,113 @@ +#include + +#include +#include +#include + +#include +#include + +static HANDLE getConout() { + HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + if (conout == INVALID_HANDLE_VALUE) { + fprintf(stderr, "error: cannot get stdout\n"); + exit(1); + } + return conout; +} + +static DWORD getConsoleMode() { + DWORD mode = 0; + if (!GetConsoleMode(getConout(), &mode)) { + fprintf(stderr, "error: GetConsoleMode failed (is stdout a console?)\n"); + exit(1); + } + return mode; +} + +static void setConsoleMode(DWORD mode) { + if (!SetConsoleMode(getConout(), mode)) { + fprintf(stderr, "error: SetConsoleMode failed (is stdout a console?)\n"); + exit(1); + } +} + +static long parseInt(const std::string &s) { + errno = 0; + char *endptr = nullptr; + long result = strtol(s.c_str(), &endptr, 0); + if (errno != 0 || !endptr || *endptr != '\0') { + fprintf(stderr, "error: could not parse integral argument '%s'\n", s.c_str()); + exit(1); + } + return result; +} + +static void usage() { + printf("Usage: ConoutMode [verb] [options]\n"); + printf("Verbs:\n"); + printf(" [info] Dumps info about mode flags.\n"); + printf(" get Prints the mode DWORD.\n"); + printf(" set VALUE Sets the mode to VALUE, which can be decimal, hex, or octal.\n"); + printf(" set VALUE MASK\n"); + printf(" Same as `set VALUE`, but only alters the bits in MASK.\n"); + exit(1); +} + +struct { + const char *name; + DWORD value; +} kOutputFlags[] = { + "ENABLE_PROCESSED_OUTPUT", ENABLE_PROCESSED_OUTPUT, // 0x0001 + "ENABLE_WRAP_AT_EOL_OUTPUT", ENABLE_WRAP_AT_EOL_OUTPUT, // 0x0002 + "ENABLE_VIRTUAL_TERMINAL_PROCESSING", 0x0004/*ENABLE_VIRTUAL_TERMINAL_PROCESSING*/, // 0x0004 + "DISABLE_NEWLINE_AUTO_RETURN", 0x0008/*DISABLE_NEWLINE_AUTO_RETURN*/, // 0x0008 + "ENABLE_LVB_GRID_WORLDWIDE", 0x0010/*ENABLE_LVB_GRID_WORLDWIDE*/, //0x0010 +}; + +int main(int argc, char *argv[]) { + std::vector args; + for (size_t i = 1; i < argc; ++i) { + args.push_back(argv[i]); + } + + if (args.empty() || args.size() == 1 && args[0] == "info") { + DWORD mode = getConsoleMode(); + printf("mode: 0x%lx\n", mode); + for (const auto &flag : kOutputFlags) { + printf("%-34s 0x%04lx %s\n", flag.name, flag.value, flag.value & mode ? "ON" : "off"); + mode &= ~flag.value; + } + for (int i = 0; i < 32; ++i) { + if (mode & (1u << i)) { + printf("Unrecognized flag: %04x\n", (1u << i)); + } + } + return 0; + } + + const auto verb = args[0]; + + if (verb == "set") { + if (args.size() == 2) { + const DWORD newMode = parseInt(args[1]); + setConsoleMode(newMode); + } else if (args.size() == 3) { + const DWORD mode = parseInt(args[1]); + const DWORD mask = parseInt(args[2]); + const int newMode = (getConsoleMode() & ~mask) | (mode & mask); + setConsoleMode(newMode); + } else { + usage(); + } + } else if (verb == "get") { + if (args.size() != 1) { + usage(); + } + printf("0x%lx\n", getConsoleMode()); + } else { + usage(); + } + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/DebugClient.py b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/DebugClient.py new file mode 100644 index 00000000..cd12df89 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/DebugClient.py @@ -0,0 +1,42 @@ +#!python +# Run with native CPython. Needs pywin32 extensions. + +# Copyright (c) 2011-2012 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import winerror +import win32pipe +import win32file +import win32api +import sys +import pywintypes +import time + +if len(sys.argv) != 2: + print("Usage: %s message" % sys.argv[0]) + sys.exit(1) + +message = "[%05.3f %s]: %s" % (time.time() % 100000, sys.argv[0], sys.argv[1]) + +win32pipe.CallNamedPipe( + "\\\\.\\pipe\\DebugServer", + message.encode(), + 16, + win32pipe.NMPWAIT_WAIT_FOREVER) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/DebugServer.py b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/DebugServer.py new file mode 100644 index 00000000..3fc068ba --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/DebugServer.py @@ -0,0 +1,63 @@ +#!python +# +# Run with native CPython. Needs pywin32 extensions. + +# Copyright (c) 2011-2012 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import win32pipe +import win32api +import win32file +import time +import threading +import sys + +# A message may not be larger than this size. +MSG_SIZE=4096 + +serverPipe = win32pipe.CreateNamedPipe( + "\\\\.\\pipe\\DebugServer", + win32pipe.PIPE_ACCESS_DUPLEX, + win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_READMODE_MESSAGE, + win32pipe.PIPE_UNLIMITED_INSTANCES, + MSG_SIZE, + MSG_SIZE, + 10 * 1000, + None) +while True: + win32pipe.ConnectNamedPipe(serverPipe, None) + (ret, data) = win32file.ReadFile(serverPipe, MSG_SIZE) + print(data.decode()) + sys.stdout.flush() + + # The client uses CallNamedPipe to send its message. CallNamedPipe waits + # for a reply message. If I send a reply, however, using WriteFile, then + # sometimes WriteFile fails with: + # pywintypes.error: (232, 'WriteFile', 'The pipe is being closed.') + # I can't figure out how to write a strictly correct pipe server, but if + # I comment out the WriteFile line, then everything seems to work. I + # think the DisconnectNamedPipe call aborts the client's CallNamedPipe + # call normally. + + try: + win32file.WriteFile(serverPipe, b'OK') + except: + pass + win32pipe.DisconnectNamedPipe(serverPipe) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/DumpLines.py b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/DumpLines.py new file mode 100644 index 00000000..40049961 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/DumpLines.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +import sys + +for i in range(1, int(sys.argv[1]) + 1): + print i, "X" * 78 diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/EnableExtendedFlags.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/EnableExtendedFlags.txt new file mode 100644 index 00000000..37914dac --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/EnableExtendedFlags.txt @@ -0,0 +1,46 @@ +Note regarding ENABLE_EXTENDED_FLAGS (2016-05-30) + +There is a complicated interaction between the ENABLE_EXTENDED_FLAGS flag +and the ENABLE_QUICK_EDIT_MODE and ENABLE_INSERT_MODE flags (presumably for +backwards compatibility?). I studied the behavior on Windows 7 and Windows +10, with both the old and new consoles, and I didn't see any differences +between versions. Here's what I seemed to observe: + + - The console has three flags internally: + - QuickEdit + - InsertMode + - ExtendedFlags + + - SetConsoleMode psuedocode: + void SetConsoleMode(..., DWORD mode) { + ExtendedFlags = (mode & (ENABLE_EXTENDED_FLAGS + | ENABLE_QUICK_EDIT_MODE + | ENABLE_INSERT_MODE )) != 0; + if (ExtendedFlags) { + QuickEdit = (mode & ENABLE_QUICK_EDIT_MODE) != 0; + InsertMode = (mode & ENABLE_INSERT_MODE) != 0; + } + } + + - Setting QuickEdit or InsertMode from the properties dialog GUI does not + affect the ExtendedFlags setting -- it simply toggles the one flag. + + - GetConsoleMode psuedocode: + GetConsoleMode(..., DWORD *result) { + if (ExtendedFlags) { + *result |= ENABLE_EXTENDED_FLAGS; + if (QuickEdit) { *result |= ENABLE_QUICK_EDIT_MODE; } + if (InsertMode) { *result |= ENABLE_INSERT_MODE; } + } + } + +Effectively, the ExtendedFlags flags controls whether the other two flags +are visible/controlled by the user application. If they aren't visible, +though, there is no way for the user application to make them visible, +except by overwriting their values! Calling SetConsoleMode with just +ENABLE_EXTENDED_FLAGS would clear the extended flags we want to read. + +Consequently, if a program temporarily alters the QuickEdit flag (e.g. to +enable mouse input), it cannot restore the original values of the QuickEdit +and InsertMode flags, UNLESS every other console program cooperates by +keeping the ExtendedFlags flag set. diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP437-Consolas.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP437-Consolas.txt new file mode 100644 index 00000000..067bd382 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP437-Consolas.txt @@ -0,0 +1,528 @@ +================================== +Code Page 437, Consolas font +================================== + +Options: -face "Consolas" -family 0x36 +Chars: A2 A3 2014 3044 30FC 4000 + +FontSurvey "-face \"Consolas\" -family 0x36" + +Windows 7 +--------- + +Size 1: 1,3 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 1,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 2,5 BAD (HHHHHH) +Size 6: 3,6 BAD (HHHHHH) +Size 7: 3,6 BAD (HHHHHH) +Size 8: 4,8 BAD (HHHHHH) +Size 9: 4,9 BAD (HHHHHH) +Size 10: 5,10 BAD (HHHHHH) +Size 11: 5,11 BAD (HHHHHH) +Size 12: 6,12 BAD (HHHHHH) +Size 13: 6,13 BAD (HHHHHH) +Size 14: 7,14 BAD (HHHHHH) +Size 15: 7,15 BAD (HHHHHH) +Size 16: 8,16 BAD (HHHHHH) +Size 17: 8,17 BAD (HHHHHH) +Size 18: 8,18 BAD (HHHHHH) +Size 19: 9,19 BAD (HHHHHH) +Size 20: 9,20 BAD (HHHHHH) +Size 21: 10,22 BAD (HHHHHH) +Size 22: 10,22 BAD (HHHHHH) +Size 23: 11,23 BAD (HHHHHH) +Size 24: 11,24 BAD (HHHHHH) +Size 25: 12,25 BAD (HHHHHH) +Size 26: 12,26 BAD (HHHHHH) +Size 27: 13,27 BAD (HHHHHH) +Size 28: 13,28 BAD (HHHHHH) +Size 29: 14,29 BAD (HHHHHH) +Size 30: 14,30 BAD (HHHHHH) +Size 31: 15,31 BAD (HHHHHH) +Size 32: 15,32 BAD (HHHHHH) +Size 33: 15,33 BAD (HHHHHH) +Size 34: 16,34 BAD (HHHHHH) +Size 35: 16,36 BAD (HHHHHH) +Size 36: 17,36 BAD (HHHHHH) +Size 37: 17,37 BAD (HHHHHH) +Size 38: 18,38 BAD (HHHHHH) +Size 39: 18,39 BAD (HHHHHH) +Size 40: 19,40 BAD (HHHHHH) +Size 41: 19,41 BAD (HHHHHH) +Size 42: 20,42 BAD (HHHHHH) +Size 43: 20,43 BAD (HHHHHH) +Size 44: 21,44 BAD (HHHHHH) +Size 45: 21,45 BAD (HHHHHH) +Size 46: 22,46 BAD (HHHHHH) +Size 47: 22,47 BAD (HHHHHH) +Size 48: 23,48 BAD (HHHHHH) +Size 49: 23,49 BAD (HHHHHH) +Size 50: 23,50 BAD (HHHHHH) +Size 51: 24,51 BAD (HHHHHH) +Size 52: 24,52 BAD (HHHHHH) +Size 53: 25,53 BAD (HHHHHH) +Size 54: 25,54 BAD (HHHHHH) +Size 55: 26,55 BAD (HHHHHH) +Size 56: 26,56 BAD (HHHHHH) +Size 57: 27,57 BAD (HHHHHH) +Size 58: 27,58 BAD (HHHHHH) +Size 59: 28,59 BAD (HHHHHH) +Size 60: 28,60 BAD (HHHHHH) +Size 61: 29,61 BAD (HHHHHH) +Size 62: 29,62 BAD (HHHHHH) +Size 63: 30,64 BAD (HHHHHH) +Size 64: 30,64 BAD (HHHHHH) +Size 65: 31,65 BAD (HHHHHH) +Size 66: 31,66 BAD (HHHHHH) +Size 67: 31,67 BAD (HHHHHH) +Size 68: 32,68 BAD (HHHHHH) +Size 69: 32,69 BAD (HHHHHH) +Size 70: 33,70 BAD (HHHHHH) +Size 71: 33,71 BAD (HHHHHH) +Size 72: 34,72 BAD (HHHHHH) +Size 73: 34,73 BAD (HHHHHH) +Size 74: 35,74 BAD (HHHHHH) +Size 75: 35,75 BAD (HHHHHH) +Size 76: 36,76 BAD (HHHHHH) +Size 77: 36,77 BAD (HHHHHH) +Size 78: 37,78 BAD (HHHHHH) +Size 79: 37,79 BAD (HHHHHH) +Size 80: 38,80 BAD (HHHHHH) +Size 81: 38,81 BAD (HHHHHH) +Size 82: 39,82 BAD (HHHHHH) +Size 83: 39,83 BAD (HHHHHH) +Size 84: 39,84 BAD (HHHHHH) +Size 85: 40,85 BAD (HHHHHH) +Size 86: 40,86 BAD (HHHHHH) +Size 87: 41,87 BAD (HHHHHH) +Size 88: 41,88 BAD (HHHHHH) +Size 89: 42,89 BAD (HHHHHH) +Size 90: 42,90 BAD (HHHHHH) +Size 91: 43,91 BAD (HHHHHH) +Size 92: 43,92 BAD (HHHHHH) +Size 93: 44,93 BAD (HHHHHH) +Size 94: 44,94 BAD (HHHHHH) +Size 95: 45,95 BAD (HHHHHH) +Size 96: 45,96 BAD (HHHHHH) +Size 97: 46,97 BAD (HHHHHH) +Size 98: 46,98 BAD (HHHHHH) +Size 99: 46,99 BAD (HHHHHH) +Size 100: 47,100 BAD (HHHHHH) + +Windows 8 +--------- + +Size 1: 1,3 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 1,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 2,5 BAD (HHHHHH) +Size 6: 3,6 BAD (HHHHHH) +Size 7: 3,6 BAD (HHHHHH) +Size 8: 4,8 BAD (HHHHHH) +Size 9: 4,9 BAD (HHHHHH) +Size 10: 5,10 BAD (HHHHHH) +Size 11: 5,11 BAD (HHHHHH) +Size 12: 6,12 BAD (HHHHHH) +Size 13: 6,13 BAD (HHHHHH) +Size 14: 7,14 BAD (HHHHHH) +Size 15: 7,15 BAD (HHHHHH) +Size 16: 8,16 BAD (HHHHHH) +Size 17: 8,17 BAD (HHHHHH) +Size 18: 8,18 BAD (HHHHHH) +Size 19: 9,19 BAD (HHHHHH) +Size 20: 9,20 BAD (HHHHHH) +Size 21: 10,22 BAD (HHHHHH) +Size 22: 10,22 BAD (HHHHHH) +Size 23: 11,23 BAD (HHHHHH) +Size 24: 11,24 BAD (HHHHHH) +Size 25: 12,25 BAD (HHHHHH) +Size 26: 12,26 BAD (HHHHHH) +Size 27: 13,27 BAD (HHHHHH) +Size 28: 13,28 BAD (HHHHHH) +Size 29: 14,29 BAD (HHHHHH) +Size 30: 14,30 BAD (HHHHHH) +Size 31: 15,31 BAD (HHHHHH) +Size 32: 15,32 BAD (HHHHHH) +Size 33: 15,33 BAD (HHHHHH) +Size 34: 16,34 BAD (HHHHHH) +Size 35: 16,36 BAD (HHHHHH) +Size 36: 17,36 BAD (HHHHHH) +Size 37: 17,37 BAD (HHHHHH) +Size 38: 18,38 BAD (HHHHHH) +Size 39: 18,39 BAD (HHHHHH) +Size 40: 19,40 BAD (HHHHHH) +Size 41: 19,41 BAD (HHHHHH) +Size 42: 20,42 BAD (HHHHHH) +Size 43: 20,43 BAD (HHHHHH) +Size 44: 21,44 BAD (HHHHHH) +Size 45: 21,45 BAD (HHHHHH) +Size 46: 22,46 BAD (HHHHHH) +Size 47: 22,47 BAD (HHHHHH) +Size 48: 23,48 BAD (HHHHHH) +Size 49: 23,49 BAD (HHHHHH) +Size 50: 23,50 BAD (HHHHHH) +Size 51: 24,51 BAD (HHHHHH) +Size 52: 24,52 BAD (HHHHHH) +Size 53: 25,53 BAD (HHHHHH) +Size 54: 25,54 BAD (HHHHHH) +Size 55: 26,55 BAD (HHHHHH) +Size 56: 26,56 BAD (HHHHHH) +Size 57: 27,57 BAD (HHHHHH) +Size 58: 27,58 BAD (HHHHHH) +Size 59: 28,59 BAD (HHHHHH) +Size 60: 28,60 BAD (HHHHHH) +Size 61: 29,61 BAD (HHHHHH) +Size 62: 29,62 BAD (HHHHHH) +Size 63: 30,64 BAD (HHHHHH) +Size 64: 30,64 BAD (HHHHHH) +Size 65: 31,65 BAD (HHHHHH) +Size 66: 31,66 BAD (HHHHHH) +Size 67: 31,67 BAD (HHHHHH) +Size 68: 32,68 BAD (HHHHHH) +Size 69: 32,69 BAD (HHHHHH) +Size 70: 33,70 BAD (HHHHHH) +Size 71: 33,71 BAD (HHHHHH) +Size 72: 34,72 BAD (HHHHHH) +Size 73: 34,73 BAD (HHHHHH) +Size 74: 35,74 BAD (HHHHHH) +Size 75: 35,75 BAD (HHHHHH) +Size 76: 36,76 BAD (HHHHHH) +Size 77: 36,77 BAD (HHHHHH) +Size 78: 37,78 BAD (HHHHHH) +Size 79: 37,79 BAD (HHHHHH) +Size 80: 38,80 BAD (HHHHHH) +Size 81: 38,81 BAD (HHHHHH) +Size 82: 39,82 BAD (HHHHHH) +Size 83: 39,83 BAD (HHHHHH) +Size 84: 39,84 BAD (HHHHHH) +Size 85: 40,85 BAD (HHHHHH) +Size 86: 40,86 BAD (HHHHHH) +Size 87: 41,87 BAD (HHHHHH) +Size 88: 41,88 BAD (HHHHHH) +Size 89: 42,89 BAD (HHHHHH) +Size 90: 42,90 BAD (HHHHHH) +Size 91: 43,91 BAD (HHHHHH) +Size 92: 43,92 BAD (HHHHHH) +Size 93: 44,93 BAD (HHHHHH) +Size 94: 44,94 BAD (HHHHHH) +Size 95: 45,95 BAD (HHHHHH) +Size 96: 45,96 BAD (HHHHHH) +Size 97: 46,97 BAD (HHHHHH) +Size 98: 46,98 BAD (HHHHHH) +Size 99: 46,99 BAD (HHHHHH) +Size 100: 47,100 BAD (HHHHHH) + +Windows 8.1 +----------- + +Size 1: 1,3 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 1,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 2,5 BAD (HHHHHH) +Size 6: 3,6 BAD (HHHHHH) +Size 7: 3,6 BAD (HHHHHH) +Size 8: 4,8 BAD (HHHHHH) +Size 9: 4,9 BAD (HHHHHH) +Size 10: 5,10 BAD (HHHHHH) +Size 11: 5,11 BAD (HHHHHH) +Size 12: 6,12 BAD (HHHHHH) +Size 13: 6,13 BAD (HHHHHH) +Size 14: 7,14 BAD (HHHHHH) +Size 15: 7,15 BAD (HHHHHH) +Size 16: 8,16 BAD (HHHHHH) +Size 17: 8,17 BAD (HHHHHH) +Size 18: 8,18 BAD (HHHHHH) +Size 19: 9,19 BAD (HHHHHH) +Size 20: 9,20 BAD (HHHHHH) +Size 21: 10,22 BAD (HHHHHH) +Size 22: 10,22 BAD (HHHHHH) +Size 23: 11,23 BAD (HHHHHH) +Size 24: 11,24 BAD (HHHHHH) +Size 25: 12,25 BAD (HHHHHH) +Size 26: 12,26 BAD (HHHHHH) +Size 27: 13,27 BAD (HHHHHH) +Size 28: 13,28 BAD (HHHHHH) +Size 29: 14,29 BAD (HHHHHH) +Size 30: 14,30 BAD (HHHHHH) +Size 31: 15,31 BAD (HHHHHH) +Size 32: 15,32 BAD (HHHHHH) +Size 33: 15,33 BAD (HHHHHH) +Size 34: 16,34 BAD (HHHHHH) +Size 35: 16,36 BAD (HHHHHH) +Size 36: 17,36 BAD (HHHHHH) +Size 37: 17,37 BAD (HHHHHH) +Size 38: 18,38 BAD (HHHHHH) +Size 39: 18,39 BAD (HHHHHH) +Size 40: 19,40 BAD (HHHHHH) +Size 41: 19,41 BAD (HHHHHH) +Size 42: 20,42 BAD (HHHHHH) +Size 43: 20,43 BAD (HHHHHH) +Size 44: 21,44 BAD (HHHHHH) +Size 45: 21,45 BAD (HHHHHH) +Size 46: 22,46 BAD (HHHHHH) +Size 47: 22,47 BAD (HHHHHH) +Size 48: 23,48 BAD (HHHHHH) +Size 49: 23,49 BAD (HHHHHH) +Size 50: 23,50 BAD (HHHHHH) +Size 51: 24,51 BAD (HHHHHH) +Size 52: 24,52 BAD (HHHHHH) +Size 53: 25,53 BAD (HHHHHH) +Size 54: 25,54 BAD (HHHHHH) +Size 55: 26,55 BAD (HHHHHH) +Size 56: 26,56 BAD (HHHHHH) +Size 57: 27,57 BAD (HHHHHH) +Size 58: 27,58 BAD (HHHHHH) +Size 59: 28,59 BAD (HHHHHH) +Size 60: 28,60 BAD (HHHHHH) +Size 61: 29,61 BAD (HHHHHH) +Size 62: 29,62 BAD (HHHHHH) +Size 63: 30,64 BAD (HHHHHH) +Size 64: 30,64 BAD (HHHHHH) +Size 65: 31,65 BAD (HHHHHH) +Size 66: 31,66 BAD (HHHHHH) +Size 67: 31,67 BAD (HHHHHH) +Size 68: 32,68 BAD (HHHHHH) +Size 69: 32,69 BAD (HHHHHH) +Size 70: 33,70 BAD (HHHHHH) +Size 71: 33,71 BAD (HHHHHH) +Size 72: 34,72 BAD (HHHHHH) +Size 73: 34,73 BAD (HHHHHH) +Size 74: 35,74 BAD (HHHHHH) +Size 75: 35,75 BAD (HHHHHH) +Size 76: 36,76 BAD (HHHHHH) +Size 77: 36,77 BAD (HHHHHH) +Size 78: 37,78 BAD (HHHHHH) +Size 79: 37,79 BAD (HHHHHH) +Size 80: 38,80 BAD (HHHHHH) +Size 81: 38,81 BAD (HHHHHH) +Size 82: 39,82 BAD (HHHHHH) +Size 83: 39,83 BAD (HHHHHH) +Size 84: 39,84 BAD (HHHHHH) +Size 85: 40,85 BAD (HHHHHH) +Size 86: 40,86 BAD (HHHHHH) +Size 87: 41,87 BAD (HHHHHH) +Size 88: 41,88 BAD (HHHHHH) +Size 89: 42,89 BAD (HHHHHH) +Size 90: 42,90 BAD (HHHHHH) +Size 91: 43,91 BAD (HHHHHH) +Size 92: 43,92 BAD (HHHHHH) +Size 93: 44,93 BAD (HHHHHH) +Size 94: 44,94 BAD (HHHHHH) +Size 95: 45,95 BAD (HHHHHH) +Size 96: 45,96 BAD (HHHHHH) +Size 97: 46,97 BAD (HHHHHH) +Size 98: 46,98 BAD (HHHHHH) +Size 99: 46,99 BAD (HHHHHH) +Size 100: 47,100 BAD (HHHHHH) + +Windows 10 14342 Old Console +---------------------------- + +Size 1: 1,3 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 1,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 2,5 BAD (HHHHHH) +Size 6: 3,6 BAD (HHHHHH) +Size 7: 3,6 BAD (HHHHHH) +Size 8: 4,8 BAD (HHHHHH) +Size 9: 4,9 BAD (HHHHHH) +Size 10: 5,10 BAD (HHHHHH) +Size 11: 5,11 BAD (HHHHHH) +Size 12: 6,12 BAD (HHHHHH) +Size 13: 6,13 BAD (HHHHHH) +Size 14: 7,14 BAD (HHHHHH) +Size 15: 7,15 BAD (HHHHHH) +Size 16: 8,16 BAD (HHHHHH) +Size 17: 8,17 BAD (HHHHHH) +Size 18: 8,18 BAD (HHHHHH) +Size 19: 9,19 BAD (HHHHHH) +Size 20: 9,20 BAD (HHHHHH) +Size 21: 10,22 BAD (HHHHHH) +Size 22: 10,22 BAD (HHHHHH) +Size 23: 11,23 BAD (HHHHHH) +Size 24: 11,24 BAD (HHHHHH) +Size 25: 12,25 BAD (HHHHHH) +Size 26: 12,26 BAD (HHHHHH) +Size 27: 13,27 BAD (HHHHHH) +Size 28: 13,28 BAD (HHHHHH) +Size 29: 14,29 BAD (HHHHHH) +Size 30: 14,30 BAD (HHHHHH) +Size 31: 15,31 BAD (HHHHHH) +Size 32: 15,32 BAD (HHHHHH) +Size 33: 15,33 BAD (HHHHHH) +Size 34: 16,34 BAD (HHHHHH) +Size 35: 16,36 BAD (HHHHHH) +Size 36: 17,36 BAD (HHHHHH) +Size 37: 17,37 BAD (HHHHHH) +Size 38: 18,38 BAD (HHHHHH) +Size 39: 18,39 BAD (HHHHHH) +Size 40: 19,40 BAD (HHHHHH) +Size 41: 19,41 BAD (HHHHHH) +Size 42: 20,42 BAD (HHHHHH) +Size 43: 20,43 BAD (HHHHHH) +Size 44: 21,44 BAD (HHHHHH) +Size 45: 21,45 BAD (HHHHHH) +Size 46: 22,46 BAD (HHHHHH) +Size 47: 22,47 BAD (HHHHHH) +Size 48: 23,48 BAD (HHHHHH) +Size 49: 23,49 BAD (HHHHHH) +Size 50: 23,50 BAD (HHHHHH) +Size 51: 24,51 BAD (HHHHHH) +Size 52: 24,52 BAD (HHHHHH) +Size 53: 25,53 BAD (HHHHHH) +Size 54: 25,54 BAD (HHHHHH) +Size 55: 26,55 BAD (HHHHHH) +Size 56: 26,56 BAD (HHHHHH) +Size 57: 27,57 BAD (HHHHHH) +Size 58: 27,58 BAD (HHHHHH) +Size 59: 28,59 BAD (HHHHHH) +Size 60: 28,60 BAD (HHHHHH) +Size 61: 29,61 BAD (HHHHHH) +Size 62: 29,62 BAD (HHHHHH) +Size 63: 30,64 BAD (HHHHHH) +Size 64: 30,64 BAD (HHHHHH) +Size 65: 31,65 BAD (HHHHHH) +Size 66: 31,66 BAD (HHHHHH) +Size 67: 31,67 BAD (HHHHHH) +Size 68: 32,68 BAD (HHHHHH) +Size 69: 32,69 BAD (HHHHHH) +Size 70: 33,70 BAD (HHHHHH) +Size 71: 33,71 BAD (HHHHHH) +Size 72: 34,72 BAD (HHHHHH) +Size 73: 34,73 BAD (HHHHHH) +Size 74: 35,74 BAD (HHHHHH) +Size 75: 35,75 BAD (HHHHHH) +Size 76: 36,76 BAD (HHHHHH) +Size 77: 36,77 BAD (HHHHHH) +Size 78: 37,78 BAD (HHHHHH) +Size 79: 37,79 BAD (HHHHHH) +Size 80: 38,80 BAD (HHHHHH) +Size 81: 38,81 BAD (HHHHHH) +Size 82: 39,82 BAD (HHHHHH) +Size 83: 39,83 BAD (HHHHHH) +Size 84: 39,84 BAD (HHHHHH) +Size 85: 40,85 BAD (HHHHHH) +Size 86: 40,86 BAD (HHHHHH) +Size 87: 41,87 BAD (HHHHHH) +Size 88: 41,88 BAD (HHHHHH) +Size 89: 42,89 BAD (HHHHHH) +Size 90: 42,90 BAD (HHHHHH) +Size 91: 43,91 BAD (HHHHHH) +Size 92: 43,92 BAD (HHHHHH) +Size 93: 44,93 BAD (HHHHHH) +Size 94: 44,94 BAD (HHHHHH) +Size 95: 45,95 BAD (HHHHHH) +Size 96: 45,96 BAD (HHHHHH) +Size 97: 46,97 BAD (HHHHHH) +Size 98: 46,98 BAD (HHHHHH) +Size 99: 46,99 BAD (HHHHHH) +Size 100: 47,100 BAD (HHHHHH) + +Windows 10 14342 New Console +---------------------------- + +Size 1: 1,1 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 1,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 2,5 BAD (HHHHHH) +Size 6: 3,6 BAD (HHHHHH) +Size 7: 3,7 BAD (HHHHHH) +Size 8: 4,8 BAD (HHHHHH) +Size 9: 4,9 BAD (HHHHHH) +Size 10: 5,10 BAD (HHHHHH) +Size 11: 5,11 BAD (HHHHHH) +Size 12: 6,12 BAD (HHHHHH) +Size 13: 6,13 BAD (HHHHHH) +Size 14: 7,14 BAD (HHHHHH) +Size 15: 7,15 BAD (HHHHHH) +Size 16: 8,16 BAD (HHHHHH) +Size 17: 8,17 BAD (HHHHHH) +Size 18: 8,18 BAD (HHHHHH) +Size 19: 9,19 BAD (HHHHHH) +Size 20: 9,20 BAD (HHHHHH) +Size 21: 10,21 BAD (HHHHHH) +Size 22: 10,22 BAD (HHHHHH) +Size 23: 11,23 BAD (HHHHHH) +Size 24: 11,24 BAD (HHHHHH) +Size 25: 12,25 BAD (HHHHHH) +Size 26: 12,26 BAD (HHHHHH) +Size 27: 13,27 BAD (HHHHHH) +Size 28: 13,28 BAD (HHHHHH) +Size 29: 14,29 BAD (HHHHHH) +Size 30: 14,30 BAD (HHHHHH) +Size 31: 15,31 BAD (HHHHHH) +Size 32: 15,32 BAD (HHHHHH) +Size 33: 15,33 BAD (HHHHHH) +Size 34: 16,34 BAD (HHHHHH) +Size 35: 16,35 BAD (HHHHHH) +Size 36: 17,36 BAD (HHHHHH) +Size 37: 17,37 BAD (HHHHHH) +Size 38: 18,38 BAD (HHHHHH) +Size 39: 18,39 BAD (HHHHHH) +Size 40: 19,40 BAD (HHHHHH) +Size 41: 19,41 BAD (HHHHHH) +Size 42: 20,42 BAD (HHHHHH) +Size 43: 20,43 BAD (HHHHHH) +Size 44: 21,44 BAD (HHHHHH) +Size 45: 21,45 BAD (HHHHHH) +Size 46: 22,46 BAD (HHHHHH) +Size 47: 22,47 BAD (HHHHHH) +Size 48: 23,48 BAD (HHHHHH) +Size 49: 23,49 BAD (HHHHHH) +Size 50: 23,50 BAD (HHHHHH) +Size 51: 24,51 BAD (HHHHHH) +Size 52: 24,52 BAD (HHHHHH) +Size 53: 25,53 BAD (HHHHHH) +Size 54: 25,54 BAD (HHHHHH) +Size 55: 26,55 BAD (HHHHHH) +Size 56: 26,56 BAD (HHHHHH) +Size 57: 27,57 BAD (HHHHHH) +Size 58: 27,58 BAD (HHHHHH) +Size 59: 28,59 BAD (HHHHHH) +Size 60: 28,60 BAD (HHHHHH) +Size 61: 29,61 BAD (HHHHHH) +Size 62: 29,62 BAD (HHHHHH) +Size 63: 30,63 BAD (HHHHHH) +Size 64: 30,64 BAD (HHHHHH) +Size 65: 31,65 BAD (HHHHHH) +Size 66: 31,66 BAD (HHHHHH) +Size 67: 31,67 BAD (HHHHHH) +Size 68: 32,68 BAD (HHHHHH) +Size 69: 32,69 BAD (HHHHHH) +Size 70: 33,70 BAD (HHHHHH) +Size 71: 33,71 BAD (HHHHHH) +Size 72: 34,72 BAD (HHHHHH) +Size 73: 34,73 BAD (HHHHHH) +Size 74: 35,74 BAD (HHHHHH) +Size 75: 35,75 BAD (HHHHHH) +Size 76: 36,76 BAD (HHHHHH) +Size 77: 36,77 BAD (HHHHHH) +Size 78: 37,78 BAD (HHHHHH) +Size 79: 37,79 BAD (HHHHHH) +Size 80: 38,80 BAD (HHHHHH) +Size 81: 38,81 BAD (HHHHHH) +Size 82: 39,82 BAD (HHHHHH) +Size 83: 39,83 BAD (HHHHHH) +Size 84: 39,84 BAD (HHHHHH) +Size 85: 40,85 BAD (HHHHHH) +Size 86: 40,86 BAD (HHHHHH) +Size 87: 41,87 BAD (HHHHHH) +Size 88: 41,88 BAD (HHHHHH) +Size 89: 42,89 BAD (HHHHHH) +Size 90: 42,90 BAD (HHHHHH) +Size 91: 43,91 BAD (HHHHHH) +Size 92: 43,92 BAD (HHHHHH) +Size 93: 44,93 BAD (HHHHHH) +Size 94: 44,94 BAD (HHHHHH) +Size 95: 45,95 BAD (HHHHHH) +Size 96: 45,96 BAD (HHHHHH) +Size 97: 46,97 BAD (HHHHHH) +Size 98: 46,98 BAD (HHHHHH) +Size 99: 46,99 BAD (HHHHHH) +Size 100: 47,100 BAD (HHHHHH) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP437-Lucida.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP437-Lucida.txt new file mode 100644 index 00000000..0eed93ad --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP437-Lucida.txt @@ -0,0 +1,633 @@ +================================== +Code Page 437, Lucida Console font +================================== + +Options: -face "Lucida Console" -family 0x36 +Chars: A2 A3 2014 3044 30FC 4000 + +FontSurvey "-face \"Lucida Console\" -family 0x36" + +Vista +----- + +Size 1: 1,2 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 2,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 3,5 BAD (HHHHHH) +Size 6: 4,6 BAD (HHHHHH) +Size 7: 4,7 BAD (HHHHHH) +Size 8: 5,8 BAD (HHHHHH) +Size 9: 5,9 BAD (HHHHHH) +Size 10: 6,10 BAD (HHHHHH) +Size 11: 7,11 BAD (HHHHHH) +Size 12: 7,12 BAD (HHHHHH) +Size 13: 8,13 BAD (HHHHHH) +Size 14: 8,14 BAD (HHHHHH) +Size 15: 9,15 BAD (HHHHHH) +Size 16: 10,16 BAD (HHHHHH) +Size 17: 10,17 BAD (HHHHHH) +Size 18: 11,18 BAD (HHHHHH) +Size 19: 11,19 BAD (HHHHHH) +Size 20: 12,20 BAD (HHHHHH) +Size 21: 13,21 BAD (HHHHHH) +Size 22: 13,22 BAD (HHHHHH) +Size 23: 14,23 BAD (HHHHHH) +Size 24: 14,24 BAD (HHHHHH) +Size 25: 15,25 BAD (HHHHHH) +Size 26: 16,26 BAD (HHHHHH) +Size 27: 16,27 BAD (HHHHHH) +Size 28: 17,28 BAD (HHHHHH) +Size 29: 17,29 BAD (HHHHHH) +Size 30: 18,30 BAD (HHHHHH) +Size 31: 19,31 BAD (HHHHHH) +Size 32: 19,32 BAD (HHHHHH) +Size 33: 20,33 BAD (HHHHHH) +Size 34: 20,34 BAD (HHHHHH) +Size 35: 21,35 BAD (HHHHHH) +Size 36: 22,36 BAD (HHHHHH) +Size 37: 22,37 BAD (HHHHHH) +Size 38: 23,38 BAD (HHHHHH) +Size 39: 23,39 BAD (HHHHHH) +Size 40: 24,40 BAD (HHHHHH) +Size 41: 25,41 BAD (HHHHHH) +Size 42: 25,42 BAD (HHHHHH) +Size 43: 26,43 BAD (HHHHHH) +Size 44: 27,44 BAD (HHHHHH) +Size 45: 27,45 BAD (HHHHHH) +Size 46: 28,46 BAD (HHHHHH) +Size 47: 28,47 BAD (HHHHHH) +Size 48: 29,48 BAD (HHHHHH) +Size 49: 30,49 BAD (HHHHHH) +Size 50: 30,50 BAD (HHHHHH) +Size 51: 31,51 BAD (HHHHHH) +Size 52: 31,52 BAD (HHHHHH) +Size 53: 32,53 BAD (HHHHHH) +Size 54: 33,54 BAD (HHHHHH) +Size 55: 33,55 BAD (HHHHHH) +Size 56: 34,56 BAD (HHHHHH) +Size 57: 34,57 BAD (HHHHHH) +Size 58: 35,58 BAD (HHHHHH) +Size 59: 36,59 BAD (HHHHHH) +Size 60: 36,60 BAD (HHHHHH) +Size 61: 37,61 BAD (HHHHHH) +Size 62: 37,62 BAD (HHHHHH) +Size 63: 38,63 BAD (HHHHHH) +Size 64: 39,65 BAD (HHHHHH) +Size 65: 39,65 BAD (HHHHHH) +Size 66: 40,66 BAD (HHHHHH) +Size 67: 40,67 BAD (HHHHHH) +Size 68: 41,68 BAD (HHHHHH) +Size 69: 42,69 BAD (HHHHHH) +Size 70: 42,70 BAD (HHHHHH) +Size 71: 43,71 BAD (HHHHHH) +Size 72: 43,72 BAD (HHHHHH) +Size 73: 44,73 BAD (HHHHHH) +Size 74: 45,74 BAD (HHHHHH) +Size 75: 45,75 BAD (HHHHHH) +Size 76: 46,76 BAD (HHHHHH) +Size 77: 46,77 BAD (HHHHHH) +Size 78: 47,78 BAD (HHHHHH) +Size 79: 48,79 BAD (HHHHHH) +Size 80: 48,80 BAD (HHHHHH) +Size 81: 49,81 BAD (HHHHHH) +Size 82: 49,82 BAD (HHHHHH) +Size 83: 50,83 BAD (HHHHHH) +Size 84: 51,84 BAD (HHHHHH) +Size 85: 51,85 BAD (HHHHHH) +Size 86: 52,86 BAD (HHHHHH) +Size 87: 52,87 BAD (HHHHHH) +Size 88: 53,88 BAD (HHHHHH) +Size 89: 54,89 BAD (HHHHHH) +Size 90: 54,90 BAD (HHHHHH) +Size 91: 55,91 BAD (HHHHHH) +Size 92: 55,92 BAD (HHHHHH) +Size 93: 56,93 BAD (HHHHHH) +Size 94: 57,94 BAD (HHHHHH) +Size 95: 57,95 BAD (HHHHHH) +Size 96: 58,96 BAD (HHHHHH) +Size 97: 58,97 BAD (HHHHHH) +Size 98: 59,98 BAD (HHHHHH) +Size 99: 60,99 BAD (HHHHHH) +Size 100: 60,100 BAD (HHHHHH) + + +Windows 7 +--------- + +Size 1: 1,2 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 2,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 3,5 BAD (HHHHHH) +Size 6: 4,6 BAD (HHHHHH) +Size 7: 4,7 BAD (HHHHHH) +Size 8: 5,8 BAD (HHHHHH) +Size 9: 5,9 BAD (HHHHHH) +Size 10: 6,10 BAD (HHHHHH) +Size 11: 7,11 BAD (HHHHHH) +Size 12: 7,12 BAD (HHHHHH) +Size 13: 8,13 BAD (HHHHHH) +Size 14: 8,14 BAD (HHHHHH) +Size 15: 9,15 BAD (HHHHHH) +Size 16: 10,16 BAD (HHHHHH) +Size 17: 10,17 BAD (HHHHHH) +Size 18: 11,18 BAD (HHHHHH) +Size 19: 11,19 BAD (HHHHHH) +Size 20: 12,20 BAD (HHHHHH) +Size 21: 13,21 BAD (HHHHHH) +Size 22: 13,22 BAD (HHHHHH) +Size 23: 14,23 BAD (HHHHHH) +Size 24: 14,24 BAD (HHHHHH) +Size 25: 15,25 BAD (HHHHHH) +Size 26: 16,26 BAD (HHHHHH) +Size 27: 16,27 BAD (HHHHHH) +Size 28: 17,28 BAD (HHHHHH) +Size 29: 17,29 BAD (HHHHHH) +Size 30: 18,30 BAD (HHHHHH) +Size 31: 19,31 BAD (HHHHHH) +Size 32: 19,32 BAD (HHHHHH) +Size 33: 20,33 BAD (HHHHHH) +Size 34: 20,34 BAD (HHHHHH) +Size 35: 21,35 BAD (HHHHHH) +Size 36: 22,36 BAD (HHHHHH) +Size 37: 22,37 BAD (HHHHHH) +Size 38: 23,38 BAD (HHHHHH) +Size 39: 23,39 BAD (HHHHHH) +Size 40: 24,40 BAD (HHHHHH) +Size 41: 25,41 BAD (HHHHHH) +Size 42: 25,42 BAD (HHHHHH) +Size 43: 26,43 BAD (HHHHHH) +Size 44: 27,44 BAD (HHHHHH) +Size 45: 27,45 BAD (HHHHHH) +Size 46: 28,46 BAD (HHHHHH) +Size 47: 28,47 BAD (HHHHHH) +Size 48: 29,48 BAD (HHHHHH) +Size 49: 30,49 BAD (HHHHHH) +Size 50: 30,50 BAD (HHHHHH) +Size 51: 31,51 BAD (HHHHHH) +Size 52: 31,52 BAD (HHHHHH) +Size 53: 32,53 BAD (HHHHHH) +Size 54: 33,54 BAD (HHHHHH) +Size 55: 33,55 BAD (HHHHHH) +Size 56: 34,56 BAD (HHHHHH) +Size 57: 34,57 BAD (HHHHHH) +Size 58: 35,58 BAD (HHHHHH) +Size 59: 36,59 BAD (HHHHHH) +Size 60: 36,60 BAD (HHHHHH) +Size 61: 37,61 BAD (HHHHHH) +Size 62: 37,62 BAD (HHHHHH) +Size 63: 38,63 BAD (HHHHHH) +Size 64: 39,65 BAD (HHHHHH) +Size 65: 39,65 BAD (HHHHHH) +Size 66: 40,66 BAD (HHHHHH) +Size 67: 40,67 BAD (HHHHHH) +Size 68: 41,68 BAD (HHHHHH) +Size 69: 42,69 BAD (HHHHHH) +Size 70: 42,70 BAD (HHHHHH) +Size 71: 43,71 BAD (HHHHHH) +Size 72: 43,72 BAD (HHHHHH) +Size 73: 44,73 BAD (HHHHHH) +Size 74: 45,74 BAD (HHHHHH) +Size 75: 45,75 BAD (HHHHHH) +Size 76: 46,76 BAD (HHHHHH) +Size 77: 46,77 BAD (HHHHHH) +Size 78: 47,78 BAD (HHHHHH) +Size 79: 48,79 BAD (HHHHHH) +Size 80: 48,80 BAD (HHHHHH) +Size 81: 49,81 BAD (HHHHHH) +Size 82: 49,82 BAD (HHHHHH) +Size 83: 50,83 BAD (HHHHHH) +Size 84: 51,84 BAD (HHHHHH) +Size 85: 51,85 BAD (HHHHHH) +Size 86: 52,86 BAD (HHHHHH) +Size 87: 52,87 BAD (HHHHHH) +Size 88: 53,88 BAD (HHHHHH) +Size 89: 54,89 BAD (HHHHHH) +Size 90: 54,90 BAD (HHHHHH) +Size 91: 55,91 BAD (HHHHHH) +Size 92: 55,92 BAD (HHHHHH) +Size 93: 56,93 BAD (HHHHHH) +Size 94: 57,94 BAD (HHHHHH) +Size 95: 57,95 BAD (HHHHHH) +Size 96: 58,96 BAD (HHHHHH) +Size 97: 58,97 BAD (HHHHHH) +Size 98: 59,98 BAD (HHHHHH) +Size 99: 60,99 BAD (HHHHHH) +Size 100: 60,100 BAD (HHHHHH) + +Windows 8 +--------- + +Size 1: 1,2 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 2,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 3,5 BAD (HHHHHH) +Size 6: 4,6 BAD (HHHHHH) +Size 7: 4,7 BAD (HHHHHH) +Size 8: 5,8 BAD (HHHHHH) +Size 9: 5,9 BAD (HHHHHH) +Size 10: 6,10 BAD (HHHHHH) +Size 11: 7,11 BAD (HHHHHH) +Size 12: 7,12 BAD (HHHHHH) +Size 13: 8,13 BAD (HHHHHH) +Size 14: 8,14 BAD (HHHHHH) +Size 15: 9,15 BAD (HHHHHH) +Size 16: 10,16 BAD (HHHHHH) +Size 17: 10,17 BAD (HHHHHH) +Size 18: 11,18 BAD (HHHHHH) +Size 19: 11,19 BAD (HHHHHH) +Size 20: 12,20 BAD (HHHHHH) +Size 21: 13,21 BAD (HHHHHH) +Size 22: 13,22 BAD (HHHHHH) +Size 23: 14,23 BAD (HHHHHH) +Size 24: 14,24 BAD (HHHHHH) +Size 25: 15,25 BAD (HHHHHH) +Size 26: 16,26 BAD (HHHHHH) +Size 27: 16,27 BAD (HHHHHH) +Size 28: 17,28 BAD (HHHHHH) +Size 29: 17,29 BAD (HHHHHH) +Size 30: 18,30 BAD (HHHHHH) +Size 31: 19,31 BAD (HHHHHH) +Size 32: 19,32 BAD (HHHHHH) +Size 33: 20,33 BAD (HHHHHH) +Size 34: 20,34 BAD (HHHHHH) +Size 35: 21,35 BAD (HHHHHH) +Size 36: 22,36 BAD (HHHHHH) +Size 37: 22,37 BAD (HHHHHH) +Size 38: 23,38 BAD (HHHHHH) +Size 39: 23,39 BAD (HHHHHH) +Size 40: 24,40 BAD (HHHHHH) +Size 41: 25,41 BAD (HHHHHH) +Size 42: 25,42 BAD (HHHHHH) +Size 43: 26,43 BAD (HHHHHH) +Size 44: 27,44 BAD (HHHHHH) +Size 45: 27,45 BAD (HHHHHH) +Size 46: 28,46 BAD (HHHHHH) +Size 47: 28,47 BAD (HHHHHH) +Size 48: 29,48 BAD (HHHHHH) +Size 49: 30,49 BAD (HHHHHH) +Size 50: 30,50 BAD (HHHHHH) +Size 51: 31,51 BAD (HHHHHH) +Size 52: 31,52 BAD (HHHHHH) +Size 53: 32,53 BAD (HHHHHH) +Size 54: 33,54 BAD (HHHHHH) +Size 55: 33,55 BAD (HHHHHH) +Size 56: 34,56 BAD (HHHHHH) +Size 57: 34,57 BAD (HHHHHH) +Size 58: 35,58 BAD (HHHHHH) +Size 59: 36,59 BAD (HHHHHH) +Size 60: 36,60 BAD (HHHHHH) +Size 61: 37,61 BAD (HHHHHH) +Size 62: 37,62 BAD (HHHHHH) +Size 63: 38,63 BAD (HHHHHH) +Size 64: 39,65 BAD (HHHHHH) +Size 65: 39,65 BAD (HHHHHH) +Size 66: 40,66 BAD (HHHHHH) +Size 67: 40,67 BAD (HHHHHH) +Size 68: 41,68 BAD (HHHHHH) +Size 69: 42,69 BAD (HHHHHH) +Size 70: 42,70 BAD (HHHHHH) +Size 71: 43,71 BAD (HHHHHH) +Size 72: 43,72 BAD (HHHHHH) +Size 73: 44,73 BAD (HHHHHH) +Size 74: 45,74 BAD (HHHHHH) +Size 75: 45,75 BAD (HHHHHH) +Size 76: 46,76 BAD (HHHHHH) +Size 77: 46,77 BAD (HHHHHH) +Size 78: 47,78 BAD (HHHHHH) +Size 79: 48,79 BAD (HHHHHH) +Size 80: 48,80 BAD (HHHHHH) +Size 81: 49,81 BAD (HHHHHH) +Size 82: 49,82 BAD (HHHHHH) +Size 83: 50,83 BAD (HHHHHH) +Size 84: 51,84 BAD (HHHHHH) +Size 85: 51,85 BAD (HHHHHH) +Size 86: 52,86 BAD (HHHHHH) +Size 87: 52,87 BAD (HHHHHH) +Size 88: 53,88 BAD (HHHHHH) +Size 89: 54,89 BAD (HHHHHH) +Size 90: 54,90 BAD (HHHHHH) +Size 91: 55,91 BAD (HHHHHH) +Size 92: 55,92 BAD (HHHHHH) +Size 93: 56,93 BAD (HHHHHH) +Size 94: 57,94 BAD (HHHHHH) +Size 95: 57,95 BAD (HHHHHH) +Size 96: 58,96 BAD (HHHHHH) +Size 97: 58,97 BAD (HHHHHH) +Size 98: 59,98 BAD (HHHHHH) +Size 99: 60,99 BAD (HHHHHH) +Size 100: 60,100 BAD (HHHHHH) + +Windows 8.1 +----------- + +Size 1: 1,2 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 2,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 3,5 BAD (HHHHHH) +Size 6: 4,6 BAD (HHHHHH) +Size 7: 4,7 BAD (HHHHHH) +Size 8: 5,8 BAD (HHHHHH) +Size 9: 5,9 BAD (HHHHHH) +Size 10: 6,10 BAD (HHHHHH) +Size 11: 7,11 BAD (HHHHHH) +Size 12: 7,12 BAD (HHHHHH) +Size 13: 8,13 BAD (HHHHHH) +Size 14: 8,14 BAD (HHHHHH) +Size 15: 9,15 BAD (HHHHHH) +Size 16: 10,16 BAD (HHHHHH) +Size 17: 10,17 BAD (HHHHHH) +Size 18: 11,18 BAD (HHHHHH) +Size 19: 11,19 BAD (HHHHHH) +Size 20: 12,20 BAD (HHHHHH) +Size 21: 13,21 BAD (HHHHHH) +Size 22: 13,22 BAD (HHHHHH) +Size 23: 14,23 BAD (HHHHHH) +Size 24: 14,24 BAD (HHHHHH) +Size 25: 15,25 BAD (HHHHHH) +Size 26: 16,26 BAD (HHHHHH) +Size 27: 16,27 BAD (HHHHHH) +Size 28: 17,28 BAD (HHHHHH) +Size 29: 17,29 BAD (HHHHHH) +Size 30: 18,30 BAD (HHHHHH) +Size 31: 19,31 BAD (HHHHHH) +Size 32: 19,32 BAD (HHHHHH) +Size 33: 20,33 BAD (HHHHHH) +Size 34: 20,34 BAD (HHHHHH) +Size 35: 21,35 BAD (HHHHHH) +Size 36: 22,36 BAD (HHHHHH) +Size 37: 22,37 BAD (HHHHHH) +Size 38: 23,38 BAD (HHHHHH) +Size 39: 23,39 BAD (HHHHHH) +Size 40: 24,40 BAD (HHHHHH) +Size 41: 25,41 BAD (HHHHHH) +Size 42: 25,42 BAD (HHHHHH) +Size 43: 26,43 BAD (HHHHHH) +Size 44: 27,44 BAD (HHHHHH) +Size 45: 27,45 BAD (HHHHHH) +Size 46: 28,46 BAD (HHHHHH) +Size 47: 28,47 BAD (HHHHHH) +Size 48: 29,48 BAD (HHHHHH) +Size 49: 30,49 BAD (HHHHHH) +Size 50: 30,50 BAD (HHHHHH) +Size 51: 31,51 BAD (HHHHHH) +Size 52: 31,52 BAD (HHHHHH) +Size 53: 32,53 BAD (HHHHHH) +Size 54: 33,54 BAD (HHHHHH) +Size 55: 33,55 BAD (HHHHHH) +Size 56: 34,56 BAD (HHHHHH) +Size 57: 34,57 BAD (HHHHHH) +Size 58: 35,58 BAD (HHHHHH) +Size 59: 36,59 BAD (HHHHHH) +Size 60: 36,60 BAD (HHHHHH) +Size 61: 37,61 BAD (HHHHHH) +Size 62: 37,62 BAD (HHHHHH) +Size 63: 38,63 BAD (HHHHHH) +Size 64: 39,65 BAD (HHHHHH) +Size 65: 39,65 BAD (HHHHHH) +Size 66: 40,66 BAD (HHHHHH) +Size 67: 40,67 BAD (HHHHHH) +Size 68: 41,68 BAD (HHHHHH) +Size 69: 42,69 BAD (HHHHHH) +Size 70: 42,70 BAD (HHHHHH) +Size 71: 43,71 BAD (HHHHHH) +Size 72: 43,72 BAD (HHHHHH) +Size 73: 44,73 BAD (HHHHHH) +Size 74: 45,74 BAD (HHHHHH) +Size 75: 45,75 BAD (HHHHHH) +Size 76: 46,76 BAD (HHHHHH) +Size 77: 46,77 BAD (HHHHHH) +Size 78: 47,78 BAD (HHHHHH) +Size 79: 48,79 BAD (HHHHHH) +Size 80: 48,80 BAD (HHHHHH) +Size 81: 49,81 BAD (HHHHHH) +Size 82: 49,82 BAD (HHHHHH) +Size 83: 50,83 BAD (HHHHHH) +Size 84: 51,84 BAD (HHHHHH) +Size 85: 51,85 BAD (HHHHHH) +Size 86: 52,86 BAD (HHHHHH) +Size 87: 52,87 BAD (HHHHHH) +Size 88: 53,88 BAD (HHHHHH) +Size 89: 54,89 BAD (HHHHHH) +Size 90: 54,90 BAD (HHHHHH) +Size 91: 55,91 BAD (HHHHHH) +Size 92: 55,92 BAD (HHHHHH) +Size 93: 56,93 BAD (HHHHHH) +Size 94: 57,94 BAD (HHHHHH) +Size 95: 57,95 BAD (HHHHHH) +Size 96: 58,96 BAD (HHHHHH) +Size 97: 58,97 BAD (HHHHHH) +Size 98: 59,98 BAD (HHHHHH) +Size 99: 60,99 BAD (HHHHHH) +Size 100: 60,100 BAD (HHHHHH) + +Windows 10 14342 Old Console +---------------------------- + +Size 1: 1,2 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 2,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 3,5 BAD (HHHHHH) +Size 6: 4,6 BAD (HHHHHH) +Size 7: 4,7 BAD (HHHHHH) +Size 8: 5,8 BAD (HHHHHH) +Size 9: 5,9 BAD (HHHHHH) +Size 10: 6,10 BAD (HHHHHH) +Size 11: 7,11 BAD (HHHHHH) +Size 12: 7,12 BAD (HHHHHH) +Size 13: 8,13 BAD (HHHHHH) +Size 14: 8,14 BAD (HHHHHH) +Size 15: 9,15 BAD (HHHHHH) +Size 16: 10,16 BAD (HHHHHH) +Size 17: 10,17 BAD (HHHHHH) +Size 18: 11,18 BAD (HHHHHH) +Size 19: 11,19 BAD (HHHHHH) +Size 20: 12,20 BAD (HHHHHH) +Size 21: 13,21 BAD (HHHHHH) +Size 22: 13,22 BAD (HHHHHH) +Size 23: 14,23 BAD (HHHHHH) +Size 24: 14,24 BAD (HHHHHH) +Size 25: 15,25 BAD (HHHHHH) +Size 26: 16,26 BAD (HHHHHH) +Size 27: 16,27 BAD (HHHHHH) +Size 28: 17,28 BAD (HHHHHH) +Size 29: 17,29 BAD (HHHHHH) +Size 30: 18,30 BAD (HHHHHH) +Size 31: 19,31 BAD (HHHHHH) +Size 32: 19,32 BAD (HHHHHH) +Size 33: 20,33 BAD (HHHHHH) +Size 34: 20,34 BAD (HHHHHH) +Size 35: 21,35 BAD (HHHHHH) +Size 36: 22,36 BAD (HHHHHH) +Size 37: 22,37 BAD (HHHHHH) +Size 38: 23,38 BAD (HHHHHH) +Size 39: 23,39 BAD (HHHHHH) +Size 40: 24,40 BAD (HHHHHH) +Size 41: 25,41 BAD (HHHHHH) +Size 42: 25,42 BAD (HHHHHH) +Size 43: 26,43 BAD (HHHHHH) +Size 44: 27,44 BAD (HHHHHH) +Size 45: 27,45 BAD (HHHHHH) +Size 46: 28,46 BAD (HHHHHH) +Size 47: 28,47 BAD (HHHHHH) +Size 48: 29,48 BAD (HHHHHH) +Size 49: 30,49 BAD (HHHHHH) +Size 50: 30,50 BAD (HHHHHH) +Size 51: 31,51 BAD (HHHHHH) +Size 52: 31,52 BAD (HHHHHH) +Size 53: 32,53 BAD (HHHHHH) +Size 54: 33,54 BAD (HHHHHH) +Size 55: 33,55 BAD (HHHHHH) +Size 56: 34,56 BAD (HHHHHH) +Size 57: 34,57 BAD (HHHHHH) +Size 58: 35,58 BAD (HHHHHH) +Size 59: 36,59 BAD (HHHHHH) +Size 60: 36,60 BAD (HHHHHH) +Size 61: 37,61 BAD (HHHHHH) +Size 62: 37,62 BAD (HHHHHH) +Size 63: 38,63 BAD (HHHHHH) +Size 64: 39,65 BAD (HHHHHH) +Size 65: 39,65 BAD (HHHHHH) +Size 66: 40,66 BAD (HHHHHH) +Size 67: 40,67 BAD (HHHHHH) +Size 68: 41,68 BAD (HHHHHH) +Size 69: 42,69 BAD (HHHHHH) +Size 70: 42,70 BAD (HHHHHH) +Size 71: 43,71 BAD (HHHHHH) +Size 72: 43,72 BAD (HHHHHH) +Size 73: 44,73 BAD (HHHHHH) +Size 74: 45,74 BAD (HHHHHH) +Size 75: 45,75 BAD (HHHHHH) +Size 76: 46,76 BAD (HHHHHH) +Size 77: 46,77 BAD (HHHHHH) +Size 78: 47,78 BAD (HHHHHH) +Size 79: 48,79 BAD (HHHHHH) +Size 80: 48,80 BAD (HHHHHH) +Size 81: 49,81 BAD (HHHHHH) +Size 82: 49,82 BAD (HHHHHH) +Size 83: 50,83 BAD (HHHHHH) +Size 84: 51,84 BAD (HHHHHH) +Size 85: 51,85 BAD (HHHHHH) +Size 86: 52,86 BAD (HHHHHH) +Size 87: 52,87 BAD (HHHHHH) +Size 88: 53,88 BAD (HHHHHH) +Size 89: 54,89 BAD (HHHHHH) +Size 90: 54,90 BAD (HHHHHH) +Size 91: 55,91 BAD (HHHHHH) +Size 92: 55,92 BAD (HHHHHH) +Size 93: 56,93 BAD (HHHHHH) +Size 94: 57,94 BAD (HHHHHH) +Size 95: 57,95 BAD (HHHHHH) +Size 96: 58,96 BAD (HHHHHH) +Size 97: 58,97 BAD (HHHHHH) +Size 98: 59,98 BAD (HHHHHH) +Size 99: 60,99 BAD (HHHHHH) +Size 100: 60,100 BAD (HHHHHH) + +Windows 10 14342 New Console +---------------------------- + +Size 1: 1,1 BAD (HHHHHH) +Size 2: 1,2 BAD (HHHHHH) +Size 3: 2,3 BAD (HHHHHH) +Size 4: 2,4 BAD (HHHHHH) +Size 5: 3,5 BAD (HHHHHH) +Size 6: 4,6 BAD (HHHHHH) +Size 7: 4,7 BAD (HHHHHH) +Size 8: 5,8 BAD (HHHHHH) +Size 9: 5,9 BAD (HHHHHH) +Size 10: 6,10 BAD (HHHHHH) +Size 11: 7,11 BAD (HHHHHH) +Size 12: 7,12 BAD (HHHHHH) +Size 13: 8,13 BAD (HHHHHH) +Size 14: 8,14 BAD (HHHHHH) +Size 15: 9,15 BAD (HHHHHH) +Size 16: 10,16 BAD (HHHHHH) +Size 17: 10,17 BAD (HHHHHH) +Size 18: 11,18 BAD (HHHHHH) +Size 19: 11,19 BAD (HHHHHH) +Size 20: 12,20 BAD (HHHHHH) +Size 21: 13,21 BAD (HHHHHH) +Size 22: 13,22 BAD (HHHHHH) +Size 23: 14,23 BAD (HHHHHH) +Size 24: 14,24 BAD (HHHHHH) +Size 25: 15,25 BAD (HHHHHH) +Size 26: 16,26 BAD (HHHHHH) +Size 27: 16,27 BAD (HHHHHH) +Size 28: 17,28 BAD (HHHHHH) +Size 29: 17,29 BAD (HHHHHH) +Size 30: 18,30 BAD (HHHHHH) +Size 31: 19,31 BAD (HHHHHH) +Size 32: 19,32 BAD (HHHHHH) +Size 33: 20,33 BAD (HHHHHH) +Size 34: 20,34 BAD (HHHHHH) +Size 35: 21,35 BAD (HHHHHH) +Size 36: 22,36 BAD (HHHHHH) +Size 37: 22,37 BAD (HHHHHH) +Size 38: 23,38 BAD (HHHHHH) +Size 39: 23,39 BAD (HHHHHH) +Size 40: 24,40 BAD (HHHHHH) +Size 41: 25,41 BAD (HHHHHH) +Size 42: 25,42 BAD (HHHHHH) +Size 43: 26,43 BAD (HHHHHH) +Size 44: 27,44 BAD (HHHHHH) +Size 45: 27,45 BAD (HHHHHH) +Size 46: 28,46 BAD (HHHHHH) +Size 47: 28,47 BAD (HHHHHH) +Size 48: 29,48 BAD (HHHHHH) +Size 49: 30,49 BAD (HHHHHH) +Size 50: 30,50 BAD (HHHHHH) +Size 51: 31,51 BAD (HHHHHH) +Size 52: 31,52 BAD (HHHHHH) +Size 53: 32,53 BAD (HHHHHH) +Size 54: 33,54 BAD (HHHHHH) +Size 55: 33,55 BAD (HHHHHH) +Size 56: 34,56 BAD (HHHHHH) +Size 57: 34,57 BAD (HHHHHH) +Size 58: 35,58 BAD (HHHHHH) +Size 59: 36,59 BAD (HHHHHH) +Size 60: 36,60 BAD (HHHHHH) +Size 61: 37,61 BAD (HHHHHH) +Size 62: 37,62 BAD (HHHHHH) +Size 63: 38,63 BAD (HHHHHH) +Size 64: 39,64 BAD (HHHHHH) +Size 65: 39,65 BAD (HHHHHH) +Size 66: 40,66 BAD (HHHHHH) +Size 67: 40,67 BAD (HHHHHH) +Size 68: 41,68 BAD (HHHHHH) +Size 69: 42,69 BAD (HHHHHH) +Size 70: 42,70 BAD (HHHHHH) +Size 71: 43,71 BAD (HHHHHH) +Size 72: 43,72 BAD (HHHHHH) +Size 73: 44,73 BAD (HHHHHH) +Size 74: 45,74 BAD (HHHHHH) +Size 75: 45,75 BAD (HHHHHH) +Size 76: 46,76 BAD (HHHHHH) +Size 77: 46,77 BAD (HHHHHH) +Size 78: 47,78 BAD (HHHHHH) +Size 79: 48,79 BAD (HHHHHH) +Size 80: 48,80 BAD (HHHHHH) +Size 81: 49,81 BAD (HHHHHH) +Size 82: 49,82 BAD (HHHHHH) +Size 83: 50,83 BAD (HHHHHH) +Size 84: 51,84 BAD (HHHHHH) +Size 85: 51,85 BAD (HHHHHH) +Size 86: 52,86 BAD (HHHHHH) +Size 87: 52,87 BAD (HHHHHH) +Size 88: 53,88 BAD (HHHHHH) +Size 89: 54,89 BAD (HHHHHH) +Size 90: 54,90 BAD (HHHHHH) +Size 91: 55,91 BAD (HHHHHH) +Size 92: 55,92 BAD (HHHHHH) +Size 93: 56,93 BAD (HHHHHH) +Size 94: 57,94 BAD (HHHHHH) +Size 95: 57,95 BAD (HHHHHH) +Size 96: 58,96 BAD (HHHHHH) +Size 97: 58,97 BAD (HHHHHH) +Size 98: 59,98 BAD (HHHHHH) +Size 99: 60,99 BAD (HHHHHH) +Size 100: 60,100 BAD (HHHHHH) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP932.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP932.txt new file mode 100644 index 00000000..ed3637ea --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP932.txt @@ -0,0 +1,630 @@ +======================================= +Code Page 932, Japanese, MS Gothic font +======================================= + +Options: -face-gothic -family 0x36 +Chars: A2 A3 2014 3044 30FC 4000 + +Vista +----- + +Size 1: 1,2 OK (HHHFFF) +Size 2: 1,2 OK (HHHFFF) +Size 3: 2,3 BAD (FFFFHH) +Size 4: 2,4 OK (HHHFFF) +Size 5: 3,5 OK (HHHFFF) +Size 6: 3,6 OK (HHHFFF) +Size 7: 4,7 OK (HHHFFF) +Size 8: 4,8 OK (HHHFFF) +Size 9: 5,9 OK (HHHFFF) +Size 10: 5,10 OK (HHHFFF) +Size 11: 6,11 OK (HHHFFF) +Size 12: 6,12 OK (HHHFFF) +Size 13: 7,13 OK (HHHFFF) +Size 14: 7,14 BAD (HHHFHH) +Size 15: 8,15 OK (HHHFFF) +Size 16: 8,16 BAD (HHHFHH) +Size 17: 9,17 OK (HHHFFF) +Size 18: 9,18 BAD (HHHFHH) +Size 19: 10,19 OK (HHHFFF) +Size 20: 10,20 BAD (HHHFHH) +Size 21: 11,21 OK (HHHFFF) +Size 22: 11,22 BAD (HHHFHH) +Size 23: 12,23 BAD (HHHFHH) +Size 24: 12,24 BAD (HHHFHH) +Size 25: 13,25 BAD (HHHFHH) +Size 26: 13,26 BAD (HHHFHH) +Size 27: 14,27 BAD (HHHFHH) +Size 28: 14,28 BAD (HHHFHH) +Size 29: 15,29 BAD (HHHFHH) +Size 30: 15,30 BAD (HHHFHH) +Size 31: 16,31 BAD (HHHFHH) +Size 32: 16,33 BAD (HHHFHH) +Size 33: 17,33 BAD (HHHFHH) +Size 34: 17,34 BAD (HHHFHH) +Size 35: 18,35 BAD (HHHFHH) +Size 36: 18,36 BAD (HHHFHH) +Size 37: 19,37 BAD (HHHFHH) +Size 38: 19,38 BAD (HHHFHH) +Size 39: 20,39 BAD (HHHFHH) +Size 40: 20,40 BAD (HHHFHH) +Size 41: 21,41 BAD (HHHFHH) +Size 42: 21,42 BAD (HHHFHH) +Size 43: 22,43 BAD (HHHFHH) +Size 44: 22,44 BAD (HHHFHH) +Size 45: 23,45 BAD (HHHFHH) +Size 46: 23,46 BAD (HHHFHH) +Size 47: 24,47 BAD (HHHFHH) +Size 48: 24,48 BAD (HHHFHH) +Size 49: 25,49 BAD (HHHFHH) +Size 50: 25,50 BAD (HHHFHH) +Size 51: 26,51 BAD (HHHFHH) +Size 52: 26,52 BAD (HHHFHH) +Size 53: 27,53 BAD (HHHFHH) +Size 54: 27,54 BAD (HHHFHH) +Size 55: 28,55 BAD (HHHFHH) +Size 56: 28,56 BAD (HHHFHH) +Size 57: 29,57 BAD (HHHFHH) +Size 58: 29,58 BAD (HHHFHH) +Size 59: 30,59 BAD (HHHFHH) +Size 60: 30,60 BAD (HHHFHH) +Size 61: 31,61 BAD (HHHFHH) +Size 62: 31,62 BAD (HHHFHH) +Size 63: 32,63 BAD (HHHFHH) +Size 64: 32,64 BAD (HHHFHH) +Size 65: 33,65 BAD (HHHFHH) +Size 66: 33,66 BAD (HHHFHH) +Size 67: 34,67 BAD (HHHFHH) +Size 68: 34,68 BAD (HHHFHH) +Size 69: 35,69 BAD (HHHFHH) +Size 70: 35,70 BAD (HHHFHH) +Size 71: 36,71 BAD (HHHFHH) +Size 72: 36,72 BAD (HHHFHH) +Size 73: 37,73 BAD (HHHFHH) +Size 74: 37,74 BAD (HHHFHH) +Size 75: 38,75 BAD (HHHFHH) +Size 76: 38,76 BAD (HHHFHH) +Size 77: 39,77 BAD (HHHFHH) +Size 78: 39,78 BAD (HHHFHH) +Size 79: 40,79 BAD (HHHFHH) +Size 80: 40,80 BAD (HHHFHH) +Size 81: 41,81 BAD (HHHFHH) +Size 82: 41,82 BAD (HHHFHH) +Size 83: 42,83 BAD (HHHFHH) +Size 84: 42,84 BAD (HHHFHH) +Size 85: 43,85 BAD (HHHFHH) +Size 86: 43,86 BAD (HHHFHH) +Size 87: 44,87 BAD (HHHFHH) +Size 88: 44,88 BAD (HHHFHH) +Size 89: 45,89 BAD (HHHFHH) +Size 90: 45,90 BAD (HHHFHH) +Size 91: 46,91 BAD (HHHFHH) +Size 92: 46,92 BAD (HHHFHH) +Size 93: 47,93 BAD (HHHFHH) +Size 94: 47,94 BAD (HHHFHH) +Size 95: 48,95 BAD (HHHFHH) +Size 96: 48,97 BAD (HHHFHH) +Size 97: 49,97 BAD (HHHFHH) +Size 98: 49,98 BAD (HHHFHH) +Size 99: 50,99 BAD (HHHFHH) +Size 100: 50,100 BAD (HHHFHH) + +Windows 7 +--------- + +Size 1: 1,2 OK (HHHFFF) +Size 2: 1,2 OK (HHHFFF) +Size 3: 2,3 BAD (FFFFHH) +Size 4: 2,4 OK (HHHFFF) +Size 5: 3,5 OK (HHHFFF) +Size 6: 3,6 OK (HHHFFF) +Size 7: 4,7 OK (HHHFFF) +Size 8: 4,8 OK (HHHFFF) +Size 9: 5,9 OK (HHHFFF) +Size 10: 5,10 OK (HHHFFF) +Size 11: 6,11 OK (HHHFFF) +Size 12: 6,12 OK (HHHFFF) +Size 13: 7,13 OK (HHHFFF) +Size 14: 7,14 BAD (FFFFFF) +Size 15: 8,15 OK (HHHFFF) +Size 16: 8,16 BAD (FFFFFF) +Size 17: 9,17 OK (HHHFFF) +Size 18: 9,18 BAD (FFFFFF) +Size 19: 10,19 OK (HHHFFF) +Size 20: 10,20 BAD (FFFFFF) +Size 21: 11,21 OK (HHHFFF) +Size 22: 11,22 BAD (FFFFFF) +Size 23: 12,23 BAD (FFFFFF) +Size 24: 12,24 BAD (FFFFFF) +Size 25: 13,25 BAD (FFFFFF) +Size 26: 13,26 BAD (FFFFFF) +Size 27: 14,27 BAD (FFFFFF) +Size 28: 14,28 BAD (FFFFFF) +Size 29: 15,29 BAD (FFFFFF) +Size 30: 15,30 BAD (FFFFFF) +Size 31: 16,31 BAD (FFFFFF) +Size 32: 16,33 BAD (FFFFFF) +Size 33: 17,33 BAD (FFFFFF) +Size 34: 17,34 BAD (FFFFFF) +Size 35: 18,35 BAD (FFFFFF) +Size 36: 18,36 BAD (FFFFFF) +Size 37: 19,37 BAD (FFFFFF) +Size 38: 19,38 BAD (FFFFFF) +Size 39: 20,39 BAD (FFFFFF) +Size 40: 20,40 BAD (FFFFFF) +Size 41: 21,41 BAD (FFFFFF) +Size 42: 21,42 BAD (FFFFFF) +Size 43: 22,43 BAD (FFFFFF) +Size 44: 22,44 BAD (FFFFFF) +Size 45: 23,45 BAD (FFFFFF) +Size 46: 23,46 BAD (FFFFFF) +Size 47: 24,47 BAD (FFFFFF) +Size 48: 24,48 BAD (FFFFFF) +Size 49: 25,49 BAD (FFFFFF) +Size 50: 25,50 BAD (FFFFFF) +Size 51: 26,51 BAD (FFFFFF) +Size 52: 26,52 BAD (FFFFFF) +Size 53: 27,53 BAD (FFFFFF) +Size 54: 27,54 BAD (FFFFFF) +Size 55: 28,55 BAD (FFFFFF) +Size 56: 28,56 BAD (FFFFFF) +Size 57: 29,57 BAD (FFFFFF) +Size 58: 29,58 BAD (FFFFFF) +Size 59: 30,59 BAD (FFFFFF) +Size 60: 30,60 BAD (FFFFFF) +Size 61: 31,61 BAD (FFFFFF) +Size 62: 31,62 BAD (FFFFFF) +Size 63: 32,63 BAD (FFFFFF) +Size 64: 32,64 BAD (FFFFFF) +Size 65: 33,65 BAD (FFFFFF) +Size 66: 33,66 BAD (FFFFFF) +Size 67: 34,67 BAD (FFFFFF) +Size 68: 34,68 BAD (FFFFFF) +Size 69: 35,69 BAD (FFFFFF) +Size 70: 35,70 BAD (FFFFFF) +Size 71: 36,71 BAD (FFFFFF) +Size 72: 36,72 BAD (FFFFFF) +Size 73: 37,73 BAD (FFFFFF) +Size 74: 37,74 BAD (FFFFFF) +Size 75: 38,75 BAD (FFFFFF) +Size 76: 38,76 BAD (FFFFFF) +Size 77: 39,77 BAD (FFFFFF) +Size 78: 39,78 BAD (FFFFFF) +Size 79: 40,79 BAD (FFFFFF) +Size 80: 40,80 BAD (FFFFFF) +Size 81: 41,81 BAD (FFFFFF) +Size 82: 41,82 BAD (FFFFFF) +Size 83: 42,83 BAD (FFFFFF) +Size 84: 42,84 BAD (FFFFFF) +Size 85: 43,85 BAD (FFFFFF) +Size 86: 43,86 BAD (FFFFFF) +Size 87: 44,87 BAD (FFFFFF) +Size 88: 44,88 BAD (FFFFFF) +Size 89: 45,89 BAD (FFFFFF) +Size 90: 45,90 BAD (FFFFFF) +Size 91: 46,91 BAD (FFFFFF) +Size 92: 46,92 BAD (FFFFFF) +Size 93: 47,93 BAD (FFFFFF) +Size 94: 47,94 BAD (FFFFFF) +Size 95: 48,95 BAD (FFFFFF) +Size 96: 48,97 BAD (FFFFFF) +Size 97: 49,97 BAD (FFFFFF) +Size 98: 49,98 BAD (FFFFFF) +Size 99: 50,99 BAD (FFFFFF) +Size 100: 50,100 BAD (FFFFFF) + +Windows 8 +--------- + +Size 1: 1,2 BAD (FFFFHH) +Size 2: 1,2 BAD (FFFFHH) +Size 3: 2,3 BAD (FFFFFF) +Size 4: 2,4 BAD (FFFFHH) +Size 5: 3,5 BAD (FFFFFF) +Size 6: 3,6 BAD (FFFFHH) +Size 7: 4,7 BAD (FFFFFF) +Size 8: 4,8 BAD (FFFFHH) +Size 9: 5,9 BAD (FFFFFF) +Size 10: 5,10 BAD (FFFFHH) +Size 11: 6,11 BAD (FFFFFF) +Size 12: 6,12 BAD (FFFFHH) +Size 13: 7,13 BAD (FFFFFF) +Size 14: 7,14 BAD (FFFFHH) +Size 15: 8,15 BAD (FFFFFF) +Size 16: 8,16 BAD (FFFFHH) +Size 17: 9,17 BAD (FFFFFF) +Size 18: 9,18 BAD (FFFFHH) +Size 19: 10,19 BAD (FFFFFF) +Size 20: 10,20 BAD (FFFFFF) +Size 21: 11,21 BAD (FFFFFF) +Size 22: 11,22 BAD (FFFFFF) +Size 23: 12,23 BAD (FFFFFF) +Size 24: 12,24 BAD (FFFFFF) +Size 25: 13,25 BAD (FFFFFF) +Size 26: 13,26 BAD (FFFFFF) +Size 27: 14,27 BAD (FFFFFF) +Size 28: 14,28 BAD (FFFFFF) +Size 29: 15,29 BAD (FFFFFF) +Size 30: 15,30 BAD (FFFFFF) +Size 31: 16,31 BAD (FFFFFF) +Size 32: 16,33 BAD (FFFFFF) +Size 33: 17,33 BAD (FFFFFF) +Size 34: 17,34 BAD (FFFFFF) +Size 35: 18,35 BAD (FFFFFF) +Size 36: 18,36 BAD (FFFFFF) +Size 37: 19,37 BAD (FFFFFF) +Size 38: 19,38 BAD (FFFFFF) +Size 39: 20,39 BAD (FFFFFF) +Size 40: 20,40 BAD (FFFFFF) +Size 41: 21,41 BAD (FFFFFF) +Size 42: 21,42 BAD (FFFFFF) +Size 43: 22,43 BAD (FFFFFF) +Size 44: 22,44 BAD (FFFFFF) +Size 45: 23,45 BAD (FFFFFF) +Size 46: 23,46 BAD (FFFFFF) +Size 47: 24,47 BAD (FFFFFF) +Size 48: 24,48 BAD (FFFFFF) +Size 49: 25,49 BAD (FFFFFF) +Size 50: 25,50 BAD (FFFFFF) +Size 51: 26,51 BAD (FFFFFF) +Size 52: 26,52 BAD (FFFFFF) +Size 53: 27,53 BAD (FFFFFF) +Size 54: 27,54 BAD (FFFFFF) +Size 55: 28,55 BAD (FFFFFF) +Size 56: 28,56 BAD (FFFFFF) +Size 57: 29,57 BAD (FFFFFF) +Size 58: 29,58 BAD (FFFFFF) +Size 59: 30,59 BAD (FFFFFF) +Size 60: 30,60 BAD (FFFFFF) +Size 61: 31,61 BAD (FFFFFF) +Size 62: 31,62 BAD (FFFFFF) +Size 63: 32,63 BAD (FFFFFF) +Size 64: 32,64 BAD (FFFFFF) +Size 65: 33,65 BAD (FFFFFF) +Size 66: 33,66 BAD (FFFFFF) +Size 67: 34,67 BAD (FFFFFF) +Size 68: 34,68 BAD (FFFFFF) +Size 69: 35,69 BAD (FFFFFF) +Size 70: 35,70 BAD (FFFFFF) +Size 71: 36,71 BAD (FFFFFF) +Size 72: 36,72 BAD (FFFFFF) +Size 73: 37,73 BAD (FFFFFF) +Size 74: 37,74 BAD (FFFFFF) +Size 75: 38,75 BAD (FFFFFF) +Size 76: 38,76 BAD (FFFFFF) +Size 77: 39,77 BAD (FFFFFF) +Size 78: 39,78 BAD (FFFFFF) +Size 79: 40,79 BAD (FFFFFF) +Size 80: 40,80 BAD (FFFFFF) +Size 81: 41,81 BAD (FFFFFF) +Size 82: 41,82 BAD (FFFFFF) +Size 83: 42,83 BAD (FFFFFF) +Size 84: 42,84 BAD (FFFFFF) +Size 85: 43,85 BAD (FFFFFF) +Size 86: 43,86 BAD (FFFFFF) +Size 87: 44,87 BAD (FFFFFF) +Size 88: 44,88 BAD (FFFFFF) +Size 89: 45,89 BAD (FFFFFF) +Size 90: 45,90 BAD (FFFFFF) +Size 91: 46,91 BAD (FFFFFF) +Size 92: 46,92 BAD (FFFFFF) +Size 93: 47,93 BAD (FFFFFF) +Size 94: 47,94 BAD (FFFFFF) +Size 95: 48,95 BAD (FFFFFF) +Size 96: 48,97 BAD (FFFFFF) +Size 97: 49,97 BAD (FFFFFF) +Size 98: 49,98 BAD (FFFFFF) +Size 99: 50,99 BAD (FFFFFF) +Size 100: 50,100 BAD (FFFFFF) + +Windows 8.1 +----------- + +Size 1: 1,2 BAD (FFFFHH) +Size 2: 1,2 BAD (FFFFHH) +Size 3: 2,3 BAD (FFFFFF) +Size 4: 2,4 BAD (FFFFHH) +Size 5: 3,5 BAD (FFFFFF) +Size 6: 3,6 BAD (FFFFHH) +Size 7: 4,7 BAD (FFFFFF) +Size 8: 4,8 BAD (FFFFHH) +Size 9: 5,9 BAD (FFFFFF) +Size 10: 5,10 BAD (FFFFHH) +Size 11: 6,11 BAD (FFFFFF) +Size 12: 6,12 BAD (FFFFHH) +Size 13: 7,13 BAD (FFFFFF) +Size 14: 7,14 BAD (FFFFHH) +Size 15: 8,15 BAD (FFFFFF) +Size 16: 8,16 BAD (FFFFHH) +Size 17: 9,17 BAD (FFFFFF) +Size 18: 9,18 BAD (FFFFHH) +Size 19: 10,19 BAD (FFFFFF) +Size 20: 10,20 BAD (FFFFFF) +Size 21: 11,21 BAD (FFFFFF) +Size 22: 11,22 BAD (FFFFFF) +Size 23: 12,23 BAD (FFFFFF) +Size 24: 12,24 BAD (FFFFFF) +Size 25: 13,25 BAD (FFFFFF) +Size 26: 13,26 BAD (FFFFFF) +Size 27: 14,27 BAD (FFFFFF) +Size 28: 14,28 BAD (FFFFFF) +Size 29: 15,29 BAD (FFFFFF) +Size 30: 15,30 BAD (FFFFFF) +Size 31: 16,31 BAD (FFFFFF) +Size 32: 16,33 BAD (FFFFFF) +Size 33: 17,33 BAD (FFFFFF) +Size 34: 17,34 BAD (FFFFFF) +Size 35: 18,35 BAD (FFFFFF) +Size 36: 18,36 BAD (FFFFFF) +Size 37: 19,37 BAD (FFFFFF) +Size 38: 19,38 BAD (FFFFFF) +Size 39: 20,39 BAD (FFFFFF) +Size 40: 20,40 BAD (FFFFFF) +Size 41: 21,41 BAD (FFFFFF) +Size 42: 21,42 BAD (FFFFFF) +Size 43: 22,43 BAD (FFFFFF) +Size 44: 22,44 BAD (FFFFFF) +Size 45: 23,45 BAD (FFFFFF) +Size 46: 23,46 BAD (FFFFFF) +Size 47: 24,47 BAD (FFFFFF) +Size 48: 24,48 BAD (FFFFFF) +Size 49: 25,49 BAD (FFFFFF) +Size 50: 25,50 BAD (FFFFFF) +Size 51: 26,51 BAD (FFFFFF) +Size 52: 26,52 BAD (FFFFFF) +Size 53: 27,53 BAD (FFFFFF) +Size 54: 27,54 BAD (FFFFFF) +Size 55: 28,55 BAD (FFFFFF) +Size 56: 28,56 BAD (FFFFFF) +Size 57: 29,57 BAD (FFFFFF) +Size 58: 29,58 BAD (FFFFFF) +Size 59: 30,59 BAD (FFFFFF) +Size 60: 30,60 BAD (FFFFFF) +Size 61: 31,61 BAD (FFFFFF) +Size 62: 31,62 BAD (FFFFFF) +Size 63: 32,63 BAD (FFFFFF) +Size 64: 32,64 BAD (FFFFFF) +Size 65: 33,65 BAD (FFFFFF) +Size 66: 33,66 BAD (FFFFFF) +Size 67: 34,67 BAD (FFFFFF) +Size 68: 34,68 BAD (FFFFFF) +Size 69: 35,69 BAD (FFFFFF) +Size 70: 35,70 BAD (FFFFFF) +Size 71: 36,71 BAD (FFFFFF) +Size 72: 36,72 BAD (FFFFFF) +Size 73: 37,73 BAD (FFFFFF) +Size 74: 37,74 BAD (FFFFFF) +Size 75: 38,75 BAD (FFFFFF) +Size 76: 38,76 BAD (FFFFFF) +Size 77: 39,77 BAD (FFFFFF) +Size 78: 39,78 BAD (FFFFFF) +Size 79: 40,79 BAD (FFFFFF) +Size 80: 40,80 BAD (FFFFFF) +Size 81: 41,81 BAD (FFFFFF) +Size 82: 41,82 BAD (FFFFFF) +Size 83: 42,83 BAD (FFFFFF) +Size 84: 42,84 BAD (FFFFFF) +Size 85: 43,85 BAD (FFFFFF) +Size 86: 43,86 BAD (FFFFFF) +Size 87: 44,87 BAD (FFFFFF) +Size 88: 44,88 BAD (FFFFFF) +Size 89: 45,89 BAD (FFFFFF) +Size 90: 45,90 BAD (FFFFFF) +Size 91: 46,91 BAD (FFFFFF) +Size 92: 46,92 BAD (FFFFFF) +Size 93: 47,93 BAD (FFFFFF) +Size 94: 47,94 BAD (FFFFFF) +Size 95: 48,95 BAD (FFFFFF) +Size 96: 48,97 BAD (FFFFFF) +Size 97: 49,97 BAD (FFFFFF) +Size 98: 49,98 BAD (FFFFFF) +Size 99: 50,99 BAD (FFFFFF) +Size 100: 50,100 BAD (FFFFFF) + +Windows 10 14342 Old Console +---------------------------- + +Size 1: 1,2 BAD (FFFFHH) +Size 2: 1,2 BAD (FFFFHH) +Size 3: 2,3 BAD (FFFFFF) +Size 4: 2,4 BAD (FFFFHH) +Size 5: 3,5 BAD (FFFFFF) +Size 6: 3,6 BAD (FFFFHH) +Size 7: 4,7 BAD (FFFFFF) +Size 8: 4,8 BAD (FFFFHH) +Size 9: 5,9 BAD (FFFFFF) +Size 10: 5,10 BAD (FFFFHH) +Size 11: 6,11 BAD (FFFFFF) +Size 12: 6,12 BAD (FFFFHH) +Size 13: 7,13 BAD (FFFFFF) +Size 14: 7,14 BAD (FFFFHH) +Size 15: 8,15 BAD (FFFFFF) +Size 16: 8,16 BAD (FFFFHH) +Size 17: 9,17 BAD (FFFFFF) +Size 18: 9,18 BAD (FFFFHH) +Size 19: 10,19 BAD (FFFFFF) +Size 20: 10,20 BAD (FFFFFF) +Size 21: 11,21 BAD (FFFFFF) +Size 22: 11,22 BAD (FFFFFF) +Size 23: 12,23 BAD (FFFFFF) +Size 24: 12,24 BAD (FFFFFF) +Size 25: 13,25 BAD (FFFFFF) +Size 26: 13,26 BAD (FFFFFF) +Size 27: 14,27 BAD (FFFFFF) +Size 28: 14,28 BAD (FFFFFF) +Size 29: 15,29 BAD (FFFFFF) +Size 30: 15,30 BAD (FFFFFF) +Size 31: 16,31 BAD (FFFFFF) +Size 32: 16,33 BAD (FFFFFF) +Size 33: 17,33 BAD (FFFFFF) +Size 34: 17,34 BAD (FFFFFF) +Size 35: 18,35 BAD (FFFFFF) +Size 36: 18,36 BAD (FFFFFF) +Size 37: 19,37 BAD (FFFFFF) +Size 38: 19,38 BAD (FFFFFF) +Size 39: 20,39 BAD (FFFFFF) +Size 40: 20,40 BAD (FFFFFF) +Size 41: 21,41 BAD (FFFFFF) +Size 42: 21,42 BAD (FFFFFF) +Size 43: 22,43 BAD (FFFFFF) +Size 44: 22,44 BAD (FFFFFF) +Size 45: 23,45 BAD (FFFFFF) +Size 46: 23,46 BAD (FFFFFF) +Size 47: 24,47 BAD (FFFFFF) +Size 48: 24,48 BAD (FFFFFF) +Size 49: 25,49 BAD (FFFFFF) +Size 50: 25,50 BAD (FFFFFF) +Size 51: 26,51 BAD (FFFFFF) +Size 52: 26,52 BAD (FFFFFF) +Size 53: 27,53 BAD (FFFFFF) +Size 54: 27,54 BAD (FFFFFF) +Size 55: 28,55 BAD (FFFFFF) +Size 56: 28,56 BAD (FFFFFF) +Size 57: 29,57 BAD (FFFFFF) +Size 58: 29,58 BAD (FFFFFF) +Size 59: 30,59 BAD (FFFFFF) +Size 60: 30,60 BAD (FFFFFF) +Size 61: 31,61 BAD (FFFFFF) +Size 62: 31,62 BAD (FFFFFF) +Size 63: 32,63 BAD (FFFFFF) +Size 64: 32,64 BAD (FFFFFF) +Size 65: 33,65 BAD (FFFFFF) +Size 66: 33,66 BAD (FFFFFF) +Size 67: 34,67 BAD (FFFFFF) +Size 68: 34,68 BAD (FFFFFF) +Size 69: 35,69 BAD (FFFFFF) +Size 70: 35,70 BAD (FFFFFF) +Size 71: 36,71 BAD (FFFFFF) +Size 72: 36,72 BAD (FFFFFF) +Size 73: 37,73 BAD (FFFFFF) +Size 74: 37,74 BAD (FFFFFF) +Size 75: 38,75 BAD (FFFFFF) +Size 76: 38,76 BAD (FFFFFF) +Size 77: 39,77 BAD (FFFFFF) +Size 78: 39,78 BAD (FFFFFF) +Size 79: 40,79 BAD (FFFFFF) +Size 80: 40,80 BAD (FFFFFF) +Size 81: 41,81 BAD (FFFFFF) +Size 82: 41,82 BAD (FFFFFF) +Size 83: 42,83 BAD (FFFFFF) +Size 84: 42,84 BAD (FFFFFF) +Size 85: 43,85 BAD (FFFFFF) +Size 86: 43,86 BAD (FFFFFF) +Size 87: 44,87 BAD (FFFFFF) +Size 88: 44,88 BAD (FFFFFF) +Size 89: 45,89 BAD (FFFFFF) +Size 90: 45,90 BAD (FFFFFF) +Size 91: 46,91 BAD (FFFFFF) +Size 92: 46,92 BAD (FFFFFF) +Size 93: 47,93 BAD (FFFFFF) +Size 94: 47,94 BAD (FFFFFF) +Size 95: 48,95 BAD (FFFFFF) +Size 96: 48,97 BAD (FFFFFF) +Size 97: 49,97 BAD (FFFFFF) +Size 98: 49,98 BAD (FFFFFF) +Size 99: 50,99 BAD (FFFFFF) +Size 100: 50,100 BAD (FFFFFF) + +Windows 10 14342 New Console +---------------------------- + +Size 1: 1,1 OK (HHHFFF) +Size 2: 1,2 OK (HHHFFF) +Size 3: 2,3 OK (HHHFFF) +Size 4: 2,4 OK (HHHFFF) +Size 5: 3,5 OK (HHHFFF) +Size 6: 3,6 OK (HHHFFF) +Size 7: 4,7 OK (HHHFFF) +Size 8: 4,8 OK (HHHFFF) +Size 9: 5,9 OK (HHHFFF) +Size 10: 5,10 OK (HHHFFF) +Size 11: 6,11 OK (HHHFFF) +Size 12: 6,12 OK (HHHFFF) +Size 13: 7,13 OK (HHHFFF) +Size 14: 7,14 OK (HHHFFF) +Size 15: 8,15 OK (HHHFFF) +Size 16: 8,16 OK (HHHFFF) +Size 17: 9,17 OK (HHHFFF) +Size 18: 9,18 OK (HHHFFF) +Size 19: 10,19 OK (HHHFFF) +Size 20: 10,20 OK (HHHFFF) +Size 21: 11,21 OK (HHHFFF) +Size 22: 11,22 OK (HHHFFF) +Size 23: 12,23 OK (HHHFFF) +Size 24: 12,24 OK (HHHFFF) +Size 25: 13,25 OK (HHHFFF) +Size 26: 13,26 OK (HHHFFF) +Size 27: 14,27 OK (HHHFFF) +Size 28: 14,28 OK (HHHFFF) +Size 29: 15,29 OK (HHHFFF) +Size 30: 15,30 OK (HHHFFF) +Size 31: 16,31 OK (HHHFFF) +Size 32: 16,32 OK (HHHFFF) +Size 33: 17,33 OK (HHHFFF) +Size 34: 17,34 OK (HHHFFF) +Size 35: 18,35 OK (HHHFFF) +Size 36: 18,36 OK (HHHFFF) +Size 37: 19,37 OK (HHHFFF) +Size 38: 19,38 OK (HHHFFF) +Size 39: 20,39 OK (HHHFFF) +Size 40: 20,40 OK (HHHFFF) +Size 41: 21,41 OK (HHHFFF) +Size 42: 21,42 OK (HHHFFF) +Size 43: 22,43 OK (HHHFFF) +Size 44: 22,44 OK (HHHFFF) +Size 45: 23,45 OK (HHHFFF) +Size 46: 23,46 OK (HHHFFF) +Size 47: 24,47 OK (HHHFFF) +Size 48: 24,48 OK (HHHFFF) +Size 49: 25,49 OK (HHHFFF) +Size 50: 25,50 OK (HHHFFF) +Size 51: 26,51 OK (HHHFFF) +Size 52: 26,52 OK (HHHFFF) +Size 53: 27,53 OK (HHHFFF) +Size 54: 27,54 OK (HHHFFF) +Size 55: 28,55 OK (HHHFFF) +Size 56: 28,56 OK (HHHFFF) +Size 57: 29,57 OK (HHHFFF) +Size 58: 29,58 OK (HHHFFF) +Size 59: 30,59 OK (HHHFFF) +Size 60: 30,60 OK (HHHFFF) +Size 61: 31,61 OK (HHHFFF) +Size 62: 31,62 OK (HHHFFF) +Size 63: 32,63 OK (HHHFFF) +Size 64: 32,64 OK (HHHFFF) +Size 65: 33,65 OK (HHHFFF) +Size 66: 33,66 OK (HHHFFF) +Size 67: 34,67 OK (HHHFFF) +Size 68: 34,68 OK (HHHFFF) +Size 69: 35,69 OK (HHHFFF) +Size 70: 35,70 OK (HHHFFF) +Size 71: 36,71 OK (HHHFFF) +Size 72: 36,72 OK (HHHFFF) +Size 73: 37,73 OK (HHHFFF) +Size 74: 37,74 OK (HHHFFF) +Size 75: 38,75 OK (HHHFFF) +Size 76: 38,76 OK (HHHFFF) +Size 77: 39,77 OK (HHHFFF) +Size 78: 39,78 OK (HHHFFF) +Size 79: 40,79 OK (HHHFFF) +Size 80: 40,80 OK (HHHFFF) +Size 81: 41,81 OK (HHHFFF) +Size 82: 41,82 OK (HHHFFF) +Size 83: 42,83 OK (HHHFFF) +Size 84: 42,84 OK (HHHFFF) +Size 85: 43,85 OK (HHHFFF) +Size 86: 43,86 OK (HHHFFF) +Size 87: 44,87 OK (HHHFFF) +Size 88: 44,88 OK (HHHFFF) +Size 89: 45,89 OK (HHHFFF) +Size 90: 45,90 OK (HHHFFF) +Size 91: 46,91 OK (HHHFFF) +Size 92: 46,92 OK (HHHFFF) +Size 93: 47,93 OK (HHHFFF) +Size 94: 47,94 OK (HHHFFF) +Size 95: 48,95 OK (HHHFFF) +Size 96: 48,96 OK (HHHFFF) +Size 97: 49,97 OK (HHHFFF) +Size 98: 49,98 OK (HHHFFF) +Size 99: 50,99 OK (HHHFFF) +Size 100: 50,100 OK (HHHFFF) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP936.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP936.txt new file mode 100644 index 00000000..43210dac --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP936.txt @@ -0,0 +1,630 @@ +========================================================== +Code Page 936, Chinese Simplified (China/PRC), SimSun font +========================================================== + +Options: -face-simsun -family 0x36 +Chars: A2 A3 2014 3044 30FC 4000 + +Vista +----- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,3 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (HHHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (HHHFHH) +Size 8: 4,9 GOOD (HHFFFF) +Size 9: 5,10 BAD (HHHFHH) +Size 10: 5,11 GOOD (HHFFFF) +Size 11: 6,13 BAD (HHHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,15 BAD (HHHFHH) +Size 14: 7,16 GOOD (HHFFFF) +Size 15: 8,17 BAD (HHHFHH) +Size 16: 8,18 GOOD (HHFFFF) +Size 17: 9,19 BAD (HHHFHH) +Size 18: 9,21 GOOD (HHFFFF) +Size 19: 10,22 BAD (HHHFHH) +Size 20: 10,23 GOOD (HHFFFF) +Size 21: 11,24 BAD (HHHFHH) +Size 22: 11,25 GOOD (HHFFFF) +Size 23: 12,26 BAD (HHHFHH) +Size 24: 12,27 GOOD (HHFFFF) +Size 25: 13,29 BAD (HHHFHH) +Size 26: 13,30 GOOD (HHFFFF) +Size 27: 14,31 BAD (HHHFHH) +Size 28: 14,32 GOOD (HHFFFF) +Size 29: 15,33 BAD (HHHFHH) +Size 30: 15,34 GOOD (HHFFFF) +Size 31: 16,35 BAD (HHHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,38 BAD (HHHFHH) +Size 34: 17,39 GOOD (HHFFFF) +Size 35: 18,40 BAD (HHHFHH) +Size 36: 18,41 GOOD (HHFFFF) +Size 37: 19,42 BAD (HHHFHH) +Size 38: 19,43 GOOD (HHFFFF) +Size 39: 20,44 BAD (HHHFHH) +Size 40: 20,46 GOOD (HHFFFF) +Size 41: 21,47 BAD (HHHFHH) +Size 42: 21,48 GOOD (HHFFFF) +Size 43: 22,49 BAD (HHHFHH) +Size 44: 22,50 GOOD (HHFFFF) +Size 45: 23,51 BAD (HHHFHH) +Size 46: 23,52 GOOD (HHFFFF) +Size 47: 24,54 BAD (HHHFHH) +Size 48: 24,55 GOOD (HHFFFF) +Size 49: 25,56 BAD (HHHFHH) +Size 50: 25,57 GOOD (HHFFFF) +Size 51: 26,58 BAD (HHHFHH) +Size 52: 26,59 GOOD (HHFFFF) +Size 53: 27,60 BAD (HHHFHH) +Size 54: 27,62 GOOD (HHFFFF) +Size 55: 28,63 BAD (HHHFHH) +Size 56: 28,64 GOOD (HHFFFF) +Size 57: 29,65 BAD (HHHFHH) +Size 58: 29,66 GOOD (HHFFFF) +Size 59: 30,67 BAD (HHHFHH) +Size 60: 30,68 GOOD (HHFFFF) +Size 61: 31,70 BAD (HHHFHH) +Size 62: 31,71 GOOD (HHFFFF) +Size 63: 32,72 BAD (HHHFHH) +Size 64: 32,73 GOOD (HHFFFF) +Size 65: 33,74 GOOD (HHFFFF) +Size 66: 33,75 GOOD (HHFFFF) +Size 67: 34,76 GOOD (HHFFFF) +Size 68: 34,78 GOOD (HHFFFF) +Size 69: 35,79 GOOD (HHFFFF) +Size 70: 35,80 GOOD (HHFFFF) +Size 71: 36,81 GOOD (HHFFFF) +Size 72: 36,82 GOOD (HHFFFF) +Size 73: 37,83 GOOD (HHFFFF) +Size 74: 37,84 GOOD (HHFFFF) +Size 75: 38,86 GOOD (HHFFFF) +Size 76: 38,87 GOOD (HHFFFF) +Size 77: 39,88 GOOD (HHFFFF) +Size 78: 39,89 GOOD (HHFFFF) +Size 79: 40,90 GOOD (HHFFFF) +Size 80: 40,91 GOOD (HHFFFF) +Size 81: 41,92 GOOD (HHFFFF) +Size 82: 41,94 GOOD (HHFFFF) +Size 83: 42,95 GOOD (HHFFFF) +Size 84: 42,96 GOOD (HHFFFF) +Size 85: 43,97 GOOD (HHFFFF) +Size 86: 43,98 GOOD (HHFFFF) +Size 87: 44,99 GOOD (HHFFFF) +Size 88: 44,100 GOOD (HHFFFF) +Size 89: 45,102 GOOD (HHFFFF) +Size 90: 45,103 GOOD (HHFFFF) +Size 91: 46,104 GOOD (HHFFFF) +Size 92: 46,105 GOOD (HHFFFF) +Size 93: 47,106 GOOD (HHFFFF) +Size 94: 47,107 GOOD (HHFFFF) +Size 95: 48,108 GOOD (HHFFFF) +Size 96: 48,111 GOOD (HHFFFF) +Size 97: 49,111 GOOD (HHFFFF) +Size 98: 49,112 GOOD (HHFFFF) +Size 99: 50,113 GOOD (HHFFFF) +Size 100: 50,114 GOOD (HHFFFF) + +Windows 7 +--------- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,3 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (FFHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (FFHFHH) +Size 8: 4,9 GOOD (HHFFFF) +Size 9: 5,10 BAD (FFHFHH) +Size 10: 5,11 GOOD (HHFFFF) +Size 11: 6,13 BAD (FFHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,15 BAD (FFHFHH) +Size 14: 7,16 GOOD (HHFFFF) +Size 15: 8,17 BAD (FFHFHH) +Size 16: 8,18 GOOD (HHFFFF) +Size 17: 9,19 BAD (FFHFHH) +Size 18: 9,21 GOOD (HHFFFF) +Size 19: 10,22 BAD (FFHFHH) +Size 20: 10,23 GOOD (HHFFFF) +Size 21: 11,24 BAD (FFHFHH) +Size 22: 11,25 GOOD (HHFFFF) +Size 23: 12,26 BAD (FFHFHH) +Size 24: 12,27 GOOD (HHFFFF) +Size 25: 13,29 BAD (FFHFHH) +Size 26: 13,30 GOOD (HHFFFF) +Size 27: 14,31 BAD (FFHFHH) +Size 28: 14,32 GOOD (HHFFFF) +Size 29: 15,33 BAD (FFHFHH) +Size 30: 15,34 GOOD (HHFFFF) +Size 31: 16,35 BAD (FFHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,38 BAD (FFHFHH) +Size 34: 17,39 GOOD (HHFFFF) +Size 35: 18,40 BAD (FFHFHH) +Size 36: 18,41 GOOD (HHFFFF) +Size 37: 19,42 BAD (FFHFHH) +Size 38: 19,43 GOOD (HHFFFF) +Size 39: 20,44 BAD (FFHFHH) +Size 40: 20,46 GOOD (HHFFFF) +Size 41: 21,47 BAD (FFHFHH) +Size 42: 21,48 GOOD (HHFFFF) +Size 43: 22,49 BAD (FFHFHH) +Size 44: 22,50 GOOD (HHFFFF) +Size 45: 23,51 BAD (FFHFHH) +Size 46: 23,52 GOOD (HHFFFF) +Size 47: 24,54 BAD (FFHFHH) +Size 48: 24,55 GOOD (HHFFFF) +Size 49: 25,56 BAD (FFHFHH) +Size 50: 25,57 GOOD (HHFFFF) +Size 51: 26,58 BAD (FFHFHH) +Size 52: 26,59 GOOD (HHFFFF) +Size 53: 27,60 BAD (FFHFHH) +Size 54: 27,62 GOOD (HHFFFF) +Size 55: 28,63 BAD (FFHFHH) +Size 56: 28,64 GOOD (HHFFFF) +Size 57: 29,65 BAD (FFHFHH) +Size 58: 29,66 GOOD (HHFFFF) +Size 59: 30,67 BAD (FFHFHH) +Size 60: 30,68 GOOD (HHFFFF) +Size 61: 31,70 BAD (FFHFHH) +Size 62: 31,71 GOOD (HHFFFF) +Size 63: 32,72 BAD (FFHFHH) +Size 64: 32,73 GOOD (HHFFFF) +Size 65: 33,74 GOOD (HHFFFF) +Size 66: 33,75 GOOD (HHFFFF) +Size 67: 34,76 GOOD (HHFFFF) +Size 68: 34,78 GOOD (HHFFFF) +Size 69: 35,79 GOOD (HHFFFF) +Size 70: 35,80 GOOD (HHFFFF) +Size 71: 36,81 GOOD (HHFFFF) +Size 72: 36,82 GOOD (HHFFFF) +Size 73: 37,83 GOOD (HHFFFF) +Size 74: 37,84 GOOD (HHFFFF) +Size 75: 38,86 GOOD (HHFFFF) +Size 76: 38,87 GOOD (HHFFFF) +Size 77: 39,88 GOOD (HHFFFF) +Size 78: 39,89 GOOD (HHFFFF) +Size 79: 40,90 GOOD (HHFFFF) +Size 80: 40,91 GOOD (HHFFFF) +Size 81: 41,92 GOOD (HHFFFF) +Size 82: 41,94 GOOD (HHFFFF) +Size 83: 42,95 GOOD (HHFFFF) +Size 84: 42,96 GOOD (HHFFFF) +Size 85: 43,97 GOOD (HHFFFF) +Size 86: 43,98 GOOD (HHFFFF) +Size 87: 44,99 GOOD (HHFFFF) +Size 88: 44,100 GOOD (HHFFFF) +Size 89: 45,102 GOOD (HHFFFF) +Size 90: 45,103 GOOD (HHFFFF) +Size 91: 46,104 GOOD (HHFFFF) +Size 92: 46,105 GOOD (HHFFFF) +Size 93: 47,106 GOOD (HHFFFF) +Size 94: 47,107 GOOD (HHFFFF) +Size 95: 48,108 GOOD (HHFFFF) +Size 96: 48,111 GOOD (HHFFFF) +Size 97: 49,111 GOOD (HHFFFF) +Size 98: 49,112 GOOD (HHFFFF) +Size 99: 50,113 GOOD (HHFFFF) +Size 100: 50,114 GOOD (HHFFFF) + +Windows 8 +--------- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,3 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (FFHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (FFHFHH) +Size 8: 4,9 GOOD (HHFFFF) +Size 9: 5,10 BAD (FFHFHH) +Size 10: 5,11 GOOD (HHFFFF) +Size 11: 6,13 BAD (FFHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,15 BAD (FFHFHH) +Size 14: 7,16 GOOD (HHFFFF) +Size 15: 8,17 BAD (FFHFHH) +Size 16: 8,18 GOOD (HHFFFF) +Size 17: 9,19 BAD (FFHFHH) +Size 18: 9,21 GOOD (HHFFFF) +Size 19: 10,22 BAD (FFHFHH) +Size 20: 10,23 GOOD (HHFFFF) +Size 21: 11,24 BAD (FFHFHH) +Size 22: 11,25 GOOD (HHFFFF) +Size 23: 12,26 BAD (FFHFHH) +Size 24: 12,27 GOOD (HHFFFF) +Size 25: 13,29 BAD (FFHFHH) +Size 26: 13,30 GOOD (HHFFFF) +Size 27: 14,31 BAD (FFHFHH) +Size 28: 14,32 GOOD (HHFFFF) +Size 29: 15,33 BAD (FFHFHH) +Size 30: 15,34 GOOD (HHFFFF) +Size 31: 16,35 BAD (FFHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,38 BAD (FFHFHH) +Size 34: 17,39 GOOD (HHFFFF) +Size 35: 18,40 BAD (FFHFHH) +Size 36: 18,41 GOOD (HHFFFF) +Size 37: 19,42 BAD (FFHFHH) +Size 38: 19,43 GOOD (HHFFFF) +Size 39: 20,44 BAD (FFHFHH) +Size 40: 20,46 GOOD (HHFFFF) +Size 41: 21,47 BAD (FFHFHH) +Size 42: 21,48 GOOD (HHFFFF) +Size 43: 22,49 BAD (FFHFHH) +Size 44: 22,50 GOOD (HHFFFF) +Size 45: 23,51 BAD (FFHFHH) +Size 46: 23,52 GOOD (HHFFFF) +Size 47: 24,54 BAD (FFHFHH) +Size 48: 24,55 GOOD (HHFFFF) +Size 49: 25,56 BAD (FFHFHH) +Size 50: 25,57 GOOD (HHFFFF) +Size 51: 26,58 BAD (FFHFHH) +Size 52: 26,59 GOOD (HHFFFF) +Size 53: 27,60 BAD (FFHFHH) +Size 54: 27,62 GOOD (HHFFFF) +Size 55: 28,63 BAD (FFHFHH) +Size 56: 28,64 GOOD (HHFFFF) +Size 57: 29,65 BAD (FFHFHH) +Size 58: 29,66 GOOD (HHFFFF) +Size 59: 30,67 BAD (FFHFHH) +Size 60: 30,68 GOOD (HHFFFF) +Size 61: 31,70 BAD (FFHFHH) +Size 62: 31,71 GOOD (HHFFFF) +Size 63: 32,72 BAD (FFHFHH) +Size 64: 32,73 GOOD (HHFFFF) +Size 65: 33,74 GOOD (HHFFFF) +Size 66: 33,75 GOOD (HHFFFF) +Size 67: 34,76 GOOD (HHFFFF) +Size 68: 34,78 GOOD (HHFFFF) +Size 69: 35,79 GOOD (HHFFFF) +Size 70: 35,80 GOOD (HHFFFF) +Size 71: 36,81 GOOD (HHFFFF) +Size 72: 36,82 GOOD (HHFFFF) +Size 73: 37,83 GOOD (HHFFFF) +Size 74: 37,84 GOOD (HHFFFF) +Size 75: 38,86 GOOD (HHFFFF) +Size 76: 38,87 GOOD (HHFFFF) +Size 77: 39,88 GOOD (HHFFFF) +Size 78: 39,89 GOOD (HHFFFF) +Size 79: 40,90 GOOD (HHFFFF) +Size 80: 40,91 GOOD (HHFFFF) +Size 81: 41,92 GOOD (HHFFFF) +Size 82: 41,94 GOOD (HHFFFF) +Size 83: 42,95 GOOD (HHFFFF) +Size 84: 42,96 GOOD (HHFFFF) +Size 85: 43,97 GOOD (HHFFFF) +Size 86: 43,98 GOOD (HHFFFF) +Size 87: 44,99 GOOD (HHFFFF) +Size 88: 44,100 GOOD (HHFFFF) +Size 89: 45,102 GOOD (HHFFFF) +Size 90: 45,103 GOOD (HHFFFF) +Size 91: 46,104 GOOD (HHFFFF) +Size 92: 46,105 GOOD (HHFFFF) +Size 93: 47,106 GOOD (HHFFFF) +Size 94: 47,107 GOOD (HHFFFF) +Size 95: 48,108 GOOD (HHFFFF) +Size 96: 48,111 GOOD (HHFFFF) +Size 97: 49,111 GOOD (HHFFFF) +Size 98: 49,112 GOOD (HHFFFF) +Size 99: 50,113 GOOD (HHFFFF) +Size 100: 50,114 GOOD (HHFFFF) + +Windows 8.1 +----------- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,3 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (FFHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (FFHFHH) +Size 8: 4,9 GOOD (HHFFFF) +Size 9: 5,10 BAD (FFHFHH) +Size 10: 5,11 GOOD (HHFFFF) +Size 11: 6,13 BAD (FFHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,15 BAD (FFHFHH) +Size 14: 7,16 GOOD (HHFFFF) +Size 15: 8,17 BAD (FFHFHH) +Size 16: 8,18 GOOD (HHFFFF) +Size 17: 9,19 BAD (FFHFHH) +Size 18: 9,21 GOOD (HHFFFF) +Size 19: 10,22 BAD (FFHFHH) +Size 20: 10,23 GOOD (HHFFFF) +Size 21: 11,24 BAD (FFHFHH) +Size 22: 11,25 GOOD (HHFFFF) +Size 23: 12,26 BAD (FFHFHH) +Size 24: 12,27 GOOD (HHFFFF) +Size 25: 13,29 BAD (FFHFHH) +Size 26: 13,30 GOOD (HHFFFF) +Size 27: 14,31 BAD (FFHFHH) +Size 28: 14,32 GOOD (HHFFFF) +Size 29: 15,33 BAD (FFHFHH) +Size 30: 15,34 GOOD (HHFFFF) +Size 31: 16,35 BAD (FFHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,38 BAD (FFHFHH) +Size 34: 17,39 GOOD (HHFFFF) +Size 35: 18,40 BAD (FFHFHH) +Size 36: 18,41 GOOD (HHFFFF) +Size 37: 19,42 BAD (FFHFHH) +Size 38: 19,43 GOOD (HHFFFF) +Size 39: 20,44 BAD (FFHFHH) +Size 40: 20,46 GOOD (HHFFFF) +Size 41: 21,47 BAD (FFHFHH) +Size 42: 21,48 GOOD (HHFFFF) +Size 43: 22,49 BAD (FFHFHH) +Size 44: 22,50 GOOD (HHFFFF) +Size 45: 23,51 BAD (FFHFHH) +Size 46: 23,52 GOOD (HHFFFF) +Size 47: 24,54 BAD (FFHFHH) +Size 48: 24,55 GOOD (HHFFFF) +Size 49: 25,56 BAD (FFHFHH) +Size 50: 25,57 GOOD (HHFFFF) +Size 51: 26,58 BAD (FFHFHH) +Size 52: 26,59 GOOD (HHFFFF) +Size 53: 27,60 BAD (FFHFHH) +Size 54: 27,62 GOOD (HHFFFF) +Size 55: 28,63 BAD (FFHFHH) +Size 56: 28,64 GOOD (HHFFFF) +Size 57: 29,65 BAD (FFHFHH) +Size 58: 29,66 GOOD (HHFFFF) +Size 59: 30,67 BAD (FFHFHH) +Size 60: 30,68 GOOD (HHFFFF) +Size 61: 31,70 BAD (FFHFHH) +Size 62: 31,71 GOOD (HHFFFF) +Size 63: 32,72 BAD (FFHFHH) +Size 64: 32,73 GOOD (HHFFFF) +Size 65: 33,74 GOOD (HHFFFF) +Size 66: 33,75 GOOD (HHFFFF) +Size 67: 34,76 GOOD (HHFFFF) +Size 68: 34,78 GOOD (HHFFFF) +Size 69: 35,79 GOOD (HHFFFF) +Size 70: 35,80 GOOD (HHFFFF) +Size 71: 36,81 GOOD (HHFFFF) +Size 72: 36,82 GOOD (HHFFFF) +Size 73: 37,83 GOOD (HHFFFF) +Size 74: 37,84 GOOD (HHFFFF) +Size 75: 38,86 GOOD (HHFFFF) +Size 76: 38,87 GOOD (HHFFFF) +Size 77: 39,88 GOOD (HHFFFF) +Size 78: 39,89 GOOD (HHFFFF) +Size 79: 40,90 GOOD (HHFFFF) +Size 80: 40,91 GOOD (HHFFFF) +Size 81: 41,92 GOOD (HHFFFF) +Size 82: 41,94 GOOD (HHFFFF) +Size 83: 42,95 GOOD (HHFFFF) +Size 84: 42,96 GOOD (HHFFFF) +Size 85: 43,97 GOOD (HHFFFF) +Size 86: 43,98 GOOD (HHFFFF) +Size 87: 44,99 GOOD (HHFFFF) +Size 88: 44,100 GOOD (HHFFFF) +Size 89: 45,102 GOOD (HHFFFF) +Size 90: 45,103 GOOD (HHFFFF) +Size 91: 46,104 GOOD (HHFFFF) +Size 92: 46,105 GOOD (HHFFFF) +Size 93: 47,106 GOOD (HHFFFF) +Size 94: 47,107 GOOD (HHFFFF) +Size 95: 48,108 GOOD (HHFFFF) +Size 96: 48,111 GOOD (HHFFFF) +Size 97: 49,111 GOOD (HHFFFF) +Size 98: 49,112 GOOD (HHFFFF) +Size 99: 50,113 GOOD (HHFFFF) +Size 100: 50,114 GOOD (HHFFFF) + +Windows 10 14342 Old Console +---------------------------- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,3 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (FFHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (FFHFHH) +Size 8: 4,9 GOOD (HHFFFF) +Size 9: 5,10 BAD (FFHFHH) +Size 10: 5,11 GOOD (HHFFFF) +Size 11: 6,13 BAD (FFHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,15 BAD (FFHFHH) +Size 14: 7,16 GOOD (HHFFFF) +Size 15: 8,17 BAD (FFHFHH) +Size 16: 8,18 GOOD (HHFFFF) +Size 17: 9,19 BAD (FFHFHH) +Size 18: 9,21 GOOD (HHFFFF) +Size 19: 10,22 BAD (FFHFHH) +Size 20: 10,23 GOOD (HHFFFF) +Size 21: 11,24 BAD (FFHFHH) +Size 22: 11,25 GOOD (HHFFFF) +Size 23: 12,26 BAD (FFHFHH) +Size 24: 12,27 GOOD (HHFFFF) +Size 25: 13,29 BAD (FFHFHH) +Size 26: 13,30 GOOD (HHFFFF) +Size 27: 14,31 BAD (FFHFHH) +Size 28: 14,32 GOOD (HHFFFF) +Size 29: 15,33 BAD (FFHFHH) +Size 30: 15,34 GOOD (HHFFFF) +Size 31: 16,35 BAD (FFHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,38 BAD (FFHFHH) +Size 34: 17,39 GOOD (HHFFFF) +Size 35: 18,40 BAD (FFHFHH) +Size 36: 18,41 GOOD (HHFFFF) +Size 37: 19,42 BAD (FFHFHH) +Size 38: 19,43 GOOD (HHFFFF) +Size 39: 20,44 BAD (FFHFHH) +Size 40: 20,46 GOOD (HHFFFF) +Size 41: 21,47 BAD (FFHFHH) +Size 42: 21,48 GOOD (HHFFFF) +Size 43: 22,49 BAD (FFHFHH) +Size 44: 22,50 GOOD (HHFFFF) +Size 45: 23,51 BAD (FFHFHH) +Size 46: 23,52 GOOD (HHFFFF) +Size 47: 24,54 BAD (FFHFHH) +Size 48: 24,55 GOOD (HHFFFF) +Size 49: 25,56 BAD (FFHFHH) +Size 50: 25,57 GOOD (HHFFFF) +Size 51: 26,58 BAD (FFHFHH) +Size 52: 26,59 GOOD (HHFFFF) +Size 53: 27,60 BAD (FFHFHH) +Size 54: 27,62 GOOD (HHFFFF) +Size 55: 28,63 BAD (FFHFHH) +Size 56: 28,64 GOOD (HHFFFF) +Size 57: 29,65 BAD (FFHFHH) +Size 58: 29,66 GOOD (HHFFFF) +Size 59: 30,67 BAD (FFHFHH) +Size 60: 30,68 GOOD (HHFFFF) +Size 61: 31,70 BAD (FFHFHH) +Size 62: 31,71 GOOD (HHFFFF) +Size 63: 32,72 BAD (FFHFHH) +Size 64: 32,73 GOOD (HHFFFF) +Size 65: 33,74 GOOD (HHFFFF) +Size 66: 33,75 GOOD (HHFFFF) +Size 67: 34,76 GOOD (HHFFFF) +Size 68: 34,78 GOOD (HHFFFF) +Size 69: 35,79 GOOD (HHFFFF) +Size 70: 35,80 GOOD (HHFFFF) +Size 71: 36,81 GOOD (HHFFFF) +Size 72: 36,82 GOOD (HHFFFF) +Size 73: 37,83 GOOD (HHFFFF) +Size 74: 37,84 GOOD (HHFFFF) +Size 75: 38,86 GOOD (HHFFFF) +Size 76: 38,87 GOOD (HHFFFF) +Size 77: 39,88 GOOD (HHFFFF) +Size 78: 39,89 GOOD (HHFFFF) +Size 79: 40,90 GOOD (HHFFFF) +Size 80: 40,91 GOOD (HHFFFF) +Size 81: 41,92 GOOD (HHFFFF) +Size 82: 41,94 GOOD (HHFFFF) +Size 83: 42,95 GOOD (HHFFFF) +Size 84: 42,96 GOOD (HHFFFF) +Size 85: 43,97 GOOD (HHFFFF) +Size 86: 43,98 GOOD (HHFFFF) +Size 87: 44,99 GOOD (HHFFFF) +Size 88: 44,100 GOOD (HHFFFF) +Size 89: 45,102 GOOD (HHFFFF) +Size 90: 45,103 GOOD (HHFFFF) +Size 91: 46,104 GOOD (HHFFFF) +Size 92: 46,105 GOOD (HHFFFF) +Size 93: 47,106 GOOD (HHFFFF) +Size 94: 47,107 GOOD (HHFFFF) +Size 95: 48,108 GOOD (HHFFFF) +Size 96: 48,111 GOOD (HHFFFF) +Size 97: 49,111 GOOD (HHFFFF) +Size 98: 49,112 GOOD (HHFFFF) +Size 99: 50,113 GOOD (HHFFFF) +Size 100: 50,114 GOOD (HHFFFF) + +Windows 10 14342 New Console +---------------------------- + +Size 1: 1,1 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,3 GOOD (HHFFFF) +Size 4: 2,4 GOOD (HHFFFF) +Size 5: 3,5 GOOD (HHFFFF) +Size 6: 3,6 GOOD (HHFFFF) +Size 7: 4,7 GOOD (HHFFFF) +Size 8: 4,8 GOOD (HHFFFF) +Size 9: 5,9 GOOD (HHFFFF) +Size 10: 5,10 GOOD (HHFFFF) +Size 11: 6,11 GOOD (HHFFFF) +Size 12: 6,12 GOOD (HHFFFF) +Size 13: 7,13 GOOD (HHFFFF) +Size 14: 7,14 GOOD (HHFFFF) +Size 15: 8,15 GOOD (HHFFFF) +Size 16: 8,16 GOOD (HHFFFF) +Size 17: 9,17 GOOD (HHFFFF) +Size 18: 9,18 GOOD (HHFFFF) +Size 19: 10,19 GOOD (HHFFFF) +Size 20: 10,20 GOOD (HHFFFF) +Size 21: 11,21 GOOD (HHFFFF) +Size 22: 11,22 GOOD (HHFFFF) +Size 23: 12,23 GOOD (HHFFFF) +Size 24: 12,24 GOOD (HHFFFF) +Size 25: 13,25 GOOD (HHFFFF) +Size 26: 13,26 GOOD (HHFFFF) +Size 27: 14,27 GOOD (HHFFFF) +Size 28: 14,28 GOOD (HHFFFF) +Size 29: 15,29 GOOD (HHFFFF) +Size 30: 15,30 GOOD (HHFFFF) +Size 31: 16,31 GOOD (HHFFFF) +Size 32: 16,32 GOOD (HHFFFF) +Size 33: 17,33 GOOD (HHFFFF) +Size 34: 17,34 GOOD (HHFFFF) +Size 35: 18,35 GOOD (HHFFFF) +Size 36: 18,36 GOOD (HHFFFF) +Size 37: 19,37 GOOD (HHFFFF) +Size 38: 19,38 GOOD (HHFFFF) +Size 39: 20,39 GOOD (HHFFFF) +Size 40: 20,40 GOOD (HHFFFF) +Size 41: 21,41 GOOD (HHFFFF) +Size 42: 21,42 GOOD (HHFFFF) +Size 43: 22,43 GOOD (HHFFFF) +Size 44: 22,44 GOOD (HHFFFF) +Size 45: 23,45 GOOD (HHFFFF) +Size 46: 23,46 GOOD (HHFFFF) +Size 47: 24,47 GOOD (HHFFFF) +Size 48: 24,48 GOOD (HHFFFF) +Size 49: 25,49 GOOD (HHFFFF) +Size 50: 25,50 GOOD (HHFFFF) +Size 51: 26,51 GOOD (HHFFFF) +Size 52: 26,52 GOOD (HHFFFF) +Size 53: 27,53 GOOD (HHFFFF) +Size 54: 27,54 GOOD (HHFFFF) +Size 55: 28,55 GOOD (HHFFFF) +Size 56: 28,56 GOOD (HHFFFF) +Size 57: 29,57 GOOD (HHFFFF) +Size 58: 29,58 GOOD (HHFFFF) +Size 59: 30,59 GOOD (HHFFFF) +Size 60: 30,60 GOOD (HHFFFF) +Size 61: 31,61 GOOD (HHFFFF) +Size 62: 31,62 GOOD (HHFFFF) +Size 63: 32,63 GOOD (HHFFFF) +Size 64: 32,64 GOOD (HHFFFF) +Size 65: 33,65 GOOD (HHFFFF) +Size 66: 33,66 GOOD (HHFFFF) +Size 67: 34,67 GOOD (HHFFFF) +Size 68: 34,68 GOOD (HHFFFF) +Size 69: 35,69 GOOD (HHFFFF) +Size 70: 35,70 GOOD (HHFFFF) +Size 71: 36,71 GOOD (HHFFFF) +Size 72: 36,72 GOOD (HHFFFF) +Size 73: 37,73 GOOD (HHFFFF) +Size 74: 37,74 GOOD (HHFFFF) +Size 75: 38,75 GOOD (HHFFFF) +Size 76: 38,76 GOOD (HHFFFF) +Size 77: 39,77 GOOD (HHFFFF) +Size 78: 39,78 GOOD (HHFFFF) +Size 79: 40,79 GOOD (HHFFFF) +Size 80: 40,80 GOOD (HHFFFF) +Size 81: 41,81 GOOD (HHFFFF) +Size 82: 41,82 GOOD (HHFFFF) +Size 83: 42,83 GOOD (HHFFFF) +Size 84: 42,84 GOOD (HHFFFF) +Size 85: 43,85 GOOD (HHFFFF) +Size 86: 43,86 GOOD (HHFFFF) +Size 87: 44,87 GOOD (HHFFFF) +Size 88: 44,88 GOOD (HHFFFF) +Size 89: 45,89 GOOD (HHFFFF) +Size 90: 45,90 GOOD (HHFFFF) +Size 91: 46,91 GOOD (HHFFFF) +Size 92: 46,92 GOOD (HHFFFF) +Size 93: 47,93 GOOD (HHFFFF) +Size 94: 47,94 GOOD (HHFFFF) +Size 95: 48,95 GOOD (HHFFFF) +Size 96: 48,96 GOOD (HHFFFF) +Size 97: 49,97 GOOD (HHFFFF) +Size 98: 49,98 GOOD (HHFFFF) +Size 99: 50,99 GOOD (HHFFFF) +Size 100: 50,100 GOOD (HHFFFF) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP949.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP949.txt new file mode 100644 index 00000000..2f0ea1e7 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP949.txt @@ -0,0 +1,630 @@ +===================================== +Code Page 949, Korean, GulimChe font +===================================== + +Options: -face-gulimche -family 0x36 +Chars: A2 A3 2014 3044 30FC 4000 + +Vista +----- + +Size 1: 1,2 OK (HHHFFF) +Size 2: 1,2 OK (HHHFFF) +Size 3: 2,3 BAD (FFFFHH) +Size 4: 2,5 OK (HHHFFF) +Size 5: 3,6 BAD (HHHFHH) +Size 6: 3,7 OK (HHHFFF) +Size 7: 4,8 BAD (HHHFHH) +Size 8: 4,9 OK (HHHFFF) +Size 9: 5,10 BAD (HHHFHH) +Size 10: 5,11 OK (HHHFFF) +Size 11: 6,13 BAD (HHHFHH) +Size 12: 6,14 OK (HHHFFF) +Size 13: 7,15 BAD (HHHFHH) +Size 14: 7,16 OK (HHHFFF) +Size 15: 8,17 BAD (HHHFHH) +Size 16: 8,18 OK (HHHFFF) +Size 17: 9,20 BAD (HHHFHH) +Size 18: 9,21 OK (HHHFFF) +Size 19: 10,22 BAD (HHHFHH) +Size 20: 10,23 OK (HHHFFF) +Size 21: 11,24 BAD (HHHFHH) +Size 22: 11,25 OK (HHHFFF) +Size 23: 12,26 BAD (HHHFHH) +Size 24: 12,28 OK (HHHFFF) +Size 25: 13,29 BAD (HHHFHH) +Size 26: 13,30 OK (HHHFFF) +Size 27: 14,31 BAD (HHHFHH) +Size 28: 14,32 OK (HHHFFF) +Size 29: 15,33 BAD (HHHFHH) +Size 30: 15,34 OK (HHHFFF) +Size 31: 16,36 BAD (HHHFHH) +Size 32: 16,37 OK (HHHFFF) +Size 33: 17,38 BAD (HHHFHH) +Size 34: 17,39 OK (HHHFFF) +Size 35: 18,40 BAD (HHHFHH) +Size 36: 18,41 OK (HHHFFF) +Size 37: 19,42 BAD (HHHFHH) +Size 38: 19,44 OK (HHHFFF) +Size 39: 20,45 BAD (HHHFHH) +Size 40: 20,46 OK (HHHFFF) +Size 41: 21,47 BAD (HHHFHH) +Size 42: 21,48 OK (HHHFFF) +Size 43: 22,49 BAD (HHHFHH) +Size 44: 22,51 OK (HHHFFF) +Size 45: 23,52 BAD (HHHFHH) +Size 46: 23,53 OK (HHHFFF) +Size 47: 24,54 BAD (HHHFHH) +Size 48: 24,55 OK (HHHFFF) +Size 49: 25,56 BAD (HHHFHH) +Size 50: 25,57 OK (HHHFFF) +Size 51: 26,59 BAD (HHHFHH) +Size 52: 26,60 OK (HHHFFF) +Size 53: 27,61 BAD (HHHFHH) +Size 54: 27,62 OK (HHHFFF) +Size 55: 28,63 BAD (HHHFHH) +Size 56: 28,64 OK (HHHFFF) +Size 57: 29,65 BAD (HHHFHH) +Size 58: 29,67 OK (HHHFFF) +Size 59: 30,68 BAD (HHHFHH) +Size 60: 30,69 OK (HHHFFF) +Size 61: 31,70 BAD (HHHFHH) +Size 62: 31,71 OK (HHHFFF) +Size 63: 32,72 BAD (HHHFHH) +Size 64: 32,74 OK (HHHFFF) +Size 65: 33,75 BAD (HHHFHH) +Size 66: 33,76 OK (HHHFFF) +Size 67: 34,77 BAD (HHHFHH) +Size 68: 34,78 OK (HHHFFF) +Size 69: 35,79 BAD (HHHFHH) +Size 70: 35,80 OK (HHHFFF) +Size 71: 36,82 BAD (HHHFHH) +Size 72: 36,83 OK (HHHFFF) +Size 73: 37,84 BAD (HHHFHH) +Size 74: 37,85 OK (HHHFFF) +Size 75: 38,86 BAD (HHHFHH) +Size 76: 38,87 OK (HHHFFF) +Size 77: 39,88 BAD (HHHFHH) +Size 78: 39,90 OK (HHHFFF) +Size 79: 40,91 BAD (HHHFHH) +Size 80: 40,92 OK (HHHFFF) +Size 81: 41,93 BAD (HHHFHH) +Size 82: 41,94 OK (HHHFFF) +Size 83: 42,95 BAD (HHHFHH) +Size 84: 42,96 OK (HHHFFF) +Size 85: 43,98 BAD (HHHFHH) +Size 86: 43,99 OK (HHHFFF) +Size 87: 44,100 BAD (HHHFHH) +Size 88: 44,101 OK (HHHFFF) +Size 89: 45,102 BAD (HHHFHH) +Size 90: 45,103 OK (HHHFFF) +Size 91: 46,105 BAD (HHHFHH) +Size 92: 46,106 OK (HHHFFF) +Size 93: 47,107 BAD (HHHFHH) +Size 94: 47,108 OK (HHHFFF) +Size 95: 48,109 BAD (HHHFHH) +Size 96: 48,110 OK (HHHFFF) +Size 97: 49,111 BAD (HHHFHH) +Size 98: 49,113 OK (HHHFFF) +Size 99: 50,114 BAD (HHHFHH) +Size 100: 50,115 OK (HHHFFF) + +Windows 7 +--------- + +Size 1: 1,2 OK (HHHFFF) +Size 2: 1,2 OK (HHHFFF) +Size 3: 2,3 BAD (FFFFHH) +Size 4: 2,5 OK (HHHFFF) +Size 5: 3,6 BAD (FFFFHH) +Size 6: 3,7 OK (HHHFFF) +Size 7: 4,8 BAD (FFFFHH) +Size 8: 4,9 OK (HHHFFF) +Size 9: 5,10 BAD (FFFFHH) +Size 10: 5,11 OK (HHHFFF) +Size 11: 6,13 BAD (FFFFHH) +Size 12: 6,14 OK (HHHFFF) +Size 13: 7,15 BAD (FFFFHH) +Size 14: 7,16 OK (HHHFFF) +Size 15: 8,17 BAD (FFFFHH) +Size 16: 8,18 OK (HHHFFF) +Size 17: 9,20 BAD (FFFFHH) +Size 18: 9,21 OK (HHHFFF) +Size 19: 10,22 BAD (FFFFHH) +Size 20: 10,23 OK (HHHFFF) +Size 21: 11,24 BAD (FFFFHH) +Size 22: 11,25 OK (HHHFFF) +Size 23: 12,26 BAD (FFFFHH) +Size 24: 12,28 OK (HHHFFF) +Size 25: 13,29 BAD (FFFFHH) +Size 26: 13,30 OK (HHHFFF) +Size 27: 14,31 BAD (FFFFHH) +Size 28: 14,32 OK (HHHFFF) +Size 29: 15,33 BAD (FFFFHH) +Size 30: 15,34 OK (HHHFFF) +Size 31: 16,36 BAD (FFFFHH) +Size 32: 16,37 OK (HHHFFF) +Size 33: 17,38 BAD (FFFFHH) +Size 34: 17,39 OK (HHHFFF) +Size 35: 18,40 BAD (FFFFHH) +Size 36: 18,41 OK (HHHFFF) +Size 37: 19,42 BAD (FFFFHH) +Size 38: 19,44 OK (HHHFFF) +Size 39: 20,45 BAD (FFFFHH) +Size 40: 20,46 OK (HHHFFF) +Size 41: 21,47 BAD (FFFFHH) +Size 42: 21,48 OK (HHHFFF) +Size 43: 22,49 BAD (FFFFHH) +Size 44: 22,51 OK (HHHFFF) +Size 45: 23,52 BAD (FFFFHH) +Size 46: 23,53 OK (HHHFFF) +Size 47: 24,54 BAD (FFFFHH) +Size 48: 24,55 OK (HHHFFF) +Size 49: 25,56 BAD (FFFFHH) +Size 50: 25,57 OK (HHHFFF) +Size 51: 26,59 BAD (FFFFHH) +Size 52: 26,60 OK (HHHFFF) +Size 53: 27,61 BAD (FFFFHH) +Size 54: 27,62 OK (HHHFFF) +Size 55: 28,63 BAD (FFFFHH) +Size 56: 28,64 OK (HHHFFF) +Size 57: 29,65 BAD (FFFFHH) +Size 58: 29,67 OK (HHHFFF) +Size 59: 30,68 BAD (FFFFHH) +Size 60: 30,69 OK (HHHFFF) +Size 61: 31,70 BAD (FFFFHH) +Size 62: 31,71 OK (HHHFFF) +Size 63: 32,72 BAD (FFFFHH) +Size 64: 32,74 OK (HHHFFF) +Size 65: 33,75 BAD (FFFFHH) +Size 66: 33,76 OK (HHHFFF) +Size 67: 34,77 BAD (FFFFHH) +Size 68: 34,78 OK (HHHFFF) +Size 69: 35,79 BAD (FFFFHH) +Size 70: 35,80 OK (HHHFFF) +Size 71: 36,82 BAD (FFFFHH) +Size 72: 36,83 OK (HHHFFF) +Size 73: 37,84 BAD (FFFFHH) +Size 74: 37,85 OK (HHHFFF) +Size 75: 38,86 BAD (FFFFHH) +Size 76: 38,87 OK (HHHFFF) +Size 77: 39,88 BAD (FFFFHH) +Size 78: 39,90 OK (HHHFFF) +Size 79: 40,91 BAD (FFFFHH) +Size 80: 40,92 OK (HHHFFF) +Size 81: 41,93 BAD (FFFFHH) +Size 82: 41,94 OK (HHHFFF) +Size 83: 42,95 BAD (FFFFHH) +Size 84: 42,96 OK (HHHFFF) +Size 85: 43,98 BAD (FFFFHH) +Size 86: 43,99 OK (HHHFFF) +Size 87: 44,100 BAD (FFFFHH) +Size 88: 44,101 OK (HHHFFF) +Size 89: 45,102 BAD (FFFFHH) +Size 90: 45,103 OK (HHHFFF) +Size 91: 46,105 BAD (FFFFHH) +Size 92: 46,106 OK (HHHFFF) +Size 93: 47,107 BAD (FFFFHH) +Size 94: 47,108 OK (HHHFFF) +Size 95: 48,109 BAD (FFFFHH) +Size 96: 48,110 OK (HHHFFF) +Size 97: 49,111 BAD (FFFFHH) +Size 98: 49,113 OK (HHHFFF) +Size 99: 50,114 BAD (FFFFHH) +Size 100: 50,115 OK (HHHFFF) + +Windows 8 +--------- + +Size 1: 1,2 OK (HHHFFF) +Size 2: 1,2 OK (HHHFFF) +Size 3: 2,3 BAD (FFFFHH) +Size 4: 2,5 OK (HHHFFF) +Size 5: 3,6 BAD (FFFFHH) +Size 6: 3,7 OK (HHHFFF) +Size 7: 4,8 BAD (FFFFHH) +Size 8: 4,9 OK (HHHFFF) +Size 9: 5,10 BAD (FFFFHH) +Size 10: 5,11 OK (HHHFFF) +Size 11: 6,13 BAD (FFFFHH) +Size 12: 6,14 OK (HHHFFF) +Size 13: 7,15 BAD (FFFFHH) +Size 14: 7,16 OK (HHHFFF) +Size 15: 8,17 BAD (FFFFHH) +Size 16: 8,18 OK (HHHFFF) +Size 17: 9,20 BAD (FFFFHH) +Size 18: 9,21 OK (HHHFFF) +Size 19: 10,22 BAD (FFFFHH) +Size 20: 10,23 OK (HHHFFF) +Size 21: 11,24 BAD (FFFFHH) +Size 22: 11,25 OK (HHHFFF) +Size 23: 12,26 BAD (FFFFHH) +Size 24: 12,28 OK (HHHFFF) +Size 25: 13,29 BAD (FFFFHH) +Size 26: 13,30 OK (HHHFFF) +Size 27: 14,31 BAD (FFFFHH) +Size 28: 14,32 OK (HHHFFF) +Size 29: 15,33 BAD (FFFFHH) +Size 30: 15,34 OK (HHHFFF) +Size 31: 16,36 BAD (FFFFHH) +Size 32: 16,37 OK (HHHFFF) +Size 33: 17,38 BAD (FFFFHH) +Size 34: 17,39 OK (HHHFFF) +Size 35: 18,40 BAD (FFFFHH) +Size 36: 18,41 OK (HHHFFF) +Size 37: 19,42 BAD (FFFFHH) +Size 38: 19,44 OK (HHHFFF) +Size 39: 20,45 BAD (FFFFHH) +Size 40: 20,46 OK (HHHFFF) +Size 41: 21,47 BAD (FFFFHH) +Size 42: 21,48 OK (HHHFFF) +Size 43: 22,49 BAD (FFFFHH) +Size 44: 22,51 OK (HHHFFF) +Size 45: 23,52 BAD (FFFFHH) +Size 46: 23,53 OK (HHHFFF) +Size 47: 24,54 BAD (FFFFHH) +Size 48: 24,55 OK (HHHFFF) +Size 49: 25,56 BAD (FFFFHH) +Size 50: 25,57 OK (HHHFFF) +Size 51: 26,59 BAD (FFFFHH) +Size 52: 26,60 OK (HHHFFF) +Size 53: 27,61 BAD (FFFFHH) +Size 54: 27,62 OK (HHHFFF) +Size 55: 28,63 BAD (FFFFHH) +Size 56: 28,64 OK (HHHFFF) +Size 57: 29,65 BAD (FFFFHH) +Size 58: 29,67 OK (HHHFFF) +Size 59: 30,68 BAD (FFFFHH) +Size 60: 30,69 OK (HHHFFF) +Size 61: 31,70 BAD (FFFFHH) +Size 62: 31,71 OK (HHHFFF) +Size 63: 32,72 BAD (FFFFHH) +Size 64: 32,74 OK (HHHFFF) +Size 65: 33,75 BAD (FFFFHH) +Size 66: 33,76 OK (HHHFFF) +Size 67: 34,77 BAD (FFFFHH) +Size 68: 34,78 OK (HHHFFF) +Size 69: 35,79 BAD (FFFFHH) +Size 70: 35,80 OK (HHHFFF) +Size 71: 36,82 BAD (FFFFHH) +Size 72: 36,83 OK (HHHFFF) +Size 73: 37,84 BAD (FFFFHH) +Size 74: 37,85 OK (HHHFFF) +Size 75: 38,86 BAD (FFFFHH) +Size 76: 38,87 OK (HHHFFF) +Size 77: 39,88 BAD (FFFFHH) +Size 78: 39,90 OK (HHHFFF) +Size 79: 40,91 BAD (FFFFHH) +Size 80: 40,92 OK (HHHFFF) +Size 81: 41,93 BAD (FFFFHH) +Size 82: 41,94 OK (HHHFFF) +Size 83: 42,95 BAD (FFFFHH) +Size 84: 42,96 OK (HHHFFF) +Size 85: 43,98 BAD (FFFFHH) +Size 86: 43,99 OK (HHHFFF) +Size 87: 44,100 BAD (FFFFHH) +Size 88: 44,101 OK (HHHFFF) +Size 89: 45,102 BAD (FFFFHH) +Size 90: 45,103 OK (HHHFFF) +Size 91: 46,105 BAD (FFFFHH) +Size 92: 46,106 OK (HHHFFF) +Size 93: 47,107 BAD (FFFFHH) +Size 94: 47,108 OK (HHHFFF) +Size 95: 48,109 BAD (FFFFHH) +Size 96: 48,110 OK (HHHFFF) +Size 97: 49,111 BAD (FFFFHH) +Size 98: 49,113 OK (HHHFFF) +Size 99: 50,114 BAD (FFFFHH) +Size 100: 50,115 OK (HHHFFF) + +Windows 8.1 +----------- + +Size 1: 1,2 OK (HHHFFF) +Size 2: 1,2 OK (HHHFFF) +Size 3: 2,3 BAD (FFFFHH) +Size 4: 2,5 OK (HHHFFF) +Size 5: 3,6 BAD (FFFFHH) +Size 6: 3,7 OK (HHHFFF) +Size 7: 4,8 BAD (FFFFHH) +Size 8: 4,9 OK (HHHFFF) +Size 9: 5,10 BAD (FFFFHH) +Size 10: 5,11 OK (HHHFFF) +Size 11: 6,13 BAD (FFFFHH) +Size 12: 6,14 OK (HHHFFF) +Size 13: 7,15 BAD (FFFFHH) +Size 14: 7,16 OK (HHHFFF) +Size 15: 8,17 BAD (FFFFHH) +Size 16: 8,18 OK (HHHFFF) +Size 17: 9,20 BAD (FFFFHH) +Size 18: 9,21 OK (HHHFFF) +Size 19: 10,22 BAD (FFFFHH) +Size 20: 10,23 OK (HHHFFF) +Size 21: 11,24 BAD (FFFFHH) +Size 22: 11,25 OK (HHHFFF) +Size 23: 12,26 BAD (FFFFHH) +Size 24: 12,28 OK (HHHFFF) +Size 25: 13,29 BAD (FFFFHH) +Size 26: 13,30 OK (HHHFFF) +Size 27: 14,31 BAD (FFFFHH) +Size 28: 14,32 OK (HHHFFF) +Size 29: 15,33 BAD (FFFFHH) +Size 30: 15,34 OK (HHHFFF) +Size 31: 16,36 BAD (FFFFHH) +Size 32: 16,37 OK (HHHFFF) +Size 33: 17,38 BAD (FFFFHH) +Size 34: 17,39 OK (HHHFFF) +Size 35: 18,40 BAD (FFFFHH) +Size 36: 18,41 OK (HHHFFF) +Size 37: 19,42 BAD (FFFFHH) +Size 38: 19,44 OK (HHHFFF) +Size 39: 20,45 BAD (FFFFHH) +Size 40: 20,46 OK (HHHFFF) +Size 41: 21,47 BAD (FFFFHH) +Size 42: 21,48 OK (HHHFFF) +Size 43: 22,49 BAD (FFFFHH) +Size 44: 22,51 OK (HHHFFF) +Size 45: 23,52 BAD (FFFFHH) +Size 46: 23,53 OK (HHHFFF) +Size 47: 24,54 BAD (FFFFHH) +Size 48: 24,55 OK (HHHFFF) +Size 49: 25,56 BAD (FFFFHH) +Size 50: 25,57 OK (HHHFFF) +Size 51: 26,59 BAD (FFFFHH) +Size 52: 26,60 OK (HHHFFF) +Size 53: 27,61 BAD (FFFFHH) +Size 54: 27,62 OK (HHHFFF) +Size 55: 28,63 BAD (FFFFHH) +Size 56: 28,64 OK (HHHFFF) +Size 57: 29,65 BAD (FFFFHH) +Size 58: 29,67 OK (HHHFFF) +Size 59: 30,68 BAD (FFFFHH) +Size 60: 30,69 OK (HHHFFF) +Size 61: 31,70 BAD (FFFFHH) +Size 62: 31,71 OK (HHHFFF) +Size 63: 32,72 BAD (FFFFHH) +Size 64: 32,74 OK (HHHFFF) +Size 65: 33,75 BAD (FFFFHH) +Size 66: 33,76 OK (HHHFFF) +Size 67: 34,77 BAD (FFFFHH) +Size 68: 34,78 OK (HHHFFF) +Size 69: 35,79 BAD (FFFFHH) +Size 70: 35,80 OK (HHHFFF) +Size 71: 36,82 BAD (FFFFHH) +Size 72: 36,83 OK (HHHFFF) +Size 73: 37,84 BAD (FFFFHH) +Size 74: 37,85 OK (HHHFFF) +Size 75: 38,86 BAD (FFFFHH) +Size 76: 38,87 OK (HHHFFF) +Size 77: 39,88 BAD (FFFFHH) +Size 78: 39,90 OK (HHHFFF) +Size 79: 40,91 BAD (FFFFHH) +Size 80: 40,92 OK (HHHFFF) +Size 81: 41,93 BAD (FFFFHH) +Size 82: 41,94 OK (HHHFFF) +Size 83: 42,95 BAD (FFFFHH) +Size 84: 42,96 OK (HHHFFF) +Size 85: 43,98 BAD (FFFFHH) +Size 86: 43,99 OK (HHHFFF) +Size 87: 44,100 BAD (FFFFHH) +Size 88: 44,101 OK (HHHFFF) +Size 89: 45,102 BAD (FFFFHH) +Size 90: 45,103 OK (HHHFFF) +Size 91: 46,105 BAD (FFFFHH) +Size 92: 46,106 OK (HHHFFF) +Size 93: 47,107 BAD (FFFFHH) +Size 94: 47,108 OK (HHHFFF) +Size 95: 48,109 BAD (FFFFHH) +Size 96: 48,110 OK (HHHFFF) +Size 97: 49,111 BAD (FFFFHH) +Size 98: 49,113 OK (HHHFFF) +Size 99: 50,114 BAD (FFFFHH) +Size 100: 50,115 OK (HHHFFF) + +Windows 10 14342 Old Console +---------------------------- + +Size 1: 1,2 OK (HHHFFF) +Size 2: 1,2 OK (HHHFFF) +Size 3: 2,3 BAD (FFFFHH) +Size 4: 2,5 OK (HHHFFF) +Size 5: 3,6 BAD (FFFFHH) +Size 6: 3,7 OK (HHHFFF) +Size 7: 4,8 BAD (FFFFHH) +Size 8: 4,9 OK (HHHFFF) +Size 9: 5,10 BAD (FFFFHH) +Size 10: 5,11 OK (HHHFFF) +Size 11: 6,13 BAD (FFFFHH) +Size 12: 6,14 OK (HHHFFF) +Size 13: 7,15 BAD (FFFFHH) +Size 14: 7,16 OK (HHHFFF) +Size 15: 8,17 BAD (FFFFHH) +Size 16: 8,18 OK (HHHFFF) +Size 17: 9,20 BAD (FFFFHH) +Size 18: 9,21 OK (HHHFFF) +Size 19: 10,22 BAD (FFFFHH) +Size 20: 10,23 OK (HHHFFF) +Size 21: 11,24 BAD (FFFFHH) +Size 22: 11,25 OK (HHHFFF) +Size 23: 12,26 BAD (FFFFHH) +Size 24: 12,28 OK (HHHFFF) +Size 25: 13,29 BAD (FFFFHH) +Size 26: 13,30 OK (HHHFFF) +Size 27: 14,31 BAD (FFFFHH) +Size 28: 14,32 OK (HHHFFF) +Size 29: 15,33 BAD (FFFFHH) +Size 30: 15,34 OK (HHHFFF) +Size 31: 16,36 BAD (FFFFHH) +Size 32: 16,37 OK (HHHFFF) +Size 33: 17,38 BAD (FFFFHH) +Size 34: 17,39 OK (HHHFFF) +Size 35: 18,40 BAD (FFFFHH) +Size 36: 18,41 OK (HHHFFF) +Size 37: 19,42 BAD (FFFFHH) +Size 38: 19,44 OK (HHHFFF) +Size 39: 20,45 BAD (FFFFHH) +Size 40: 20,46 OK (HHHFFF) +Size 41: 21,47 BAD (FFFFHH) +Size 42: 21,48 OK (HHHFFF) +Size 43: 22,49 BAD (FFFFHH) +Size 44: 22,51 OK (HHHFFF) +Size 45: 23,52 BAD (FFFFHH) +Size 46: 23,53 OK (HHHFFF) +Size 47: 24,54 BAD (FFFFHH) +Size 48: 24,55 OK (HHHFFF) +Size 49: 25,56 BAD (FFFFHH) +Size 50: 25,57 OK (HHHFFF) +Size 51: 26,59 BAD (FFFFHH) +Size 52: 26,60 OK (HHHFFF) +Size 53: 27,61 BAD (FFFFHH) +Size 54: 27,62 OK (HHHFFF) +Size 55: 28,63 BAD (FFFFHH) +Size 56: 28,64 OK (HHHFFF) +Size 57: 29,65 BAD (FFFFHH) +Size 58: 29,67 OK (HHHFFF) +Size 59: 30,68 BAD (FFFFHH) +Size 60: 30,69 OK (HHHFFF) +Size 61: 31,70 BAD (FFFFHH) +Size 62: 31,71 OK (HHHFFF) +Size 63: 32,72 BAD (FFFFHH) +Size 64: 32,74 OK (HHHFFF) +Size 65: 33,75 BAD (FFFFHH) +Size 66: 33,76 OK (HHHFFF) +Size 67: 34,77 BAD (FFFFHH) +Size 68: 34,78 OK (HHHFFF) +Size 69: 35,79 BAD (FFFFHH) +Size 70: 35,80 OK (HHHFFF) +Size 71: 36,82 BAD (FFFFHH) +Size 72: 36,83 OK (HHHFFF) +Size 73: 37,84 BAD (FFFFHH) +Size 74: 37,85 OK (HHHFFF) +Size 75: 38,86 BAD (FFFFHH) +Size 76: 38,87 OK (HHHFFF) +Size 77: 39,88 BAD (FFFFHH) +Size 78: 39,90 OK (HHHFFF) +Size 79: 40,91 BAD (FFFFHH) +Size 80: 40,92 OK (HHHFFF) +Size 81: 41,93 BAD (FFFFHH) +Size 82: 41,94 OK (HHHFFF) +Size 83: 42,95 BAD (FFFFHH) +Size 84: 42,96 OK (HHHFFF) +Size 85: 43,98 BAD (FFFFHH) +Size 86: 43,99 OK (HHHFFF) +Size 87: 44,100 BAD (FFFFHH) +Size 88: 44,101 OK (HHHFFF) +Size 89: 45,102 BAD (FFFFHH) +Size 90: 45,103 OK (HHHFFF) +Size 91: 46,105 BAD (FFFFHH) +Size 92: 46,106 OK (HHHFFF) +Size 93: 47,107 BAD (FFFFHH) +Size 94: 47,108 OK (HHHFFF) +Size 95: 48,109 BAD (FFFFHH) +Size 96: 48,110 OK (HHHFFF) +Size 97: 49,111 BAD (FFFFHH) +Size 98: 49,113 OK (HHHFFF) +Size 99: 50,114 BAD (FFFFHH) +Size 100: 50,115 OK (HHHFFF) + +Windows 10 14342 New Console +---------------------------- + +Size 1: 1,1 OK (HHHFFF) +Size 2: 1,2 OK (HHHFFF) +Size 3: 2,3 OK (HHHFFF) +Size 4: 2,4 OK (HHHFFF) +Size 5: 3,5 OK (HHHFFF) +Size 6: 3,6 OK (HHHFFF) +Size 7: 4,7 OK (HHHFFF) +Size 8: 4,8 OK (HHHFFF) +Size 9: 5,9 OK (HHHFFF) +Size 10: 5,10 OK (HHHFFF) +Size 11: 6,11 OK (HHHFFF) +Size 12: 6,12 OK (HHHFFF) +Size 13: 7,13 OK (HHHFFF) +Size 14: 7,14 OK (HHHFFF) +Size 15: 8,15 OK (HHHFFF) +Size 16: 8,16 OK (HHHFFF) +Size 17: 9,17 OK (HHHFFF) +Size 18: 9,18 OK (HHHFFF) +Size 19: 10,19 OK (HHHFFF) +Size 20: 10,20 OK (HHHFFF) +Size 21: 11,21 OK (HHHFFF) +Size 22: 11,22 OK (HHHFFF) +Size 23: 12,23 OK (HHHFFF) +Size 24: 12,24 OK (HHHFFF) +Size 25: 13,25 OK (HHHFFF) +Size 26: 13,26 OK (HHHFFF) +Size 27: 14,27 OK (HHHFFF) +Size 28: 14,28 OK (HHHFFF) +Size 29: 15,29 OK (HHHFFF) +Size 30: 15,30 OK (HHHFFF) +Size 31: 16,31 OK (HHHFFF) +Size 32: 16,32 OK (HHHFFF) +Size 33: 17,33 OK (HHHFFF) +Size 34: 17,34 OK (HHHFFF) +Size 35: 18,35 OK (HHHFFF) +Size 36: 18,36 OK (HHHFFF) +Size 37: 19,37 OK (HHHFFF) +Size 38: 19,38 OK (HHHFFF) +Size 39: 20,39 OK (HHHFFF) +Size 40: 20,40 OK (HHHFFF) +Size 41: 21,41 OK (HHHFFF) +Size 42: 21,42 OK (HHHFFF) +Size 43: 22,43 OK (HHHFFF) +Size 44: 22,44 OK (HHHFFF) +Size 45: 23,45 OK (HHHFFF) +Size 46: 23,46 OK (HHHFFF) +Size 47: 24,47 OK (HHHFFF) +Size 48: 24,48 OK (HHHFFF) +Size 49: 25,49 OK (HHHFFF) +Size 50: 25,50 OK (HHHFFF) +Size 51: 26,51 OK (HHHFFF) +Size 52: 26,52 OK (HHHFFF) +Size 53: 27,53 OK (HHHFFF) +Size 54: 27,54 OK (HHHFFF) +Size 55: 28,55 OK (HHHFFF) +Size 56: 28,56 OK (HHHFFF) +Size 57: 29,57 OK (HHHFFF) +Size 58: 29,58 OK (HHHFFF) +Size 59: 30,59 OK (HHHFFF) +Size 60: 30,60 OK (HHHFFF) +Size 61: 31,61 OK (HHHFFF) +Size 62: 31,62 OK (HHHFFF) +Size 63: 32,63 OK (HHHFFF) +Size 64: 32,64 OK (HHHFFF) +Size 65: 33,65 OK (HHHFFF) +Size 66: 33,66 OK (HHHFFF) +Size 67: 34,67 OK (HHHFFF) +Size 68: 34,68 OK (HHHFFF) +Size 69: 35,69 OK (HHHFFF) +Size 70: 35,70 OK (HHHFFF) +Size 71: 36,71 OK (HHHFFF) +Size 72: 36,72 OK (HHHFFF) +Size 73: 37,73 OK (HHHFFF) +Size 74: 37,74 OK (HHHFFF) +Size 75: 38,75 OK (HHHFFF) +Size 76: 38,76 OK (HHHFFF) +Size 77: 39,77 OK (HHHFFF) +Size 78: 39,78 OK (HHHFFF) +Size 79: 40,79 OK (HHHFFF) +Size 80: 40,80 OK (HHHFFF) +Size 81: 41,81 OK (HHHFFF) +Size 82: 41,82 OK (HHHFFF) +Size 83: 42,83 OK (HHHFFF) +Size 84: 42,84 OK (HHHFFF) +Size 85: 43,85 OK (HHHFFF) +Size 86: 43,86 OK (HHHFFF) +Size 87: 44,87 OK (HHHFFF) +Size 88: 44,88 OK (HHHFFF) +Size 89: 45,89 OK (HHHFFF) +Size 90: 45,90 OK (HHHFFF) +Size 91: 46,91 OK (HHHFFF) +Size 92: 46,92 OK (HHHFFF) +Size 93: 47,93 OK (HHHFFF) +Size 94: 47,94 OK (HHHFFF) +Size 95: 48,95 OK (HHHFFF) +Size 96: 48,96 OK (HHHFFF) +Size 97: 49,97 OK (HHHFFF) +Size 98: 49,98 OK (HHHFFF) +Size 99: 50,99 OK (HHHFFF) +Size 100: 50,100 OK (HHHFFF) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP950.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP950.txt new file mode 100644 index 00000000..0dbade50 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/CP950.txt @@ -0,0 +1,630 @@ +=========================================================== +Code Page 950, Chinese Traditional (Taiwan), MingLight font +=========================================================== + +Options: -face-minglight -family 0x36 +Chars: A2 A3 2014 3044 30FC 4000 + +Vista +----- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,4 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (HHHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (HHHFHH) +Size 8: 4,10 GOOD (HHFFFF) +Size 9: 5,11 BAD (HHHFHH) +Size 10: 5,12 GOOD (HHFFFF) +Size 11: 6,13 BAD (HHHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,16 BAD (HHHFHH) +Size 14: 7,17 GOOD (HHFFFF) +Size 15: 8,18 BAD (HHHFHH) +Size 16: 8,19 GOOD (HHFFFF) +Size 17: 9,20 BAD (HHHFHH) +Size 18: 9,22 GOOD (HHFFFF) +Size 19: 10,23 BAD (HHHFHH) +Size 20: 10,24 GOOD (HHFFFF) +Size 21: 11,25 BAD (HHHFHH) +Size 22: 11,26 GOOD (HHFFFF) +Size 23: 12,28 BAD (HHHFHH) +Size 24: 12,29 GOOD (HHFFFF) +Size 25: 13,30 BAD (HHHFHH) +Size 26: 13,31 GOOD (HHFFFF) +Size 27: 14,32 BAD (HHHFHH) +Size 28: 14,34 GOOD (HHFFFF) +Size 29: 15,35 BAD (HHHFHH) +Size 30: 15,36 GOOD (HHFFFF) +Size 31: 16,37 BAD (HHHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,40 BAD (HHHFHH) +Size 34: 17,41 GOOD (HHFFFF) +Size 35: 18,42 BAD (HHHFHH) +Size 36: 18,43 GOOD (HHFFFF) +Size 37: 19,44 BAD (HHHFHH) +Size 38: 19,46 GOOD (HHFFFF) +Size 39: 20,47 BAD (HHHFHH) +Size 40: 20,48 GOOD (HHFFFF) +Size 41: 21,49 BAD (HHHFHH) +Size 42: 21,50 GOOD (HHFFFF) +Size 43: 22,52 BAD (HHHFHH) +Size 44: 22,53 GOOD (HHFFFF) +Size 45: 23,54 BAD (HHHFHH) +Size 46: 23,55 GOOD (HHFFFF) +Size 47: 24,56 BAD (HHHFHH) +Size 48: 24,58 GOOD (HHFFFF) +Size 49: 25,59 BAD (HHHFHH) +Size 50: 25,60 GOOD (HHFFFF) +Size 51: 26,61 BAD (HHHFHH) +Size 52: 26,62 GOOD (HHFFFF) +Size 53: 27,64 BAD (HHHFHH) +Size 54: 27,65 GOOD (HHFFFF) +Size 55: 28,66 BAD (HHHFHH) +Size 56: 28,67 GOOD (HHFFFF) +Size 57: 29,68 BAD (HHHFHH) +Size 58: 29,70 GOOD (HHFFFF) +Size 59: 30,71 BAD (HHHFHH) +Size 60: 30,72 GOOD (HHFFFF) +Size 61: 31,73 BAD (HHHFHH) +Size 62: 31,74 GOOD (HHFFFF) +Size 63: 32,76 BAD (HHHFHH) +Size 64: 32,77 GOOD (HHFFFF) +Size 65: 33,78 BAD (HHHFHH) +Size 66: 33,79 GOOD (HHFFFF) +Size 67: 34,80 BAD (HHHFHH) +Size 68: 34,82 GOOD (HHFFFF) +Size 69: 35,83 BAD (HHHFHH) +Size 70: 35,84 GOOD (HHFFFF) +Size 71: 36,85 BAD (HHHFHH) +Size 72: 36,86 GOOD (HHFFFF) +Size 73: 37,88 BAD (HHHFHH) +Size 74: 37,89 GOOD (HHFFFF) +Size 75: 38,90 BAD (HHHFHH) +Size 76: 38,91 GOOD (HHFFFF) +Size 77: 39,92 BAD (HHHFHH) +Size 78: 39,94 GOOD (HHFFFF) +Size 79: 40,95 BAD (HHHFHH) +Size 80: 40,96 GOOD (HHFFFF) +Size 81: 41,97 BAD (HHHFHH) +Size 82: 41,98 GOOD (HHFFFF) +Size 83: 42,100 BAD (HHHFHH) +Size 84: 42,101 GOOD (HHFFFF) +Size 85: 43,102 BAD (HHHFHH) +Size 86: 43,103 GOOD (HHFFFF) +Size 87: 44,104 BAD (HHHFHH) +Size 88: 44,106 GOOD (HHFFFF) +Size 89: 45,107 BAD (HHHFHH) +Size 90: 45,108 GOOD (HHFFFF) +Size 91: 46,109 BAD (HHHFHH) +Size 92: 46,110 GOOD (HHFFFF) +Size 93: 47,112 BAD (HHHFHH) +Size 94: 47,113 GOOD (HHFFFF) +Size 95: 48,114 BAD (HHHFHH) +Size 96: 48,115 GOOD (HHFFFF) +Size 97: 49,116 BAD (HHHFHH) +Size 98: 49,118 GOOD (HHFFFF) +Size 99: 50,119 BAD (HHHFHH) +Size 100: 50,120 GOOD (HHFFFF) + +Windows 7 +--------- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,4 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (FFHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (FFHFHH) +Size 8: 4,10 GOOD (HHFFFF) +Size 9: 5,11 BAD (FFHFHH) +Size 10: 5,12 GOOD (HHFFFF) +Size 11: 6,13 BAD (FFHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,16 BAD (FFHFHH) +Size 14: 7,17 GOOD (HHFFFF) +Size 15: 8,18 BAD (FFHFHH) +Size 16: 8,19 GOOD (HHFFFF) +Size 17: 9,20 BAD (FFHFHH) +Size 18: 9,22 GOOD (HHFFFF) +Size 19: 10,23 BAD (FFHFHH) +Size 20: 10,24 GOOD (HHFFFF) +Size 21: 11,25 BAD (FFHFHH) +Size 22: 11,26 GOOD (HHFFFF) +Size 23: 12,28 BAD (FFHFHH) +Size 24: 12,29 GOOD (HHFFFF) +Size 25: 13,30 BAD (FFHFHH) +Size 26: 13,31 GOOD (HHFFFF) +Size 27: 14,32 BAD (FFHFHH) +Size 28: 14,34 GOOD (HHFFFF) +Size 29: 15,35 BAD (FFHFHH) +Size 30: 15,36 GOOD (HHFFFF) +Size 31: 16,37 BAD (FFHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,40 BAD (FFHFHH) +Size 34: 17,41 GOOD (HHFFFF) +Size 35: 18,42 BAD (FFHFHH) +Size 36: 18,43 GOOD (HHFFFF) +Size 37: 19,44 BAD (FFHFHH) +Size 38: 19,46 GOOD (HHFFFF) +Size 39: 20,47 BAD (FFHFHH) +Size 40: 20,48 GOOD (HHFFFF) +Size 41: 21,49 BAD (FFHFHH) +Size 42: 21,50 GOOD (HHFFFF) +Size 43: 22,52 BAD (FFHFHH) +Size 44: 22,53 GOOD (HHFFFF) +Size 45: 23,54 BAD (FFHFHH) +Size 46: 23,55 GOOD (HHFFFF) +Size 47: 24,56 BAD (FFHFHH) +Size 48: 24,58 GOOD (HHFFFF) +Size 49: 25,59 BAD (FFHFHH) +Size 50: 25,60 GOOD (HHFFFF) +Size 51: 26,61 BAD (FFHFHH) +Size 52: 26,62 GOOD (HHFFFF) +Size 53: 27,64 BAD (FFHFHH) +Size 54: 27,65 GOOD (HHFFFF) +Size 55: 28,66 BAD (FFHFHH) +Size 56: 28,67 GOOD (HHFFFF) +Size 57: 29,68 BAD (FFHFHH) +Size 58: 29,70 GOOD (HHFFFF) +Size 59: 30,71 BAD (FFHFHH) +Size 60: 30,72 GOOD (HHFFFF) +Size 61: 31,73 BAD (FFHFHH) +Size 62: 31,74 GOOD (HHFFFF) +Size 63: 32,76 BAD (FFHFHH) +Size 64: 32,77 GOOD (HHFFFF) +Size 65: 33,78 BAD (FFHFHH) +Size 66: 33,79 GOOD (HHFFFF) +Size 67: 34,80 BAD (FFHFHH) +Size 68: 34,82 GOOD (HHFFFF) +Size 69: 35,83 BAD (FFHFHH) +Size 70: 35,84 GOOD (HHFFFF) +Size 71: 36,85 BAD (FFHFHH) +Size 72: 36,86 GOOD (HHFFFF) +Size 73: 37,88 BAD (FFHFHH) +Size 74: 37,89 GOOD (HHFFFF) +Size 75: 38,90 BAD (FFHFHH) +Size 76: 38,91 GOOD (HHFFFF) +Size 77: 39,92 BAD (FFHFHH) +Size 78: 39,94 GOOD (HHFFFF) +Size 79: 40,95 BAD (FFHFHH) +Size 80: 40,96 GOOD (HHFFFF) +Size 81: 41,97 BAD (FFHFHH) +Size 82: 41,98 GOOD (HHFFFF) +Size 83: 42,100 BAD (FFHFHH) +Size 84: 42,101 GOOD (HHFFFF) +Size 85: 43,102 BAD (FFHFHH) +Size 86: 43,103 GOOD (HHFFFF) +Size 87: 44,104 BAD (FFHFHH) +Size 88: 44,106 GOOD (HHFFFF) +Size 89: 45,107 BAD (FFHFHH) +Size 90: 45,108 GOOD (HHFFFF) +Size 91: 46,109 BAD (FFHFHH) +Size 92: 46,110 GOOD (HHFFFF) +Size 93: 47,112 BAD (FFHFHH) +Size 94: 47,113 GOOD (HHFFFF) +Size 95: 48,114 BAD (FFHFHH) +Size 96: 48,115 GOOD (HHFFFF) +Size 97: 49,116 BAD (FFHFHH) +Size 98: 49,118 GOOD (HHFFFF) +Size 99: 50,119 BAD (FFHFHH) +Size 100: 50,120 GOOD (HHFFFF) + +Windows 8 +--------- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,4 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (FFHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (FFHFHH) +Size 8: 4,10 GOOD (HHFFFF) +Size 9: 5,11 BAD (FFHFHH) +Size 10: 5,12 GOOD (HHFFFF) +Size 11: 6,13 BAD (FFHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,16 BAD (FFHFHH) +Size 14: 7,17 GOOD (HHFFFF) +Size 15: 8,18 BAD (FFHFHH) +Size 16: 8,19 GOOD (HHFFFF) +Size 17: 9,20 BAD (FFHFHH) +Size 18: 9,22 GOOD (HHFFFF) +Size 19: 10,23 BAD (FFHFHH) +Size 20: 10,24 GOOD (HHFFFF) +Size 21: 11,25 BAD (FFHFHH) +Size 22: 11,26 GOOD (HHFFFF) +Size 23: 12,28 BAD (FFHFHH) +Size 24: 12,29 GOOD (HHFFFF) +Size 25: 13,30 BAD (FFHFHH) +Size 26: 13,31 GOOD (HHFFFF) +Size 27: 14,32 BAD (FFHFHH) +Size 28: 14,34 GOOD (HHFFFF) +Size 29: 15,35 BAD (FFHFHH) +Size 30: 15,36 GOOD (HHFFFF) +Size 31: 16,37 BAD (FFHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,40 BAD (FFHFHH) +Size 34: 17,41 GOOD (HHFFFF) +Size 35: 18,42 BAD (FFHFHH) +Size 36: 18,43 GOOD (HHFFFF) +Size 37: 19,44 BAD (FFHFHH) +Size 38: 19,46 GOOD (HHFFFF) +Size 39: 20,47 BAD (FFHFHH) +Size 40: 20,48 GOOD (HHFFFF) +Size 41: 21,49 BAD (FFHFHH) +Size 42: 21,50 GOOD (HHFFFF) +Size 43: 22,52 BAD (FFHFHH) +Size 44: 22,53 GOOD (HHFFFF) +Size 45: 23,54 BAD (FFHFHH) +Size 46: 23,55 GOOD (HHFFFF) +Size 47: 24,56 BAD (FFHFHH) +Size 48: 24,58 GOOD (HHFFFF) +Size 49: 25,59 BAD (FFHFHH) +Size 50: 25,60 GOOD (HHFFFF) +Size 51: 26,61 BAD (FFHFHH) +Size 52: 26,62 GOOD (HHFFFF) +Size 53: 27,64 BAD (FFHFHH) +Size 54: 27,65 GOOD (HHFFFF) +Size 55: 28,66 BAD (FFHFHH) +Size 56: 28,67 GOOD (HHFFFF) +Size 57: 29,68 BAD (FFHFHH) +Size 58: 29,70 GOOD (HHFFFF) +Size 59: 30,71 BAD (FFHFHH) +Size 60: 30,72 GOOD (HHFFFF) +Size 61: 31,73 BAD (FFHFHH) +Size 62: 31,74 GOOD (HHFFFF) +Size 63: 32,76 BAD (FFHFHH) +Size 64: 32,77 GOOD (HHFFFF) +Size 65: 33,78 BAD (FFHFHH) +Size 66: 33,79 GOOD (HHFFFF) +Size 67: 34,80 BAD (FFHFHH) +Size 68: 34,82 GOOD (HHFFFF) +Size 69: 35,83 BAD (FFHFHH) +Size 70: 35,84 GOOD (HHFFFF) +Size 71: 36,85 BAD (FFHFHH) +Size 72: 36,86 GOOD (HHFFFF) +Size 73: 37,88 BAD (FFHFHH) +Size 74: 37,89 GOOD (HHFFFF) +Size 75: 38,90 BAD (FFHFHH) +Size 76: 38,91 GOOD (HHFFFF) +Size 77: 39,92 BAD (FFHFHH) +Size 78: 39,94 GOOD (HHFFFF) +Size 79: 40,95 BAD (FFHFHH) +Size 80: 40,96 GOOD (HHFFFF) +Size 81: 41,97 BAD (FFHFHH) +Size 82: 41,98 GOOD (HHFFFF) +Size 83: 42,100 BAD (FFHFHH) +Size 84: 42,101 GOOD (HHFFFF) +Size 85: 43,102 BAD (FFHFHH) +Size 86: 43,103 GOOD (HHFFFF) +Size 87: 44,104 BAD (FFHFHH) +Size 88: 44,106 GOOD (HHFFFF) +Size 89: 45,107 BAD (FFHFHH) +Size 90: 45,108 GOOD (HHFFFF) +Size 91: 46,109 BAD (FFHFHH) +Size 92: 46,110 GOOD (HHFFFF) +Size 93: 47,112 BAD (FFHFHH) +Size 94: 47,113 GOOD (HHFFFF) +Size 95: 48,114 BAD (FFHFHH) +Size 96: 48,115 GOOD (HHFFFF) +Size 97: 49,116 BAD (FFHFHH) +Size 98: 49,118 GOOD (HHFFFF) +Size 99: 50,119 BAD (FFHFHH) +Size 100: 50,120 GOOD (HHFFFF) + +Windows 8.1 +----------- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,4 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (FFHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (FFHFHH) +Size 8: 4,10 GOOD (HHFFFF) +Size 9: 5,11 BAD (FFHFHH) +Size 10: 5,12 GOOD (HHFFFF) +Size 11: 6,13 BAD (FFHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,16 BAD (FFHFHH) +Size 14: 7,17 GOOD (HHFFFF) +Size 15: 8,18 BAD (FFHFHH) +Size 16: 8,19 GOOD (HHFFFF) +Size 17: 9,20 BAD (FFHFHH) +Size 18: 9,22 GOOD (HHFFFF) +Size 19: 10,23 BAD (FFHFHH) +Size 20: 10,24 GOOD (HHFFFF) +Size 21: 11,25 BAD (FFHFHH) +Size 22: 11,26 GOOD (HHFFFF) +Size 23: 12,28 BAD (FFHFHH) +Size 24: 12,29 GOOD (HHFFFF) +Size 25: 13,30 BAD (FFHFHH) +Size 26: 13,31 GOOD (HHFFFF) +Size 27: 14,32 BAD (FFHFHH) +Size 28: 14,34 GOOD (HHFFFF) +Size 29: 15,35 BAD (FFHFHH) +Size 30: 15,36 GOOD (HHFFFF) +Size 31: 16,37 BAD (FFHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,40 BAD (FFHFHH) +Size 34: 17,41 GOOD (HHFFFF) +Size 35: 18,42 BAD (FFHFHH) +Size 36: 18,43 GOOD (HHFFFF) +Size 37: 19,44 BAD (FFHFHH) +Size 38: 19,46 GOOD (HHFFFF) +Size 39: 20,47 BAD (FFHFHH) +Size 40: 20,48 GOOD (HHFFFF) +Size 41: 21,49 BAD (FFHFHH) +Size 42: 21,50 GOOD (HHFFFF) +Size 43: 22,52 BAD (FFHFHH) +Size 44: 22,53 GOOD (HHFFFF) +Size 45: 23,54 BAD (FFHFHH) +Size 46: 23,55 GOOD (HHFFFF) +Size 47: 24,56 BAD (FFHFHH) +Size 48: 24,58 GOOD (HHFFFF) +Size 49: 25,59 BAD (FFHFHH) +Size 50: 25,60 GOOD (HHFFFF) +Size 51: 26,61 BAD (FFHFHH) +Size 52: 26,62 GOOD (HHFFFF) +Size 53: 27,64 BAD (FFHFHH) +Size 54: 27,65 GOOD (HHFFFF) +Size 55: 28,66 BAD (FFHFHH) +Size 56: 28,67 GOOD (HHFFFF) +Size 57: 29,68 BAD (FFHFHH) +Size 58: 29,70 GOOD (HHFFFF) +Size 59: 30,71 BAD (FFHFHH) +Size 60: 30,72 GOOD (HHFFFF) +Size 61: 31,73 BAD (FFHFHH) +Size 62: 31,74 GOOD (HHFFFF) +Size 63: 32,76 BAD (FFHFHH) +Size 64: 32,77 GOOD (HHFFFF) +Size 65: 33,78 BAD (FFHFHH) +Size 66: 33,79 GOOD (HHFFFF) +Size 67: 34,80 BAD (FFHFHH) +Size 68: 34,82 GOOD (HHFFFF) +Size 69: 35,83 BAD (FFHFHH) +Size 70: 35,84 GOOD (HHFFFF) +Size 71: 36,85 BAD (FFHFHH) +Size 72: 36,86 GOOD (HHFFFF) +Size 73: 37,88 BAD (FFHFHH) +Size 74: 37,89 GOOD (HHFFFF) +Size 75: 38,90 BAD (FFHFHH) +Size 76: 38,91 GOOD (HHFFFF) +Size 77: 39,92 BAD (FFHFHH) +Size 78: 39,94 GOOD (HHFFFF) +Size 79: 40,95 BAD (FFHFHH) +Size 80: 40,96 GOOD (HHFFFF) +Size 81: 41,97 BAD (FFHFHH) +Size 82: 41,98 GOOD (HHFFFF) +Size 83: 42,100 BAD (FFHFHH) +Size 84: 42,101 GOOD (HHFFFF) +Size 85: 43,102 BAD (FFHFHH) +Size 86: 43,103 GOOD (HHFFFF) +Size 87: 44,104 BAD (FFHFHH) +Size 88: 44,106 GOOD (HHFFFF) +Size 89: 45,107 BAD (FFHFHH) +Size 90: 45,108 GOOD (HHFFFF) +Size 91: 46,109 BAD (FFHFHH) +Size 92: 46,110 GOOD (HHFFFF) +Size 93: 47,112 BAD (FFHFHH) +Size 94: 47,113 GOOD (HHFFFF) +Size 95: 48,114 BAD (FFHFHH) +Size 96: 48,115 GOOD (HHFFFF) +Size 97: 49,116 BAD (FFHFHH) +Size 98: 49,118 GOOD (HHFFFF) +Size 99: 50,119 BAD (FFHFHH) +Size 100: 50,120 GOOD (HHFFFF) + +Windows 10 14342 Old Console +---------------------------- + +Size 1: 1,2 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,4 BAD (FFHFHH) +Size 4: 2,5 GOOD (HHFFFF) +Size 5: 3,6 BAD (FFHFHH) +Size 6: 3,7 GOOD (HHFFFF) +Size 7: 4,8 BAD (FFHFHH) +Size 8: 4,10 GOOD (HHFFFF) +Size 9: 5,11 BAD (FFHFHH) +Size 10: 5,12 GOOD (HHFFFF) +Size 11: 6,13 BAD (FFHFHH) +Size 12: 6,14 GOOD (HHFFFF) +Size 13: 7,16 BAD (FFHFHH) +Size 14: 7,17 GOOD (HHFFFF) +Size 15: 8,18 BAD (FFHFHH) +Size 16: 8,19 GOOD (HHFFFF) +Size 17: 9,20 BAD (FFHFHH) +Size 18: 9,22 GOOD (HHFFFF) +Size 19: 10,23 BAD (FFHFHH) +Size 20: 10,24 GOOD (HHFFFF) +Size 21: 11,25 BAD (FFHFHH) +Size 22: 11,26 GOOD (HHFFFF) +Size 23: 12,28 BAD (FFHFHH) +Size 24: 12,29 GOOD (HHFFFF) +Size 25: 13,30 BAD (FFHFHH) +Size 26: 13,31 GOOD (HHFFFF) +Size 27: 14,32 BAD (FFHFHH) +Size 28: 14,34 GOOD (HHFFFF) +Size 29: 15,35 BAD (FFHFHH) +Size 30: 15,36 GOOD (HHFFFF) +Size 31: 16,37 BAD (FFHFHH) +Size 32: 16,38 GOOD (HHFFFF) +Size 33: 17,40 BAD (FFHFHH) +Size 34: 17,41 GOOD (HHFFFF) +Size 35: 18,42 BAD (FFHFHH) +Size 36: 18,43 GOOD (HHFFFF) +Size 37: 19,44 BAD (FFHFHH) +Size 38: 19,46 GOOD (HHFFFF) +Size 39: 20,47 BAD (FFHFHH) +Size 40: 20,48 GOOD (HHFFFF) +Size 41: 21,49 BAD (FFHFHH) +Size 42: 21,50 GOOD (HHFFFF) +Size 43: 22,52 BAD (FFHFHH) +Size 44: 22,53 GOOD (HHFFFF) +Size 45: 23,54 BAD (FFHFHH) +Size 46: 23,55 GOOD (HHFFFF) +Size 47: 24,56 BAD (FFHFHH) +Size 48: 24,58 GOOD (HHFFFF) +Size 49: 25,59 BAD (FFHFHH) +Size 50: 25,60 GOOD (HHFFFF) +Size 51: 26,61 BAD (FFHFHH) +Size 52: 26,62 GOOD (HHFFFF) +Size 53: 27,64 BAD (FFHFHH) +Size 54: 27,65 GOOD (HHFFFF) +Size 55: 28,66 BAD (FFHFHH) +Size 56: 28,67 GOOD (HHFFFF) +Size 57: 29,68 BAD (FFHFHH) +Size 58: 29,70 GOOD (HHFFFF) +Size 59: 30,71 BAD (FFHFHH) +Size 60: 30,72 GOOD (HHFFFF) +Size 61: 31,73 BAD (FFHFHH) +Size 62: 31,74 GOOD (HHFFFF) +Size 63: 32,76 BAD (FFHFHH) +Size 64: 32,77 GOOD (HHFFFF) +Size 65: 33,78 BAD (FFHFHH) +Size 66: 33,79 GOOD (HHFFFF) +Size 67: 34,80 BAD (FFHFHH) +Size 68: 34,82 GOOD (HHFFFF) +Size 69: 35,83 BAD (FFHFHH) +Size 70: 35,84 GOOD (HHFFFF) +Size 71: 36,85 BAD (FFHFHH) +Size 72: 36,86 GOOD (HHFFFF) +Size 73: 37,88 BAD (FFHFHH) +Size 74: 37,89 GOOD (HHFFFF) +Size 75: 38,90 BAD (FFHFHH) +Size 76: 38,91 GOOD (HHFFFF) +Size 77: 39,92 BAD (FFHFHH) +Size 78: 39,94 GOOD (HHFFFF) +Size 79: 40,95 BAD (FFHFHH) +Size 80: 40,96 GOOD (HHFFFF) +Size 81: 41,97 BAD (FFHFHH) +Size 82: 41,98 GOOD (HHFFFF) +Size 83: 42,100 BAD (FFHFHH) +Size 84: 42,101 GOOD (HHFFFF) +Size 85: 43,102 BAD (FFHFHH) +Size 86: 43,103 GOOD (HHFFFF) +Size 87: 44,104 BAD (FFHFHH) +Size 88: 44,106 GOOD (HHFFFF) +Size 89: 45,107 BAD (FFHFHH) +Size 90: 45,108 GOOD (HHFFFF) +Size 91: 46,109 BAD (FFHFHH) +Size 92: 46,110 GOOD (HHFFFF) +Size 93: 47,112 BAD (FFHFHH) +Size 94: 47,113 GOOD (HHFFFF) +Size 95: 48,114 BAD (FFHFHH) +Size 96: 48,115 GOOD (HHFFFF) +Size 97: 49,116 BAD (FFHFHH) +Size 98: 49,118 GOOD (HHFFFF) +Size 99: 50,119 BAD (FFHFHH) +Size 100: 50,120 GOOD (HHFFFF) + +Windows 10 14342 New Console +---------------------------- + +Size 1: 1,1 GOOD (HHFFFF) +Size 2: 1,2 GOOD (HHFFFF) +Size 3: 2,3 GOOD (HHFFFF) +Size 4: 2,4 GOOD (HHFFFF) +Size 5: 3,5 GOOD (HHFFFF) +Size 6: 3,6 GOOD (HHFFFF) +Size 7: 4,7 GOOD (HHFFFF) +Size 8: 4,8 GOOD (HHFFFF) +Size 9: 5,9 GOOD (HHFFFF) +Size 10: 5,10 GOOD (HHFFFF) +Size 11: 6,11 GOOD (HHFFFF) +Size 12: 6,12 GOOD (HHFFFF) +Size 13: 7,13 GOOD (HHFFFF) +Size 14: 7,14 GOOD (HHFFFF) +Size 15: 8,15 GOOD (HHFFFF) +Size 16: 8,16 GOOD (HHFFFF) +Size 17: 9,17 GOOD (HHFFFF) +Size 18: 9,18 GOOD (HHFFFF) +Size 19: 10,19 GOOD (HHFFFF) +Size 20: 10,20 GOOD (HHFFFF) +Size 21: 11,21 GOOD (HHFFFF) +Size 22: 11,22 GOOD (HHFFFF) +Size 23: 12,23 GOOD (HHFFFF) +Size 24: 12,24 GOOD (HHFFFF) +Size 25: 13,25 GOOD (HHFFFF) +Size 26: 13,26 GOOD (HHFFFF) +Size 27: 14,27 GOOD (HHFFFF) +Size 28: 14,28 GOOD (HHFFFF) +Size 29: 15,29 GOOD (HHFFFF) +Size 30: 15,30 GOOD (HHFFFF) +Size 31: 16,31 GOOD (HHFFFF) +Size 32: 16,32 GOOD (HHFFFF) +Size 33: 17,33 GOOD (HHFFFF) +Size 34: 17,34 GOOD (HHFFFF) +Size 35: 18,35 GOOD (HHFFFF) +Size 36: 18,36 GOOD (HHFFFF) +Size 37: 19,37 GOOD (HHFFFF) +Size 38: 19,38 GOOD (HHFFFF) +Size 39: 20,39 GOOD (HHFFFF) +Size 40: 20,40 GOOD (HHFFFF) +Size 41: 21,41 GOOD (HHFFFF) +Size 42: 21,42 GOOD (HHFFFF) +Size 43: 22,43 GOOD (HHFFFF) +Size 44: 22,44 GOOD (HHFFFF) +Size 45: 23,45 GOOD (HHFFFF) +Size 46: 23,46 GOOD (HHFFFF) +Size 47: 24,47 GOOD (HHFFFF) +Size 48: 24,48 GOOD (HHFFFF) +Size 49: 25,49 GOOD (HHFFFF) +Size 50: 25,50 GOOD (HHFFFF) +Size 51: 26,51 GOOD (HHFFFF) +Size 52: 26,52 GOOD (HHFFFF) +Size 53: 27,53 GOOD (HHFFFF) +Size 54: 27,54 GOOD (HHFFFF) +Size 55: 28,55 GOOD (HHFFFF) +Size 56: 28,56 GOOD (HHFFFF) +Size 57: 29,57 GOOD (HHFFFF) +Size 58: 29,58 GOOD (HHFFFF) +Size 59: 30,59 GOOD (HHFFFF) +Size 60: 30,60 GOOD (HHFFFF) +Size 61: 31,61 GOOD (HHFFFF) +Size 62: 31,62 GOOD (HHFFFF) +Size 63: 32,63 GOOD (HHFFFF) +Size 64: 32,64 GOOD (HHFFFF) +Size 65: 33,65 GOOD (HHFFFF) +Size 66: 33,66 GOOD (HHFFFF) +Size 67: 34,67 GOOD (HHFFFF) +Size 68: 34,68 GOOD (HHFFFF) +Size 69: 35,69 GOOD (HHFFFF) +Size 70: 35,70 GOOD (HHFFFF) +Size 71: 36,71 GOOD (HHFFFF) +Size 72: 36,72 GOOD (HHFFFF) +Size 73: 37,73 GOOD (HHFFFF) +Size 74: 37,74 GOOD (HHFFFF) +Size 75: 38,75 GOOD (HHFFFF) +Size 76: 38,76 GOOD (HHFFFF) +Size 77: 39,77 GOOD (HHFFFF) +Size 78: 39,78 GOOD (HHFFFF) +Size 79: 40,79 GOOD (HHFFFF) +Size 80: 40,80 GOOD (HHFFFF) +Size 81: 41,81 GOOD (HHFFFF) +Size 82: 41,82 GOOD (HHFFFF) +Size 83: 42,83 GOOD (HHFFFF) +Size 84: 42,84 GOOD (HHFFFF) +Size 85: 43,85 GOOD (HHFFFF) +Size 86: 43,86 GOOD (HHFFFF) +Size 87: 44,87 GOOD (HHFFFF) +Size 88: 44,88 GOOD (HHFFFF) +Size 89: 45,89 GOOD (HHFFFF) +Size 90: 45,90 GOOD (HHFFFF) +Size 91: 46,91 GOOD (HHFFFF) +Size 92: 46,92 GOOD (HHFFFF) +Size 93: 47,93 GOOD (HHFFFF) +Size 94: 47,94 GOOD (HHFFFF) +Size 95: 48,95 GOOD (HHFFFF) +Size 96: 48,96 GOOD (HHFFFF) +Size 97: 49,97 GOOD (HHFFFF) +Size 98: 49,98 GOOD (HHFFFF) +Size 99: 50,99 GOOD (HHFFFF) +Size 100: 50,100 GOOD (HHFFFF) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/MinimumWindowWidths.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/MinimumWindowWidths.txt new file mode 100644 index 00000000..d5261d8d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/MinimumWindowWidths.txt @@ -0,0 +1,16 @@ +The narrowest allowed console window, in pixels, on a conventional (~96dpi) +monitor: + +(mode con: cols=40 lines=40) && SetFont.exe -face "Lucida Console" -h 1 && (ping -n 4 127.0.0.1 > NUL) && cls && GetConsolePos.exe && SetFont.exe -face "Lucida Console" -h 12 + +(mode con: cols=40 lines=40) && SetFont.exe -face "Lucida Console" -h 16 && (ping -n 4 127.0.0.1 > NUL) && cls && GetConsolePos.exe && SetFont.exe -face "Lucida Console" -h 12 + + sz1:px sz1:col sz16:px sz16:col +Vista: 124 104 137 10 +Windows 7: 132 112 147 11 +Windows 8: 140 120 147 11 +Windows 8.1: 140 120 147 11 +Windows 10 OLD: 136 116 147 11 +Windows 10 NEW: 136 103 136 10 + +I used build 14342 to test Windows 10. diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/Results.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/Results.txt new file mode 100644 index 00000000..15a825cb --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/Results.txt @@ -0,0 +1,4 @@ +As before, avoid odd sizes in favor of even sizes. + +It's curious that the Japanese font is handled so poorly, especially with +Windows 8 and later. diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/Windows10SetFontBugginess.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/Windows10SetFontBugginess.txt new file mode 100644 index 00000000..fef397a1 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Font-Report-June2016/Windows10SetFontBugginess.txt @@ -0,0 +1,144 @@ +Issues: + + - Starting with the 14342 build, changing the font using + SetCurrentConsoleFontEx does not affect the window size. e.g. The content + itself will resize/redraw, but the window neither shrinks nor expands. + Presumably this is an oversight? It's almost a convenience; if a program + is going to resize the window anyway, then it's nice that the window size + contraints don't get in the way. Ordinarily, changing the font doesn't just + change the window size in pixels--it can also change the size as measured in + rows and columns. + + - (Aside: in the 14342 build, there is also a bug with wmic.exe. Open a console + with more than 300 lines of screen buffer, then fill those lines with, e.g., + dir /s. Then run wmic.exe. You won't be able to see the wmic.exe prompt. + If you query the screen buffer info somehow, you'll notice that the srWindow + is not contained within the dwSize. This breaks winpty's scraping, because + it's invalid.) + + - In build 14316, with the Japanese locale, with the 437 code page, attempting + to set the Consolas font instead sets the Terminal (raster) font. It seems + to pick an appropriate vertical size. + + - It seems necessary to specify "-family 0x36" for maximum reliability. + Setting the family to 0 almost always works, and specifying just -tt rarely + works. + +Win7 + English locale / 437 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt selects Terminal font instead + SetFont.exe -face Consolas -h 16 -family 0x36 works + Japanese locale / 932 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt selects Terminal font instead + SetFont.exe -face Consolas -h 16 -family 0x36 works + Japanese locale / 437 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt unreliable + SetFont.exe -face Consolas -h 16 -family 0x36 works + +Win10 Build 10586 + New console + Japanese locale / 437 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt selects Terminal instead + SetFont.exe -face Consolas -h 16 -family 0x36 works + +Win10 Build 14316 + Old console + English locale / 437 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt selects Terminal font instead + SetFont.exe -face Consolas -h 16 -family 0x36 works + Japanese locale / 932 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt selects Terminal font instead + SetFont.exe -face Consolas -h 16 -family 0x36 works + Japanese locale / 437 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt selected very small Consolas font + SetFont.exe -face Consolas -h 16 -family 0x36 works + New console + English locale / 437 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt works + SetFont.exe -face Consolas -h 16 -family 0x36 works + Japanese locale / 932 code page: + SetFont.exe -face Consolas -h 16 selects gothic instead + SetFont.exe -face Consolas -h 16 -tt selects gothic instead + SetFont.exe -face Consolas -h 16 -family 0x36 selects gothic instead + Japanese locale / 437 code page: + SetFont.exe -face Consolas -h 16 selects Terminal font instead + SetFont.exe -face Consolas -h 16 -tt selects Terminal font instead + SetFont.exe -face Consolas -h 16 -family 0x36(*) selects Terminal font instead + +Win10 Build 14342 + Old Console + English locale / 437 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt selects Terminal font instead + SetFont.exe -face Consolas -h 16 -family 0x36 works + Japanese locale / 932 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt selects Terminal font instead + SetFont.exe -face Consolas -h 16 -family 0x36 works + Japanese locale / 437 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt selects Terminal font instead + SetFont.exe -face Consolas -h 16 -family 0x36 works + New console + English locale / 437 code page: + SetFont.exe -face Consolas -h 16 works + SetFont.exe -face Consolas -h 16 -tt works + SetFont.exe -face Consolas -h 16 -family 0x36 works + Japanese locale / 932 code page: + SetFont.exe -face Consolas -h 16 selects gothic instead + SetFont.exe -face Consolas -h 16 -tt selects gothic instead + SetFont.exe -face Consolas -h 16 -family 0x36 selects gothic instead + Japanese locale / 437 code page: + SetFont.exe -face Consolas -h 16 selects Terminal font instead + SetFont.exe -face Consolas -h 16 -tt works + SetFont.exe -face Consolas -h 16 -family 0x36 works + +(*) I was trying to figure out whether the inconsistency was at when I stumbled +onto this completely unexpected bug. Here's more detail: + + F:\>SetFont.exe -face Consolas -h 16 -family 0x36 -weight normal -w 8 + Setting to: nFont=0 dwFontSize=(8,16) FontFamily=0x36 FontWeight=400 FaceName="Consolas" + SetCurrentConsoleFontEx returned 1 + + F:\>GetFont.exe + largestConsoleWindowSize=(96,50) + maxWnd=0: nFont=0 dwFontSize=(12,16) FontFamily=0x30 FontWeight=400 FaceName=Terminal (54 65 72 6D 69 6E 61 6C) + maxWnd=1: nFont=0 dwFontSize=(96,25) FontFamily=0x30 FontWeight=400 FaceName=Terminal (54 65 72 6D 69 6E 61 6C) + 00-00: 12x16 + GetNumberOfConsoleFonts returned 0 + CP=437 OutputCP=437 + + F:\>SetFont.exe -face "Lucida Console" -h 16 -family 0x36 -weight normal + Setting to: nFont=0 dwFontSize=(0,16) FontFamily=0x36 FontWeight=400 FaceName="Lucida Console" + SetCurrentConsoleFontEx returned 1 + + F:\>GetFont.exe + largestConsoleWindowSize=(96,50) + maxWnd=0: nFont=0 dwFontSize=(12,16) FontFamily=0x30 FontWeight=400 FaceName=Terminal (54 65 72 6D 69 6E 61 6C) + maxWnd=1: nFont=0 dwFontSize=(96,25) FontFamily=0x30 FontWeight=400 FaceName=Terminal (54 65 72 6D 69 6E 61 6C) + 00-00: 12x16 + GetNumberOfConsoleFonts returned 0 + CP=437 OutputCP=437 + + F:\>SetFont.exe -face "Lucida Console" -h 12 -family 0x36 -weight normal + Setting to: nFont=0 dwFontSize=(0,12) FontFamily=0x36 FontWeight=400 FaceName="Lucida Console" + SetCurrentConsoleFontEx returned 1 + + F:\>GetFont.exe + largestConsoleWindowSize=(230,66) + maxWnd=0: nFont=0 dwFontSize=(5,12) FontFamily=0x30 FontWeight=400 FaceName=Terminal (54 65 72 6D 69 6E 61 6C) + maxWnd=1: nFont=0 dwFontSize=(116,36) FontFamily=0x30 FontWeight=400 FaceName=Terminal (54 65 72 6D 69 6E 61 6C) + 00-00: 5x12 + GetNumberOfConsoleFonts returned 0 + CP=437 OutputCP=437 + +Even attempting to set to a Lucida Console / Consolas font from the Console +properties dialog fails. diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/FontSurvey.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/FontSurvey.cc new file mode 100644 index 00000000..254bcc81 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/FontSurvey.cc @@ -0,0 +1,100 @@ +#include + +#include +#include +#include + +#include + +#include "TestUtil.cc" + +#define COUNT_OF(array) (sizeof(array) / sizeof((array)[0])) + +// See https://en.wikipedia.org/wiki/List_of_CJK_fonts +const wchar_t kMSGothic[] = { 0xff2d, 0xff33, 0x0020, 0x30b4, 0x30b7, 0x30c3, 0x30af, 0 }; // Japanese +const wchar_t kNSimSun[] = { 0x65b0, 0x5b8b, 0x4f53, 0 }; // Simplified Chinese +const wchar_t kMingLight[] = { 0x7d30, 0x660e, 0x9ad4, 0 }; // Traditional Chinese +const wchar_t kGulimChe[] = { 0xad74, 0xb9bc, 0xccb4, 0 }; // Korean + +std::vector condense(const std::vector &buf) { + std::vector ret; + size_t i = 0; + while (i < buf.size()) { + if (buf[i].Char.UnicodeChar == L' ' && + ((buf[i].Attributes & 0x300) == 0)) { + // end of line + break; + } else if (i + 1 < buf.size() && + ((buf[i].Attributes & 0x300) == 0x100) && + ((buf[i + 1].Attributes & 0x300) == 0x200) && + buf[i].Char.UnicodeChar != L' ' && + buf[i].Char.UnicodeChar == buf[i + 1].Char.UnicodeChar) { + // double-width + ret.push_back(true); + i += 2; + } else if ((buf[i].Attributes & 0x300) == 0) { + // single-width + ret.push_back(false); + i++; + } else { + ASSERT(false && "unexpected output"); + } + } + return ret; +} + +int main(int argc, char *argv[]) { + if (argc != 2) { + printf("Usage: %s \"arguments for SetFont.exe\"\n", argv[0]); + return 1; + } + + const char *setFontArgs = argv[1]; + + const wchar_t testLine[] = { 0xA2, 0xA3, 0x2014, 0x3044, 0x30FC, 0x4000, 0 }; + const HANDLE conout = openConout(); + + char setFontCmd[1024]; + for (int h = 1; h <= 100; ++h) { + sprintf(setFontCmd, ".\\SetFont.exe %s -h %d && cls", setFontArgs, h); + system(setFontCmd); + + CONSOLE_FONT_INFOEX infoex = {}; + infoex.cbSize = sizeof(infoex); + BOOL success = GetCurrentConsoleFontEx(conout, FALSE, &infoex); + ASSERT(success && "GetCurrentConsoleFontEx failed"); + + DWORD actual = 0; + success = WriteConsoleW(conout, testLine, wcslen(testLine), &actual, nullptr); + ASSERT(success && actual == wcslen(testLine)); + + std::vector readBuf(14); + const SMALL_RECT readRegion = {0, 0, static_cast(readBuf.size() - 1), 0}; + SMALL_RECT readRegion2 = readRegion; + success = ReadConsoleOutputW( + conout, readBuf.data(), + {static_cast(readBuf.size()), 1}, + {0, 0}, + &readRegion2); + ASSERT(success && !memcmp(&readRegion, &readRegion2, sizeof(readRegion))); + + const auto widths = condense(readBuf); + std::string widthsStr; + for (bool width : widths) { + widthsStr.append(width ? "F" : "H"); + } + char size[16]; + sprintf(size, "%d,%d", infoex.dwFontSize.X, infoex.dwFontSize.Y); + const char *status = ""; + if (widthsStr == "HHFFFF") { + status = "GOOD"; + } else if (widthsStr == "HHHFFF") { + status = "OK"; + } else { + status = "BAD"; + } + trace("Size %3d: %-7s %-4s (%s)", h, size, status, widthsStr.c_str()); + } + sprintf(setFontCmd, ".\\SetFont.exe %s -h 14", setFontArgs); + system(setFontCmd); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/FormatChar.h b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/FormatChar.h new file mode 100644 index 00000000..aade488f --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/FormatChar.h @@ -0,0 +1,21 @@ +#include +#include +#include + +static inline void formatChar(char *str, char ch) +{ + // Print some common control codes. + switch (ch) { + case '\r': strcpy(str, "CR "); break; + case '\n': strcpy(str, "LF "); break; + case ' ': strcpy(str, "SP "); break; + case 27: strcpy(str, "^[ "); break; + case 3: strcpy(str, "^C "); break; + default: + if (isgraph(ch)) + sprintf(str, "%c ", ch); + else + sprintf(str, "%02x ", ch); + break; + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/FreezePerfTest.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/FreezePerfTest.cc new file mode 100644 index 00000000..2c0b0086 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/FreezePerfTest.cc @@ -0,0 +1,62 @@ +#include + +#include "TestUtil.cc" + +const int SC_CONSOLE_MARK = 0xFFF2; +const int SC_CONSOLE_SELECT_ALL = 0xFFF5; + +int main(int argc, char *argv[0]) { + + if (argc != 2) { + printf("Usage: %s (mark|selectall|read)\n", argv[0]); + return 1; + } + + enum class Test { Mark, SelectAll, Read } test; + if (!strcmp(argv[1], "mark")) { + test = Test::Mark; + } else if (!strcmp(argv[1], "selectall")) { + test = Test::SelectAll; + } else if (!strcmp(argv[1], "read")) { + test = Test::Read; + } else { + printf("Invalid test: %s\n", argv[1]); + return 1; + } + + HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + TimeMeasurement tm; + HWND hwnd = GetConsoleWindow(); + + setWindowPos(0, 0, 1, 1); + setBufferSize(100, 3000); + system("cls"); + setWindowPos(0, 2975, 100, 25); + setCursorPos(0, 2999); + + ShowWindow(hwnd, SW_HIDE); + + for (int i = 0; i < 1000; ++i) { + // CONSOLE_SCREEN_BUFFER_INFO info = {}; + // GetConsoleScreenBufferInfo(conout, &info); + + if (test == Test::Mark) { + SendMessage(hwnd, WM_SYSCOMMAND, SC_CONSOLE_MARK, 0); + SendMessage(hwnd, WM_CHAR, 27, 0x00010001); + } else if (test == Test::SelectAll) { + SendMessage(hwnd, WM_SYSCOMMAND, SC_CONSOLE_SELECT_ALL, 0); + SendMessage(hwnd, WM_CHAR, 27, 0x00010001); + } else if (test == Test::Read) { + static CHAR_INFO buffer[100 * 3000]; + const SMALL_RECT readRegion = {0, 0, 99, 2999}; + SMALL_RECT tmp = readRegion; + BOOL ret = ReadConsoleOutput(conout, buffer, {100, 3000}, {0, 0}, &tmp); + ASSERT(ret && !memcmp(&tmp, &readRegion, sizeof(tmp))); + } + } + + ShowWindow(hwnd, SW_SHOW); + + printf("elapsed: %f\n", tm.elapsed()); + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/GetCh.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/GetCh.cc new file mode 100644 index 00000000..cd6ed194 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/GetCh.cc @@ -0,0 +1,20 @@ +#include +#include +#include + +int main() { + printf("\nPress any keys -- Ctrl-D exits\n\n"); + + while (true) { + const int ch = getch(); + printf("0x%x", ch); + if (isgraph(ch)) { + printf(" '%c'", ch); + } + printf("\n"); + if (ch == 0x4) { // Ctrl-D + break; + } + } + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/GetConsolePos.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/GetConsolePos.cc new file mode 100644 index 00000000..1f3cc531 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/GetConsolePos.cc @@ -0,0 +1,41 @@ +#include + +#include + +#include "TestUtil.cc" + +int main() { + const HANDLE conout = openConout(); + + CONSOLE_SCREEN_BUFFER_INFO info = {}; + BOOL ret = GetConsoleScreenBufferInfo(conout, &info); + ASSERT(ret && "GetConsoleScreenBufferInfo failed"); + + trace("cursor=%d,%d", info.dwCursorPosition.X, info.dwCursorPosition.Y); + printf("cursor=%d,%d\n", info.dwCursorPosition.X, info.dwCursorPosition.Y); + + trace("srWindow={L=%d,T=%d,R=%d,B=%d}", info.srWindow.Left, info.srWindow.Top, info.srWindow.Right, info.srWindow.Bottom); + printf("srWindow={L=%d,T=%d,R=%d,B=%d}\n", info.srWindow.Left, info.srWindow.Top, info.srWindow.Right, info.srWindow.Bottom); + + trace("dwSize=%d,%d", info.dwSize.X, info.dwSize.Y); + printf("dwSize=%d,%d\n", info.dwSize.X, info.dwSize.Y); + + const HWND hwnd = GetConsoleWindow(); + if (hwnd != NULL) { + RECT r = {}; + if (GetWindowRect(hwnd, &r)) { + const int w = r.right - r.left; + const int h = r.bottom - r.top; + trace("hwnd: pos=(%d,%d) size=(%d,%d)", r.left, r.top, w, h); + printf("hwnd: pos=(%d,%d) size=(%d,%d)\n", r.left, r.top, w, h); + } else { + trace("GetWindowRect failed"); + printf("GetWindowRect failed\n"); + } + } else { + trace("GetConsoleWindow returned NULL"); + printf("GetConsoleWindow returned NULL\n"); + } + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/GetFont.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/GetFont.cc new file mode 100644 index 00000000..38625317 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/GetFont.cc @@ -0,0 +1,261 @@ +#include +#include +#include +#include + +#include "../src/shared/OsModule.h" +#include "../src/shared/StringUtil.h" + +#include "TestUtil.cc" +#include "../src/shared/StringUtil.cc" + +#define COUNT_OF(x) (sizeof(x) / sizeof((x)[0])) + +// Some of these types and functions are missing from the MinGW headers. +// Others are undocumented. + +struct AGENT_CONSOLE_FONT_INFO { + DWORD nFont; + COORD dwFontSize; +}; + +struct AGENT_CONSOLE_FONT_INFOEX { + ULONG cbSize; + DWORD nFont; + COORD dwFontSize; + UINT FontFamily; + UINT FontWeight; + WCHAR FaceName[LF_FACESIZE]; +}; + +// undocumented XP API +typedef BOOL WINAPI SetConsoleFont_t( + HANDLE hOutput, + DWORD dwFontIndex); + +// undocumented XP API +typedef DWORD WINAPI GetNumberOfConsoleFonts_t(); + +// XP and up +typedef BOOL WINAPI GetCurrentConsoleFont_t( + HANDLE hOutput, + BOOL bMaximumWindow, + AGENT_CONSOLE_FONT_INFO *lpConsoleCurrentFont); + +// XP and up +typedef COORD WINAPI GetConsoleFontSize_t( + HANDLE hConsoleOutput, + DWORD nFont); + +// Vista and up +typedef BOOL WINAPI GetCurrentConsoleFontEx_t( + HANDLE hConsoleOutput, + BOOL bMaximumWindow, + AGENT_CONSOLE_FONT_INFOEX *lpConsoleCurrentFontEx); + +// Vista and up +typedef BOOL WINAPI SetCurrentConsoleFontEx_t( + HANDLE hConsoleOutput, + BOOL bMaximumWindow, + AGENT_CONSOLE_FONT_INFOEX *lpConsoleCurrentFontEx); + +#define GET_MODULE_PROC(mod, funcName) \ + m_##funcName = reinterpret_cast((mod).proc(#funcName)); \ + +#define DEFINE_ACCESSOR(funcName) \ + funcName##_t &funcName() const { \ + ASSERT(valid()); \ + return *m_##funcName; \ + } + +class XPFontAPI { +public: + XPFontAPI() : m_kernel32(L"kernel32.dll") { + GET_MODULE_PROC(m_kernel32, GetCurrentConsoleFont); + GET_MODULE_PROC(m_kernel32, GetConsoleFontSize); + } + + bool valid() const { + return m_GetCurrentConsoleFont != NULL && + m_GetConsoleFontSize != NULL; + } + + DEFINE_ACCESSOR(GetCurrentConsoleFont) + DEFINE_ACCESSOR(GetConsoleFontSize) + +private: + OsModule m_kernel32; + GetCurrentConsoleFont_t *m_GetCurrentConsoleFont; + GetConsoleFontSize_t *m_GetConsoleFontSize; +}; + +class UndocumentedXPFontAPI : public XPFontAPI { +public: + UndocumentedXPFontAPI() : m_kernel32(L"kernel32.dll") { + GET_MODULE_PROC(m_kernel32, SetConsoleFont); + GET_MODULE_PROC(m_kernel32, GetNumberOfConsoleFonts); + } + + bool valid() const { + return this->XPFontAPI::valid() && + m_SetConsoleFont != NULL && + m_GetNumberOfConsoleFonts != NULL; + } + + DEFINE_ACCESSOR(SetConsoleFont) + DEFINE_ACCESSOR(GetNumberOfConsoleFonts) + +private: + OsModule m_kernel32; + SetConsoleFont_t *m_SetConsoleFont; + GetNumberOfConsoleFonts_t *m_GetNumberOfConsoleFonts; +}; + +class VistaFontAPI : public XPFontAPI { +public: + VistaFontAPI() : m_kernel32(L"kernel32.dll") { + GET_MODULE_PROC(m_kernel32, GetCurrentConsoleFontEx); + GET_MODULE_PROC(m_kernel32, SetCurrentConsoleFontEx); + } + + bool valid() const { + return this->XPFontAPI::valid() && + m_GetCurrentConsoleFontEx != NULL && + m_SetCurrentConsoleFontEx != NULL; + } + + DEFINE_ACCESSOR(GetCurrentConsoleFontEx) + DEFINE_ACCESSOR(SetCurrentConsoleFontEx) + +private: + OsModule m_kernel32; + GetCurrentConsoleFontEx_t *m_GetCurrentConsoleFontEx; + SetCurrentConsoleFontEx_t *m_SetCurrentConsoleFontEx; +}; + +static std::vector > readFontTable( + XPFontAPI &api, HANDLE conout, DWORD maxCount) { + std::vector > ret; + for (DWORD i = 0; i < maxCount; ++i) { + COORD size = api.GetConsoleFontSize()(conout, i); + if (size.X == 0 && size.Y == 0) { + break; + } + ret.push_back(std::make_pair(i, size)); + } + return ret; +} + +static void dumpFontTable(HANDLE conout) { + const int kMaxCount = 1000; + XPFontAPI api; + if (!api.valid()) { + printf("dumpFontTable: cannot dump font table -- missing APIs\n"); + return; + } + std::vector > table = + readFontTable(api, conout, kMaxCount); + std::string line; + char tmp[128]; + size_t first = 0; + while (first < table.size()) { + size_t last = std::min(table.size() - 1, first + 10 - 1); + winpty_snprintf(tmp, "%02u-%02u:", + static_cast(first), static_cast(last)); + line = tmp; + for (size_t i = first; i <= last; ++i) { + if (i % 10 == 5) { + line += " - "; + } + winpty_snprintf(tmp, " %2dx%-2d", + table[i].second.X, table[i].second.Y); + line += tmp; + } + printf("%s\n", line.c_str()); + first = last + 1; + } + if (table.size() == kMaxCount) { + printf("... stopped reading at %d fonts ...\n", kMaxCount); + } +} + +static std::string stringToCodePoints(const std::wstring &str) { + std::string ret = "("; + for (size_t i = 0; i < str.size(); ++i) { + char tmp[32]; + winpty_snprintf(tmp, "%X", str[i]); + if (ret.size() > 1) { + ret.push_back(' '); + } + ret += tmp; + } + ret.push_back(')'); + return ret; +} + +static void dumpFontInfoEx( + const AGENT_CONSOLE_FONT_INFOEX &infoex) { + std::wstring faceName(infoex.FaceName, + winpty_wcsnlen(infoex.FaceName, COUNT_OF(infoex.FaceName))); + cprintf(L"nFont=%u dwFontSize=(%d,%d) " + "FontFamily=0x%x FontWeight=%u FaceName=%ls %hs\n", + static_cast(infoex.nFont), + infoex.dwFontSize.X, infoex.dwFontSize.Y, + infoex.FontFamily, infoex.FontWeight, faceName.c_str(), + stringToCodePoints(faceName).c_str()); +} + +static void dumpVistaFont(VistaFontAPI &api, HANDLE conout, BOOL maxWindow) { + AGENT_CONSOLE_FONT_INFOEX infoex = {0}; + infoex.cbSize = sizeof(infoex); + if (!api.GetCurrentConsoleFontEx()(conout, maxWindow, &infoex)) { + printf("GetCurrentConsoleFontEx call failed\n"); + return; + } + dumpFontInfoEx(infoex); +} + +static void dumpXPFont(XPFontAPI &api, HANDLE conout, BOOL maxWindow) { + AGENT_CONSOLE_FONT_INFO info = {0}; + if (!api.GetCurrentConsoleFont()(conout, maxWindow, &info)) { + printf("GetCurrentConsoleFont call failed\n"); + return; + } + printf("nFont=%u dwFontSize=(%d,%d)\n", + static_cast(info.nFont), + info.dwFontSize.X, info.dwFontSize.Y); +} + +static void dumpFontAndTable(HANDLE conout) { + VistaFontAPI vista; + if (vista.valid()) { + printf("maxWnd=0: "); dumpVistaFont(vista, conout, FALSE); + printf("maxWnd=1: "); dumpVistaFont(vista, conout, TRUE); + dumpFontTable(conout); + return; + } + UndocumentedXPFontAPI xp; + if (xp.valid()) { + printf("maxWnd=0: "); dumpXPFont(xp, conout, FALSE); + printf("maxWnd=1: "); dumpXPFont(xp, conout, TRUE); + dumpFontTable(conout); + return; + } + printf("setSmallFont: neither Vista nor XP APIs detected -- giving up\n"); + dumpFontTable(conout); +} + +int main() { + const HANDLE conout = openConout(); + const COORD largest = GetLargestConsoleWindowSize(conout); + printf("largestConsoleWindowSize=(%d,%d)\n", largest.X, largest.Y); + dumpFontAndTable(conout); + UndocumentedXPFontAPI xp; + if (xp.valid()) { + printf("GetNumberOfConsoleFonts returned %u\n", xp.GetNumberOfConsoleFonts()()); + } else { + printf("The GetNumberOfConsoleFonts API was missing\n"); + } + printf("CP=%u OutputCP=%u\n", GetConsoleCP(), GetConsoleOutputCP()); + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/IdentifyConsoleWindow.ps1 b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/IdentifyConsoleWindow.ps1 new file mode 100644 index 00000000..0c488597 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/IdentifyConsoleWindow.ps1 @@ -0,0 +1,51 @@ +# +# Usage: powershell \IdentifyConsoleWindow.ps1 +# +# This script determines whether the process has a console attached, whether +# that console has a non-NULL window (e.g. HWND), and whether the window is on +# the current window station. +# + +$signature = @' +[DllImport("kernel32.dll", SetLastError=true)] +public static extern IntPtr GetConsoleWindow(); + +[DllImport("kernel32.dll", CharSet=CharSet.Auto, SetLastError=true)] +public static extern bool SetConsoleTitle(String title); + +[DllImport("user32.dll", CharSet=CharSet.Auto, SetLastError=true)] +public static extern int GetWindowText(IntPtr hWnd, + System.Text.StringBuilder lpString, + int nMaxCount); +'@ + +$WinAPI = Add-Type -MemberDefinition $signature ` + -Name WinAPI -Namespace IdentifyConsoleWindow -PassThru + +if (!$WinAPI::SetConsoleTitle("ConsoleWindowScript")) { + echo "error: could not change console title -- is a console attached?" + exit 1 +} else { + echo "note: successfully set console title to ""ConsoleWindowScript""." +} + +$hwnd = $WinAPI::GetConsoleWindow() +if ($hwnd -eq 0) { + echo "note: GetConsoleWindow returned NULL." +} else { + echo "note: GetConsoleWindow returned 0x$($hwnd.ToString("X"))." + $sb = New-Object System.Text.StringBuilder -ArgumentList 4096 + if ($WinAPI::GetWindowText($hwnd, $sb, $sb.Capacity)) { + $title = $sb.ToString() + echo "note: GetWindowText returned ""${title}""." + if ($title -eq "ConsoleWindowScript") { + echo "success!" + } else { + echo "error: expected to see ""ConsoleWindowScript""." + echo " (Perhaps the console window is on a different window station?)" + } + } else { + echo "error: GetWindowText could not read the window title." + echo " (Perhaps the console window is on a different window station?)" + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/IsNewConsole.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/IsNewConsole.cc new file mode 100644 index 00000000..2b554c72 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/IsNewConsole.cc @@ -0,0 +1,87 @@ +// Determines whether this is a new console by testing whether MARK moves the +// cursor. +// +// WARNING: This test program may behave erratically if run under winpty. +// + +#include + +#include +#include + +#include "TestUtil.cc" + +const int SC_CONSOLE_MARK = 0xFFF2; +const int SC_CONSOLE_SELECT_ALL = 0xFFF5; + +static COORD getWindowPos(HANDLE conout) { + CONSOLE_SCREEN_BUFFER_INFO info = {}; + BOOL ret = GetConsoleScreenBufferInfo(conout, &info); + ASSERT(ret && "GetConsoleScreenBufferInfo failed"); + return { info.srWindow.Left, info.srWindow.Top }; +} + +static COORD getWindowSize(HANDLE conout) { + CONSOLE_SCREEN_BUFFER_INFO info = {}; + BOOL ret = GetConsoleScreenBufferInfo(conout, &info); + ASSERT(ret && "GetConsoleScreenBufferInfo failed"); + return { + static_cast(info.srWindow.Right - info.srWindow.Left + 1), + static_cast(info.srWindow.Bottom - info.srWindow.Top + 1) + }; +} + +static COORD getCursorPos(HANDLE conout) { + CONSOLE_SCREEN_BUFFER_INFO info = {}; + BOOL ret = GetConsoleScreenBufferInfo(conout, &info); + ASSERT(ret && "GetConsoleScreenBufferInfo failed"); + return info.dwCursorPosition; +} + +static void setCursorPos(HANDLE conout, COORD pos) { + BOOL ret = SetConsoleCursorPosition(conout, pos); + ASSERT(ret && "SetConsoleCursorPosition failed"); +} + +int main() { + const HANDLE conout = openConout(); + const HWND hwnd = GetConsoleWindow(); + ASSERT(hwnd != NULL && "GetConsoleWindow() returned NULL"); + + // With the legacy console, the Mark command moves the the cursor to the + // top-left cell of the visible console window. Determine whether this + // is the new console by seeing if the cursor moves. + + const auto windowSize = getWindowSize(conout); + if (windowSize.X <= 1) { + printf("Error: console window must be at least 2 columns wide\n"); + trace("Error: console window must be at least 2 columns wide"); + return 1; + } + + bool cursorMoved = false; + const auto initialPos = getCursorPos(conout); + + const auto windowPos = getWindowPos(conout); + setCursorPos(conout, { static_cast(windowPos.X + 1), windowPos.Y }); + + { + const auto posA = getCursorPos(conout); + SendMessage(hwnd, WM_SYSCOMMAND, SC_CONSOLE_MARK, 0); + const auto posB = getCursorPos(conout); + cursorMoved = memcmp(&posA, &posB, sizeof(posA)) != 0; + SendMessage(hwnd, WM_CHAR, 27, 0x00010001); // Send ESCAPE + } + + setCursorPos(conout, initialPos); + + if (cursorMoved) { + printf("Legacy console (i.e. MARK moved cursor)\n"); + trace("Legacy console (i.e. MARK moved cursor)"); + } else { + printf("Windows 10 new console (i.e MARK did not move cursor)\n"); + trace("Windows 10 new console (i.e MARK did not move cursor)"); + } + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/MouseInputNotes.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/MouseInputNotes.txt new file mode 100644 index 00000000..18460c68 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/MouseInputNotes.txt @@ -0,0 +1,90 @@ +Introduction +============ + +The only specification I could find describing mouse input escape sequences +was the /usr/share/doc/xterm/ctlseqs.txt.gz file installed on my Ubuntu +machine. + +Here are the relevant escape sequences: + + * [ON] CSI '?' M 'h' Enable mouse input mode M + * [OFF] CSI '?' M 'l' Disable mouse input mode M + * [EVT] CSI 'M' F X Y Mouse event (default or mode 1005) + * [EVT6] CSI '<' F ';' X ';' Y 'M' Mouse event with mode 1006 + * [EVT6] CSI '<' F ';' X ';' Y 'm' Mouse event with mode 1006 (up) + * [EVT15] CSI F ';' X ';' Y 'M' Mouse event with mode 1015 + +The first batch of modes affect what events are reported: + + * 9: Presses only (not as well-supported as the other modes) + * 1000: Presses and releases + * 1002: Presses, releases, and moves-while-pressed + * 1003: Presses, releases, and all moves + +The next batch of modes affect the encoding of the mouse events: + + * 1005: The X and Y coordinates are UTF-8 codepoints rather than bytes. + * 1006: Use the EVT6 sequences instead of EVT + * 1015: Use the EVT15 sequence instead of EVT (aka URVXT-mode) + +Support for modes in existing terminals +======================================= + + | 9 1000 1002 1003 | 1004 | overflow | defhi | 1005 1006 1015 +---------------------------------+---------------------+------+--------------+-------+---------------- +Eclipse TM Terminal (Neon) | _ _ _ _ | _ | n/a | n/a | _ _ _ +gnome-terminal 3.6.2 | X X X X | _ | suppressed*b | 0x07 | _ X X +iTerm2 2.1.4 | _ X X X | OI | wrap*z | n/a | X X X +jediterm/IntelliJ | _ X X X | _ | ch='?' | 0xff | X X X +Konsole 2.13.2 | _ X X *a | _ | suppressed | 0xff | X X X +mintty 2.2.2 | X X X X | OI | ch='\0' | 0xff | X X X +putty 0.66 | _ X X _ | _ | suppressed | 0xff | _ X X +rxvt 2.7.10 | X X _ _ | _ | wrap*z | n/a | _ _ _ +screen(under xterm) | X X X X | _ | suppressed | 0xff | _ _ _ +urxvt 9.21 | X X X X | _ | wrap*z | n/a | X _ X +xfce4-terminal 0.6.3 (GTK2 VTE) | X X X X | _ | wrap | n/a | _ _ _ +xterm | X X X X | OI | ch='\0' | 0xff | X X X + +*a: Mode 1003 is handled the same way as 1002. +*b: The coordinate wraps from 0xff to 0x00, then maxs out at 0x07. I'm + guessing this behavior is a bug? I'm using the Xubuntu 14.04 + gnome-terminal. +*z: These terminals have a bug where column 224 (and row 224, presumably) + yields a truncated escape sequence. 224 + 32 is 0, so it would normally + yield `CSI 'M' F '\0' Y`, but the '\0' is interpreted as a NUL-terminator. + +Problem 1: How do these flags work? +=================================== + +Terminals accept the OFF sequence with any of the input modes. This makes +little sense--there are two multi-value settings, not seven independent flags! + +All the terminals handle Granularity the same way. ON-Granularity sets +Granularity to the specified value, and OFF-Granularity sets Granularity to +OFF. + +Terminals vary in how they handle the Encoding modes. For example: + + * xterm. ON-Encoding sets Encoding. OFF-Encoding with a non-active Encoding + has no effect. OFF-Encoding otherwise resets Encoding to Default. + + * mintty (tested 2.2.2), iTerm2 2.1.4, and jediterm. ON-Encoding sets + Encoding. OFF-Encoding resets Encoding to Default. + + * Konsole (tested 2.13.2) seems to configure each encoding method + independently. The effective Encoding is the first enabled encoding in this + list: + - Mode 1006 + - Mode 1015 + - Mode 1005 + - Default + + * gnome-terminal (tested 3.6.2) also configures each encoding method + independently. The effective Encoding is the first enabled encoding in + this list: + - Mode 1006 + - Mode 1015 + - Default + Mode 1005 is not supported. + + * xfce4 terminal 0.6.3 (GTK2 VTE) always outputs the default encoding method. diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/MoveConsoleWindow.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/MoveConsoleWindow.cc new file mode 100644 index 00000000..7d9684fe --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/MoveConsoleWindow.cc @@ -0,0 +1,34 @@ +#include + +#include "TestUtil.cc" + +int main(int argc, char *argv[]) { + if (argc != 3 && argc != 5) { + printf("Usage: %s x y\n", argv[0]); + printf("Usage: %s x y width height\n", argv[0]); + return 1; + } + + HWND hwnd = GetConsoleWindow(); + + const int x = atoi(argv[1]); + const int y = atoi(argv[2]); + + int w = 0, h = 0; + if (argc == 3) { + RECT r = {}; + BOOL ret = GetWindowRect(hwnd, &r); + ASSERT(ret && "GetWindowRect failed on console window"); + w = r.right - r.left; + h = r.bottom - r.top; + } else { + w = atoi(argv[3]); + h = atoi(argv[4]); + } + + BOOL ret = MoveWindow(hwnd, x, y, w, h, TRUE); + trace("MoveWindow: ret=%d", ret); + printf("MoveWindow: ret=%d\n", ret); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Notes.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Notes.txt new file mode 100644 index 00000000..410e1841 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Notes.txt @@ -0,0 +1,219 @@ +Test programs +------------- + +Cygwin + emacs + vim + mc (Midnight Commander) + lynx + links + less + more + wget + +Capturing the console output +---------------------------- + +Initial idea: + +In the agent, keep track of the remote terminal state for N lines of +(window+history). Also keep track of the terminal size. Regularly poll for +changes to the console screen buffer, then use some number of edits to bring +the remote terminal into sync with the console. + +This idea seems to have trouble when a Unix terminal is resized. When the +server receives a resize notification, it can have a hard time figuring out +what the terminal did. Race conditions might also be a problem. + +The behavior of the terminal can be tricky: + + - When the window is expanded by one line, does the terminal add a blank line + to the bottom or move a line from the history into the top? + + - When the window is shrunk by one line, does the terminal delete the topmost + or the bottommost line? Can it delete the line with the cursor? + +Some popular behaviors for expanding: + - [all] If there are no history lines, then add a line at the bottom. + - [konsole] Always add a line at the bottom. + - [putty,xterm,rxvt] Pull in a history line from the top. + - [g-t] I can't tell. It seems to add a blank line, until the program writes + to stdout or until I click the scroll bar, then the output "snaps" back down, + pulling lines out of the history. I thought I saw different behavior + between Ubuntu 10.10 and 11.10, so maybe GNOME 3 changed something. Avoid + using "bash" to test this behavior because "bash" apparently always writes + the prompt after terminal resize. + +Some popular behaviors for shrinking: + - [konsole,putty,xterm,rxvt] If the line at the bottom is blank, then delete + it. Otherwise, move the topmost line into history. + - [g-t] If the line at the bottom has not been touched, then delete it. + Otherwise, move the topmost line into history. + +(TODO: I need to test my theories about the terminal behavior better still. +It's interesting to see how g-t handles clear differently than every other +terminal.) + +There is an ANSI escape sequence (DSR) that sends the current cursor location +to the terminal's input. One idea I had was to use this code to figure out how +the terminal had handled a resize. I currently think this idea won't work due +to race conditions. + +Newer idea: + +Keep track of the last N lines that have been sent to the remote terminal. +Poll for changes to console output. When the output changes, send just the +changed content to the terminal. In particular: + - Don't send a cursor position (CUP) code. Instead, if the line that's 3 + steps up from the latest line changes, send a relative cursor up (CUU) + code. It's OK to send an absolute column number code (CHA). + - At least in general, don't try to send complete screenshots of the current + console window. + +The idea is that sending just the changes should have good behavior for streams +of output, even when those streams modify the output (e.g. an archiver, or +maybe a downloader/packager/wget). I need to think about whether this works +for full-screen programs (e.g. emacs, less, lynx, the above list of programs). + +I noticed that console programs don't typically modify the window or buffer +coordinates. edit.com is an exception. + +I tested the pager in native Python (more?), and I verified that ENTER and SPACE +both paid no attention to the location of the console window within the screen +buffer. This makes sense -- why would they care? The Cygwin less, on the other +hand, does care. If I scroll the window up, then Cygwin less will write to a +position within the window. I didn't really expect this behavior, but it +doesn't seem to be a problem. + +Setting up a TestNetServer service +---------------------------------- + +First run the deploy.sh script to copy files into deploy. Make sure +TestNetServer.exe will run in a bare environment (no MinGW or Qt in the path). + +Install the Windows Server 2003 Resource Kit. It will have two programs in it, +instsrv and srvany. + +Run: + + InstSrv TestNetServer \srvany.exe + +This creates a service named "TestNetServer" that uses the Microsoft service +wrapper. To configure the new service to run TestNetServer, set a registry +value: + + [HKLM\SYSTEM\CurrentControlSet\Services\TestNetServer\Parameters] + Application=\TestNetServer.exe + +Also see http://www.iopus.com/guides/srvany.htm. + +To remove the service, run: + + InstSrv TestNetServer REMOVE + +TODO +---- + +Agent: When resizing the console, consider whether to add lines to the top +or bottom. I remember thinking the current behavior was wrong for some +application, but I forgot which one. + +Make the font as small as possible. The console window dimensions are limited by +the screen size, so making the font small reduces an unnecessary limitation on the +PseudoConsole size. There's a documented Vista/Win7 API for this +(SetCurrentConsoleFontEx), and apparently WinXP has an undocumented API +(SetConsoleFont): + http://blogs.microsoft.co.il/blogs/pavely/archive/2009/07/23/changing-console-fonts.aspx + +Make the agent work with DOS programs like edit and qbasic. + - Detect that the terminal program has resized the window/buffer and enter a + simple just-scrape-and-dont-resize mode. Track the client window size and + send the intersection of the console and the agent's client. + - I also need to generate keyboard scan codes. + - Solve the NTVDM.EXE console shutdown problem, probably by ignoring NTVDM.EXE + when it appears on the GetConsoleProcessList list. + +Rename the agent? Is the term "proxy" more accurate? + +Optimize the polling. e.g. Use a longer poll interval when the console is idle. +Do a minimal poll that checks whether the sync marker or window has moved. + +Increase the console buffer size to ~9000 lines. Beware making it so big that +reading the sync column exhausts the 32KB conhost<->agent heap. + +Reduce the memory overhead of the agent. The agent's m_bufferData array can +be small (a few hundred lines?) relative to the console buffer size. + +Try to handle console background color better. + Unix terminal emulators have a user-configurable foreground and background +color, and for best results, the agent really needs to avoid changing the colors, +especially the background color. It's undesirable/ugly to SSH into a machine +and see the command prompt change the colors. It's especially ugly that the +terminal retains its original colors and only drawn cells get the new colors. +(e.g. Resizing the window to the right uses the local terminal colors rather +than the remote colors.) It's especially ugly in gnome-terminal, which draws +user-configurable black as black, but VT100 black as dark-gray. + If there were a way to query the terminal emulator's colors, then I could +match the console's colors to the terminal and everything would just work. As +far as I know, that's not possible. + I thought of a kludge that might work. Instead of translating console white +and black to VT/100 white and black, I would translate them to "reset" and +"invert". I'd translate other colors normally. This approach should produce +ideal results for command-line work and tolerable results for full-screen +programs without configuration. Configuring the agent for black-on-white or +white-on-black would produce ideal results in all situations. + This kludge only really applies to the SSH application. For a Win32 Konsole +application, it should be easy to get the colors right all the time. + +Try using the screen reader API: + - To eliminate polling. + - To detect when a line wraps. When a line wraps, it'd be nice not to send a + CRLF to the terminal emulator so copy-and-paste works better. + - To detect hard tabs with Cygwin. + +Implement VT100/ANSI escape sequence recognition for input. Decide where this +functionality belongs. PseudoConsole.dll? Disambiguating ESC from an escape +sequence might be tricky. For the SSH server, I was thinking that when a small +SSH payload ended with an ESC character, I could assume the character was really +an ESC keypress, on the assumption that if it were an escape sequence, the +payload would probably contain the whole sequence. I'm not sure this works, +especially if there's a lot of other traffic multiplexed on the SSH socket. + +Support Unicode. + - Some DOS programs draw using line/box characters. Can these characters be + translated to the Unicode equivalents? + +Create automated tests. + +Experiment with the Terminator emulator, an emulator that doesn't wrap lines. +How many columns does it report having? What column does it report the cursor +in as it's writing past the right end of the window? Will Terminator be a +problem if I implement line wrapping detection in the agent? + +BUG: After the unix-adapter/pconsole.exe program exits, the blinking cursor is +replaced with a hidden cursor. + +Fix assert() in the agent. If it fails, the failure message needs to be +reported somewhere. Pop up a dialog box? Maybe switch the active desktop, +then show a dialog box? + +TODO: There's already a pconsole project on GitHub. Maybe rename this project +to something else? winpty? + +TODO: Can the DebugServer system be replaced with OutputDebugString? How +do we decide whose processes' output to collect? + +TODO: Three executables: + build/winpty-agent.exe + build/winpty.dll + build/console.exe + +BUG: Run the pconsole.exe inside another console. As I type dir, I see this: + D:\rprichard\pconsole> + D:\rprichard\pconsole>d + D:\rprichard\pconsole>di + D:\rprichard\pconsole>dir + In the output of "dir", every other line is blank. + There was a bug in Terminal::sendLine that was causing this to happen + frequently. Now that I fixed it, this bug should only manifest on lines + whose last column is not a space (i.e. a full line). diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/OSVersion.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/OSVersion.cc new file mode 100644 index 00000000..456708f0 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/OSVersion.cc @@ -0,0 +1,27 @@ +#include + +#include +#include +#include + +#include + +int main() { + setlocale(LC_ALL, ""); + + OSVERSIONINFOEXW info = {0}; + info.dwOSVersionInfoSize = sizeof(info); + assert(GetVersionExW((OSVERSIONINFOW*)&info)); + + printf("dwMajorVersion = %d\n", (int)info.dwMajorVersion); + printf("dwMinorVersion = %d\n", (int)info.dwMinorVersion); + printf("dwBuildNumber = %d\n", (int)info.dwBuildNumber); + printf("dwPlatformId = %d\n", (int)info.dwPlatformId); + printf("szCSDVersion = %ls\n", info.szCSDVersion); + printf("wServicePackMajor = %d\n", info.wServicePackMajor); + printf("wServicePackMinor = %d\n", info.wServicePackMinor); + printf("wSuiteMask = 0x%x\n", (unsigned int)info.wSuiteMask); + printf("wProductType = 0x%x\n", (unsigned int)info.wProductType); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ScreenBufferFreezeInactive.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ScreenBufferFreezeInactive.cc new file mode 100644 index 00000000..656d4f12 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ScreenBufferFreezeInactive.cc @@ -0,0 +1,101 @@ +// +// Verify that console selection blocks writes to an inactive console screen +// buffer. Writes TEST PASSED or TEST FAILED to the popup console window. +// + +#include +#include + +#include + +#include "TestUtil.cc" + +const int SC_CONSOLE_MARK = 0xFFF2; +const int SC_CONSOLE_SELECT_ALL = 0xFFF5; + +bool g_useMark = false; + +CALLBACK DWORD pausingThread(LPVOID dummy) +{ + HWND hwnd = GetConsoleWindow(); + trace("Sending selection to freeze"); + SendMessage(hwnd, WM_SYSCOMMAND, + g_useMark ? SC_CONSOLE_MARK : + SC_CONSOLE_SELECT_ALL, + 0); + Sleep(1000); + trace("Sending escape WM_CHAR to unfreeze"); + SendMessage(hwnd, WM_CHAR, 27, 0x00010001); + Sleep(1000); +} + +static HANDLE createBuffer() { + HANDLE buf = CreateConsoleScreenBuffer( + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, + CONSOLE_TEXTMODE_BUFFER, + NULL); + ASSERT(buf != INVALID_HANDLE_VALUE); + return buf; +} + +static void runTest(bool useMark, bool createEarly) { + trace("======================================="); + trace("useMark=%d createEarly=%d", useMark, createEarly); + g_useMark = useMark; + HANDLE buf = INVALID_HANDLE_VALUE; + + if (createEarly) { + buf = createBuffer(); + } + + CreateThread(NULL, 0, + pausingThread, NULL, + 0, NULL); + Sleep(500); + + if (!createEarly) { + trace("Creating buffer"); + TimeMeasurement tm1; + buf = createBuffer(); + const double elapsed1 = tm1.elapsed(); + if (elapsed1 >= 0.250) { + printf("!!! TEST FAILED !!!\n"); + Sleep(2000); + return; + } + } + + trace("Writing to aux buffer"); + TimeMeasurement tm2; + DWORD actual = 0; + BOOL ret = WriteConsoleW(buf, L"HI", 2, &actual, NULL); + const double elapsed2 = tm2.elapsed(); + trace("Writing to aux buffer: finished: ret=%d actual=%d (elapsed=%1.3f)", ret, actual, elapsed2); + if (elapsed2 < 0.250) { + printf("!!! TEST FAILED !!!\n"); + } else { + printf("TEST PASSED\n"); + } + Sleep(2000); +} + +int main(int argc, char **argv) { + if (argc == 1) { + startChildProcess(L"child"); + return 0; + } + + std::string arg = argv[1]; + if (arg == "child") { + for (int useMark = 0; useMark <= 1; useMark++) { + for (int createEarly = 0; createEarly <= 1; createEarly++) { + runTest(useMark, createEarly); + } + } + printf("done...\n"); + Sleep(1000); + } + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ScreenBufferTest.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ScreenBufferTest.cc new file mode 100644 index 00000000..fa584b9f --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ScreenBufferTest.cc @@ -0,0 +1,671 @@ +// +// Windows versions tested +// +// Vista Enterprise SP2 32-bit +// - ver reports [Version 6.0.6002] +// - kernel32.dll product/file versions are 6.0.6002.19381 +// +// Windows 7 Ultimate SP1 32-bit +// - ver reports [Version 6.1.7601] +// - conhost.exe product/file versions are 6.1.7601.18847 +// - kernel32.dll product/file versions are 6.1.7601.18847 +// +// Windows Server 2008 R2 Datacenter SP1 64-bit +// - ver reports [Version 6.1.7601] +// - conhost.exe product/file versions are 6.1.7601.23153 +// - kernel32.dll product/file versions are 6.1.7601.23153 +// +// Windows 8 Enterprise 32-bit +// - ver reports [Version 6.2.9200] +// - conhost.exe product/file versions are 6.2.9200.16578 +// - kernel32.dll product/file versions are 6.2.9200.16859 +// + +// +// Specific version details on working Server 2008 R2: +// +// dwMajorVersion = 6 +// dwMinorVersion = 1 +// dwBuildNumber = 7601 +// dwPlatformId = 2 +// szCSDVersion = Service Pack 1 +// wServicePackMajor = 1 +// wServicePackMinor = 0 +// wSuiteMask = 0x190 +// wProductType = 0x3 +// +// Specific version details on broken Win7: +// +// dwMajorVersion = 6 +// dwMinorVersion = 1 +// dwBuildNumber = 7601 +// dwPlatformId = 2 +// szCSDVersion = Service Pack 1 +// wServicePackMajor = 1 +// wServicePackMinor = 0 +// wSuiteMask = 0x100 +// wProductType = 0x1 +// + +#include +#include +#include + +#include "TestUtil.cc" + +const char *g_prefix = ""; + +static void dumpHandles() { + trace("%sSTDIN=0x%I64x STDOUT=0x%I64x STDERR=0x%I64x", + g_prefix, + (long long)GetStdHandle(STD_INPUT_HANDLE), + (long long)GetStdHandle(STD_OUTPUT_HANDLE), + (long long)GetStdHandle(STD_ERROR_HANDLE)); +} + +static const char *successOrFail(BOOL ret) { + return ret ? "ok" : "FAILED"; +} + +static void startChildInSameConsole(const wchar_t *args, BOOL + bInheritHandles=FALSE) { + wchar_t program[1024]; + wchar_t cmdline[1024]; + GetModuleFileNameW(NULL, program, 1024); + swprintf(cmdline, L"\"%ls\" %ls", program, args); + + STARTUPINFOW sui; + PROCESS_INFORMATION pi; + memset(&sui, 0, sizeof(sui)); + memset(&pi, 0, sizeof(pi)); + sui.cb = sizeof(sui); + + CreateProcessW(program, cmdline, + NULL, NULL, + /*bInheritHandles=*/bInheritHandles, + /*dwCreationFlags=*/0, + NULL, NULL, + &sui, &pi); +} + +static void closeHandle(HANDLE h) { + trace("%sClosing handle 0x%I64x...", g_prefix, (long long)h); + trace("%sClosing handle 0x%I64x... %s", g_prefix, (long long)h, successOrFail(CloseHandle(h))); +} + +static HANDLE createBuffer() { + + // If sa isn't provided, the handle defaults to not-inheritable. + SECURITY_ATTRIBUTES sa = {0}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + trace("%sCreating a new buffer...", g_prefix); + HANDLE conout = CreateConsoleScreenBuffer( + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &sa, + CONSOLE_TEXTMODE_BUFFER, NULL); + + trace("%sCreating a new buffer... 0x%I64x", g_prefix, (long long)conout); + return conout; +} + +static HANDLE openConout() { + + // If sa isn't provided, the handle defaults to not-inheritable. + SECURITY_ATTRIBUTES sa = {0}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + trace("%sOpening CONOUT...", g_prefix); + HANDLE conout = CreateFileW(L"CONOUT$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &sa, + OPEN_EXISTING, 0, NULL); + trace("%sOpening CONOUT... 0x%I64x", g_prefix, (long long)conout); + return conout; +} + +static void setConsoleActiveScreenBuffer(HANDLE conout) { + trace("%sSetConsoleActiveScreenBuffer(0x%I64x) called...", + g_prefix, (long long)conout); + trace("%sSetConsoleActiveScreenBuffer(0x%I64x) called... %s", + g_prefix, (long long)conout, + successOrFail(SetConsoleActiveScreenBuffer(conout))); +} + +static void writeTest(HANDLE conout, const char *msg) { + char writeData[256]; + sprintf(writeData, "%s%s\n", g_prefix, msg); + + trace("%sWriting to 0x%I64x: '%s'...", + g_prefix, (long long)conout, msg); + DWORD actual = 0; + BOOL ret = WriteConsoleA(conout, writeData, strlen(writeData), &actual, NULL); + trace("%sWriting to 0x%I64x: '%s'... %s", + g_prefix, (long long)conout, msg, + successOrFail(ret && actual == strlen(writeData))); +} + +static void writeTest(const char *msg) { + writeTest(GetStdHandle(STD_OUTPUT_HANDLE), msg); +} + + + +/////////////////////////////////////////////////////////////////////////////// +// TEST 1 -- create new buffer, activate it, and close the handle. The console +// automatically switches the screen buffer back to the original. +// +// This test passes everywhere. +// + +static void test1(int argc, char *argv[]) { + if (!strcmp(argv[1], "1")) { + startChildProcess(L"1:child"); + return; + } + + HANDLE origBuffer = GetStdHandle(STD_OUTPUT_HANDLE); + writeTest(origBuffer, "<-- origBuffer -->"); + + HANDLE newBuffer = createBuffer(); + writeTest(newBuffer, "<-- newBuffer -->"); + setConsoleActiveScreenBuffer(newBuffer); + Sleep(2000); + + writeTest(origBuffer, "TEST PASSED!"); + + // Closing the handle w/o switching the active screen buffer automatically + // switches the console back to the original buffer. + closeHandle(newBuffer); + + while (true) { + Sleep(1000); + } +} + + + +/////////////////////////////////////////////////////////////////////////////// +// TEST 2 -- Test program that creates and activates newBuffer, starts a child +// process, then closes its newBuffer handle. newBuffer remains activated, +// because the child keeps it active. (Also see TEST D.) +// + +static void test2(int argc, char *argv[]) { + if (!strcmp(argv[1], "2")) { + startChildProcess(L"2:parent"); + return; + } + + if (!strcmp(argv[1], "2:parent")) { + g_prefix = "parent: "; + dumpHandles(); + HANDLE origBuffer = GetStdHandle(STD_OUTPUT_HANDLE); + writeTest(origBuffer, "<-- origBuffer -->"); + + HANDLE newBuffer = createBuffer(); + writeTest(newBuffer, "<-- newBuffer -->"); + setConsoleActiveScreenBuffer(newBuffer); + + Sleep(1000); + writeTest(newBuffer, "bInheritHandles=FALSE:"); + startChildInSameConsole(L"2:child", FALSE); + Sleep(1000); + writeTest(newBuffer, "bInheritHandles=TRUE:"); + startChildInSameConsole(L"2:child", TRUE); + + Sleep(1000); + trace("parent:----"); + + // Close the new buffer. The active screen buffer doesn't automatically + // switch back to origBuffer, because the child process has a handle open + // to the original buffer. + closeHandle(newBuffer); + + Sleep(600 * 1000); + return; + } + + if (!strcmp(argv[1], "2:child")) { + g_prefix = "child: "; + dumpHandles(); + // The child's output isn't visible, because it's still writing to + // origBuffer. + trace("child:----"); + writeTest("writing to STDOUT"); + + // Handle inheritability is curious. The console handles this program + // creates are inheritable, but CreateProcess is called with both + // bInheritHandles=TRUE and bInheritHandles=FALSE. + // + // Vista and Windows 7: bInheritHandles has no effect. The child and + // parent processes have the same STDIN/STDOUT/STDERR handles: + // 0x3, 0x7, and 0xB. The parent has a 0xF handle for newBuffer. + // The child can only write to 0x7, 0xB, and 0xF. Only the writes to + // 0xF are visible (i.e. they touch newBuffer). + // + // Windows 8 or Windows 10 (legacy or non-legacy): the lowest 2 bits of + // the HANDLE to WriteConsole seem to be ignored. The new process' + // console handles always refer to the buffer that was active when they + // started, but the values of the handles depend upon bInheritHandles. + // With bInheritHandles=TRUE, the child has the same + // STDIN/STDOUT/STDERR/newBuffer handles as the parent, and the three + // output handles all work, though their output is all visible. With + // bInheritHandles=FALSE, the child has different STDIN/STDOUT/STDERR + // handles, and only the new STDOUT/STDERR handles work. + // + for (unsigned int i = 0x1; i <= 0xB0; ++i) { + char msg[256]; + sprintf(msg, "Write to handle 0x%x", i); + HANDLE h = reinterpret_cast(i); + writeTest(h, msg); + } + + Sleep(600 * 1000); + return; + } +} + + + +/////////////////////////////////////////////////////////////////////////////// +// TEST A -- demonstrate an apparent Windows bug with screen buffers +// +// Steps: +// - The parent starts a child process. +// - The child process creates and activates newBuffer +// - The parent opens CONOUT$ and writes to it. +// - The parent closes CONOUT$. +// - At this point, broken Windows reactivates origBuffer. +// - The child writes to newBuffer again. +// - The child activates origBuffer again, then closes newBuffer. +// +// Test passes if the message "TEST PASSED!" is visible. +// Test commonly fails if conhost.exe crashes. +// +// Results: +// - Windows 7 Ultimate SP1 32-bit: conhost.exe crashes +// - Windows Server 2008 R2 Datacenter SP1 64-bit: PASS +// - Windows 8 Enterprise 32-bit: PASS +// - Windows 10 64-bit (legacy and non-legacy): PASS +// + +static void testA_parentWork() { + // Open an extra CONOUT$ handle so that the HANDLE values in parent and + // child don't collide. I think it's OK if they collide, but since we're + // trying to track down a Windows bug, it's best to avoid unnecessary + // complication. + HANDLE dummy = openConout(); + + Sleep(3000); + + // Step 2: Open CONOUT$ in the parent. This opens the active buffer, which + // was just created in the child. It's handle 0x13. Write to it. + + HANDLE newBuffer = openConout(); + writeTest(newBuffer, "step2: writing to newBuffer"); + + Sleep(3000); + + // Step 3: Close handle 0x13. With Windows 7, the console switches back to + // origBuffer, and (unless I'm missing something) it shouldn't. + + closeHandle(newBuffer); +} + +static void testA_childWork() { + HANDLE origBuffer = GetStdHandle(STD_OUTPUT_HANDLE); + + // + // Step 1: Create the new screen buffer in the child process and make it + // active. (Typically, it's handle 0x0F.) + // + + HANDLE newBuffer = createBuffer(); + + setConsoleActiveScreenBuffer(newBuffer); + writeTest(newBuffer, "<-- newBuffer -->"); + + Sleep(9000); + trace("child:----"); + + // Step 4: write to the newBuffer again. + writeTest(newBuffer, "TEST PASSED!"); + + // + // Step 5: Switch back to the original screen buffer and close the new + // buffer. The switch call succeeds, but the CloseHandle call freezes for + // several seconds, because conhost.exe crashes. + // + Sleep(3000); + + setConsoleActiveScreenBuffer(origBuffer); + writeTest(origBuffer, "writing to origBuffer"); + + closeHandle(newBuffer); + + // The console HWND is NULL. + trace("child: console HWND=0x%I64x", (long long)GetConsoleWindow()); + + // At this point, the console window has closed, but the parent/child + // processes are still running. Calling AllocConsole would fail, but + // calling FreeConsole followed by AllocConsole would both succeed, and a + // new console would appear. +} + +static void testA(int argc, char *argv[]) { + + if (!strcmp(argv[1], "A")) { + startChildProcess(L"A:parent"); + return; + } + + if (!strcmp(argv[1], "A:parent")) { + g_prefix = "parent: "; + trace("parent:----"); + dumpHandles(); + writeTest("<-- origBuffer -->"); + startChildInSameConsole(L"A:child"); + testA_parentWork(); + Sleep(120000); + return; + } + + if (!strcmp(argv[1], "A:child")) { + g_prefix = "child: "; + dumpHandles(); + testA_childWork(); + Sleep(120000); + return; + } +} + + + +/////////////////////////////////////////////////////////////////////////////// +// TEST B -- invert TEST A -- also crashes conhost on Windows 7 +// +// Test passes if the message "TEST PASSED!" is visible. +// Test commonly fails if conhost.exe crashes. +// +// Results: +// - Windows 7 Ultimate SP1 32-bit: conhost.exe crashes +// - Windows Server 2008 R2 Datacenter SP1 64-bit: PASS +// - Windows 8 Enterprise 32-bit: PASS +// - Windows 10 64-bit (legacy and non-legacy): PASS +// + +static void testB(int argc, char *argv[]) { + if (!strcmp(argv[1], "B")) { + startChildProcess(L"B:parent"); + return; + } + + if (!strcmp(argv[1], "B:parent")) { + g_prefix = "parent: "; + startChildInSameConsole(L"B:child"); + writeTest("<-- origBuffer -->"); + HANDLE origBuffer = GetStdHandle(STD_OUTPUT_HANDLE); + + // + // Step 1: Create the new buffer and make it active. + // + trace("%s----", g_prefix); + HANDLE newBuffer = createBuffer(); + setConsoleActiveScreenBuffer(newBuffer); + writeTest(newBuffer, "<-- newBuffer -->"); + + // + // Step 4: Attempt to write again to the new buffer. + // + Sleep(9000); + trace("%s----", g_prefix); + writeTest(newBuffer, "TEST PASSED!"); + + // + // Step 5: Switch back to the original buffer. + // + Sleep(3000); + trace("%s----", g_prefix); + setConsoleActiveScreenBuffer(origBuffer); + closeHandle(newBuffer); + writeTest(origBuffer, "writing to the initial buffer"); + + Sleep(60000); + return; + } + + if (!strcmp(argv[1], "B:child")) { + g_prefix = "child: "; + Sleep(3000); + trace("%s----", g_prefix); + + // + // Step 2: Open the newly active buffer and write to it. + // + HANDLE newBuffer = openConout(); + writeTest(newBuffer, "writing to newBuffer"); + + // + // Step 3: Close the newly active buffer. + // + Sleep(3000); + closeHandle(newBuffer); + + Sleep(60000); + return; + } +} + + + +/////////////////////////////////////////////////////////////////////////////// +// TEST C -- Interleaving open/close of console handles also seems to break on +// Windows 7. +// +// Test: +// - child creates and activates newBuf1 +// - parent opens newBuf1 +// - child creates and activates newBuf2 +// - parent opens newBuf2, then closes newBuf1 +// - child switches back to newBuf1 +// * At this point, the console starts malfunctioning. +// - parent and child close newBuf2 +// - child closes newBuf1 +// +// Test passes if the message "TEST PASSED!" is visible. +// Test commonly fails if conhost.exe crashes. +// +// Results: +// - Windows 7 Ultimate SP1 32-bit: conhost.exe crashes +// - Windows Server 2008 R2 Datacenter SP1 64-bit: PASS +// - Windows 8 Enterprise 32-bit: PASS +// - Windows 10 64-bit (legacy and non-legacy): PASS +// + +static void testC(int argc, char *argv[]) { + if (!strcmp(argv[1], "C")) { + startChildProcess(L"C:parent"); + return; + } + + if (!strcmp(argv[1], "C:parent")) { + startChildInSameConsole(L"C:child"); + writeTest("<-- origBuffer -->"); + g_prefix = "parent: "; + + // At time=4, open newBuffer1. + Sleep(4000); + trace("%s---- t=4", g_prefix); + const HANDLE newBuffer1 = openConout(); + + // At time=8, open newBuffer2, and close newBuffer1. + Sleep(4000); + trace("%s---- t=8", g_prefix); + const HANDLE newBuffer2 = openConout(); + closeHandle(newBuffer1); + + // At time=25, cleanup of newBuffer2. + Sleep(17000); + trace("%s---- t=25", g_prefix); + closeHandle(newBuffer2); + + Sleep(240000); + return; + } + + if (!strcmp(argv[1], "C:child")) { + g_prefix = "child: "; + + // At time=2, create newBuffer1 and activate it. + Sleep(2000); + trace("%s---- t=2", g_prefix); + const HANDLE newBuffer1 = createBuffer(); + setConsoleActiveScreenBuffer(newBuffer1); + writeTest(newBuffer1, "<-- newBuffer1 -->"); + + // At time=6, create newBuffer2 and activate it. + Sleep(4000); + trace("%s---- t=6", g_prefix); + const HANDLE newBuffer2 = createBuffer(); + setConsoleActiveScreenBuffer(newBuffer2); + writeTest(newBuffer2, "<-- newBuffer2 -->"); + + // At time=10, attempt to switch back to newBuffer1. The parent process + // has opened and closed its handle to newBuffer1, so does it still exist? + Sleep(4000); + trace("%s---- t=10", g_prefix); + setConsoleActiveScreenBuffer(newBuffer1); + writeTest(newBuffer1, "write to newBuffer1: TEST PASSED!"); + + // At time=25, cleanup of newBuffer2. + Sleep(15000); + trace("%s---- t=25", g_prefix); + closeHandle(newBuffer2); + + // At time=35, cleanup of newBuffer1. The console should switch to the + // initial buffer again. + Sleep(10000); + trace("%s---- t=35", g_prefix); + closeHandle(newBuffer1); + + Sleep(240000); + return; + } +} + + + +/////////////////////////////////////////////////////////////////////////////// +// TEST D -- parent creates a new buffer, child launches, writes, +// closes it output handle, then parent writes again. (Also see TEST 2.) +// +// On success, this will appear: +// +// parent: <-- newBuffer --> +// child: writing to newBuffer +// parent: TEST PASSED! +// +// If this appears, it indicates that the child's closing its output handle did +// not destroy newBuffer. +// +// Results: +// - Windows 7 Ultimate SP1 32-bit: PASS +// - Windows 8 Enterprise 32-bit: PASS +// - Windows 10 64-bit (legacy and non-legacy): PASS +// + +static void testD(int argc, char *argv[]) { + if (!strcmp(argv[1], "D")) { + startChildProcess(L"D:parent"); + return; + } + + if (!strcmp(argv[1], "D:parent")) { + g_prefix = "parent: "; + HANDLE origBuffer = GetStdHandle(STD_OUTPUT_HANDLE); + writeTest(origBuffer, "<-- origBuffer -->"); + + HANDLE newBuffer = createBuffer(); + writeTest(newBuffer, "<-- newBuffer -->"); + setConsoleActiveScreenBuffer(newBuffer); + + // At t=2, start a child process, explicitly forcing it to use + // newBuffer for its standard handles. These calls are apparently + // redundant on Windows 8 and up. + Sleep(2000); + trace("parent:----"); + trace("parent: starting child process"); + SetStdHandle(STD_OUTPUT_HANDLE, newBuffer); + SetStdHandle(STD_ERROR_HANDLE, newBuffer); + startChildInSameConsole(L"D:child"); + SetStdHandle(STD_OUTPUT_HANDLE, origBuffer); + SetStdHandle(STD_ERROR_HANDLE, origBuffer); + + // At t=6, write again to newBuffer. + Sleep(4000); + trace("parent:----"); + writeTest(newBuffer, "TEST PASSED!"); + + // At t=8, close the newBuffer. In earlier versions of windows + // (including Server 2008 R2), the console then switches back to + // origBuffer. As of Windows 8, it doesn't, because somehow the child + // process is keeping the console on newBuffer, even though the child + // process closed its STDIN/STDOUT/STDERR handles. Killing the child + // process by hand after the test finishes *does* force the console + // back to origBuffer. + Sleep(2000); + closeHandle(newBuffer); + + Sleep(120000); + return; + } + + if (!strcmp(argv[1], "D:child")) { + g_prefix = "child: "; + // At t=2, the child starts. + trace("child:----"); + dumpHandles(); + writeTest("writing to newBuffer"); + + // At t=4, the child explicitly closes its handle. + Sleep(2000); + trace("child:----"); + if (GetStdHandle(STD_ERROR_HANDLE) != GetStdHandle(STD_OUTPUT_HANDLE)) { + closeHandle(GetStdHandle(STD_ERROR_HANDLE)); + } + closeHandle(GetStdHandle(STD_OUTPUT_HANDLE)); + closeHandle(GetStdHandle(STD_INPUT_HANDLE)); + + Sleep(120000); + return; + } +} + + + +int main(int argc, char *argv[]) { + if (argc == 1) { + printf("USAGE: %s testnum\n", argv[0]); + return 0; + } + + if (argv[1][0] == '1') { + test1(argc, argv); + } else if (argv[1][0] == '2') { + test2(argc, argv); + } else if (argv[1][0] == 'A') { + testA(argc, argv); + } else if (argv[1][0] == 'B') { + testB(argc, argv); + } else if (argv[1][0] == 'C') { + testC(argc, argv); + } else if (argv[1][0] == 'D') { + testD(argc, argv); + } + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ScreenBufferTest2.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ScreenBufferTest2.cc new file mode 100644 index 00000000..2b648c94 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ScreenBufferTest2.cc @@ -0,0 +1,151 @@ +#include + +#include "TestUtil.cc" + +const char *g_prefix = ""; + +static void dumpHandles() { + trace("%sSTDIN=0x%I64x STDOUT=0x%I64x STDERR=0x%I64x", + g_prefix, + (long long)GetStdHandle(STD_INPUT_HANDLE), + (long long)GetStdHandle(STD_OUTPUT_HANDLE), + (long long)GetStdHandle(STD_ERROR_HANDLE)); +} + +static HANDLE createBuffer() { + + // If sa isn't provided, the handle defaults to not-inheritable. + SECURITY_ATTRIBUTES sa = {0}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + trace("%sCreating a new buffer...", g_prefix); + HANDLE conout = CreateConsoleScreenBuffer( + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &sa, + CONSOLE_TEXTMODE_BUFFER, NULL); + + trace("%sCreating a new buffer... 0x%I64x", g_prefix, (long long)conout); + return conout; +} + +static const char *successOrFail(BOOL ret) { + return ret ? "ok" : "FAILED"; +} + +static void setConsoleActiveScreenBuffer(HANDLE conout) { + trace("%sSetConsoleActiveScreenBuffer(0x%I64x) called...", + g_prefix, (long long)conout); + trace("%sSetConsoleActiveScreenBuffer(0x%I64x) called... %s", + g_prefix, (long long)conout, + successOrFail(SetConsoleActiveScreenBuffer(conout))); +} + +static void writeTest(HANDLE conout, const char *msg) { + char writeData[256]; + sprintf(writeData, "%s%s\n", g_prefix, msg); + + trace("%sWriting to 0x%I64x: '%s'...", + g_prefix, (long long)conout, msg); + DWORD actual = 0; + BOOL ret = WriteConsoleA(conout, writeData, strlen(writeData), &actual, NULL); + trace("%sWriting to 0x%I64x: '%s'... %s", + g_prefix, (long long)conout, msg, + successOrFail(ret && actual == strlen(writeData))); +} + +static HANDLE startChildInSameConsole(const wchar_t *args, BOOL + bInheritHandles=FALSE) { + wchar_t program[1024]; + wchar_t cmdline[1024]; + GetModuleFileNameW(NULL, program, 1024); + swprintf(cmdline, L"\"%ls\" %ls", program, args); + + STARTUPINFOW sui; + PROCESS_INFORMATION pi; + memset(&sui, 0, sizeof(sui)); + memset(&pi, 0, sizeof(pi)); + sui.cb = sizeof(sui); + + CreateProcessW(program, cmdline, + NULL, NULL, + /*bInheritHandles=*/bInheritHandles, + /*dwCreationFlags=*/0, + NULL, NULL, + &sui, &pi); + + return pi.hProcess; +} + +static HANDLE dup(HANDLE h, HANDLE targetProcess) { + HANDLE h2 = INVALID_HANDLE_VALUE; + BOOL ret = DuplicateHandle( + GetCurrentProcess(), h, + targetProcess, &h2, + 0, TRUE, DUPLICATE_SAME_ACCESS); + trace("dup(0x%I64x) to process 0x%I64x... %s, 0x%I64x", + (long long)h, + (long long)targetProcess, + successOrFail(ret), + (long long)h2); + return h2; +} + +int main(int argc, char *argv[]) { + if (argc == 1) { + startChildProcess(L"parent"); + return 0; + } + + if (!strcmp(argv[1], "parent")) { + g_prefix = "parent: "; + dumpHandles(); + HANDLE hChild = startChildInSameConsole(L"child"); + + // Windows 10. + HANDLE orig1 = GetStdHandle(STD_OUTPUT_HANDLE); + HANDLE new1 = createBuffer(); + + Sleep(2000); + setConsoleActiveScreenBuffer(new1); + + // Handle duplication results to child process in same console: + // - Windows XP: fails + // - Windows 7 Ultimate SP1 32-bit: fails + // - Windows Server 2008 R2 Datacenter SP1 64-bit: fails + // - Windows 8 Enterprise 32-bit: succeeds + // - Windows 10: succeeds + HANDLE orig2 = dup(orig1, GetCurrentProcess()); + HANDLE new2 = dup(new1, GetCurrentProcess()); + + dup(orig1, hChild); + dup(new1, hChild); + + // The writes to orig1/orig2 are invisible. The writes to new1/new2 + // are visible. + writeTest(orig1, "write to orig1"); + writeTest(orig2, "write to orig2"); + writeTest(new1, "write to new1"); + writeTest(new2, "write to new2"); + + Sleep(120000); + return 0; + } + + if (!strcmp(argv[1], "child")) { + g_prefix = "child: "; + dumpHandles(); + Sleep(4000); + for (unsigned int i = 0x1; i <= 0xB0; ++i) { + char msg[256]; + sprintf(msg, "Write to handle 0x%x", i); + HANDLE h = reinterpret_cast(i); + writeTest(h, msg); + } + Sleep(120000); + return 0; + } + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SelectAllTest.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SelectAllTest.cc new file mode 100644 index 00000000..a6c27739 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SelectAllTest.cc @@ -0,0 +1,45 @@ +#define _WIN32_WINNT 0x0501 +#include +#include + +#include "../src/shared/DebugClient.cc" + +const int SC_CONSOLE_MARK = 0xFFF2; +const int SC_CONSOLE_SELECT_ALL = 0xFFF5; + +CALLBACK DWORD pausingThread(LPVOID dummy) +{ + HWND hwnd = GetConsoleWindow(); + while (true) { + SendMessage(hwnd, WM_SYSCOMMAND, SC_CONSOLE_SELECT_ALL, 0); + Sleep(1000); + SendMessage(hwnd, WM_CHAR, 27, 0x00010001); + Sleep(1000); + } +} + +int main() +{ + HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE); + CONSOLE_SCREEN_BUFFER_INFO info; + + GetConsoleScreenBufferInfo(out, &info); + COORD initial = info.dwCursorPosition; + + CreateThread(NULL, 0, + pausingThread, NULL, + 0, NULL); + + for (int i = 0; i < 30; ++i) { + Sleep(100); + GetConsoleScreenBufferInfo(out, &info); + if (memcmp(&info.dwCursorPosition, &initial, sizeof(COORD)) != 0) { + trace("cursor moved to [%d,%d]", + info.dwCursorPosition.X, + info.dwCursorPosition.Y); + } else { + trace("cursor in expected position"); + } + } + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetBufferSize.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetBufferSize.cc new file mode 100644 index 00000000..b50a1f8d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetBufferSize.cc @@ -0,0 +1,32 @@ +#include + +#include +#include + +#include "TestUtil.cc" + +int main(int argc, char *argv[]) { + if (argc != 3) { + printf("Usage: %s x y width height\n", argv[0]); + return 1; + } + + const HANDLE conout = CreateFileW(L"CONOUT$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + ASSERT(conout != INVALID_HANDLE_VALUE); + + COORD size = { + (short)atoi(argv[1]), + (short)atoi(argv[2]), + }; + + BOOL ret = SetConsoleScreenBufferSize(conout, size); + const unsigned lastError = GetLastError(); + const char *const retStr = ret ? "OK" : "failed"; + trace("SetConsoleScreenBufferSize ret: %s (LastError=0x%x)", retStr, lastError); + printf("SetConsoleScreenBufferSize ret: %s (LastError=0x%x)\n", retStr, lastError); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetCursorPos.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetCursorPos.cc new file mode 100644 index 00000000..d20fdbdf --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetCursorPos.cc @@ -0,0 +1,10 @@ +#include + +#include "TestUtil.cc" + +int main(int argc, char *argv[]) { + int col = atoi(argv[1]); + int row = atoi(argv[2]); + setCursorPos(col, row); + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetFont.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetFont.cc new file mode 100644 index 00000000..9bcd4b4c --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetFont.cc @@ -0,0 +1,145 @@ +#include +#include +#include +#include +#include + +#include "TestUtil.cc" + +#define COUNT_OF(array) (sizeof(array) / sizeof((array)[0])) + +// See https://en.wikipedia.org/wiki/List_of_CJK_fonts +const wchar_t kMSGothic[] = { 0xff2d, 0xff33, 0x0020, 0x30b4, 0x30b7, 0x30c3, 0x30af, 0 }; // Japanese +const wchar_t kNSimSun[] = { 0x65b0, 0x5b8b, 0x4f53, 0 }; // Simplified Chinese +const wchar_t kMingLight[] = { 0x7d30, 0x660e, 0x9ad4, 0 }; // Traditional Chinese +const wchar_t kGulimChe[] = { 0xad74, 0xb9bc, 0xccb4, 0 }; // Korean + +int main() { + setlocale(LC_ALL, ""); + wchar_t *cmdline = GetCommandLineW(); + int argc = 0; + wchar_t **argv = CommandLineToArgvW(cmdline, &argc); + const HANDLE conout = openConout(); + + if (argc == 1) { + cprintf(L"Usage:\n"); + cprintf(L" SetFont \n"); + cprintf(L" SetFont options\n"); + cprintf(L"\n"); + cprintf(L"Options for SetCurrentConsoleFontEx:\n"); + cprintf(L" -idx INDEX\n"); + cprintf(L" -w WIDTH\n"); + cprintf(L" -h HEIGHT\n"); + cprintf(L" -family (0xNN|NN)\n"); + cprintf(L" -weight (normal|bold|NNN)\n"); + cprintf(L" -face FACENAME\n"); + cprintf(L" -face-{gothic|simsun|minglight|gulimche) [JP,CN-sim,CN-tra,KR]\n"); + cprintf(L" -tt\n"); + cprintf(L" -vec\n"); + cprintf(L" -vp\n"); + cprintf(L" -dev\n"); + cprintf(L" -roman\n"); + cprintf(L" -swiss\n"); + cprintf(L" -modern\n"); + cprintf(L" -script\n"); + cprintf(L" -decorative\n"); + return 0; + } + + if (isdigit(argv[1][0])) { + int index = _wtoi(argv[1]); + HMODULE kernel32 = LoadLibraryW(L"kernel32.dll"); + FARPROC proc = GetProcAddress(kernel32, "SetConsoleFont"); + if (proc == NULL) { + cprintf(L"Couldn't get address of SetConsoleFont\n"); + } else { + BOOL ret = reinterpret_cast(proc)( + conout, index); + cprintf(L"SetFont returned %d\n", ret); + } + return 0; + } + + CONSOLE_FONT_INFOEX fontex = {0}; + fontex.cbSize = sizeof(fontex); + + for (int i = 1; i < argc; ++i) { + std::wstring arg = argv[i]; + if (i + 1 < argc) { + std::wstring next = argv[i + 1]; + if (arg == L"-idx") { + fontex.nFont = _wtoi(next.c_str()); + ++i; continue; + } else if (arg == L"-w") { + fontex.dwFontSize.X = _wtoi(next.c_str()); + ++i; continue; + } else if (arg == L"-h") { + fontex.dwFontSize.Y = _wtoi(next.c_str()); + ++i; continue; + } else if (arg == L"-weight") { + if (next == L"normal") { + fontex.FontWeight = 400; + } else if (next == L"bold") { + fontex.FontWeight = 700; + } else { + fontex.FontWeight = _wtoi(next.c_str()); + } + ++i; continue; + } else if (arg == L"-face") { + wcsncpy(fontex.FaceName, next.c_str(), COUNT_OF(fontex.FaceName)); + ++i; continue; + } else if (arg == L"-family") { + fontex.FontFamily = strtol(narrowString(next).c_str(), nullptr, 0); + ++i; continue; + } + } + if (arg == L"-tt") { + fontex.FontFamily |= TMPF_TRUETYPE; + } else if (arg == L"-vec") { + fontex.FontFamily |= TMPF_VECTOR; + } else if (arg == L"-vp") { + // Setting the TMPF_FIXED_PITCH bit actually indicates variable + // pitch. + fontex.FontFamily |= TMPF_FIXED_PITCH; + } else if (arg == L"-dev") { + fontex.FontFamily |= TMPF_DEVICE; + } else if (arg == L"-roman") { + fontex.FontFamily = (fontex.FontFamily & ~0xF0) | FF_ROMAN; + } else if (arg == L"-swiss") { + fontex.FontFamily = (fontex.FontFamily & ~0xF0) | FF_SWISS; + } else if (arg == L"-modern") { + fontex.FontFamily = (fontex.FontFamily & ~0xF0) | FF_MODERN; + } else if (arg == L"-script") { + fontex.FontFamily = (fontex.FontFamily & ~0xF0) | FF_SCRIPT; + } else if (arg == L"-decorative") { + fontex.FontFamily = (fontex.FontFamily & ~0xF0) | FF_DECORATIVE; + } else if (arg == L"-face-gothic") { + wcsncpy(fontex.FaceName, kMSGothic, COUNT_OF(fontex.FaceName)); + } else if (arg == L"-face-simsun") { + wcsncpy(fontex.FaceName, kNSimSun, COUNT_OF(fontex.FaceName)); + } else if (arg == L"-face-minglight") { + wcsncpy(fontex.FaceName, kMingLight, COUNT_OF(fontex.FaceName)); + } else if (arg == L"-face-gulimche") { + wcsncpy(fontex.FaceName, kGulimChe, COUNT_OF(fontex.FaceName)); + } else { + cprintf(L"Unrecognized argument: %ls\n", arg.c_str()); + exit(1); + } + } + + cprintf(L"Setting to: nFont=%u dwFontSize=(%d,%d) " + L"FontFamily=0x%x FontWeight=%u " + L"FaceName=\"%ls\"\n", + static_cast(fontex.nFont), + fontex.dwFontSize.X, fontex.dwFontSize.Y, + fontex.FontFamily, fontex.FontWeight, + fontex.FaceName); + + BOOL ret = SetCurrentConsoleFontEx( + conout, + FALSE, + &fontex); + cprintf(L"SetCurrentConsoleFontEx returned %d\n", ret); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetWindowRect.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetWindowRect.cc new file mode 100644 index 00000000..6291dd67 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/SetWindowRect.cc @@ -0,0 +1,36 @@ +#include + +#include +#include + +#include "TestUtil.cc" + +int main(int argc, char *argv[]) { + if (argc != 5) { + printf("Usage: %s x y width height\n", argv[0]); + return 1; + } + + const HANDLE conout = CreateFileW(L"CONOUT$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + ASSERT(conout != INVALID_HANDLE_VALUE); + + SMALL_RECT sr = { + (short)atoi(argv[1]), + (short)atoi(argv[2]), + (short)(atoi(argv[1]) + atoi(argv[3]) - 1), + (short)(atoi(argv[2]) + atoi(argv[4]) - 1), + }; + + trace("Calling SetConsoleWindowInfo with {L=%d,T=%d,R=%d,B=%d}", + sr.Left, sr.Top, sr.Right, sr.Bottom); + BOOL ret = SetConsoleWindowInfo(conout, TRUE, &sr); + const unsigned lastError = GetLastError(); + const char *const retStr = ret ? "OK" : "failed"; + trace("SetConsoleWindowInfo ret: %s (LastError=0x%x)", retStr, lastError); + printf("SetConsoleWindowInfo ret: %s (LastError=0x%x)\n", retStr, lastError); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ShowArgv.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ShowArgv.cc new file mode 100644 index 00000000..29a0f091 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ShowArgv.cc @@ -0,0 +1,12 @@ +// This test program is useful for studying commandline<->argv conversion. + +#include +#include + +int main(int argc, char **argv) +{ + printf("cmdline = [%s]\n", GetCommandLine()); + for (int i = 0; i < argc; ++i) + printf("[%s]\n", argv[i]); + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ShowConsoleInput.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ShowConsoleInput.cc new file mode 100644 index 00000000..75fbfb81 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/ShowConsoleInput.cc @@ -0,0 +1,40 @@ +#include +#include +#include + +int main(int argc, char *argv[]) +{ + static int escCount = 0; + + HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); + while (true) { + DWORD count; + INPUT_RECORD ir; + if (!ReadConsoleInput(hStdin, &ir, 1, &count)) { + printf("ReadConsoleInput failed\n"); + return 1; + } + + if (true) { + DWORD mode; + GetConsoleMode(hStdin, &mode); + SetConsoleMode(hStdin, mode & ~ENABLE_PROCESSED_INPUT); + } + + if (ir.EventType == KEY_EVENT) { + const KEY_EVENT_RECORD &ker = ir.Event.KeyEvent; + printf("%s", ker.bKeyDown ? "dn" : "up"); + printf(" ch="); + if (isprint(ker.uChar.AsciiChar)) + printf("'%c'", ker.uChar.AsciiChar); + printf("%d", ker.uChar.AsciiChar); + printf(" vk=%#x", ker.wVirtualKeyCode); + printf(" scan=%#x", ker.wVirtualScanCode); + printf(" state=%#x", (int)ker.dwControlKeyState); + printf(" repeat=%d", ker.wRepeatCount); + printf("\n"); + if (ker.uChar.AsciiChar == 27 && ++escCount == 6) + break; + } + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Spew.py b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Spew.py new file mode 100644 index 00000000..9d1796af --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Spew.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +i = 0; +while True: + i += 1 + print(i) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/TestUtil.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/TestUtil.cc new file mode 100644 index 00000000..c832a12b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/TestUtil.cc @@ -0,0 +1,172 @@ +// This file is included into test programs using #include + +#include +#include +#include +#include +#include +#include +#include + +#include "../src/shared/DebugClient.h" +#include "../src/shared/TimeMeasurement.h" + +#include "../src/shared/DebugClient.cc" +#include "../src/shared/WinptyAssert.cc" +#include "../src/shared/WinptyException.cc" + +// Launch this test program again, in a new console that we will destroy. +static void startChildProcess(const wchar_t *args) { + wchar_t program[1024]; + wchar_t cmdline[1024]; + GetModuleFileNameW(NULL, program, 1024); + swprintf(cmdline, L"\"%ls\" %ls", program, args); + + STARTUPINFOW sui; + PROCESS_INFORMATION pi; + memset(&sui, 0, sizeof(sui)); + memset(&pi, 0, sizeof(pi)); + sui.cb = sizeof(sui); + + CreateProcessW(program, cmdline, + NULL, NULL, + /*bInheritHandles=*/FALSE, + /*dwCreationFlags=*/CREATE_NEW_CONSOLE, + NULL, NULL, + &sui, &pi); +} + +static void setBufferSize(HANDLE conout, int x, int y) { + COORD size = { static_cast(x), static_cast(y) }; + BOOL success = SetConsoleScreenBufferSize(conout, size); + trace("setBufferSize: (%d,%d), result=%d", x, y, success); +} + +static void setWindowPos(HANDLE conout, int x, int y, int w, int h) { + SMALL_RECT r = { + static_cast(x), static_cast(y), + static_cast(x + w - 1), + static_cast(y + h - 1) + }; + BOOL success = SetConsoleWindowInfo(conout, /*bAbsolute=*/TRUE, &r); + trace("setWindowPos: (%d,%d,%d,%d), result=%d", x, y, w, h, success); +} + +static void setCursorPos(HANDLE conout, int x, int y) { + COORD coord = { static_cast(x), static_cast(y) }; + SetConsoleCursorPosition(conout, coord); +} + +static void setBufferSize(int x, int y) { + setBufferSize(GetStdHandle(STD_OUTPUT_HANDLE), x, y); +} + +static void setWindowPos(int x, int y, int w, int h) { + setWindowPos(GetStdHandle(STD_OUTPUT_HANDLE), x, y, w, h); +} + +static void setCursorPos(int x, int y) { + setCursorPos(GetStdHandle(STD_OUTPUT_HANDLE), x, y); +} + +static void countDown(int sec) { + for (int i = sec; i > 0; --i) { + printf("%d.. ", i); + fflush(stdout); + Sleep(1000); + } + printf("\n"); +} + +static void writeBox(int x, int y, int w, int h, char ch, int attributes=7) { + CHAR_INFO info = { 0 }; + info.Char.AsciiChar = ch; + info.Attributes = attributes; + std::vector buf(w * h, info); + HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + COORD bufSize = { static_cast(w), static_cast(h) }; + COORD bufCoord = { 0, 0 }; + SMALL_RECT writeRegion = { + static_cast(x), + static_cast(y), + static_cast(x + w - 1), + static_cast(y + h - 1) + }; + WriteConsoleOutputA(conout, buf.data(), bufSize, bufCoord, &writeRegion); +} + +static void setChar(int x, int y, char ch, int attributes=7) { + writeBox(x, y, 1, 1, ch, attributes); +} + +static void fillChar(int x, int y, int repeat, char ch) { + COORD coord = { static_cast(x), static_cast(y) }; + DWORD actual = 0; + FillConsoleOutputCharacterA( + GetStdHandle(STD_OUTPUT_HANDLE), + ch, repeat, coord, &actual); +} + +static void repeatChar(int count, char ch) { + for (int i = 0; i < count; ++i) { + putchar(ch); + } + fflush(stdout); +} + +// I don't know why, but wprintf fails to print this face name, +// "MS ゴシック" (aka MS Gothic). It helps to use wprintf instead of printf, and +// it helps to call `setlocale(LC_ALL, "")`, but the Japanese symbols are +// ultimately converted to `?` symbols, even though MS Gothic is able to +// display its own name, and the current code page is 932 (Shift-JIS). +static void cvfprintf(HANDLE conout, const wchar_t *fmt, va_list ap) { + wchar_t buffer[256]; + vswprintf(buffer, 256 - 1, fmt, ap); + buffer[255] = L'\0'; + DWORD actual = 0; + if (!WriteConsoleW(conout, buffer, wcslen(buffer), &actual, NULL)) { + wprintf(L"WriteConsoleW call failed!\n"); + } +} + +static void cfprintf(HANDLE conout, const wchar_t *fmt, ...) { + va_list ap; + va_start(ap, fmt); + cvfprintf(conout, fmt, ap); + va_end(ap); +} + +static void cprintf(const wchar_t *fmt, ...) { + va_list ap; + va_start(ap, fmt); + cvfprintf(GetStdHandle(STD_OUTPUT_HANDLE), fmt, ap); + va_end(ap); +} + +static std::string narrowString(const std::wstring &input) +{ + int mblen = WideCharToMultiByte( + CP_UTF8, 0, + input.data(), input.size(), + NULL, 0, NULL, NULL); + if (mblen <= 0) { + return std::string(); + } + std::vector tmp(mblen); + int mblen2 = WideCharToMultiByte( + CP_UTF8, 0, + input.data(), input.size(), + tmp.data(), tmp.size(), + NULL, NULL); + assert(mblen2 == mblen); + return std::string(tmp.data(), tmp.size()); +} + +HANDLE openConout() { + const HANDLE conout = CreateFileW(L"CONOUT$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + ASSERT(conout != INVALID_HANDLE_VALUE); + return conout; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnicodeDoubleWidthTest.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnicodeDoubleWidthTest.cc new file mode 100644 index 00000000..7210d410 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnicodeDoubleWidthTest.cc @@ -0,0 +1,102 @@ +// Demonstrates how U+30FC is sometimes handled as a single-width character +// when it should be handled as a double-width character. +// +// It only runs on computers where 932 is a valid code page. Set the system +// local to "Japanese (Japan)" to ensure this. +// +// The problem seems to happen when U+30FC is printed in a console using the +// Lucida Console font, and only when that font is at certain sizes. +// + +#include +#include +#include +#include +#include +#include + +#include "TestUtil.cc" + +#define COUNT_OF(x) (sizeof(x) / sizeof((x)[0])) + +static void setFont(const wchar_t *faceName, int pxSize) { + CONSOLE_FONT_INFOEX infoex = {0}; + infoex.cbSize = sizeof(infoex); + infoex.dwFontSize.Y = pxSize; + wcsncpy(infoex.FaceName, faceName, COUNT_OF(infoex.FaceName)); + BOOL ret = SetCurrentConsoleFontEx( + GetStdHandle(STD_OUTPUT_HANDLE), FALSE, &infoex); + assert(ret); +} + +static bool performTest(const wchar_t testChar) { + const HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + + SetConsoleTextAttribute(conout, 7); + + system("cls"); + DWORD actual = 0; + BOOL ret = WriteConsoleW(conout, &testChar, 1, &actual, NULL); + assert(ret && actual == 1); + + CHAR_INFO verify[2]; + COORD bufSize = {2, 1}; + COORD bufCoord = {0, 0}; + const SMALL_RECT readRegion = {0, 0, 1, 0}; + SMALL_RECT actualRegion = readRegion; + ret = ReadConsoleOutputW(conout, verify, bufSize, bufCoord, &actualRegion); + assert(ret && !memcmp(&readRegion, &actualRegion, sizeof(readRegion))); + assert(verify[0].Char.UnicodeChar == testChar); + + if (verify[1].Char.UnicodeChar == testChar) { + // Typical double-width behavior with a TrueType font. Pass. + assert(verify[0].Attributes == 0x107); + assert(verify[1].Attributes == 0x207); + return true; + } else if (verify[1].Char.UnicodeChar == 0) { + // Typical double-width behavior with a Raster Font. Pass. + assert(verify[0].Attributes == 7); + assert(verify[1].Attributes == 0); + return true; + } else if (verify[1].Char.UnicodeChar == L' ') { + // Single-width behavior. Fail. + assert(verify[0].Attributes == 7); + assert(verify[1].Attributes == 7); + return false; + } else { + // Unexpected output. + assert(false); + } +} + +int main(int argc, char *argv[]) { + setlocale(LC_ALL, ""); + if (argc == 1) { + startChildProcess(L"CHILD"); + return 0; + } + + assert(SetConsoleCP(932)); + assert(SetConsoleOutputCP(932)); + + const wchar_t testChar = 0x30FC; + const wchar_t *const faceNames[] = { + L"Lucida Console", + L"Consolas", + L"MS ゴシック", + }; + + trace("Test started"); + + for (auto faceName : faceNames) { + for (int px = 1; px <= 50; ++px) { + setFont(faceName, px); + if (!performTest(testChar)) { + trace("FAILURE: %s %dpx", narrowString(faceName).c_str(), px); + } + } + } + + trace("Test complete"); + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnicodeWideTest1.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnicodeWideTest1.cc new file mode 100644 index 00000000..a8d798e7 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnicodeWideTest1.cc @@ -0,0 +1,246 @@ +#include + +#include +#include + +#include "TestUtil.cc" + +#define COUNT_OF(x) (sizeof(x) / sizeof((x)[0])) + + +CHAR_INFO ci(wchar_t ch, WORD attributes) { + CHAR_INFO ret; + ret.Char.UnicodeChar = ch; + ret.Attributes = attributes; + return ret; +} + +CHAR_INFO ci(wchar_t ch) { + return ci(ch, 7); +} + +CHAR_INFO ci() { + return ci(L' '); +} + +bool operator==(SMALL_RECT x, SMALL_RECT y) { + return !memcmp(&x, &y, sizeof(x)); +} + +SMALL_RECT sr(COORD pt, COORD size) { + return { + pt.X, pt.Y, + static_cast(pt.X + size.X - 1), + static_cast(pt.Y + size.Y - 1) + }; +} + +static void set( + const COORD pt, + const COORD size, + const std::vector &data) { + assert(data.size() == size.X * size.Y); + SMALL_RECT writeRegion = sr(pt, size); + BOOL ret = WriteConsoleOutputW( + GetStdHandle(STD_OUTPUT_HANDLE), + data.data(), size, {0, 0}, &writeRegion); + assert(ret && writeRegion == sr(pt, size)); +} + +static void set( + const COORD pt, + const std::vector &data) { + set(pt, {static_cast(data.size()), 1}, data); +} + +static void writeAttrsAt( + const COORD pt, + const std::vector &data) { + DWORD actual = 0; + BOOL ret = WriteConsoleOutputAttribute( + GetStdHandle(STD_OUTPUT_HANDLE), + data.data(), data.size(), pt, &actual); + assert(ret && actual == data.size()); +} + +static void writeCharsAt( + const COORD pt, + const std::vector &data) { + DWORD actual = 0; + BOOL ret = WriteConsoleOutputCharacterW( + GetStdHandle(STD_OUTPUT_HANDLE), + data.data(), data.size(), pt, &actual); + assert(ret && actual == data.size()); +} + +static void writeChars( + const std::vector &data) { + DWORD actual = 0; + BOOL ret = WriteConsoleW( + GetStdHandle(STD_OUTPUT_HANDLE), + data.data(), data.size(), &actual, NULL); + assert(ret && actual == data.size()); +} + +std::vector get( + const COORD pt, + const COORD size) { + std::vector data(size.X * size.Y); + SMALL_RECT readRegion = sr(pt, size); + BOOL ret = ReadConsoleOutputW( + GetStdHandle(STD_OUTPUT_HANDLE), + data.data(), size, {0, 0}, &readRegion); + assert(ret && readRegion == sr(pt, size)); + return data; +} + +std::vector readCharsAt( + const COORD pt, + int size) { + std::vector data(size); + DWORD actual = 0; + BOOL ret = ReadConsoleOutputCharacterW( + GetStdHandle(STD_OUTPUT_HANDLE), + data.data(), data.size(), pt, &actual); + assert(ret); + data.resize(actual); // With double-width chars, we can read fewer than `size`. + return data; +} + +static void dump(const COORD pt, const COORD size) { + for (CHAR_INFO ci : get(pt, size)) { + printf("%04X %04X\n", ci.Char.UnicodeChar, ci.Attributes); + } +} + +static void dumpCharsAt(const COORD pt, int size) { + for (wchar_t ch : readCharsAt(pt, size)) { + printf("%04X\n", ch); + } +} + +static COORD getCursorPos() { + CONSOLE_SCREEN_BUFFER_INFO info = { sizeof(info) }; + assert(GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &info)); + return info.dwCursorPosition; +} + +static void test1() { + // We write "䀀䀀", then write "䀁" in the middle of the two. The second + // write turns the first and last cells into spaces. The LEADING/TRAILING + // flags retain consistency. + printf("test1 - overlap full-width char with full-width char\n"); + writeCharsAt({1,0}, {0x4000, 0x4000}); + dump({0,0}, {6,1}); + printf("\n"); + writeCharsAt({2,0}, {0x4001}); + dump({0,0}, {6,1}); + printf("\n"); +} + +static void test2() { + // Like `test1`, but use a lower-level API to do the write. Consistency is + // preserved here too -- the first and last cells are replaced with spaces. + printf("test2 - overlap full-width char with full-width char (lowlevel)\n"); + writeCharsAt({1,0}, {0x4000, 0x4000}); + dump({0,0}, {6,1}); + printf("\n"); + set({2,0}, {ci(0x4001,0x107), ci(0x4001,0x207)}); + dump({0,0}, {6,1}); + printf("\n"); +} + +static void test3() { + // However, the lower-level API can break the LEADING/TRAILING invariant + // explicitly: + printf("test3 - explicitly violate LEADING/TRAILING using lowlevel API\n"); + set({1,0}, { + ci(0x4000, 0x207), + ci(0x4001, 0x107), + ci(0x3044, 7), + ci(L'X', 0x107), + ci(L'X', 0x207), + }); + dump({0,0}, {7,1}); +} + +static void test4() { + // It is possible for the two cells of a double-width character to have two + // colors. + printf("test4 - use lowlevel to assign two colors to one full-width char\n"); + set({0,0}, { + ci(0x4000, 0x142), + ci(0x4000, 0x224), + }); + dump({0,0}, {2,1}); +} + +static void test5() { + // WriteConsoleOutputAttribute doesn't seem to affect the LEADING/TRAILING + // flags. + printf("test5 - WriteConsoleOutputAttribute cannot affect LEADING/TRAILING\n"); + + // Trying to clear the flags doesn't work... + writeCharsAt({0,0}, {0x4000}); + dump({0,0}, {2,1}); + writeAttrsAt({0,0}, {0x42, 0x24}); + printf("\n"); + dump({0,0}, {2,1}); + + // ... and trying to add them also doesn't work. + writeCharsAt({0,1}, {'A', ' '}); + writeAttrsAt({0,1}, {0x107, 0x207}); + printf("\n"); + dump({0,1}, {2,1}); +} + +static void test6() { + // The cursor position may be on either cell of a double-width character. + // Visually, the cursor appears under both cells, regardless of which + // specific one has the cursor. + printf("test6 - cursor can be either left or right cell of full-width char\n"); + + writeCharsAt({2,1}, {0x4000}); + + setCursorPos(2, 1); + auto pos1 = getCursorPos(); + Sleep(1000); + + setCursorPos(3, 1); + auto pos2 = getCursorPos(); + Sleep(1000); + + setCursorPos(0, 15); + printf("%d,%d\n", pos1.X, pos1.Y); + printf("%d,%d\n", pos2.X, pos2.Y); +} + +static void runTest(void (&test)()) { + system("cls"); + setCursorPos(0, 14); + test(); + system("pause"); +} + +int main(int argc, char *argv[]) { + if (argc == 1) { + startChildProcess(L"CHILD"); + return 0; + } + + setWindowPos(0, 0, 1, 1); + setBufferSize(80, 40); + setWindowPos(0, 0, 80, 40); + + auto cp = GetConsoleOutputCP(); + assert(cp == 932 || cp == 936 || cp == 949 || cp == 950); + + runTest(test1); + runTest(test2); + runTest(test3); + runTest(test4); + runTest(test5); + runTest(test6); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnicodeWideTest2.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnicodeWideTest2.cc new file mode 100644 index 00000000..05f80f70 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnicodeWideTest2.cc @@ -0,0 +1,130 @@ +// +// Test half-width vs full-width characters. +// + +#include +#include +#include +#include + +#include "TestUtil.cc" + +static void writeChars(const wchar_t *text) { + wcslen(text); + const int len = wcslen(text); + DWORD actual = 0; + BOOL ret = WriteConsoleW( + GetStdHandle(STD_OUTPUT_HANDLE), + text, len, &actual, NULL); + trace("writeChars: ret=%d, actual=%lld", ret, (long long)actual); +} + +static void dumpChars(int x, int y, int w, int h) { + BOOL ret; + const COORD bufSize = {w, h}; + const COORD bufCoord = {0, 0}; + const SMALL_RECT topLeft = {x, y, x + w - 1, y + h - 1}; + CHAR_INFO mbcsData[w * h]; + CHAR_INFO unicodeData[w * h]; + SMALL_RECT readRegion; + readRegion = topLeft; + ret = ReadConsoleOutputW(GetStdHandle(STD_OUTPUT_HANDLE), unicodeData, + bufSize, bufCoord, &readRegion); + assert(ret); + readRegion = topLeft; + ret = ReadConsoleOutputA(GetStdHandle(STD_OUTPUT_HANDLE), mbcsData, + bufSize, bufCoord, &readRegion); + assert(ret); + + printf("\n"); + for (int i = 0; i < w * h; ++i) { + printf("(%02d,%02d) CHAR: %04x %4x -- %02x %4x\n", + x + i % w, y + i / w, + (unsigned short)unicodeData[i].Char.UnicodeChar, + (unsigned short)unicodeData[i].Attributes, + (unsigned char)mbcsData[i].Char.AsciiChar, + (unsigned short)mbcsData[i].Attributes); + } +} + +int main(int argc, char *argv[]) { + system("cls"); + setWindowPos(0, 0, 1, 1); + setBufferSize(80, 38); + setWindowPos(0, 0, 80, 38); + + // Write text. + const wchar_t text1[] = { + 0x3044, // U+3044 (HIRAGANA LETTER I) + 0x2014, // U+2014 (EM DASH) + 0x3044, // U+3044 (HIRAGANA LETTER I) + 0xFF2D, // U+FF2D (FULLWIDTH LATIN CAPITAL LETTER M) + 0x30FC, // U+30FC (KATAKANA-HIRAGANA PROLONGED SOUND MARK) + 0x0031, // U+3031 (DIGIT ONE) + 0x2014, // U+2014 (EM DASH) + 0x0032, // U+0032 (DIGIT TWO) + 0x005C, // U+005C (REVERSE SOLIDUS) + 0x3044, // U+3044 (HIRAGANA LETTER I) + 0 + }; + setCursorPos(0, 0); + writeChars(text1); + + setCursorPos(78, 1); + writeChars(L"<>"); + + const wchar_t text2[] = { + 0x0032, // U+3032 (DIGIT TWO) + 0x3044, // U+3044 (HIRAGANA LETTER I) + 0, + }; + setCursorPos(78, 1); + writeChars(text2); + + system("pause"); + + dumpChars(0, 0, 17, 1); + dumpChars(2, 0, 2, 1); + dumpChars(2, 0, 1, 1); + dumpChars(3, 0, 1, 1); + dumpChars(78, 1, 2, 1); + dumpChars(0, 2, 2, 1); + + system("pause"); + system("cls"); + + const wchar_t text3[] = { + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 1 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 2 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 3 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 4 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 5 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 6 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 7 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 8 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 9 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 10 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 11 + 0x30FC, 0x30FC, 0x30FC, 0xFF2D, // 12 + L'\r', '\n', + L'\r', '\n', + 0 + }; + writeChars(text3); + system("pause"); + { + const COORD bufSize = {80, 2}; + const COORD bufCoord = {0, 0}; + SMALL_RECT readRegion = {0, 0, 79, 1}; + CHAR_INFO unicodeData[160]; + BOOL ret = ReadConsoleOutputW(GetStdHandle(STD_OUTPUT_HANDLE), unicodeData, + bufSize, bufCoord, &readRegion); + assert(ret); + for (int i = 0; i < 96; ++i) { + printf("%04x ", unicodeData[i].Char.UnicodeChar); + } + printf("\n"); + } + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnixEcho.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnixEcho.cc new file mode 100644 index 00000000..372e0451 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/UnixEcho.cc @@ -0,0 +1,89 @@ +/* + * Unix test code that puts the terminal into raw mode, then echos typed + * characters to stdout. Derived from sample code in the Stevens book, posted + * online at http://www.lafn.org/~dave/linux/terminalIO.html. + */ + +#include +#include +#include +#include +#include "FormatChar.h" + +static struct termios save_termios; +static int term_saved; + +/* RAW! mode */ +int tty_raw(int fd) +{ + struct termios buf; + + if (tcgetattr(fd, &save_termios) < 0) /* get the original state */ + return -1; + + buf = save_termios; + + /* echo off, canonical mode off, extended input + processing off, signal chars off */ + buf.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); + + /* no SIGINT on BREAK, CR-to-NL off, input parity + check off, don't strip the 8th bit on input, + ouput flow control off */ + buf.c_iflag &= ~(BRKINT | ICRNL | ISTRIP | IXON); + + /* clear size bits, parity checking off */ + buf.c_cflag &= ~(CSIZE | PARENB); + + /* set 8 bits/char */ + buf.c_cflag |= CS8; + + /* output processing off */ + buf.c_oflag &= ~(OPOST); + + buf.c_cc[VMIN] = 1; /* 1 byte at a time */ + buf.c_cc[VTIME] = 0; /* no timer on input */ + + if (tcsetattr(fd, TCSAFLUSH, &buf) < 0) + return -1; + + term_saved = 1; + + return 0; +} + + +/* set it to normal! */ +int tty_reset(int fd) +{ + if (term_saved) + if (tcsetattr(fd, TCSAFLUSH, &save_termios) < 0) + return -1; + + return 0; +} + + +int main() +{ + tty_raw(0); + + int count = 0; + while (true) { + char ch; + char buf[16]; + int actual = read(0, &ch, 1); + if (actual != 1) { + perror("read error"); + break; + } + formatChar(buf, ch); + fputs(buf, stdout); + fflush(stdout); + if (ch == 3) // Ctrl-C + break; + } + + tty_reset(0); + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Utf16Echo.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Utf16Echo.cc new file mode 100644 index 00000000..ef5f302d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Utf16Echo.cc @@ -0,0 +1,46 @@ +#include +#include +#include +#include + +#include +#include + +int main(int argc, char *argv[]) { + system("cls"); + + if (argc == 1) { + printf("Usage: %s hhhh\n", argv[0]); + return 0; + } + + std::wstring dataToWrite; + for (int i = 1; i < argc; ++i) { + wchar_t ch = strtol(argv[i], NULL, 16); + dataToWrite.push_back(ch); + } + + DWORD actual = 0; + BOOL ret = WriteConsoleW( + GetStdHandle(STD_OUTPUT_HANDLE), + dataToWrite.data(), dataToWrite.size(), &actual, NULL); + assert(ret && actual == dataToWrite.size()); + + // Read it back. + std::vector readBuffer(dataToWrite.size() * 2); + COORD bufSize = {static_cast(readBuffer.size()), 1}; + COORD bufCoord = {0, 0}; + SMALL_RECT topLeft = {0, 0, static_cast(readBuffer.size() - 1), 0}; + ret = ReadConsoleOutputW( + GetStdHandle(STD_OUTPUT_HANDLE), readBuffer.data(), + bufSize, bufCoord, &topLeft); + assert(ret); + + printf("\n"); + for (int i = 0; i < readBuffer.size(); ++i) { + printf("CHAR: %04x %04x\n", + readBuffer[i].Char.UnicodeChar, + readBuffer[i].Attributes); + } + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/VeryLargeRead.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/VeryLargeRead.cc new file mode 100644 index 00000000..58f08970 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/VeryLargeRead.cc @@ -0,0 +1,122 @@ +// +// 2015-09-25 +// I measured these limits on the size of a single ReadConsoleOutputW call. +// The limit seems to more-or-less disppear with Windows 8, which is the first +// OS to stop using ALPCs for console I/O. My guess is that the new I/O +// method does not use the 64KiB shared memory buffer that the ALPC method +// uses. +// +// I'm guessing the remaining difference between Windows 8/8.1 and Windows 10 +// might be related to the 32-vs-64-bitness. +// +// Client OSs +// +// Windows XP 32-bit VM ==> up to 13304 characters +// - 13304x1 works, but 13305x1 fails instantly +// Windows 7 32-bit VM ==> between 16-17 thousand characters +// - 16000x1 works, 17000x1 fails instantly +// - 163x100 *crashes* conhost.exe but leaves VeryLargeRead.exe running +// Windows 8 32-bit VM ==> between 240-250 million characters +// - 10000x24000 works, but 10000x25000 does not +// Windows 8.1 32-bit VM ==> between 240-250 million characters +// - 10000x24000 works, but 10000x25000 does not +// Windows 10 64-bit VM ==> no limit (tested to 576 million characters) +// - 24000x24000 works +// - `ver` reports [Version 10.0.10240], conhost.exe and ConhostV1.dll are +// 10.0.10240.16384 for file and product version. ConhostV2.dll is +// 10.0.10240.16391 for file and product version. +// +// Server OSs +// +// Windows Server 2008 64-bit VM ==> 14300-14400 characters +// - 14300x1 works, 14400x1 fails instantly +// - This OS does not have conhost.exe. +// - `ver` reports [Version 6.0.6002] +// Windows Server 2008 R2 64-bit VM ==> 15600-15700 characters +// - 15600x1 works, 15700x1 fails instantly +// - This OS has conhost.exe, and procexp.exe reveals console ALPC ports in +// use in conhost.exe. +// - `ver` reports [Version 6.1.7601], conhost.exe is 6.1.7601.23153 for file +// and product version. +// Windows Server 2012 64-bit VM ==> at least 100 million characters +// - 10000x10000 works (VM had only 1GiB of RAM, so I skipped larger tests) +// - This OS has Windows 8's task manager and procexp.exe reveals the same +// lack of ALPC ports and the same \Device\ConDrv\* files as Windows 8. +// - `ver` reports [Version 6.2.9200], conhost.exe is 6.2.9200.16579 for file +// and product version. +// +// To summarize: +// +// client-OS server-OS notes +// --------------------------------------------------------------------------- +// XP Server 2008 CSRSS, small reads +// 7 Server 2008 R2 ALPC-to-conhost, small reads +// 8, 8.1 Server 2012 new I/O interface, large reads allowed +// 10 enhanced console w/rewrapping +// +// (Presumably, Win2K, Vista, and Win2K3 behave the same as XP. conhost.exe +// was announced as a Win7 feature.) +// + +#include +#include +#include + +#include "TestUtil.cc" + +int main(int argc, char *argv[]) { + long long width = 9000; + long long height = 9000; + + assert(argc >= 1); + if (argc == 4) { + width = atoi(argv[2]); + height = atoi(argv[3]); + } else { + if (argc == 3) { + width = atoi(argv[1]); + height = atoi(argv[2]); + } + wchar_t args[1024]; + swprintf(args, 1024, L"CHILD %lld %lld", width, height); + startChildProcess(args); + return 0; + } + + const HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + + setWindowPos(0, 0, 1, 1); + setBufferSize(width, height); + setWindowPos(0, 0, std::min(80LL, width), std::min(50LL, height)); + + setCursorPos(0, 0); + printf("A"); + fflush(stdout); + setCursorPos(width - 2, height - 1); + printf("B"); + fflush(stdout); + + trace("sizeof(CHAR_INFO) = %d", (int)sizeof(CHAR_INFO)); + + trace("Allocating buffer..."); + CHAR_INFO *buffer = new CHAR_INFO[width * height]; + assert(buffer != NULL); + memset(&buffer[0], 0, sizeof(CHAR_INFO)); + memset(&buffer[width * height - 2], 0, sizeof(CHAR_INFO)); + + COORD bufSize = { width, height }; + COORD bufCoord = { 0, 0 }; + SMALL_RECT readRegion = { 0, 0, width - 1, height - 1 }; + trace("ReadConsoleOutputW: calling..."); + BOOL success = ReadConsoleOutputW(conout, buffer, bufSize, bufCoord, &readRegion); + trace("ReadConsoleOutputW: success=%d", success); + + assert(buffer[0].Char.UnicodeChar == L'A'); + assert(buffer[width * height - 2].Char.UnicodeChar == L'B'); + trace("Top-left and bottom-right characters read successfully!"); + + Sleep(30000); + + delete [] buffer; + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/VkEscapeTest.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/VkEscapeTest.cc new file mode 100644 index 00000000..97bf59f9 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/VkEscapeTest.cc @@ -0,0 +1,56 @@ +/* + * Sending VK_PAUSE to the console window almost works as a mechanism for + * pausing it, but it doesn't because the console could turn off the + * ENABLE_LINE_INPUT console mode flag. + */ + +#define _WIN32_WINNT 0x0501 +#include +#include +#include + +CALLBACK DWORD pausingThread(LPVOID dummy) +{ + if (1) { + Sleep(1000); + HWND hwnd = GetConsoleWindow(); + SendMessage(hwnd, WM_KEYDOWN, VK_PAUSE, 1); + Sleep(1000); + SendMessage(hwnd, WM_KEYDOWN, VK_ESCAPE, 1); + } + + if (0) { + INPUT_RECORD ir; + memset(&ir, 0, sizeof(ir)); + ir.EventType = KEY_EVENT; + ir.Event.KeyEvent.bKeyDown = TRUE; + ir.Event.KeyEvent.wVirtualKeyCode = VK_PAUSE; + ir.Event.KeyEvent.wRepeatCount = 1; + } + + return 0; +} + +int main() +{ + HANDLE hin = GetStdHandle(STD_INPUT_HANDLE); + HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); + COORD c = { 0, 0 }; + + DWORD mode; + GetConsoleMode(hin, &mode); + SetConsoleMode(hin, mode & + ~(ENABLE_LINE_INPUT)); + + CreateThread(NULL, 0, + pausingThread, NULL, + 0, NULL); + + int i = 0; + while (true) { + Sleep(100); + printf("%d\n", ++i); + } + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win10ResizeWhileFrozen.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win10ResizeWhileFrozen.cc new file mode 100644 index 00000000..82feaf3c --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win10ResizeWhileFrozen.cc @@ -0,0 +1,52 @@ +/* + * Demonstrates a conhost hang that occurs when widening the console buffer + * while selection is in progress. The problem affects the new Windows 10 + * console, not the "legacy" console mode that Windows 10 also includes. + * + * First tested with: + * - Windows 10.0.10240 + * - conhost.exe version 10.0.10240.16384 + * - ConhostV1.dll version 10.0.10240.16384 + * - ConhostV2.dll version 10.0.10240.16391 + */ + +#include +#include +#include +#include + +#include "TestUtil.cc" + +const int SC_CONSOLE_MARK = 0xFFF2; +const int SC_CONSOLE_SELECT_ALL = 0xFFF5; + +int main(int argc, char *argv[]) { + if (argc == 1) { + startChildProcess(L"CHILD"); + return 0; + } + + setWindowPos(0, 0, 1, 1); + setBufferSize(80, 25); + setWindowPos(0, 0, 80, 25); + + countDown(5); + + SendMessage(GetConsoleWindow(), WM_SYSCOMMAND, SC_CONSOLE_SELECT_ALL, 0); + Sleep(2000); + + // This API call does not return. In the console window, the "Select All" + // operation appears to end. The console window becomes non-responsive, + // and the conhost.exe process must be killed from the Task Manager. + // (Killing this test program or closing the console window is not + // sufficient.) + // + // The same hang occurs whether line resizing is off or on. It happens + // with both "Mark" and "Select All". Calling setBufferSize with the + // existing buffer size does not hang, but calling it with only a changed + // buffer height *does* hang. Calling setWindowPos does not hang. + setBufferSize(120, 25); + + printf("Done...\n"); + Sleep(2000); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win10WrapTest1.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win10WrapTest1.cc new file mode 100644 index 00000000..645fa95d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win10WrapTest1.cc @@ -0,0 +1,57 @@ +/* + * Demonstrates some wrapping behaviors of the new Windows 10 console. + */ + +#include +#include +#include + +#include "TestUtil.cc" + +int main(int argc, char *argv[]) { + if (argc == 1) { + startChildProcess(L"CHILD"); + return 0; + } + + setWindowPos(0, 0, 1, 1); + setBufferSize(40, 20); + setWindowPos(0, 0, 40, 20); + + system("cls"); + + repeatChar(39, 'A'); repeatChar(1, ' '); + repeatChar(39, 'B'); repeatChar(1, ' '); + printf("\n"); + + repeatChar(39, 'C'); repeatChar(1, ' '); + repeatChar(39, 'D'); repeatChar(1, ' '); + printf("\n"); + + repeatChar(40, 'E'); + repeatChar(40, 'F'); + printf("\n"); + + repeatChar(39, 'G'); repeatChar(1, ' '); + repeatChar(39, 'H'); repeatChar(1, ' '); + printf("\n"); + + Sleep(2000); + + setChar(39, 0, '*', 0x24); + setChar(39, 1, '*', 0x24); + + setChar(39, 3, ' ', 0x24); + setChar(39, 4, ' ', 0x24); + + setChar(38, 6, ' ', 0x24); + setChar(38, 7, ' ', 0x24); + + Sleep(2000); + setWindowPos(0, 0, 35, 20); + setBufferSize(35, 20); + trace("DONE"); + + printf("Sleeping forever...\n"); + while(true) { Sleep(1000); } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win10WrapTest2.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win10WrapTest2.cc new file mode 100644 index 00000000..50615fc8 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win10WrapTest2.cc @@ -0,0 +1,30 @@ +#include + +#include "TestUtil.cc" + +int main(int argc, char *argv[]) { + if (argc == 1) { + startChildProcess(L"CHILD"); + return 0; + } + + const int WIDTH = 25; + + setWindowPos(0, 0, 1, 1); + setBufferSize(WIDTH, 40); + setWindowPos(0, 0, WIDTH, 20); + + system("cls"); + + for (int i = 0; i < 100; ++i) { + printf("FOO(%d)\n", i); + } + + repeatChar(5, '\n'); + repeatChar(WIDTH * 5, '.'); + repeatChar(10, '\n'); + setWindowPos(0, 20, WIDTH, 20); + writeBox(0, 5, 1, 10, '|'); + + Sleep(120000); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Echo1.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Echo1.cc new file mode 100644 index 00000000..06fc79f7 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Echo1.cc @@ -0,0 +1,26 @@ +/* + * A Win32 program that reads raw console input with ReadFile and echos + * it to stdout. + */ + +#include +#include +#include + +int main() +{ + int count = 0; + HANDLE hStdIn = GetStdHandle(STD_INPUT_HANDLE); + HANDLE hStdOut = GetStdHandle(STD_OUTPUT_HANDLE); + SetConsoleMode(hStdIn, 0); + + while (true) { + DWORD actual; + char ch; + ReadFile(hStdIn, &ch, 1, &actual, NULL); + printf("%02x ", ch); + if (++count == 50) + break; + } + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Echo2.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Echo2.cc new file mode 100644 index 00000000..b2ea2ad1 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Echo2.cc @@ -0,0 +1,19 @@ +/* + * A Win32 program that reads raw console input with getch and echos + * it to stdout. + */ + +#include +#include + +int main() +{ + int count = 0; + while (true) { + int ch = getch(); + printf("%02x ", ch); + if (++count == 50) + break; + } + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Test1.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Test1.cc new file mode 100644 index 00000000..a40d318a --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Test1.cc @@ -0,0 +1,46 @@ +#define _WIN32_WINNT 0x0501 +#include "../src/shared/DebugClient.cc" +#include +#include + +const int SC_CONSOLE_MARK = 0xFFF2; + +CALLBACK DWORD writerThread(void*) +{ + while (true) { + Sleep(1000); + trace("writing"); + printf("X\n"); + trace("written"); + } +} + +int main() +{ + CreateThread(NULL, 0, writerThread, NULL, 0, NULL); + trace("marking console"); + HWND hwnd = GetConsoleWindow(); + PostMessage(hwnd, WM_SYSCOMMAND, SC_CONSOLE_MARK, 0); + + Sleep(2000); + + trace("reading output"); + CHAR_INFO buf[1]; + COORD bufSize = { 1, 1 }; + COORD zeroCoord = { 0, 0 }; + SMALL_RECT readRect = { 0, 0, 0, 0 }; + ReadConsoleOutput(GetStdHandle(STD_OUTPUT_HANDLE), + buf, + bufSize, + zeroCoord, + &readRect); + trace("done reading output"); + + Sleep(2000); + + PostMessage(hwnd, WM_CHAR, 27, 0x00010001); + + Sleep(1100); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Test2.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Test2.cc new file mode 100644 index 00000000..2777bad4 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Test2.cc @@ -0,0 +1,70 @@ +/* + * This test demonstrates that putting a console into selection mode does not + * block the low-level console APIs, even though it blocks WriteFile. + */ + +#define _WIN32_WINNT 0x0501 +#include "../src/shared/DebugClient.cc" +#include +#include + +const int SC_CONSOLE_MARK = 0xFFF2; + +CALLBACK DWORD writerThread(void*) +{ + CHAR_INFO xChar, fillChar; + memset(&xChar, 0, sizeof(xChar)); + xChar.Char.AsciiChar = 'X'; + xChar.Attributes = 7; + memset(&fillChar, 0, sizeof(fillChar)); + fillChar.Char.AsciiChar = ' '; + fillChar.Attributes = 7; + COORD oneCoord = { 1, 1 }; + COORD zeroCoord = { 0, 0 }; + + while (true) { + SMALL_RECT writeRegion = { 5, 5, 5, 5 }; + WriteConsoleOutput(GetStdHandle(STD_OUTPUT_HANDLE), + &xChar, oneCoord, + zeroCoord, + &writeRegion); + Sleep(500); + SMALL_RECT scrollRect = { 1, 1, 20, 20 }; + COORD destCoord = { 0, 0 }; + ScrollConsoleScreenBuffer(GetStdHandle(STD_OUTPUT_HANDLE), + &scrollRect, + NULL, + destCoord, + &fillChar); + } +} + +int main() +{ + CreateThread(NULL, 0, writerThread, NULL, 0, NULL); + trace("marking console"); + HWND hwnd = GetConsoleWindow(); + PostMessage(hwnd, WM_SYSCOMMAND, SC_CONSOLE_MARK, 0); + + Sleep(2000); + + trace("reading output"); + CHAR_INFO buf[1]; + COORD bufSize = { 1, 1 }; + COORD zeroCoord = { 0, 0 }; + SMALL_RECT readRect = { 0, 0, 0, 0 }; + ReadConsoleOutput(GetStdHandle(STD_OUTPUT_HANDLE), + buf, + bufSize, + zeroCoord, + &readRect); + trace("done reading output"); + + Sleep(2000); + + PostMessage(hwnd, WM_CHAR, 27, 0x00010001); + + Sleep(1100); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Test3.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Test3.cc new file mode 100644 index 00000000..1fb92aff --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Test3.cc @@ -0,0 +1,78 @@ +/* + * Creates a window station and starts a process under it. The new process + * also gets a new console. + */ + +#include +#include +#include + +int main() +{ + BOOL success; + + SECURITY_ATTRIBUTES sa; + memset(&sa, 0, sizeof(sa)); + sa.bInheritHandle = TRUE; + + HWINSTA originalStation = GetProcessWindowStation(); + printf("originalStation == 0x%x\n", originalStation); + HWINSTA station = CreateWindowStation(NULL, + 0, + WINSTA_ALL_ACCESS, + &sa); + printf("station == 0x%x\n", station); + if (!SetProcessWindowStation(station)) + printf("SetWindowStation failed!\n"); + HDESK desktop = CreateDesktop("Default", NULL, NULL, + /*dwFlags=*/0, GENERIC_ALL, + &sa); + printf("desktop = 0x%x\n", desktop); + + char stationName[256]; + stationName[0] = '\0'; + success = GetUserObjectInformation(station, UOI_NAME, + stationName, sizeof(stationName), + NULL); + printf("stationName = [%s]\n", stationName); + + char startupDesktop[256]; + sprintf(startupDesktop, "%s\\Default", stationName); + + STARTUPINFO sui; + PROCESS_INFORMATION pi; + memset(&sui, 0, sizeof(sui)); + memset(&pi, 0, sizeof(pi)); + sui.cb = sizeof(STARTUPINFO); + sui.lpDesktop = startupDesktop; + + // Start a cmd subprocess, and have it start its own cmd subprocess. + // Both subprocesses will connect to the same non-interactive window + // station. + + const char program[] = "c:\\windows\\system32\\cmd.exe"; + char cmdline[256]; + sprintf(cmdline, "%s /c cmd", program); + success = CreateProcess(program, + cmdline, + NULL, + NULL, + /*bInheritHandles=*/FALSE, + /*dwCreationFlags=*/CREATE_NEW_CONSOLE, + NULL, NULL, + &sui, + &pi); + + printf("pid == %d\n", pi.dwProcessId); + + // This sleep is necessary. We must give the child enough time to + // connect to the specified window station. + Sleep(5000); + + SetProcessWindowStation(originalStation); + CloseWindowStation(station); + CloseDesktop(desktop); + Sleep(5000); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Write1.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Write1.cc new file mode 100644 index 00000000..6e5bf966 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/Win32Write1.cc @@ -0,0 +1,44 @@ +/* + * A Win32 program that scrolls and writes to the console using the ioctl-like + * interface. + */ + +#include +#include + +int main() +{ + HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + + for (int i = 0; i < 80; ++i) { + + CONSOLE_SCREEN_BUFFER_INFO info; + GetConsoleScreenBufferInfo(conout, &info); + + SMALL_RECT src = { 0, 1, info.dwSize.X - 1, info.dwSize.Y - 1 }; + COORD destOrigin = { 0, 0 }; + CHAR_INFO fillCharInfo = { 0 }; + fillCharInfo.Char.AsciiChar = ' '; + fillCharInfo.Attributes = 7; + ScrollConsoleScreenBuffer(conout, + &src, + NULL, + destOrigin, + &fillCharInfo); + + CHAR_INFO buffer = { 0 }; + buffer.Char.AsciiChar = 'X'; + buffer.Attributes = 7; + COORD bufferSize = { 1, 1 }; + COORD bufferCoord = { 0, 0 }; + SMALL_RECT writeRegion = { 0, 0, 0, 0 }; + writeRegion.Left = writeRegion.Right = i; + writeRegion.Top = writeRegion.Bottom = 5; + WriteConsoleOutput(conout, + &buffer, bufferSize, bufferCoord, + &writeRegion); + + Sleep(250); + } + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/WindowsBugCrashReader.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/WindowsBugCrashReader.cc new file mode 100644 index 00000000..e6d9558d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/WindowsBugCrashReader.cc @@ -0,0 +1,27 @@ +// I noticed this on the ConEmu web site: +// +// https://social.msdn.microsoft.com/Forums/en-US/40c8e395-cca9-45c8-b9b8-2fbe6782ac2b/readconsoleoutput-cause-access-violation-writing-location-exception +// https://conemu.github.io/en/MicrosoftBugs.html +// +// In Windows 7, 8, and 8.1, a ReadConsoleOutputW with an out-of-bounds read +// region crashes the application. I have reproduced the problem on Windows 8 +// and 8.1, but not on Windows 7. +// + +#include + +#include "TestUtil.cc" + +int main() { + setWindowPos(0, 0, 1, 1); + setBufferSize(80, 25); + setWindowPos(0, 0, 80, 25); + + const HANDLE conout = openConout(); + static CHAR_INFO lineBuf[80]; + SMALL_RECT readRegion = { 0, 999, 79, 999 }; + const BOOL ret = ReadConsoleOutputW(conout, lineBuf, {80, 1}, {0, 0}, &readRegion); + ASSERT(!ret && "ReadConsoleOutputW should have failed"); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/WriteConsole.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/WriteConsole.cc new file mode 100644 index 00000000..a03670ca --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/WriteConsole.cc @@ -0,0 +1,106 @@ +#include + +#include +#include +#include + +#include +#include + +static std::wstring mbsToWcs(const std::string &s) { + const size_t len = mbstowcs(nullptr, s.c_str(), 0); + if (len == static_cast(-1)) { + assert(false && "mbsToWcs: invalid string"); + } + std::wstring ret; + ret.resize(len); + const size_t len2 = mbstowcs(&ret[0], s.c_str(), len); + assert(len == len2); + return ret; +} + +uint32_t parseHex(wchar_t ch, bool &invalid) { + if (ch >= L'0' && ch <= L'9') { + return ch - L'0'; + } else if (ch >= L'a' && ch <= L'f') { + return ch - L'a' + 10; + } else if (ch >= L'A' && ch <= L'F') { + return ch - L'A' + 10; + } else { + invalid = true; + return 0; + } +} + +int main(int argc, char *argv[]) { + std::vector args; + for (int i = 1; i < argc; ++i) { + args.push_back(mbsToWcs(argv[i])); + } + + std::wstring out; + for (const auto &arg : args) { + if (!out.empty()) { + out.push_back(L' '); + } + for (size_t i = 0; i < arg.size(); ++i) { + wchar_t ch = arg[i]; + wchar_t nch = i + 1 < arg.size() ? arg[i + 1] : L'\0'; + if (ch == L'\\') { + switch (nch) { + case L'a': ch = L'\a'; ++i; break; + case L'b': ch = L'\b'; ++i; break; + case L'e': ch = L'\x1b'; ++i; break; + case L'f': ch = L'\f'; ++i; break; + case L'n': ch = L'\n'; ++i; break; + case L'r': ch = L'\r'; ++i; break; + case L't': ch = L'\t'; ++i; break; + case L'v': ch = L'\v'; ++i; break; + case L'\\': ch = L'\\'; ++i; break; + case L'\'': ch = L'\''; ++i; break; + case L'\"': ch = L'\"'; ++i; break; + case L'\?': ch = L'\?'; ++i; break; + case L'x': + if (i + 3 < arg.size()) { + bool invalid = false; + uint32_t d1 = parseHex(arg[i + 2], invalid); + uint32_t d2 = parseHex(arg[i + 3], invalid); + if (!invalid) { + i += 3; + ch = (d1 << 4) | d2; + } + } + break; + case L'u': + if (i + 5 < arg.size()) { + bool invalid = false; + uint32_t d1 = parseHex(arg[i + 2], invalid); + uint32_t d2 = parseHex(arg[i + 3], invalid); + uint32_t d3 = parseHex(arg[i + 4], invalid); + uint32_t d4 = parseHex(arg[i + 5], invalid); + if (!invalid) { + i += 5; + ch = (d1 << 24) | (d2 << 16) | (d3 << 8) | d4; + } + } + break; + default: break; + } + } + out.push_back(ch); + } + } + + DWORD actual = 0; + if (!WriteConsoleW( + GetStdHandle(STD_OUTPUT_HANDLE), + out.c_str(), + out.size(), + &actual, + nullptr)) { + fprintf(stderr, "WriteConsole failed (is stdout a console?)\n"); + exit(1); + } + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/build32.sh b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/build32.sh new file mode 100644 index 00000000..162993ce --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/build32.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e +name=$1 +name=${name%.} +name=${name%.cc} +name=${name%.exe} +echo Compiling $name.cc to $name.exe +i686-w64-mingw32-g++.exe -static -std=c++11 $name.cc -o $name.exe +i686-w64-mingw32-strip $name.exe diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/build64.sh b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/build64.sh new file mode 100644 index 00000000..67579676 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/build64.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e +name=$1 +name=${name%.} +name=${name%.cc} +name=${name%.exe} +echo Compiling $name.cc to $name.exe +x86_64-w64-mingw32-g++.exe -static -std=c++11 $name.cc -o $name.exe +x86_64-w64-mingw32-strip $name.exe diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/color-test.sh b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/color-test.sh new file mode 100644 index 00000000..065c8094 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/color-test.sh @@ -0,0 +1,212 @@ +#!/bin/bash + +FORE=$1 +BACK=$2 +FILL=$3 + +if [ "$FORE" = "" ]; then + FORE=DefaultFore +fi +if [ "$BACK" = "" ]; then + BACK=DefaultBack +fi + +# To detect color changes, we want a character that fills the whole cell +# if possible. U+2588 is perfect, except that it becomes invisible in the +# original xterm, when bolded. For that terminal, use something else, like +# "#" or "@". +if [ "$FILL" = "" ]; then + FILL="█" +fi + +# SGR (Select Graphic Rendition) +s() { + printf '\033[0m' + while [ "$1" != "" ]; do + printf '\033['"$1"'m' + shift + done +} + +# Print +p() { + echo -n "$@" +} + +# Print with newline +pn() { + echo "$@" +} + +# For practical reasons, sandwich black and white in-between the other colors. +FORE_COLORS="31 30 37 32 33 34 35 36" +BACK_COLORS="41 40 47 42 43 44 45 46" + + + +### Test order of Invert(7) -- it does not matter what order it appears in. + +# The Red color setting here (31) is shadowed by the green setting (32). The +# Reverse flag does not cause (32) to alter the background color immediately; +# instead, the Reverse flag is applied once to determine the final effective +# Fore/Back colors. +s 7 31 32; p " -- Should be: $BACK-on-green -- "; s; pn +s 31 7 32; p " -- Should be: $BACK-on-green -- "; s; pn +s 31 32 7; p " -- Should be: $BACK-on-green -- "; s; pn + +# As above, but for the background color. +s 7 41 42; p " -- Should be: green-on-$FORE -- "; s; pn +s 41 7 42; p " -- Should be: green-on-$FORE -- "; s; pn +s 41 42 7; p " -- Should be: green-on-$FORE -- "; s; pn + +# One last, related test +s 7; p "Invert text"; s 7 1; p " with some words bold"; s; pn; +s 0; p "Normal text"; s 0 1; p " with some words bold"; s; pn; + +pn + + + +### Test effect of Bold(1) on color, with and without Invert(7). + +# The Bold flag does not affect the background color when Reverse is missing. +# There should always be 8 colored boxes. +p " " +for x in $BACK_COLORS; do + s $x; p "-"; s $x 1; p "-" +done +s; pn " Bold should not affect background" + +# On some terminals, Bold affects color, and on some it doesn't. If there +# are only 8 colored boxes, then the next two tests will also show 8 colored +# boxes. If there are 16 boxes, then exactly one of the next two tests will +# also have 16 boxes. +p " " +for x in $FORE_COLORS; do + s $x; p "$FILL"; s $x 1; p "$FILL" +done +s; pn " Does bold affect foreground color?" + +# On some terminals, Bold+Invert highlights the final Background color. +p " " +for x in $FORE_COLORS; do + s $x 7; p "-"; s $x 7 1; p "-" +done +s; pn " Test if Bold+Invert affects background color" + +# On some terminals, Bold+Invert highlights the final Foreground color. +p " " +for x in $BACK_COLORS; do + s $x 7; p "$FILL"; s $x 7 1; p "$FILL" +done +s; pn " Test if Bold+Invert affects foreground color" + +pn + + + +### Test for support of ForeHi and BackHi properties. + +# ForeHi +p " " +for x in $FORE_COLORS; do + hi=$(( $x + 60 )) + s $x; p "$FILL"; s $hi; p "$FILL" +done +s; pn " Test for support of ForeHi colors" +p " " +for x in $FORE_COLORS; do + hi=$(( $x + 60 )) + s $x; p "$FILL"; s $x $hi; p "$FILL" +done +s; pn " Test for support of ForeHi colors (w/compat)" + +# BackHi +p " " +for x in $BACK_COLORS; do + hi=$(( $x + 60 )) + s $x; p "-"; s $hi; p "-" +done +s; pn " Test for support of BackHi colors" +p " " +for x in $BACK_COLORS; do + hi=$(( $x + 60 )) + s $x; p "-"; s $x $hi; p "-" +done +s; pn " Test for support of BackHi colors (w/compat)" + +pn + + + +### Identify the default fore and back colors. + +pn "Match default fore and back colors against 16-color palette" +pn " ==fore== ==back==" +for fore in $FORE_COLORS; do + forehi=$(( $fore + 60 )) + back=$(( $fore + 10 )) + backhi=$(( $back + 60 )) + p " " + s $fore; p "$FILL"; s; p "$FILL"; s $fore; p "$FILL"; s; p " " + s $forehi; p "$FILL"; s; p "$FILL"; s $forehi; p "$FILL"; s; p " " + s $back; p "-"; s; p "-"; s $back; p "-"; s; p " " + s $backhi; p "-"; s; p "-"; s $backhi; p "-"; s; p " " + pn " $fore $forehi $back $backhi" +done + +pn + + + +### Test coloring of rest-of-line. + +# +# When a new line is scrolled in, every cell in the line receives the +# current background color, which can be the default/transparent color. +# + +p "Newline with red background: usually no red -->"; s 41; pn +s; pn "This text is plain, but rest is red if scrolled -->" +s; p " "; s 41; printf '\033[1K'; s; printf '\033[1C'; pn "<-- red Erase-in-Line to beginning" +s; p "red Erase-in-Line to end -->"; s 41; printf '\033[0K'; s; pn +pn + + + +### Moving the cursor around does not change colors of anything. + +pn "Test modifying uncolored lines with a colored SGR:" +pn "aaaa" +pn +pn "____e" +s 31 42; printf '\033[4C\033[3A'; pn "bb" +pn "cccc" +pn "dddd" +s; pn + +pn "Test modifying colored+inverted+bold line with plain text:" +s 42 31 7 1; printf 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\r'; +s; pn "This text is plain and followed by green-on-red -->" +pn + + + +### Full-width character overwriting + +pn 'Overwrite part of a full-width char with a half-width char' +p 'initial U+4000 ideographs -->'; s 31 42; p '䀀䀀'; s; pn +p 'write X to index #1 -->'; s 31 42; p '䀀䀀'; s 35 44; printf '\033[24G'; p X; s; pn +p 'write X to index #2 -->'; s 31 42; p '䀀䀀'; s 35 44; printf '\033[25G'; p X; s; pn +p 'write X to index #3 -->'; s 31 42; p '䀀䀀'; s 35 44; printf '\033[26G'; p X; s; pn +p 'write X to index #4 -->'; s 31 42; p '䀀䀀'; s 35 44; printf '\033[27G'; p X; s; pn +pn + +pn 'Verify that Erase-in-Line can "fix" last char in line' +p 'original -->'; s 31 42; p '䀀䀀'; s; pn +p 'overwrite -->'; s 31 42; p '䀀䀀'; s 35 44; printf '\033[30G'; p 'XXX'; s; pn +p 'overwrite + Erase-in-Line -->'; s 31 42; p '䀀䀀'; s 35 44; printf '\033[30G'; p 'XXX'; s; printf '\033[0K'; pn +p 'original -->'; s 31 42; p 'X䀀䀀'; s; pn +p 'overwrite -->'; s 31 42; p 'X䀀䀀'; s 35 44; printf '\033[30G'; p 'ーー'; s; pn +p 'overwrite + Erase-in-Line -->'; s 31 42; p 'X䀀䀀'; s 35 44; printf '\033[30G'; p 'ーー'; s; printf '\033[0K'; pn +pn diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/font-notes.txt b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/font-notes.txt new file mode 100644 index 00000000..d4e36d8e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/font-notes.txt @@ -0,0 +1,300 @@ +================================================================== +Notes regarding fonts, code pages, and East Asian character widths +================================================================== + + +Registry settings +================= + + * There are console registry settings in `HKCU\Console`. That key has many + default settings (e.g. the default font settings) and also per-app subkeys + for app-specific overrides. + + * It is possible to override the code page with an app-specific setting. + + * There are registry settings in + `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Console`. In particular, + the `TrueTypeFont` subkey has a list of suitable font names associated with + various CJK code pages, as well as default font names. + + * There are two values in `HKLM\SYSTEM\CurrentControlSet\Control\Nls\CodePage` + that specify the current code pages -- `OEMCP` and `ACP`. Setting the + system locale via the Control Panel's "Region" or "Language" dialogs seems + to change these code page values. + + +Console fonts +============= + + * The `FontFamily` field of `CONSOLE_FONT_INFOEX` has two parts: + - The high four bits can be exactly one of the `FF_xxxx` font families: + FF_DONTCARE(0x00) + FF_ROMAN(0x10) + FF_SWISS(0x20) + FF_MODERN(0x30) + FF_SCRIPT(0x40) + FF_DECORATIVE(0x50) + - The low four bits are a bitmask: + TMPF_FIXED_PITCH(1) -- actually means variable pitch + TMPF_VECTOR(2) + TMPF_TRUETYPE(4) + TMPF_DEVICE(8) + + * Each console has its own independent console font table. The current font + is identified with an index into this table. The size of the table is + returned by the undocumented `GetNumberOfConsoleFonts` API. It is apparently + possible to get the table size without this API, by instead calling + `GetConsoleFontSize` on each nonnegative index starting with 0 until the API + fails by returning (0, 0). + + * The font table grows dynamically. Each time the console is configured with + a previously-unused (FaceName, Size) combination, two entries are added to + the font table -- one with normal weight and one with bold weight. Fonts + added this way are always TrueType fonts. + + * Initially, the font table appears to contain only raster fonts. For + example, on an English Windows 8 installation, here is the initial font + table: + font 0: 4x6 + font 1: 6x8 + font 2: 8x8 + font 3: 16x8 + font 4: 5x12 + font 5: 7x12 + font 6: 8x12 -- the current font + font 7: 16x12 + font 8: 12x16 + font 9: 10x18 + `GetNumberOfConsoleFonts` returns 10, and this table matches the raster font + sizes according to the console properties dialog. + + * With a Japanese or Chinese locale, the initial font table appears to contain + the sizes applicable to both the East Asian raster font, as well as the + sizes for the CP437/CP1252 raster font. + + * The index passed to `SetCurrentConsoleFontEx` apparently has no effect. + The undocumented `SetConsoleFont` API, however, accepts *only* a font index, + and on Windows 8 English, it switches between all 10 fonts, even font index + #0. + + * If the index passed to `SetConsoleFont` identifies a Raster Font + incompatible with the current code page, then another Raster Font is + activated. + + * Passing "Terminal" to `SetCurrentConsoleFontEx` seems to have no effect. + Perhaps relatedly, `SetCurrentConsoleFontEx` does not fail if it is given a + bogus `FaceName`. Some font is still chosen and activated. Passing a face + name and height seems to work reliably, modulo the CP936 issue described + below. + + +Console fonts and code pages +============================ + + * On an English Windows installation, the default code page is 437, and it + cannot be set to 932 (Shift-JIS). (The API call fails.) Changing the + system locale to "Japanese (Japan)" using the Region/Language dialog + changes the default CP to 932 and permits changing the console CP between + 437 and 932. + + * A console has both an input code page and an output code page + (`{Get,Set}ConsoleCP` and `{Get,Set}ConsoleOutputCP`). I'm not going to + distinguish between the two for this document; presumably only the output + CP matters. The code page can change while the console is open, e.g. + by running `mode con: cp select={932,437,1252}` or by calling + `SetConsoleOutputCP`. + + * The current code page restricts which TrueType fonts and which Raster Font + sizes are available in the console properties dialog. This can change + while the console is open. + + * Changing the code page almost(?) always changes the current console font. + So far, I don't know how the new font is chosen. + + * With a CP of 932, the only TrueType font available in the console properties + dialog is "MS Gothic", displayed as "MS ゴシック". It is still possible to + use the English-default TrueType console fonts, Lucida Console and Consolas, + via `SetCurrentConsoleFontEx`. + + * When using a Raster Font and CP437 or CP1252, writing a UTF-16 codepoint not + representable in the code page instead writes a question mark ('?') to the + console. This conversion does not apply with a TrueType font, nor with the + Raster Font for CP932 or CP936. + + +ReadConsoleOutput and double-width characters +============================================== + + * With a Raster Font active, when `ReadConsoleOutputW` reads two cells of a + double-width character, it fills only a single `CHAR_INFO` structure. The + unused trailing `CHAR_INFO` structures are zero-filled. With a TrueType + font active, `ReadConsoleOutputW` instead fills two `CHAR_INFO` structures, + the first marked with `COMMON_LVB_LEADING_BYTE` and the second marked with + `COMMON_LVB_TRAILING_BYTE`. The flag is a misnomer--there aren't two + *bytes*, but two cells, and they have equal `CHAR_INFO.Char.UnicodeChar` + values. + + * `ReadConsoleOutputA`, on the other hand, reads two `CHAR_INFO` cells, and + if the UTF-16 value can be represented as two bytes in the ANSI/OEM CP, then + the two bytes are placed in the two `CHAR_INFO.Char.AsciiChar` values, and + the `COMMON_LVB_{LEADING,TRAILING}_BYTE` values are also used. If the + codepoint isn't representable, I don't remember what happens -- I think the + `AsciiChar` values take on an invalid marker. + + * Reading only one cell of a double-width character reads a space (U+0020) + instead. Raster-vs-TrueType and wide-vs-ANSI do not matter. + - XXX: what about attributes? Can a double-width character have mismatched + color attributes? + - XXX: what happens when writing to just one cell of a double-width + character? + + +Default Windows fonts for East Asian languages +============================================== +CP932 / Japanese: "MS ゴシック" (MS Gothic) +CP936 / Chinese Simplified: "新宋体" (SimSun) + + +Unreliable character width (half-width vs full-width) +===================================================== + +The half-width vs full-width status of a codepoint depends on at least these variables: + * OS version (Win10 legacy and new modes are different versions) + * system locale (English vs Japanese vs Chinese Simplified vs Chinese Traditional, etc) + * code page (437 vs 932 vs 936, etc) + * raster vs TrueType (Terminal vs MS Gothic vs SimSun, etc) + * font size + * rendered-vs-model (rendered width can be larger or smaller than model width) + +Example 1: U+2014 (EM DASH): East_Asian_Width: Ambiguous +-------------------------------------------------------- + rendered modeled +CP932: Win7/8 Raster Fonts half half +CP932: Win7/8 Gothic 14/15px half full +CP932: Win7/8 Consolas 14/15px half full +CP932: Win7/8 Lucida Console 14px half full +CP932: Win7/8 Lucida Console 15px half half +CP932: Win10New Raster Fonts half half +CP932: Win10New Gothic 14/15px half half +CP932: Win10New Consolas 14/15px half half +CP932: Win10New Lucida Console 14/15px half half + +CP936: Win7/8 Raster Fonts full full +CP936: Win7/8 SimSun 14px full full +CP936: Win7/8 SimSun 15px full half +CP936: Win7/8 Consolas 14/15px half full +CP936: Win10New Raster Fonts full full +CP936: Win10New SimSum 14/15px full full +CP936: Win10New Consolas 14/15px half half + +Example 2: U+3044 (HIRAGANA LETTER I): East_Asian_Width: Wide +------------------------------------------------------------- + rendered modeled +CP932: Win7/8/10N Raster Fonts full full +CP932: Win7/8/10N Gothic 14/15px full full +CP932: Win7/8/10N Consolas 14/15px half(*2) full +CP932: Win7/8/10N Lucida Console 14/15px half(*3) full + +CP936: Win7/8/10N Raster Fonts full full +CP936: Win7/8/10N SimSun 14/15px full full +CP936: Win7/8/10N Consolas 14/15px full full + +Example 3: U+30FC (KATAKANA-HIRAGANA PROLONGED SOUND MARK): East_Asian_Width: Wide +---------------------------------------------------------------------------------- + rendered modeled +CP932: Win7 Raster Fonts full full +CP932: Win7 Gothic 14/15px full full +CP932: Win7 Consolas 14/15px half(*2) full +CP932: Win7 Lucida Console 14px half(*3) full +CP932: Win7 Lucida Console 15px half(*3) half +CP932: Win8 Raster Fonts full full +CP932: Win8 Gothic 14px full half +CP932: Win8 Gothic 15px full full +CP932: Win8 Consolas 14/15px half(*2) full +CP932: Win8 Lucida Console 14px half(*3) full +CP932: Win8 Lucida Console 15px half(*3) half +CP932: Win10New Raster Fonts full full +CP932: Win10New Gothic 14/15px full full +CP932: Win10New Consolas 14/15px half(*2) half +CP932: Win10New Lucida Console 14/15px half(*2) half + +CP936: Win7/8 Raster Fonts full full +CP936: Win7/8 SimSun 14px full full +CP936: Win7/8 SimSun 15px full half +CP936: Win7/8 Consolas 14px full full +CP936: Win7/8 Consolas 15px full half +CP936: Win10New Raster Fonts full full +CP936: Win10New SimSum 14/15px full full +CP936: Win10New Consolas 14/15px full full + +Example 4: U+4000 (CJK UNIFIED IDEOGRAPH-4000): East_Asian_Width: Wide +---------------------------------------------------------------------- + rendered modeled +CP932: Win7 Raster Fonts half(*1) half +CP932: Win7 Gothic 14/15px full full +CP932: Win7 Consolas 14/15px half(*2) full +CP932: Win7 Lucida Console 14px half(*3) full +CP932: Win7 Lucida Console 15px half(*3) half +CP932: Win8 Raster Fonts half(*1) half +CP932: Win8 Gothic 14px full half +CP932: Win8 Gothic 15px full full +CP932: Win8 Consolas 14/15px half(*2) full +CP932: Win8 Lucida Console 14px half(*3) full +CP932: Win8 Lucida Console 15px half(*3) half +CP932: Win10New Raster Fonts half(*1) half +CP932: Win10New Gothic 14/15px full full +CP932: Win10New Consolas 14/15px half(*2) half +CP932: Win10New Lucida Console 14/15px half(*2) half + +CP936: Win7/8 Raster Fonts full full +CP936: Win7/8 SimSun 14px full full +CP936: Win7/8 SimSun 15px full half +CP936: Win7/8 Consolas 14px full full +CP936: Win7/8 Consolas 15px full half +CP936: Win10New Raster Fonts full full +CP936: Win10New SimSum 14/15px full full +CP936: Win10New Consolas 14/15px full full + +(*1) Rendered as a half-width filled white box +(*2) Rendered as a half-width box with a question mark inside +(*3) Rendered as a half-width empty box +(!!) One of the only places in Win10New where rendered and modeled width disagree + + +Windows quirk: unreliable font heights with CP936 / Chinese Simplified +====================================================================== + +When I set the font to 新宋体 17px, using either the properties dialog or +`SetCurrentConsoleFontEx`, the height reported by `GetCurrentConsoleFontEx` is +not 17, but is instead 19. The same problem does not affect Raster Fonts, +nor have I seen the problem in the English or Japanese locales. I observed +this with Windows 7 and Windows 10 new mode. + +If I set the font using the facename, width, *and* height, then the +`SetCurrentConsoleFontEx` and `GetCurrentConsoleFontEx` values agree. If I +set the font using *only* the facename and height, then the two values +disagree. + + +Windows bug: GetCurrentConsoleFontEx is initially invalid +========================================================= + + - Assume there is no configured console font name in the registry. In this + case, the console defaults to a raster font. + - Open a new console and call the `GetCurrentConsoleFontEx` API. + - The `FaceName` field of the returned `CONSOLE_FONT_INFOEX` data + structure is incorrect. On Windows 7, 8, and 10, I observed that the + field was blank. On Windows 8, occasionally, it instead contained: + U+AE72 U+75BE U+0001 + The other fields of the structure all appeared correct: + nFont=6 dwFontSize=(8,12) FontFamily=0x30 FontWeight=400 + - The `FaceName` field becomes initialized easily: + - Open the console properties dialog and click OK. (Cancel is not + sufficient.) + - Call the undocumented `SetConsoleFont` with the current font table + index, which is 6 in the example above. + - It seems that the console uncritically accepts whatever string is + stored in the registry, including a blank string, and passes it on the + the `GetCurrentConsoleFontEx` caller. It is possible to get the console + to *write* a blank setting into the registry -- simply open the console + (default or app-specific) properties and click OK. diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/misc/winbug-15048.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/winbug-15048.cc new file mode 100644 index 00000000..0e98d648 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/misc/winbug-15048.cc @@ -0,0 +1,201 @@ +/* + +Test program demonstrating a problem in Windows 15048's ReadConsoleOutput API. + +To compile: + + cl /nologo /EHsc winbug-15048.cc shell32.lib + +Example of regressed input: + +Case 1: + + > chcp 932 + > winbug-15048 -face-gothic 3044 + + Correct output: + + 1**34 (nb: U+3044 replaced with '**' to avoid MSVC encoding warning) + 5678 + + ReadConsoleOutputW (both rows, 3 cols) + row 0: U+0031(0007) U+3044(0107) U+3044(0207) U+0033(0007) + row 1: U+0035(0007) U+0036(0007) U+0037(0007) U+0038(0007) + + ReadConsoleOutputW (both rows, 4 cols) + row 0: U+0031(0007) U+3044(0107) U+3044(0207) U+0033(0007) U+0034(0007) + row 1: U+0035(0007) U+0036(0007) U+0037(0007) U+0038(0007) U+0020(0007) + + ReadConsoleOutputW (second row) + row 1: U+0035(0007) U+0036(0007) U+0037(0007) U+0038(0007) U+0020(0007) + + ... + + Win10 15048 bad output: + + 1**34 + 5678 + + ReadConsoleOutputW (both rows, 3 cols) + row 0: U+0031(0007) U+3044(0007) U+0033(0007) U+0035(0007) + row 1: U+0036(0007) U+0037(0007) U+0038(0007) U+0000(0000) + + ReadConsoleOutputW (both rows, 4 cols) + row 0: U+0031(0007) U+3044(0007) U+0033(0007) U+0034(0007) U+0035(0007) + row 1: U+0036(0007) U+0037(0007) U+0038(0007) U+0020(0007) U+0000(0000) + + ReadConsoleOutputW (second row) + row 1: U+0035(0007) U+0036(0007) U+0037(0007) U+0038(0007) U+0020(0007) + + ... + + The U+3044 character (HIRAGANA LETTER I) occupies two columns, but it only + fills one record in the ReadConsoleOutput output buffer, which has the + effect of shifting the first cell of the second row into the last cell of + the first row. Ordinarily, the first and second cells would also have the + COMMON_LVB_LEADING_BYTE and COMMON_LVB_TRAILING_BYTE attributes set, which + allows winpty to detect the double-column character. + +Case 2: + + > chcp 437 + > winbug-15048 -face "Lucida Console" -h 4 221A + + The same issue happens with U+221A (SQUARE ROOT), but only in certain + fonts. The console seems to think this character occupies two columns + if the font is sufficiently small. The Windows console properties dialog + doesn't allow fonts below 5 pt, but winpty tries to use 2pt and 4pt Lucida + Console to allow very large console windows. + +Case 3: + + > chcp 437 + > winbug-15048 -face "Lucida Console" -h 12 FF12 + + The console selection system thinks U+FF12 (FULLWIDTH DIGIT TWO) occupies + two columns, which happens to be correct, but it's displayed as a single + column unrecognized character. It otherwise behaves the same as the other + cases. + +*/ + +#include +#include +#include +#include +#include +#include + +#include + +#define COUNT_OF(array) (sizeof(array) / sizeof((array)[0])) + +// See https://en.wikipedia.org/wiki/List_of_CJK_fonts +const wchar_t kMSGothic[] = { 0xff2d, 0xff33, 0x0020, 0x30b4, 0x30b7, 0x30c3, 0x30af, 0 }; // Japanese +const wchar_t kNSimSun[] = { 0x65b0, 0x5b8b, 0x4f53, 0 }; // Simplified Chinese +const wchar_t kMingLight[] = { 0x7d30, 0x660e, 0x9ad4, 0 }; // Traditional Chinese +const wchar_t kGulimChe[] = { 0xad74, 0xb9bc, 0xccb4, 0 }; // Korean + +static void set_font(const wchar_t *name, int size) { + const HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + CONSOLE_FONT_INFOEX fontex {}; + fontex.cbSize = sizeof(fontex); + fontex.dwFontSize.Y = size; + fontex.FontWeight = 400; + fontex.FontFamily = 0x36; + wcsncpy(fontex.FaceName, name, COUNT_OF(fontex.FaceName)); + assert(SetCurrentConsoleFontEx(conout, FALSE, &fontex)); +} + +static void usage(const wchar_t *prog) { + printf("Usage: %ls [options]\n", prog); + printf(" -h HEIGHT\n"); + printf(" -face FACENAME\n"); + printf(" -face-{gothic|simsun|minglight|gulimche) [JP,CN-sim,CN-tra,KR]\n"); + printf(" hhhh -- print U+hhhh\n"); + exit(1); +} + +static void dump_region(SMALL_RECT region, const char *name) { + const HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + + CHAR_INFO buf[1000]; + memset(buf, 0xcc, sizeof(buf)); + + const int w = region.Right - region.Left + 1; + const int h = region.Bottom - region.Top + 1; + + assert(ReadConsoleOutputW( + conout, buf, { (short)w, (short)h }, { 0, 0 }, + ®ion)); + + printf("\n"); + printf("ReadConsoleOutputW (%s)\n", name); + for (int y = 0; y < h; ++y) { + printf("row %d: ", region.Top + y); + for (int i = 0; i < region.Left * 13; ++i) { + printf(" "); + } + for (int x = 0; x < w; ++x) { + const int i = y * w + x; + printf("U+%04x(%04x) ", buf[i].Char.UnicodeChar, buf[i].Attributes); + } + printf("\n"); + } +} + +int main() { + wchar_t *cmdline = GetCommandLineW(); + int argc = 0; + wchar_t **argv = CommandLineToArgvW(cmdline, &argc); + const wchar_t *font_name = L"Lucida Console"; + int font_height = 8; + int test_ch = 0xff12; // U+FF12 FULLWIDTH DIGIT TWO + + for (int i = 1; i < argc; ++i) { + const std::wstring arg = argv[i]; + const std::wstring next = i + 1 < argc ? argv[i + 1] : L""; + if (arg == L"-face" && i + 1 < argc) { + font_name = argv[i + 1]; + i++; + } else if (arg == L"-face-gothic") { + font_name = kMSGothic; + } else if (arg == L"-face-simsun") { + font_name = kNSimSun; + } else if (arg == L"-face-minglight") { + font_name = kMingLight; + } else if (arg == L"-face-gulimche") { + font_name = kGulimChe; + } else if (arg == L"-h" && i + 1 < argc) { + font_height = _wtoi(next.c_str()); + i++; + } else if (arg.c_str()[0] != '-') { + test_ch = wcstol(arg.c_str(), NULL, 16); + } else { + printf("Unrecognized argument: %ls\n", arg.c_str()); + usage(argv[0]); + } + } + + const HANDLE conout = GetStdHandle(STD_OUTPUT_HANDLE); + + set_font(font_name, font_height); + + system("cls"); + DWORD actual = 0; + wchar_t output[] = L"1234\n5678\n"; + output[1] = test_ch; + WriteConsoleW(conout, output, 10, &actual, nullptr); + + dump_region({ 0, 0, 3, 1 }, "both rows, 3 cols"); + dump_region({ 0, 0, 4, 1 }, "both rows, 4 cols"); + dump_region({ 0, 1, 4, 1 }, "second row"); + dump_region({ 0, 0, 4, 0 }, "first row"); + dump_region({ 1, 0, 4, 0 }, "first row, skip 1"); + dump_region({ 2, 0, 4, 0 }, "first row, skip 2"); + dump_region({ 3, 0, 4, 0 }, "first row, skip 3"); + + set_font(font_name, 14); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/ship/build-pty4j-libpty.bat b/services/edge-agent/node_modules/node-pty/deps/winpty/ship/build-pty4j-libpty.bat new file mode 100644 index 00000000..b6bca7b0 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/ship/build-pty4j-libpty.bat @@ -0,0 +1,36 @@ +@echo off + +setlocal +cd %~dp0.. +set Path=C:\Python27;C:\Program Files\Git\cmd;%Path% + +call "%VS140COMNTOOLS%\VsDevCmd.bat" || goto :fail + +rmdir /s/q build-libpty 2>NUL +mkdir build-libpty\win +mkdir build-libpty\win\x86 +mkdir build-libpty\win\x86_64 +mkdir build-libpty\win\xp + +rmdir /s/q src\Release 2>NUL +rmdir /s/q src\.vs 2>NUL +del src\*.vcxproj src\*.vcxproj.filters src\*.sln src\*.sdf 2>NUL + +call vcbuild.bat --msvc-platform Win32 --gyp-msvs-version 2015 --toolset v140_xp || goto :fail +copy src\Release\Win32\winpty.dll build-libpty\win\xp || goto :fail +copy src\Release\Win32\winpty-agent.exe build-libpty\win\xp || goto :fail + +call vcbuild.bat --msvc-platform Win32 --gyp-msvs-version 2015 || goto :fail +copy src\Release\Win32\winpty.dll build-libpty\win\x86 || goto :fail +copy src\Release\Win32\winpty-agent.exe build-libpty\win\x86 || goto :fail + +call vcbuild.bat --msvc-platform x64 --gyp-msvs-version 2015 || goto :fail +copy src\Release\x64\winpty.dll build-libpty\win\x86_64 || goto :fail +copy src\Release\x64\winpty-agent.exe build-libpty\win\x86_64 || goto :fail + +echo success +goto :EOF + +:fail +echo error: build failed +exit /b 1 diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/ship/common_ship.py b/services/edge-agent/node_modules/node-pty/deps/winpty/ship/common_ship.py new file mode 100644 index 00000000..b46cd5b8 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/ship/common_ship.py @@ -0,0 +1,53 @@ +import os +import sys + +if os.name != "nt": + sys.exit("Error: ship scripts require native Python 2.7. (wrong os.name)") +if sys.version_info[0:2] != (2,7): + sys.exit("Error: ship scripts require native Python 2.7. (wrong version)") + +import glob +import shutil +import subprocess +from distutils.spawn import find_executable + +topDir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) + +with open(topDir + "/VERSION.txt", "rt") as f: + winptyVersion = f.read().strip() + +def rmrf(patterns): + for pattern in patterns: + for path in glob.glob(pattern): + if os.path.isdir(path) and not os.path.islink(path): + print "+ rm -r " + path + sys.stdout.flush() + shutil.rmtree(path) + elif os.path.isfile(path): + print "+ rm " + path + sys.stdout.flush() + os.remove(path) + +def mkdir(path): + if not os.path.isdir(path): + os.makedirs(path) + +def requireExe(name, guesses): + if find_executable(name) is None: + for guess in guesses: + if os.path.exists(guess): + newDir = os.path.dirname(guess) + print "Adding " + newDir + " to Path to provide " + name + os.environ["Path"] = newDir + ";" + os.environ["Path"] + ret = find_executable(name) + if ret is None: + sys.exit("Error: required EXE is missing from Path: " + name) + return ret + +requireExe("git.exe", [ + "C:\\Program Files\\Git\\cmd\\git.exe", + "C:\\Program Files (x86)\\Git\\cmd\\git.exe" +]) + +commitHash = subprocess.check_output(["git.exe", "rev-parse", "HEAD"]).decode().strip() +defaultPathEnviron = "C:\\Windows\\System32;C:\\Windows" diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/ship/make_msvc_package.py b/services/edge-agent/node_modules/node-pty/deps/winpty/ship/make_msvc_package.py new file mode 100644 index 00000000..220f02b2 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/ship/make_msvc_package.py @@ -0,0 +1,165 @@ +#!python + +# Copyright (c) 2016 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +# +# Run with native CPython 2.7. +# +# This script looks for MSVC using a version-specific environment variable, +# such as VS140COMNTOOLS for MSVC 2015. +# + +import common_ship + +import argparse +import os +import shutil +import subprocess +import sys + +os.chdir(common_ship.topDir) +ZIP_TOOL = common_ship.requireExe("7z.exe", [ + "C:\\Program Files\\7-Zip\\7z.exe", + "C:\\Program Files (x86)\\7-Zip\\7z.exe", +]) + +MSVC_VERSION_TABLE = { + "2015" : { + "package_name" : "msvc2015", + "gyp_version" : "2015", + "common_tools_env" : "VS140COMNTOOLS", + "xp_toolset" : "v140_xp", + }, + "2013" : { + "package_name" : "msvc2013", + "gyp_version" : "2013", + "common_tools_env" : "VS120COMNTOOLS", + "xp_toolset" : "v120_xp", + }, +} + +ARCH_TABLE = { + "x64" : { + "msvc_platform" : "x64", + }, + "ia32" : { + "msvc_platform" : "Win32", + }, +} + +def readArguments(): + parser = argparse.ArgumentParser() + parser.add_argument("--msvc-version", default="2015") + ret = parser.parse_args() + if ret.msvc_version not in MSVC_VERSION_TABLE: + sys.exit("Error: unrecognized version: " + ret.msvc_version + ". " + + "Versions: " + " ".join(sorted(MSVC_VERSION_TABLE.keys()))) + return ret + +ARGS = readArguments() + +def checkoutGyp(): + if os.path.isdir("build-gyp"): + return + subprocess.check_call([ + "git.exe", + "clone", + "https://chromium.googlesource.com/external/gyp", + "build-gyp" + ]) + +def cleanMsvc(): + common_ship.rmrf(""" + src/Release src/.vs src/gen + src/*.vcxproj src/*.vcxproj.filters src/*.sln src/*.sdf + """.split()) + +def build(arch, packageDir, xp=False): + archInfo = ARCH_TABLE[arch] + versionInfo = MSVC_VERSION_TABLE[ARGS.msvc_version] + + devCmdPath = os.path.join(os.environ[versionInfo["common_tools_env"]], "VsDevCmd.bat") + if not os.path.isfile(devCmdPath): + sys.exit("Error: MSVC environment script missing: " + devCmdPath) + + newEnv = os.environ.copy() + newEnv["PATH"] = os.path.dirname(sys.executable) + ";" + common_ship.defaultPathEnviron + commandLine = ( + '"' + devCmdPath + '" && ' + " vcbuild.bat" + + " --gyp-msvs-version " + versionInfo["gyp_version"] + + " --msvc-platform " + archInfo["msvc_platform"] + + " --commit-hash " + common_ship.commitHash + ) + + subprocess.check_call(commandLine, shell=True, env=newEnv) + + archPackageDir = os.path.join(packageDir, arch) + if xp: + archPackageDir += "_xp" + + common_ship.mkdir(archPackageDir + "/bin") + common_ship.mkdir(archPackageDir + "/lib") + + binSrc = os.path.join(common_ship.topDir, "src/Release", archInfo["msvc_platform"]) + + shutil.copy(binSrc + "/winpty.dll", archPackageDir + "/bin") + shutil.copy(binSrc + "/winpty-agent.exe", archPackageDir + "/bin") + shutil.copy(binSrc + "/winpty-debugserver.exe", archPackageDir + "/bin") + shutil.copy(binSrc + "/winpty.lib", archPackageDir + "/lib") + +def buildPackage(): + versionInfo = MSVC_VERSION_TABLE[ARGS.msvc_version] + + packageName = "winpty-%s-%s" % ( + common_ship.winptyVersion, + versionInfo["package_name"], + ) + + packageRoot = os.path.join(common_ship.topDir, "ship/packages") + packageDir = os.path.join(packageRoot, packageName) + packageFile = packageDir + ".zip" + + common_ship.rmrf([packageDir]) + common_ship.rmrf([packageFile]) + common_ship.mkdir(packageDir) + + checkoutGyp() + cleanMsvc() + build("ia32", packageDir, True) + build("x64", packageDir, True) + cleanMsvc() + build("ia32", packageDir) + build("x64", packageDir) + + topDir = common_ship.topDir + + common_ship.mkdir(packageDir + "/include") + shutil.copy(topDir + "/src/include/winpty.h", packageDir + "/include") + shutil.copy(topDir + "/src/include/winpty_constants.h", packageDir + "/include") + shutil.copy(topDir + "/LICENSE", packageDir) + shutil.copy(topDir + "/README.md", packageDir) + shutil.copy(topDir + "/RELEASES.md", packageDir) + + subprocess.check_call([ZIP_TOOL, "a", packageFile, "."], cwd=packageDir) + +if __name__ == "__main__": + buildPackage() diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/ship/ship.py b/services/edge-agent/node_modules/node-pty/deps/winpty/ship/ship.py new file mode 100644 index 00000000..12874bac --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/ship/ship.py @@ -0,0 +1,108 @@ +#!python + +# Copyright (c) 2015 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +# +# Run with native CPython 2.7 on a 64-bit computer. +# +# Each of the targets in BUILD_TARGETS must be installed to the default +# location. Each target must have the appropriate MinGW and non-MinGW +# compilers installed, as well as make and tar. +# + +import common_ship + +import multiprocessing +import os +import shutil +import subprocess +import sys + +os.chdir(common_ship.topDir) + +def dllVersion(path): + version = subprocess.check_output( + ["powershell.exe", + "[System.Diagnostics.FileVersionInfo]::GetVersionInfo(\"" + path + "\").FileVersion"]) + return version.strip() + +# Determine other build parameters. +print "Determining Cygwin/MSYS2 DLL versions..." +sys.stdout.flush() +BUILD_TARGETS = [ + # { + # "name": "msys", + # "path": "C:\\MinGW\\bin;C:\\MinGW\\msys\\1.0\\bin", + # # The parallel make.exe in the original MSYS/MinGW project hangs. + # "make_binary": "mingw32-make.exe", + # }, + { + "name": "msys2-" + dllVersion("C:\\msys32\\usr\\bin\\msys-2.0.dll") + "-ia32", + "path": "C:\\msys32\\mingw32\\bin;C:\\msys32\\usr\\bin", + }, + { + "name": "msys2-" + dllVersion("C:\\msys64\\usr\\bin\\msys-2.0.dll") + "-x64", + "path": "C:\\msys64\\mingw64\\bin;C:\\msys64\\usr\\bin", + }, + { + "name": "cygwin-" + dllVersion("C:\\cygwin\\bin\\cygwin1.dll") + "-ia32", + "path": "C:\\cygwin\\bin", + }, + { + "name": "cygwin-" + dllVersion("C:\\cygwin64\\bin\\cygwin1.dll") + "-x64", + "path": "C:\\cygwin64\\bin", + }, +] + +def buildTarget(target): + packageName = "winpty-" + common_ship.winptyVersion + "-" + target["name"] + if os.path.exists("ship\\packages\\" + packageName): + shutil.rmtree("ship\\packages\\" + packageName) + oldPath = os.environ["PATH"] + os.environ["PATH"] = target["path"] + ";" + common_ship.defaultPathEnviron + subprocess.check_call(["sh.exe", "configure"]) + makeBinary = target.get("make_binary", "make.exe") + subprocess.check_call([makeBinary, "clean"]) + makeBaseCmd = [ + makeBinary, + "USE_PCH=0", + "COMMIT_HASH=" + common_ship.commitHash, + "PREFIX=ship/packages/" + packageName + ] + subprocess.check_call(makeBaseCmd + ["all", "tests", "-j%d" % multiprocessing.cpu_count()]) + subprocess.check_call(["build\\trivial_test.exe"]) + subprocess.check_call(makeBaseCmd + ["install"]) + subprocess.check_call(["tar.exe", "cvfz", + packageName + ".tar.gz", + packageName], cwd=os.path.join(os.getcwd(), "ship", "packages")) + os.environ["PATH"] = oldPath + +def main(): + oldPath = os.environ["PATH"] + for t in BUILD_TARGETS: + os.environ["PATH"] = t["path"] + ";" + common_ship.defaultPathEnviron + subprocess.check_output(["tar.exe", "--help"]) + subprocess.check_output(["make.exe", "--help"]) + for t in BUILD_TARGETS: + buildTarget(t) + +if __name__ == "__main__": + main() diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Agent.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Agent.cc new file mode 100644 index 00000000..4ce2a634 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Agent.cc @@ -0,0 +1,613 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "Agent.h" + +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include "../include/winpty_constants.h" + +#include "../shared/AgentMsg.h" +#include "../shared/Buffer.h" +#include "../shared/DebugClient.h" +#include "../shared/GenRandom.h" +#include "../shared/StringBuilder.h" +#include "../shared/StringUtil.h" +#include "../shared/WindowsVersion.h" +#include "../shared/WinptyAssert.h" + +#include "ConsoleFont.h" +#include "ConsoleInput.h" +#include "NamedPipe.h" +#include "Scraper.h" +#include "Terminal.h" +#include "Win32ConsoleBuffer.h" + +namespace { + +static BOOL WINAPI consoleCtrlHandler(DWORD dwCtrlType) +{ + if (dwCtrlType == CTRL_C_EVENT) { + // Do nothing and claim to have handled the event. + return TRUE; + } + return FALSE; +} + +// We can detect the new Windows 10 console by observing the effect of the +// Mark command. In older consoles, Mark temporarily moves the cursor to the +// top-left of the console window. In the new console, the cursor isn't +// initially moved. +// +// We might like to use Mark to freeze the console, but we can't, because when +// the Mark command ends, the console moves the cursor back to its starting +// point, even if the console application has moved it in the meantime. +static void detectNewWindows10Console( + Win32Console &console, Win32ConsoleBuffer &buffer) +{ + if (!isAtLeastWindows8()) { + return; + } + + ConsoleScreenBufferInfo info = buffer.bufferInfo(); + + // Make sure the window isn't 1x1. AFAIK, this should never happen + // accidentally. It is difficult to make it happen deliberately. + if (info.srWindow.Left == info.srWindow.Right && + info.srWindow.Top == info.srWindow.Bottom) { + trace("detectNewWindows10Console: Initial console window was 1x1 -- " + "expanding for test"); + setSmallFont(buffer.conout(), 400, false); + buffer.moveWindow(SmallRect(0, 0, 1, 1)); + buffer.resizeBuffer(Coord(400, 1)); + buffer.moveWindow(SmallRect(0, 0, 2, 1)); + // This use of GetLargestConsoleWindowSize ought to be unnecessary + // given the behavior I've seen from moveWindow(0, 0, 1, 1), but + // I'd like to be especially sure, considering that this code will + // rarely be tested. + const auto largest = GetLargestConsoleWindowSize(buffer.conout()); + buffer.moveWindow( + SmallRect(0, 0, std::min(largest.X, buffer.bufferSize().X), 1)); + info = buffer.bufferInfo(); + ASSERT(info.srWindow.Right > info.srWindow.Left && + "Could not expand console window from 1x1"); + } + + // Test whether MARK moves the cursor. + const Coord initialPosition(info.srWindow.Right, info.srWindow.Bottom); + buffer.setCursorPosition(initialPosition); + ASSERT(!console.frozen()); + console.setFreezeUsesMark(true); + console.setFrozen(true); + const bool isNewW10 = (buffer.cursorPosition() == initialPosition); + console.setFrozen(false); + buffer.setCursorPosition(Coord(0, 0)); + + trace("Attempting to detect new Windows 10 console using MARK: %s", + isNewW10 ? "detected" : "not detected"); + console.setFreezeUsesMark(false); + console.setNewW10(isNewW10); +} + +static inline WriteBuffer newPacket() { + WriteBuffer packet; + packet.putRawValue(0); // Reserve space for size. + return packet; +} + +static HANDLE duplicateHandle(HANDLE h) { + HANDLE ret = nullptr; + if (!DuplicateHandle( + GetCurrentProcess(), h, + GetCurrentProcess(), &ret, + 0, FALSE, DUPLICATE_SAME_ACCESS)) { + ASSERT(false && "DuplicateHandle failed!"); + } + return ret; +} + +// It's safe to truncate a handle from 64-bits to 32-bits, or to sign-extend it +// back to 64-bits. See the MSDN article, "Interprocess Communication Between +// 32-bit and 64-bit Applications". +// https://msdn.microsoft.com/en-us/library/windows/desktop/aa384203.aspx +static int64_t int64FromHandle(HANDLE h) { + return static_cast(reinterpret_cast(h)); +} + +} // anonymous namespace + +Agent::Agent(LPCWSTR controlPipeName, + uint64_t agentFlags, + int mouseMode, + int initialCols, + int initialRows) : + m_useConerr((agentFlags & WINPTY_FLAG_CONERR) != 0), + m_plainMode((agentFlags & WINPTY_FLAG_PLAIN_OUTPUT) != 0), + m_mouseMode(mouseMode) +{ + trace("Agent::Agent entered"); + + ASSERT(initialCols >= 1 && initialRows >= 1); + initialCols = std::min(initialCols, MAX_CONSOLE_WIDTH); + initialRows = std::min(initialRows, MAX_CONSOLE_HEIGHT); + + const bool outputColor = + !m_plainMode || (agentFlags & WINPTY_FLAG_COLOR_ESCAPES); + const Coord initialSize(initialCols, initialRows); + + auto primaryBuffer = openPrimaryBuffer(); + if (m_useConerr) { + m_errorBuffer = Win32ConsoleBuffer::createErrorBuffer(); + } + + detectNewWindows10Console(m_console, *primaryBuffer); + + m_controlPipe = &connectToControlPipe(controlPipeName); + m_coninPipe = &createDataServerPipe(false, L"conin"); + m_conoutPipe = &createDataServerPipe(true, L"conout"); + if (m_useConerr) { + m_conerrPipe = &createDataServerPipe(true, L"conerr"); + } + + // Send an initial response packet to winpty.dll containing pipe names. + { + auto setupPacket = newPacket(); + setupPacket.putWString(m_coninPipe->name()); + setupPacket.putWString(m_conoutPipe->name()); + if (m_useConerr) { + setupPacket.putWString(m_conerrPipe->name()); + } + writePacket(setupPacket); + } + + std::unique_ptr primaryTerminal; + primaryTerminal.reset(new Terminal(*m_conoutPipe, + m_plainMode, + outputColor)); + m_primaryScraper.reset(new Scraper(m_console, + *primaryBuffer, + std::move(primaryTerminal), + initialSize)); + if (m_useConerr) { + std::unique_ptr errorTerminal; + errorTerminal.reset(new Terminal(*m_conerrPipe, + m_plainMode, + outputColor)); + m_errorScraper.reset(new Scraper(m_console, + *m_errorBuffer, + std::move(errorTerminal), + initialSize)); + } + + m_console.setTitle(m_currentTitle); + + const HANDLE conin = GetStdHandle(STD_INPUT_HANDLE); + m_consoleInput.reset( + new ConsoleInput(conin, m_mouseMode, *this, m_console)); + + // Setup Ctrl-C handling. First restore default handling of Ctrl-C. This + // attribute is inherited by child processes. Then register a custom + // Ctrl-C handler that does nothing. The handler will be called when the + // agent calls GenerateConsoleCtrlEvent. + SetConsoleCtrlHandler(NULL, FALSE); + SetConsoleCtrlHandler(consoleCtrlHandler, TRUE); + + setPollInterval(25); +} + +Agent::~Agent() +{ + trace("Agent::~Agent entered"); + try { + agentShutdown(); + if (m_childProcess != NULL) { + CloseHandle(m_childProcess); + } + } catch (const std::exception &e) { + // Log the exception or handle it as needed + trace("Exception in Agent::~Agent: %s", e.what()); + } catch (...) { + // Catch any other types of exceptions + trace("Unknown exception in Agent::~Agent"); + } +} + +// Write a "Device Status Report" command to the terminal. The terminal will +// reply with a row+col escape sequence. Presumably, the DSR reply will not +// split a keypress escape sequence, so it should be safe to assume that the +// bytes before it are complete keypresses. +void Agent::sendDsr() +{ + if (!m_plainMode && !m_conoutPipe->isClosed()) { + m_conoutPipe->write("\x1B[6n"); + } +} + +NamedPipe &Agent::connectToControlPipe(LPCWSTR pipeName) +{ + NamedPipe &pipe = createNamedPipe(); + pipe.connectToServer(pipeName, NamedPipe::OpenMode::Duplex); + pipe.setReadBufferSize(64 * 1024); + return pipe; +} + +// Returns a new server named pipe. It has not yet been connected. +NamedPipe &Agent::createDataServerPipe(bool write, const wchar_t *kind) +{ + const auto name = + (WStringBuilder(128) + << L"\\\\.\\pipe\\winpty-" + << kind << L'-' + << GenRandom().uniqueName()).str_moved(); + NamedPipe &pipe = createNamedPipe(); + pipe.openServerPipe( + name.c_str(), + write ? NamedPipe::OpenMode::Writing + : NamedPipe::OpenMode::Reading, + write ? 8192 : 0, + write ? 0 : 256); + if (!write) { + pipe.setReadBufferSize(64 * 1024); + } + return pipe; +} + +void Agent::onPipeIo(NamedPipe &namedPipe) +{ + if (&namedPipe == m_conoutPipe || &namedPipe == m_conerrPipe) { + autoClosePipesForShutdown(); + } else if (&namedPipe == m_coninPipe) { + pollConinPipe(); + } else if (&namedPipe == m_controlPipe) { + pollControlPipe(); + } +} + +void Agent::pollControlPipe() +{ + if (m_controlPipe->isClosed()) { + trace("Agent exiting (control pipe is closed)"); + shutdown(); + return; + } + + while (true) { + uint64_t packetSize = 0; + const auto amt1 = + m_controlPipe->peek(&packetSize, sizeof(packetSize)); + if (amt1 < sizeof(packetSize)) { + break; + } + ASSERT(packetSize >= sizeof(packetSize) && packetSize <= SIZE_MAX); + if (m_controlPipe->bytesAvailable() < packetSize) { + if (m_controlPipe->readBufferSize() < packetSize) { + m_controlPipe->setReadBufferSize(packetSize); + } + break; + } + std::vector packetData; + packetData.resize(packetSize); + const auto amt2 = m_controlPipe->read(packetData.data(), packetSize); + ASSERT(amt2 == packetSize); + try { + ReadBuffer buffer(std::move(packetData)); + buffer.getRawValue(); // Discard the size. + handlePacket(buffer); + } catch (const ReadBuffer::DecodeError&) { + ASSERT(false && "Decode error"); + } + } +} + +void Agent::handlePacket(ReadBuffer &packet) +{ + const int type = packet.getInt32(); + switch (type) { + case AgentMsg::StartProcess: + handleStartProcessPacket(packet); + break; + case AgentMsg::SetSize: + // TODO: I think it might make sense to collapse consecutive SetSize + // messages. i.e. The terminal process can probably generate SetSize + // messages faster than they can be processed, and some GUIs might + // generate a flood of them, so if we can read multiple SetSize packets + // at once, we can ignore the early ones. + handleSetSizePacket(packet); + break; + case AgentMsg::GetConsoleProcessList: + handleGetConsoleProcessListPacket(packet); + break; + default: + trace("Unrecognized message, id:%d", type); + } +} + +void Agent::writePacket(WriteBuffer &packet) +{ + const auto &bytes = packet.buf(); + packet.replaceRawValue(0, bytes.size()); + m_controlPipe->write(bytes.data(), bytes.size()); +} + +void Agent::handleStartProcessPacket(ReadBuffer &packet) +{ + ASSERT(m_childProcess == nullptr); + ASSERT(!m_closingOutputPipes); + + const uint64_t spawnFlags = packet.getInt64(); + const bool wantProcessHandle = packet.getInt32() != 0; + const bool wantThreadHandle = packet.getInt32() != 0; + const auto program = packet.getWString(); + const auto cmdline = packet.getWString(); + const auto cwd = packet.getWString(); + const auto env = packet.getWString(); + const auto desktop = packet.getWString(); + packet.assertEof(); + + auto cmdlineV = vectorWithNulFromString(cmdline); + auto desktopV = vectorWithNulFromString(desktop); + auto envV = vectorFromString(env); + + LPCWSTR programArg = program.empty() ? nullptr : program.c_str(); + LPWSTR cmdlineArg = cmdline.empty() ? nullptr : cmdlineV.data(); + LPCWSTR cwdArg = cwd.empty() ? nullptr : cwd.c_str(); + LPWSTR envArg = env.empty() ? nullptr : envV.data(); + + STARTUPINFOW sui = {}; + PROCESS_INFORMATION pi = {}; + sui.cb = sizeof(sui); + sui.lpDesktop = desktop.empty() ? nullptr : desktopV.data(); + BOOL inheritHandles = FALSE; + if (m_useConerr) { + inheritHandles = TRUE; + sui.dwFlags |= STARTF_USESTDHANDLES; + sui.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + sui.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); + sui.hStdError = m_errorBuffer->conout(); + } + + const BOOL success = + CreateProcessW(programArg, cmdlineArg, nullptr, nullptr, + /*bInheritHandles=*/inheritHandles, + /*dwCreationFlags=*/CREATE_UNICODE_ENVIRONMENT, + envArg, cwdArg, &sui, &pi); + const int lastError = success ? 0 : GetLastError(); + + trace("CreateProcess: %s %u", + (success ? "success" : "fail"), + static_cast(pi.dwProcessId)); + + auto reply = newPacket(); + if (success) { + int64_t replyProcess = 0; + int64_t replyThread = 0; + if (wantProcessHandle) { + replyProcess = int64FromHandle(duplicateHandle(pi.hProcess)); + } + if (wantThreadHandle) { + replyThread = int64FromHandle(duplicateHandle(pi.hThread)); + } + CloseHandle(pi.hThread); + m_childProcess = pi.hProcess; + m_autoShutdown = (spawnFlags & WINPTY_SPAWN_FLAG_AUTO_SHUTDOWN) != 0; + m_exitAfterShutdown = (spawnFlags & WINPTY_SPAWN_FLAG_EXIT_AFTER_SHUTDOWN) != 0; + reply.putInt32(static_cast(StartProcessResult::ProcessCreated)); + reply.putInt64(replyProcess); + reply.putInt64(replyThread); + } else { + reply.putInt32(static_cast(StartProcessResult::CreateProcessFailed)); + reply.putInt32(lastError); + } + writePacket(reply); +} + +void Agent::handleSetSizePacket(ReadBuffer &packet) +{ + const int cols = packet.getInt32(); + const int rows = packet.getInt32(); + packet.assertEof(); + resizeWindow(cols, rows); + auto reply = newPacket(); + writePacket(reply); +} + +void Agent::handleGetConsoleProcessListPacket(ReadBuffer &packet) +{ + packet.assertEof(); + + auto processList = std::vector(64); + auto processCount = GetConsoleProcessList(&processList[0], processList.size()); + if (processList.size() < processCount) { + processList.resize(processCount); + processCount = GetConsoleProcessList(&processList[0], processList.size()); + } + + if (processCount == 0) { + trace("GetConsoleProcessList failed"); + } + + auto reply = newPacket(); + reply.putInt32(processCount); + for (DWORD i = 0; i < processCount; i++) { + reply.putInt32(processList[i]); + } + writePacket(reply); +} + +void Agent::pollConinPipe() +{ + const std::string newData = m_coninPipe->readAllToString(); + if (hasDebugFlag("input_separated_bytes")) { + // This debug flag is intended to help with testing incomplete escape + // sequences and multibyte UTF-8 encodings. (I wonder if the normal + // code path ought to advance a state machine one byte at a time.) + for (size_t i = 0; i < newData.size(); ++i) { + m_consoleInput->writeInput(newData.substr(i, 1)); + } + } else { + m_consoleInput->writeInput(newData); + } +} + +void Agent::onPollTimeout() +{ + m_consoleInput->updateInputFlags(); + const bool enableMouseMode = m_consoleInput->shouldActivateTerminalMouse(); + + // Give the ConsoleInput object a chance to flush input from an incomplete + // escape sequence (e.g. pressing ESC). + m_consoleInput->flushIncompleteEscapeCode(); + + const bool shouldScrapeContent = !m_closingOutputPipes; + + // Check if the child process has exited. + if (m_autoShutdown && + m_childProcess != nullptr && + WaitForSingleObject(m_childProcess, 0) == WAIT_OBJECT_0) { + CloseHandle(m_childProcess); + m_childProcess = nullptr; + + // Close the data socket to signal to the client that the child + // process has exited. If there's any data left to send, send it + // before closing the socket. + m_closingOutputPipes = true; + } + + // Scrape for output *after* the above exit-check to ensure that we collect + // the child process's final output. + if (shouldScrapeContent) { + syncConsoleTitle(); + scrapeBuffers(); + } + + // We must ensure that we disable mouse mode before closing the CONOUT + // pipe, so update the mouse mode here. + m_primaryScraper->terminal().enableMouseMode( + enableMouseMode && !m_closingOutputPipes); + + autoClosePipesForShutdown(); +} + +void Agent::autoClosePipesForShutdown() +{ + if (m_closingOutputPipes) { + // We don't want to close a pipe before it's connected! If we do, the + // libwinpty client may try to connect to a non-existent pipe. This + // case is important for short-lived programs. + if (m_conoutPipe->isConnected() && + m_conoutPipe->bytesToSend() == 0) { + trace("Closing CONOUT pipe (auto-shutdown)"); + m_conoutPipe->closePipe(); + } + if (m_conerrPipe != nullptr && + m_conerrPipe->isConnected() && + m_conerrPipe->bytesToSend() == 0) { + trace("Closing CONERR pipe (auto-shutdown)"); + m_conerrPipe->closePipe(); + } + if (m_exitAfterShutdown && + m_conoutPipe->isClosed() && + (m_conerrPipe == nullptr || m_conerrPipe->isClosed())) { + trace("Agent exiting (exit-after-shutdown)"); + shutdown(); + } + } +} + +std::unique_ptr Agent::openPrimaryBuffer() +{ + // If we're using a separate buffer for stderr, and a program were to + // activate the stderr buffer, then we could accidentally scrape the same + // buffer twice. That probably shouldn't happen in ordinary use, but it + // can be avoided anyway by using the original console screen buffer in + // that mode. + if (!m_useConerr) { + return Win32ConsoleBuffer::openConout(); + } else { + return Win32ConsoleBuffer::openStdout(); + } +} + +void Agent::resizeWindow(int cols, int rows) +{ + ASSERT(cols >= 1 && rows >= 1); + cols = std::min(cols, MAX_CONSOLE_WIDTH); + rows = std::min(rows, MAX_CONSOLE_HEIGHT); + + Win32Console::FreezeGuard guard(m_console, m_console.frozen()); + const Coord newSize(cols, rows); + ConsoleScreenBufferInfo info; + auto primaryBuffer = openPrimaryBuffer(); + m_primaryScraper->resizeWindow(*primaryBuffer, newSize, info); + m_consoleInput->setMouseWindowRect(info.windowRect()); + if (m_errorScraper) { + m_errorScraper->resizeWindow(*m_errorBuffer, newSize, info); + } + + // Synthesize a WINDOW_BUFFER_SIZE_EVENT event. Normally, Windows + // generates this event only when the buffer size changes, not when the + // window size changes. This behavior is undesirable in two ways: + // - When winpty expands the window horizontally, it must expand the + // buffer first, then the window. At least some programs (e.g. the WSL + // bash.exe wrapper) use the window width rather than the buffer width, + // so there is a short timespan during which they can read the wrong + // value. + // - If the window's vertical size is changed, no event is generated, + // even though a typical well-behaved console program cares about the + // *window* height, not the *buffer* height. + // This synthesization works around a design flaw in the console. It's probably + // harmless. See https://github.com/rprichard/winpty/issues/110. + INPUT_RECORD sizeEvent {}; + sizeEvent.EventType = WINDOW_BUFFER_SIZE_EVENT; + sizeEvent.Event.WindowBufferSizeEvent.dwSize = primaryBuffer->bufferSize(); + DWORD actual {}; + WriteConsoleInputW(GetStdHandle(STD_INPUT_HANDLE), &sizeEvent, 1, &actual); +} + +void Agent::scrapeBuffers() +{ + Win32Console::FreezeGuard guard(m_console, m_console.frozen()); + ConsoleScreenBufferInfo info; + m_primaryScraper->scrapeBuffer(*openPrimaryBuffer(), info); + m_consoleInput->setMouseWindowRect(info.windowRect()); + if (m_errorScraper) { + m_errorScraper->scrapeBuffer(*m_errorBuffer, info); + } +} + +void Agent::syncConsoleTitle() +{ + std::wstring newTitle = m_console.title(); + if (newTitle != m_currentTitle) { + std::string command = std::string("\x1b]0;") + + utf8FromWide(newTitle) + "\x07"; + m_conoutPipe->write(command.c_str()); + m_currentTitle = newTitle; + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Agent.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Agent.h new file mode 100644 index 00000000..1dde48fe --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Agent.h @@ -0,0 +1,103 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef AGENT_H +#define AGENT_H + +#include +#include + +#include +#include + +#include "DsrSender.h" +#include "EventLoop.h" +#include "Win32Console.h" + +class ConsoleInput; +class NamedPipe; +class ReadBuffer; +class Scraper; +class WriteBuffer; +class Win32ConsoleBuffer; + +class Agent : public EventLoop, public DsrSender +{ +public: + Agent(LPCWSTR controlPipeName, + uint64_t agentFlags, + int mouseMode, + int initialCols, + int initialRows); + virtual ~Agent(); + void sendDsr() override; + +private: + NamedPipe &connectToControlPipe(LPCWSTR pipeName); + NamedPipe &createDataServerPipe(bool write, const wchar_t *kind); + +private: + void pollControlPipe(); + void handlePacket(ReadBuffer &packet); + void writePacket(WriteBuffer &packet); + void handleStartProcessPacket(ReadBuffer &packet); + void handleSetSizePacket(ReadBuffer &packet); + void handleGetConsoleProcessListPacket(ReadBuffer &packet); + void pollConinPipe(); + +protected: + virtual void onPollTimeout() override; + virtual void onPipeIo(NamedPipe &namedPipe) override; + +private: + void autoClosePipesForShutdown(); + std::unique_ptr openPrimaryBuffer(); + void resizeWindow(int cols, int rows); + void scrapeBuffers(); + void syncConsoleTitle(); + +private: + const bool m_useConerr; + const bool m_plainMode; + const int m_mouseMode; + Win32Console m_console; + std::unique_ptr m_primaryScraper; + std::unique_ptr m_errorScraper; + std::unique_ptr m_errorBuffer; + NamedPipe *m_controlPipe = nullptr; + NamedPipe *m_coninPipe = nullptr; + NamedPipe *m_conoutPipe = nullptr; + NamedPipe *m_conerrPipe = nullptr; + bool m_autoShutdown = false; + bool m_exitAfterShutdown = false; + bool m_closingOutputPipes = false; + std::unique_ptr m_consoleInput; + HANDLE m_childProcess = nullptr; + + // If the title is initialized to the empty string, then cmd.exe will + // sometimes print this error: + // Not enough storage is available to process this command. + // It happens on Windows 7 when logged into a Cygwin SSH session, for + // example. Using a title of a single space character avoids the problem. + // See https://github.com/rprichard/winpty/issues/74. + std::wstring m_currentTitle = L" "; +}; + +#endif // AGENT_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/AgentCreateDesktop.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/AgentCreateDesktop.cc new file mode 100644 index 00000000..9ad6503b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/AgentCreateDesktop.cc @@ -0,0 +1,84 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "AgentCreateDesktop.h" + +#include "../shared/BackgroundDesktop.h" +#include "../shared/Buffer.h" +#include "../shared/DebugClient.h" +#include "../shared/StringUtil.h" + +#include "EventLoop.h" +#include "NamedPipe.h" + +namespace { + +static inline WriteBuffer newPacket() { + WriteBuffer packet; + packet.putRawValue(0); // Reserve space for size. + return packet; +} + +class CreateDesktopLoop : public EventLoop { +public: + CreateDesktopLoop(LPCWSTR controlPipeName); + +protected: + virtual void onPipeIo(NamedPipe &namedPipe) override; + +private: + void writePacket(WriteBuffer &packet); + + BackgroundDesktop m_desktop; + NamedPipe &m_pipe; +}; + +CreateDesktopLoop::CreateDesktopLoop(LPCWSTR controlPipeName) : + m_pipe(createNamedPipe()) { + m_pipe.connectToServer(controlPipeName, NamedPipe::OpenMode::Duplex); + auto packet = newPacket(); + packet.putWString(m_desktop.desktopName()); + writePacket(packet); +} + +void CreateDesktopLoop::writePacket(WriteBuffer &packet) { + const auto &bytes = packet.buf(); + packet.replaceRawValue(0, bytes.size()); + m_pipe.write(bytes.data(), bytes.size()); +} + +void CreateDesktopLoop::onPipeIo(NamedPipe &namedPipe) { + if (m_pipe.isClosed()) { + shutdown(); + } +} + +} // anonymous namespace + +void handleCreateDesktop(LPCWSTR controlPipeName) { + try { + CreateDesktopLoop loop(controlPipeName); + loop.run(); + trace("Agent exiting..."); + } catch (const WinptyException &e) { + trace("handleCreateDesktop: internal error: %s", + utf8FromWide(e.what()).c_str()); + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/AgentCreateDesktop.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/AgentCreateDesktop.h new file mode 100644 index 00000000..2ae539c7 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/AgentCreateDesktop.h @@ -0,0 +1,28 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef AGENT_CREATE_DESKTOP_H +#define AGENT_CREATE_DESKTOP_H + +#include + +void handleCreateDesktop(LPCWSTR controlPipeName); + +#endif // AGENT_CREATE_DESKTOP_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleFont.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleFont.cc new file mode 100644 index 00000000..2e0d979a --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleFont.cc @@ -0,0 +1,632 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "ConsoleFont.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "../shared/DebugClient.h" +#include "../shared/OsModule.h" +#include "../shared/StringUtil.h" +#include "../shared/WindowsVersion.h" +#include "../shared/WinptyAssert.h" +#include "../shared/winpty_snprintf.h" + +namespace { + +#define COUNT_OF(x) (sizeof(x) / sizeof((x)[0])) + +// See https://en.wikipedia.org/wiki/List_of_CJK_fonts +const wchar_t kLucidaConsole[] = L"Lucida Console"; +const wchar_t kMSGothic[] = { 0xff2d, 0xff33, 0x0020, 0x30b4, 0x30b7, 0x30c3, 0x30af, 0 }; // 932, Japanese +const wchar_t kNSimSun[] = { 0x65b0, 0x5b8b, 0x4f53, 0 }; // 936, Chinese Simplified +const wchar_t kGulimChe[] = { 0xad74, 0xb9bc, 0xccb4, 0 }; // 949, Korean +const wchar_t kMingLight[] = { 0x7d30, 0x660e, 0x9ad4, 0 }; // 950, Chinese Traditional + +struct FontSize { + short size; + int width; +}; + +struct Font { + const wchar_t *faceName; + unsigned int family; + short size; +}; + +// Ideographs in East Asian languages take two columns rather than one. +// In the console screen buffer, a "full-width" character will occupy two +// cells of the buffer, the first with attribute 0x100 and the second with +// attribute 0x200. +// +// Windows does not correctly identify code points as double-width in all +// configurations. It depends heavily on the code page, the font facename, +// and (somehow) even the font size. In the 437 code page (MS-DOS), for +// example, no codepoints are interpreted as double-width. When the console +// is in an East Asian code page (932, 936, 949, or 950), then sometimes +// selecting a "Western" facename like "Lucida Console" or "Consolas" doesn't +// register, or if the font *can* be chosen, then the console doesn't handle +// double-width correctly. I tested the double-width handling by writing +// several code points with WriteConsole and checking whether one or two cells +// were filled. +// +// In the Japanese code page (932), Microsoft's default font is MS Gothic. +// MS Gothic double-width handling seems to be broken with console versions +// prior to Windows 10 (including Windows 10's legacy mode), and it's +// especially broken in Windows 8 and 8.1. +// +// Test by running: misc/Utf16Echo A2 A3 2014 3044 30FC 4000 +// +// The first three codepoints are always rendered as half-width with the +// Windows Japanese fonts. (Of these, the first two must be half-width, +// but U+2014 could be either.) The last three are rendered as full-width, +// and they are East_Asian_Width=Wide. +// +// Windows 7 fails by modeling all codepoints as full-width with font +// sizes 22 and above. +// +// Windows 8 gets U+00A2, U+00A3, U+2014, U+30FC, and U+4000 wrong, but +// using a point size not listed in the console properties dialog +// (e.g. "9") is less wrong: +// +// | code point | +// font | 00A2 00A3 2014 3044 30FC 4000 | cell size +// ------------+---------------------------------+---------- +// 8 | F F F F H H | 4x8 +// 9 | F F F F F F | 5x9 +// 16 | F F F F H H | 8x16 +// raster 6x13 | H H H F F H(*) | 6x13 +// +// (*) The Raster Font renders U+4000 as a white box (i.e. an unsupported +// character). +// + +// See: +// - misc/Font-Report-June2016 directory for per-size details +// - misc/font-notes.txt +// - misc/Utf16Echo.cc, misc/FontSurvey.cc, misc/SetFont.cc, misc/GetFont.cc + +const FontSize kLucidaFontSizes[] = { + { 5, 3 }, + { 6, 4 }, + { 8, 5 }, + { 10, 6 }, + { 12, 7 }, + { 14, 8 }, + { 16, 10 }, + { 18, 11 }, + { 20, 12 }, + { 36, 22 }, + { 48, 29 }, + { 60, 36 }, + { 72, 43 }, +}; + +// Japanese. Used on Vista and Windows 7. +const FontSize k932GothicVista[] = { + { 6, 3 }, + { 8, 4 }, + { 10, 5 }, + { 12, 6 }, + { 13, 7 }, + { 15, 8 }, + { 17, 9 }, + { 19, 10 }, + { 21, 11 }, + // All larger fonts are more broken w.r.t. full-size East Asian characters. +}; + +// Japanese. Used on Windows 8, 8.1, and the legacy 10 console. +const FontSize k932GothicWin8[] = { + // All of these characters are broken w.r.t. full-size East Asian + // characters, but they're equally broken. + { 5, 3 }, + { 7, 4 }, + { 9, 5 }, + { 11, 6 }, + { 13, 7 }, + { 15, 8 }, + { 17, 9 }, + { 20, 10 }, + { 22, 11 }, + { 24, 12 }, + // include extra-large fonts for small terminals + { 36, 18 }, + { 48, 24 }, + { 60, 30 }, + { 72, 36 }, +}; + +// Japanese. Used on the new Windows 10 console. +const FontSize k932GothicWin10[] = { + { 6, 3 }, + { 8, 4 }, + { 10, 5 }, + { 12, 6 }, + { 14, 7 }, + { 16, 8 }, + { 18, 9 }, + { 20, 10 }, + { 22, 11 }, + { 24, 12 }, + // include extra-large fonts for small terminals + { 36, 18 }, + { 48, 24 }, + { 60, 30 }, + { 72, 36 }, +}; + +// Chinese Simplified. +const FontSize k936SimSun[] = { + { 6, 3 }, + { 8, 4 }, + { 10, 5 }, + { 12, 6 }, + { 14, 7 }, + { 16, 8 }, + { 18, 9 }, + { 20, 10 }, + { 22, 11 }, + { 24, 12 }, + // include extra-large fonts for small terminals + { 36, 18 }, + { 48, 24 }, + { 60, 30 }, + { 72, 36 }, +}; + +// Korean. +const FontSize k949GulimChe[] = { + { 6, 3 }, + { 8, 4 }, + { 10, 5 }, + { 12, 6 }, + { 14, 7 }, + { 16, 8 }, + { 18, 9 }, + { 20, 10 }, + { 22, 11 }, + { 24, 12 }, + // include extra-large fonts for small terminals + { 36, 18 }, + { 48, 24 }, + { 60, 30 }, + { 72, 36 }, +}; + +// Chinese Traditional. +const FontSize k950MingLight[] = { + { 6, 3 }, + { 8, 4 }, + { 10, 5 }, + { 12, 6 }, + { 14, 7 }, + { 16, 8 }, + { 18, 9 }, + { 20, 10 }, + { 22, 11 }, + { 24, 12 }, + // include extra-large fonts for small terminals + { 36, 18 }, + { 48, 24 }, + { 60, 30 }, + { 72, 36 }, +}; + +// Some of these types and functions are missing from the MinGW headers. +// Others are undocumented. + +struct AGENT_CONSOLE_FONT_INFO { + DWORD nFont; + COORD dwFontSize; +}; + +struct AGENT_CONSOLE_FONT_INFOEX { + ULONG cbSize; + DWORD nFont; + COORD dwFontSize; + UINT FontFamily; + UINT FontWeight; + WCHAR FaceName[LF_FACESIZE]; +}; + +// undocumented XP API +typedef BOOL WINAPI SetConsoleFont_t( + HANDLE hOutput, + DWORD dwFontIndex); + +// undocumented XP API +typedef DWORD WINAPI GetNumberOfConsoleFonts_t(); + +// XP and up +typedef BOOL WINAPI GetCurrentConsoleFont_t( + HANDLE hOutput, + BOOL bMaximumWindow, + AGENT_CONSOLE_FONT_INFO *lpConsoleCurrentFont); + +// XP and up +typedef COORD WINAPI GetConsoleFontSize_t( + HANDLE hConsoleOutput, + DWORD nFont); + +// Vista and up +typedef BOOL WINAPI GetCurrentConsoleFontEx_t( + HANDLE hConsoleOutput, + BOOL bMaximumWindow, + AGENT_CONSOLE_FONT_INFOEX *lpConsoleCurrentFontEx); + +// Vista and up +typedef BOOL WINAPI SetCurrentConsoleFontEx_t( + HANDLE hConsoleOutput, + BOOL bMaximumWindow, + AGENT_CONSOLE_FONT_INFOEX *lpConsoleCurrentFontEx); + +#define GET_MODULE_PROC(mod, funcName) \ + m_##funcName = reinterpret_cast((mod).proc(#funcName)); \ + +#define DEFINE_ACCESSOR(funcName) \ + funcName##_t &funcName() const { \ + ASSERT(valid()); \ + return *m_##funcName; \ + } + +class XPFontAPI { +public: + XPFontAPI() : m_kernel32(L"kernel32.dll") { + GET_MODULE_PROC(m_kernel32, GetCurrentConsoleFont); + GET_MODULE_PROC(m_kernel32, GetConsoleFontSize); + } + + bool valid() const { + return m_GetCurrentConsoleFont != NULL && + m_GetConsoleFontSize != NULL; + } + + DEFINE_ACCESSOR(GetCurrentConsoleFont) + DEFINE_ACCESSOR(GetConsoleFontSize) + +private: + OsModule m_kernel32; + GetCurrentConsoleFont_t *m_GetCurrentConsoleFont; + GetConsoleFontSize_t *m_GetConsoleFontSize; +}; + +class VistaFontAPI : public XPFontAPI { +public: + VistaFontAPI() : m_kernel32(L"kernel32.dll") { + GET_MODULE_PROC(m_kernel32, GetCurrentConsoleFontEx); + GET_MODULE_PROC(m_kernel32, SetCurrentConsoleFontEx); + } + + bool valid() const { + return this->XPFontAPI::valid() && + m_GetCurrentConsoleFontEx != NULL && + m_SetCurrentConsoleFontEx != NULL; + } + + DEFINE_ACCESSOR(GetCurrentConsoleFontEx) + DEFINE_ACCESSOR(SetCurrentConsoleFontEx) + +private: + OsModule m_kernel32; + GetCurrentConsoleFontEx_t *m_GetCurrentConsoleFontEx; + SetCurrentConsoleFontEx_t *m_SetCurrentConsoleFontEx; +}; + +static std::vector > readFontTable( + XPFontAPI &api, HANDLE conout, DWORD maxCount) { + std::vector > ret; + for (DWORD i = 0; i < maxCount; ++i) { + COORD size = api.GetConsoleFontSize()(conout, i); + if (size.X == 0 && size.Y == 0) { + break; + } + ret.push_back(std::make_pair(i, size)); + } + return ret; +} + +static void dumpFontTable(HANDLE conout, const char *prefix) { + const int kMaxCount = 1000; + if (!isTracingEnabled()) { + return; + } + XPFontAPI api; + if (!api.valid()) { + trace("dumpFontTable: cannot dump font table -- missing APIs"); + return; + } + std::vector > table = + readFontTable(api, conout, kMaxCount); + std::string line; + char tmp[128]; + size_t first = 0; + while (first < table.size()) { + size_t last = std::min(table.size() - 1, first + 10 - 1); + winpty_snprintf(tmp, "%sfonts %02u-%02u:", + prefix, static_cast(first), static_cast(last)); + line = tmp; + for (size_t i = first; i <= last; ++i) { + if (i % 10 == 5) { + line += " - "; + } + winpty_snprintf(tmp, " %2dx%-2d", + table[i].second.X, table[i].second.Y); + line += tmp; + } + trace("%s", line.c_str()); + first = last + 1; + } + if (table.size() == kMaxCount) { + trace("%sfonts: ... stopped reading at %d fonts ...", + prefix, kMaxCount); + } +} + +static std::string stringToCodePoints(const std::wstring &str) { + std::string ret = "("; + for (size_t i = 0; i < str.size(); ++i) { + char tmp[32]; + winpty_snprintf(tmp, "%X", str[i]); + if (ret.size() > 1) { + ret.push_back(' '); + } + ret += tmp; + } + ret.push_back(')'); + return ret; +} + +static void dumpFontInfoEx( + const AGENT_CONSOLE_FONT_INFOEX &infoex, + const char *prefix) { + if (!isTracingEnabled()) { + return; + } + std::wstring faceName(infoex.FaceName, + winpty_wcsnlen(infoex.FaceName, COUNT_OF(infoex.FaceName))); + trace("%snFont=%u dwFontSize=(%d,%d) " + "FontFamily=0x%x FontWeight=%u FaceName=%s %s", + prefix, + static_cast(infoex.nFont), + infoex.dwFontSize.X, infoex.dwFontSize.Y, + infoex.FontFamily, infoex.FontWeight, utf8FromWide(faceName).c_str(), + stringToCodePoints(faceName).c_str()); +} + +static void dumpVistaFont(VistaFontAPI &api, HANDLE conout, const char *prefix) { + if (!isTracingEnabled()) { + return; + } + AGENT_CONSOLE_FONT_INFOEX infoex = {0}; + infoex.cbSize = sizeof(infoex); + if (!api.GetCurrentConsoleFontEx()(conout, FALSE, &infoex)) { + trace("GetCurrentConsoleFontEx call failed"); + return; + } + dumpFontInfoEx(infoex, prefix); +} + +static void dumpXPFont(XPFontAPI &api, HANDLE conout, const char *prefix) { + if (!isTracingEnabled()) { + return; + } + AGENT_CONSOLE_FONT_INFO info = {0}; + if (!api.GetCurrentConsoleFont()(conout, FALSE, &info)) { + trace("GetCurrentConsoleFont call failed"); + return; + } + trace("%snFont=%u dwFontSize=(%d,%d)", + prefix, + static_cast(info.nFont), + info.dwFontSize.X, info.dwFontSize.Y); +} + +static bool setFontVista( + VistaFontAPI &api, + HANDLE conout, + const Font &font) { + AGENT_CONSOLE_FONT_INFOEX infoex = {}; + infoex.cbSize = sizeof(AGENT_CONSOLE_FONT_INFOEX); + infoex.dwFontSize.Y = font.size; + infoex.FontFamily = font.family; + infoex.FontWeight = 400; + winpty_wcsncpy_nul(infoex.FaceName, font.faceName); + dumpFontInfoEx(infoex, "setFontVista: setting font to: "); + if (!api.SetCurrentConsoleFontEx()(conout, FALSE, &infoex)) { + trace("setFontVista: SetCurrentConsoleFontEx call failed"); + return false; + } + memset(&infoex, 0, sizeof(infoex)); + infoex.cbSize = sizeof(infoex); + if (!api.GetCurrentConsoleFontEx()(conout, FALSE, &infoex)) { + trace("setFontVista: GetCurrentConsoleFontEx call failed"); + return false; + } + if (wcsncmp(infoex.FaceName, font.faceName, + COUNT_OF(infoex.FaceName)) != 0) { + trace("setFontVista: face name was not set"); + dumpFontInfoEx(infoex, "setFontVista: post-call font: "); + return false; + } + // We'd like to verify that the new font size is correct, but we can't + // predict what it will be, even though we just set it to `pxSize` through + // an apprently symmetric interface. For the Chinese and Korean fonts, the + // new `infoex.dwFontSize.Y` value can be slightly larger than the height + // we specified. + return true; +} + +static Font selectSmallFont(int codePage, int columns, bool isNewW10) { + // Iterate over a set of font sizes according to the code page, and select + // one. + + const wchar_t *faceName = nullptr; + unsigned int fontFamily = 0; + const FontSize *table = nullptr; + size_t tableSize = 0; + + switch (codePage) { + case 932: // Japanese + faceName = kMSGothic; + fontFamily = 0x36; + if (isNewW10) { + table = k932GothicWin10; + tableSize = COUNT_OF(k932GothicWin10); + } else if (isAtLeastWindows8()) { + table = k932GothicWin8; + tableSize = COUNT_OF(k932GothicWin8); + } else { + table = k932GothicVista; + tableSize = COUNT_OF(k932GothicVista); + } + break; + case 936: // Chinese Simplified + faceName = kNSimSun; + fontFamily = 0x36; + table = k936SimSun; + tableSize = COUNT_OF(k936SimSun); + break; + case 949: // Korean + faceName = kGulimChe; + fontFamily = 0x36; + table = k949GulimChe; + tableSize = COUNT_OF(k949GulimChe); + break; + case 950: // Chinese Traditional + faceName = kMingLight; + fontFamily = 0x36; + table = k950MingLight; + tableSize = COUNT_OF(k950MingLight); + break; + default: + faceName = kLucidaConsole; + fontFamily = 0x36; + table = kLucidaFontSizes; + tableSize = COUNT_OF(kLucidaFontSizes); + break; + } + + size_t bestIndex = static_cast(-1); + std::tuple bestScore = std::make_tuple(-1, -1); + + // We might want to pick the smallest possible font, because we don't know + // how large the monitor is (and the monitor size can change). We might + // want to pick a larger font to accommodate console programs that resize + // the console on their own, like DOS edit.com, which tends to resize the + // console to 80 columns. + + for (size_t i = 0; i < tableSize; ++i) { + const int width = table[i].width * columns; + + // In general, we'd like to pick a font size where cutting the number + // of columns in half doesn't immediately violate the minimum width + // constraint. (e.g. To run DOS edit.com, a user might resize their + // terminal to ~100 columns so it's big enough to show the 80 columns + // post-resize.) To achieve this, give priority to fonts that allow + // this halving. We don't want to encourage *very* large fonts, + // though, so disable the effect as the number of columns scales from + // 80 to 40. + const int halfColumns = std::min(columns, std::max(40, columns / 2)); + const int halfWidth = table[i].width * halfColumns; + + std::tuple thisScore = std::make_tuple(-1, -1); + if (width >= 160 && halfWidth >= 160) { + // Both sizes are good. Prefer the smaller fonts. + thisScore = std::make_tuple(2, -width); + } else if (width >= 160) { + // Prefer the smaller fonts. + thisScore = std::make_tuple(1, -width); + } else { + // Otherwise, prefer the largest font in our table. + thisScore = std::make_tuple(0, width); + } + if (thisScore > bestScore) { + bestIndex = i; + bestScore = thisScore; + } + } + + ASSERT(bestIndex != static_cast(-1)); + return Font { faceName, fontFamily, table[bestIndex].size }; +} + +static void setSmallFontVista(VistaFontAPI &api, HANDLE conout, + int columns, bool isNewW10) { + int codePage = GetConsoleOutputCP(); + const auto font = selectSmallFont(codePage, columns, isNewW10); + if (setFontVista(api, conout, font)) { + trace("setSmallFontVista: success"); + return; + } + if (codePage == 932 || codePage == 936 || + codePage == 949 || codePage == 950) { + trace("setSmallFontVista: falling back to default codepage font instead"); + const auto fontFB = selectSmallFont(0, columns, isNewW10); + if (setFontVista(api, conout, fontFB)) { + trace("setSmallFontVista: fallback was successful"); + return; + } + } + trace("setSmallFontVista: failure"); +} + +struct FontSizeComparator { + bool operator()(const std::pair &obj1, + const std::pair &obj2) const { + int score1 = obj1.second.X + obj1.second.Y; + int score2 = obj2.second.X + obj2.second.Y; + return score1 < score2; + } +}; + +} // anonymous namespace + +// A Windows console window can never be larger than the desktop window. To +// maximize the possible size of the console in rows*cols, try to configure +// the console with a small font. Unfortunately, we cannot make the font *too* +// small, because there is also a minimum window size in pixels. +void setSmallFont(HANDLE conout, int columns, bool isNewW10) { + trace("setSmallFont: attempting to set a small font for %d columns " + "(CP=%u OutputCP=%u)", + columns, + static_cast(GetConsoleCP()), + static_cast(GetConsoleOutputCP())); + VistaFontAPI vista; + if (vista.valid()) { + dumpVistaFont(vista, conout, "previous font: "); + dumpFontTable(conout, "previous font table: "); + setSmallFontVista(vista, conout, columns, isNewW10); + dumpVistaFont(vista, conout, "new font: "); + dumpFontTable(conout, "new font table: "); + return; + } + trace("setSmallFont: neither Vista nor XP APIs detected -- giving up"); + dumpFontTable(conout, "font table: "); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleFont.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleFont.h new file mode 100644 index 00000000..99cb1069 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleFont.h @@ -0,0 +1,28 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef CONSOLEFONT_H +#define CONSOLEFONT_H + +#include + +void setSmallFont(HANDLE conout, int columns, bool isNewW10); + +#endif // CONSOLEFONT_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInput.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInput.cc new file mode 100644 index 00000000..192cac2a --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInput.cc @@ -0,0 +1,852 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "ConsoleInput.h" + +#include +#include + +#include +#include + +#include "../include/winpty_constants.h" + +#include "../shared/DebugClient.h" +#include "../shared/StringBuilder.h" +#include "../shared/UnixCtrlChars.h" + +#include "ConsoleInputReencoding.h" +#include "DebugShowInput.h" +#include "DefaultInputMap.h" +#include "DsrSender.h" +#include "UnicodeEncoding.h" +#include "Win32Console.h" + +// MAPVK_VK_TO_VSC isn't defined by the old MinGW. +#ifndef MAPVK_VK_TO_VSC +#define MAPVK_VK_TO_VSC 0 +#endif + +namespace { + +struct MouseRecord { + bool release; + int flags; + COORD coord; + + std::string toString() const; +}; + +std::string MouseRecord::toString() const { + StringBuilder sb(40); + sb << "pos=" << coord.X << ',' << coord.Y + << " flags=0x" << hexOfInt(flags); + if (release) { + sb << " release"; + } + return sb.str_moved(); +} + +const unsigned int kIncompleteEscapeTimeoutMs = 1000u; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { return 0; } \ + } while(0) + +#define ADVANCE() \ + do { \ + pch++; \ + if (pch == stop) { return -1; } \ + } while(0) + +#define SCAN_INT(out, maxLen) \ + do { \ + (out) = 0; \ + CHECK(isdigit(*pch)); \ + const char *begin = pch; \ + do { \ + CHECK(pch - begin + 1 < maxLen); \ + (out) = (out) * 10 + *pch - '0'; \ + ADVANCE(); \ + } while (isdigit(*pch)); \ + } while(0) + +#define SCAN_SIGNED_INT(out, maxLen) \ + do { \ + bool negative = false; \ + if (*pch == '-') { \ + negative = true; \ + ADVANCE(); \ + } \ + SCAN_INT(out, maxLen); \ + if (negative) { \ + (out) = -(out); \ + } \ + } while(0) + +// Match the Device Status Report console input: ESC [ nn ; mm R +// Returns: +// 0 no match +// >0 match, returns length of match +// -1 incomplete match +static int matchDsr(const char *input, int inputSize) +{ + int32_t dummy = 0; + const char *pch = input; + const char *stop = input + inputSize; + CHECK(*pch == '\x1B'); ADVANCE(); + CHECK(*pch == '['); ADVANCE(); + SCAN_INT(dummy, 8); + CHECK(*pch == ';'); ADVANCE(); + SCAN_INT(dummy, 8); + CHECK(*pch == 'R'); + return pch - input + 1; +} + +static int matchMouseDefault(const char *input, int inputSize, + MouseRecord &out) +{ + const char *pch = input; + const char *stop = input + inputSize; + CHECK(*pch == '\x1B'); ADVANCE(); + CHECK(*pch == '['); ADVANCE(); + CHECK(*pch == 'M'); ADVANCE(); + out.flags = (*pch - 32) & 0xFF; ADVANCE(); + out.coord.X = (*pch - '!') & 0xFF; + ADVANCE(); + out.coord.Y = (*pch - '!') & 0xFF; + out.release = false; + return pch - input + 1; +} + +static int matchMouse1006(const char *input, int inputSize, MouseRecord &out) +{ + const char *pch = input; + const char *stop = input + inputSize; + int32_t temp; + CHECK(*pch == '\x1B'); ADVANCE(); + CHECK(*pch == '['); ADVANCE(); + CHECK(*pch == '<'); ADVANCE(); + SCAN_INT(out.flags, 8); + CHECK(*pch == ';'); ADVANCE(); + SCAN_SIGNED_INT(temp, 8); out.coord.X = temp - 1; + CHECK(*pch == ';'); ADVANCE(); + SCAN_SIGNED_INT(temp, 8); out.coord.Y = temp - 1; + CHECK(*pch == 'M' || *pch == 'm'); + out.release = (*pch == 'm'); + return pch - input + 1; +} + +static int matchMouse1015(const char *input, int inputSize, MouseRecord &out) +{ + const char *pch = input; + const char *stop = input + inputSize; + int32_t temp; + CHECK(*pch == '\x1B'); ADVANCE(); + CHECK(*pch == '['); ADVANCE(); + SCAN_INT(out.flags, 8); out.flags -= 32; + CHECK(*pch == ';'); ADVANCE(); + SCAN_SIGNED_INT(temp, 8); out.coord.X = temp - 1; + CHECK(*pch == ';'); ADVANCE(); + SCAN_SIGNED_INT(temp, 8); out.coord.Y = temp - 1; + CHECK(*pch == 'M'); + out.release = false; + return pch - input + 1; +} + +// Match a mouse input escape sequence of any kind. +// 0 no match +// >0 match, returns length of match +// -1 incomplete match +static int matchMouseRecord(const char *input, int inputSize, MouseRecord &out) +{ + memset(&out, 0, sizeof(out)); + int ret; + if ((ret = matchMouse1006(input, inputSize, out)) != 0) { return ret; } + if ((ret = matchMouse1015(input, inputSize, out)) != 0) { return ret; } + if ((ret = matchMouseDefault(input, inputSize, out)) != 0) { return ret; } + return 0; +} + +#undef CHECK +#undef ADVANCE +#undef SCAN_INT + +} // anonymous namespace + +ConsoleInput::ConsoleInput(HANDLE conin, int mouseMode, DsrSender &dsrSender, + Win32Console &console) : + m_console(console), + m_conin(conin), + m_mouseMode(mouseMode), + m_dsrSender(dsrSender) +{ + addDefaultEntriesToInputMap(m_inputMap); + if (hasDebugFlag("dump_input_map")) { + m_inputMap.dumpInputMap(); + } + + // Configure Quick Edit mode according to the mouse mode. Enable + // InsertMode for two reasons: + // - If it's OFF, it's difficult for the user to turn it ON. The + // properties dialog is inaccesible. winpty still faithfully handles + // the Insert key, which toggles between the insertion and overwrite + // modes. + // - When we modify the QuickEdit setting, if ExtendedFlags is OFF, + // then we must choose the InsertMode setting. I don't *think* this + // case happens, though, because a new console always has ExtendedFlags + // ON. + // See misc/EnableExtendedFlags.txt. + DWORD mode = 0; + if (!GetConsoleMode(conin, &mode)) { + trace("Agent startup: GetConsoleMode failed"); + } else { + mode |= ENABLE_EXTENDED_FLAGS; + mode |= ENABLE_INSERT_MODE; + if (m_mouseMode == WINPTY_MOUSE_MODE_AUTO) { + mode |= ENABLE_QUICK_EDIT_MODE; + } else { + mode &= ~ENABLE_QUICK_EDIT_MODE; + } + if (!SetConsoleMode(conin, mode)) { + trace("Agent startup: SetConsoleMode failed"); + } + } + + updateInputFlags(true); +} + +void ConsoleInput::writeInput(const std::string &input) +{ + if (input.size() == 0) { + return; + } + + if (isTracingEnabled()) { + static bool debugInput = hasDebugFlag("input"); + if (debugInput) { + std::string dumpString; + for (size_t i = 0; i < input.size(); ++i) { + const char ch = input[i]; + const char ctrl = decodeUnixCtrlChar(ch); + if (ctrl != '\0') { + dumpString += '^'; + dumpString += ctrl; + } else { + dumpString += ch; + } + } + dumpString += " ("; + for (size_t i = 0; i < input.size(); ++i) { + if (i > 0) { + dumpString += ' '; + } + const unsigned char uch = input[i]; + char buf[32]; + winpty_snprintf(buf, "%02X", uch); + dumpString += buf; + } + dumpString += ')'; + trace("input chars: %s", dumpString.c_str()); + } + } + + m_byteQueue.append(input); + doWrite(false); + if (!m_byteQueue.empty() && !m_dsrSent) { + trace("send DSR"); + m_dsrSender.sendDsr(); + m_dsrSent = true; + } + m_lastWriteTick = GetTickCount(); +} + +void ConsoleInput::flushIncompleteEscapeCode() +{ + if (!m_byteQueue.empty() && + (GetTickCount() - m_lastWriteTick) > kIncompleteEscapeTimeoutMs) { + doWrite(true); + m_byteQueue.clear(); + } +} + +void ConsoleInput::updateInputFlags(bool forceTrace) +{ + const DWORD mode = inputConsoleMode(); + const bool newFlagEE = (mode & ENABLE_EXTENDED_FLAGS) != 0; + const bool newFlagMI = (mode & ENABLE_MOUSE_INPUT) != 0; + const bool newFlagQE = (mode & ENABLE_QUICK_EDIT_MODE) != 0; + const bool newFlagEI = (mode & 0x200) != 0; + if (forceTrace || + newFlagEE != m_enableExtendedEnabled || + newFlagMI != m_mouseInputEnabled || + newFlagQE != m_quickEditEnabled || + newFlagEI != m_escapeInputEnabled) { + trace("CONIN modes: Extended=%s, MouseInput=%s QuickEdit=%s EscapeInput=%s", + newFlagEE ? "on" : "off", + newFlagMI ? "on" : "off", + newFlagQE ? "on" : "off", + newFlagEI ? "on" : "off"); + } + m_enableExtendedEnabled = newFlagEE; + m_mouseInputEnabled = newFlagMI; + m_quickEditEnabled = newFlagQE; + m_escapeInputEnabled = newFlagEI; +} + +bool ConsoleInput::shouldActivateTerminalMouse() +{ + // Return whether the agent should activate the terminal's mouse mode. + if (m_mouseMode == WINPTY_MOUSE_MODE_AUTO) { + // Some programs (e.g. Cygwin command-line programs like bash.exe and + // python2.7.exe) turn off ENABLE_EXTENDED_FLAGS and turn on + // ENABLE_MOUSE_INPUT, but do not turn off QuickEdit mode and do not + // actually care about mouse input. Only enable the terminal mouse + // mode if ENABLE_EXTENDED_FLAGS is on. See + // misc/EnableExtendedFlags.txt. + return m_mouseInputEnabled && !m_quickEditEnabled && + m_enableExtendedEnabled; + } else if (m_mouseMode == WINPTY_MOUSE_MODE_FORCE) { + return true; + } else { + return false; + } +} + +void ConsoleInput::doWrite(bool isEof) +{ + const char *data = m_byteQueue.c_str(); + std::vector records; + size_t idx = 0; + while (idx < m_byteQueue.size()) { + int charSize = scanInput(records, &data[idx], m_byteQueue.size() - idx, isEof); + if (charSize == -1) + break; + idx += charSize; + } + m_byteQueue.erase(0, idx); + flushInputRecords(records); +} + +void ConsoleInput::flushInputRecords(std::vector &records) +{ + if (records.size() == 0) { + return; + } + DWORD actual = 0; + if (!WriteConsoleInputW(m_conin, records.data(), records.size(), &actual)) { + trace("WriteConsoleInputW failed"); + } + records.clear(); +} + +// This behavior isn't strictly correct, because the keypresses (probably?) +// adopt the keyboard state (e.g. Ctrl/Alt/Shift modifiers) of the current +// window station's keyboard, which has no necessary relationship to the winpty +// instance. It's unlikely to be an issue in practice, but it's conceivable. +// (Imagine a foreground SSH server, where the local user holds down Ctrl, +// while the remote user tries to use WSL navigation keys.) I suspect using +// the BackgroundDesktop mechanism in winpty would fix the problem. +// +// https://github.com/rprichard/winpty/issues/116 +static void sendKeyMessage(HWND hwnd, bool isKeyDown, uint16_t virtualKey) +{ + uint32_t scanCode = MapVirtualKey(virtualKey, MAPVK_VK_TO_VSC); + if (scanCode > 255) { + scanCode = 0; + } + SendMessage(hwnd, isKeyDown ? WM_KEYDOWN : WM_KEYUP, virtualKey, + (scanCode << 16) | 1u | (isKeyDown ? 0u : 0xc0000000u)); +} + +int ConsoleInput::scanInput(std::vector &records, + const char *input, + int inputSize, + bool isEof) +{ + ASSERT(inputSize >= 1); + + // Ctrl-C. + // + // In processed mode, use GenerateConsoleCtrlEvent so that Ctrl-C handlers + // are called. GenerateConsoleCtrlEvent unfortunately doesn't interrupt + // ReadConsole calls[1]. Using WM_KEYDOWN/UP fixes the ReadConsole + // problem, but breaks in background window stations/desktops. + // + // In unprocessed mode, there's an entry for Ctrl-C in the SimpleEncoding + // table in DefaultInputMap. + // + // [1] https://github.com/rprichard/winpty/issues/116 + if (input[0] == '\x03' && (inputConsoleMode() & ENABLE_PROCESSED_INPUT)) { + flushInputRecords(records); + trace("Ctrl-C"); + const BOOL ret = GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0); + trace("GenerateConsoleCtrlEvent: %d", ret); + return 1; + } + + if (input[0] == '\x1B') { + // Attempt to match the Device Status Report (DSR) reply. + int dsrLen = matchDsr(input, inputSize); + if (dsrLen > 0) { + trace("Received a DSR reply"); + m_dsrSent = false; + return dsrLen; + } else if (!isEof && dsrLen == -1) { + // Incomplete DSR match. + trace("Incomplete DSR match"); + return -1; + } + + int mouseLen = scanMouseInput(records, input, inputSize); + if (mouseLen > 0 || (!isEof && mouseLen == -1)) { + return mouseLen; + } + } + + // Search the input map. + InputMap::Key match; + bool incomplete; + int matchLen = m_inputMap.lookupKey(input, inputSize, match, incomplete); + if (!isEof && incomplete) { + // Incomplete match -- need more characters (or wait for a + // timeout to signify flushed input). + trace("Incomplete escape sequence"); + return -1; + } else if (matchLen > 0) { + uint32_t winCodePointDn = match.unicodeChar; + if ((match.keyState & LEFT_CTRL_PRESSED) && (match.keyState & LEFT_ALT_PRESSED)) { + winCodePointDn = '\0'; + } + uint32_t winCodePointUp = winCodePointDn; + if (match.keyState & LEFT_ALT_PRESSED) { + winCodePointUp = '\0'; + } + appendKeyPress(records, match.virtualKey, + winCodePointDn, winCodePointUp, match.keyState, + match.unicodeChar, match.keyState); + return matchLen; + } + + // Recognize Alt-. + // + // This code doesn't match Alt-ESC, which is encoded as `ESC ESC`, but + // maybe it should. I was concerned that pressing ESC rapidly enough could + // accidentally trigger Alt-ESC. (e.g. The user would have to be faster + // than the DSR flushing mechanism or use a decrepit terminal. The user + // might be on a slow network connection.) + if (input[0] == '\x1B' && inputSize >= 2 && input[1] != '\x1B') { + const int len = utf8CharLength(input[1]); + if (len > 0) { + if (1 + len > inputSize) { + // Incomplete character. + trace("Incomplete UTF-8 character in Alt-"); + return -1; + } + appendUtf8Char(records, &input[1], len, true); + return 1 + len; + } + } + + // A UTF-8 character. + const int len = utf8CharLength(input[0]); + if (len == 0) { + static bool debugInput = isTracingEnabled() && hasDebugFlag("input"); + if (debugInput) { + trace("Discarding invalid input byte: %02X", + static_cast(input[0])); + } + return 1; + } + if (len > inputSize) { + // Incomplete character. + trace("Incomplete UTF-8 character"); + return -1; + } + appendUtf8Char(records, &input[0], len, false); + return len; +} + +int ConsoleInput::scanMouseInput(std::vector &records, + const char *input, + int inputSize) +{ + MouseRecord record; + const int len = matchMouseRecord(input, inputSize, record); + if (len <= 0) { + return len; + } + + if (isTracingEnabled()) { + static bool debugInput = hasDebugFlag("input"); + if (debugInput) { + trace("mouse input: %s", record.toString().c_str()); + } + } + + const int button = record.flags & 0x03; + INPUT_RECORD newRecord = {0}; + newRecord.EventType = MOUSE_EVENT; + MOUSE_EVENT_RECORD &mer = newRecord.Event.MouseEvent; + + mer.dwMousePosition.X = + m_mouseWindowRect.Left + + std::max(0, std::min(record.coord.X, + m_mouseWindowRect.width() - 1)); + + mer.dwMousePosition.Y = + m_mouseWindowRect.Top + + std::max(0, std::min(record.coord.Y, + m_mouseWindowRect.height() - 1)); + + // The modifier state is neatly independent of everything else. + if (record.flags & 0x04) { mer.dwControlKeyState |= SHIFT_PRESSED; } + if (record.flags & 0x08) { mer.dwControlKeyState |= LEFT_ALT_PRESSED; } + if (record.flags & 0x10) { mer.dwControlKeyState |= LEFT_CTRL_PRESSED; } + + if (record.flags & 0x40) { + // Mouse wheel + mer.dwEventFlags |= MOUSE_WHEELED; + if (button == 0) { + // up + mer.dwButtonState |= 0x00780000; + } else if (button == 1) { + // down + mer.dwButtonState |= 0xff880000; + } else { + // Invalid -- do nothing + return len; + } + } else { + // Ordinary mouse event + if (record.flags & 0x20) { mer.dwEventFlags |= MOUSE_MOVED; } + if (button == 3) { + m_mouseButtonState = 0; + // Potentially advance double-click detection. + m_doubleClick.released = true; + } else { + const DWORD relevantFlag = + (button == 0) ? FROM_LEFT_1ST_BUTTON_PRESSED : + (button == 1) ? FROM_LEFT_2ND_BUTTON_PRESSED : + (button == 2) ? RIGHTMOST_BUTTON_PRESSED : + 0; + ASSERT(relevantFlag != 0); + if (record.release) { + m_mouseButtonState &= ~relevantFlag; + if (relevantFlag == m_doubleClick.button) { + // Potentially advance double-click detection. + m_doubleClick.released = true; + } else { + // End double-click detection. + m_doubleClick = DoubleClickDetection(); + } + } else if ((m_mouseButtonState & relevantFlag) == 0) { + // The button has been newly pressed. + m_mouseButtonState |= relevantFlag; + // Detect a double-click. This code looks for an exact + // coordinate match, which is stricter than what Windows does, + // but Windows has pixel coordinates, and we only have terminal + // coordinates. + if (m_doubleClick.button == relevantFlag && + m_doubleClick.pos == record.coord && + (GetTickCount() - m_doubleClick.tick < + GetDoubleClickTime())) { + // Record a double-click and end double-click detection. + mer.dwEventFlags |= DOUBLE_CLICK; + m_doubleClick = DoubleClickDetection(); + } else { + // Begin double-click detection. + m_doubleClick.button = relevantFlag; + m_doubleClick.pos = record.coord; + m_doubleClick.tick = GetTickCount(); + } + } + } + } + + mer.dwButtonState |= m_mouseButtonState; + + if (m_mouseInputEnabled && !m_quickEditEnabled) { + if (isTracingEnabled()) { + static bool debugInput = hasDebugFlag("input"); + if (debugInput) { + trace("mouse event: %s", mouseEventToString(mer).c_str()); + } + } + + records.push_back(newRecord); + } + + return len; +} + +void ConsoleInput::appendUtf8Char(std::vector &records, + const char *charBuffer, + const int charLen, + const bool terminalAltEscape) +{ + const uint32_t codePoint = decodeUtf8(charBuffer); + if (codePoint == static_cast(-1)) { + static bool debugInput = isTracingEnabled() && hasDebugFlag("input"); + if (debugInput) { + StringBuilder error(64); + error << "Discarding invalid UTF-8 sequence:"; + for (int i = 0; i < charLen; ++i) { + error << ' '; + error << hexOfInt(charBuffer[i]); + } + trace("%s", error.c_str()); + } + return; + } + + const short charScan = codePoint > 0xFFFF ? -1 : VkKeyScan(codePoint); + uint16_t virtualKey = 0; + uint16_t winKeyState = 0; + uint32_t winCodePointDn = codePoint; + uint32_t winCodePointUp = codePoint; + uint16_t vtKeyState = 0; + + if (charScan != -1) { + virtualKey = charScan & 0xFF; + if (charScan & 0x100) { + winKeyState |= SHIFT_PRESSED; + } + if (charScan & 0x200) { + winKeyState |= LEFT_CTRL_PRESSED; + } + if (charScan & 0x400) { + winKeyState |= RIGHT_ALT_PRESSED; + } + if (terminalAltEscape && (winKeyState & LEFT_CTRL_PRESSED)) { + // If the terminal escapes a Ctrl- with Alt, then set the + // codepoint to 0. On the other hand, if a character requires + // AltGr (like U+00B2 on a German layout), then VkKeyScan will + // report both Ctrl and Alt pressed, and we should keep the + // codepoint. See https://github.com/rprichard/winpty/issues/109. + winCodePointDn = 0; + winCodePointUp = 0; + } + } + if (terminalAltEscape) { + winCodePointUp = 0; + winKeyState |= LEFT_ALT_PRESSED; + vtKeyState |= LEFT_ALT_PRESSED; + } + + appendKeyPress(records, virtualKey, + winCodePointDn, winCodePointUp, winKeyState, + codePoint, vtKeyState); +} + +void ConsoleInput::appendKeyPress(std::vector &records, + const uint16_t virtualKey, + const uint32_t winCodePointDn, + const uint32_t winCodePointUp, + const uint16_t winKeyState, + const uint32_t vtCodePoint, + const uint16_t vtKeyState) +{ + const bool ctrl = (winKeyState & LEFT_CTRL_PRESSED) != 0; + const bool leftAlt = (winKeyState & LEFT_ALT_PRESSED) != 0; + const bool rightAlt = (winKeyState & RIGHT_ALT_PRESSED) != 0; + const bool shift = (winKeyState & SHIFT_PRESSED) != 0; + const bool enhanced = (winKeyState & ENHANCED_KEY) != 0; + bool hasDebugInput = false; + + if (isTracingEnabled()) { + static bool debugInput = hasDebugFlag("input"); + if (debugInput) { + hasDebugInput = true; + InputMap::Key key = { virtualKey, winCodePointDn, winKeyState }; + trace("keypress: %s", key.toString().c_str()); + } + } + + if (m_escapeInputEnabled && + (virtualKey == VK_UP || + virtualKey == VK_DOWN || + virtualKey == VK_LEFT || + virtualKey == VK_RIGHT || + virtualKey == VK_HOME || + virtualKey == VK_END) && + !ctrl && !leftAlt && !rightAlt && !shift) { + flushInputRecords(records); + if (hasDebugInput) { + trace("sending keypress to console HWND"); + } + sendKeyMessage(m_console.hwnd(), true, virtualKey); + sendKeyMessage(m_console.hwnd(), false, virtualKey); + return; + } + + uint16_t stepKeyState = 0; + if (ctrl) { + stepKeyState |= LEFT_CTRL_PRESSED; + appendInputRecord(records, TRUE, VK_CONTROL, 0, stepKeyState); + } + if (leftAlt) { + stepKeyState |= LEFT_ALT_PRESSED; + appendInputRecord(records, TRUE, VK_MENU, 0, stepKeyState); + } + if (rightAlt) { + stepKeyState |= RIGHT_ALT_PRESSED; + appendInputRecord(records, TRUE, VK_MENU, 0, stepKeyState | ENHANCED_KEY); + } + if (shift) { + stepKeyState |= SHIFT_PRESSED; + appendInputRecord(records, TRUE, VK_SHIFT, 0, stepKeyState); + } + if (enhanced) { + stepKeyState |= ENHANCED_KEY; + } + if (m_escapeInputEnabled) { + reencodeEscapedKeyPress(records, virtualKey, vtCodePoint, vtKeyState); + } else { + appendCPInputRecords(records, TRUE, virtualKey, winCodePointDn, stepKeyState); + } + appendCPInputRecords(records, FALSE, virtualKey, winCodePointUp, stepKeyState); + if (enhanced) { + stepKeyState &= ~ENHANCED_KEY; + } + if (shift) { + stepKeyState &= ~SHIFT_PRESSED; + appendInputRecord(records, FALSE, VK_SHIFT, 0, stepKeyState); + } + if (rightAlt) { + stepKeyState &= ~RIGHT_ALT_PRESSED; + appendInputRecord(records, FALSE, VK_MENU, 0, stepKeyState | ENHANCED_KEY); + } + if (leftAlt) { + stepKeyState &= ~LEFT_ALT_PRESSED; + appendInputRecord(records, FALSE, VK_MENU, 0, stepKeyState); + } + if (ctrl) { + stepKeyState &= ~LEFT_CTRL_PRESSED; + appendInputRecord(records, FALSE, VK_CONTROL, 0, stepKeyState); + } +} + +void ConsoleInput::appendCPInputRecords(std::vector &records, + BOOL keyDown, + uint16_t virtualKey, + uint32_t codePoint, + uint16_t keyState) +{ + // This behavior really doesn't match that of the Windows console (in + // normal, non-escape-mode). Judging by the copy-and-paste behavior, + // Windows apparently handles everything outside of the keyboard layout by + // first sending a sequence of Alt+KeyPad events, then finally a key-up + // event whose UnicodeChar has the appropriate value. For U+00A2 (CENT + // SIGN): + // + // key: dn rpt=1 scn=56 LAlt-MENU ch=0 + // key: dn rpt=1 scn=79 LAlt-NUMPAD1 ch=0 + // key: up rpt=1 scn=79 LAlt-NUMPAD1 ch=0 + // key: dn rpt=1 scn=76 LAlt-NUMPAD5 ch=0 + // key: up rpt=1 scn=76 LAlt-NUMPAD5 ch=0 + // key: dn rpt=1 scn=76 LAlt-NUMPAD5 ch=0 + // key: up rpt=1 scn=76 LAlt-NUMPAD5 ch=0 + // key: up rpt=1 scn=56 MENU ch=0xa2 + // + // The Alt+155 value matches the encoding of U+00A2 in CP-437. Curiously, + // if I use "chcp 1252" to change the encoding, then copy-and-pasting + // produces Alt+162 instead. (U+00A2 is 162 in CP-1252.) However, typing + // Alt+155 or Alt+162 produce the same characters regardless of console + // code page. (That is, they use CP-437 and yield U+00A2 and U+00F3.) + // + // For characters outside the BMP, Windows repeats the process for both + // UTF-16 code units, e.g, for U+1F300 (CYCLONE): + // + // key: dn rpt=1 scn=56 LAlt-MENU ch=0 + // key: dn rpt=1 scn=77 LAlt-NUMPAD6 ch=0 + // key: up rpt=1 scn=77 LAlt-NUMPAD6 ch=0 + // key: dn rpt=1 scn=81 LAlt-NUMPAD3 ch=0 + // key: up rpt=1 scn=81 LAlt-NUMPAD3 ch=0 + // key: up rpt=1 scn=56 MENU ch=0xd83c + // key: dn rpt=1 scn=56 LAlt-MENU ch=0 + // key: dn rpt=1 scn=77 LAlt-NUMPAD6 ch=0 + // key: up rpt=1 scn=77 LAlt-NUMPAD6 ch=0 + // key: dn rpt=1 scn=81 LAlt-NUMPAD3 ch=0 + // key: up rpt=1 scn=81 LAlt-NUMPAD3 ch=0 + // key: up rpt=1 scn=56 MENU ch=0xdf00 + // + // In this case, it sends Alt+63 twice, which signifies '?'. Apparently + // CMD and Cygwin bash are both able to decode this. + // + // Also note that typing Alt+NNN still works if NumLock is off, e.g.: + // + // key: dn rpt=1 scn=56 LAlt-MENU ch=0 + // key: dn rpt=1 scn=79 LAlt-END ch=0 + // key: up rpt=1 scn=79 LAlt-END ch=0 + // key: dn rpt=1 scn=76 LAlt-CLEAR ch=0 + // key: up rpt=1 scn=76 LAlt-CLEAR ch=0 + // key: dn rpt=1 scn=76 LAlt-CLEAR ch=0 + // key: up rpt=1 scn=76 LAlt-CLEAR ch=0 + // key: up rpt=1 scn=56 MENU ch=0xa2 + // + // Evidently, the Alt+NNN key events are not intended to be decoded to a + // character. Maybe programs are looking for a key-up ALT/MENU event with + // a non-zero character? + + wchar_t ws[2]; + const int wslen = encodeUtf16(ws, codePoint); + + if (wslen == 1) { + appendInputRecord(records, keyDown, virtualKey, ws[0], keyState); + } else if (wslen == 2) { + appendInputRecord(records, keyDown, virtualKey, ws[0], keyState); + appendInputRecord(records, keyDown, virtualKey, ws[1], keyState); + } else { + // This situation isn't that bad, but it should never happen, + // because invalid codepoints shouldn't reach this point. + trace("INTERNAL ERROR: appendInputRecordCP: invalid codePoint: " + "U+%04X", codePoint); + } +} + +void ConsoleInput::appendInputRecord(std::vector &records, + BOOL keyDown, + uint16_t virtualKey, + wchar_t utf16Char, + uint16_t keyState) +{ + INPUT_RECORD ir = {}; + ir.EventType = KEY_EVENT; + ir.Event.KeyEvent.bKeyDown = keyDown; + ir.Event.KeyEvent.wRepeatCount = 1; + ir.Event.KeyEvent.wVirtualKeyCode = virtualKey; + ir.Event.KeyEvent.wVirtualScanCode = + MapVirtualKey(virtualKey, MAPVK_VK_TO_VSC); + ir.Event.KeyEvent.uChar.UnicodeChar = utf16Char; + ir.Event.KeyEvent.dwControlKeyState = keyState; + records.push_back(ir); +} + +DWORD ConsoleInput::inputConsoleMode() +{ + DWORD mode = 0; + if (!GetConsoleMode(m_conin, &mode)) { + trace("GetConsoleMode failed"); + return 0; + } + return mode; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInput.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInput.h new file mode 100644 index 00000000..e807d973 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInput.h @@ -0,0 +1,109 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef CONSOLEINPUT_H +#define CONSOLEINPUT_H + +#include +#include + +#include +#include +#include + +#include "Coord.h" +#include "InputMap.h" +#include "SmallRect.h" + +class Win32Console; +class DsrSender; + +class ConsoleInput +{ +public: + ConsoleInput(HANDLE conin, int mouseMode, DsrSender &dsrSender, + Win32Console &console); + void writeInput(const std::string &input); + void flushIncompleteEscapeCode(); + void setMouseWindowRect(SmallRect val) { m_mouseWindowRect = val; } + void updateInputFlags(bool forceTrace=false); + bool shouldActivateTerminalMouse(); + +private: + void doWrite(bool isEof); + void flushInputRecords(std::vector &records); + int scanInput(std::vector &records, + const char *input, + int inputSize, + bool isEof); + int scanMouseInput(std::vector &records, + const char *input, + int inputSize); + void appendUtf8Char(std::vector &records, + const char *charBuffer, + int charLen, + bool terminalAltEscape); + void appendKeyPress(std::vector &records, + uint16_t virtualKey, + uint32_t winCodePointDn, + uint32_t winCodePointUp, + uint16_t winKeyState, + uint32_t vtCodePoint, + uint16_t vtKeyState); + +public: + static void appendCPInputRecords(std::vector &records, + BOOL keyDown, + uint16_t virtualKey, + uint32_t codePoint, + uint16_t keyState); + static void appendInputRecord(std::vector &records, + BOOL keyDown, + uint16_t virtualKey, + wchar_t utf16Char, + uint16_t keyState); + +private: + DWORD inputConsoleMode(); + +private: + Win32Console &m_console; + HANDLE m_conin = nullptr; + int m_mouseMode = 0; + DsrSender &m_dsrSender; + bool m_dsrSent = false; + std::string m_byteQueue; + InputMap m_inputMap; + DWORD m_lastWriteTick = 0; + DWORD m_mouseButtonState = 0; + struct DoubleClickDetection { + DWORD button = 0; + Coord pos; + DWORD tick = 0; + bool released = false; + } m_doubleClick; + bool m_enableExtendedEnabled = false; + bool m_mouseInputEnabled = false; + bool m_quickEditEnabled = false; + bool m_escapeInputEnabled = false; + SmallRect m_mouseWindowRect; +}; + +#endif // CONSOLEINPUT_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInputReencoding.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInputReencoding.cc new file mode 100644 index 00000000..b79545ee --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInputReencoding.cc @@ -0,0 +1,121 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "ConsoleInputReencoding.h" + +#include "ConsoleInput.h" + +namespace { + +static void outch(std::vector &out, wchar_t ch) { + ConsoleInput::appendInputRecord(out, TRUE, 0, ch, 0); +} + +} // anonymous namespace + +void reencodeEscapedKeyPress( + std::vector &out, + uint16_t virtualKey, + uint32_t codePoint, + uint16_t keyState) { + + struct EscapedKey { + enum { None, Numeric, Letter } kind; + wchar_t content[2]; + }; + + EscapedKey escapeCode = {}; + switch (virtualKey) { + case VK_UP: escapeCode = { EscapedKey::Letter, {'A'} }; break; + case VK_DOWN: escapeCode = { EscapedKey::Letter, {'B'} }; break; + case VK_RIGHT: escapeCode = { EscapedKey::Letter, {'C'} }; break; + case VK_LEFT: escapeCode = { EscapedKey::Letter, {'D'} }; break; + case VK_CLEAR: escapeCode = { EscapedKey::Letter, {'E'} }; break; + case VK_F1: escapeCode = { EscapedKey::Numeric, {'1', '1'} }; break; + case VK_F2: escapeCode = { EscapedKey::Numeric, {'1', '2'} }; break; + case VK_F3: escapeCode = { EscapedKey::Numeric, {'1', '3'} }; break; + case VK_F4: escapeCode = { EscapedKey::Numeric, {'1', '4'} }; break; + case VK_F5: escapeCode = { EscapedKey::Numeric, {'1', '5'} }; break; + case VK_F6: escapeCode = { EscapedKey::Numeric, {'1', '7'} }; break; + case VK_F7: escapeCode = { EscapedKey::Numeric, {'1', '8'} }; break; + case VK_F8: escapeCode = { EscapedKey::Numeric, {'1', '9'} }; break; + case VK_F9: escapeCode = { EscapedKey::Numeric, {'2', '0'} }; break; + case VK_F10: escapeCode = { EscapedKey::Numeric, {'2', '1'} }; break; + case VK_F11: escapeCode = { EscapedKey::Numeric, {'2', '3'} }; break; + case VK_F12: escapeCode = { EscapedKey::Numeric, {'2', '4'} }; break; + case VK_HOME: escapeCode = { EscapedKey::Letter, {'H'} }; break; + case VK_INSERT: escapeCode = { EscapedKey::Numeric, {'2'} }; break; + case VK_DELETE: escapeCode = { EscapedKey::Numeric, {'3'} }; break; + case VK_END: escapeCode = { EscapedKey::Letter, {'F'} }; break; + case VK_PRIOR: escapeCode = { EscapedKey::Numeric, {'5'} }; break; + case VK_NEXT: escapeCode = { EscapedKey::Numeric, {'6'} }; break; + } + if (escapeCode.kind != EscapedKey::None) { + int flags = 0; + if (keyState & SHIFT_PRESSED) { flags |= 0x1; } + if (keyState & LEFT_ALT_PRESSED) { flags |= 0x2; } + if (keyState & LEFT_CTRL_PRESSED) { flags |= 0x4; } + outch(out, L'\x1b'); + outch(out, L'['); + if (escapeCode.kind == EscapedKey::Numeric) { + for (wchar_t ch : escapeCode.content) { + if (ch != L'\0') { + outch(out, ch); + } + } + } else if (flags != 0) { + outch(out, L'1'); + } + if (flags != 0) { + outch(out, L';'); + outch(out, L'1' + flags); + } + if (escapeCode.kind == EscapedKey::Numeric) { + outch(out, L'~'); + } else { + outch(out, escapeCode.content[0]); + } + return; + } + + switch (virtualKey) { + case VK_BACK: + if (keyState & LEFT_ALT_PRESSED) { + outch(out, L'\x1b'); + } + outch(out, L'\x7f'); + return; + case VK_TAB: + if (keyState & SHIFT_PRESSED) { + outch(out, L'\x1b'); + outch(out, L'['); + outch(out, L'Z'); + return; + } + break; + } + + if (codePoint != 0) { + if (keyState & LEFT_ALT_PRESSED) { + outch(out, L'\x1b'); + } + ConsoleInput::appendCPInputRecords(out, TRUE, 0, codePoint, 0); + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInputReencoding.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInputReencoding.h new file mode 100644 index 00000000..63bc006b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleInputReencoding.h @@ -0,0 +1,36 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef AGENT_CONSOLE_INPUT_REENCODING_H +#define AGENT_CONSOLE_INPUT_REENCODING_H + +#include + +#include + +#include + +void reencodeEscapedKeyPress( + std::vector &records, + uint16_t virtualKey, + uint32_t codePoint, + uint16_t keyState); + +#endif // AGENT_CONSOLE_INPUT_REENCODING_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleLine.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleLine.cc new file mode 100644 index 00000000..1d2bcb76 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleLine.cc @@ -0,0 +1,152 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// +// ConsoleLine +// +// This data structure keep tracks of the previous CHAR_INFO content of an +// output line and determines when a line has changed. Detecting line changes +// is made complicated by terminal resizing. +// + +#include "ConsoleLine.h" + +#include + +#include "../shared/WinptyAssert.h" + +static CHAR_INFO blankChar(WORD attributes) +{ + // N.B.: As long as we write to UnicodeChar rather than AsciiChar, there + // are no padding bytes that could contain uninitialized bytes. This fact + // is important for efficient comparison. + CHAR_INFO ret; + ret.Attributes = attributes; + ret.Char.UnicodeChar = L' '; + return ret; +} + +static bool isLineBlank(const CHAR_INFO *line, int length, WORD attributes) +{ + for (int col = 0; col < length; ++col) { + if (line[col].Attributes != attributes || + line[col].Char.UnicodeChar != L' ') { + return false; + } + } + return true; +} + +static inline bool areLinesEqual( + const CHAR_INFO *line1, + const CHAR_INFO *line2, + int length) +{ + return memcmp(line1, line2, sizeof(CHAR_INFO) * length) == 0; +} + +ConsoleLine::ConsoleLine() : m_prevLength(0) +{ +} + +void ConsoleLine::reset() +{ + m_prevLength = 0; + m_prevData.clear(); +} + +// Determines whether the given line is sufficiently different from the +// previously seen line as to justify reoutputting the line. The function +// also sets the `ConsoleLine` to the given line, exactly as if `setLine` had +// been called. +bool ConsoleLine::detectChangeAndSetLine(const CHAR_INFO *const line, const int newLength) +{ + ASSERT(newLength >= 1); + ASSERT(m_prevLength <= static_cast(m_prevData.size())); + + if (newLength == m_prevLength) { + bool equalLines = areLinesEqual(m_prevData.data(), line, newLength); + if (!equalLines) { + setLine(line, newLength); + } + return !equalLines; + } else { + if (m_prevLength == 0) { + setLine(line, newLength); + return true; + } + + ASSERT(m_prevLength >= 1); + const WORD prevBlank = m_prevData[m_prevLength - 1].Attributes; + const WORD newBlank = line[newLength - 1].Attributes; + + bool equalLines = false; + if (newLength < m_prevLength) { + // The line has become shorter. The lines are equal if the common + // part is equal, and if the newly truncated characters were blank. + equalLines = + areLinesEqual(m_prevData.data(), line, newLength) && + isLineBlank(m_prevData.data() + newLength, + m_prevLength - newLength, + newBlank); + } else { + // + // The line has become longer. The lines are equal if the common + // part is equal, and if both the extra characters and any + // potentially reexposed characters are blank. + // + // Two of the most relevant terminals for winpty--mintty and + // jediterm--don't (currently) erase the obscured content when a + // line is cleared, so we should anticipate its existence when + // making a terminal wider and reoutput the line. See: + // + // * https://github.com/mintty/mintty/issues/480 + // * https://github.com/JetBrains/jediterm/issues/118 + // + ASSERT(newLength > m_prevLength); + equalLines = + areLinesEqual(m_prevData.data(), line, m_prevLength) && + isLineBlank(m_prevData.data() + m_prevLength, + std::min(m_prevData.size(), newLength) - m_prevLength, + prevBlank) && + isLineBlank(line + m_prevLength, + newLength - m_prevLength, + prevBlank); + } + setLine(line, newLength); + return !equalLines; + } +} + +void ConsoleLine::setLine(const CHAR_INFO *const line, const int newLength) +{ + if (static_cast(m_prevData.size()) < newLength) { + m_prevData.resize(newLength); + } + memcpy(m_prevData.data(), line, sizeof(CHAR_INFO) * newLength); + m_prevLength = newLength; +} + +void ConsoleLine::blank(WORD attributes) +{ + m_prevData.resize(1); + m_prevData[0] = blankChar(attributes); + m_prevLength = 1; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleLine.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleLine.h new file mode 100644 index 00000000..802c189c --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/ConsoleLine.h @@ -0,0 +1,41 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef CONSOLE_LINE_H +#define CONSOLE_LINE_H + +#include + +#include + +class ConsoleLine +{ +public: + ConsoleLine(); + void reset(); + bool detectChangeAndSetLine(const CHAR_INFO *line, int newLength); + void setLine(const CHAR_INFO *line, int newLength); + void blank(WORD attributes); +private: + int m_prevLength; + std::vector m_prevData; +}; + +#endif // CONSOLE_LINE_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Coord.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Coord.h new file mode 100644 index 00000000..74c98add --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Coord.h @@ -0,0 +1,87 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef COORD_H +#define COORD_H + +#include + +#include + +#include "../shared/winpty_snprintf.h" + +struct Coord : COORD { + Coord() + { + X = 0; + Y = 0; + } + + Coord(SHORT x, SHORT y) + { + X = x; + Y = y; + } + + Coord(COORD other) + { + *(COORD*)this = other; + } + + Coord(const Coord &other) + { + *(COORD*)this = *(const COORD*)&other; + } + + Coord &operator=(const Coord &other) + { + *(COORD*)this = *(const COORD*)&other; + return *this; + } + + bool operator==(const Coord &other) const + { + return X == other.X && Y == other.Y; + } + + bool operator!=(const Coord &other) const + { + return !(*this == other); + } + + Coord operator+(const Coord &other) const + { + return Coord(X + other.X, Y + other.Y); + } + + bool isEmpty() const + { + return X <= 0 || Y <= 0; + } + + std::string toString() const + { + char ret[32]; + winpty_snprintf(ret, "(%d,%d)", X, Y); + return std::string(ret); + } +}; + +#endif // COORD_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DebugShowInput.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DebugShowInput.cc new file mode 100644 index 00000000..191b2e14 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DebugShowInput.cc @@ -0,0 +1,239 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "DebugShowInput.h" + +#include +#include +#include +#include + +#include + +#include "../shared/StringBuilder.h" +#include "InputMap.h" + +namespace { + +struct Flag { + DWORD value; + const char *text; +}; + +static const Flag kButtonStates[] = { + { FROM_LEFT_1ST_BUTTON_PRESSED, "1" }, + { FROM_LEFT_2ND_BUTTON_PRESSED, "2" }, + { FROM_LEFT_3RD_BUTTON_PRESSED, "3" }, + { FROM_LEFT_4TH_BUTTON_PRESSED, "4" }, + { RIGHTMOST_BUTTON_PRESSED, "R" }, +}; + +static const Flag kControlKeyStates[] = { + { CAPSLOCK_ON, "CapsLock" }, + { ENHANCED_KEY, "Enhanced" }, + { LEFT_ALT_PRESSED, "LAlt" }, + { LEFT_CTRL_PRESSED, "LCtrl" }, + { NUMLOCK_ON, "NumLock" }, + { RIGHT_ALT_PRESSED, "RAlt" }, + { RIGHT_CTRL_PRESSED, "RCtrl" }, + { SCROLLLOCK_ON, "ScrollLock" }, + { SHIFT_PRESSED, "Shift" }, +}; + +static const Flag kMouseEventFlags[] = { + { DOUBLE_CLICK, "Double" }, + { 8/*MOUSE_HWHEELED*/, "HWheel" }, + { MOUSE_MOVED, "Move" }, + { MOUSE_WHEELED, "Wheel" }, +}; + +static void writeFlags(StringBuilder &out, DWORD flags, + const char *remainderName, + const Flag *table, size_t tableSize, + char pre, char sep, char post) { + DWORD remaining = flags; + bool wroteSomething = false; + for (size_t i = 0; i < tableSize; ++i) { + const Flag &f = table[i]; + if ((f.value & flags) == f.value) { + if (!wroteSomething && pre != '\0') { + out << pre; + } else if (wroteSomething && sep != '\0') { + out << sep; + } + out << f.text; + wroteSomething = true; + remaining &= ~f.value; + } + } + if (remaining != 0) { + if (!wroteSomething && pre != '\0') { + out << pre; + } else if (wroteSomething && sep != '\0') { + out << sep; + } + out << remainderName << "(0x" << hexOfInt(remaining) << ')'; + wroteSomething = true; + } + if (wroteSomething && post != '\0') { + out << post; + } +} + +template +static void writeFlags(StringBuilder &out, DWORD flags, + const char *remainderName, + const Flag (&table)[n], + char pre, char sep, char post) { + writeFlags(out, flags, remainderName, table, n, pre, sep, post); +} + +} // anonymous namespace + +std::string controlKeyStatePrefix(DWORD controlKeyState) { + StringBuilder sb; + writeFlags(sb, controlKeyState, + "keyState", kControlKeyStates, '\0', '-', '-'); + return sb.str_moved(); +} + +std::string mouseEventToString(const MOUSE_EVENT_RECORD &mer) { + const uint16_t buttons = mer.dwButtonState & 0xFFFF; + const int16_t wheel = mer.dwButtonState >> 16; + StringBuilder sb; + sb << "pos=" << mer.dwMousePosition.X << ',' + << mer.dwMousePosition.Y; + writeFlags(sb, mer.dwControlKeyState, "keyState", kControlKeyStates, ' ', ' ', '\0'); + writeFlags(sb, mer.dwEventFlags, "flags", kMouseEventFlags, ' ', ' ', '\0'); + writeFlags(sb, buttons, "buttons", kButtonStates, ' ', ' ', '\0'); + if (wheel != 0) { + sb << " wheel=" << wheel; + } + return sb.str_moved(); +} + +void debugShowInput(bool enableMouse, bool escapeInput) { + HANDLE conin = GetStdHandle(STD_INPUT_HANDLE); + DWORD origConsoleMode = 0; + if (!GetConsoleMode(conin, &origConsoleMode)) { + fprintf(stderr, "Error: could not read console mode -- " + "is STDIN a console handle?\n"); + exit(1); + } + DWORD restoreConsoleMode = origConsoleMode; + if (enableMouse && !(restoreConsoleMode & ENABLE_EXTENDED_FLAGS)) { + // We need to disable QuickEdit mode, because it blocks mouse events. + // If ENABLE_EXTENDED_FLAGS wasn't originally in the console mode, then + // we have no way of knowning whether QuickEdit or InsertMode are + // currently enabled. Enable them both (eventually), because they're + // sensible defaults. This case shouldn't happen typically. See + // misc/EnableExtendedFlags.txt. + restoreConsoleMode |= ENABLE_EXTENDED_FLAGS; + restoreConsoleMode |= ENABLE_QUICK_EDIT_MODE; + restoreConsoleMode |= ENABLE_INSERT_MODE; + } + DWORD newConsoleMode = restoreConsoleMode; + newConsoleMode &= ~ENABLE_PROCESSED_INPUT; + newConsoleMode &= ~ENABLE_LINE_INPUT; + newConsoleMode &= ~ENABLE_ECHO_INPUT; + newConsoleMode |= ENABLE_WINDOW_INPUT; + if (enableMouse) { + newConsoleMode |= ENABLE_MOUSE_INPUT; + newConsoleMode &= ~ENABLE_QUICK_EDIT_MODE; + } else { + newConsoleMode &= ~ENABLE_MOUSE_INPUT; + } + if (escapeInput) { + // As of this writing (2016-06-05), Microsoft has shipped two preview + // builds of Windows 10 (14316 and 14342) that include a new "Windows + // Subsystem for Linux" that runs Ubuntu in a new subsystem. Running + // bash in this subsystem requires the non-legacy console mode, and the + // console input buffer is put into a special mode where escape + // sequences are written into the console input buffer. This mode is + // enabled with the 0x200 flag, which is as-yet undocumented. + // See https://github.com/rprichard/winpty/issues/82. + newConsoleMode |= 0x200; + } + if (!SetConsoleMode(conin, newConsoleMode)) { + fprintf(stderr, "Error: could not set console mode " + "(0x%x -> 0x%x -> 0x%x)\n", + static_cast(origConsoleMode), + static_cast(newConsoleMode), + static_cast(restoreConsoleMode)); + exit(1); + } + printf("\nPress any keys -- Ctrl-D exits\n\n"); + INPUT_RECORD records[32]; + DWORD actual = 0; + bool finished = false; + while (!finished && + ReadConsoleInputW(conin, records, 32, &actual) && actual >= 1) { + StringBuilder sb; + for (DWORD i = 0; i < actual; ++i) { + const INPUT_RECORD &record = records[i]; + if (record.EventType == KEY_EVENT) { + const KEY_EVENT_RECORD &ker = record.Event.KeyEvent; + InputMap::Key key = { + ker.wVirtualKeyCode, + ker.uChar.UnicodeChar, + static_cast(ker.dwControlKeyState), + }; + sb << "key: " << (ker.bKeyDown ? "dn" : "up") + << " rpt=" << ker.wRepeatCount + << " scn=" << (ker.wVirtualScanCode ? "0x" : "") << hexOfInt(ker.wVirtualScanCode) + << ' ' << key.toString() << '\n'; + if ((ker.dwControlKeyState & + (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) && + ker.wVirtualKeyCode == 'D') { + finished = true; + break; + } else if (ker.wVirtualKeyCode == 0 && + ker.wVirtualScanCode == 0 && + ker.uChar.UnicodeChar == 4) { + // Also look for a zeroed-out Ctrl-D record generated for + // ENABLE_VIRTUAL_TERMINAL_INPUT. + finished = true; + break; + } + } else if (record.EventType == MOUSE_EVENT) { + const MOUSE_EVENT_RECORD &mer = record.Event.MouseEvent; + sb << "mouse: " << mouseEventToString(mer) << '\n'; + } else if (record.EventType == WINDOW_BUFFER_SIZE_EVENT) { + const WINDOW_BUFFER_SIZE_RECORD &wbsr = + record.Event.WindowBufferSizeEvent; + sb << "buffer-resized: dwSize=(" + << wbsr.dwSize.X << ',' + << wbsr.dwSize.Y << ")\n"; + } else if (record.EventType == MENU_EVENT) { + const MENU_EVENT_RECORD &mer = record.Event.MenuEvent; + sb << "menu-event: commandId=0x" + << hexOfInt(mer.dwCommandId) << '\n'; + } else if (record.EventType == FOCUS_EVENT) { + const FOCUS_EVENT_RECORD &fer = record.Event.FocusEvent; + sb << "focus: " << (fer.bSetFocus ? "gained" : "lost") << '\n'; + } + } + + const auto str = sb.str_moved(); + fwrite(str.data(), 1, str.size(), stdout); + fflush(stdout); + } + SetConsoleMode(conin, restoreConsoleMode); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DebugShowInput.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DebugShowInput.h new file mode 100644 index 00000000..4fa13604 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DebugShowInput.h @@ -0,0 +1,32 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef AGENT_DEBUG_SHOW_INPUT_H +#define AGENT_DEBUG_SHOW_INPUT_H + +#include + +#include + +std::string controlKeyStatePrefix(DWORD controlKeyState); +std::string mouseEventToString(const MOUSE_EVENT_RECORD &mer); +void debugShowInput(bool enableMouse, bool escapeInput); + +#endif // AGENT_DEBUG_SHOW_INPUT_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DefaultInputMap.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DefaultInputMap.cc new file mode 100644 index 00000000..5e29d98e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DefaultInputMap.cc @@ -0,0 +1,422 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "DefaultInputMap.h" + +#include +#include + +#include + +#include "../shared/StringBuilder.h" +#include "../shared/WinptyAssert.h" +#include "InputMap.h" + +#define ESC "\x1B" +#define DIM(x) (sizeof(x) / sizeof((x)[0])) + +namespace { + +struct EscapeEncoding { + bool alt_prefix_allowed; + char prefix; + char id; + int modifiers; + InputMap::Key key; +}; + +// Modifiers. A "modifier" is an integer from 2 to 8 that conveys the status +// of Shift(1), Alt(2), and Ctrl(4). The value is constructed by OR'ing the +// appropriate value for each active modifier, then adding 1. +// +// Details: +// - kBare: expands to: ESC +// - kSemiMod: expands to: ESC ; +// - kBareMod: expands to: ESC +const int kBare = 0x01; +const int kSemiMod = 0x02; +const int kBareMod = 0x04; + +// Numeric escape sequences suffixes: +// - with no flag: accept: ~ +// - kSuffixCtrl: accept: ~ ^ +// - kSuffixShift: accept: ~ $ +// - kSuffixBoth: accept: ~ ^ $ @ +const int kSuffixCtrl = 0x08; +const int kSuffixShift = 0x10; +const int kSuffixBoth = kSuffixCtrl | kSuffixShift; + +static const EscapeEncoding escapeLetterEncodings[] = { + // Conventional arrow keys + // kBareMod: Ubuntu /etc/inputrc and IntelliJ/JediTerm use escapes like: ESC [ n ABCD + { true, '[', 'A', kBare | kBareMod | kSemiMod, { VK_UP, '\0', 0 } }, + { true, '[', 'B', kBare | kBareMod | kSemiMod, { VK_DOWN, '\0', 0 } }, + { true, '[', 'C', kBare | kBareMod | kSemiMod, { VK_RIGHT, '\0', 0 } }, + { true, '[', 'D', kBare | kBareMod | kSemiMod, { VK_LEFT, '\0', 0 } }, + + // putty. putty uses this sequence for Ctrl-Arrow, Shift-Arrow, and + // Ctrl-Shift-Arrow, but I can only decode to one choice, so I'm just + // leaving the modifier off altogether. + { true, 'O', 'A', kBare, { VK_UP, '\0', 0 } }, + { true, 'O', 'B', kBare, { VK_DOWN, '\0', 0 } }, + { true, 'O', 'C', kBare, { VK_RIGHT, '\0', 0 } }, + { true, 'O', 'D', kBare, { VK_LEFT, '\0', 0 } }, + + // rxvt, rxvt-unicode + // Shift-Ctrl-Arrow can't be identified. It's the same as Shift-Arrow. + { true, '[', 'a', kBare, { VK_UP, '\0', SHIFT_PRESSED } }, + { true, '[', 'b', kBare, { VK_DOWN, '\0', SHIFT_PRESSED } }, + { true, '[', 'c', kBare, { VK_RIGHT, '\0', SHIFT_PRESSED } }, + { true, '[', 'd', kBare, { VK_LEFT, '\0', SHIFT_PRESSED } }, + { true, 'O', 'a', kBare, { VK_UP, '\0', LEFT_CTRL_PRESSED } }, + { true, 'O', 'b', kBare, { VK_DOWN, '\0', LEFT_CTRL_PRESSED } }, + { true, 'O', 'c', kBare, { VK_RIGHT, '\0', LEFT_CTRL_PRESSED } }, + { true, 'O', 'd', kBare, { VK_LEFT, '\0', LEFT_CTRL_PRESSED } }, + + // Numpad 5 with NumLock off + // * xterm, mintty, and gnome-terminal use `ESC [ E`. + // * putty, TERM=cygwin, TERM=linux all use `ESC [ G` for 5 + // * putty uses `ESC O G` for Ctrl-5 and Shift-5. Omit the modifier + // as with putty's arrow keys. + // * I never saw modifiers inserted into these escapes, but I think + // it should be completely OK with the CSI escapes. + { true, '[', 'E', kBare | kSemiMod, { VK_CLEAR, '\0', 0 } }, + { true, '[', 'G', kBare | kSemiMod, { VK_CLEAR, '\0', 0 } }, + { true, 'O', 'G', kBare, { VK_CLEAR, '\0', 0 } }, + + // Home/End, letter version + // * gnome-terminal uses `ESC O [HF]`. I never saw it modified. + // kBareMod: IntelliJ/JediTerm uses escapes like: ESC [ n HF + { true, '[', 'H', kBare | kBareMod | kSemiMod, { VK_HOME, '\0', 0 } }, + { true, '[', 'F', kBare | kBareMod | kSemiMod, { VK_END, '\0', 0 } }, + { true, 'O', 'H', kBare, { VK_HOME, '\0', 0 } }, + { true, 'O', 'F', kBare, { VK_END, '\0', 0 } }, + + // F1-F4, letter version (xterm, VTE, konsole) + { true, '[', 'P', kSemiMod, { VK_F1, '\0', 0 } }, + { true, '[', 'Q', kSemiMod, { VK_F2, '\0', 0 } }, + { true, '[', 'R', kSemiMod, { VK_F3, '\0', 0 } }, + { true, '[', 'S', kSemiMod, { VK_F4, '\0', 0 } }, + + // GNOME VTE and Konsole have special encodings for modified F1-F4: + // * [VTE] ESC O 1 ; n [PQRS] + // * [Konsole] ESC O n [PQRS] + { false, 'O', 'P', kBare | kBareMod | kSemiMod, { VK_F1, '\0', 0 } }, + { false, 'O', 'Q', kBare | kBareMod | kSemiMod, { VK_F2, '\0', 0 } }, + { false, 'O', 'R', kBare | kBareMod | kSemiMod, { VK_F3, '\0', 0 } }, + { false, 'O', 'S', kBare | kBareMod | kSemiMod, { VK_F4, '\0', 0 } }, + + // Handle the "application numpad" escape sequences. + // + // Terminals output these codes under various circumstances: + // * rxvt-unicode: numpad, hold down SHIFT + // * rxvt: numpad, by default + // * xterm: numpad, after enabling app-mode using DECPAM (`ESC =`). xterm + // generates `ESC O ` for modified numpad presses, + // necessitating kBareMod. + // * mintty: by combining Ctrl with various keys such as '1' or ','. + // Handling those keys is difficult, because mintty is generating the + // same sequence for Ctrl-1 and Ctrl-NumPadEnd -- should the virtualKey + // be '1' or VK_HOME? + + { true, 'O', 'M', kBare | kBareMod, { VK_RETURN, '\r', 0 } }, + { true, 'O', 'j', kBare | kBareMod, { VK_MULTIPLY, '*', 0 } }, + { true, 'O', 'k', kBare | kBareMod, { VK_ADD, '+', 0 } }, + { true, 'O', 'm', kBare | kBareMod, { VK_SUBTRACT, '-', 0 } }, + { true, 'O', 'n', kBare | kBareMod, { VK_DELETE, '\0', 0 } }, + { true, 'O', 'o', kBare | kBareMod, { VK_DIVIDE, '/', 0 } }, + { true, 'O', 'p', kBare | kBareMod, { VK_INSERT, '\0', 0 } }, + { true, 'O', 'q', kBare | kBareMod, { VK_END, '\0', 0 } }, + { true, 'O', 'r', kBare | kBareMod, { VK_DOWN, '\0', 0 } }, + { true, 'O', 's', kBare | kBareMod, { VK_NEXT, '\0', 0 } }, + { true, 'O', 't', kBare | kBareMod, { VK_LEFT, '\0', 0 } }, + { true, 'O', 'u', kBare | kBareMod, { VK_CLEAR, '\0', 0 } }, + { true, 'O', 'v', kBare | kBareMod, { VK_RIGHT, '\0', 0 } }, + { true, 'O', 'w', kBare | kBareMod, { VK_HOME, '\0', 0 } }, + { true, 'O', 'x', kBare | kBareMod, { VK_UP, '\0', 0 } }, + { true, 'O', 'y', kBare | kBareMod, { VK_PRIOR, '\0', 0 } }, + + { true, '[', 'M', kBare | kSemiMod, { VK_RETURN, '\r', 0 } }, + { true, '[', 'j', kBare | kSemiMod, { VK_MULTIPLY, '*', 0 } }, + { true, '[', 'k', kBare | kSemiMod, { VK_ADD, '+', 0 } }, + { true, '[', 'm', kBare | kSemiMod, { VK_SUBTRACT, '-', 0 } }, + { true, '[', 'n', kBare | kSemiMod, { VK_DELETE, '\0', 0 } }, + { true, '[', 'o', kBare | kSemiMod, { VK_DIVIDE, '/', 0 } }, + { true, '[', 'p', kBare | kSemiMod, { VK_INSERT, '\0', 0 } }, + { true, '[', 'q', kBare | kSemiMod, { VK_END, '\0', 0 } }, + { true, '[', 'r', kBare | kSemiMod, { VK_DOWN, '\0', 0 } }, + { true, '[', 's', kBare | kSemiMod, { VK_NEXT, '\0', 0 } }, + { true, '[', 't', kBare | kSemiMod, { VK_LEFT, '\0', 0 } }, + { true, '[', 'u', kBare | kSemiMod, { VK_CLEAR, '\0', 0 } }, + { true, '[', 'v', kBare | kSemiMod, { VK_RIGHT, '\0', 0 } }, + { true, '[', 'w', kBare | kSemiMod, { VK_HOME, '\0', 0 } }, + { true, '[', 'x', kBare | kSemiMod, { VK_UP, '\0', 0 } }, + { true, '[', 'y', kBare | kSemiMod, { VK_PRIOR, '\0', 0 } }, + + { false, '[', 'Z', kBare, { VK_TAB, '\t', SHIFT_PRESSED } }, +}; + +static const EscapeEncoding escapeNumericEncodings[] = { + { true, '[', 1, kBare | kSemiMod | kSuffixBoth, { VK_HOME, '\0', 0 } }, + { true, '[', 2, kBare | kSemiMod | kSuffixBoth, { VK_INSERT, '\0', 0 } }, + { true, '[', 3, kBare | kSemiMod | kSuffixBoth, { VK_DELETE, '\0', 0 } }, + { true, '[', 4, kBare | kSemiMod | kSuffixBoth, { VK_END, '\0', 0 } }, + { true, '[', 5, kBare | kSemiMod | kSuffixBoth, { VK_PRIOR, '\0', 0 } }, + { true, '[', 6, kBare | kSemiMod | kSuffixBoth, { VK_NEXT, '\0', 0 } }, + { true, '[', 7, kBare | kSemiMod | kSuffixBoth, { VK_HOME, '\0', 0 } }, + { true, '[', 8, kBare | kSemiMod | kSuffixBoth, { VK_END, '\0', 0 } }, + { true, '[', 11, kBare | kSemiMod | kSuffixBoth, { VK_F1, '\0', 0 } }, + { true, '[', 12, kBare | kSemiMod | kSuffixBoth, { VK_F2, '\0', 0 } }, + { true, '[', 13, kBare | kSemiMod | kSuffixBoth, { VK_F3, '\0', 0 } }, + { true, '[', 14, kBare | kSemiMod | kSuffixBoth, { VK_F4, '\0', 0 } }, + { true, '[', 15, kBare | kSemiMod | kSuffixBoth, { VK_F5, '\0', 0 } }, + { true, '[', 17, kBare | kSemiMod | kSuffixBoth, { VK_F6, '\0', 0 } }, + { true, '[', 18, kBare | kSemiMod | kSuffixBoth, { VK_F7, '\0', 0 } }, + { true, '[', 19, kBare | kSemiMod | kSuffixBoth, { VK_F8, '\0', 0 } }, + { true, '[', 20, kBare | kSemiMod | kSuffixBoth, { VK_F9, '\0', 0 } }, + { true, '[', 21, kBare | kSemiMod | kSuffixBoth, { VK_F10, '\0', 0 } }, + { true, '[', 23, kBare | kSemiMod | kSuffixBoth, { VK_F11, '\0', 0 } }, + { true, '[', 24, kBare | kSemiMod | kSuffixBoth, { VK_F12, '\0', 0 } }, + { true, '[', 25, kBare | kSemiMod | kSuffixBoth, { VK_F3, '\0', SHIFT_PRESSED } }, + { true, '[', 26, kBare | kSemiMod | kSuffixBoth, { VK_F4, '\0', SHIFT_PRESSED } }, + { true, '[', 28, kBare | kSemiMod | kSuffixBoth, { VK_F5, '\0', SHIFT_PRESSED } }, + { true, '[', 29, kBare | kSemiMod | kSuffixBoth, { VK_F6, '\0', SHIFT_PRESSED } }, + { true, '[', 31, kBare | kSemiMod | kSuffixBoth, { VK_F7, '\0', SHIFT_PRESSED } }, + { true, '[', 32, kBare | kSemiMod | kSuffixBoth, { VK_F8, '\0', SHIFT_PRESSED } }, + { true, '[', 33, kBare | kSemiMod | kSuffixBoth, { VK_F9, '\0', SHIFT_PRESSED } }, + { true, '[', 34, kBare | kSemiMod | kSuffixBoth, { VK_F10, '\0', SHIFT_PRESSED } }, +}; + +const int kCsiShiftModifier = 1; +const int kCsiAltModifier = 2; +const int kCsiCtrlModifier = 4; + +static inline bool useEnhancedForVirtualKey(uint16_t vk) { + switch (vk) { + case VK_UP: + case VK_DOWN: + case VK_LEFT: + case VK_RIGHT: + case VK_INSERT: + case VK_DELETE: + case VK_HOME: + case VK_END: + case VK_PRIOR: + case VK_NEXT: + return true; + default: + return false; + } +} + +static void addSimpleEntries(InputMap &inputMap) { + struct SimpleEncoding { + const char *encoding; + InputMap::Key key; + }; + + static const SimpleEncoding simpleEncodings[] = { + // Ctrl- seems to be handled OK by the default code path. + + { "\x7F", { VK_BACK, '\x08', 0, } }, + { ESC "\x7F", { VK_BACK, '\x08', LEFT_ALT_PRESSED, } }, + { "\x03", { 'C', '\x03', LEFT_CTRL_PRESSED, } }, + + // Handle special F1-F5 for TERM=linux and TERM=cygwin. + { ESC "[[A", { VK_F1, '\0', 0 } }, + { ESC "[[B", { VK_F2, '\0', 0 } }, + { ESC "[[C", { VK_F3, '\0', 0 } }, + { ESC "[[D", { VK_F4, '\0', 0 } }, + { ESC "[[E", { VK_F5, '\0', 0 } }, + + { ESC ESC "[[A", { VK_F1, '\0', LEFT_ALT_PRESSED } }, + { ESC ESC "[[B", { VK_F2, '\0', LEFT_ALT_PRESSED } }, + { ESC ESC "[[C", { VK_F3, '\0', LEFT_ALT_PRESSED } }, + { ESC ESC "[[D", { VK_F4, '\0', LEFT_ALT_PRESSED } }, + { ESC ESC "[[E", { VK_F5, '\0', LEFT_ALT_PRESSED } }, + }; + + for (size_t i = 0; i < DIM(simpleEncodings); ++i) { + auto k = simpleEncodings[i].key; + if (useEnhancedForVirtualKey(k.virtualKey)) { + k.keyState |= ENHANCED_KEY; + } + inputMap.set(simpleEncodings[i].encoding, + strlen(simpleEncodings[i].encoding), + k); + } +} + +struct ExpandContext { + InputMap &inputMap; + const EscapeEncoding &e; + char *buffer; + char *bufferEnd; +}; + +static inline void setEncoding(const ExpandContext &ctx, char *end, + uint16_t extraKeyState) { + InputMap::Key k = ctx.e.key; + k.keyState |= extraKeyState; + if (k.keyState & LEFT_CTRL_PRESSED) { + switch (k.virtualKey) { + case VK_ADD: + case VK_DIVIDE: + case VK_MULTIPLY: + case VK_SUBTRACT: + k.unicodeChar = '\0'; + break; + case VK_RETURN: + k.unicodeChar = '\n'; + break; + } + } + if (useEnhancedForVirtualKey(k.virtualKey)) { + k.keyState |= ENHANCED_KEY; + } + ctx.inputMap.set(ctx.buffer, end - ctx.buffer, k); +} + +static inline uint16_t keyStateForMod(int mod) { + int ret = 0; + if ((mod - 1) & kCsiShiftModifier) ret |= SHIFT_PRESSED; + if ((mod - 1) & kCsiAltModifier) ret |= LEFT_ALT_PRESSED; + if ((mod - 1) & kCsiCtrlModifier) ret |= LEFT_CTRL_PRESSED; + return ret; +} + +static void expandNumericEncodingSuffix(const ExpandContext &ctx, char *p, + uint16_t extraKeyState) { + ASSERT(p <= ctx.bufferEnd - 1); + { + char *q = p; + *q++ = '~'; + setEncoding(ctx, q, extraKeyState); + } + if (ctx.e.modifiers & kSuffixShift) { + char *q = p; + *q++ = '$'; + setEncoding(ctx, q, extraKeyState | SHIFT_PRESSED); + } + if (ctx.e.modifiers & kSuffixCtrl) { + char *q = p; + *q++ = '^'; + setEncoding(ctx, q, extraKeyState | LEFT_CTRL_PRESSED); + } + if (ctx.e.modifiers & (kSuffixCtrl | kSuffixShift)) { + char *q = p; + *q++ = '@'; + setEncoding(ctx, q, extraKeyState | SHIFT_PRESSED | LEFT_CTRL_PRESSED); + } +} + +template +static inline void expandEncodingAfterAltPrefix( + const ExpandContext &ctx, char *p, uint16_t extraKeyState) { + auto appendId = [&](char *&ptr) { + const auto idstr = decOfInt(ctx.e.id); + ASSERT(ptr <= ctx.bufferEnd - idstr.size()); + std::copy(idstr.data(), idstr.data() + idstr.size(), ptr); + ptr += idstr.size(); + }; + ASSERT(p <= ctx.bufferEnd - 2); + *p++ = '\x1b'; + *p++ = ctx.e.prefix; + if (ctx.e.modifiers & kBare) { + char *q = p; + if (is_numeric) { + appendId(q); + expandNumericEncodingSuffix(ctx, q, extraKeyState); + } else { + ASSERT(q <= ctx.bufferEnd - 1); + *q++ = ctx.e.id; + setEncoding(ctx, q, extraKeyState); + } + } + if (ctx.e.modifiers & kBareMod) { + ASSERT(!is_numeric && "kBareMod is invalid with numeric sequences"); + for (int mod = 2; mod <= 8; ++mod) { + char *q = p; + ASSERT(q <= ctx.bufferEnd - 2); + *q++ = '0' + mod; + *q++ = ctx.e.id; + setEncoding(ctx, q, extraKeyState | keyStateForMod(mod)); + } + } + if (ctx.e.modifiers & kSemiMod) { + for (int mod = 2; mod <= 8; ++mod) { + char *q = p; + if (is_numeric) { + appendId(q); + ASSERT(q <= ctx.bufferEnd - 2); + *q++ = ';'; + *q++ = '0' + mod; + expandNumericEncodingSuffix( + ctx, q, extraKeyState | keyStateForMod(mod)); + } else { + ASSERT(q <= ctx.bufferEnd - 4); + *q++ = '1'; + *q++ = ';'; + *q++ = '0' + mod; + *q++ = ctx.e.id; + setEncoding(ctx, q, extraKeyState | keyStateForMod(mod)); + } + } + } +} + +template +static inline void expandEncoding(const ExpandContext &ctx) { + if (ctx.e.alt_prefix_allowed) { + // For better or for worse, this code expands all of: + // * ESC [ -- + // * ESC ESC [ -- Alt- + // * ESC [ 1 ; 3 -- Alt- + // * ESC ESC [ 1 ; 3 -- Alt- specified twice + // I suspect no terminal actually emits the last one (i.e. specifying + // the Alt modifier using both methods), but I have seen a terminal + // that emitted a prefix ESC for Alt and a non-Alt modifier. + char *p = ctx.buffer; + ASSERT(p <= ctx.bufferEnd - 1); + *p++ = '\x1b'; + expandEncodingAfterAltPrefix(ctx, p, LEFT_ALT_PRESSED); + } + expandEncodingAfterAltPrefix(ctx, ctx.buffer, 0); +} + +template +static void addEscapes(InputMap &inputMap, const EscapeEncoding (&encodings)[N]) { + char buffer[32]; + for (size_t i = 0; i < DIM(encodings); ++i) { + ExpandContext ctx = { + inputMap, encodings[i], + buffer, buffer + sizeof(buffer) + }; + expandEncoding(ctx); + } +} + +} // anonymous namespace + +void addDefaultEntriesToInputMap(InputMap &inputMap) { + addEscapes(inputMap, escapeLetterEncodings); + addEscapes(inputMap, escapeNumericEncodings); + addSimpleEntries(inputMap); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DefaultInputMap.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DefaultInputMap.h new file mode 100644 index 00000000..c4b90836 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DefaultInputMap.h @@ -0,0 +1,28 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef DEFAULT_INPUT_MAP_H +#define DEFAULT_INPUT_MAP_H + +class InputMap; + +void addDefaultEntriesToInputMap(InputMap &inputMap); + +#endif // DEFAULT_INPUT_MAP_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DsrSender.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DsrSender.h new file mode 100644 index 00000000..1ec0a97d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/DsrSender.h @@ -0,0 +1,30 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef DSRSENDER_H +#define DSRSENDER_H + +class DsrSender +{ +public: + virtual void sendDsr() = 0; +}; + +#endif // DSRSENDER_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/EventLoop.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/EventLoop.cc new file mode 100644 index 00000000..ba5cf18c --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/EventLoop.cc @@ -0,0 +1,99 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "EventLoop.h" + +#include + +#include "NamedPipe.h" +#include "../shared/DebugClient.h" +#include "../shared/WinptyAssert.h" + +EventLoop::~EventLoop() { + for (NamedPipe *pipe : m_pipes) { + delete pipe; + } + m_pipes.clear(); +} + +// Enter the event loop. Runs until the I/O or timeout handler calls exit(). +void EventLoop::run() +{ + std::vector waitHandles; + DWORD lastTime = GetTickCount(); + while (!m_exiting) { + bool didSomething = false; + + // Attempt to make progress with the pipes. + waitHandles.clear(); + for (size_t i = 0; i < m_pipes.size(); ++i) { + if (m_pipes[i]->serviceIo(&waitHandles)) { + onPipeIo(*m_pipes[i]); + didSomething = true; + } + } + + // Call the timeout if enough time has elapsed. + if (m_pollInterval > 0) { + int elapsed = GetTickCount() - lastTime; + if (elapsed >= m_pollInterval) { + onPollTimeout(); + lastTime = GetTickCount(); + didSomething = true; + } + } + + if (didSomething) + continue; + + // If there's nothing to do, wait. + DWORD timeout = INFINITE; + if (m_pollInterval > 0) + timeout = std::max(0, (int)(lastTime + m_pollInterval - GetTickCount())); + if (waitHandles.size() == 0) { + ASSERT(timeout != INFINITE); + if (timeout > 0) + Sleep(timeout); + } else { + DWORD result = WaitForMultipleObjects(waitHandles.size(), + waitHandles.data(), + FALSE, + timeout); + ASSERT(result != WAIT_FAILED); + } + } +} + +NamedPipe &EventLoop::createNamedPipe() +{ + NamedPipe *ret = new NamedPipe(); + m_pipes.push_back(ret); + return *ret; +} + +void EventLoop::setPollInterval(int ms) +{ + m_pollInterval = ms; +} + +void EventLoop::shutdown() +{ + m_exiting = true; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/EventLoop.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/EventLoop.h new file mode 100644 index 00000000..eddb0f62 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/EventLoop.h @@ -0,0 +1,47 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef EVENTLOOP_H +#define EVENTLOOP_H + +#include + +class NamedPipe; + +class EventLoop +{ +public: + virtual ~EventLoop(); + void run(); + +protected: + NamedPipe &createNamedPipe(); + void setPollInterval(int ms); + void shutdown(); + virtual void onPollTimeout() {} + virtual void onPipeIo(NamedPipe &namedPipe) {} + +private: + bool m_exiting = false; + std::vector m_pipes; + int m_pollInterval = 0; +}; + +#endif // EVENTLOOP_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/InputMap.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/InputMap.cc new file mode 100644 index 00000000..b1fbfc2e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/InputMap.cc @@ -0,0 +1,246 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "InputMap.h" + +#include +#include +#include +#include + +#include "DebugShowInput.h" +#include "SimplePool.h" +#include "../shared/DebugClient.h" +#include "../shared/UnixCtrlChars.h" +#include "../shared/WinptyAssert.h" +#include "../shared/winpty_snprintf.h" + +namespace { + +static const char *getVirtualKeyString(int virtualKey) +{ + switch (virtualKey) { +#define WINPTY_GVKS_KEY(x) case VK_##x: return #x; + WINPTY_GVKS_KEY(RBUTTON) WINPTY_GVKS_KEY(F9) + WINPTY_GVKS_KEY(CANCEL) WINPTY_GVKS_KEY(F10) + WINPTY_GVKS_KEY(MBUTTON) WINPTY_GVKS_KEY(F11) + WINPTY_GVKS_KEY(XBUTTON1) WINPTY_GVKS_KEY(F12) + WINPTY_GVKS_KEY(XBUTTON2) WINPTY_GVKS_KEY(F13) + WINPTY_GVKS_KEY(BACK) WINPTY_GVKS_KEY(F14) + WINPTY_GVKS_KEY(TAB) WINPTY_GVKS_KEY(F15) + WINPTY_GVKS_KEY(CLEAR) WINPTY_GVKS_KEY(F16) + WINPTY_GVKS_KEY(RETURN) WINPTY_GVKS_KEY(F17) + WINPTY_GVKS_KEY(SHIFT) WINPTY_GVKS_KEY(F18) + WINPTY_GVKS_KEY(CONTROL) WINPTY_GVKS_KEY(F19) + WINPTY_GVKS_KEY(MENU) WINPTY_GVKS_KEY(F20) + WINPTY_GVKS_KEY(PAUSE) WINPTY_GVKS_KEY(F21) + WINPTY_GVKS_KEY(CAPITAL) WINPTY_GVKS_KEY(F22) + WINPTY_GVKS_KEY(HANGUL) WINPTY_GVKS_KEY(F23) + WINPTY_GVKS_KEY(JUNJA) WINPTY_GVKS_KEY(F24) + WINPTY_GVKS_KEY(FINAL) WINPTY_GVKS_KEY(NUMLOCK) + WINPTY_GVKS_KEY(KANJI) WINPTY_GVKS_KEY(SCROLL) + WINPTY_GVKS_KEY(ESCAPE) WINPTY_GVKS_KEY(LSHIFT) + WINPTY_GVKS_KEY(CONVERT) WINPTY_GVKS_KEY(RSHIFT) + WINPTY_GVKS_KEY(NONCONVERT) WINPTY_GVKS_KEY(LCONTROL) + WINPTY_GVKS_KEY(ACCEPT) WINPTY_GVKS_KEY(RCONTROL) + WINPTY_GVKS_KEY(MODECHANGE) WINPTY_GVKS_KEY(LMENU) + WINPTY_GVKS_KEY(SPACE) WINPTY_GVKS_KEY(RMENU) + WINPTY_GVKS_KEY(PRIOR) WINPTY_GVKS_KEY(BROWSER_BACK) + WINPTY_GVKS_KEY(NEXT) WINPTY_GVKS_KEY(BROWSER_FORWARD) + WINPTY_GVKS_KEY(END) WINPTY_GVKS_KEY(BROWSER_REFRESH) + WINPTY_GVKS_KEY(HOME) WINPTY_GVKS_KEY(BROWSER_STOP) + WINPTY_GVKS_KEY(LEFT) WINPTY_GVKS_KEY(BROWSER_SEARCH) + WINPTY_GVKS_KEY(UP) WINPTY_GVKS_KEY(BROWSER_FAVORITES) + WINPTY_GVKS_KEY(RIGHT) WINPTY_GVKS_KEY(BROWSER_HOME) + WINPTY_GVKS_KEY(DOWN) WINPTY_GVKS_KEY(VOLUME_MUTE) + WINPTY_GVKS_KEY(SELECT) WINPTY_GVKS_KEY(VOLUME_DOWN) + WINPTY_GVKS_KEY(PRINT) WINPTY_GVKS_KEY(VOLUME_UP) + WINPTY_GVKS_KEY(EXECUTE) WINPTY_GVKS_KEY(MEDIA_NEXT_TRACK) + WINPTY_GVKS_KEY(SNAPSHOT) WINPTY_GVKS_KEY(MEDIA_PREV_TRACK) + WINPTY_GVKS_KEY(INSERT) WINPTY_GVKS_KEY(MEDIA_STOP) + WINPTY_GVKS_KEY(DELETE) WINPTY_GVKS_KEY(MEDIA_PLAY_PAUSE) + WINPTY_GVKS_KEY(HELP) WINPTY_GVKS_KEY(LAUNCH_MAIL) + WINPTY_GVKS_KEY(LWIN) WINPTY_GVKS_KEY(LAUNCH_MEDIA_SELECT) + WINPTY_GVKS_KEY(RWIN) WINPTY_GVKS_KEY(LAUNCH_APP1) + WINPTY_GVKS_KEY(APPS) WINPTY_GVKS_KEY(LAUNCH_APP2) + WINPTY_GVKS_KEY(SLEEP) WINPTY_GVKS_KEY(OEM_1) + WINPTY_GVKS_KEY(NUMPAD0) WINPTY_GVKS_KEY(OEM_PLUS) + WINPTY_GVKS_KEY(NUMPAD1) WINPTY_GVKS_KEY(OEM_COMMA) + WINPTY_GVKS_KEY(NUMPAD2) WINPTY_GVKS_KEY(OEM_MINUS) + WINPTY_GVKS_KEY(NUMPAD3) WINPTY_GVKS_KEY(OEM_PERIOD) + WINPTY_GVKS_KEY(NUMPAD4) WINPTY_GVKS_KEY(OEM_2) + WINPTY_GVKS_KEY(NUMPAD5) WINPTY_GVKS_KEY(OEM_3) + WINPTY_GVKS_KEY(NUMPAD6) WINPTY_GVKS_KEY(OEM_4) + WINPTY_GVKS_KEY(NUMPAD7) WINPTY_GVKS_KEY(OEM_5) + WINPTY_GVKS_KEY(NUMPAD8) WINPTY_GVKS_KEY(OEM_6) + WINPTY_GVKS_KEY(NUMPAD9) WINPTY_GVKS_KEY(OEM_7) + WINPTY_GVKS_KEY(MULTIPLY) WINPTY_GVKS_KEY(OEM_8) + WINPTY_GVKS_KEY(ADD) WINPTY_GVKS_KEY(OEM_102) + WINPTY_GVKS_KEY(SEPARATOR) WINPTY_GVKS_KEY(PROCESSKEY) + WINPTY_GVKS_KEY(SUBTRACT) WINPTY_GVKS_KEY(PACKET) + WINPTY_GVKS_KEY(DECIMAL) WINPTY_GVKS_KEY(ATTN) + WINPTY_GVKS_KEY(DIVIDE) WINPTY_GVKS_KEY(CRSEL) + WINPTY_GVKS_KEY(F1) WINPTY_GVKS_KEY(EXSEL) + WINPTY_GVKS_KEY(F2) WINPTY_GVKS_KEY(EREOF) + WINPTY_GVKS_KEY(F3) WINPTY_GVKS_KEY(PLAY) + WINPTY_GVKS_KEY(F4) WINPTY_GVKS_KEY(ZOOM) + WINPTY_GVKS_KEY(F5) WINPTY_GVKS_KEY(NONAME) + WINPTY_GVKS_KEY(F6) WINPTY_GVKS_KEY(PA1) + WINPTY_GVKS_KEY(F7) WINPTY_GVKS_KEY(OEM_CLEAR) + WINPTY_GVKS_KEY(F8) +#undef WINPTY_GVKS_KEY + default: return NULL; + } +} + +} // anonymous namespace + +std::string InputMap::Key::toString() const { + std::string ret; + ret += controlKeyStatePrefix(keyState); + char buf[256]; + const char *vkString = getVirtualKeyString(virtualKey); + if (vkString != NULL) { + ret += vkString; + } else if ((virtualKey >= 'A' && virtualKey <= 'Z') || + (virtualKey >= '0' && virtualKey <= '9')) { + ret += static_cast(virtualKey); + } else { + winpty_snprintf(buf, "%#x", virtualKey); + ret += buf; + } + if (unicodeChar >= 32 && unicodeChar <= 126) { + winpty_snprintf(buf, " ch='%c'", + static_cast(unicodeChar)); + } else { + winpty_snprintf(buf, " ch=%#x", + static_cast(unicodeChar)); + } + ret += buf; + return ret; +} + +void InputMap::set(const char *encoding, int encodingLen, const Key &key) { + ASSERT(encodingLen > 0); + setHelper(m_root, encoding, encodingLen, key); +} + +void InputMap::setHelper(Node &node, const char *encoding, int encodingLen, const Key &key) { + if (encodingLen == 0) { + node.key = key; + } else { + setHelper(getOrCreateChild(node, encoding[0]), encoding + 1, encodingLen - 1, key); + } +} + +InputMap::Node &InputMap::getOrCreateChild(Node &node, unsigned char ch) { + Node *ret = getChild(node, ch); + if (ret != NULL) { + return *ret; + } + if (node.childCount < Node::kTinyCount) { + // Maintain sorted order for the sake of the InputMap dumping. + int insertIndex = node.childCount; + for (int i = 0; i < node.childCount; ++i) { + if (ch < node.u.tiny.values[i]) { + insertIndex = i; + break; + } + } + for (int j = node.childCount; j > insertIndex; --j) { + node.u.tiny.values[j] = node.u.tiny.values[j - 1]; + node.u.tiny.children[j] = node.u.tiny.children[j - 1]; + } + node.u.tiny.values[insertIndex] = ch; + node.u.tiny.children[insertIndex] = ret = m_nodePool.alloc(); + ++node.childCount; + return *ret; + } + if (node.childCount == Node::kTinyCount) { + Branch *branch = m_branchPool.alloc(); + for (int i = 0; i < node.childCount; ++i) { + branch->children[node.u.tiny.values[i]] = node.u.tiny.children[i]; + } + node.u.branch = branch; + } + node.u.branch->children[ch] = ret = m_nodePool.alloc(); + ++node.childCount; + return *ret; +} + +// Find the longest matching key and node. +int InputMap::lookupKey(const char *input, int inputSize, + Key &keyOut, bool &incompleteOut) const { + keyOut = kKeyZero; + incompleteOut = false; + + const Node *node = &m_root; + InputMap::Key longestMatch = kKeyZero; + int longestMatchLen = 0; + + for (int i = 0; i < inputSize; ++i) { + unsigned char ch = input[i]; + node = getChild(*node, ch); + if (node == NULL) { + keyOut = longestMatch; + return longestMatchLen; + } else if (node->hasKey()) { + longestMatchLen = i + 1; + longestMatch = node->key; + } + } + keyOut = longestMatch; + incompleteOut = node->childCount > 0; + return longestMatchLen; +} + +void InputMap::dumpInputMap() const { + std::string encoding; + dumpInputMapHelper(m_root, encoding); +} + +void InputMap::dumpInputMapHelper( + const Node &node, std::string &encoding) const { + if (node.hasKey()) { + trace("%s -> %s", + encoding.c_str(), + node.key.toString().c_str()); + } + for (int i = 0; i < 256; ++i) { + const Node *child = getChild(node, i); + if (child != NULL) { + size_t oldSize = encoding.size(); + if (!encoding.empty()) { + encoding.push_back(' '); + } + char ctrlChar = decodeUnixCtrlChar(i); + if (ctrlChar != '\0') { + encoding.push_back('^'); + encoding.push_back(static_cast(ctrlChar)); + } else if (i == ' ') { + encoding.append("' '"); + } else { + encoding.push_back(static_cast(i)); + } + dumpInputMapHelper(*child, encoding); + encoding.resize(oldSize); + } + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/InputMap.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/InputMap.h new file mode 100644 index 00000000..9a666c79 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/InputMap.h @@ -0,0 +1,114 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef INPUT_MAP_H +#define INPUT_MAP_H + +#include +#include +#include + +#include + +#include "SimplePool.h" +#include "../shared/WinptyAssert.h" + +class InputMap { +public: + struct Key { + uint16_t virtualKey; + uint32_t unicodeChar; + uint16_t keyState; + + std::string toString() const; + }; + +private: + struct Node; + + struct Branch { + Branch() { + memset(&children, 0, sizeof(children)); + } + + Node *children[256]; + }; + + struct Node { + Node() : childCount(0) { + Key zeroKey = { 0, 0, 0 }; + key = zeroKey; + } + + Key key; + int childCount; + enum { kTinyCount = 8 }; + union { + Branch *branch; + struct { + unsigned char values[kTinyCount]; + Node *children[kTinyCount]; + } tiny; + } u; + + bool hasKey() const { + return key.virtualKey != 0 || key.unicodeChar != 0; + } + }; + +private: + SimplePool m_nodePool; + SimplePool m_branchPool; + Node m_root; + +public: + void set(const char *encoding, int encodingLen, const Key &key); + int lookupKey(const char *input, int inputSize, + Key &keyOut, bool &incompleteOut) const; + void dumpInputMap() const; + +private: + Node *getChild(Node &node, unsigned char ch) { + return const_cast(getChild(static_cast(node), ch)); + } + + const Node *getChild(const Node &node, unsigned char ch) const { + if (node.childCount <= Node::kTinyCount) { + for (int i = 0; i < node.childCount; ++i) { + if (node.u.tiny.values[i] == ch) { + return node.u.tiny.children[i]; + } + } + return NULL; + } else { + return node.u.branch->children[ch]; + } + } + + void setHelper(Node &node, const char *encoding, int encodingLen, const Key &key); + Node &getOrCreateChild(Node &node, unsigned char ch); + void dumpInputMapHelper(const Node &node, std::string &encoding) const; +}; + +const InputMap::Key kKeyZero = { 0, 0, 0 }; + +void dumpInputMap(InputMap &inputMap); + +#endif // INPUT_MAP_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/LargeConsoleRead.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/LargeConsoleRead.cc new file mode 100644 index 00000000..80ac640e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/LargeConsoleRead.cc @@ -0,0 +1,71 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "LargeConsoleRead.h" + +#include + +#include "../shared/WindowsVersion.h" +#include "Scraper.h" +#include "Win32ConsoleBuffer.h" + +LargeConsoleReadBuffer::LargeConsoleReadBuffer() : + m_rect(0, 0, 0, 0), m_rectWidth(0) +{ +} + +void largeConsoleRead(LargeConsoleReadBuffer &out, + Win32ConsoleBuffer &buffer, + const SmallRect &readArea, + WORD attributesMask) { + ASSERT(readArea.Left >= 0 && + readArea.Top >= 0 && + readArea.Right >= readArea.Left && + readArea.Bottom >= readArea.Top && + readArea.width() <= MAX_CONSOLE_WIDTH); + const size_t count = readArea.width() * readArea.height(); + if (out.m_data.size() < count) { + out.m_data.resize(count); + } + out.m_rect = readArea; + out.m_rectWidth = readArea.width(); + + static const bool useLargeReads = isAtLeastWindows8(); + if (useLargeReads) { + buffer.read(readArea, out.m_data.data()); + } else { + const int maxReadLines = std::max(1, MAX_CONSOLE_WIDTH / readArea.width()); + int curLine = readArea.Top; + while (curLine <= readArea.Bottom) { + const SmallRect subReadArea( + readArea.Left, + curLine, + readArea.width(), + std::min(maxReadLines, readArea.Bottom + 1 - curLine)); + buffer.read(subReadArea, out.lineDataMut(curLine)); + curLine = subReadArea.Bottom + 1; + } + } + if (attributesMask != static_cast(~0)) { + for (size_t i = 0; i < count; ++i) { + out.m_data[i].Attributes &= attributesMask; + } + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/LargeConsoleRead.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/LargeConsoleRead.h new file mode 100644 index 00000000..1bcf2c02 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/LargeConsoleRead.h @@ -0,0 +1,68 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef LARGE_CONSOLE_READ_H +#define LARGE_CONSOLE_READ_H + +#include +#include + +#include + +#include "SmallRect.h" +#include "../shared/DebugClient.h" +#include "../shared/WinptyAssert.h" + +class Win32ConsoleBuffer; + +class LargeConsoleReadBuffer { +public: + LargeConsoleReadBuffer(); + const SmallRect &rect() const { return m_rect; } + const CHAR_INFO *lineData(int line) const { + validateLineNumber(line); + return &m_data[(line - m_rect.Top) * m_rectWidth]; + } + +private: + CHAR_INFO *lineDataMut(int line) { + validateLineNumber(line); + return &m_data[(line - m_rect.Top) * m_rectWidth]; + } + + void validateLineNumber(int line) const { + if (line < m_rect.Top || line > m_rect.Bottom) { + trace("Fatal error: LargeConsoleReadBuffer: invalid line %d for " + "read rect %s", line, m_rect.toString().c_str()); + abort(); + } + } + + SmallRect m_rect; + int m_rectWidth; + std::vector m_data; + + friend void largeConsoleRead(LargeConsoleReadBuffer &out, + Win32ConsoleBuffer &buffer, + const SmallRect &readArea, + WORD attributesMask); +}; + +#endif // LARGE_CONSOLE_READ_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/NamedPipe.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/NamedPipe.cc new file mode 100644 index 00000000..64044e6e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/NamedPipe.cc @@ -0,0 +1,378 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include + +#include + +#include "EventLoop.h" +#include "NamedPipe.h" +#include "../shared/DebugClient.h" +#include "../shared/StringUtil.h" +#include "../shared/WindowsSecurity.h" +#include "../shared/WinptyAssert.h" + +// Returns true if anything happens (data received, data sent, pipe error). +bool NamedPipe::serviceIo(std::vector *waitHandles) +{ + bool justConnected = false; + const auto kError = ServiceResult::Error; + const auto kProgress = ServiceResult::Progress; + const auto kNoProgress = ServiceResult::NoProgress; + if (m_handle == NULL) { + return false; + } + if (m_connectEvent.get() != nullptr) { + // We're still connecting this server pipe. Check whether the pipe is + // now connected. If it isn't, add the pipe to the list of handles to + // wait on. + DWORD actual = 0; + BOOL success = + GetOverlappedResult(m_handle, &m_connectOver, &actual, FALSE); + if (!success && GetLastError() == ERROR_PIPE_CONNECTED) { + // I'm not sure this can happen, but it's easy to handle if it + // does. + success = TRUE; + } + if (!success) { + ASSERT(GetLastError() == ERROR_IO_INCOMPLETE && + "Pended ConnectNamedPipe call failed"); + waitHandles->push_back(m_connectEvent.get()); + } else { + TRACE("Server pipe [%s] connected", + utf8FromWide(m_name).c_str()); + m_connectEvent.dispose(); + startPipeWorkers(); + justConnected = true; + } + } + const auto readProgress = m_inputWorker ? m_inputWorker->service() : kNoProgress; + const auto writeProgress = m_outputWorker ? m_outputWorker->service() : kNoProgress; + if (readProgress == kError || writeProgress == kError) { + closePipe(); + return true; + } + if (m_inputWorker && m_inputWorker->getWaitEvent() != nullptr) { + waitHandles->push_back(m_inputWorker->getWaitEvent()); + } + if (m_outputWorker && m_outputWorker->getWaitEvent() != nullptr) { + waitHandles->push_back(m_outputWorker->getWaitEvent()); + } + return justConnected + || readProgress == kProgress + || writeProgress == kProgress; +} + +// manual reset, initially unset +static OwnedHandle createEvent() { + HANDLE ret = CreateEventW(nullptr, TRUE, FALSE, nullptr); + ASSERT(ret != nullptr && "CreateEventW failed"); + return OwnedHandle(ret); +} + +NamedPipe::IoWorker::IoWorker(NamedPipe &namedPipe) : + m_namedPipe(namedPipe), + m_event(createEvent()) +{ +} + +NamedPipe::ServiceResult NamedPipe::IoWorker::service() +{ + ServiceResult progress = ServiceResult::NoProgress; + if (m_pending) { + DWORD actual = 0; + BOOL ret = GetOverlappedResult(m_namedPipe.m_handle, &m_over, &actual, FALSE); + if (!ret) { + if (GetLastError() == ERROR_IO_INCOMPLETE) { + // There is a pending I/O. + return progress; + } else { + // Pipe error. + return ServiceResult::Error; + } + } + ResetEvent(m_event.get()); + m_pending = false; + completeIo(actual); + m_currentIoSize = 0; + progress = ServiceResult::Progress; + } + DWORD nextSize = 0; + bool isRead = false; + while (shouldIssueIo(&nextSize, &isRead)) { + m_currentIoSize = nextSize; + DWORD actual = 0; + memset(&m_over, 0, sizeof(m_over)); + m_over.hEvent = m_event.get(); + BOOL ret = isRead + ? ReadFile(m_namedPipe.m_handle, m_buffer, nextSize, &actual, &m_over) + : WriteFile(m_namedPipe.m_handle, m_buffer, nextSize, &actual, &m_over); + if (!ret) { + if (GetLastError() == ERROR_IO_PENDING) { + // There is a pending I/O. + m_pending = true; + return progress; + } else { + // Pipe error. + return ServiceResult::Error; + } + } + ResetEvent(m_event.get()); + completeIo(actual); + m_currentIoSize = 0; + progress = ServiceResult::Progress; + } + return progress; +} + +// This function is called after CancelIo has returned. We need to block until +// the I/O operations have completed, which should happen very quickly. +// https://blogs.msdn.microsoft.com/oldnewthing/20110202-00/?p=11613 +void NamedPipe::IoWorker::waitForCanceledIo() +{ + if (m_pending) { + DWORD actual = 0; + GetOverlappedResult(m_namedPipe.m_handle, &m_over, &actual, TRUE); + m_pending = false; + } +} + +HANDLE NamedPipe::IoWorker::getWaitEvent() +{ + return m_pending ? m_event.get() : NULL; +} + +void NamedPipe::InputWorker::completeIo(DWORD size) +{ + m_namedPipe.m_inQueue.append(m_buffer, size); +} + +bool NamedPipe::InputWorker::shouldIssueIo(DWORD *size, bool *isRead) +{ + *isRead = true; + ASSERT(!m_namedPipe.isConnecting()); + if (m_namedPipe.isClosed()) { + return false; + } else if (m_namedPipe.m_inQueue.size() < m_namedPipe.readBufferSize()) { + *size = kIoSize; + return true; + } else { + return false; + } +} + +void NamedPipe::OutputWorker::completeIo(DWORD size) +{ + ASSERT(size == m_currentIoSize); +} + +bool NamedPipe::OutputWorker::shouldIssueIo(DWORD *size, bool *isRead) +{ + *isRead = false; + if (!m_namedPipe.m_outQueue.empty()) { + auto &out = m_namedPipe.m_outQueue; + const DWORD writeSize = std::min(out.size(), kIoSize); + std::copy(&out[0], &out[writeSize], m_buffer); + out.erase(0, writeSize); + *size = writeSize; + return true; + } else { + return false; + } +} + +DWORD NamedPipe::OutputWorker::getPendingIoSize() +{ + return m_pending ? m_currentIoSize : 0; +} + +void NamedPipe::openServerPipe(LPCWSTR pipeName, OpenMode::t openMode, + int outBufferSize, int inBufferSize) { + ASSERT(isClosed()); + ASSERT((openMode & OpenMode::Duplex) != 0); + const DWORD winOpenMode = + ((openMode & OpenMode::Reading) ? PIPE_ACCESS_INBOUND : 0) + | ((openMode & OpenMode::Writing) ? PIPE_ACCESS_OUTBOUND : 0) + | FILE_FLAG_FIRST_PIPE_INSTANCE + | FILE_FLAG_OVERLAPPED; + const auto sd = createPipeSecurityDescriptorOwnerFullControl(); + ASSERT(sd && "error creating data pipe SECURITY_DESCRIPTOR"); + SECURITY_ATTRIBUTES sa = {}; + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = sd.get(); + HANDLE handle = CreateNamedPipeW( + pipeName, + /*dwOpenMode=*/winOpenMode, + /*dwPipeMode=*/rejectRemoteClientsPipeFlag(), + /*nMaxInstances=*/1, + /*nOutBufferSize=*/outBufferSize, + /*nInBufferSize=*/inBufferSize, + /*nDefaultTimeOut=*/30000, + &sa); + TRACE("opened server pipe [%s], handle == %p", + utf8FromWide(pipeName).c_str(), handle); + ASSERT(handle != INVALID_HANDLE_VALUE && "Could not open server pipe"); + m_name = pipeName; + m_handle = handle; + m_openMode = openMode; + + // Start an asynchronous connection attempt. + m_connectEvent = createEvent(); + memset(&m_connectOver, 0, sizeof(m_connectOver)); + m_connectOver.hEvent = m_connectEvent.get(); + BOOL success = ConnectNamedPipe(m_handle, &m_connectOver); + const auto err = GetLastError(); + if (!success && err == ERROR_PIPE_CONNECTED) { + success = TRUE; + } + if (success) { + TRACE("Server pipe [%s] connected", utf8FromWide(pipeName).c_str()); + m_connectEvent.dispose(); + startPipeWorkers(); + } else if (err != ERROR_IO_PENDING) { + ASSERT(false && "ConnectNamedPipe call failed"); + } +} + +void NamedPipe::connectToServer(LPCWSTR pipeName, OpenMode::t openMode) +{ + ASSERT(isClosed()); + ASSERT((openMode & OpenMode::Duplex) != 0); + HANDLE handle = CreateFileW( + pipeName, + GENERIC_READ | GENERIC_WRITE, + 0, + NULL, + OPEN_EXISTING, + SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION | FILE_FLAG_OVERLAPPED, + NULL); + TRACE("connected to [%s], handle == %p", + utf8FromWide(pipeName).c_str(), handle); + ASSERT(handle != INVALID_HANDLE_VALUE && "Could not connect to pipe"); + m_name = pipeName; + m_handle = handle; + m_openMode = openMode; + startPipeWorkers(); +} + +void NamedPipe::startPipeWorkers() +{ + if (m_openMode & OpenMode::Reading) { + m_inputWorker.reset(new InputWorker(*this)); + } + if (m_openMode & OpenMode::Writing) { + m_outputWorker.reset(new OutputWorker(*this)); + } +} + +size_t NamedPipe::bytesToSend() +{ + ASSERT(m_openMode & OpenMode::Writing); + auto ret = m_outQueue.size(); + if (m_outputWorker != NULL) { + ret += m_outputWorker->getPendingIoSize(); + } + return ret; +} + +void NamedPipe::write(const void *data, size_t size) +{ + ASSERT(m_openMode & OpenMode::Writing); + m_outQueue.append(reinterpret_cast(data), size); +} + +void NamedPipe::write(const char *text) +{ + write(text, strlen(text)); +} + +size_t NamedPipe::readBufferSize() +{ + ASSERT(m_openMode & OpenMode::Reading); + return m_readBufferSize; +} + +void NamedPipe::setReadBufferSize(size_t size) +{ + ASSERT(m_openMode & OpenMode::Reading); + m_readBufferSize = size; +} + +size_t NamedPipe::bytesAvailable() +{ + ASSERT(m_openMode & OpenMode::Reading); + return m_inQueue.size(); +} + +size_t NamedPipe::peek(void *data, size_t size) +{ + ASSERT(m_openMode & OpenMode::Reading); + const auto out = reinterpret_cast(data); + const size_t ret = std::min(size, m_inQueue.size()); + std::copy(&m_inQueue[0], &m_inQueue[ret], out); + return ret; +} + +size_t NamedPipe::read(void *data, size_t size) +{ + size_t ret = peek(data, size); + m_inQueue.erase(0, ret); + return ret; +} + +std::string NamedPipe::readToString(size_t size) +{ + ASSERT(m_openMode & OpenMode::Reading); + size_t retSize = std::min(size, m_inQueue.size()); + std::string ret = m_inQueue.substr(0, retSize); + m_inQueue.erase(0, retSize); + return ret; +} + +std::string NamedPipe::readAllToString() +{ + ASSERT(m_openMode & OpenMode::Reading); + std::string ret = m_inQueue; + m_inQueue.clear(); + return ret; +} + +void NamedPipe::closePipe() +{ + if (m_handle == NULL) { + return; + } + CancelIo(m_handle); + if (m_connectEvent.get() != nullptr) { + DWORD actual = 0; + GetOverlappedResult(m_handle, &m_connectOver, &actual, TRUE); + m_connectEvent.dispose(); + } + if (m_inputWorker) { + m_inputWorker->waitForCanceledIo(); + m_inputWorker.reset(); + } + if (m_outputWorker) { + m_outputWorker->waitForCanceledIo(); + m_outputWorker.reset(); + } + CloseHandle(m_handle); + m_handle = NULL; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/NamedPipe.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/NamedPipe.h new file mode 100644 index 00000000..0a4d8b0c --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/NamedPipe.h @@ -0,0 +1,125 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef NAMEDPIPE_H +#define NAMEDPIPE_H + +#include + +#include +#include +#include + +#include "../shared/OwnedHandle.h" + +class EventLoop; + +class NamedPipe +{ +private: + // The EventLoop uses these private members. + friend class EventLoop; + NamedPipe() {} + ~NamedPipe() { closePipe(); } + bool serviceIo(std::vector *waitHandles); + void startPipeWorkers(); + + enum class ServiceResult { NoProgress, Error, Progress }; + +private: + class IoWorker + { + public: + IoWorker(NamedPipe &namedPipe); + virtual ~IoWorker() {} + ServiceResult service(); + void waitForCanceledIo(); + HANDLE getWaitEvent(); + protected: + NamedPipe &m_namedPipe; + bool m_pending = false; + DWORD m_currentIoSize = 0; + OwnedHandle m_event; + OVERLAPPED m_over = {}; + enum { kIoSize = 64 * 1024 }; + char m_buffer[kIoSize]; + virtual void completeIo(DWORD size) = 0; + virtual bool shouldIssueIo(DWORD *size, bool *isRead) = 0; + }; + + class InputWorker : public IoWorker + { + public: + InputWorker(NamedPipe &namedPipe) : IoWorker(namedPipe) {} + protected: + virtual void completeIo(DWORD size) override; + virtual bool shouldIssueIo(DWORD *size, bool *isRead) override; + }; + + class OutputWorker : public IoWorker + { + public: + OutputWorker(NamedPipe &namedPipe) : IoWorker(namedPipe) {} + DWORD getPendingIoSize(); + protected: + virtual void completeIo(DWORD size) override; + virtual bool shouldIssueIo(DWORD *size, bool *isRead) override; + }; + +public: + struct OpenMode { + typedef int t; + enum { None = 0, Reading = 1, Writing = 2, Duplex = 3 }; + }; + + std::wstring name() const { return m_name; } + void openServerPipe(LPCWSTR pipeName, OpenMode::t openMode, + int outBufferSize, int inBufferSize); + void connectToServer(LPCWSTR pipeName, OpenMode::t openMode); + size_t bytesToSend(); + void write(const void *data, size_t size); + void write(const char *text); + size_t readBufferSize(); + void setReadBufferSize(size_t size); + size_t bytesAvailable(); + size_t peek(void *data, size_t size); + size_t read(void *data, size_t size); + std::string readToString(size_t size); + std::string readAllToString(); + void closePipe(); + bool isClosed() { return m_handle == nullptr; } + bool isConnected() { return !isClosed() && !isConnecting(); } + bool isConnecting() { return m_connectEvent.get() != nullptr; } + +private: + // Input/output buffers + std::wstring m_name; + OVERLAPPED m_connectOver = {}; + OwnedHandle m_connectEvent; + OpenMode::t m_openMode = OpenMode::None; + size_t m_readBufferSize = 64 * 1024; + std::string m_inQueue; + std::string m_outQueue; + HANDLE m_handle = nullptr; + std::unique_ptr m_inputWorker; + std::unique_ptr m_outputWorker; +}; + +#endif // NAMEDPIPE_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Scraper.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Scraper.cc new file mode 100644 index 00000000..21f9c671 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Scraper.cc @@ -0,0 +1,699 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "Scraper.h" + +#include + +#include + +#include +#include + +#include "../shared/WinptyAssert.h" +#include "../shared/winpty_snprintf.h" + +#include "ConsoleFont.h" +#include "Win32Console.h" +#include "Win32ConsoleBuffer.h" + +namespace { + +template +T constrained(T min, T val, T max) { + ASSERT(min <= max); + return std::min(std::max(min, val), max); +} + +} // anonymous namespace + +Scraper::Scraper( + Win32Console &console, + Win32ConsoleBuffer &buffer, + std::unique_ptr terminal, + Coord initialSize) : + m_console(console), + m_terminal(std::move(terminal)), + m_ptySize(initialSize) +{ + m_consoleBuffer = &buffer; + + resetConsoleTracking(Terminal::OmitClear, buffer.windowRect().top()); + + m_bufferData.resize(BUFFER_LINE_COUNT); + + // Setup the initial screen buffer and window size. + // + // Use SetConsoleWindowInfo to shrink the console window as much as + // possible -- to a 1x1 cell at the top-left. This call always succeeds. + // Prior to the new Windows 10 console, it also actually resizes the GUI + // window to 1x1 cell. Nevertheless, even though the GUI window can + // therefore be narrower than its minimum, calling + // SetConsoleScreenBufferSize with a 1x1 size still fails. + // + // While the small font intends to support large buffers, a user could + // still hit a limit imposed by their monitor width, so cap the new window + // size to GetLargestConsoleWindowSize(). + setSmallFont(buffer.conout(), initialSize.X, m_console.isNewW10()); + buffer.moveWindow(SmallRect(0, 0, 1, 1)); + buffer.resizeBufferRange(Coord(initialSize.X, BUFFER_LINE_COUNT)); + const auto largest = GetLargestConsoleWindowSize(buffer.conout()); + buffer.moveWindow(SmallRect( + 0, 0, + std::min(initialSize.X, largest.X), + std::min(initialSize.Y, largest.Y))); + buffer.setCursorPosition(Coord(0, 0)); + + // For the sake of the color translation heuristic, set the console color + // to LtGray-on-Black. + buffer.setTextAttribute(Win32ConsoleBuffer::kDefaultAttributes); + buffer.clearAllLines(m_consoleBuffer->bufferInfo()); + + m_consoleBuffer = nullptr; +} + +Scraper::~Scraper() +{ +} + +// Whether or not the agent is frozen on entry, it will be frozen on exit. +void Scraper::resizeWindow(Win32ConsoleBuffer &buffer, + Coord newSize, + ConsoleScreenBufferInfo &finalInfoOut) +{ + m_consoleBuffer = &buffer; + m_ptySize = newSize; + syncConsoleContentAndSize(true, finalInfoOut); + m_consoleBuffer = nullptr; +} + +// This function may freeze the agent, but it will not unfreeze it. +void Scraper::scrapeBuffer(Win32ConsoleBuffer &buffer, + ConsoleScreenBufferInfo &finalInfoOut) +{ + m_consoleBuffer = &buffer; + syncConsoleContentAndSize(false, finalInfoOut); + m_consoleBuffer = nullptr; +} + +void Scraper::resetConsoleTracking( + Terminal::SendClearFlag sendClear, int64_t scrapedLineCount) +{ + for (ConsoleLine &line : m_bufferData) { + line.reset(); + } + m_syncRow = -1; + m_scrapedLineCount = scrapedLineCount; + m_scrolledCount = 0; + m_maxBufferedLine = -1; + m_dirtyWindowTop = -1; + m_dirtyLineCount = 0; + m_terminal->reset(sendClear, m_scrapedLineCount); +} + +// Detect window movement. If the window moves down (presumably as a +// result of scrolling), then assume that all screen buffer lines down to +// the bottom of the window are dirty. +void Scraper::markEntireWindowDirty(const SmallRect &windowRect) +{ + m_dirtyLineCount = std::max(m_dirtyLineCount, + windowRect.top() + windowRect.height()); +} + +// Scan the screen buffer and advance the dirty line count when we find +// non-empty lines. +void Scraper::scanForDirtyLines(const SmallRect &windowRect) +{ + const int w = m_readBuffer.rect().width(); + ASSERT(m_dirtyLineCount >= 1); + const CHAR_INFO *const prevLine = + m_readBuffer.lineData(m_dirtyLineCount - 1); + WORD prevLineAttr = prevLine[w - 1].Attributes; + const int stopLine = windowRect.top() + windowRect.height(); + + for (int line = m_dirtyLineCount; line < stopLine; ++line) { + const CHAR_INFO *lineData = m_readBuffer.lineData(line); + for (int col = 0; col < w; ++col) { + const WORD colAttr = lineData[col].Attributes; + if (lineData[col].Char.UnicodeChar != L' ' || + colAttr != prevLineAttr) { + m_dirtyLineCount = line + 1; + break; + } + } + prevLineAttr = lineData[w - 1].Attributes; + } +} + +// Clear lines in the line buffer. The `firstRow` parameter is in +// screen-buffer coordinates. +void Scraper::clearBufferLines( + const int firstRow, + const int count) +{ + ASSERT(!m_directMode); + for (int row = firstRow; row < firstRow + count; ++row) { + const int64_t bufLine = row + m_scrolledCount; + m_maxBufferedLine = std::max(m_maxBufferedLine, bufLine); + m_bufferData[bufLine % BUFFER_LINE_COUNT].blank( + Win32ConsoleBuffer::kDefaultAttributes); + } +} + +static bool cursorInWindow(const ConsoleScreenBufferInfo &info) +{ + return info.dwCursorPosition.Y >= info.srWindow.Top && + info.dwCursorPosition.Y <= info.srWindow.Bottom; +} + +void Scraper::resizeImpl(const ConsoleScreenBufferInfo &origInfo) +{ + ASSERT(m_console.frozen()); + const int cols = m_ptySize.X; + const int rows = m_ptySize.Y; + Coord finalBufferSize; + + { + // + // To accommodate Windows 10, erase all lines up to the top of the + // visible window. It's hard to tell whether this is strictly + // necessary. It ensures that the sync marker won't move downward, + // and it ensures that we won't repeat lines that have already scrolled + // up into the scrollback. + // + // It *is* possible for these blank lines to reappear in the visible + // window (e.g. if the window is made taller), but because we blanked + // the lines in the line buffer, we still don't output them again. + // + const Coord origBufferSize = origInfo.bufferSize(); + const SmallRect origWindowRect = origInfo.windowRect(); + + if (m_directMode) { + for (ConsoleLine &line : m_bufferData) { + line.reset(); + } + } else { + m_consoleBuffer->clearLines(0, origWindowRect.Top, origInfo); + clearBufferLines(0, origWindowRect.Top); + if (m_syncRow != -1) { + createSyncMarker(std::min( + m_syncRow, + BUFFER_LINE_COUNT - rows + - SYNC_MARKER_LEN + - SYNC_MARKER_MARGIN)); + } + } + + finalBufferSize = Coord( + cols, + // If there was previously no scrollback (e.g. a full-screen app + // in direct mode) and we're reducing the window height, then + // reduce the console buffer's height too. + (origWindowRect.height() == origBufferSize.Y) + ? rows + : std::max(rows, origBufferSize.Y)); + + // Reset the console font size. We need to do this before shrinking + // the window, because we might need to make the font bigger to permit + // a smaller window width. Making the font smaller could expand the + // screen buffer, which would hang the conhost process in the + // Windows 10 (10240 build) if the console selection is in progress, so + // unfreeze it first. + m_console.setFrozen(false); + setSmallFont(m_consoleBuffer->conout(), cols, m_console.isNewW10()); + } + + // We try to make the font small enough so that the entire screen buffer + // fits on the monitor, but it can't be guaranteed. + const auto largest = + GetLargestConsoleWindowSize(m_consoleBuffer->conout()); + const short visibleCols = std::min(cols, largest.X); + const short visibleRows = std::min(rows, largest.Y); + + { + // Make the window small enough. We want the console frozen during + // this step so we don't accidentally move the window above the cursor. + m_console.setFrozen(true); + const auto info = m_consoleBuffer->bufferInfo(); + const auto &bufferSize = info.dwSize; + const int tmpWindowWidth = std::min(bufferSize.X, visibleCols); + const int tmpWindowHeight = std::min(bufferSize.Y, visibleRows); + SmallRect tmpWindowRect( + 0, + std::min(bufferSize.Y - tmpWindowHeight, + info.windowRect().Top), + tmpWindowWidth, + tmpWindowHeight); + if (cursorInWindow(info)) { + tmpWindowRect = tmpWindowRect.ensureLineIncluded( + info.cursorPosition().Y); + } + m_consoleBuffer->moveWindow(tmpWindowRect); + } + + { + // Resize the buffer to the final desired size. + m_console.setFrozen(false); + m_consoleBuffer->resizeBufferRange(finalBufferSize); + } + + { + // Expand the window to its full size. + m_console.setFrozen(true); + const ConsoleScreenBufferInfo info = m_consoleBuffer->bufferInfo(); + + SmallRect finalWindowRect( + 0, + std::min(info.bufferSize().Y - visibleRows, + info.windowRect().Top), + visibleCols, + visibleRows); + + // + // Once a line in the screen buffer is "dirty", it should stay visible + // in the console window, so that we continue to update its content in + // the terminal. This code is particularly (only?) necessary on + // Windows 10, where making the buffer wider can rewrap lines and move + // the console window upward. + // + if (!m_directMode && m_dirtyLineCount > finalWindowRect.Bottom + 1) { + // In theory, we avoid ensureLineIncluded, because, a massive + // amount of output could have occurred while the console was + // unfrozen, so that the *top* of the window is now below the + // dirtiest tracked line. + finalWindowRect = SmallRect( + 0, m_dirtyLineCount - visibleRows, + visibleCols, visibleRows); + } + + // Highest priority constraint: ensure that the cursor remains visible. + if (cursorInWindow(info)) { + finalWindowRect = finalWindowRect.ensureLineIncluded( + info.cursorPosition().Y); + } + + m_consoleBuffer->moveWindow(finalWindowRect); + m_dirtyWindowTop = finalWindowRect.Top; + } + + ASSERT(m_console.frozen()); +} + +void Scraper::syncConsoleContentAndSize( + bool forceResize, + ConsoleScreenBufferInfo &finalInfoOut) +{ + // We'll try to avoid freezing the console by reading large chunks (or + // all!) of the screen buffer without otherwise attempting to synchronize + // with the console application. We can only do this on Windows 10 and up + // because: + // - Prior to Windows 8, the size of a ReadConsoleOutputW call was limited + // by the ~32KB RPC buffer. + // - Prior to Windows 10, an out-of-range read region crashes the caller. + // (See misc/WindowsBugCrashReader.cc.) + // + if (!m_console.isNewW10() || forceResize) { + m_console.setFrozen(true); + } + + const ConsoleScreenBufferInfo info = m_consoleBuffer->bufferInfo(); + bool cursorVisible = true; + CONSOLE_CURSOR_INFO cursorInfo = {}; + if (!GetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &cursorInfo)) { + trace("GetConsoleCursorInfo failed"); + } else { + cursorVisible = cursorInfo.bVisible != 0; + } + + // If an app resizes the buffer height, then we enter "direct mode", where + // we stop trying to track incremental console changes. + const bool newDirectMode = (info.bufferSize().Y != BUFFER_LINE_COUNT); + if (newDirectMode != m_directMode) { + trace("Entering %s mode", newDirectMode ? "direct" : "scrolling"); + resetConsoleTracking(Terminal::SendClear, + newDirectMode ? 0 : info.windowRect().top()); + m_directMode = newDirectMode; + + // When we switch from direct->scrolling mode, make sure the console is + // the right size. + if (!m_directMode) { + m_console.setFrozen(true); + forceResize = true; + } + } + + if (m_directMode) { + // In direct-mode, resizing the console redraws the terminal, so do it + // before scraping. + if (forceResize) { + resizeImpl(info); + } + directScrapeOutput(info, cursorVisible); + } else { + if (!m_console.frozen()) { + if (!scrollingScrapeOutput(info, cursorVisible, true)) { + m_console.setFrozen(true); + } + } + if (m_console.frozen()) { + scrollingScrapeOutput(info, cursorVisible, false); + } + // In scrolling mode, we want to scrape before resizing, because we'll + // erase everything in the console buffer up to the top of the console + // window. + if (forceResize) { + resizeImpl(info); + } + } + + finalInfoOut = forceResize ? m_consoleBuffer->bufferInfo() : info; +} + +// Try to match Windows' behavior w.r.t. to the LVB attribute flags. In some +// situations, Windows ignores the LVB flags on a character cell because of +// backwards compatibility -- apparently some programs set the flags without +// intending to enable reverse-video or underscores. +// +// [rprichard 2017-01-15] I haven't actually noticed any old programs that need +// this treatment -- the motivation for this function comes from the MSDN +// documentation for SetConsoleMode and ENABLE_LVB_GRID_WORLDWIDE. +WORD Scraper::attributesMask() +{ + const auto WINPTY_ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x4u; + const auto WINPTY_ENABLE_LVB_GRID_WORLDWIDE = 0x10u; + const auto WINPTY_COMMON_LVB_REVERSE_VIDEO = 0x4000u; + const auto WINPTY_COMMON_LVB_UNDERSCORE = 0x8000u; + + const auto cp = GetConsoleOutputCP(); + const auto isCjk = (cp == 932 || cp == 936 || cp == 949 || cp == 950); + + const DWORD outputMode = [this]{ + ASSERT(this->m_consoleBuffer != nullptr); + DWORD mode = 0; + if (!GetConsoleMode(this->m_consoleBuffer->conout(), &mode)) { + mode = 0; + } + return mode; + }(); + const bool hasEnableLvbGridWorldwide = + (outputMode & WINPTY_ENABLE_LVB_GRID_WORLDWIDE) != 0; + const bool hasEnableVtProcessing = + (outputMode & WINPTY_ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0; + + // The new Windows 10 console (as of 14393) seems to respect + // COMMON_LVB_REVERSE_VIDEO even in CP437 w/o the other enabling modes, so + // try to match that behavior. + const auto isReverseSupported = + isCjk || hasEnableLvbGridWorldwide || hasEnableVtProcessing || m_console.isNewW10(); + const auto isUnderscoreSupported = + isCjk || hasEnableLvbGridWorldwide || hasEnableVtProcessing; + + WORD mask = ~0; + if (!isReverseSupported) { mask &= ~WINPTY_COMMON_LVB_REVERSE_VIDEO; } + if (!isUnderscoreSupported) { mask &= ~WINPTY_COMMON_LVB_UNDERSCORE; } + return mask; +} + +void Scraper::directScrapeOutput(const ConsoleScreenBufferInfo &info, + bool consoleCursorVisible) +{ + const SmallRect windowRect = info.windowRect(); + + const SmallRect scrapeRect( + windowRect.left(), windowRect.top(), + std::min(std::min(windowRect.width(), m_ptySize.X), + MAX_CONSOLE_WIDTH), + std::min(std::min(windowRect.height(), m_ptySize.Y), + BUFFER_LINE_COUNT)); + const int w = scrapeRect.width(); + const int h = scrapeRect.height(); + + const Coord cursor = info.cursorPosition(); + const bool showTerminalCursor = + consoleCursorVisible && scrapeRect.contains(cursor); + const int cursorColumn = !showTerminalCursor ? -1 : cursor.X - scrapeRect.Left; + const int cursorLine = !showTerminalCursor ? -1 : cursor.Y - scrapeRect.Top; + + if (!showTerminalCursor) { + m_terminal->hideTerminalCursor(); + } + + largeConsoleRead(m_readBuffer, *m_consoleBuffer, scrapeRect, attributesMask()); + + for (int line = 0; line < h; ++line) { + const CHAR_INFO *const curLine = + m_readBuffer.lineData(scrapeRect.top() + line); + ConsoleLine &bufLine = m_bufferData[line]; + if (bufLine.detectChangeAndSetLine(curLine, w)) { + const int lineCursorColumn = + line == cursorLine ? cursorColumn : -1; + m_terminal->sendLine(line, curLine, w, lineCursorColumn); + } + } + + if (showTerminalCursor) { + m_terminal->showTerminalCursor(cursorColumn, cursorLine); + } +} + +bool Scraper::scrollingScrapeOutput(const ConsoleScreenBufferInfo &info, + bool consoleCursorVisible, + bool tentative) +{ + const Coord cursor = info.cursorPosition(); + const SmallRect windowRect = info.windowRect(); + + if (m_syncRow != -1) { + // If a synchronizing marker was placed into the history, look for it + // and adjust the scroll count. + const int markerRow = findSyncMarker(); + if (markerRow == -1) { + if (tentative) { + // I *think* it's possible to keep going, but it's simple to + // bail out. + return false; + } + // Something has happened. Reset the terminal. + trace("Sync marker has disappeared -- resetting the terminal" + " (m_syncCounter=%u)", + m_syncCounter); + resetConsoleTracking(Terminal::SendClear, windowRect.top()); + } else if (markerRow != m_syncRow) { + ASSERT(markerRow < m_syncRow); + m_scrolledCount += (m_syncRow - markerRow); + m_syncRow = markerRow; + // If the buffer has scrolled, then the entire window is dirty. + markEntireWindowDirty(windowRect); + } + } + + // Creating a new sync row requires clearing part of the console buffer, so + // avoid doing it if there's already a sync row that's good enough. + const int newSyncRow = + static_cast(windowRect.top()) - SYNC_MARKER_LEN - SYNC_MARKER_MARGIN; + const bool shouldCreateSyncRow = + newSyncRow >= m_syncRow + SYNC_MARKER_LEN + SYNC_MARKER_MARGIN; + if (tentative && shouldCreateSyncRow) { + // It's difficult even in principle to put down a new marker if the + // console can scroll an arbitrarily amount while we're writing. + return false; + } + + // Update the dirty line count: + // - If the window has moved, the entire window is dirty. + // - Everything up to the cursor is dirty. + // - All lines above the window are dirty. + // - Any non-blank lines are dirty. + if (m_dirtyWindowTop != -1) { + if (windowRect.top() > m_dirtyWindowTop) { + // The window has moved down, presumably as a result of scrolling. + markEntireWindowDirty(windowRect); + } else if (windowRect.top() < m_dirtyWindowTop) { + if (tentative) { + // I *think* it's possible to keep going, but it's simple to + // bail out. + return false; + } + // The window has moved upward. This is generally not expected to + // happen, but the CMD/PowerShell CLS command will move the window + // to the top as part of clearing everything else in the console. + trace("Window moved upward -- resetting the terminal" + " (m_syncCounter=%u)", + m_syncCounter); + resetConsoleTracking(Terminal::SendClear, windowRect.top()); + } + } + m_dirtyWindowTop = windowRect.top(); + m_dirtyLineCount = std::max(m_dirtyLineCount, cursor.Y + 1); + m_dirtyLineCount = std::max(m_dirtyLineCount, (int)windowRect.top()); + + // There will be at least one dirty line, because there is a cursor. + ASSERT(m_dirtyLineCount >= 1); + + // The first line to scrape, in virtual line coordinates. + const int64_t firstVirtLine = std::min(m_scrapedLineCount, + windowRect.top() + m_scrolledCount); + + // Read all the data we will need from the console. Start reading with the + // first line to scrape, but adjust the the read area upward to account for + // scanForDirtyLines' need to read the previous attribute. Read to the + // bottom of the window. (It's not clear to me whether the + // m_dirtyLineCount adjustment here is strictly necessary. It isn't + // necessary so long as the cursor is inside the current window.) + const int firstReadLine = std::min(firstVirtLine - m_scrolledCount, + m_dirtyLineCount - 1); + const int stopReadLine = std::max(windowRect.top() + windowRect.height(), + m_dirtyLineCount); + ASSERT(firstReadLine >= 0 && stopReadLine > firstReadLine); + largeConsoleRead(m_readBuffer, + *m_consoleBuffer, + SmallRect(0, firstReadLine, + std::min(info.bufferSize().X, + MAX_CONSOLE_WIDTH), + stopReadLine - firstReadLine), + attributesMask()); + + // If we're scraping the buffer without freezing it, we have to query the + // buffer position data separately from the buffer content, so the two + // could easily be out-of-sync. If they *are* out-of-sync, abort the + // scrape operation and restart it frozen. (We may have updated the + // dirty-line high-water-mark, but that should be OK.) + if (tentative) { + const auto infoCheck = m_consoleBuffer->bufferInfo(); + if (info.bufferSize() != infoCheck.bufferSize() || + info.windowRect() != infoCheck.windowRect() || + info.cursorPosition() != infoCheck.cursorPosition()) { + return false; + } + if (m_syncRow != -1 && m_syncRow != findSyncMarker()) { + return false; + } + } + + if (shouldCreateSyncRow) { + ASSERT(!tentative); + createSyncMarker(newSyncRow); + } + + // At this point, we're finished interacting (reading or writing) the + // console, and we just need to convert our collected data into terminal + // output. + + scanForDirtyLines(windowRect); + + // Note that it's possible for all the lines on the current window to + // be non-dirty. + + // The line to stop scraping at, in virtual line coordinates. + const int64_t stopVirtLine = + std::min(m_dirtyLineCount, windowRect.top() + windowRect.height()) + + m_scrolledCount; + + const bool showTerminalCursor = + consoleCursorVisible && windowRect.contains(cursor); + const int64_t cursorLine = !showTerminalCursor ? -1 : cursor.Y + m_scrolledCount; + const int cursorColumn = !showTerminalCursor ? -1 : cursor.X; + + if (!showTerminalCursor) { + m_terminal->hideTerminalCursor(); + } + + bool sawModifiedLine = false; + + const int w = m_readBuffer.rect().width(); + for (int64_t line = firstVirtLine; line < stopVirtLine; ++line) { + const CHAR_INFO *curLine = + m_readBuffer.lineData(line - m_scrolledCount); + ConsoleLine &bufLine = m_bufferData[line % BUFFER_LINE_COUNT]; + if (line > m_maxBufferedLine) { + m_maxBufferedLine = line; + sawModifiedLine = true; + } + if (sawModifiedLine) { + bufLine.setLine(curLine, w); + } else { + sawModifiedLine = bufLine.detectChangeAndSetLine(curLine, w); + } + if (sawModifiedLine) { + const int lineCursorColumn = + line == cursorLine ? cursorColumn : -1; + m_terminal->sendLine(line, curLine, w, lineCursorColumn); + } + } + + m_scrapedLineCount = windowRect.top() + m_scrolledCount; + + if (showTerminalCursor) { + m_terminal->showTerminalCursor(cursorColumn, cursorLine); + } + + return true; +} + +void Scraper::syncMarkerText(CHAR_INFO (&output)[SYNC_MARKER_LEN]) +{ + // XXX: The marker text generated here could easily collide with ordinary + // console output. Does it make sense to try to avoid the collision? + char str[SYNC_MARKER_LEN + 1]; + winpty_snprintf(str, "S*Y*N*C*%08x", m_syncCounter); + for (int i = 0; i < SYNC_MARKER_LEN; ++i) { + output[i].Char.UnicodeChar = str[i]; + output[i].Attributes = 7; + } +} + +int Scraper::findSyncMarker() +{ + ASSERT(m_syncRow >= 0); + CHAR_INFO marker[SYNC_MARKER_LEN]; + CHAR_INFO column[BUFFER_LINE_COUNT]; + syncMarkerText(marker); + SmallRect rect(0, 0, 1, m_syncRow + SYNC_MARKER_LEN); + m_consoleBuffer->read(rect, column); + int i; + for (i = m_syncRow; i >= 0; --i) { + int j; + for (j = 0; j < SYNC_MARKER_LEN; ++j) { + if (column[i + j].Char.UnicodeChar != marker[j].Char.UnicodeChar) + break; + } + if (j == SYNC_MARKER_LEN) + return i; + } + return -1; +} + +void Scraper::createSyncMarker(int row) +{ + ASSERT(row >= 1); + + // Clear the lines around the marker to ensure that Windows 10's rewrapping + // does not affect the marker. + m_consoleBuffer->clearLines(row - 1, SYNC_MARKER_LEN + 1, + m_consoleBuffer->bufferInfo()); + + // Write a new marker. + m_syncCounter++; + CHAR_INFO marker[SYNC_MARKER_LEN]; + syncMarkerText(marker); + m_syncRow = row; + SmallRect markerRect(0, m_syncRow, 1, SYNC_MARKER_LEN); + m_consoleBuffer->write(markerRect, marker); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Scraper.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Scraper.h new file mode 100644 index 00000000..9c10d80a --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Scraper.h @@ -0,0 +1,103 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef AGENT_SCRAPER_H +#define AGENT_SCRAPER_H + +#include + +#include + +#include +#include + +#include "ConsoleLine.h" +#include "Coord.h" +#include "LargeConsoleRead.h" +#include "SmallRect.h" +#include "Terminal.h" + +class ConsoleScreenBufferInfo; +class Win32Console; +class Win32ConsoleBuffer; + +// We must be able to issue a single ReadConsoleOutputW call of +// MAX_CONSOLE_WIDTH characters, and a single read of approximately several +// hundred fewer characters than BUFFER_LINE_COUNT. +const int BUFFER_LINE_COUNT = 3000; +const int MAX_CONSOLE_WIDTH = 2500; +const int MAX_CONSOLE_HEIGHT = 2000; +const int SYNC_MARKER_LEN = 16; +const int SYNC_MARKER_MARGIN = 200; + +class Scraper { +public: + Scraper( + Win32Console &console, + Win32ConsoleBuffer &buffer, + std::unique_ptr terminal, + Coord initialSize); + ~Scraper(); + void resizeWindow(Win32ConsoleBuffer &buffer, + Coord newSize, + ConsoleScreenBufferInfo &finalInfoOut); + void scrapeBuffer(Win32ConsoleBuffer &buffer, + ConsoleScreenBufferInfo &finalInfoOut); + Terminal &terminal() { return *m_terminal; } + +private: + void resetConsoleTracking( + Terminal::SendClearFlag sendClear, int64_t scrapedLineCount); + void markEntireWindowDirty(const SmallRect &windowRect); + void scanForDirtyLines(const SmallRect &windowRect); + void clearBufferLines(int firstRow, int count); + void resizeImpl(const ConsoleScreenBufferInfo &origInfo); + void syncConsoleContentAndSize(bool forceResize, + ConsoleScreenBufferInfo &finalInfoOut); + WORD attributesMask(); + void directScrapeOutput(const ConsoleScreenBufferInfo &info, + bool consoleCursorVisible); + bool scrollingScrapeOutput(const ConsoleScreenBufferInfo &info, + bool consoleCursorVisible, + bool tentative); + void syncMarkerText(CHAR_INFO (&output)[SYNC_MARKER_LEN]); + int findSyncMarker(); + void createSyncMarker(int row); + +private: + Win32Console &m_console; + Win32ConsoleBuffer *m_consoleBuffer = nullptr; + std::unique_ptr m_terminal; + + int m_syncRow = -1; + unsigned int m_syncCounter = 0; + + bool m_directMode = false; + Coord m_ptySize; + int64_t m_scrapedLineCount = 0; + int64_t m_scrolledCount = 0; + int64_t m_maxBufferedLine = -1; + LargeConsoleReadBuffer m_readBuffer; + std::vector m_bufferData; + int m_dirtyWindowTop = -1; + int m_dirtyLineCount = 0; +}; + +#endif // AGENT_SCRAPER_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/SimplePool.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/SimplePool.h new file mode 100644 index 00000000..41ff94a9 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/SimplePool.h @@ -0,0 +1,75 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef SIMPLE_POOL_H +#define SIMPLE_POOL_H + +#include + +#include + +#include "../shared/WinptyAssert.h" + +template +class SimplePool { +public: + ~SimplePool(); + T *alloc(); + void clear(); +private: + struct Chunk { + size_t count; + T *data; + }; + std::vector m_chunks; +}; + +template +SimplePool::~SimplePool() { + clear(); +} + +template +void SimplePool::clear() { + for (size_t ci = 0; ci < m_chunks.size(); ++ci) { + Chunk &chunk = m_chunks[ci]; + for (size_t ti = 0; ti < chunk.count; ++ti) { + chunk.data[ti].~T(); + } + free(chunk.data); + } + m_chunks.clear(); +} + +template +T *SimplePool::alloc() { + if (m_chunks.empty() || m_chunks.back().count == chunkSize) { + T *newData = reinterpret_cast(malloc(sizeof(T) * chunkSize)); + ASSERT(newData != NULL); + Chunk newChunk = { 0, newData }; + m_chunks.push_back(newChunk); + } + Chunk &chunk = m_chunks.back(); + T *ret = &chunk.data[chunk.count++]; + new (ret) T(); + return ret; +} + +#endif // SIMPLE_POOL_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/SmallRect.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/SmallRect.h new file mode 100644 index 00000000..bad0b886 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/SmallRect.h @@ -0,0 +1,143 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef SMALLRECT_H +#define SMALLRECT_H + +#include + +#include +#include + +#include "../shared/winpty_snprintf.h" +#include "Coord.h" + +struct SmallRect : SMALL_RECT +{ + SmallRect() + { + Left = Right = Top = Bottom = 0; + } + + SmallRect(SHORT x, SHORT y, SHORT width, SHORT height) + { + Left = x; + Top = y; + Right = x + width - 1; + Bottom = y + height - 1; + } + + SmallRect(const COORD &topLeft, const COORD &size) + { + Left = topLeft.X; + Top = topLeft.Y; + Right = Left + size.X - 1; + Bottom = Top + size.Y - 1; + } + + SmallRect(const SMALL_RECT &other) + { + *(SMALL_RECT*)this = other; + } + + SmallRect(const SmallRect &other) + { + *(SMALL_RECT*)this = *(const SMALL_RECT*)&other; + } + + SmallRect &operator=(const SmallRect &other) + { + *(SMALL_RECT*)this = *(const SMALL_RECT*)&other; + return *this; + } + + bool contains(const SmallRect &other) const + { + return other.Left >= Left && + other.Right <= Right && + other.Top >= Top && + other.Bottom <= Bottom; + } + + bool contains(const Coord &other) const + { + return other.X >= Left && + other.X <= Right && + other.Y >= Top && + other.Y <= Bottom; + } + + SmallRect intersected(const SmallRect &other) const + { + int x1 = std::max(Left, other.Left); + int x2 = std::min(Right, other.Right); + int y1 = std::max(Top, other.Top); + int y2 = std::min(Bottom, other.Bottom); + return SmallRect(x1, + y1, + std::max(0, x2 - x1 + 1), + std::max(0, y2 - y1 + 1)); + } + + SmallRect ensureLineIncluded(SHORT line) const + { + const SHORT h = height(); + if (line < Top) { + return SmallRect(Left, line, width(), h); + } else if (line > Bottom) { + return SmallRect(Left, line - h + 1, width(), h); + } else { + return *this; + } + } + + SHORT top() const { return Top; } + SHORT left() const { return Left; } + SHORT width() const { return Right - Left + 1; } + SHORT height() const { return Bottom - Top + 1; } + void setTop(SHORT top) { Top = top; } + void setLeft(SHORT left) { Left = left; } + void setWidth(SHORT width) { Right = Left + width - 1; } + void setHeight(SHORT height) { Bottom = Top + height - 1; } + Coord size() const { return Coord(width(), height()); } + + bool operator==(const SmallRect &other) const + { + return Left == other.Left && + Right == other.Right && + Top == other.Top && + Bottom == other.Bottom; + } + + bool operator!=(const SmallRect &other) const + { + return !(*this == other); + } + + std::string toString() const + { + char ret[64]; + winpty_snprintf(ret, "(x=%d,y=%d,w=%d,h=%d)", + Left, Top, width(), height()); + return std::string(ret); + } +}; + +#endif // SMALLRECT_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Terminal.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Terminal.cc new file mode 100644 index 00000000..afa0a362 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Terminal.cc @@ -0,0 +1,535 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "Terminal.h" + +#include +#include +#include + +#include + +#include "NamedPipe.h" +#include "UnicodeEncoding.h" +#include "../shared/DebugClient.h" +#include "../shared/WinptyAssert.h" +#include "../shared/winpty_snprintf.h" + +#define CSI "\x1b[" + +// Work around the old MinGW, which lacks COMMON_LVB_LEADING_BYTE and +// COMMON_LVB_TRAILING_BYTE. +const int WINPTY_COMMON_LVB_LEADING_BYTE = 0x100; +const int WINPTY_COMMON_LVB_TRAILING_BYTE = 0x200; +const int WINPTY_COMMON_LVB_REVERSE_VIDEO = 0x4000; +const int WINPTY_COMMON_LVB_UNDERSCORE = 0x8000; + +const int COLOR_ATTRIBUTE_MASK = + FOREGROUND_BLUE | + FOREGROUND_GREEN | + FOREGROUND_RED | + FOREGROUND_INTENSITY | + BACKGROUND_BLUE | + BACKGROUND_GREEN | + BACKGROUND_RED | + BACKGROUND_INTENSITY | + WINPTY_COMMON_LVB_REVERSE_VIDEO | + WINPTY_COMMON_LVB_UNDERSCORE; + +const int FLAG_RED = 1; +const int FLAG_GREEN = 2; +const int FLAG_BLUE = 4; +const int FLAG_BRIGHT = 8; + +const int BLACK = 0; +const int DKGRAY = BLACK | FLAG_BRIGHT; +const int LTGRAY = FLAG_RED | FLAG_GREEN | FLAG_BLUE; +const int WHITE = LTGRAY | FLAG_BRIGHT; + +// SGR parameters (Select Graphic Rendition) +const int SGR_FORE = 30; +const int SGR_FORE_HI = 90; +const int SGR_BACK = 40; +const int SGR_BACK_HI = 100; + +namespace { + +static void outUInt(std::string &out, unsigned int n) +{ + char buf[32]; + char *pbuf = &buf[32]; + *(--pbuf) = '\0'; + do { + *(--pbuf) = '0' + n % 10; + n /= 10; + } while (n != 0); + out.append(pbuf); +} + +static void outputSetColorSgrParams(std::string &out, bool isFore, int color) +{ + out.push_back(';'); + const int sgrBase = isFore ? SGR_FORE : SGR_BACK; + if (color & FLAG_BRIGHT) { + // Some terminals don't support the 9X/10X "intensive" color parameters + // (e.g. the Eclipse TM terminal as of this writing). Those terminals + // will quietly ignore a 9X/10X code, and the other terminals will + // ignore a 3X/4X code if it's followed by a 9X/10X code. Therefore, + // output a 3X/4X code as a fallback, then override it. + const int colorBase = color & ~FLAG_BRIGHT; + outUInt(out, sgrBase + colorBase); + out.push_back(';'); + outUInt(out, sgrBase + (SGR_FORE_HI - SGR_FORE) + colorBase); + } else { + outUInt(out, sgrBase + color); + } +} + +static void outputSetColor(std::string &out, int color) +{ + int fore = 0; + int back = 0; + if (color & FOREGROUND_RED) fore |= FLAG_RED; + if (color & FOREGROUND_GREEN) fore |= FLAG_GREEN; + if (color & FOREGROUND_BLUE) fore |= FLAG_BLUE; + if (color & FOREGROUND_INTENSITY) fore |= FLAG_BRIGHT; + if (color & BACKGROUND_RED) back |= FLAG_RED; + if (color & BACKGROUND_GREEN) back |= FLAG_GREEN; + if (color & BACKGROUND_BLUE) back |= FLAG_BLUE; + if (color & BACKGROUND_INTENSITY) back |= FLAG_BRIGHT; + + if (color & WINPTY_COMMON_LVB_REVERSE_VIDEO) { + // n.b.: The COMMON_LVB_REVERSE_VIDEO flag also swaps + // FOREGROUND_INTENSITY and BACKGROUND_INTENSITY. Tested on + // Windows 10 v14393. + std::swap(fore, back); + } + + // Translate the fore/back colors into terminal escape codes using + // a heuristic that works OK with common white-on-black or + // black-on-white color schemes. We don't know which color scheme + // the terminal is using. It is ugly to force white-on-black text + // on a black-on-white terminal, and it's even ugly to force the + // matching scheme. It's probably relevant that the default + // fore/back terminal colors frequently do not match any of the 16 + // palette colors. + + // Typical default terminal color schemes (according to palette, + // when possible): + // - mintty: LtGray-on-Black(A) + // - putty: LtGray-on-Black(A) + // - xterm: LtGray-on-Black(A) + // - Konsole: LtGray-on-Black(A) + // - JediTerm/JetBrains: Black-on-White(B) + // - rxvt: Black-on-White(B) + + // If the background is the default color (black), then it will + // map to Black(A) or White(B). If we translate White to White, + // then a Black background and a White background in the console + // are both White with (B). Therefore, we should translate White + // using SGR 7 (Invert). The typical finished mapping table for + // background grayscale colors is: + // + // (A) White => LtGray(fore) + // (A) Black => Black(back) + // (A) LtGray => LtGray + // (A) DkGray => DkGray + // + // (B) White => Black(fore) + // (B) Black => White(back) + // (B) LtGray => LtGray + // (B) DkGray => DkGray + // + + out.append(CSI "0"); + if (back == BLACK) { + if (fore == LTGRAY) { + // The "default" foreground color. Use the terminal's + // default colors. + } else if (fore == WHITE) { + // Sending the literal color white would behave poorly if + // the terminal were black-on-white. Sending Bold is not + // guaranteed to alter the color, but it will make the text + // visually distinct, so do that instead. + out.append(";1"); + } else if (fore == DKGRAY) { + // Set the foreground color to DkGray(90) with a fallback + // of LtGray(37) for terminals that don't handle the 9X SGR + // parameters (e.g. Eclipse's TM Terminal as of this + // writing). + out.append(";37;90"); + } else { + outputSetColorSgrParams(out, true, fore); + } + } else if (back == WHITE) { + // Set the background color using Invert on the default + // foreground color, and set the foreground color by setting a + // background color. + + // Use the terminal's inverted colors. + out.append(";7"); + if (fore == LTGRAY || fore == BLACK) { + // We're likely mapping Console White to terminal LtGray or + // Black. If they are the Console foreground color, then + // don't set a terminal foreground color to avoid creating + // invisible text. + } else { + outputSetColorSgrParams(out, false, fore); + } + } else { + // Set the foreground and background to match exactly that in + // the Windows console. + outputSetColorSgrParams(out, true, fore); + outputSetColorSgrParams(out, false, back); + } + if (fore == back) { + // The foreground and background colors are exactly equal, so + // attempt to hide the text using the Conceal SGR parameter, + // which some terminals support. + out.append(";8"); + } + if (color & WINPTY_COMMON_LVB_UNDERSCORE) { + out.append(";4"); + } + out.push_back('m'); +} + +static inline unsigned int fixSpecialCharacters(unsigned int ch) +{ + if (ch <= 0x1b) { + switch (ch) { + // The Windows Console has a popup window (e.g. that appears with + // F7) that is sometimes bordered with box-drawing characters. + // With the Japanese and Korean system locales (CP932 and CP949), + // the UnicodeChar values for the box-drawing characters are 1 + // through 6. Detect this and map the values to the correct + // Unicode values. + // + // N.B. In the English locale, the UnicodeChar values are correct, + // and they identify single-line characters rather than + // double-line. In the Chinese Simplified and Traditional locales, + // the popups use ASCII characters instead. + case 1: return 0x2554; // BOX DRAWINGS DOUBLE DOWN AND RIGHT + case 2: return 0x2557; // BOX DRAWINGS DOUBLE DOWN AND LEFT + case 3: return 0x255A; // BOX DRAWINGS DOUBLE UP AND RIGHT + case 4: return 0x255D; // BOX DRAWINGS DOUBLE UP AND LEFT + case 5: return 0x2551; // BOX DRAWINGS DOUBLE VERTICAL + case 6: return 0x2550; // BOX DRAWINGS DOUBLE HORIZONTAL + + // Convert an escape character to some other character. This + // conversion only applies to console cells containing an escape + // character. In newer versions of Windows 10 (e.g. 10.0.10586), + // the non-legacy console recognizes escape sequences in + // WriteConsole and interprets them without writing them to the + // cells of the screen buffer. In that case, the conversion here + // does not apply. + case 0x1b: return '?'; + } + } + return ch; +} + +static inline bool isFullWidthCharacter(const CHAR_INFO *data, int width) +{ + if (width < 2) { + return false; + } + return + (data[0].Attributes & WINPTY_COMMON_LVB_LEADING_BYTE) && + (data[1].Attributes & WINPTY_COMMON_LVB_TRAILING_BYTE) && + data[0].Char.UnicodeChar == data[1].Char.UnicodeChar; +} + +// Scan to find a single Unicode Scalar Value. Full-width characters occupy +// two console cells, and this code also tries to handle UTF-16 surrogate +// pairs. +// +// Windows expands at least some wide characters outside the Basic +// Multilingual Plane into four cells, such as U+20000: +// 1. 0xD840, attr=0x107 +// 2. 0xD840, attr=0x207 +// 3. 0xDC00, attr=0x107 +// 4. 0xDC00, attr=0x207 +// Even in the Traditional Chinese locale on Windows 10, this text is rendered +// as two boxes, but if those boxes are copied-and-pasted, the character is +// copied correctly. +static inline void scanUnicodeScalarValue( + const CHAR_INFO *data, int width, + int &outCellCount, unsigned int &outCharValue) +{ + ASSERT(width >= 1); + + const int w1 = isFullWidthCharacter(data, width) ? 2 : 1; + const wchar_t c1 = data[0].Char.UnicodeChar; + + if ((c1 & 0xF800) == 0xD800) { + // The first cell is either a leading or trailing surrogate pair. + if ((c1 & 0xFC00) != 0xD800 || + width <= w1 || + ((data[w1].Char.UnicodeChar & 0xFC00) != 0xDC00)) { + // Invalid surrogate pair + outCellCount = w1; + outCharValue = '?'; + } else { + // Valid surrogate pair + outCellCount = w1 + (isFullWidthCharacter(&data[w1], width - w1) ? 2 : 1); + outCharValue = decodeSurrogatePair(c1, data[w1].Char.UnicodeChar); + } + } else { + outCellCount = w1; + outCharValue = c1; + } +} + +} // anonymous namespace + +void Terminal::reset(SendClearFlag sendClearFirst, int64_t newLine) +{ + if (sendClearFirst == SendClear && !m_plainMode) { + // 0m ==> reset SGR parameters + // 1;1H ==> move cursor to top-left position + // 2J ==> clear the entire screen + m_output.write(CSI "0m" CSI "1;1H" CSI "2J"); + } + m_remoteLine = newLine; + m_remoteColumn = 0; + m_lineData.clear(); + m_cursorHidden = false; + m_remoteColor = -1; +} + +void Terminal::sendLine(int64_t line, const CHAR_INFO *lineData, int width, + int cursorColumn) +{ + ASSERT(width >= 1); + + moveTerminalToLine(line); + + // If possible, see if we can append to what we've already output for this + // line. + if (m_lineDataValid) { + ASSERT(m_lineData.size() == static_cast(m_remoteColumn)); + if (m_remoteColumn > 0) { + // In normal mode, if m_lineData.size() equals `width`, then we + // will have trouble outputing the "erase rest of line" command, + // which must be output before reaching the end of the line. In + // plain mode, we don't output that command, so we're OK with a + // full line. + bool okWidth = false; + if (m_plainMode) { + okWidth = static_cast(width) >= m_lineData.size(); + } else { + okWidth = static_cast(width) > m_lineData.size(); + } + if (!okWidth || + memcmp(m_lineData.data(), lineData, + sizeof(CHAR_INFO) * m_lineData.size()) != 0) { + m_lineDataValid = false; + } + } + } + if (!m_lineDataValid) { + // We can't reuse, so we must reset this line. + hideTerminalCursor(); + if (m_plainMode) { + // We can't backtrack, so repeat this line. + m_output.write("\r\n"); + } else { + m_output.write("\r"); + } + m_lineDataValid = true; + m_lineData.clear(); + m_remoteColumn = 0; + } + + std::string &termLine = m_termLineWorkingBuffer; + termLine.clear(); + size_t trimmedLineLength = 0; + int trimmedCellCount = m_lineData.size(); + bool alreadyErasedLine = false; + + int cellCount = 1; + for (int i = m_lineData.size(); i < width; i += cellCount) { + if (m_outputColor) { + int color = lineData[i].Attributes & COLOR_ATTRIBUTE_MASK; + if (color != m_remoteColor) { + outputSetColor(termLine, color); + trimmedLineLength = termLine.size(); + m_remoteColor = color; + + // All the cells just up to this color change will be output. + trimmedCellCount = i; + } + } + unsigned int ch; + scanUnicodeScalarValue(&lineData[i], width - i, cellCount, ch); + if (ch == ' ') { + // Tentatively add this space character. We'll only output it if + // we see something interesting after it. + termLine.push_back(' '); + } else { + if (i + cellCount == width) { + // We'd like to erase the line after outputting all non-blank + // characters, but this doesn't work if the last cell in the + // line is non-blank. At the point, the cursor is positioned + // just past the end of the line, but in many terminals, + // issuing a CSI 0K at that point also erases the last cell in + // the line. Work around this behavior by issuing the erase + // one character early in that case. + if (!m_plainMode) { + termLine.append(CSI "0K"); // Erase from cursor to EOL + } + alreadyErasedLine = true; + } + ch = fixSpecialCharacters(ch); + char enc[4]; + int enclen = encodeUtf8(enc, ch); + if (enclen == 0) { + enc[0] = '?'; + enclen = 1; + } + termLine.append(enc, enclen); + trimmedLineLength = termLine.size(); + + // All the cells up to and including this cell will be output. + trimmedCellCount = i + cellCount; + } + } + + if (cursorColumn != -1 && trimmedCellCount > cursorColumn) { + // The line content would run past the cursor, so hide it before we + // output. + hideTerminalCursor(); + } + + m_output.write(termLine.data(), trimmedLineLength); + if (!alreadyErasedLine && !m_plainMode) { + m_output.write(CSI "0K"); // Erase from cursor to EOL + } + + ASSERT(trimmedCellCount <= width); + m_lineData.insert(m_lineData.end(), + &lineData[m_lineData.size()], + &lineData[trimmedCellCount]); + m_remoteColumn = trimmedCellCount; +} + +void Terminal::showTerminalCursor(int column, int64_t line) +{ + moveTerminalToLine(line); + if (!m_plainMode) { + if (m_remoteColumn != column) { + char buffer[32]; + winpty_snprintf(buffer, CSI "%dG", column + 1); + m_output.write(buffer); + m_lineDataValid = (column == 0); + m_lineData.clear(); + m_remoteColumn = column; + } + if (m_cursorHidden) { + m_output.write(CSI "?25h"); + m_cursorHidden = false; + } + } +} + +void Terminal::hideTerminalCursor() +{ + if (!m_plainMode) { + if (m_cursorHidden) { + return; + } + m_output.write(CSI "?25l"); + m_cursorHidden = true; + } +} + +void Terminal::moveTerminalToLine(int64_t line) +{ + if (line == m_remoteLine) { + return; + } + + // Do not use CPL or CNL. Konsole 2.5.4 does not support Cursor Previous + // Line (CPL) -- there are "Undecodable sequence" errors. gnome-terminal + // 2.32.0 does handle it. Cursor Next Line (CNL) does nothing if the + // cursor is on the last line already. + + hideTerminalCursor(); + + if (line < m_remoteLine) { + if (m_plainMode) { + // We can't backtrack, so instead repeat the lines again. + m_output.write("\r\n"); + m_remoteLine = line; + } else { + // Backtrack and overwrite previous lines. + // CUrsor Up (CUU) + char buffer[32]; + winpty_snprintf(buffer, "\r" CSI "%uA", + static_cast(m_remoteLine - line)); + m_output.write(buffer); + m_remoteLine = line; + } + } else if (line > m_remoteLine) { + while (line > m_remoteLine) { + m_output.write("\r\n"); + m_remoteLine++; + } + } + + m_lineDataValid = true; + m_lineData.clear(); + m_remoteColumn = 0; +} + +void Terminal::enableMouseMode(bool enabled) +{ + if (m_mouseModeEnabled == enabled || m_plainMode) { + return; + } + m_mouseModeEnabled = enabled; + if (enabled) { + // Start by disabling UTF-8 coordinate mode (1005), just in case we + // have a terminal that does not support 1006/1015 modes, and 1005 + // happens to be enabled. The UTF-8 coordinates can't be unambiguously + // decoded. + // + // Enable basic mouse support first (1000), then try to switch to + // button-move mode (1002), then try full mouse-move mode (1003). + // Terminals that don't support a mode will be stuck at the highest + // mode they do support. + // + // Enable encoding mode 1015 first, then try to switch to 1006. On + // some terminals, both modes will be enabled, but 1006 will have + // priority. On other terminals, 1006 wins because it's listed last. + // + // See misc/MouseInputNotes.txt for details. + m_output.write( + CSI "?1005l" + CSI "?1000h" CSI "?1002h" CSI "?1003h" CSI "?1015h" CSI "?1006h"); + } else { + // Resetting both encoding modes (1006 and 1015) is necessary, but + // apparently we only need to use reset on one of the 100[023] modes. + // Doing both doesn't hurt. + m_output.write( + CSI "?1006l" CSI "?1015l" CSI "?1003l" CSI "?1002l" CSI "?1000l"); + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Terminal.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Terminal.h new file mode 100644 index 00000000..058eb265 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Terminal.h @@ -0,0 +1,69 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef TERMINAL_H +#define TERMINAL_H + +#include +#include + +#include +#include + +#include "Coord.h" + +class NamedPipe; + +class Terminal +{ +public: + explicit Terminal(NamedPipe &output, bool plainMode, bool outputColor) + : m_output(output), m_plainMode(plainMode), m_outputColor(outputColor) + { + } + + enum SendClearFlag { OmitClear, SendClear }; + void reset(SendClearFlag sendClearFirst, int64_t newLine); + void sendLine(int64_t line, const CHAR_INFO *lineData, int width, + int cursorColumn); + void showTerminalCursor(int column, int64_t line); + void hideTerminalCursor(); + +private: + void moveTerminalToLine(int64_t line); + +public: + void enableMouseMode(bool enabled); + +private: + NamedPipe &m_output; + int64_t m_remoteLine = 0; + int m_remoteColumn = 0; + bool m_lineDataValid = true; + std::vector m_lineData; + bool m_cursorHidden = false; + int m_remoteColor = -1; + std::string m_termLineWorkingBuffer; + bool m_plainMode = false; + bool m_outputColor = true; + bool m_mouseModeEnabled = false; +}; + +#endif // TERMINAL_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/UnicodeEncoding.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/UnicodeEncoding.h new file mode 100644 index 00000000..6b0de3ef --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/UnicodeEncoding.h @@ -0,0 +1,157 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef UNICODE_ENCODING_H +#define UNICODE_ENCODING_H + +#include + +// Encode the Unicode codepoint with UTF-8. The buffer must be at least 4 +// bytes in size. +static inline int encodeUtf8(char *out, uint32_t code) { + if (code < 0x80) { + out[0] = code; + return 1; + } else if (code < 0x800) { + out[0] = ((code >> 6) & 0x1F) | 0xC0; + out[1] = ((code >> 0) & 0x3F) | 0x80; + return 2; + } else if (code < 0x10000) { + if (code >= 0xD800 && code <= 0xDFFF) { + // The code points 0xD800 to 0xDFFF are reserved for UTF-16 + // surrogate pairs and do not have an encoding in UTF-8. + return 0; + } + out[0] = ((code >> 12) & 0x0F) | 0xE0; + out[1] = ((code >> 6) & 0x3F) | 0x80; + out[2] = ((code >> 0) & 0x3F) | 0x80; + return 3; + } else if (code < 0x110000) { + out[0] = ((code >> 18) & 0x07) | 0xF0; + out[1] = ((code >> 12) & 0x3F) | 0x80; + out[2] = ((code >> 6) & 0x3F) | 0x80; + out[3] = ((code >> 0) & 0x3F) | 0x80; + return 4; + } else { + // Encoding error + return 0; + } +} + +// Encode the Unicode codepoint with UTF-16. The buffer must be large enough +// to hold the output -- either 1 or 2 elements. +static inline int encodeUtf16(wchar_t *out, uint32_t code) { + if (code < 0x10000) { + if (code >= 0xD800 && code <= 0xDFFF) { + // The code points 0xD800 to 0xDFFF are reserved for UTF-16 + // surrogate pairs and do not have an encoding in UTF-16. + return 0; + } + out[0] = code; + return 1; + } else if (code < 0x110000) { + code -= 0x10000; + out[0] = 0xD800 | (code >> 10); + out[1] = 0xDC00 | (code & 0x3FF); + return 2; + } else { + // Encoding error + return 0; + } +} + +// Return the byte size of a UTF-8 character using the value of the first +// byte. +static inline int utf8CharLength(char firstByte) { + // This code would probably be faster if it used __builtin_clz. + if ((firstByte & 0x80) == 0) { + return 1; + } else if ((firstByte & 0xE0) == 0xC0) { + return 2; + } else if ((firstByte & 0xF0) == 0xE0) { + return 3; + } else if ((firstByte & 0xF8) == 0xF0) { + return 4; + } else { + // Malformed UTF-8. + return 0; + } +} + +// The pointer must point to 1-4 bytes, as indicated by the first byte. +// Returns -1 on decoding error. +static inline uint32_t decodeUtf8(const char *in) { + const uint32_t kInvalid = static_cast(-1); + switch (utf8CharLength(in[0])) { + case 1: { + return in[0]; + } + case 2: { + if ((in[1] & 0xC0) != 0x80) { + return kInvalid; + } + uint32_t tmp = 0; + tmp = (in[0] & 0x1F) << 6; + tmp |= (in[1] & 0x3F); + return tmp <= 0x7F ? kInvalid : tmp; + } + case 3: { + if ((in[1] & 0xC0) != 0x80 || + (in[2] & 0xC0) != 0x80) { + return kInvalid; + } + uint32_t tmp = 0; + tmp = (in[0] & 0x0F) << 12; + tmp |= (in[1] & 0x3F) << 6; + tmp |= (in[2] & 0x3F); + if (tmp <= 0x07FF || (tmp >= 0xD800 && tmp <= 0xDFFF)) { + return kInvalid; + } else { + return tmp; + } + } + case 4: { + if ((in[1] & 0xC0) != 0x80 || + (in[2] & 0xC0) != 0x80 || + (in[3] & 0xC0) != 0x80) { + return kInvalid; + } + uint32_t tmp = 0; + tmp = (in[0] & 0x07) << 18; + tmp |= (in[1] & 0x3F) << 12; + tmp |= (in[2] & 0x3F) << 6; + tmp |= (in[3] & 0x3F); + if (tmp <= 0xFFFF || tmp > 0x10FFFF) { + return kInvalid; + } else { + return tmp; + } + } + default: { + return kInvalid; + } + } +} + +static inline uint32_t decodeSurrogatePair(wchar_t ch1, wchar_t ch2) { + return ((ch1 - 0xD800) << 10) + (ch2 - 0xDC00) + 0x10000; +} + +#endif // UNICODE_ENCODING_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/UnicodeEncodingTest.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/UnicodeEncodingTest.cc new file mode 100644 index 00000000..cd4abeb1 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/UnicodeEncodingTest.cc @@ -0,0 +1,189 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// Encode every code-point using this module and verify that it matches the +// encoding generated using Windows WideCharToMultiByte. + +#include "UnicodeEncoding.h" + +#include +#include +#include +#include +#include + +static void correctnessByCode() +{ + char mbstr1[4]; + char mbstr2[4]; + wchar_t wch[2]; + for (unsigned int code = 0; code < 0x110000; ++code) { + + // Surrogate pair reserved region. + const bool isReserved = (code >= 0xD800 && code <= 0xDFFF); + + int mblen1 = encodeUtf8(mbstr1, code); + if (isReserved ? mblen1 != 0 : mblen1 <= 0) { + printf("Error: 0x%04X: mblen1=%d\n", code, mblen1); + continue; + } + + int wlen = encodeUtf16(wch, code); + if (isReserved ? wlen != 0 : wlen <= 0) { + printf("Error: 0x%04X: wlen=%d\n", code, wlen); + continue; + } + + if (isReserved) { + continue; + } + + if (mblen1 != utf8CharLength(mbstr1[0])) { + printf("Error: 0x%04X: mblen1=%d, utf8CharLength(mbstr1[0])=%d\n", + code, mblen1, utf8CharLength(mbstr1[0])); + continue; + } + + if (code != decodeUtf8(mbstr1)) { + printf("Error: 0x%04X: decodeUtf8(mbstr1)=%u\n", + code, decodeUtf8(mbstr1)); + continue; + } + + int mblen2 = WideCharToMultiByte(CP_UTF8, 0, wch, wlen, mbstr2, 4, NULL, NULL); + if (mblen1 != mblen2) { + printf("Error: 0x%04X: mblen1=%d, mblen2=%d\n", code, mblen1, mblen2); + continue; + } + + if (memcmp(mbstr1, mbstr2, mblen1) != 0) { + printf("Error: 0x%04x: encodings are different\n", code); + continue; + } + } +} + +static const char *encodingStr(char (&output)[128], char (&buf)[4]) +{ + sprintf(output, "Encoding %02X %02X %02X %02X", + static_cast(buf[0]), + static_cast(buf[1]), + static_cast(buf[2]), + static_cast(buf[3])); + return output; +} + +// This test can take a couple of minutes to run. +static void correctnessByUtf8Encoding() +{ + for (uint64_t encoding = 0; encoding <= 0xFFFFFFFF; ++encoding) { + + char mb[4]; + mb[0] = encoding; + mb[1] = encoding >> 8; + mb[2] = encoding >> 16; + mb[3] = encoding >> 24; + + const int mblen = utf8CharLength(mb[0]); + if (mblen == 0) { + continue; + } + + // Test this module. + const uint32_t code1 = decodeUtf8(mb); + wchar_t ws1[2] = {}; + const int wslen1 = encodeUtf16(ws1, code1); + + // Test using Windows. We can't decode a codepoint directly; we have + // to do UTF8->UTF16, then decode the surrogate pair. + wchar_t ws2[2] = {}; + const int wslen2 = MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, mb, mblen, ws2, 2); + const uint32_t code2 = + (wslen2 == 1 ? ws2[0] : + wslen2 == 2 ? decodeSurrogatePair(ws2[0], ws2[1]) : + static_cast(-1)); + + // Verify that the two implementations match. + char prefix[128]; + if (code1 != code2) { + printf("%s: code1=0x%04x code2=0x%04x\n", + encodingStr(prefix, mb), + code1, code2); + continue; + } + if (wslen1 != wslen2) { + printf("%s: wslen1=%d wslen2=%d\n", + encodingStr(prefix, mb), + wslen1, wslen2); + continue; + } + if (memcmp(ws1, ws2, wslen1 * sizeof(wchar_t)) != 0) { + printf("%s: ws1 != ws2\n", encodingStr(prefix, mb)); + continue; + } + } +} + +wchar_t g_wch_TEST[] = { 0xD840, 0xDC00 }; +char g_ch_TEST[4]; +wchar_t *volatile g_pwch = g_wch_TEST; +char *volatile g_pch = g_ch_TEST; +unsigned int volatile g_code = 0xA2000; + +static void performance() +{ + { + clock_t start = clock(); + for (long long i = 0; i < 250000000LL; ++i) { + int mblen = WideCharToMultiByte(CP_UTF8, 0, g_pwch, 2, g_pch, 4, NULL, NULL); + assert(mblen == 4); + } + clock_t stop = clock(); + printf("%.3fns per char\n", (double)(stop - start) / CLOCKS_PER_SEC * 4.0); + } + + { + clock_t start = clock(); + for (long long i = 0; i < 3000000000LL; ++i) { + int mblen = encodeUtf8(g_pch, g_code); + assert(mblen == 4); + } + clock_t stop = clock(); + printf("%.3fns per char\n", (double)(stop - start) / CLOCKS_PER_SEC / 3.0); + } +} + +int main() +{ + printf("Testing correctnessByCode...\n"); + fflush(stdout); + correctnessByCode(); + + printf("Testing correctnessByUtf8Encoding... (may take a couple minutes)\n"); + fflush(stdout); + correctnessByUtf8Encoding(); + + printf("Testing performance...\n"); + fflush(stdout); + performance(); + + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32Console.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32Console.cc new file mode 100644 index 00000000..d53de021 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32Console.cc @@ -0,0 +1,107 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "Win32Console.h" + +#include +#include + +#include + +#include "../shared/DebugClient.h" +#include "../shared/WinptyAssert.h" + +Win32Console::Win32Console() : m_titleWorkBuf(16) +{ + // The console window must be non-NULL. It is used for two purposes: + // (1) "Freezing" the console to detect the exact number of lines that + // have scrolled. + // (2) Killing processes attached to the console, by posting a WM_CLOSE + // message to the console window. + m_hwnd = GetConsoleWindow(); + ASSERT(m_hwnd != nullptr); +} + +std::wstring Win32Console::title() +{ + while (true) { + // Calling GetConsoleTitleW is tricky, because its behavior changed + // from XP->Vista, then again from Win7->Win8. The Vista+Win7 behavior + // is especially broken. + // + // The MSDN documentation documents nSize as the "size of the buffer + // pointed to by the lpConsoleTitle parameter, in characters" and the + // successful return value as "the length of the console window's + // title, in characters." + // + // On XP, the function returns the title length, AFTER truncation + // (excluding the NUL terminator). If the title is blank, the API + // returns 0 and does not NUL-terminate the buffer. To accommodate + // XP, the function must: + // * Terminate the buffer itself. + // * Double the size of the title buffer in a loop. + // + // On Vista and up, the function returns the non-truncated title + // length (excluding the NUL terminator). + // + // On Vista and Windows 7, there is a bug where the buffer size is + // interpreted as a byte count rather than a wchar_t count. To + // work around this, we must pass GetConsoleTitleW a buffer that is + // twice as large as what is actually needed. + // + // See misc/*/Test_GetConsoleTitleW.cc for tests demonstrating Windows' + // behavior. + + DWORD count = GetConsoleTitleW(m_titleWorkBuf.data(), + m_titleWorkBuf.size()); + const size_t needed = (count + 1) * sizeof(wchar_t); + if (m_titleWorkBuf.size() < needed) { + m_titleWorkBuf.resize(needed); + continue; + } + m_titleWorkBuf[count] = L'\0'; + return m_titleWorkBuf.data(); + } +} + +void Win32Console::setTitle(const std::wstring &title) +{ + if (!SetConsoleTitleW(title.c_str())) { + trace("SetConsoleTitleW failed"); + } +} + +void Win32Console::setFrozen(bool frozen) { + const int SC_CONSOLE_MARK = 0xFFF2; + const int SC_CONSOLE_SELECT_ALL = 0xFFF5; + if (frozen == m_frozen) { + // Do nothing. + } else if (frozen) { + // Enter selection mode by activating either Mark or SelectAll. + const int command = m_freezeUsesMark ? SC_CONSOLE_MARK + : SC_CONSOLE_SELECT_ALL; + SendMessage(m_hwnd, WM_SYSCOMMAND, command, 0); + m_frozen = true; + } else { + // Send Escape to cancel the selection. + SendMessage(m_hwnd, WM_CHAR, 27, 0x00010001); + m_frozen = false; + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32Console.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32Console.h new file mode 100644 index 00000000..ed83877e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32Console.h @@ -0,0 +1,67 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef AGENT_WIN32_CONSOLE_H +#define AGENT_WIN32_CONSOLE_H + +#include + +#include +#include + +class Win32Console +{ +public: + class FreezeGuard { + public: + FreezeGuard(Win32Console &console, bool frozen) : + m_console(console), m_previous(console.frozen()) { + m_console.setFrozen(frozen); + } + ~FreezeGuard() { + m_console.setFrozen(m_previous); + } + FreezeGuard(const FreezeGuard &other) = delete; + FreezeGuard &operator=(const FreezeGuard &other) = delete; + private: + Win32Console &m_console; + bool m_previous; + }; + + Win32Console(); + + HWND hwnd() { return m_hwnd; } + std::wstring title(); + void setTitle(const std::wstring &title); + void setFreezeUsesMark(bool useMark) { m_freezeUsesMark = useMark; } + void setNewW10(bool isNewW10) { m_isNewW10 = isNewW10; } + bool isNewW10() { return m_isNewW10; } + void setFrozen(bool frozen=true); + bool frozen() { return m_frozen; } + +private: + HWND m_hwnd = nullptr; + bool m_frozen = false; + bool m_freezeUsesMark = false; + bool m_isNewW10 = false; + std::vector m_titleWorkBuf; +}; + +#endif // AGENT_WIN32_CONSOLE_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32ConsoleBuffer.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32ConsoleBuffer.cc new file mode 100644 index 00000000..ed93f408 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32ConsoleBuffer.cc @@ -0,0 +1,193 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "Win32ConsoleBuffer.h" + +#include + +#include "../shared/DebugClient.h" +#include "../shared/StringBuilder.h" +#include "../shared/WinptyAssert.h" + +std::unique_ptr Win32ConsoleBuffer::openStdout() { + return std::unique_ptr( + new Win32ConsoleBuffer(GetStdHandle(STD_OUTPUT_HANDLE), false)); +} + +std::unique_ptr Win32ConsoleBuffer::openConout() { + const HANDLE conout = CreateFileW(L"CONOUT$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + ASSERT(conout != INVALID_HANDLE_VALUE); + return std::unique_ptr( + new Win32ConsoleBuffer(conout, true)); +} + +std::unique_ptr Win32ConsoleBuffer::createErrorBuffer() { + SECURITY_ATTRIBUTES sa = {}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + const HANDLE conout = + CreateConsoleScreenBuffer(GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &sa, + CONSOLE_TEXTMODE_BUFFER, + nullptr); + ASSERT(conout != INVALID_HANDLE_VALUE); + return std::unique_ptr( + new Win32ConsoleBuffer(conout, true)); +} + +HANDLE Win32ConsoleBuffer::conout() { + return m_conout; +} + +void Win32ConsoleBuffer::clearLines( + int row, + int count, + const ConsoleScreenBufferInfo &info) { + // TODO: error handling + const int width = info.bufferSize().X; + DWORD actual = 0; + if (!FillConsoleOutputCharacterW( + m_conout, L' ', width * count, Coord(0, row), + &actual) || static_cast(actual) != width * count) { + trace("FillConsoleOutputCharacterW failed"); + } + if (!FillConsoleOutputAttribute( + m_conout, kDefaultAttributes, width * count, Coord(0, row), + &actual) || static_cast(actual) != width * count) { + trace("FillConsoleOutputAttribute failed"); + } +} + +void Win32ConsoleBuffer::clearAllLines(const ConsoleScreenBufferInfo &info) { + clearLines(0, info.bufferSize().Y, info); +} + +ConsoleScreenBufferInfo Win32ConsoleBuffer::bufferInfo() { + // TODO: error handling + ConsoleScreenBufferInfo info; + if (!GetConsoleScreenBufferInfo(m_conout, &info)) { + trace("GetConsoleScreenBufferInfo failed"); + } + return info; +} + +Coord Win32ConsoleBuffer::bufferSize() { + return bufferInfo().bufferSize(); +} + +SmallRect Win32ConsoleBuffer::windowRect() { + return bufferInfo().windowRect(); +} + +bool Win32ConsoleBuffer::resizeBufferRange(const Coord &initialSize, + Coord &finalSize) { + if (SetConsoleScreenBufferSize(m_conout, initialSize)) { + finalSize = initialSize; + return true; + } + // The font might be too small to accommodate a very narrow console window. + // In that case, rather than simply give up, it's better to try wider + // buffer sizes until the call succeeds. + Coord size = initialSize; + while (size.X < 20) { + size.X++; + if (SetConsoleScreenBufferSize(m_conout, size)) { + finalSize = size; + trace("SetConsoleScreenBufferSize: initial size (%d,%d) failed, " + "but wider size (%d,%d) succeeded", + initialSize.X, initialSize.Y, + finalSize.X, finalSize.Y); + return true; + } + } + trace("SetConsoleScreenBufferSize failed: " + "tried (%d,%d) through (%d,%d)", + initialSize.X, initialSize.Y, + size.X, size.Y); + return false; +} + +void Win32ConsoleBuffer::resizeBuffer(const Coord &size) { + // TODO: error handling + if (!SetConsoleScreenBufferSize(m_conout, size)) { + trace("SetConsoleScreenBufferSize failed: size=(%d,%d)", + size.X, size.Y); + } +} + +void Win32ConsoleBuffer::moveWindow(const SmallRect &rect) { + // TODO: error handling + if (!SetConsoleWindowInfo(m_conout, TRUE, &rect)) { + trace("SetConsoleWindowInfo failed"); + } +} + +Coord Win32ConsoleBuffer::cursorPosition() { + return bufferInfo().dwCursorPosition; +} + +void Win32ConsoleBuffer::setCursorPosition(const Coord &coord) { + // TODO: error handling + if (!SetConsoleCursorPosition(m_conout, coord)) { + trace("SetConsoleCursorPosition failed"); + } +} + +void Win32ConsoleBuffer::read(const SmallRect &rect, CHAR_INFO *data) { + // TODO: error handling + SmallRect tmp(rect); + if (!ReadConsoleOutputW(m_conout, data, rect.size(), Coord(), &tmp) && + isTracingEnabled()) { + StringBuilder sb(256); + auto outStruct = [&](const SMALL_RECT &sr) { + sb << "{L=" << sr.Left << ",T=" << sr.Top + << ",R=" << sr.Right << ",B=" << sr.Bottom << '}'; + }; + sb << "Win32ConsoleBuffer::read: ReadConsoleOutput failed: readRegion="; + outStruct(rect); + CONSOLE_SCREEN_BUFFER_INFO info = {}; + if (GetConsoleScreenBufferInfo(m_conout, &info)) { + sb << ", dwSize=(" << info.dwSize.X << ',' << info.dwSize.Y + << "), srWindow="; + outStruct(info.srWindow); + } else { + sb << ", GetConsoleScreenBufferInfo also failed"; + } + trace("%s", sb.c_str()); + } +} + +void Win32ConsoleBuffer::write(const SmallRect &rect, const CHAR_INFO *data) { + // TODO: error handling + SmallRect tmp(rect); + if (!WriteConsoleOutputW(m_conout, data, rect.size(), Coord(), &tmp)) { + trace("WriteConsoleOutput failed"); + } +} + +void Win32ConsoleBuffer::setTextAttribute(WORD attributes) { + if (!SetConsoleTextAttribute(m_conout, attributes)) { + trace("SetConsoleTextAttribute failed"); + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32ConsoleBuffer.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32ConsoleBuffer.h new file mode 100644 index 00000000..a68d8d30 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/Win32ConsoleBuffer.h @@ -0,0 +1,99 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef AGENT_WIN32_CONSOLE_BUFFER_H +#define AGENT_WIN32_CONSOLE_BUFFER_H + +#include + +#include + +#include + +#include "Coord.h" +#include "SmallRect.h" + +class ConsoleScreenBufferInfo : public CONSOLE_SCREEN_BUFFER_INFO { +public: + ConsoleScreenBufferInfo() + { + memset(this, 0, sizeof(*this)); + } + + Coord bufferSize() const { return dwSize; } + SmallRect windowRect() const { return srWindow; } + Coord cursorPosition() const { return dwCursorPosition; } +}; + +class Win32ConsoleBuffer { +private: + Win32ConsoleBuffer(HANDLE conout, bool owned) : + m_conout(conout), m_owned(owned) + { + } + +public: + static const int kDefaultAttributes = 7; + + ~Win32ConsoleBuffer() { + if (m_owned) { + CloseHandle(m_conout); + } + } + + static std::unique_ptr openStdout(); + static std::unique_ptr openConout(); + static std::unique_ptr createErrorBuffer(); + + Win32ConsoleBuffer(const Win32ConsoleBuffer &other) = delete; + Win32ConsoleBuffer &operator=(const Win32ConsoleBuffer &other) = delete; + + HANDLE conout(); + void clearLines(int row, int count, const ConsoleScreenBufferInfo &info); + void clearAllLines(const ConsoleScreenBufferInfo &info); + + // Buffer and window sizes. + ConsoleScreenBufferInfo bufferInfo(); + Coord bufferSize(); + SmallRect windowRect(); + void resizeBuffer(const Coord &size); + bool resizeBufferRange(const Coord &initialSize, Coord &finalSize); + bool resizeBufferRange(const Coord &initialSize) { + Coord dummy; + return resizeBufferRange(initialSize, dummy); + } + void moveWindow(const SmallRect &rect); + + // Cursor. + Coord cursorPosition(); + void setCursorPosition(const Coord &point); + + // Screen content. + void read(const SmallRect &rect, CHAR_INFO *data); + void write(const SmallRect &rect, const CHAR_INFO *data); + + void setTextAttribute(WORD attributes); + +private: + HANDLE m_conout = nullptr; + bool m_owned = false; +}; + +#endif // AGENT_WIN32_CONSOLE_BUFFER_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/main.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/main.cc new file mode 100644 index 00000000..2420fde4 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/main.cc @@ -0,0 +1,114 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include +#include +#include +#include + +#include "../shared/StringUtil.h" +#include "../shared/WindowsVersion.h" +#include "../shared/WinptyAssert.h" +#include "../shared/WinptyVersion.h" + +#include "Agent.h" +#include "AgentCreateDesktop.h" +#include "DebugShowInput.h" + +const char USAGE[] = +"Usage: %ls controlPipeName flags mouseMode cols rows\n" +"Usage: %ls controlPipeName --create-desktop\n" +"\n" +"Ordinarily, this program is launched by winpty.dll and is not directly\n" +"useful to winpty users. However, it also has options intended for\n" +"debugging winpty.\n" +"\n" +"Usage: %ls [options]\n" +"\n" +"Options:\n" +" --show-input [--with-mouse] [--escape-input]\n" +" Dump INPUT_RECORDs from the console input buffer\n" +" --with-mouse: Include MOUSE_INPUT_RECORDs in the dump\n" +" output\n" +" --escape-input: Direct the new Windows 10 console to use\n" +" escape sequences for input\n" +" --version Print the winpty version\n"; + +static uint64_t winpty_atoi64(const char *str) { + return strtoll(str, NULL, 10); +} + +int main() { + dumpWindowsVersion(); + dumpVersionToTrace(); + + // Technically, we should free the CommandLineToArgvW return value using + // a single call to LocalFree, but the call will never actually happen in + // the normal case. + int argc = 0; + wchar_t *cmdline = GetCommandLineW(); + ASSERT(cmdline != nullptr && "GetCommandLineW returned NULL"); + wchar_t **argv = CommandLineToArgvW(cmdline, &argc); + ASSERT(argv != nullptr && "CommandLineToArgvW returned NULL"); + + if (argc == 2 && !wcscmp(argv[1], L"--version")) { + dumpVersionToStdout(); + return 0; + } + + if (argc >= 2 && !wcscmp(argv[1], L"--show-input")) { + bool withMouse = false; + bool escapeInput = false; + for (int i = 2; i < argc; ++i) { + if (!wcscmp(argv[i], L"--with-mouse")) { + withMouse = true; + } else if (!wcscmp(argv[i], L"--escape-input")) { + escapeInput = true; + } else { + fprintf(stderr, "Unrecognized --show-input option: %ls\n", + argv[i]); + return 1; + } + } + debugShowInput(withMouse, escapeInput); + return 0; + } + + if (argc == 3 && !wcscmp(argv[2], L"--create-desktop")) { + handleCreateDesktop(argv[1]); + return 0; + } + + if (argc != 6) { + fprintf(stderr, USAGE, argv[0], argv[0], argv[0]); + return 1; + } + + Agent agent(argv[1], + winpty_atoi64(utf8FromWide(argv[2]).c_str()), + atoi(utf8FromWide(argv[3]).c_str()), + atoi(utf8FromWide(argv[4]).c_str()), + atoi(utf8FromWide(argv[5]).c_str())); + agent.run(); + + // The Agent destructor shouldn't return, but if it does, exit + // unsuccessfully. + return 1; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/subdir.mk b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/subdir.mk new file mode 100644 index 00000000..1c7d37e3 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/agent/subdir.mk @@ -0,0 +1,61 @@ +# Copyright (c) 2011-2015 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +ALL_TARGETS += build/winpty-agent.exe + +$(eval $(call def_mingw_target,agent,-DWINPTY_AGENT_ASSERT)) + +AGENT_OBJECTS = \ + build/agent/agent/Agent.o \ + build/agent/agent/AgentCreateDesktop.o \ + build/agent/agent/ConsoleFont.o \ + build/agent/agent/ConsoleInput.o \ + build/agent/agent/ConsoleInputReencoding.o \ + build/agent/agent/ConsoleLine.o \ + build/agent/agent/DebugShowInput.o \ + build/agent/agent/DefaultInputMap.o \ + build/agent/agent/EventLoop.o \ + build/agent/agent/InputMap.o \ + build/agent/agent/LargeConsoleRead.o \ + build/agent/agent/NamedPipe.o \ + build/agent/agent/Scraper.o \ + build/agent/agent/Terminal.o \ + build/agent/agent/Win32Console.o \ + build/agent/agent/Win32ConsoleBuffer.o \ + build/agent/agent/main.o \ + build/agent/shared/BackgroundDesktop.o \ + build/agent/shared/Buffer.o \ + build/agent/shared/DebugClient.o \ + build/agent/shared/GenRandom.o \ + build/agent/shared/OwnedHandle.o \ + build/agent/shared/StringUtil.o \ + build/agent/shared/WindowsSecurity.o \ + build/agent/shared/WindowsVersion.o \ + build/agent/shared/WinptyAssert.o \ + build/agent/shared/WinptyException.o \ + build/agent/shared/WinptyVersion.o + +build/agent/shared/WinptyVersion.o : build/gen/GenVersion.h + +build/winpty-agent.exe : $(AGENT_OBJECTS) + $(info Linking $@) + @$(MINGW_CXX) $(MINGW_LDFLAGS) -o $@ $^ + +-include $(AGENT_OBJECTS:.o=.d) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/configurations.gypi b/services/edge-agent/node_modules/node-pty/deps/winpty/src/configurations.gypi new file mode 100644 index 00000000..e990a603 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/configurations.gypi @@ -0,0 +1,60 @@ +# By default gyp/msbuild build for 32-bit Windows. This gyp include file +# defines configurations for both 32-bit and 64-bit Windows. To use it, run: +# +# C:\...\winpty\src>gyp -I configurations.gypi +# +# This command generates Visual Studio project files with a Release +# configuration and two Platforms--Win32 and x64. Both can be built: +# +# C:\...\winpty\src>msbuild winpty.sln /p:Platform=Win32 +# C:\...\winpty\src>msbuild winpty.sln /p:Platform=x64 +# +# The output is placed in: +# +# C:\...\winpty\src\Release\Win32 +# C:\...\winpty\src\Release\x64 +# +# Windows XP note: By default, the project files will use the default "toolset" +# for the given MSVC version. For MSVC 2013 and MSVC 2015, the default toolset +# generates binaries that do not run on Windows XP. To target Windows XP, +# select the XP-specific toolset by passing +# -D WINPTY_MSBUILD_TOOLSET={v120_xp,v140_xp} to gyp (v120_xp == MSVC 2013, +# v140_xp == MSVC 2015). Unfortunately, it isn't possible to have a single +# project file with configurations for both XP and post-XP. This seems to be a +# limitation of the MSVC project file format. +# +# This file is not included by default, because I suspect it would interfere +# with node-gyp, which has a different system for building 32-vs-64-bit +# binaries. It uses a common.gypi, and the project files it generates can only +# build a single architecture, the output paths are not differentiated by +# architecture. + +{ + 'variables': { + 'WINPTY_MSBUILD_TOOLSET%': '', + }, + 'target_defaults': { + 'default_configuration': 'Release_Win32', + 'configurations': { + 'Release_Win32': { + 'msvs_configuration_platform': 'Win32', + }, + 'Release_x64': { + 'msvs_configuration_platform': 'x64', + }, + }, + 'msvs_configuration_attributes': { + 'OutputDirectory': '$(SolutionDir)$(ConfigurationName)\\$(Platform)', + 'IntermediateDirectory': '$(ConfigurationName)\\$(Platform)\\obj\\$(ProjectName)', + }, + 'msvs_settings': { + 'VCLinkerTool': { + 'SubSystem': '1', # /SUBSYSTEM:CONSOLE + }, + 'VCCLCompilerTool': { + 'RuntimeLibrary': '0', # MultiThreaded (/MT) + }, + }, + 'msbuild_toolset' : '<(WINPTY_MSBUILD_TOOLSET)', + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/debugserver/DebugServer.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/debugserver/DebugServer.cc new file mode 100644 index 00000000..353d31c1 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/debugserver/DebugServer.cc @@ -0,0 +1,117 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include +#include + +#include + +#include "../shared/WindowsSecurity.h" +#include "../shared/WinptyException.h" + +const wchar_t *kPipeName = L"\\\\.\\pipe\\DebugServer"; + +// A message may not be larger than this size. +const int MSG_SIZE = 4096; + +static void usage(const char *program, int code) { + printf("Usage: %s [--everyone]\n" + "\n" + "Creates the named pipe %ls and reads messages. Prints each\n" + "message to stdout. By default, only the current user can send messages.\n" + "Pass --everyone to let anyone send a message.\n" + "\n" + "Use the WINPTY_DEBUG environment variable to enable winpty trace output.\n" + "(e.g. WINPTY_DEBUG=trace for the default trace output.) Set WINPTYDBG=1\n" + "to enable trace with older winpty versions.\n", + program, kPipeName); + exit(code); +} + +int main(int argc, char *argv[]) { + bool everyone = false; + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--everyone") { + everyone = true; + } else if (arg == "-h" || arg == "--help") { + usage(argv[0], 0); + } else { + usage(argv[0], 1); + } + } + + SecurityDescriptor sd; + PSECURITY_ATTRIBUTES psa = nullptr; + SECURITY_ATTRIBUTES sa = {}; + if (everyone) { + try { + sd = createPipeSecurityDescriptorOwnerFullControlEveryoneWrite(); + } catch (const WinptyException &e) { + fprintf(stderr, + "error creating security descriptor: %ls\n", e.what()); + exit(1); + } + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = sd.get(); + psa = &sa; + } + + HANDLE serverPipe = CreateNamedPipeW( + kPipeName, + /*dwOpenMode=*/PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + /*dwPipeMode=*/PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | + rejectRemoteClientsPipeFlag(), + /*nMaxInstances=*/1, + /*nOutBufferSize=*/MSG_SIZE, + /*nInBufferSize=*/MSG_SIZE, + /*nDefaultTimeOut=*/10 * 1000, + psa); + + if (serverPipe == INVALID_HANDLE_VALUE) { + fprintf(stderr, "error: could not create %ls pipe: error %u\n", + kPipeName, static_cast(GetLastError())); + exit(1); + } + + char msgBuffer[MSG_SIZE + 1]; + + while (true) { + if (!ConnectNamedPipe(serverPipe, nullptr)) { + fprintf(stderr, "error: ConnectNamedPipe failed\n"); + fflush(stderr); + exit(1); + } + DWORD bytesRead = 0; + if (!ReadFile(serverPipe, msgBuffer, MSG_SIZE, &bytesRead, nullptr)) { + fprintf(stderr, "error: ReadFile on pipe failed\n"); + fflush(stderr); + DisconnectNamedPipe(serverPipe); + continue; + } + msgBuffer[bytesRead] = '\n'; + fwrite(msgBuffer, 1, bytesRead + 1, stdout); + fflush(stdout); + + DWORD bytesWritten = 0; + WriteFile(serverPipe, "OK", 2, &bytesWritten, nullptr); + DisconnectNamedPipe(serverPipe); + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/debugserver/subdir.mk b/services/edge-agent/node_modules/node-pty/deps/winpty/src/debugserver/subdir.mk new file mode 100644 index 00000000..beed1bd5 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/debugserver/subdir.mk @@ -0,0 +1,41 @@ +# Copyright (c) 2015 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +ALL_TARGETS += build/winpty-debugserver.exe + +$(eval $(call def_mingw_target,debugserver,)) + +DEBUGSERVER_OBJECTS = \ + build/debugserver/debugserver/DebugServer.o \ + build/debugserver/shared/DebugClient.o \ + build/debugserver/shared/OwnedHandle.o \ + build/debugserver/shared/StringUtil.o \ + build/debugserver/shared/WindowsSecurity.o \ + build/debugserver/shared/WindowsVersion.o \ + build/debugserver/shared/WinptyAssert.o \ + build/debugserver/shared/WinptyException.o + +build/debugserver/shared/WindowsVersion.o : build/gen/GenVersion.h + +build/winpty-debugserver.exe : $(DEBUGSERVER_OBJECTS) + $(info Linking $@) + @$(MINGW_CXX) $(MINGW_LDFLAGS) -o $@ $^ + +-include $(DEBUGSERVER_OBJECTS:.o=.d) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/include/winpty.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/include/winpty.h new file mode 100644 index 00000000..fdfe4bca --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/include/winpty.h @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2011-2016 Ryan Prichard + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +#ifndef WINPTY_H +#define WINPTY_H + +#include + +#include "winpty_constants.h" + +/* On 32-bit Windows, winpty functions have the default __cdecl (not __stdcall) + * calling convention. (64-bit Windows has only a single calling convention.) + * When compiled with __declspec(dllexport), with either MinGW or MSVC, the + * winpty functions are unadorned--no underscore prefix or '@nn' suffix--so + * GetProcAddress can be used easily. */ +#ifdef COMPILING_WINPTY_DLL +#define WINPTY_API __declspec(dllexport) +#else +#define WINPTY_API __declspec(dllimport) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* The winpty API uses wide characters, instead of UTF-8, to avoid conversion + * complications related to surrogates. Windows generally tolerates unpaired + * surrogates in text, which makes conversion to and from UTF-8 ambiguous and + * complicated. (There are different UTF-8 variants that deal with UTF-16 + * surrogates differently.) */ + + + +/***************************************************************************** + * Error handling. */ + +/* All the APIs have an optional winpty_error_t output parameter. If a + * non-NULL argument is specified, then either the API writes NULL to the + * value (on success) or writes a newly allocated winpty_error_t object. The + * object must be freed using winpty_error_free. */ + +/* An error object. */ +typedef struct winpty_error_s winpty_error_t; +typedef winpty_error_t *winpty_error_ptr_t; + +/* An error code -- one of WINPTY_ERROR_xxx. */ +typedef DWORD winpty_result_t; + +/* Gets the error code from the error object. */ +WINPTY_API winpty_result_t winpty_error_code(winpty_error_ptr_t err); + +/* Returns a textual representation of the error. The string is freed when + * the error is freed. */ +WINPTY_API LPCWSTR winpty_error_msg(winpty_error_ptr_t err); + +/* Free the error object. Every error returned from the winpty API must be + * freed. */ +WINPTY_API void winpty_error_free(winpty_error_ptr_t err); + + + +/***************************************************************************** + * Configuration of a new agent. */ + +/* The winpty_config_t object is not thread-safe. */ +typedef struct winpty_config_s winpty_config_t; + +/* Allocate a winpty_config_t value. Returns NULL on error. There are no + * required settings -- the object may immediately be used. agentFlags is a + * set of zero or more WINPTY_FLAG_xxx values. An unrecognized flag results + * in an assertion failure. */ +WINPTY_API winpty_config_t * +winpty_config_new(UINT64 agentFlags, winpty_error_ptr_t *err /*OPTIONAL*/); + +/* Free the cfg object after passing it to winpty_open. */ +WINPTY_API void winpty_config_free(winpty_config_t *cfg); + +WINPTY_API void +winpty_config_set_initial_size(winpty_config_t *cfg, int cols, int rows); + +/* Set the mouse mode to one of the WINPTY_MOUSE_MODE_xxx constants. */ +WINPTY_API void +winpty_config_set_mouse_mode(winpty_config_t *cfg, int mouseMode); + +/* Amount of time to wait for the agent to startup and to wait for any given + * agent RPC request. Must be greater than 0. Can be INFINITE. */ +WINPTY_API void +winpty_config_set_agent_timeout(winpty_config_t *cfg, DWORD timeoutMs); + + + +/***************************************************************************** + * Start the agent. */ + +/* The winpty_t object is thread-safe. */ +typedef struct winpty_s winpty_t; + +/* Starts the agent. Returns NULL on error. This process will connect to the + * agent over a control pipe, and the agent will open data pipes (e.g. CONIN + * and CONOUT). */ +WINPTY_API winpty_t * +winpty_open(const winpty_config_t *cfg, + winpty_error_ptr_t *err /*OPTIONAL*/); + +/* A handle to the agent process. This value is valid for the lifetime of the + * winpty_t object. Do not close it. */ +WINPTY_API HANDLE winpty_agent_process(winpty_t *wp); + + + +/***************************************************************************** + * I/O pipes. */ + +/* Returns the names of named pipes used for terminal I/O. Each input or + * output direction uses a different half-duplex pipe. The agent creates + * these pipes, and the client can connect to them using ordinary I/O methods. + * The strings are freed when the winpty_t object is freed. + * + * winpty_conerr_name returns NULL unless WINPTY_FLAG_CONERR is specified. + * + * N.B.: CreateFile does not block when connecting to a local server pipe. If + * the server pipe does not exist or is already connected, then it fails + * instantly. */ +WINPTY_API LPCWSTR winpty_conin_name(winpty_t *wp); +WINPTY_API LPCWSTR winpty_conout_name(winpty_t *wp); +WINPTY_API LPCWSTR winpty_conerr_name(winpty_t *wp); + + + +/***************************************************************************** + * winpty agent RPC call: process creation. */ + +/* The winpty_spawn_config_t object is not thread-safe. */ +typedef struct winpty_spawn_config_s winpty_spawn_config_t; + +/* winpty_spawn_config strings do not need to live as long as the config + * object. They are copied. Returns NULL on error. spawnFlags is a set of + * zero or more WINPTY_SPAWN_FLAG_xxx values. An unrecognized flag results in + * an assertion failure. + * + * env is a a pointer to an environment block like that passed to + * CreateProcess--a contiguous array of NUL-terminated "VAR=VAL" strings + * followed by a final NUL terminator. + * + * N.B.: If you want to gather all of the child's output, you may want the + * WINPTY_SPAWN_FLAG_AUTO_SHUTDOWN flag. + */ +WINPTY_API winpty_spawn_config_t * +winpty_spawn_config_new(UINT64 spawnFlags, + LPCWSTR appname /*OPTIONAL*/, + LPCWSTR cmdline /*OPTIONAL*/, + LPCWSTR cwd /*OPTIONAL*/, + LPCWSTR env /*OPTIONAL*/, + winpty_error_ptr_t *err /*OPTIONAL*/); + +/* Free the cfg object after passing it to winpty_spawn. */ +WINPTY_API void winpty_spawn_config_free(winpty_spawn_config_t *cfg); + +/* + * Spawns the new process. + * + * The function initializes all output parameters to zero or NULL. + * + * On success, the function returns TRUE. For each of process_handle and + * thread_handle that is non-NULL, the HANDLE returned from CreateProcess is + * duplicated from the agent and returned to the winpty client. The client is + * responsible for closing these HANDLES. + * + * On failure, the function returns FALSE, and if err is non-NULL, then *err + * is set to an error object. + * + * If the agent's CreateProcess call failed, then *create_process_error is set + * to GetLastError(), and the WINPTY_ERROR_SPAWN_CREATE_PROCESS_FAILED error + * is returned. + * + * winpty_spawn can only be called once per winpty_t object. If it is called + * before the output data pipe(s) is/are connected, then collected output is + * buffered until the pipes are connected, rather than being discarded. + * + * N.B.: GetProcessId works even if the process has exited. The PID is not + * recycled until the NT process object is freed. + * (https://blogs.msdn.microsoft.com/oldnewthing/20110107-00/?p=11803) + */ +WINPTY_API BOOL +winpty_spawn(winpty_t *wp, + const winpty_spawn_config_t *cfg, + HANDLE *process_handle /*OPTIONAL*/, + HANDLE *thread_handle /*OPTIONAL*/, + DWORD *create_process_error /*OPTIONAL*/, + winpty_error_ptr_t *err /*OPTIONAL*/); + + + +/***************************************************************************** + * winpty agent RPC calls: everything else */ + +/* Change the size of the Windows console window. */ +WINPTY_API BOOL +winpty_set_size(winpty_t *wp, int cols, int rows, + winpty_error_ptr_t *err /*OPTIONAL*/); + +/* Gets a list of processes attached to the console. */ +WINPTY_API int +winpty_get_console_process_list(winpty_t *wp, int *processList, const int processCount, + winpty_error_ptr_t *err /*OPTIONAL*/); + +/* Frees the winpty_t object and the OS resources contained in it. This + * call breaks the connection with the agent, which should then close its + * console, terminating the processes attached to it. + * + * This function must not be called if any other threads are using the + * winpty_t object. Undefined behavior results. */ +WINPTY_API void winpty_free(winpty_t *wp); + + + +/****************************************************************************/ + +#ifdef __cplusplus +} +#endif + +#endif /* WINPTY_H */ diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/include/winpty_constants.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/include/winpty_constants.h new file mode 100644 index 00000000..11e34cf1 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/include/winpty_constants.h @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2016 Ryan Prichard + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +#ifndef WINPTY_CONSTANTS_H +#define WINPTY_CONSTANTS_H + +/* + * You may want to include winpty.h instead, which includes this header. + * + * This file is split out from winpty.h so that the agent can access the + * winpty flags without also declaring the libwinpty APIs. + */ + +/***************************************************************************** + * Error codes. */ + +#define WINPTY_ERROR_SUCCESS 0 +#define WINPTY_ERROR_OUT_OF_MEMORY 1 +#define WINPTY_ERROR_SPAWN_CREATE_PROCESS_FAILED 2 +#define WINPTY_ERROR_LOST_CONNECTION 3 +#define WINPTY_ERROR_AGENT_EXE_MISSING 4 +#define WINPTY_ERROR_UNSPECIFIED 5 +#define WINPTY_ERROR_AGENT_DIED 6 +#define WINPTY_ERROR_AGENT_TIMEOUT 7 +#define WINPTY_ERROR_AGENT_CREATION_FAILED 8 + + + +/***************************************************************************** + * Configuration of a new agent. */ + +/* Create a new screen buffer (connected to the "conerr" terminal pipe) and + * pass it to child processes as the STDERR handle. This flag also prevents + * the agent from reopening CONOUT$ when it polls -- regardless of whether the + * active screen buffer changes, winpty continues to monitor the original + * primary screen buffer. */ +#define WINPTY_FLAG_CONERR 0x1ull + +/* Don't output escape sequences. */ +#define WINPTY_FLAG_PLAIN_OUTPUT 0x2ull + +/* Do output color escape sequences. These escapes are output by default, but + * are suppressed with WINPTY_FLAG_PLAIN_OUTPUT. Use this flag to reenable + * them. */ +#define WINPTY_FLAG_COLOR_ESCAPES 0x4ull + +/* On XP and Vista, winpty needs to put the hidden console on a desktop in a + * service window station so that its polling does not interfere with other + * (visible) console windows. To create this desktop, it must change the + * process' window station (i.e. SetProcessWindowStation) for the duration of + * the winpty_open call. In theory, this change could interfere with the + * winpty client (e.g. other threads, spawning children), so winpty by default + * spawns a special agent process to create the hidden desktop. Spawning + * processes on Windows is slow, though, so if + * WINPTY_FLAG_ALLOW_CURPROC_DESKTOP_CREATION is set, winpty changes this + * process' window station instead. + * See https://github.com/rprichard/winpty/issues/58. */ +#define WINPTY_FLAG_ALLOW_CURPROC_DESKTOP_CREATION 0x8ull + +#define WINPTY_FLAG_MASK (0ull \ + | WINPTY_FLAG_CONERR \ + | WINPTY_FLAG_PLAIN_OUTPUT \ + | WINPTY_FLAG_COLOR_ESCAPES \ + | WINPTY_FLAG_ALLOW_CURPROC_DESKTOP_CREATION \ +) + +/* QuickEdit mode is initially disabled, and the agent does not send mouse + * mode sequences to the terminal. If it receives mouse input, though, it + * still writes MOUSE_EVENT_RECORD values into CONIN. */ +#define WINPTY_MOUSE_MODE_NONE 0 + +/* QuickEdit mode is initially enabled. As CONIN enters or leaves mouse + * input mode (i.e. where ENABLE_MOUSE_INPUT is on and ENABLE_QUICK_EDIT_MODE + * is off), the agent enables or disables mouse input on the terminal. + * + * This is the default mode. */ +#define WINPTY_MOUSE_MODE_AUTO 1 + +/* QuickEdit mode is initially disabled, and the agent enables the terminal's + * mouse input mode. It does not disable terminal mouse mode (until exit). */ +#define WINPTY_MOUSE_MODE_FORCE 2 + + + +/***************************************************************************** + * winpty agent RPC call: process creation. */ + +/* If the spawn is marked "auto-shutdown", then the agent shuts down console + * output once the process exits. The agent stops polling for new console + * output, and once all pending data has been written to the output pipe, the + * agent closes the pipe. (At that point, the pipe may still have data in it, + * which the client may read. Once all the data has been read, further reads + * return EOF.) */ +#define WINPTY_SPAWN_FLAG_AUTO_SHUTDOWN 1ull + +/* After the agent shuts down output, and after all output has been written + * into the pipe(s), exit the agent by closing the console. If there any + * surviving processes still attached to the console, they are killed. + * + * Note: With this flag, an RPC call (e.g. winpty_set_size) issued after the + * agent exits will fail with an I/O or dead-agent error. */ +#define WINPTY_SPAWN_FLAG_EXIT_AFTER_SHUTDOWN 2ull + +/* All the spawn flags. */ +#define WINPTY_SPAWN_FLAG_MASK (0ull \ + | WINPTY_SPAWN_FLAG_AUTO_SHUTDOWN \ + | WINPTY_SPAWN_FLAG_EXIT_AFTER_SHUTDOWN \ +) + + + +#endif /* WINPTY_CONSTANTS_H */ diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/AgentLocation.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/AgentLocation.cc new file mode 100644 index 00000000..82d00b2d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/AgentLocation.cc @@ -0,0 +1,75 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "AgentLocation.h" + +#include + +#include + +#include "../shared/WinptyAssert.h" + +#include "LibWinptyException.h" + +#define AGENT_EXE L"winpty-agent.exe" + +static HMODULE getCurrentModule() { + HMODULE module; + if (!GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(getCurrentModule), + &module)) { + ASSERT(false && "GetModuleHandleEx failed"); + } + return module; +} + +static std::wstring getModuleFileName(HMODULE module) { + const int bufsize = 4096; + wchar_t path[bufsize]; + int size = GetModuleFileNameW(module, path, bufsize); + ASSERT(size != 0 && size != bufsize); + return std::wstring(path); +} + +static std::wstring dirname(const std::wstring &path) { + std::wstring::size_type pos = path.find_last_of(L"\\/"); + if (pos == std::wstring::npos) { + return L""; + } else { + return path.substr(0, pos); + } +} + +static bool pathExists(const std::wstring &path) { + return GetFileAttributesW(path.c_str()) != 0xFFFFFFFF; +} + +std::wstring findAgentProgram() { + std::wstring progDir = dirname(getModuleFileName(getCurrentModule())); + std::wstring ret = progDir + (L"\\" AGENT_EXE); + if (!pathExists(ret)) { + throw LibWinptyException( + WINPTY_ERROR_AGENT_EXE_MISSING, + (L"agent executable does not exist: '" + ret + L"'").c_str()); + } + return ret; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/AgentLocation.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/AgentLocation.h new file mode 100644 index 00000000..a96b854c --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/AgentLocation.h @@ -0,0 +1,28 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef LIBWINPTY_AGENT_LOCATION_H +#define LIBWINPTY_AGENT_LOCATION_H + +#include + +std::wstring findAgentProgram(); + +#endif // LIBWINPTY_AGENT_LOCATION_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/LibWinptyException.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/LibWinptyException.h new file mode 100644 index 00000000..2274798d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/LibWinptyException.h @@ -0,0 +1,54 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef LIB_WINPTY_EXCEPTION_H +#define LIB_WINPTY_EXCEPTION_H + +#include "../include/winpty.h" + +#include "../shared/WinptyException.h" + +#include +#include + +class LibWinptyException : public WinptyException { +public: + LibWinptyException(winpty_result_t code, const wchar_t *what) : + m_code(code), m_what(std::make_shared(what)) {} + + winpty_result_t code() const WINPTY_NOEXCEPT { + return m_code; + } + + const wchar_t *what() const WINPTY_NOEXCEPT override { + return m_what->c_str(); + } + + std::shared_ptr whatSharedStr() const WINPTY_NOEXCEPT { + return m_what; + } + +private: + winpty_result_t m_code; + // Using a shared_ptr ensures that copying the object raises no exception. + std::shared_ptr m_what; +}; + +#endif // LIB_WINPTY_EXCEPTION_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/WinptyInternal.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/WinptyInternal.h new file mode 100644 index 00000000..93e992d5 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/WinptyInternal.h @@ -0,0 +1,72 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef LIBWINPTY_WINPTY_INTERNAL_H +#define LIBWINPTY_WINPTY_INTERNAL_H + +#include +#include + +#include "../include/winpty.h" + +#include "../shared/Mutex.h" +#include "../shared/OwnedHandle.h" + +// The structures in this header are not intended to be accessed directly by +// client programs. + +struct winpty_error_s { + winpty_result_t code; + const wchar_t *msgStatic; + // Use a pointer to a std::shared_ptr so that the struct remains simple + // enough to statically initialize, for the benefit of static error + // objects like kOutOfMemory. + std::shared_ptr *msgDynamic; +}; + +struct winpty_config_s { + uint64_t flags = 0; + int cols = 80; + int rows = 25; + int mouseMode = WINPTY_MOUSE_MODE_AUTO; + DWORD timeoutMs = 30000; +}; + +struct winpty_s { + Mutex mutex; + OwnedHandle agentProcess; + OwnedHandle controlPipe; + DWORD agentTimeoutMs = 0; + OwnedHandle ioEvent; + std::wstring spawnDesktopName; + std::wstring coninPipeName; + std::wstring conoutPipeName; + std::wstring conerrPipeName; +}; + +struct winpty_spawn_config_s { + uint64_t winptyFlags = 0; + std::wstring appname; + std::wstring cmdline; + std::wstring cwd; + std::wstring env; +}; + +#endif // LIBWINPTY_WINPTY_INTERNAL_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/subdir.mk b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/subdir.mk new file mode 100644 index 00000000..ba32bad6 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/subdir.mk @@ -0,0 +1,46 @@ +# Copyright (c) 2011-2015 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +ALL_TARGETS += build/winpty.dll + +$(eval $(call def_mingw_target,libwinpty,-DCOMPILING_WINPTY_DLL)) + +LIBWINPTY_OBJECTS = \ + build/libwinpty/libwinpty/AgentLocation.o \ + build/libwinpty/libwinpty/winpty.o \ + build/libwinpty/shared/BackgroundDesktop.o \ + build/libwinpty/shared/Buffer.o \ + build/libwinpty/shared/DebugClient.o \ + build/libwinpty/shared/GenRandom.o \ + build/libwinpty/shared/OwnedHandle.o \ + build/libwinpty/shared/StringUtil.o \ + build/libwinpty/shared/WindowsSecurity.o \ + build/libwinpty/shared/WindowsVersion.o \ + build/libwinpty/shared/WinptyAssert.o \ + build/libwinpty/shared/WinptyException.o \ + build/libwinpty/shared/WinptyVersion.o + +build/libwinpty/shared/WinptyVersion.o : build/gen/GenVersion.h + +build/winpty.dll : $(LIBWINPTY_OBJECTS) + $(info Linking $@) + @$(MINGW_CXX) $(MINGW_LDFLAGS) -shared -o $@ $^ -Wl,--out-implib,build/winpty.lib + +-include $(LIBWINPTY_OBJECTS:.o=.d) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/winpty.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/winpty.cc new file mode 100644 index 00000000..3d977498 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/libwinpty/winpty.cc @@ -0,0 +1,970 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include + +#include +#include +#include + +#include +#include +#include + +#include "../include/winpty.h" + +#include "../shared/AgentMsg.h" +#include "../shared/BackgroundDesktop.h" +#include "../shared/Buffer.h" +#include "../shared/DebugClient.h" +#include "../shared/GenRandom.h" +#include "../shared/OwnedHandle.h" +#include "../shared/StringBuilder.h" +#include "../shared/StringUtil.h" +#include "../shared/WindowsSecurity.h" +#include "../shared/WindowsVersion.h" +#include "../shared/WinptyAssert.h" +#include "../shared/WinptyException.h" +#include "../shared/WinptyVersion.h" + +#include "AgentLocation.h" +#include "LibWinptyException.h" +#include "WinptyInternal.h" + + + +/***************************************************************************** + * Error handling -- translate C++ exceptions to an optional error object + * output and log the result. */ + +static const winpty_error_s kOutOfMemory = { + WINPTY_ERROR_OUT_OF_MEMORY, + L"Out of memory", + nullptr +}; + +static const winpty_error_s kBadRpcPacket = { + WINPTY_ERROR_UNSPECIFIED, + L"Bad RPC packet", + nullptr +}; + +static const winpty_error_s kUncaughtException = { + WINPTY_ERROR_UNSPECIFIED, + L"Uncaught C++ exception", + nullptr +}; + +/* Gets the error code from the error object. */ +WINPTY_API winpty_result_t winpty_error_code(winpty_error_ptr_t err) { + return err != nullptr ? err->code : WINPTY_ERROR_SUCCESS; +} + +/* Returns a textual representation of the error. The string is freed when + * the error is freed. */ +WINPTY_API LPCWSTR winpty_error_msg(winpty_error_ptr_t err) { + if (err != nullptr) { + if (err->msgStatic != nullptr) { + return err->msgStatic; + } else { + ASSERT(err->msgDynamic != nullptr); + std::wstring *msgPtr = err->msgDynamic->get(); + ASSERT(msgPtr != nullptr); + return msgPtr->c_str(); + } + } else { + return L"Success"; + } +} + +/* Free the error object. Every error returned from the winpty API must be + * freed. */ +WINPTY_API void winpty_error_free(winpty_error_ptr_t err) { + if (err != nullptr && err->msgDynamic != nullptr) { + delete err->msgDynamic; + delete err; + } +} + +static void translateException(winpty_error_ptr_t *&err) { + winpty_error_ptr_t ret = nullptr; + try { + try { + throw; + } catch (const ReadBuffer::DecodeError&) { + ret = const_cast(&kBadRpcPacket); + } catch (const LibWinptyException &e) { + std::unique_ptr obj(new winpty_error_t); + obj->code = e.code(); + obj->msgStatic = nullptr; + obj->msgDynamic = + new std::shared_ptr(e.whatSharedStr()); + ret = obj.release(); + } catch (const WinptyException &e) { + std::unique_ptr obj(new winpty_error_t); + std::shared_ptr msg(new std::wstring(e.what())); + obj->code = WINPTY_ERROR_UNSPECIFIED; + obj->msgStatic = nullptr; + obj->msgDynamic = new std::shared_ptr(msg); + ret = obj.release(); + } + } catch (const std::bad_alloc&) { + ret = const_cast(&kOutOfMemory); + } catch (...) { + ret = const_cast(&kUncaughtException); + } + trace("libwinpty error: code=%u msg='%s'", + static_cast(ret->code), + utf8FromWide(winpty_error_msg(ret)).c_str()); + if (err != nullptr) { + *err = ret; + } else { + winpty_error_free(ret); + } +} + +#define API_TRY \ + if (err != nullptr) { *err = nullptr; } \ + try + +#define API_CATCH(ret) \ + catch (...) { translateException(err); return (ret); } + + + +/***************************************************************************** + * Configuration of a new agent. */ + +WINPTY_API winpty_config_t * +winpty_config_new(UINT64 flags, winpty_error_ptr_t *err /*OPTIONAL*/) { + API_TRY { + ASSERT((flags & WINPTY_FLAG_MASK) == flags); + std::unique_ptr ret(new winpty_config_t); + ret->flags = flags; + return ret.release(); + } API_CATCH(nullptr) +} + +WINPTY_API void winpty_config_free(winpty_config_t *cfg) { + delete cfg; +} + +WINPTY_API void +winpty_config_set_initial_size(winpty_config_t *cfg, int cols, int rows) { + ASSERT(cfg != nullptr && cols > 0 && rows > 0); + cfg->cols = cols; + cfg->rows = rows; +} + +WINPTY_API void +winpty_config_set_mouse_mode(winpty_config_t *cfg, int mouseMode) { + ASSERT(cfg != nullptr && + mouseMode >= WINPTY_MOUSE_MODE_NONE && + mouseMode <= WINPTY_MOUSE_MODE_FORCE); + cfg->mouseMode = mouseMode; +} + +WINPTY_API void +winpty_config_set_agent_timeout(winpty_config_t *cfg, DWORD timeoutMs) { + ASSERT(cfg != nullptr && timeoutMs > 0); + cfg->timeoutMs = timeoutMs; +} + + + +/***************************************************************************** + * Agent I/O. */ + +namespace { + +// Once an I/O operation fails with ERROR_IO_PENDING, the caller *must* wait +// for it to complete, even after calling CancelIo on it! See +// https://blogs.msdn.microsoft.com/oldnewthing/20110202-00/?p=11613. This +// class enforces that requirement. +class PendingIo { + HANDLE m_file; + OVERLAPPED &m_over; + bool m_finished; +public: + // The file handle and OVERLAPPED object must live as long as the PendingIo + // object. + PendingIo(HANDLE file, OVERLAPPED &over) : + m_file(file), m_over(over), m_finished(false) {} + ~PendingIo() { + if (!m_finished) { + // We're not usually that interested in CancelIo's return value. + // In any case, we must not throw an exception in this dtor. + CancelIo(m_file); + waitForCompletion(); + } + } + std::tuple waitForCompletion(DWORD &actual) WINPTY_NOEXCEPT { + m_finished = true; + const BOOL success = + GetOverlappedResult(m_file, &m_over, &actual, TRUE); + return std::make_tuple(success, GetLastError()); + } + std::tuple waitForCompletion() WINPTY_NOEXCEPT { + DWORD actual = 0; + return waitForCompletion(actual); + } +}; + +} // anonymous namespace + +static void handlePendingIo(winpty_t &wp, OVERLAPPED &over, BOOL &success, + DWORD &lastError, DWORD &actual) { + if (!success && lastError == ERROR_IO_PENDING) { + PendingIo io(wp.controlPipe.get(), over); + const HANDLE waitHandles[2] = { wp.ioEvent.get(), + wp.agentProcess.get() }; + DWORD waitRet = WaitForMultipleObjects( + 2, waitHandles, FALSE, wp.agentTimeoutMs); + if (waitRet != WAIT_OBJECT_0) { + // The I/O is still pending. Cancel it, close the I/O event, and + // throw an exception. + if (waitRet == WAIT_OBJECT_0 + 1) { + throw LibWinptyException(WINPTY_ERROR_AGENT_DIED, L"agent died"); + } else if (waitRet == WAIT_TIMEOUT) { + throw LibWinptyException(WINPTY_ERROR_AGENT_TIMEOUT, + L"agent timed out"); + } else if (waitRet == WAIT_FAILED) { + throwWindowsError(L"WaitForMultipleObjects failed"); + } else { + ASSERT(false && + "unexpected WaitForMultipleObjects return value"); + } + } + std::tie(success, lastError) = io.waitForCompletion(actual); + } +} + +static void handlePendingIo(winpty_t &wp, OVERLAPPED &over, BOOL &success, + DWORD &lastError) { + DWORD actual = 0; + handlePendingIo(wp, over, success, lastError, actual); +} + +static void handleReadWriteErrors(winpty_t &wp, BOOL success, DWORD lastError, + const wchar_t *genericErrMsg) { + if (!success) { + // If the pipe connection is broken after it's been connected, then + // later I/O operations fail with ERROR_BROKEN_PIPE (reads) or + // ERROR_NO_DATA (writes). With Wine, they may also fail with + // ERROR_PIPE_NOT_CONNECTED. See this gist[1]. + // + // [1] https://gist.github.com/rprichard/8dd8ca134b39534b7da2733994aa07ba + if (lastError == ERROR_BROKEN_PIPE || lastError == ERROR_NO_DATA || + lastError == ERROR_PIPE_NOT_CONNECTED) { + throw LibWinptyException(WINPTY_ERROR_LOST_CONNECTION, + L"lost connection to agent"); + } else { + throwWindowsError(genericErrMsg, lastError); + } + } +} + +// Calls ConnectNamedPipe to wait until the agent connects to the control pipe. +static void +connectControlPipe(winpty_t &wp) { + OVERLAPPED over = {}; + over.hEvent = wp.ioEvent.get(); + BOOL success = ConnectNamedPipe(wp.controlPipe.get(), &over); + DWORD lastError = GetLastError(); + handlePendingIo(wp, over, success, lastError); + if (!success && lastError == ERROR_PIPE_CONNECTED) { + success = TRUE; + } + if (!success) { + throwWindowsError(L"ConnectNamedPipe failed", lastError); + } +} + +static void writeData(winpty_t &wp, const void *data, size_t amount) { + // Perform a single pipe write. + DWORD actual = 0; + OVERLAPPED over = {}; + over.hEvent = wp.ioEvent.get(); + BOOL success = WriteFile(wp.controlPipe.get(), data, amount, + &actual, &over); + DWORD lastError = GetLastError(); + if (!success) { + handlePendingIo(wp, over, success, lastError, actual); + handleReadWriteErrors(wp, success, lastError, L"WriteFile failed"); + ASSERT(success); + } + // TODO: Can a partial write actually happen somehow? + ASSERT(actual == amount && "WriteFile wrote fewer bytes than requested"); +} + +static inline WriteBuffer newPacket() { + WriteBuffer packet; + packet.putRawValue(0); // Reserve space for size. + return packet; +} + +static void writePacket(winpty_t &wp, WriteBuffer &packet) { + const auto &buf = packet.buf(); + packet.replaceRawValue(0, buf.size()); + writeData(wp, buf.data(), buf.size()); +} + +static size_t readData(winpty_t &wp, void *data, size_t amount) { + DWORD actual = 0; + OVERLAPPED over = {}; + over.hEvent = wp.ioEvent.get(); + BOOL success = ReadFile(wp.controlPipe.get(), data, amount, + &actual, &over); + DWORD lastError = GetLastError(); + if (!success) { + handlePendingIo(wp, over, success, lastError, actual); + handleReadWriteErrors(wp, success, lastError, L"ReadFile failed"); + } + return actual; +} + +static void readAll(winpty_t &wp, void *data, size_t amount) { + while (amount > 0) { + const size_t chunk = readData(wp, data, amount); + ASSERT(chunk <= amount && "readData result is larger than amount"); + data = reinterpret_cast(data) + chunk; + amount -= chunk; + } +} + +static uint64_t readUInt64(winpty_t &wp) { + uint64_t ret = 0; + readAll(wp, &ret, sizeof(ret)); + return ret; +} + +// Returns a reply packet's payload. +static ReadBuffer readPacket(winpty_t &wp) { + const uint64_t packetSize = readUInt64(wp); + if (packetSize < sizeof(packetSize) || packetSize > SIZE_MAX) { + throwWinptyException(L"Agent RPC error: invalid packet size"); + } + const size_t payloadSize = packetSize - sizeof(packetSize); + std::vector bytes(payloadSize); + readAll(wp, bytes.data(), bytes.size()); + return ReadBuffer(std::move(bytes)); +} + +static OwnedHandle createControlPipe(const std::wstring &name) { + const auto sd = createPipeSecurityDescriptorOwnerFullControl(); + if (!sd) { + throwWinptyException( + L"could not create the control pipe's SECURITY_DESCRIPTOR"); + } + SECURITY_ATTRIBUTES sa = {}; + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = sd.get(); + HANDLE ret = CreateNamedPipeW(name.c_str(), + /*dwOpenMode=*/ + PIPE_ACCESS_DUPLEX | + FILE_FLAG_FIRST_PIPE_INSTANCE | + FILE_FLAG_OVERLAPPED, + /*dwPipeMode=*/rejectRemoteClientsPipeFlag(), + /*nMaxInstances=*/1, + /*nOutBufferSize=*/8192, + /*nInBufferSize=*/256, + /*nDefaultTimeOut=*/30000, + &sa); + if (ret == INVALID_HANDLE_VALUE) { + throwWindowsError(L"CreateNamedPipeW failed"); + } + return OwnedHandle(ret); +} + + + +/***************************************************************************** + * Start the agent. */ + +static OwnedHandle createEvent() { + // manual reset, initially unset + HANDLE h = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (h == nullptr) { + throwWindowsError(L"CreateEventW failed"); + } + return OwnedHandle(h); +} + +// For debugging purposes, provide a way to keep the console on the main window +// station, visible. +static bool shouldShowConsoleWindow() { + char buf[32]; + return GetEnvironmentVariableA("WINPTY_SHOW_CONSOLE", buf, sizeof(buf)) > 0; +} + +static bool shouldCreateBackgroundDesktop(bool &createUsingAgent) { + // Prior to Windows 7, winpty's repeated selection-deselection loop + // prevented the user from interacting with their *visible* console + // windows, unless we placed the console onto a background desktop. + // The SetProcessWindowStation call interferes with the clipboard and + // isn't thread-safe, though[1]. The call should perhaps occur in a + // special agent subprocess. Spawning a process in a background desktop + // also breaks ConEmu, but marking the process SW_HIDE seems to correct + // that[2]. + // + // Windows 7 moved a lot of console handling out of csrss.exe and into + // a per-console conhost.exe process, which may explain why it isn't + // affected. + // + // This is a somewhat risky change, so there are low-level flags to + // assist in debugging if there are issues. + // + // [1] https://github.com/rprichard/winpty/issues/58 + // [2] https://github.com/rprichard/winpty/issues/70 + bool ret = !shouldShowConsoleWindow() && !isAtLeastWindows7(); + const bool force = hasDebugFlag("force_desktop"); + const bool force_spawn = hasDebugFlag("force_desktop_spawn"); + const bool force_curproc = hasDebugFlag("force_desktop_curproc"); + const bool suppress = hasDebugFlag("no_desktop"); + if (force + force_spawn + force_curproc + suppress > 1) { + trace("error: Only one of force_desktop, force_desktop_spawn, " + "force_desktop_curproc, and no_desktop may be set"); + } else if (force) { + ret = true; + } else if (force_spawn) { + ret = true; + createUsingAgent = true; + } else if (force_curproc) { + ret = true; + createUsingAgent = false; + } else if (suppress) { + ret = false; + } + return ret; +} + +static bool shouldSpecifyHideFlag() { + const bool force = hasDebugFlag("force_sw_hide"); + const bool suppress = hasDebugFlag("no_sw_hide"); + bool ret = !shouldShowConsoleWindow(); + if (force && suppress) { + trace("error: Both the force_sw_hide and no_sw_hide flags are set"); + } else if (force) { + ret = true; + } else if (suppress) { + ret = false; + } + return ret; +} + +static OwnedHandle startAgentProcess( + const std::wstring &desktop, + const std::wstring &controlPipeName, + const std::wstring ¶ms, + DWORD creationFlags, + DWORD &agentPid) { + const std::wstring exePath = findAgentProgram(); + const std::wstring cmdline = + (WStringBuilder(256) + << L"\"" << exePath << L"\" " + << controlPipeName << L' ' + << params).str_moved(); + + auto cmdlineV = vectorWithNulFromString(cmdline); + auto desktopV = vectorWithNulFromString(desktop); + + // Start the agent. + STARTUPINFOW sui = {}; + sui.cb = sizeof(sui); + sui.lpDesktop = desktop.empty() ? nullptr : desktopV.data(); + + if (shouldSpecifyHideFlag()) { + sui.dwFlags |= STARTF_USESHOWWINDOW; + sui.wShowWindow = SW_HIDE; + } + PROCESS_INFORMATION pi = {}; + const BOOL success = + CreateProcessW(exePath.c_str(), + cmdlineV.data(), + nullptr, nullptr, + /*bInheritHandles=*/FALSE, + /*dwCreationFlags=*/creationFlags, + nullptr, nullptr, + &sui, &pi); + if (!success) { + const DWORD lastError = GetLastError(); + const auto errStr = + (WStringBuilder(256) + << L"winpty-agent CreateProcess failed: cmdline='" << cmdline + << L"' err=0x" << whexOfInt(lastError)).str_moved(); + throw LibWinptyException( + WINPTY_ERROR_AGENT_CREATION_FAILED, errStr.c_str()); + } + CloseHandle(pi.hThread); + TRACE("Created agent successfully, pid=%u, cmdline=%s", + static_cast(pi.dwProcessId), + utf8FromWide(cmdline).c_str()); + agentPid = pi.dwProcessId; + return OwnedHandle(pi.hProcess); +} + +static void verifyPipeClientPid(HANDLE serverPipe, DWORD agentPid) { + const auto client = getNamedPipeClientProcessId(serverPipe); + const auto success = std::get<0>(client); + const auto lastError = std::get<2>(client); + if (success == GetNamedPipeClientProcessId_Result::Success) { + const auto clientPid = std::get<1>(client); + if (clientPid != agentPid) { + WStringBuilder errMsg; + errMsg << L"Security check failed: pipe client pid (" << clientPid + << L") does not match agent pid (" << agentPid << L")"; + throwWinptyException(errMsg.c_str()); + } + } else if (success == GetNamedPipeClientProcessId_Result::UnsupportedOs) { + trace("Pipe client PID security check skipped: " + "GetNamedPipeClientProcessId unsupported on this OS version"); + } else { + throwWindowsError(L"GetNamedPipeClientProcessId failed", lastError); + } +} + +static std::unique_ptr +createAgentSession(const winpty_config_t *cfg, + const std::wstring &desktop, + const std::wstring ¶ms, + DWORD creationFlags) { + std::unique_ptr wp(new winpty_t); + wp->agentTimeoutMs = cfg->timeoutMs; + wp->ioEvent = createEvent(); + + // Create control server pipe. + const auto pipeName = + L"\\\\.\\pipe\\winpty-control-" + GenRandom().uniqueName(); + wp->controlPipe = createControlPipe(pipeName); + + DWORD agentPid = 0; + wp->agentProcess = startAgentProcess( + desktop, pipeName, params, creationFlags, agentPid); + connectControlPipe(*wp.get()); + verifyPipeClientPid(wp->controlPipe.get(), agentPid); + + return std::move(wp); +} + +namespace { + +class AgentDesktop { +public: + virtual std::wstring name() = 0; + virtual ~AgentDesktop() {} +}; + +class AgentDesktopDirect : public AgentDesktop { +public: + AgentDesktopDirect(BackgroundDesktop &&desktop) : + m_desktop(std::move(desktop)) + { + } + std::wstring name() override { return m_desktop.desktopName(); } +private: + BackgroundDesktop m_desktop; +}; + +class AgentDesktopIndirect : public AgentDesktop { +public: + AgentDesktopIndirect(std::unique_ptr &&wp, + std::wstring &&desktopName) : + m_wp(std::move(wp)), + m_desktopName(std::move(desktopName)) + { + } + std::wstring name() override { return m_desktopName; } +private: + std::unique_ptr m_wp; + std::wstring m_desktopName; +}; + +} // anonymous namespace + +std::unique_ptr +setupBackgroundDesktop(const winpty_config_t *cfg) { + bool useDesktopAgent = + !(cfg->flags & WINPTY_FLAG_ALLOW_CURPROC_DESKTOP_CREATION); + const bool useDesktop = shouldCreateBackgroundDesktop(useDesktopAgent); + + if (!useDesktop) { + return std::unique_ptr(); + } + + if (useDesktopAgent) { + auto wp = createAgentSession( + cfg, std::wstring(), L"--create-desktop", DETACHED_PROCESS); + + // Read the desktop name. + auto packet = readPacket(*wp.get()); + auto desktopName = packet.getWString(); + packet.assertEof(); + + if (desktopName.empty()) { + return std::unique_ptr(); + } else { + return std::unique_ptr( + new AgentDesktopIndirect(std::move(wp), + std::move(desktopName))); + } + } else { + try { + BackgroundDesktop desktop; + return std::unique_ptr(new AgentDesktopDirect( + std::move(desktop))); + } catch (const WinptyException &e) { + trace("Error: failed to create background desktop, " + "using original desktop instead: %s", + utf8FromWide(e.what()).c_str()); + return std::unique_ptr(); + } + } +} + +WINPTY_API winpty_t * +winpty_open(const winpty_config_t *cfg, + winpty_error_ptr_t *err /*OPTIONAL*/) { + API_TRY { + ASSERT(cfg != nullptr); + dumpWindowsVersion(); + dumpVersionToTrace(); + + // Setup a background desktop for the agent. + auto desktop = setupBackgroundDesktop(cfg); + const auto desktopName = desktop ? desktop->name() : std::wstring(); + + // Start the primary agent session. + const auto params = + (WStringBuilder(128) + << cfg->flags << L' ' + << cfg->mouseMode << L' ' + << cfg->cols << L' ' + << cfg->rows).str_moved(); + auto wp = createAgentSession(cfg, desktopName, params, + CREATE_NEW_CONSOLE); + + // Close handles to the background desktop and restore the original + // window station. This must wait until we know the agent is running + // -- if we close these handles too soon, then the desktop and + // windowstation will be destroyed before the agent can connect with + // them. + // + // If we used a separate agent process to create the desktop, we + // disconnect from that process here, allowing it to exit. + desktop.reset(); + + // If we ran the agent process on a background desktop, then when we + // spawn a child process from the agent, it will need to be explicitly + // placed back onto the original desktop. + if (!desktopName.empty()) { + wp->spawnDesktopName = getCurrentDesktopName(); + } + + // Get the CONIN/CONOUT pipe names. + auto packet = readPacket(*wp.get()); + wp->coninPipeName = packet.getWString(); + wp->conoutPipeName = packet.getWString(); + if (cfg->flags & WINPTY_FLAG_CONERR) { + wp->conerrPipeName = packet.getWString(); + } + packet.assertEof(); + + return wp.release(); + } API_CATCH(nullptr) +} + +WINPTY_API HANDLE winpty_agent_process(winpty_t *wp) { + ASSERT(wp != nullptr); + return wp->agentProcess.get(); +} + + + +/***************************************************************************** + * I/O pipes. */ + +static const wchar_t *cstrFromWStringOrNull(const std::wstring &str) { + try { + return str.c_str(); + } catch (const std::bad_alloc&) { + return nullptr; + } +} + +WINPTY_API LPCWSTR winpty_conin_name(winpty_t *wp) { + ASSERT(wp != nullptr); + return cstrFromWStringOrNull(wp->coninPipeName); +} + +WINPTY_API LPCWSTR winpty_conout_name(winpty_t *wp) { + ASSERT(wp != nullptr); + return cstrFromWStringOrNull(wp->conoutPipeName); +} + +WINPTY_API LPCWSTR winpty_conerr_name(winpty_t *wp) { + ASSERT(wp != nullptr); + if (wp->conerrPipeName.empty()) { + return nullptr; + } else { + return cstrFromWStringOrNull(wp->conerrPipeName); + } +} + + + +/***************************************************************************** + * winpty agent RPC calls. */ + +namespace { + +// Close the control pipe if something goes wrong with the pipe communication, +// which could leave the control pipe in an inconsistent state. +class RpcOperation { +public: + RpcOperation(winpty_t &wp) : m_wp(wp) { + if (m_wp.controlPipe.get() == nullptr) { + throwWinptyException(L"Agent shutdown due to RPC failure"); + } + } + ~RpcOperation() { + if (!m_success) { + trace("~RpcOperation: Closing control pipe"); + m_wp.controlPipe.dispose(true); + } + } + void success() { m_success = true; } +private: + winpty_t &m_wp; + bool m_success = false; +}; + +} // anonymous namespace + + + +/***************************************************************************** + * winpty agent RPC call: process creation. */ + +// Return a std::wstring containing every character of the environment block. +// Typically, the block is non-empty, so the std::wstring returned ends with +// two NUL terminators. (These two terminators are counted in size(), so +// calling c_str() produces a triply-terminated string.) +static std::wstring wstringFromEnvBlock(const wchar_t *env) { + std::wstring envStr; + if (env != NULL) { + const wchar_t *p = env; + while (*p != L'\0') { + p += wcslen(p) + 1; + } + p++; + envStr.assign(env, p); + + // Assuming the environment was non-empty, envStr now ends with two NUL + // terminators. + // + // If the environment were empty, though, then envStr would only be + // singly terminated, but the MSDN documentation thinks an env block is + // always doubly-terminated, so add an extra NUL just in case it + // matters. + const auto envStrSz = envStr.size(); + if (envStrSz == 1) { + ASSERT(envStr[0] == L'\0'); + envStr.push_back(L'\0'); + } else { + ASSERT(envStrSz >= 3); + ASSERT(envStr[envStrSz - 3] != L'\0'); + ASSERT(envStr[envStrSz - 2] == L'\0'); + ASSERT(envStr[envStrSz - 1] == L'\0'); + } + } + return envStr; +} + +WINPTY_API winpty_spawn_config_t * +winpty_spawn_config_new(UINT64 winptyFlags, + LPCWSTR appname /*OPTIONAL*/, + LPCWSTR cmdline /*OPTIONAL*/, + LPCWSTR cwd /*OPTIONAL*/, + LPCWSTR env /*OPTIONAL*/, + winpty_error_ptr_t *err /*OPTIONAL*/) { + API_TRY { + ASSERT((winptyFlags & WINPTY_SPAWN_FLAG_MASK) == winptyFlags); + std::unique_ptr cfg(new winpty_spawn_config_t); + cfg->winptyFlags = winptyFlags; + if (appname != nullptr) { cfg->appname = appname; } + if (cmdline != nullptr) { cfg->cmdline = cmdline; } + if (cwd != nullptr) { cfg->cwd = cwd; } + if (env != nullptr) { cfg->env = wstringFromEnvBlock(env); } + return cfg.release(); + } API_CATCH(nullptr) +} + +WINPTY_API void winpty_spawn_config_free(winpty_spawn_config_t *cfg) { + delete cfg; +} + +// It's safe to truncate a handle from 64-bits to 32-bits, or to sign-extend it +// back to 64-bits. See the MSDN article, "Interprocess Communication Between +// 32-bit and 64-bit Applications". +// https://msdn.microsoft.com/en-us/library/windows/desktop/aa384203.aspx +static inline HANDLE handleFromInt64(int64_t i) { + return reinterpret_cast(static_cast(i)); +} + +// Given a process and a handle in that process, duplicate the handle into the +// current process and close it in the originating process. +static inline OwnedHandle stealHandle(HANDLE process, HANDLE handle) { + HANDLE result = nullptr; + if (!DuplicateHandle(process, handle, + GetCurrentProcess(), + &result, 0, FALSE, + DUPLICATE_CLOSE_SOURCE | DUPLICATE_SAME_ACCESS)) { + throwWindowsError(L"DuplicateHandle of process handle"); + } + return OwnedHandle(result); +} + +WINPTY_API BOOL +winpty_spawn(winpty_t *wp, + const winpty_spawn_config_t *cfg, + HANDLE *process_handle /*OPTIONAL*/, + HANDLE *thread_handle /*OPTIONAL*/, + DWORD *create_process_error /*OPTIONAL*/, + winpty_error_ptr_t *err /*OPTIONAL*/) { + API_TRY { + ASSERT(wp != nullptr && cfg != nullptr); + + if (process_handle != nullptr) { *process_handle = nullptr; } + if (thread_handle != nullptr) { *thread_handle = nullptr; } + if (create_process_error != nullptr) { *create_process_error = 0; } + + LockGuard lock(wp->mutex); + RpcOperation rpc(*wp); + + // Send spawn request. + auto packet = newPacket(); + packet.putInt32(AgentMsg::StartProcess); + packet.putInt64(cfg->winptyFlags); + packet.putInt32(process_handle != nullptr); + packet.putInt32(thread_handle != nullptr); + packet.putWString(cfg->appname); + packet.putWString(cfg->cmdline); + packet.putWString(cfg->cwd); + packet.putWString(cfg->env); + packet.putWString(wp->spawnDesktopName); + writePacket(*wp, packet); + + // Receive reply. + auto reply = readPacket(*wp); + const auto result = static_cast(reply.getInt32()); + if (result == StartProcessResult::CreateProcessFailed) { + const DWORD lastError = reply.getInt32(); + reply.assertEof(); + if (create_process_error != nullptr) { + *create_process_error = lastError; + } + rpc.success(); + throw LibWinptyException(WINPTY_ERROR_SPAWN_CREATE_PROCESS_FAILED, + L"CreateProcess failed"); + } else if (result == StartProcessResult::ProcessCreated) { + const HANDLE remoteProcess = handleFromInt64(reply.getInt64()); + const HANDLE remoteThread = handleFromInt64(reply.getInt64()); + reply.assertEof(); + OwnedHandle localProcess; + OwnedHandle localThread; + if (remoteProcess != nullptr) { + localProcess = + stealHandle(wp->agentProcess.get(), remoteProcess); + } + if (remoteThread != nullptr) { + localThread = + stealHandle(wp->agentProcess.get(), remoteThread); + } + if (process_handle != nullptr) { + *process_handle = localProcess.release(); + } + if (thread_handle != nullptr) { + *thread_handle = localThread.release(); + } + rpc.success(); + } else { + throwWinptyException( + L"Agent RPC error: invalid StartProcessResult"); + } + return TRUE; + } API_CATCH(FALSE) +} + + + +/***************************************************************************** + * winpty agent RPC calls: everything else */ + +WINPTY_API BOOL +winpty_set_size(winpty_t *wp, int cols, int rows, + winpty_error_ptr_t *err /*OPTIONAL*/) { + API_TRY { + ASSERT(wp != nullptr && cols > 0 && rows > 0); + LockGuard lock(wp->mutex); + RpcOperation rpc(*wp); + auto packet = newPacket(); + packet.putInt32(AgentMsg::SetSize); + packet.putInt32(cols); + packet.putInt32(rows); + writePacket(*wp, packet); + readPacket(*wp).assertEof(); + rpc.success(); + return TRUE; + } API_CATCH(FALSE) +} + +WINPTY_API int +winpty_get_console_process_list(winpty_t *wp, int *processList, const int processCount, + winpty_error_ptr_t *err /*OPTIONAL*/) { + API_TRY { + ASSERT(wp != nullptr); + ASSERT(processList != nullptr); + LockGuard lock(wp->mutex); + RpcOperation rpc(*wp); + auto packet = newPacket(); + packet.putInt32(AgentMsg::GetConsoleProcessList); + writePacket(*wp, packet); + auto reply = readPacket(*wp); + + auto actualProcessCount = reply.getInt32(); + + if (actualProcessCount <= processCount) { + for (auto i = 0; i < actualProcessCount; i++) { + processList[i] = reply.getInt32(); + } + } + + reply.assertEof(); + rpc.success(); + return actualProcessCount; + } API_CATCH(0) +} + +WINPTY_API void winpty_free(winpty_t *wp) { + // At least in principle, CloseHandle can fail, so this deletion can + // fail. It won't throw an exception, but maybe there's an error that + // should be propagated? + delete wp; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/AgentMsg.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/AgentMsg.h new file mode 100644 index 00000000..ab60c6b9 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/AgentMsg.h @@ -0,0 +1,38 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_SHARED_AGENT_MSG_H +#define WINPTY_SHARED_AGENT_MSG_H + +struct AgentMsg +{ + enum Type { + StartProcess, + SetSize, + GetConsoleProcessList, + }; +}; + +enum class StartProcessResult { + CreateProcessFailed, + ProcessCreated, +}; + +#endif // WINPTY_SHARED_AGENT_MSG_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/BackgroundDesktop.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/BackgroundDesktop.cc new file mode 100644 index 00000000..1bea7e53 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/BackgroundDesktop.cc @@ -0,0 +1,122 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "BackgroundDesktop.h" + +#include + +#include "DebugClient.h" +#include "StringUtil.h" +#include "WinptyException.h" + +namespace { + +static std::wstring getObjectName(HANDLE object) { + BOOL success; + DWORD lengthNeeded = 0; + GetUserObjectInformationW(object, UOI_NAME, + nullptr, 0, + &lengthNeeded); + ASSERT(lengthNeeded % sizeof(wchar_t) == 0); + std::unique_ptr tmp( + new wchar_t[lengthNeeded / sizeof(wchar_t)]); + success = GetUserObjectInformationW(object, UOI_NAME, + tmp.get(), lengthNeeded, + nullptr); + if (!success) { + throwWindowsError(L"GetUserObjectInformationW failed"); + } + return std::wstring(tmp.get()); +} + +static std::wstring getDesktopName(HWINSTA winsta, HDESK desk) { + return getObjectName(winsta) + L"\\" + getObjectName(desk); +} + +} // anonymous namespace + +// Get a non-interactive window station for the agent. +// TODO: review security w.r.t. windowstation and desktop. +BackgroundDesktop::BackgroundDesktop() { + try { + m_originalStation = GetProcessWindowStation(); + if (m_originalStation == nullptr) { + throwWindowsError( + L"BackgroundDesktop ctor: " + L"GetProcessWindowStation returned NULL"); + } + m_newStation = + CreateWindowStationW(nullptr, 0, WINSTA_ALL_ACCESS, nullptr); + if (m_newStation == nullptr) { + throwWindowsError( + L"BackgroundDesktop ctor: CreateWindowStationW returned NULL"); + } + if (!SetProcessWindowStation(m_newStation)) { + throwWindowsError( + L"BackgroundDesktop ctor: SetProcessWindowStation failed"); + } + m_newDesktop = CreateDesktopW( + L"Default", nullptr, nullptr, 0, GENERIC_ALL, nullptr); + if (m_newDesktop == nullptr) { + throwWindowsError( + L"BackgroundDesktop ctor: CreateDesktopW failed"); + } + m_newDesktopName = getDesktopName(m_newStation, m_newDesktop); + TRACE("Created background desktop: %s", + utf8FromWide(m_newDesktopName).c_str()); + } catch (...) { + dispose(); + throw; + } +} + +void BackgroundDesktop::dispose() WINPTY_NOEXCEPT { + if (m_originalStation != nullptr) { + SetProcessWindowStation(m_originalStation); + m_originalStation = nullptr; + } + if (m_newDesktop != nullptr) { + CloseDesktop(m_newDesktop); + m_newDesktop = nullptr; + } + if (m_newStation != nullptr) { + CloseWindowStation(m_newStation); + m_newStation = nullptr; + } +} + +std::wstring getCurrentDesktopName() { + // MSDN says that the handles returned by GetProcessWindowStation and + // GetThreadDesktop do not need to be passed to CloseWindowStation and + // CloseDesktop, respectively. + const HWINSTA winsta = GetProcessWindowStation(); + if (winsta == nullptr) { + throwWindowsError( + L"getCurrentDesktopName: " + L"GetProcessWindowStation returned NULL"); + } + const HDESK desk = GetThreadDesktop(GetCurrentThreadId()); + if (desk == nullptr) { + throwWindowsError( + L"getCurrentDesktopName: " + L"GetThreadDesktop returned NULL"); + } + return getDesktopName(winsta, desk); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/BackgroundDesktop.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/BackgroundDesktop.h new file mode 100644 index 00000000..c692e57d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/BackgroundDesktop.h @@ -0,0 +1,73 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_SHARED_BACKGROUND_DESKTOP_H +#define WINPTY_SHARED_BACKGROUND_DESKTOP_H + +#include + +#include + +#include "WinptyException.h" + +class BackgroundDesktop { +public: + BackgroundDesktop(); + ~BackgroundDesktop() { dispose(); } + void dispose() WINPTY_NOEXCEPT; + const std::wstring &desktopName() const { return m_newDesktopName; } + + BackgroundDesktop(const BackgroundDesktop &other) = delete; + BackgroundDesktop &operator=(const BackgroundDesktop &other) = delete; + + // We can't default the move constructor and assignment operator with + // MSVC 2013. We *could* if we required at least MSVC 2015 to build. + + BackgroundDesktop(BackgroundDesktop &&other) : + m_originalStation(other.m_originalStation), + m_newStation(other.m_newStation), + m_newDesktop(other.m_newDesktop), + m_newDesktopName(std::move(other.m_newDesktopName)) { + other.m_originalStation = nullptr; + other.m_newStation = nullptr; + other.m_newDesktop = nullptr; + } + BackgroundDesktop &operator=(BackgroundDesktop &&other) { + dispose(); + m_originalStation = other.m_originalStation; + m_newStation = other.m_newStation; + m_newDesktop = other.m_newDesktop; + m_newDesktopName = std::move(other.m_newDesktopName); + other.m_originalStation = nullptr; + other.m_newStation = nullptr; + other.m_newDesktop = nullptr; + return *this; + } + +private: + HWINSTA m_originalStation = nullptr; + HWINSTA m_newStation = nullptr; + HDESK m_newDesktop = nullptr; + std::wstring m_newDesktopName; +}; + +std::wstring getCurrentDesktopName(); + +#endif // WINPTY_SHARED_BACKGROUND_DESKTOP_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/Buffer.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/Buffer.cc new file mode 100644 index 00000000..158a629d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/Buffer.cc @@ -0,0 +1,103 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "Buffer.h" + +#include + +#include "DebugClient.h" +#include "WinptyAssert.h" + +// Define the READ_BUFFER_CHECK() macro. It *must* evaluate its condition, +// exactly once. +#define READ_BUFFER_CHECK(cond) \ + do { \ + if (!(cond)) { \ + trace("decode error: %s", #cond); \ + throw DecodeError(); \ + } \ + } while (false) + +enum class Piece : uint8_t { Int32, Int64, WString }; + +void WriteBuffer::putRawData(const void *data, size_t len) { + const auto p = reinterpret_cast(data); + m_buf.insert(m_buf.end(), p, p + len); +} + +void WriteBuffer::replaceRawData(size_t pos, const void *data, size_t len) { + ASSERT(pos <= m_buf.size() && len <= m_buf.size() - pos); + const auto p = reinterpret_cast(data); + std::copy(p, p + len, &m_buf[pos]); +} + +void WriteBuffer::putInt32(int32_t i) { + putRawValue(Piece::Int32); + putRawValue(i); +} + +void WriteBuffer::putInt64(int64_t i) { + putRawValue(Piece::Int64); + putRawValue(i); +} + +// len is in characters, excluding NUL, i.e. the number of wchar_t elements +void WriteBuffer::putWString(const wchar_t *str, size_t len) { + putRawValue(Piece::WString); + putRawValue(static_cast(len)); + putRawData(str, sizeof(wchar_t) * len); +} + +void ReadBuffer::getRawData(void *data, size_t len) { + ASSERT(m_off <= m_buf.size()); + READ_BUFFER_CHECK(len <= m_buf.size() - m_off); + const char *const inp = &m_buf[m_off]; + std::copy(inp, inp + len, reinterpret_cast(data)); + m_off += len; +} + +int32_t ReadBuffer::getInt32() { + READ_BUFFER_CHECK(getRawValue() == Piece::Int32); + return getRawValue(); +} + +int64_t ReadBuffer::getInt64() { + READ_BUFFER_CHECK(getRawValue() == Piece::Int64); + return getRawValue(); +} + +std::wstring ReadBuffer::getWString() { + READ_BUFFER_CHECK(getRawValue() == Piece::WString); + const uint64_t charLen = getRawValue(); + READ_BUFFER_CHECK(charLen <= SIZE_MAX / sizeof(wchar_t)); + // To be strictly conforming, we can't use the convenient wstring + // constructor, because the string in m_buf mightn't be aligned. + std::wstring ret; + if (charLen > 0) { + const size_t byteLen = charLen * sizeof(wchar_t); + ret.resize(charLen); + getRawData(&ret[0], byteLen); + } + return ret; +} + +void ReadBuffer::assertEof() { + READ_BUFFER_CHECK(m_off == m_buf.size()); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/Buffer.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/Buffer.h new file mode 100644 index 00000000..c2dd382e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/Buffer.h @@ -0,0 +1,102 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_SHARED_BUFFER_H +#define WINPTY_SHARED_BUFFER_H + +#include +#include + +#include +#include +#include +#include + +#include "WinptyException.h" + +class WriteBuffer { +private: + std::vector m_buf; + +public: + WriteBuffer() {} + + template void putRawValue(const T &t) { + putRawData(&t, sizeof(t)); + } + template void replaceRawValue(size_t pos, const T &t) { + replaceRawData(pos, &t, sizeof(t)); + } + + void putRawData(const void *data, size_t len); + void replaceRawData(size_t pos, const void *data, size_t len); + void putInt32(int32_t i); + void putInt64(int64_t i); + void putWString(const wchar_t *str, size_t len); + void putWString(const wchar_t *str) { putWString(str, wcslen(str)); } + void putWString(const std::wstring &str) { putWString(str.data(), str.size()); } + std::vector &buf() { return m_buf; } + + // MSVC 2013 does not generate these automatically, so help it out. + WriteBuffer(WriteBuffer &&other) : m_buf(std::move(other.m_buf)) {} + WriteBuffer &operator=(WriteBuffer &&other) { + m_buf = std::move(other.m_buf); + return *this; + } +}; + +class ReadBuffer { +public: + class DecodeError : public WinptyException { + virtual const wchar_t *what() const WINPTY_NOEXCEPT override { + return L"DecodeError: RPC message decoding error"; + } + }; + +private: + std::vector m_buf; + size_t m_off = 0; + +public: + explicit ReadBuffer(std::vector &&buf) : m_buf(std::move(buf)) {} + + template T getRawValue() { + T ret = {}; + getRawData(&ret, sizeof(ret)); + return ret; + } + + void getRawData(void *data, size_t len); + int32_t getInt32(); + int64_t getInt64(); + std::wstring getWString(); + void assertEof(); + + // MSVC 2013 does not generate these automatically, so help it out. + ReadBuffer(ReadBuffer &&other) : + m_buf(std::move(other.m_buf)), m_off(other.m_off) {} + ReadBuffer &operator=(ReadBuffer &&other) { + m_buf = std::move(other.m_buf); + m_off = other.m_off; + return *this; + } +}; + +#endif // WINPTY_SHARED_BUFFER_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/DebugClient.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/DebugClient.cc new file mode 100644 index 00000000..bafe0c89 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/DebugClient.cc @@ -0,0 +1,187 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "DebugClient.h" + +#include +#include +#include +#include + +#include +#include + +#include "winpty_snprintf.h" + +const wchar_t *const kPipeName = L"\\\\.\\pipe\\DebugServer"; + +void *volatile g_debugConfig; + +namespace { + +// It would be easy to accidentally trample on the Windows LastError value +// by adding logging/debugging code. Ensure that can't happen by saving and +// restoring the value. This saving and restoring doesn't happen along the +// fast path. +class PreserveLastError { +public: + PreserveLastError() : m_lastError(GetLastError()) {} + ~PreserveLastError() { SetLastError(m_lastError); } +private: + DWORD m_lastError; +}; + +} // anonymous namespace + +static void sendToDebugServer(const char *message) +{ + HANDLE tracePipe = INVALID_HANDLE_VALUE; + + do { + // The default impersonation level is SECURITY_IMPERSONATION, which allows + // a sufficiently authorized named pipe server to impersonate the client. + // There's no need for impersonation in this debugging system, so reduce + // the impersonation level to SECURITY_IDENTIFICATION, which allows a + // server to merely identify us. + tracePipe = CreateFileW( + kPipeName, + GENERIC_READ | GENERIC_WRITE, + 0, NULL, OPEN_EXISTING, + SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, + NULL); + } while (tracePipe == INVALID_HANDLE_VALUE && + GetLastError() == ERROR_PIPE_BUSY && + WaitNamedPipeW(kPipeName, NMPWAIT_WAIT_FOREVER)); + + if (tracePipe != INVALID_HANDLE_VALUE) { + DWORD newMode = PIPE_READMODE_MESSAGE; + SetNamedPipeHandleState(tracePipe, &newMode, NULL, NULL); + char response[16]; + DWORD actual = 0; + TransactNamedPipe(tracePipe, + const_cast(message), strlen(message), + response, sizeof(response), &actual, NULL); + CloseHandle(tracePipe); + } +} + +// Get the current UTC time as milliseconds from the epoch (ignoring leap +// seconds). Use the Unix epoch for consistency with DebugClient.py. There +// are 134774 days between 1601-01-01 (the Win32 epoch) and 1970-01-01 (the +// Unix epoch). +static long long unixTimeMillis() +{ + FILETIME fileTime; + GetSystemTimeAsFileTime(&fileTime); + long long msTime = (((long long)fileTime.dwHighDateTime << 32) + + fileTime.dwLowDateTime) / 10000; + return msTime - 134774LL * 24 * 3600 * 1000; +} + +static const char *getDebugConfig() +{ + if (g_debugConfig == NULL) { + PreserveLastError preserve; + const int bufSize = 256; + char buf[bufSize]; + DWORD actualSize = + GetEnvironmentVariableA("WINPTY_DEBUG", buf, bufSize); + if (actualSize == 0 || actualSize >= static_cast(bufSize)) { + buf[0] = '\0'; + } + const size_t len = strlen(buf) + 1; + char *newConfig = new char[len]; + std::copy(buf, buf + len, newConfig); + void *oldValue = InterlockedCompareExchangePointer( + &g_debugConfig, newConfig, NULL); + if (oldValue != NULL) { + delete [] newConfig; + } + } + return static_cast(g_debugConfig); +} + +bool isTracingEnabled() +{ + static bool disabled, enabled; + if (disabled) { + return false; + } else if (enabled) { + return true; + } else { + // Recognize WINPTY_DEBUG=1 for backwards compatibility. + PreserveLastError preserve; + bool value = hasDebugFlag("trace") || hasDebugFlag("1"); + disabled = !value; + enabled = value; + return value; + } +} + +bool hasDebugFlag(const char *flag) +{ + if (strchr(flag, ',') != NULL) { + trace("INTERNAL ERROR: hasDebugFlag flag has comma: '%s'", flag); + abort(); + } + const char *const configCStr = getDebugConfig(); + if (configCStr[0] == '\0') { + return false; + } + PreserveLastError preserve; + std::string config(configCStr); + std::string flagStr(flag); + config = "," + config + ","; + flagStr = "," + flagStr + ","; + return config.find(flagStr) != std::string::npos; +} + +void trace(const char *format, ...) +{ + if (!isTracingEnabled()) + return; + + PreserveLastError preserve; + char message[1024]; + + va_list ap; + va_start(ap, format); + winpty_vsnprintf(message, format, ap); + message[sizeof(message) - 1] = '\0'; + va_end(ap); + + const int currentTime = (int)(unixTimeMillis() % (100000 * 1000)); + + char moduleName[1024]; + moduleName[0] = '\0'; + GetModuleFileNameA(NULL, moduleName, sizeof(moduleName)); + const char *baseName = strrchr(moduleName, '\\'); + baseName = (baseName != NULL) ? baseName + 1 : moduleName; + + char fullMessage[1024]; + winpty_snprintf(fullMessage, + "[%05d.%03d %s,p%04d,t%04d]: %s", + currentTime / 1000, currentTime % 1000, + baseName, (int)GetCurrentProcessId(), (int)GetCurrentThreadId(), + message); + fullMessage[sizeof(fullMessage) - 1] = '\0'; + + sendToDebugServer(fullMessage); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/DebugClient.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/DebugClient.h new file mode 100644 index 00000000..b1260711 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/DebugClient.h @@ -0,0 +1,38 @@ +// Copyright (c) 2011-2012 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef DEBUGCLIENT_H +#define DEBUGCLIENT_H + +#include "winpty_snprintf.h" + +bool isTracingEnabled(); +bool hasDebugFlag(const char *flag); +void trace(const char *format, ...) WINPTY_SNPRINTF_FORMAT(1, 2); + +// This macro calls trace without evaluating the arguments. +#define TRACE(format, ...) \ + do { \ + if (isTracingEnabled()) { \ + trace((format), ## __VA_ARGS__); \ + } \ + } while (false) + +#endif // DEBUGCLIENT_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/GenRandom.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/GenRandom.cc new file mode 100644 index 00000000..6d792064 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/GenRandom.cc @@ -0,0 +1,138 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "GenRandom.h" + +#include +#include + +#include "DebugClient.h" +#include "StringBuilder.h" + +static volatile LONG g_pipeCounter; + +GenRandom::GenRandom() : m_advapi32(L"advapi32.dll") { + // First try to use the pseudo-documented RtlGenRandom function from + // advapi32.dll. Creating a CryptoAPI context is slow, and RtlGenRandom + // avoids the overhead. It's documented in this blog post[1] and on + // MSDN[2] with a disclaimer about future breakage. This technique is + // apparently built-in into the MSVC CRT, though, for the rand_s function, + // so perhaps it is stable enough. + // + // [1] http://blogs.msdn.com/b/michael_howard/archive/2005/01/14/353379.aspx + // [2] https://msdn.microsoft.com/en-us/library/windows/desktop/aa387694(v=vs.85).aspx + // + // Both RtlGenRandom and the Crypto API functions exist in XP and up. + m_rtlGenRandom = reinterpret_cast( + m_advapi32.proc("SystemFunction036")); + // The OsModule class logs an error message if the proc is nullptr. + if (m_rtlGenRandom != nullptr) { + return; + } + + // Fall back to the crypto API. + m_cryptProvIsValid = + CryptAcquireContext(&m_cryptProv, nullptr, nullptr, + PROV_RSA_FULL, CRYPT_VERIFYCONTEXT) != 0; + if (!m_cryptProvIsValid) { + trace("GenRandom: CryptAcquireContext failed: %u", + static_cast(GetLastError())); + } +} + +GenRandom::~GenRandom() { + if (m_cryptProvIsValid) { + CryptReleaseContext(m_cryptProv, 0); + } +} + +// Returns false if the context is invalid or the generation fails. +bool GenRandom::fillBuffer(void *buffer, size_t size) { + memset(buffer, 0, size); + bool success = false; + if (m_rtlGenRandom != nullptr) { + success = m_rtlGenRandom(buffer, size) != 0; + if (!success) { + trace("GenRandom: RtlGenRandom/SystemFunction036 failed: %u", + static_cast(GetLastError())); + } + } else if (m_cryptProvIsValid) { + success = + CryptGenRandom(m_cryptProv, size, + reinterpret_cast(buffer)) != 0; + if (!success) { + trace("GenRandom: CryptGenRandom failed, size=%d, lasterror=%u", + static_cast(size), + static_cast(GetLastError())); + } + } + return success; +} + +// Returns an empty string if either of CryptAcquireContext or CryptGenRandom +// fail. +std::string GenRandom::randomBytes(size_t numBytes) { + std::string ret(numBytes, '\0'); + if (!fillBuffer(&ret[0], numBytes)) { + return std::string(); + } + return ret; +} + +std::wstring GenRandom::randomHexString(size_t numBytes) { + const std::string bytes = randomBytes(numBytes); + std::wstring ret(bytes.size() * 2, L'\0'); + for (size_t i = 0; i < bytes.size(); ++i) { + static const wchar_t hex[] = L"0123456789abcdef"; + ret[i * 2] = hex[static_cast(bytes[i]) >> 4]; + ret[i * 2 + 1] = hex[static_cast(bytes[i]) & 0xF]; + } + return ret; +} + +// Returns a 64-bit value representing the number of 100-nanosecond intervals +// since January 1, 1601. +static uint64_t systemTimeAsUInt64() { + FILETIME monotonicTime = {}; + GetSystemTimeAsFileTime(&monotonicTime); + return (static_cast(monotonicTime.dwHighDateTime) << 32) | + static_cast(monotonicTime.dwLowDateTime); +} + +// Generates a unique and hard-to-guess case-insensitive string suitable for +// use in a pipe filename or a Windows object name. +std::wstring GenRandom::uniqueName() { + // First include enough information to avoid collisions assuming + // cooperative software. This code assumes that a process won't die and + // be replaced with a recycled PID within a single GetSystemTimeAsFileTime + // interval. + WStringBuilder sb(64); + sb << GetCurrentProcessId() + << L'-' << InterlockedIncrement(&g_pipeCounter) + << L'-' << whexOfInt(systemTimeAsUInt64()); + // It isn't clear to me how the crypto APIs would fail. It *probably* + // doesn't matter that much anyway? In principle, a predictable pipe name + // is subject to a local denial-of-service attack. + auto random = randomHexString(16); + if (!random.empty()) { + sb << L'-' << random; + } + return sb.str_moved(); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/GenRandom.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/GenRandom.h new file mode 100644 index 00000000..746cb1ec --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/GenRandom.h @@ -0,0 +1,55 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_GEN_RANDOM_H +#define WINPTY_GEN_RANDOM_H + +// The original MinGW requires that we include wincrypt.h. With MinGW-w64 and +// MSVC, including windows.h is sufficient. +#include +#include + +#include + +#include "OsModule.h" + +class GenRandom { + typedef BOOLEAN WINAPI RtlGenRandom_t(PVOID, ULONG); + + OsModule m_advapi32; + RtlGenRandom_t *m_rtlGenRandom = nullptr; + bool m_cryptProvIsValid = false; + HCRYPTPROV m_cryptProv = 0; + +public: + GenRandom(); + ~GenRandom(); + bool fillBuffer(void *buffer, size_t size); + std::string randomBytes(size_t numBytes); + std::wstring randomHexString(size_t numBytes); + std::wstring uniqueName(); + + // Return true if the crypto context was successfully initialized. + bool valid() const { + return m_rtlGenRandom != nullptr || m_cryptProvIsValid; + } +}; + +#endif // WINPTY_GEN_RANDOM_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/GetCommitHash.bat b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/GetCommitHash.bat new file mode 100644 index 00000000..a9f8e9ce --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/GetCommitHash.bat @@ -0,0 +1,13 @@ +@echo off + +REM -- Echo the git commit hash. If git isn't available for some reason, +REM -- output nothing instead. + +git rev-parse HEAD >NUL 2>NUL && ( + git rev-parse HEAD +) || ( + echo none +) + +REM -- Set ERRORLEVEL to 0 using this cryptic syntax. +(call ) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/Mutex.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/Mutex.h new file mode 100644 index 00000000..98215365 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/Mutex.h @@ -0,0 +1,54 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// Recent 4.x MinGW and MinGW-w64 gcc compilers lack std::mutex and +// std::lock_guard. I have a 5.2.0 MinGW-w64 compiler packaged through MSYS2 +// that *is* new enough, but that's one compiler against several deficient +// ones. Wrap CRITICAL_SECTION instead. + +#ifndef WINPTY_SHARED_MUTEX_H +#define WINPTY_SHARED_MUTEX_H + +#include + +class Mutex { + CRITICAL_SECTION m_mutex; +public: + Mutex() { InitializeCriticalSection(&m_mutex); } + ~Mutex() { DeleteCriticalSection(&m_mutex); } + void lock() { EnterCriticalSection(&m_mutex); } + void unlock() { LeaveCriticalSection(&m_mutex); } + + Mutex(const Mutex &other) = delete; + Mutex &operator=(const Mutex &other) = delete; +}; + +template +class LockGuard { + T &m_lock; +public: + LockGuard(T &lock) : m_lock(lock) { m_lock.lock(); } + ~LockGuard() { m_lock.unlock(); } + + LockGuard(const LockGuard &other) = delete; + LockGuard &operator=(const LockGuard &other) = delete; +}; + +#endif // WINPTY_SHARED_MUTEX_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/OsModule.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/OsModule.h new file mode 100644 index 00000000..9713fa2b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/OsModule.h @@ -0,0 +1,63 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_SHARED_OS_MODULE_H +#define WINPTY_SHARED_OS_MODULE_H + +#include + +#include + +#include "DebugClient.h" +#include "WinptyAssert.h" +#include "WinptyException.h" + +class OsModule { + HMODULE m_module; +public: + enum class LoadErrorBehavior { Abort, Throw }; + OsModule(const wchar_t *fileName, + LoadErrorBehavior behavior=LoadErrorBehavior::Abort) { + m_module = LoadLibraryW(fileName); + if (behavior == LoadErrorBehavior::Abort) { + ASSERT(m_module != NULL); + } else { + if (m_module == nullptr) { + const auto err = GetLastError(); + throwWindowsError( + (L"LoadLibraryW error: " + std::wstring(fileName)).c_str(), + err); + } + } + } + ~OsModule() { + FreeLibrary(m_module); + } + HMODULE handle() const { return m_module; } + FARPROC proc(const char *funcName) { + FARPROC ret = GetProcAddress(m_module, funcName); + if (ret == NULL) { + trace("GetProcAddress: %s is missing", funcName); + } + return ret; + } +}; + +#endif // WINPTY_SHARED_OS_MODULE_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/OwnedHandle.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/OwnedHandle.cc new file mode 100644 index 00000000..7b173536 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/OwnedHandle.cc @@ -0,0 +1,36 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "OwnedHandle.h" + +#include "DebugClient.h" +#include "WinptyException.h" + +void OwnedHandle::dispose(bool nothrow) { + if (m_h != nullptr && m_h != INVALID_HANDLE_VALUE) { + if (!CloseHandle(m_h)) { + trace("CloseHandle(%p) failed", m_h); + if (!nothrow) { + throwWindowsError(L"CloseHandle failed"); + } + } + } + m_h = nullptr; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/OwnedHandle.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/OwnedHandle.h new file mode 100644 index 00000000..70a8d616 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/OwnedHandle.h @@ -0,0 +1,45 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_SHARED_OWNED_HANDLE_H +#define WINPTY_SHARED_OWNED_HANDLE_H + +#include + +class OwnedHandle { + HANDLE m_h; +public: + OwnedHandle() : m_h(nullptr) {} + explicit OwnedHandle(HANDLE h) : m_h(h) {} + ~OwnedHandle() { dispose(true); } + void dispose(bool nothrow=false); + HANDLE get() const { return m_h; } + HANDLE release() { HANDLE ret = m_h; m_h = nullptr; return ret; } + OwnedHandle(const OwnedHandle &other) = delete; + OwnedHandle(OwnedHandle &&other) : m_h(other.release()) {} + OwnedHandle &operator=(const OwnedHandle &other) = delete; + OwnedHandle &operator=(OwnedHandle &&other) { + dispose(); + m_h = other.release(); + return *this; + } +}; + +#endif // WINPTY_SHARED_OWNED_HANDLE_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/PrecompiledHeader.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/PrecompiledHeader.h new file mode 100644 index 00000000..7d9b8f8b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/PrecompiledHeader.h @@ -0,0 +1,43 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_PRECOMPILED_HEADER_H +#define WINPTY_PRECOMPILED_HEADER_H + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#endif // WINPTY_PRECOMPILED_HEADER_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringBuilder.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringBuilder.h new file mode 100644 index 00000000..f3155bdd --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringBuilder.h @@ -0,0 +1,227 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// Efficient integer->string conversion and string concatenation. The +// hexadecimal conversion may optionally have leading zeros. Other ways to +// convert integers to strings in C++ suffer these drawbacks: +// +// * std::stringstream: Inefficient, even more so than stdio. +// +// * std::to_string: No hexadecimal output, tends to use heap allocation, not +// supported on Cygwin. +// +// * stdio routines: Requires parsing a format string (inefficient). The +// caller *must* know how large the content is for correctness. The +// string-printf functions are extremely inconsistent on Windows. In +// particular, 64-bit integers, wide strings, and return values are +// problem areas. +// +// StringBuilderTest.cc is a standalone program that tests this header. + +#ifndef WINPTY_STRING_BUILDER_H +#define WINPTY_STRING_BUILDER_H + +#include +#include +#include + +#ifdef STRING_BUILDER_TESTING +#include +#define STRING_BUILDER_CHECK(cond) assert(cond) +#else +#define STRING_BUILDER_CHECK(cond) +#endif // STRING_BUILDER_TESTING + +#include "WinptyAssert.h" + +template +struct ValueString { + std::array m_array; + size_t m_offset; + size_t m_size; + + const C *c_str() const { return m_array.data() + m_offset; } + const C *data() const { return m_array.data() + m_offset; } + size_t size() const { return m_size; } + std::basic_string str() const { + return std::basic_string(data(), m_size); + } +}; + +#ifdef _MSC_VER +// Disable an MSVC /SDL error that forbids unsigned negation. Signed negation +// invokes undefined behavior for INTxx_MIN, so unsigned negation is simpler to +// reason about. (We assume twos-complement in any case.) +#define STRING_BUILDER_ALLOW_UNSIGNED_NEGATE(x) \ + ( \ + __pragma(warning(push)) \ + __pragma(warning(disable:4146)) \ + (x) \ + __pragma(warning(pop)) \ + ) +#else +#define STRING_BUILDER_ALLOW_UNSIGNED_NEGATE(x) (x) +#endif + +// Formats an integer as decimal without leading zeros. +template +ValueString gdecOfInt(const I value) { + typedef typename std::make_unsigned::type U; + auto unsValue = static_cast(value); + const bool isNegative = (value < 0); + if (isNegative) { + unsValue = STRING_BUILDER_ALLOW_UNSIGNED_NEGATE(-unsValue); + } + decltype(gdecOfInt(value)) out; + auto &arr = out.m_array; + C *const endp = arr.data() + arr.size(); + C *outp = endp; + *(--outp) = '\0'; + STRING_BUILDER_CHECK(outp >= arr.data()); + do { + const int digit = unsValue % 10; + unsValue /= 10; + *(--outp) = '0' + digit; + STRING_BUILDER_CHECK(outp >= arr.data()); + } while (unsValue != 0); + if (isNegative) { + *(--outp) = '-'; + STRING_BUILDER_CHECK(outp >= arr.data()); + } + out.m_offset = outp - arr.data(); + out.m_size = endp - outp - 1; + return out; +} + +template decltype(gdecOfInt(0)) decOfInt(I i) { + return gdecOfInt(i); +} + +template decltype(gdecOfInt(0)) wdecOfInt(I i) { + return gdecOfInt(i); +} + +// Formats an integer as hexadecimal, with or without leading zeros. +template +ValueString ghexOfInt(const I value) { + typedef typename std::make_unsigned::type U; + const auto unsValue = static_cast(value); + static const C hex[16] = {'0','1','2','3','4','5','6','7', + '8','9','a','b','c','d','e','f'}; + decltype(ghexOfInt(value)) out; + auto &arr = out.m_array; + C *outp = arr.data(); + int inIndex = 0; + int shift = sizeof(I) * 8 - 4; + const int len = sizeof(I) * 2; + if (!leadingZeros) { + for (; inIndex < len - 1; ++inIndex, shift -= 4) { + STRING_BUILDER_CHECK(shift >= 0 && shift < sizeof(unsValue) * 8); + const int digit = (unsValue >> shift) & 0xF; + if (digit != 0) { + break; + } + } + } + for (; inIndex < len; ++inIndex, shift -= 4) { + const int digit = (unsValue >> shift) & 0xF; + *(outp++) = hex[digit]; + STRING_BUILDER_CHECK(outp <= arr.data() + arr.size()); + } + *(outp++) = '\0'; + STRING_BUILDER_CHECK(outp <= arr.data() + arr.size()); + out.m_offset = 0; + out.m_size = outp - arr.data() - 1; + return out; +} + +template +decltype(ghexOfInt(0)) hexOfInt(I i) { + return ghexOfInt(i); +} + +template +decltype(ghexOfInt(0)) whexOfInt(I i) { + return ghexOfInt(i); +} + +template +class GStringBuilder { +public: + typedef std::basic_string StringType; + + GStringBuilder() {} + GStringBuilder(size_t capacity) { + m_out.reserve(capacity); + } + + GStringBuilder &operator<<(C ch) { m_out.push_back(ch); return *this; } + GStringBuilder &operator<<(const C *str) { m_out.append(str); return *this; } + GStringBuilder &operator<<(const StringType &str) { m_out.append(str); return *this; } + + template + GStringBuilder &operator<<(const ValueString &str) { + m_out.append(str.data(), str.size()); + return *this; + } + +private: + // Forbid output of char/wchar_t for GStringBuilder if the type doesn't + // exactly match the builder element type. The code still allows + // signed char and unsigned char, but I'm a little worried about what + // happens if a user tries to output int8_t or uint8_t. + template + typename std::enable_if< + (std::is_same::value || std::is_same::value) && + !std::is_same::value, GStringBuilder&>::type + operator<<(P ch) { + ASSERT(false && "Method was not supposed to be reachable."); + return *this; + } + +public: + GStringBuilder &operator<<(short i) { return *this << gdecOfInt(i); } + GStringBuilder &operator<<(unsigned short i) { return *this << gdecOfInt(i); } + GStringBuilder &operator<<(int i) { return *this << gdecOfInt(i); } + GStringBuilder &operator<<(unsigned int i) { return *this << gdecOfInt(i); } + GStringBuilder &operator<<(long i) { return *this << gdecOfInt(i); } + GStringBuilder &operator<<(unsigned long i) { return *this << gdecOfInt(i); } + GStringBuilder &operator<<(long long i) { return *this << gdecOfInt(i); } + GStringBuilder &operator<<(unsigned long long i) { return *this << gdecOfInt(i); } + + GStringBuilder &operator<<(const void *p) { + m_out.push_back(static_cast('0')); + m_out.push_back(static_cast('x')); + *this << ghexOfInt(reinterpret_cast(p)); + return *this; + } + + StringType str() { return m_out; } + StringType str_moved() { return std::move(m_out); } + const C *c_str() const { return m_out.c_str(); } + +private: + StringType m_out; +}; + +typedef GStringBuilder StringBuilder; +typedef GStringBuilder WStringBuilder; + +#endif // WINPTY_STRING_BUILDER_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringBuilderTest.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringBuilderTest.cc new file mode 100644 index 00000000..e6c2d313 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringBuilderTest.cc @@ -0,0 +1,114 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#define STRING_BUILDER_TESTING + +#include "StringBuilder.h" + +#include +#include + +#include +#include + +void display(const std::string &str) { fprintf(stderr, "%s", str.c_str()); } +void display(const std::wstring &str) { fprintf(stderr, "%ls", str.c_str()); } + +#define CHECK_EQ(x, y) \ + do { \ + const auto xval = (x); \ + const auto yval = (y); \ + if (xval != yval) { \ + fprintf(stderr, "error: %s:%d: %s != %s: ", \ + __FILE__, __LINE__, #x, #y); \ + display(xval); \ + fprintf(stderr, " != "); \ + display(yval); \ + fprintf(stderr, "\n"); \ + } \ + } while(0) + +template +std::basic_string decOfIntSS(const I value) { + // std::to_string and std::to_wstring are missing in Cygwin as of this + // writing (early 2016). + std::basic_stringstream ss; + ss << +value; // We must promote char to print it as an integer. + return ss.str(); +} + + +template +std::basic_string hexOfIntSS(const I value) { + typedef typename std::make_unsigned::type U; + const unsigned long long u64Value = value & static_cast(~0); + std::basic_stringstream ss; + if (leadingZeros) { + ss << std::setfill(static_cast('0')) << std::setw(sizeof(I) * 2); + } + ss << std::hex << u64Value; + return ss.str(); +} + +template +void testValue(I value) { + CHECK_EQ(decOfInt(value).str(), (decOfIntSS(value))); + CHECK_EQ(wdecOfInt(value).str(), (decOfIntSS(value))); + CHECK_EQ((hexOfInt(value).str()), (hexOfIntSS(value))); + CHECK_EQ((hexOfInt(value).str()), (hexOfIntSS(value))); + CHECK_EQ((whexOfInt(value).str()), (hexOfIntSS(value))); + CHECK_EQ((whexOfInt(value).str()), (hexOfIntSS(value))); +} + +template +void testType() { + typedef typename std::make_unsigned::type U; + const U quarter = static_cast(1) << (sizeof(U) * 8 - 2); + for (unsigned quarterIndex = 0; quarterIndex < 4; ++quarterIndex) { + for (int offset = -18; offset <= 18; ++offset) { + const I value = quarter * quarterIndex + static_cast(offset); + testValue(value); + } + } + testValue(static_cast(42)); + testValue(static_cast(123456)); + testValue(static_cast(0xdeadfacecafebeefull)); +} + +int main() { + testType(); + + testType(); + testType(); + testType(); + testType(); + testType(); + + testType(); + testType(); + testType(); + testType(); + testType(); + + StringBuilder() << static_cast("TEST"); + WStringBuilder() << static_cast("TEST"); + + fprintf(stderr, "All tests completed!\n"); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringUtil.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringUtil.cc new file mode 100644 index 00000000..3a85a3ec --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringUtil.cc @@ -0,0 +1,55 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "StringUtil.h" + +#include + +#include "WinptyAssert.h" + +// Workaround. MinGW (from mingw.org) does not have wcsnlen. MinGW-w64 *does* +// have wcsnlen, but use this function for consistency. +size_t winpty_wcsnlen(const wchar_t *s, size_t maxlen) { + ASSERT(s != NULL); + for (size_t i = 0; i < maxlen; ++i) { + if (s[i] == L'\0') { + return i; + } + } + return maxlen; +} + +std::string utf8FromWide(const std::wstring &input) { + int mblen = WideCharToMultiByte( + CP_UTF8, 0, + input.data(), input.size(), + NULL, 0, NULL, NULL); + if (mblen <= 0) { + return std::string(); + } + std::vector tmp(mblen); + int mblen2 = WideCharToMultiByte( + CP_UTF8, 0, + input.data(), input.size(), + tmp.data(), tmp.size(), + NULL, NULL); + ASSERT(mblen2 == mblen); + return std::string(tmp.data(), tmp.size()); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringUtil.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringUtil.h new file mode 100644 index 00000000..e4bf3c91 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/StringUtil.h @@ -0,0 +1,80 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_SHARED_STRING_UTIL_H +#define WINPTY_SHARED_STRING_UTIL_H + +#include +#include +#include + +#include +#include +#include + +#include "WinptyAssert.h" + +size_t winpty_wcsnlen(const wchar_t *s, size_t maxlen); +std::string utf8FromWide(const std::wstring &input); + +// Return a vector containing each character in the string. +template +std::vector vectorFromString(const std::basic_string &str) { + return std::vector(str.begin(), str.end()); +} + +// Return a vector containing each character in the string, followed by a +// NUL terminator. +template +std::vector vectorWithNulFromString(const std::basic_string &str) { + std::vector ret; + ret.reserve(str.size() + 1); + ret.insert(ret.begin(), str.begin(), str.end()); + ret.push_back('\0'); + return ret; +} + +// A safer(?) version of wcsncpy that is accepted by MSVC's /SDL mode. +template +wchar_t *winpty_wcsncpy(wchar_t (&d)[N], const wchar_t *s) { + ASSERT(s != nullptr); + size_t i = 0; + for (; i < N; ++i) { + if (s[i] == L'\0') { + break; + } + d[i] = s[i]; + } + for (; i < N; ++i) { + d[i] = L'\0'; + } + return d; +} + +// Like wcsncpy, but ensure that the destination buffer is NUL-terminated. +template +wchar_t *winpty_wcsncpy_nul(wchar_t (&d)[N], const wchar_t *s) { + static_assert(N > 0, "array cannot be 0-size"); + winpty_wcsncpy(d, s); + d[N - 1] = L'\0'; + return d; +} + +#endif // WINPTY_SHARED_STRING_UTIL_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/TimeMeasurement.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/TimeMeasurement.h new file mode 100644 index 00000000..716a027f --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/TimeMeasurement.h @@ -0,0 +1,63 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// Convenience header library for using the high-resolution performance counter +// to measure how long some process takes. + +#ifndef TIME_MEASUREMENT_H +#define TIME_MEASUREMENT_H + +#include +#include +#include + +class TimeMeasurement { +public: + TimeMeasurement() { + static double freq = static_cast(getFrequency()); + m_freq = freq; + m_start = value(); + } + + double elapsed() { + uint64_t elapsedTicks = value() - m_start; + return static_cast(elapsedTicks) / m_freq; + } + +private: + uint64_t getFrequency() { + LARGE_INTEGER freq; + BOOL success = QueryPerformanceFrequency(&freq); + assert(success && "QueryPerformanceFrequency failed"); + return freq.QuadPart; + } + + uint64_t value() { + LARGE_INTEGER ret; + BOOL success = QueryPerformanceCounter(&ret); + assert(success && "QueryPerformanceCounter failed"); + return ret.QuadPart; + } + + uint64_t m_start; + double m_freq; +}; + +#endif // TIME_MEASUREMENT_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/UnixCtrlChars.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/UnixCtrlChars.h new file mode 100644 index 00000000..39dfa62e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/UnixCtrlChars.h @@ -0,0 +1,45 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef UNIX_CTRL_CHARS_H +#define UNIX_CTRL_CHARS_H + +inline char decodeUnixCtrlChar(char ch) { + const char ctrlKeys[] = { + /* 0x00 */ '@', /* 0x01 */ 'A', /* 0x02 */ 'B', /* 0x03 */ 'C', + /* 0x04 */ 'D', /* 0x05 */ 'E', /* 0x06 */ 'F', /* 0x07 */ 'G', + /* 0x08 */ 'H', /* 0x09 */ 'I', /* 0x0A */ 'J', /* 0x0B */ 'K', + /* 0x0C */ 'L', /* 0x0D */ 'M', /* 0x0E */ 'N', /* 0x0F */ 'O', + /* 0x10 */ 'P', /* 0x11 */ 'Q', /* 0x12 */ 'R', /* 0x13 */ 'S', + /* 0x14 */ 'T', /* 0x15 */ 'U', /* 0x16 */ 'V', /* 0x17 */ 'W', + /* 0x18 */ 'X', /* 0x19 */ 'Y', /* 0x1A */ 'Z', /* 0x1B */ '[', + /* 0x1C */ '\\', /* 0x1D */ ']', /* 0x1E */ '^', /* 0x1F */ '_', + }; + unsigned char uch = ch; + if (uch < 32) { + return ctrlKeys[uch]; + } else if (uch == 127) { + return '?'; + } else { + return '\0'; + } +} + +#endif // UNIX_CTRL_CHARS_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/UpdateGenVersion.bat b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/UpdateGenVersion.bat new file mode 100644 index 00000000..ea2a7d64 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/UpdateGenVersion.bat @@ -0,0 +1,20 @@ +@echo off + +rem -- Echo the git commit hash. If git isn't available for some reason, +rem -- output nothing instead. + +mkdir ..\gen 2>nul + +set /p VERSION=<..\..\VERSION.txt +set COMMIT=%1 + +echo // AUTO-GENERATED BY %0 %*>..\gen\GenVersion.h +echo const char GenVersion_Version[] = "%VERSION%";>>..\gen\GenVersion.h +echo const char GenVersion_Commit[] = "%COMMIT%";>>..\gen\GenVersion.h + +rem -- The winpty.gyp file expects the script to output the include directory, +rem -- relative to src. +echo gen + +rem -- Set ERRORLEVEL to 0 using this cryptic syntax. +(call ) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsSecurity.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsSecurity.cc new file mode 100644 index 00000000..711a8637 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsSecurity.cc @@ -0,0 +1,460 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "WindowsSecurity.h" + +#include + +#include "DebugClient.h" +#include "OsModule.h" +#include "OwnedHandle.h" +#include "StringBuilder.h" +#include "WindowsVersion.h" +#include "WinptyAssert.h" +#include "WinptyException.h" + +namespace { + +struct LocalFreer { + void operator()(void *ptr) { + if (ptr != nullptr) { + LocalFree(reinterpret_cast(ptr)); + } + } +}; + +typedef std::unique_ptr PointerLocal; + +template +SecurityItem localItem(typename T::type v) { + typedef typename T::type P; + struct Impl : SecurityItem::Impl { + P m_v; + Impl(P v) : m_v(v) {} + virtual ~Impl() { + LocalFree(reinterpret_cast(m_v)); + } + }; + return SecurityItem(v, std::unique_ptr(new Impl { v })); +} + +Sid allocatedSid(PSID v) { + struct Impl : Sid::Impl { + PSID m_v; + Impl(PSID v) : m_v(v) {} + virtual ~Impl() { + if (m_v != nullptr) { + FreeSid(m_v); + } + } + }; + return Sid(v, std::unique_ptr(new Impl { v })); +} + +} // anonymous namespace + +// Returns a handle to the thread's effective security token. If the thread +// is impersonating another user, its token is returned, and otherwise, the +// process' security token is opened. The handle is opened with TOKEN_QUERY. +static OwnedHandle openSecurityTokenForQuery() { + HANDLE token = nullptr; + // It is unclear to me whether OpenAsSelf matters for winpty, or what the + // most appropriate value is. + if (!OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, + /*OpenAsSelf=*/FALSE, &token)) { + if (GetLastError() != ERROR_NO_TOKEN) { + throwWindowsError(L"OpenThreadToken failed"); + } + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { + throwWindowsError(L"OpenProcessToken failed"); + } + } + ASSERT(token != nullptr && + "OpenThreadToken/OpenProcessToken token is NULL"); + return OwnedHandle(token); +} + +// Returns the TokenOwner of the thread's effective security token. +Sid getOwnerSid() { + struct Impl : Sid::Impl { + std::unique_ptr buffer; + }; + + OwnedHandle token = openSecurityTokenForQuery(); + DWORD actual = 0; + BOOL success; + success = GetTokenInformation(token.get(), TokenOwner, + nullptr, 0, &actual); + if (success) { + throwWinptyException(L"getOwnerSid: GetTokenInformation: " + L"expected ERROR_INSUFFICIENT_BUFFER"); + } else if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + throwWindowsError(L"getOwnerSid: GetTokenInformation: " + L"expected ERROR_INSUFFICIENT_BUFFER"); + } + std::unique_ptr impl(new Impl); + impl->buffer = std::unique_ptr(new char[actual]); + success = GetTokenInformation(token.get(), TokenOwner, + impl->buffer.get(), actual, &actual); + if (!success) { + throwWindowsError(L"getOwnerSid: GetTokenInformation"); + } + TOKEN_OWNER tmp; + ASSERT(actual >= sizeof(tmp)); + std::copy( + impl->buffer.get(), + impl->buffer.get() + sizeof(tmp), + reinterpret_cast(&tmp)); + return Sid(tmp.Owner, std::move(impl)); +} + +Sid wellKnownSid( + const wchar_t *debuggingName, + SID_IDENTIFIER_AUTHORITY authority, + BYTE authorityCount, + DWORD subAuthority0/*=0*/, + DWORD subAuthority1/*=0*/) { + PSID psid = nullptr; + if (!AllocateAndInitializeSid(&authority, authorityCount, + subAuthority0, + subAuthority1, + 0, 0, 0, 0, 0, 0, + &psid)) { + const auto err = GetLastError(); + const auto msg = + std::wstring(L"wellKnownSid: error getting ") + + debuggingName + L" SID"; + throwWindowsError(msg.c_str(), err); + } + return allocatedSid(psid); +} + +Sid builtinAdminsSid() { + // S-1-5-32-544 + SID_IDENTIFIER_AUTHORITY authority = { SECURITY_NT_AUTHORITY }; + return wellKnownSid(L"BUILTIN\\Administrators group", + authority, 2, + SECURITY_BUILTIN_DOMAIN_RID, // 32 + DOMAIN_ALIAS_RID_ADMINS); // 544 +} + +Sid localSystemSid() { + // S-1-5-18 + SID_IDENTIFIER_AUTHORITY authority = { SECURITY_NT_AUTHORITY }; + return wellKnownSid(L"LocalSystem account", + authority, 1, + SECURITY_LOCAL_SYSTEM_RID); // 18 +} + +Sid everyoneSid() { + // S-1-1-0 + SID_IDENTIFIER_AUTHORITY authority = { SECURITY_WORLD_SID_AUTHORITY }; + return wellKnownSid(L"Everyone account", + authority, 1, + SECURITY_WORLD_RID); // 0 +} + +static SecurityDescriptor finishSecurityDescriptor( + size_t daclEntryCount, + EXPLICIT_ACCESSW *daclEntries, + Acl &outAcl) { + { + PACL aclRaw = nullptr; + DWORD aclError = + SetEntriesInAclW(daclEntryCount, + daclEntries, + nullptr, &aclRaw); + if (aclError != ERROR_SUCCESS) { + WStringBuilder sb(64); + sb << L"finishSecurityDescriptor: " + << L"SetEntriesInAcl failed: " << aclError; + throwWinptyException(sb.c_str()); + } + outAcl = localItem(aclRaw); + } + + const PSECURITY_DESCRIPTOR sdRaw = + reinterpret_cast( + LocalAlloc(LPTR, SECURITY_DESCRIPTOR_MIN_LENGTH)); + if (sdRaw == nullptr) { + throwWinptyException(L"finishSecurityDescriptor: LocalAlloc failed"); + } + SecurityDescriptor sd = localItem(sdRaw); + if (!InitializeSecurityDescriptor(sdRaw, SECURITY_DESCRIPTOR_REVISION)) { + throwWindowsError( + L"finishSecurityDescriptor: InitializeSecurityDescriptor"); + } + if (!SetSecurityDescriptorDacl(sdRaw, TRUE, outAcl.get(), FALSE)) { + throwWindowsError( + L"finishSecurityDescriptor: SetSecurityDescriptorDacl"); + } + + return std::move(sd); +} + +// Create a security descriptor that grants full control to the local system +// account, built-in administrators, and the owner. +SecurityDescriptor +createPipeSecurityDescriptorOwnerFullControl() { + + struct Impl : SecurityDescriptor::Impl { + Sid localSystem; + Sid builtinAdmins; + Sid owner; + std::array daclEntries = {}; + Acl dacl; + SecurityDescriptor value; + }; + + std::unique_ptr impl(new Impl); + impl->localSystem = localSystemSid(); + impl->builtinAdmins = builtinAdminsSid(); + impl->owner = getOwnerSid(); + + for (auto &ea : impl->daclEntries) { + ea.grfAccessPermissions = GENERIC_ALL; + ea.grfAccessMode = SET_ACCESS; + ea.grfInheritance = NO_INHERITANCE; + ea.Trustee.TrusteeForm = TRUSTEE_IS_SID; + } + impl->daclEntries[0].Trustee.ptstrName = + reinterpret_cast(impl->localSystem.get()); + impl->daclEntries[1].Trustee.ptstrName = + reinterpret_cast(impl->builtinAdmins.get()); + impl->daclEntries[2].Trustee.ptstrName = + reinterpret_cast(impl->owner.get()); + + impl->value = finishSecurityDescriptor( + impl->daclEntries.size(), + impl->daclEntries.data(), + impl->dacl); + + const auto retValue = impl->value.get(); + return SecurityDescriptor(retValue, std::move(impl)); +} + +SecurityDescriptor +createPipeSecurityDescriptorOwnerFullControlEveryoneWrite() { + + struct Impl : SecurityDescriptor::Impl { + Sid localSystem; + Sid builtinAdmins; + Sid owner; + Sid everyone; + std::array daclEntries = {}; + Acl dacl; + SecurityDescriptor value; + }; + + std::unique_ptr impl(new Impl); + impl->localSystem = localSystemSid(); + impl->builtinAdmins = builtinAdminsSid(); + impl->owner = getOwnerSid(); + impl->everyone = everyoneSid(); + + for (auto &ea : impl->daclEntries) { + ea.grfAccessPermissions = GENERIC_ALL; + ea.grfAccessMode = SET_ACCESS; + ea.grfInheritance = NO_INHERITANCE; + ea.Trustee.TrusteeForm = TRUSTEE_IS_SID; + } + impl->daclEntries[0].Trustee.ptstrName = + reinterpret_cast(impl->localSystem.get()); + impl->daclEntries[1].Trustee.ptstrName = + reinterpret_cast(impl->builtinAdmins.get()); + impl->daclEntries[2].Trustee.ptstrName = + reinterpret_cast(impl->owner.get()); + impl->daclEntries[3].Trustee.ptstrName = + reinterpret_cast(impl->everyone.get()); + // Avoid using FILE_GENERIC_WRITE because it includes FILE_APPEND_DATA, + // which is equal to FILE_CREATE_PIPE_INSTANCE. Instead, include all the + // flags that comprise FILE_GENERIC_WRITE, except for the one. + impl->daclEntries[3].grfAccessPermissions = + FILE_GENERIC_READ | + FILE_WRITE_ATTRIBUTES | FILE_WRITE_DATA | FILE_WRITE_EA | + STANDARD_RIGHTS_WRITE | SYNCHRONIZE; + + impl->value = finishSecurityDescriptor( + impl->daclEntries.size(), + impl->daclEntries.data(), + impl->dacl); + + const auto retValue = impl->value.get(); + return SecurityDescriptor(retValue, std::move(impl)); +} + +SecurityDescriptor getObjectSecurityDescriptor(HANDLE handle) { + PACL dacl = nullptr; + PSECURITY_DESCRIPTOR sd = nullptr; + const DWORD errCode = GetSecurityInfo(handle, SE_KERNEL_OBJECT, + OWNER_SECURITY_INFORMATION | + GROUP_SECURITY_INFORMATION | + DACL_SECURITY_INFORMATION, + nullptr, nullptr, &dacl, nullptr, &sd); + if (errCode != ERROR_SUCCESS) { + throwWindowsError(L"GetSecurityInfo failed"); + } + return localItem(sd); +} + +// The (SID/SD)<->string conversion APIs are useful for testing/debugging, so +// create convenient accessor functions for them. They're too slow for +// ordinary use. The APIs exist in XP and up, but the MinGW headers only +// declare the SID<->string APIs, not the SD APIs. MinGW also gets the +// prototype wrong for ConvertStringSidToSidW (LPWSTR instead of LPCWSTR) and +// requires WINVER to be defined. MSVC and MinGW-w64 get everything right, but +// for consistency, use LoadLibrary/GetProcAddress for all four APIs. + +typedef BOOL WINAPI ConvertStringSidToSidW_t( + LPCWSTR StringSid, + PSID *Sid); + +typedef BOOL WINAPI ConvertSidToStringSidW_t( + PSID Sid, + LPWSTR *StringSid); + +typedef BOOL WINAPI ConvertStringSecurityDescriptorToSecurityDescriptorW_t( + LPCWSTR StringSecurityDescriptor, + DWORD StringSDRevision, + PSECURITY_DESCRIPTOR *SecurityDescriptor, + PULONG SecurityDescriptorSize); + +typedef BOOL WINAPI ConvertSecurityDescriptorToStringSecurityDescriptorW_t( + PSECURITY_DESCRIPTOR SecurityDescriptor, + DWORD RequestedStringSDRevision, + SECURITY_INFORMATION SecurityInformation, + LPWSTR *StringSecurityDescriptor, + PULONG StringSecurityDescriptorLen); + +#define GET_MODULE_PROC(mod, funcName) \ + const auto p##funcName = \ + reinterpret_cast( \ + mod.proc(#funcName)); \ + if (p##funcName == nullptr) { \ + throwWinptyException( \ + L"" L ## #funcName L" API is missing from ADVAPI32.DLL"); \ + } + +const DWORD kSDDL_REVISION_1 = 1; + +std::wstring sidToString(PSID sid) { + OsModule advapi32(L"advapi32.dll"); + GET_MODULE_PROC(advapi32, ConvertSidToStringSidW); + wchar_t *sidString = NULL; + BOOL success = pConvertSidToStringSidW(sid, &sidString); + if (!success) { + throwWindowsError(L"ConvertSidToStringSidW failed"); + } + PointerLocal freer(sidString); + return std::wstring(sidString); +} + +Sid stringToSid(const std::wstring &str) { + // Cast the string from const wchar_t* to LPWSTR because the function is + // incorrectly prototyped in the MinGW sddl.h header. The API does not + // modify the string -- it is correctly prototyped as taking LPCWSTR in + // MinGW-w64, MSVC, and MSDN. + OsModule advapi32(L"advapi32.dll"); + GET_MODULE_PROC(advapi32, ConvertStringSidToSidW); + PSID psid = nullptr; + BOOL success = pConvertStringSidToSidW(const_cast(str.c_str()), + &psid); + if (!success) { + const auto err = GetLastError(); + throwWindowsError( + (std::wstring(L"ConvertStringSidToSidW failed on \"") + + str + L'"').c_str(), + err); + } + return localItem(psid); +} + +SecurityDescriptor stringToSd(const std::wstring &str) { + OsModule advapi32(L"advapi32.dll"); + GET_MODULE_PROC(advapi32, ConvertStringSecurityDescriptorToSecurityDescriptorW); + PSECURITY_DESCRIPTOR desc = nullptr; + if (!pConvertStringSecurityDescriptorToSecurityDescriptorW( + str.c_str(), kSDDL_REVISION_1, &desc, nullptr)) { + const auto err = GetLastError(); + throwWindowsError( + (std::wstring(L"ConvertStringSecurityDescriptorToSecurityDescriptorW failed on \"") + + str + L'"').c_str(), + err); + } + return localItem(desc); +} + +std::wstring sdToString(PSECURITY_DESCRIPTOR sd) { + OsModule advapi32(L"advapi32.dll"); + GET_MODULE_PROC(advapi32, ConvertSecurityDescriptorToStringSecurityDescriptorW); + wchar_t *sdString = nullptr; + if (!pConvertSecurityDescriptorToStringSecurityDescriptorW( + sd, + kSDDL_REVISION_1, + OWNER_SECURITY_INFORMATION | + GROUP_SECURITY_INFORMATION | + DACL_SECURITY_INFORMATION, + &sdString, + nullptr)) { + throwWindowsError( + L"ConvertSecurityDescriptorToStringSecurityDescriptor failed"); + } + PointerLocal freer(sdString); + return std::wstring(sdString); +} + +// Vista added a useful flag to CreateNamedPipe, PIPE_REJECT_REMOTE_CLIENTS, +// that rejects remote connections. Return this flag on Vista, or return 0 +// otherwise. +DWORD rejectRemoteClientsPipeFlag() { + if (isAtLeastWindowsVista()) { + // MinGW lacks this flag; MinGW-w64 has it. + const DWORD kPIPE_REJECT_REMOTE_CLIENTS = 8; + return kPIPE_REJECT_REMOTE_CLIENTS; + } else { + trace("Omitting PIPE_REJECT_REMOTE_CLIENTS on pre-Vista OS"); + return 0; + } +} + +typedef BOOL WINAPI GetNamedPipeClientProcessId_t( + HANDLE Pipe, + PULONG ClientProcessId); + +std::tuple +getNamedPipeClientProcessId(HANDLE serverPipe) { + OsModule kernel32(L"kernel32.dll"); + const auto pGetNamedPipeClientProcessId = + reinterpret_cast( + kernel32.proc("GetNamedPipeClientProcessId")); + if (pGetNamedPipeClientProcessId == nullptr) { + return std::make_tuple( + GetNamedPipeClientProcessId_Result::UnsupportedOs, 0, 0); + } + ULONG pid = 0; + if (!pGetNamedPipeClientProcessId(serverPipe, &pid)) { + return std::make_tuple( + GetNamedPipeClientProcessId_Result::Failure, 0, GetLastError()); + } + return std::make_tuple( + GetNamedPipeClientProcessId_Result::Success, + static_cast(pid), + 0); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsSecurity.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsSecurity.h new file mode 100644 index 00000000..5f9d53af --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsSecurity.h @@ -0,0 +1,104 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_WINDOWS_SECURITY_H +#define WINPTY_WINDOWS_SECURITY_H + +#include +#include + +#include +#include +#include +#include + +// PSID and PSECURITY_DESCRIPTOR are both pointers to void, but we want +// Sid and SecurityDescriptor to be different types. +struct SidTag { typedef PSID type; }; +struct AclTag { typedef PACL type; }; +struct SecurityDescriptorTag { typedef PSECURITY_DESCRIPTOR type; }; + +template +class SecurityItem { +public: + struct Impl { + virtual ~Impl() {} + }; + +private: + typedef typename T::type P; + P m_v; + std::unique_ptr m_pimpl; + +public: + P get() const { return m_v; } + operator bool() const { return m_v != nullptr; } + + SecurityItem() : m_v(nullptr) {} + SecurityItem(P v, std::unique_ptr &&pimpl) : + m_v(v), m_pimpl(std::move(pimpl)) {} + SecurityItem(SecurityItem &&other) : + m_v(other.m_v), m_pimpl(std::move(other.m_pimpl)) { + other.m_v = nullptr; + } + SecurityItem &operator=(SecurityItem &&other) { + m_v = other.m_v; + other.m_v = nullptr; + m_pimpl = std::move(other.m_pimpl); + return *this; + } +}; + +typedef SecurityItem Sid; +typedef SecurityItem Acl; +typedef SecurityItem SecurityDescriptor; + +Sid getOwnerSid(); +Sid wellKnownSid( + const wchar_t *debuggingName, + SID_IDENTIFIER_AUTHORITY authority, + BYTE authorityCount, + DWORD subAuthority0=0, + DWORD subAuthority1=0); +Sid builtinAdminsSid(); +Sid localSystemSid(); +Sid everyoneSid(); + +SecurityDescriptor createPipeSecurityDescriptorOwnerFullControl(); +SecurityDescriptor createPipeSecurityDescriptorOwnerFullControlEveryoneWrite(); +SecurityDescriptor getObjectSecurityDescriptor(HANDLE handle); + +std::wstring sidToString(PSID sid); +Sid stringToSid(const std::wstring &str); +SecurityDescriptor stringToSd(const std::wstring &str); +std::wstring sdToString(PSECURITY_DESCRIPTOR sd); + +DWORD rejectRemoteClientsPipeFlag(); + +enum class GetNamedPipeClientProcessId_Result { + Success, + Failure, + UnsupportedOs, +}; + +std::tuple +getNamedPipeClientProcessId(HANDLE serverPipe); + +#endif // WINPTY_WINDOWS_SECURITY_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsVersion.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsVersion.cc new file mode 100644 index 00000000..d89b00d8 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsVersion.cc @@ -0,0 +1,252 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "WindowsVersion.h" + +#include +#include + +#include +#include +#include + +#include "DebugClient.h" +#include "OsModule.h" +#include "StringBuilder.h" +#include "StringUtil.h" +#include "WinptyAssert.h" +#include "WinptyException.h" + +namespace { + +typedef std::tuple Version; + +// This function can only return a version up to 6.2 unless the executable is +// manifested for a newer version of Windows. See the MSDN documentation for +// GetVersionEx. +OSVERSIONINFOEX getWindowsVersionInfo() { + // Allow use of deprecated functions (i.e. GetVersionEx). We need to use + // GetVersionEx for the old MinGW toolchain and with MSVC when it targets XP. + // Having two code paths makes code harder to test, and it's not obvious how + // to detect the presence of a new enough SDK. (Including ntverp.h and + // examining VER_PRODUCTBUILD apparently works, but even then, MinGW-w64 and + // MSVC seem to use different version numbers.) +#ifdef _MSC_VER +#pragma warning(push) +#pragma warning(disable:4996) +#endif + OSVERSIONINFOEX info = {}; + info.dwOSVersionInfoSize = sizeof(info); + const auto success = GetVersionEx(reinterpret_cast(&info)); + ASSERT(success && "GetVersionEx failed"); + return info; +#ifdef _MSC_VER +#pragma warning(pop) +#endif +} + +Version getWindowsVersion() { + const auto info = getWindowsVersionInfo(); + return Version(info.dwMajorVersion, info.dwMinorVersion); +} + +struct ModuleNotFound : WinptyException { + virtual const wchar_t *what() const WINPTY_NOEXCEPT override { + return L"ModuleNotFound"; + } +}; + +// Throws WinptyException on error. +std::wstring getSystemDirectory() { + wchar_t systemDirectory[MAX_PATH]; + const UINT size = GetSystemDirectoryW(systemDirectory, MAX_PATH); + if (size == 0) { + throwWindowsError(L"GetSystemDirectory failed"); + } else if (size >= MAX_PATH) { + throwWinptyException( + L"GetSystemDirectory: path is longer than MAX_PATH"); + } + return systemDirectory; +} + +#define GET_VERSION_DLL_API(name) \ + const auto p ## name = \ + reinterpret_cast( \ + versionDll.proc(#name)); \ + if (p ## name == nullptr) { \ + throwWinptyException(L ## #name L" is missing"); \ + } + +// Throws WinptyException on error. +VS_FIXEDFILEINFO getFixedFileInfo(const std::wstring &path) { + // version.dll is not a conventional KnownDll, so if we link to it, there's + // a danger of accidentally loading a malicious DLL. In a more typical + // application, perhaps we'd guard against this security issue by + // controlling which directories this code runs in (e.g. *not* the + // "Downloads" directory), but that's harder for the winpty library. + OsModule versionDll( + (getSystemDirectory() + L"\\version.dll").c_str(), + OsModule::LoadErrorBehavior::Throw); + GET_VERSION_DLL_API(GetFileVersionInfoSizeW); + GET_VERSION_DLL_API(GetFileVersionInfoW); + GET_VERSION_DLL_API(VerQueryValueW); + DWORD size = pGetFileVersionInfoSizeW(path.c_str(), nullptr); + if (!size) { + // I see ERROR_FILE_NOT_FOUND on Win7 and + // ERROR_RESOURCE_DATA_NOT_FOUND on WinXP. + if (GetLastError() == ERROR_FILE_NOT_FOUND || + GetLastError() == ERROR_RESOURCE_DATA_NOT_FOUND) { + throw ModuleNotFound(); + } else { + throwWindowsError( + (L"GetFileVersionInfoSizeW failed on " + path).c_str()); + } + } + std::unique_ptr versionBuffer(new char[size]); + if (!pGetFileVersionInfoW(path.c_str(), 0, size, versionBuffer.get())) { + throwWindowsError((L"GetFileVersionInfoW failed on " + path).c_str()); + } + VS_FIXEDFILEINFO *versionInfo = nullptr; + UINT versionInfoSize = 0; + if (!pVerQueryValueW( + versionBuffer.get(), L"\\", + reinterpret_cast(&versionInfo), &versionInfoSize) || + versionInfo == nullptr || + versionInfoSize != sizeof(VS_FIXEDFILEINFO) || + versionInfo->dwSignature != 0xFEEF04BD) { + throwWinptyException((L"VerQueryValueW failed on " + path).c_str()); + } + return *versionInfo; +} + +uint64_t productVersionFromInfo(const VS_FIXEDFILEINFO &info) { + return (static_cast(info.dwProductVersionMS) << 32) | + (static_cast(info.dwProductVersionLS)); +} + +uint64_t fileVersionFromInfo(const VS_FIXEDFILEINFO &info) { + return (static_cast(info.dwFileVersionMS) << 32) | + (static_cast(info.dwFileVersionLS)); +} + +std::string versionToString(uint64_t version) { + StringBuilder b(32); + b << ((uint16_t)(version >> 48)); + b << '.'; + b << ((uint16_t)(version >> 32)); + b << '.'; + b << ((uint16_t)(version >> 16)); + b << '.'; + b << ((uint16_t)(version >> 0)); + return b.str_moved(); +} + +} // anonymous namespace + +// Returns true for Windows Vista (or Windows Server 2008) or newer. +bool isAtLeastWindowsVista() { + return getWindowsVersion() >= Version(6, 0); +} + +// Returns true for Windows 7 (or Windows Server 2008 R2) or newer. +bool isAtLeastWindows7() { + return getWindowsVersion() >= Version(6, 1); +} + +// Returns true for Windows 8 (or Windows Server 2012) or newer. +bool isAtLeastWindows8() { + return getWindowsVersion() >= Version(6, 2); +} + +#define WINPTY_IA32 1 +#define WINPTY_X64 2 + +#if defined(_M_IX86) || defined(__i386__) +#define WINPTY_ARCH WINPTY_IA32 +#elif defined(_M_X64) || defined(__x86_64__) +#define WINPTY_ARCH WINPTY_X64 +#endif + +typedef BOOL WINAPI IsWow64Process_t(HANDLE hProcess, PBOOL Wow64Process); + +void dumpWindowsVersion() { + if (!isTracingEnabled()) { + return; + } + const auto info = getWindowsVersionInfo(); + StringBuilder b; + b << info.dwMajorVersion << '.' << info.dwMinorVersion + << '.' << info.dwBuildNumber << ' ' + << "SP" << info.wServicePackMajor << '.' << info.wServicePackMinor + << ' '; + switch (info.wProductType) { + case VER_NT_WORKSTATION: b << "Client"; break; + case VER_NT_DOMAIN_CONTROLLER: b << "DomainController"; break; + case VER_NT_SERVER: b << "Server"; break; + default: + b << "product=" << info.wProductType; break; + } + b << ' '; +#if WINPTY_ARCH == WINPTY_IA32 + b << "IA32"; + OsModule kernel32(L"kernel32.dll"); + IsWow64Process_t *pIsWow64Process = + reinterpret_cast( + kernel32.proc("IsWow64Process")); + if (pIsWow64Process != nullptr) { + BOOL result = false; + const BOOL success = pIsWow64Process(GetCurrentProcess(), &result); + if (!success) { + b << " WOW64:error"; + } else if (success && result) { + b << " WOW64"; + } + } else { + b << " WOW64:missingapi"; + } +#elif WINPTY_ARCH == WINPTY_X64 + b << "X64"; +#endif + const auto dllVersion = [](const wchar_t *dllPath) -> std::string { + try { + const auto info = getFixedFileInfo(dllPath); + StringBuilder fb(64); + fb << utf8FromWide(dllPath) << ':'; + fb << "F:" << versionToString(fileVersionFromInfo(info)) << '/' + << "P:" << versionToString(productVersionFromInfo(info)); + return fb.str_moved(); + } catch (const ModuleNotFound&) { + return utf8FromWide(dllPath) + ":none"; + } catch (const WinptyException &e) { + trace("Error getting %s version: %s", + utf8FromWide(dllPath).c_str(), utf8FromWide(e.what()).c_str()); + return utf8FromWide(dllPath) + ":error"; + } + }; + b << ' ' << dllVersion(L"kernel32.dll"); + // ConEmu provides a DLL that hooks many Windows APIs, especially console + // APIs. Its existence and version number could be useful in debugging. +#if WINPTY_ARCH == WINPTY_IA32 + b << ' ' << dllVersion(L"ConEmuHk.dll"); +#elif WINPTY_ARCH == WINPTY_X64 + b << ' ' << dllVersion(L"ConEmuHk64.dll"); +#endif + trace("Windows version: %s", b.c_str()); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsVersion.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsVersion.h new file mode 100644 index 00000000..a8079841 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WindowsVersion.h @@ -0,0 +1,29 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_SHARED_WINDOWS_VERSION_H +#define WINPTY_SHARED_WINDOWS_VERSION_H + +bool isAtLeastWindowsVista(); +bool isAtLeastWindows7(); +bool isAtLeastWindows8(); +void dumpWindowsVersion(); + +#endif // WINPTY_SHARED_WINDOWS_VERSION_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyAssert.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyAssert.cc new file mode 100644 index 00000000..1ff0de47 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyAssert.cc @@ -0,0 +1,55 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "WinptyAssert.h" + +#include +#include + +#include "DebugClient.h" + +void assertTrace(const char *file, int line, const char *cond) { + trace("Assertion failed: %s, file %s, line %d", + cond, file, line); +} + +#ifdef WINPTY_AGENT_ASSERT + +void agentShutdown() { + HWND hwnd = GetConsoleWindow(); + if (hwnd != NULL) { + PostMessage(hwnd, WM_CLOSE, 0, 0); + Sleep(30000); + trace("Agent shutdown: WM_CLOSE did not end agent process"); + } else { + trace("Agent shutdown: GetConsoleWindow() is NULL"); + } + // abort() prints a message to the console, and if it is frozen, then the + // process would hang, so instead use exit(). (We shouldn't ever get here, + // though, because the WM_CLOSE message should have ended this process.) + exit(1); +} + +void agentAssertFail(const char *file, int line, const char *cond) { + assertTrace(file, line, cond); + agentShutdown(); +} + +#endif diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyAssert.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyAssert.h new file mode 100644 index 00000000..b2b8b5e6 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyAssert.h @@ -0,0 +1,64 @@ +// Copyright (c) 2011-2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_ASSERT_H +#define WINPTY_ASSERT_H + +#ifdef WINPTY_AGENT_ASSERT + +void agentShutdown(); +void agentAssertFail(const char *file, int line, const char *cond); + +// Calling the standard assert() function does not work in the agent because +// the error message would be printed to the console, and the only way the +// user can see the console is via a working agent! Moreover, the console may +// be frozen, so attempting to write to it would block forever. This custom +// assert function instead sends the message to the DebugServer, then attempts +// to close the console, then quietly exits. +#define ASSERT(cond) \ + do { \ + if (!(cond)) { \ + agentAssertFail(__FILE__, __LINE__, #cond); \ + } \ + } while(0) + +#else + +void assertTrace(const char *file, int line, const char *cond); + +// In the other targets, log the assert failure to the debugserver, then fail +// using the ordinary assert mechanism. In case assert is compiled out, fail +// using abort. The amount of code inlined is unfortunate, but asserts aren't +// used much outside the agent. +#include +#include +#define ASSERT_CONDITION(cond) (false && (cond)) +#define ASSERT(cond) \ + do { \ + if (!(cond)) { \ + assertTrace(__FILE__, __LINE__, #cond); \ + assert(ASSERT_CONDITION(#cond)); \ + abort(); \ + } \ + } while(0) + +#endif + +#endif // WINPTY_ASSERT_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyException.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyException.cc new file mode 100644 index 00000000..d0d48823 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyException.cc @@ -0,0 +1,57 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "WinptyException.h" + +#include +#include + +#include "StringBuilder.h" + +namespace { + +class ExceptionImpl : public WinptyException { +public: + ExceptionImpl(const wchar_t *what) : + m_what(std::make_shared(what)) {} + virtual const wchar_t *what() const WINPTY_NOEXCEPT override { + return m_what->c_str(); + } +private: + // Using a shared_ptr ensures that copying the object raises no exception. + std::shared_ptr m_what; +}; + +} // anonymous namespace + +void throwWinptyException(const wchar_t *what) { + throw ExceptionImpl(what); +} + +void throwWindowsError(const wchar_t *prefix, DWORD errorCode) { + WStringBuilder sb(64); + if (prefix != nullptr) { + sb << prefix << L": "; + } + // It might make sense to use FormatMessage here, but IIRC, its API is hard + // to figure out. + sb << L"Windows error " << errorCode; + throwWinptyException(sb.c_str()); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyException.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyException.h new file mode 100644 index 00000000..ec353369 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyException.h @@ -0,0 +1,43 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_EXCEPTION_H +#define WINPTY_EXCEPTION_H + +#include + +#if defined(__GNUC__) +#define WINPTY_NOEXCEPT noexcept +#elif defined(_MSC_VER) && _MSC_VER >= 1900 +#define WINPTY_NOEXCEPT noexcept +#else +#define WINPTY_NOEXCEPT +#endif + +class WinptyException { +public: + virtual const wchar_t *what() const WINPTY_NOEXCEPT = 0; + virtual ~WinptyException() {} +}; + +void throwWinptyException(const wchar_t *what); +void throwWindowsError(const wchar_t *prefix, DWORD error=GetLastError()); + +#endif // WINPTY_EXCEPTION_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyVersion.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyVersion.cc new file mode 100644 index 00000000..76bb8a58 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyVersion.cc @@ -0,0 +1,42 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "WinptyVersion.h" + +#include +#include + +#include "DebugClient.h" + +// This header is auto-generated by either the Makefile (Unix) or +// UpdateGenVersion.bat (gyp). It is placed in a 'gen' directory, which is +// added to the search path. +#include "GenVersion.h" + +void dumpVersionToStdout() { + printf("winpty version %s\n", GenVersion_Version); + printf("commit %s\n", GenVersion_Commit); +} + +void dumpVersionToTrace() { + trace("winpty version %s (commit %s)", + GenVersion_Version, + GenVersion_Commit); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyVersion.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyVersion.h new file mode 100644 index 00000000..e6224d7b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/WinptyVersion.h @@ -0,0 +1,27 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_VERSION_H +#define WINPTY_VERSION_H + +void dumpVersionToStdout(); +void dumpVersionToTrace(); + +#endif // WINPTY_VERSION_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/winpty_snprintf.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/winpty_snprintf.h new file mode 100644 index 00000000..e716f245 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/shared/winpty_snprintf.h @@ -0,0 +1,99 @@ +// Copyright (c) 2016 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef WINPTY_SNPRINTF_H +#define WINPTY_SNPRINTF_H + +#include +#include +#include + +#include "WinptyAssert.h" + +#if defined(__CYGWIN__) || defined(__MSYS__) +#define WINPTY_SNPRINTF_FORMAT(fmtarg, vararg) \ + __attribute__((format(printf, (fmtarg), ((vararg))))) +#elif defined(__GNUC__) +#define WINPTY_SNPRINTF_FORMAT(fmtarg, vararg) \ + __attribute__((format(ms_printf, (fmtarg), ((vararg))))) +#else +#define WINPTY_SNPRINTF_FORMAT(fmtarg, vararg) +#endif + +// Returns a value between 0 and size - 1 (inclusive) on success. Returns -1 +// on failure (including truncation). The output buffer is always +// NUL-terminated. +inline int +winpty_vsnprintf(char *out, size_t size, const char *fmt, va_list ap) { + ASSERT(size > 0); + out[0] = '\0'; +#if defined(_MSC_VER) && _MSC_VER < 1900 + // MSVC 2015 added a C99-conforming vsnprintf. + int count = _vsnprintf_s(out, size, _TRUNCATE, fmt, ap); +#else + // MinGW configurations frequently provide a vsnprintf function that simply + // calls one of the MS _vsnprintf* functions, which are not C99 conformant. + int count = vsnprintf(out, size, fmt, ap); +#endif + if (count < 0 || static_cast(count) >= size) { + // On truncation, some *printf* implementations return the + // non-truncated size, but other implementations returns -1. Return + // -1 for consistency. + count = -1; + // Guarantee NUL termination. + out[size - 1] = '\0'; + } else { + // Guarantee NUL termination. + out[count] = '\0'; + } + return count; +} + +// Wraps winpty_vsnprintf. +inline int winpty_snprintf(char *out, size_t size, const char *fmt, ...) + WINPTY_SNPRINTF_FORMAT(3, 4); +inline int winpty_snprintf(char *out, size_t size, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + const int count = winpty_vsnprintf(out, size, fmt, ap); + va_end(ap); + return count; +} + +// Wraps winpty_vsnprintf with automatic size determination. +template +int winpty_vsnprintf(char (&out)[size], const char *fmt, va_list ap) { + return winpty_vsnprintf(out, size, fmt, ap); +} + +// Wraps winpty_vsnprintf with automatic size determination. +template +int winpty_snprintf(char (&out)[size], const char *fmt, ...) + WINPTY_SNPRINTF_FORMAT(2, 3); +template +int winpty_snprintf(char (&out)[size], const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + const int count = winpty_vsnprintf(out, size, fmt, ap); + va_end(ap); + return count; +} + +#endif // WINPTY_SNPRINTF_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/subdir.mk b/services/edge-agent/node_modules/node-pty/deps/winpty/src/subdir.mk new file mode 100644 index 00000000..9ae8031b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/subdir.mk @@ -0,0 +1,5 @@ +include src/agent/subdir.mk +include src/debugserver/subdir.mk +include src/libwinpty/subdir.mk +include src/tests/subdir.mk +include src/unix-adapter/subdir.mk diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/tests/subdir.mk b/services/edge-agent/node_modules/node-pty/deps/winpty/src/tests/subdir.mk new file mode 100644 index 00000000..18799c4a --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/tests/subdir.mk @@ -0,0 +1,28 @@ +# Copyright (c) 2015 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +build/%.exe : src/tests/%.cc build/winpty.dll + $(info Building $@) + @$(MINGW_CXX) $(MINGW_CXXFLAGS) $(MINGW_LDFLAGS) -o $@ $^ + +TEST_PROGRAMS = \ + build/trivial_test.exe + +-include $(TEST_PROGRAMS:.exe=.d) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/tests/trivial_test.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/tests/trivial_test.cc new file mode 100644 index 00000000..2188a4be --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/tests/trivial_test.cc @@ -0,0 +1,158 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include + +#include +#include +#include +#include +#include +#include + +#include "../include/winpty.h" +#include "../shared/DebugClient.h" + +static std::vector filterContent( + const std::vector &content) { + std::vector result; + auto it = content.begin(); + const auto itEnd = content.end(); + while (it < itEnd) { + if (*it == '\r') { + // Filter out carriage returns. Sometimes the output starts with + // a single CR; other times, it has multiple CRs. + it++; + } else if (*it == '\x1b' && (it + 1) < itEnd && *(it + 1) == '[') { + // Filter out escape sequences. They have no interior letters and + // end with a single letter. + it += 2; + while (it < itEnd && !isalpha(*it)) { + it++; + } + it++; + } else { + // Let everything else through. + result.push_back(*it); + it++; + } + } + return result; +} + +// Read bytes from the non-overlapped file handle until the file is closed or +// until an I/O error occurs. +static std::vector readAll(HANDLE handle) { + unsigned char buf[1024]; + std::vector result; + while (true) { + DWORD amount = 0; + BOOL ret = ReadFile(handle, buf, sizeof(buf), &amount, nullptr); + if (!ret || amount == 0) { + break; + } + result.insert(result.end(), buf, buf + amount); + } + return result; +} + +static void parentTest() { + wchar_t program[1024]; + wchar_t cmdline[1024]; + GetModuleFileNameW(nullptr, program, 1024); + + { + // XXX: We'd like to use swprintf, which is part of C99 and takes a + // size_t maxlen argument. MinGW-w64 has this function, as does MSVC. + // The old MinGW doesn't, though -- instead, it apparently provides an + // swprintf taking no maxlen argument. This *might* be a regression? + // (There is also no swnprintf, but that function is obsolescent with a + // correct swprintf, and it isn't in POSIX or ISO C.) + // + // Visual C++ 6 also provided this non-conformant swprintf, and I'm + // guessing MSVCRT.DLL does too. (My impression is that the old MinGW + // prefers to rely on MSVCRT.DLL for convenience?) + // + // I could compile differently for old MinGW, but what if it fixes its + // function later? Instead, use a workaround. It's starting to make + // sense to drop MinGW support in favor of MinGW-w64. This is too + // annoying. + // + // grepbait: OLD-MINGW / WINPTY_TARGET_MSYS1 + cmdline[0] = L'\0'; + wcscat(cmdline, L"\""); + wcscat(cmdline, program); + wcscat(cmdline, L"\" CHILD"); + } + // swnprintf(cmdline, sizeof(cmdline) / sizeof(cmdline[0]), + // L"\"%ls\" CHILD", program); + + auto agentCfg = winpty_config_new(0, nullptr); + assert(agentCfg != nullptr); + auto pty = winpty_open(agentCfg, nullptr); + assert(pty != nullptr); + winpty_config_free(agentCfg); + + HANDLE conin = CreateFileW( + winpty_conin_name(pty), + GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); + HANDLE conout = CreateFileW( + winpty_conout_name(pty), + GENERIC_READ, 0, nullptr, OPEN_EXISTING, 0, nullptr); + assert(conin != INVALID_HANDLE_VALUE); + assert(conout != INVALID_HANDLE_VALUE); + + auto spawnCfg = winpty_spawn_config_new( + WINPTY_SPAWN_FLAG_AUTO_SHUTDOWN, program, cmdline, + nullptr, nullptr, nullptr); + assert(spawnCfg != nullptr); + HANDLE process = nullptr; + BOOL spawnSuccess = winpty_spawn( + pty, spawnCfg, &process, nullptr, nullptr, nullptr); + assert(spawnSuccess && process != nullptr); + + auto content = readAll(conout); + content = filterContent(content); + + std::vector expectedContent = { + 'H', 'I', '\n', 'X', 'Y', '\n' + }; + DWORD exitCode = 0; + assert(GetExitCodeProcess(process, &exitCode) && exitCode == 42); + CloseHandle(process); + CloseHandle(conin); + CloseHandle(conout); + assert(content == expectedContent); + winpty_free(pty); +} + +static void childTest() { + printf("HI\nXY\n"); + exit(42); +} + +int main(int argc, char *argv[]) { + if (argc == 1) { + parentTest(); + } else { + childTest(); + } + return 0; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/InputHandler.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/InputHandler.cc new file mode 100644 index 00000000..39f1e096 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/InputHandler.cc @@ -0,0 +1,114 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "InputHandler.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "../shared/DebugClient.h" +#include "Util.h" +#include "WakeupFd.h" + +InputHandler::InputHandler( + HANDLE conin, int inputfd, WakeupFd &completionWakeup) : + m_conin(conin), + m_inputfd(inputfd), + m_completionWakeup(completionWakeup), + m_threadHasBeenJoined(false), + m_shouldShutdown(0), + m_threadCompleted(0) +{ + pthread_create(&m_thread, NULL, InputHandler::threadProcS, this); +} + +void InputHandler::shutdown() { + startShutdown(); + if (!m_threadHasBeenJoined) { + int ret = pthread_join(m_thread, NULL); + assert(ret == 0 && "pthread_join failed"); + m_threadHasBeenJoined = true; + } +} + +void InputHandler::threadProc() { + std::vector buffer(4096); + fd_set readfds; + FD_ZERO(&readfds); + while (true) { + // Handle shutdown. + m_wakeup.reset(); + if (m_shouldShutdown) { + trace("InputHandler: shutting down"); + break; + } + + // Block until data arrives. + { + const int max_fd = std::max(m_inputfd, m_wakeup.fd()); + FD_SET(m_inputfd, &readfds); + FD_SET(m_wakeup.fd(), &readfds); + selectWrapper("InputHandler", max_fd + 1, &readfds); + if (!FD_ISSET(m_inputfd, &readfds)) { + continue; + } + } + + const int numRead = read(m_inputfd, &buffer[0], buffer.size()); + if (numRead == -1 && errno == EINTR) { + // Apparently, this read is interrupted on Cygwin 1.7 by a SIGWINCH + // signal even though I set the SA_RESTART flag on the handler. + continue; + } + + // tty is closed, or the read failed for some unexpected reason. + if (numRead <= 0) { + trace("InputHandler: tty read failed: numRead=%d", numRead); + break; + } + + DWORD written = 0; + BOOL ret = WriteFile(m_conin, + &buffer[0], numRead, + &written, NULL); + if (!ret || written != static_cast(numRead)) { + if (!ret && GetLastError() == ERROR_BROKEN_PIPE) { + trace("InputHandler: pipe closed: written=%u", + static_cast(written)); + } else { + trace("InputHandler: write failed: " + "ret=%d lastError=0x%x numRead=%d written=%u", + ret, + static_cast(GetLastError()), + numRead, + static_cast(written)); + } + break; + } + } + m_threadCompleted = 1; + m_completionWakeup.set(); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/InputHandler.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/InputHandler.h new file mode 100644 index 00000000..9c3f540d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/InputHandler.h @@ -0,0 +1,56 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef UNIX_ADAPTER_INPUT_HANDLER_H +#define UNIX_ADAPTER_INPUT_HANDLER_H + +#include +#include +#include + +#include "WakeupFd.h" + +// Connect a Cygwin blocking fd to winpty CONIN. +class InputHandler { +public: + InputHandler(HANDLE conin, int inputfd, WakeupFd &completionWakeup); + ~InputHandler() { shutdown(); } + bool isComplete() { return m_threadCompleted; } + void startShutdown() { m_shouldShutdown = 1; m_wakeup.set(); } + void shutdown(); + +private: + static void *threadProcS(void *pvthis) { + reinterpret_cast(pvthis)->threadProc(); + return NULL; + } + void threadProc(); + + HANDLE m_conin; + int m_inputfd; + pthread_t m_thread; + WakeupFd &m_completionWakeup; + WakeupFd m_wakeup; + bool m_threadHasBeenJoined; + volatile sig_atomic_t m_shouldShutdown; + volatile sig_atomic_t m_threadCompleted; +}; + +#endif // UNIX_ADAPTER_INPUT_HANDLER_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/OutputHandler.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/OutputHandler.cc new file mode 100644 index 00000000..573b8adc --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/OutputHandler.cc @@ -0,0 +1,80 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "OutputHandler.h" + +#include +#include +#include +#include + +#include +#include + +#include "../shared/DebugClient.h" +#include "Util.h" +#include "WakeupFd.h" + +OutputHandler::OutputHandler( + HANDLE conout, int outputfd, WakeupFd &completionWakeup) : + m_conout(conout), + m_outputfd(outputfd), + m_completionWakeup(completionWakeup), + m_threadHasBeenJoined(false), + m_threadCompleted(0) +{ + pthread_create(&m_thread, NULL, OutputHandler::threadProcS, this); +} + +void OutputHandler::shutdown() { + if (!m_threadHasBeenJoined) { + int ret = pthread_join(m_thread, NULL); + assert(ret == 0 && "pthread_join failed"); + m_threadHasBeenJoined = true; + } +} + +void OutputHandler::threadProc() { + std::vector buffer(4096); + while (true) { + DWORD numRead = 0; + BOOL ret = ReadFile(m_conout, + &buffer[0], buffer.size(), + &numRead, NULL); + if (!ret || numRead == 0) { + if (!ret && GetLastError() == ERROR_BROKEN_PIPE) { + trace("OutputHandler: pipe closed: numRead=%u", + static_cast(numRead)); + } else { + trace("OutputHandler: read failed: " + "ret=%d lastError=0x%x numRead=%u", + ret, + static_cast(GetLastError()), + static_cast(numRead)); + } + break; + } + if (!writeAll(m_outputfd, &buffer[0], numRead)) { + break; + } + } + m_threadCompleted = 1; + m_completionWakeup.set(); +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/OutputHandler.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/OutputHandler.h new file mode 100644 index 00000000..48241c55 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/OutputHandler.h @@ -0,0 +1,53 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef UNIX_ADAPTER_OUTPUT_HANDLER_H +#define UNIX_ADAPTER_OUTPUT_HANDLER_H + +#include +#include +#include + +#include "WakeupFd.h" + +// Connect winpty CONOUT/CONERR to a Cygwin blocking fd. +class OutputHandler { +public: + OutputHandler(HANDLE conout, int outputfd, WakeupFd &completionWakeup); + ~OutputHandler() { shutdown(); } + bool isComplete() { return m_threadCompleted; } + void shutdown(); + +private: + static void *threadProcS(void *pvthis) { + reinterpret_cast(pvthis)->threadProc(); + return NULL; + } + void threadProc(); + + HANDLE m_conout; + int m_outputfd; + pthread_t m_thread; + WakeupFd &m_completionWakeup; + bool m_threadHasBeenJoined; + volatile sig_atomic_t m_threadCompleted; +}; + +#endif // UNIX_ADAPTER_OUTPUT_HANDLER_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/Util.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/Util.cc new file mode 100644 index 00000000..e13f84a5 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/Util.cc @@ -0,0 +1,86 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "Util.h" + +#include +#include +#include +#include +#include + +#include "../shared/DebugClient.h" + +// Write the entire buffer, restarting it as necessary. +bool writeAll(int fd, const void *buffer, size_t size) { + size_t written = 0; + while (written < size) { + int ret = write(fd, + reinterpret_cast(buffer) + written, + size - written); + if (ret == -1 && errno == EINTR) { + continue; + } + if (ret <= 0) { + trace("write failed: " + "fd=%d errno=%d size=%u written=%d ret=%d", + fd, + errno, + static_cast(size), + static_cast(written), + ret); + return false; + } + assert(static_cast(ret) <= size - written); + written += ret; + } + assert(written == size); + return true; +} + +bool writeStr(int fd, const char *str) { + return writeAll(fd, str, strlen(str)); +} + +void selectWrapper(const char *diagName, int nfds, fd_set *readfds) { + int ret = select(nfds, readfds, NULL, NULL, NULL); + if (ret < 0) { + if (errno == EINTR) { + FD_ZERO(readfds); + return; + } +#ifdef WINPTY_TARGET_MSYS1 + // The select system call sometimes fails with EAGAIN instead of EINTR. + // This apparantly only happens with the old Cygwin fork "MSYS" used in + // the mingw.org project. select is not supposed to fail with EAGAIN, + // and EAGAIN does not make much sense as an error code. (The whole + // point of select is to block.) + if (errno == EAGAIN) { + trace("%s select returned EAGAIN: interpreting like EINTR", + diagName); + FD_ZERO(readfds); + return; + } +#endif + fprintf(stderr, "Internal error: %s select failed: " + "error %d", diagName, errno); + abort(); + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/Util.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/Util.h new file mode 100644 index 00000000..cadb4c82 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/Util.h @@ -0,0 +1,31 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef UNIX_ADAPTER_UTIL_H +#define UNIX_ADAPTER_UTIL_H + +#include +#include + +bool writeAll(int fd, const void *buffer, size_t size); +bool writeStr(int fd, const char *str); +void selectWrapper(const char *diagName, int nfds, fd_set *readfds); + +#endif // UNIX_ADAPTER_UTIL_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/WakeupFd.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/WakeupFd.cc new file mode 100644 index 00000000..6b473790 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/WakeupFd.cc @@ -0,0 +1,70 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#include "WakeupFd.h" + +#include +#include +#include +#include +#include + +static void setFdNonBlock(int fd) { + int status = fcntl(fd, F_GETFL); + fcntl(fd, F_SETFL, status | O_NONBLOCK); +} + +WakeupFd::WakeupFd() { + int pipeFd[2]; + if (pipe(pipeFd) != 0) { + perror("Could not create internal wakeup pipe"); + abort(); + } + m_pipeReadFd = pipeFd[0]; + m_pipeWriteFd = pipeFd[1]; + setFdNonBlock(m_pipeReadFd); + setFdNonBlock(m_pipeWriteFd); +} + +WakeupFd::~WakeupFd() { + close(m_pipeReadFd); + close(m_pipeWriteFd); +} + +void WakeupFd::set() { + char dummy = 0; + int ret; + do { + ret = write(m_pipeWriteFd, &dummy, 1); + } while (ret < 0 && errno == EINTR); +} + +void WakeupFd::reset() { + char tmpBuf[256]; + while (true) { + int amount = read(m_pipeReadFd, tmpBuf, sizeof(tmpBuf)); + if (amount < 0 && errno == EAGAIN) { + break; + } else if (amount <= 0) { + perror("error reading from internal wakeup pipe"); + abort(); + } + } +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/WakeupFd.h b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/WakeupFd.h new file mode 100644 index 00000000..dd8d362a --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/WakeupFd.h @@ -0,0 +1,42 @@ +// Copyright (c) 2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +#ifndef UNIX_ADAPTER_WAKEUP_FD_H +#define UNIX_ADAPTER_WAKEUP_FD_H + +class WakeupFd { +public: + WakeupFd(); + ~WakeupFd(); + int fd() { return m_pipeReadFd; } + void set(); + void reset(); + +private: + // Do not allow copying the WakeupFd object. + WakeupFd(const WakeupFd &other); + WakeupFd &operator=(const WakeupFd &other); + +private: + int m_pipeReadFd; + int m_pipeWriteFd; +}; + +#endif // UNIX_ADAPTER_WAKEUP_FD_H diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/main.cc b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/main.cc new file mode 100644 index 00000000..992cb70e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/main.cc @@ -0,0 +1,729 @@ +// Copyright (c) 2011-2015 Ryan Prichard +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// MSYS's sys/cygwin.h header only declares cygwin_internal if WINVER is +// defined, which is defined in windows.h. Therefore, include windows.h early. +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include "../shared/DebugClient.h" +#include "../shared/UnixCtrlChars.h" +#include "../shared/WinptyVersion.h" +#include "InputHandler.h" +#include "OutputHandler.h" +#include "Util.h" +#include "WakeupFd.h" + +#define CSI "\x1b[" + +static WakeupFd *g_mainWakeup = NULL; + +static WakeupFd &mainWakeup() +{ + if (g_mainWakeup == NULL) { + static const char msg[] = "Internal error: g_mainWakeup is NULL\r\n"; + write(STDERR_FILENO, msg, sizeof(msg) - 1); + abort(); + } + return *g_mainWakeup; +} + +struct SavedTermiosMode { + int count; + bool valid[3]; + termios mode[3]; +}; + +// Put the input terminal into non-canonical mode. +static SavedTermiosMode setRawTerminalMode( + bool allowNonTtys, bool setStdout, bool setStderr) +{ + SavedTermiosMode ret; + const char *const kNames[3] = { "stdin", "stdout", "stderr" }; + + ret.valid[0] = true; + ret.valid[1] = setStdout; + ret.valid[2] = setStderr; + + for (int i = 0; i < 3; ++i) { + if (!ret.valid[i]) { + continue; + } + if (!isatty(i)) { + ret.valid[i] = false; + if (!allowNonTtys) { + fprintf(stderr, "%s is not a tty\n", kNames[i]); + exit(1); + } + } else { + ret.valid[i] = true; + if (tcgetattr(i, &ret.mode[i]) < 0) { + perror("tcgetattr failed"); + exit(1); + } + } + } + + if (ret.valid[STDIN_FILENO]) { + termios buf; + if (tcgetattr(STDIN_FILENO, &buf) < 0) { + perror("tcgetattr failed"); + exit(1); + } + buf.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); + buf.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); + buf.c_cflag &= ~(CSIZE | PARENB); + buf.c_cflag |= CS8; + buf.c_cc[VMIN] = 1; // blocking read + buf.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &buf) < 0) { + fprintf(stderr, "tcsetattr failed\n"); + exit(1); + } + } + + for (int i = STDOUT_FILENO; i <= STDERR_FILENO; ++i) { + if (!ret.valid[i]) { + continue; + } + termios buf; + if (tcgetattr(i, &buf) < 0) { + perror("tcgetattr failed"); + exit(1); + } + buf.c_cflag &= ~(CSIZE | PARENB); + buf.c_cflag |= CS8; + buf.c_oflag &= ~OPOST; + if (tcsetattr(i, TCSAFLUSH, &buf) < 0) { + fprintf(stderr, "tcsetattr failed\n"); + exit(1); + } + } + + return ret; +} + +static void restoreTerminalMode(const SavedTermiosMode &original) +{ + for (int i = 0; i < 3; ++i) { + if (!original.valid[i]) { + continue; + } + if (tcsetattr(i, TCSAFLUSH, &original.mode[i]) < 0) { + perror("error restoring terminal mode"); + exit(1); + } + } +} + +static void debugShowKey(bool allowNonTtys) +{ + printf("\nPress any keys -- Ctrl-D exits\n\n"); + const SavedTermiosMode saved = + setRawTerminalMode(allowNonTtys, false, false); + char buf[128]; + while (true) { + const ssize_t len = read(STDIN_FILENO, buf, sizeof(buf)); + if (len <= 0) { + break; + } + for (int i = 0; i < len; ++i) { + char ctrl = decodeUnixCtrlChar(buf[i]); + if (ctrl == '\0') { + putchar(buf[i]); + } else { + putchar('^'); + putchar(ctrl); + } + } + for (int i = 0; i < len; ++i) { + unsigned char uch = buf[i]; + printf("\t%3d %04o 0x%02x\n", uch, uch, uch); + fflush(stdout); + } + if (buf[0] == 4) { + // Ctrl-D + break; + } + } + restoreTerminalMode(saved); +} + +static void terminalResized(int signo) +{ + mainWakeup().set(); +} + +static void registerResizeSignalHandler() +{ + struct sigaction resizeSigAct; + memset(&resizeSigAct, 0, sizeof(resizeSigAct)); + resizeSigAct.sa_handler = terminalResized; + resizeSigAct.sa_flags = SA_RESTART; + sigaction(SIGWINCH, &resizeSigAct, NULL); +} + +// Convert the path to a Win32 path if it is a POSIX path, and convert slashes +// to backslashes. +static std::string convertPosixPathToWin(const std::string &path) +{ + char *tmp; +#if defined(CYGWIN_VERSION_CYGWIN_CONV) && \ + CYGWIN_VERSION_API_MINOR >= CYGWIN_VERSION_CYGWIN_CONV + // MSYS2 and versions of Cygwin released after 2009 or so use this API. + // The original MSYS still lacks this API. + ssize_t newSize = cygwin_conv_path(CCP_POSIX_TO_WIN_A | CCP_ABSOLUTE, + path.c_str(), NULL, 0); + assert(newSize >= 0); + tmp = new char[newSize + 1]; + ssize_t success = cygwin_conv_path(CCP_POSIX_TO_WIN_A | CCP_ABSOLUTE, + path.c_str(), tmp, newSize + 1); + assert(success == 0); +#else + // In the current Cygwin header file, this API is documented as deprecated + // because it's restricted to paths of MAX_PATH length. In the CVS version + // of MSYS, the newer API doesn't exist, and this older API is implemented + // using msys_p2w, which seems like it would handle paths larger than + // MAX_PATH, but there's no way to query how large the new path is. + // Hopefully, this is large enough. + tmp = new char[MAX_PATH + path.size()]; + cygwin_conv_to_win32_path(path.c_str(), tmp); +#endif + for (int i = 0; tmp[i] != '\0'; ++i) { + if (tmp[i] == '/') + tmp[i] = '\\'; + } + std::string ret(tmp); + delete [] tmp; + return ret; +} + +static std::string resolvePath(const std::string &path) +{ + char ret[PATH_MAX]; + ret[0] = '\0'; + if (realpath(path.c_str(), ret) != ret) { + return std::string(); + } + return ret; +} + +template +static bool endsWith(const std::string &path, const char (&suf)[N]) +{ + const size_t suffixLen = N - 1; + char actualSuf[N]; + if (path.size() < suffixLen) { + return false; + } + strcpy(actualSuf, &path.c_str()[path.size() - suffixLen]); + for (size_t i = 0; i < suffixLen; ++i) { + actualSuf[i] = tolower(actualSuf[i]); + } + return !strcmp(actualSuf, suf); +} + +static std::string findProgram( + const char *winptyProgName, + const std::string &prog) +{ + std::string candidate; + if (prog.find('/') == std::string::npos && + prog.find('\\') == std::string::npos) { + // XXX: It would be nice to use a lambda here (once/if old MSYS support + // is dropped). + // Search the PATH. + const char *const pathVar = getenv("PATH"); + const std::string pathList(pathVar ? pathVar : ""); + size_t elpos = 0; + while (true) { + const size_t elend = pathList.find(':', elpos); + candidate = pathList.substr(elpos, elend - elpos); + if (!candidate.empty() && *(candidate.end() - 1) != '/') { + candidate += '/'; + } + candidate += prog; + candidate = resolvePath(candidate); + if (!candidate.empty()) { + int perm = X_OK; + if (endsWith(candidate, ".bat") || endsWith(candidate, ".cmd")) { +#ifdef __MSYS__ + // In MSYS/MSYS2, batch files don't have the execute bit + // set, so just check that they're readable. + perm = R_OK; +#endif + } else if (endsWith(candidate, ".com") || endsWith(candidate, ".exe")) { + // Do nothing. + } else { + // Make the exe extension explicit so that we don't try to + // run shell scripts with CreateProcess/winpty_spawn. + candidate += ".exe"; + } + if (!access(candidate.c_str(), perm)) { + break; + } + } + if (elend == std::string::npos) { + fprintf(stderr, "%s: error: cannot start '%s': Not found in PATH\n", + winptyProgName, prog.c_str()); + exit(1); + } else { + elpos = elend + 1; + } + } + } else { + candidate = resolvePath(prog); + if (candidate.empty()) { + std::string errstr(strerror(errno)); + fprintf(stderr, "%s: error: cannot start '%s': %s\n", + winptyProgName, prog.c_str(), errstr.c_str()); + exit(1); + } + } + return convertPosixPathToWin(candidate); +} + +// Convert argc/argv into a Win32 command-line following the escaping convention +// documented on MSDN. (e.g. see CommandLineToArgvW documentation) +static std::string argvToCommandLine(const std::vector &argv) +{ + std::string result; + for (size_t argIndex = 0; argIndex < argv.size(); ++argIndex) { + if (argIndex > 0) + result.push_back(' '); + const char *arg = argv[argIndex].c_str(); + const bool quote = + strchr(arg, ' ') != NULL || + strchr(arg, '\t') != NULL || + *arg == '\0'; + if (quote) + result.push_back('\"'); + int bsCount = 0; + for (const char *p = arg; *p != '\0'; ++p) { + if (*p == '\\') { + bsCount++; + } else if (*p == '\"') { + result.append(bsCount * 2 + 1, '\\'); + result.push_back('\"'); + bsCount = 0; + } else { + result.append(bsCount, '\\'); + bsCount = 0; + result.push_back(*p); + } + } + if (quote) { + result.append(bsCount * 2, '\\'); + result.push_back('\"'); + } else { + result.append(bsCount, '\\'); + } + } + return result; +} + +static wchar_t *heapMbsToWcs(const char *text) +{ + // Calling mbstowcs with a NULL first argument seems to be broken on MSYS. + // Instead of returning the size of the converted string, it returns 0. + // Using strlen(text) * 2 is probably big enough. + size_t maxLen = strlen(text) * 2 + 1; + wchar_t *ret = new wchar_t[maxLen]; + size_t len = mbstowcs(ret, text, maxLen); + assert(len != (size_t)-1 && len < maxLen); + return ret; +} + +static char *heapWcsToMbs(const wchar_t *text) +{ + // Calling wcstombs with a NULL first argument seems to be broken on MSYS. + // Instead of returning the size of the converted string, it returns 0. + // Using wcslen(text) * 3 is big enough for UTF-8 and probably other + // encodings. For UTF-8, codepoints that fit in a single wchar + // (U+0000 to U+FFFF) are encoded using 1-3 bytes. The remaining code + // points needs two wchar's and are encoded using 4 bytes. + size_t maxLen = wcslen(text) * 3 + 1; + char *ret = new char[maxLen]; + size_t len = wcstombs(ret, text, maxLen); + if (len == (size_t)-1 || len >= maxLen) { + delete [] ret; + return NULL; + } else { + return ret; + } +} + +static std::string wcsToMbs(const wchar_t *text) +{ + std::string ret; + const char *ptr = heapWcsToMbs(text); + if (ptr != NULL) { + ret = ptr; + delete [] ptr; + } + return ret; +} + +void setupWin32Environment() +{ + std::map varsToCopy; + const char *vars[] = { + "WINPTY_DEBUG", + "WINPTY_SHOW_CONSOLE", + NULL + }; + for (int i = 0; vars[i] != NULL; ++i) { + const char *cstr = getenv(vars[i]); + if (cstr != NULL && cstr[0] != '\0') { + varsToCopy[vars[i]] = cstr; + } + } + +#if defined(__MSYS__) && CYGWIN_VERSION_API_MINOR >= 48 || \ + !defined(__MSYS__) && CYGWIN_VERSION_API_MINOR >= 153 + // Use CW_SYNC_WINENV to copy the Unix environment to the Win32 + // environment. The command performs special translation on some variables + // (such as PATH and TMP). It also copies the debugging environment + // variables. + // + // Note that the API minor versions have diverged in Cygwin and MSYS. + // CW_SYNC_WINENV was added to Cygwin in version 153. (Cygwin's + // include/cygwin/version.h says that CW_SETUP_WINENV was added in 153. + // The flag was renamed 8 days after it was added, but the API docs weren't + // updated.) The flag was added to MSYS in version 48. + // + // Also, in my limited testing, this call seems to be necessary with Cygwin + // but unnecessary with MSYS. Perhaps MSYS is automatically syncing the + // Unix environment with the Win32 environment before starting console.exe? + // It shouldn't hurt to call it for MSYS. + cygwin_internal(CW_SYNC_WINENV); +#endif + + // Copy debugging environment variables from the Cygwin environment + // to the Win32 environment so the agent will inherit it. + for (std::map::iterator it = varsToCopy.begin(); + it != varsToCopy.end(); + ++it) { + wchar_t *nameW = heapMbsToWcs(it->first.c_str()); + wchar_t *valueW = heapMbsToWcs(it->second.c_str()); + SetEnvironmentVariableW(nameW, valueW); + delete [] nameW; + delete [] valueW; + } + + // Clear the TERM variable. The child process's immediate console/terminal + // environment is a Windows console, not the terminal that winpty is + // communicating with. Leaving the TERM variable set can break programs in + // various ways. (e.g. arrows keys broken in Cygwin less, IronPython's + // help(...) function doesn't start, misc programs decide they should + // output color escape codes on pre-Win10). See + // https://github.com/rprichard/winpty/issues/43. + SetEnvironmentVariableW(L"TERM", NULL); +} + +static void usage(const char *program, int exitCode) +{ + printf("Usage: %s [options] [--] program [args]\n", program); + printf("\n"); + printf("Options:\n"); + printf(" -h, --help Show this help message\n"); + printf(" --mouse Enable terminal mouse input\n"); + printf(" --showkey Dump STDIN escape sequences\n"); + printf(" --version Show the winpty version number\n"); + exit(exitCode); +} + +struct Arguments { + std::vector childArgv; + bool mouseInput; + bool testAllowNonTtys; + bool testConerr; + bool testPlainOutput; + bool testColorEscapes; +}; + +static void parseArguments(int argc, char *argv[], Arguments &out) +{ + out.mouseInput = false; + out.testAllowNonTtys = false; + out.testConerr = false; + out.testPlainOutput = false; + out.testColorEscapes = false; + bool doShowKeys = false; + const char *const program = argc >= 1 ? argv[0] : ""; + int argi = 1; + while (argi < argc) { + std::string arg(argv[argi++]); + if (arg.size() >= 1 && arg[0] == '-') { + if (arg == "-h" || arg == "--help") { + usage(program, 0); + } else if (arg == "--mouse") { + out.mouseInput = true; + } else if (arg == "--showkey") { + doShowKeys = true; + } else if (arg == "--version") { + dumpVersionToStdout(); + exit(0); + } else if (arg == "-Xallow-non-tty") { + out.testAllowNonTtys = true; + } else if (arg == "-Xconerr") { + out.testConerr = true; + } else if (arg == "-Xplain") { + out.testPlainOutput = true; + } else if (arg == "-Xcolor") { + out.testColorEscapes = true; + } else if (arg == "--") { + break; + } else { + fprintf(stderr, "Error: unrecognized option: '%s'\n", + arg.c_str()); + exit(1); + } + } else { + out.childArgv.push_back(arg); + break; + } + } + for (; argi < argc; ++argi) { + out.childArgv.push_back(argv[argi]); + } + if (doShowKeys) { + debugShowKey(out.testAllowNonTtys); + exit(0); + } + if (out.childArgv.size() == 0) { + usage(program, 1); + } +} + +static std::string errorMessageToString(DWORD err) +{ + // Use FormatMessageW rather than FormatMessageA, because we want to use + // wcstombs to convert to the Cygwin locale, which might not match the + // codepage FormatMessageA would use. We need to convert using wcstombs, + // rather than print using %ls, because %ls doesn't work in the original + // MSYS. + wchar_t *wideMsgPtr = NULL; + const DWORD formatRet = FormatMessageW( + FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_IGNORE_INSERTS, + NULL, + err, + MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + reinterpret_cast(&wideMsgPtr), + 0, + NULL); + if (formatRet == 0 || wideMsgPtr == NULL) { + return std::string(); + } + std::string msg = wcsToMbs(wideMsgPtr); + LocalFree(wideMsgPtr); + const size_t pos = msg.find_last_not_of(" \r\n\t"); + if (pos == std::string::npos) { + msg.clear(); + } else { + msg.erase(pos + 1); + } + return msg; +} + +static std::string formatErrorMessage(DWORD err) +{ + char buf[64]; + sprintf(buf, "error %#x", static_cast(err)); + std::string ret = errorMessageToString(err); + if (ret.empty()) { + ret += buf; + } else { + ret += " ("; + ret += buf; + ret += ")"; + } + return ret; +} + +int main(int argc, char *argv[]) +{ + setlocale(LC_ALL, ""); + + g_mainWakeup = new WakeupFd(); + + Arguments args; + parseArguments(argc, argv, args); + + setupWin32Environment(); + + winsize sz = { 0 }; + sz.ws_col = 80; + sz.ws_row = 25; + ioctl(STDIN_FILENO, TIOCGWINSZ, &sz); + + DWORD agentFlags = WINPTY_FLAG_ALLOW_CURPROC_DESKTOP_CREATION; + if (args.testConerr) { agentFlags |= WINPTY_FLAG_CONERR; } + if (args.testPlainOutput) { agentFlags |= WINPTY_FLAG_PLAIN_OUTPUT; } + if (args.testColorEscapes) { agentFlags |= WINPTY_FLAG_COLOR_ESCAPES; } + winpty_config_t *agentCfg = winpty_config_new(agentFlags, NULL); + assert(agentCfg != NULL); + winpty_config_set_initial_size(agentCfg, sz.ws_col, sz.ws_row); + if (args.mouseInput) { + winpty_config_set_mouse_mode(agentCfg, WINPTY_MOUSE_MODE_FORCE); + } + + winpty_error_ptr_t openErr = NULL; + winpty_t *wp = winpty_open(agentCfg, &openErr); + if (wp == NULL) { + fprintf(stderr, "Error creating winpty: %s\n", + wcsToMbs(winpty_error_msg(openErr)).c_str()); + exit(1); + } + winpty_config_free(agentCfg); + winpty_error_free(openErr); + + HANDLE conin = CreateFileW(winpty_conin_name(wp), GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, 0, NULL); + HANDLE conout = CreateFileW(winpty_conout_name(wp), GENERIC_READ, 0, NULL, + OPEN_EXISTING, 0, NULL); + assert(conin != INVALID_HANDLE_VALUE); + assert(conout != INVALID_HANDLE_VALUE); + HANDLE conerr = NULL; + if (args.testConerr) { + conerr = CreateFileW(winpty_conerr_name(wp), GENERIC_READ, 0, NULL, + OPEN_EXISTING, 0, NULL); + assert(conerr != INVALID_HANDLE_VALUE); + } + + HANDLE childHandle = NULL; + + { + // Start the child process under the console. + args.childArgv[0] = findProgram(argv[0], args.childArgv[0]); + std::string cmdLine = argvToCommandLine(args.childArgv); + wchar_t *cmdLineW = heapMbsToWcs(cmdLine.c_str()); + + winpty_spawn_config_t *spawnCfg = winpty_spawn_config_new( + WINPTY_SPAWN_FLAG_AUTO_SHUTDOWN, + NULL, cmdLineW, NULL, NULL, NULL); + assert(spawnCfg != NULL); + + winpty_error_ptr_t spawnErr = NULL; + DWORD lastError = 0; + BOOL spawnRet = winpty_spawn(wp, spawnCfg, &childHandle, NULL, + &lastError, &spawnErr); + winpty_spawn_config_free(spawnCfg); + + if (!spawnRet) { + winpty_result_t spawnCode = winpty_error_code(spawnErr); + if (spawnCode == WINPTY_ERROR_SPAWN_CREATE_PROCESS_FAILED) { + fprintf(stderr, "%s: error: cannot start '%s': %s\n", + argv[0], + cmdLine.c_str(), + formatErrorMessage(lastError).c_str()); + } else { + fprintf(stderr, "%s: error: cannot start '%s': internal error: %s\n", + argv[0], + cmdLine.c_str(), + wcsToMbs(winpty_error_msg(spawnErr)).c_str()); + } + exit(1); + } + winpty_error_free(spawnErr); + delete [] cmdLineW; + } + + registerResizeSignalHandler(); + SavedTermiosMode mode = + setRawTerminalMode(args.testAllowNonTtys, true, args.testConerr); + + InputHandler inputHandler(conin, STDIN_FILENO, mainWakeup()); + OutputHandler outputHandler(conout, STDOUT_FILENO, mainWakeup()); + OutputHandler *errorHandler = NULL; + if (args.testConerr) { + errorHandler = new OutputHandler(conerr, STDERR_FILENO, mainWakeup()); + } + + while (true) { + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(mainWakeup().fd(), &readfds); + selectWrapper("main thread", mainWakeup().fd() + 1, &readfds); + mainWakeup().reset(); + + // Check for terminal resize. + { + winsize sz2; + ioctl(STDIN_FILENO, TIOCGWINSZ, &sz2); + if (memcmp(&sz, &sz2, sizeof(sz)) != 0) { + sz = sz2; + winpty_set_size(wp, sz.ws_col, sz.ws_row, NULL); + } + } + + // Check for an I/O handler shutting down (possibly indicating that the + // child process has exited). + if (inputHandler.isComplete() || outputHandler.isComplete() || + (errorHandler != NULL && errorHandler->isComplete())) { + break; + } + } + + // Kill the agent connection. This will kill the agent, closing the CONIN + // and CONOUT pipes on the agent pipe, prompting our I/O handler to shut + // down. + winpty_free(wp); + + inputHandler.shutdown(); + outputHandler.shutdown(); + CloseHandle(conin); + CloseHandle(conout); + + if (errorHandler != NULL) { + errorHandler->shutdown(); + delete errorHandler; + CloseHandle(conerr); + } + + restoreTerminalMode(mode); + + DWORD exitCode = 0; + if (!GetExitCodeProcess(childHandle, &exitCode)) { + exitCode = 1; + } + CloseHandle(childHandle); + return exitCode; +} diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/subdir.mk b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/subdir.mk new file mode 100644 index 00000000..200193a1 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/unix-adapter/subdir.mk @@ -0,0 +1,41 @@ +# Copyright (c) 2011-2015 Ryan Prichard +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +ALL_TARGETS += build/$(UNIX_ADAPTER_EXE) + +$(eval $(call def_unix_target,unix-adapter,)) + +UNIX_ADAPTER_OBJECTS = \ + build/unix-adapter/unix-adapter/InputHandler.o \ + build/unix-adapter/unix-adapter/OutputHandler.o \ + build/unix-adapter/unix-adapter/Util.o \ + build/unix-adapter/unix-adapter/WakeupFd.o \ + build/unix-adapter/unix-adapter/main.o \ + build/unix-adapter/shared/DebugClient.o \ + build/unix-adapter/shared/WinptyAssert.o \ + build/unix-adapter/shared/WinptyVersion.o + +build/unix-adapter/shared/WinptyVersion.o : build/gen/GenVersion.h + +build/$(UNIX_ADAPTER_EXE) : $(UNIX_ADAPTER_OBJECTS) build/winpty.dll + $(info Linking $@) + @$(UNIX_CXX) $(UNIX_LDFLAGS) -o $@ $^ + +-include $(UNIX_ADAPTER_OBJECTS:.o=.d) diff --git a/services/edge-agent/node_modules/node-pty/deps/winpty/src/winpty.gyp b/services/edge-agent/node_modules/node-pty/deps/winpty/src/winpty.gyp new file mode 100644 index 00000000..1ac5758b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/deps/winpty/src/winpty.gyp @@ -0,0 +1,234 @@ +{ + # The MSVC generator is the default. Select the compiler version by + # passing -G msvs_version= to gyp. is a string like 2013e. + # See gyp\pylib\gyp\MSVSVersion.py for sample version strings. You + # can also pass configurations.gypi to gyp for 32-bit and 64-bit builds. + # See that file for details. + # + # Pass --format=make to gyp to generate a Makefile instead. The Makefile + # can be configured by passing variables to make, e.g.: + # make -j4 CXX=i686-w64-mingw32-g++ LDFLAGS="-static -static-libgcc -static-libstdc++" + + 'variables': { + 'WINPTY_COMMIT_HASH%': ' { + // // Flow control doesn't work on Windows + // if (process.platform === 'win32') { + // return; + // } + // this.timeout(10000); + // const pty = new terminalConstructor(SHELL, [], {handleFlowControl: true, flowControlPause: 'PAUSE', flowControlResume: 'RESUME'}); + // let read: string = ''; + // pty.on('data', data => read += data); + // pty.on('pause', () => read += 'paused'); + // pty.on('resume', () => read += 'resumed'); + // pty.write('1'); + // pty.write('PAUSE'); + // pty.write('2'); + // pty.write('RESUME'); + // pty.write('3'); + // await pollUntil(() => { + // return stripEscapeSequences(read).endsWith('1pausedresumed23'); + // }, 100, 10); + // }); + }); +}); +function stripEscapeSequences(data) { + return data.replace(/\u001b\[0K/, ''); +} +//# sourceMappingURL=terminal.test.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/terminal.test.js.map b/services/edge-agent/node_modules/node-pty/lib/terminal.test.js.map new file mode 100644 index 00000000..b9d18400 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/terminal.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"terminal.test.js","sourceRoot":"","sources":["../src/terminal.test.ts"],"names":[],"mappings":";AAAA;;;GAGG;;;;;;;;;;;;;;;AAEH,+BAAiC;AACjC,qDAAoD;AACpD,+CAA8C;AAC9C,uCAAsC;AAGtC,IAAM,mBAAmB,GAAG,CAAC,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,iCAAe,CAAC,CAAC,CAAC,2BAAY,CAAC;AAC5F,IAAM,KAAK,GAAG,CAAC,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC;AAEvE,IAAI,YAA4C,CAAC;AACjD,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE;IAChC,YAAY,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CAC7C;KAAM;IACL,YAAY,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;CAC1C;AAED;IAA2B,gCAAQ;IAAnC;;IA4BA,CAAC;IA3BQ,gCAAS,GAAhB,UAAoB,IAAY,EAAE,KAAQ,EAAE,IAAY,EAAE,UAA2B;QAA3B,2BAAA,EAAA,kBAA2B;QACnF,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,CAAC,CAAC;IACjD,CAAC;IACS,6BAAM,GAAhB,UAAiB,IAAqB;QACpC,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;IAC7C,CAAC;IACM,6BAAM,GAAb,UAAc,IAAY,EAAE,IAAY;QACtC,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;IAC7C,CAAC;IACM,4BAAK,GAAZ;QACE,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;IAC7C,CAAC;IACM,8BAAO,GAAd;QACE,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;IAC7C,CAAC;IACM,2BAAI,GAAX,UAAY,MAAe;QACzB,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;IAC7C,CAAC;IACD,sBAAW,iCAAO;aAAlB;YACE,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;QAC7C,CAAC;;;OAAA;IACD,sBAAW,gCAAM;aAAjB;YACE,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;QAC7C,CAAC;;;OAAA;IACD,sBAAW,+BAAK;aAAhB;YACE,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;QAC7C,CAAC;;;OAAA;IACH,mBAAC;AAAD,CAAC,AA5BD,CAA2B,mBAAQ,GA4BlC;AAED,QAAQ,CAAC,UAAU,EAAE;IACnB,QAAQ,CAAC,aAAa,EAAE;QACtB,EAAE,CAAC,6BAA6B,EAAE;YAChC,MAAM,CAAC,MAAM,CACX,cAAM,OAAA,IAAU,YAAa,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,EAAjD,CAAiD,EACvD,sCAAsC,CACvC,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,QAAQ,CAAC,WAAW,EAAE;QACpB,EAAE,CAAC,iCAAiC,EAAE;YACpC,IAAM,CAAC,GAAG,IAAI,YAAY,EAAE,CAAC;YAC7B,MAAM,CAAC,YAAY,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAApC,CAAoC,CAAC,CAAC;YAChE,MAAM,CAAC,YAAY,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,CAAC,EAA/B,CAA+B,CAAC,CAAC;YAC3D,MAAM,CAAC,YAAY,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,EAAE,EAAE,QAAQ,CAAC,EAAhC,CAAgC,CAAC,CAAC;YAE5D,MAAM,CAAC,MAAM,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAApC,CAAoC,CAAC,CAAC;YAC1D,MAAM,CAAC,MAAM,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,CAAC,EAA/B,CAA+B,CAAC,CAAC;YACrD,MAAM,CAAC,MAAM,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,EAAE,EAAE,QAAQ,CAAC,EAAhC,CAAgC,CAAC,CAAC;QACxD,CAAC,CAAC,CAAC;QACH,EAAE,CAAC,4CAA4C,EAAE;YAC/C,IAAM,CAAC,GAAG,IAAI,YAAY,EAAE,CAAC;YAC7B,MAAM,CAAC,YAAY,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,EAA5C,CAA4C,CAAC,CAAC;YACxE,MAAM,CAAC,YAAY,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAvC,CAAuC,CAAC,CAAC;YACnE,MAAM,CAAC,YAAY,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAxC,CAAwC,CAAC,CAAC;YAEpE,MAAM,CAAC,MAAM,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,EAA5C,CAA4C,CAAC,CAAC;YAClE,MAAM,CAAC,MAAM,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAvC,CAAuC,CAAC,CAAC;YAC7D,MAAM,CAAC,MAAM,CAAC,cAAM,OAAA,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAxC,CAAwC,CAAC,CAAC;QAChE,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,QAAQ,CAAC,wBAAwB,EAAE;QACjC,EAAE,CAAC,0CAA0C,EAAE;YAC7C,IAAM,GAAG,GAAG,IAAI,mBAAmB,CAAC,KAAK,EAAE,EAAE,EAAE,EAAC,iBAAiB,EAAE,IAAI,EAAE,gBAAgB,EAAE,KAAK,EAAE,iBAAiB,EAAE,KAAK,EAAC,CAAC,CAAC;YAC7H,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,iBAAiB,EAAE,IAAI,CAAC,CAAC;YAC1C,MAAM,CAAC,KAAK,CAAE,GAAW,CAAC,iBAAiB,EAAE,KAAK,CAAC,CAAC;YACpD,MAAM,CAAC,KAAK,CAAE,GAAW,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC;QACvD,CAAC,CAAC,CAAC;QACH,oFAAoF;QACpF,+EAA+E;QAC/E,4CAA4C;QAC5C,wCAAwC;QACxC,cAAc;QACd,MAAM;QAEN,yBAAyB;QACzB,uIAAuI;QACvI,2BAA2B;QAC3B,0CAA0C;QAC1C,6CAA6C;QAC7C,+CAA+C;QAC/C,oBAAoB;QACpB,wBAAwB;QACxB,oBAAoB;QACpB,yBAAyB;QACzB,oBAAoB;QACpB,4BAA4B;QAC5B,sEAAsE;QACtE,iBAAiB;QACjB,MAAM;IACR,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,SAAS,oBAAoB,CAAC,IAAY;IACxC,OAAO,IAAI,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;AACxC,CAAC"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/testUtils.test.js b/services/edge-agent/node_modules/node-pty/lib/testUtils.test.js new file mode 100644 index 00000000..bbd1b7f7 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/testUtils.test.js @@ -0,0 +1,28 @@ +"use strict"; +/** + * Copyright (c) 2019, Microsoft Corporation (MIT License). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pollUntil = void 0; +function pollUntil(cb, timeout, interval) { + return new Promise(function (resolve, reject) { + var intervalId = setInterval(function () { + if (cb()) { + clearInterval(intervalId); + clearTimeout(timeoutId); + resolve(); + } + }, interval); + var timeoutId = setTimeout(function () { + clearInterval(intervalId); + if (cb()) { + resolve(); + } + else { + reject(); + } + }, timeout); + }); +} +exports.pollUntil = pollUntil; +//# sourceMappingURL=testUtils.test.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/testUtils.test.js.map b/services/edge-agent/node_modules/node-pty/lib/testUtils.test.js.map new file mode 100644 index 00000000..2d79f6d7 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/testUtils.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"testUtils.test.js","sourceRoot":"","sources":["../src/testUtils.test.ts"],"names":[],"mappings":";AAAA;;GAEG;;;AAEH,SAAgB,SAAS,CAAC,EAAiB,EAAE,OAAe,EAAE,QAAgB;IAC5E,OAAO,IAAI,OAAO,CAAO,UAAC,OAAO,EAAE,MAAM;QACvC,IAAM,UAAU,GAAG,WAAW,CAAC;YAC7B,IAAI,EAAE,EAAE,EAAE;gBACR,aAAa,CAAC,UAAU,CAAC,CAAC;gBAC1B,YAAY,CAAC,SAAS,CAAC,CAAC;gBACxB,OAAO,EAAE,CAAC;aACX;QACH,CAAC,EAAE,QAAQ,CAAC,CAAC;QACb,IAAM,SAAS,GAAG,UAAU,CAAC;YAC3B,aAAa,CAAC,UAAU,CAAC,CAAC;YAC1B,IAAI,EAAE,EAAE,EAAE;gBACR,OAAO,EAAE,CAAC;aACX;iBAAM;gBACL,MAAM,EAAE,CAAC;aACV;QACH,CAAC,EAAE,OAAO,CAAC,CAAC;IACd,CAAC,CAAC,CAAC;AACL,CAAC;AAlBD,8BAkBC"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/types.js b/services/edge-agent/node_modules/node-pty/lib/types.js new file mode 100644 index 00000000..3768e95f --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/types.js @@ -0,0 +1,7 @@ +"use strict"; +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/types.js.map b/services/edge-agent/node_modules/node-pty/lib/types.js.map new file mode 100644 index 00000000..5ab8a957 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";AAAA;;;GAGG"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/unixTerminal.js b/services/edge-agent/node_modules/node-pty/lib/unixTerminal.js new file mode 100644 index 00000000..1ec12f79 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/unixTerminal.js @@ -0,0 +1,346 @@ +"use strict"; +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.UnixTerminal = void 0; +/** + * Copyright (c) 2012-2015, Christopher Jeffrey (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +var fs = require("fs"); +var path = require("path"); +var tty = require("tty"); +var terminal_1 = require("./terminal"); +var utils_1 = require("./utils"); +var native = utils_1.loadNativeModule('pty'); +var pty = native.module; +var helperPath = native.dir + '/spawn-helper'; +helperPath = path.resolve(__dirname, helperPath); +helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); +var DEFAULT_FILE = 'sh'; +var DEFAULT_NAME = 'xterm'; +var DESTROY_SOCKET_TIMEOUT_MS = 200; +var UnixTerminal = /** @class */ (function (_super) { + __extends(UnixTerminal, _super); + function UnixTerminal(file, args, opt) { + var _a, _b; + var _this = _super.call(this, opt) || this; + _this._boundClose = false; + _this._emittedClose = false; + if (typeof args === 'string') { + throw new Error('args as a string is not supported on unix.'); + } + // Initialize arguments + args = args || []; + file = file || DEFAULT_FILE; + opt = opt || {}; + opt.env = opt.env || process.env; + _this._cols = opt.cols || terminal_1.DEFAULT_COLS; + _this._rows = opt.rows || terminal_1.DEFAULT_ROWS; + var uid = (_a = opt.uid) !== null && _a !== void 0 ? _a : -1; + var gid = (_b = opt.gid) !== null && _b !== void 0 ? _b : -1; + var env = utils_1.assign({}, opt.env); + if (opt.env === process.env) { + _this._sanitizeEnv(env); + } + var cwd = opt.cwd || process.cwd(); + env.PWD = cwd; + var name = opt.name || env.TERM || DEFAULT_NAME; + env.TERM = name; + var parsedEnv = _this._parseEnv(env); + var encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); + var onexit = function (code, signal) { + // XXX Sometimes a data event is emitted after exit. Wait til socket is + // destroyed. + if (!_this._emittedClose) { + if (_this._boundClose) { + return; + } + _this._boundClose = true; + // From macOS High Sierra 10.13.2 sometimes the socket never gets + // closed. A timeout is applied here to avoid the terminal never being + // destroyed when this occurs. + var timeout_1 = setTimeout(function () { + timeout_1 = null; + // Destroying the socket now will cause the close event to fire + _this._socket.destroy(); + }, DESTROY_SOCKET_TIMEOUT_MS); + _this.once('close', function () { + if (timeout_1 !== null) { + clearTimeout(timeout_1); + } + _this.emit('exit', code, signal); + }); + return; + } + _this.emit('exit', code, signal); + }; + // fork + var term = pty.fork(file, args, parsedEnv, cwd, _this._cols, _this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit); + _this._socket = new tty.ReadStream(term.fd); + if (encoding !== null) { + _this._socket.setEncoding(encoding); + } + _this._writeStream = new CustomWriteStream(term.fd, (encoding || undefined)); + // setup + _this._socket.on('error', function (err) { + // NOTE: fs.ReadStream gets EAGAIN twice at first: + if (err.code) { + if (~err.code.indexOf('EAGAIN')) { + return; + } + } + // close + _this._close(); + // EIO on exit from fs.ReadStream: + if (!_this._emittedClose) { + _this._emittedClose = true; + _this.emit('close'); + } + // EIO, happens when someone closes our child process: the only process in + // the terminal. + // node < 0.6.14: errno 5 + // node >= 0.6.14: read EIO + if (err.code) { + if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) { + return; + } + } + // throw anything else + if (_this.listeners('error').length < 2) { + throw err; + } + }); + _this._pid = term.pid; + _this._fd = term.fd; + _this._pty = term.pty; + _this._file = file; + _this._name = name; + _this._readable = true; + _this._writable = true; + _this._socket.on('close', function () { + if (_this._emittedClose) { + return; + } + _this._emittedClose = true; + _this._close(); + _this.emit('close'); + }); + _this._forwardEvents(); + return _this; + } + Object.defineProperty(UnixTerminal.prototype, "master", { + get: function () { return this._master; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(UnixTerminal.prototype, "slave", { + get: function () { return this._slave; }, + enumerable: false, + configurable: true + }); + UnixTerminal.prototype._write = function (data) { + this._writeStream.write(data); + }; + Object.defineProperty(UnixTerminal.prototype, "fd", { + /* Accessors */ + get: function () { return this._fd; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(UnixTerminal.prototype, "ptsName", { + get: function () { return this._pty; }, + enumerable: false, + configurable: true + }); + /** + * openpty + */ + UnixTerminal.open = function (opt) { + var self = Object.create(UnixTerminal.prototype); + opt = opt || {}; + if (arguments.length > 1) { + opt = { + cols: arguments[1], + rows: arguments[2] + }; + } + var cols = opt.cols || terminal_1.DEFAULT_COLS; + var rows = opt.rows || terminal_1.DEFAULT_ROWS; + var encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); + // open + var term = pty.open(cols, rows); + self._master = new tty.ReadStream(term.master); + if (encoding !== null) { + self._master.setEncoding(encoding); + } + self._master.resume(); + self._slave = new tty.ReadStream(term.slave); + if (encoding !== null) { + self._slave.setEncoding(encoding); + } + self._slave.resume(); + self._socket = self._master; + self._pid = -1; + self._fd = term.master; + self._pty = term.pty; + self._file = process.argv[0] || 'node'; + self._name = process.env.TERM || ''; + self._readable = true; + self._writable = true; + self._socket.on('error', function (err) { + self._close(); + if (self.listeners('error').length < 2) { + throw err; + } + }); + self._socket.on('close', function () { + self._close(); + }); + return self; + }; + UnixTerminal.prototype.destroy = function () { + var _this = this; + this._close(); + // Need to close the read stream so node stops reading a dead file + // descriptor. Then we can safely SIGHUP the shell. + this._socket.once('close', function () { + _this.kill('SIGHUP'); + }); + this._socket.destroy(); + this._writeStream.dispose(); + }; + UnixTerminal.prototype.kill = function (signal) { + try { + process.kill(this.pid, signal || 'SIGHUP'); + } + catch (e) { /* swallow */ } + }; + Object.defineProperty(UnixTerminal.prototype, "process", { + /** + * Gets the name of the process. + */ + get: function () { + if (process.platform === 'darwin') { + var title = pty.process(this._fd); + return (title !== 'kernel_task') ? title : this._file; + } + return pty.process(this._fd, this._pty) || this._file; + }, + enumerable: false, + configurable: true + }); + /** + * TTY + */ + UnixTerminal.prototype.resize = function (cols, rows) { + if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { + throw new Error('resizing must be done using positive cols and rows'); + } + pty.resize(this._fd, cols, rows); + this._cols = cols; + this._rows = rows; + }; + UnixTerminal.prototype.clear = function () { + }; + UnixTerminal.prototype._sanitizeEnv = function (env) { + // Make sure we didn't start our server from inside tmux. + delete env['TMUX']; + delete env['TMUX_PANE']; + // Make sure we didn't start our server from inside screen. + // http://web.mit.edu/gnu/doc/html/screen_20.html + delete env['STY']; + delete env['WINDOW']; + // Delete some variables that might confuse our terminal. + delete env['WINDOWID']; + delete env['TERMCAP']; + delete env['COLUMNS']; + delete env['LINES']; + }; + return UnixTerminal; +}(terminal_1.Terminal)); +exports.UnixTerminal = UnixTerminal; +/** + * A custom write stream that writes directly to a file descriptor with proper + * handling of backpressure and errors. This avoids some event loop exhaustion + * issues that can occur when using the standard APIs in Node. + */ +var CustomWriteStream = /** @class */ (function () { + function CustomWriteStream(_fd, _encoding) { + this._fd = _fd; + this._encoding = _encoding; + this._writeQueue = []; + } + CustomWriteStream.prototype.dispose = function () { + clearImmediate(this._writeImmediate); + this._writeImmediate = undefined; + }; + CustomWriteStream.prototype.write = function (data) { + // Writes are put in a queue and processed asynchronously in order to handle + // backpressure from the kernel buffer. + var buffer = typeof data === 'string' + ? Buffer.from(data, this._encoding) + : Buffer.from(data); + if (buffer.byteLength !== 0) { + this._writeQueue.push({ buffer: buffer, offset: 0 }); + if (this._writeQueue.length === 1) { + this._processWriteQueue(); + } + } + }; + CustomWriteStream.prototype._processWriteQueue = function () { + var _this = this; + this._writeImmediate = undefined; + if (this._writeQueue.length === 0) { + return; + } + var task = this._writeQueue[0]; + // Write to the underlying file descriptor and handle it directly, rather + // than using the `net.Socket`/`tty.WriteStream` wrappers which swallow and + // mask errors like EAGAIN and can cause the thread to block indefinitely. + fs.write(this._fd, task.buffer, task.offset, function (err, written) { + if (err) { + if ('code' in err && err.code === 'EAGAIN') { + // `setImmediate` is used to yield to the event loop and re-attempt + // the write later. + _this._writeImmediate = setImmediate(function () { return _this._processWriteQueue(); }); + } + else { + // Stop processing immediately on unexpected error and log + _this._writeQueue.length = 0; + console.error('Unhandled pty write error', err); + } + return; + } + task.offset += written; + if (task.offset >= task.buffer.byteLength) { + _this._writeQueue.shift(); + } + // Since there is more room in the kernel buffer, we can continue to write + // until we hit EAGAIN or exhaust the queue. + // + // Note that old versions of bash, like v3.2 which ships in macOS, appears + // to have a bug in its readline implementation that causes data + // corruption when writes to the pty happens too quickly. Instead of + // trying to workaround that we just accept it so that large pastes are as + // fast as possible. + // Context: https://github.com/microsoft/node-pty/issues/833 + _this._processWriteQueue(); + }); + }; + return CustomWriteStream; +}()); +//# sourceMappingURL=unixTerminal.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/unixTerminal.js.map b/services/edge-agent/node_modules/node-pty/lib/unixTerminal.js.map new file mode 100644 index 00000000..6e4bdc12 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/unixTerminal.js.map @@ -0,0 +1 @@ +{"version":3,"file":"unixTerminal.js","sourceRoot":"","sources":["../src/unixTerminal.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA;;;;GAIG;AACH,uBAAyB;AAEzB,2BAA6B;AAC7B,yBAA2B;AAC3B,uCAAkE;AAGlE,iCAAmD;AAEnD,IAAM,MAAM,GAAG,wBAAgB,CAAC,KAAK,CAAC,CAAC;AACvC,IAAM,GAAG,GAAgB,MAAM,CAAC,MAAM,CAAC;AACvC,IAAI,UAAU,GAAG,MAAM,CAAC,GAAG,GAAG,eAAe,CAAC;AAC9C,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;AACjD,UAAU,GAAG,UAAU,CAAC,OAAO,CAAC,UAAU,EAAE,mBAAmB,CAAC,CAAC;AACjE,UAAU,GAAG,UAAU,CAAC,OAAO,CAAC,mBAAmB,EAAE,4BAA4B,CAAC,CAAC;AAEnF,IAAM,YAAY,GAAG,IAAI,CAAC;AAC1B,IAAM,YAAY,GAAG,OAAO,CAAC;AAC7B,IAAM,yBAAyB,GAAG,GAAG,CAAC;AAEtC;IAAkC,gCAAQ;IAqBxC,sBAAY,IAAa,EAAE,IAAwB,EAAE,GAAqB;;QAA1E,YACE,kBAAM,GAAG,CAAC,SAuHX;QAnIO,iBAAW,GAAY,KAAK,CAAC;QAC7B,mBAAa,GAAY,KAAK,CAAC;QAarC,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE;YAC5B,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;SAC/D;QAED,uBAAuB;QACvB,IAAI,GAAG,IAAI,IAAI,EAAE,CAAC;QAClB,IAAI,GAAG,IAAI,IAAI,YAAY,CAAC;QAC5B,GAAG,GAAG,GAAG,IAAI,EAAE,CAAC;QAChB,GAAG,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC;QAEjC,KAAI,CAAC,KAAK,GAAG,GAAG,CAAC,IAAI,IAAI,uBAAY,CAAC;QACtC,KAAI,CAAC,KAAK,GAAG,GAAG,CAAC,IAAI,IAAI,uBAAY,CAAC;QACtC,IAAM,GAAG,SAAG,GAAG,CAAC,GAAG,mCAAI,CAAC,CAAC,CAAC;QAC1B,IAAM,GAAG,SAAG,GAAG,CAAC,GAAG,mCAAI,CAAC,CAAC,CAAC;QAC1B,IAAM,GAAG,GAAgB,cAAM,CAAC,EAAE,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QAE7C,IAAI,GAAG,CAAC,GAAG,KAAK,OAAO,CAAC,GAAG,EAAE;YAC3B,KAAI,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;SACxB;QAED,IAAM,GAAG,GAAG,GAAG,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;QACrC,GAAG,CAAC,GAAG,GAAG,GAAG,CAAC;QACd,IAAM,IAAI,GAAG,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,IAAI,IAAI,YAAY,CAAC;QAClD,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;QAChB,IAAM,SAAS,GAAG,KAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAEtC,IAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAEtE,IAAM,MAAM,GAAG,UAAC,IAAY,EAAE,MAAc;YAC1C,uEAAuE;YACvE,aAAa;YACb,IAAI,CAAC,KAAI,CAAC,aAAa,EAAE;gBACvB,IAAI,KAAI,CAAC,WAAW,EAAE;oBACpB,OAAO;iBACR;gBACD,KAAI,CAAC,WAAW,GAAG,IAAI,CAAC;gBACxB,iEAAiE;gBACjE,sEAAsE;gBACtE,8BAA8B;gBAC9B,IAAI,SAAO,GAA0B,UAAU,CAAC;oBAC9C,SAAO,GAAG,IAAI,CAAC;oBACf,+DAA+D;oBAC/D,KAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;gBACzB,CAAC,EAAE,yBAAyB,CAAC,CAAC;gBAC9B,KAAI,CAAC,IAAI,CAAC,OAAO,EAAE;oBACjB,IAAI,SAAO,KAAK,IAAI,EAAE;wBACpB,YAAY,CAAC,SAAO,CAAC,CAAC;qBACvB;oBACD,KAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;gBAClC,CAAC,CAAC,CAAC;gBACH,OAAO;aACR;YACD,KAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;QAClC,CAAC,CAAC;QAEF,OAAO;QACP,IAAM,IAAI,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,KAAI,CAAC,KAAK,EAAE,KAAI,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,QAAQ,KAAK,MAAM,CAAC,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;QAE/H,KAAI,CAAC,OAAO,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC3C,IAAI,QAAQ,KAAK,IAAI,EAAE;YACrB,KAAI,CAAC,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;SACpC;QACD,KAAI,CAAC,YAAY,GAAG,IAAI,iBAAiB,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,QAAQ,IAAI,SAAS,CAAmB,CAAC,CAAC;QAE9F,QAAQ;QACR,KAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,UAAC,GAAQ;YAChC,kDAAkD;YAClD,IAAI,GAAG,CAAC,IAAI,EAAE;gBACZ,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE;oBAC/B,OAAO;iBACR;aACF;YAED,QAAQ;YACR,KAAI,CAAC,MAAM,EAAE,CAAC;YACd,kCAAkC;YAClC,IAAI,CAAC,KAAI,CAAC,aAAa,EAAE;gBACvB,KAAI,CAAC,aAAa,GAAG,IAAI,CAAC;gBAC1B,KAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;aACpB;YAED,0EAA0E;YAC1E,gBAAgB;YAChB,yBAAyB;YACzB,2BAA2B;YAC3B,IAAI,GAAG,CAAC,IAAI,EAAE;gBACZ,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE;oBAC5D,OAAO;iBACR;aACF;YAED,sBAAsB;YACtB,IAAI,KAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE;gBACtC,MAAM,GAAG,CAAC;aACX;QACH,CAAC,CAAC,CAAC;QAEH,KAAI,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC;QACrB,KAAI,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,CAAC;QACnB,KAAI,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC;QAErB,KAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QAClB,KAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QAElB,KAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACtB,KAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QAEtB,KAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE;YACvB,IAAI,KAAI,CAAC,aAAa,EAAE;gBACtB,OAAO;aACR;YACD,KAAI,CAAC,aAAa,GAAG,IAAI,CAAC;YAC1B,KAAI,CAAC,MAAM,EAAE,CAAC;YACd,KAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACrB,CAAC,CAAC,CAAC;QAEH,KAAI,CAAC,cAAc,EAAE,CAAC;;IACxB,CAAC;IA3HD,sBAAW,gCAAM;aAAjB,cAA8C,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;;;OAAA;IACpE,sBAAW,+BAAK;aAAhB,cAA6C,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;;;OAAA;IA4HxD,6BAAM,GAAhB,UAAiB,IAAqB;QACpC,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAChC,CAAC;IAGD,sBAAI,4BAAE;QADN,eAAe;aACf,cAAmB,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;;;OAAA;IACrC,sBAAI,iCAAO;aAAX,cAAwB,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;;;OAAA;IAE3C;;OAEG;IAEW,iBAAI,GAAlB,UAAmB,GAAoB;QACrC,IAAM,IAAI,GAAiB,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC;QACjE,GAAG,GAAG,GAAG,IAAI,EAAE,CAAC;QAEhB,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE;YACxB,GAAG,GAAG;gBACJ,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC;gBAClB,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC;aACnB,CAAC;SACH;QAED,IAAM,IAAI,GAAG,GAAG,CAAC,IAAI,IAAI,uBAAY,CAAC;QACtC,IAAM,IAAI,GAAG,GAAG,CAAC,IAAI,IAAI,uBAAY,CAAC;QACtC,IAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAEtE,OAAO;QACP,IAAM,IAAI,GAAqB,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAEpD,IAAI,CAAC,OAAO,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC/C,IAAI,QAAQ,KAAK,IAAI,EAAE;YACrB,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;SACpC;QACD,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;QAEtB,IAAI,CAAC,MAAM,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC7C,IAAI,QAAQ,KAAK,IAAI,EAAE;YACrB,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;SACnC;QACD,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;QAErB,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC;QACf,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC;QACvB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC;QAErB,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC;QACvC,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;QAEpC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QAEtB,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,UAAA,GAAG;YAC1B,IAAI,CAAC,MAAM,EAAE,CAAC;YACd,IAAI,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE;gBACtC,MAAM,GAAG,CAAC;aACX;QACH,CAAC,CAAC,CAAC;QAEH,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE;YACvB,IAAI,CAAC,MAAM,EAAE,CAAC;QAChB,CAAC,CAAC,CAAC;QAEH,OAAO,IAAI,CAAC;IACd,CAAC;IAEM,8BAAO,GAAd;QAAA,iBAWC;QAVC,IAAI,CAAC,MAAM,EAAE,CAAC;QAEd,kEAAkE;QAClE,mDAAmD;QACnD,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE;YACzB,KAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACtB,CAAC,CAAC,CAAC;QAEH,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;QACvB,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,CAAC;IAC9B,CAAC;IAEM,2BAAI,GAAX,UAAY,MAAe;QACzB,IAAI;YACF,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,MAAM,IAAI,QAAQ,CAAC,CAAC;SAC5C;QAAC,OAAO,CAAC,EAAE,EAAE,aAAa,EAAE;IAC/B,CAAC;IAKD,sBAAW,iCAAO;QAHlB;;WAEG;aACH;YACE,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,EAAE;gBACjC,IAAM,KAAK,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBACpC,OAAO,CAAC,KAAK,KAAK,aAAa,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC;aACvD;YAED,OAAO,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC;QACxD,CAAC;;;OAAA;IAED;;OAEG;IAEI,6BAAM,GAAb,UAAc,IAAY,EAAE,IAAY;QACtC,IAAI,IAAI,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,QAAQ,EAAE;YAClG,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;SACvE;QACD,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QACjC,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QAClB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;IACpB,CAAC;IAEM,4BAAK,GAAZ;IAEA,CAAC;IAEO,mCAAY,GAApB,UAAqB,GAAgB;QACnC,yDAAyD;QACzD,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC;QACnB,OAAO,GAAG,CAAC,WAAW,CAAC,CAAC;QAExB,2DAA2D;QAC3D,iDAAiD;QACjD,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC;QAClB,OAAO,GAAG,CAAC,QAAQ,CAAC,CAAC;QAErB,yDAAyD;QACzD,OAAO,GAAG,CAAC,UAAU,CAAC,CAAC;QACvB,OAAO,GAAG,CAAC,SAAS,CAAC,CAAC;QACtB,OAAO,GAAG,CAAC,SAAS,CAAC,CAAC;QACtB,OAAO,GAAG,CAAC,OAAO,CAAC,CAAC;IACtB,CAAC;IACH,mBAAC;AAAD,CAAC,AAlRD,CAAkC,mBAAQ,GAkRzC;AAlRY,oCAAY;AA2RzB;;;;GAIG;AACH;IAKE,2BACmB,GAAW,EACX,SAAyB;QADzB,QAAG,GAAH,GAAG,CAAQ;QACX,cAAS,GAAT,SAAS,CAAgB;QAL3B,gBAAW,GAAiB,EAAE,CAAC;IAOhD,CAAC;IAED,mCAAO,GAAP;QACE,cAAc,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;QACrC,IAAI,CAAC,eAAe,GAAG,SAAS,CAAC;IACnC,CAAC;IAED,iCAAK,GAAL,UAAM,IAAqB;QACzB,4EAA4E;QAC5E,uCAAuC;QACvC,IAAM,MAAM,GAAG,OAAO,IAAI,KAAK,QAAQ;YACrC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAEtB,IAAI,MAAM,CAAC,UAAU,KAAK,CAAC,EAAE;YAC3B,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,MAAM,QAAA,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC;YAC7C,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE;gBACjC,IAAI,CAAC,kBAAkB,EAAE,CAAC;aAC3B;SACF;IACH,CAAC;IAEO,8CAAkB,GAA1B;QAAA,iBA0CC;QAzCC,IAAI,CAAC,eAAe,GAAG,SAAS,CAAC;QAEjC,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE;YACjC,OAAO;SACR;QAED,IAAM,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;QAEjC,yEAAyE;QACzE,2EAA2E;QAC3E,0EAA0E;QAC1E,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,UAAC,GAAG,EAAE,OAAO;YACxD,IAAI,GAAG,EAAE;gBACP,IAAI,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE;oBAC1C,mEAAmE;oBACnE,mBAAmB;oBACnB,KAAI,CAAC,eAAe,GAAG,YAAY,CAAC,cAAM,OAAA,KAAI,CAAC,kBAAkB,EAAE,EAAzB,CAAyB,CAAC,CAAC;iBACtE;qBAAM;oBACL,0DAA0D;oBAC1D,KAAI,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC;oBAC5B,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,GAAG,CAAC,CAAC;iBACjD;gBACD,OAAO;aACR;YAED,IAAI,CAAC,MAAM,IAAI,OAAO,CAAC;YACvB,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE;gBACzC,KAAI,CAAC,WAAW,CAAC,KAAK,EAAE,CAAC;aAC1B;YAED,0EAA0E;YAC1E,4CAA4C;YAC5C,EAAE;YACF,0EAA0E;YAC1E,gEAAgE;YAChE,oEAAoE;YACpE,0EAA0E;YAC1E,oBAAoB;YACpB,4DAA4D;YAC5D,KAAI,CAAC,kBAAkB,EAAE,CAAC;QAC5B,CAAC,CAAC,CAAC;IACL,CAAC;IACH,wBAAC;AAAD,CAAC,AA1ED,IA0EC"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/unixTerminal.test.js b/services/edge-agent/node_modules/node-pty/lib/unixTerminal.test.js new file mode 100644 index 00000000..30ba2579 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/unixTerminal.test.js @@ -0,0 +1,351 @@ +"use strict"; +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; + return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (_) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +var unixTerminal_1 = require("./unixTerminal"); +var assert = require("assert"); +var cp = require("child_process"); +var path = require("path"); +var tty = require("tty"); +var fs = require("fs"); +var os_1 = require("os"); +var testUtils_test_1 = require("./testUtils.test"); +var FIXTURES_PATH = path.normalize(path.join(__dirname, '..', 'fixtures', 'utf8-character.txt')); +if (process.platform !== 'win32') { + describe('UnixTerminal', function () { + describe('Constructor', function () { + it('should set a valid pts name', function () { + var term = new unixTerminal_1.UnixTerminal('/bin/bash', [], {}); + var regExp; + if (process.platform === 'linux') { + // https://linux.die.net/man/4/pts + regExp = /^\/dev\/pts\/\d+$/; + } + if (process.platform === 'darwin') { + // https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man4/pty.4.html + regExp = /^\/dev\/tty[p-sP-S][a-z0-9]+$/; + } + if (regExp) { + assert.ok(regExp.test(term.ptsName), '"' + term.ptsName + '" should match ' + regExp.toString()); + } + assert.ok(tty.isatty(term.fd)); + }); + }); + describe('PtyForkEncodingOption', function () { + it('should default to utf8', function (done) { + var term = new unixTerminal_1.UnixTerminal('/bin/bash', ['-c', "cat \"" + FIXTURES_PATH + "\""]); + term.on('data', function (data) { + assert.strictEqual(typeof data, 'string'); + assert.strictEqual(data, '\u00E6'); + done(); + }); + }); + it('should return a Buffer when encoding is null', function (done) { + var term = new unixTerminal_1.UnixTerminal('/bin/bash', ['-c', "cat \"" + FIXTURES_PATH + "\""], { + encoding: null + }); + term.on('data', function (data) { + assert.strictEqual(typeof data, 'object'); + assert.ok(data instanceof Buffer); + assert.strictEqual(0xC3, data[0]); + assert.strictEqual(0xA6, data[1]); + done(); + }); + }); + it('should support other encodings', function (done) { + var text = 'test æ!'; + var term = new unixTerminal_1.UnixTerminal(undefined, ['-c', 'echo "' + text + '"'], { + encoding: 'base64' + }); + var buffer = ''; + term.onData(function (data) { + assert.strictEqual(typeof data, 'string'); + buffer += data; + }); + term.onExit(function () { + assert.strictEqual(Buffer.alloc(8, buffer, 'base64').toString().replace('\r', '').replace('\n', ''), text); + done(); + }); + }); + }); + describe('open', function () { + var term; + afterEach(function () { + if (term) { + term.slave.destroy(); + term.master.destroy(); + } + }); + it('should open a pty with access to a master and slave socket', function (done) { + term = unixTerminal_1.UnixTerminal.open({}); + var slavebuf = ''; + term.slave.on('data', function (data) { + slavebuf += data; + }); + var masterbuf = ''; + term.master.on('data', function (data) { + masterbuf += data; + }); + testUtils_test_1.pollUntil(function () { + if (masterbuf === 'slave\r\nmaster\r\n' && slavebuf === 'master\n') { + done(); + return true; + } + return false; + }, 200, 10); + term.slave.write('slave\n'); + term.master.write('master\n'); + }); + }); + describe('close', function () { + var term = new unixTerminal_1.UnixTerminal('node'); + it('should exit when terminal is destroyed programmatically', function (done) { + term.on('exit', function (code, signal) { + assert.strictEqual(code, 0); + assert.strictEqual(signal, os_1.constants.signals.SIGHUP); + done(); + }); + term.destroy(); + }); + }); + describe('signals in parent and child', function () { + it('SIGINT - custom in parent and child', function (done) { + // this test is cumbersome - we have to run it in a sub process to + // see behavior of SIGINT handlers + var data = "\n var pty = require('./lib/index');\n process.on('SIGINT', () => console.log('SIGINT in parent'));\n var ptyProcess = pty.spawn('node', ['-e', 'process.on(\"SIGINT\", ()=>console.log(\"SIGINT in child\"));setTimeout(() => null, 300);'], {\n name: 'xterm-color',\n cols: 80,\n rows: 30,\n cwd: process.env.HOME,\n env: process.env\n });\n ptyProcess.on('data', function (data) {\n console.log(data);\n });\n setTimeout(() => null, 500);\n console.log('ready', ptyProcess.pid);\n "; + var buffer = []; + var p = cp.spawn('node', ['-e', data]); + var sub = ''; + p.stdout.on('data', function (data) { + if (!data.toString().indexOf('ready')) { + sub = data.toString().split(' ')[1].slice(0, -1); + setTimeout(function () { + process.kill(parseInt(sub), 'SIGINT'); // SIGINT to child + p.kill('SIGINT'); // SIGINT to parent + }, 200); + } + else { + buffer.push(data.toString().replace(/^\s+|\s+$/g, '')); + } + }); + p.on('close', function () { + // handlers in parent and child should have been triggered + assert.strictEqual(buffer.indexOf('SIGINT in child') !== -1, true); + assert.strictEqual(buffer.indexOf('SIGINT in parent') !== -1, true); + done(); + }); + }); + it('SIGINT - custom in parent, default in child', function (done) { + // this tests the original idea of the signal(...) change in pty.cc: + // to make sure the SIGINT handler of a pty child is reset to default + // and does not interfere with the handler in the parent + var data = "\n var pty = require('./lib/index');\n process.on('SIGINT', () => console.log('SIGINT in parent'));\n var ptyProcess = pty.spawn('node', ['-e', 'setTimeout(() => console.log(\"should not be printed\"), 300);'], {\n name: 'xterm-color',\n cols: 80,\n rows: 30,\n cwd: process.env.HOME,\n env: process.env\n });\n ptyProcess.on('data', function (data) {\n console.log(data);\n });\n setTimeout(() => null, 500);\n console.log('ready', ptyProcess.pid);\n "; + var buffer = []; + var p = cp.spawn('node', ['-e', data]); + var sub = ''; + p.stdout.on('data', function (data) { + if (!data.toString().indexOf('ready')) { + sub = data.toString().split(' ')[1].slice(0, -1); + setTimeout(function () { + process.kill(parseInt(sub), 'SIGINT'); // SIGINT to child + p.kill('SIGINT'); // SIGINT to parent + }, 200); + } + else { + buffer.push(data.toString().replace(/^\s+|\s+$/g, '')); + } + }); + p.on('close', function () { + // handlers in parent and child should have been triggered + assert.strictEqual(buffer.indexOf('should not be printed') !== -1, false); + assert.strictEqual(buffer.indexOf('SIGINT in parent') !== -1, true); + done(); + }); + }); + it('SIGHUP default (child only)', function (done) { + var term = new unixTerminal_1.UnixTerminal('node', ['-e', "\n console.log('ready');\n setTimeout(()=>console.log('timeout'), 200);" + ]); + var buffer = ''; + term.on('data', function (data) { + if (data === 'ready\r\n') { + term.kill(); + } + else { + buffer += data; + } + }); + term.on('exit', function () { + // no timeout in buffer + assert.strictEqual(buffer, ''); + done(); + }); + }); + it('SIGUSR1 - custom in parent and child', function (done) { + var pHandlerCalled = 0; + var handleSigUsr = function (h) { + return function () { + pHandlerCalled += 1; + process.removeListener('SIGUSR1', h); + }; + }; + process.on('SIGUSR1', handleSigUsr(handleSigUsr)); + var term = new unixTerminal_1.UnixTerminal('node', ['-e', "\n process.on('SIGUSR1', () => {\n console.log('SIGUSR1 in child');\n });\n console.log('ready');\n setTimeout(()=>null, 200);" + ]); + var buffer = ''; + term.on('data', function (data) { + if (data === 'ready\r\n') { + process.kill(process.pid, 'SIGUSR1'); + term.kill('SIGUSR1'); + } + else { + buffer += data; + } + }); + term.on('exit', function () { + // should have called both handlers and only once + assert.strictEqual(pHandlerCalled, 1); + assert.strictEqual(buffer, 'SIGUSR1 in child\r\n'); + done(); + }); + }); + }); + describe('spawn', function () { + if (process.platform === 'darwin') { + it('should return the name of the process', function (done) { + var term = new unixTerminal_1.UnixTerminal('/bin/echo'); + assert.strictEqual(term.process, '/bin/echo'); + term.on('exit', function () { return done(); }); + term.destroy(); + }); + it('should return the name of the sub process', function (done) { + var data = "\n var pty = require('./lib/index');\n var ptyProcess = pty.spawn('zsh', ['-c', 'python3'], {\n env: process.env\n });\n ptyProcess.on('data', function (data) {\n if (ptyProcess.process === 'Python') {\n console.log('title', ptyProcess.process);\n console.log('ready', ptyProcess.pid);\n }\n });\n "; + var p = cp.spawn('node', ['-e', data]); + var sub = ''; + var pid = ''; + p.stdout.on('data', function (data) { + if (!data.toString().indexOf('title')) { + sub = data.toString().split(' ')[1].slice(0, -1); + } + else if (!data.toString().indexOf('ready')) { + pid = data.toString().split(' ')[1].slice(0, -1); + process.kill(parseInt(pid), 'SIGINT'); + p.kill('SIGINT'); + } + }); + p.on('exit', function () { + assert.notStrictEqual(pid, ''); + assert.strictEqual(sub, 'Python'); + done(); + }); + }); + it('should close on exec', function (done) { + var data = "\n var pty = require('./lib/index');\n var ptyProcess = pty.spawn('node', ['-e', 'setTimeout(() => console.log(\"hello from terminal\"), 300);']);\n ptyProcess.on('data', function (data) {\n console.log(data);\n });\n setTimeout(() => null, 500);\n console.log('ready', ptyProcess.pid);\n "; + var buffer = []; + var readFd = fs.openSync(FIXTURES_PATH, 'r'); + var p = cp.spawn('node', ['-e', data], { + stdio: ['ignore', 'pipe', 'pipe', readFd] + }); + var sub = ''; + p.stdout.on('data', function (data) { + if (!data.toString().indexOf('ready')) { + sub = data.toString().split(' ')[1].slice(0, -1); + try { + fs.statSync("/proc/" + sub + "/fd/" + readFd); + done('not reachable'); + } + catch (error) { + assert.notStrictEqual(error.message.indexOf('ENOENT'), -1); + } + setTimeout(function () { + process.kill(parseInt(sub), 'SIGINT'); // SIGINT to child + p.kill('SIGINT'); // SIGINT to parent + }, 200); + } + else { + buffer.push(data.toString().replace(/^\s+|\s+$/g, '')); + } + }); + p.on('close', function () { + done(); + }); + }); + } + it('should handle exec() errors', function (done) { + var term = new unixTerminal_1.UnixTerminal('/bin/bogus.exe', []); + term.on('exit', function (code, signal) { + assert.strictEqual(code, 1); + done(); + }); + }); + it('should handle chdir() errors', function (done) { + var term = new unixTerminal_1.UnixTerminal('/bin/echo', [], { cwd: '/nowhere' }); + term.on('exit', function (code, signal) { + assert.strictEqual(code, 1); + done(); + }); + }); + it('should not leak child process', function (done) { + var count = cp.execSync('ps -ax | grep node | wc -l'); + var term = new unixTerminal_1.UnixTerminal('node', ['-e', "\n console.log('ready');\n setTimeout(()=>console.log('timeout'), 200);" + ]); + term.on('data', function (data) { return __awaiter(void 0, void 0, void 0, function () { + var newCount; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!(data === 'ready\r\n')) return [3 /*break*/, 2]; + process.kill(term.pid, 'SIGINT'); + return [4 /*yield*/, setTimeout(function () { return null; }, 1000)]; + case 1: + _a.sent(); + newCount = cp.execSync('ps -ax | grep node | wc -l'); + assert.strictEqual(count.toString(), newCount.toString()); + done(); + _a.label = 2; + case 2: return [2 /*return*/]; + } + }); + }); }); + }); + }); + }); +} +//# sourceMappingURL=unixTerminal.test.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/unixTerminal.test.js.map b/services/edge-agent/node_modules/node-pty/lib/unixTerminal.test.js.map new file mode 100644 index 00000000..89405393 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/unixTerminal.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"unixTerminal.test.js","sourceRoot":"","sources":["../src/unixTerminal.test.ts"],"names":[],"mappings":";AAAA;;;GAGG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAEH,+CAA8C;AAC9C,+BAAiC;AACjC,kCAAoC;AACpC,2BAA6B;AAC7B,yBAA2B;AAC3B,uBAAyB;AACzB,yBAA+B;AAC/B,mDAA6C;AAG7C,IAAM,aAAa,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,oBAAoB,CAAC,CAAC,CAAC;AAEnG,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE;IAChC,QAAQ,CAAC,cAAc,EAAE;QACvB,QAAQ,CAAC,aAAa,EAAE;YACtB,EAAE,CAAC,6BAA6B,EAAE;gBAChC,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,WAAW,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;gBACnD,IAAI,MAA0B,CAAC;gBAC/B,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE;oBAChC,kCAAkC;oBAClC,MAAM,GAAG,mBAAmB,CAAC;iBAC9B;gBACD,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,EAAE;oBACjC,qGAAqG;oBACrG,MAAM,GAAG,+BAA+B,CAAC;iBAC1C;gBACD,IAAI,MAAM,EAAE;oBACV,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,GAAG,GAAG,IAAI,CAAC,OAAO,GAAG,iBAAiB,GAAG,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;iBAClG;gBACD,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;YACjC,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,QAAQ,CAAC,uBAAuB,EAAE;YAChC,EAAE,CAAC,wBAAwB,EAAE,UAAC,IAAI;gBAChC,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,WAAW,EAAE,CAAE,IAAI,EAAE,WAAQ,aAAa,OAAG,CAAE,CAAC,CAAC;gBAC/E,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;oBACnB,MAAM,CAAC,WAAW,CAAC,OAAO,IAAI,EAAE,QAAQ,CAAC,CAAC;oBAC1C,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;oBACnC,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,8CAA8C,EAAE,UAAC,IAAI;gBACtD,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,WAAW,EAAE,CAAE,IAAI,EAAE,WAAQ,aAAa,OAAG,CAAE,EAAE;oBAC7E,QAAQ,EAAE,IAAI;iBACf,CAAC,CAAC;gBACH,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;oBACnB,MAAM,CAAC,WAAW,CAAC,OAAO,IAAI,EAAE,QAAQ,CAAC,CAAC;oBAC1C,MAAM,CAAC,EAAE,CAAC,IAAI,YAAY,MAAM,CAAC,CAAC;oBAClC,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;oBAClC,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;oBAClC,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,gCAAgC,EAAE,UAAC,IAAI;gBACxC,IAAM,IAAI,GAAG,SAAS,CAAC;gBACvB,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,GAAG,GAAG,CAAC,EAAE;oBACtE,QAAQ,EAAE,QAAQ;iBACnB,CAAC,CAAC;gBACH,IAAI,MAAM,GAAG,EAAE,CAAC;gBAChB,IAAI,CAAC,MAAM,CAAC,UAAC,IAAI;oBACf,MAAM,CAAC,WAAW,CAAC,OAAO,IAAI,EAAE,QAAQ,CAAC,CAAC;oBAC1C,MAAM,IAAI,IAAI,CAAC;gBACjB,CAAC,CAAC,CAAC;gBACH,IAAI,CAAC,MAAM,CAAC;oBACV,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;oBAC3G,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,QAAQ,CAAC,MAAM,EAAE;YACf,IAAI,IAAkB,CAAC;YAEvB,SAAS,CAAC;gBACR,IAAI,IAAI,EAAE;oBACR,IAAI,CAAC,KAAM,CAAC,OAAO,EAAE,CAAC;oBACtB,IAAI,CAAC,MAAO,CAAC,OAAO,EAAE,CAAC;iBACxB;YACH,CAAC,CAAC,CAAC;YAEH,EAAE,CAAC,4DAA4D,EAAE,UAAC,IAAI;gBACpE,IAAI,GAAG,2BAAY,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;gBAE7B,IAAI,QAAQ,GAAG,EAAE,CAAC;gBAClB,IAAI,CAAC,KAAM,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;oBAC1B,QAAQ,IAAI,IAAI,CAAC;gBACnB,CAAC,CAAC,CAAC;gBAEH,IAAI,SAAS,GAAG,EAAE,CAAC;gBACnB,IAAI,CAAC,MAAO,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;oBAC3B,SAAS,IAAI,IAAI,CAAC;gBACpB,CAAC,CAAC,CAAC;gBAEH,0BAAS,CAAC;oBACR,IAAI,SAAS,KAAK,qBAAqB,IAAI,QAAQ,KAAK,UAAU,EAAE;wBAClE,IAAI,EAAE,CAAC;wBACP,OAAO,IAAI,CAAC;qBACb;oBACD,OAAO,KAAK,CAAC;gBACf,CAAC,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;gBAEZ,IAAI,CAAC,KAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;gBAC7B,IAAI,CAAC,MAAO,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;YACjC,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QACH,QAAQ,CAAC,OAAO,EAAE;YAChB,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,MAAM,CAAC,CAAC;YACtC,EAAE,CAAC,yDAAyD,EAAE,UAAC,IAAI;gBACjE,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI,EAAE,MAAM;oBAC3B,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;oBAC5B,MAAM,CAAC,WAAW,CAAC,MAAM,EAAE,cAAS,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;oBACrD,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;gBACH,IAAI,CAAC,OAAO,EAAE,CAAC;YACjB,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QACH,QAAQ,CAAC,6BAA6B,EAAE;YACtC,EAAE,CAAC,qCAAqC,EAAE,UAAA,IAAI;gBAC5C,kEAAkE;gBAClE,kCAAkC;gBAClC,IAAM,IAAI,GAAG,slBAeZ,CAAC;gBACF,IAAM,MAAM,GAAa,EAAE,CAAC;gBAC5B,IAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;gBACzC,IAAI,GAAG,GAAG,EAAE,CAAC;gBACb,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;oBACvB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE;wBACrC,GAAG,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;wBACjD,UAAU,CAAC;4BACT,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAE,kBAAkB;4BAC1D,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAuB,mBAAmB;wBAC7D,CAAC,EAAE,GAAG,CAAC,CAAC;qBACT;yBAAM;wBACL,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC,CAAC;qBACxD;gBACH,CAAC,CAAC,CAAC;gBACH,CAAC,CAAC,EAAE,CAAC,OAAO,EAAE;oBACZ,0DAA0D;oBAC1D,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;oBACnE,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;oBACpE,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,6CAA6C,EAAE,UAAA,IAAI;gBACpD,oEAAoE;gBACpE,qEAAqE;gBACrE,wDAAwD;gBACxD,IAAM,IAAI,GAAG,2jBAeZ,CAAC;gBACF,IAAM,MAAM,GAAa,EAAE,CAAC;gBAC5B,IAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;gBACzC,IAAI,GAAG,GAAG,EAAE,CAAC;gBACb,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;oBACvB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE;wBACrC,GAAG,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;wBACjD,UAAU,CAAC;4BACT,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAE,kBAAkB;4BAC1D,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAuB,mBAAmB;wBAC7D,CAAC,EAAE,GAAG,CAAC,CAAC;qBACT;yBAAM;wBACL,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC,CAAC;qBACxD;gBACH,CAAC,CAAC,CAAC;gBACH,CAAC,CAAC,EAAE,CAAC,OAAO,EAAE;oBACZ,0DAA0D;oBAC1D,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;oBAC1E,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;oBACpE,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,6BAA6B,EAAE,UAAA,IAAI;gBACpC,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,MAAM,EAAE,CAAE,IAAI,EAAE,uFAED;iBAC5C,CAAC,CAAC;gBACH,IAAI,MAAM,GAAG,EAAE,CAAC;gBAChB,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;oBACnB,IAAI,IAAI,KAAK,WAAW,EAAE;wBACxB,IAAI,CAAC,IAAI,EAAE,CAAC;qBACb;yBAAM;wBACL,MAAM,IAAI,IAAI,CAAC;qBAChB;gBACH,CAAC,CAAC,CAAC;gBACH,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE;oBACd,uBAAuB;oBACvB,MAAM,CAAC,WAAW,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;oBAC/B,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,sCAAsC,EAAE,UAAA,IAAI;gBAC7C,IAAI,cAAc,GAAG,CAAC,CAAC;gBACvB,IAAM,YAAY,GAAG,UAAS,CAAM;oBAClC,OAAO;wBACL,cAAc,IAAI,CAAC,CAAC;wBACpB,OAAO,CAAC,cAAc,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;oBACvC,CAAC,CAAC;gBACJ,CAAC,CAAC;gBACF,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC;gBAElD,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,MAAM,EAAE,CAAE,IAAI,EAAE,qKAKnB;iBAC1B,CAAC,CAAC;gBACH,IAAI,MAAM,GAAG,EAAE,CAAC;gBAChB,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;oBACnB,IAAI,IAAI,KAAK,WAAW,EAAE;wBACxB,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;wBACrC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;qBACtB;yBAAM;wBACL,MAAM,IAAI,IAAI,CAAC;qBAChB;gBACH,CAAC,CAAC,CAAC;gBACH,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE;oBACd,iDAAiD;oBACjD,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC;oBACtC,MAAM,CAAC,WAAW,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;oBACnD,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QACH,QAAQ,CAAC,OAAO,EAAE;YAChB,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,EAAE;gBACjC,EAAE,CAAC,uCAAuC,EAAE,UAAC,IAAI;oBAC/C,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,WAAW,CAAC,CAAC;oBAC3C,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC;oBAC9C,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,cAAM,OAAA,IAAI,EAAE,EAAN,CAAM,CAAC,CAAC;oBAC9B,IAAI,CAAC,OAAO,EAAE,CAAC;gBACjB,CAAC,CAAC,CAAC;gBACH,EAAE,CAAC,2CAA2C,EAAE,UAAC,IAAI;oBACnD,IAAM,IAAI,GAAG,6ZAWZ,CAAC;oBACF,IAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;oBACzC,IAAI,GAAG,GAAG,EAAE,CAAC;oBACb,IAAI,GAAG,GAAG,EAAE,CAAC;oBACb,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;wBACvB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE;4BACrC,GAAG,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;yBAClD;6BAAM,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE;4BAC5C,GAAG,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;4BACjD,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,CAAC;4BACtC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;yBAClB;oBACH,CAAC,CAAC,CAAC;oBACH,CAAC,CAAC,EAAE,CAAC,MAAM,EAAE;wBACX,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;wBAC/B,MAAM,CAAC,WAAW,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;wBAClC,IAAI,EAAE,CAAC;oBACT,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;gBACH,EAAE,CAAC,sBAAsB,EAAE,UAAC,IAAI;oBAC9B,IAAM,IAAI,GAAG,6WAQZ,CAAC;oBACF,IAAM,MAAM,GAAa,EAAE,CAAC;oBAC5B,IAAM,MAAM,GAAG,EAAE,CAAC,QAAQ,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC;oBAC/C,IAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE;wBACvC,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;qBAC1C,CAAC,CAAC;oBACH,IAAI,GAAG,GAAG,EAAE,CAAC;oBACb,CAAC,CAAC,MAAO,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;wBACxB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE;4BACrC,GAAG,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;4BACjD,IAAI;gCACF,EAAE,CAAC,QAAQ,CAAC,WAAS,GAAG,YAAO,MAAQ,CAAC,CAAC;gCACzC,IAAI,CAAC,eAAe,CAAC,CAAC;6BACvB;4BAAC,OAAO,KAAK,EAAE;gCACd,MAAM,CAAC,cAAc,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;6BAC5D;4BACD,UAAU,CAAC;gCACT,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAE,kBAAkB;gCAC1D,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAuB,mBAAmB;4BAC7D,CAAC,EAAE,GAAG,CAAC,CAAC;yBACT;6BAAM;4BACL,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC,CAAC;yBACxD;oBACH,CAAC,CAAC,CAAC;oBACH,CAAC,CAAC,EAAE,CAAC,OAAO,EAAE;wBACZ,IAAI,EAAE,CAAC;oBACT,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;aACJ;YACD,EAAE,CAAC,6BAA6B,EAAE,UAAC,IAAI;gBACrC,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,gBAAgB,EAAE,EAAE,CAAC,CAAC;gBACpD,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI,EAAE,MAAM;oBAC3B,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;oBAC5B,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,8BAA8B,EAAE,UAAC,IAAI;gBACtC,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,WAAW,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,CAAC;gBACpE,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI,EAAE,MAAM;oBAC3B,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;oBAC5B,IAAI,EAAE,CAAC;gBACT,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,+BAA+B,EAAE,UAAC,IAAI;gBACvC,IAAM,KAAK,GAAG,EAAE,CAAC,QAAQ,CAAC,4BAA4B,CAAC,CAAC;gBACxD,IAAM,IAAI,GAAG,IAAI,2BAAY,CAAC,MAAM,EAAE,CAAE,IAAI,EAAE,2FAEC;iBAC9C,CAAC,CAAC;gBACH,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAO,IAAI;;;;;qCACrB,CAAA,IAAI,KAAK,WAAW,CAAA,EAApB,wBAAoB;gCACtB,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;gCACjC,qBAAM,UAAU,CAAC,cAAM,OAAA,IAAI,EAAJ,CAAI,EAAE,IAAI,CAAC,EAAA;;gCAAlC,SAAkC,CAAC;gCAC7B,QAAQ,GAAG,EAAE,CAAC,QAAQ,CAAC,4BAA4B,CAAC,CAAC;gCAC3D,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,QAAQ,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC;gCAC1D,IAAI,EAAE,CAAC;;;;;qBAEV,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;CACJ"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/utils.js b/services/edge-agent/node_modules/node-pty/lib/utils.js new file mode 100644 index 00000000..af7918b6 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/utils.js @@ -0,0 +1,39 @@ +"use strict"; +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loadNativeModule = exports.assign = void 0; +function assign(target) { + var sources = []; + for (var _i = 1; _i < arguments.length; _i++) { + sources[_i - 1] = arguments[_i]; + } + sources.forEach(function (source) { return Object.keys(source).forEach(function (key) { return target[key] = source[key]; }); }); + return target; +} +exports.assign = assign; +function loadNativeModule(name) { + // Check build, debug, and then prebuilds. + var dirs = ['build/Release', 'build/Debug', "prebuilds/" + process.platform + "-" + process.arch]; + // Check relative to the parent dir for unbundled and then the current dir for bundled + var relative = ['..', '.']; + var lastError; + for (var _i = 0, dirs_1 = dirs; _i < dirs_1.length; _i++) { + var d = dirs_1[_i]; + for (var _a = 0, relative_1 = relative; _a < relative_1.length; _a++) { + var r = relative_1[_a]; + var dir = r + "/" + d + "/"; + try { + return { dir: dir, module: require(dir + "/" + name + ".node") }; + } + catch (e) { + lastError = e; + } + } + } + throw new Error("Failed to load native module: " + name + ".node, checked: " + dirs.join(', ') + ": " + lastError); +} +exports.loadNativeModule = loadNativeModule; +//# sourceMappingURL=utils.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/utils.js.map b/services/edge-agent/node_modules/node-pty/lib/utils.js.map new file mode 100644 index 00000000..af0b7453 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/utils.js.map @@ -0,0 +1 @@ +{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":";AAAA;;;GAGG;;;AAEH,SAAgB,MAAM,CAAC,MAAW;IAAE,iBAAiB;SAAjB,UAAiB,EAAjB,qBAAiB,EAAjB,IAAiB;QAAjB,gCAAiB;;IACnD,OAAO,CAAC,OAAO,CAAC,UAAA,MAAM,IAAI,OAAA,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,UAAA,GAAG,IAAI,OAAA,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,EAAzB,CAAyB,CAAC,EAA7D,CAA6D,CAAC,CAAC;IACzF,OAAO,MAAM,CAAC;AAChB,CAAC;AAHD,wBAGC;AAGD,SAAgB,gBAAgB,CAAC,IAAY;IAC3C,0CAA0C;IAC1C,IAAM,IAAI,GAAG,CAAC,eAAe,EAAE,aAAa,EAAE,eAAa,OAAO,CAAC,QAAQ,SAAI,OAAO,CAAC,IAAM,CAAC,CAAC;IAC/F,sFAAsF;IACtF,IAAM,QAAQ,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC7B,IAAI,SAAkB,CAAC;IACvB,KAAgB,UAAI,EAAJ,aAAI,EAAJ,kBAAI,EAAJ,IAAI,EAAE;QAAjB,IAAM,CAAC,aAAA;QACV,KAAgB,UAAQ,EAAR,qBAAQ,EAAR,sBAAQ,EAAR,IAAQ,EAAE;YAArB,IAAM,CAAC,iBAAA;YACV,IAAM,GAAG,GAAM,CAAC,SAAI,CAAC,MAAG,CAAC;YACzB,IAAI;gBACF,OAAO,EAAE,GAAG,KAAA,EAAE,MAAM,EAAE,OAAO,CAAI,GAAG,SAAI,IAAI,UAAO,CAAC,EAAE,CAAC;aACxD;YAAC,OAAO,CAAC,EAAE;gBACV,SAAS,GAAG,CAAC,CAAC;aACf;SACF;KACF;IACD,MAAM,IAAI,KAAK,CAAC,mCAAiC,IAAI,wBAAmB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,UAAK,SAAW,CAAC,CAAC;AAC3G,CAAC;AAjBD,4CAiBC"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsConoutConnection.js b/services/edge-agent/node_modules/node-pty/lib/windowsConoutConnection.js new file mode 100644 index 00000000..1be15ca0 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsConoutConnection.js @@ -0,0 +1,125 @@ +"use strict"; +/** + * Copyright (c) 2020, Microsoft Corporation (MIT License). + */ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; + return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (_) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ConoutConnection = void 0; +var worker_threads_1 = require("worker_threads"); +var conout_1 = require("./shared/conout"); +var path_1 = require("path"); +var eventEmitter2_1 = require("./eventEmitter2"); +/** + * The amount of time to wait for additional data after the conpty shell process has exited before + * shutting down the worker and sockets. The timer will be reset if a new data event comes in after + * the timer has started. + */ +var FLUSH_DATA_INTERVAL = 1000; +/** + * Connects to and manages the lifecycle of the conout socket. This socket must be drained on + * another thread in order to avoid deadlocks where Conpty waits for the out socket to drain + * when `ClosePseudoConsole` is called. This happens when data is being written to the terminal when + * the pty is closed. + * + * See also: + * - https://github.com/microsoft/node-pty/issues/375 + * - https://github.com/microsoft/vscode/issues/76548 + * - https://github.com/microsoft/terminal/issues/1810 + * - https://docs.microsoft.com/en-us/windows/console/closepseudoconsole + */ +var ConoutConnection = /** @class */ (function () { + function ConoutConnection(_conoutPipeName, _useConptyDll) { + var _this = this; + this._conoutPipeName = _conoutPipeName; + this._useConptyDll = _useConptyDll; + this._isDisposed = false; + this._onReady = new eventEmitter2_1.EventEmitter2(); + var workerData = { + conoutPipeName: _conoutPipeName + }; + var scriptPath = __dirname.replace('node_modules.asar', 'node_modules.asar.unpacked'); + this._worker = new worker_threads_1.Worker(path_1.join(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData }); + this._worker.on('message', function (message) { + switch (message) { + case 1 /* READY */: + _this._onReady.fire(); + return; + default: + console.warn('Unexpected ConoutWorkerMessage', message); + } + }); + } + Object.defineProperty(ConoutConnection.prototype, "onReady", { + get: function () { return this._onReady.event; }, + enumerable: false, + configurable: true + }); + ConoutConnection.prototype.dispose = function () { + if (!this._useConptyDll && this._isDisposed) { + return; + } + this._isDisposed = true; + // Drain all data from the socket before closing + this._drainDataAndClose(); + }; + ConoutConnection.prototype.connectSocket = function (socket) { + socket.connect(conout_1.getWorkerPipeName(this._conoutPipeName)); + }; + ConoutConnection.prototype._drainDataAndClose = function () { + var _this = this; + if (this._drainTimeout) { + clearTimeout(this._drainTimeout); + } + this._drainTimeout = setTimeout(function () { return _this._destroySocket(); }, FLUSH_DATA_INTERVAL); + }; + ConoutConnection.prototype._destroySocket = function () { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, this._worker.terminate()]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); + }; + return ConoutConnection; +}()); +exports.ConoutConnection = ConoutConnection; +//# sourceMappingURL=windowsConoutConnection.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsConoutConnection.js.map b/services/edge-agent/node_modules/node-pty/lib/windowsConoutConnection.js.map new file mode 100644 index 00000000..31238914 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsConoutConnection.js.map @@ -0,0 +1 @@ +{"version":3,"file":"windowsConoutConnection.js","sourceRoot":"","sources":["../src/windowsConoutConnection.ts"],"names":[],"mappings":";AAAA;;GAEG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAEH,iDAAwC;AAGxC,0CAAsF;AACtF,6BAA4B;AAC5B,iDAAwD;AAExD;;;;GAIG;AACH,IAAM,mBAAmB,GAAG,IAAI,CAAC;AAEjC;;;;;;;;;;;GAWG;AACH;IAQE,0BACU,eAAuB,EACvB,aAAsB;QAFhC,iBAkBC;QAjBS,oBAAe,GAAf,eAAe,CAAQ;QACvB,kBAAa,GAAb,aAAa,CAAS;QAPxB,gBAAW,GAAY,KAAK,CAAC;QAE7B,aAAQ,GAAG,IAAI,6BAAa,EAAQ,CAAC;QAO3C,IAAM,UAAU,GAAgB;YAC9B,cAAc,EAAE,eAAe;SAChC,CAAC;QACF,IAAM,UAAU,GAAG,SAAS,CAAC,OAAO,CAAC,mBAAmB,EAAE,4BAA4B,CAAC,CAAC;QACxF,IAAI,CAAC,OAAO,GAAG,IAAI,uBAAM,CAAC,WAAI,CAAC,UAAU,EAAE,8BAA8B,CAAC,EAAE,EAAE,UAAU,YAAA,EAAE,CAAC,CAAC;QAC5F,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,UAAC,OAA4B;YACtD,QAAQ,OAAO,EAAE;gBACf;oBACE,KAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;oBACrB,OAAO;gBACT;oBACE,OAAO,CAAC,IAAI,CAAC,gCAAgC,EAAE,OAAO,CAAC,CAAC;aAC3D;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IApBD,sBAAW,qCAAO;aAAlB,cAAqC,OAAO,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;;;OAAA;IAsBlE,kCAAO,GAAP;QACE,IAAI,CAAC,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC,WAAW,EAAE;YAC3C,OAAO;SACR;QACD,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;QACxB,gDAAgD;QAChD,IAAI,CAAC,kBAAkB,EAAE,CAAC;IAC5B,CAAC;IAED,wCAAa,GAAb,UAAc,MAAc;QAC1B,MAAM,CAAC,OAAO,CAAC,0BAAiB,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC;IAC1D,CAAC;IAEO,6CAAkB,GAA1B;QAAA,iBAKC;QAJC,IAAI,IAAI,CAAC,aAAa,EAAE;YACtB,YAAY,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;SAClC;QACD,IAAI,CAAC,aAAa,GAAG,UAAU,CAAC,cAAM,OAAA,KAAI,CAAC,cAAc,EAAE,EAArB,CAAqB,EAAE,mBAAmB,CAAC,CAAC;IACpF,CAAC;IAEa,yCAAc,GAA5B;;;;4BACE,qBAAM,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,EAAA;;wBAA9B,SAA8B,CAAC;;;;;KAChC;IACH,uBAAC;AAAD,CAAC,AAnDD,IAmDC;AAnDY,4CAAgB"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.js b/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.js new file mode 100644 index 00000000..a358ffb1 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.js @@ -0,0 +1,320 @@ +"use strict"; +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.argsToCommandLine = exports.WindowsPtyAgent = void 0; +var fs = require("fs"); +var os = require("os"); +var path = require("path"); +var child_process_1 = require("child_process"); +var net_1 = require("net"); +var windowsConoutConnection_1 = require("./windowsConoutConnection"); +var utils_1 = require("./utils"); +var conptyNative; +var winptyNative; +/** + * The amount of time to wait for additional data after the conpty shell process has exited before + * shutting down the socket. The timer will be reset if a new data event comes in after the timer + * has started. + */ +var FLUSH_DATA_INTERVAL = 1000; +/** + * This agent sits between the WindowsTerminal class and provides a common interface for both conpty + * and winpty. + */ +var WindowsPtyAgent = /** @class */ (function () { + function WindowsPtyAgent(file, args, env, cwd, cols, rows, debug, _useConpty, _useConptyDll, conptyInheritCursor) { + var _this = this; + if (_useConptyDll === void 0) { _useConptyDll = false; } + if (conptyInheritCursor === void 0) { conptyInheritCursor = false; } + this._useConpty = _useConpty; + this._useConptyDll = _useConptyDll; + this._pid = 0; + this._innerPid = 0; + if (this._useConpty === undefined || this._useConpty === true) { + this._useConpty = this._getWindowsBuildNumber() >= 18309; + } + if (this._useConpty) { + if (!conptyNative) { + conptyNative = utils_1.loadNativeModule('conpty').module; + } + } + else { + if (!winptyNative) { + winptyNative = utils_1.loadNativeModule('pty').module; + } + } + this._ptyNative = this._useConpty ? conptyNative : winptyNative; + // Sanitize input variable. + cwd = path.resolve(cwd); + // Compose command line + var commandLine = argsToCommandLine(file, args); + // Open pty session. + var term; + if (this._useConpty) { + term = this._ptyNative.startProcess(file, cols, rows, debug, this._generatePipeName(), conptyInheritCursor, this._useConptyDll); + } + else { + term = this._ptyNative.startProcess(file, commandLine, env, cwd, cols, rows, debug); + this._pid = term.pid; + this._innerPid = term.innerPid; + } + // Not available on windows. + this._fd = term.fd; + // Generated incremental number that has no real purpose besides using it + // as a terminal id. + this._pty = term.pty; + // Create terminal pipe IPC channel and forward to a local unix socket. + this._outSocket = new net_1.Socket(); + this._outSocket.setEncoding('utf8'); + // The conout socket must be ready out on another thread to avoid deadlocks + this._conoutSocketWorker = new windowsConoutConnection_1.ConoutConnection(term.conout, this._useConptyDll); + this._conoutSocketWorker.onReady(function () { + _this._conoutSocketWorker.connectSocket(_this._outSocket); + }); + this._outSocket.on('connect', function () { + _this._outSocket.emit('ready_datapipe'); + }); + var inSocketFD = fs.openSync(term.conin, 'w'); + this._inSocket = new net_1.Socket({ + fd: inSocketFD, + readable: false, + writable: true + }); + this._inSocket.setEncoding('utf8'); + if (this._useConpty) { + var connect = this._ptyNative.connect(this._pty, commandLine, cwd, env, this._useConptyDll, function (c) { return _this._$onProcessExit(c); }); + this._innerPid = connect.pid; + } + } + Object.defineProperty(WindowsPtyAgent.prototype, "inSocket", { + get: function () { return this._inSocket; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsPtyAgent.prototype, "outSocket", { + get: function () { return this._outSocket; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsPtyAgent.prototype, "fd", { + get: function () { return this._fd; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsPtyAgent.prototype, "innerPid", { + get: function () { return this._innerPid; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsPtyAgent.prototype, "pty", { + get: function () { return this._pty; }, + enumerable: false, + configurable: true + }); + WindowsPtyAgent.prototype.resize = function (cols, rows) { + if (this._useConpty) { + if (this._exitCode !== undefined) { + throw new Error('Cannot resize a pty that has already exited'); + } + this._ptyNative.resize(this._pty, cols, rows, this._useConptyDll); + return; + } + this._ptyNative.resize(this._pid, cols, rows); + }; + WindowsPtyAgent.prototype.clear = function () { + if (this._useConpty) { + this._ptyNative.clear(this._pty, this._useConptyDll); + } + }; + WindowsPtyAgent.prototype.kill = function () { + var _this = this; + // Tell the agent to kill the pty, this releases handles to the process + if (this._useConpty) { + if (!this._useConptyDll) { + this._inSocket.readable = false; + this._outSocket.readable = false; + this._getConsoleProcessList().then(function (consoleProcessList) { + consoleProcessList.forEach(function (pid) { + try { + process.kill(pid); + } + catch (e) { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + }); + this._ptyNative.kill(this._pty, this._useConptyDll); + this._conoutSocketWorker.dispose(); + } + else { + // Close the input write handle to signal the end of session. + this._inSocket.destroy(); + this._ptyNative.kill(this._pty, this._useConptyDll); + this._outSocket.on('data', function () { + _this._conoutSocketWorker.dispose(); + }); + } + } + else { + // Because pty.kill closes the handle, it will kill most processes by itself. + // Process IDs can be reused as soon as all handles to them are + // dropped, so we want to immediately kill the entire console process list. + // If we do not force kill all processes here, node servers in particular + // seem to become detached and remain running (see + // Microsoft/vscode#26807). + var processList = this._ptyNative.getProcessList(this._pid); + this._ptyNative.kill(this._pid, this._innerPid); + processList.forEach(function (pid) { + try { + process.kill(pid); + } + catch (e) { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + } + }; + WindowsPtyAgent.prototype._getConsoleProcessList = function () { + var _this = this; + return new Promise(function (resolve) { + var agent = child_process_1.fork(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()]); + agent.on('message', function (message) { + clearTimeout(timeout); + resolve(message.consoleProcessList); + }); + var timeout = setTimeout(function () { + // Something went wrong, just send back the shell PID + agent.kill(); + resolve([_this._innerPid]); + }, 5000); + }); + }; + Object.defineProperty(WindowsPtyAgent.prototype, "exitCode", { + get: function () { + if (this._useConpty) { + return this._exitCode; + } + var winptyExitCode = this._ptyNative.getExitCode(this._innerPid); + return winptyExitCode === -1 ? undefined : winptyExitCode; + }, + enumerable: false, + configurable: true + }); + WindowsPtyAgent.prototype._getWindowsBuildNumber = function () { + var osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release()); + var buildNumber = 0; + if (osVersion && osVersion.length === 4) { + buildNumber = parseInt(osVersion[3]); + } + return buildNumber; + }; + WindowsPtyAgent.prototype._generatePipeName = function () { + return "conpty-" + Math.random() * 10000000; + }; + /** + * Triggered from the native side when a contpy process exits. + */ + WindowsPtyAgent.prototype._$onProcessExit = function (exitCode) { + var _this = this; + this._exitCode = exitCode; + if (!this._useConptyDll) { + this._flushDataAndCleanUp(); + this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); }); + } + }; + WindowsPtyAgent.prototype._flushDataAndCleanUp = function () { + var _this = this; + if (this._useConptyDll) { + return; + } + if (this._closeTimeout) { + clearTimeout(this._closeTimeout); + } + this._closeTimeout = setTimeout(function () { return _this._cleanUpProcess(); }, FLUSH_DATA_INTERVAL); + }; + WindowsPtyAgent.prototype._cleanUpProcess = function () { + if (this._useConptyDll) { + return; + } + this._inSocket.readable = false; + this._outSocket.readable = false; + this._outSocket.destroy(); + }; + return WindowsPtyAgent; +}()); +exports.WindowsPtyAgent = WindowsPtyAgent; +// Convert argc/argv into a Win32 command-line following the escaping convention +// documented on MSDN (e.g. see CommandLineToArgvW documentation). Copied from +// winpty project. +function argsToCommandLine(file, args) { + if (isCommandLine(args)) { + if (args.length === 0) { + return file; + } + return argsToCommandLine(file, []) + " " + args; + } + var argv = [file]; + Array.prototype.push.apply(argv, args); + var result = ''; + for (var argIndex = 0; argIndex < argv.length; argIndex++) { + if (argIndex > 0) { + result += ' '; + } + var arg = argv[argIndex]; + // if it is empty or it contains whitespace and is not already quoted + var hasLopsidedEnclosingQuote = xOr((arg[0] !== '"'), (arg[arg.length - 1] !== '"')); + var hasNoEnclosingQuotes = ((arg[0] !== '"') && (arg[arg.length - 1] !== '"')); + var quote = arg === '' || + (arg.indexOf(' ') !== -1 || + arg.indexOf('\t') !== -1) && + ((arg.length > 1) && + (hasLopsidedEnclosingQuote || hasNoEnclosingQuotes)); + if (quote) { + result += '\"'; + } + var bsCount = 0; + for (var i = 0; i < arg.length; i++) { + var p = arg[i]; + if (p === '\\') { + bsCount++; + } + else if (p === '"') { + result += repeatText('\\', bsCount * 2 + 1); + result += '"'; + bsCount = 0; + } + else { + result += repeatText('\\', bsCount); + bsCount = 0; + result += p; + } + } + if (quote) { + result += repeatText('\\', bsCount * 2); + result += '\"'; + } + else { + result += repeatText('\\', bsCount); + } + } + return result; +} +exports.argsToCommandLine = argsToCommandLine; +function isCommandLine(args) { + return typeof args === 'string'; +} +function repeatText(text, count) { + var result = ''; + for (var i = 0; i < count; i++) { + result += text; + } + return result; +} +function xOr(arg1, arg2) { + return ((arg1 && !arg2) || (!arg1 && arg2)); +} +//# sourceMappingURL=windowsPtyAgent.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.js.map b/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.js.map new file mode 100644 index 00000000..990b2cf4 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.js.map @@ -0,0 +1 @@ +{"version":3,"file":"windowsPtyAgent.js","sourceRoot":"","sources":["../src/windowsPtyAgent.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;;AAEH,uBAAyB;AACzB,uBAAyB;AACzB,2BAA6B;AAC7B,+CAAqC;AACrC,2BAA6B;AAE7B,qEAA6D;AAC7D,iCAA2C;AAE3C,IAAI,YAA2B,CAAC;AAChC,IAAI,YAA2B,CAAC;AAEhC;;;;GAIG;AACH,IAAM,mBAAmB,GAAG,IAAI,CAAC;AAEjC;;;GAGG;AACH;IAmBE,yBACE,IAAY,EACZ,IAAuB,EACvB,GAAa,EACb,GAAW,EACX,IAAY,EACZ,IAAY,EACZ,KAAc,EACN,UAA+B,EAC/B,aAA8B,EACtC,mBAAoC;QAVtC,iBAyEC;QAhES,8BAAA,EAAA,qBAA8B;QACtC,oCAAA,EAAA,2BAAoC;QAF5B,eAAU,GAAV,UAAU,CAAqB;QAC/B,kBAAa,GAAb,aAAa,CAAiB;QAzBhC,SAAI,GAAW,CAAC,CAAC;QACjB,cAAS,GAAW,CAAC,CAAC;QA2B5B,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS,IAAI,IAAI,CAAC,UAAU,KAAK,IAAI,EAAE;YAC7D,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,sBAAsB,EAAE,IAAI,KAAK,CAAC;SAC1D;QACD,IAAI,IAAI,CAAC,UAAU,EAAE;YACnB,IAAI,CAAC,YAAY,EAAE;gBACjB,YAAY,GAAG,wBAAgB,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC;aAClD;SACF;aAAM;YACL,IAAI,CAAC,YAAY,EAAE;gBACjB,YAAY,GAAG,wBAAgB,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC;aAC/C;SACF;QACD,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,YAAY,CAAC;QAEhE,2BAA2B;QAC3B,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAExB,uBAAuB;QACvB,IAAM,WAAW,GAAG,iBAAiB,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAElD,oBAAoB;QACpB,IAAI,IAAqC,CAAC;QAC1C,IAAI,IAAI,CAAC,UAAU,EAAE;YACnB,IAAI,GAAI,IAAI,CAAC,UAA4B,CAAC,YAAY,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,iBAAiB,EAAE,EAAE,mBAAmB,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;SACpJ;aAAM;YACL,IAAI,GAAI,IAAI,CAAC,UAA4B,CAAC,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;YACvG,IAAI,CAAC,IAAI,GAAI,IAAuB,CAAC,GAAG,CAAC;YACzC,IAAI,CAAC,SAAS,GAAI,IAAuB,CAAC,QAAQ,CAAC;SACpD;QAED,4BAA4B;QAC5B,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,CAAC;QAEnB,0EAA0E;QAC1E,oBAAoB;QACpB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC;QAErB,uEAAuE;QACvE,IAAI,CAAC,UAAU,GAAG,IAAI,YAAM,EAAE,CAAC;QAC/B,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QACpC,2EAA2E;QAC3E,IAAI,CAAC,mBAAmB,GAAG,IAAI,0CAAgB,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;QACjF,IAAI,CAAC,mBAAmB,CAAC,OAAO,CAAC;YAC/B,KAAI,CAAC,mBAAmB,CAAC,aAAa,CAAC,KAAI,CAAC,UAAU,CAAC,CAAC;QAC1D,CAAC,CAAC,CAAC;QACH,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE;YAC5B,KAAI,CAAC,UAAU,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;QACzC,CAAC,CAAC,CAAC;QAEH,IAAM,UAAU,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAChD,IAAI,CAAC,SAAS,GAAG,IAAI,YAAM,CAAC;YAC1B,EAAE,EAAE,UAAU;YACd,QAAQ,EAAE,KAAK;YACf,QAAQ,EAAE,IAAI;SACf,CAAC,CAAC;QACH,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAEnC,IAAI,IAAI,CAAC,UAAU,EAAE;YACnB,IAAM,OAAO,GAAI,IAAI,CAAC,UAA4B,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,aAAa,EAAE,UAAA,CAAC,IAAI,OAAA,KAAI,CAAC,eAAe,CAAC,CAAC,CAAC,EAAvB,CAAuB,CAAC,CAAC;YAC/I,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC;SAC9B;IACH,CAAC;IA/ED,sBAAW,qCAAQ;aAAnB,cAAgC,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC;;;OAAA;IACxD,sBAAW,sCAAS;aAApB,cAAiC,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;;;OAAA;IAC1D,sBAAW,+BAAE;aAAb,cAAuB,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;;;OAAA;IACzC,sBAAW,qCAAQ;aAAnB,cAAgC,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC;;;OAAA;IACxD,sBAAW,gCAAG;aAAd,cAA2B,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;;;OAAA;IA6EvC,gCAAM,GAAb,UAAc,IAAY,EAAE,IAAY;QACtC,IAAI,IAAI,CAAC,UAAU,EAAE;YACnB,IAAI,IAAI,CAAC,SAAS,KAAK,SAAS,EAAE;gBAChC,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;aAChE;YACA,IAAI,CAAC,UAA4B,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;YACrF,OAAO;SACR;QACA,IAAI,CAAC,UAA4B,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;IACnE,CAAC;IAEM,+BAAK,GAAZ;QACE,IAAI,IAAI,CAAC,UAAU,EAAE;YAClB,IAAI,CAAC,UAA4B,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;SACzE;IACH,CAAC;IAEM,8BAAI,GAAX;QAAA,iBA0CC;QAzCC,uEAAuE;QACvE,IAAI,IAAI,CAAC,UAAU,EAAE;YACnB,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE;gBACvB,IAAI,CAAC,SAAS,CAAC,QAAQ,GAAG,KAAK,CAAC;gBAChC,IAAI,CAAC,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC;gBACjC,IAAI,CAAC,sBAAsB,EAAE,CAAC,IAAI,CAAC,UAAA,kBAAkB;oBACnD,kBAAkB,CAAC,OAAO,CAAC,UAAC,GAAW;wBACrC,IAAI;4BACF,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;yBACnB;wBAAC,OAAO,CAAC,EAAE;4BACV,uDAAuD;yBACxD;oBACH,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;gBACF,IAAI,CAAC,UAA4B,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;gBACvE,IAAI,CAAC,mBAAmB,CAAC,OAAO,EAAE,CAAC;aACpC;iBAAM;gBACL,6DAA6D;gBAC7D,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC;gBACxB,IAAI,CAAC,UAA4B,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;gBACvE,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE;oBACzB,KAAI,CAAC,mBAAmB,CAAC,OAAO,EAAE,CAAC;gBACrC,CAAC,CAAC,CAAC;aACJ;SACF;aAAM;YACL,6EAA6E;YAC7E,+DAA+D;YAC/D,2EAA2E;YAC3E,yEAAyE;YACzE,kDAAkD;YAClD,2BAA2B;YAC3B,IAAM,WAAW,GAAc,IAAI,CAAC,UAA4B,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAC1F,IAAI,CAAC,UAA4B,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;YACnE,WAAW,CAAC,OAAO,CAAC,UAAA,GAAG;gBACrB,IAAI;oBACF,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;iBACnB;gBAAC,OAAO,CAAC,EAAE;oBACV,uDAAuD;iBACxD;YACH,CAAC,CAAC,CAAC;SACJ;IACH,CAAC;IAEO,gDAAsB,GAA9B;QAAA,iBAaC;QAZC,OAAO,IAAI,OAAO,CAAW,UAAA,OAAO;YAClC,IAAM,KAAK,GAAG,oBAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,2BAA2B,CAAC,EAAE,CAAE,KAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAE,CAAC,CAAC;YACrG,KAAK,CAAC,EAAE,CAAC,SAAS,EAAE,UAAA,OAAO;gBACzB,YAAY,CAAC,OAAO,CAAC,CAAC;gBACtB,OAAO,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC;YACtC,CAAC,CAAC,CAAC;YACH,IAAM,OAAO,GAAG,UAAU,CAAC;gBACzB,qDAAqD;gBACrD,KAAK,CAAC,IAAI,EAAE,CAAC;gBACb,OAAO,CAAC,CAAE,KAAI,CAAC,SAAS,CAAE,CAAC,CAAC;YAC9B,CAAC,EAAE,IAAI,CAAC,CAAC;QACX,CAAC,CAAC,CAAC;IACL,CAAC;IAED,sBAAW,qCAAQ;aAAnB;YACE,IAAI,IAAI,CAAC,UAAU,EAAE;gBACnB,OAAO,IAAI,CAAC,SAAS,CAAC;aACvB;YACD,IAAM,cAAc,GAAI,IAAI,CAAC,UAA4B,CAAC,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YACtF,OAAO,cAAc,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,cAAc,CAAC;QAC5D,CAAC;;;OAAA;IAEO,gDAAsB,GAA9B;QACE,IAAM,SAAS,GAAG,CAAC,sBAAsB,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC;QAC9D,IAAI,WAAW,GAAW,CAAC,CAAC;QAC5B,IAAI,SAAS,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE;YACvC,WAAW,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;SACtC;QACD,OAAO,WAAW,CAAC;IACrB,CAAC;IAEO,2CAAiB,GAAzB;QACE,OAAO,YAAU,IAAI,CAAC,MAAM,EAAE,GAAG,QAAU,CAAC;IAC9C,CAAC;IAED;;OAEG;IACK,yCAAe,GAAvB,UAAwB,QAAgB;QAAxC,iBAMC;QALC,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC;QAC1B,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE;YACvB,IAAI,CAAC,oBAAoB,EAAE,CAAC;YAC5B,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,cAAM,OAAA,KAAI,CAAC,oBAAoB,EAAE,EAA3B,CAA2B,CAAC,CAAC;SAC/D;IACH,CAAC;IAEO,8CAAoB,GAA5B;QAAA,iBAQC;QAPC,IAAI,IAAI,CAAC,aAAa,EAAE;YACtB,OAAO;SACR;QACD,IAAI,IAAI,CAAC,aAAa,EAAE;YACtB,YAAY,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;SAClC;QACD,IAAI,CAAC,aAAa,GAAG,UAAU,CAAC,cAAM,OAAA,KAAI,CAAC,eAAe,EAAE,EAAtB,CAAsB,EAAE,mBAAmB,CAAC,CAAC;IACrF,CAAC;IAEO,yCAAe,GAAvB;QACE,IAAI,IAAI,CAAC,aAAa,EAAE;YACtB,OAAO;SACR;QACD,IAAI,CAAC,SAAS,CAAC,QAAQ,GAAG,KAAK,CAAC;QAChC,IAAI,CAAC,UAAU,CAAC,QAAQ,GAAG,KAAK,CAAC;QACjC,IAAI,CAAC,UAAU,CAAC,OAAO,EAAE,CAAC;IAC5B,CAAC;IACH,sBAAC;AAAD,CAAC,AA5ND,IA4NC;AA5NY,0CAAe;AA8N5B,gFAAgF;AAChF,8EAA8E;AAC9E,kBAAkB;AAClB,SAAgB,iBAAiB,CAAC,IAAY,EAAE,IAAuB;IACrE,IAAI,aAAa,CAAC,IAAI,CAAC,EAAE;QACvB,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE;YACrB,OAAO,IAAI,CAAC;SACb;QACD,OAAU,iBAAiB,CAAC,IAAI,EAAE,EAAE,CAAC,SAAI,IAAM,CAAC;KACjD;IACD,IAAM,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC;IACpB,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACvC,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,KAAK,IAAI,QAAQ,GAAG,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE;QACzD,IAAI,QAAQ,GAAG,CAAC,EAAE;YAChB,MAAM,IAAI,GAAG,CAAC;SACf;QACD,IAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC3B,qEAAqE;QACrE,IAAM,yBAAyB,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;QACvF,IAAM,oBAAoB,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;QACjF,IAAM,KAAK,GACT,GAAG,KAAK,EAAE;YACV,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;gBACxB,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;gBACzB,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC;oBACjB,CAAC,yBAAyB,IAAI,oBAAoB,CAAC,CAAC,CAAC;QACvD,IAAI,KAAK,EAAE;YACT,MAAM,IAAI,IAAI,CAAC;SAChB;QACD,IAAI,OAAO,GAAG,CAAC,CAAC;QAChB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;YACnC,IAAM,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;YACjB,IAAI,CAAC,KAAK,IAAI,EAAE;gBACd,OAAO,EAAE,CAAC;aACX;iBAAM,IAAI,CAAC,KAAK,GAAG,EAAE;gBACpB,MAAM,IAAI,UAAU,CAAC,IAAI,EAAE,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;gBAC5C,MAAM,IAAI,GAAG,CAAC;gBACd,OAAO,GAAG,CAAC,CAAC;aACb;iBAAM;gBACL,MAAM,IAAI,UAAU,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;gBACpC,OAAO,GAAG,CAAC,CAAC;gBACZ,MAAM,IAAI,CAAC,CAAC;aACb;SACF;QACD,IAAI,KAAK,EAAE;YACT,MAAM,IAAI,UAAU,CAAC,IAAI,EAAE,OAAO,GAAG,CAAC,CAAC,CAAC;YACxC,MAAM,IAAI,IAAI,CAAC;SAChB;aAAM;YACL,MAAM,IAAI,UAAU,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;SACrC;KACF;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAlDD,8CAkDC;AAED,SAAS,aAAa,CAAC,IAAuB;IAC5C,OAAO,OAAO,IAAI,KAAK,QAAQ,CAAC;AAClC,CAAC;AAED,SAAS,UAAU,CAAC,IAAY,EAAE,KAAa;IAC7C,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,EAAE,EAAE;QAC9B,MAAM,IAAI,IAAI,CAAC;KAChB;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,GAAG,CAAC,IAAa,EAAE,IAAa;IACvC,OAAO,CAAC,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC;AAC9C,CAAC"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.test.js b/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.test.js new file mode 100644 index 00000000..15bbf5ba --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.test.js @@ -0,0 +1,90 @@ +"use strict"; +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +var assert = require("assert"); +var windowsPtyAgent_1 = require("./windowsPtyAgent"); +function check(file, args, expected) { + assert.equal(windowsPtyAgent_1.argsToCommandLine(file, args), expected); +} +if (process.platform === 'win32') { + describe('argsToCommandLine', function () { + describe('Plain strings', function () { + it('doesn\'t quote plain string', function () { + check('asdf', [], 'asdf'); + }); + it('doesn\'t escape backslashes', function () { + check('\\asdf\\qwer\\', [], '\\asdf\\qwer\\'); + }); + it('doesn\'t escape multiple backslashes', function () { + check('asdf\\\\qwer', [], 'asdf\\\\qwer'); + }); + it('adds backslashes before quotes', function () { + check('"asdf"qwer"', [], '\\"asdf\\"qwer\\"'); + }); + it('escapes backslashes before quotes', function () { + check('asdf\\"qwer', [], 'asdf\\\\\\"qwer'); + }); + }); + describe('Quoted strings', function () { + it('quotes string with spaces', function () { + check('asdf qwer', [], '"asdf qwer"'); + }); + it('quotes empty string', function () { + check('', [], '""'); + }); + it('quotes string with tabs', function () { + check('asdf\tqwer', [], '"asdf\tqwer"'); + }); + it('escapes only the last backslash', function () { + check('\\asdf \\qwer\\', [], '"\\asdf \\qwer\\\\"'); + }); + it('doesn\'t escape multiple backslashes', function () { + check('asdf \\\\qwer', [], '"asdf \\\\qwer"'); + }); + it('escapes backslashes before quotes', function () { + check('asdf \\"qwer', [], '"asdf \\\\\\"qwer"'); + }); + it('escapes multiple backslashes at the end', function () { + check('asdf qwer\\\\', [], '"asdf qwer\\\\\\\\"'); + }); + }); + describe('Multiple arguments', function () { + it('joins arguments with spaces', function () { + check('asdf', ['qwer zxcv', '', '"'], 'asdf "qwer zxcv" "" \\"'); + }); + it('array argument all in quotes', function () { + check('asdf', ['"surounded by quotes"'], 'asdf \\"surounded by quotes\\"'); + }); + it('array argument quotes in the middle', function () { + check('asdf', ['quotes "in the" middle'], 'asdf "quotes \\"in the\\" middle"'); + }); + it('array argument quotes near start', function () { + check('asdf', ['"quotes" near start'], 'asdf "\\"quotes\\" near start"'); + }); + it('array argument quotes near end', function () { + check('asdf', ['quotes "near end"'], 'asdf "quotes \\"near end\\""'); + }); + }); + describe('Args as CommandLine', function () { + it('should handle empty string', function () { + check('file', '', 'file'); + }); + it('should not change args', function () { + check('file', 'foo bar baz', 'file foo bar baz'); + check('file', 'foo \\ba"r \baz', 'file foo \\ba"r \baz'); + }); + }); + describe('Real-world cases', function () { + it('quotes within quotes', function () { + check('cmd.exe', ['/c', 'powershell -noexit -command \'Set-location \"C:\\user\"\''], 'cmd.exe /c "powershell -noexit -command \'Set-location \\\"C:\\user\\"\'"'); + }); + it('space within quotes', function () { + check('cmd.exe', ['/k', '"C:\\Users\\alros\\Desktop\\test script.bat"'], 'cmd.exe /k \\"C:\\Users\\alros\\Desktop\\test script.bat\\"'); + }); + }); + }); +} +//# sourceMappingURL=windowsPtyAgent.test.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.test.js.map b/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.test.js.map new file mode 100644 index 00000000..f92251ad --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsPtyAgent.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"windowsPtyAgent.test.js","sourceRoot":"","sources":["../src/windowsPtyAgent.test.ts"],"names":[],"mappings":";AAAA;;;GAGG;;AAEH,+BAAiC;AACjC,qDAAsD;AAEtD,SAAS,KAAK,CAAC,IAAY,EAAE,IAAuB,EAAE,QAAgB;IACpE,MAAM,CAAC,KAAK,CAAC,mCAAiB,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC,CAAC;AACxD,CAAC;AAED,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE;IAChC,QAAQ,CAAC,mBAAmB,EAAE;QAC5B,QAAQ,CAAC,eAAe,EAAE;YACxB,EAAE,CAAC,6BAA6B,EAAE;gBAChC,KAAK,CAAC,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,CAAC;YAC5B,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,6BAA6B,EAAE;gBAChC,KAAK,CAAC,gBAAgB,EAAE,EAAE,EAAE,gBAAgB,CAAC,CAAC;YAChD,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,sCAAsC,EAAE;gBACzC,KAAK,CAAC,cAAc,EAAE,EAAE,EAAE,cAAc,CAAC,CAAC;YAC5C,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,gCAAgC,EAAE;gBACnC,KAAK,CAAC,aAAa,EAAE,EAAE,EAAE,mBAAmB,CAAC,CAAC;YAChD,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,mCAAmC,EAAE;gBACtC,KAAK,CAAC,aAAa,EAAE,EAAE,EAAE,iBAAiB,CAAC,CAAC;YAC9C,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,QAAQ,CAAC,gBAAgB,EAAE;YACzB,EAAE,CAAC,2BAA2B,EAAE;gBAC9B,KAAK,CAAC,WAAW,EAAE,EAAE,EAAE,aAAa,CAAC,CAAC;YACxC,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,qBAAqB,EAAE;gBACxB,KAAK,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,CAAC;YACtB,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,yBAAyB,EAAE;gBAC5B,KAAK,CAAC,YAAY,EAAE,EAAE,EAAE,cAAc,CAAC,CAAC;YAC1C,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,iCAAiC,EAAE;gBACpC,KAAK,CAAC,iBAAiB,EAAE,EAAE,EAAE,qBAAqB,CAAC,CAAC;YACtD,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,sCAAsC,EAAE;gBACzC,KAAK,CAAC,eAAe,EAAE,EAAE,EAAE,iBAAiB,CAAC,CAAC;YAChD,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,mCAAmC,EAAE;gBACtC,KAAK,CAAC,cAAc,EAAE,EAAE,EAAE,oBAAoB,CAAC,CAAC;YAClD,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,yCAAyC,EAAE;gBAC5C,KAAK,CAAC,eAAe,EAAE,EAAE,EAAE,qBAAqB,CAAC,CAAC;YACpD,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,QAAQ,CAAC,oBAAoB,EAAE;YAC7B,EAAE,CAAC,6BAA6B,EAAE;gBAChC,KAAK,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,yBAAyB,CAAC,CAAC;YACnE,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,8BAA8B,EAAE;gBACjC,KAAK,CAAC,MAAM,EAAE,CAAC,uBAAuB,CAAC,EAAE,gCAAgC,CAAC,CAAC;YAC7E,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,qCAAqC,EAAE;gBACxC,KAAK,CAAC,MAAM,EAAE,CAAC,wBAAwB,CAAC,EAAE,mCAAmC,CAAC,CAAC;YACjF,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,kCAAkC,EAAE;gBACrC,KAAK,CAAC,MAAM,EAAE,CAAC,qBAAqB,CAAC,EAAE,gCAAgC,CAAC,CAAC;YAC3E,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,gCAAgC,EAAE;gBACnC,KAAK,CAAC,MAAM,EAAE,CAAC,mBAAmB,CAAC,EAAE,8BAA8B,CAAC,CAAC;YACvE,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,QAAQ,CAAC,qBAAqB,EAAE;YAC9B,EAAE,CAAC,4BAA4B,EAAE;gBAC/B,KAAK,CAAC,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,CAAC;YAC5B,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,wBAAwB,EAAE;gBAC3B,KAAK,CAAC,MAAM,EAAE,aAAa,EAAE,kBAAkB,CAAC,CAAC;gBACjD,KAAK,CAAC,MAAM,EAAE,iBAAiB,EAAE,sBAAsB,CAAC,CAAC;YAC3D,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,QAAQ,CAAC,kBAAkB,EAAE;YAC3B,EAAE,CAAC,sBAAsB,EAAE;gBACzB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,2DAA2D,CAAC,EAAE,2EAA2E,CAAC,CAAC;YACrK,CAAC,CAAC,CAAC;YACH,EAAE,CAAC,qBAAqB,EAAE;gBACxB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,8CAA8C,CAAC,EAAE,6DAA6D,CAAC,CAAC;YAC1I,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;CACJ"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.js b/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.js new file mode 100644 index 00000000..3c38f89d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.js @@ -0,0 +1,199 @@ +"use strict"; +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WindowsTerminal = void 0; +var terminal_1 = require("./terminal"); +var windowsPtyAgent_1 = require("./windowsPtyAgent"); +var utils_1 = require("./utils"); +var DEFAULT_FILE = 'cmd.exe'; +var DEFAULT_NAME = 'Windows Shell'; +var WindowsTerminal = /** @class */ (function (_super) { + __extends(WindowsTerminal, _super); + function WindowsTerminal(file, args, opt) { + var _this = _super.call(this, opt) || this; + _this._checkType('args', args, 'string', true); + // Initialize arguments + args = args || []; + file = file || DEFAULT_FILE; + opt = opt || {}; + opt.env = opt.env || process.env; + if (opt.encoding) { + console.warn('Setting encoding on Windows is not supported'); + } + var env = utils_1.assign({}, opt.env); + _this._cols = opt.cols || terminal_1.DEFAULT_COLS; + _this._rows = opt.rows || terminal_1.DEFAULT_ROWS; + var cwd = opt.cwd || process.cwd(); + var name = opt.name || env.TERM || DEFAULT_NAME; + var parsedEnv = _this._parseEnv(env); + // If the terminal is ready + _this._isReady = false; + // Functions that need to run after `ready` event is emitted. + _this._deferreds = []; + // Create new termal. + _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); + _this._socket = _this._agent.outSocket; + // Not available until `ready` event emitted. + _this._pid = _this._agent.innerPid; + _this._fd = _this._agent.fd; + _this._pty = _this._agent.pty; + // The forked windows terminal is not available until `ready` event is + // emitted. + _this._socket.on('ready_datapipe', function () { + // Run deferreds and set ready state once the first data event is received. + _this._socket.once('data', function () { + // Wait until the first data event is fired then we can run deferreds. + if (!_this._isReady) { + // Terminal is now ready and we can avoid having to defer method + // calls. + _this._isReady = true; + // Execute all deferred methods + _this._deferreds.forEach(function (fn) { + // NB! In order to ensure that `this` has all its references + // updated any variable that need to be available in `this` before + // the deferred is run has to be declared above this forEach + // statement. + fn.run(); + }); + // Reset + _this._deferreds = []; + } + }); + // Shutdown if `error` event is emitted. + _this._socket.on('error', function (err) { + // Close terminal session. + _this._close(); + // EIO, happens when someone closes our child process: the only process + // in the terminal. + // node < 0.6.14: errno 5 + // node >= 0.6.14: read EIO + if (err.code) { + if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) + return; + } + // Throw anything else. + if (_this.listeners('error').length < 2) { + throw err; + } + }); + // Cleanup after the socket is closed. + _this._socket.on('close', function () { + _this.emit('exit', _this._agent.exitCode); + _this._close(); + }); + }); + _this._file = file; + _this._name = name; + _this._readable = true; + _this._writable = true; + _this._forwardEvents(); + return _this; + } + WindowsTerminal.prototype._write = function (data) { + this._defer(this._doWrite, data); + }; + WindowsTerminal.prototype._doWrite = function (data) { + this._agent.inSocket.write(data); + }; + /** + * openpty + */ + WindowsTerminal.open = function (options) { + throw new Error('open() not supported on windows, use Fork() instead.'); + }; + /** + * TTY + */ + WindowsTerminal.prototype.resize = function (cols, rows) { + var _this = this; + if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { + throw new Error('resizing must be done using positive cols and rows'); + } + this._deferNoArgs(function () { + _this._agent.resize(cols, rows); + _this._cols = cols; + _this._rows = rows; + }); + }; + WindowsTerminal.prototype.clear = function () { + var _this = this; + this._deferNoArgs(function () { + _this._agent.clear(); + }); + }; + WindowsTerminal.prototype.destroy = function () { + var _this = this; + this._deferNoArgs(function () { + _this.kill(); + }); + }; + WindowsTerminal.prototype.kill = function (signal) { + var _this = this; + this._deferNoArgs(function () { + if (signal) { + throw new Error('Signals not supported on windows.'); + } + _this._close(); + _this._agent.kill(); + }); + }; + WindowsTerminal.prototype._deferNoArgs = function (deferredFn) { + var _this = this; + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this); + return; + } + // Queue until terminal is ready. + this._deferreds.push({ + run: function () { return deferredFn.call(_this); } + }); + }; + WindowsTerminal.prototype._defer = function (deferredFn, arg) { + var _this = this; + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this, arg); + return; + } + // Queue until terminal is ready. + this._deferreds.push({ + run: function () { return deferredFn.call(_this, arg); } + }); + }; + Object.defineProperty(WindowsTerminal.prototype, "process", { + get: function () { return this._name; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsTerminal.prototype, "master", { + get: function () { throw new Error('master is not supported on Windows'); }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsTerminal.prototype, "slave", { + get: function () { throw new Error('slave is not supported on Windows'); }, + enumerable: false, + configurable: true + }); + return WindowsTerminal; +}(terminal_1.Terminal)); +exports.WindowsTerminal = WindowsTerminal; +//# sourceMappingURL=windowsTerminal.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.js.map b/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.js.map new file mode 100644 index 00000000..6ed255e8 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.js.map @@ -0,0 +1 @@ +{"version":3,"file":"windowsTerminal.js","sourceRoot":"","sources":["../src/windowsTerminal.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;;;;;;;;;;;;;;;AAGH,uCAAkE;AAClE,qDAAoD;AAGpD,iCAAiC;AAEjC,IAAM,YAAY,GAAG,SAAS,CAAC;AAC/B,IAAM,YAAY,GAAG,eAAe,CAAC;AAErC;IAAqC,mCAAQ;IAK3C,yBAAY,IAAa,EAAE,IAAwB,EAAE,GAA4B;QAAjF,YACE,kBAAM,GAAG,CAAC,SAgGX;QA9FC,KAAI,CAAC,UAAU,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC;QAE9C,uBAAuB;QACvB,IAAI,GAAG,IAAI,IAAI,EAAE,CAAC;QAClB,IAAI,GAAG,IAAI,IAAI,YAAY,CAAC;QAC5B,GAAG,GAAG,GAAG,IAAI,EAAE,CAAC;QAChB,GAAG,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC;QAEjC,IAAI,GAAG,CAAC,QAAQ,EAAE;YAChB,OAAO,CAAC,IAAI,CAAC,8CAA8C,CAAC,CAAC;SAC9D;QAED,IAAM,GAAG,GAAG,cAAM,CAAC,EAAE,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QAChC,KAAI,CAAC,KAAK,GAAG,GAAG,CAAC,IAAI,IAAI,uBAAY,CAAC;QACtC,KAAI,CAAC,KAAK,GAAG,GAAG,CAAC,IAAI,IAAI,uBAAY,CAAC;QACtC,IAAM,GAAG,GAAG,GAAG,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;QACrC,IAAM,IAAI,GAAG,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,IAAI,IAAI,YAAY,CAAC;QAClD,IAAM,SAAS,GAAG,KAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAEtC,2BAA2B;QAC3B,KAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;QAEtB,6DAA6D;QAC7D,KAAI,CAAC,UAAU,GAAG,EAAE,CAAC;QAErB,qBAAqB;QACrB,KAAI,CAAC,MAAM,GAAG,IAAI,iCAAe,CAAC,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,KAAI,CAAC,KAAK,EAAE,KAAI,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,YAAY,EAAE,GAAG,CAAC,mBAAmB,CAAC,CAAC;QACvJ,KAAI,CAAC,OAAO,GAAG,KAAI,CAAC,MAAM,CAAC,SAAS,CAAC;QAErC,6CAA6C;QAC7C,KAAI,CAAC,IAAI,GAAG,KAAI,CAAC,MAAM,CAAC,QAAQ,CAAC;QACjC,KAAI,CAAC,GAAG,GAAG,KAAI,CAAC,MAAM,CAAC,EAAE,CAAC;QAC1B,KAAI,CAAC,IAAI,GAAG,KAAI,CAAC,MAAM,CAAC,GAAG,CAAC;QAE5B,sEAAsE;QACtE,WAAW;QACX,KAAI,CAAC,OAAO,CAAC,EAAE,CAAC,gBAAgB,EAAE;YAEhC,2EAA2E;YAC3E,KAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE;gBACxB,sEAAsE;gBACtE,IAAI,CAAC,KAAI,CAAC,QAAQ,EAAE;oBAClB,gEAAgE;oBAChE,SAAS;oBACT,KAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;oBAErB,+BAA+B;oBAC/B,KAAI,CAAC,UAAU,CAAC,OAAO,CAAC,UAAA,EAAE;wBACxB,4DAA4D;wBAC5D,kEAAkE;wBAClE,4DAA4D;wBAC5D,aAAa;wBACb,EAAE,CAAC,GAAG,EAAE,CAAC;oBACX,CAAC,CAAC,CAAC;oBAEH,QAAQ;oBACR,KAAI,CAAC,UAAU,GAAG,EAAE,CAAC;iBACtB;YACH,CAAC,CAAC,CAAC;YAEH,wCAAwC;YACxC,KAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,UAAA,GAAG;gBAC1B,0BAA0B;gBAC1B,KAAI,CAAC,MAAM,EAAE,CAAC;gBAEd,uEAAuE;gBACvE,mBAAmB;gBACnB,yBAAyB;gBACzB,2BAA2B;gBAC3B,IAAU,GAAI,CAAC,IAAI,EAAE;oBACnB,IAAI,CAAO,GAAI,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,IAAI,CAAO,GAAI,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC;wBAAE,OAAO;iBACpF;gBAED,uBAAuB;gBACvB,IAAI,KAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE;oBACtC,MAAM,GAAG,CAAC;iBACX;YACH,CAAC,CAAC,CAAC;YAEH,sCAAsC;YACtC,KAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE;gBACvB,KAAI,CAAC,IAAI,CAAC,MAAM,EAAE,KAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;gBACxC,KAAI,CAAC,MAAM,EAAE,CAAC;YAChB,CAAC,CAAC,CAAC;QAEL,CAAC,CAAC,CAAC;QAEH,KAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QAClB,KAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QAElB,KAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACtB,KAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QAEtB,KAAI,CAAC,cAAc,EAAE,CAAC;;IACxB,CAAC;IAES,gCAAM,GAAhB,UAAiB,IAAqB;QACpC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACnC,CAAC;IAEO,kCAAQ,GAAhB,UAAiB,IAAqB;QACpC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACnC,CAAC;IAED;;OAEG;IAEW,oBAAI,GAAlB,UAAmB,OAAyB;QAC1C,MAAM,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;IAC1E,CAAC;IAED;;OAEG;IAEI,gCAAM,GAAb,UAAc,IAAY,EAAE,IAAY;QAAxC,iBASC;QARC,IAAI,IAAI,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,QAAQ,EAAE;YAClG,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;SACvE;QACD,IAAI,CAAC,YAAY,CAAC;YAChB,KAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YAC/B,KAAI,CAAC,KAAK,GAAG,IAAI,CAAC;YAClB,KAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QACpB,CAAC,CAAC,CAAC;IACL,CAAC;IAEM,+BAAK,GAAZ;QAAA,iBAIC;QAHC,IAAI,CAAC,YAAY,CAAC;YAChB,KAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACtB,CAAC,CAAC,CAAC;IACL,CAAC;IAEM,iCAAO,GAAd;QAAA,iBAIC;QAHC,IAAI,CAAC,YAAY,CAAC;YAChB,KAAI,CAAC,IAAI,EAAE,CAAC;QACd,CAAC,CAAC,CAAC;IACL,CAAC;IAEM,8BAAI,GAAX,UAAY,MAAe;QAA3B,iBAQC;QAPC,IAAI,CAAC,YAAY,CAAC;YAChB,IAAI,MAAM,EAAE;gBACV,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;aACtD;YACD,KAAI,CAAC,MAAM,EAAE,CAAC;YACd,KAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QACrB,CAAC,CAAC,CAAC;IACL,CAAC;IAEO,sCAAY,GAApB,UAAwB,UAAsB;QAA9C,iBAWC;QAVC,qCAAqC;QACrC,IAAI,IAAI,CAAC,QAAQ,EAAE;YACjB,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACtB,OAAO;SACR;QAED,iCAAiC;QACjC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YACnB,GAAG,EAAE,cAAM,OAAA,UAAU,CAAC,IAAI,CAAC,KAAI,CAAC,EAArB,CAAqB;SACjC,CAAC,CAAC;IACL,CAAC;IAEO,gCAAM,GAAd,UAAkB,UAA4B,EAAE,GAAM;QAAtD,iBAWC;QAVC,qCAAqC;QACrC,IAAI,IAAI,CAAC,QAAQ,EAAE;YACjB,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;YAC3B,OAAO;SACR;QAED,iCAAiC;QACjC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YACnB,GAAG,EAAE,cAAM,OAAA,UAAU,CAAC,IAAI,CAAC,KAAI,EAAE,GAAG,CAAC,EAA1B,CAA0B;SACtC,CAAC,CAAC;IACL,CAAC;IAED,sBAAW,oCAAO;aAAlB,cAA+B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;;;OAAA;IACnD,sBAAW,mCAAM;aAAjB,cAA8B,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC,CAAC,CAAC;;;OAAA;IACtF,sBAAW,kCAAK;aAAhB,cAA6B,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC,CAAC,CAAC;;;OAAA;IACtF,sBAAC;AAAD,CAAC,AA1LD,CAAqC,mBAAQ,GA0L5C;AA1LY,0CAAe"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.test.js b/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.test.js new file mode 100644 index 00000000..af5f343d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.test.js @@ -0,0 +1,219 @@ +"use strict"; +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +var fs = require("fs"); +var assert = require("assert"); +var windowsTerminal_1 = require("./windowsTerminal"); +var path = require("path"); +var psList = require("ps-list"); +function pollForProcessState(desiredState, intervalMs, timeoutMs) { + if (intervalMs === void 0) { intervalMs = 100; } + if (timeoutMs === void 0) { timeoutMs = 2000; } + return new Promise(function (resolve) { + var tries = 0; + var interval = setInterval(function () { + psList({ all: true }).then(function (ps) { + var success = true; + var pids = Object.keys(desiredState).map(function (k) { return parseInt(k, 10); }); + console.log('expected pids', JSON.stringify(pids)); + pids.forEach(function (pid) { + if (desiredState[pid]) { + if (!ps.some(function (p) { return p.pid === pid; })) { + console.log("pid " + pid + " does not exist"); + success = false; + } + } + else { + if (ps.some(function (p) { return p.pid === pid; })) { + console.log("pid " + pid + " still exists"); + success = false; + } + } + }); + if (success) { + clearInterval(interval); + resolve(); + return; + } + tries++; + if (tries * intervalMs >= timeoutMs) { + clearInterval(interval); + var processListing = pids.map(function (k) { return k + ": " + desiredState[k]; }).join('\n'); + assert.fail("Bad process state, expected:\n" + processListing); + resolve(); + } + }); + }, intervalMs); + }); +} +function pollForProcessTreeSize(pid, size, intervalMs, timeoutMs) { + if (intervalMs === void 0) { intervalMs = 100; } + if (timeoutMs === void 0) { timeoutMs = 2000; } + return new Promise(function (resolve) { + var tries = 0; + var interval = setInterval(function () { + psList({ all: true }).then(function (ps) { + var openList = []; + openList.push(ps.filter(function (p) { return p.pid === pid; }).map(function (p) { + return { name: p.name, pid: p.pid }; + })[0]); + var list = []; + var _loop_1 = function () { + var current = openList.shift(); + ps.filter(function (p) { return p.ppid === current.pid; }).map(function (p) { + return { name: p.name, pid: p.pid }; + }).forEach(function (p) { return openList.push(p); }); + list.push(current); + }; + while (openList.length) { + _loop_1(); + } + console.log('list', JSON.stringify(list)); + var success = list.length === size; + if (success) { + clearInterval(interval); + resolve(list); + return; + } + tries++; + if (tries * intervalMs >= timeoutMs) { + clearInterval(interval); + assert.fail("Bad process state, expected: " + size + ", actual: " + list.length); + } + }); + }, intervalMs); + }); +} +if (process.platform === 'win32') { + [[false, false], [true, false], [true, true]].forEach(function (_a) { + var useConpty = _a[0], useConptyDll = _a[1]; + describe("WindowsTerminal (useConpty = " + useConpty + ", useConptyDll = " + useConptyDll + ")", function () { + describe('kill', function () { + it('should not crash parent process', function (done) { + this.timeout(20000); + var term = new windowsTerminal_1.WindowsTerminal('cmd.exe', [], { useConpty: useConpty, useConptyDll: useConptyDll }); + term.on('exit', function () { return done(); }); + term.kill(); + }); + it('should kill the process tree', function (done) { + this.timeout(20000); + var term = new windowsTerminal_1.WindowsTerminal('cmd.exe', [], { useConpty: useConpty, useConptyDll: useConptyDll }); + // Start sub-processes + term.write('powershell.exe\r'); + term.write('node.exe\r'); + console.log('start poll for tree size'); + pollForProcessTreeSize(term.pid, 3, 500, 5000).then(function (list) { + assert.strictEqual(list[0].name.toLowerCase(), 'cmd.exe'); + assert.strictEqual(list[1].name.toLowerCase(), 'powershell.exe'); + assert.strictEqual(list[2].name.toLowerCase(), 'node.exe'); + term.kill(); + var desiredState = {}; + desiredState[list[0].pid] = false; + desiredState[list[1].pid] = false; + desiredState[list[2].pid] = false; + term.on('exit', function () { + pollForProcessState(desiredState, 1000, 5000).then(function () { + done(); + }); + }); + }); + }); + }); + describe('resize', function () { + it('should throw a non-native exception when resizing an invalid value', function (done) { + this.timeout(20000); + var term = new windowsTerminal_1.WindowsTerminal('cmd.exe', [], { useConpty: useConpty, useConptyDll: useConptyDll }); + assert.throws(function () { return term.resize(-1, -1); }); + assert.throws(function () { return term.resize(0, 0); }); + assert.doesNotThrow(function () { return term.resize(1, 1); }); + term.on('exit', function () { + done(); + }); + term.kill(); + }); + it('should throw a non-native exception when resizing a killed terminal', function (done) { + this.timeout(20000); + var term = new windowsTerminal_1.WindowsTerminal('cmd.exe', [], { useConpty: useConpty, useConptyDll: useConptyDll }); + term._defer(function () { + term.once('exit', function () { + assert.throws(function () { return term.resize(1, 1); }); + done(); + }); + term.destroy(); + }); + }); + }); + describe('Args as CommandLine', function () { + it('should not fail running a file containing a space in the path', function (done) { + this.timeout(10000); + var spaceFolder = path.resolve(__dirname, '..', 'fixtures', 'space folder'); + if (!fs.existsSync(spaceFolder)) { + fs.mkdirSync(spaceFolder); + } + var cmdCopiedPath = path.resolve(spaceFolder, 'cmd.exe'); + var data = fs.readFileSync(process.env.windir + "\\System32\\cmd.exe"); + fs.writeFileSync(cmdCopiedPath, data); + if (!fs.existsSync(cmdCopiedPath)) { + // Skip test if git bash isn't installed + return; + } + var term = new windowsTerminal_1.WindowsTerminal(cmdCopiedPath, '/c echo "hello world"', { useConpty: useConpty, useConptyDll: useConptyDll }); + var result = ''; + term.on('data', function (data) { + result += data; + }); + term.on('exit', function () { + assert.ok(result.indexOf('hello world') >= 1); + done(); + }); + }); + }); + describe('env', function () { + it('should set environment variables of the shell', function (done) { + this.timeout(10000); + var term = new windowsTerminal_1.WindowsTerminal('cmd.exe', '/C echo %FOO%', { useConpty: useConpty, useConptyDll: useConptyDll, env: { FOO: 'BAR' } }); + var result = ''; + term.on('data', function (data) { + result += data; + }); + term.on('exit', function () { + assert.ok(result.indexOf('BAR') >= 0); + done(); + }); + }); + }); + describe('On close', function () { + it('should return process zero exit codes', function (done) { + this.timeout(10000); + var term = new windowsTerminal_1.WindowsTerminal('cmd.exe', '/C exit', { useConpty: useConpty, useConptyDll: useConptyDll }); + term.on('exit', function (code) { + assert.strictEqual(code, 0); + done(); + }); + }); + it('should return process non-zero exit codes', function (done) { + this.timeout(10000); + var term = new windowsTerminal_1.WindowsTerminal('cmd.exe', '/C exit 2', { useConpty: useConpty, useConptyDll: useConptyDll }); + term.on('exit', function (code) { + assert.strictEqual(code, 2); + done(); + }); + }); + }); + describe('Write', function () { + it('should accept input', function (done) { + this.timeout(10000); + var term = new windowsTerminal_1.WindowsTerminal('cmd.exe', '', { useConpty: useConpty, useConptyDll: useConptyDll }); + term.write('exit\r'); + term.on('exit', function () { + done(); + }); + }); + }); + }); + }); +} +//# sourceMappingURL=windowsTerminal.test.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.test.js.map b/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.test.js.map new file mode 100644 index 00000000..f8b67359 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/windowsTerminal.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"windowsTerminal.test.js","sourceRoot":"","sources":["../src/windowsTerminal.test.ts"],"names":[],"mappings":";AAAA;;;GAGG;;AAEH,uBAAyB;AACzB,+BAAiC;AACjC,qDAAoD;AACpD,2BAA6B;AAC7B,gCAAkC;AAYlC,SAAS,mBAAmB,CAAC,YAA2B,EAAE,UAAwB,EAAE,SAAwB;IAAlD,2BAAA,EAAA,gBAAwB;IAAE,0BAAA,EAAA,gBAAwB;IAC1G,OAAO,IAAI,OAAO,CAAO,UAAA,OAAO;QAC9B,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,IAAM,QAAQ,GAAG,WAAW,CAAC;YAC3B,MAAM,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,UAAA,EAAE;gBAC3B,IAAI,OAAO,GAAG,IAAI,CAAC;gBACnB,IAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,GAAG,CAAC,UAAA,CAAC,IAAI,OAAA,QAAQ,CAAC,CAAC,EAAE,EAAE,CAAC,EAAf,CAAe,CAAC,CAAC;gBACjE,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;gBACnD,IAAI,CAAC,OAAO,CAAC,UAAA,GAAG;oBACd,IAAI,YAAY,CAAC,GAAG,CAAC,EAAE;wBACrB,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,UAAA,CAAC,IAAI,OAAA,CAAC,CAAC,GAAG,KAAK,GAAG,EAAb,CAAa,CAAC,EAAE;4BAChC,OAAO,CAAC,GAAG,CAAC,SAAO,GAAG,oBAAiB,CAAC,CAAC;4BACzC,OAAO,GAAG,KAAK,CAAC;yBACjB;qBACF;yBAAM;wBACL,IAAI,EAAE,CAAC,IAAI,CAAC,UAAA,CAAC,IAAI,OAAA,CAAC,CAAC,GAAG,KAAK,GAAG,EAAb,CAAa,CAAC,EAAE;4BAC/B,OAAO,CAAC,GAAG,CAAC,SAAO,GAAG,kBAAe,CAAC,CAAC;4BACvC,OAAO,GAAG,KAAK,CAAC;yBACjB;qBACF;gBACH,CAAC,CAAC,CAAC;gBACH,IAAI,OAAO,EAAE;oBACX,aAAa,CAAC,QAAQ,CAAC,CAAC;oBACxB,OAAO,EAAE,CAAC;oBACV,OAAO;iBACR;gBACD,KAAK,EAAE,CAAC;gBACR,IAAI,KAAK,GAAG,UAAU,IAAI,SAAS,EAAE;oBACnC,aAAa,CAAC,QAAQ,CAAC,CAAC;oBACxB,IAAM,cAAc,GAAG,IAAI,CAAC,GAAG,CAAC,UAAA,CAAC,IAAI,OAAG,CAAC,UAAK,YAAY,CAAC,CAAC,CAAG,EAA1B,CAA0B,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;oBAC5E,MAAM,CAAC,IAAI,CAAC,mCAAiC,cAAgB,CAAC,CAAC;oBAC/D,OAAO,EAAE,CAAC;iBACX;YACH,CAAC,CAAC,CAAC;QACL,CAAC,EAAE,UAAU,CAAC,CAAC;IACjB,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,sBAAsB,CAAC,GAAW,EAAE,IAAY,EAAE,UAAwB,EAAE,SAAwB;IAAlD,2BAAA,EAAA,gBAAwB;IAAE,0BAAA,EAAA,gBAAwB;IAC3G,OAAO,IAAI,OAAO,CAA8B,UAAA,OAAO;QACrD,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,IAAM,QAAQ,GAAG,WAAW,CAAC;YAC3B,MAAM,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,UAAA,EAAE;gBAC3B,IAAM,QAAQ,GAAgC,EAAE,CAAC;gBACjD,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,UAAA,CAAC,IAAI,OAAA,CAAC,CAAC,GAAG,KAAK,GAAG,EAAb,CAAa,CAAC,CAAC,GAAG,CAAC,UAAA,CAAC;oBAC/C,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC;gBACtC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;gBACP,IAAM,IAAI,GAAgC,EAAE,CAAC;;oBAE3C,IAAM,OAAO,GAAG,QAAQ,CAAC,KAAK,EAAG,CAAC;oBAClC,EAAE,CAAC,MAAM,CAAC,UAAA,CAAC,IAAI,OAAA,CAAC,CAAC,IAAI,KAAK,OAAO,CAAC,GAAG,EAAtB,CAAsB,CAAC,CAAC,GAAG,CAAC,UAAA,CAAC;wBAC1C,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC;oBACtC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAA,CAAC,IAAI,OAAA,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAhB,CAAgB,CAAC,CAAC;oBAClC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;;gBALrB,OAAO,QAAQ,CAAC,MAAM;;iBAMrB;gBACD,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;gBAC1C,IAAM,OAAO,GAAG,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC;gBACrC,IAAI,OAAO,EAAE;oBACX,aAAa,CAAC,QAAQ,CAAC,CAAC;oBACxB,OAAO,CAAC,IAAI,CAAC,CAAC;oBACd,OAAO;iBACR;gBACD,KAAK,EAAE,CAAC;gBACR,IAAI,KAAK,GAAG,UAAU,IAAI,SAAS,EAAE;oBACnC,aAAa,CAAC,QAAQ,CAAC,CAAC;oBACxB,MAAM,CAAC,IAAI,CAAC,kCAAgC,IAAI,kBAAa,IAAI,CAAC,MAAQ,CAAC,CAAC;iBAC7E;YACH,CAAC,CAAC,CAAC;QACL,CAAC,EAAE,UAAU,CAAC,CAAC;IACjB,CAAC,CAAC,CAAC;AACL,CAAC;AAED,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE;IAChC,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,UAAC,EAAyB;YAAxB,SAAS,QAAA,EAAE,YAAY,QAAA;QAC7E,QAAQ,CAAC,kCAAgC,SAAS,yBAAoB,YAAY,MAAG,EAAE;YACrF,QAAQ,CAAC,MAAM,EAAE;gBACf,EAAE,CAAC,iCAAiC,EAAE,UAAU,IAAI;oBAClD,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAM,IAAI,GAAG,IAAI,iCAAe,CAAC,SAAS,EAAE,EAAE,EAAE,EAAE,SAAS,WAAA,EAAE,YAAY,cAAA,EAAE,CAAC,CAAC;oBAC7E,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,cAAM,OAAA,IAAI,EAAE,EAAN,CAAM,CAAC,CAAC;oBAC9B,IAAI,CAAC,IAAI,EAAE,CAAC;gBACd,CAAC,CAAC,CAAC;gBACH,EAAE,CAAC,8BAA8B,EAAE,UAAU,IAAgB;oBAC3D,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAM,IAAI,GAAG,IAAI,iCAAe,CAAC,SAAS,EAAE,EAAE,EAAE,EAAE,SAAS,WAAA,EAAE,YAAY,cAAA,EAAE,CAAC,CAAC;oBAC7E,sBAAsB;oBACtB,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;oBAC/B,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;oBACzB,OAAO,CAAC,GAAG,CAAC,0BAA0B,CAAC,CAAC;oBACxC,sBAAsB,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,UAAA,IAAI;wBACtD,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,SAAS,CAAC,CAAC;wBAC1D,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,gBAAgB,CAAC,CAAC;wBACjE,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,UAAU,CAAC,CAAC;wBAC3D,IAAI,CAAC,IAAI,EAAE,CAAC;wBACZ,IAAM,YAAY,GAAkB,EAAE,CAAC;wBACvC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;wBAClC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;wBAClC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;wBAClC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE;4BACd,mBAAmB,CAAC,YAAY,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC;gCACjD,IAAI,EAAE,CAAC;4BACT,CAAC,CAAC,CAAC;wBACL,CAAC,CAAC,CAAC;oBACL,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YAEH,QAAQ,CAAC,QAAQ,EAAE;gBACjB,EAAE,CAAC,oEAAoE,EAAE,UAAS,IAAI;oBACpF,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAM,IAAI,GAAG,IAAI,iCAAe,CAAC,SAAS,EAAE,EAAE,EAAE,EAAE,SAAS,WAAA,EAAE,YAAY,cAAA,EAAE,CAAC,CAAC;oBAC7E,MAAM,CAAC,MAAM,CAAC,cAAM,OAAA,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAnB,CAAmB,CAAC,CAAC;oBACzC,MAAM,CAAC,MAAM,CAAC,cAAM,OAAA,IAAI,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,EAAjB,CAAiB,CAAC,CAAC;oBACvC,MAAM,CAAC,YAAY,CAAC,cAAM,OAAA,IAAI,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,EAAjB,CAAiB,CAAC,CAAC;oBAC7C,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE;wBACd,IAAI,EAAE,CAAC;oBACT,CAAC,CAAC,CAAC;oBACH,IAAI,CAAC,IAAI,EAAE,CAAC;gBACd,CAAC,CAAC,CAAC;gBACH,EAAE,CAAC,qEAAqE,EAAE,UAAS,IAAI;oBACrF,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAM,IAAI,GAAG,IAAI,iCAAe,CAAC,SAAS,EAAE,EAAE,EAAE,EAAE,SAAS,WAAA,EAAE,YAAY,cAAA,EAAE,CAAC,CAAC;oBACvE,IAAK,CAAC,MAAM,CAAC;wBACjB,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE;4BAChB,MAAM,CAAC,MAAM,CAAC,cAAM,OAAA,IAAI,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,EAAjB,CAAiB,CAAC,CAAC;4BACvC,IAAI,EAAE,CAAC;wBACT,CAAC,CAAC,CAAC;wBACH,IAAI,CAAC,OAAO,EAAE,CAAC;oBACjB,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YAEH,QAAQ,CAAC,qBAAqB,EAAE;gBAC9B,EAAE,CAAC,+DAA+D,EAAE,UAAU,IAAI;oBAChF,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAM,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,cAAc,CAAC,CAAC;oBAC9E,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE;wBAC/B,EAAE,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC;qBAC3B;oBAED,IAAM,aAAa,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;oBAC3D,IAAM,IAAI,GAAG,EAAE,CAAC,YAAY,CAAI,OAAO,CAAC,GAAG,CAAC,MAAM,wBAAqB,CAAC,CAAC;oBACzE,EAAE,CAAC,aAAa,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC;oBAEtC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,aAAa,CAAC,EAAE;wBACjC,wCAAwC;wBACxC,OAAO;qBACR;oBACD,IAAM,IAAI,GAAG,IAAI,iCAAe,CAAC,aAAa,EAAE,uBAAuB,EAAE,EAAE,SAAS,WAAA,EAAE,YAAY,cAAA,EAAE,CAAC,CAAC;oBACtG,IAAI,MAAM,GAAG,EAAE,CAAC;oBAChB,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;wBACnB,MAAM,IAAI,IAAI,CAAC;oBACjB,CAAC,CAAC,CAAC;oBACH,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE;wBACd,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC;wBAC9C,IAAI,EAAE,CAAC;oBACT,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YAEH,QAAQ,CAAC,KAAK,EAAE;gBACd,EAAE,CAAC,+CAA+C,EAAE,UAAU,IAAI;oBAChE,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAM,IAAI,GAAG,IAAI,iCAAe,CAAC,SAAS,EAAE,eAAe,EAAE,EAAE,SAAS,WAAA,EAAE,YAAY,cAAA,EAAE,GAAG,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,EAAC,CAAC,CAAC;oBAC9G,IAAI,MAAM,GAAG,EAAE,CAAC;oBAChB,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;wBACnB,MAAM,IAAI,IAAI,CAAC;oBACjB,CAAC,CAAC,CAAC;oBACH,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE;wBACd,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;wBACtC,IAAI,EAAE,CAAC;oBACT,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YAEH,QAAQ,CAAC,UAAU,EAAE;gBACnB,EAAE,CAAC,uCAAuC,EAAE,UAAU,IAAI;oBACxD,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAM,IAAI,GAAG,IAAI,iCAAe,CAAC,SAAS,EAAE,SAAS,EAAE,EAAE,SAAS,WAAA,EAAE,YAAY,cAAA,EAAE,CAAC,CAAC;oBACpF,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;wBACnB,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;wBAC5B,IAAI,EAAE,CAAC;oBACT,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;gBAEH,EAAE,CAAC,2CAA2C,EAAE,UAAU,IAAI;oBAC5D,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAM,IAAI,GAAG,IAAI,iCAAe,CAAC,SAAS,EAAE,WAAW,EAAE,EAAE,SAAS,WAAA,EAAE,YAAY,cAAA,EAAE,CAAC,CAAC;oBACtF,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,UAAC,IAAI;wBACnB,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;wBAC5B,IAAI,EAAE,CAAC;oBACT,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YAEH,QAAQ,CAAC,OAAO,EAAE;gBAChB,EAAE,CAAC,qBAAqB,EAAE,UAAU,IAAI;oBACtC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAM,IAAI,GAAG,IAAI,iCAAe,CAAC,SAAS,EAAE,EAAE,EAAE,EAAE,SAAS,WAAA,EAAE,YAAY,cAAA,EAAE,CAAC,CAAC;oBAC7E,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;oBACrB,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE;wBACd,IAAI,EAAE,CAAC;oBACT,CAAC,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;CACJ"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/worker/conoutSocketWorker.js b/services/edge-agent/node_modules/node-pty/lib/worker/conoutSocketWorker.js new file mode 100644 index 00000000..0451e2c6 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/worker/conoutSocketWorker.js @@ -0,0 +1,22 @@ +"use strict"; +/** + * Copyright (c) 2020, Microsoft Corporation (MIT License). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +var worker_threads_1 = require("worker_threads"); +var net_1 = require("net"); +var conout_1 = require("../shared/conout"); +var conoutPipeName = worker_threads_1.workerData.conoutPipeName; +var conoutSocket = new net_1.Socket(); +conoutSocket.setEncoding('utf8'); +conoutSocket.connect(conoutPipeName, function () { + var server = net_1.createServer(function (workerSocket) { + conoutSocket.pipe(workerSocket); + }); + server.listen(conout_1.getWorkerPipeName(conoutPipeName)); + if (!worker_threads_1.parentPort) { + throw new Error('worker_threads parentPort is null'); + } + worker_threads_1.parentPort.postMessage(1 /* READY */); +}); +//# sourceMappingURL=conoutSocketWorker.js.map \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/lib/worker/conoutSocketWorker.js.map b/services/edge-agent/node_modules/node-pty/lib/worker/conoutSocketWorker.js.map new file mode 100644 index 00000000..5924b613 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/lib/worker/conoutSocketWorker.js.map @@ -0,0 +1 @@ +{"version":3,"file":"conoutSocketWorker.js","sourceRoot":"","sources":["../../src/worker/conoutSocketWorker.ts"],"names":[],"mappings":";AAAA;;GAEG;;AAEH,iDAAwD;AACxD,2BAA2C;AAC3C,2CAAuF;AAE/E,IAAA,cAAc,GAAM,2BAA0B,eAAhC,CAAiC;AAEvD,IAAM,YAAY,GAAG,IAAI,YAAM,EAAE,CAAC;AAClC,YAAY,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;AACjC,YAAY,CAAC,OAAO,CAAC,cAAc,EAAE;IACnC,IAAM,MAAM,GAAG,kBAAY,CAAC,UAAA,YAAY;QACtC,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;IAClC,CAAC,CAAC,CAAC;IACH,MAAM,CAAC,MAAM,CAAC,0BAAiB,CAAC,cAAc,CAAC,CAAC,CAAC;IACjD,IAAI,CAAC,2BAAU,EAAE;QACf,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;KACtD;IACD,2BAAU,CAAC,WAAW,eAA2B,CAAC;AACpD,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/package.json b/services/edge-agent/node_modules/node-pty/package.json new file mode 100644 index 00000000..94a2c143 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/package.json @@ -0,0 +1,64 @@ +{ + "name": "node-pty", + "description": "Fork pseudoterminals in Node.JS", + "author": { + "name": "Microsoft Corporation" + }, + "version": "1.1.0", + "license": "MIT", + "main": "./lib/index.js", + "types": "./typings/node-pty.d.ts", + "repository": { + "type": "git", + "url": "git://github.com/microsoft/node-pty.git" + }, + "files": [ + "binding.gyp", + "lib/", + "scripts/", + "src/", + "deps/", + "prebuilds/", + "third_party/", + "typings/" + ], + "homepage": "https://github.com/microsoft/node-pty", + "bugs": { + "url": "https://github.com/microsoft/node-pty/issues" + }, + "keywords": [ + "pty", + "tty", + "terminal", + "pseudoterminal", + "forkpty", + "openpty" + ], + "scripts": { + "build": "tsc -b ./src/tsconfig.json", + "watch": "tsc -b -w ./src/tsconfig.json", + "lint": "eslint -c .eslintrc.js --ext .ts src/", + "install": "node scripts/prebuild.js || node-gyp rebuild", + "postinstall": "node scripts/post-install.js", + "compileCommands": "node scripts/gen-compile-commands.js", + "test": "cross-env NODE_ENV=test mocha -R spec --exit lib/*.test.js", + "posttest": "npm run lint", + "prepare": "npm run build", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "node-addon-api": "^7.1.0" + }, + "devDependencies": { + "@types/mocha": "^7.0.2", + "@types/node": "12", + "@typescript-eslint/eslint-plugin": "^2.27.0", + "@typescript-eslint/parser": "^2.27.0", + "cross-env": "^5.1.4", + "eslint": "^6.8.0", + "mocha": "10", + "node-gyp": "^11.4.2", + "ps-list": "^6.0.0", + "typescript": "^3.8.3" + } +} \ No newline at end of file diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/darwin-arm64/pty.node b/services/edge-agent/node_modules/node-pty/prebuilds/darwin-arm64/pty.node new file mode 100644 index 00000000..c0583612 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/darwin-arm64/pty.node differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper b/services/edge-agent/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper new file mode 100644 index 00000000..7a0df325 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/darwin-x64/pty.node b/services/edge-agent/node_modules/node-pty/prebuilds/darwin-x64/pty.node new file mode 100644 index 00000000..1e882716 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/darwin-x64/pty.node differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper b/services/edge-agent/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper new file mode 100644 index 00000000..6c67ef79 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty.node b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty.node new file mode 100644 index 00000000..6a44cd7b Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty.node differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty.pdb new file mode 100644 index 00000000..9766aa98 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe new file mode 100644 index 00000000..40217d33 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty/conpty.dll b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty/conpty.dll new file mode 100644 index 00000000..f8ea864b Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty/conpty.dll differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node new file mode 100644 index 00000000..959d55bb Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.pdb new file mode 100644 index 00000000..5223204c Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/pty.node b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/pty.node new file mode 100644 index 00000000..e0f13722 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/pty.node differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/pty.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/pty.pdb new file mode 100644 index 00000000..e13db7f6 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/pty.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty-agent.exe b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty-agent.exe new file mode 100644 index 00000000..72d3e538 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty-agent.exe differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty-agent.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty-agent.pdb new file mode 100644 index 00000000..cf406004 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty-agent.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty.dll b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty.dll new file mode 100644 index 00000000..db82607c Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty.dll differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty.pdb new file mode 100644 index 00000000..5d18fd96 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-arm64/winpty.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty.node b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty.node new file mode 100644 index 00000000..409cffba Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty.node differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty.pdb new file mode 100644 index 00000000..f1a94888 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe new file mode 100644 index 00000000..3db21937 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty/conpty.dll b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty/conpty.dll new file mode 100644 index 00000000..eb66b162 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty/conpty.dll differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node new file mode 100644 index 00000000..361129db Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.pdb new file mode 100644 index 00000000..91aa7b3f Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/pty.node b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/pty.node new file mode 100644 index 00000000..e363064e Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/pty.node differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/pty.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/pty.pdb new file mode 100644 index 00000000..97855326 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/pty.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty-agent.exe b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty-agent.exe new file mode 100644 index 00000000..505d35aa Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty-agent.exe differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty-agent.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty-agent.pdb new file mode 100644 index 00000000..537482ac Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty-agent.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty.dll b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty.dll new file mode 100644 index 00000000..a63a2f75 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty.dll differ diff --git a/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty.pdb b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty.pdb new file mode 100644 index 00000000..17d24d14 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/prebuilds/win32-x64/winpty.pdb differ diff --git a/services/edge-agent/node_modules/node-pty/scripts/gen-compile-commands.js b/services/edge-agent/node_modules/node-pty/scripts/gen-compile-commands.js new file mode 100644 index 00000000..84a60ea5 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/scripts/gen-compile-commands.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2025, Microsoft Corporation (MIT License). + */ + +const { execSync } = require('child_process'); + +console.log(`\x1b[32m> Generating compile_commands.json...\x1b[0m`); +execSync('npx --offline node-gyp configure -- -f compile_commands_json'); diff --git a/services/edge-agent/node_modules/node-pty/scripts/increment-version.js b/services/edge-agent/node_modules/node-pty/scripts/increment-version.js new file mode 100644 index 00000000..10a52809 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/scripts/increment-version.js @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2019, Microsoft Corporation (MIT License). + */ + +const cp = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const packageJson = require('../package.json'); + +// Determine if this is a stable or beta release +const publishedVersions = getPublishedVersions(); +const isStableRelease = !publishedVersions.includes(packageJson.version); + +// Get the next version +const nextVersion = isStableRelease ? packageJson.version : getNextBetaVersion(); +console.log(`Setting version to ${nextVersion}`); + +// Set the version in package.json +const packageJsonFile = path.resolve(__dirname, '..', 'package.json'); +packageJson.version = nextVersion; +fs.writeFileSync(packageJsonFile, JSON.stringify(packageJson, null, 2)); + +function getNextBetaVersion() { + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.exec(packageJson.version)) { + console.error('The package.json version must be of the form x.y.z'); + process.exit(1); + } + const tag = 'beta'; + const stableVersion = packageJson.version.split('.'); + const nextStableVersion = `${stableVersion[0]}.${parseInt(stableVersion[1]) + 1}.0`; + const publishedVersions = getPublishedVersions(nextStableVersion, tag); + if (publishedVersions.length === 0) { + return `${nextStableVersion}-${tag}1`; + } + const latestPublishedVersion = publishedVersions.sort((a, b) => { + const aVersion = parseInt(a.substr(a.search(/[0-9]+$/))); + const bVersion = parseInt(b.substr(b.search(/[0-9]+$/))); + return aVersion > bVersion ? -1 : 1; + })[0]; + const latestTagVersion = parseInt(latestPublishedVersion.substr(latestPublishedVersion.search(/[0-9]+$/)), 10); + return `${nextStableVersion}-${tag}${latestTagVersion + 1}`; +} + +function getPublishedVersions(version, tag) { + const isWin32 = process.platform === 'win32'; + const versionsProcess = isWin32 ? + cp.spawnSync('npm.cmd', ['view', packageJson.name, 'versions', '--json'], { shell: true }) : + cp.spawnSync('npm', ['view', packageJson.name, 'versions', '--json']); + const versionsJson = JSON.parse(versionsProcess.stdout); + if (tag) { + return versionsJson.filter(v => !v.search(new RegExp(`${version}-${tag}[0-9]+`))); + } + return versionsJson; +} diff --git a/services/edge-agent/node_modules/node-pty/scripts/post-install.js b/services/edge-agent/node_modules/node-pty/scripts/post-install.js new file mode 100644 index 00000000..8dbac507 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/scripts/post-install.js @@ -0,0 +1,80 @@ +//@ts-check + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const RELEASE_DIR = path.join(__dirname, '../build/Release'); +const BUILD_FILES = [ + path.join(RELEASE_DIR, 'conpty.node'), + path.join(RELEASE_DIR, 'conpty.pdb'), + path.join(RELEASE_DIR, 'conpty_console_list.node'), + path.join(RELEASE_DIR, 'conpty_console_list.pdb'), + path.join(RELEASE_DIR, 'pty.node'), + path.join(RELEASE_DIR, 'pty.pdb'), + path.join(RELEASE_DIR, 'spawn-helper'), + path.join(RELEASE_DIR, 'winpty-agent.exe'), + path.join(RELEASE_DIR, 'winpty-agent.pdb'), + path.join(RELEASE_DIR, 'winpty.dll'), + path.join(RELEASE_DIR, 'winpty.pdb') +]; +const CONPTY_DIR = path.join(__dirname, '../third_party/conpty'); +const CONPTY_SUPPORTED_ARCH = ['x64', 'arm64']; + +console.log('\x1b[32m> Cleaning release folder...\x1b[0m'); + +/** @param {string} folder */ +function cleanFolderRecursive(folder) { + var files = []; + if (fs.existsSync(folder)) { + files = fs.readdirSync(folder); + files.forEach(function(file,index) { + var curPath = path.join(folder, file); + if (fs.lstatSync(curPath).isDirectory()) { // recurse + cleanFolderRecursive(curPath); + fs.rmdirSync(curPath); + } else if (BUILD_FILES.indexOf(curPath) < 0){ // delete file + fs.unlinkSync(curPath); + } + }); + } +}; + +try { + cleanFolderRecursive(RELEASE_DIR); +} catch(e) { + console.log(e); + process.exit(1); +} + +console.log(`\x1b[32m> Moving conpty.dll...\x1b[0m`); +if (os.platform() !== 'win32') { + console.log(' SKIPPED (not Windows)'); +} else { + let windowsArch; + if (process.env.npm_config_arch) { + windowsArch = process.env.npm_config_arch; + console.log(` Using $npm_config_arch: ${windowsArch}`); + } else { + windowsArch = os.arch(); + console.log(` Using os.arch(): ${windowsArch}`); + } + + if (!CONPTY_SUPPORTED_ARCH.includes(windowsArch)) { + console.log(` SKIPPED (unsupported architecture ${windowsArch})`); + } else { + const versionFolder = fs.readdirSync(CONPTY_DIR)[0]; + console.log(` Found version ${versionFolder}`); + const sourceFolder = path.join(CONPTY_DIR, versionFolder, `win10-${windowsArch}`); + const destFolder = path.join(RELEASE_DIR, 'conpty'); + fs.mkdirSync(destFolder, { recursive: true }); + for (const file of ['conpty.dll', 'OpenConsole.exe']) { + const sourceFile = path.join(sourceFolder, file); + const destFile = path.join(destFolder, file); + console.log(` Copying ${sourceFile} -> ${destFile}`); + fs.copyFileSync(sourceFile, destFile); + } + } +} + +process.exit(0); diff --git a/services/edge-agent/node_modules/node-pty/scripts/prebuild.js b/services/edge-agent/node_modules/node-pty/scripts/prebuild.js new file mode 100644 index 00000000..17f1d980 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/scripts/prebuild.js @@ -0,0 +1,34 @@ +//@ts-check + +const fs = require('fs'); +const path = require('path'); + +/** + * This script checks for the prebuilt binaries for the current platform and + * architecture. It exits with 0 if prebuilds are found and 1 if not. + * + * If npm_config_build_from_source is set then it removes the prebuilds for the + * current platform so they are not loaded at runtime. + * + * Usage: + * node scripts/prebuild.js + */ + +const PREBUILDS_ROOT = path.join(__dirname, '..', 'prebuilds'); +const PREBUILD_DIR = path.join(__dirname, '..', 'prebuilds', `${process.platform}-${process.arch}`); + +// Do not use prebuilds when npm_config_build_from_source is set +if (process.env.npm_config_build_from_source === 'true') { + console.log('\x1b[33m> Removing prebuilds and rebuilding because npm_config_build_from_source is set\x1b[0m'); + fs.rmSync(PREBUILDS_ROOT, { recursive: true, force: true }); + process.exit(1); +} + +// Check whether the correct prebuilt files exist +console.log('\x1b[32m> Checking prebuilds...\x1b[0m'); +if (!fs.existsSync(PREBUILD_DIR)) { + console.log(`\x1b[33m> Rebuilding because directory ${PREBUILD_DIR} does not exist\x1b[0m`); + process.exit(1); +} + +process.exit(0); diff --git a/services/edge-agent/node_modules/node-pty/src/conpty_console_list_agent.ts b/services/edge-agent/node_modules/node-pty/src/conpty_console_list_agent.ts new file mode 100644 index 00000000..181ccabb --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/conpty_console_list_agent.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2019, Microsoft Corporation (MIT License). + * + * This module fetches the console process list for a particular PID. It must be + * called from a different process (child_process.fork) as there can only be a + * single console attached to a process. + */ + +import { loadNativeModule } from './utils'; + +const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList; +const shellPid = parseInt(process.argv[2], 10); +const consoleProcessList = getConsoleProcessList(shellPid); +process.send!({ consoleProcessList }); +process.exit(0); diff --git a/services/edge-agent/node_modules/node-pty/src/eventEmitter2.test.ts b/services/edge-agent/node_modules/node-pty/src/eventEmitter2.test.ts new file mode 100644 index 00000000..a65bfc2a --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/eventEmitter2.test.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2019, Microsoft Corporation (MIT License). + */ + +import * as assert from 'assert'; +import { EventEmitter2 } from './eventEmitter2'; + +describe('EventEmitter2', () => { + it('should fire listeners multiple times', () => { + const order: string[] = []; + const emitter = new EventEmitter2(); + emitter.event(data => order.push(data + 'a')); + emitter.event(data => order.push(data + 'b')); + emitter.fire(1); + emitter.fire(2); + assert.deepEqual(order, [ '1a', '1b', '2a', '2b' ]); + }); + + it('should not fire listeners once disposed', () => { + const order: string[] = []; + const emitter = new EventEmitter2(); + emitter.event(data => order.push(data + 'a')); + const disposeB = emitter.event(data => order.push(data + 'b')); + emitter.event(data => order.push(data + 'c')); + emitter.fire(1); + disposeB.dispose(); + emitter.fire(2); + assert.deepEqual(order, [ '1a', '1b', '1c', '2a', '2c' ]); + }); +}); diff --git a/services/edge-agent/node_modules/node-pty/src/eventEmitter2.ts b/services/edge-agent/node_modules/node-pty/src/eventEmitter2.ts new file mode 100644 index 00000000..6779d0cc --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/eventEmitter2.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2019, Microsoft Corporation (MIT License). + */ + +import { IDisposable } from './types'; + +interface IListener { + (e: T): void; +} + +export interface IEvent { + (listener: (e: T) => any): IDisposable; +} + +export class EventEmitter2 { + private _listeners: IListener[] = []; + private _event?: IEvent; + + public get event(): IEvent { + if (!this._event) { + this._event = (listener: (e: T) => any) => { + this._listeners.push(listener); + const disposable = { + dispose: () => { + for (let i = 0; i < this._listeners.length; i++) { + if (this._listeners[i] === listener) { + this._listeners.splice(i, 1); + return; + } + } + } + }; + return disposable; + }; + } + return this._event; + } + + public fire(data: T): void { + const queue: IListener[] = []; + for (let i = 0; i < this._listeners.length; i++) { + queue.push(this._listeners[i]); + } + for (let i = 0; i < queue.length; i++) { + queue[i].call(undefined, data); + } + } +} diff --git a/services/edge-agent/node_modules/node-pty/src/index.ts b/services/edge-agent/node_modules/node-pty/src/index.ts new file mode 100644 index 00000000..8a7e9505 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/index.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import { ITerminal, IPtyOpenOptions, IPtyForkOptions, IWindowsPtyForkOptions } from './interfaces'; +import { ArgvOrCommandLine } from './types'; +import { loadNativeModule } from './utils'; + +let terminalCtor: any; +if (process.platform === 'win32') { + terminalCtor = require('./windowsTerminal').WindowsTerminal; +} else { + terminalCtor = require('./unixTerminal').UnixTerminal; +} + +/** + * Forks a process as a pseudoterminal. + * @param file The file to launch. + * @param args The file's arguments as argv (string[]) or in a pre-escaped + * CommandLine format (string). Note that the CommandLine option is only + * available on Windows and is expected to be escaped properly. + * @param options The options of the terminal. + * @throws When the file passed to spawn with does not exists. + * @see CommandLineToArgvW https://msdn.microsoft.com/en-us/library/windows/desktop/bb776391(v=vs.85).aspx + * @see Parsing C++ Comamnd-Line Arguments https://msdn.microsoft.com/en-us/library/17w5ykft.aspx + * @see GetCommandLine https://msdn.microsoft.com/en-us/library/windows/desktop/ms683156.aspx + */ +export function spawn(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions | IWindowsPtyForkOptions): ITerminal { + return new terminalCtor(file, args, opt); +} + +/** @deprecated */ +export function fork(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions | IWindowsPtyForkOptions): ITerminal { + return new terminalCtor(file, args, opt); +} + +/** @deprecated */ +export function createTerminal(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions | IWindowsPtyForkOptions): ITerminal { + return new terminalCtor(file, args, opt); +} + +export function open(options: IPtyOpenOptions): ITerminal { + return terminalCtor.open(options); +} + +/** + * Expose the native API when not Windows, note that this is not public API and + * could be removed at any time. + */ +export const native = (process.platform !== 'win32' ? loadNativeModule('pty').module : null); diff --git a/services/edge-agent/node_modules/node-pty/src/interfaces.ts b/services/edge-agent/node_modules/node-pty/src/interfaces.ts new file mode 100644 index 00000000..a269e77b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/interfaces.ts @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +export interface IProcessEnv { + [key: string]: string | undefined; +} + +export interface ITerminal { + /** + * Gets the name of the process. + */ + process: string; + + /** + * Gets the process ID. + */ + pid: number; + + /** + * Writes data to the socket. + * @param data The data to write. + */ + write(data: string | Buffer): void; + + /** + * Resize the pty. + * @param cols The number of columns. + * @param rows The number of rows. + */ + resize(cols: number, rows: number): void; + + /** + * Clears the pty's internal representation of its buffer. This is a no-op + * unless on Windows/ConPTY. + */ + clear(): void; + + /** + * Close, kill and destroy the socket. + */ + destroy(): void; + + /** + * Kill the pty. + * @param signal The signal to send, by default this is SIGHUP. This is not + * supported on Windows. + */ + kill(signal?: string): void; + + /** + * Set the pty socket encoding. + */ + setEncoding(encoding: string | null): void; + + /** + * Resume the pty socket. + */ + resume(): void; + + /** + * Pause the pty socket. + */ + pause(): void; + + /** + * Alias for ITerminal.on(eventName, listener). + */ + addListener(eventName: string, listener: (...args: any[]) => any): void; + + /** + * Adds the listener function to the end of the listeners array for the event + * named eventName. + * @param eventName The event name. + * @param listener The callback function + */ + on(eventName: string, listener: (...args: any[]) => any): void; + + /** + * Returns a copy of the array of listeners for the event named eventName. + */ + listeners(eventName: string): Function[]; + + /** + * Removes the specified listener from the listener array for the event named + * eventName. + */ + removeListener(eventName: string, listener: (...args: any[]) => any): void; + + /** + * Removes all listeners, or those of the specified eventName. + */ + removeAllListeners(eventName: string): void; + + /** + * Adds a one time listener function for the event named eventName. The next + * time eventName is triggered, this listener is removed and then invoked. + */ + once(eventName: string, listener: (...args: any[]) => any): void; +} + +interface IBasePtyForkOptions { + name?: string; + cols?: number; + rows?: number; + cwd?: string; + env?: IProcessEnv; + encoding?: string | null; + handleFlowControl?: boolean; + flowControlPause?: string; + flowControlResume?: string; +} + +export interface IPtyForkOptions extends IBasePtyForkOptions { + uid?: number; + gid?: number; +} + +export interface IWindowsPtyForkOptions extends IBasePtyForkOptions { + useConpty?: boolean; + useConptyDll?: boolean; + conptyInheritCursor?: boolean; +} + +export interface IPtyOpenOptions { + cols?: number; + rows?: number; + encoding?: string | null; +} diff --git a/services/edge-agent/node_modules/node-pty/src/native.d.ts b/services/edge-agent/node_modules/node-pty/src/native.d.ts new file mode 100644 index 00000000..c53e086b --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/native.d.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +interface IConptyNative { + startProcess(file: string, cols: number, rows: number, debug: boolean, pipeName: string, conptyInheritCursor: boolean, useConptyDll: boolean): IConptyProcess; + connect(ptyId: number, commandLine: string, cwd: string, env: string[], useConptyDll: boolean, onExitCallback: (exitCode: number) => void): { pid: number }; + resize(ptyId: number, cols: number, rows: number, useConptyDll: boolean): void; + clear(ptyId: number, useConptyDll: boolean): void; + kill(ptyId: number, useConptyDll: boolean): void; +} + +interface IWinptyNative { + startProcess(file: string, commandLine: string, env: string[], cwd: string, cols: number, rows: number, debug: boolean): IWinptyProcess; + resize(pid: number, cols: number, rows: number): void; + kill(pid: number, innerPid: number): void; + getProcessList(pid: number): number[]; + getExitCode(innerPid: number): number; +} + +interface IUnixNative { + fork(file: string, args: string[], parsedEnv: string[], cwd: string, cols: number, rows: number, uid: number, gid: number, useUtf8: boolean, helperPath: string, onExitCallback: (code: number, signal: number) => void): IUnixProcess; + open(cols: number, rows: number): IUnixOpenProcess; + process(fd: number, pty?: string): string; + resize(fd: number, cols: number, rows: number): void; +} + +interface IConptyProcess { + pty: number; + fd: number; + conin: string; + conout: string; +} + +interface IWinptyProcess { + pty: number; + fd: number; + conin: string; + conout: string; + pid: number; + innerPid: number; +} + +interface IUnixProcess { + fd: number; + pid: number; + pty: string; +} + +interface IUnixOpenProcess { + master: number; + slave: number; + pty: string; +} diff --git a/services/edge-agent/node_modules/node-pty/src/shared/conout.ts b/services/edge-agent/node_modules/node-pty/src/shared/conout.ts new file mode 100644 index 00000000..7a7e05f8 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/shared/conout.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2020, Microsoft Corporation (MIT License). + */ + +export interface IWorkerData { + conoutPipeName: string; +} + +export const enum ConoutWorkerMessage { + READY = 1 +} + +export function getWorkerPipeName(conoutPipeName: string): string { + return `${conoutPipeName}-worker`; +} diff --git a/services/edge-agent/node_modules/node-pty/src/terminal.test.ts b/services/edge-agent/node_modules/node-pty/src/terminal.test.ts new file mode 100644 index 00000000..253bd683 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/terminal.test.ts @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import * as assert from 'assert'; +import { WindowsTerminal } from './windowsTerminal'; +import { UnixTerminal } from './unixTerminal'; +import { Terminal } from './terminal'; +import { Socket } from 'net'; + +const terminalConstructor = (process.platform === 'win32') ? WindowsTerminal : UnixTerminal; +const SHELL = (process.platform === 'win32') ? 'cmd.exe' : '/bin/bash'; + +let terminalCtor: WindowsTerminal | UnixTerminal; +if (process.platform === 'win32') { + terminalCtor = require('./windowsTerminal'); +} else { + terminalCtor = require('./unixTerminal'); +} + +class TestTerminal extends Terminal { + public checkType(name: string, value: T, type: string, allowArray: boolean = false): void { + this._checkType(name, value, type, allowArray); + } + protected _write(data: string | Buffer): void { + throw new Error('Method not implemented.'); + } + public resize(cols: number, rows: number): void { + throw new Error('Method not implemented.'); + } + public clear(): void { + throw new Error('Method not implemented.'); + } + public destroy(): void { + throw new Error('Method not implemented.'); + } + public kill(signal?: string): void { + throw new Error('Method not implemented.'); + } + public get process(): string { + throw new Error('Method not implemented.'); + } + public get master(): Socket { + throw new Error('Method not implemented.'); + } + public get slave(): Socket { + throw new Error('Method not implemented.'); + } +} + +describe('Terminal', () => { + describe('constructor', () => { + it('should do basic type checks', () => { + assert.throws( + () => new (terminalCtor)('a', 'b', { 'name': {} }), + 'name must be a string (not a object)' + ); + }); + }); + + describe('checkType', () => { + it('should throw for the wrong type', () => { + const t = new TestTerminal(); + assert.doesNotThrow(() => t.checkType('foo', 'test', 'string')); + assert.doesNotThrow(() => t.checkType('foo', 1, 'number')); + assert.doesNotThrow(() => t.checkType('foo', {}, 'object')); + + assert.throws(() => t.checkType('foo', 'test', 'number')); + assert.throws(() => t.checkType('foo', 1, 'object')); + assert.throws(() => t.checkType('foo', {}, 'string')); + }); + it('should throw for wrong types within arrays', () => { + const t = new TestTerminal(); + assert.doesNotThrow(() => t.checkType('foo', ['test'], 'string', true)); + assert.doesNotThrow(() => t.checkType('foo', [1], 'number', true)); + assert.doesNotThrow(() => t.checkType('foo', [{}], 'object', true)); + + assert.throws(() => t.checkType('foo', ['test'], 'number', true)); + assert.throws(() => t.checkType('foo', [1], 'object', true)); + assert.throws(() => t.checkType('foo', [{}], 'string', true)); + }); + }); + + describe('automatic flow control', () => { + it('should respect ctor flow control options', () => { + const pty = new terminalConstructor(SHELL, [], {handleFlowControl: true, flowControlPause: 'abc', flowControlResume: '123'}); + assert.equal(pty.handleFlowControl, true); + assert.equal((pty as any)._flowControlPause, 'abc'); + assert.equal((pty as any)._flowControlResume, '123'); + }); + // TODO: I don't think this test ever worked due to pollUntil being used incorrectly + // it('should do flow control automatically', async function(): Promise { + // // Flow control doesn't work on Windows + // if (process.platform === 'win32') { + // return; + // } + + // this.timeout(10000); + // const pty = new terminalConstructor(SHELL, [], {handleFlowControl: true, flowControlPause: 'PAUSE', flowControlResume: 'RESUME'}); + // let read: string = ''; + // pty.on('data', data => read += data); + // pty.on('pause', () => read += 'paused'); + // pty.on('resume', () => read += 'resumed'); + // pty.write('1'); + // pty.write('PAUSE'); + // pty.write('2'); + // pty.write('RESUME'); + // pty.write('3'); + // await pollUntil(() => { + // return stripEscapeSequences(read).endsWith('1pausedresumed23'); + // }, 100, 10); + // }); + }); +}); + +function stripEscapeSequences(data: string): string { + return data.replace(/\u001b\[0K/, ''); +} diff --git a/services/edge-agent/node_modules/node-pty/src/terminal.ts b/services/edge-agent/node_modules/node-pty/src/terminal.ts new file mode 100644 index 00000000..5fdde70e --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/terminal.ts @@ -0,0 +1,211 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import { Socket } from 'net'; +import { EventEmitter } from 'events'; +import { ITerminal, IPtyForkOptions, IProcessEnv } from './interfaces'; +import { EventEmitter2, IEvent } from './eventEmitter2'; +import { IExitEvent } from './types'; + +export const DEFAULT_COLS: number = 80; +export const DEFAULT_ROWS: number = 24; + +/** + * Default messages to indicate PAUSE/RESUME for automatic flow control. + * To avoid conflicts with rebound XON/XOFF control codes (such as on-my-zsh), + * the sequences can be customized in `IPtyForkOptions`. + */ +const FLOW_CONTROL_PAUSE = '\x13'; // defaults to XOFF +const FLOW_CONTROL_RESUME = '\x11'; // defaults to XON + +export abstract class Terminal implements ITerminal { + protected _socket!: Socket; // HACK: This is unsafe + protected _pid: number = 0; + protected _fd: number = 0; + protected _pty: any; + + protected _file!: string; // HACK: This is unsafe + protected _name!: string; // HACK: This is unsafe + protected _cols: number = 0; + protected _rows: number = 0; + + protected _readable: boolean = false; + protected _writable: boolean = false; + + protected _internalee: EventEmitter; + private _flowControlPause: string; + private _flowControlResume: string; + public handleFlowControl: boolean; + + private _onData = new EventEmitter2(); + public get onData(): IEvent { return this._onData.event; } + private _onExit = new EventEmitter2(); + public get onExit(): IEvent { return this._onExit.event; } + + public get pid(): number { return this._pid; } + public get cols(): number { return this._cols; } + public get rows(): number { return this._rows; } + + constructor(opt?: IPtyForkOptions) { + // for 'close' + this._internalee = new EventEmitter(); + + // setup flow control handling + this.handleFlowControl = !!(opt?.handleFlowControl); + this._flowControlPause = opt?.flowControlPause || FLOW_CONTROL_PAUSE; + this._flowControlResume = opt?.flowControlResume || FLOW_CONTROL_RESUME; + + if (!opt) { + return; + } + + // Do basic type checks here in case node-pty is being used within JavaScript. If the wrong + // types go through to the C++ side it can lead to hard to diagnose exceptions. + this._checkType('name', opt.name ? opt.name : undefined, 'string'); + this._checkType('cols', opt.cols ? opt.cols : undefined, 'number'); + this._checkType('rows', opt.rows ? opt.rows : undefined, 'number'); + this._checkType('cwd', opt.cwd ? opt.cwd : undefined, 'string'); + this._checkType('env', opt.env ? opt.env : undefined, 'object'); + this._checkType('uid', opt.uid ? opt.uid : undefined, 'number'); + this._checkType('gid', opt.gid ? opt.gid : undefined, 'number'); + this._checkType('encoding', opt.encoding ? opt.encoding : undefined, 'string'); + } + + protected abstract _write(data: string | Buffer): void; + + public write(data: string | Buffer): void { + if (this.handleFlowControl) { + // PAUSE/RESUME messages are not forwarded to the pty + if (data === this._flowControlPause) { + this.pause(); + return; + } + if (data === this._flowControlResume) { + this.resume(); + return; + } + } + // everything else goes to the real pty + this._write(data); + } + + protected _forwardEvents(): void { + this.on('data', e => this._onData.fire(e)); + this.on('exit', (exitCode, signal) => this._onExit.fire({ exitCode, signal })); + } + + protected _checkType(name: string, value: T | undefined, type: string, allowArray: boolean = false): void { + if (value === undefined) { + return; + } + if (allowArray) { + if (Array.isArray(value)) { + value.forEach((v, i) => { + if (typeof v !== type) { + throw new Error(`${name}[${i}] must be a ${type} (not a ${typeof v[i]})`); + } + }); + return; + } + } + if (typeof value !== type) { + throw new Error(`${name} must be a ${type} (not a ${typeof value})`); + } + } + + /** See net.Socket.end */ + public end(data: string): void { + this._socket.end(data); + } + + /** See stream.Readable.pipe */ + public pipe(dest: any, options: any): any { + return this._socket.pipe(dest, options); + } + + /** See net.Socket.pause */ + public pause(): Socket { + return this._socket.pause(); + } + + /** See net.Socket.resume */ + public resume(): Socket { + return this._socket.resume(); + } + + /** See net.Socket.setEncoding */ + public setEncoding(encoding: string | null): void { + if ((this._socket as any)._decoder) { + delete (this._socket as any)._decoder; + } + if (encoding) { + this._socket.setEncoding(encoding); + } + } + + public addListener(eventName: string, listener: (...args: any[]) => any): void { this.on(eventName, listener); } + public on(eventName: string, listener: (...args: any[]) => any): void { + if (eventName === 'close') { + this._internalee.on('close', listener); + return; + } + this._socket.on(eventName, listener); + } + + public emit(eventName: string, ...args: any[]): any { + if (eventName === 'close') { + return this._internalee.emit.apply(this._internalee, arguments as any); + } + return this._socket.emit.apply(this._socket, arguments as any); + } + + public listeners(eventName: string): Function[] { + return this._socket.listeners(eventName); + } + + public removeListener(eventName: string, listener: (...args: any[]) => any): void { + this._socket.removeListener(eventName, listener); + } + + public removeAllListeners(eventName: string): void { + this._socket.removeAllListeners(eventName); + } + + public once(eventName: string, listener: (...args: any[]) => any): void { + this._socket.once(eventName, listener); + } + + public abstract resize(cols: number, rows: number): void; + public abstract clear(): void; + public abstract destroy(): void; + public abstract kill(signal?: string): void; + + public abstract get process(): string; + public abstract get master(): Socket| undefined; + public abstract get slave(): Socket | undefined; + + protected _close(): void { + this._socket.readable = false; + this.write = () => {}; + this.end = () => {}; + this._writable = false; + this._readable = false; + } + + protected _parseEnv(env: IProcessEnv): string[] { + const keys = Object.keys(env || {}); + const pairs = []; + + for (let i = 0; i < keys.length; i++) { + if (keys[i] === undefined) { + continue; + } + pairs.push(keys[i] + '=' + env[keys[i]]); + } + + return pairs; + } +} diff --git a/services/edge-agent/node_modules/node-pty/src/testUtils.test.ts b/services/edge-agent/node_modules/node-pty/src/testUtils.test.ts new file mode 100644 index 00000000..0bdabffa --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/testUtils.test.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2019, Microsoft Corporation (MIT License). + */ + +export function pollUntil(cb: () => boolean, timeout: number, interval: number): Promise { + return new Promise((resolve, reject) => { + const intervalId = setInterval(() => { + if (cb()) { + clearInterval(intervalId); + clearTimeout(timeoutId); + resolve(); + } + }, interval); + const timeoutId = setTimeout(() => { + clearInterval(intervalId); + if (cb()) { + resolve(); + } else { + reject(); + } + }, timeout); + }); +} diff --git a/services/edge-agent/node_modules/node-pty/src/tsconfig.json b/services/edge-agent/node_modules/node-pty/src/tsconfig.json new file mode 100644 index 00000000..13ffba65 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "rootDir": ".", + "outDir": "../lib", + "sourceMap": true, + "lib": [ + "es2015" + ], + "strict": true + }, + "exclude": [ + "node_modules", + "scripts", + "index.js", + "demo.js", + "lib", + "test", + "examples" + ] +} diff --git a/services/edge-agent/node_modules/node-pty/src/types.ts b/services/edge-agent/node_modules/node-pty/src/types.ts new file mode 100644 index 00000000..94c2ac74 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/types.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +export type ArgvOrCommandLine = string[] | string; + +export interface IExitEvent { + exitCode: number; + signal: number | undefined; +} + +export interface IDisposable { + dispose(): void; +} diff --git a/services/edge-agent/node_modules/node-pty/src/unix/pty.cc b/services/edge-agent/node_modules/node-pty/src/unix/pty.cc new file mode 100644 index 00000000..7b4b9e1f --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/unix/pty.cc @@ -0,0 +1,799 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey (MIT License) + * Copyright (c) 2017, Daniel Imms (MIT License) + * + * pty.cc: + * This file is responsible for starting processes + * with pseudo-terminal file descriptors. + * + * See: + * man pty + * man tty_ioctl + * man termios + * man forkpty + */ + +/** + * Includes + */ + +#define NODE_ADDON_API_DISABLE_DEPRECATED +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +/* forkpty */ +/* http://www.gnu.org/software/gnulib/manual/html_node/forkpty.html */ +#if defined(__linux__) +#include +#elif defined(__APPLE__) +#include +#elif defined(__FreeBSD__) +#include +#include +#elif defined(__OpenBSD__) +#include +#include +#endif + +/* Some platforms name VWERASE and VDISCARD differently */ +#if !defined(VWERASE) && defined(VWERSE) +#define VWERASE VWERSE +#endif +#if !defined(VDISCARD) && defined(VDISCRD) +#define VDISCARD VDISCRD +#endif + +/* for pty_getproc */ +#if defined(__linux__) +#include +#include +#elif defined(__APPLE__) +#include +#include +#include +#include +#include +#include +#include +#endif + +/* NSIG - macro for highest signal + 1, should be defined */ +#ifndef NSIG +#define NSIG 32 +#endif + +/* macOS 10.14 back does not define this constant */ +#ifndef POSIX_SPAWN_SETSID + #define POSIX_SPAWN_SETSID 1024 +#endif + +/* environ for execvpe */ +/* node/src/node_child_process.cc */ +#if !defined(__APPLE__) +extern char **environ; +#endif + +#if defined(__APPLE__) +extern "C" { +// Changes the current thread's directory to a path or directory file +// descriptor. libpthread only exposes a syscall wrapper starting in +// macOS 10.12, but the system call dates back to macOS 10.5. On older OSes, +// the syscall is issued directly. +int pthread_chdir_np(const char* dir) API_AVAILABLE(macosx(10.12)); +int pthread_fchdir_np(int fd) API_AVAILABLE(macosx(10.12)); +} + +#define HANDLE_EINTR(x) ({ \ + int eintr_wrapper_counter = 0; \ + decltype(x) eintr_wrapper_result; \ + do { \ + eintr_wrapper_result = (x); \ + } while (eintr_wrapper_result == -1 && errno == EINTR && \ + eintr_wrapper_counter++ < 100); \ + eintr_wrapper_result; \ +}) +#endif + +struct ExitEvent { + int exit_code = 0, signal_code = 0; +}; + +void SetupExitCallback(Napi::Env env, Napi::Function cb, pid_t pid) { + std::thread *th = new std::thread; + // Don't use Napi::AsyncWorker which is limited by UV_THREADPOOL_SIZE. + auto tsfn = Napi::ThreadSafeFunction::New( + env, + cb, // JavaScript function called asynchronously + "SetupExitCallback_resource", // Name + 0, // Unlimited queue + 1, // Only one thread will use this initially + [th](Napi::Env) { // Finalizer used to clean threads up + th->join(); + delete th; + }); + *th = std::thread([tsfn = std::move(tsfn), pid] { + auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) { + cb.Call({Napi::Number::New(env, exit_event->exit_code), + Napi::Number::New(env, exit_event->signal_code)}); + delete exit_event; + }; + + int ret; + int stat_loc; +#if defined(__APPLE__) + // Based on + // https://source.chromium.org/chromium/chromium/src/+/main:base/process/kill_mac.cc;l=35-69? + int kq = HANDLE_EINTR(kqueue()); + struct kevent change = {0}; + EV_SET(&change, pid, EVFILT_PROC, EV_ADD, NOTE_EXIT, 0, NULL); + ret = HANDLE_EINTR(kevent(kq, &change, 1, NULL, 0, NULL)); + if (ret == -1) { + if (errno == ESRCH) { + // At this point, one of the following has occurred: + // 1. The process has died but has not yet been reaped. + // 2. The process has died and has already been reaped. + // 3. The process is in the process of dying. It's no longer + // kqueueable, but it may not be waitable yet either. Mark calls + // this case the "zombie death race". + ret = HANDLE_EINTR(waitpid(pid, &stat_loc, WNOHANG)); + if (ret == 0) { + ret = kill(pid, SIGKILL); + if (ret != -1) { + HANDLE_EINTR(waitpid(pid, &stat_loc, 0)); + } + } + } + } else { + struct kevent event = {0}; + ret = HANDLE_EINTR(kevent(kq, NULL, 0, &event, 1, NULL)); + if (ret == 1) { + if ((event.fflags & NOTE_EXIT) && + (event.ident == static_cast(pid))) { + // The process is dead or dying. This won't block for long, if at + // all. + HANDLE_EINTR(waitpid(pid, &stat_loc, 0)); + } + } + } +#else + while (true) { + errno = 0; + if ((ret = waitpid(pid, &stat_loc, 0)) != pid) { + if (ret == -1 && errno == EINTR) { + continue; + } + if (ret == -1 && errno == ECHILD) { + // XXX node v0.8.x seems to have this problem. + // waitpid is already handled elsewhere. + ; + } else { + assert(false); + } + } + break; + } +#endif + ExitEvent *exit_event = new ExitEvent; + if (WIFEXITED(stat_loc)) { + exit_event->exit_code = WEXITSTATUS(stat_loc); // errno? + } + if (WIFSIGNALED(stat_loc)) { + exit_event->signal_code = WTERMSIG(stat_loc); + } + auto status = tsfn.BlockingCall(exit_event, callback); // In main thread + switch (status) { + case napi_closing: + break; + + case napi_queue_full: + Napi::Error::Fatal("SetupExitCallback", "Queue was full"); + + case napi_ok: + if (tsfn.Release() != napi_ok) { + Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.Release() failed"); + } + break; + + default: + Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.BlockingCall() failed"); + } + }); +} + +/** + * Methods + */ + +Napi::Value PtyFork(const Napi::CallbackInfo& info); +Napi::Value PtyOpen(const Napi::CallbackInfo& info); +Napi::Value PtyResize(const Napi::CallbackInfo& info); +Napi::Value PtyGetProc(const Napi::CallbackInfo& info); + +/** + * Functions + */ + +static int +pty_nonblock(int); + +#if defined(__APPLE__) +static char * +pty_getproc(int); +#else +static char * +pty_getproc(int, char *); +#endif + +#if defined(__APPLE__) || defined(__OpenBSD__) +static void +pty_posix_spawn(char** argv, char** env, + const struct termios *termp, + const struct winsize *winp, + int* master, + pid_t* pid, + int* err); +#endif + +struct DelBuf { + int len; + DelBuf(int len) : len(len) {} + void operator()(char **p) { + if (p == nullptr) + return; + for (int i = 0; i < len; i++) + free(p[i]); + delete[] p; + } +}; + +Napi::Value PtyFork(const Napi::CallbackInfo& info) { + Napi::Env napiEnv(info.Env()); + Napi::HandleScope scope(napiEnv); + + if (info.Length() != 11 || + !info[0].IsString() || + !info[1].IsArray() || + !info[2].IsArray() || + !info[3].IsString() || + !info[4].IsNumber() || + !info[5].IsNumber() || + !info[6].IsNumber() || + !info[7].IsNumber() || + !info[8].IsBoolean() || + !info[9].IsString() || + !info[10].IsFunction()) { + throw Napi::Error::New(napiEnv, "Usage: pty.fork(file, args, env, cwd, cols, rows, uid, gid, utf8, helperPath, onexit)"); + } + + // file + std::string file = info[0].As(); + + // args + Napi::Array argv_ = info[1].As(); + + // env + Napi::Array env_ = info[2].As(); + int envc = env_.Length(); + std::unique_ptr env_unique_ptr(new char *[envc + 1], DelBuf(envc + 1)); + char **env = env_unique_ptr.get(); + env[envc] = NULL; + for (int i = 0; i < envc; i++) { + std::string pair = env_.Get(i).As(); + env[i] = strdup(pair.c_str()); + } + + // cwd + std::string cwd_ = info[3].As(); + + // size + struct winsize winp; + winp.ws_col = info[4].As().Int32Value(); + winp.ws_row = info[5].As().Int32Value(); + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + +#if !defined(__APPLE__) + // uid / gid + int uid = info[6].As().Int32Value(); + int gid = info[7].As().Int32Value(); +#endif + + // termios + struct termios t = termios(); + struct termios *term = &t; + term->c_iflag = ICRNL | IXON | IXANY | IMAXBEL | BRKINT; + if (info[8].As().Value()) { +#if defined(IUTF8) + term->c_iflag |= IUTF8; +#endif + } + term->c_oflag = OPOST | ONLCR; + term->c_cflag = CREAD | CS8 | HUPCL; + term->c_lflag = ICANON | ISIG | IEXTEN | ECHO | ECHOE | ECHOK | ECHOKE | ECHOCTL; + + term->c_cc[VEOF] = 4; + term->c_cc[VEOL] = -1; + term->c_cc[VEOL2] = -1; + term->c_cc[VERASE] = 0x7f; + term->c_cc[VWERASE] = 23; + term->c_cc[VKILL] = 21; + term->c_cc[VREPRINT] = 18; + term->c_cc[VINTR] = 3; + term->c_cc[VQUIT] = 0x1c; + term->c_cc[VSUSP] = 26; + term->c_cc[VSTART] = 17; + term->c_cc[VSTOP] = 19; + term->c_cc[VLNEXT] = 22; + term->c_cc[VDISCARD] = 15; + term->c_cc[VMIN] = 1; + term->c_cc[VTIME] = 0; + + #if (__APPLE__) + term->c_cc[VDSUSP] = 25; + term->c_cc[VSTATUS] = 20; + #endif + + cfsetispeed(term, B38400); + cfsetospeed(term, B38400); + + // helperPath + std::string helper_path = info[9].As(); + + pid_t pid; + int master; +#if defined(__APPLE__) + int argc = argv_.Length(); + int argl = argc + 4; + std::unique_ptr argv_unique_ptr(new char *[argl], DelBuf(argl)); + char **argv = argv_unique_ptr.get(); + argv[0] = strdup(helper_path.c_str()); + argv[1] = strdup(cwd_.c_str()); + argv[2] = strdup(file.c_str()); + argv[argl - 1] = NULL; + for (int i = 0; i < argc; i++) { + std::string arg = argv_.Get(i).As(); + argv[i + 3] = strdup(arg.c_str()); + } + + int err = -1; + pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err); + if (err != 0) { + throw Napi::Error::New(napiEnv, "posix_spawnp failed."); + } + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } +#else + int argc = argv_.Length(); + int argl = argc + 2; + std::unique_ptr argv_unique_ptr(new char *[argl], DelBuf(argl)); + char** argv = argv_unique_ptr.get(); + argv[0] = strdup(file.c_str()); + argv[argl - 1] = NULL; + for (int i = 0; i < argc; i++) { + std::string arg = argv_.Get(i).As(); + argv[i + 1] = strdup(arg.c_str()); + } + + sigset_t newmask, oldmask; + struct sigaction sig_action; + // temporarily block all signals + // this is needed due to a race condition in openpty + // and to avoid running signal handlers in the child + // before exec* happened + sigfillset(&newmask); + pthread_sigmask(SIG_SETMASK, &newmask, &oldmask); + + pid = forkpty(&master, nullptr, static_cast(term), static_cast(&winp)); + + if (!pid) { + // remove all signal handler from child + sig_action.sa_handler = SIG_DFL; + sig_action.sa_flags = 0; + sigemptyset(&sig_action.sa_mask); + for (int i = 0 ; i < NSIG ; i++) { // NSIG is a macro for all signals + 1 + sigaction(i, &sig_action, NULL); + } + } + + // reenable signals + pthread_sigmask(SIG_SETMASK, &oldmask, NULL); + + switch (pid) { + case -1: + throw Napi::Error::New(napiEnv, "forkpty(3) failed."); + case 0: + if (strlen(cwd_.c_str())) { + if (chdir(cwd_.c_str()) == -1) { + perror("chdir(2) failed."); + _exit(1); + } + } + + if (uid != -1 && gid != -1) { + if (setgid(gid) == -1) { + perror("setgid(2) failed."); + _exit(1); + } + if (setuid(uid) == -1) { + perror("setuid(2) failed."); + _exit(1); + } + } + + { + char **old = environ; + environ = env; + execvp(argv[0], argv); + environ = old; + perror("execvp(3) failed."); + _exit(1); + } + default: + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + } +#endif + + Napi::Object obj = Napi::Object::New(napiEnv); + obj.Set("fd", Napi::Number::New(napiEnv, master)); + obj.Set("pid", Napi::Number::New(napiEnv, pid)); + obj.Set("pty", Napi::String::New(napiEnv, ptsname(master))); + + // Set up process exit callback. + Napi::Function cb = info[10].As(); + SetupExitCallback(napiEnv, cb, pid); + return obj; +} + +Napi::Value PtyOpen(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 2 || + !info[0].IsNumber() || + !info[1].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.open(cols, rows)"); + } + + // size + struct winsize winp; + winp.ws_col = info[0].As().Int32Value(); + winp.ws_row = info[1].As().Int32Value(); + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + + // pty + int master, slave; + int ret = openpty(&master, &slave, nullptr, NULL, static_cast(&winp)); + + if (ret == -1) { + throw Napi::Error::New(env, "openpty(3) failed."); + } + + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(env, "Could not set master fd to nonblocking."); + } + + if (pty_nonblock(slave) == -1) { + throw Napi::Error::New(env, "Could not set slave fd to nonblocking."); + } + + Napi::Object obj = Napi::Object::New(env); + obj.Set("master", Napi::Number::New(env, master)); + obj.Set("slave", Napi::Number::New(env, slave)); + obj.Set("pty", Napi::String::New(env, ptsname(master))); + + return obj; +} + +Napi::Value PtyResize(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 3 || + !info[0].IsNumber() || + !info[1].IsNumber() || + !info[2].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.resize(fd, cols, rows)"); + } + + int fd = info[0].As().Int32Value(); + + struct winsize winp; + winp.ws_col = info[1].As().Int32Value(); + winp.ws_row = info[2].As().Int32Value(); + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + + if (ioctl(fd, TIOCSWINSZ, &winp) == -1) { + switch (errno) { + case EBADF: + throw Napi::Error::New(env, "ioctl(2) failed, EBADF"); + case EFAULT: + throw Napi::Error::New(env, "ioctl(2) failed, EFAULT"); + case EINVAL: + throw Napi::Error::New(env, "ioctl(2) failed, EINVAL"); + case ENOTTY: + throw Napi::Error::New(env, "ioctl(2) failed, ENOTTY"); + } + throw Napi::Error::New(env, "ioctl(2) failed"); + } + + return env.Undefined(); +} + +/** + * Foreground Process Name + */ +Napi::Value PtyGetProc(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + +#if defined(__APPLE__) + if (info.Length() != 1 || + !info[0].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.process(pid)"); + } + + int fd = info[0].As().Int32Value(); + char *name = pty_getproc(fd); +#else + if (info.Length() != 2 || + !info[0].IsNumber() || + !info[1].IsString()) { + throw Napi::Error::New(env, "Usage: pty.process(fd, tty)"); + } + + int fd = info[0].As().Int32Value(); + + std::string tty_ = info[1].As(); + char *tty = strdup(tty_.c_str()); + char *name = pty_getproc(fd, tty); + free(tty); +#endif + + if (name == NULL) { + return env.Undefined(); + } + + Napi::String name_ = Napi::String::New(env, name); + free(name); + return name_; +} + +/** + * Nonblocking FD + */ + +static int +pty_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags == -1) return -1; + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +/** + * pty_getproc + * Taken from tmux. + */ + +// Taken from: tmux (http://tmux.sourceforge.net/) +// Copyright (c) 2009 Nicholas Marriott +// Copyright (c) 2009 Joshua Elsasser +// Copyright (c) 2009 Todd Carson +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER +// IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +// OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +#if defined(__linux__) + +static char * +pty_getproc(int fd, char *tty) { + FILE *f; + char *path, *buf; + size_t len; + int ch; + pid_t pgrp; + int r; + + if ((pgrp = tcgetpgrp(fd)) == -1) { + return NULL; + } + + r = asprintf(&path, "/proc/%lld/cmdline", (long long)pgrp); + if (r == -1 || path == NULL) return NULL; + + if ((f = fopen(path, "r")) == NULL) { + free(path); + return NULL; + } + + free(path); + + len = 0; + buf = NULL; + while ((ch = fgetc(f)) != EOF) { + if (ch == '\0') break; + buf = (char *)realloc(buf, len + 2); + if (buf == NULL) return NULL; + buf[len++] = ch; + } + + if (buf != NULL) { + buf[len] = '\0'; + } + + fclose(f); + return buf; +} + +#elif defined(__APPLE__) + +static char * +pty_getproc(int fd) { + int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, 0 }; + size_t size; + struct kinfo_proc kp; + + if ((mib[3] = tcgetpgrp(fd)) == -1) { + return NULL; + } + + size = sizeof kp; + if (sysctl(mib, 4, &kp, &size, NULL, 0) == -1) { + return NULL; + } + + if (size != (sizeof kp) || *kp.kp_proc.p_comm == '\0') { + return NULL; + } + + return strdup(kp.kp_proc.p_comm); +} + +#else + +static char * +pty_getproc(int fd, char *tty) { + return NULL; +} + +#endif + +#if defined(__APPLE__) +static void +pty_posix_spawn(char** argv, char** env, + const struct termios *termp, + const struct winsize *winp, + int* master, + pid_t* pid, + int* err) { + int low_fds[3]; + size_t count = 0; + + for (; count < 3; count++) { + low_fds[count] = posix_openpt(O_RDWR); + if (low_fds[count] >= STDERR_FILENO) + break; + } + + int flags = POSIX_SPAWN_CLOEXEC_DEFAULT | + POSIX_SPAWN_SETSIGDEF | + POSIX_SPAWN_SETSIGMASK | + POSIX_SPAWN_SETSID; + *master = posix_openpt(O_RDWR); + if (*master == -1) { + return; + } + + int res = grantpt(*master) || unlockpt(*master); + if (res == -1) { + return; + } + + // Use TIOCPTYGNAME instead of ptsname() to avoid threading problems. + int slave; + char slave_pty_name[128]; + res = ioctl(*master, TIOCPTYGNAME, slave_pty_name); + if (res == -1) { + return; + } + + slave = open(slave_pty_name, O_RDWR | O_NOCTTY); + if (slave == -1) { + return; + } + + if (termp) { + res = tcsetattr(slave, TCSANOW, termp); + if (res == -1) { + return; + }; + } + + if (winp) { + res = ioctl(slave, TIOCSWINSZ, winp); + if (res == -1) { + return; + } + } + + posix_spawn_file_actions_t acts; + posix_spawn_file_actions_init(&acts); + posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO); + posix_spawn_file_actions_addclose(&acts, slave); + posix_spawn_file_actions_addclose(&acts, *master); + + posix_spawnattr_t attrs; + posix_spawnattr_init(&attrs); + *err = posix_spawnattr_setflags(&attrs, flags); + if (*err != 0) { + goto done; + } + + sigset_t signal_set; + /* Reset all signal the child to their default behavior */ + sigfillset(&signal_set); + *err = posix_spawnattr_setsigdefault(&attrs, &signal_set); + if (*err != 0) { + goto done; + } + + /* Reset the signal mask for all signals */ + sigemptyset(&signal_set); + *err = posix_spawnattr_setsigmask(&attrs, &signal_set); + if (*err != 0) { + goto done; + } + + do + *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env); + while (*err == EINTR); +done: + posix_spawn_file_actions_destroy(&acts); + posix_spawnattr_destroy(&attrs); + + for (; count > 0; count--) { + close(low_fds[count]); + } +} +#endif + +/** + * Init + */ + +Napi::Object init(Napi::Env env, Napi::Object exports) { + exports.Set("fork", Napi::Function::New(env, PtyFork)); + exports.Set("open", Napi::Function::New(env, PtyOpen)); + exports.Set("resize", Napi::Function::New(env, PtyResize)); + exports.Set("process", Napi::Function::New(env, PtyGetProc)); + return exports; +} + +NODE_API_MODULE(NODE_GYP_MODULE_NAME, init) diff --git a/services/edge-agent/node_modules/node-pty/src/unix/spawn-helper.cc b/services/edge-agent/node_modules/node-pty/src/unix/spawn-helper.cc new file mode 100644 index 00000000..8066328f --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/unix/spawn-helper.cc @@ -0,0 +1,23 @@ +#include +#include +#include +#include + +int main (int argc, char** argv) { + char *slave_path = ttyname(STDIN_FILENO); + // open implicit attaches a process to a terminal device if: + // - process has no controlling terminal yet + // - O_NOCTTY is not set + close(open(slave_path, O_RDWR)); + + char *cwd = argv[1]; + char *file = argv[2]; + argv = &argv[2]; + + if (strlen(cwd) && chdir(cwd) == -1) { + _exit(1); + } + + execvp(file, argv); + return 1; +} diff --git a/services/edge-agent/node_modules/node-pty/src/unixTerminal.test.ts b/services/edge-agent/node_modules/node-pty/src/unixTerminal.test.ts new file mode 100644 index 00000000..69647468 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/unixTerminal.test.ts @@ -0,0 +1,367 @@ +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import { UnixTerminal } from './unixTerminal'; +import * as assert from 'assert'; +import * as cp from 'child_process'; +import * as path from 'path'; +import * as tty from 'tty'; +import * as fs from 'fs'; +import { constants } from 'os'; +import { pollUntil } from './testUtils.test'; +import { pid } from 'process'; + +const FIXTURES_PATH = path.normalize(path.join(__dirname, '..', 'fixtures', 'utf8-character.txt')); + +if (process.platform !== 'win32') { + describe('UnixTerminal', () => { + describe('Constructor', () => { + it('should set a valid pts name', () => { + const term = new UnixTerminal('/bin/bash', [], {}); + let regExp: RegExp | undefined; + if (process.platform === 'linux') { + // https://linux.die.net/man/4/pts + regExp = /^\/dev\/pts\/\d+$/; + } + if (process.platform === 'darwin') { + // https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man4/pty.4.html + regExp = /^\/dev\/tty[p-sP-S][a-z0-9]+$/; + } + if (regExp) { + assert.ok(regExp.test(term.ptsName), '"' + term.ptsName + '" should match ' + regExp.toString()); + } + assert.ok(tty.isatty(term.fd)); + }); + }); + + describe('PtyForkEncodingOption', () => { + it('should default to utf8', (done) => { + const term = new UnixTerminal('/bin/bash', [ '-c', `cat "${FIXTURES_PATH}"` ]); + term.on('data', (data) => { + assert.strictEqual(typeof data, 'string'); + assert.strictEqual(data, '\u00E6'); + done(); + }); + }); + it('should return a Buffer when encoding is null', (done) => { + const term = new UnixTerminal('/bin/bash', [ '-c', `cat "${FIXTURES_PATH}"` ], { + encoding: null + }); + term.on('data', (data) => { + assert.strictEqual(typeof data, 'object'); + assert.ok(data instanceof Buffer); + assert.strictEqual(0xC3, data[0]); + assert.strictEqual(0xA6, data[1]); + done(); + }); + }); + it('should support other encodings', (done) => { + const text = 'test æ!'; + const term = new UnixTerminal(undefined, ['-c', 'echo "' + text + '"'], { + encoding: 'base64' + }); + let buffer = ''; + term.onData((data) => { + assert.strictEqual(typeof data, 'string'); + buffer += data; + }); + term.onExit(() => { + assert.strictEqual(Buffer.alloc(8, buffer, 'base64').toString().replace('\r', '').replace('\n', ''), text); + done(); + }); + }); + }); + + describe('open', () => { + let term: UnixTerminal; + + afterEach(() => { + if (term) { + term.slave!.destroy(); + term.master!.destroy(); + } + }); + + it('should open a pty with access to a master and slave socket', (done) => { + term = UnixTerminal.open({}); + + let slavebuf = ''; + term.slave!.on('data', (data) => { + slavebuf += data; + }); + + let masterbuf = ''; + term.master!.on('data', (data) => { + masterbuf += data; + }); + + pollUntil(() => { + if (masterbuf === 'slave\r\nmaster\r\n' && slavebuf === 'master\n') { + done(); + return true; + } + return false; + }, 200, 10); + + term.slave!.write('slave\n'); + term.master!.write('master\n'); + }); + }); + describe('close', () => { + const term = new UnixTerminal('node'); + it('should exit when terminal is destroyed programmatically', (done) => { + term.on('exit', (code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, constants.signals.SIGHUP); + done(); + }); + term.destroy(); + }); + }); + describe('signals in parent and child', () => { + it('SIGINT - custom in parent and child', done => { + // this test is cumbersome - we have to run it in a sub process to + // see behavior of SIGINT handlers + const data = ` + var pty = require('./lib/index'); + process.on('SIGINT', () => console.log('SIGINT in parent')); + var ptyProcess = pty.spawn('node', ['-e', 'process.on("SIGINT", ()=>console.log("SIGINT in child"));setTimeout(() => null, 300);'], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + ptyProcess.on('data', function (data) { + console.log(data); + }); + setTimeout(() => null, 500); + console.log('ready', ptyProcess.pid); + `; + const buffer: string[] = []; + const p = cp.spawn('node', ['-e', data]); + let sub = ''; + p.stdout.on('data', (data) => { + if (!data.toString().indexOf('ready')) { + sub = data.toString().split(' ')[1].slice(0, -1); + setTimeout(() => { + process.kill(parseInt(sub), 'SIGINT'); // SIGINT to child + p.kill('SIGINT'); // SIGINT to parent + }, 200); + } else { + buffer.push(data.toString().replace(/^\s+|\s+$/g, '')); + } + }); + p.on('close', () => { + // handlers in parent and child should have been triggered + assert.strictEqual(buffer.indexOf('SIGINT in child') !== -1, true); + assert.strictEqual(buffer.indexOf('SIGINT in parent') !== -1, true); + done(); + }); + }); + it('SIGINT - custom in parent, default in child', done => { + // this tests the original idea of the signal(...) change in pty.cc: + // to make sure the SIGINT handler of a pty child is reset to default + // and does not interfere with the handler in the parent + const data = ` + var pty = require('./lib/index'); + process.on('SIGINT', () => console.log('SIGINT in parent')); + var ptyProcess = pty.spawn('node', ['-e', 'setTimeout(() => console.log("should not be printed"), 300);'], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + ptyProcess.on('data', function (data) { + console.log(data); + }); + setTimeout(() => null, 500); + console.log('ready', ptyProcess.pid); + `; + const buffer: string[] = []; + const p = cp.spawn('node', ['-e', data]); + let sub = ''; + p.stdout.on('data', (data) => { + if (!data.toString().indexOf('ready')) { + sub = data.toString().split(' ')[1].slice(0, -1); + setTimeout(() => { + process.kill(parseInt(sub), 'SIGINT'); // SIGINT to child + p.kill('SIGINT'); // SIGINT to parent + }, 200); + } else { + buffer.push(data.toString().replace(/^\s+|\s+$/g, '')); + } + }); + p.on('close', () => { + // handlers in parent and child should have been triggered + assert.strictEqual(buffer.indexOf('should not be printed') !== -1, false); + assert.strictEqual(buffer.indexOf('SIGINT in parent') !== -1, true); + done(); + }); + }); + it('SIGHUP default (child only)', done => { + const term = new UnixTerminal('node', [ '-e', ` + console.log('ready'); + setTimeout(()=>console.log('timeout'), 200);` + ]); + let buffer = ''; + term.on('data', (data) => { + if (data === 'ready\r\n') { + term.kill(); + } else { + buffer += data; + } + }); + term.on('exit', () => { + // no timeout in buffer + assert.strictEqual(buffer, ''); + done(); + }); + }); + it('SIGUSR1 - custom in parent and child', done => { + let pHandlerCalled = 0; + const handleSigUsr = function(h: any): any { + return function(): void { + pHandlerCalled += 1; + process.removeListener('SIGUSR1', h); + }; + }; + process.on('SIGUSR1', handleSigUsr(handleSigUsr)); + + const term = new UnixTerminal('node', [ '-e', ` + process.on('SIGUSR1', () => { + console.log('SIGUSR1 in child'); + }); + console.log('ready'); + setTimeout(()=>null, 200);` + ]); + let buffer = ''; + term.on('data', (data) => { + if (data === 'ready\r\n') { + process.kill(process.pid, 'SIGUSR1'); + term.kill('SIGUSR1'); + } else { + buffer += data; + } + }); + term.on('exit', () => { + // should have called both handlers and only once + assert.strictEqual(pHandlerCalled, 1); + assert.strictEqual(buffer, 'SIGUSR1 in child\r\n'); + done(); + }); + }); + }); + describe('spawn', () => { + if (process.platform === 'darwin') { + it('should return the name of the process', (done) => { + const term = new UnixTerminal('/bin/echo'); + assert.strictEqual(term.process, '/bin/echo'); + term.on('exit', () => done()); + term.destroy(); + }); + it('should return the name of the sub process', (done) => { + const data = ` + var pty = require('./lib/index'); + var ptyProcess = pty.spawn('zsh', ['-c', 'python3'], { + env: process.env + }); + ptyProcess.on('data', function (data) { + if (ptyProcess.process === 'Python') { + console.log('title', ptyProcess.process); + console.log('ready', ptyProcess.pid); + } + }); + `; + const p = cp.spawn('node', ['-e', data]); + let sub = ''; + let pid = ''; + p.stdout.on('data', (data) => { + if (!data.toString().indexOf('title')) { + sub = data.toString().split(' ')[1].slice(0, -1); + } else if (!data.toString().indexOf('ready')) { + pid = data.toString().split(' ')[1].slice(0, -1); + process.kill(parseInt(pid), 'SIGINT'); + p.kill('SIGINT'); + } + }); + p.on('exit', () => { + assert.notStrictEqual(pid, ''); + assert.strictEqual(sub, 'Python'); + done(); + }); + }); + it('should close on exec', (done) => { + const data = ` + var pty = require('./lib/index'); + var ptyProcess = pty.spawn('node', ['-e', 'setTimeout(() => console.log("hello from terminal"), 300);']); + ptyProcess.on('data', function (data) { + console.log(data); + }); + setTimeout(() => null, 500); + console.log('ready', ptyProcess.pid); + `; + const buffer: string[] = []; + const readFd = fs.openSync(FIXTURES_PATH, 'r'); + const p = cp.spawn('node', ['-e', data], { + stdio: ['ignore', 'pipe', 'pipe', readFd] + }); + let sub = ''; + p.stdout!.on('data', (data) => { + if (!data.toString().indexOf('ready')) { + sub = data.toString().split(' ')[1].slice(0, -1); + try { + fs.statSync(`/proc/${sub}/fd/${readFd}`); + done('not reachable'); + } catch (error) { + assert.notStrictEqual(error.message.indexOf('ENOENT'), -1); + } + setTimeout(() => { + process.kill(parseInt(sub), 'SIGINT'); // SIGINT to child + p.kill('SIGINT'); // SIGINT to parent + }, 200); + } else { + buffer.push(data.toString().replace(/^\s+|\s+$/g, '')); + } + }); + p.on('close', () => { + done(); + }); + }); + } + it('should handle exec() errors', (done) => { + const term = new UnixTerminal('/bin/bogus.exe', []); + term.on('exit', (code, signal) => { + assert.strictEqual(code, 1); + done(); + }); + }); + it('should handle chdir() errors', (done) => { + const term = new UnixTerminal('/bin/echo', [], { cwd: '/nowhere' }); + term.on('exit', (code, signal) => { + assert.strictEqual(code, 1); + done(); + }); + }); + it('should not leak child process', (done) => { + const count = cp.execSync('ps -ax | grep node | wc -l'); + const term = new UnixTerminal('node', [ '-e', ` + console.log('ready'); + setTimeout(()=>console.log('timeout'), 200);` + ]); + term.on('data', async (data) => { + if (data === 'ready\r\n') { + process.kill(term.pid, 'SIGINT'); + await setTimeout(() => null, 1000); + const newCount = cp.execSync('ps -ax | grep node | wc -l'); + assert.strictEqual(count.toString(), newCount.toString()); + done(); + } + }); + }); + }); + }); +} diff --git a/services/edge-agent/node_modules/node-pty/src/unixTerminal.ts b/services/edge-agent/node_modules/node-pty/src/unixTerminal.ts new file mode 100644 index 00000000..98733dc0 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/unixTerminal.ts @@ -0,0 +1,388 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +import * as fs from 'fs'; +import * as net from 'net'; +import * as path from 'path'; +import * as tty from 'tty'; +import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from './terminal'; +import { IProcessEnv, IPtyForkOptions, IPtyOpenOptions } from './interfaces'; +import { ArgvOrCommandLine, IDisposable } from './types'; +import { assign, loadNativeModule } from './utils'; + +const native = loadNativeModule('pty'); +const pty: IUnixNative = native.module; +let helperPath = native.dir + '/spawn-helper'; +helperPath = path.resolve(__dirname, helperPath); +helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); + +const DEFAULT_FILE = 'sh'; +const DEFAULT_NAME = 'xterm'; +const DESTROY_SOCKET_TIMEOUT_MS = 200; + +export class UnixTerminal extends Terminal { + protected _fd: number; + protected _pty: string; + + protected _file: string; + protected _name: string; + + protected _readable: boolean; + protected _writable: boolean; + + private _boundClose: boolean = false; + private _emittedClose: boolean = false; + + private _writeStream: CustomWriteStream; + + private _master: net.Socket | undefined; + private _slave: net.Socket | undefined; + + public get master(): net.Socket | undefined { return this._master; } + public get slave(): net.Socket | undefined { return this._slave; } + + constructor(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions) { + super(opt); + + if (typeof args === 'string') { + throw new Error('args as a string is not supported on unix.'); + } + + // Initialize arguments + args = args || []; + file = file || DEFAULT_FILE; + opt = opt || {}; + opt.env = opt.env || process.env; + + this._cols = opt.cols || DEFAULT_COLS; + this._rows = opt.rows || DEFAULT_ROWS; + const uid = opt.uid ?? -1; + const gid = opt.gid ?? -1; + const env: IProcessEnv = assign({}, opt.env); + + if (opt.env === process.env) { + this._sanitizeEnv(env); + } + + const cwd = opt.cwd || process.cwd(); + env.PWD = cwd; + const name = opt.name || env.TERM || DEFAULT_NAME; + env.TERM = name; + const parsedEnv = this._parseEnv(env); + + const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); + + const onexit = (code: number, signal: number): void => { + // XXX Sometimes a data event is emitted after exit. Wait til socket is + // destroyed. + if (!this._emittedClose) { + if (this._boundClose) { + return; + } + this._boundClose = true; + // From macOS High Sierra 10.13.2 sometimes the socket never gets + // closed. A timeout is applied here to avoid the terminal never being + // destroyed when this occurs. + let timeout: NodeJS.Timeout | null = setTimeout(() => { + timeout = null; + // Destroying the socket now will cause the close event to fire + this._socket.destroy(); + }, DESTROY_SOCKET_TIMEOUT_MS); + this.once('close', () => { + if (timeout !== null) { + clearTimeout(timeout); + } + this.emit('exit', code, signal); + }); + return; + } + this.emit('exit', code, signal); + }; + + // fork + const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit); + + this._socket = new tty.ReadStream(term.fd); + if (encoding !== null) { + this._socket.setEncoding(encoding); + } + this._writeStream = new CustomWriteStream(term.fd, (encoding || undefined) as BufferEncoding); + + // setup + this._socket.on('error', (err: any) => { + // NOTE: fs.ReadStream gets EAGAIN twice at first: + if (err.code) { + if (~err.code.indexOf('EAGAIN')) { + return; + } + } + + // close + this._close(); + // EIO on exit from fs.ReadStream: + if (!this._emittedClose) { + this._emittedClose = true; + this.emit('close'); + } + + // EIO, happens when someone closes our child process: the only process in + // the terminal. + // node < 0.6.14: errno 5 + // node >= 0.6.14: read EIO + if (err.code) { + if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) { + return; + } + } + + // throw anything else + if (this.listeners('error').length < 2) { + throw err; + } + }); + + this._pid = term.pid; + this._fd = term.fd; + this._pty = term.pty; + + this._file = file; + this._name = name; + + this._readable = true; + this._writable = true; + + this._socket.on('close', () => { + if (this._emittedClose) { + return; + } + this._emittedClose = true; + this._close(); + this.emit('close'); + }); + + this._forwardEvents(); + } + + protected _write(data: string | Buffer): void { + this._writeStream.write(data); + } + + /* Accessors */ + get fd(): number { return this._fd; } + get ptsName(): string { return this._pty; } + + /** + * openpty + */ + + public static open(opt: IPtyOpenOptions): UnixTerminal { + const self: UnixTerminal = Object.create(UnixTerminal.prototype); + opt = opt || {}; + + if (arguments.length > 1) { + opt = { + cols: arguments[1], + rows: arguments[2] + }; + } + + const cols = opt.cols || DEFAULT_COLS; + const rows = opt.rows || DEFAULT_ROWS; + const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); + + // open + const term: IUnixOpenProcess = pty.open(cols, rows); + + self._master = new tty.ReadStream(term.master); + if (encoding !== null) { + self._master.setEncoding(encoding); + } + self._master.resume(); + + self._slave = new tty.ReadStream(term.slave); + if (encoding !== null) { + self._slave.setEncoding(encoding); + } + self._slave.resume(); + + self._socket = self._master; + self._pid = -1; + self._fd = term.master; + self._pty = term.pty; + + self._file = process.argv[0] || 'node'; + self._name = process.env.TERM || ''; + + self._readable = true; + self._writable = true; + + self._socket.on('error', err => { + self._close(); + if (self.listeners('error').length < 2) { + throw err; + } + }); + + self._socket.on('close', () => { + self._close(); + }); + + return self; + } + + public destroy(): void { + this._close(); + + // Need to close the read stream so node stops reading a dead file + // descriptor. Then we can safely SIGHUP the shell. + this._socket.once('close', () => { + this.kill('SIGHUP'); + }); + + this._socket.destroy(); + this._writeStream.dispose(); + } + + public kill(signal?: string): void { + try { + process.kill(this.pid, signal || 'SIGHUP'); + } catch (e) { /* swallow */ } + } + + /** + * Gets the name of the process. + */ + public get process(): string { + if (process.platform === 'darwin') { + const title = pty.process(this._fd); + return (title !== 'kernel_task') ? title : this._file; + } + + return pty.process(this._fd, this._pty) || this._file; + } + + /** + * TTY + */ + + public resize(cols: number, rows: number): void { + if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { + throw new Error('resizing must be done using positive cols and rows'); + } + pty.resize(this._fd, cols, rows); + this._cols = cols; + this._rows = rows; + } + + public clear(): void { + + } + + private _sanitizeEnv(env: IProcessEnv): void { + // Make sure we didn't start our server from inside tmux. + delete env['TMUX']; + delete env['TMUX_PANE']; + + // Make sure we didn't start our server from inside screen. + // http://web.mit.edu/gnu/doc/html/screen_20.html + delete env['STY']; + delete env['WINDOW']; + + // Delete some variables that might confuse our terminal. + delete env['WINDOWID']; + delete env['TERMCAP']; + delete env['COLUMNS']; + delete env['LINES']; + } +} + +interface IWriteTask { + /** The buffer being written. */ + buffer: Buffer; + /** The current offset of not yet written data. */ + offset: number; +} + +/** + * A custom write stream that writes directly to a file descriptor with proper + * handling of backpressure and errors. This avoids some event loop exhaustion + * issues that can occur when using the standard APIs in Node. + */ +class CustomWriteStream implements IDisposable { + + private readonly _writeQueue: IWriteTask[] = []; + private _writeImmediate: NodeJS.Immediate | undefined; + + constructor( + private readonly _fd: number, + private readonly _encoding: BufferEncoding + ) { + } + + dispose(): void { + clearImmediate(this._writeImmediate); + this._writeImmediate = undefined; + } + + write(data: string | Buffer): void { + // Writes are put in a queue and processed asynchronously in order to handle + // backpressure from the kernel buffer. + const buffer = typeof data === 'string' + ? Buffer.from(data, this._encoding) + : Buffer.from(data); + + if (buffer.byteLength !== 0) { + this._writeQueue.push({ buffer, offset: 0 }); + if (this._writeQueue.length === 1) { + this._processWriteQueue(); + } + } + } + + private _processWriteQueue(): void { + this._writeImmediate = undefined; + + if (this._writeQueue.length === 0) { + return; + } + + const task = this._writeQueue[0]; + + // Write to the underlying file descriptor and handle it directly, rather + // than using the `net.Socket`/`tty.WriteStream` wrappers which swallow and + // mask errors like EAGAIN and can cause the thread to block indefinitely. + fs.write(this._fd, task.buffer, task.offset, (err, written) => { + if (err) { + if ('code' in err && err.code === 'EAGAIN') { + // `setImmediate` is used to yield to the event loop and re-attempt + // the write later. + this._writeImmediate = setImmediate(() => this._processWriteQueue()); + } else { + // Stop processing immediately on unexpected error and log + this._writeQueue.length = 0; + console.error('Unhandled pty write error', err); + } + return; + } + + task.offset += written; + if (task.offset >= task.buffer.byteLength) { + this._writeQueue.shift(); + } + + // Since there is more room in the kernel buffer, we can continue to write + // until we hit EAGAIN or exhaust the queue. + // + // Note that old versions of bash, like v3.2 which ships in macOS, appears + // to have a bug in its readline implementation that causes data + // corruption when writes to the pty happens too quickly. Instead of + // trying to workaround that we just accept it so that large pastes are as + // fast as possible. + // Context: https://github.com/microsoft/node-pty/issues/833 + this._processWriteQueue(); + }); + } +} diff --git a/services/edge-agent/node_modules/node-pty/src/utils.ts b/services/edge-agent/node_modules/node-pty/src/utils.ts new file mode 100644 index 00000000..81a70c77 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/utils.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +export function assign(target: any, ...sources: any[]): any { + sources.forEach(source => Object.keys(source).forEach(key => target[key] = source[key])); + return target; +} + + +export function loadNativeModule(name: string): {dir: string, module: any} { + // Check build, debug, and then prebuilds. + const dirs = ['build/Release', 'build/Debug', `prebuilds/${process.platform}-${process.arch}`]; + // Check relative to the parent dir for unbundled and then the current dir for bundled + const relative = ['..', '.']; + let lastError: unknown; + for (const d of dirs) { + for (const r of relative) { + const dir = `${r}/${d}/`; + try { + return { dir, module: require(`${dir}/${name}.node`) }; + } catch (e) { + lastError = e; + } + } + } + throw new Error(`Failed to load native module: ${name}.node, checked: ${dirs.join(', ')}: ${lastError}`); +} diff --git a/services/edge-agent/node_modules/node-pty/src/win/conpty.cc b/services/edge-agent/node_modules/node-pty/src/win/conpty.cc new file mode 100644 index 00000000..7b286d3d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/win/conpty.cc @@ -0,0 +1,583 @@ +/** + * Copyright (c) 2013-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + * + * pty.cc: + * This file is responsible for starting processes + * with pseudo-terminal file descriptors. + */ + +#define _WIN32_WINNT 0x600 + +#define NODE_ADDON_API_DISABLE_DEPRECATED +#include +#include +#include // PathCombine, PathIsRelative +#include +#include +#include +#include +#include +#include +#include +#include "path_util.h" +#include "conpty.h" + +// Taken from the RS5 Windows SDK, but redefined here in case we're targeting <= 17134 +#ifndef PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE +#define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE \ + ProcThreadAttributeValue(22, FALSE, TRUE, FALSE) + +typedef VOID* HPCON; +typedef HRESULT (__stdcall *PFNCREATEPSEUDOCONSOLE)(COORD c, HANDLE hIn, HANDLE hOut, DWORD dwFlags, HPCON* phpcon); +typedef HRESULT (__stdcall *PFNRESIZEPSEUDOCONSOLE)(HPCON hpc, COORD newSize); +typedef HRESULT (__stdcall *PFNCLEARPSEUDOCONSOLE)(HPCON hpc); +typedef void (__stdcall *PFNCLOSEPSEUDOCONSOLE)(HPCON hpc); +typedef void (__stdcall *PFNRELEASEPSEUDOCONSOLE)(HPCON hpc); + +#endif + +struct pty_baton { + int id; + HANDLE hIn; + HANDLE hOut; + HPCON hpc; + + HANDLE hShell; + + pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {}; +}; + +static std::vector> ptyHandles; +static volatile LONG ptyCounter; + +static pty_baton* get_pty_baton(int id) { + auto it = std::find_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) { + return ptyHandle->id == id; + }); + if (it != ptyHandles.end()) { + return it->get(); + } + return nullptr; +} + +static bool remove_pty_baton(int id) { + auto it = std::remove_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) { + return ptyHandle->id == id; + }); + if (it != ptyHandles.end()) { + ptyHandles.erase(it); + return true; + } + return false; +} + +struct ExitEvent { + int exit_code = 0; +}; + +void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) { + std::thread *th = new std::thread; + // Don't use Napi::AsyncWorker which is limited by UV_THREADPOOL_SIZE. + auto tsfn = Napi::ThreadSafeFunction::New( + env, + cb, // JavaScript function called asynchronously + "SetupExitCallback_resource", // Name + 0, // Unlimited queue + 1, // Only one thread will use this initially + [th](Napi::Env) { // Finalizer used to clean threads up + th->join(); + delete th; + }); + *th = std::thread([tsfn = std::move(tsfn), baton] { + auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) { + cb.Call({Napi::Number::New(env, exit_event->exit_code)}); + delete exit_event; + }; + + ExitEvent *exit_event = new ExitEvent; + // Wait for process to complete. + WaitForSingleObject(baton->hShell, INFINITE); + // Get process exit code. + GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code)); + // Clean up handles + CloseHandle(baton->hShell); + assert(remove_pty_baton(baton->id)); + + auto status = tsfn.BlockingCall(exit_event, callback); // In main thread + switch (status) { + case napi_closing: + break; + + case napi_queue_full: + Napi::Error::Fatal("SetupExitCallback", "Queue was full"); + + case napi_ok: + if (tsfn.Release() != napi_ok) { + Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.Release() failed"); + } + break; + + default: + Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.BlockingCall() failed"); + } + }); +} + +Napi::Error errorWithCode(const Napi::CallbackInfo& info, const char* text) { + std::stringstream errorText; + errorText << text; + errorText << ", error code: " << GetLastError(); + return Napi::Error::New(info.Env(), errorText.str()); +} + +// Returns a new server named pipe. It has not yet been connected. +bool createDataServerPipe(bool write, + std::wstring kind, + HANDLE* hServer, + std::wstring &name, + const std::wstring &pipeName) +{ + *hServer = INVALID_HANDLE_VALUE; + + name = L"\\\\.\\pipe\\" + pipeName + L"-" + kind; + + const DWORD winOpenMode = PIPE_ACCESS_INBOUND | PIPE_ACCESS_OUTBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE/* | FILE_FLAG_OVERLAPPED */; + + SECURITY_ATTRIBUTES sa = {}; + sa.nLength = sizeof(sa); + + *hServer = CreateNamedPipeW( + name.c_str(), + /*dwOpenMode=*/winOpenMode, + /*dwPipeMode=*/PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + /*nMaxInstances=*/1, + /*nOutBufferSize=*/128 * 1024, + /*nInBufferSize=*/128 * 1024, + /*nDefaultTimeOut=*/30000, + &sa); + + return *hServer != INVALID_HANDLE_VALUE; +} + +HANDLE LoadConptyDll(const Napi::CallbackInfo& info, + const bool useConptyDll) +{ + if (!useConptyDll) { + return LoadLibraryExW(L"kernel32.dll", 0, 0); + } + wchar_t currentDir[MAX_PATH]; + HMODULE hModule = GetModuleHandleA("conpty.node"); + if (hModule == NULL) { + throw errorWithCode(info, "Failed to get conpty.node module handle"); + } + DWORD result = GetModuleFileNameW(hModule, currentDir, MAX_PATH); + if (result == 0) { + throw errorWithCode(info, "Failed to get conpty.node module file name"); + } + PathRemoveFileSpecW(currentDir); + wchar_t conptyDllPath[MAX_PATH]; + PathCombineW(conptyDllPath, currentDir, L"conpty\\conpty.dll"); + if (!path_util::file_exists(conptyDllPath)) { + std::wstring errorMessage = L"Cannot find conpty.dll at " + std::wstring(conptyDllPath); + std::string errorMessageStr = path_util::wstring_to_string(errorMessage); + throw errorWithCode(info, errorMessageStr.c_str()); + } + + return LoadLibraryW(conptyDllPath); +} + +HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info, + COORD size, + DWORD dwFlags, + HANDLE *phInput, + HANDLE *phOutput, + HPCON* phPC, + std::wstring& inName, + std::wstring& outName, + const std::wstring& pipeName, + const bool useConptyDll) +{ + HANDLE hLibrary = LoadConptyDll(info, useConptyDll); + DWORD error = GetLastError(); + bool fLoadedDll = hLibrary != nullptr; + if (fLoadedDll) + { + PFNCREATEPSEUDOCONSOLE const pfnCreate = (PFNCREATEPSEUDOCONSOLE)GetProcAddress( + (HMODULE)hLibrary, + useConptyDll ? "ConptyCreatePseudoConsole" : "CreatePseudoConsole"); + if (pfnCreate) + { + if (phPC == NULL || phInput == NULL || phOutput == NULL) + { + return E_INVALIDARG; + } + + bool success = createDataServerPipe(true, L"in", phInput, inName, pipeName); + if (!success) + { + return HRESULT_FROM_WIN32(GetLastError()); + } + success = createDataServerPipe(false, L"out", phOutput, outName, pipeName); + if (!success) + { + return HRESULT_FROM_WIN32(GetLastError()); + } + return pfnCreate(size, *phInput, *phOutput, dwFlags, phPC); + } + else + { + // Failed to find CreatePseudoConsole in kernel32. This is likely because + // the user is not running a build of Windows that supports that API. + // We should fall back to winpty in this case. + return HRESULT_FROM_WIN32(GetLastError()); + } + } else { + throw errorWithCode(info, "Failed to load conpty.dll"); + } + + // Failed to find kernel32. This is realy unlikely - honestly no idea how + // this is even possible to hit. But if it does happen, fall back to winpty. + return HRESULT_FROM_WIN32(GetLastError()); +} + +static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + Napi::Object marshal; + std::wstring inName, outName; + BOOL fSuccess = FALSE; + std::unique_ptr mutableCommandline; + PROCESS_INFORMATION _piClient{}; + + if (info.Length() != 7 || + !info[0].IsString() || + !info[1].IsNumber() || + !info[2].IsNumber() || + !info[3].IsBoolean() || + !info[4].IsString() || + !info[5].IsBoolean() || + !info[6].IsBoolean()) { + throw Napi::Error::New(env, "Usage: pty.startProcess(file, cols, rows, debug, pipeName, inheritCursor, useConptyDll)"); + } + + const std::wstring filename(path_util::to_wstring(info[0].As())); + const SHORT cols = static_cast(info[1].As().Uint32Value()); + const SHORT rows = static_cast(info[2].As().Uint32Value()); + const bool debug = info[3].As().Value(); + const std::wstring pipeName(path_util::to_wstring(info[4].As())); + const bool inheritCursor = info[5].As().Value(); + const bool useConptyDll = info[6].As().Value(); + + // use environment 'Path' variable to determine location of + // the relative path that we have recieved (e.g cmd.exe) + std::wstring shellpath; + if (::PathIsRelativeW(filename.c_str())) { + shellpath = path_util::get_shell_path(filename.c_str()); + } else { + shellpath = filename; + } + + if (shellpath.empty() || !path_util::file_exists(shellpath)) { + std::string why; + why += "File not found: "; + why += path_util::wstring_to_string(shellpath); + throw Napi::Error::New(env, why); + } + + HANDLE hIn, hOut; + HPCON hpc; + HRESULT hr = CreateNamedPipesAndPseudoConsole(info, {cols, rows}, inheritCursor ? 1/*PSEUDOCONSOLE_INHERIT_CURSOR*/ : 0, &hIn, &hOut, &hpc, inName, outName, pipeName, useConptyDll); + + // Restore default handling of ctrl+c + SetConsoleCtrlHandler(NULL, FALSE); + + // Set return values + marshal = Napi::Object::New(env); + + if (SUCCEEDED(hr)) { + // We were able to instantiate a conpty + const int ptyId = InterlockedIncrement(&ptyCounter); + marshal.Set("pty", Napi::Number::New(env, ptyId)); + ptyHandles.emplace_back( + std::make_unique(ptyId, hIn, hOut, hpc)); + } else { + throw Napi::Error::New(env, "Cannot launch conpty"); + } + + std::string inNameStr = path_util::wstring_to_string(inName); + if (inNameStr.empty()) { + throw Napi::Error::New(env, "Failed to initialize conpty conin"); + } + std::string outNameStr = path_util::wstring_to_string(outName); + if (outNameStr.empty()) { + throw Napi::Error::New(env, "Failed to initialize conpty conout"); + } + + marshal.Set("fd", Napi::Number::New(env, -1)); + marshal.Set("conin", Napi::String::New(env, inNameStr)); + marshal.Set("conout", Napi::String::New(env, outNameStr)); + return marshal; +} + +static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + // If we're working with conpty's we need to call ConnectNamedPipe here AFTER + // the Socket has attempted to connect to the other end, then actually + // spawn the process here. + + std::stringstream errorText; + BOOL fSuccess = FALSE; + + if (info.Length() != 6 || + !info[0].IsNumber() || + !info[1].IsString() || + !info[2].IsString() || + !info[3].IsArray() || + !info[4].IsBoolean() || + !info[5].IsFunction()) { + throw Napi::Error::New(env, "Usage: pty.connect(id, cmdline, cwd, env, useConptyDll, exitCallback)"); + } + + const int id = info[0].As().Int32Value(); + const std::wstring cmdline(path_util::to_wstring(info[1].As())); + const std::wstring cwd(path_util::to_wstring(info[2].As())); + const Napi::Array envValues = info[3].As(); + const bool useConptyDll = info[4].As().Value(); + Napi::Function exitCallback = info[5].As(); + + // Fetch pty handle from ID and start process + pty_baton* handle = get_pty_baton(id); + if (!handle) { + throw Napi::Error::New(env, "Invalid pty handle"); + } + + // Prepare command line + std::unique_ptr mutableCommandline = std::make_unique(cmdline.length() + 1); + HRESULT hr = StringCchCopyW(mutableCommandline.get(), cmdline.length() + 1, cmdline.c_str()); + + // Prepare cwd + std::unique_ptr mutableCwd = std::make_unique(cwd.length() + 1); + hr = StringCchCopyW(mutableCwd.get(), cwd.length() + 1, cwd.c_str()); + + // Prepare environment + std::wstring envStr; + if (!envValues.IsEmpty()) { + std::wstring envBlock; + for(uint32_t i = 0; i < envValues.Length(); i++) { + envBlock += path_util::to_wstring(envValues.Get(i).As()); + envBlock += L'\0'; + } + envBlock += L'\0'; + envStr = std::move(envBlock); + } + std::vector envV(envStr.cbegin(), envStr.cend()); + LPWSTR envArg = envV.empty() ? nullptr : envV.data(); + + ConnectNamedPipe(handle->hIn, nullptr); + ConnectNamedPipe(handle->hOut, nullptr); + + // Attach the pseudoconsole to the client application we're creating + STARTUPINFOEXW siEx{0}; + siEx.StartupInfo.cb = sizeof(STARTUPINFOEXW); + siEx.StartupInfo.dwFlags |= STARTF_USESTDHANDLES; + siEx.StartupInfo.hStdError = nullptr; + siEx.StartupInfo.hStdInput = nullptr; + siEx.StartupInfo.hStdOutput = nullptr; + + SIZE_T size = 0; + InitializeProcThreadAttributeList(NULL, 1, 0, &size); + BYTE *attrList = new BYTE[size]; + siEx.lpAttributeList = reinterpret_cast(attrList); + + fSuccess = InitializeProcThreadAttributeList(siEx.lpAttributeList, 1, 0, &size); + if (!fSuccess) { + throw errorWithCode(info, "InitializeProcThreadAttributeList failed"); + } + fSuccess = UpdateProcThreadAttribute(siEx.lpAttributeList, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + handle->hpc, + sizeof(HPCON), + NULL, + NULL); + if (!fSuccess) { + throw errorWithCode(info, "UpdateProcThreadAttribute failed"); + } + + PROCESS_INFORMATION piClient{}; + fSuccess = !!CreateProcessW( + nullptr, + mutableCommandline.get(), + nullptr, // lpProcessAttributes + nullptr, // lpThreadAttributes + false, // bInheritHandles VERY IMPORTANT that this is false + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, // dwCreationFlags + envArg, // lpEnvironment + mutableCwd.get(), // lpCurrentDirectory + &siEx.StartupInfo, // lpStartupInfo + &piClient // lpProcessInformation + ); + if (!fSuccess) { + throw errorWithCode(info, "Cannot create process"); + } + + HANDLE hLibrary = LoadConptyDll(info, useConptyDll); + bool fLoadedDll = hLibrary != nullptr; + if (useConptyDll && fLoadedDll) + { + PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress( + (HMODULE)hLibrary, "ConptyReleasePseudoConsole"); + if (pfnReleasePseudoConsole) + { + pfnReleasePseudoConsole(handle->hpc); + } + } + + // Update handle + handle->hShell = piClient.hProcess; + + // Close the thread handle to avoid resource leak + CloseHandle(piClient.hThread); + // Close the input read and output write handle of the pseudoconsole + CloseHandle(handle->hIn); + CloseHandle(handle->hOut); + + SetupExitCallback(env, exitCallback, handle); + + // Return + auto marshal = Napi::Object::New(env); + marshal.Set("pid", Napi::Number::New(env, piClient.dwProcessId)); + return marshal; +} + +static Napi::Value PtyResize(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 4 || + !info[0].IsNumber() || + !info[1].IsNumber() || + !info[2].IsNumber() || + !info[3].IsBoolean()) { + throw Napi::Error::New(env, "Usage: pty.resize(id, cols, rows, useConptyDll)"); + } + + int id = info[0].As().Int32Value(); + SHORT cols = static_cast(info[1].As().Uint32Value()); + SHORT rows = static_cast(info[2].As().Uint32Value()); + const bool useConptyDll = info[3].As().Value(); + + const pty_baton* handle = get_pty_baton(id); + + if (handle != nullptr) { + HANDLE hLibrary = LoadConptyDll(info, useConptyDll); + bool fLoadedDll = hLibrary != nullptr; + if (fLoadedDll) + { + PFNRESIZEPSEUDOCONSOLE const pfnResizePseudoConsole = (PFNRESIZEPSEUDOCONSOLE)GetProcAddress( + (HMODULE)hLibrary, + useConptyDll ? "ConptyResizePseudoConsole" : "ResizePseudoConsole"); + if (pfnResizePseudoConsole) + { + COORD size = {cols, rows}; + pfnResizePseudoConsole(handle->hpc, size); + } + } + } + + return env.Undefined(); +} + +static Napi::Value PtyClear(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 2 || + !info[0].IsNumber() || + !info[1].IsBoolean()) { + throw Napi::Error::New(env, "Usage: pty.clear(id, useConptyDll)"); + } + + int id = info[0].As().Int32Value(); + const bool useConptyDll = info[1].As().Value(); + + // This API is only supported for conpty.dll as it was introduced in a later version of Windows. + // We could hook it up to point at >= a version of Windows only, but the future is conpty.dll + // anyway. + if (!useConptyDll) { + return env.Undefined(); + } + + const pty_baton* handle = get_pty_baton(id); + + if (handle != nullptr) { + HANDLE hLibrary = LoadConptyDll(info, useConptyDll); + bool fLoadedDll = hLibrary != nullptr; + if (fLoadedDll) + { + PFNCLEARPSEUDOCONSOLE const pfnClearPseudoConsole = (PFNCLEARPSEUDOCONSOLE)GetProcAddress((HMODULE)hLibrary, "ConptyClearPseudoConsole"); + if (pfnClearPseudoConsole) + { + pfnClearPseudoConsole(handle->hpc); + } + } + } + + return env.Undefined(); +} + +static Napi::Value PtyKill(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 2 || + !info[0].IsNumber() || + !info[1].IsBoolean()) { + throw Napi::Error::New(env, "Usage: pty.kill(id, useConptyDll)"); + } + + int id = info[0].As().Int32Value(); + const bool useConptyDll = info[1].As().Value(); + + const pty_baton* handle = get_pty_baton(id); + + if (handle != nullptr) { + HANDLE hLibrary = LoadConptyDll(info, useConptyDll); + bool fLoadedDll = hLibrary != nullptr; + if (fLoadedDll) + { + PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress( + (HMODULE)hLibrary, + useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); + if (pfnClosePseudoConsole) + { + pfnClosePseudoConsole(handle->hpc); + } + } + if (useConptyDll) { + TerminateProcess(handle->hShell, 1); + } + } + + return env.Undefined(); +} + +/** +* Init +*/ + +Napi::Object init(Napi::Env env, Napi::Object exports) { + exports.Set("startProcess", Napi::Function::New(env, PtyStartProcess)); + exports.Set("connect", Napi::Function::New(env, PtyConnect)); + exports.Set("resize", Napi::Function::New(env, PtyResize)); + exports.Set("clear", Napi::Function::New(env, PtyClear)); + exports.Set("kill", Napi::Function::New(env, PtyKill)); + return exports; +}; + +NODE_API_MODULE(NODE_GYP_MODULE_NAME, init); diff --git a/services/edge-agent/node_modules/node-pty/src/win/conpty.h b/services/edge-agent/node_modules/node-pty/src/win/conpty.h new file mode 100644 index 00000000..4cef31c4 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/win/conpty.h @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// This header prototypes the Pseudoconsole symbols from conpty.lib with their original names. +// This is required because we cannot import __imp_CreatePseudoConsole from a static library +// as it doesn't produce an import lib. +// We can't use an /ALTERNATENAME trick because it seems that that name is only resolved when the +// linker cannot otherwise find the symbol. + +#pragma once + +#include + +#ifndef CONPTY_IMPEXP +#define CONPTY_IMPEXP __declspec(dllimport) +#endif + +#ifndef CONPTY_EXPORT +#ifdef __cplusplus +#define CONPTY_EXPORT extern "C" CONPTY_IMPEXP +#else +#define CONPTY_EXPORT extern CONPTY_IMPEXP +#endif +#endif + +#define PSEUDOCONSOLE_RESIZE_QUIRK (2u) +#define PSEUDOCONSOLE_PASSTHROUGH_MODE (8u) + +CONPTY_EXPORT HRESULT WINAPI ConptyCreatePseudoConsole(COORD size, HANDLE hInput, HANDLE hOutput, DWORD dwFlags, HPCON* phPC); +CONPTY_EXPORT HRESULT WINAPI ConptyCreatePseudoConsoleAsUser(HANDLE hToken, COORD size, HANDLE hInput, HANDLE hOutput, DWORD dwFlags, HPCON* phPC); + +CONPTY_EXPORT HRESULT WINAPI ConptyResizePseudoConsole(HPCON hPC, COORD size); +CONPTY_EXPORT HRESULT WINAPI ConptyClearPseudoConsole(HPCON hPC); +CONPTY_EXPORT HRESULT WINAPI ConptyShowHidePseudoConsole(HPCON hPC, bool show); +CONPTY_EXPORT HRESULT WINAPI ConptyReparentPseudoConsole(HPCON hPC, HWND newParent); +CONPTY_EXPORT HRESULT WINAPI ConptyReleasePseudoConsole(HPCON hPC); + +CONPTY_EXPORT VOID WINAPI ConptyClosePseudoConsole(HPCON hPC); +CONPTY_EXPORT VOID WINAPI ConptyClosePseudoConsoleTimeout(HPCON hPC, DWORD dwMilliseconds); + +CONPTY_EXPORT HRESULT WINAPI ConptyPackPseudoConsole(HANDLE hServerProcess, HANDLE hRef, HANDLE hSignal, HPCON* phPC); diff --git a/services/edge-agent/node_modules/node-pty/src/win/conpty_console_list.cc b/services/edge-agent/node_modules/node-pty/src/win/conpty_console_list.cc new file mode 100644 index 00000000..4c8ab393 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/win/conpty_console_list.cc @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2019, Microsoft Corporation (MIT License). + */ + +#define NODE_ADDON_API_DISABLE_DEPRECATED +#include +#include + +static Napi::Value ApiConsoleProcessList(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + if (info.Length() != 1 || + !info[0].IsNumber()) { + throw Napi::Error::New(env, "Usage: getConsoleProcessList(shellPid)"); + } + + const DWORD pid = info[0].As().Uint32Value(); + + if (!FreeConsole()) { + throw Napi::Error::New(env, "FreeConsole failed"); + } + if (!AttachConsole(pid)) { + throw Napi::Error::New(env, "AttachConsole failed"); + } + auto processList = std::vector(64); + auto processCount = GetConsoleProcessList(&processList[0], static_cast(processList.size())); + if (processList.size() < processCount) { + processList.resize(processCount); + processCount = GetConsoleProcessList(&processList[0], static_cast(processList.size())); + } + FreeConsole(); + + Napi::Array result = Napi::Array::New(env); + for (DWORD i = 0; i < processCount; i++) { + result.Set(i, Napi::Number::New(env, processList[i])); + } + return result; +} + +Napi::Object init(Napi::Env env, Napi::Object exports) { + exports.Set("getConsoleProcessList", Napi::Function::New(env, ApiConsoleProcessList)); + return exports; +}; + +NODE_API_MODULE(NODE_GYP_MODULE_NAME, init); diff --git a/services/edge-agent/node_modules/node-pty/src/win/path_util.cc b/services/edge-agent/node_modules/node-pty/src/win/path_util.cc new file mode 100644 index 00000000..764c0330 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/win/path_util.cc @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2013-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +#include +#include // PathCombine +#include +#include "path_util.h" + +namespace path_util { + +std::wstring to_wstring(const Napi::String& str) { + const std::u16string & u16 = str.Utf16Value(); + return std::wstring(u16.begin(), u16.end()); +} + +std::string wstring_to_string(const std::wstring &wide_string) { + if (wide_string.empty()) { + return ""; + } + const auto size_needed = WideCharToMultiByte(CP_UTF8, 0, &wide_string.at(0), (int)wide_string.size(), nullptr, 0, nullptr, nullptr); + if (size_needed <= 0) { + return ""; + } + std::string result(size_needed, 0); + WideCharToMultiByte(CP_UTF8, 0, &wide_string.at(0), (int)wide_string.size(), &result.at(0), size_needed, nullptr, nullptr); + return result; +} + +const char* from_wstring(const wchar_t* wstr) { + int bufferSize = WideCharToMultiByte(CP_UTF8, 0, wstr, -1, NULL, 0, NULL, NULL); + if (bufferSize <= 0) { + return ""; + } + char *output = new char[bufferSize]; + int status = WideCharToMultiByte(CP_UTF8, 0, wstr, -1, output, bufferSize, NULL, NULL); + if (status == 0) { + return ""; + } + return output; +} + +bool file_exists(std::wstring filename) { + DWORD attr = ::GetFileAttributesW(filename.c_str()); + if (attr == INVALID_FILE_ATTRIBUTES || (attr & FILE_ATTRIBUTE_DIRECTORY)) { + return false; + } + return true; +} + +// cmd.exe -> C:\Windows\system32\cmd.exe +std::wstring get_shell_path(std::wstring filename) { + std::wstring shellpath; + + if (file_exists(filename)) { + return shellpath; + } + + wchar_t* buffer_ = new wchar_t[MAX_ENV]; + int read = ::GetEnvironmentVariableW(L"Path", buffer_, MAX_ENV); + if (read) { + std::wstring delimiter = L";"; + size_t pos = 0; + std::vector paths; + std::wstring buffer(buffer_); + while ((pos = buffer.find(delimiter)) != std::wstring::npos) { + paths.push_back(buffer.substr(0, pos)); + buffer.erase(0, pos + delimiter.length()); + } + + const wchar_t *filename_ = filename.c_str(); + + for (size_t i = 0; i < paths.size(); ++i) { + std::wstring path = paths[i]; + wchar_t searchPath[MAX_PATH]; + ::PathCombineW(searchPath, const_cast(path.c_str()), filename_); + + if (searchPath == NULL) { + continue; + } + + if (file_exists(searchPath)) { + shellpath = searchPath; + break; + } + } + } + + delete[] buffer_; + return shellpath; +} + +} // namespace path_util diff --git a/services/edge-agent/node_modules/node-pty/src/win/path_util.h b/services/edge-agent/node_modules/node-pty/src/win/path_util.h new file mode 100644 index 00000000..0be99b6d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/win/path_util.h @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2013-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +#ifndef NODE_PTY_PATH_UTIL_H_ +#define NODE_PTY_PATH_UTIL_H_ + +#define NODE_ADDON_API_DISABLE_DEPRECATED +#include +#include + +#define MAX_ENV 65536 + +namespace path_util { + +std::wstring to_wstring(const Napi::String& str); +std::string wstring_to_string(const std::wstring &wide_string); +const char* from_wstring(const wchar_t* wstr); +bool file_exists(std::wstring filename); +std::wstring get_shell_path(std::wstring filename); + +} // namespace path_util + +#endif // NODE_PTY_PATH_UTIL_H_ diff --git a/services/edge-agent/node_modules/node-pty/src/win/winpty.cc b/services/edge-agent/node_modules/node-pty/src/win/winpty.cc new file mode 100644 index 00000000..3996f8d6 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/win/winpty.cc @@ -0,0 +1,333 @@ +/** + * Copyright (c) 2013-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + * + * pty.cc: + * This file is responsible for starting processes + * with pseudo-terminal file descriptors. + */ + +#define NODE_ADDON_API_DISABLE_DEPRECATED +#include +#include +#include +#include +#include // PathCombine, PathIsRelative +#include +#include +#include +#include +#include +#include + +#include "path_util.h" + +/** +* Misc +*/ +#define WINPTY_DBG_VARIABLE TEXT("WINPTYDBG") + +/** +* winpty +*/ +static std::vector ptyHandles; +static volatile LONG ptyCounter; + +/** +* Helpers +*/ + +/** Keeps track of the handles created by PtyStartProcess */ +static std::map createdHandles; + +static winpty_t *get_pipe_handle(DWORD pid) { + for (size_t i = 0; i < ptyHandles.size(); ++i) { + winpty_t *ptyHandle = ptyHandles[i]; + HANDLE current = winpty_agent_process(ptyHandle); + if (GetProcessId(current) == pid) { + return ptyHandle; + } + } + return nullptr; +} + +static bool remove_pipe_handle(DWORD pid) { + for (size_t i = 0; i < ptyHandles.size(); ++i) { + winpty_t *ptyHandle = ptyHandles[i]; + HANDLE current = winpty_agent_process(ptyHandle); + if (GetProcessId(current) == pid) { + winpty_free(ptyHandle); + ptyHandles.erase(ptyHandles.begin() + i); + ptyHandle = nullptr; + return true; + } + } + return false; +} + +Napi::Error error_with_winpty_msg(const char *generalMsg, winpty_error_ptr_t error_ptr, Napi::Env env) { + std::string why; + why += generalMsg; + why += ": "; + why += path_util::wstring_to_string(winpty_error_msg(error_ptr)); + winpty_error_free(error_ptr); + return Napi::Error::New(env, why); +} + +static Napi::Value PtyGetExitCode(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 1 || + !info[0].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.getExitCode(pid)"); + } + + DWORD pid = info[0].As().Uint32Value(); + HANDLE handle = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid); + if (handle == NULL) { + return Napi::Number::New(env, -1); + } + + DWORD exitCode = 0; + BOOL success = GetExitCodeProcess(handle, &exitCode); + if (success == FALSE) { + exitCode = -1; + } + + CloseHandle(handle); + return Napi::Number::New(env, exitCode); +} + +static Napi::Value PtyGetProcessList(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 1 || + !info[0].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.getProcessList(pid)"); + } + + DWORD pid = info[0].As().Uint32Value(); + winpty_t *pc = get_pipe_handle(pid); + if (pc == nullptr) { + return Napi::Number::New(env, 0); + } + int processList[64]; + const int processCount = 64; + int actualCount = winpty_get_console_process_list(pc, processList, processCount, nullptr); + if (actualCount <= 0) { + return Napi::Number::New(env, 0); + } + Napi::Array result = Napi::Array::New(env, actualCount); + for (int i = 0; i < actualCount; i++) { + result.Set(i, Napi::Number::New(env, processList[i])); + } + return result; +} + +static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 7 || + !info[0].IsString() || + !info[1].IsString() || + !info[2].IsArray() || + !info[3].IsString() || + !info[4].IsNumber() || + !info[5].IsNumber() || + !info[6].IsBoolean()) { + throw Napi::Error::New(env, "Usage: pty.startProcess(file, cmdline, env, cwd, cols, rows, debug)"); + } + + std::wstring filename(path_util::to_wstring(info[0].As())); + std::wstring cmdline(path_util::to_wstring(info[1].As())); + std::wstring cwd(path_util::to_wstring(info[3].As())); + + // create environment block + std::wstring envStr; + const Napi::Array envValues = info[2].As(); + if (!envValues.IsEmpty()) { + std::wstring envBlock; + for(uint32_t i = 0; i < envValues.Length(); i++) { + envBlock += path_util::to_wstring(envValues.Get(i).As()); + envBlock += L'\0'; + } + envStr = std::move(envBlock); + } + + // use environment 'Path' variable to determine location of + // the relative path that we have recieved (e.g cmd.exe) + std::wstring shellpath; + if (::PathIsRelativeW(filename.c_str())) { + shellpath = path_util::get_shell_path(filename); + } else { + shellpath = filename; + } + + if (shellpath.empty() || !path_util::file_exists(shellpath)) { + std::string why; + why += "File not found: "; + why += path_util::wstring_to_string(shellpath); + throw Napi::Error::New(env, why); + } + + int cols = info[4].As().Int32Value(); + int rows = info[5].As().Int32Value(); + bool debug = info[6].As().Value(); + + // Enable/disable debugging + SetEnvironmentVariable(WINPTY_DBG_VARIABLE, debug ? "1" : NULL); // NULL = deletes variable + + // Create winpty config + winpty_error_ptr_t error_ptr = nullptr; + winpty_config_t* winpty_config = winpty_config_new(0, &error_ptr); + if (winpty_config == nullptr) { + throw error_with_winpty_msg("Error creating WinPTY config", error_ptr, env); + } + winpty_error_free(error_ptr); + + // Set pty size on config + winpty_config_set_initial_size(winpty_config, cols, rows); + + // Start the pty agent + winpty_t *pc = winpty_open(winpty_config, &error_ptr); + winpty_config_free(winpty_config); + if (pc == nullptr) { + throw error_with_winpty_msg("Error launching WinPTY agent", error_ptr, env); + } + winpty_error_free(error_ptr); + + // Create winpty spawn config + winpty_spawn_config_t* config = winpty_spawn_config_new(WINPTY_SPAWN_FLAG_AUTO_SHUTDOWN, shellpath.c_str(), cmdline.c_str(), cwd.c_str(), envStr.c_str(), &error_ptr); + if (config == nullptr) { + winpty_free(pc); + throw error_with_winpty_msg("Error creating WinPTY spawn config", error_ptr, env); + } + winpty_error_free(error_ptr); + + // Spawn the new process + HANDLE handle = nullptr; + BOOL spawnSuccess = winpty_spawn(pc, config, &handle, nullptr, nullptr, &error_ptr); + winpty_spawn_config_free(config); + if (!spawnSuccess) { + if (handle) { + CloseHandle(handle); + } + winpty_free(pc); + throw error_with_winpty_msg("Unable to start terminal process", error_ptr, env); + } + winpty_error_free(error_ptr); + + LPCWSTR coninPipeName = winpty_conin_name(pc); + std::string coninPipeNameStr(path_util::from_wstring(coninPipeName)); + if (coninPipeNameStr.empty()) { + CloseHandle(handle); + winpty_free(pc); + throw Napi::Error::New(env, "Failed to initialize winpty conin"); + } + + LPCWSTR conoutPipeName = winpty_conout_name(pc); + std::string conoutPipeNameStr(path_util::from_wstring(conoutPipeName)); + if (conoutPipeNameStr.empty()) { + CloseHandle(handle); + winpty_free(pc); + throw Napi::Error::New(env, "Failed to initialize winpty conout"); + } + + DWORD innerPid = GetProcessId(handle); + if (createdHandles[innerPid]) { + CloseHandle(handle); + winpty_free(pc); + std::stringstream why; + why << "There is already a process with innerPid " << innerPid; + throw Napi::Error::New(env, why.str()); + } + createdHandles[innerPid] = handle; + + // Save pty struct for later use + ptyHandles.push_back(pc); + + DWORD pid = GetProcessId(winpty_agent_process(pc)); + Napi::Object marshal = Napi::Object::New(env); + marshal.Set("innerPid", Napi::Number::New(env, (int)innerPid)); + marshal.Set("pid", Napi::Number::New(env, (int)pid)); + marshal.Set("pty", Napi::Number::New(env, InterlockedIncrement(&ptyCounter))); + marshal.Set("fd", Napi::Number::New(env, -1)); + marshal.Set("conin", Napi::String::New(env, coninPipeNameStr)); + marshal.Set("conout", Napi::String::New(env, conoutPipeNameStr)); + + return marshal; +} + +static Napi::Value PtyResize(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 3 || + !info[0].IsNumber() || + !info[1].IsNumber() || + !info[2].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.resize(pid, cols, rows)"); + } + + DWORD pid = info[0].As().Uint32Value(); + int cols = info[1].As().Int32Value(); + int rows = info[2].As().Int32Value(); + + winpty_t *pc = get_pipe_handle(pid); + + if (pc == nullptr) { + throw Napi::Error::New(env, "The pty doesn't appear to exist"); + } + BOOL success = winpty_set_size(pc, cols, rows, nullptr); + if (!success) { + throw Napi::Error::New(env, "The pty could not be resized"); + } + + return env.Undefined(); +} + +static Napi::Value PtyKill(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 2 || + !info[0].IsNumber() || + !info[1].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.kill(pid, innerPid)"); + } + + DWORD pid = info[0].As().Uint32Value(); + DWORD innerPid = info[1].As().Uint32Value(); + + winpty_t *pc = get_pipe_handle(pid); + if (pc == nullptr) { + throw Napi::Error::New(env, "Pty seems to have been killed already"); + } + + assert(remove_pipe_handle(pid)); + + HANDLE innerPidHandle = createdHandles[innerPid]; + createdHandles.erase(innerPid); + CloseHandle(innerPidHandle); + + return env.Undefined(); +} + +/** +* Init +*/ + +Napi::Object init(Napi::Env env, Napi::Object exports) { + exports.Set("startProcess", Napi::Function::New(env, PtyStartProcess)); + exports.Set("resize", Napi::Function::New(env, PtyResize)); + exports.Set("kill", Napi::Function::New(env, PtyKill)); + exports.Set("getExitCode", Napi::Function::New(env, PtyGetExitCode)); + exports.Set("getProcessList", Napi::Function::New(env, PtyGetProcessList)); + return exports; +}; + +NODE_API_MODULE(NODE_GYP_MODULE_NAME, init); diff --git a/services/edge-agent/node_modules/node-pty/src/windowsConoutConnection.ts b/services/edge-agent/node_modules/node-pty/src/windowsConoutConnection.ts new file mode 100644 index 00000000..fa2d62de --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/windowsConoutConnection.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2020, Microsoft Corporation (MIT License). + */ + +import { Worker } from 'worker_threads'; +import { Socket } from 'net'; +import { IDisposable } from './types'; +import { IWorkerData, ConoutWorkerMessage, getWorkerPipeName } from './shared/conout'; +import { join } from 'path'; +import { IEvent, EventEmitter2 } from './eventEmitter2'; + +/** + * The amount of time to wait for additional data after the conpty shell process has exited before + * shutting down the worker and sockets. The timer will be reset if a new data event comes in after + * the timer has started. + */ +const FLUSH_DATA_INTERVAL = 1000; + +/** + * Connects to and manages the lifecycle of the conout socket. This socket must be drained on + * another thread in order to avoid deadlocks where Conpty waits for the out socket to drain + * when `ClosePseudoConsole` is called. This happens when data is being written to the terminal when + * the pty is closed. + * + * See also: + * - https://github.com/microsoft/node-pty/issues/375 + * - https://github.com/microsoft/vscode/issues/76548 + * - https://github.com/microsoft/terminal/issues/1810 + * - https://docs.microsoft.com/en-us/windows/console/closepseudoconsole + */ +export class ConoutConnection implements IDisposable { + private _worker: Worker; + private _drainTimeout: NodeJS.Timeout | undefined; + private _isDisposed: boolean = false; + + private _onReady = new EventEmitter2(); + public get onReady(): IEvent { return this._onReady.event; } + + constructor( + private _conoutPipeName: string, + private _useConptyDll: boolean + ) { + const workerData: IWorkerData = { + conoutPipeName: _conoutPipeName + }; + const scriptPath = __dirname.replace('node_modules.asar', 'node_modules.asar.unpacked'); + this._worker = new Worker(join(scriptPath, 'worker/conoutSocketWorker.js'), { workerData }); + this._worker.on('message', (message: ConoutWorkerMessage) => { + switch (message) { + case ConoutWorkerMessage.READY: + this._onReady.fire(); + return; + default: + console.warn('Unexpected ConoutWorkerMessage', message); + } + }); + } + + dispose(): void { + if (!this._useConptyDll && this._isDisposed) { + return; + } + this._isDisposed = true; + // Drain all data from the socket before closing + this._drainDataAndClose(); + } + + connectSocket(socket: Socket): void { + socket.connect(getWorkerPipeName(this._conoutPipeName)); + } + + private _drainDataAndClose(): void { + if (this._drainTimeout) { + clearTimeout(this._drainTimeout); + } + this._drainTimeout = setTimeout(() => this._destroySocket(), FLUSH_DATA_INTERVAL); + } + + private async _destroySocket(): Promise { + await this._worker.terminate(); + } +} diff --git a/services/edge-agent/node_modules/node-pty/src/windowsPtyAgent.test.ts b/services/edge-agent/node_modules/node-pty/src/windowsPtyAgent.test.ts new file mode 100644 index 00000000..dc2104b3 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/windowsPtyAgent.test.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import * as assert from 'assert'; +import { argsToCommandLine } from './windowsPtyAgent'; + +function check(file: string, args: string | string[], expected: string): void { + assert.equal(argsToCommandLine(file, args), expected); +} + +if (process.platform === 'win32') { + describe('argsToCommandLine', () => { + describe('Plain strings', () => { + it('doesn\'t quote plain string', () => { + check('asdf', [], 'asdf'); + }); + it('doesn\'t escape backslashes', () => { + check('\\asdf\\qwer\\', [], '\\asdf\\qwer\\'); + }); + it('doesn\'t escape multiple backslashes', () => { + check('asdf\\\\qwer', [], 'asdf\\\\qwer'); + }); + it('adds backslashes before quotes', () => { + check('"asdf"qwer"', [], '\\"asdf\\"qwer\\"'); + }); + it('escapes backslashes before quotes', () => { + check('asdf\\"qwer', [], 'asdf\\\\\\"qwer'); + }); + }); + + describe('Quoted strings', () => { + it('quotes string with spaces', () => { + check('asdf qwer', [], '"asdf qwer"'); + }); + it('quotes empty string', () => { + check('', [], '""'); + }); + it('quotes string with tabs', () => { + check('asdf\tqwer', [], '"asdf\tqwer"'); + }); + it('escapes only the last backslash', () => { + check('\\asdf \\qwer\\', [], '"\\asdf \\qwer\\\\"'); + }); + it('doesn\'t escape multiple backslashes', () => { + check('asdf \\\\qwer', [], '"asdf \\\\qwer"'); + }); + it('escapes backslashes before quotes', () => { + check('asdf \\"qwer', [], '"asdf \\\\\\"qwer"'); + }); + it('escapes multiple backslashes at the end', () => { + check('asdf qwer\\\\', [], '"asdf qwer\\\\\\\\"'); + }); + }); + + describe('Multiple arguments', () => { + it('joins arguments with spaces', () => { + check('asdf', ['qwer zxcv', '', '"'], 'asdf "qwer zxcv" "" \\"'); + }); + it('array argument all in quotes', () => { + check('asdf', ['"surounded by quotes"'], 'asdf \\"surounded by quotes\\"'); + }); + it('array argument quotes in the middle', () => { + check('asdf', ['quotes "in the" middle'], 'asdf "quotes \\"in the\\" middle"'); + }); + it('array argument quotes near start', () => { + check('asdf', ['"quotes" near start'], 'asdf "\\"quotes\\" near start"'); + }); + it('array argument quotes near end', () => { + check('asdf', ['quotes "near end"'], 'asdf "quotes \\"near end\\""'); + }); + }); + + describe('Args as CommandLine', () => { + it('should handle empty string', () => { + check('file', '', 'file'); + }); + it('should not change args', () => { + check('file', 'foo bar baz', 'file foo bar baz'); + check('file', 'foo \\ba"r \baz', 'file foo \\ba"r \baz'); + }); + }); + + describe('Real-world cases', () => { + it('quotes within quotes', () => { + check('cmd.exe', ['/c', 'powershell -noexit -command \'Set-location \"C:\\user\"\''], 'cmd.exe /c "powershell -noexit -command \'Set-location \\\"C:\\user\\"\'"'); + }); + it('space within quotes', () => { + check('cmd.exe', ['/k', '"C:\\Users\\alros\\Desktop\\test script.bat"'], 'cmd.exe /k \\"C:\\Users\\alros\\Desktop\\test script.bat\\"'); + }); + }); + }); +} diff --git a/services/edge-agent/node_modules/node-pty/src/windowsPtyAgent.ts b/services/edge-agent/node_modules/node-pty/src/windowsPtyAgent.ts new file mode 100644 index 00000000..d7054449 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/windowsPtyAgent.ts @@ -0,0 +1,321 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { fork } from 'child_process'; +import { Socket } from 'net'; +import { ArgvOrCommandLine } from './types'; +import { ConoutConnection } from './windowsConoutConnection'; +import { loadNativeModule } from './utils'; + +let conptyNative: IConptyNative; +let winptyNative: IWinptyNative; + +/** + * The amount of time to wait for additional data after the conpty shell process has exited before + * shutting down the socket. The timer will be reset if a new data event comes in after the timer + * has started. + */ +const FLUSH_DATA_INTERVAL = 1000; + +/** + * This agent sits between the WindowsTerminal class and provides a common interface for both conpty + * and winpty. + */ +export class WindowsPtyAgent { + private _inSocket: Socket; + private _outSocket: Socket; + private _pid: number = 0; + private _innerPid: number = 0; + private _closeTimeout: NodeJS.Timer | undefined; + private _exitCode: number | undefined; + private _conoutSocketWorker: ConoutConnection; + + private _fd: any; + private _pty: number; + private _ptyNative: IConptyNative | IWinptyNative; + + public get inSocket(): Socket { return this._inSocket; } + public get outSocket(): Socket { return this._outSocket; } + public get fd(): any { return this._fd; } + public get innerPid(): number { return this._innerPid; } + public get pty(): number { return this._pty; } + + constructor( + file: string, + args: ArgvOrCommandLine, + env: string[], + cwd: string, + cols: number, + rows: number, + debug: boolean, + private _useConpty: boolean | undefined, + private _useConptyDll: boolean = false, + conptyInheritCursor: boolean = false + ) { + if (this._useConpty === undefined || this._useConpty === true) { + this._useConpty = this._getWindowsBuildNumber() >= 18309; + } + if (this._useConpty) { + if (!conptyNative) { + conptyNative = loadNativeModule('conpty').module; + } + } else { + if (!winptyNative) { + winptyNative = loadNativeModule('pty').module; + } + } + this._ptyNative = this._useConpty ? conptyNative : winptyNative; + + // Sanitize input variable. + cwd = path.resolve(cwd); + + // Compose command line + const commandLine = argsToCommandLine(file, args); + + // Open pty session. + let term: IConptyProcess | IWinptyProcess; + if (this._useConpty) { + term = (this._ptyNative as IConptyNative).startProcess(file, cols, rows, debug, this._generatePipeName(), conptyInheritCursor, this._useConptyDll); + } else { + term = (this._ptyNative as IWinptyNative).startProcess(file, commandLine, env, cwd, cols, rows, debug); + this._pid = (term as IWinptyProcess).pid; + this._innerPid = (term as IWinptyProcess).innerPid; + } + + // Not available on windows. + this._fd = term.fd; + + // Generated incremental number that has no real purpose besides using it + // as a terminal id. + this._pty = term.pty; + + // Create terminal pipe IPC channel and forward to a local unix socket. + this._outSocket = new Socket(); + this._outSocket.setEncoding('utf8'); + // The conout socket must be ready out on another thread to avoid deadlocks + this._conoutSocketWorker = new ConoutConnection(term.conout, this._useConptyDll); + this._conoutSocketWorker.onReady(() => { + this._conoutSocketWorker.connectSocket(this._outSocket); + }); + this._outSocket.on('connect', () => { + this._outSocket.emit('ready_datapipe'); + }); + + const inSocketFD = fs.openSync(term.conin, 'w'); + this._inSocket = new Socket({ + fd: inSocketFD, + readable: false, + writable: true + }); + this._inSocket.setEncoding('utf8'); + + if (this._useConpty) { + const connect = (this._ptyNative as IConptyNative).connect(this._pty, commandLine, cwd, env, this._useConptyDll, c => this._$onProcessExit(c)); + this._innerPid = connect.pid; + } + } + + public resize(cols: number, rows: number): void { + if (this._useConpty) { + if (this._exitCode !== undefined) { + throw new Error('Cannot resize a pty that has already exited'); + } + (this._ptyNative as IConptyNative).resize(this._pty, cols, rows, this._useConptyDll); + return; + } + (this._ptyNative as IWinptyNative).resize(this._pid, cols, rows); + } + + public clear(): void { + if (this._useConpty) { + (this._ptyNative as IConptyNative).clear(this._pty, this._useConptyDll); + } + } + + public kill(): void { + // Tell the agent to kill the pty, this releases handles to the process + if (this._useConpty) { + if (!this._useConptyDll) { + this._inSocket.readable = false; + this._outSocket.readable = false; + this._getConsoleProcessList().then(consoleProcessList => { + consoleProcessList.forEach((pid: number) => { + try { + process.kill(pid); + } catch (e) { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + }); + (this._ptyNative as IConptyNative).kill(this._pty, this._useConptyDll); + this._conoutSocketWorker.dispose(); + } else { + // Close the input write handle to signal the end of session. + this._inSocket.destroy(); + (this._ptyNative as IConptyNative).kill(this._pty, this._useConptyDll); + this._outSocket.on('data', () => { + this._conoutSocketWorker.dispose(); + }); + } + } else { + // Because pty.kill closes the handle, it will kill most processes by itself. + // Process IDs can be reused as soon as all handles to them are + // dropped, so we want to immediately kill the entire console process list. + // If we do not force kill all processes here, node servers in particular + // seem to become detached and remain running (see + // Microsoft/vscode#26807). + const processList: number[] = (this._ptyNative as IWinptyNative).getProcessList(this._pid); + (this._ptyNative as IWinptyNative).kill(this._pid, this._innerPid); + processList.forEach(pid => { + try { + process.kill(pid); + } catch (e) { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + } + } + + private _getConsoleProcessList(): Promise { + return new Promise(resolve => { + const agent = fork(path.join(__dirname, 'conpty_console_list_agent'), [ this._innerPid.toString() ]); + agent.on('message', message => { + clearTimeout(timeout); + resolve(message.consoleProcessList); + }); + const timeout = setTimeout(() => { + // Something went wrong, just send back the shell PID + agent.kill(); + resolve([ this._innerPid ]); + }, 5000); + }); + } + + public get exitCode(): number | undefined { + if (this._useConpty) { + return this._exitCode; + } + const winptyExitCode = (this._ptyNative as IWinptyNative).getExitCode(this._innerPid); + return winptyExitCode === -1 ? undefined : winptyExitCode; + } + + private _getWindowsBuildNumber(): number { + const osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release()); + let buildNumber: number = 0; + if (osVersion && osVersion.length === 4) { + buildNumber = parseInt(osVersion[3]); + } + return buildNumber; + } + + private _generatePipeName(): string { + return `conpty-${Math.random() * 10000000}`; + } + + /** + * Triggered from the native side when a contpy process exits. + */ + private _$onProcessExit(exitCode: number): void { + this._exitCode = exitCode; + if (!this._useConptyDll) { + this._flushDataAndCleanUp(); + this._outSocket.on('data', () => this._flushDataAndCleanUp()); + } + } + + private _flushDataAndCleanUp(): void { + if (this._useConptyDll) { + return; + } + if (this._closeTimeout) { + clearTimeout(this._closeTimeout); + } + this._closeTimeout = setTimeout(() => this._cleanUpProcess(), FLUSH_DATA_INTERVAL); + } + + private _cleanUpProcess(): void { + if (this._useConptyDll) { + return; + } + this._inSocket.readable = false; + this._outSocket.readable = false; + this._outSocket.destroy(); + } +} + +// Convert argc/argv into a Win32 command-line following the escaping convention +// documented on MSDN (e.g. see CommandLineToArgvW documentation). Copied from +// winpty project. +export function argsToCommandLine(file: string, args: ArgvOrCommandLine): string { + if (isCommandLine(args)) { + if (args.length === 0) { + return file; + } + return `${argsToCommandLine(file, [])} ${args}`; + } + const argv = [file]; + Array.prototype.push.apply(argv, args); + let result = ''; + for (let argIndex = 0; argIndex < argv.length; argIndex++) { + if (argIndex > 0) { + result += ' '; + } + const arg = argv[argIndex]; + // if it is empty or it contains whitespace and is not already quoted + const hasLopsidedEnclosingQuote = xOr((arg[0] !== '"'), (arg[arg.length - 1] !== '"')); + const hasNoEnclosingQuotes = ((arg[0] !== '"') && (arg[arg.length - 1] !== '"')); + const quote = + arg === '' || + (arg.indexOf(' ') !== -1 || + arg.indexOf('\t') !== -1) && + ((arg.length > 1) && + (hasLopsidedEnclosingQuote || hasNoEnclosingQuotes)); + if (quote) { + result += '\"'; + } + let bsCount = 0; + for (let i = 0; i < arg.length; i++) { + const p = arg[i]; + if (p === '\\') { + bsCount++; + } else if (p === '"') { + result += repeatText('\\', bsCount * 2 + 1); + result += '"'; + bsCount = 0; + } else { + result += repeatText('\\', bsCount); + bsCount = 0; + result += p; + } + } + if (quote) { + result += repeatText('\\', bsCount * 2); + result += '\"'; + } else { + result += repeatText('\\', bsCount); + } + } + return result; +} + +function isCommandLine(args: ArgvOrCommandLine): args is string { + return typeof args === 'string'; +} + +function repeatText(text: string, count: number): string { + let result = ''; + for (let i = 0; i < count; i++) { + result += text; + } + return result; +} + +function xOr(arg1: boolean, arg2: boolean): boolean { + return ((arg1 && !arg2) || (!arg1 && arg2)); +} diff --git a/services/edge-agent/node_modules/node-pty/src/windowsTerminal.test.ts b/services/edge-agent/node_modules/node-pty/src/windowsTerminal.test.ts new file mode 100644 index 00000000..8f1274ed --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/windowsTerminal.test.ts @@ -0,0 +1,229 @@ +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import * as fs from 'fs'; +import * as assert from 'assert'; +import { WindowsTerminal } from './windowsTerminal'; +import * as path from 'path'; +import * as psList from 'ps-list'; + +interface IProcessState { + // Whether the PID must exist or must not exist + [pid: number]: boolean; +} + +interface IWindowsProcessTreeResult { + name: string; + pid: number; +} + +function pollForProcessState(desiredState: IProcessState, intervalMs: number = 100, timeoutMs: number = 2000): Promise { + return new Promise(resolve => { + let tries = 0; + const interval = setInterval(() => { + psList({ all: true }).then(ps => { + let success = true; + const pids = Object.keys(desiredState).map(k => parseInt(k, 10)); + console.log('expected pids', JSON.stringify(pids)); + pids.forEach(pid => { + if (desiredState[pid]) { + if (!ps.some(p => p.pid === pid)) { + console.log(`pid ${pid} does not exist`); + success = false; + } + } else { + if (ps.some(p => p.pid === pid)) { + console.log(`pid ${pid} still exists`); + success = false; + } + } + }); + if (success) { + clearInterval(interval); + resolve(); + return; + } + tries++; + if (tries * intervalMs >= timeoutMs) { + clearInterval(interval); + const processListing = pids.map(k => `${k}: ${desiredState[k]}`).join('\n'); + assert.fail(`Bad process state, expected:\n${processListing}`); + resolve(); + } + }); + }, intervalMs); + }); +} + +function pollForProcessTreeSize(pid: number, size: number, intervalMs: number = 100, timeoutMs: number = 2000): Promise { + return new Promise(resolve => { + let tries = 0; + const interval = setInterval(() => { + psList({ all: true }).then(ps => { + const openList: IWindowsProcessTreeResult[] = []; + openList.push(ps.filter(p => p.pid === pid).map(p => { + return { name: p.name, pid: p.pid }; + })[0]); + const list: IWindowsProcessTreeResult[] = []; + while (openList.length) { + const current = openList.shift()!; + ps.filter(p => p.ppid === current.pid).map(p => { + return { name: p.name, pid: p.pid }; + }).forEach(p => openList.push(p)); + list.push(current); + } + console.log('list', JSON.stringify(list)); + const success = list.length === size; + if (success) { + clearInterval(interval); + resolve(list); + return; + } + tries++; + if (tries * intervalMs >= timeoutMs) { + clearInterval(interval); + assert.fail(`Bad process state, expected: ${size}, actual: ${list.length}`); + } + }); + }, intervalMs); + }); +} + +if (process.platform === 'win32') { + [[false, false], [true, false], [true, true]].forEach(([useConpty, useConptyDll]) => { + describe(`WindowsTerminal (useConpty = ${useConpty}, useConptyDll = ${useConptyDll})`, () => { + describe('kill', () => { + it('should not crash parent process', function (done) { + this.timeout(20000); + const term = new WindowsTerminal('cmd.exe', [], { useConpty, useConptyDll }); + term.on('exit', () => done()); + term.kill(); + }); + it('should kill the process tree', function (done: Mocha.Done): void { + this.timeout(20000); + const term = new WindowsTerminal('cmd.exe', [], { useConpty, useConptyDll }); + // Start sub-processes + term.write('powershell.exe\r'); + term.write('node.exe\r'); + console.log('start poll for tree size'); + pollForProcessTreeSize(term.pid, 3, 500, 5000).then(list => { + assert.strictEqual(list[0].name.toLowerCase(), 'cmd.exe'); + assert.strictEqual(list[1].name.toLowerCase(), 'powershell.exe'); + assert.strictEqual(list[2].name.toLowerCase(), 'node.exe'); + term.kill(); + const desiredState: IProcessState = {}; + desiredState[list[0].pid] = false; + desiredState[list[1].pid] = false; + desiredState[list[2].pid] = false; + term.on('exit', () => { + pollForProcessState(desiredState, 1000, 5000).then(() => { + done(); + }); + }); + }); + }); + }); + + describe('resize', () => { + it('should throw a non-native exception when resizing an invalid value', function(done) { + this.timeout(20000); + const term = new WindowsTerminal('cmd.exe', [], { useConpty, useConptyDll }); + assert.throws(() => term.resize(-1, -1)); + assert.throws(() => term.resize(0, 0)); + assert.doesNotThrow(() => term.resize(1, 1)); + term.on('exit', () => { + done(); + }); + term.kill(); + }); + it('should throw a non-native exception when resizing a killed terminal', function(done) { + this.timeout(20000); + const term = new WindowsTerminal('cmd.exe', [], { useConpty, useConptyDll }); + (term)._defer(() => { + term.once('exit', () => { + assert.throws(() => term.resize(1, 1)); + done(); + }); + term.destroy(); + }); + }); + }); + + describe('Args as CommandLine', () => { + it('should not fail running a file containing a space in the path', function (done) { + this.timeout(10000); + const spaceFolder = path.resolve(__dirname, '..', 'fixtures', 'space folder'); + if (!fs.existsSync(spaceFolder)) { + fs.mkdirSync(spaceFolder); + } + + const cmdCopiedPath = path.resolve(spaceFolder, 'cmd.exe'); + const data = fs.readFileSync(`${process.env.windir}\\System32\\cmd.exe`); + fs.writeFileSync(cmdCopiedPath, data); + + if (!fs.existsSync(cmdCopiedPath)) { + // Skip test if git bash isn't installed + return; + } + const term = new WindowsTerminal(cmdCopiedPath, '/c echo "hello world"', { useConpty, useConptyDll }); + let result = ''; + term.on('data', (data) => { + result += data; + }); + term.on('exit', () => { + assert.ok(result.indexOf('hello world') >= 1); + done(); + }); + }); + }); + + describe('env', () => { + it('should set environment variables of the shell', function (done) { + this.timeout(10000); + const term = new WindowsTerminal('cmd.exe', '/C echo %FOO%', { useConpty, useConptyDll, env: { FOO: 'BAR' }}); + let result = ''; + term.on('data', (data) => { + result += data; + }); + term.on('exit', () => { + assert.ok(result.indexOf('BAR') >= 0); + done(); + }); + }); + }); + + describe('On close', () => { + it('should return process zero exit codes', function (done) { + this.timeout(10000); + const term = new WindowsTerminal('cmd.exe', '/C exit', { useConpty, useConptyDll }); + term.on('exit', (code) => { + assert.strictEqual(code, 0); + done(); + }); + }); + + it('should return process non-zero exit codes', function (done) { + this.timeout(10000); + const term = new WindowsTerminal('cmd.exe', '/C exit 2', { useConpty, useConptyDll }); + term.on('exit', (code) => { + assert.strictEqual(code, 2); + done(); + }); + }); + }); + + describe('Write', () => { + it('should accept input', function (done) { + this.timeout(10000); + const term = new WindowsTerminal('cmd.exe', '', { useConpty, useConptyDll }); + term.write('exit\r'); + term.on('exit', () => { + done(); + }); + }); + }); + }); + }); +} diff --git a/services/edge-agent/node_modules/node-pty/src/windowsTerminal.ts b/services/edge-agent/node_modules/node-pty/src/windowsTerminal.ts new file mode 100644 index 00000000..13f6c6db --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/windowsTerminal.ts @@ -0,0 +1,203 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import { Socket } from 'net'; +import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from './terminal'; +import { WindowsPtyAgent } from './windowsPtyAgent'; +import { IPtyOpenOptions, IWindowsPtyForkOptions } from './interfaces'; +import { ArgvOrCommandLine } from './types'; +import { assign } from './utils'; + +const DEFAULT_FILE = 'cmd.exe'; +const DEFAULT_NAME = 'Windows Shell'; + +export class WindowsTerminal extends Terminal { + private _isReady: boolean; + private _deferreds: { run: () => void }[]; + private _agent: WindowsPtyAgent; + + constructor(file?: string, args?: ArgvOrCommandLine, opt?: IWindowsPtyForkOptions) { + super(opt); + + this._checkType('args', args, 'string', true); + + // Initialize arguments + args = args || []; + file = file || DEFAULT_FILE; + opt = opt || {}; + opt.env = opt.env || process.env; + + if (opt.encoding) { + console.warn('Setting encoding on Windows is not supported'); + } + + const env = assign({}, opt.env); + this._cols = opt.cols || DEFAULT_COLS; + this._rows = opt.rows || DEFAULT_ROWS; + const cwd = opt.cwd || process.cwd(); + const name = opt.name || env.TERM || DEFAULT_NAME; + const parsedEnv = this._parseEnv(env); + + // If the terminal is ready + this._isReady = false; + + // Functions that need to run after `ready` event is emitted. + this._deferreds = []; + + // Create new termal. + this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); + this._socket = this._agent.outSocket; + + // Not available until `ready` event emitted. + this._pid = this._agent.innerPid; + this._fd = this._agent.fd; + this._pty = this._agent.pty; + + // The forked windows terminal is not available until `ready` event is + // emitted. + this._socket.on('ready_datapipe', () => { + + // Run deferreds and set ready state once the first data event is received. + this._socket.once('data', () => { + // Wait until the first data event is fired then we can run deferreds. + if (!this._isReady) { + // Terminal is now ready and we can avoid having to defer method + // calls. + this._isReady = true; + + // Execute all deferred methods + this._deferreds.forEach(fn => { + // NB! In order to ensure that `this` has all its references + // updated any variable that need to be available in `this` before + // the deferred is run has to be declared above this forEach + // statement. + fn.run(); + }); + + // Reset + this._deferreds = []; + } + }); + + // Shutdown if `error` event is emitted. + this._socket.on('error', err => { + // Close terminal session. + this._close(); + + // EIO, happens when someone closes our child process: the only process + // in the terminal. + // node < 0.6.14: errno 5 + // node >= 0.6.14: read EIO + if ((err).code) { + if (~(err).code.indexOf('errno 5') || ~(err).code.indexOf('EIO')) return; + } + + // Throw anything else. + if (this.listeners('error').length < 2) { + throw err; + } + }); + + // Cleanup after the socket is closed. + this._socket.on('close', () => { + this.emit('exit', this._agent.exitCode); + this._close(); + }); + + }); + + this._file = file; + this._name = name; + + this._readable = true; + this._writable = true; + + this._forwardEvents(); + } + + protected _write(data: string | Buffer): void { + this._defer(this._doWrite, data); + } + + private _doWrite(data: string | Buffer): void { + this._agent.inSocket.write(data); + } + + /** + * openpty + */ + + public static open(options?: IPtyOpenOptions): void { + throw new Error('open() not supported on windows, use Fork() instead.'); + } + + /** + * TTY + */ + + public resize(cols: number, rows: number): void { + if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { + throw new Error('resizing must be done using positive cols and rows'); + } + this._deferNoArgs(() => { + this._agent.resize(cols, rows); + this._cols = cols; + this._rows = rows; + }); + } + + public clear(): void { + this._deferNoArgs(() => { + this._agent.clear(); + }); + } + + public destroy(): void { + this._deferNoArgs(() => { + this.kill(); + }); + } + + public kill(signal?: string): void { + this._deferNoArgs(() => { + if (signal) { + throw new Error('Signals not supported on windows.'); + } + this._close(); + this._agent.kill(); + }); + } + + private _deferNoArgs(deferredFn: () => void): void { + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this); + return; + } + + // Queue until terminal is ready. + this._deferreds.push({ + run: () => deferredFn.call(this) + }); + } + + private _defer(deferredFn: (arg: A) => void, arg: A): void { + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this, arg); + return; + } + + // Queue until terminal is ready. + this._deferreds.push({ + run: () => deferredFn.call(this, arg) + }); + } + + public get process(): string { return this._name; } + public get master(): Socket { throw new Error('master is not supported on Windows'); } + public get slave(): Socket { throw new Error('slave is not supported on Windows'); } +} diff --git a/services/edge-agent/node_modules/node-pty/src/worker/conoutSocketWorker.ts b/services/edge-agent/node_modules/node-pty/src/worker/conoutSocketWorker.ts new file mode 100644 index 00000000..79a4148d --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/src/worker/conoutSocketWorker.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2020, Microsoft Corporation (MIT License). + */ + +import { parentPort, workerData } from 'worker_threads'; +import { Socket, createServer } from 'net'; +import { ConoutWorkerMessage, IWorkerData, getWorkerPipeName } from '../shared/conout'; + +const { conoutPipeName } = (workerData as IWorkerData); + +const conoutSocket = new Socket(); +conoutSocket.setEncoding('utf8'); +conoutSocket.connect(conoutPipeName, () => { + const server = createServer(workerSocket => { + conoutSocket.pipe(workerSocket); + }); + server.listen(getWorkerPipeName(conoutPipeName)); + if (!parentPort) { + throw new Error('worker_threads parentPort is null'); + } + parentPort.postMessage(ConoutWorkerMessage.READY); +}); diff --git a/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-arm64/OpenConsole.exe b/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-arm64/OpenConsole.exe new file mode 100644 index 00000000..40217d33 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-arm64/OpenConsole.exe differ diff --git a/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-arm64/conpty.dll b/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-arm64/conpty.dll new file mode 100644 index 00000000..f8ea864b Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-arm64/conpty.dll differ diff --git a/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-x64/OpenConsole.exe b/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-x64/OpenConsole.exe new file mode 100644 index 00000000..3db21937 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-x64/OpenConsole.exe differ diff --git a/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-x64/conpty.dll b/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-x64/conpty.dll new file mode 100644 index 00000000..eb66b162 Binary files /dev/null and b/services/edge-agent/node_modules/node-pty/third_party/conpty/1.23.251008001/win10-x64/conpty.dll differ diff --git a/services/edge-agent/node_modules/node-pty/typings/node-pty.d.ts b/services/edge-agent/node_modules/node-pty/typings/node-pty.d.ts new file mode 100644 index 00000000..6f050ff1 --- /dev/null +++ b/services/edge-agent/node_modules/node-pty/typings/node-pty.d.ts @@ -0,0 +1,211 @@ +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +declare module 'node-pty' { + /** + * Forks a process as a pseudoterminal. + * @param file The file to launch. + * @param args The file's arguments as argv (string[]) or in a pre-escaped CommandLine format + * (string). Note that the CommandLine option is only available on Windows and is expected to be + * escaped properly. + * @param options The options of the terminal. + * @see CommandLineToArgvW https://msdn.microsoft.com/en-us/library/windows/desktop/bb776391(v=vs.85).aspx + * @see Parsing C++ Comamnd-Line Arguments https://msdn.microsoft.com/en-us/library/17w5ykft.aspx + * @see GetCommandLine https://msdn.microsoft.com/en-us/library/windows/desktop/ms683156.aspx + */ + export function spawn(file: string, args: string[] | string, options: IPtyForkOptions | IWindowsPtyForkOptions): IPty; + + export interface IBasePtyForkOptions { + + /** + * Name of the terminal to be set in environment ($TERM variable). + */ + name?: string; + + /** + * Number of intial cols of the pty. + */ + cols?: number; + + /** + * Number of initial rows of the pty. + */ + rows?: number; + + /** + * Working directory to be set for the child program. + */ + cwd?: string; + + /** + * Environment to be set for the child program. + */ + env?: { [key: string]: string | undefined }; + + /** + * String encoding of the underlying pty. + * If set, incoming data will be decoded to strings and outgoing strings to bytes applying this encoding. + * If unset, incoming data will be delivered as raw bytes (Buffer type). + * By default 'utf8' is assumed, to unset it explicitly set it to `null`. + */ + encoding?: string | null; + + /** + * (EXPERIMENTAL) + * Whether to enable flow control handling (false by default). If enabled a message of `flowControlPause` + * will pause the socket and thus blocking the child program execution due to buffer back pressure. + * A message of `flowControlResume` will resume the socket into flow mode. + * For performance reasons only a single message as a whole will match (no message part matching). + * If flow control is enabled the `flowControlPause` and `flowControlResume` messages are not forwarded to + * the underlying pseudoterminal. + */ + handleFlowControl?: boolean; + + /** + * (EXPERIMENTAL) + * The string that should pause the pty when `handleFlowControl` is true. Default is XOFF ('\x13'). + */ + flowControlPause?: string; + + /** + * (EXPERIMENTAL) + * The string that should resume the pty when `handleFlowControl` is true. Default is XON ('\x11'). + */ + flowControlResume?: string; + } + + export interface IPtyForkOptions extends IBasePtyForkOptions { + /** + * Security warning: use this option with great caution, + * as opened file descriptors with higher privileges might leak to the child program. + */ + uid?: number; + gid?: number; + } + + export interface IWindowsPtyForkOptions extends IBasePtyForkOptions { + /** + * Whether to use the ConPTY system on Windows. When this is not set, ConPTY will be used when + * the Windows build number is >= 18309 (instead of winpty). Note that ConPTY is available from + * build 17134 but is too unstable to enable by default. + * + * This setting does nothing on non-Windows. + */ + useConpty?: boolean; + + /** + * (EXPERIMENTAL) + * + * Whether to use the conpty.dll shipped with the node-pty package instead of the one built into + * Windows. Defaults to false. + */ + useConptyDll?: boolean; + + /** + * Whether to use PSEUDOCONSOLE_INHERIT_CURSOR in conpty. + * @see https://docs.microsoft.com/en-us/windows/console/createpseudoconsole + */ + conptyInheritCursor?: boolean; + } + + /** + * An interface representing a pseudoterminal, on Windows this is emulated via the winpty library. + */ + export interface IPty { + /** + * The process ID of the outer process. + */ + readonly pid: number; + + /** + * The column size in characters. + */ + readonly cols: number; + + /** + * The row size in characters. + */ + readonly rows: number; + + /** + * The title of the active process. + */ + readonly process: string; + + /** + * (EXPERIMENTAL) + * Whether to handle flow control. Useful to disable/re-enable flow control during runtime. + * Use this for binary data that is likely to contain the `flowControlPause` string by accident. + */ + handleFlowControl: boolean; + + /** + * Adds an event listener for when a data event fires. This happens when data is returned from + * the pty. + * @returns an `IDisposable` to stop listening. + */ + readonly onData: IEvent; + + /** + * Adds an event listener for when an exit event fires. This happens when the pty exits. + * @returns an `IDisposable` to stop listening. + */ + readonly onExit: IEvent<{ exitCode: number, signal?: number }>; + + /** + * Resizes the dimensions of the pty. + * @param columns The number of columns to use. + * @param rows The number of rows to use. + */ + resize(columns: number, rows: number): void; + + /** + * Clears the pty's internal representation of its buffer. This is a no-op + * unless on Windows/ConPTY. This is useful if the buffer is cleared on the + * frontend in order to synchronize state with the backend to avoid ConPTY + * possibly reprinting the screen. + */ + clear(): void; + + /** + * Writes data to the pty. + * @param data The data to write. + */ + write(data: string | Buffer): void; + + /** + * Kills the pty. + * @param signal The signal to use, defaults to SIGHUP. This parameter is not supported on + * Windows. + * @throws Will throw when signal is used on Windows. + */ + kill(signal?: string): void; + + /** + * Pauses the pty for customizable flow control. + */ + pause(): void; + + /** + * Resumes the pty for customizable flow control. + */ + resume(): void; + } + + /** + * An object that can be disposed via a dispose function. + */ + export interface IDisposable { + dispose(): void; + } + + /** + * An event that can be listened to. + * @returns an `IDisposable` to stop listening. + */ + export interface IEvent { + (listener: (e: T) => any): IDisposable; + } +} diff --git a/services/edge-agent/package-lock.json b/services/edge-agent/package-lock.json new file mode 100644 index 00000000..ee879d5a --- /dev/null +++ b/services/edge-agent/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "truckwash-edge-agent", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "truckwash-edge-agent", + "dependencies": { + "node-pty": "^1.1.0" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + } + } +} diff --git a/services/edge-agent/package.json b/services/edge-agent/package.json new file mode 100644 index 00000000..5902f06a --- /dev/null +++ b/services/edge-agent/package.json @@ -0,0 +1,11 @@ +{ + "name": "truckwash-edge-agent", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + }, + "dependencies": { + "node-pty": "^1.1.0" + } +} diff --git a/services/edge-agent/test-support/fake-agent.mjs b/services/edge-agent/test-support/fake-agent.mjs new file mode 100644 index 00000000..be90d357 --- /dev/null +++ b/services/edge-agent/test-support/fake-agent.mjs @@ -0,0 +1,120 @@ +import process from "node:process"; + +const EDGE_TEST_BROKER_URL = process.env.EDGE_TEST_BROKER_URL || ""; +const EDGE_TEST_GATEWAY_ID = process.env.EDGE_TEST_GATEWAY_ID || ""; +const EDGE_TEST_AGENT_TOKEN = process.env.EDGE_TEST_AGENT_TOKEN || ""; +const EDGE_TEST_BEHAVIOR = process.env.EDGE_TEST_BEHAVIOR || "success"; + +function normalizeBrokerUrl(rawUrl) { + if (!rawUrl) { + throw new Error("Missing EDGE_TEST_BROKER_URL"); + } + + const url = new URL(rawUrl); + if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname)) { + url.hostname = "host.docker.internal"; + } + return url; +} + +function buildAgentWsUrl() { + if (!EDGE_TEST_GATEWAY_ID) { + throw new Error("Missing EDGE_TEST_GATEWAY_ID"); + } + if (!EDGE_TEST_AGENT_TOKEN) { + throw new Error("Missing EDGE_TEST_AGENT_TOKEN"); + } + + const url = normalizeBrokerUrl(EDGE_TEST_BROKER_URL); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = "/ws/agent"; + url.search = new URLSearchParams({ + gatewayId: EDGE_TEST_GATEWAY_ID, + token: EDGE_TEST_AGENT_TOKEN, + }).toString(); + return url.toString(); +} + +function sendJson(socket, payload) { + socket.send(JSON.stringify(payload)); +} + +if (EDGE_TEST_BEHAVIOR === "offline") { + console.log("fake-agent-offline"); + process.exit(0); +} + +const socket = new WebSocket(buildAgentWsUrl()); +const keepAlive = setInterval(() => {}, 1_000); + +socket.addEventListener("open", () => { + console.log(`fake-agent-open:${EDGE_TEST_BEHAVIOR}`); +}); + +socket.addEventListener("message", ({ data }) => { + const message = JSON.parse(String(data)); + + if (message.type === "COMMAND") { + if (EDGE_TEST_BEHAVIOR === "timeout") { + return; + } + + sendJson(socket, { + type: "COMMAND_RESULT", + commandId: message.commandId, + ok: true, + payload: { + source: "fake-agent", + commandType: message.commandType, + echo: message.payload || {}, + }, + }); + return; + } + + if (message.type === "OPEN_ROOT_SHELL") { + if (EDGE_TEST_BEHAVIOR === "shell-open-fail") { + socket.close(); + return; + } + + sendJson(socket, { + type: "SHELL_OPENED", + sessionId: message.payload.sessionId, + }); + sendJson(socket, { + type: "SHELL_OUTPUT", + sessionId: message.payload.sessionId, + data: "root@fake-agent:~# ", + }); + return; + } + + if (message.type === "SHELL_INPUT") { + sendJson(socket, { + type: "SHELL_OUTPUT", + sessionId: message.payload.sessionId, + data: `echo:${String(message.payload.data || "")}`, + }); + return; + } + + if (message.type === "CLOSE_ROOT_SHELL") { + sendJson(socket, { + type: "SHELL_EXIT", + sessionId: message.payload.sessionId, + code: 0, + }); + socket.close(); + } +}); + +socket.addEventListener("error", (event) => { + console.error("fake-agent-error", event?.message || ""); + process.exitCode = 1; +}); + +socket.addEventListener("close", () => { + clearInterval(keepAlive); + setTimeout(() => process.exit(process.exitCode || 0), 25); +}); diff --git a/services/edge-agent/test/agent.test.mjs b/services/edge-agent/test/agent.test.mjs new file mode 100644 index 00000000..d79ec584 --- /dev/null +++ b/services/edge-agent/test/agent.test.mjs @@ -0,0 +1,1099 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { + buildStatusReport, + claimIfNeeded, + createShellBridge, + discoverShellyDevices, + finalizePendingUpdateOnStartup, + getAgentStatus, + getRelayStatus, + loadConfig, + parseCliArgs, + processPolledShellAction, + processPolledCommand, + runCli, + runUpdate, + setRelayState, + startAgent, + handleAgentCommand, + verifyPendingUpdate, +} from "../dist/agent.mjs"; + +const execFile = promisify(execFileCallback); +const agentEntryPath = fileURLToPath(new URL("../dist/agent.mjs", import.meta.url)); + +function makeFetchResponse(body) { + const bytes = Buffer.from(body); + return { + ok: true, + async arrayBuffer() { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + }, + }; +} + +async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (predicate()) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`Timed out waiting for ${description}`); +} + +test("claimIfNeeded persists claimed gateway credentials", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-")); + const configPath = path.join(tempDir, "config.json"); + await writeFile(configPath, JSON.stringify({ + apiUrl: "https://api.example.test", + installToken: "claim-token", + })); + + const requests = []; + const fakeFetch = async (url, options) => { + requests.push({ url, options }); + return { + ok: true, + async json() { + return { + data: { + gateway: { id: 9001 }, + agent_token: "agent-token", + release_channel: "stable", + broker_url: "https://broker.example.test", + }, + }; + }, + }; + }; + + const config = await claimIfNeeded({ + apiUrl: "https://api.example.test", + installToken: "claim-token", + }, configPath, fakeFetch); + + assert.equal(requests.length, 1); + assert.equal(config.gatewayId, 9001); + assert.equal(config.agentToken, "agent-token"); + assert.equal(config.brokerUrl, "https://broker.example.test"); + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("relay status and switch commands support both Shelly RPC and legacy endpoints", async () => { + const fakeFetch = async (url) => { + if (String(url).includes("Switch.GetStatus")) { + return { + ok: true, + async json() { + return { output: true }; + }, + }; + } + if (String(url).includes("Switch.Set")) { + return { + ok: true, + async json() { + return { output: false }; + }, + }; + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const status = await getRelayStatus({ localIp: "10.1.0.31", channel: 0 }, fakeFetch); + const switched = await setRelayState({ localIp: "10.1.0.31", channel: 0, on: false }, fakeFetch); + + assert.equal(status.online, true); + assert.equal(status.on, true); + assert.equal(switched.on, false); +}); + +test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => { + const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => { + assert.equal(String(url), "http://192.168.1.2/shelly"); + return { + ok: true, + async json() { + return { + id: "shelly1minig3-e4b3231f6410", + mac: "E4B3231F6410", + model: "S3SW-001X8EU", + type: "Shelly 1 Mini Gen3", + num_switches: 1, + }; + }, + }; + }); + + assert.equal(inventory.length, 1); + assert.equal(inventory[0].device_id, "E4B3231F6410"); + assert.equal(inventory[0].model, "S3SW-001X8EU"); + assert.equal(inventory[0].capabilities.generation, 3); +}); + +test("relay status reads use the legacy endpoint when the RPC status endpoint stalls", async () => { + const urls = []; + const fakeFetch = async (url) => { + urls.push(String(url)); + if (String(url).includes("Switch.GetStatus")) { + return new Promise(() => {}); + } + + if (String(url) === "http://10.1.0.31/relay/0") { + return { + ok: true, + async json() { + return { ison: false }; + }, + }; + } + + throw new Error(`Unexpected URL: ${url}`); + }; + const startedAt = Date.now(); + + const status = await getRelayStatus({ localIp: "10.1.0.31", channel: 0, timeoutMs: 50 }, fakeFetch); + + assert.equal(status.online, true); + assert.equal(status.on, false); + assert.deepEqual(urls.sort(), [ + "http://10.1.0.31/relay/0", + "http://10.1.0.31/rpc/Switch.GetStatus?id=0", + ]); + assert.ok(Date.now() - startedAt < 250); +}); + +test("relay switch commands fail quickly when the local Shelly request stalls", async () => { + let calls = 0; + const hangingFetch = async () => { + calls += 1; + return new Promise(() => {}); + }; + const startedAt = Date.now(); + + await assert.rejects( + () => setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, timeoutMs: 50 }, hangingFetch), + /HTTP request timed out after 50ms/ + ); + + assert.equal(calls, 1); + assert.ok(Date.now() - startedAt < 500); +}); + +test("relay switch commands pass timer values to local Shelly APIs", async () => { + const legacyUrls = []; + const legacyFetch = async (url) => { + legacyUrls.push(String(url)); + return { + ok: true, + async json() { + return { ison: true, has_timer: true, timer_duration: 3 }; + }, + }; + }; + + await setRelayState({ + localIp: "10.1.0.31", + channel: 0, + on: true, + toggleAfter: 3, + deviceGeneration: 1, + }, legacyFetch); + + assert.equal( + legacyUrls[0], + "http://10.1.0.31/relay/0?turn=on&timer=3" + ); + + const gen3Urls = []; + const gen3Fetch = async (url) => { + gen3Urls.push(String(url)); + + return { + ok: true, + async json() { + return { output: true }; + }, + }; + }; + + await setRelayState({ + localIp: "10.1.0.31", + channel: 0, + on: true, + toggle_after: 3, + device_generation: 3, + }, gen3Fetch); + + assert.equal( + gen3Urls[0], + "http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3" + ); + + const fallbackUrls = []; + const legacyFallbackFetch = async (url) => { + fallbackUrls.push(String(url)); + if (String(url).includes("/rpc/")) { + throw new Error("RPC switch endpoint unsupported"); + } + + return { + ok: true, + async json() { + return { ison: true, has_timer: true, timer_duration: 3 }; + }, + }; + }; + + await setRelayState({ + localIp: "10.1.0.31", + channel: 0, + on: true, + toggle_after: 3, + device_model: "S3SW-001X8EU", + }, legacyFallbackFetch); + + assert.deepEqual(fallbackUrls, [ + "http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3", + "http://10.1.0.31/relay/0?turn=on&timer=3", + ]); +}); + +test("runUpdate stages a pending verification restart after installing new artifacts", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-update-")); + const configPath = path.join(tempDir, "config.json"); + const liveConfig = { + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + installDir: tempDir, + restartMode: "spawn", + installedVersion: "1.0.0", + targetVersion: "1.0.0", + }; + + await writeFile(configPath, JSON.stringify(liveConfig, null, 2)); + await writeFile(path.join(tempDir, "agent.mjs"), "// old agent\n"); + await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ name: "old-edge-agent" }, null, 2)); + + const execCalls = []; + const fakeExecFile = async (command, args, options) => { + execCalls.push({ command, args, options }); + return { stdout: "{}" }; + }; + const fakeFetch = async (url) => { + if (String(url).endsWith("/agent.mjs")) { + return makeFetchResponse("// new agent\n"); + } + if (String(url).endsWith("/package.json")) { + return makeFetchResponse(JSON.stringify({ name: "new-edge-agent" }, null, 2)); + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const result = await runUpdate({ + artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs", + packageUrl: "https://api.example.test/edge-agent/artifacts/package.json", + targetVersion: "1.1.0", + releaseChannel: "stable", + restartMode: "spawn", + }, fakeFetch, { + configPath, + config: liveConfig, + liveConfig, + execFileImpl: fakeExecFile, + }); + + const persistedConfig = JSON.parse(await readFile(configPath, "utf8")); + + assert.equal(result.__agentCommandEnvelope, true); + assert.equal(result.payload.verification_pending, true); + assert.equal(result.payload.target_version, "1.1.0"); + assert.equal(liveConfig.targetVersion, "1.1.0"); + assert.equal(liveConfig.installedVersion, "1.0.0"); + assert.equal(persistedConfig.pendingUpdate.targetVersion, "1.1.0"); + assert.equal(persistedConfig.pendingUpdate.previousVersion, "1.0.0"); + assert.match(persistedConfig.pendingUpdate.backupDir, /\.updates/); + assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// new agent\n"); + assert.equal(execCalls.length, 2); + assert.deepEqual(execCalls[0].args, ["install", "--omit=dev"]); + assert.deepEqual(execCalls[1].args, [path.join(tempDir, "agent.mjs"), "status", "--config", configPath]); + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("handleAgentCommand returns an uninstall follow-up envelope for gateway removal", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-envelope-")); + const configPath = path.join(tempDir, "config.json"); + await writeFile(configPath, JSON.stringify({ + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + installToken: "install-token", + brokerUrl: "https://broker.example.test", + restartMode: "spawn", + })); + + const result = await handleAgentCommand( + { + commandType: "UNINSTALL_AGENT", + payload: { + serviceName: "truckwash-edge-agent.service", + }, + }, + { + configPath, + config: await loadConfig(configPath), + liveConfig: null, + } + ); + + assert.equal(result.__agentCommandEnvelope, true); + assert.equal(result.payload.uninstall_scheduled, true); + assert.equal(result.followUp.type, "UNINSTALL_AGENT"); + assert.equal(result.followUp.uninstallPlan.configPath, configPath); + assert.equal(result.followUp.uninstallPlan.serviceName, "truckwash-edge-agent.service"); + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("processPolledCommand acknowledges uninstall before clearing credentials and exiting", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-")); + const configPath = path.join(tempDir, "config.json"); + const config = { + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + installToken: "install-token", + brokerUrl: "https://broker.example.test", + restartMode: "spawn", + }; + await writeFile(configPath, JSON.stringify(config, null, 2)); + + const commandResultPosts = []; + const fakeFetch = async (url, options = {}) => { + const body = options.body ? JSON.parse(options.body) : {}; + + if (String(url).endsWith("/commands/77/result")) { + commandResultPosts.push({ url, body }); + return { + ok: true, + async json() { + return { data: { acknowledged: true } }; + }, + }; + } + + throw new Error(`Unexpected URL: ${url}`); + }; + + const exitCodes = []; + const result = await processPolledCommand( + { + ...config, + configPath, + }, + { + id: 77, + commandType: "UNINSTALL_AGENT", + payload: { + serviceName: "truckwash-edge-agent.service", + }, + }, + fakeFetch, + { + waitImpl: async () => {}, + exitProcessImpl: (code) => { + exitCodes.push(code); + }, + } + ); + + const persisted = JSON.parse(await readFile(configPath, "utf8")); + + assert.equal(result.ok, true); + assert.equal(result.payload.uninstall_scheduled, true); + assert.equal(commandResultPosts.length, 1); + assert.equal(commandResultPosts[0].body.ok, true); + assert.equal(commandResultPosts[0].body.payload.uninstall_scheduled, true); + assert.deepEqual(exitCodes, [0]); + assert.equal(persisted.gatewayId, null); + assert.equal(persisted.agentToken, null); + assert.equal(persisted.installToken, null); + assert.equal(persisted.brokerUrl, null); + assert.equal(persisted.lastUninstall.state, "SCHEDULED"); + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("finalizePendingUpdateOnStartup promotes the target version and clears the pending update marker", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-startup-")); + const configPath = path.join(tempDir, "config.json"); + const config = { + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + installDir: tempDir, + installedVersion: "1.0.0", + targetVersion: "1.1.0", + pendingUpdate: { + targetVersion: "1.1.0", + previousVersion: "1.0.0", + releaseChannel: "stable", + backupDir: path.join(tempDir, ".updates", "backup"), + }, + }; + await writeFile(configPath, JSON.stringify(config, null, 2)); + + const finalized = await finalizePendingUpdateOnStartup(config, configPath, { + liveConfig: config, + }); + const persisted = JSON.parse(await readFile(configPath, "utf8")); + + assert.equal(finalized.installedVersion, "1.1.0"); + assert.equal(finalized.pendingUpdate, null); + assert.equal(finalized.lastUpdate.state, "COMPLETED"); + assert.equal(persisted.installedVersion, "1.1.0"); + assert.equal(persisted.pendingUpdate, null); + assert.equal(persisted.lastUpdate.targetVersion, "1.1.0"); + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("verifyPendingUpdate rolls back the previous files and respawns the agent when startup verification times out", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-rollback-")); + const updatesDir = path.join(tempDir, ".updates", "rollback-case"); + const configPath = path.join(tempDir, "config.json"); + + await mkdir(updatesDir, { recursive: true }); + await writeFile(path.join(tempDir, "agent.mjs"), "// broken new agent\n"); + await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ name: "broken-edge-agent" }, null, 2)); + await writeFile(path.join(updatesDir, "agent.mjs"), "// old agent\n"); + await writeFile(path.join(updatesDir, "package.json"), JSON.stringify({ name: "old-edge-agent" }, null, 2)); + + const config = { + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + installDir: tempDir, + restartMode: "spawn", + installedVersion: "1.0.0", + targetVersion: "1.1.0", + pendingUpdate: { + targetVersion: "1.1.0", + previousVersion: "1.0.0", + releaseChannel: "stable", + backupDir: updatesDir, + installDir: tempDir, + restartMode: "spawn", + verificationTimeoutSeconds: 5, + }, + }; + await writeFile(configPath, JSON.stringify(config, null, 2)); + + const execCalls = []; + const fakeExecFile = async (command, args, options) => { + execCalls.push({ command, args, options }); + return { stdout: "{}" }; + }; + const spawnCalls = []; + const fakeSpawn = (command, args, options) => { + spawnCalls.push({ command, args, options }); + return { + unref() {}, + }; + }; + + const result = await verifyPendingUpdate(configPath, { + execFileImpl: fakeExecFile, + spawnImpl: fakeSpawn, + timeoutMs: 1, + waitImpl: async () => {}, + verifyIntervalMs: 1, + }); + + const persisted = JSON.parse(await readFile(configPath, "utf8")); + + assert.equal(result.verified, false); + assert.equal(result.rolledBack, true); + assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// old agent\n"); + assert.equal(persisted.pendingUpdate, null); + assert.equal(persisted.lastUpdate.state, "ROLLED_BACK"); + assert.equal(persisted.installedVersion, "1.0.0"); + assert.equal(execCalls.length, 1); + assert.deepEqual(execCalls[0].args, ["install", "--omit=dev"]); + assert.equal(spawnCalls.length, 1); + assert.equal(spawnCalls[0].command, process.execPath); + assert.deepEqual(spawnCalls[0].args, [path.join(tempDir, "agent.mjs"), "--config", configPath]); + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("shell bridge proxies PTY output, input, resize, close, and dispose events", async () => { + const messages = []; + const createdPtys = []; + + const shell = createShellBridge( + (message) => messages.push(message), + { + createPtyProcess: async (options) => { + const listeners = { + data: null, + exit: null, + }; + + const pty = { + options, + writes: [], + resizes: [], + killed: false, + onData(callback) { + listeners.data = callback; + return { + dispose() { + listeners.data = null; + }, + }; + }, + onExit(callback) { + listeners.exit = callback; + return { + dispose() { + listeners.exit = null; + }, + }; + }, + write(data) { + this.writes.push(data); + }, + resize(cols, rows) { + this.resizes.push({ cols, rows }); + }, + kill() { + this.killed = true; + listeners.exit?.({ exitCode: 0 }); + }, + emitData(data) { + listeners.data?.(data); + }, + }; + + createdPtys.push(pty); + return pty; + }, + } + ); + + await shell.open({ + sessionId: "test-shell", + shellCommand: "/bin/bash", + shellArgs: ["-l"], + cols: 90, + rows: 24, + cwd: "/tmp", + }); + + createdPtys[0].emitData("root@pi:~# "); + shell.input({ sessionId: "test-shell", data: "ls\r" }); + shell.resize({ sessionId: "test-shell", cols: 120, rows: 40 }); + shell.dispose(); + + assert.equal(createdPtys.length, 1); + assert.equal(createdPtys[0].options.command, "/bin/bash"); + assert.deepEqual(createdPtys[0].options.args, ["-l"]); + assert.equal(createdPtys[0].options.cols, 90); + assert.equal(createdPtys[0].options.rows, 24); + assert.equal(createdPtys[0].options.cwd, "/tmp"); + assert.deepEqual(createdPtys[0].writes, ["ls\r"]); + assert.deepEqual(createdPtys[0].resizes, [{ cols: 120, rows: 40 }]); + assert.equal(createdPtys[0].killed, true); + assert.ok(messages.some((message) => message.type === "SHELL_OPENED" && message.sessionId === "test-shell")); + assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("root@pi"))); + assert.ok(messages.some((message) => message.type === "SHELL_EXIT" && message.code === 0)); +}); + +test("shell bridge reports structured spawn failures", async () => { + const messages = []; + const shell = createShellBridge( + (message) => messages.push(message), + { + createPtyProcess: async () => { + throw new Error("node-pty unavailable"); + }, + } + ); + + await shell.open({ sessionId: "spawn-failure-shell" }); + + assert.ok( + messages.some( + (message) => + message.type === "SHELL_OUTPUT" && + message.sessionId === "spawn-failure-shell" && + /Failed to start root shell: node-pty unavailable/.test(message.data) + ) + ); + assert.ok( + messages.some( + (message) => + message.type === "SHELL_EXIT" && + message.sessionId === "spawn-failure-shell" && + message.code === 1 && + message.reason === "shell_spawn_failed" && + message.message === "node-pty unavailable" + ) + ); +}); + +test("startAgent reports API polling metadata, executes polled commands, and uploads shell events", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-")); + const configPath = path.join(tempDir, "config.json"); + await writeFile(configPath, JSON.stringify({ + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + heartbeatIntervalSeconds: 0.05, + commandPollTimeoutSeconds: 0, + commandPollRetryDelayMs: 5, + shellActionPollTimeoutSeconds: 0, + shellActionPollRetryDelayMs: 5, + enableShellAccess: true, + })); + + const heartbeats = []; + const commandResultPosts = []; + const shellActionResults = []; + const shellEventPosts = []; + let polledCommandDelivered = false; + let openActionDelivered = false; + let closeActionDelivered = false; + let metricSample = 0; + + const fakeFetch = async (url, options = {}) => { + const body = options.body ? JSON.parse(options.body) : {}; + + if (String(url).endsWith("/heartbeat")) { + heartbeats.push({ url, body }); + return { + ok: true, + async json() { + return { data: { ok: true } }; + }, + }; + } + + if (String(url).endsWith("/commands/poll")) { + if (!polledCommandDelivered) { + polledCommandDelivered = true; + return { + ok: true, + async json() { + return { + data: { + id: 99, + commandType: "DISCOVER_SHELLY", + payload: { + candidateIps: ["10.1.0.31"], + }, + }, + }; + }, + }; + } + + await new Promise((resolve) => setTimeout(resolve, 5)); + return { + ok: true, + async json() { + return { data: null }; + }, + }; + } + + if (String(url).endsWith("/commands/99/result")) { + commandResultPosts.push({ url, body }); + return { + ok: true, + async json() { + return { data: { acknowledged: true } }; + }, + }; + } + + if (String(url).endsWith("/shell-actions/poll")) { + if (!openActionDelivered) { + openActionDelivered = true; + return { + ok: true, + async json() { + return { + data: { + id: 501, + actionType: "OPEN", + payload: { + sessionId: 44, + cols: 100, + rows: 30, + reason: "Investigate relay drift", + }, + }, + }; + }, + }; + } + + if (!closeActionDelivered) { + closeActionDelivered = true; + return { + ok: true, + async json() { + return { + data: { + id: 502, + actionType: "CLOSE", + payload: { + sessionId: 44, + }, + }, + }; + }, + }; + } + + await new Promise((resolve) => setTimeout(resolve, 5)); + return { + ok: true, + async json() { + return { data: null }; + }, + }; + } + + if (/\/shell-actions\/\d+\/result$/.test(String(url))) { + shellActionResults.push({ url, body }); + return { + ok: true, + async json() { + return { data: { acknowledged: true } }; + }, + }; + } + + if (String(url).includes("/shell-sessions/44/events")) { + shellEventPosts.push({ url, body }); + return { + ok: true, + async json() { + return { data: { acknowledged: true } }; + }, + }; + } + + if (String(url) === "http://10.1.0.31/shelly") { + return { + ok: true, + async json() { + return { + mac: "AA:BB:CC:DD:EE:FF", + model: "Shelly Plus 1PM", + num_switches: 1, + }; + }, + }; + } + + throw new Error(`Unexpected URL: ${url}`); + }; + + const collectMetricsImpl = async ({ latencyMs }) => { + metricSample += 1; + return { + cpuSnapshot: { idle: metricSample, total: metricSample + 10 }, + metrics: { + latency_ms: latencyMs, + cpu_usage_pct: 27.5, + memory_usage_pct: 48.2, + memory_used_bytes: 2048, + memory_total_bytes: 4096, + disk_usage_pct: 61.4, + disk_used_bytes: 8192, + disk_total_bytes: 16384, + disk_mount: "/", + }, + }; + }; + + const shellCalls = []; + const createShellBridgeImpl = (sendMessage) => ({ + async open(payload) { + shellCalls.push({ type: "open", payload }); + sendMessage({ type: "SHELL_OPENED", sessionId: payload.sessionId }); + sendMessage({ type: "SHELL_OUTPUT", sessionId: payload.sessionId, data: "root@pi:~# " }); + }, + input(payload) { + shellCalls.push({ type: "input", payload }); + }, + resize(payload) { + shellCalls.push({ type: "resize", payload }); + }, + close(payload) { + shellCalls.push({ type: "close", payload }); + sendMessage({ type: "SHELL_EXIT", sessionId: payload.sessionId, code: 0 }); + }, + dispose() { + shellCalls.push({ type: "dispose" }); + }, + }); + + let agent = null; + try { + agent = await startAgent({ + configPath, + fetchImpl: fakeFetch, + collectMetricsImpl, + createShellBridgeImpl, + }); + + assert.equal(heartbeats[0].body.status, "ONLINE"); + assert.equal(heartbeats[0].body.metadata.command_transport, "API_POLLING"); + assert.equal(heartbeats[0].body.metadata.shell_transport, "API_POLLING"); + assert.equal(heartbeats[0].body.metadata.system_metrics.cpu_usage_pct, 27.5); + assert.equal(heartbeats[0].body.metadata.system_metrics.memory_usage_pct, 48.2); + assert.equal(heartbeats[0].body.metadata.system_metrics.disk_usage_pct, 61.4); + assert.equal(heartbeats[0].body.metadata.system_metrics.latency_ms, null); + assert.equal(heartbeats[0].body.metadata.broker_connected, false); + assert.equal(heartbeats[0].body.metadata.broker_url, null); + assert.equal("discovery_status" in heartbeats[0].body, false); + + await waitFor( + () => + commandResultPosts.length === 1 && + shellActionResults.length === 2 && + shellEventPosts.length > 0 && + heartbeats.some( + (heartbeat) => + heartbeat.body.metadata.system_metrics && + typeof heartbeat.body.metadata.system_metrics.latency_ms === "number" + ), + { timeoutMs: 1000, description: "agent polling activity and follow-up heartbeat" } + ); + + assert.equal(commandResultPosts.length, 1); + assert.equal(commandResultPosts[0].body.ok, true); + assert.equal(Array.isArray(commandResultPosts[0].body.payload.inventory), true); + assert.equal(commandResultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF"); + assert.equal(commandResultPosts[0].body.payload.inventory[0].capabilities.generation, 2); + + assert.ok(shellCalls.some((call) => call.type === "open")); + assert.ok(shellCalls.some((call) => call.type === "close")); + assert.equal(shellActionResults.length, 2); + assert.ok(shellActionResults.every((result) => result.body.ok === true)); + assert.ok( + shellEventPosts.some((request) => + request.body.events.some((event) => event.type === "OPENED") + ) + ); + assert.ok( + shellEventPosts.some((request) => + request.body.events.some((event) => event.type === "OUTPUT") + ) + ); + assert.ok( + shellEventPosts.some((request) => + request.body.events.some((event) => event.type === "CLOSED") + ) + ); + assert.ok( + heartbeats.some( + (heartbeat) => + heartbeat.body.metadata.system_metrics && + typeof heartbeat.body.metadata.system_metrics.latency_ms === "number" + ) + ); + } finally { + await agent?.stop(); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("processPolledShellAction denies shell access when locally disabled", async () => { + const submissions = []; + const fakeFetch = async (url, options = {}) => { + if (/\/shell-actions\/\d+\/result$/.test(String(url))) { + submissions.push({ url, body: JSON.parse(options.body) }); + return { + ok: true, + async json() { + return { data: { acknowledged: true } }; + }, + }; + } + + throw new Error(`Unexpected URL: ${url}`); + }; + + const shell = { + async open() { + throw new Error("should not run"); + }, + input() { + throw new Error("should not run"); + }, + resize() { + throw new Error("should not run"); + }, + close() { + throw new Error("should not run"); + }, + }; + + const result = await processPolledShellAction( + { + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + enableShellAccess: false, + }, + { + id: 501, + actionType: "OPEN", + payload: { + sessionId: 44, + }, + }, + shell, + fakeFetch + ); + + assert.equal(result.ok, false); + assert.match(result.error, /Shell access is disabled/); + assert.equal(submissions.length, 1); + assert.equal(submissions[0].body.ok, false); +}); + +test("status helpers report config without exposing the agent token", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-")); + const configPath = path.join(tempDir, "config.json"); + await writeFile(configPath, JSON.stringify({ + apiUrl: "https://api.example.test", + installToken: "install-token", + gatewayId: 42, + agentToken: "agent-token", + installedVersion: "1.2.3", + targetVersion: "1.2.4", + releaseChannel: "stable", + brokerUrl: "https://broker.example.test", + heartbeatIntervalSeconds: 30, + })); + + const report = await getAgentStatus(configPath); + const built = buildStatusReport(configPath, { + apiUrl: "https://api.example.test", + installToken: "install-token", + gatewayId: 42, + agentToken: "agent-token", + brokerUrl: "https://broker.example.test", + }); + + assert.equal(report.command, "status"); + assert.equal(report.configPath, configPath); + assert.equal(report.claimed, true); + assert.equal(report.state, "CLAIMED"); + assert.equal(report.gatewayId, 42); + assert.equal(report.transport, "HYBRID"); + assert.equal(report.brokerUrl, "https://broker.example.test"); + assert.equal(report.hasInstallToken, true); + assert.equal("agentToken" in report, false); + assert.equal(built.state, "CLAIMED"); + assert.equal(built.transport, "HYBRID"); + assert.equal(built.brokerUrl, "https://broker.example.test"); + assert.equal("agentToken" in built, false); + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("parseCliArgs understands the status command and config flag", () => { + assert.deepEqual( + parseCliArgs(["status", "--config", "/tmp/config.json"]), + { command: "status", configPath: "/tmp/config.json" } + ); + assert.deepEqual( + parseCliArgs(["--config", "/tmp/config.json"]), + { command: "start", configPath: "/tmp/config.json" } + ); +}); + +test("runCli emits a status report for the status command", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-cli-")); + const configPath = path.join(tempDir, "config.json"); + await writeFile(configPath, JSON.stringify({ + apiUrl: "https://api.example.test", + installToken: "install-token", + })); + + let stdout = ""; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk, encoding, callback) => { + stdout += String(chunk); + if (typeof encoding === "function") { + encoding(); + } else if (typeof callback === "function") { + callback(); + } + return true; + }); + + try { + const report = await runCli(["status", "--config", configPath]); + assert.equal(report.state, "PENDING_CLAIM"); + const parsed = JSON.parse(stdout); + assert.equal(parsed.command, "status"); + assert.equal(parsed.claimed, false); + assert.equal(parsed.transport, "API_POLLING"); + assert.equal("brokerWsUrl" in parsed, false); + } finally { + process.stdout.write = originalWrite; + } + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("agent CLI status command prints the report", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-process-")); + const configPath = path.join(tempDir, "config.json"); + await writeFile(configPath, JSON.stringify({ + apiUrl: "https://api.example.test", + installToken: "install-token", + gatewayId: 7, + agentToken: "secret-token", + })); + + const { stdout } = await execFile( + process.execPath, + [agentEntryPath, "status", "--config", configPath], + { windowsHide: true, encoding: "utf8" } + ); + const parsed = JSON.parse(stdout); + + assert.equal(parsed.command, "status"); + assert.equal(parsed.gatewayId, 7); + assert.equal(parsed.claimed, true); + assert.equal(parsed.transport, "API_POLLING"); + assert.equal("agentToken" in parsed, false); + + await rm(tempDir, { recursive: true, force: true }); +}); diff --git a/services/edge-broker/Dockerfile b/services/edge-broker/Dockerfile new file mode 100644 index 00000000..241ef841 --- /dev/null +++ b/services/edge-broker/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine + +WORKDIR /app + +ENV NODE_ENV=production + +COPY services/edge-broker/package*.json ./ +RUN npm ci --omit=dev + +COPY services/edge-broker/server.mjs ./server.mjs + +EXPOSE 4300 + +CMD ["node", "server.mjs"] diff --git a/services/edge-broker/live/live-smoke.mjs b/services/edge-broker/live/live-smoke.mjs new file mode 100644 index 00000000..8226f951 --- /dev/null +++ b/services/edge-broker/live/live-smoke.mjs @@ -0,0 +1,264 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; + +import WebSocket from "ws"; + +import { createBrokerServer } from "../server.mjs"; + +const execFile = promisify(execFileCallback); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fakeAgentScriptPath = path.resolve(__dirname, "../../edge-agent/test-support/fake-agent.mjs"); + +async function runCommand(command, args, { allowFailure = false } = {}) { + try { + return await execFile(command, args, { + windowsHide: true, + encoding: "utf8", + }); + } catch (error) { + if (allowFailure) { + return { + stdout: error.stdout || "", + stderr: error.stderr || "", + code: error.code, + }; + } + throw error; + } +} + +async function removeContainer(name) { + await runCommand("docker", ["rm", "-f", name], { allowFailure: true }); +} + +async function startFakeAgent({ port, gatewayId, agentToken, behavior }) { + const containerName = `edge-live-${behavior}-${randomUUID().slice(0, 8)}`; + const brokerUrl = `http://127.0.0.1:${port}`; + + await runCommand("docker", [ + "run", + "-d", + "--rm", + "--name", + containerName, + "-e", + `EDGE_TEST_BROKER_URL=${brokerUrl}`, + "-e", + `EDGE_TEST_GATEWAY_ID=${gatewayId}`, + "-e", + `EDGE_TEST_AGENT_TOKEN=${agentToken}`, + "-e", + `EDGE_TEST_BEHAVIOR=${behavior}`, + "-v", + `${fakeAgentScriptPath}:/app/fake-agent.mjs:ro`, + "node:22-alpine", + "node", + "/app/fake-agent.mjs", + ]); + + return containerName; +} + +async function waitForCondition(predicate, { timeoutMs = 5_000, intervalMs = 50, message = "Timed out" } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(message); +} + +function waitForSocketEvent(socket, eventName) { + return new Promise((resolve) => { + socket.once(eventName, (...args) => resolve(args)); + }); +} + +function collectMessages(socket) { + const messages = []; + socket.on("message", (raw) => { + messages.push(JSON.parse(raw.toString())); + }); + return messages; +} + +async function createHarness(t, options = {}) { + const broker = createBrokerServer({ + authMode: "stub", + sharedSecret: "secret", + commandTimeoutMs: options.commandTimeoutMs ?? 300, + validateShellSession: async () => ({ + id: "shell-live-1", + gateway_id: String(options.gatewayId ?? 701), + reason: "diagnostic", + }), + }); + const address = await broker.listen(0); + const port = address.port; + const gatewayId = String(options.gatewayId ?? 701); + const agentToken = options.agentToken ?? "agent-token"; + const containers = []; + + t.after(async () => { + await Promise.all(containers.map((name) => removeContainer(name))); + await broker.close(); + }); + + return { + broker, + port, + gatewayId, + agentToken, + async startAgent(behavior) { + const containerName = await startFakeAgent({ + port, + gatewayId, + agentToken, + behavior, + }); + containers.push(containerName); + if (behavior !== "offline") { + await waitForCondition( + () => broker.state.agents.has(gatewayId), + { message: `Fake agent did not connect for behavior ${behavior}` } + ); + } + return containerName; + }, + }; +} + +test("live smoke: dockerized fake agent handles command and shell round-trips", async (t) => { + const harness = await createHarness(t); + await harness.startAgent("success"); + + const commandResponse = await fetch(`http://127.0.0.1:${harness.port}/api/gateways/${harness.gatewayId}/commands`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-edge-broker-secret": "secret", + }, + body: JSON.stringify({ + commandType: "GET_RELAY_STATUS", + payload: { relayId: "M-7", localIp: "10.1.0.31" }, + }), + }); + const commandJson = await commandResponse.json(); + + assert.equal(commandResponse.status, 200); + assert.equal(commandJson.ok, true); + assert.deepEqual(commandJson.payload.echo, { + relayId: "M-7", + localIp: "10.1.0.31", + }); + + const browser = new WebSocket(`ws://127.0.0.1:${harness.port}/ws/browser-shell?token=session-token`); + const browserMessages = collectMessages(browser); + t.after(() => browser.readyState < 2 && browser.close()); + + await waitForSocketEvent(browser, "open"); + await waitForCondition( + () => browserMessages.some((message) => message.type === "opened"), + { message: "Browser shell never opened" } + ); + + browser.send(JSON.stringify({ type: "input", data: "ls\r" })); + await waitForCondition( + () => browserMessages.some((message) => message.type === "output" && message.data.includes("echo:ls")), + { message: "Browser shell never received echoed input" } + ); + + browser.send(JSON.stringify({ type: "close" })); + await waitForCondition( + () => browserMessages.some((message) => message.type === "closed" && message.code === 0), + { message: "Browser shell never closed cleanly" } + ); +}); + +test("live smoke: offline agent returns gateway unavailable", async (t) => { + const harness = await createHarness(t); + await harness.startAgent("offline"); + await new Promise((resolve) => setTimeout(resolve, 150)); + + const response = await fetch(`http://127.0.0.1:${harness.port}/api/gateways/${harness.gatewayId}/commands`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-edge-broker-secret": "secret", + }, + body: JSON.stringify({ + commandType: "GET_RELAY_STATUS", + payload: { relayId: "M-7" }, + }), + }); + const json = await response.json(); + + assert.equal(response.status, 503); + assert.equal(json.error, "Gateway agent is offline"); +}); + +test("live smoke: unresponsive agent times out command dispatch", async (t) => { + const harness = await createHarness(t, { commandTimeoutMs: 200 }); + await harness.startAgent("timeout"); + + const response = await fetch(`http://127.0.0.1:${harness.port}/api/gateways/${harness.gatewayId}/commands`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-edge-broker-secret": "secret", + }, + body: JSON.stringify({ + commandType: "GET_RELAY_STATUS", + payload: { relayId: "M-7" }, + }), + }); + const json = await response.json(); + + assert.equal(response.status, 504); + assert.equal(json.ok, false); + assert.equal(json.error, "Agent command timed out"); +}); + +test("live smoke: bad shared secret is rejected", async (t) => { + const harness = await createHarness(t); + await harness.startAgent("success"); + + const response = await fetch(`http://127.0.0.1:${harness.port}/api/gateways/${harness.gatewayId}/commands`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-edge-broker-secret": "wrong-secret", + }, + body: JSON.stringify({ + commandType: "GET_RELAY_STATUS", + payload: { relayId: "M-7" }, + }), + }); + const json = await response.json(); + + assert.equal(response.status, 403); + assert.equal(json.error, "Forbidden"); +}); + +test("live smoke: browser shell fails cleanly when agent disconnects before shell open", async (t) => { + const harness = await createHarness(t); + await harness.startAgent("shell-open-fail"); + + const browser = new WebSocket(`ws://127.0.0.1:${harness.port}/ws/browser-shell?token=session-token`); + const browserMessages = collectMessages(browser); + const closePromise = waitForSocketEvent(browser, "close"); + + await waitForSocketEvent(browser, "open"); + await closePromise; + + assert.deepEqual(browserMessages, []); + assert.equal(harness.broker.state.browserSessions.size, 0); + assert.equal(harness.broker.state.agents.size, 0); +}); diff --git a/services/edge-broker/node_modules/.package-lock.json b/services/edge-broker/node_modules/.package-lock.json new file mode 100644 index 00000000..ec33da94 --- /dev/null +++ b/services/edge-broker/node_modules/.package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "truckwash-edge-broker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/services/edge-broker/node_modules/ws/LICENSE b/services/edge-broker/node_modules/ws/LICENSE new file mode 100644 index 00000000..1da5b96a --- /dev/null +++ b/services/edge-broker/node_modules/ws/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2011 Einar Otto Stangvik +Copyright (c) 2013 Arnout Kazemier and contributors +Copyright (c) 2016 Luigi Pinca and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/services/edge-broker/node_modules/ws/README.md b/services/edge-broker/node_modules/ws/README.md new file mode 100644 index 00000000..21f10df1 --- /dev/null +++ b/services/edge-broker/node_modules/ws/README.md @@ -0,0 +1,548 @@ +# ws: a Node.js WebSocket library + +[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws) +[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) +[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws) + +ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and +server implementation. + +Passes the quite extensive Autobahn test suite: [server][server-report], +[client][client-report]. + +**Note**: This module does not work in the browser. The client in the docs is a +reference to a backend with the role of a client in the WebSocket communication. +Browser clients must use the native +[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) +object. To make the same code work seamlessly on Node.js and the browser, you +can use one of the many wrappers available on npm, like +[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws). + +## Table of Contents + +- [Protocol support](#protocol-support) +- [Installing](#installing) + - [Opt-in for performance](#opt-in-for-performance) + - [Legacy opt-in for performance](#legacy-opt-in-for-performance) +- [API docs](#api-docs) +- [WebSocket compression](#websocket-compression) +- [Usage examples](#usage-examples) + - [Sending and receiving text data](#sending-and-receiving-text-data) + - [Sending binary data](#sending-binary-data) + - [Simple server](#simple-server) + - [External HTTP/S server](#external-https-server) + - [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server) + - [Client authentication](#client-authentication) + - [Server broadcast](#server-broadcast) + - [Round-trip time](#round-trip-time) + - [Use the Node.js streams API](#use-the-nodejs-streams-api) + - [Other examples](#other-examples) +- [FAQ](#faq) + - [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client) + - [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections) + - [How to connect via a proxy?](#how-to-connect-via-a-proxy) +- [Changelog](#changelog) +- [License](#license) + +## Protocol support + +- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`) +- **HyBi drafts 13-17** (Current default, alternatively option + `protocolVersion: 13`) + +## Installing + +``` +npm install ws +``` + +### Opt-in for performance + +[bufferutil][] is an optional module that can be installed alongside the ws +module: + +``` +npm install --save-optional bufferutil +``` + +This is a binary addon that improves the performance of certain operations such +as masking and unmasking the data payload of the WebSocket frames. Prebuilt +binaries are available for the most popular platforms, so you don't necessarily +need to have a C++ compiler installed on your machine. + +To force ws to not use bufferutil, use the +[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This +can be useful to enhance security in systems where a user can put a package in +the package search path of an application of another user, due to how the +Node.js resolver algorithm works. + +#### Legacy opt-in for performance + +If you are running on an old version of Node.js (prior to v18.14.0), ws also +supports the [utf-8-validate][] module: + +``` +npm install --save-optional utf-8-validate +``` + +This contains a binary polyfill for [`buffer.isUtf8()`][]. + +To force ws not to use utf-8-validate, use the +[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable. + +## API docs + +See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and +utility functions. + +## WebSocket compression + +ws supports the [permessage-deflate extension][permessage-deflate] which enables +the client and server to negotiate a compression algorithm and its parameters, +and then selectively apply it to the data payloads of each WebSocket message. + +The extension is disabled by default on the server and enabled by default on the +client. It adds a significant overhead in terms of performance and memory +consumption so we suggest to enable it only if it is really needed. + +Note that Node.js has a variety of issues with high-performance compression, +where increased concurrency, especially on Linux, can lead to [catastrophic +memory fragmentation][node-zlib-bug] and slow performance. If you intend to use +permessage-deflate in production, it is worthwhile to set up a test +representative of your workload and ensure Node.js/zlib will handle it with +acceptable performance and memory usage. + +Tuning of permessage-deflate can be done via the options defined below. You can +also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly +into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs]. + +See [the docs][ws-server-options] for more options. + +```js +import WebSocket, { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ + port: 8080, + perMessageDeflate: { + zlibDeflateOptions: { + // See zlib defaults. + chunkSize: 1024, + memLevel: 7, + level: 3 + }, + zlibInflateOptions: { + chunkSize: 10 * 1024 + }, + // Other options settable: + clientNoContextTakeover: true, // Defaults to negotiated value. + serverNoContextTakeover: true, // Defaults to negotiated value. + serverMaxWindowBits: 10, // Defaults to negotiated value. + // Below options specified as default values. + concurrencyLimit: 10, // Limits zlib concurrency for perf. + threshold: 1024 // Size (in bytes) below which messages + // should not be compressed if context takeover is disabled. + } +}); +``` + +The client will only use the extension if it is supported and enabled on the +server. To always disable the extension on the client, set the +`perMessageDeflate` option to `false`. + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://www.host.com/path', { + perMessageDeflate: false +}); +``` + +## Usage examples + +### Sending and receiving text data + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://www.host.com/path'); + +ws.on('error', console.error); + +ws.on('open', function open() { + ws.send('something'); +}); + +ws.on('message', function message(data) { + console.log('received: %s', data); +}); +``` + +### Sending binary data + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://www.host.com/path'); + +ws.on('error', console.error); + +ws.on('open', function open() { + const array = new Float32Array(5); + + for (var i = 0; i < array.length; ++i) { + array[i] = i / 2; + } + + ws.send(array); +}); +``` + +### Simple server + +```js +import { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log('received: %s', data); + }); + + ws.send('something'); +}); +``` + +### External HTTP/S server + +```js +import { createServer } from 'https'; +import { readFileSync } from 'fs'; +import { WebSocketServer } from 'ws'; + +const server = createServer({ + cert: readFileSync('/path/to/cert.pem'), + key: readFileSync('/path/to/key.pem') +}); +const wss = new WebSocketServer({ server }); + +wss.on('connection', function connection(ws) { + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log('received: %s', data); + }); + + ws.send('something'); +}); + +server.listen(8080); +``` + +### Multiple servers sharing a single HTTP/S server + +```js +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; + +const server = createServer(); +const wss1 = new WebSocketServer({ noServer: true }); +const wss2 = new WebSocketServer({ noServer: true }); + +wss1.on('connection', function connection(ws) { + ws.on('error', console.error); + + // ... +}); + +wss2.on('connection', function connection(ws) { + ws.on('error', console.error); + + // ... +}); + +server.on('upgrade', function upgrade(request, socket, head) { + const { pathname } = new URL(request.url, 'wss://base.url'); + + if (pathname === '/foo') { + wss1.handleUpgrade(request, socket, head, function done(ws) { + wss1.emit('connection', ws, request); + }); + } else if (pathname === '/bar') { + wss2.handleUpgrade(request, socket, head, function done(ws) { + wss2.emit('connection', ws, request); + }); + } else { + socket.destroy(); + } +}); + +server.listen(8080); +``` + +### Client authentication + +```js +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; + +function onSocketError(err) { + console.error(err); +} + +const server = createServer(); +const wss = new WebSocketServer({ noServer: true }); + +wss.on('connection', function connection(ws, request, client) { + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log(`Received message ${data} from user ${client}`); + }); +}); + +server.on('upgrade', function upgrade(request, socket, head) { + socket.on('error', onSocketError); + + // This function is not defined on purpose. Implement it with your own logic. + authenticate(request, function next(err, client) { + if (err || !client) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + + socket.removeListener('error', onSocketError); + + wss.handleUpgrade(request, socket, head, function done(ws) { + wss.emit('connection', ws, request, client); + }); + }); +}); + +server.listen(8080); +``` + +Also see the provided [example][session-parse-example] using `express-session`. + +### Server broadcast + +A client WebSocket broadcasting to all connected WebSocket clients, including +itself. + +```js +import WebSocket, { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('error', console.error); + + ws.on('message', function message(data, isBinary) { + wss.clients.forEach(function each(client) { + if (client.readyState === WebSocket.OPEN) { + client.send(data, { binary: isBinary }); + } + }); + }); +}); +``` + +A client WebSocket broadcasting to every other connected WebSocket clients, +excluding itself. + +```js +import WebSocket, { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('error', console.error); + + ws.on('message', function message(data, isBinary) { + wss.clients.forEach(function each(client) { + if (client !== ws && client.readyState === WebSocket.OPEN) { + client.send(data, { binary: isBinary }); + } + }); + }); +}); +``` + +### Round-trip time + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('wss://websocket-echo.com/'); + +ws.on('error', console.error); + +ws.on('open', function open() { + console.log('connected'); + ws.send(Date.now()); +}); + +ws.on('close', function close() { + console.log('disconnected'); +}); + +ws.on('message', function message(data) { + console.log(`Round-trip time: ${Date.now() - data} ms`); + + setTimeout(function timeout() { + ws.send(Date.now()); + }, 500); +}); +``` + +### Use the Node.js streams API + +```js +import WebSocket, { createWebSocketStream } from 'ws'; + +const ws = new WebSocket('wss://websocket-echo.com/'); + +const duplex = createWebSocketStream(ws, { encoding: 'utf8' }); + +duplex.on('error', console.error); + +duplex.pipe(process.stdout); +process.stdin.pipe(duplex); +``` + +### Other examples + +For a full example with a browser client communicating with a ws server, see the +examples folder. + +Otherwise, see the test cases. + +## FAQ + +### How to get the IP address of the client? + +The remote IP address can be obtained from the raw socket. + +```js +import { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws, req) { + const ip = req.socket.remoteAddress; + + ws.on('error', console.error); +}); +``` + +When the server runs behind a proxy like NGINX, the de-facto standard is to use +the `X-Forwarded-For` header. + +```js +wss.on('connection', function connection(ws, req) { + const ip = req.headers['x-forwarded-for'].split(',')[0].trim(); + + ws.on('error', console.error); +}); +``` + +### How to detect and close broken connections? + +Sometimes, the link between the server and the client can be interrupted in a +way that keeps both the server and the client unaware of the broken state of the +connection (e.g. when pulling the cord). + +In these cases, ping messages can be used as a means to verify that the remote +endpoint is still responsive. + +```js +import { WebSocketServer } from 'ws'; + +function heartbeat() { + this.isAlive = true; +} + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.isAlive = true; + ws.on('error', console.error); + ws.on('pong', heartbeat); +}); + +const interval = setInterval(function ping() { + wss.clients.forEach(function each(ws) { + if (ws.isAlive === false) return ws.terminate(); + + ws.isAlive = false; + ws.ping(); + }); +}, 30000); + +wss.on('close', function close() { + clearInterval(interval); +}); +``` + +Pong messages are automatically sent in response to ping messages as required by +the spec. + +Just like the server example above, your clients might as well lose connection +without knowing it. You might want to add a ping listener on your clients to +prevent that. A simple implementation would be: + +```js +import WebSocket from 'ws'; + +function heartbeat() { + clearTimeout(this.pingTimeout); + + // Use `WebSocket#terminate()`, which immediately destroys the connection, + // instead of `WebSocket#close()`, which waits for the close timer. + // Delay should be equal to the interval at which your server + // sends out pings plus a conservative assumption of the latency. + this.pingTimeout = setTimeout(() => { + this.terminate(); + }, 30000 + 1000); +} + +const client = new WebSocket('wss://websocket-echo.com/'); + +client.on('error', console.error); +client.on('open', heartbeat); +client.on('ping', heartbeat); +client.on('close', function clear() { + clearTimeout(this.pingTimeout); +}); +``` + +### How to connect via a proxy? + +Use a custom `http.Agent` implementation like [https-proxy-agent][] or +[socks-proxy-agent][]. + +## Changelog + +We're using the GitHub [releases][changelog] for changelog entries. + +## License + +[MIT](LICENSE) + +[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input +[bufferutil]: https://github.com/websockets/bufferutil +[changelog]: https://github.com/websockets/ws/releases +[client-report]: http://websockets.github.io/ws/autobahn/clients/ +[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent +[node-zlib-bug]: https://github.com/nodejs/node/issues/8871 +[node-zlib-deflaterawdocs]: + https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options +[permessage-deflate]: https://tools.ietf.org/html/rfc7692 +[server-report]: http://websockets.github.io/ws/autobahn/servers/ +[session-parse-example]: ./examples/express-session-parse +[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent +[utf-8-validate]: https://github.com/websockets/utf-8-validate +[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback diff --git a/services/edge-broker/node_modules/ws/browser.js b/services/edge-broker/node_modules/ws/browser.js new file mode 100644 index 00000000..ca4f628a --- /dev/null +++ b/services/edge-broker/node_modules/ws/browser.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = function () { + throw new Error( + 'ws does not work in the browser. Browser clients must use the native ' + + 'WebSocket object' + ); +}; diff --git a/services/edge-broker/node_modules/ws/index.js b/services/edge-broker/node_modules/ws/index.js new file mode 100644 index 00000000..3fdb7b21 --- /dev/null +++ b/services/edge-broker/node_modules/ws/index.js @@ -0,0 +1,22 @@ +'use strict'; + +const createWebSocketStream = require('./lib/stream'); +const extension = require('./lib/extension'); +const PerMessageDeflate = require('./lib/permessage-deflate'); +const Receiver = require('./lib/receiver'); +const Sender = require('./lib/sender'); +const subprotocol = require('./lib/subprotocol'); +const WebSocket = require('./lib/websocket'); +const WebSocketServer = require('./lib/websocket-server'); + +WebSocket.createWebSocketStream = createWebSocketStream; +WebSocket.extension = extension; +WebSocket.PerMessageDeflate = PerMessageDeflate; +WebSocket.Receiver = Receiver; +WebSocket.Sender = Sender; +WebSocket.Server = WebSocketServer; +WebSocket.subprotocol = subprotocol; +WebSocket.WebSocket = WebSocket; +WebSocket.WebSocketServer = WebSocketServer; + +module.exports = WebSocket; diff --git a/services/edge-broker/node_modules/ws/lib/buffer-util.js b/services/edge-broker/node_modules/ws/lib/buffer-util.js new file mode 100644 index 00000000..f7536e28 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/buffer-util.js @@ -0,0 +1,131 @@ +'use strict'; + +const { EMPTY_BUFFER } = require('./constants'); + +const FastBuffer = Buffer[Symbol.species]; + +/** + * Merges an array of buffers into a new buffer. + * + * @param {Buffer[]} list The array of buffers to concat + * @param {Number} totalLength The total length of buffers in the list + * @return {Buffer} The resulting buffer + * @public + */ +function concat(list, totalLength) { + if (list.length === 0) return EMPTY_BUFFER; + if (list.length === 1) return list[0]; + + const target = Buffer.allocUnsafe(totalLength); + let offset = 0; + + for (let i = 0; i < list.length; i++) { + const buf = list[i]; + target.set(buf, offset); + offset += buf.length; + } + + if (offset < totalLength) { + return new FastBuffer(target.buffer, target.byteOffset, offset); + } + + return target; +} + +/** + * Masks a buffer using the given mask. + * + * @param {Buffer} source The buffer to mask + * @param {Buffer} mask The mask to use + * @param {Buffer} output The buffer where to store the result + * @param {Number} offset The offset at which to start writing + * @param {Number} length The number of bytes to mask. + * @public + */ +function _mask(source, mask, output, offset, length) { + for (let i = 0; i < length; i++) { + output[offset + i] = source[i] ^ mask[i & 3]; + } +} + +/** + * Unmasks a buffer using the given mask. + * + * @param {Buffer} buffer The buffer to unmask + * @param {Buffer} mask The mask to use + * @public + */ +function _unmask(buffer, mask) { + for (let i = 0; i < buffer.length; i++) { + buffer[i] ^= mask[i & 3]; + } +} + +/** + * Converts a buffer to an `ArrayBuffer`. + * + * @param {Buffer} buf The buffer to convert + * @return {ArrayBuffer} Converted buffer + * @public + */ +function toArrayBuffer(buf) { + if (buf.length === buf.buffer.byteLength) { + return buf.buffer; + } + + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length); +} + +/** + * Converts `data` to a `Buffer`. + * + * @param {*} data The data to convert + * @return {Buffer} The buffer + * @throws {TypeError} + * @public + */ +function toBuffer(data) { + toBuffer.readOnly = true; + + if (Buffer.isBuffer(data)) return data; + + let buf; + + if (data instanceof ArrayBuffer) { + buf = new FastBuffer(data); + } else if (ArrayBuffer.isView(data)) { + buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength); + } else { + buf = Buffer.from(data); + toBuffer.readOnly = false; + } + + return buf; +} + +module.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask +}; + +/* istanbul ignore else */ +if (!process.env.WS_NO_BUFFER_UTIL) { + try { + const bufferUtil = require('bufferutil'); + + module.exports.mask = function (source, mask, output, offset, length) { + if (length < 48) _mask(source, mask, output, offset, length); + else bufferUtil.mask(source, mask, output, offset, length); + }; + + module.exports.unmask = function (buffer, mask) { + if (buffer.length < 32) _unmask(buffer, mask); + else bufferUtil.unmask(buffer, mask); + }; + } catch (e) { + // Continue regardless of the error. + } +} diff --git a/services/edge-broker/node_modules/ws/lib/constants.js b/services/edge-broker/node_modules/ws/lib/constants.js new file mode 100644 index 00000000..69b2fe3c --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/constants.js @@ -0,0 +1,19 @@ +'use strict'; + +const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments']; +const hasBlob = typeof Blob !== 'undefined'; + +if (hasBlob) BINARY_TYPES.push('blob'); + +module.exports = { + BINARY_TYPES, + CLOSE_TIMEOUT: 30000, + EMPTY_BUFFER: Buffer.alloc(0), + GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + hasBlob, + kForOnEventAttribute: Symbol('kIsForOnEventAttribute'), + kListener: Symbol('kListener'), + kStatusCode: Symbol('status-code'), + kWebSocket: Symbol('websocket'), + NOOP: () => {} +}; diff --git a/services/edge-broker/node_modules/ws/lib/event-target.js b/services/edge-broker/node_modules/ws/lib/event-target.js new file mode 100644 index 00000000..fea4cbc5 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/event-target.js @@ -0,0 +1,292 @@ +'use strict'; + +const { kForOnEventAttribute, kListener } = require('./constants'); + +const kCode = Symbol('kCode'); +const kData = Symbol('kData'); +const kError = Symbol('kError'); +const kMessage = Symbol('kMessage'); +const kReason = Symbol('kReason'); +const kTarget = Symbol('kTarget'); +const kType = Symbol('kType'); +const kWasClean = Symbol('kWasClean'); + +/** + * Class representing an event. + */ +class Event { + /** + * Create a new `Event`. + * + * @param {String} type The name of the event + * @throws {TypeError} If the `type` argument is not specified + */ + constructor(type) { + this[kTarget] = null; + this[kType] = type; + } + + /** + * @type {*} + */ + get target() { + return this[kTarget]; + } + + /** + * @type {String} + */ + get type() { + return this[kType]; + } +} + +Object.defineProperty(Event.prototype, 'target', { enumerable: true }); +Object.defineProperty(Event.prototype, 'type', { enumerable: true }); + +/** + * Class representing a close event. + * + * @extends Event + */ +class CloseEvent extends Event { + /** + * Create a new `CloseEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {Number} [options.code=0] The status code explaining why the + * connection was closed + * @param {String} [options.reason=''] A human-readable string explaining why + * the connection was closed + * @param {Boolean} [options.wasClean=false] Indicates whether or not the + * connection was cleanly closed + */ + constructor(type, options = {}) { + super(type); + + this[kCode] = options.code === undefined ? 0 : options.code; + this[kReason] = options.reason === undefined ? '' : options.reason; + this[kWasClean] = options.wasClean === undefined ? false : options.wasClean; + } + + /** + * @type {Number} + */ + get code() { + return this[kCode]; + } + + /** + * @type {String} + */ + get reason() { + return this[kReason]; + } + + /** + * @type {Boolean} + */ + get wasClean() { + return this[kWasClean]; + } +} + +Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true }); +Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true }); +Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true }); + +/** + * Class representing an error event. + * + * @extends Event + */ +class ErrorEvent extends Event { + /** + * Create a new `ErrorEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.error=null] The error that generated this event + * @param {String} [options.message=''] The error message + */ + constructor(type, options = {}) { + super(type); + + this[kError] = options.error === undefined ? null : options.error; + this[kMessage] = options.message === undefined ? '' : options.message; + } + + /** + * @type {*} + */ + get error() { + return this[kError]; + } + + /** + * @type {String} + */ + get message() { + return this[kMessage]; + } +} + +Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true }); +Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true }); + +/** + * Class representing a message event. + * + * @extends Event + */ +class MessageEvent extends Event { + /** + * Create a new `MessageEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.data=null] The message content + */ + constructor(type, options = {}) { + super(type); + + this[kData] = options.data === undefined ? null : options.data; + } + + /** + * @type {*} + */ + get data() { + return this[kData]; + } +} + +Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true }); + +/** + * This provides methods for emulating the `EventTarget` interface. It's not + * meant to be used directly. + * + * @mixin + */ +const EventTarget = { + /** + * Register an event listener. + * + * @param {String} type A string representing the event type to listen for + * @param {(Function|Object)} handler The listener to add + * @param {Object} [options] An options object specifies characteristics about + * the event listener + * @param {Boolean} [options.once=false] A `Boolean` indicating that the + * listener should be invoked at most once after being added. If `true`, + * the listener would be automatically removed when invoked. + * @public + */ + addEventListener(type, handler, options = {}) { + for (const listener of this.listeners(type)) { + if ( + !options[kForOnEventAttribute] && + listener[kListener] === handler && + !listener[kForOnEventAttribute] + ) { + return; + } + } + + let wrapper; + + if (type === 'message') { + wrapper = function onMessage(data, isBinary) { + const event = new MessageEvent('message', { + data: isBinary ? data : data.toString() + }); + + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === 'close') { + wrapper = function onClose(code, message) { + const event = new CloseEvent('close', { + code, + reason: message.toString(), + wasClean: this._closeFrameReceived && this._closeFrameSent + }); + + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === 'error') { + wrapper = function onError(error) { + const event = new ErrorEvent('error', { + error, + message: error.message + }); + + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === 'open') { + wrapper = function onOpen() { + const event = new Event('open'); + + event[kTarget] = this; + callListener(handler, this, event); + }; + } else { + return; + } + + wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; + wrapper[kListener] = handler; + + if (options.once) { + this.once(type, wrapper); + } else { + this.on(type, wrapper); + } + }, + + /** + * Remove an event listener. + * + * @param {String} type A string representing the event type to remove + * @param {(Function|Object)} handler The listener to remove + * @public + */ + removeEventListener(type, handler) { + for (const listener of this.listeners(type)) { + if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { + this.removeListener(type, listener); + break; + } + } + } +}; + +module.exports = { + CloseEvent, + ErrorEvent, + Event, + EventTarget, + MessageEvent +}; + +/** + * Call an event listener + * + * @param {(Function|Object)} listener The listener to call + * @param {*} thisArg The value to use as `this`` when calling the listener + * @param {Event} event The event to pass to the listener + * @private + */ +function callListener(listener, thisArg, event) { + if (typeof listener === 'object' && listener.handleEvent) { + listener.handleEvent.call(listener, event); + } else { + listener.call(thisArg, event); + } +} diff --git a/services/edge-broker/node_modules/ws/lib/extension.js b/services/edge-broker/node_modules/ws/lib/extension.js new file mode 100644 index 00000000..3d7895c1 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/extension.js @@ -0,0 +1,203 @@ +'use strict'; + +const { tokenChars } = require('./validation'); + +/** + * Adds an offer to the map of extension offers or a parameter to the map of + * parameters. + * + * @param {Object} dest The map of extension offers or parameters + * @param {String} name The extension or parameter name + * @param {(Object|Boolean|String)} elem The extension parameters or the + * parameter value + * @private + */ +function push(dest, name, elem) { + if (dest[name] === undefined) dest[name] = [elem]; + else dest[name].push(elem); +} + +/** + * Parses the `Sec-WebSocket-Extensions` header into an object. + * + * @param {String} header The field value of the header + * @return {Object} The parsed object + * @public + */ +function parse(header) { + const offers = Object.create(null); + let params = Object.create(null); + let mustUnescape = false; + let isEscaping = false; + let inQuotes = false; + let extensionName; + let paramName; + let start = -1; + let code = -1; + let end = -1; + let i = 0; + + for (; i < header.length; i++) { + code = header.charCodeAt(i); + + if (extensionName === undefined) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if ( + i !== 0 && + (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ + ) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + const name = header.slice(start, end); + if (code === 0x2c) { + push(offers, name, params); + params = Object.create(null); + } else { + extensionName = name; + } + + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (paramName === undefined) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 0x20 || code === 0x09) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x3b || code === 0x2c) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + push(params, header.slice(start, end), true); + if (code === 0x2c) { + push(offers, extensionName, params); + params = Object.create(null); + extensionName = undefined; + } + + start = end = -1; + } else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) { + paramName = header.slice(start, i); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else { + // + // The value of a quoted-string after unescaping must conform to the + // token ABNF, so only token characters are valid. + // Ref: https://tools.ietf.org/html/rfc6455#section-9.1 + // + if (isEscaping) { + if (tokenChars[code] !== 1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (start === -1) start = i; + else if (!mustUnescape) mustUnescape = true; + isEscaping = false; + } else if (inQuotes) { + if (tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 0x22 /* '"' */ && start !== -1) { + inQuotes = false; + end = i; + } else if (code === 0x5c /* '\' */) { + isEscaping = true; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) { + inQuotes = true; + } else if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (start !== -1 && (code === 0x20 || code === 0x09)) { + if (end === -1) end = i; + } else if (code === 0x3b || code === 0x2c) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + let value = header.slice(start, end); + if (mustUnescape) { + value = value.replace(/\\/g, ''); + mustUnescape = false; + } + push(params, paramName, value); + if (code === 0x2c) { + push(offers, extensionName, params); + params = Object.create(null); + extensionName = undefined; + } + + paramName = undefined; + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + } + + if (start === -1 || inQuotes || code === 0x20 || code === 0x09) { + throw new SyntaxError('Unexpected end of input'); + } + + if (end === -1) end = i; + const token = header.slice(start, end); + if (extensionName === undefined) { + push(offers, token, params); + } else { + if (paramName === undefined) { + push(params, token, true); + } else if (mustUnescape) { + push(params, paramName, token.replace(/\\/g, '')); + } else { + push(params, paramName, token); + } + push(offers, extensionName, params); + } + + return offers; +} + +/** + * Builds the `Sec-WebSocket-Extensions` header field value. + * + * @param {Object} extensions The map of extensions and parameters to format + * @return {String} A string representing the given object + * @public + */ +function format(extensions) { + return Object.keys(extensions) + .map((extension) => { + let configurations = extensions[extension]; + if (!Array.isArray(configurations)) configurations = [configurations]; + return configurations + .map((params) => { + return [extension] + .concat( + Object.keys(params).map((k) => { + let values = params[k]; + if (!Array.isArray(values)) values = [values]; + return values + .map((v) => (v === true ? k : `${k}=${v}`)) + .join('; '); + }) + ) + .join('; '); + }) + .join(', '); + }) + .join(', '); +} + +module.exports = { format, parse }; diff --git a/services/edge-broker/node_modules/ws/lib/limiter.js b/services/edge-broker/node_modules/ws/lib/limiter.js new file mode 100644 index 00000000..3fd35784 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/limiter.js @@ -0,0 +1,55 @@ +'use strict'; + +const kDone = Symbol('kDone'); +const kRun = Symbol('kRun'); + +/** + * A very simple job queue with adjustable concurrency. Adapted from + * https://github.com/STRML/async-limiter + */ +class Limiter { + /** + * Creates a new `Limiter`. + * + * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed + * to run concurrently + */ + constructor(concurrency) { + this[kDone] = () => { + this.pending--; + this[kRun](); + }; + this.concurrency = concurrency || Infinity; + this.jobs = []; + this.pending = 0; + } + + /** + * Adds a job to the queue. + * + * @param {Function} job The job to run + * @public + */ + add(job) { + this.jobs.push(job); + this[kRun](); + } + + /** + * Removes a job from the queue and runs it if possible. + * + * @private + */ + [kRun]() { + if (this.pending === this.concurrency) return; + + if (this.jobs.length) { + const job = this.jobs.shift(); + + this.pending++; + job(this[kDone]); + } + } +} + +module.exports = Limiter; diff --git a/services/edge-broker/node_modules/ws/lib/permessage-deflate.js b/services/edge-broker/node_modules/ws/lib/permessage-deflate.js new file mode 100644 index 00000000..aa5db761 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/permessage-deflate.js @@ -0,0 +1,528 @@ +'use strict'; + +const zlib = require('zlib'); + +const bufferUtil = require('./buffer-util'); +const Limiter = require('./limiter'); +const { kStatusCode } = require('./constants'); + +const FastBuffer = Buffer[Symbol.species]; +const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); +const kPerMessageDeflate = Symbol('permessage-deflate'); +const kTotalLength = Symbol('total-length'); +const kCallback = Symbol('callback'); +const kBuffers = Symbol('buffers'); +const kError = Symbol('error'); + +// +// We limit zlib concurrency, which prevents severe memory fragmentation +// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913 +// and https://github.com/websockets/ws/issues/1202 +// +// Intentionally global; it's the global thread pool that's an issue. +// +let zlibLimiter; + +/** + * permessage-deflate implementation. + */ +class PerMessageDeflate { + /** + * Creates a PerMessageDeflate instance. + * + * @param {Object} [options] Configuration options + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support + * for, or request, a custom client window size + * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ + * acknowledge disabling of client context takeover + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib + * @param {Boolean} [options.isServer=false] Create the instance in either + * server or client mode + * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the + * use of a custom server window size + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed if context takeover is disabled + * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on + * deflate + * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on + * inflate + */ + constructor(options) { + this._options = options || {}; + this._threshold = + this._options.threshold !== undefined ? this._options.threshold : 1024; + this._maxPayload = this._options.maxPayload | 0; + this._isServer = !!this._options.isServer; + this._deflate = null; + this._inflate = null; + + this.params = null; + + if (!zlibLimiter) { + const concurrency = + this._options.concurrencyLimit !== undefined + ? this._options.concurrencyLimit + : 10; + zlibLimiter = new Limiter(concurrency); + } + } + + /** + * @type {String} + */ + static get extensionName() { + return 'permessage-deflate'; + } + + /** + * Create an extension negotiation offer. + * + * @return {Object} Extension parameters + * @public + */ + offer() { + const params = {}; + + if (this._options.serverNoContextTakeover) { + params.server_no_context_takeover = true; + } + if (this._options.clientNoContextTakeover) { + params.client_no_context_takeover = true; + } + if (this._options.serverMaxWindowBits) { + params.server_max_window_bits = this._options.serverMaxWindowBits; + } + if (this._options.clientMaxWindowBits) { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } else if (this._options.clientMaxWindowBits == null) { + params.client_max_window_bits = true; + } + + return params; + } + + /** + * Accept an extension negotiation offer/response. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Object} Accepted configuration + * @public + */ + accept(configurations) { + configurations = this.normalizeParams(configurations); + + this.params = this._isServer + ? this.acceptAsServer(configurations) + : this.acceptAsClient(configurations); + + return this.params; + } + + /** + * Releases all resources used by the extension. + * + * @public + */ + cleanup() { + if (this._inflate) { + this._inflate.close(); + this._inflate = null; + } + + if (this._deflate) { + const callback = this._deflate[kCallback]; + + this._deflate.close(); + this._deflate = null; + + if (callback) { + callback( + new Error( + 'The deflate stream was closed while data was being processed' + ) + ); + } + } + } + + /** + * Accept an extension negotiation offer. + * + * @param {Array} offers The extension negotiation offers + * @return {Object} Accepted configuration + * @private + */ + acceptAsServer(offers) { + const opts = this._options; + const accepted = offers.find((params) => { + if ( + (opts.serverNoContextTakeover === false && + params.server_no_context_takeover) || + (params.server_max_window_bits && + (opts.serverMaxWindowBits === false || + (typeof opts.serverMaxWindowBits === 'number' && + opts.serverMaxWindowBits > params.server_max_window_bits))) || + (typeof opts.clientMaxWindowBits === 'number' && + !params.client_max_window_bits) + ) { + return false; + } + + return true; + }); + + if (!accepted) { + throw new Error('None of the extension offers can be accepted'); + } + + if (opts.serverNoContextTakeover) { + accepted.server_no_context_takeover = true; + } + if (opts.clientNoContextTakeover) { + accepted.client_no_context_takeover = true; + } + if (typeof opts.serverMaxWindowBits === 'number') { + accepted.server_max_window_bits = opts.serverMaxWindowBits; + } + if (typeof opts.clientMaxWindowBits === 'number') { + accepted.client_max_window_bits = opts.clientMaxWindowBits; + } else if ( + accepted.client_max_window_bits === true || + opts.clientMaxWindowBits === false + ) { + delete accepted.client_max_window_bits; + } + + return accepted; + } + + /** + * Accept the extension negotiation response. + * + * @param {Array} response The extension negotiation response + * @return {Object} Accepted configuration + * @private + */ + acceptAsClient(response) { + const params = response[0]; + + if ( + this._options.clientNoContextTakeover === false && + params.client_no_context_takeover + ) { + throw new Error('Unexpected parameter "client_no_context_takeover"'); + } + + if (!params.client_max_window_bits) { + if (typeof this._options.clientMaxWindowBits === 'number') { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } + } else if ( + this._options.clientMaxWindowBits === false || + (typeof this._options.clientMaxWindowBits === 'number' && + params.client_max_window_bits > this._options.clientMaxWindowBits) + ) { + throw new Error( + 'Unexpected or invalid parameter "client_max_window_bits"' + ); + } + + return params; + } + + /** + * Normalize parameters. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Array} The offers/response with normalized parameters + * @private + */ + normalizeParams(configurations) { + configurations.forEach((params) => { + Object.keys(params).forEach((key) => { + let value = params[key]; + + if (value.length > 1) { + throw new Error(`Parameter "${key}" must have only a single value`); + } + + value = value[0]; + + if (key === 'client_max_window_bits') { + if (value !== true) { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (!this._isServer) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else if (key === 'server_max_window_bits') { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if ( + key === 'client_no_context_takeover' || + key === 'server_no_context_takeover' + ) { + if (value !== true) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else { + throw new Error(`Unknown parameter "${key}"`); + } + + params[key] = value; + }); + }); + + return configurations; + } + + /** + * Decompress data. Concurrency limited. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + decompress(data, fin, callback) { + zlibLimiter.add((done) => { + this._decompress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + + /** + * Compress data. Concurrency limited. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + compress(data, fin, callback) { + zlibLimiter.add((done) => { + this._compress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + + /** + * Decompress data. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _decompress(data, fin, callback) { + const endpoint = this._isServer ? 'client' : 'server'; + + if (!this._inflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = + typeof this.params[key] !== 'number' + ? zlib.Z_DEFAULT_WINDOWBITS + : this.params[key]; + + this._inflate = zlib.createInflateRaw({ + ...this._options.zlibInflateOptions, + windowBits + }); + this._inflate[kPerMessageDeflate] = this; + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + this._inflate.on('error', inflateOnError); + this._inflate.on('data', inflateOnData); + } + + this._inflate[kCallback] = callback; + + this._inflate.write(data); + if (fin) this._inflate.write(TRAILER); + + this._inflate.flush(() => { + const err = this._inflate[kError]; + + if (err) { + this._inflate.close(); + this._inflate = null; + callback(err); + return; + } + + const data = bufferUtil.concat( + this._inflate[kBuffers], + this._inflate[kTotalLength] + ); + + if (this._inflate._readableState.endEmitted) { + this._inflate.close(); + this._inflate = null; + } else { + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._inflate.reset(); + } + } + + callback(null, data); + }); + } + + /** + * Compress data. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _compress(data, fin, callback) { + const endpoint = this._isServer ? 'server' : 'client'; + + if (!this._deflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = + typeof this.params[key] !== 'number' + ? zlib.Z_DEFAULT_WINDOWBITS + : this.params[key]; + + this._deflate = zlib.createDeflateRaw({ + ...this._options.zlibDeflateOptions, + windowBits + }); + + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + + this._deflate.on('data', deflateOnData); + } + + this._deflate[kCallback] = callback; + + this._deflate.write(data); + this._deflate.flush(zlib.Z_SYNC_FLUSH, () => { + if (!this._deflate) { + // + // The deflate stream was closed while data was being processed. + // + return; + } + + let data = bufferUtil.concat( + this._deflate[kBuffers], + this._deflate[kTotalLength] + ); + + if (fin) { + data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4); + } + + // + // Ensure that the callback will not be called again in + // `PerMessageDeflate#cleanup()`. + // + this._deflate[kCallback] = null; + + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._deflate.reset(); + } + + callback(null, data); + }); + } +} + +module.exports = PerMessageDeflate; + +/** + * The listener of the `zlib.DeflateRaw` stream `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function deflateOnData(chunk) { + this[kBuffers].push(chunk); + this[kTotalLength] += chunk.length; +} + +/** + * The listener of the `zlib.InflateRaw` stream `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function inflateOnData(chunk) { + this[kTotalLength] += chunk.length; + + if ( + this[kPerMessageDeflate]._maxPayload < 1 || + this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload + ) { + this[kBuffers].push(chunk); + return; + } + + this[kError] = new RangeError('Max payload size exceeded'); + this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'; + this[kError][kStatusCode] = 1009; + this.removeListener('data', inflateOnData); + + // + // The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the + // fact that in Node.js versions prior to 13.10.0, the callback for + // `zlib.flush()` is not called if `zlib.close()` is used. Utilizing + // `zlib.reset()` ensures that either the callback is invoked or an error is + // emitted. + // + this.reset(); +} + +/** + * The listener of the `zlib.InflateRaw` stream `'error'` event. + * + * @param {Error} err The emitted error + * @private + */ +function inflateOnError(err) { + // + // There is no need to call `Zlib#close()` as the handle is automatically + // closed when an error is emitted. + // + this[kPerMessageDeflate]._inflate = null; + + if (this[kError]) { + this[kCallback](this[kError]); + return; + } + + err[kStatusCode] = 1007; + this[kCallback](err); +} diff --git a/services/edge-broker/node_modules/ws/lib/receiver.js b/services/edge-broker/node_modules/ws/lib/receiver.js new file mode 100644 index 00000000..54d9b4fa --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/receiver.js @@ -0,0 +1,706 @@ +'use strict'; + +const { Writable } = require('stream'); + +const PerMessageDeflate = require('./permessage-deflate'); +const { + BINARY_TYPES, + EMPTY_BUFFER, + kStatusCode, + kWebSocket +} = require('./constants'); +const { concat, toArrayBuffer, unmask } = require('./buffer-util'); +const { isValidStatusCode, isValidUTF8 } = require('./validation'); + +const FastBuffer = Buffer[Symbol.species]; + +const GET_INFO = 0; +const GET_PAYLOAD_LENGTH_16 = 1; +const GET_PAYLOAD_LENGTH_64 = 2; +const GET_MASK = 3; +const GET_DATA = 4; +const INFLATING = 5; +const DEFER_EVENT = 6; + +/** + * HyBi Receiver implementation. + * + * @extends Writable + */ +class Receiver extends Writable { + /** + * Creates a Receiver instance. + * + * @param {Object} [options] Options object + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {String} [options.binaryType=nodebuffer] The type for binary data + * @param {Object} [options.extensions] An object containing the negotiated + * extensions + * @param {Boolean} [options.isServer=false] Specifies whether to operate in + * client or server mode + * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + */ + constructor(options = {}) { + super(); + + this._allowSynchronousEvents = + options.allowSynchronousEvents !== undefined + ? options.allowSynchronousEvents + : true; + this._binaryType = options.binaryType || BINARY_TYPES[0]; + this._extensions = options.extensions || {}; + this._isServer = !!options.isServer; + this._maxPayload = options.maxPayload | 0; + this._skipUTF8Validation = !!options.skipUTF8Validation; + this[kWebSocket] = undefined; + + this._bufferedBytes = 0; + this._buffers = []; + + this._compressed = false; + this._payloadLength = 0; + this._mask = undefined; + this._fragmented = 0; + this._masked = false; + this._fin = false; + this._opcode = 0; + + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragments = []; + + this._errored = false; + this._loop = false; + this._state = GET_INFO; + } + + /** + * Implements `Writable.prototype._write()`. + * + * @param {Buffer} chunk The chunk of data to write + * @param {String} encoding The character encoding of `chunk` + * @param {Function} cb Callback + * @private + */ + _write(chunk, encoding, cb) { + if (this._opcode === 0x08 && this._state == GET_INFO) return cb(); + + this._bufferedBytes += chunk.length; + this._buffers.push(chunk); + this.startLoop(cb); + } + + /** + * Consumes `n` bytes from the buffered data. + * + * @param {Number} n The number of bytes to consume + * @return {Buffer} The consumed bytes + * @private + */ + consume(n) { + this._bufferedBytes -= n; + + if (n === this._buffers[0].length) return this._buffers.shift(); + + if (n < this._buffers[0].length) { + const buf = this._buffers[0]; + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + + return new FastBuffer(buf.buffer, buf.byteOffset, n); + } + + const dst = Buffer.allocUnsafe(n); + + do { + const buf = this._buffers[0]; + const offset = dst.length - n; + + if (n >= buf.length) { + dst.set(this._buffers.shift(), offset); + } else { + dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + } + + n -= buf.length; + } while (n > 0); + + return dst; + } + + /** + * Starts the parsing loop. + * + * @param {Function} cb Callback + * @private + */ + startLoop(cb) { + this._loop = true; + + do { + switch (this._state) { + case GET_INFO: + this.getInfo(cb); + break; + case GET_PAYLOAD_LENGTH_16: + this.getPayloadLength16(cb); + break; + case GET_PAYLOAD_LENGTH_64: + this.getPayloadLength64(cb); + break; + case GET_MASK: + this.getMask(); + break; + case GET_DATA: + this.getData(cb); + break; + case INFLATING: + case DEFER_EVENT: + this._loop = false; + return; + } + } while (this._loop); + + if (!this._errored) cb(); + } + + /** + * Reads the first two bytes of a frame. + * + * @param {Function} cb Callback + * @private + */ + getInfo(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + + const buf = this.consume(2); + + if ((buf[0] & 0x30) !== 0x00) { + const error = this.createError( + RangeError, + 'RSV2 and RSV3 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_2_3' + ); + + cb(error); + return; + } + + const compressed = (buf[0] & 0x40) === 0x40; + + if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { + const error = this.createError( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + + cb(error); + return; + } + + this._fin = (buf[0] & 0x80) === 0x80; + this._opcode = buf[0] & 0x0f; + this._payloadLength = buf[1] & 0x7f; + + if (this._opcode === 0x00) { + if (compressed) { + const error = this.createError( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + + cb(error); + return; + } + + if (!this._fragmented) { + const error = this.createError( + RangeError, + 'invalid opcode 0', + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + + cb(error); + return; + } + + this._opcode = this._fragmented; + } else if (this._opcode === 0x01 || this._opcode === 0x02) { + if (this._fragmented) { + const error = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + + cb(error); + return; + } + + this._compressed = compressed; + } else if (this._opcode > 0x07 && this._opcode < 0x0b) { + if (!this._fin) { + const error = this.createError( + RangeError, + 'FIN must be set', + true, + 1002, + 'WS_ERR_EXPECTED_FIN' + ); + + cb(error); + return; + } + + if (compressed) { + const error = this.createError( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + + cb(error); + return; + } + + if ( + this._payloadLength > 0x7d || + (this._opcode === 0x08 && this._payloadLength === 1) + ) { + const error = this.createError( + RangeError, + `invalid payload length ${this._payloadLength}`, + true, + 1002, + 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' + ); + + cb(error); + return; + } + } else { + const error = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + + cb(error); + return; + } + + if (!this._fin && !this._fragmented) this._fragmented = this._opcode; + this._masked = (buf[1] & 0x80) === 0x80; + + if (this._isServer) { + if (!this._masked) { + const error = this.createError( + RangeError, + 'MASK must be set', + true, + 1002, + 'WS_ERR_EXPECTED_MASK' + ); + + cb(error); + return; + } + } else if (this._masked) { + const error = this.createError( + RangeError, + 'MASK must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_MASK' + ); + + cb(error); + return; + } + + if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; + else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; + else this.haveLength(cb); + } + + /** + * Gets extended payload length (7+16). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength16(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + + this._payloadLength = this.consume(2).readUInt16BE(0); + this.haveLength(cb); + } + + /** + * Gets extended payload length (7+64). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength64(cb) { + if (this._bufferedBytes < 8) { + this._loop = false; + return; + } + + const buf = this.consume(8); + const num = buf.readUInt32BE(0); + + // + // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned + // if payload length is greater than this number. + // + if (num > Math.pow(2, 53 - 32) - 1) { + const error = this.createError( + RangeError, + 'Unsupported WebSocket frame: payload length > 2^53 - 1', + false, + 1009, + 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH' + ); + + cb(error); + return; + } + + this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); + this.haveLength(cb); + } + + /** + * Payload length has been read. + * + * @param {Function} cb Callback + * @private + */ + haveLength(cb) { + if (this._payloadLength && this._opcode < 0x08) { + this._totalPayloadLength += this._payloadLength; + if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { + const error = this.createError( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' + ); + + cb(error); + return; + } + } + + if (this._masked) this._state = GET_MASK; + else this._state = GET_DATA; + } + + /** + * Reads mask bytes. + * + * @private + */ + getMask() { + if (this._bufferedBytes < 4) { + this._loop = false; + return; + } + + this._mask = this.consume(4); + this._state = GET_DATA; + } + + /** + * Reads data bytes. + * + * @param {Function} cb Callback + * @private + */ + getData(cb) { + let data = EMPTY_BUFFER; + + if (this._payloadLength) { + if (this._bufferedBytes < this._payloadLength) { + this._loop = false; + return; + } + + data = this.consume(this._payloadLength); + + if ( + this._masked && + (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0 + ) { + unmask(data, this._mask); + } + } + + if (this._opcode > 0x07) { + this.controlMessage(data, cb); + return; + } + + if (this._compressed) { + this._state = INFLATING; + this.decompress(data, cb); + return; + } + + if (data.length) { + // + // This message is not compressed so its length is the sum of the payload + // length of all fragments. + // + this._messageLength = this._totalPayloadLength; + this._fragments.push(data); + } + + this.dataMessage(cb); + } + + /** + * Decompresses data. + * + * @param {Buffer} data Compressed data + * @param {Function} cb Callback + * @private + */ + decompress(data, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + + perMessageDeflate.decompress(data, this._fin, (err, buf) => { + if (err) return cb(err); + + if (buf.length) { + this._messageLength += buf.length; + if (this._messageLength > this._maxPayload && this._maxPayload > 0) { + const error = this.createError( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' + ); + + cb(error); + return; + } + + this._fragments.push(buf); + } + + this.dataMessage(cb); + if (this._state === GET_INFO) this.startLoop(cb); + }); + } + + /** + * Handles a data message. + * + * @param {Function} cb Callback + * @private + */ + dataMessage(cb) { + if (!this._fin) { + this._state = GET_INFO; + return; + } + + const messageLength = this._messageLength; + const fragments = this._fragments; + + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragmented = 0; + this._fragments = []; + + if (this._opcode === 2) { + let data; + + if (this._binaryType === 'nodebuffer') { + data = concat(fragments, messageLength); + } else if (this._binaryType === 'arraybuffer') { + data = toArrayBuffer(concat(fragments, messageLength)); + } else if (this._binaryType === 'blob') { + data = new Blob(fragments); + } else { + data = fragments; + } + + if (this._allowSynchronousEvents) { + this.emit('message', data, true); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit('message', data, true); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } else { + const buf = concat(fragments, messageLength); + + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + + cb(error); + return; + } + + if (this._state === INFLATING || this._allowSynchronousEvents) { + this.emit('message', buf, false); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit('message', buf, false); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + } + + /** + * Handles a control message. + * + * @param {Buffer} data Data to handle + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + controlMessage(data, cb) { + if (this._opcode === 0x08) { + if (data.length === 0) { + this._loop = false; + this.emit('conclude', 1005, EMPTY_BUFFER); + this.end(); + } else { + const code = data.readUInt16BE(0); + + if (!isValidStatusCode(code)) { + const error = this.createError( + RangeError, + `invalid status code ${code}`, + true, + 1002, + 'WS_ERR_INVALID_CLOSE_CODE' + ); + + cb(error); + return; + } + + const buf = new FastBuffer( + data.buffer, + data.byteOffset + 2, + data.length - 2 + ); + + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + + cb(error); + return; + } + + this._loop = false; + this.emit('conclude', code, buf); + this.end(); + } + + this._state = GET_INFO; + return; + } + + if (this._allowSynchronousEvents) { + this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + + /** + * Builds an error object. + * + * @param {function(new:Error|RangeError)} ErrorCtor The error constructor + * @param {String} message The error message + * @param {Boolean} prefix Specifies whether or not to add a default prefix to + * `message` + * @param {Number} statusCode The status code + * @param {String} errorCode The exposed error code + * @return {(Error|RangeError)} The error + * @private + */ + createError(ErrorCtor, message, prefix, statusCode, errorCode) { + this._loop = false; + this._errored = true; + + const err = new ErrorCtor( + prefix ? `Invalid WebSocket frame: ${message}` : message + ); + + Error.captureStackTrace(err, this.createError); + err.code = errorCode; + err[kStatusCode] = statusCode; + return err; + } +} + +module.exports = Receiver; diff --git a/services/edge-broker/node_modules/ws/lib/sender.js b/services/edge-broker/node_modules/ws/lib/sender.js new file mode 100644 index 00000000..a8b1da3a --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/sender.js @@ -0,0 +1,602 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */ + +'use strict'; + +const { Duplex } = require('stream'); +const { randomFillSync } = require('crypto'); + +const PerMessageDeflate = require('./permessage-deflate'); +const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants'); +const { isBlob, isValidStatusCode } = require('./validation'); +const { mask: applyMask, toBuffer } = require('./buffer-util'); + +const kByteLength = Symbol('kByteLength'); +const maskBuffer = Buffer.alloc(4); +const RANDOM_POOL_SIZE = 8 * 1024; +let randomPool; +let randomPoolPointer = RANDOM_POOL_SIZE; + +const DEFAULT = 0; +const DEFLATING = 1; +const GET_BLOB_DATA = 2; + +/** + * HyBi Sender implementation. + */ +class Sender { + /** + * Creates a Sender instance. + * + * @param {Duplex} socket The connection socket + * @param {Object} [extensions] An object containing the negotiated extensions + * @param {Function} [generateMask] The function used to generate the masking + * key + */ + constructor(socket, extensions, generateMask) { + this._extensions = extensions || {}; + + if (generateMask) { + this._generateMask = generateMask; + this._maskBuffer = Buffer.alloc(4); + } + + this._socket = socket; + + this._firstFragment = true; + this._compress = false; + + this._bufferedBytes = 0; + this._queue = []; + this._state = DEFAULT; + this.onerror = NOOP; + this[kWebSocket] = undefined; + } + + /** + * Frames a piece of data according to the HyBi WebSocket protocol. + * + * @param {(Buffer|String)} data The data to frame + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @return {(Buffer|String)[]} The framed data + * @public + */ + static frame(data, options) { + let mask; + let merge = false; + let offset = 2; + let skipMasking = false; + + if (options.mask) { + mask = options.maskBuffer || maskBuffer; + + if (options.generateMask) { + options.generateMask(mask); + } else { + if (randomPoolPointer === RANDOM_POOL_SIZE) { + /* istanbul ignore else */ + if (randomPool === undefined) { + // + // This is lazily initialized because server-sent frames must not + // be masked so it may never be used. + // + randomPool = Buffer.alloc(RANDOM_POOL_SIZE); + } + + randomFillSync(randomPool, 0, RANDOM_POOL_SIZE); + randomPoolPointer = 0; + } + + mask[0] = randomPool[randomPoolPointer++]; + mask[1] = randomPool[randomPoolPointer++]; + mask[2] = randomPool[randomPoolPointer++]; + mask[3] = randomPool[randomPoolPointer++]; + } + + skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; + offset = 6; + } + + let dataLength; + + if (typeof data === 'string') { + if ( + (!options.mask || skipMasking) && + options[kByteLength] !== undefined + ) { + dataLength = options[kByteLength]; + } else { + data = Buffer.from(data); + dataLength = data.length; + } + } else { + dataLength = data.length; + merge = options.mask && options.readOnly && !skipMasking; + } + + let payloadLength = dataLength; + + if (dataLength >= 65536) { + offset += 8; + payloadLength = 127; + } else if (dataLength > 125) { + offset += 2; + payloadLength = 126; + } + + const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset); + + target[0] = options.fin ? options.opcode | 0x80 : options.opcode; + if (options.rsv1) target[0] |= 0x40; + + target[1] = payloadLength; + + if (payloadLength === 126) { + target.writeUInt16BE(dataLength, 2); + } else if (payloadLength === 127) { + target[2] = target[3] = 0; + target.writeUIntBE(dataLength, 4, 6); + } + + if (!options.mask) return [target, data]; + + target[1] |= 0x80; + target[offset - 4] = mask[0]; + target[offset - 3] = mask[1]; + target[offset - 2] = mask[2]; + target[offset - 1] = mask[3]; + + if (skipMasking) return [target, data]; + + if (merge) { + applyMask(data, mask, target, offset, dataLength); + return [target]; + } + + applyMask(data, mask, data, 0, dataLength); + return [target, data]; + } + + /** + * Sends a close message to the other peer. + * + * @param {Number} [code] The status code component of the body + * @param {(String|Buffer)} [data] The message component of the body + * @param {Boolean} [mask=false] Specifies whether or not to mask the message + * @param {Function} [cb] Callback + * @public + */ + close(code, data, mask, cb) { + let buf; + + if (code === undefined) { + buf = EMPTY_BUFFER; + } else if (typeof code !== 'number' || !isValidStatusCode(code)) { + throw new TypeError('First argument must be a valid error code number'); + } else if (data === undefined || !data.length) { + buf = Buffer.allocUnsafe(2); + buf.writeUInt16BE(code, 0); + } else { + const length = Buffer.byteLength(data); + + if (length > 123) { + throw new RangeError('The message must not be greater than 123 bytes'); + } + + buf = Buffer.allocUnsafe(2 + length); + buf.writeUInt16BE(code, 0); + + if (typeof data === 'string') { + buf.write(data, 2); + } else { + buf.set(data, 2); + } + } + + const options = { + [kByteLength]: buf.length, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x08, + readOnly: false, + rsv1: false + }; + + if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, buf, false, options, cb]); + } else { + this.sendFrame(Sender.frame(buf, options), cb); + } + } + + /** + * Sends a ping message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + ping(data, mask, cb) { + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (byteLength > 125) { + throw new RangeError('The data size must not be greater than 125 bytes'); + } + + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x09, + readOnly, + rsv1: false + }; + + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options, cb]); + } else { + this.getBlobData(data, false, options, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(Sender.frame(data, options), cb); + } + } + + /** + * Sends a pong message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + pong(data, mask, cb) { + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (byteLength > 125) { + throw new RangeError('The data size must not be greater than 125 bytes'); + } + + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x0a, + readOnly, + rsv1: false + }; + + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options, cb]); + } else { + this.getBlobData(data, false, options, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(Sender.frame(data, options), cb); + } + } + + /** + * Sends a data message to the other peer. + * + * @param {*} data The message to send + * @param {Object} options Options object + * @param {Boolean} [options.binary=false] Specifies whether `data` is binary + * or text + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` + * @param {Boolean} [options.fin=false] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Function} [cb] Callback + * @public + */ + send(data, options, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + let opcode = options.binary ? 2 : 1; + let rsv1 = options.compress; + + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (this._firstFragment) { + this._firstFragment = false; + if ( + rsv1 && + perMessageDeflate && + perMessageDeflate.params[ + perMessageDeflate._isServer + ? 'server_no_context_takeover' + : 'client_no_context_takeover' + ] + ) { + rsv1 = byteLength >= perMessageDeflate._threshold; + } + this._compress = rsv1; + } else { + rsv1 = false; + opcode = 0; + } + + if (options.fin) this._firstFragment = true; + + const opts = { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1 + }; + + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, this._compress, opts, cb]); + } else { + this.getBlobData(data, this._compress, opts, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, this._compress, opts, cb]); + } else { + this.dispatch(data, this._compress, opts, cb); + } + } + + /** + * Gets the contents of a blob as binary data. + * + * @param {Blob} blob The blob + * @param {Boolean} [compress=false] Specifies whether or not to compress + * the data + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + getBlobData(blob, compress, options, cb) { + this._bufferedBytes += options[kByteLength]; + this._state = GET_BLOB_DATA; + + blob + .arrayBuffer() + .then((arrayBuffer) => { + if (this._socket.destroyed) { + const err = new Error( + 'The socket was closed while the blob was being read' + ); + + // + // `callCallbacks` is called in the next tick to ensure that errors + // that might be thrown in the callbacks behave like errors thrown + // outside the promise chain. + // + process.nextTick(callCallbacks, this, err, cb); + return; + } + + this._bufferedBytes -= options[kByteLength]; + const data = toBuffer(arrayBuffer); + + if (!compress) { + this._state = DEFAULT; + this.sendFrame(Sender.frame(data, options), cb); + this.dequeue(); + } else { + this.dispatch(data, compress, options, cb); + } + }) + .catch((err) => { + // + // `onError` is called in the next tick for the same reason that + // `callCallbacks` above is. + // + process.nextTick(onError, this, err, cb); + }); + } + + /** + * Dispatches a message. + * + * @param {(Buffer|String)} data The message to send + * @param {Boolean} [compress=false] Specifies whether or not to compress + * `data` + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + dispatch(data, compress, options, cb) { + if (!compress) { + this.sendFrame(Sender.frame(data, options), cb); + return; + } + + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + + this._bufferedBytes += options[kByteLength]; + this._state = DEFLATING; + perMessageDeflate.compress(data, options.fin, (_, buf) => { + if (this._socket.destroyed) { + const err = new Error( + 'The socket was closed while data was being compressed' + ); + + callCallbacks(this, err, cb); + return; + } + + this._bufferedBytes -= options[kByteLength]; + this._state = DEFAULT; + options.readOnly = false; + this.sendFrame(Sender.frame(buf, options), cb); + this.dequeue(); + }); + } + + /** + * Executes queued send operations. + * + * @private + */ + dequeue() { + while (this._state === DEFAULT && this._queue.length) { + const params = this._queue.shift(); + + this._bufferedBytes -= params[3][kByteLength]; + Reflect.apply(params[0], this, params.slice(1)); + } + } + + /** + * Enqueues a send operation. + * + * @param {Array} params Send operation parameters. + * @private + */ + enqueue(params) { + this._bufferedBytes += params[3][kByteLength]; + this._queue.push(params); + } + + /** + * Sends a frame. + * + * @param {(Buffer | String)[]} list The frame to send + * @param {Function} [cb] Callback + * @private + */ + sendFrame(list, cb) { + if (list.length === 2) { + this._socket.cork(); + this._socket.write(list[0]); + this._socket.write(list[1], cb); + this._socket.uncork(); + } else { + this._socket.write(list[0], cb); + } + } +} + +module.exports = Sender; + +/** + * Calls queued callbacks with an error. + * + * @param {Sender} sender The `Sender` instance + * @param {Error} err The error to call the callbacks with + * @param {Function} [cb] The first callback + * @private + */ +function callCallbacks(sender, err, cb) { + if (typeof cb === 'function') cb(err); + + for (let i = 0; i < sender._queue.length; i++) { + const params = sender._queue[i]; + const callback = params[params.length - 1]; + + if (typeof callback === 'function') callback(err); + } +} + +/** + * Handles a `Sender` error. + * + * @param {Sender} sender The `Sender` instance + * @param {Error} err The error + * @param {Function} [cb] The first pending callback + * @private + */ +function onError(sender, err, cb) { + callCallbacks(sender, err, cb); + sender.onerror(err); +} diff --git a/services/edge-broker/node_modules/ws/lib/stream.js b/services/edge-broker/node_modules/ws/lib/stream.js new file mode 100644 index 00000000..4c58c911 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/stream.js @@ -0,0 +1,161 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */ +'use strict'; + +const WebSocket = require('./websocket'); +const { Duplex } = require('stream'); + +/** + * Emits the `'close'` event on a stream. + * + * @param {Duplex} stream The stream. + * @private + */ +function emitClose(stream) { + stream.emit('close'); +} + +/** + * The listener of the `'end'` event. + * + * @private + */ +function duplexOnEnd() { + if (!this.destroyed && this._writableState.finished) { + this.destroy(); + } +} + +/** + * The listener of the `'error'` event. + * + * @param {Error} err The error + * @private + */ +function duplexOnError(err) { + this.removeListener('error', duplexOnError); + this.destroy(); + if (this.listenerCount('error') === 0) { + // Do not suppress the throwing behavior. + this.emit('error', err); + } +} + +/** + * Wraps a `WebSocket` in a duplex stream. + * + * @param {WebSocket} ws The `WebSocket` to wrap + * @param {Object} [options] The options for the `Duplex` constructor + * @return {Duplex} The duplex stream + * @public + */ +function createWebSocketStream(ws, options) { + let terminateOnDestroy = true; + + const duplex = new Duplex({ + ...options, + autoDestroy: false, + emitClose: false, + objectMode: false, + writableObjectMode: false + }); + + ws.on('message', function message(msg, isBinary) { + const data = + !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; + + if (!duplex.push(data)) ws.pause(); + }); + + ws.once('error', function error(err) { + if (duplex.destroyed) return; + + // Prevent `ws.terminate()` from being called by `duplex._destroy()`. + // + // - If the `'error'` event is emitted before the `'open'` event, then + // `ws.terminate()` is a noop as no socket is assigned. + // - Otherwise, the error is re-emitted by the listener of the `'error'` + // event of the `Receiver` object. The listener already closes the + // connection by calling `ws.close()`. This allows a close frame to be + // sent to the other peer. If `ws.terminate()` is called right after this, + // then the close frame might not be sent. + terminateOnDestroy = false; + duplex.destroy(err); + }); + + ws.once('close', function close() { + if (duplex.destroyed) return; + + duplex.push(null); + }); + + duplex._destroy = function (err, callback) { + if (ws.readyState === ws.CLOSED) { + callback(err); + process.nextTick(emitClose, duplex); + return; + } + + let called = false; + + ws.once('error', function error(err) { + called = true; + callback(err); + }); + + ws.once('close', function close() { + if (!called) callback(err); + process.nextTick(emitClose, duplex); + }); + + if (terminateOnDestroy) ws.terminate(); + }; + + duplex._final = function (callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once('open', function open() { + duplex._final(callback); + }); + return; + } + + // If the value of the `_socket` property is `null` it means that `ws` is a + // client websocket and the handshake failed. In fact, when this happens, a + // socket is never assigned to the websocket. Wait for the `'error'` event + // that will be emitted by the websocket. + if (ws._socket === null) return; + + if (ws._socket._writableState.finished) { + callback(); + if (duplex._readableState.endEmitted) duplex.destroy(); + } else { + ws._socket.once('finish', function finish() { + // `duplex` is not destroyed here because the `'end'` event will be + // emitted on `duplex` after this `'finish'` event. The EOF signaling + // `null` chunk is, in fact, pushed when the websocket emits `'close'`. + callback(); + }); + ws.close(); + } + }; + + duplex._read = function () { + if (ws.isPaused) ws.resume(); + }; + + duplex._write = function (chunk, encoding, callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once('open', function open() { + duplex._write(chunk, encoding, callback); + }); + return; + } + + ws.send(chunk, callback); + }; + + duplex.on('end', duplexOnEnd); + duplex.on('error', duplexOnError); + return duplex; +} + +module.exports = createWebSocketStream; diff --git a/services/edge-broker/node_modules/ws/lib/subprotocol.js b/services/edge-broker/node_modules/ws/lib/subprotocol.js new file mode 100644 index 00000000..d4381e88 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/subprotocol.js @@ -0,0 +1,62 @@ +'use strict'; + +const { tokenChars } = require('./validation'); + +/** + * Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names. + * + * @param {String} header The field value of the header + * @return {Set} The subprotocol names + * @public + */ +function parse(header) { + const protocols = new Set(); + let start = -1; + let end = -1; + let i = 0; + + for (i; i < header.length; i++) { + const code = header.charCodeAt(i); + + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if ( + i !== 0 && + (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ + ) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x2c /* ',' */) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + + const protocol = header.slice(start, end); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + + if (start === -1 || end !== -1) { + throw new SyntaxError('Unexpected end of input'); + } + + const protocol = header.slice(start, i); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + return protocols; +} + +module.exports = { parse }; diff --git a/services/edge-broker/node_modules/ws/lib/validation.js b/services/edge-broker/node_modules/ws/lib/validation.js new file mode 100644 index 00000000..4a2e68d5 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/validation.js @@ -0,0 +1,152 @@ +'use strict'; + +const { isUtf8 } = require('buffer'); + +const { hasBlob } = require('./constants'); + +// +// Allowed token characters: +// +// '!', '#', '$', '%', '&', ''', '*', '+', '-', +// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~' +// +// tokenChars[32] === 0 // ' ' +// tokenChars[33] === 1 // '!' +// tokenChars[34] === 0 // '"' +// ... +// +// prettier-ignore +const tokenChars = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31 + 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63 + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127 +]; + +/** + * Checks if a status code is allowed in a close frame. + * + * @param {Number} code The status code + * @return {Boolean} `true` if the status code is valid, else `false` + * @public + */ +function isValidStatusCode(code) { + return ( + (code >= 1000 && + code <= 1014 && + code !== 1004 && + code !== 1005 && + code !== 1006) || + (code >= 3000 && code <= 4999) + ); +} + +/** + * Checks if a given buffer contains only correct UTF-8. + * Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by + * Markus Kuhn. + * + * @param {Buffer} buf The buffer to check + * @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false` + * @public + */ +function _isValidUTF8(buf) { + const len = buf.length; + let i = 0; + + while (i < len) { + if ((buf[i] & 0x80) === 0) { + // 0xxxxxxx + i++; + } else if ((buf[i] & 0xe0) === 0xc0) { + // 110xxxxx 10xxxxxx + if ( + i + 1 === len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i] & 0xfe) === 0xc0 // Overlong + ) { + return false; + } + + i += 2; + } else if ((buf[i] & 0xf0) === 0xe0) { + // 1110xxxx 10xxxxxx 10xxxxxx + if ( + i + 2 >= len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong + (buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF) + ) { + return false; + } + + i += 3; + } else if ((buf[i] & 0xf8) === 0xf0) { + // 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + if ( + i + 3 >= len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i + 3] & 0xc0) !== 0x80 || + (buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong + (buf[i] === 0xf4 && buf[i + 1] > 0x8f) || + buf[i] > 0xf4 // > U+10FFFF + ) { + return false; + } + + i += 4; + } else { + return false; + } + } + + return true; +} + +/** + * Determines whether a value is a `Blob`. + * + * @param {*} value The value to be tested + * @return {Boolean} `true` if `value` is a `Blob`, else `false` + * @private + */ +function isBlob(value) { + return ( + hasBlob && + typeof value === 'object' && + typeof value.arrayBuffer === 'function' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + (value[Symbol.toStringTag] === 'Blob' || + value[Symbol.toStringTag] === 'File') + ); +} + +module.exports = { + isBlob, + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars +}; + +if (isUtf8) { + module.exports.isValidUTF8 = function (buf) { + return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf); + }; +} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) { + try { + const isValidUTF8 = require('utf-8-validate'); + + module.exports.isValidUTF8 = function (buf) { + return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf); + }; + } catch (e) { + // Continue regardless of the error. + } +} diff --git a/services/edge-broker/node_modules/ws/lib/websocket-server.js b/services/edge-broker/node_modules/ws/lib/websocket-server.js new file mode 100644 index 00000000..68aa7897 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/websocket-server.js @@ -0,0 +1,554 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */ + +'use strict'; + +const EventEmitter = require('events'); +const http = require('http'); +const { Duplex } = require('stream'); +const { createHash } = require('crypto'); + +const extension = require('./extension'); +const PerMessageDeflate = require('./permessage-deflate'); +const subprotocol = require('./subprotocol'); +const WebSocket = require('./websocket'); +const { CLOSE_TIMEOUT, GUID, kWebSocket } = require('./constants'); + +const keyRegex = /^[+/0-9A-Za-z]{22}==$/; + +const RUNNING = 0; +const CLOSING = 1; +const CLOSED = 2; + +/** + * Class representing a WebSocket server. + * + * @extends EventEmitter + */ +class WebSocketServer extends EventEmitter { + /** + * Create a `WebSocketServer` instance. + * + * @param {Object} options Configuration options + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Boolean} [options.autoPong=true] Specifies whether or not to + * automatically send a pong in response to a ping + * @param {Number} [options.backlog=511] The maximum length of the queue of + * pending connections + * @param {Boolean} [options.clientTracking=true] Specifies whether or not to + * track clients + * @param {Number} [options.closeTimeout=30000] Duration in milliseconds to + * wait for the closing handshake to finish after `websocket.close()` is + * called + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable + * permessage-deflate + * @param {Number} [options.port] The port where to bind the server + * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S + * server to use + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @param {Function} [options.verifyClient] A hook to reject connections + * @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket` + * class to use. It must be the `WebSocket` class or class that extends it + * @param {Function} [callback] A listener for the `listening` event + */ + constructor(options, callback) { + super(); + + options = { + allowSynchronousEvents: true, + autoPong: true, + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: false, + handleProtocols: null, + clientTracking: true, + closeTimeout: CLOSE_TIMEOUT, + verifyClient: null, + noServer: false, + backlog: null, // use default (511 as implemented in net.js) + server: null, + host: null, + path: null, + port: null, + WebSocket, + ...options + }; + + if ( + (options.port == null && !options.server && !options.noServer) || + (options.port != null && (options.server || options.noServer)) || + (options.server && options.noServer) + ) { + throw new TypeError( + 'One and only one of the "port", "server", or "noServer" options ' + + 'must be specified' + ); + } + + if (options.port != null) { + this._server = http.createServer((req, res) => { + const body = http.STATUS_CODES[426]; + + res.writeHead(426, { + 'Content-Length': body.length, + 'Content-Type': 'text/plain' + }); + res.end(body); + }); + this._server.listen( + options.port, + options.host, + options.backlog, + callback + ); + } else if (options.server) { + this._server = options.server; + } + + if (this._server) { + const emitConnection = this.emit.bind(this, 'connection'); + + this._removeListeners = addListeners(this._server, { + listening: this.emit.bind(this, 'listening'), + error: this.emit.bind(this, 'error'), + upgrade: (req, socket, head) => { + this.handleUpgrade(req, socket, head, emitConnection); + } + }); + } + + if (options.perMessageDeflate === true) options.perMessageDeflate = {}; + if (options.clientTracking) { + this.clients = new Set(); + this._shouldEmitClose = false; + } + + this.options = options; + this._state = RUNNING; + } + + /** + * Returns the bound address, the address family name, and port of the server + * as reported by the operating system if listening on an IP socket. + * If the server is listening on a pipe or UNIX domain socket, the name is + * returned as a string. + * + * @return {(Object|String|null)} The address of the server + * @public + */ + address() { + if (this.options.noServer) { + throw new Error('The server is operating in "noServer" mode'); + } + + if (!this._server) return null; + return this._server.address(); + } + + /** + * Stop the server from accepting new connections and emit the `'close'` event + * when all existing connections are closed. + * + * @param {Function} [cb] A one-time listener for the `'close'` event + * @public + */ + close(cb) { + if (this._state === CLOSED) { + if (cb) { + this.once('close', () => { + cb(new Error('The server is not running')); + }); + } + + process.nextTick(emitClose, this); + return; + } + + if (cb) this.once('close', cb); + + if (this._state === CLOSING) return; + this._state = CLOSING; + + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } + + if (this.clients) { + if (!this.clients.size) { + process.nextTick(emitClose, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(emitClose, this); + } + } else { + const server = this._server; + + this._removeListeners(); + this._removeListeners = this._server = null; + + // + // The HTTP/S server was created internally. Close it, and rely on its + // `'close'` event. + // + server.close(() => { + emitClose(this); + }); + } + } + + /** + * See if a given request should be handled by this server instance. + * + * @param {http.IncomingMessage} req Request object to inspect + * @return {Boolean} `true` if the request is valid, else `false` + * @public + */ + shouldHandle(req) { + if (this.options.path) { + const index = req.url.indexOf('?'); + const pathname = index !== -1 ? req.url.slice(0, index) : req.url; + + if (pathname !== this.options.path) return false; + } + + return true; + } + + /** + * Handle a HTTP Upgrade request. + * + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @public + */ + handleUpgrade(req, socket, head, cb) { + socket.on('error', socketOnError); + + const key = req.headers['sec-websocket-key']; + const upgrade = req.headers.upgrade; + const version = +req.headers['sec-websocket-version']; + + if (req.method !== 'GET') { + const message = 'Invalid HTTP method'; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); + return; + } + + if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') { + const message = 'Invalid Upgrade header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (key === undefined || !keyRegex.test(key)) { + const message = 'Missing or invalid Sec-WebSocket-Key header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (version !== 13 && version !== 8) { + const message = 'Missing or invalid Sec-WebSocket-Version header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, { + 'Sec-WebSocket-Version': '13, 8' + }); + return; + } + + if (!this.shouldHandle(req)) { + abortHandshake(socket, 400); + return; + } + + const secWebSocketProtocol = req.headers['sec-websocket-protocol']; + let protocols = new Set(); + + if (secWebSocketProtocol !== undefined) { + try { + protocols = subprotocol.parse(secWebSocketProtocol); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Protocol header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + + const secWebSocketExtensions = req.headers['sec-websocket-extensions']; + const extensions = {}; + + if ( + this.options.perMessageDeflate && + secWebSocketExtensions !== undefined + ) { + const perMessageDeflate = new PerMessageDeflate({ + ...this.options.perMessageDeflate, + isServer: true, + maxPayload: this.options.maxPayload + }); + + try { + const offers = extension.parse(secWebSocketExtensions); + + if (offers[PerMessageDeflate.extensionName]) { + perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); + extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + } catch (err) { + const message = + 'Invalid or unacceptable Sec-WebSocket-Extensions header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + + // + // Optionally call external client verification handler. + // + if (this.options.verifyClient) { + const info = { + origin: + req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`], + secure: !!(req.socket.authorized || req.socket.encrypted), + req + }; + + if (this.options.verifyClient.length === 2) { + this.options.verifyClient(info, (verified, code, message, headers) => { + if (!verified) { + return abortHandshake(socket, code || 401, message, headers); + } + + this.completeUpgrade( + extensions, + key, + protocols, + req, + socket, + head, + cb + ); + }); + return; + } + + if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); + } + + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); + } + + /** + * Upgrade the connection to WebSocket. + * + * @param {Object} extensions The accepted extensions + * @param {String} key The value of the `Sec-WebSocket-Key` header + * @param {Set} protocols The subprotocols + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @throws {Error} If called more than once with the same socket + * @private + */ + completeUpgrade(extensions, key, protocols, req, socket, head, cb) { + // + // Destroy the socket if the client has already sent a FIN packet. + // + if (!socket.readable || !socket.writable) return socket.destroy(); + + if (socket[kWebSocket]) { + throw new Error( + 'server.handleUpgrade() was called more than once with the same ' + + 'socket, possibly due to a misconfiguration' + ); + } + + if (this._state > RUNNING) return abortHandshake(socket, 503); + + const digest = createHash('sha1') + .update(key + GUID) + .digest('base64'); + + const headers = [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${digest}` + ]; + + const ws = new this.options.WebSocket(null, undefined, this.options); + + if (protocols.size) { + // + // Optionally call external protocol selection handler. + // + const protocol = this.options.handleProtocols + ? this.options.handleProtocols(protocols, req) + : protocols.values().next().value; + + if (protocol) { + headers.push(`Sec-WebSocket-Protocol: ${protocol}`); + ws._protocol = protocol; + } + } + + if (extensions[PerMessageDeflate.extensionName]) { + const params = extensions[PerMessageDeflate.extensionName].params; + const value = extension.format({ + [PerMessageDeflate.extensionName]: [params] + }); + headers.push(`Sec-WebSocket-Extensions: ${value}`); + ws._extensions = extensions; + } + + // + // Allow external modification/inspection of handshake headers. + // + this.emit('headers', headers, req); + + socket.write(headers.concat('\r\n').join('\r\n')); + socket.removeListener('error', socketOnError); + + ws.setSocket(socket, head, { + allowSynchronousEvents: this.options.allowSynchronousEvents, + maxPayload: this.options.maxPayload, + skipUTF8Validation: this.options.skipUTF8Validation + }); + + if (this.clients) { + this.clients.add(ws); + ws.on('close', () => { + this.clients.delete(ws); + + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(emitClose, this); + } + }); + } + + cb(ws, req); + } +} + +module.exports = WebSocketServer; + +/** + * Add event listeners on an `EventEmitter` using a map of + * pairs. + * + * @param {EventEmitter} server The event emitter + * @param {Object.} map The listeners to add + * @return {Function} A function that will remove the added listeners when + * called + * @private + */ +function addListeners(server, map) { + for (const event of Object.keys(map)) server.on(event, map[event]); + + return function removeListeners() { + for (const event of Object.keys(map)) { + server.removeListener(event, map[event]); + } + }; +} + +/** + * Emit a `'close'` event on an `EventEmitter`. + * + * @param {EventEmitter} server The event emitter + * @private + */ +function emitClose(server) { + server._state = CLOSED; + server.emit('close'); +} + +/** + * Handle socket errors. + * + * @private + */ +function socketOnError() { + this.destroy(); +} + +/** + * Close the connection when preconditions are not fulfilled. + * + * @param {Duplex} socket The socket of the upgrade request + * @param {Number} code The HTTP response status code + * @param {String} [message] The HTTP response body + * @param {Object} [headers] Additional HTTP response headers + * @private + */ +function abortHandshake(socket, code, message, headers) { + // + // The socket is writable unless the user destroyed or ended it before calling + // `server.handleUpgrade()` or in the `verifyClient` function, which is a user + // error. Handling this does not make much sense as the worst that can happen + // is that some of the data written by the user might be discarded due to the + // call to `socket.end()` below, which triggers an `'error'` event that in + // turn causes the socket to be destroyed. + // + message = message || http.STATUS_CODES[code]; + headers = { + Connection: 'close', + 'Content-Type': 'text/html', + 'Content-Length': Buffer.byteLength(message), + ...headers + }; + + socket.once('finish', socket.destroy); + + socket.end( + `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` + + Object.keys(headers) + .map((h) => `${h}: ${headers[h]}`) + .join('\r\n') + + '\r\n\r\n' + + message + ); +} + +/** + * Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least + * one listener for it, otherwise call `abortHandshake()`. + * + * @param {WebSocketServer} server The WebSocket server + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The socket of the upgrade request + * @param {Number} code The HTTP response status code + * @param {String} message The HTTP response body + * @param {Object} [headers] The HTTP response headers + * @private + */ +function abortHandshakeOrEmitwsClientError( + server, + req, + socket, + code, + message, + headers +) { + if (server.listenerCount('wsClientError')) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + + server.emit('wsClientError', err, socket, req); + } else { + abortHandshake(socket, code, message, headers); + } +} diff --git a/services/edge-broker/node_modules/ws/lib/websocket.js b/services/edge-broker/node_modules/ws/lib/websocket.js new file mode 100644 index 00000000..75d5bb28 --- /dev/null +++ b/services/edge-broker/node_modules/ws/lib/websocket.js @@ -0,0 +1,1393 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$", "caughtErrors": "none" }] */ + +'use strict'; + +const EventEmitter = require('events'); +const https = require('https'); +const http = require('http'); +const net = require('net'); +const tls = require('tls'); +const { randomBytes, createHash } = require('crypto'); +const { Duplex, Readable } = require('stream'); +const { URL } = require('url'); + +const PerMessageDeflate = require('./permessage-deflate'); +const Receiver = require('./receiver'); +const Sender = require('./sender'); +const { isBlob } = require('./validation'); + +const { + BINARY_TYPES, + CLOSE_TIMEOUT, + EMPTY_BUFFER, + GUID, + kForOnEventAttribute, + kListener, + kStatusCode, + kWebSocket, + NOOP +} = require('./constants'); +const { + EventTarget: { addEventListener, removeEventListener } +} = require('./event-target'); +const { format, parse } = require('./extension'); +const { toBuffer } = require('./buffer-util'); + +const kAborted = Symbol('kAborted'); +const protocolVersions = [8, 13]; +const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; +const subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; + +/** + * Class representing a WebSocket. + * + * @extends EventEmitter + */ +class WebSocket extends EventEmitter { + /** + * Create a new `WebSocket`. + * + * @param {(String|URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options + */ + constructor(address, protocols, options) { + super(); + + this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; + this._closeFrameReceived = false; + this._closeFrameSent = false; + this._closeMessage = EMPTY_BUFFER; + this._closeTimer = null; + this._errorEmitted = false; + this._extensions = {}; + this._paused = false; + this._protocol = ''; + this._readyState = WebSocket.CONNECTING; + this._receiver = null; + this._sender = null; + this._socket = null; + + if (address !== null) { + this._bufferedAmount = 0; + this._isServer = false; + this._redirects = 0; + + if (protocols === undefined) { + protocols = []; + } else if (!Array.isArray(protocols)) { + if (typeof protocols === 'object' && protocols !== null) { + options = protocols; + protocols = []; + } else { + protocols = [protocols]; + } + } + + initAsClient(this, address, protocols, options); + } else { + this._autoPong = options.autoPong; + this._closeTimeout = options.closeTimeout; + this._isServer = true; + } + } + + /** + * For historical reasons, the custom "nodebuffer" type is used by the default + * instead of "blob". + * + * @type {String} + */ + get binaryType() { + return this._binaryType; + } + + set binaryType(type) { + if (!BINARY_TYPES.includes(type)) return; + + this._binaryType = type; + + // + // Allow to change `binaryType` on the fly. + // + if (this._receiver) this._receiver._binaryType = type; + } + + /** + * @type {Number} + */ + get bufferedAmount() { + if (!this._socket) return this._bufferedAmount; + + return this._socket._writableState.length + this._sender._bufferedBytes; + } + + /** + * @type {String} + */ + get extensions() { + return Object.keys(this._extensions).join(); + } + + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onclose() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + + /** + * @type {String} + */ + get url() { + return this._url; + } + + /** + * Set up the socket and the internal resources. + * + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options) { + const receiver = new Receiver({ + allowSynchronousEvents: options.allowSynchronousEvents, + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation + }); + + const sender = new Sender(socket, this._extensions, options.generateMask); + + this._receiver = receiver; + this._sender = sender; + this._socket = socket; + + receiver[kWebSocket] = this; + sender[kWebSocket] = this; + socket[kWebSocket] = this; + + receiver.on('conclude', receiverOnConclude); + receiver.on('drain', receiverOnDrain); + receiver.on('error', receiverOnError); + receiver.on('message', receiverOnMessage); + receiver.on('ping', receiverOnPing); + receiver.on('pong', receiverOnPong); + + sender.onerror = senderOnError; + + // + // These methods may not be available if `socket` is just a `Duplex`. + // + if (socket.setTimeout) socket.setTimeout(0); + if (socket.setNoDelay) socket.setNoDelay(); + + if (head.length > 0) socket.unshift(head); + + socket.on('close', socketOnClose); + socket.on('data', socketOnData); + socket.on('end', socketOnEnd); + socket.on('error', socketOnError); + + this._readyState = WebSocket.OPEN; + this.emit('open'); + } + + /** + * Emit the `'close'` event. + * + * @private + */ + emitClose() { + if (!this._socket) { + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + return; + } + + if (this._extensions[PerMessageDeflate.extensionName]) { + this._extensions[PerMessageDeflate.extensionName].cleanup(); + } + + this._receiver.removeAllListeners(); + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + } + + /** + * Start a closing handshake. + * + * +----------+ +-----------+ +----------+ + * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - + * | +----------+ +-----------+ +----------+ | + * +----------+ +-----------+ | + * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING + * +----------+ +-----------+ | + * | | | +---+ | + * +------------------------+-->|fin| - - - - + * | +---+ | +---+ + * - - - - -|fin|<---------------------+ + * +---+ + * + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing + * @public + */ + close(code, data) { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + abortHandshake(this, this._req, msg); + return; + } + + if (this.readyState === WebSocket.CLOSING) { + if ( + this._closeFrameSent && + (this._closeFrameReceived || this._receiver._writableState.errorEmitted) + ) { + this._socket.end(); + } + + return; + } + + this._readyState = WebSocket.CLOSING; + this._sender.close(code, data, !this._isServer, (err) => { + // + // This error is handled by the `'error'` listener on the socket. We only + // want to know if the close frame has been sent here. + // + if (err) return; + + this._closeFrameSent = true; + + if ( + this._closeFrameReceived || + this._receiver._writableState.errorEmitted + ) { + this._socket.end(); + } + }); + + setCloseTimer(this); + } + + /** + * Pause the socket. + * + * @public + */ + pause() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = true; + this._socket.pause(); + } + + /** + * Send a ping. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent + * @public + */ + ping(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof data === 'function') { + cb = data; + data = mask = undefined; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + this._sender.ping(data || EMPTY_BUFFER, mask, cb); + } + + /** + * Send a pong. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent + * @public + */ + pong(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof data === 'function') { + cb = data; + data = mask = undefined; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + this._sender.pong(data || EMPTY_BUFFER, mask, cb); + } + + /** + * Resume the socket. + * + * @public + */ + resume() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = false; + if (!this._receiver._writableState.needDrain) this._socket.resume(); + } + + /** + * Send a data message. + * + * @param {*} data The message to send + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out + * @public + */ + send(data, options, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof options === 'function') { + cb = options; + options = {}; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + const opts = { + binary: typeof data !== 'string', + mask: !this._isServer, + compress: true, + fin: true, + ...options + }; + + if (!this._extensions[PerMessageDeflate.extensionName]) { + opts.compress = false; + } + + this._sender.send(data || EMPTY_BUFFER, opts, cb); + } + + /** + * Forcibly close the connection. + * + * @public + */ + terminate() { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + abortHandshake(this, this._req, msg); + return; + } + + if (this._socket) { + this._readyState = WebSocket.CLOSING; + this._socket.destroy(); + } + } +} + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +[ + 'binaryType', + 'bufferedAmount', + 'extensions', + 'isPaused', + 'protocol', + 'readyState', + 'url' +].forEach((property) => { + Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); +}); + +// +// Add the `onopen`, `onerror`, `onclose`, and `onmessage` attributes. +// See https://html.spec.whatwg.org/multipage/comms.html#the-websocket-interface +// +['open', 'error', 'close', 'message'].forEach((method) => { + Object.defineProperty(WebSocket.prototype, `on${method}`, { + enumerable: true, + get() { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; + } + + return null; + }, + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } + } + + if (typeof handler !== 'function') return; + + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); + } + }); +}); + +WebSocket.prototype.addEventListener = addEventListener; +WebSocket.prototype.removeEventListener = removeEventListener; + +module.exports = WebSocket; + +/** + * Initialize a WebSocket client. + * + * @param {WebSocket} websocket The client to initialize + * @param {(String|URL)} address The URL to which to connect + * @param {Array} protocols The subprotocols + * @param {Object} [options] Connection options + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether any + * of the `'message'`, `'ping'`, and `'pong'` events can be emitted multiple + * times in the same tick + * @param {Boolean} [options.autoPong=true] Specifies whether or not to + * automatically send a pong in response to a ping + * @param {Number} [options.closeTimeout=30000] Duration in milliseconds to wait + * for the closing handshake to finish after `websocket.close()` is called + * @param {Function} [options.finishRequest] A function which can be used to + * customize the headers of each http request before it is sent + * @param {Boolean} [options.followRedirects=false] Whether or not to follow + * redirects + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the + * handshake request + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Number} [options.maxRedirects=10] The maximum number of redirects + * allowed + * @param {String} [options.origin] Value of the `Origin` or + * `Sec-WebSocket-Origin` header + * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable + * permessage-deflate + * @param {Number} [options.protocolVersion=13] Value of the + * `Sec-WebSocket-Version` header + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ +function initAsClient(websocket, address, protocols, options) { + const opts = { + allowSynchronousEvents: true, + autoPong: true, + closeTimeout: CLOSE_TIMEOUT, + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options, + socketPath: undefined, + hostname: undefined, + protocol: undefined, + timeout: undefined, + method: 'GET', + host: undefined, + path: undefined, + port: undefined + }; + + websocket._autoPong = opts.autoPong; + websocket._closeTimeout = opts.closeTimeout; + + if (!protocolVersions.includes(opts.protocolVersion)) { + throw new RangeError( + `Unsupported protocol version: ${opts.protocolVersion} ` + + `(supported versions: ${protocolVersions.join(', ')})` + ); + } + + let parsedUrl; + + if (address instanceof URL) { + parsedUrl = address; + } else { + try { + parsedUrl = new URL(address); + } catch { + throw new SyntaxError(`Invalid URL: ${address}`); + } + } + + if (parsedUrl.protocol === 'http:') { + parsedUrl.protocol = 'ws:'; + } else if (parsedUrl.protocol === 'https:') { + parsedUrl.protocol = 'wss:'; + } + + websocket._url = parsedUrl.href; + + const isSecure = parsedUrl.protocol === 'wss:'; + const isIpcUrl = parsedUrl.protocol === 'ws+unix:'; + let invalidUrlMessage; + + if (parsedUrl.protocol !== 'ws:' && !isSecure && !isIpcUrl) { + invalidUrlMessage = + 'The URL\'s protocol must be one of "ws:", "wss:", ' + + '"http:", "https:", or "ws+unix:"'; + } else if (isIpcUrl && !parsedUrl.pathname) { + invalidUrlMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidUrlMessage = 'The URL contains a fragment identifier'; + } + + if (invalidUrlMessage) { + const err = new SyntaxError(invalidUrlMessage); + + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } + } + + const defaultPort = isSecure ? 443 : 80; + const key = randomBytes(16).toString('base64'); + const request = isSecure ? https.request : http.request; + const protocolSet = new Set(); + let perMessageDeflate; + + opts.createConnection = + opts.createConnection || (isSecure ? tlsConnect : netConnect); + opts.defaultPort = opts.defaultPort || defaultPort; + opts.port = parsedUrl.port || defaultPort; + opts.host = parsedUrl.hostname.startsWith('[') + ? parsedUrl.hostname.slice(1, -1) + : parsedUrl.hostname; + opts.headers = { + ...opts.headers, + 'Sec-WebSocket-Version': opts.protocolVersion, + 'Sec-WebSocket-Key': key, + Connection: 'Upgrade', + Upgrade: 'websocket' + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + + if (opts.perMessageDeflate) { + perMessageDeflate = new PerMessageDeflate({ + ...opts.perMessageDeflate, + isServer: false, + maxPayload: opts.maxPayload + }); + opts.headers['Sec-WebSocket-Extensions'] = format({ + [PerMessageDeflate.extensionName]: perMessageDeflate.offer() + }); + } + if (protocols.length) { + for (const protocol of protocols) { + if ( + typeof protocol !== 'string' || + !subprotocolRegex.test(protocol) || + protocolSet.has(protocol) + ) { + throw new SyntaxError( + 'An invalid or duplicated subprotocol was specified' + ); + } + + protocolSet.add(protocol); + } + + opts.headers['Sec-WebSocket-Protocol'] = protocols.join(','); + } + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers['Sec-WebSocket-Origin'] = opts.origin; + } else { + opts.headers.Origin = opts.origin; + } + } + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; + } + + if (isIpcUrl) { + const parts = opts.path.split(':'); + + opts.socketPath = parts[0]; + opts.path = parts[1]; + } + + let req; + + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalIpc = isIpcUrl; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isIpcUrl + ? opts.socketPath + : parsedUrl.host; + + const headers = options && options.headers; + + // + // Shallow copy the user provided options so that headers can be changed + // without mutating the original object. + // + options = { ...options, headers: {} }; + + if (headers) { + for (const [key, value] of Object.entries(headers)) { + options.headers[key.toLowerCase()] = value; + } + } + } else if (websocket.listenerCount('redirect') === 0) { + const isSameHost = isIpcUrl + ? websocket._originalIpc + ? opts.socketPath === websocket._originalHostOrSocketPath + : false + : websocket._originalIpc + ? false + : parsedUrl.host === websocket._originalHostOrSocketPath; + + if (!isSameHost || (websocket._originalSecure && !isSecure)) { + // + // Match curl 7.77.0 behavior and drop the following headers. These + // headers are also dropped when following a redirect to a subdomain. + // + delete opts.headers.authorization; + delete opts.headers.cookie; + + if (!isSameHost) delete opts.headers.host; + + opts.auth = undefined; + } + } + + // + // Match curl 7.77.0 behavior and make the first `Authorization` header win. + // If the `Authorization` header is set, then there is nothing to do as it + // will take precedence. + // + if (opts.auth && !options.headers.authorization) { + options.headers.authorization = + 'Basic ' + Buffer.from(opts.auth).toString('base64'); + } + + req = websocket._req = request(opts); + + if (websocket._redirects) { + // + // Unlike what is done for the `'upgrade'` event, no early exit is + // triggered here if the user calls `websocket.close()` or + // `websocket.terminate()` from a listener of the `'redirect'` event. This + // is because the user can also call `request.destroy()` with an error + // before calling `websocket.close()` or `websocket.terminate()` and this + // would result in an error being emitted on the `request` object with no + // `'error'` event listeners attached. + // + websocket.emit('redirect', websocket.url, req); + } + } else { + req = websocket._req = request(opts); + } + + if (opts.timeout) { + req.on('timeout', () => { + abortHandshake(websocket, req, 'Opening handshake has timed out'); + }); + } + + req.on('error', (err) => { + if (req === null || req[kAborted]) return; + + req = websocket._req = null; + emitErrorAndClose(websocket, err); + }); + + req.on('response', (res) => { + const location = res.headers.location; + const statusCode = res.statusCode; + + if ( + location && + opts.followRedirects && + statusCode >= 300 && + statusCode < 400 + ) { + if (++websocket._redirects > opts.maxRedirects) { + abortHandshake(websocket, req, 'Maximum redirects exceeded'); + return; + } + + req.abort(); + + let addr; + + try { + addr = new URL(location, address); + } catch (e) { + const err = new SyntaxError(`Invalid URL: ${location}`); + emitErrorAndClose(websocket, err); + return; + } + + initAsClient(websocket, addr, protocols, options); + } else if (!websocket.emit('unexpected-response', req, res)) { + abortHandshake( + websocket, + req, + `Unexpected server response: ${res.statusCode}` + ); + } + }); + + req.on('upgrade', (res, socket, head) => { + websocket.emit('upgrade', res); + + // + // The user may have closed the connection from a listener of the + // `'upgrade'` event. + // + if (websocket.readyState !== WebSocket.CONNECTING) return; + + req = websocket._req = null; + + const upgrade = res.headers.upgrade; + + if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') { + abortHandshake(websocket, socket, 'Invalid Upgrade header'); + return; + } + + const digest = createHash('sha1') + .update(key + GUID) + .digest('base64'); + + if (res.headers['sec-websocket-accept'] !== digest) { + abortHandshake(websocket, socket, 'Invalid Sec-WebSocket-Accept header'); + return; + } + + const serverProt = res.headers['sec-websocket-protocol']; + let protError; + + if (serverProt !== undefined) { + if (!protocolSet.size) { + protError = 'Server sent a subprotocol but none was requested'; + } else if (!protocolSet.has(serverProt)) { + protError = 'Server sent an invalid subprotocol'; + } + } else if (protocolSet.size) { + protError = 'Server sent no subprotocol'; + } + + if (protError) { + abortHandshake(websocket, socket, protError); + return; + } + + if (serverProt) websocket._protocol = serverProt; + + const secWebSocketExtensions = res.headers['sec-websocket-extensions']; + + if (secWebSocketExtensions !== undefined) { + if (!perMessageDeflate) { + const message = + 'Server sent a Sec-WebSocket-Extensions header but no extension ' + + 'was requested'; + abortHandshake(websocket, socket, message); + return; + } + + let extensions; + + try { + extensions = parse(secWebSocketExtensions); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + const extensionNames = Object.keys(extensions); + + if ( + extensionNames.length !== 1 || + extensionNames[0] !== PerMessageDeflate.extensionName + ) { + const message = 'Server indicated an extension that was not requested'; + abortHandshake(websocket, socket, message); + return; + } + + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + websocket._extensions[PerMessageDeflate.extensionName] = + perMessageDeflate; + } + + websocket.setSocket(socket, head, { + allowSynchronousEvents: opts.allowSynchronousEvents, + generateMask: opts.generateMask, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); + }); + + if (opts.finishRequest) { + opts.finishRequest(req, websocket); + } else { + req.end(); + } +} + +/** + * Emit the `'error'` and `'close'` events. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {Error} The error to emit + * @private + */ +function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket.CLOSING; + // + // The following assignment is practically useless and is done only for + // consistency. + // + websocket._errorEmitted = true; + websocket.emit('error', err); + websocket.emitClose(); +} + +/** + * Create a `net.Socket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {net.Socket} The newly created socket used to start the connection + * @private + */ +function netConnect(options) { + options.path = options.socketPath; + return net.connect(options); +} + +/** + * Create a `tls.TLSSocket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {tls.TLSSocket} The newly created socket used to start the connection + * @private + */ +function tlsConnect(options) { + options.path = undefined; + + if (!options.servername && options.servername !== '') { + options.servername = net.isIP(options.host) ? '' : options.host; + } + + return tls.connect(options); +} + +/** + * Abort the handshake and emit an error. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {(http.ClientRequest|net.Socket|tls.Socket)} stream The request to + * abort or the socket to destroy + * @param {String} message The error message + * @private + */ +function abortHandshake(websocket, stream, message) { + websocket._readyState = WebSocket.CLOSING; + + const err = new Error(message); + Error.captureStackTrace(err, abortHandshake); + + if (stream.setHeader) { + stream[kAborted] = true; + stream.abort(); + + if (stream.socket && !stream.socket.destroyed) { + // + // On Node.js >= 14.3.0 `request.abort()` does not destroy the socket if + // called after the request completed. See + // https://github.com/websockets/ws/issues/1869. + // + stream.socket.destroy(); + } + + process.nextTick(emitErrorAndClose, websocket, err); + } else { + stream.destroy(err); + stream.once('error', websocket.emit.bind(websocket, 'error')); + stream.once('close', websocket.emitClose.bind(websocket)); + } +} + +/** + * Handle cases where the `ping()`, `pong()`, or `send()` methods are called + * when the `readyState` attribute is `CLOSING` or `CLOSED`. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {*} [data] The data to send + * @param {Function} [cb] Callback + * @private + */ +function sendAfterClose(websocket, data, cb) { + if (data) { + const length = isBlob(data) ? data.size : toBuffer(data).length; + + // + // The `_bufferedAmount` property is used only when the peer is a client and + // the opening handshake fails. Under these circumstances, in fact, the + // `setSocket()` method is not called, so the `_socket` and `_sender` + // properties are set to `null`. + // + if (websocket._socket) websocket._sender._bufferedBytes += length; + else websocket._bufferedAmount += length; + } + + if (cb) { + const err = new Error( + `WebSocket is not open: readyState ${websocket.readyState} ` + + `(${readyStates[websocket.readyState]})` + ); + process.nextTick(cb, err); + } +} + +/** + * The listener of the `Receiver` `'conclude'` event. + * + * @param {Number} code The status code + * @param {Buffer} reason The reason for closing + * @private + */ +function receiverOnConclude(code, reason) { + const websocket = this[kWebSocket]; + + websocket._closeFrameReceived = true; + websocket._closeMessage = reason; + websocket._closeCode = code; + + if (websocket._socket[kWebSocket] === undefined) return; + + websocket._socket.removeListener('data', socketOnData); + process.nextTick(resume, websocket._socket); + + if (code === 1005) websocket.close(); + else websocket.close(code, reason); +} + +/** + * The listener of the `Receiver` `'drain'` event. + * + * @private + */ +function receiverOnDrain() { + const websocket = this[kWebSocket]; + + if (!websocket.isPaused) websocket._socket.resume(); +} + +/** + * The listener of the `Receiver` `'error'` event. + * + * @param {(RangeError|Error)} err The emitted error + * @private + */ +function receiverOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket._socket[kWebSocket] !== undefined) { + websocket._socket.removeListener('data', socketOnData); + + // + // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See + // https://github.com/websockets/ws/issues/1940. + // + process.nextTick(resume, websocket._socket); + + websocket.close(err[kStatusCode]); + } + + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } +} + +/** + * The listener of the `Receiver` `'finish'` event. + * + * @private + */ +function receiverOnFinish() { + this[kWebSocket].emitClose(); +} + +/** + * The listener of the `Receiver` `'message'` event. + * + * @param {Buffer|ArrayBuffer|Buffer[])} data The message + * @param {Boolean} isBinary Specifies whether the message is binary or not + * @private + */ +function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit('message', data, isBinary); +} + +/** + * The listener of the `Receiver` `'ping'` event. + * + * @param {Buffer} data The data included in the ping frame + * @private + */ +function receiverOnPing(data) { + const websocket = this[kWebSocket]; + + if (websocket._autoPong) websocket.pong(data, !this._isServer, NOOP); + websocket.emit('ping', data); +} + +/** + * The listener of the `Receiver` `'pong'` event. + * + * @param {Buffer} data The data included in the pong frame + * @private + */ +function receiverOnPong(data) { + this[kWebSocket].emit('pong', data); +} + +/** + * Resume a readable stream + * + * @param {Readable} stream The readable stream + * @private + */ +function resume(stream) { + stream.resume(); +} + +/** + * The `Sender` error event handler. + * + * @param {Error} The error + * @private + */ +function senderOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket.readyState === WebSocket.CLOSED) return; + if (websocket.readyState === WebSocket.OPEN) { + websocket._readyState = WebSocket.CLOSING; + setCloseTimer(websocket); + } + + // + // `socket.end()` is used instead of `socket.destroy()` to allow the other + // peer to finish sending queued data. There is no need to set a timer here + // because `CLOSING` means that it is already set or not needed. + // + this._socket.end(); + + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } +} + +/** + * Set a timer to destroy the underlying raw socket of a WebSocket. + * + * @param {WebSocket} websocket The WebSocket instance + * @private + */ +function setCloseTimer(websocket) { + websocket._closeTimer = setTimeout( + websocket._socket.destroy.bind(websocket._socket), + websocket._closeTimeout + ); +} + +/** + * The listener of the socket `'close'` event. + * + * @private + */ +function socketOnClose() { + const websocket = this[kWebSocket]; + + this.removeListener('close', socketOnClose); + this.removeListener('data', socketOnData); + this.removeListener('end', socketOnEnd); + + websocket._readyState = WebSocket.CLOSING; + + // + // The close frame might not have been received or the `'end'` event emitted, + // for example, if the socket was destroyed due to an error. Ensure that the + // `receiver` stream is closed after writing any remaining buffered data to + // it. If the readable side of the socket is in flowing mode then there is no + // buffered data as everything has been already written. If instead, the + // socket is paused, any possible buffered data will be read as a single + // chunk. + // + if ( + !this._readableState.endEmitted && + !websocket._closeFrameReceived && + !websocket._receiver._writableState.errorEmitted && + this._readableState.length !== 0 + ) { + const chunk = this.read(this._readableState.length); + + websocket._receiver.write(chunk); + } + + websocket._receiver.end(); + + this[kWebSocket] = undefined; + + clearTimeout(websocket._closeTimer); + + if ( + websocket._receiver._writableState.finished || + websocket._receiver._writableState.errorEmitted + ) { + websocket.emitClose(); + } else { + websocket._receiver.on('error', receiverOnFinish); + websocket._receiver.on('finish', receiverOnFinish); + } +} + +/** + * The listener of the socket `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function socketOnData(chunk) { + if (!this[kWebSocket]._receiver.write(chunk)) { + this.pause(); + } +} + +/** + * The listener of the socket `'end'` event. + * + * @private + */ +function socketOnEnd() { + const websocket = this[kWebSocket]; + + websocket._readyState = WebSocket.CLOSING; + websocket._receiver.end(); + this.end(); +} + +/** + * The listener of the socket `'error'` event. + * + * @private + */ +function socketOnError() { + const websocket = this[kWebSocket]; + + this.removeListener('error', socketOnError); + this.on('error', NOOP); + + if (websocket) { + websocket._readyState = WebSocket.CLOSING; + this.destroy(); + } +} diff --git a/services/edge-broker/node_modules/ws/package.json b/services/edge-broker/node_modules/ws/package.json new file mode 100644 index 00000000..3618050a --- /dev/null +++ b/services/edge-broker/node_modules/ws/package.json @@ -0,0 +1,70 @@ +{ + "name": "ws", + "version": "8.20.0", + "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", + "keywords": [ + "HyBi", + "Push", + "RFC-6455", + "WebSocket", + "WebSockets", + "real-time" + ], + "homepage": "https://github.com/websockets/ws", + "bugs": "https://github.com/websockets/ws/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/websockets/ws.git" + }, + "author": "Einar Otto Stangvik (http://2x.io)", + "license": "MIT", + "main": "index.js", + "exports": { + ".": { + "browser": "./browser.js", + "import": "./wrapper.mjs", + "require": "./index.js" + }, + "./package.json": "./package.json" + }, + "browser": "browser.js", + "engines": { + "node": ">=10.0.0" + }, + "files": [ + "browser.js", + "index.js", + "lib/*.js", + "wrapper.mjs" + ], + "scripts": { + "test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js", + "integration": "mocha --throw-deprecation test/*.integration.js", + "lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\"" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "benchmark": "^2.1.4", + "bufferutil": "^4.0.1", + "eslint": "^10.0.1", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.0.0", + "globals": "^17.0.0", + "mocha": "^8.4.0", + "nyc": "^15.0.0", + "prettier": "^3.0.0", + "utf-8-validate": "^6.0.0" + } +} diff --git a/services/edge-broker/node_modules/ws/wrapper.mjs b/services/edge-broker/node_modules/ws/wrapper.mjs new file mode 100644 index 00000000..a8ffabbb --- /dev/null +++ b/services/edge-broker/node_modules/ws/wrapper.mjs @@ -0,0 +1,21 @@ +import createWebSocketStream from './lib/stream.js'; +import extension from './lib/extension.js'; +import PerMessageDeflate from './lib/permessage-deflate.js'; +import Receiver from './lib/receiver.js'; +import Sender from './lib/sender.js'; +import subprotocol from './lib/subprotocol.js'; +import WebSocket from './lib/websocket.js'; +import WebSocketServer from './lib/websocket-server.js'; + +export { + createWebSocketStream, + extension, + PerMessageDeflate, + Receiver, + Sender, + subprotocol, + WebSocket, + WebSocketServer +}; + +export default WebSocket; diff --git a/services/edge-broker/package-lock.json b/services/edge-broker/package-lock.json new file mode 100644 index 00000000..b9251a1f --- /dev/null +++ b/services/edge-broker/package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "truckwash-edge-broker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "truckwash-edge-broker", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/services/edge-broker/package.json b/services/edge-broker/package.json new file mode 100644 index 00000000..e0294c3d --- /dev/null +++ b/services/edge-broker/package.json @@ -0,0 +1,12 @@ +{ + "name": "truckwash-edge-broker", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "test:live": "node --test live/live-smoke.mjs" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/services/edge-broker/server.mjs b/services/edge-broker/server.mjs new file mode 100644 index 00000000..f600078e --- /dev/null +++ b/services/edge-broker/server.mjs @@ -0,0 +1,1097 @@ +import http from "node:http"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { WebSocketServer } from "ws"; + +const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000; + +function parseJsonBody(req) { + return new Promise((resolve, reject) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk.toString("utf8"); + }); + req.on("end", () => { + try { + resolve(raw === "" ? {} : JSON.parse(raw)); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); +} + +function jsonResponse(res, statusCode, body) { + res.writeHead(statusCode, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function trimTrailingSlash(value) { + return String(value || "").replace(/\/+$/, ""); +} + +async function parseJsonResponse(response) { + const text = await response.text(); + if (text === "") { + return {}; + } + + try { + return JSON.parse(text); + } catch { + return { error: text }; + } +} + +function resolveManagerUrl(options = {}) { + return trimTrailingSlash(options.managerUrl || process.env.EDGE_MANAGER_URL || process.env.EDGE_PUBLIC_API_URL || ""); +} + +function resolveAuthMode(options = {}, managerUrl = "") { + if (options.authMode) { + return options.authMode; + } + if (process.env.EDGE_AUTH_MODE) { + return process.env.EDGE_AUTH_MODE; + } + return "strict"; +} + +function parseScopes(value) { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set( + value + .map((scope) => String(scope || "").trim().toLowerCase()) + .filter(Boolean) + ) + ); +} + +function eventScopes(message) { + switch (message?.type) { + case "gateway.telemetry": + case "presence.changed": + return ["overview", "statistics"]; + case "task.updated": + return ["tasks", "overview"]; + case "log.append": + return ["logs"]; + case "stats.updated": + return ["statistics"]; + default: + return []; + } +} + +function sessionAllowsScopes(sessionRecord, scopes) { + const subscriptions = sessionRecord.subscriptions || new Set(); + if (subscriptions.has("*")) { + return true; + } + if (!scopes || scopes.length === 0) { + return true; + } + return scopes.some((scope) => subscriptions.has(scope)); +} + +function sendJson(ws, payload) { + if (!ws || ws.readyState !== 1) { + return false; + } + + ws.send(JSON.stringify(payload)); + return true; +} + +function currentTimestamp() { + return new Date().toISOString(); +} + +function normalizeErrorMessage(error, fallback = "Connection step failed") { + if (error instanceof Error && error.message) { + return error.message; + } + + const message = String(error || "").trim(); + return message || fallback; +} + +function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) { + const statusText = { + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 500: "Internal Server Error", + 503: "Service Unavailable", + }[statusCode] || "WebSocket Upgrade Rejected"; + const body = JSON.stringify({ + ok: false, + error_code: errorCode, + message, + details, + }); + + socket.end( + [ + `HTTP/1.1 ${statusCode} ${statusText}`, + "content-type: application/json; charset=utf-8", + `content-length: ${Buffer.byteLength(body)}`, + "connection: close", + "", + body, + ].join("\r\n") + ); +} + +export function createBrokerServer(options = {}) { + const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? ""; + const managerUrl = resolveManagerUrl(options); + const authMode = resolveAuthMode(options, managerUrl); + const commandTimeoutMs = options.commandTimeoutMs ?? 10000; + const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS; + + const agents = new Map(); + const pendingCommands = new Map(); + const browserShellSessions = new Map(); + const browserStreamSessions = new Map(); + const gatewayStreamSessions = new Map(); + const inflightGatewaySyncs = new Map(); + + const managerRequest = async (path, body = {}, method = "POST") => { + if (!managerUrl) { + throw new Error("Edge manager URL is not configured"); + } + + const response = await fetch(`${managerUrl}${path}`, { + method, + headers: { + "content-type": "application/json", + ...(sharedSecret ? { "x-edge-broker-secret": sharedSecret } : {}), + }, + body: method === "GET" ? undefined : JSON.stringify(body), + }); + const json = await parseJsonResponse(response); + if (!response.ok) { + const error = new Error(json?.data?.message || json?.message || json?.error || `HTTP ${response.status}`); + error.status = response.status; + error.code = json?.data?.error_code || json?.error_code || null; + error.details = json?.data?.diagnostics || json?.diagnostics || null; + throw error; + } + + return json?.data ?? json; + }; + + const validateAgent = + options.validateAgent || + (authMode === "stub" + ? async ({ gatewayId }) => ({ id: gatewayId, gateway_id: gatewayId, label: `Gateway ${gatewayId}` }) + : async ({ gatewayId, token }) => + managerRequest(`/edge-agent/internal/gateways/${gatewayId}/validate`, { token })); + const validateShellSession = + options.validateShellSession || + (authMode === "stub" + ? async ({ token }) => ({ id: token, gateway_id: 1, reason: "stub", cols: 120, rows: 32 }) + : async ({ token }) => managerRequest("/edge-agent/internal/shell-sessions/validate", { token })); + const markShellSessionOpened = + options.markShellSessionOpened || + (authMode === "stub" + ? async () => ({}) + : async (token, connectionId) => + managerRequest("/edge-agent/internal/shell-sessions/opened", { + token, + connection_id: connectionId, + })); + const closeShellSession = + options.closeShellSession || + (authMode === "stub" + ? async () => ({}) + : async (_id, token, transcript, reason, details = {}) => + managerRequest("/edge-agent/internal/shell-sessions/close", { + token, + transcript, + reason, + ...details, + })); + const validateBrowserStream = + options.validateBrowserStream || + (authMode === "stub" + ? async ({ token }) => ({ + id: token, + gateway_id: 1, + scopes: ["overview", "tasks", "logs", "statistics"], + }) + : async ({ token }) => managerRequest("/edge-agent/internal/browser-streams/validate", { token })); + const reportGatewayPresence = + options.reportGatewayPresence || + (authMode === "stub" + ? async () => ({}) + : async (gatewayId, { status, connectionId, reason = null, metadata = {} } = {}) => + managerRequest(`/edge-agent/internal/gateways/${gatewayId}/presence`, { + status, + connection_id: connectionId, + reason, + metadata, + })); + const requestGatewayBacklog = + options.requestGatewayBacklog || + (authMode === "stub" + ? async () => ({ gateway: {}, dispatch: [] }) + : async (gatewayId, payload = {}) => + managerRequest(`/edge-agent/internal/gateways/${gatewayId}/backlog`, payload)); + const ingestTelemetry = + options.ingestTelemetry || + (authMode === "stub" + ? async (_gatewayId, payload = {}) => payload + : async (gatewayId, payload = {}) => + managerRequest(`/edge-agent/internal/gateways/${gatewayId}/telemetry`, payload)); + const ingestTaskEvent = + options.ingestTaskEvent || + (authMode === "stub" + ? async (_gatewayId, _operationId, payload = {}) => payload + : async (gatewayId, operationId, payload = {}) => + managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/events`, payload)); + const ingestTaskResult = + options.ingestTaskResult || + (authMode === "stub" + ? async (_gatewayId, _operationId, payload = {}) => payload + : async (gatewayId, operationId, payload = {}) => + managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/complete`, payload)); + const ingestLogEntry = + options.ingestLogEntry || + (authMode === "stub" + ? async (_gatewayId, payload = {}) => payload + : async (gatewayId, payload = {}) => + managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload)); + + const broadcastGatewayEvent = (gatewayId, message) => { + const sessionIds = gatewayStreamSessions.get(String(gatewayId)); + if (!sessionIds || sessionIds.size === 0) { + return; + } + + const allowedScopes = eventScopes(message); + for (const sessionId of sessionIds.values()) { + const sessionRecord = browserStreamSessions.get(String(sessionId)); + if (!sessionRecord) { + continue; + } + if (!sessionAllowsScopes(sessionRecord, allowedScopes)) { + continue; + } + + sendJson(sessionRecord.ws, message); + } + }; + + const closeBrowserShellSession = async (sessionRecord, reason, details = {}) => { + try { + await closeShellSession( + sessionRecord.session.id, + sessionRecord.ws.sessionToken, + sessionRecord.transcript, + reason, + { + message: sessionRecord.closedMessage || null, + code: sessionRecord.closedCode ?? null, + stage: sessionRecord.closedStage || null, + broker_connection_id: sessionRecord.agentConnectionId || null, + details: sessionRecord.closedDetails || {}, + ...details, + } + ); + } catch { + // Preserve socket teardown even when the manager callback is unavailable. + } + }; + + const clearShellOpenTimer = (sessionRecord) => { + if (sessionRecord?.openTimer) { + clearTimeout(sessionRecord.openTimer); + sessionRecord.openTimer = null; + } + }; + + const closeBrowserShellSocket = (sessionRecord, reason, message = null, code = 1000, details = {}) => { + sessionRecord.closedReason = reason; + sessionRecord.closedMessage = message || null; + sessionRecord.closedCode = code; + sessionRecord.closedDetails = details && typeof details === "object" ? details : {}; + sessionRecord.closedStage = String(sessionRecord.closedDetails.stage || (sessionRecord.opened ? "shell_active" : "shell_open")); + clearShellOpenTimer(sessionRecord); + if (sessionRecord.ws.readyState >= 2) { + return; + } + + sendJson(sessionRecord.ws, { + type: "closed", + reason, + code, + ...(message ? { message } : {}), + ...(Object.keys(sessionRecord.closedDetails).length ? { details: sessionRecord.closedDetails } : {}), + }); + sessionRecord.ws.close(code, reason); + }; + + const markGatewayShellSessionsClosed = (gatewayId, reason) => { + for (const sessionRecord of browserShellSessions.values()) { + if (String(sessionRecord.session.gateway_id) !== String(gatewayId)) { + continue; + } + + closeBrowserShellSocket(sessionRecord, reason, "Gateway agent disconnected from the broker.", 1011, { + stage: "agent_disconnect", + gateway_id: gatewayId, + shell_session_id: sessionRecord.session.id, + }); + } + }; + + const registerGatewayStreamSession = (sessionRecord) => { + const gatewayId = String(sessionRecord.session.gateway_id); + if (!gatewayStreamSessions.has(gatewayId)) { + gatewayStreamSessions.set(gatewayId, new Set()); + } + gatewayStreamSessions.get(gatewayId).add(String(sessionRecord.session.id)); + browserStreamSessions.set(String(sessionRecord.session.id), sessionRecord); + }; + + const removeGatewayStreamSession = (sessionRecord) => { + browserStreamSessions.delete(String(sessionRecord.session.id)); + const gatewayId = String(sessionRecord.session.gateway_id); + const sessionIds = gatewayStreamSessions.get(gatewayId); + if (!sessionIds) { + return; + } + sessionIds.delete(String(sessionRecord.session.id)); + if (sessionIds.size === 0) { + gatewayStreamSessions.delete(gatewayId); + } + }; + + const syncGatewayBacklog = async (gatewayId, explicitAgent = null) => { + const normalizedGatewayId = String(gatewayId); + const agent = explicitAgent || agents.get(normalizedGatewayId); + if (!agent || agent.readyState !== 1) { + return { queued: false }; + } + + if (inflightGatewaySyncs.has(normalizedGatewayId)) { + return inflightGatewaySyncs.get(normalizedGatewayId); + } + + const syncPromise = (async () => { + const backlog = await requestGatewayBacklog(normalizedGatewayId, { + agent_instance_id: agent.agentInstanceId || null, + }); + const dispatch = Array.isArray(backlog?.dispatch) ? backlog.dispatch : []; + for (const instruction of dispatch) { + sendJson(agent, instruction); + } + return { + queued: dispatch.length > 0, + dispatch, + }; + })().finally(() => { + inflightGatewaySyncs.delete(normalizedGatewayId); + }); + + inflightGatewaySyncs.set(normalizedGatewayId, syncPromise); + return syncPromise; + }; + + const sendAgentWelcome = (ws) => + sendJson(ws, { + type: "WELCOME", + target: "edge-agent", + gatewayId: String(ws.gatewayId || ""), + connectionId: ws.connectionId || null, + agentInstanceId: ws.agentInstanceId || null, + serverTime: currentTimestamp(), + broker: { + authMode, + }, + }); + + const sendAgentConnectionProgress = (ws, stage, status, message, details = {}) => + sendJson(ws, { + type: "CONNECTION_PROGRESS", + gatewayId: String(ws.gatewayId || ""), + connectionId: ws.connectionId || null, + stage, + status, + message, + ...details, + serverTime: currentTimestamp(), + }); + + const sendAgentConnectionError = (ws, stage, error, details = {}) => + sendJson(ws, { + type: "CONNECTION_ERROR", + gatewayId: String(ws.gatewayId || ""), + connectionId: ws.connectionId || null, + stage, + status: "failed", + message: normalizeErrorMessage(error), + error: normalizeErrorMessage(error), + ...details, + serverTime: currentTimestamp(), + }); + + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + if (req.method === "GET" && url.pathname === "/api/health") { + jsonResponse(res, 200, { + ok: true, + service: "edge-broker", + auth_mode: authMode, + manager_url_configured: Boolean(managerUrl), + shared_secret_configured: Boolean(sharedSecret), + agents_connected: agents.size, + }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") { + if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) { + jsonResponse(res, 403, { + ok: false, + error: "Forbidden", + shared_secret_required: true, + }); + return; + } + + jsonResponse(res, 200, { + ok: true, + shared_secret_required: Boolean(sharedSecret), + }); + return; + } + + if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) { + if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) { + jsonResponse(res, 403, { error: "Forbidden" }); + return; + } + + const gatewayId = url.pathname.split("/")[3]; + const agent = agents.get(String(gatewayId)); + if (!agent || agent.readyState !== 1) { + jsonResponse(res, 503, { error: "Gateway agent is offline" }); + return; + } + + const body = await parseJsonBody(req); + const commandId = randomUUID(); + const promise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingCommands.delete(commandId); + reject(new Error("Agent command timed out")); + }, commandTimeoutMs); + pendingCommands.set(commandId, { + resolve, + reject, + timeout, + }); + }); + + sendJson(agent, { + type: "COMMAND", + commandId, + commandType: body.commandType, + payload: body.payload || {}, + jobId: body.jobId ?? null, + }); + + try { + const result = await promise; + jsonResponse(res, 200, result); + } catch (error) { + jsonResponse(res, 504, { ok: false, error: error instanceof Error ? error.message : String(error) }); + } + return; + } + + if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) { + if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) { + jsonResponse(res, 403, { error: "Forbidden" }); + return; + } + + const gatewayId = url.pathname.split("/")[3]; + const result = await syncGatewayBacklog(gatewayId); + jsonResponse(res, 200, { ok: true, ...result }); + return; + } + + jsonResponse(res, 404, { error: "Not found" }); + } catch (error) { + jsonResponse(res, 500, { error: error instanceof Error ? error.message : String(error) }); + } + }); + + const wss = new WebSocketServer({ noServer: true }); + server.on("upgrade", async (req, socket, head) => { + const url = new URL(req.url, "http://localhost"); + try { + if (url.pathname === "/ws/agent") { + const gatewayId = String(url.searchParams.get("gatewayId") || ""); + const token = String(url.searchParams.get("token") || ""); + const agentInstanceId = String(url.searchParams.get("agentInstanceId") || ""); + if (gatewayId === "" || token === "") { + socket.destroy(); + return; + } + + let gatewayInfo; + try { + gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers }); + } catch (error) { + const status = Number(error?.status) === 403 ? 403 : Number(error?.status) === 401 ? 401 : 503; + rejectUpgrade(socket, status, error?.code || "agent_validation_failed", normalizeErrorMessage(error, "Gateway agent could not be validated."), { + stage: "agent_validate", + }); + return; + } + + + wss.handleUpgrade(req, socket, head, (ws) => { + const existing = agents.get(gatewayId); + if (existing && existing.readyState < 2) { + existing.close(); + } + + ws.gatewayId = gatewayId; + ws.gatewayInfo = gatewayInfo; + ws.agentInstanceId = agentInstanceId || null; + ws.connectionId = randomUUID(); + agents.set(gatewayId, ws); + wss.emit("connection", ws, req); + sendAgentWelcome(ws); + sendAgentConnectionProgress(ws, "presence", "started", "Reporting broker presence to the edge manager."); + reportGatewayPresence(gatewayId, { + status: "connected", + connectionId: ws.connectionId, + metadata: { + remote_address: req.socket.remoteAddress || null, + agent_instance_id: ws.agentInstanceId, + }, + }) + .then(() => { + sendAgentConnectionProgress(ws, "presence", "succeeded", "Broker presence was reported."); + }) + .catch((error) => { + sendAgentConnectionError(ws, "presence", error); + }); + broadcastGatewayEvent(gatewayId, { + type: "presence.changed", + gatewayId, + status: "connected", + connectionId: ws.connectionId, + }); + sendAgentConnectionProgress(ws, "backlog", "started", "Synchronizing queued gateway work."); + syncGatewayBacklog(gatewayId, ws) + .then((result) => { + sendAgentConnectionProgress(ws, "backlog", "succeeded", "Queued gateway work was synchronized.", { + queued: Boolean(result?.queued), + dispatchCount: Array.isArray(result?.dispatch) ? result.dispatch.length : 0, + }); + }) + .catch((error) => { + sendAgentConnectionError(ws, "backlog", error); + }); + }); + return; + } + + if (url.pathname === "/ws/browser-shell") { + const token = String(url.searchParams.get("token") || ""); + if (token === "") { + rejectUpgrade(socket, 400, "shell_session_token_missing", "Missing shell session token."); + return; + } + + let session; + try { + session = await validateShellSession({ token, headers: req.headers }); + } catch (error) { + rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", normalizeErrorMessage(error, "Shell session could not be validated."), { + stage: "shell_session_validate", + }); + return; + } + + + wss.handleUpgrade(req, socket, head, (ws) => { + ws.sessionToken = token; + ws.sessionInfo = session; + const sessionRecord = { + ws, + session, + transcript: "", + closedReason: null, + closedMessage: null, + closedCode: null, + closedDetails: {}, + closedStage: null, + opened: false, + openTimer: null, + agentConnectionId: null, + }; + browserShellSessions.set(String(session.id), sessionRecord); + wss.emit("connection", ws, req); + + const agent = agents.get(String(session.gateway_id)); + if (agent && agent.readyState === 1) { + sessionRecord.agentConnectionId = agent.connectionId || null; + sendJson(agent, { + type: "OPEN_ROOT_SHELL", + payload: { + sessionId: String(session.id), + reason: session.reason, + cols: session.cols ?? session.metadata?.cols ?? null, + rows: session.rows ?? session.metadata?.rows ?? null, + cwd: session.cwd ?? session.metadata?.cwd ?? null, + shellCommand: session.shell_command ?? session.metadata?.shell_command ?? null, + shellArgs: session.shell_args ?? session.metadata?.shell_args ?? [], + }, + }); + sessionRecord.openTimer = setTimeout(() => { + closeBrowserShellSocket( + sessionRecord, + "shell_open_timeout", + "Gateway agent did not confirm that the shell opened before the broker timeout.", + 1011, + { + stage: "shell_open", + timeout_ms: shellOpenTimeoutMs, + gateway_id: session.gateway_id, + shell_session_id: session.id, + } + ); + }, Math.max(250, shellOpenTimeoutMs)); + sessionRecord.openTimer.unref?.(); + } else { + closeBrowserShellSocket( + sessionRecord, + "agent_offline", + "Gateway agent is not connected to the broker.", + 1011, + { + stage: "agent_lookup", + gateway_id: session.gateway_id, + shell_session_id: session.id, + } + ); + } + }); + return; + } + + if (url.pathname === "/ws/browser-gateway-stream") { + const token = String(url.searchParams.get("token") || ""); + if (token === "") { + socket.destroy(); + return; + } + + const session = await validateBrowserStream({ token, headers: req.headers }); + wss.handleUpgrade(req, socket, head, (ws) => { + ws.sessionToken = token; + ws.streamSessionInfo = session; + const sessionRecord = { + ws, + session, + subscriptions: new Set(parseScopes(session.scopes || ["overview", "tasks", "logs", "statistics"])), + }; + registerGatewayStreamSession(sessionRecord); + sendJson(ws, { + type: "gateway.stream.ready", + gatewayId: String(session.gateway_id), + subscriptions: Array.from(sessionRecord.subscriptions.values()), + connected: Boolean(agents.get(String(session.gateway_id))?.readyState === 1), + }); + wss.emit("connection", ws, req); + }); + return; + } + } catch (error) { + rejectUpgrade(socket, 500, "websocket_upgrade_failed", normalizeErrorMessage(error, "WebSocket upgrade failed.")); + return; + } + + socket.destroy(); + }); + + wss.on("connection", (ws) => { + ws.on("message", async (raw) => { + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + return; + } + + try { + if (ws.gatewayId) { + if (message.type === "COMMAND_RESULT") { + const pending = pendingCommands.get(message.commandId); + if (!pending) { + return; + } + clearTimeout(pending.timeout); + pendingCommands.delete(message.commandId); + pending.resolve({ + ok: Boolean(message.ok), + payload: message.payload, + error: message.error, + }); + return; + } + + if (message.type === "TELEMETRY") { + const payload = { + ...(message.payload || {}), + broker_connection_id: ws.connectionId || null, + broker_agent_instance_id: ws.agentInstanceId || null, + }; + let ingested = null; + let ingestError = null; + try { + ingested = await ingestTelemetry(String(ws.gatewayId), payload); + } catch (error) { + ingestError = error instanceof Error ? error.message : String(error); + } + const fallbackStatistics = { + system_metrics: payload?.metadata?.system_metrics || {}, + container_health: payload?.metadata?.container_health || {}, + }; + broadcastGatewayEvent(String(ws.gatewayId), { + type: "gateway.telemetry", + gatewayId: String(ws.gatewayId), + telemetry: payload, + gateway: ingested?.gateway || ingested || null, + error: ingestError, + }); + broadcastGatewayEvent(String(ws.gatewayId), { + type: "stats.updated", + gatewayId: String(ws.gatewayId), + statistics: ingested?.statistics || ingested || fallbackStatistics, + error: ingestError, + }); + return; + } + + if (message.type === "TASK_EVENT") { + const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0); + if (!Number.isFinite(operationId) || operationId <= 0) { + return; + } + + const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {}); + broadcastGatewayEvent(String(ws.gatewayId), { + type: "task.updated", + gatewayId: String(ws.gatewayId), + operationId, + operation, + }); + return; + } + + if (message.type === "TASK_RESULT") { + const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0); + if (!Number.isFinite(operationId) || operationId <= 0) { + return; + } + + const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {}); + broadcastGatewayEvent(String(ws.gatewayId), { + type: "task.updated", + gatewayId: String(ws.gatewayId), + operationId, + operation, + }); + await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {}); + return; + } + + if (message.type === "LOG_FRAME") { + const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {}); + broadcastGatewayEvent(String(ws.gatewayId), { + type: "log.append", + gatewayId: String(ws.gatewayId), + entry: logEntry, + }); + return; + } + + if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) { + const sessionRecord = browserShellSessions.get(String(message.sessionId)); + if (!sessionRecord) { + return; + } + + if (message.type === "SHELL_OUTPUT") { + sessionRecord.transcript += String(message.data || ""); + sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") }); + return; + } + + if (message.type === "SHELL_OPENED") { + sessionRecord.opened = true; + sessionRecord.agentConnectionId = ws.connectionId || sessionRecord.agentConnectionId || null; + clearShellOpenTimer(sessionRecord); + await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {}); + sendJson(sessionRecord.ws, { type: "opened" }); + return; + } + + if (message.type === "SHELL_EXIT") { + const reason = String(message.reason || "agent_exit"); + const parsedExitCode = Number(message.code); + const exitCode = Number.isFinite(parsedExitCode) ? parsedExitCode : 0; + const closeMessage = message.message ? String(message.message) : null; + clearShellOpenTimer(sessionRecord); + sendJson(sessionRecord.ws, { + type: "closed", + reason, + code: exitCode, + ...(closeMessage ? { message: closeMessage } : {}), + }); + await closeBrowserShellSession(sessionRecord, reason, { + message: closeMessage, + code: exitCode, + stage: sessionRecord.opened ? "shell_active" : "shell_start", + broker_connection_id: ws.connectionId || null, + }); + browserShellSessions.delete(String(message.sessionId)); + if (sessionRecord.ws.readyState < 2) { + sessionRecord.ws.close(1000, reason); + } + } + } + return; + } + + if (ws.sessionInfo) { + const sessionId = String(ws.sessionInfo.id); + const agent = agents.get(String(ws.sessionInfo.gateway_id)); + if (!agent || agent.readyState !== 1) { + return; + } + if (message.type === "input") { + sendJson(agent, { + type: "SHELL_INPUT", + payload: { + sessionId, + data: String(message.data || ""), + }, + }); + return; + } + if (message.type === "resize") { + sendJson(agent, { + type: "RESIZE_ROOT_SHELL", + payload: { + sessionId, + cols: Number(message.cols || 0), + rows: Number(message.rows || 0), + }, + }); + return; + } + if (message.type === "close") { + sendJson(agent, { + type: "CLOSE_ROOT_SHELL", + payload: { sessionId }, + }); + } + return; + } + + if (ws.streamSessionInfo) { + const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id)); + if (!sessionRecord) { + return; + } + + if (message.type === "SUBSCRIBE") { + for (const scope of parseScopes(message.scopes || message.subscriptions || [])) { + sessionRecord.subscriptions.add(scope); + } + sendJson(sessionRecord.ws, { + type: "subscribed", + subscriptions: Array.from(sessionRecord.subscriptions.values()), + }); + return; + } + + if (message.type === "UNSUBSCRIBE") { + for (const scope of parseScopes(message.scopes || message.subscriptions || [])) { + sessionRecord.subscriptions.delete(scope); + } + sendJson(sessionRecord.ws, { + type: "unsubscribed", + subscriptions: Array.from(sessionRecord.subscriptions.values()), + }); + return; + } + + if (message.type === "PING") { + sendJson(sessionRecord.ws, { type: "PONG" }); + } + } + } catch { + // Ignore stale gateway/session delivery errors without killing the broker process. + } + }); + + ws.on("close", async (code, buffer) => { + const closeReason = buffer?.toString?.("utf8") || null; + + if (ws.gatewayId) { + if (agents.get(String(ws.gatewayId)) === ws) { + agents.delete(String(ws.gatewayId)); + } + markGatewayShellSessionsClosed(String(ws.gatewayId), "agent_disconnected"); + broadcastGatewayEvent(String(ws.gatewayId), { + type: "presence.changed", + gatewayId: String(ws.gatewayId), + status: "disconnected", + reason: closeReason || "agent_disconnected", + }); + reportGatewayPresence(String(ws.gatewayId), { + status: "disconnected", + connectionId: ws.connectionId || null, + reason: closeReason || "agent_disconnected", + metadata: { + agent_instance_id: ws.agentInstanceId || null, + }, + }).catch(() => {}); + return; + } + + if (ws.sessionInfo) { + const sessionId = String(ws.sessionInfo.id); + const agent = agents.get(String(ws.sessionInfo.gateway_id)); + if (agent && agent.readyState === 1) { + sendJson(agent, { + type: "CLOSE_ROOT_SHELL", + payload: { sessionId }, + }); + } + const sessionRecord = browserShellSessions.get(sessionId); + if (sessionRecord) { + await closeBrowserShellSession(sessionRecord, sessionRecord.closedReason || "browser_closed", { + code, + stage: sessionRecord.closedStage || "browser_socket_close", + message: sessionRecord.closedMessage || null, + }); + browserShellSessions.delete(sessionId); + } + return; + } + + if (ws.streamSessionInfo) { + const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id)); + if (sessionRecord) { + removeGatewayStreamSession(sessionRecord); + } + } + }); + }); + + return { + server, + listen(port = Number(process.env.PORT || 4300)) { + return new Promise((resolve) => { + server.listen(port, () => resolve(server.address())); + }); + }, + close() { + return new Promise((resolve, reject) => { + for (const agent of agents.values()) { + agent.terminate(); + } + for (const session of browserShellSessions.values()) { + session.ws.terminate(); + } + for (const session of browserStreamSessions.values()) { + session.ws.terminate(); + } + for (const pending of pendingCommands.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("Broker shutting down")); + } + pendingCommands.clear(); + + wss.close(() => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }); + }, + state: { + agents, + browserShellSessions, + browserStreamSessions, + gatewayStreamSessions, + pendingCommands, + managerUrl, + authMode, + }, + }; +} + +async function runBrokerFromCli() { + const broker = createBrokerServer(); + let shuttingDown = false; + const shutdown = async (signal) => { + if (shuttingDown) { + return; + } + shuttingDown = true; + try { + await broker.close(); + process.exit(0); + } catch (error) { + console.error(`Failed to shut down broker after ${signal}:`, error); + process.exit(1); + } + }; + + process.on("SIGINT", () => { + void shutdown("SIGINT"); + }); + process.on("SIGTERM", () => { + void shutdown("SIGTERM"); + }); + + const requestedPort = Number(process.env.PORT || 4300); + const address = await broker.listen(Number.isFinite(requestedPort) ? requestedPort : 4300); + const normalizedPort = + typeof address === "object" && address !== null && "port" in address + ? address.port + : requestedPort; + console.log(`TruckWash edge broker listening on ${normalizedPort}`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + runBrokerFromCli().catch((error) => { + console.error("TruckWash edge broker failed to start:", error); + process.exit(1); + }); +} diff --git a/services/edge-broker/test/broker.test.mjs b/services/edge-broker/test/broker.test.mjs new file mode 100644 index 00000000..1f539b69 --- /dev/null +++ b/services/edge-broker/test/broker.test.mjs @@ -0,0 +1,781 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import net from "node:net"; +import WebSocket from "ws"; + +import { createBrokerServer } from "../server.mjs"; + +function collectMessages(socket) { + const messages = []; + socket.on("message", (raw) => { + messages.push(JSON.parse(raw.toString())); + }); + return messages; +} + +function waitForClose(socket) { + return new Promise((resolve) => { + socket.once("close", resolve); + }); +} + +function waitForCloseOrError(socket) { + return new Promise((resolve) => { + const onDone = () => { + socket.off("error", onDone); + socket.off("close", onDone); + resolve(); + }; + socket.once("error", onDone); + socket.once("close", onDone); + }); +} + +function rawUpgradeRequest(port, path) { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host: "127.0.0.1", port }, () => { + socket.write( + [ + `GET ${path} HTTP/1.1`, + `Host: 127.0.0.1:${port}`, + "Connection: Upgrade", + "Upgrade: websocket", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13", + "", + "", + ].join("\r\n") + ); + }); + let response = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + response += chunk; + }); + socket.on("end", () => resolve(response)); + socket.on("error", reject); + }); +} + +async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (predicate()) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`Timed out waiting for ${description}`); +} + +test("broker defaults to strict auth and fails closed when manager URL is missing", async () => { + const previousEnv = { + EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE, + EDGE_MANAGER_URL: process.env.EDGE_MANAGER_URL, + EDGE_PUBLIC_API_URL: process.env.EDGE_PUBLIC_API_URL, + }; + delete process.env.EDGE_AUTH_MODE; + delete process.env.EDGE_MANAGER_URL; + delete process.env.EDGE_PUBLIC_API_URL; + + let broker; + try { + broker = createBrokerServer({ sharedSecret: "secret" }); + assert.equal(broker.state.authMode, "strict"); + assert.equal(broker.state.managerUrl, ""); + + const address = await broker.listen(0); + const port = address.port; + const shellResponse = await rawUpgradeRequest(port, "/ws/browser-shell?token=session-token"); + const agentResponse = await rawUpgradeRequest(port, "/ws/agent?gatewayId=701&token=agent-token"); + + assert.doesNotMatch(shellResponse, /101 Switching Protocols/); + assert.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m); + assert.match(shellResponse, /"error_code":"shell_session_invalid"/); + assert.match(shellResponse, /Edge manager URL is not configured/); + + assert.doesNotMatch(agentResponse, /101 Switching Protocols/); + assert.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m); + assert.match(agentResponse, /"error_code":"agent_validation_failed"/); + assert.match(agentResponse, /"stage":"agent_validate"/); + assert.match(agentResponse, /Edge manager URL is not configured/); + } finally { + if (broker) { + await broker.close(); + } + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +}); + +test("broker dispatches commands to connected agents", async () => { + const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 }); + const address = await broker.listen(0); + const port = address.port; + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + + await new Promise((resolve) => agent.once("open", resolve)); + agent.on("message", (raw) => { + const message = JSON.parse(raw.toString()); + if (message.type === "COMMAND") { + agent.send(JSON.stringify({ + type: "COMMAND_RESULT", + commandId: message.commandId, + ok: true, + payload: { online: true, on: true }, + })); + } + }); + + const response = await fetch(`http://127.0.0.1:${port}/api/gateways/701/commands`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-edge-broker-secret": "secret", + }, + body: JSON.stringify({ + commandType: "GET_RELAY_STATUS", + payload: { relayId: "M-7" }, + }), + }); + const json = await response.json(); + + assert.equal(response.status, 200); + assert.equal(json.ok, true); + assert.equal(json.payload.on, true); + + agent.terminate(); + await broker.close(); +}); + +test("broker exposes health and shared-secret diagnostics", async () => { + const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" }); + const address = await broker.listen(0); + const port = address.port; + + const healthResponse = await fetch(`http://127.0.0.1:${port}/api/health`); + const healthJson = await healthResponse.json(); + + assert.equal(healthResponse.status, 200); + assert.equal(healthJson.ok, true); + assert.equal(healthJson.service, "edge-broker"); + assert.equal(healthJson.auth_mode, "manager"); + assert.equal(healthJson.manager_url_configured, true); + assert.equal(healthJson.shared_secret_configured, true); + + const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, { + method: "POST", + headers: { + "x-edge-broker-secret": "wrong-secret", + }, + }); + const invalidSecretJson = await invalidSecretResponse.json(); + + assert.equal(invalidSecretResponse.status, 403); + assert.equal(invalidSecretJson.ok, false); + assert.equal(invalidSecretJson.shared_secret_required, true); + + const validSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, { + method: "POST", + headers: { + "x-edge-broker-secret": "secret", + }, + }); + const validSecretJson = await validSecretResponse.json(); + + assert.equal(validSecretResponse.status, 200); + assert.equal(validSecretJson.ok, true); + assert.equal(validSecretJson.shared_secret_required, true); + + await broker.close(); +}); + +test("broker bridges browser shell sessions through the connected agent", async () => { + const closedSessions = []; + const broker = createBrokerServer({ + authMode: "stub", + validateShellSession: async () => ({ id: "shell-1", gateway_id: "701", reason: "diagnostic" }), + closeShellSession: async (_id, _token, transcript, reason) => { + closedSessions.push({ transcript, reason }); + }, + }); + const address = await broker.listen(0); + const port = address.port; + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + await new Promise((resolve) => agent.once("open", resolve)); + + agent.on("message", (raw) => { + const message = JSON.parse(raw.toString()); + if (message.type === "OPEN_ROOT_SHELL") { + agent.send(JSON.stringify({ type: "SHELL_OPENED", sessionId: "shell-1" })); + agent.send(JSON.stringify({ type: "SHELL_OUTPUT", sessionId: "shell-1", data: "root@pi:~# " })); + agent.send(JSON.stringify({ type: "SHELL_EXIT", sessionId: "shell-1", code: 0 })); + } + }); + + const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`); + const browserMessages = collectMessages(browser); + await new Promise((resolve) => browser.once("open", resolve)); + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.ok(browserMessages.some((message) => message.type === "opened")); + assert.ok(browserMessages.some((message) => message.type === "output" && /root@pi/.test(message.data))); + assert.ok(browserMessages.some((message) => message.type === "closed" && message.reason === "agent_exit" && message.code === 0)); + assert.equal(closedSessions.length, 1); + assert.equal(closedSessions[0].reason, "agent_exit"); + + browser.terminate(); + agent.terminate(); + await broker.close(); +}); + +test("broker forwards browser shell input, resize, and close events to the agent", async () => { + const broker = createBrokerServer({ + authMode: "stub", + validateShellSession: async () => ({ id: "shell-2", gateway_id: "701", reason: "diagnostic" }), + }); + const address = await broker.listen(0); + const port = address.port; + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + await new Promise((resolve) => agent.once("open", resolve)); + + const agentMessages = collectMessages(agent); + const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`); + await new Promise((resolve) => browser.once("open", resolve)); + await waitFor( + () => agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-2"), + { description: "agent shell open request" } + ); + + browser.send(JSON.stringify({ type: "input", data: "ls\r" })); + browser.send(JSON.stringify({ type: "resize", cols: 140, rows: 44 })); + browser.send(JSON.stringify({ type: "close" })); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-2")); + assert.ok( + agentMessages.some( + (message) => + message.type === "SHELL_INPUT" && + message.payload.sessionId === "shell-2" && + message.payload.data === "ls\r" + ) + ); + assert.ok( + agentMessages.some( + (message) => + message.type === "RESIZE_ROOT_SHELL" && + message.payload.sessionId === "shell-2" && + message.payload.cols === 140 && + message.payload.rows === 44 + ) + ); + assert.ok(agentMessages.some((message) => message.type === "CLOSE_ROOT_SHELL" && message.payload.sessionId === "shell-2")); + + browser.terminate(); + agent.terminate(); + await broker.close(); +}); + +test("broker closes browser shell sessions immediately when no agent is connected", async () => { + const closedSessions = []; + const broker = createBrokerServer({ + authMode: "stub", + validateShellSession: async () => ({ id: "shell-3", gateway_id: "701", reason: "diagnostic" }), + closeShellSession: async (_id, _token, transcript, reason) => { + closedSessions.push({ transcript, reason }); + }, + }); + const address = await broker.listen(0); + const port = address.port; + + const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`); + const browserMessages = collectMessages(browser); + await new Promise((resolve) => browser.once("open", resolve)); + await waitForClose(browser); + await waitFor(() => closedSessions.length === 1, { description: "offline shell session close callback" }); + + assert.ok( + browserMessages.some( + (message) => + message.type === "closed" && + message.reason === "agent_offline" && + /not connected to the broker/i.test(message.message) + ) + ); + assert.deepEqual(closedSessions, [{ transcript: "", reason: "agent_offline" }]); + + await broker.close(); +}); + +test("broker rejects browser shell upgrades without a token using HTTP diagnostics", async () => { + const broker = createBrokerServer({ authMode: "stub" }); + const address = await broker.listen(0); + const port = address.port; + + const response = await rawUpgradeRequest(port, "/ws/browser-shell"); + + assert.match(response, /^HTTP\/1\.1 400 Bad Request/m); + assert.match(response, /"error_code":"shell_session_token_missing"/); + assert.match(response, /"message":"Missing shell session token\."/); + + await broker.close(); +}); + +test("broker rejects invalid browser shell upgrades without leaking the token", async () => { + const broker = createBrokerServer({ + authMode: "stub", + validateShellSession: async () => { + const error = new Error("Shell session expired"); + error.status = 401; + error.code = "shell_session_expired"; + throw error; + }, + }); + const address = await broker.listen(0); + const port = address.port; + const rawToken = "session-token-secret"; + + const response = await rawUpgradeRequest(port, `/ws/browser-shell?token=${rawToken}`); + + assert.match(response, /^HTTP\/1\.1 401 Unauthorized/m); + assert.match(response, /"error_code":"shell_session_expired"/); + assert.doesNotMatch(response, new RegExp(rawToken)); + + await broker.close(); +}); + +test("broker closes browser shell sessions when the agent never reports shell opened", async () => { + const closedSessions = []; + const broker = createBrokerServer({ + authMode: "stub", + shellOpenTimeoutMs: 30, + validateShellSession: async () => ({ id: "shell-timeout", gateway_id: "701", reason: "diagnostic" }), + closeShellSession: async (_id, _token, transcript, reason, details) => { + closedSessions.push({ transcript, reason, details }); + }, + }); + const address = await broker.listen(0); + const port = address.port; + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + const agentMessages = collectMessages(agent); + await new Promise((resolve) => agent.once("open", resolve)); + + const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`); + const browserMessages = collectMessages(browser); + await new Promise((resolve) => browser.once("open", resolve)); + + await waitFor( + () => + agentMessages.some( + (message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-timeout" + ), + { description: "agent shell open request before timeout" } + ); + await waitForClose(browser); + await waitFor(() => closedSessions.length === 1, { description: "timeout shell session close callback" }); + + assert.ok(browserMessages.some((message) => message.type === "closed" && message.reason === "shell_open_timeout")); + assert.equal(closedSessions[0].reason, "shell_open_timeout"); + assert.equal(closedSessions[0].details.stage, "shell_open"); + assert.equal(closedSessions[0].details.code, 1011); + + agent.terminate(); + await broker.close(); +}); + +test("broker closes browser shell sessions when the agent disconnects before shell open", async () => { + const closedSessions = []; + const broker = createBrokerServer({ + authMode: "stub", + validateShellSession: async () => ({ id: "shell-4", gateway_id: "701", reason: "diagnostic" }), + closeShellSession: async (_id, _token, transcript, reason) => { + closedSessions.push({ transcript, reason }); + }, + }); + const address = await broker.listen(0); + const port = address.port; + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + await new Promise((resolve) => agent.once("open", resolve)); + + const agentMessages = collectMessages(agent); + const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`); + const browserMessages = collectMessages(browser); + await new Promise((resolve) => browser.once("open", resolve)); + await waitFor( + () => agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-4"), + { description: "agent shell open request before disconnect" } + ); + + agent.terminate(); + await waitForClose(browser); + await waitFor(() => closedSessions.length === 1, { description: "disconnect shell session close callback" }); + + assert.ok(agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-4")); + assert.ok(browserMessages.some((message) => message.type === "closed" && message.reason === "agent_disconnected")); + assert.deepEqual(closedSessions, [{ transcript: "", reason: "agent_disconnected" }]); + + await broker.close(); +}); + +test("broker defaults to strict auth when no validators are configured", async () => { + const broker = createBrokerServer(); + const address = await broker.listen(0); + const port = address.port; + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + await waitForCloseOrError(agent); + + await broker.close(); +}); + +test("broker sends an agent welcome before connection progress and backlog dispatch", async () => { + const broker = createBrokerServer({ + authMode: "stub", + reportGatewayPresence: async () => ({}), + requestGatewayBacklog: async () => ({ + dispatch: [ + { + type: "TASK_DISPATCH", + taskType: "OPERATION", + operation: { + id: 91, + type: "DISCOVERY", + request: {}, + }, + }, + ], + }), + }); + const address = await broker.listen(0); + const port = address.port; + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + const messages = collectMessages(agent); + await new Promise((resolve) => agent.once("open", resolve)); + + await waitFor( + () => messages.some((message) => message.type === "TASK_DISPATCH" && message.operation?.id === 91), + { description: "backlog dispatch after connection welcome" } + ); + + assert.equal(messages[0].type, "WELCOME"); + assert.equal(messages[0].gatewayId, "701"); + assert.equal(typeof messages[0].connectionId, "string"); + assert.ok(messages[0].connectionId.length > 0); + + const firstProgressIndex = messages.findIndex((message) => message.type === "CONNECTION_PROGRESS"); + const dispatchIndex = messages.findIndex((message) => message.type === "TASK_DISPATCH"); + assert.ok(firstProgressIndex > 0); + assert.ok(dispatchIndex > firstProgressIndex); + assert.ok( + messages.some( + (message) => + message.type === "CONNECTION_PROGRESS" && + message.stage === "presence" && + message.status === "started" + ) + ); + assert.ok( + messages.some( + (message) => + message.type === "CONNECTION_PROGRESS" && + message.stage === "backlog" && + message.status === "started" + ) + ); + assert.ok( + messages.some( + (message) => + message.type === "CONNECTION_PROGRESS" && + message.stage === "backlog" && + message.status === "succeeded" && + message.dispatchCount === 1 + ) + ); + + agent.terminate(); + await broker.close(); +}); + +test("broker sends connection errors to agents after the welcome message", async () => { + const broker = createBrokerServer({ + authMode: "stub", + reportGatewayPresence: async () => { + throw new Error("presence callback unavailable"); + }, + requestGatewayBacklog: async () => { + throw new Error("backlog sync unavailable"); + }, + }); + const address = await broker.listen(0); + const port = address.port; + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + const messages = collectMessages(agent); + await new Promise((resolve) => agent.once("open", resolve)); + + await waitFor( + () => messages.filter((message) => message.type === "CONNECTION_ERROR").length >= 2, + { description: "agent connection error notifications" } + ); + + assert.equal(messages[0].type, "WELCOME"); + assert.ok( + messages.some( + (message) => + message.type === "CONNECTION_ERROR" && + message.stage === "presence" && + message.error === "presence callback unavailable" + ) + ); + assert.ok( + messages.some( + (message) => + message.type === "CONNECTION_ERROR" && + message.stage === "backlog" && + message.error === "backlog sync unavailable" + ) + ); + + agent.terminate(); + await broker.close(); +}); + +test("broker syncs queued gateway backlog on agent connect and manual sync", async () => { + const backlogRequests = []; + const broker = createBrokerServer({ + authMode: "stub", + sharedSecret: "secret", + requestGatewayBacklog: async (gatewayId, payload) => { + backlogRequests.push({ gatewayId, payload }); + return { + dispatch: [ + { + type: "TASK_DISPATCH", + taskType: "OPERATION", + operation: { + id: 91, + type: "DISCOVERY", + request: {}, + }, + }, + ], + }; + }, + }); + const address = await broker.listen(0); + const port = address.port; + + const agent = new WebSocket( + `ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token&agentInstanceId=instance-1` + ); + const messages = collectMessages(agent); + await new Promise((resolve) => agent.once("open", resolve)); + + await waitFor( + () => messages.some((message) => message.type === "TASK_DISPATCH" && message.operation?.id === 91), + { description: "initial backlog dispatch" } + ); + assert.equal(backlogRequests.length, 1); + assert.equal(backlogRequests[0].gatewayId, "701"); + assert.equal(backlogRequests[0].payload.agent_instance_id, "instance-1"); + + const response = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-edge-broker-secret": "secret", + }, + body: JSON.stringify({ gatewayId: 701 }), + }); + const json = await response.json(); + + assert.equal(response.status, 200); + assert.equal(json.ok, true); + await waitFor(() => backlogRequests.length >= 2, { description: "manual sync backlog request" }); + + agent.terminate(); + await broker.close(); +}); + +test("broker fans out telemetry, task, log, and presence updates to browser gateway streams", async () => { + const telemetryPayloads = []; + const broker = createBrokerServer({ + authMode: "stub", + validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }), + validateBrowserStream: async () => ({ + id: "stream-1", + gateway_id: "701", + scopes: ["overview", "tasks", "logs", "statistics"], + }), + ingestTelemetry: async (_gatewayId, payload) => { + telemetryPayloads.push(payload); + return { gateway: { id: 701, metadata: payload.metadata || {} } }; + }, + ingestTaskEvent: async (_gatewayId, operationId, payload) => ({ + id: operationId, + status: "IN_PROGRESS", + latest_event: payload, + }), + ingestLogEntry: async (_gatewayId, payload) => ({ + id: 5001, + ...payload, + }), + }); + const address = await broker.listen(0); + const port = address.port; + + const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-gateway-stream?token=stream-token`); + const browserMessages = collectMessages(browser); + await new Promise((resolve) => browser.once("open", resolve)); + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + await new Promise((resolve) => agent.once("open", resolve)); + + agent.send( + JSON.stringify({ + type: "TELEMETRY", + payload: { + status: "ONLINE", + metadata: { + broker_connected: true, + }, + }, + }) + ); + agent.send( + JSON.stringify({ + type: "TASK_EVENT", + operationId: 41, + payload: { + level: "INFO", + code: "DISCOVERY_RUNNING", + message: "Discovery is running", + }, + }) + ); + agent.send( + JSON.stringify({ + type: "LOG_FRAME", + payload: { + level: "INFO", + stream: "agent", + source: "EDGE_AGENT", + message: "Gateway heartbeat acknowledged", + }, + }) + ); + + await waitFor( + () => browserMessages.some((message) => message.type === "presence.changed" && message.status === "connected"), + { description: "presence update" } + ); + assert.ok(browserMessages.some((message) => message.type === "gateway.telemetry")); + assert.ok(browserMessages.some((message) => message.type === "stats.updated")); + assert.ok(browserMessages.some((message) => message.type === "task.updated" && message.operationId === 41)); + assert.ok(browserMessages.some((message) => message.type === "log.append" && /heartbeat/.test(message.entry?.message))); + assert.equal(telemetryPayloads[0]?.broker_connection_id?.length > 0, true); + + browser.terminate(); + agent.terminate(); + await broker.close(); +}); + +test("broker survives telemetry ingestion failures for stale gateways", async () => { + const broker = createBrokerServer({ + authMode: "stub", + validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }), + ingestTelemetry: async () => { + throw new Error("Edge gateway not found"); + }, + }); + const address = await broker.listen(0); + const port = address.port; + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + await new Promise((resolve) => agent.once("open", resolve)); + + agent.send( + JSON.stringify({ + type: "TELEMETRY", + payload: { + status: "ONLINE", + }, + }) + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(broker.server.listening, true); + + agent.terminate(); + await broker.close(); +}); + +test("broker still fans out telemetry when manager ingestion fails", async () => { + const broker = createBrokerServer({ + authMode: "stub", + validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }), + validateBrowserStream: async () => ({ + id: "stream-telemetry-fallback", + gateway_id: "701", + scopes: ["overview", "statistics"], + }), + ingestTelemetry: async () => { + throw new Error("manager unavailable"); + }, + }); + const address = await broker.listen(0); + const port = address.port; + + const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-gateway-stream?token=stream-token`); + const browserMessages = collectMessages(browser); + await new Promise((resolve) => browser.once("open", resolve)); + + const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`); + await new Promise((resolve) => agent.once("open", resolve)); + + agent.send( + JSON.stringify({ + type: "TELEMETRY", + payload: { + status: "ONLINE", + metadata: { + system_metrics: { + cpu_usage_pct: 31, + }, + container_health: { + state: "ONLINE", + summary: "6/6 containers healthy", + }, + }, + }, + }) + ); + + await waitFor( + () => browserMessages.some((message) => message.type === "gateway.telemetry" && message.error === "manager unavailable"), + { description: "telemetry fanout after ingest failure" } + ); + assert.ok( + browserMessages.some( + (message) => message.type === "stats.updated" && message.statistics?.system_metrics?.cpu_usage_pct === 31 + ) + ); + + browser.terminate(); + agent.terminate(); + await broker.close(); +}); diff --git a/services/edge-broker/test/config.test.mjs b/services/edge-broker/test/config.test.mjs new file mode 100644 index 00000000..94c9672b --- /dev/null +++ b/services/edge-broker/test/config.test.mjs @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDirectory, "../../.."); + +function readRequiredSource(...pathSegments) { + const sourcePath = path.resolve(repoRoot, ...pathSegments); + assert.equal(existsSync(sourcePath), true, `Expected config fixture to exist: ${sourcePath}`); + return readFileSync(sourcePath, "utf8"); +} + +const baseComposeSource = readRequiredSource("docker-compose.yml"); +const exampleComposeSource = readRequiredSource("docker-compose.example.yml"); +const standaloneProdComposeSource = readRequiredSource("docker-compose.prod.standalone.yml"); +const traefikSource = [ + readRequiredSource("services", "traefik", "traefik.yml"), + readRequiredSource("services", "traefik", "traefik.prod.yml"), +].join("\n"); + +function readComposeServiceBlock(composeSource, serviceName) { + const escapedServiceName = serviceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const servicePattern = new RegExp( + `^[ ]{2}${escapedServiceName}:\\r?\\n([\\s\\S]*?)(?=^[ ]{2}[A-Za-z0-9_-]+:|^volumes:|^networks:|(?![\\s\\S]))`, + "m" + ); + const match = composeSource.match(servicePattern); + assert.ok(match, `Expected docker compose service block for ${serviceName}`); + return match[0]; +} + +test("traefik does not expose a dedicated public edge broker port", () => { + assert.doesNotMatch(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/); +}); + +test("base docker compose routes edge broker traffic through traefik", () => { + const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker"); + assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); + assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/); + assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip\.stripPrefix\.prefixes=\/edge-broker/); + assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip-local\.stripPrefix\.prefixes=\/api\/edge-broker/); + assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/); +}); + +test("example docker compose routes edge broker traffic through traefik", () => { + const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker"); + assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); + assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/); + assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/); +}); + +test("standalone production compose routes edge broker traffic through traefik", () => { + const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker"); + assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); + assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/); + assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/); + assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/); +}); + +test("compose config does not provide insecure broker secret defaults", () => { + for (const composeSource of [baseComposeSource, exampleComposeSource]) { + assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/); + assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/); + } +}); + +test("base docker compose wires the broker into each php worker", () => { + for (const serviceName of ["php1", "php2", "php3", "php4", "php5", "php-staging", "php-cron"]) { + const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName); + assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/); + assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/); + assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/); + } +}); diff --git a/services/edge-broker/test/server-entrypoint.test.mjs b/services/edge-broker/test/server-entrypoint.test.mjs new file mode 100644 index 00000000..b9b3c807 --- /dev/null +++ b/services/edge-broker/test/server-entrypoint.test.mjs @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const brokerEntryPath = path.resolve(testDirectory, "../server.mjs"); + +test("server entrypoint starts the broker and stays alive until terminated", async () => { + const child = spawn(process.execPath, [brokerEntryPath], { + env: { + ...process.env, + PORT: "0", + EDGE_AUTH_MODE: "stub", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Broker entrypoint did not report readiness.\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + }, 10_000); + + child.once("exit", (code, signal) => { + clearTimeout(timeout); + reject(new Error(`Broker entrypoint exited early with code=${code} signal=${signal}.\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + }); + + const poll = () => { + if (/TruckWash edge broker listening on \d+/.test(stdout)) { + clearTimeout(timeout); + resolve(); + return; + } + setTimeout(poll, 25); + }; + poll(); + }); + + assert.equal(child.exitCode, null, `Broker exited unexpectedly.\nstdout:\n${stdout}\nstderr:\n${stderr}`); + + const exitResult = await new Promise((resolve, reject) => { + child.once("exit", (code, signal) => resolve({ code, signal })); + child.kill("SIGTERM"); + setTimeout(() => reject(new Error("Broker did not exit after SIGTERM.")), 10_000); + }); + + const exitedCleanly = exitResult.code === 0 || exitResult.signal === "SIGTERM"; + assert.equal(exitedCleanly, true, `Broker exited unsuccessfully.\nstdout:\n${stdout}\nstderr:\n${stderr}`); +}); diff --git a/services/nginx/app/.phpunit.cache/test-results b/services/nginx/app/.phpunit.cache/test-results new file mode 100644 index 00000000..46afcacf --- /dev/null +++ b/services/nginx/app/.phpunit.cache/test-results @@ -0,0 +1 @@ +{"version":"pest_3.8.6","defects":{"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_type__eligibility__summary__and_webhook_endpoints":1,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_defines_reusable_self_serve_wash_and_machine_type_schemas":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_redistributes_booked_department_75_net_amounts_using_fixed_pricing_and_wash_subscription_weights":7,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_classified_booked_department_75_amounts_undistributed_when_no_monthly_basis_exists":8,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_unclassified_booked_department_75_lines_undistributed_with_warnings":8,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_v2_historical_distribution_and_pricing_history_paths_in_openapi":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_defines_new_reusable_v2_schemas_for_normalization_comparison_versioning_and_distribution":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_supports_bulk_booked_invoice_line_payloads_with_top_level_department_numbers":7,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_claims_a_slot_atomically_with_nx_and_expiration":8,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_returns_false_when_the_slot_is_already_claimed_and_clamps_ttl_to_one_second":8,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_economic_v2_invoice_paths_in_openapi":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_aligns_legacy_compare_schema_with_runtime_payload_by_removing_stale_required_order__ids":1,"P\\Tests\\Unit\\Search\\SystemSearchEntityTypeCoverageTest::__pest_evaluable_it_documents_every_supported_search_entity_type_in_openapi_enum":1,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_system_wide_search_endpoints_in_openapi":1,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_debug__intent_and_parser_metadata_schema_in_openapi":1,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_e_conomic_indexed_customer_matching_and_synonym_behavior":1,"P\\Tests\\Integration\\Database\\DbConnectionTest::__pest_evaluable_it_can_connect_to_a_configured_MySQL_instance_in_integration_mode":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_runs_best_effort_backfill_repeatedly_without_introducing_duplicate_same_start_rows":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_resolves_version_aware_distribution_payload_shapes_over_a_real_date_range":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_creates_closes_and_rotates_fixed_pricing_versions_without_overlap":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_tracks_vehicle_and_discount_version_lifecycles_with_closure_semantics":1,"P\\Tests\\Integration\\Permissions\\LegacyPermissionRedisCacheScriptTest::__pest_evaluable_it_keeps_legacy_route_permission_redis_cache_script_green":1,"P\\Tests\\Unit\\Goals\\GoalsOpenApiSpecTest::__pest_evaluable_it_documents_advanced_goals_target_duration_fields_in_openapi":1,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineSchemaConformanceTest::__pest_evaluable_it_documents_the_date_key_in_the_openapi_department_weather_timeline_schema":1,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_builds_timeline_entries_with_mostly__clear_weather_when_forecast_payload_is_empty":8,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_wires_departments_weather_route_through_the_forecast_fallback_path":7,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_groups_attachments_by_object_id_and_preserves_empty_object_groups":8,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_keeps_listMany_payload_parity_with_list_for_one_object_id":8,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_department_weather_target_endpoints_and_reusable_schemas_in_openapi":1,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_unknown_weather_status_behavior_when_targets_are_missing":1,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_respects_the_1_request_per_second_Shelly_gate_for_back_to_back_requests":8,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_in_progress_self_serve_wash_start_and_machine_relay_fields":1,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_stores_config_json_with_apostrophes_without_violating_json_constraints":8,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_is_on__then_turns_off_cleaner_machine_and_selector_relays":7,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_program_selector_is_off_and_only_disables_configured_relays":7,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_wash_included_minutes_in_config_schemas":1,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_calls":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_channel_id_is_missing_for_gate_calls":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_with_terminal_status_details_when_call_never_reaches_accepted_state":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_when_call_reaches_accepted_state":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_immediately_when_status_transitions_to_accepted":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_maps_legacy_timeout_option_to_documented_ringTimeout_payload_field":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_clamps_derived_ringTimeout_to_Bird_documented_max_when_gate_timeout_is_high":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_passes_documented_hangup_cause_when_provided":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_drops_unsupported_hangup_cause_values_from_request_payload":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_flash_calls":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_creates_gate_flash_call_with_documented_ringTimeout_payload":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_polls_flash_call_and_succeeds_once_accepted":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_flash_gate_flow_when_terminal_failure_status_is_returned":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_does_not_fallback_to_regular_gate_call_when_flash_succeeds":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_falls_back_to_regular_gate_call_when_flash_fails":8,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_property_gate_lane_commands_and_sanitized_gate_failure_responses":1,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_returns_raw_payload_for_malformed_JSON_responses_and_null_for_empty_responses":8,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_keeps_Bird_number_and_webhook_routes_intact":7,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_handles_malformed_and_empty_response_bodies_without_throwing_transport_errors":8,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_multi_department_slot_statuses_using_worst_severity_and_unknown_fallback_rules":7,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_relay_is_online__then_turns_off_cleaner_and_machine_relays":7,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_bills_primary_product_when_selector_relay_is_online_even_if_relay_output_is_off":7,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_all_Bird_voice_call_parity_endpoints_including_gather_recordings_insights_and_log":1,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_flash_hangup_endpoint_and_marks_end_alias_as_deprecated":1,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_defines_request_and_response_schemas_for_Bird_call_command_recording_insight_log_and_flash_payloads":1,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueRouteRegistrationTest::__pest_evaluable_it_registers_collected_invoice_queue_endpoints_in_orderInvoicesRoute":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_uses_a_consistent_unavailable_service_contract_for_missing_queue_dependencies":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_wires_zero_cost_and_zero_quantity_skip_guard_into_transfer_line_builder":7,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_queued_transfer_jobs_to_completion":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_deduplicates_active_jobs_per_transfer_target":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_rejects_invalid_payloads_without_inserting_queue_rows":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_supports_fail_retry_and_reprocess_lifecycle_transitions":1,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_supports_synchronous_fallback_branches_before_queue_enqueue_on_economic_invoice_export_routes":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_keeps_queue_status_endpoints_guarded_while_collected_invoice_export_routes_support_synchronous_fallback":7,"P\\Tests\\Unit\\Invoicing\\UserCollectedInvoiceUpdateRouteValidationTest::__pest_evaluable_it_supports_independent_po__number_and_closed__at_updates_for_PUT__collected_invoices_in_user_route":7,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_returns_pagination_metadata_and_strict_status_handling_for_collected_invoice_queue_list":7,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_enforces_retry_constraints_for_collected_invoice_queue_jobs_before_retry_execution":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_collected_queue_list_metadata_and_strict_status_filter_enum_in_openapi":7,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_builds_a_completed_collected_invoice_queue_summary_from_payload_and_result":8,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_prefers_queue_error_messages_for_failed_jobs_and_keeps_null_safe_outcome_fields":8,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_uses_deterministic_status_message_fallback_when_no_explicit_message_exists":8,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_handles_object_payload_result_values_and_malformed_fields_without_throwing":8,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_collected_invoice_transfer_queue_from_queue_list_and_status_routes":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_order_transfer_queue_from_draft_and_invoice_status_routes":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_PHP_agent_artifacts_and_management_polling_config":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__and_operation_endpoints_without_shell_transport_wiring":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_relay_fallback_and_transport_health_details_without_shell_or_update_runtime_state":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_resolves_edge_agent_artifacts_from_the_source_tree_layout":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_mounted_and_baked_in_artifact_directories_before_repo_fallbacks":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_router_resources_before_mounted_and_baked_in_artifact_directories":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_orders_for_an_admin_scoped_user_and_limits_the_results_to_the_permitted_departments":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_only_the_targeted_customer_orders_for_subuser_sessions":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_returns_the_current_auth_and_permission_failures_when_order_listing_is_not_allowed":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_creates_orders_through_the_real_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_creation_requests":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_primary_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_reassigns_invoice_collections_when_changing_an_order_across_the_draft_customer_boundary":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_primary_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_primary_order_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_legacy_alias_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_alias_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_legacy_alias_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_unsupported_legacy_field_value_updates_on_both_update_endpoints":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_deletes_orders_through_the_real_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_delete_requests":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_when_certificate_metadata_changes_through_the_primary_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_for_legacy_field_value_updates_through_the_alias_endpoint":8,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_every_selected_API_operation_covered_by_happy_path_and_failure_tests":8,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_the_OpenAPI_manifest_entries_aligned_with_the_API_spec":8,"P\\Tests\\Api\\ApiFixturesCleanupTest::__pest_evaluable_it_removes_generated_customer_traces_during_fixture_cleanup":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_in_with_valid_customer_credentials":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_login_payloads_and_credentials":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_returns_the_cached_auth_session_payload_for_a_valid_token":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_includes_economic_runtime_config_for_uncached_auth_sessions":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_auth_session_tokens":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_the_token_for_future_session_calls":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_cached_subuser_sessions":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_logout_tokens":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_the_raw_202_gather_webhook_contract_over_HTTP":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_still_prompts_for_department_selection_when_only_one_department_is_eligible":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_accepts_the_legacy_initial_webhook_body_with_top_level_call_identifiers":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_native_flow_gather_data_after_a_top_level_department_selection_when_distinct_gate_choices_exist":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_opens_the_gate_immediately_after_a_top_level_department_selection_when_entrance_and_exit_share_the_same_gate":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_uses_a_multi_digit_gather_contract_when_10_departments_are_eligible":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_a_raw_400_transport_error_for_malformed_webhook_payloads":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_only_visible_departments_and_can_return_a_single_department_with_the_slack_webhook":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_department_listing_when_the_permission_is_missing":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_creates_departments_through_the_real_endpoint":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_create_requests":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_updates_departments_through_the_real_endpoint":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_update_requests":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_department_categories_for_a_department":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_category_requests":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_lists_the_draft_customer_config_entry_in_economic_config_responses":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_draft_customer_config_value_through_economic_config_updates":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_order_draft_exports_for_the_configured_draft_customer":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_booked_invoice_exports_for_the_configured_draft_customer":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_collected_invoice_exports_for_the_configured_draft_customer":8,"P\\Tests\\Api\\PingApiTest::__pest_evaluable_it_returns_the_ping_contract":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_department_terminal_readers_are_requested_without_terminal_setup":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_sends_a_Stripe_invoice_by_email_and_persists_the_hosted_invoice_association":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_conflict_when_a_Stripe_hosted_invoice_is_already_active_for_the_order":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_allows_sending_a_new_Stripe_hosted_invoice_when_the_existing_association_is_already_terminal":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_voids_an_unpaid_Stripe_hosted_invoice_and_clears_the_local_order_association":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_refuses_to_cancel_a_paid_Stripe_hosted_invoice":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_creating_a_payment_intent_for_a_department_without_terminal_setup":8,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_the_newest_vehicle_last__order__id_that_still_has_order_items":8,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_a_null_vehicle_last__order__id_when_no_order_with_items_exists":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_syncs_gateway_snapshots_into_cached_list_payloads_and_refreshes_fleet_usage":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_keeps_only_the_module_facade_in_the_global_classes_directory_and_conditionally_loads_module_routes":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_the_compose_edge_gateway_artifacts_and_forwarded_https_scheme":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_compose_stack_artifacts_and_management_polling_config":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__cancel_endpoints__and_operation_endpoints_without_shell_transport_wiring":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_the_v2_operator_facing_edge_gateway_routes":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_defines_the_v2_edge_gateway_schema_bootstrap_tables":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_stores_operation_metadata_and_event_timelines_for_management_workflows":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_prefers_EDGE__PUBLIC__API__URL_when_explicitly_configured":8,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_returns_the_updated_lane_id_after_editing_a_scanner_whose_null_lane_was_already_cached":8,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_serves_installer_artifacts_and_recovers_gateway_runtime_status_after_fresh_heartbeats":8,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_polls_operations_and_commands__submits_results__and_records_broker_presence_for_task_pages":8,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_builds_broker_backlog_and_completes_gateway_operations_through_broker_endpoints":8,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_creates_install_tokens__tracks_installer_status__and_exposes_claimed_gateway_detail_to_authorized_operators":8,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_manages_edge_gateway_metadata__bindings__operations__sessions__rotation__cutover__and_deletion":8,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_assembles_tasks__logs__statistics__operations__commands__and_shell_lifecycle_state_from_persisted_records":1,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_invalid_edge_broker_shared_secrets":8,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_rejects_operator_edge_routes_when_module_permission_or_department_access_is_missing":8,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_rejects_missing_and_invalid_edge_agent_tokens":8,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_gateway_cutover_relay_bindings_used_by_self_serve_Shelly_dispatch":1,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_validates_broker_sessions_and_ingests_presence__telemetry__logs__and_shell_lifecycle_data":8,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_shell_session_creation_while_broker_presence_is_unavailable":8,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_ignores_and_soft_deletes_edge_gateways_whose_department_no_longer_exists":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_appends_forwarded_ports_when_the_forwarded_host_omits_them":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_websocket_broker_urls_on_the_traefik_broker_path":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_localhost_websocket_broker_urls_on_the_local_traefik_api_prefix":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_keeps_root_host_api_urls_unprefixed_when_the_request_is_not_under_the_local_api_alias":8,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_resolves_simulator_gateway_service_bindings_from_lane_relay_slots":7,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_overtime_minutes_when_a_saved_shift_end_extends_past_the_approved_original_end":7,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_unapproved_overtime_from_a_bounded_shift_updateTime_fallback":7,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_caps_current_slot_hours_to_elapsed_minutes_and_zeroes_future_slots":7,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_default_distribution_department_config_value_through_economic_config_updates":8,"P\\Tests\\Unit\\Replication\\ReplicationSecretBoxTest::__pest_evaluable_it_encrypts_replication_secrets_without_storing_plaintext":8,"P\\Tests\\Unit\\Replication\\ReplicationSecretBoxTest::__pest_evaluable_it_builds_active_database_and_redis_config_from_encrypted_bootstrap_snapshots":8,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_does_not_require_replica_SQL_threads_before_database_provisioning_configures_them":7,"P\\Tests\\Unit\\Invoicing\\EconomicDraftCustomerOpenApiSpecTest::__pest_evaluable_it_documents_transaction_draft_customer_config_and_auth_runtime_fields_in_all_tracked_openapi_copies":1,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_MinIO_free_space_and_catch_up_math_safely":7,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_counts_complaint_rows_by_department_and_created__at_reporting_range":1,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_updates_and_deletes_complaint_rows":1,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_parses_created__by__name_from_the_users_table":1,"P\\Tests\\Integration\\DailyReports\\DepartmentOutsideHoursStatisticsServiceIntegrationTest::__pest_evaluable_it_integrates_orders__xlvask__and_self_serve_into_one_outside_hours_summary_with_missing_hours_diagnostics":1,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_install_session_updates_and_derives_gateway_runtime_status_from_heartbeats":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_only_collected_invoice_jobs_and_respects_the_manual_batch_limit":1,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_the_configured_database_in_integration_mode":1,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_redis_in_integration_mode_when_configured":1,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_minio_in_integration_mode_when_configured":1,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_Coolify_service_payloads_from_raw_compose_without_a_service_type":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_the_selected_Coolify_project_and_resolves_server_UUID_from_the_instance_default":8,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_Coolify_GitHub_App_application_payloads_so_pulls_use_the_app_token":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_the_self_contained_Coolify_API_Dockerfile_for_API_applications":7,"P\\Tests\\Unit\\Tooling\\ComposerEntrypointTest::__pest_evaluable_it_checks_PSR_HTTP_message_interfaces_before_trusting_a_Composer_vendor_tree":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_defines_release_manager_schema__routes__permissions__and_system_status_integration_hooks":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_requires_non_default_release_channel_runtime_URLs_and_preserves_load_balancer_paths":7,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_wires_MinIO_replication_through_routes_and_bootstrap_snapshots":7,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_defines_Coolify_schema__route_permissions__and_replication_integration_hooks":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_chooses_a_requested_runtime_channel_only_when_it_is_available_to_the_principal":8,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_rejects_standalone_order_booking_completion_outside_POS":8,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_allows_linked_POS_order_booking_completion_for_mobile_POS_compatibility":8,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_accepts_numeric_safety_seal_strings_when_completing_a_linked_POS_order_booking":8,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_disables_the_legacy_complete_wash_without_certificate_route":8,"P\\Tests\\Api\\OrderBookingsUpdateApiTest::__pest_evaluable_it_detaches_an_order_booking_and_clears_the_matching_order_link":8,"P\\Tests\\Api\\OrderBookingsUpdateApiTest::__pest_evaluable_it_keeps_the_linked_order_when_order__id_is_omitted_from_an_order_booking_update":8,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_allows_explicit_runtime_selection_of_any_enabled_release_channel":7,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_requires_authentication_before_checking_in_progress_wash_permissions":8,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_reports_both_elevated_and_customer_self_serve_permissions_when_lane_polling_is_not_allowed":8,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_allows_customer_self_serve_permission_to_view_their_own_in_progress_wash_details":8,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_redacts_another_customers_in_progress_wash_from_customer_self_serve_lane_polling":8,"P\\Tests\\Api\\SelfserveFixtureApiTest::__pest_evaluable_it_creates_a_comprehensive_self_serve_API_scenario_with_demo_relays":8,"P\\Tests\\Api\\SelfserveZZZShellyGuardApiTest::__pest_evaluable_it_did_not_record_any_real_Shelly_request_attempts_during_self_serve_API_tests":8,"P\\Tests\\Api\\ApiFixturesCleanupTest::__pest_evaluable_it_restores_preserved_module_config_rows_during_fixture_cleanup":8,"P\\Tests\\Api\\BrandingApiTest::__pest_evaluable_it_creates_lists_and_updates_complete_branding_values":8,"P\\Tests\\Api\\BrandingApiTest::__pest_evaluable_it_rejects_branding_requests_without_permissions_or_valid_input":8,"P\\Tests\\Api\\BrandingApiTest::__pest_evaluable_it_assigns_and_clears_department_branding_for_superusers":8,"P\\Tests\\Api\\BrandingApiTest::__pest_evaluable_it_rejects_invalid_department_branding_assignments":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_previews_monthly_split_changes_without_moving_orders_or_creating_collections":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_splits_a_selected_March_and_April_collected_invoice_into_monthly_collections":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_sets_closed__at_to_month_end_when_split_month_has_ended":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_splits_the_whole_affected_collection_even_when_only_one_month_is_selected":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_does_not_affect_booked_collected_invoices":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_skips_draft_linked__Stripe__and_single_month_collections":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_rejects_invalid_monthly_split_date_ranges":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_allows_superusers_to_filter_archived_departments":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_does_not_allow_regular_department_listings_to_reveal_archived_departments_through_filters":8,"P\\Tests\\Api\\EdgeGatewayConfigApiTest::__pest_evaluable_it_stores_broker_settings_in_edge_gateway_module_config_and_uses_them_for_shell_sessions":8,"P\\Tests\\Api\\EdgeGatewayConfigApiTest::__pest_evaluable_it_returns_broker_diagnostics_for_the_current_edge_gateway_module_config_values":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_defaults_order_PO_from_a_linked_booking_when_creating_without_an_order_PO":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_keeps_an_explicit_order_PO_when_creating_a_linked_order":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_defaults_blank_order_PO_from_a_linked_booking_on_order_updates":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_requires_explicit_confirmation_before_deleting_protected_orders":8,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_rejects_plate_scanner_edits_when_the_permission_is_missing":8,"P\\Tests\\Api\\ReferenceSuggestionsApiTest::__pest_evaluable_it_returns_ranked_POS_reference_suggestions_from_bookings__orders__and_customer_vehicles":8,"P\\Tests\\Api\\ReferenceSuggestionsApiTest::__pest_evaluable_it_orders_reference_suggestions_by_match_relevance_before_context_and_frequency":8,"P\\Tests\\Api\\ReferenceSuggestionsApiTest::__pest_evaluable_it_enforces_authentication__list_permission__and_department_access_for_reference_suggestions":8},"times":{"P\\Tests\\Unit\\Auth\\CreateTokenTest::__pest_evaluable_it_creates_a_64_char_auth_token_for_an_existing_user":0.11,"P\\Tests\\Unit\\Auth\\CreateTokenTest::__pest_evaluable_it_throws_a_clear_exception_when_customer_user_is_missing":0.017,"P\\Tests\\Unit\\Auth\\LegacyAuthScriptParityTest::__pest_evaluable_it_keeps_passkey_challenge_legacy_script_green_during_migration":0.032,"P\\Tests\\Unit\\Auth\\LegacyAuthScriptParityTest::__pest_evaluable_it_keeps_register_CVR_legacy_script_green_during_migration":0.035,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_deterministic_department_digit_map_and_caps_to_9_options":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_resolves_department_ids_by_selected_digit":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_resolves_gate_type_digit_to_entrance_or_exit":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_stores__loads_and_clears_ivr_state_with_ttl":0.001,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_validates_state_caller_fingerprint_matching":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_department_and_gate_prompts":0,"P\\Tests\\Unit\\Permissions\\PermissionNodeDefinitionTest::__pest_evaluable_it_creates_permission_nodes_linked_to_subuser_permission_keys":0,"P\\Tests\\Unit\\Permissions\\PermissionNodeDefinitionTest::__pest_evaluable_it_rejects_empty_permission_definitions":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_normalizes_button_arrays__json_and_csv_inputs":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_rejects_invalid_button_inputs":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_normalizes_vehicle_type_values_and_null_semantics":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_rejects_invalid_vehicle_type_values":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_rejects_non_numeric_vehicle_type_strings":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_keeps_lane_service_enum_contract_for_MACHINE":0,"P\\Tests\\Unit\\Subusers\\SubuserGrantInitializationTest::__pest_evaluable_it_initializes_booking_node_grants_without_DB_dependency":0,"P\\Tests\\Unit\\Subusers\\SubuserGrantInitializationTest::__pest_evaluable_it_initializes_selfserve_node_grants_without_DB_dependency":0,"P\\Tests\\Unit\\Subusers\\SubusersRoutePermissionLinkTest::__pest_evaluable_it_keeps_subusers_route_list_permission_linked_to_SUBUSERS__LIST_node":0.009,"P\\Tests\\Unit\\Search\\SystemSearchEntityTypeCoverageTest::__pest_evaluable_it_keeps_route_and_service_entity_type_registries_in_sync_with_expanded_coverage":0,"P\\Tests\\Unit\\Search\\SystemSearchEntityTypeCoverageTest::__pest_evaluable_it_documents_every_supported_search_entity_type_in_openapi_enum":0.047,"P\\Tests\\Unit\\Search\\SystemSearchEconomicCustomerIndexWiringTest::__pest_evaluable_it_wires_local_e_conomic_customer_index_into_customer_related_search_entities":0.016,"P\\Tests\\Unit\\Search\\SystemSearchEconomicCustomerIndexWiringTest::__pest_evaluable_it_registers_cron_tasks_that_keep_the_e_conomic_search_index_refreshed":0.018,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_marks_system_search_cache_dirty_from_generic_db_object_mutation_flows":0.052,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_marks_system_search_cache_dirty_from_object_property_mutations":0.01,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_marks_system_search_cache_dirty_when_module_config_values_change":0.008,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_registers_cron_maintenance_task_for_system_search_cache":0.075,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_redacts_obvious_sensitive_fragments_before_sending_query_to_intent_parser":0.001,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_builds_payload_with_redacted_query_and_parses_strict_JSON_output":0.001,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_falls_back_safely_when_OpenAI_is_disabled":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_handles_malformed_OpenAI_response_payloads_without_throwing":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_uses_parser_cache_for_identical_query_and_allowed_type_combinations":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_caps_alias_and_hint_payloads_from_OpenAI_and_filters_hints_to_allowed_types":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_system_wide_search_endpoints_in_openapi":0.106,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_debug__intent_and_parser_metadata_schema_in_openapi":0.035,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_e_conomic_indexed_customer_matching_and_synonym_behavior":0.022,"P\\Tests\\Unit\\Search\\SystemSearchRouteBehaviorTest::__pest_evaluable_it_normalizes_type_lists_from_csv_json_and_arrays":0,"P\\Tests\\Unit\\Search\\SystemSearchRouteBehaviorTest::__pest_evaluable_it_parses_booleans_and_clamps_integers_using_route_defaults":0,"P\\Tests\\Unit\\Search\\SystemSearchRouteBehaviorTest::__pest_evaluable_it_exposes_expected_searchable_entity_types":0,"P\\Tests\\Unit\\Search\\SystemSearchRouteWiringTest::__pest_evaluable_it_registers_system_wide_search_GET_and_POST_endpoints_with_intent_debug_support":0.028,"P\\Tests\\Unit\\Search\\SystemSearchRouteWiringTest::__pest_evaluable_it_registers_superuser_cache_clear_and_rebuild_endpoints_for_system_search":0.031,"P\\Tests\\Unit\\Search\\SystemSearchRouteWiringTest::__pest_evaluable_it_passes_permission_and_own_scope_context_into_system_search_service":0.008,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_does_not_invoke_intent_parser_when_lexical_confidence_is_already_high":0.001,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_invokes_parser_on_low_confidence_lexical_results_and_applies_hints_only_boosts":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_never_lets_parser_entity_hints_override_explicit_include_filters":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_returns_fallback_parser_metadata_when_parser_fails_gracefully":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_scopes_query_cache_by_permission_context_to_avoid_cross_user_cache_leakage":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_caps_AI_driven_expanded_terms_to_prevent_query_amplification":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_expands_danish_discount_wording_into_lexical_discount_synonyms":0.003,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_invokes_parser_for_intent_driven_natural_language_queries_even_when_lexical_score_is_high":0.003,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_uses_association_hints_to_pull_related_customer_records_from_non_customer_matches":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_prefers_newer_records_when_relevance_scores_are_comparable":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_keeps_explicit_identifier_matches_ahead_of_newer_but_weaker_records":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_promotes_invoices_orders_order_bookings_and_customers_in_ranking":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_never_prioritizes_cancelled_bookings_over_active_bookings":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_heavily_demotes_configured_low_priority_entity_types_in_ranking":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_tokenizes_unicode_names_without_stripping_non_ascii_letters":0.001,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_does_not_treat_explicit_identifier_queries_as_intent_driven":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_requires_broader_term_coverage_for_multi_word_scoring":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_enriches_object_attachment_results_with_associated_customer_context":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_includes_the_economic_customer_index_in_cache_dependencies_for_customer_scoped_results":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_falls_back_to_invoice_date_ranges_when_invoice_names_are_missing":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_derives_xlvask_customer_numbers_only_from_digits_only_extern_ids":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_replaces_unnamed_user_titles_with_the_customer_context_name":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_uses_the_goal_criteria_label_for_department_goal_titles":0,"P\\Tests\\Integration\\Search\\SystemSearchCacheIntegrationTest::__pest_evaluable_it_reuses_parser_cache_entries_for_repeated_natural_language_intent_requests":0.129,"P\\Tests\\Integration\\Search\\SystemSearchCacheIntegrationTest::__pest_evaluable_it_clears_parser_cache_namespace_via_clearAll_to_force_a_fresh_parse":0,"P\\Tests\\Integration\\Search\\SystemSearchCacheIntegrationTest::__pest_evaluable_it_bumps_per_table_cache_versions_when_a_dirty_table_marker_is_registered":0.05,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_fixed_pricing_when_regular_orders_yield_no_customers":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_wash_subscriptions_when_regular_orders_yield_no_customers":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CliBackfillCommandTest::__pest_evaluable_it_registers_economic_v2_backfill_command_in_cli_dispatcher":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2CliBackfillCommandTest::__pest_evaluable_it_provides_a_backfill_cron_script_entrypoint":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_returns_exact__match_when_totals_lines_and_departments_are_identical":0.001,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_returns_total__mismatch_when_only_totals_differ":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_detects_line_level_mismatches_for_quantity_and_price":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_detects_departmental_distribution_mismatches":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_returns_missing__target_when_draft_or_booked_target_is_unavailable":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_normalizes_draft_invoice_lines_including_departmental_distributions":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_marks_text_only_zero_value_lines_as_non_billable_and_keeps_deterministic_key":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_normalizes_booked_invoices_and_computes_net_total_delta_from_lines":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_contains_internal_normalization_path_with_departmental_metadata_support":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_economic_v2_invoice_paths_in_openapi":0.016,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_v2_historical_distribution_and_pricing_history_paths_in_openapi":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_aligns_legacy_compare_schema_with_runtime_payload_by_removing_stale_required_order__ids":0.012,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_defines_new_reusable_v2_schemas_for_normalization_comparison_versioning_and_distribution":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicV2RevenueAndBarredSupportTest::__pest_evaluable_it_supports_barred_filtering_when_listing_e_conomic_customers":0.08,"P\\Tests\\Unit\\Invoicing\\EconomicV2RevenueAndBarredSupportTest::__pest_evaluable_it_implements_a_dedicated_v2_e_conomic_revenue_statistics_service_and_route":0.014,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_registers_all_collected_invoice_economic_v2_routes_with_explicit_permissions":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_registers_version_aware_distribution_and_pricing_history_v2_routes":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_caches_v2_distribution_responses_with_a_configurable_redis_ttl":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_writes_fixed_pricing_versions_from_create_and_delete_flows":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_writes_vehicle_subscription_versions_for_create_update_delete_flows":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_writes_discount_override_versions_from_superuser_discounts_route":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_keeps_legacy_compare_endpoint_path_for_backward_compatibility":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_implements_effective_range_lifecycle_methods_for_all_versioned_entities":0.036,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_closes_previous_active_interval_before_inserting_a_new_version":0.021,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_includes_best_effort_backfill_with_provenance_and_confidence_metadata":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_anchors_historical_resolution_on_order_created__at_timestamps_in_distribution_service":0.013,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_guards_against_empty_order_ids_in_net_amount_calculation":0.081,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_guards_against_empty_customer_arrays_in_date_range_transaction_fetches":0.074,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_uses_centralized_duplicate_filtering_for_possible_duplicate_detection":0.081,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteCacheHelpersTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteCacheHelpersTest::__pest_evaluable_it_builds_deterministic_cache_keys_per_scope_and_date_range":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteCacheHelpersTest::__pest_evaluable_it_falls_back_to_resolver_directly_when_cache_ttl_is_disabled":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_requires_superuser_permission_for_invoicing_period_distribution_all_endpoint":0.05,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_uses_shared_date_range_normalization_across_invoicing_period_endpoints":0.705,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_normalizes_a_valid_invoicing_date_range_to_full_day_timestamps":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_rejects_invalid_date_formats":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_rejects_descending_date_ranges":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_finds_duplicate_orders_regardless_of_original_input_order":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_runs_best_effort_backfill_when_fixed_pricing_version_history_is_empty":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_self_serve_conditions_with_combined_AND_and_OR_semantics":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_prefers_condition_results_over_question_answers_when_evaluating_task_gates":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_type__eligibility__summary__and_webhook_endpoints":0.008,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_defines_reusable_self_serve_wash_and_machine_type_schemas":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_machine_types__eligibility__summaries__and_machine_start_webhook_routes":0.012,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_machine_type_support_wired_into_lanes__tasks__and_conditions_routes":0.014,"P\\Tests\\Unit\\N8n\\N8nRouteHelpersTest::__pest_evaluable_it_normalizes_webhook_methods_and_falls_back_to_POST_for_unsupported_verbs":0,"P\\Tests\\Unit\\N8n\\N8nRouteHelpersTest::__pest_evaluable_it_filters_request_parameters_down_to_the_allowed_n8n_query_keys":0,"P\\Tests\\Unit\\N8n\\N8nRouteWiringTest::__pest_evaluable_it_registers_workflow_lifecycle_endpoints_for_the_n8n_module":0.182,"P\\Tests\\Unit\\N8n\\N8nRouteWiringTest::__pest_evaluable_it_registers_execution_and_webhook_trigger_endpoints_for_the_n8n_module":0.115,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_redistributes_booked_department_75_net_amounts_using_fixed_pricing_and_wash_subscription_weights":0.001,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_classified_booked_department_75_amounts_undistributed_when_no_monthly_basis_exists":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_unclassified_booked_department_75_lines_undistributed_with_warnings":0,"P\\Tests\\Unit\\Invoicing\\EconomicEndpointUrlEncodingTest::__pest_evaluable_it_encodes_raw_filter_query_values_that_include_timestamps":0,"P\\Tests\\Unit\\Invoicing\\EconomicEndpointUrlEncodingTest::__pest_evaluable_it_avoids_double_encoding_query_values_that_are_already_escaped":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_supports_bulk_booked_invoice_line_payloads_with_top_level_department_numbers":0,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_claims_a_slot_atomically_with_nx_and_expiration":0,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_returns_false_when_the_slot_is_already_claimed_and_clamps_ttl_to_one_second":0,"P\\Tests\\Unit\\Bookings\\OrderBookingRouteIdempotencyGuardTest::__pest_evaluable_it_guards_order_booking_creation_with_redis_backed_idempotency":0.101,"P\\Tests\\Unit\\DynamicImages\\DynamicImagePreRenderCronWiringTest::__pest_evaluable_it_registers_dynamic_image_pre_render_cron_task_and_related_helpers":0.01,"P\\Tests\\Integration\\Database\\DbConnectionTest::__pest_evaluable_it_can_connect_to_a_configured_MySQL_instance_in_integration_mode":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_runs_best_effort_backfill_repeatedly_without_introducing_duplicate_same_start_rows":0.013,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_resolves_version_aware_distribution_payload_shapes_over_a_real_date_range":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_creates_closes_and_rotates_fixed_pricing_versions_without_overlap":0.015,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_tracks_vehicle_and_discount_version_lifecycles_with_closure_semantics":0.014,"P\\Tests\\Integration\\Permissions\\LegacyPermissionRedisCacheScriptTest::__pest_evaluable_it_keeps_legacy_route_permission_redis_cache_script_green":0.01,"P\\Tests\\Unit\\Permissions\\AllowOwnOrDepartmentAccessForbiddenTest::__pest_evaluable_it_returns_both_own_and_elevated_permissions_when_both_are_missing":0,"P\\Tests\\Unit\\Permissions\\AllowOwnOrDepartmentAccessForbiddenTest::__pest_evaluable_it_returns_only_elevated_permission_when_own_permission_exists_but_own_context_fails":0,"P\\Tests\\Unit\\Permissions\\AllowOwnOrDepartmentAccessForbiddenTest::__pest_evaluable_it_allows_elevated_permission_path_without_forbidden_and_validates_department_access":0,"P\\Tests\\Unit\\Permissions\\ForbiddenResponseWiringTest::__pest_evaluable_it_routes_and_trait_use_forbidden_responses_for_permission_and_ownership_denials":0.19,"P\\Tests\\Unit\\Permissions\\GroupPermissionSessionInvalidationWiringTest::__pest_evaluable_it_invalidates_related_user_permission_and_auth_session_caches_when_group_permissions_change":0.064,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_parses_advanced_target_duration_from_snake__case_fields":0.36,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_parses_advanced_target_duration_from_camelCase_aliases":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_normalizes_ENTIRE__DURATION_to_ignore_target__duration__every":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_defaults_recurring_target__duration__every_to_1_when_omitted":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_keeps_strict_legacy_mode_when_target__duration_is_invalid":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_computes_weekly_target_using_touched_ISO_weeks_for_March_2026":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_applies_target__duration__every_for_weekly_cadence":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_prorates_ENTIRE__DURATION_target_by_overlap_days":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_splits_advanced_target_by_override_weights_per_department":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_preserves_legacy_target_behavior_when_target__duration_is_missing":0,"P\\Tests\\Unit\\Goals\\GoalsLegacyRendererScriptsTest::__pest_evaluable_it_keeps_legacy_monthly_renderer_script_green":0.466,"P\\Tests\\Unit\\Goals\\GoalsLegacyRendererScriptsTest::__pest_evaluable_it_keeps_legacy_department_daily_renderer_script_green":0.879,"P\\Tests\\Unit\\Goals\\GoalsOpenApiSpecTest::__pest_evaluable_it_documents_advanced_goals_target_duration_fields_in_openapi":0.07,"P\\Tests\\Unit\\Workfeed\\WorkfeedClientConformanceTest::__pest_evaluable_it_uses_company_scoped_workfeed_endpoints_and_raw_Authorization_header":0.006,"P\\Tests\\Unit\\Workfeed\\WorkfeedClientConformanceTest::__pest_evaluable_it_normalizes_documented_shift_query_parameters":0.005,"P\\Tests\\Unit\\Workfeed\\WorkfeedConfigRouteWiringTest::__pest_evaluable_it_registers_module_config_endpoints_for_workfeed":0.007,"P\\Tests\\Unit\\Workfeed\\WorkfeedRouteHelpersTest::__pest_evaluable_it_filters_request_parameters_down_to_the_allowed_workfeed_query_keys":0.001,"P\\Tests\\Unit\\Workfeed\\WorkfeedRouteWiringTest::__pest_evaluable_it_registers_employee_and_department_endpoints_for_the_workfeed_module":0.008,"P\\Tests\\Unit\\Workfeed\\WorkfeedRouteWiringTest::__pest_evaluable_it_registers_shift_endpoints_for_the_workfeed_module":0.008,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_workfeed_employee_hours_for_the_hour_slot_based_on_overlap":0.001,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_extracts_department_id_from_supported_workfeed_shift_shapes":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_normalizes_wrapped_workfeed_collections_from_common_response_keys":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineSchemaConformanceTest::__pest_evaluable_it_includes_a_date_key_in_department_weather_timeline_entries":0.006,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineSchemaConformanceTest::__pest_evaluable_it_documents_the_date_key_in_the_openapi_department_weather_timeline_schema":0.034,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_builds_a_timeline_range_from_start_of_yesterday_to_end_of_today":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_builds_a_timeline_range_relative_to_an_explicit_selected_date_override":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_workfeed_employee_hours_across_multiple_departments_for_one_hour_slot":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_normalizes_department_id_input_from_scalar_csv_and_nested_array_values":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_builds_a_timeline_range_relative_to_explicit_date__from_and_date__to_override":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_resolves_forecast_day_count_for_a_given_timeline_range_with_sane_limits":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_resolves_weather_coordinates_from_valid_coordinates_only":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_returns_null_weather_coordinates_when_all_department_locations_are_invalid":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_classifies_weatherapi_no_matching_location_errors_as_location_lookup_failures":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_does_not_classify_non_location_weatherapi_errors_as_location_lookup_failures":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_returns_an_empty_forecast_fallback_when_coordinates_are_unavailable":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_builds_timeline_entries_with_mostly__clear_weather_when_forecast_payload_is_empty":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_wires_departments_weather_route_through_the_forecast_fallback_path":0.007,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_builds_deterministic_cache_keys_for_department_sets_and_timeline_ranges":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_builds_order_insensitive_cache_keys_for_equivalent_department_id_sets":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_falls_back_to_resolver_directly_when_cache_ttl_is_disabled":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_marks_non_started_slots_as_unknown_regardless_of_wash_hour_ratio":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherPreloadCronWiringTest::__pest_evaluable_it_registers_and_implements_cron_preloading_for_department_weather_cache":0.012,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_uses_sane_stale_ttl_defaults_and_clamps_stale_ttl_below_fresh_ttl":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_uses_sane_hot_activity_ttl_defaults_and_clamps_to_a_positive_value":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_serves_fresh_cached_payloads_without_invoking_the_resolver":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_serves_stale_cached_payloads_and_enqueues_a_refresh_signal":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_recomputes_and_rewrites_cache_payloads_when_stale_window_is_exceeded":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_records_hot_keys_order_insensitively_and_applies_preload_target_caps":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_normalizes_batched_wash_count_rows_into_hourly_slot_totals":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_wires_department_weather_route_to_use_batched_wash_aggregation":0.015,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_groups_attachments_by_object_id_and_preserves_empty_object_groups":0,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_keeps_listMany_payload_parity_with_list_for_one_object_id":0,"P\\Tests\\Unit\\Orders\\EconomicModuleOrdersBatchHelpersTest::__pest_evaluable_it_ensures_economic_module_rows_in_one_sanitized_batch":0,"P\\Tests\\Unit\\Orders\\EconomicModuleOrdersBatchHelpersTest::__pest_evaluable_it_returns_id_keyed_economic_module_payload_with_null_defaults":0,"P\\Tests\\Unit\\Orders\\OrdersRouteListBatchingWiringTest::__pest_evaluable_it_wires_GET__orders_through_batched_enrichment_and_stripe_snapshot_caching":0.116,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchTest::__pest_evaluable_it_returns_cashier_names_from_cache_and_fetches_missing_ones_in_batch":0,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchTest::__pest_evaluable_it_returns_deterministic_map_for_duplicate_and_unsorted_cashier_ids":0,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchWiringTest::__pest_evaluable_it_implements_batched_cashier_name_lookup_with_cache_first_fallback_behavior":0.141,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchWiringTest::__pest_evaluable_it_sanitizes_and_deduplicates_cashier_ids_before_batch_lookup":0.064,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_overtime_minutes_when_a_shift_carries_an_extended_approval_end_timestamp":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_unapproved_overtime_from_a_bounded_shift_updateTime_fallback":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_does_not_treat_late_unapproved_administrative_edits_as_overtime":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_changes_weather_timeline_cache_key_when_department_weather_targets_change":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_evaluates_healthy_degraded_and_unhealthy_statuses_from_configured_department_targets":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_returns_unknown_when_configured_targets_are_missing_for_department_status_evaluation":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_multi_department_slot_statuses_using_worst_severity_and_unknown_fallback_rules":0.046,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_department_weather_target_endpoints_and_reusable_schemas_in_openapi":0.008,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_unknown_weather_status_behavior_when_targets_are_missing":0.01,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsRouteWiringTest::__pest_evaluable_it_registers_department_weather_target_routes_with_explicit_read_and_manage_permissions":0.006,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsRouteWiringTest::__pest_evaluable_it_persists_department_weather_targets_using_canonical_department_variable_keys":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_machine_relay_status_get_and_set_endpoints":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_batches_sequential_machine_relay_status_requests_into_a_single_Shelly_get_call":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_respects_the_1_request_per_second_Shelly_gate_for_back_to_back_requests":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_retries_missing_Shelly_status_payloads_until_relay_status_becomes_ready":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_times_out_with_a_clear_Shelly_readiness_error_when_payload_remains_missing":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_uses_direct_set_switch_and_seeds_cache_so_immediate_status_read_does_not_call_Shelly_get":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_lane_gate_open_endpoint":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_in_progress_self_serve_wash_details_endpoint":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_is_on__then_turns_off_cleaner_machine_and_selector_relays":0.132,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_program_selector_is_off_and_only_disables_configured_relays":0.001,"P\\Tests\\Unit\\Selfserve\\DepartmentSelfServeEnabledRelaySyncWiringTest::__pest_evaluable_it_syncs_lane_relay_states_when_department_self_serve_enabled_flag_changes":0.034,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_supports_hard_relay_set_even_when_lane_status_is_CLOSED":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStartCleanerRelayWiringTest::__pest_evaluable_it_enables_cleaner_relay_on_wash_start_command_and_webhook_flow":0.016,"P\\Tests\\Unit\\Selfserve\\SelfserveWashCompletionRelayWiringTest::__pest_evaluable_it_forces_machine_relay_off_when_a_self_serve_wash_session_is_completed":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_exit_port_by_switching_relay__in__id_on":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_entrance_port_by_switching_relay__out__id_on":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_in_progress_self_serve_wash_start_and_machine_relay_fields":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_typed_task_gates_with_strict_semantics":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_validates_typed_task_gates_for_known_references":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_fails_validation_when_typed_task_gates_reference_unknown_entities":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_config_draft_publish_rollback_lifecycle_endpoints":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_legacy_self_serve_CRUD_routes_syncing_canonical_drafts":0.012,"P\\Tests\\Unit\\Selfserve\\SelfserveStartCleanerRelayWiringTest::__pest_evaluable_it_keeps_cleaner_relay_enable_wired_into_machine_relay_start_paths":0.01,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_stores_config_json_with_apostrophes_without_violating_json_constraints":0.277,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_encodes_config_json_payloads_with_apostrophes_before_persistence":0,"P\\Tests\\Unit\\Selfserve\\SelfserveWashCompletionRelayWiringTest::__pest_evaluable_it_forces_machine_and_cleaner_relays_off_when_a_self_serve_wash_session_is_completed":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_vehicle_type_override_into_self_serve_preview_and_synchronization_routes":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_uses_local_demo_responses_and_skips_Shelly_cloud_calls_for_demo_relay_ids":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_returns_default_OFF_status_for_demo_relay_ids_without_Shelly_lookups":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_excludes_demo_relay_ids_from_Shelly_status_batch_payloads":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_keeps_demo_relay_gate_open_queued_but_skips_Shelly_switch_calls":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_allowed_services_route_through_machine_relay_visibility_sync":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_enables_MACHINE_relay_when_MACHINE_is_visible_in_allowed_services":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_turns_MACHINE_relay_off_immediately_when_MACHINE_is_not_visible":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_does_not_enable_MACHINE_relay_when_allowEnable_is_false_even_if_MACHINE_is_visible":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_is_a_safe_no_op_when_MACHINE_relay_is_not_configured":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRelayVisibilitySyncWiringTest::__pest_evaluable_it_synchronizes_machine_relay_from_visible_services_during_session_synchronization":0.011,"P\\Tests\\Unit\\Selfserve\\SelfserveRelayVisibilitySyncWiringTest::__pest_evaluable_it_adds_explicit_relay_disable_session_helper_for_visibility_driven_OFF_transitions":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_property_gate_command_permissions":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneCommandEnumTest::__pest_evaluable_it_parses_property_gate_lane_commands":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_opens_entrance_gate_for_OPEN__PROPERTY__ACCESS__GATE_command":0.196,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_opens_exit_gate_for_OPEN__PROPERTY__EXIT__GATE_command":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_blocks_property_gate_commands_when_department_self_serve_is_disabled":0.033,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_throws_a_clear_error_when_entrance_gate_is_missing_for_property_access_command":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_wraps_low_level_gate_errors_for_property_access_command_failures":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveWashFlowMachineAllowedWiringTest::__pest_evaluable_it_reads_machine_allowed_state_from_session_summary_payload_safely":0.007,"P\\Tests\\Unit\\Selfserve\\DbObjectPaginationLegacyColumnCompatibilityTest::__pest_evaluable_it_guards_pagination_against_searchable_fields_missing_from_legacy_schemas":0.05,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_adds_dynamic__images__vehicle__type_column_for_legacy_selfserve_wash_session_task_schemas":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_computes_billable_minutes_when_elapsed_minutes_exceed_included_minutes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_computes_zero_billable_minutes_when_elapsed_minutes_equal_included_minutes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_computes_zero_billable_minutes_when_included_minutes_exceed_elapsed_minutes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_handles_zero_and_low_elapsed_wash_time_edge_cases":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_wash_included_minutes_in_config_schemas":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_machine_wash_included_minutes_into_self_serve_module_config":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_blocks_explicit_relay_writes_when_department_self_serve_is_disabled":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_returns_disabled_no_op_from_machine_relay_visibility_sync_when_department_self_serve_is_disabled":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_property_gate_lane_commands_and_sanitized_gate_failure_responses":0.005,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_calls":0.022,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_channel_id_is_missing_for_gate_calls":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_with_terminal_status_details_when_call_never_reaches_accepted_state":0.067,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_when_call_reaches_accepted_state":0.001,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_immediately_when_status_transitions_to_accepted":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_maps_legacy_timeout_option_to_documented_ringTimeout_payload_field":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_clamps_derived_ringTimeout_to_Bird_documented_max_when_gate_timeout_is_high":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_passes_documented_hangup_cause_when_provided":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_drops_unsupported_hangup_cause_values_from_request_payload":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_flash_calls":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_creates_gate_flash_call_with_documented_ringTimeout_payload":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_polls_flash_call_and_succeeds_once_accepted":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_flash_gate_flow_when_terminal_failure_status_is_returned":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_does_not_fallback_to_regular_gate_call_when_flash_succeeds":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_falls_back_to_regular_gate_call_when_flash_fails":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_wraps_low_level_gate_errors_for_property_exit_command_failures":0.002,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_falls_back_to_regular_gate_call_when_flash_caller_id_is_not_confirmed":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_maps_voice_call_facade_methods_to_documented_endpoints_and_methods":0.024,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_maps_recordings_insights_log_and_flash_methods_to_documented_resources":0.001,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_encodes_path_segments_before_building_outbound_endpoints":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_serializes_query_parameters_for_GET_transport_requests":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_maps_4xx_and_5xx_transport_failures_into_informative_exceptions":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_returns_raw_payload_for_malformed_JSON_responses_and_null_for_empty_responses":0.007,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_all_Bird_voice_call_parity_endpoints_including_gather_recordings_insights_and_log":0.008,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_flash_hangup_endpoint_and_marks_end_alias_as_deprecated":0.006,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_defines_request_and_response_schemas_for_Bird_call_command_recording_insight_log_and_flash_payloads":0.009,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_accepts_valid_payloads_for_create_and_nested_call_flow_commands":0.002,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_rejects_unknown_fields_and_invalid_enum_values_in_strict_schemas":0.051,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_supports_CSV_normalization_in_schema_validation_for_log_filters":0,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_validates_flash_hangup_payloads_for_both_supported_request_shapes":0,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_fails_before_forward_step_when_strict_validation_fails_and_forwards_when_valid":0,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_registers_full_Bird_voice_call_route_surface_with_strict_validation_and_permissions":0.04,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_registers_flash_hangup_endpoint_and_keeps_end_alias_wired_as_compatibility_path":0.033,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_keeps_Bird_number_and_webhook_routes_intact":0.063,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_handles_malformed_and_empty_response_bodies_without_throwing_transport_errors":0.005,"P\\Tests\\Unit\\Selfserve\\DepartmentGatesRelaysRouteWiringTest::__pest_evaluable_it_validates_department_gate_config_on_both_create_and_update_routes":0.059,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_adds_wash__started__at_column_for_legacy_selfserve_wash_session_schemas":0.002,"P\\Tests\\Unit\\Selfserve\\SelfserveWashStartedAtWiringTest::__pest_evaluable_it_stores_wash__started__at_in_self_serve_wash_sessions_when_machine_start_is_triggered":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveWashStartedAtWiringTest::__pest_evaluable_it_passes_lane_wash_start_time_into_the_session_machine_start_marker":0.01,"P\\Tests\\Unit\\Selfserve\\SelfserveWashStartedAtWiringTest::__pest_evaluable_it_resolves_in_progress_wash__started__at_from_session_with_compatibility_fallbacks":0.004,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_normalizes_say_payload_and_applies_hangup_default":0.596,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_normalizes_log_query_csv_and_integer_fields":0,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_normalizes_nested_create_call_payload_sections_and_drops_unknown_keys":0,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_keeps_gather_nested_say_payload_unchanged_when_hangup_is_omitted":0,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_supports_no_body_and_flash_hangup_payload_normalization":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_respects_the_2_second_Shelly_gate_for_back_to_back_requests":0.023,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_enforces_a_2_second_gap_between_sequential_switch_requests_used_by_wash_start_and_stop_flows":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_keeps_back_to_back_get_switch_requests_ordered_through_the_relay_controller":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_executes_sequential_switch_requests_used_by_wash_start_and_stop_flows":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_does_not_print_Shelly_switch_responses_to_output":0,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitWiringTest::__pest_evaluable_it_enforces_a_global_2_second_Shelly_gate_in_sendPostRequest":0.006,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitWiringTest::__pest_evaluable_it_uses_Redis_NX_PX_semantics_for_cross_request_Shelly_rate_limiting":0.006,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitBehaviorTest::__pest_evaluable_it_enforces_the_2_second_Shelly_gate_across_separate_request_contexts":0,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitBehaviorTest::__pest_evaluable_it_does_not_delay_when_the_Shelly_gate_is_already_expired":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_relay_is_online__then_turns_off_cleaner_and_machine_relays":0.62,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_program_selector_relay_is_offline_and_only_disables_configured_relays":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_bills_primary_product_when_selector_relay_is_online_even_if_relay_output_is_off":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceModeBillingTest::__pest_evaluable_it_bills_manual_self_serve_stop_using_full_elapsed_minutes_without_included_minute_reduction":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceModeBillingTest::__pest_evaluable_it_keeps_included_minute_reduction_for_automatic_mode":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_on_first_dtmf_input_captured_within_the_300_second_window":0.005,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_does_not_send_Slack_when_dtmf_input_repeats_without_change":0.008,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_again_when_dtmf_input_changes_within_the_window":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_keeps_gathering_and_does_not_hang_up_before_300_seconds_have_elapsed":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_announces_timeout_waits_10_seconds_and_sends_hangup_once_at_or_after_300_seconds":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_keeps_polling_until_terminal_status_and_only_then_finalizes":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_finalizes_immediately_when_webhook_payload_is_already_terminal_before_timeout_logic":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_does_not_duplicate_timeout_or_hangup_actions_across_retries_and_lock_contention":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_keeps_state_when_polling_fails_so_completion_is_not_falsely_reported":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_when_dtmf_input_repeats_without_change":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_answers_immediately_once_and_does_not_re_answer_on_subsequent_webhook_retries":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_configures_gather_with_retry_loop_semantics_until_input_is_entered":0.004,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_when_gather_result_provides_keys_field_instead_of_dtmf":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_when_gather_conditions_variable_keys_carries_the_entered_value":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_configures_gather_to_check_every_2_seconds_with_retry_loop_semantics_until_input_is_entered":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_ignores_non_numeric_customer__number_values_from_request":0.042,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_casts_numeric_customer__number_strings_to_int_before_lookup":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_prefers_user__id_when_both_user__id_and_customer__number_are_valid":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_falls_back_to_customer__number_when_user__id_is_invalid":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_normalizes_request_identifiers_before_user_lookups":0.005,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_rejects_non_positive_and_non_digit_request_values":0.003,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_pagination_results_when_present":0.006,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_falls_back_to_collection_count_when_pagination_metadata_is_missing":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_returns_zero_when_neither_pagination_nor_collection_is_available":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_counts_object_based_collections_when_pagination_metadata_is_missing":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_uses_collection_when_present":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_falls_back_to_paymentTerms_when_collection_is_missing":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_supports_top_level_array_payloads":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_returns_an_empty_array_when_no_known_collection_shape_exists":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_uses_grant__2_in_economic__endpoint__t_when_explicitly_requested_and_configured":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_falls_back_to_grant__1_in_economic__endpoint__t_when_grant__2_is_missing":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_required_primary_grant_in_economic__endpoint__t":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_app_secret_in_economic__endpoint__t":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_uses_grant__2_in_legacy_economic__m_when_available":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_falls_back_to_grant__1_in_legacy_economic__m_when_grant__2_is_missing":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_required_primary_grant_in_legacy_economic__m":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_app_secret_in_legacy_economic__m":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_unfiltered_total_when_no_customer_filters_are_active":0.004,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_filtered_total_when_search_is_active":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_filtered_total_when_barred_filter_is_active":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_treats__null__search_and_barred_values_as_no_filter":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_falls_back_to_filtered_total_when_unfiltered_total_is_missing":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRouteEconomicFailureHandlingTest::__pest_evaluable_it_maps_e_conomic_integration_failures_in_customer_listing_to_explicit_502_responses":0.007,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRouteEconomicFailureHandlingTest::__pest_evaluable_it_logs_LIST__CUSTOMERS_success_only_after_pagination_and_customer_mapping_are_completed":0.005,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRouteEconomicFailureHandlingTest::__pest_evaluable_it_guards_against_malformed_customer_list_payloads_before_calling_paginate":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_deterministic_upstream_exceptions_for_non_2xx_endpoint_trait_responses":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_deterministic_upstream_exceptions_for_non_2xx_legacy_economic__m_responses":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_returns_raw_payload_unchanged_on_successful_HTTP_statuses":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersListResponseValidationTest::__pest_evaluable_it_accepts_valid_list_payloads_that_include_collection_and_pagination_results":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersListResponseValidationTest::__pest_evaluable_it_throws_when_pagination_is_missing_from_the_response_payload":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersListResponseValidationTest::__pest_evaluable_it_throws_when_upstream_responds_with_an_error_shaped_payload":0,"P\\Tests\\Unit\\Router\\RouterThrowableHandlingTest::__pest_evaluable_it_catches_throwables_during_auto_route_loading_and_maps_them_to_internal_server_errors":0.039,"P\\Tests\\Unit\\Users\\EconomicCustomerZeroHandlingTest::__pest_evaluable_it_does_not_request_e_conomic_customer_data_when_customer_number_is_zero":0.004,"P\\Tests\\Unit\\Users\\EconomicCustomerZeroHandlingTest::__pest_evaluable_it_short_circuits_e_conomic_customer_lookup_for_non_positive_customer_numbers":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueRouteRegistrationTest::__pest_evaluable_it_registers_queued_economic_invoice_endpoints_in_economicInvoiceRoute":0.013,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueRouteRegistrationTest::__pest_evaluable_it_registers_collected_invoice_queue_endpoints_in_orderInvoicesRoute":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueSchemaBootstrapTest::__pest_evaluable_it_defines_economic_transfer_queue_jobs_schema_bootstrap_table_and_tracking_columns":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueSchemaBootstrapTest::__pest_evaluable_it_provides_queue_processor_class_constants_and_processing_entrypoint":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueCronIntegrationTest::__pest_evaluable_it_registers_economic_transfer_queue_cron_task_and_handler":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_economic_transfer_queue_paths_in_openapi":0.013,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_economic_transfer_queue_schemas_in_openapi":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_skips_order_items_with_zero_quantity_for_e_conomic_export":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_skips_order_items_with_zero_price_for_e_conomic_export":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_keeps_positive_quantity_and_price_items_billable_for_e_conomic_export":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_wires_zero_cost_and_zero_quantity_skip_guard_into_transfer_line_builder":0.048,"P\\Tests\\Unit\\Invoicing\\EconomicInvoiceDraftZeroItemSkipWiringTest::__pest_evaluable_it_skips_zero_cost_and_zero_quantity_items_in_legacy_economic_draft_helper":0.023,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_skips_malformed_order_item_payloads_for_e_conomic_export_safety":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueHardeningTest::__pest_evaluable_it_hardens_transfer_queue_with_type_validation_retry_caps_and_stale_lock_recovery":0.043,"P\\Tests\\Unit\\Router\\AutoloadRedisCacheValidationTest::__pest_evaluable_it_validates_cached_autoload_paths_before_returning_and_clears_stale_cache_entries":0.008,"P\\Tests\\Unit\\Orders\\OrdersRegistrationDateRangeQueryTest::__pest_evaluable_it_applies_created__at_bounds_directly_in_SQL_when_filtering_orders_by_registration_number":0.001,"P\\Tests\\Unit\\Orders\\OrdersRegistrationDateRangeQueryTest::__pest_evaluable_it_rejects_an_inverted_date_range_for_registration_lookups":0,"P\\Tests\\Unit\\Orders\\OrdersRegistrationDateRangeQueryTest::__pest_evaluable_it_returns_early_when_registration_number_is_blank":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueCronIntegrationTest::__pest_evaluable_it_wires_queue_worker_class_loading_for_CLI_queue_action":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_guards_all_economic_invoice_queue_endpoints_before_constructing_queue_service":0.012,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_guards_all_collected_invoice_queue_endpoints_before_constructing_queue_service":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_uses_a_consistent_unavailable_service_contract_for_missing_queue_dependencies":0.009,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_gracefully_handles_unavailable_queue_dependencies_on_collected_invoice_queue_endpoints":0.005,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_returns_the_queue_job_id_in_POST__collected_invoices_economic_enqueue_response":0.204,"P\\Tests\\Unit\\Invoicing\\UserCollectedInvoiceUpdateRouteValidationTest::__pest_evaluable_it_requires_id_and_at_least_one_mutable_field_for_PUT__collected_invoices_in_user_route":0.019,"P\\Tests\\Unit\\Invoicing\\UserCollectedInvoiceUpdateRouteValidationTest::__pest_evaluable_it_supports_independent_po__number_and_closed__at_updates_for_PUT__collected_invoices_in_user_route":0.008,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_returns_queued_contract_for_POST__collected_invoices_stripe_book":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_keeps_order_draft_export_route_queue_only":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_keeps_collected_invoice_draft_producing_routes_queue_only_in_orderInvoicesRoute":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_guards_collected_invoice_and_stripe_draft_producing_endpoints_before_constructing_queue_service":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_unavailable_queue_dependency_responses_for_async_economic_transfer_endpoints":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueuePayloadValidationTest::__pest_evaluable_it_normalizes_order_payloads_to_strict_positive_integer_ids":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueuePayloadValidationTest::__pest_evaluable_it_normalizes_collected_invoice_payload_booleans_to_strict_bool_values":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueuePayloadValidationTest::__pest_evaluable_it_rejects_invalid_transfer_payloads_before_enqueue_write_attempts":0,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_queued_transfer_jobs_to_completion":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_deduplicates_active_jobs_per_transfer_target":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_rejects_invalid_payloads_without_inserting_queue_rows":0.013,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_supports_fail_retry_and_reprocess_lifecycle_transitions":0.014,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_keeps_queue_guards_on_economic_invoice_queue_status_and_retry_routes":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_supports_synchronous_fallback_branches_before_queue_enqueue_on_economic_invoice_export_routes":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_keeps_queue_status_endpoints_guarded_while_collected_invoice_export_routes_support_synchronous_fallback":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_uses_a_consistent_unavailable_service_contract_for_queue_status_lifecycle_endpoints":0.057,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_supports_synchronous_fallback_and_queued_processing_for_order_draft_export_route":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_supports_synchronous_fallback_and_queued_processing_for_collected_invoice_draft_producing_routes":0.006,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_documents_both_synchronous_fallback_and_queued_response_contracts_for_POST__collected_invoices_economic":0.165,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_documents_both_synchronous_fallback_and_queued_response_contracts_for_POST__collected_invoices_stripe_book":0.018,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_200_fallback_plus_202_queue_contracts_for_export_endpoints_and_keeps_503_on_queue_lifecycle_endpoints":0.009,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_returns_pagination_metadata_and_strict_status_handling_for_collected_invoice_queue_list":0.006,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_enforces_retry_constraints_for_collected_invoice_queue_jobs_before_retry_execution":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_collected_queue_list_metadata_and_strict_status_filter_enum_in_openapi":0,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_builds_a_completed_collected_invoice_queue_summary_from_payload_and_result":0.029,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_prefers_queue_error_messages_for_failed_jobs_and_keeps_null_safe_outcome_fields":0,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_uses_deterministic_status_message_fallback_when_no_explicit_message_exists":0,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_handles_object_payload_result_values_and_malformed_fields_without_throwing":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_collected_invoice_transfer_queue_from_queue_list_and_status_routes":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_order_transfer_queue_from_draft_and_invoice_status_routes":0.009,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_marks_queued_transactions_and_clears_requires__action_when_all_actionable_work_is_already_queued":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_keeps_requires__action_true_when_a_customer_still_has_unqueued_actionable_transactions":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_blocks_fixed_pricing_or_subscription_customers_with_no_transactions_when_a_relevant_queue_job_exists":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_queues_admin_commands__exposes_agent_poll_result_handlers__and_keeps_heartbeats_from_mutating_discovery_state":0.003,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_defines_the_dispatchable_gateway_guard_on_the_loaded_manager_class":0.009,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"recent heartbeat stays online\"\"":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"late heartbeat degrades after one minute\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"stale heartbeat goes offline after five minutes\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"explicit offline reports stay offline even when heartbeat is fresh\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"missing heartbeat is treated as offline\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_marks_ready_discovery_as_stale_when_the_gateway_heartbeat_has_expired":0.727,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_the_edge_gateway_management_REST_endpoints":0.021,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_public_installer__claim__heartbeat__command_polling__and_internal_broker_auth_endpoints":0.004,"P\\Tests\\Unit\\Selfserve\\EdgeBrokerClientConfigTest::__pest_evaluable_it_uses_the_same_default_broker_url_and_shared_secret_fallback_as_the_docker_stack":0.267,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_forwarded_https_scheme_when_proxied":0.336,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_appends_forwarded_ports_when_the_forwarded_host_omits_them":0.28,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_infers_https_for_the_staging_api_host_when_only_the_https_port_is_present":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_prefers_EDGE__PUBLIC__API__URL_when_explicitly_configured":1.003,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_normalizes_public_broker_overrides_to_https_and_browser_shell_urls_to_wss":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_keeps_localhost_broker_overrides_on_plain_http_and_ws_for_local_development":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_includes_node_pty_build_prerequisites_in_the_generated_install_script":0.007,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_prefers_EDGE__PUBLIC__API__URL_when_explicitly_configured_and_derives_the_broker_from_the_api_origin":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_normalizes_public_broker_overrides_to_https__strips_paths__and_browser_shell_urls_to_wss":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_merges_incoming_heartbeat_metadata_with_existing_gateway_metadata":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_the_v2_operator_facing_edge_gateway_routes":0.008,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_PHP_edge_agent_routes_for_operations_and_legacy_relay_command_polling":0.027,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_defines_the_v2_edge_gateway_schema_bootstrap_tables":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_stores_operation_metadata_and_event_timelines_for_management_workflows":0.005,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_PHP_agent_artifacts_and_management_polling_config":0.011,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__and_operation_endpoints_without_shell_transport_wiring":0.013,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_keeps_relay_dispatch_and_discovery_queueing_on_the_edge_gateway_manager":0.02,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_loads_relay_command_helpers_on_the_manager_and_gateway_operations_on_the_dedicated_service":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_relay_fallback_and_transport_health_details_without_shell_or_update_runtime_state":0.837,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_resolves_edge_agent_artifacts_from_the_source_tree_layout":0.445,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_includes_the_container_mounted_artifact_directory_as_a_candidate":0.021,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_reads_install_artifacts_through_the_shared_locator":0.019,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_builds_update_payloads_with_checksums_from_resolved_artifact_paths":2.276,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_resolves_edge_agent_artifacts_from_a_supported_runtime_layout":0.273,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_the_PHP_agent_artifacts_and_forwarded_https_scheme":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_explains_the_legacy_dist_mount_mismatch_when_php_artifacts_are_unavailable":0.005,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_mounted_and_baked_in_artifact_directories_before_repo_fallbacks":0.022,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_falls_back_to_baked_in_artifacts_when_the_mount_path_is_absent":0.013,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_router_resources_before_mounted_and_baked_in_artifact_directories":0,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_orders_for_an_admin_scoped_user_and_limits_the_results_to_the_permitted_departments":1.109,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_only_the_targeted_customer_orders_for_subuser_sessions":2.935,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_returns_the_current_auth_and_permission_failures_when_order_listing_is_not_allowed":1.375,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_creates_orders_through_the_real_endpoint":0.982,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_creation_requests":3.423,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_primary_endpoint":1.13,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_reassigns_invoice_collections_when_changing_an_order_across_the_draft_customer_boundary":1.615,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_primary_endpoint":5.592,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_primary_order_endpoint":1.29,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_legacy_alias_endpoint":1.517,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_alias_endpoint":4.136,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_legacy_alias_endpoint":0.512,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_unsupported_legacy_field_value_updates_on_both_update_endpoints":0.965,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_deletes_orders_through_the_real_endpoint":1.045,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_delete_requests":1.64,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_when_certificate_metadata_changes_through_the_primary_endpoint":3.683,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_for_legacy_field_value_updates_through_the_alias_endpoint":3.194,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayOperationLegacySchemaCompatibilityTest::__pest_evaluable_it_keeps_the_legacy_operation__type_column_compatible_with_v2_operation_queueing":0.022,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_builds_deterministic_keys_for_equivalent_contexts":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_stores_and_retrieves_full_order_bookings_payloads":0.001,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_ignores_malformed_cached_payloads_and_clears_order_bookings_list_caches":0.001,"P\\Tests\\Unit\\Bookings\\OrderBookingsRouteCacheWiringTest::__pest_evaluable_it_caches_the_paginated_order_bookings_list_response_and_invalidates_it_on_booking_changes":0.051,"P\\Tests\\Unit\\Bookings\\VehicleSearchBookedMetadataRouteContractTest::__pest_evaluable_it_keeps_booked_vehicle_search_metadata_aligned_with_filtered_pending_order_bookings":0.052,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0.001,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_builds_deterministic_keys_for_equivalent_count_contexts":0.039,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_stores_and_retrieves_normalized_booking_counts":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_ignores_malformed_payloads_and_clears_order_bookings_count_caches":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsRouteWiringTest::__pest_evaluable_it_adds_a_cached_order_bookings_counts_endpoint_and_invalidates_it_on_booking_changes":0.04,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayFleetUsageStatisticsTest::__pest_evaluable_it_summarizes_fleet_usage_statistics_for_the_dashboard_landing_view":0,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_every_selected_API_operation_covered_by_happy_path_and_failure_tests":0.271,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_the_OpenAPI_manifest_entries_aligned_with_the_API_spec":0.097,"P\\Tests\\Api\\ApiFixturesCleanupTest::__pest_evaluable_it_removes_generated_customer_traces_during_fixture_cleanup":8.099,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_in_with_valid_customer_credentials":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_login_payloads_and_credentials":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_returns_the_cached_auth_session_payload_for_a_valid_token":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_includes_economic_runtime_config_for_uncached_auth_sessions":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_auth_session_tokens":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_the_token_for_future_session_calls":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_cached_subuser_sessions":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_logout_tokens":0,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_the_raw_202_gather_webhook_contract_over_HTTP":0.788,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_still_prompts_for_department_selection_when_only_one_department_is_eligible":0.732,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_accepts_the_legacy_initial_webhook_body_with_top_level_call_identifiers":0.692,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_native_flow_gather_data_after_a_top_level_department_selection_when_distinct_gate_choices_exist":1.515,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_opens_the_gate_immediately_after_a_top_level_department_selection_when_entrance_and_exit_share_the_same_gate":1.719,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_uses_a_multi_digit_gather_contract_when_10_departments_are_eligible":0.587,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_a_raw_400_transport_error_for_malformed_webhook_payloads":0.419,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_only_visible_departments_and_can_return_a_single_department_with_the_slack_webhook":1.048,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_department_listing_when_the_permission_is_missing":0.378,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_creates_departments_through_the_real_endpoint":0.59,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_create_requests":1.554,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_updates_departments_through_the_real_endpoint":0.788,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_update_requests":1.552,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_department_categories_for_a_department":1.006,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_category_requests":1.191,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_lists_the_draft_customer_config_entry_in_economic_config_responses":0,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_draft_customer_config_value_through_economic_config_updates":0,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_order_draft_exports_for_the_configured_draft_customer":0,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_booked_invoice_exports_for_the_configured_draft_customer":0,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_collected_invoice_exports_for_the_configured_draft_customer":0,"P\\Tests\\Api\\PingApiTest::__pest_evaluable_it_returns_the_ping_contract":0.877,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_department_terminal_readers_are_requested_without_terminal_setup":1.281,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_sends_a_Stripe_invoice_by_email_and_persists_the_hosted_invoice_association":5.434,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_conflict_when_a_Stripe_hosted_invoice_is_already_active_for_the_order":4.756,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_allows_sending_a_new_Stripe_hosted_invoice_when_the_existing_association_is_already_terminal":3.943,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_voids_an_unpaid_Stripe_hosted_invoice_and_clears_the_local_order_association":3.746,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_refuses_to_cancel_a_paid_Stripe_hosted_invoice":4.131,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_creating_a_payment_intent_for_a_department_without_terminal_setup":0.726,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_the_newest_vehicle_last__order__id_that_still_has_order_items":1.326,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_a_null_vehicle_last__order__id_when_no_order_with_items_exists":1.159,"P\\Tests\\Unit\\Users\\EconomicCustomerModelParsingTest::__pest_evaluable_it_parses_cached_economic_customer_payloads_that_use_snake__case_customer__number":0,"P\\Tests\\Unit\\Users\\EconomicCustomerModelParsingTest::__pest_evaluable_it_leaves_the_economic_customer_model_empty_when_no_valid_customer_number_is_present":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayFleetUsageStatisticsTest::__pest_evaluable_it_derives_fleet_usage_directly_from_cached_gateway_row_summaries":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_the_compose_edge_gateway_artifacts_and_forwarded_https_scheme":1.031,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayMetadataUpdateContractTest::__pest_evaluable_it_adds_a_gateway_metadata_update_endpoint_with_label_and_primary_assignment_fields":0.009,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayMetadataUpdateContractTest::__pest_evaluable_it_reassigns_department_primary_gateways_through_dedicated_manager_helpers":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayShellPollingTransportTest::__pest_evaluable_it_removes_shell_access_from_the_edge_gateway_HTTP_contracts":0.007,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_compose_stack_artifacts_and_management_polling_config":0.06,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_deterministic_cache_keys":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_stores_and_retrieves_cached_list_and_detail_payloads":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_syncs_gateway_snapshots_into_cached_list_payloads_and_refreshes_fleet_usage":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_removes_deleted_gateways_from_cached_list_and_detail_payloads":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__cancel_endpoints__and_operation_endpoints_without_shell_transport_wiring":0.02,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_module_scoped_edge_gateway_operator_routes":0.008,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_edge_gateway_module_config_endpoints":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_edge_gateway_config_endpoints_from_the_module_route_directory":0.015,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_keeps_only_the_module_facade_in_the_global_classes_directory_and_conditionally_loads_module_routes":0.007,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayInstallSessionLifecycleTest::__pest_evaluable_it_caps_install_session_diagnostics_and_events_while_preserving_the_first_start_timestamp":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayInstallSessionLifecycleTest::__pest_evaluable_it_clears_terminal_failure_details_after_a_successful_claim_update":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayInstallSessionLifecycleTest::__pest_evaluable_it_marks_expired_non_terminal_install_sessions_as_terminal_when_read_back":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_websocket_broker_urls_on_the_traefik_broker_path":0.965,"P\\Tests\\Unit\\Selfserve\\DepartmentGateConfigRelayTest::__pest_evaluable_it_validates_relay_gate_configs_and_keeps_relay_specific_fields_in_the_payload":0,"P\\Tests\\Unit\\Selfserve\\DepartmentGateConfigRelayTest::__pest_evaluable_it_rejects_relay_gate_configs_without_a_logical_relay_id":0,"P\\Tests\\Unit\\Bird\\DepartmentGatesRelayOpenTest::__pest_evaluable_it_dispatches_relay_backed_gates_through_the_edge_gateway_relay_manager":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayDepartmentWorkspaceContractTest::__pest_evaluable_it_defines_the_department_hardware_workspace_service_payload_surface":0.033,"P\\Tests\\Unit\\Selfserve\\PlateScannerWorkspaceContractTest::__pest_evaluable_it_adds_lane_aware_scanner_management_and_a_dedicated_rotate_key_action":0.01,"P\\Tests\\Unit\\Selfserve\\PlateScannerWorkspaceContractTest::__pest_evaluable_it_uses_the_scanner_default_lane_before_requiring_an_explicit_lane__id_in_machine_button_webhooks":0.004,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_maps_gateway_relay_status_responses_into_the_Shelly_cloud_payload_shape":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_maps_gateway_relay_switch_responses_into_the_Shelly_cloud_payload_shape":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_requires_a_valid_department_id_for_gateway_transport_requests":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_rejects_unsupported_gateway_transport_endpoints":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_surfaces_binding_lookup_failures_while_resolving_relay_state_through_the_gateway_transport":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_surfaces_offline_gateway_failures_while_dispatching_relay_switch_commands":0,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_returns_the_updated_lane_id_after_editing_a_scanner_whose_null_lane_was_already_cached":21.835,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_rejects_plate_scanner_edits_when_the_permission_is_missing":7.837,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_normalizes_owned_Shelly_devices_into_relay_select_options":0,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_fails_fast_when_Shelly_inventory_does_not_include_owned_devices_status":0,"P\\Tests\\Unit\\Selfserve\\DepartmentLaneRelayOptionsRouteWiringTest::__pest_evaluable_it_registers_a_department_lane_relay_options_endpoint_backed_by_Shelly_inventory":0.032,"P\\Tests\\Unit\\Selfserve\\DepartmentLaneRelayNullificationRouteWiringTest::__pest_evaluable_it_nullifies_department_lane_relay_fields_when_blank_select_values_are_submitted":0.008,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_serves_installer_artifacts_and_recovers_gateway_runtime_status_after_fresh_heartbeats":109.487,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_polls_operations_and_commands__submits_results__and_records_broker_presence_for_task_pages":229.768,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_rejects_missing_and_invalid_edge_agent_tokens":12.815,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_validates_broker_sessions_and_ingests_presence__telemetry__logs__and_shell_lifecycle_data":180.867,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_builds_broker_backlog_and_completes_gateway_operations_through_broker_endpoints":147.749,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_invalid_edge_broker_shared_secrets":6.832,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_creates_install_tokens__tracks_installer_status__and_exposes_claimed_gateway_detail_to_authorized_operators":134.744,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_manages_edge_gateway_metadata__bindings__operations__sessions__rotation__cutover__and_deletion":272.219,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_rejects_operator_edge_routes_when_module_permission_or_department_access_is_missing":46.681,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_install_session_updates_and_derives_gateway_runtime_status_from_heartbeats":0.015,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_assembles_tasks__logs__statistics__operations__commands__and_shell_lifecycle_state_from_persisted_records":0.019,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_localhost_websocket_broker_urls_on_the_local_traefik_api_prefix":1.312,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_keeps_root_host_api_urls_unprefixed_when_the_request_is_not_under_the_local_api_alias":0.603,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_imports_a_matching_e_conomic_customer_into_the_local_system_when_no_local_record_exists":0.001,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_reports_when_the_local_customer_already_has_a_login_account":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_creates_a_new_e_conomic_customer_and_returns_a_created_result_for_new_rows":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_creates_the_economic_record_for_an_existing_local_account_when_no_matching_upstream_customer_exists":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_rejects_CVR_conflicts_when_the_upstream_customer_number_does_not_match_the_submitted_phone_number":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_registers_the_customer_import_route_and_wires_it_through_the_mass_import_service":0.009,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_builds_customer_name_cache_payloads_from_economic_data_or_display_name_fallbacks":0,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_guards_bulk_customer_name_cache_writes_behind_a_resolved_payload_check":0.003,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_ignores_and_soft_deletes_edge_gateways_whose_department_no_longer_exists":35.946,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_only_trusts_broker_presence_while_the_broker_heartbeat_is_fresh":1.458,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_refreshes_broker_presence_from_broker_telemetry_heartbeats":0.006,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_resolves_the_injected_gateway_transport_when_a_department_is_in_gateway_mode":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_resolves_the_injected_cloud_transport_when_a_department_is_in_cloud_mode":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_lets_relay_tests_override_the_department_transport_without_changing_department_mode":0,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_falls_back_to_local_device_and_control_names_when_Shelly_cloud_list_metadata_is_unavailable":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_uses_local_only_gateway_dispatch_for_explicit_local_transport_overrides":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_marks_non_injected_local_overrides_as_local_only_gateway_transport":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_exit_port_by_switching_relay__out__id_on":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_entrance_port_by_switching_relay__in__id_on":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_forwards_Shelly_toggle__after_timers_to_gateway_relay_switches":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_marks_non_injected_local_and_gateway_overrides_as_local_only_gateway_transport":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_preserves_local_gateway_diagnostic_metadata_on_relay_status_snapshots":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_includes_positive_relay_timers_in_Shelly_switch_payloads":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_applies_Shelly_transport_overrides_across_self_serve_relay_side_effect_routes":0.029,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayLegacyRouteShimTest::__pest_evaluable_it_keeps_guarded_legacy_root_shims_for_moved_edge_gateway_routes":0.025,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_gateway_cutover_relay_bindings_used_by_self_serve_Shelly_dispatch":0.015,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_passes_explicit_timer_values_when_opening_lane_gates":0,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_keeps_Shelly_1_Mini_Gen3_type__model__and_generation_aligned_with_Shelly_metadata":0,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_shell_session_creation_while_broker_presence_is_unavailable":23.503,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_broker_settings_as_editable_edge_gateway_module_config":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_session_management_endpoints_and_OpenAPI_coverage":0.011,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_self_serve_force_stop_distinct_from_normal_STOP_relay_and_gate_behavior":0.015,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_read_only_self_serve_preview_and_summary_refreshes_from_touching_relay_hardware":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveWashFlowMachineAllowedWiringTest::__pest_evaluable_it_separates_session_synchronization_from_relay_hardware_synchronization":0.016,"P\\Tests\\Unit\\Selfserve\\ShellyRealRequestGuardTest::__pest_evaluable_it_blocks_and_records_test_mode_Shelly_POST_requests_before_cURL_can_run":0.001,"P\\Tests\\Unit\\Selfserve\\ShellyRealRequestGuardTest::__pest_evaluable_it_blocks_and_records_test_mode_Shelly_GET_requests_before_cURL_can_run":0,"P\\Tests\\Unit\\Selfserve\\ZZZShellyGuardSafetyMetaTest::__pest_evaluable_it_did_not_record_any_real_Shelly_request_attempts_during_self_serve_tests":0,"P\\Tests\\Api\\SelfserveFixtureApiTest::__pest_evaluable_it_creates_a_comprehensive_self_serve_API_scenario_with_demo_relays":0,"P\\Tests\\Api\\SelfserveZZZShellyGuardApiTest::__pest_evaluable_it_did_not_record_any_real_Shelly_request_attempts_during_self_serve_API_tests":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_serializes_questions__conditions__tasks__scopes__and_gateways_into_one_graph":0.239,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_derives_studio_vehicle_type_lookup_rows_from_selectable_wash_products":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_applies_saved_layout_without_changing_graph_semantics":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_keeps_layout_loading_compatible_with_native_PDO_named_placeholders":0.013,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_builds_guided_simulator_debug_payload_with_blockers_and_canvas_annotations":0.028,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_resolves_simulator_gateway_service_bindings_from_lane_relay_slots":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_persists_allowed_services_without_relay_writes_for_pre_start_wash_setup":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_the_all_in_one_self_serve_studio_replacement_endpoints":0.009,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_continues_start_when_entrance_relay_dispatch_times_out_ambiguously":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_still_fails_start_for_non_timeout_entrance_relay_errors":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_parses_deferred_relay_side_effects_on_start_command_arguments":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_skips_cleaner_and_machine_relay_side_effects_when_start_asks_to_defer_them":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_keeps_cleaner_and_machine_relay_side_effects_for_normal_start_commands":0,"P\\Tests\\Unit\\Selfserve\\SelfservePropertyGatePermissionBypassTest::__pest_evaluable_it_allows_property_gate_commands_for_customers_with_an_active_wash_in_the_target_department":0,"P\\Tests\\Unit\\Selfserve\\SelfservePropertyGatePermissionBypassTest::__pest_evaluable_it_does_not_bypass_property_gate_permissions_without_a_positive_customer_number":0,"P\\Tests\\Unit\\Selfserve\\SelfservePropertyGatePermissionBypassTest::__pest_evaluable_it_does_not_bypass_property_gate_permissions_when_the_customer_has_no_active_wash_in_the_target_department":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_the_all_in_one_self_serve_studio_replacement_API":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_allows_own_permission_customers_to_preview_borrowed_registration_plates_without_ownership_checks":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_allows_customer_scoped_answers_to_be_stored_for_borrowed_registration_plates":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_does_not_apply_saved_answers_from_another_customer_for_the_same_registration_plate":0.027,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_scopes_saved_self_serve_answers_by_customer_number_and_registration_plate":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_loads_saved_answers_from_the_authenticated_customer_context_instead_of_the_plate_owner":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_does_not_count_shifts_without_punches_when_checkIn_checkOut_are_null":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_overtime_minutes_when_a_saved_shift_end_extends_past_the_approved_original_end":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_does_not_count_removed_approved_time_when_a_saved_shift_end_is_shortened":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_caps_current_slot_hours_to_elapsed_minutes_and_zeroes_future_slots":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_checked_in_shifts_without_checkOut_up_to_occurredUntil":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_keeps_the_main_period_response_local_only_for_booked_state_and_customer_names":0.024,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_streams_the_main_period_response_instead_of_encoding_the_full_payload_at_once":0.033,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_maps_batched_period_transaction_rows_to_the_legacy_transaction_response_shape":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_uses_batched_period_transactions_and_keyed_customer_maps_in_the_main_period_route":0.033,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_falls_back_to_configured_e_conomic_default_department_for_missing_customer_default_department_in_distributions":0.021,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_default_distribution_department_config_value_through_economic_config_updates":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_MySQL_GTID_interval_counts_and_coverage_percentages":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_reports_empty_source_GTID_sets_as_caught_up":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_Redis_offset_percentages_safely":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_MariaDB_GTID_coverage_by_domain_sequence":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_normalizes_public_replication_kind_aliases":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_supports_MariaDB_prerequisites_without_requiring_Oracle_MySQL_variables":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_reports_MariaDB_specific_blockers_when_GTID_or_binary_logging_prerequisites_are_missing":0,"P\\Tests\\Unit\\Replication\\ReplicationSecretBoxTest::__pest_evaluable_it_encrypts_replication_secrets_without_storing_plaintext":0.002,"P\\Tests\\Unit\\Replication\\ReplicationSecretBoxTest::__pest_evaluable_it_builds_active_database_and_redis_config_from_encrypted_bootstrap_snapshots":0,"P\\Tests\\Unit\\Replication\\SuperuserReplicationRouteWiringTest::__pest_evaluable_it_registers_superuser_replication_endpoints_and_permissions":0.019,"P\\Tests\\Unit\\Replication\\SuperuserReplicationRouteWiringTest::__pest_evaluable_it_documents_replication_management_in_openapi":0.016,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_generates_replication_ready_MariaDB_compose_templates_without_embedding_secrets":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_generates_Redis_replica_compose_templates_with_primary_connection_placeholders":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_does_not_require_replica_SQL_threads_before_database_provisioning_configures_them":0.006,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_creates_the_generated_replication_user_on_the_primary_during_provisioning":0.007,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_detects_missing_database_tables_before_provisioning_a_preseeded_replica":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_allows_failed_replicas_to_be_removed_without_allowing_primary_or_healthy_replica_removal":0.033,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_seeds_MariaDB_replicas_in_place_instead_of_requiring_container_recreation":0.024,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_replication_operation_progress_schema_idempotent_for_existing_installs":0.039,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_limits_MariaDB_log_table_seeding_to_the_last_month":0.002,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_skips_log_table_data_during_MariaDB_seeding_and_replication":0.003,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_operational_log_tables_schema_only_during_MariaDB_seeding_and_replication":0.005,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_provisions_Redis_replicas_after_a_connectivity_only_preflight_and_reports_sync_progress":0.015,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_Redis_promotion_caught_up__durable__and_metadata_safe":0.007,"P\\Tests\\Unit\\Auth\\CreateTokenTest::__pest_evaluable_it_creates_auth_tokens_and_reports_missing_customer_users_clearly":0.271,"P\\Tests\\Unit\\Auth\\EconomicCreateCustomerResponseTest::__pest_evaluable_it_returns_the_raw_upstream_create_response_and_preserves_the_requested_payload":0,"P\\Tests\\Unit\\Auth\\RegisterCvrLegacyScriptTest::__pest_evaluable_it_keeps_the_legacy_register_cvr_route_harness_passing":0.043,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_raw_202_gather_response_for_initial_department_selection":0.205,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_accepts_legacy_initial_webhook_payloads_with_top_level_call_identifiers":0.005,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_flow_data_gather_payload_after_department_selection_in_native_flow_mode_when_distinct_gate_choices_exist":0.002,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_opens_the_gate_immediately_after_department_selection_when_entrance_and_exit_resolve_to_the_same_gate_in_native_flow_mode":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_flow_data_completion_payload_after_gate_confirmation_in_native_flow_mode":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_second_stage_raw_202_gather_response_after_department_selection_when_distinct_gate_choices_exist":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_opens_the_shared_gate_immediately_after_department_selection_in_raw_command_mode":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_opens_the_selected_gate_and_clears_redis_state_on_final_selection":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_reprompts_with_invalid_selection_while_keeping_webhook_state":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_uses_fallback_dtmf_extraction_when_event_gather_keys_are_missing":0.001,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_prompts_for_department_selection_even_when_only_one_eligible_department_exists":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_prompts_for_a_single_available_gate_type_and_opens_only_after_explicit_confirmation":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_supports_multi_digit_department_selections_before_gate_confirmation":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_direct_raw_200_completion_when_no_departments_are_eligible":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_continues_with_the_first_gather_prompt_when_backend_call_acceptance_fails":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_business_failure_raw_200_response_when_gate_opening_fails":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_deterministic_department_digit_map_without_capping_options":0.009,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_compact_gate_option_maps_and_resolves_selected_gate_type_by_digit":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_department_prompts_with_multi_digit_guidance_and_compact_gate_prompts":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_normalizes_menu_digit_input_from_Bird_dtmf_payload_values":0,"P\\Tests\\Unit\\Bird\\DepartmentGatesPhoneCallOpenTest::__pest_evaluable_it_forwards_phone_number_and_call_duration_threshold_to_the_Bird_gate_helper":0.002,"P\\Tests\\Unit\\Bird\\DepartmentGatesPhoneCallOpenTest::__pest_evaluable_it_wraps_Bird_helper_failures_and_reports_them_to_Slack":0,"P\\Tests\\Unit\\Bookings\\NonPosBookingCompletionRemovalTest::__pest_evaluable_it_unregisters_legacy_booking_completion_forms":0.13,"P\\Tests\\Unit\\Bookings\\NonPosBookingCompletionRemovalTest::__pest_evaluable_it_keeps_legacy_wash_certificate_downloads_but_disables_generation_and_completion":0.166,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsComplaintsOpenApiSpecTest::__pest_evaluable_it_documents_the_daily_report_complaints_CRUD_and_customer_lookup_endpoints_in_openapi":0.011,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsComplaintsRouteContractTest::__pest_evaluable_it_wires_complaint_create__lookup__list__edit__and_delete_routes_with_validation_and_parsing":0.01,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOutsideHoursOpenApiSpecTest::__pest_evaluable_it_documents_outside_hours_summary_and_trend_schemas_in_openapi":0.016,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOutsideHoursRouteContractTest::__pest_evaluable_it_wires_outside_hours_summary_and_trend_endpoints_through_the_dedicated_statistics_service":0.007,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOutsideHoursRouteContractTest::__pest_evaluable_it_initializes_the_outside_hours_statistics_service_before_building_the_transaction_count_summary_payload":0.006,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewOpenApiSpecTest::__pest_evaluable_it_documents_the_daily_report_overview_endpoint_and_reusable_schemas_in_openapi":0.013,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_builds_the_overview_payload_from_batched_repository_data_with_deterministic_tile_states":0.013,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_marks_overtime_unavailable_when_not_every_selected_department_can_be_mapped_to_workfeed":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_normalizes_department_id_input_from_csv_strings_and_nested_values":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_limits_overtime_counting_to_the_selected_reporting_range":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_does_not_count_negative_approved_overtime_when_a_saved_end_shortens_the_shift":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_ignores_late_unapproved_administrative_edits_when_calculating_overtime":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_wires_the_overview_route_to_batched_repository_methods_and_overview_path":0.013,"P\\Tests\\Unit\\DailyReports\\DepartmentOutsideHoursStatisticsServiceTest::__pest_evaluable_it_counts_only_outside_hours_washes_and_flags_missing_opening_hours_without_counting_them_as_closed":0.002,"P\\Tests\\Unit\\DailyReports\\DepartmentOutsideHoursStatisticsServiceTest::__pest_evaluable_it_deduplicates_linked_washes_with_self_serve_first__then_xlvask__then_orders":0,"P\\Tests\\Unit\\DailyReports\\DepartmentOutsideHoursStatisticsServiceTest::__pest_evaluable_it_builds_daily_trend_points_with_per_day_missing_hours_diagnostics":0.001,"P\\Tests\\Unit\\Database\\DbObjectRedisNamespaceSafetyTest::__pest_evaluable_it_keeps_db_object_Redis_access_namespace_safe":0.008,"P\\Tests\\Unit\\DynamicImages\\DepartmentLaneDynamicImageRouteTest::__pest_evaluable_it_allows_studio_lane_dynamic_image_previews_to_override_the_saved_image_id":0.012,"P\\Tests\\Unit\\DynamicImages\\DepartmentLaneDynamicImageRouteTest::__pest_evaluable_it_accepts_ordered_dynamic_image_button_tokens_including_reset_start_and_zero":0.003,"P\\Tests\\Unit\\DynamicImages\\DepartmentLaneDynamicImageRouteTest::__pest_evaluable_it_renders_machine_one_dynamic_image_steps_from_the_ordered_button_payload":0.014,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_uses_queued_job_payload_customer_context_before_result_data_exists":0,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_exposes_collected_invoice_queue_monitor_and_per_user_terminal_clear_routes":0.008,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_runs_collected_invoice_queue_batches_through_an_explicit_manual_endpoint":0.026,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_bounds_e_conomic_curl_calls_below_the_PHP_request_timeout":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersDiscountFallbackTest::__pest_evaluable_it_falls_back_to_a_later_customer_template_product_when_earlier_probes_fail_on_missing_currency_prices":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersDiscountFallbackTest::__pest_evaluable_it_returns_zero_when_every_customer_template_lookup_fails_due_to_missing_currency_prices":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersDiscountFallbackTest::__pest_evaluable_it_rethrows_unrelated_discount_lookup_failures":0,"P\\Tests\\Unit\\Invoicing\\EconomicDraftCustomerOpenApiSpecTest::__pest_evaluable_it_documents_transaction_draft_customer_config_and_auth_runtime_fields_in_all_tracked_openapi_copies":0.306,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueCronEntrypointWiringTest::__pest_evaluable_it_wires_root_cron_entrypoint_to_the_full_cron_scheduler_with_queue_worker_tasks":0.009,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_additive_collected_invoice_queue_pagination_metadata_and_retry_conflict_semantics":0.009,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_exposes_an_explicit_collected_invoice_transfer_queue_run_route_instead_of_ticking_read_endpoints":0.013,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_keeps_order_transfer_queue_status_routes_read_only":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_assigns_classified_booked_department_75_amounts_to_fallback_when_no_monthly_basis_exists":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_fixed_pricing_using_the_configured_fallback_department":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_wash_subscriptions_using_the_configured_fallback_department":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_uses_the_configured_fallback_department_when_a_subscription_has_no_customer_department_basis":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_excludes_orphaned_customer_traces_from_fixed_pricing_and_customer_price_distributions":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_excludes_orphaned_customers_from_subscription_fallback_versions":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceOrderOverrideTest::__pest_evaluable_it_inherits_department_eligibility_when_no_order_override_is_set":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceOrderOverrideTest::__pest_evaluable_it_allows_an_order_level_include_override_to_overrule_department_exclusion":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceOrderOverrideTest::__pest_evaluable_it_allows_an_order_level_exclude_override_to_overrule_department_inclusion":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_builds_deterministic_automatic_flag_fingerprints_and_interactive_price_message_parts":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_builds_interactive_message_parts_for_order_and_wash_certificate_warnings":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_includes_order_item_preview_context_for_required_order_field_warnings":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_allows_tank_cleaning_products_for_only_tank_cleaning_customers":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_does_not_flag_interior_wash_variants_as_historical_primary_product_mismatches":0.001,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_does_not_report_duplicate_primary_vehicle_products_from_duplicated_detector_rows_for_the_same_order_item":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_uses_attached_wash_certificate_documents_instead_of_safety_seal_text_for_certificate_presence":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_loads_wash_certificate_attachment_presence_from_order_attachment_content":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_uses_the_highest_customer_specific_discount_in_expected_price_breakdowns":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_uses_a_preloaded_e_conomic_global_discount_in_expected_price_breakdowns":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_does_not_report_a_price_mismatch_when_a_product_specific_discount_makes_the_expected_price_zero":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_preloads_and_caches_missing_e_conomic_discounts_before_price_mismatch_detection":0.04,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_seeds_order_item_preview_cache_from_period_rows":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_sorts_manual_flags_before_automatic_warnings_and_preserves_legacy_circle_indicators_without_flags":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_scopes_invoice_period_flags_to_the_customer_card_that_can_render_them":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_keeps_order_item_preview_context_compact_for_the_period_response":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_formats_stored_manual_flags_with_the_creating_superuser_display_name":0.003,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_validates_supported_manual_flag_fields_by_target_type":0.136,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_wires_invoice_period_flag_routes_with_explicit_list_create_and_update_permissions":0.043,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_uses_the_users_display__name_column_in_detector_queries":0.115,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_aggregates_customer_price_overrides_by_customer_number_for_price_mismatch_detection":0.115,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_guards_optional_customer_vehicle_deleted__at_filtering_behind_a_column_check":0.097,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_limits_historical_primary_product_lookup_to_current_period_registrations":0.08,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_provides_a_batched_plain_row_transaction_query_for_invoicing_period_responses":0.11,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_adds_guarded_composite_indexes_for_invoicing_period_lookups":0.155,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_blocks_invoicing_when_all_actionable_transactions_are_backed_by_valid_e_conomic_drafts":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_keeps_invoicing_available_when_valid_drafts_only_cover_part_of_the_actionable_work":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_excludes_errored__booked__deleted__and_missing_external_id_invoice_collections_at_query_time":0.027,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_still_checks_valid_drafts_when_the_collection_table_has_no_deleted_marker_column":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_blocks_fixed_pricing_and_subscription_customer_level_work_when_a_relevant_valid_draft_exists":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_keeps_queue_blocking_ahead_of_the_draft_label_when_all_work_is_covered_by_queue_or_draft_state":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_detects_paginated_period_mode_only_when_pagination_parameters_are_present":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_normalizes_period_pagination_options_and_clamps_invalid_page_and_limit_values":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_slices_only_the_active_period_view_and_keeps_exact_full_result_type_counts":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_returns_the_entire_active_period_view_when_the_limit_is_all":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_searches_customer_fields_and_order_fields_at_the_customer_card_level":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_applies_requires_action_and_booked_visibility_filters_before_counting_and_slicing":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_normalizes_targeted_customer_number_filters_from_comma_separated_or_repeated_values":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_filters_period_customer_number_candidates_to_targeted_customers_only":0,"P\\Tests\\Unit\\MotorApi\\MotorApiCachedResultTest::__pest_evaluable_it_only_writes_cached_MotorAPI_metadata_when_a_response_object_exists":0,"P\\Tests\\Unit\\Orders\\OrderBookingsCompletionDedupTest::__pest_evaluable_it_attaches_and_emails_a_wash_certificate_when_a_booking_is_already_linked_to_a_pos_order_without_one":0,"P\\Tests\\Unit\\Orders\\OrderBookingsCompletionDedupTest::__pest_evaluable_it_does_not_create_or_email_a_duplicate_wash_certificate_when_a_linked_pos_order_already_has_one":0,"P\\Tests\\Unit\\Orders\\OrderBookingsCompletionDedupTest::__pest_evaluable_it_keeps_standalone_booking_completion_behavior_unchanged_for_wash_certificates":0,"P\\Tests\\Unit\\Orders\\OrdersAutoWashCertificateCompletionTest::__pest_evaluable_it_auto_attaches_a_wash_certificate_on_order_completion_when_a_wash_certificate_item_is_present":0.001,"P\\Tests\\Unit\\Orders\\OrdersAutoWashCertificateCompletionTest::__pest_evaluable_it_keeps_order_completion_idempotent_when_a_wash_certificate_is_already_attached":0,"P\\Tests\\Unit\\Orders\\OrdersAutoWashCertificateCompletionTest::__pest_evaluable_it_allows_blank_safety_seal_values_when_auto_attaching_a_wash_certificate_on_completion":0,"P\\Tests\\Unit\\Orders\\OrdersIncludeInInvoiceOverrideTest::__pest_evaluable_it_lets_the_order_level_override_include_an_otherwise_excluded_order":0,"P\\Tests\\Unit\\Orders\\OrdersIncludeInInvoiceOverrideTest::__pest_evaluable_it_lets_the_order_level_override_exclude_an_otherwise_included_order":0,"P\\Tests\\Unit\\Orders\\OrdersIncludeInInvoiceOverrideTest::__pest_evaluable_it_falls_back_to_the_department_invoicing_rule_when_the_override_is_null":0,"P\\Tests\\Unit\\Orders\\OrdersIncludeInInvoiceOverrideTest::__pest_evaluable_it_serializes_both_raw_and_effective_include__in__invoice_values":0,"P\\Tests\\Unit\\Orders\\OrdersInputNormalizerTest::__pest_evaluable_it_normalizes_sql_and_datetime_local_created__at_values":0,"P\\Tests\\Unit\\Orders\\OrdersInputNormalizerTest::__pest_evaluable_it_rejects_invalid_created__at_values":0,"P\\Tests\\Unit\\Orders\\OrdersInputNormalizerTest::__pest_evaluable_it_normalizes_include__in__invoice_tri_state_inputs":0,"P\\Tests\\Unit\\Orders\\OrdersInputNormalizerTest::__pest_evaluable_it_rejects_invalid_include__in__invoice_values":0,"P\\Tests\\Unit\\Orders\\OrdersRouteSettingsUpdateWiringTest::__pest_evaluable_it_wires_order_create_and_update_routes_through_the_settings_normalizers":0.035,"P\\Tests\\Unit\\Orders\\OrdersRouteSettingsUpdateWiringTest::__pest_evaluable_it_keeps_line_item_invoice_filtering_in_the_order_net_amount_calculation":0.004,"P\\Tests\\Unit\\Orders\\OrdersRouteStripePaymentIntentLifecycleWiringTest::__pest_evaluable_it_wires_mobile_stripe_payment_intent_routes_to_normalized_lifecycle_handling":0.008,"P\\Tests\\Unit\\Orders\\StripePaymentIntentsPersistenceWiringTest::__pest_evaluable_it_wires_stripe_payment_intent_persistence_to_prune_duplicates_and_clear_reader_state_safely":0.104,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_does_not_send_empty_mget_commands_to_redis":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_generates_MinIO_replica_compose_templates_without_embedding_secrets":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_MinIO_free_space_and_catch_up_math_safely":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_wires_MinIO_replication_through_routes_and_bootstrap_snapshots":0.07,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_nested_v2_ALL_and_ANY_expression_trees_with_trace_output":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_v2_if__else_if__and_else_condition_branches_in_order":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_v2_case_expressions_against_question_values":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_returns_false_and_traces_v2_condition_expression_cycles":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_fails_validation_when_nested_conditions_form_cycles":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_migrates_legacy_AND_and_OR_rules_into_grouped_v2_condition_expressions":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_repairs_legacy_defaulted_always_task_gates_during_v2_normalization":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_does_not_let_legacy_defaulted_always_task_gates_bypass_validation":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_rejects_unsupported_legacy_task_target_rules_after_migration":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_validates_v2_expressions_for_empty_used_conditions__missing_refs__invalid_operators__and_cycles":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_validates_nested_v2_branch_and_case_expressions":0,"P\\Tests\\Unit\\Selfserve\\SelfserveInProgressWashAccessTest::__pest_evaluable_it_keeps_own_in_progress_self_serve_wash_details_visible_to_the_customer":0,"P\\Tests\\Unit\\Selfserve\\SelfserveInProgressWashAccessTest::__pest_evaluable_it_redacts_another_customers_in_progress_wash_details_during_customer_lane_polling":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_the_physical_machine_ON_signal_was_recorded__then_turns_off_cleaner_and_machine_relays":0.014,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_no_physical_machine_ON_signal_was_recorded_and_only_disables_configured_relays":0.009,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_does_not_use_selector_relay_online_status_as_machine_wash_billing_evidence":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_continues_STOP_when_exit_relay_dispatch_times_out_ambiguously":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_still_fails_STOP_for_non_timeout_exit_relay_errors":0.012,"P\\Tests\\Unit\\Selfserve\\SelfserveMachineSignalTest::__pest_evaluable_it_normalizes_Shelly_input_toggle_ON_events_as_machine_start_signals":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveMachineSignalTest::__pest_evaluable_it_normalizes_Shelly_switch_ON_events_and_nested_status_payloads":0,"P\\Tests\\Unit\\Selfserve\\SelfserveMachineSignalTest::__pest_evaluable_it_recognizes_Shelly_OFF_events_but_does_not_treat_them_as_billable_machine_starts":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_local_edge_gateway_machine_ON_signal_monitor_endpoints":0.021,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_lane_level_self_serve_toggles_through_lane_APIs__guest_payloads__and_edge_workspace_readiness":0.011,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_returns_authoritative_allowed_service_state_from_self_serve_session_summaries":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_filters_machine_button_tasks_out_of_self_serve_snapshots_when_MACHINE_is_not_allowed":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_blocks_new_self_serve_eligibility_and_session_sync_for_disabled_lanes":0.009,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_adds_lane_level_self_serve_enablement_for_existing_department_lanes":0.002,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_creates_canvas_only_self_serve_studio_layout_storage":0.002,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_uses_mysql_safe_identifiers_for_self_serve_studio_virtual_hardware_storage":0.009,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_serializes_configurable_studio_actions_with_event__gate__scope__and_ordering_edges":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_validates_action_configuration_and_keeps_warnings_non_blocking":0.018,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_serializes_v2_condition_expressions_without_standalone_rule_nodes":0.05,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_serializes_branch_and_case_condition_expression_dependencies":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_keeps_runtime_on_published_v2_configs_and_leaves_draft_JSON_as_the_studio_edit_surface":0.008,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_surfaces_task_attachments_in_studio_graph__simulator__and_flow_responses":0.016,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_exposes_dynamic_images_and_referenced_machine_types_as_studio_lookup_choices":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_keeps_lane_management_fields_on_lane_scope_nodes":0.024,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_routes_studio_lane_graph_operations_through_department__lanes":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_projects_visible_question_answer_paths_into_grouped_task_service_and_signal_outcomes":0.011,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_truncates_path_outcome_projection_when_the_state_cap_is_reached":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_returns_complete_terminal_path_results_for_wide_question_trees_and_reports_progress":0.364,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_generates_and_merges_virtual_hardware_as_studio_only_relay_coverage":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_renders_virtual_gateway_nodes_and_task_service_edges_in_the_studio_graph":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_inserts_configured_action_signals_into_the_simulator_timeline_in_runtime_order":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_simulates_lane_scoped_wash_start_actions_for_property_gates_and_lane_entrance_ports":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_adds_ordered_simulator_signal_timeline_rows_for_virtual_hardware_dry_runs":0,"P\\Tests\\Unit\\Selfserve\\SelfserveWashFlowMachineAllowedWiringTest::__pest_evaluable_it_infers_legacy_defaulted_always_task_gates_from_condition__id_at_runtime":0,"P\\Tests\\Unit\\Selfserve\\SelfserveWashSessionStateTest::__pest_evaluable_it_treats_terminal_self_serve_wash_session_statuses_as_closed":0,"P\\Tests\\Unit\\Selfserve\\SelfserveWashSessionStateTest::__pest_evaluable_it_freezes_elapsed_self_serve_wash_minutes_at_completion_time":0,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_exposes_chauffeur_management_endpoints_on_the_subusers_route":0.728,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_includes_grant_management_fields_in_the_subusers_payload_builder":0.014,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_links_grant_disable_operations_to_SUBUSERS__DELETE_for_own_customer_managers":0.022,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_prevents_own_customer_managers_from_editing_driver_owned_account_profiles":0.018,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_only_allows_invite_resend_while_setup_is_still_pending":0.028,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusOpenApiSpecTest::__pest_evaluable_it_documents_the_superuser_system_status_snapshot_endpoint_in_openapi":0.008,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusOpenApiSpecTest::__pest_evaluable_it_defines_the_reusable_system_status_schemas_and_enums":0.006,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusRouteWiringTest::__pest_evaluable_it_registers_the_aggregated_superuser_system_status_endpoint_and_permission":0.006,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusRouteWiringTest::__pest_evaluable_it_keeps_the_legacy_database_status_endpoint_wired_through_the_shared_snapshot_service":0.004,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reduces_overall_status_using_down_and_degraded_precedence":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_runtime_usage_percentages_consistently":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reuses_cached_module_probes_only_when_the_ttl_is_still_valid_and_force_is_false":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"success\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"unauthorized\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"forbidden\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"rate limited\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"server error\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"no response\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_transport_errors_as_down":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_normalizes_and_deduplicates_warning_entries_for_snapshots":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_interprets_recaptcha_probe_payloads_safely with data set \"dataset \"dummy token rejected but credentials valid\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_interprets_recaptcha_probe_payloads_safely with data set \"dataset \"invalid secret is down\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_interprets_recaptcha_probe_payloads_safely with data set \"dataset \"unexpected validation errors degrade\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"recaptcha\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"email\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"motorapi\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"fxratesapi\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"weatherapi\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"workfeed\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"gatewayapi\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"xlvask\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"limble\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"license plate recognizer\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"bird\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_uses_runtime_economic_credentials_for_the_economic_probe":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_returns_down_without_attempting_economic_http_calls_when_runtime_credentials_are_missing":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reports_backup_probe_failures_from_local_validation":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_returns_configured_for_shelly_when_no_known_device_id_is_available_for_probing":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_an_authenticated_shelly_status_probe_when_a_known_device_id_exists":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_validates_selfserve_schema_and_minute_product_configuration":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reports_invalid_selfserve_minute_configuration_before_touching_the_schema":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reports_missing_selfserve_minute_products":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_surfaces_selfserve_bootstrap_failures":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_adds_localization_metadata_for_disabled_and_missing_config_modules":40.262,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_marks_newly_supported_modules_as_probe_backed_and_leaves_only_truly_unsupported_modules_as_configuration_only":38.499,"P\\Tests\\Unit\\SystemStatus\\SystemSessionActivityTrackerTest::__pest_evaluable_it_detects_device_types_from_common_user_agents":0,"P\\Tests\\Unit\\SystemStatus\\SystemSessionActivityTrackerTest::__pest_evaluable_it_treats_recent_sessions_as_active_within_the_configured_activity_window":0,"P\\Tests\\Unit\\SystemStatus\\SystemSessionActivityTrackerTest::__pest_evaluable_it_converts_database_utc_datetimes_into_timezone_aware_iso_strings":0,"P\\Tests\\Unit\\SystemStatus\\SystemSessionActivityTrackerTest::__pest_evaluable_it_treats_timezone_aware_iso_timestamps_as_active_using_absolute_time":0,"P\\Tests\\Unit\\Tooling\\LegacyTestInventoryTest::__pest_evaluable_it_keeps_every_legacy_PHP_test_accounted_for_in_the_manifest":0.489,"P\\Tests\\Unit\\Tooling\\MySqlSchemaCompatibilityTest::__pest_evaluable_it_keeps_schema_bootstrap_SQL_compatible_with_the_MySQL_runner":6.55,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_allows_callers_to_resolve_customer_names_without_e_conomic_fallback":0.003,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_returns_an_empty_customer_name_map_without_touching_cache_for_empty_input":0,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_returns_no_rows_for_empty_array_field_filters":0,"P\\Tests\\Unit\\Users\\UsersRedisNamespaceSafetyTest::__pest_evaluable_it_keeps_users_Redis_access_namespace_safe":0.005,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_builds_department_weather_preload_targets_in_cli_without_a_request_uri":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherPreloadCronWiringTest::__pest_evaluable_it_registers_and_implements_cron_warming_for_workfeed_employee_name_cache":0.007,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_multi_department_slot_statuses_using_worst_severity_when_any_department_is_unhealthy":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_all_unhealthy_multi_department_slot_statuses_as_unhealthy":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_returns_unknown_when_aggregated_multi_department_slot_targets_are_missing":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_returns_unknown_when_aggregated_multi_department_slot_has_no_evaluable_hours":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_returns_unknown_when_aggregated_multi_department_slot_has_not_started_yet":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsRouteWiringTest::__pest_evaluable_it_registers_department_weather_hour_details_route_with_weather_read_and_department_access_checks":0.007,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_weather_hour_contributions_grouped_per_employee_for_a_slot":0.001,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_weather_hour_contributions_for_canonical_nested_workfeed_employee_schema":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_extracts_weather_employee_identity_from_supported_shift_payload_shapes":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_prefers_canonical_workfeed_employee_schema_fields_over_generic_employee_names":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_does_not_use_workfeed_schema_name_fields_as_employee_ids":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_returns_a_null_employee_name_when_no_workfeed_employee_name_is_available":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_resolves_missing_workfeed_employee_names_from_the_employees_endpoint":0.042,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_filters_employee_hour_rows_that_cannot_be_resolved_to_a_workfeed_schema_name":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_resolves_employee_display_name_from_cache_when_shift_payload_lacks_a_name":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_formats_workfeed_employee_display_names_from_the_documented_firstname_lastname_schema":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_prefers_workfeed_schema_names_over_generic_display_name_fields":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_falls_back_to_legacy_display_name_fields_when_schema_names_are_absent":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_ignores_placeholder_workfeed_display_names":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_ignores_employee_id_placeholder_display_names_when_the_id_is_known":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_normalizes_registrations_for_XL_Vask_automation_signatures":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_builds_stable_XL_Vask_automation_item_signatures":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_normalizes_persisted_XL_Vask_usage_log_rows_before_helper_hydration":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_builds_stable_OpenAI_cache_keys_for_identical_automation_input":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_changes_OpenAI_cache_keys_when_automation_eligibility_input_changes":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_declares_a_persistent_OpenAI_cache_table_for_XL_Vask_automation":0.005,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_declares_cached_amount_summary_columns_for_XL_Vask_usage_logs":0.012,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_scores_same_day_orders_with_matching_XL_Vask_products_and_extra_add_ons_as_attach_suggestions":0.003,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_does_not_score_an_order_with_only_the_primary_product_as_a_matching_add_on_attachment":0,"P\\Tests\\Unit\\XLVask\\XLVaskUsageLogHelperTest::__pest_evaluable_it_accepts_persisted_ignore_metadata_from_xlvask_usage_log_rows":0.019,"P\\Tests\\Unit\\XLVask\\XLVaskUsageLogHelperTest::__pest_evaluable_it_calculates_XL_Vask_amount_summaries_without_hydrating_order_item_previews":0.006,"P\\Tests\\Unit\\XLVask\\XLVaskUsageRouteContractTest::__pest_evaluable_it_exposes_direct_linked_order_metadata_on_XL_Vask_usage_order_rows":0.003,"P\\Tests\\Unit\\XLVask\\XLVaskUsageRouteContractTest::__pest_evaluable_it_returns_cached_amount_summaries_on_XL_Vask_usage_order_rows_without_widening_the_usage_log_object_payload":0.003,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_counts_complaint_rows_by_department_and_created__at_reporting_range":0.284,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_updates_and_deletes_complaint_rows":0.005,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_parses_created__by__name_from_the_users_table":0.005,"P\\Tests\\Integration\\DailyReports\\DepartmentOutsideHoursStatisticsServiceIntegrationTest::__pest_evaluable_it_integrates_orders__xlvask__and_self_serve_into_one_outside_hours_summary_with_missing_hours_diagnostics":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_only_collected_invoice_jobs_and_respects_the_manual_batch_limit":0.014,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_the_configured_database_in_integration_mode":0,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_redis_in_integration_mode_when_configured":0,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_minio_in_integration_mode_when_configured":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_redacts_sensitive_release_timeline_payload_fields_recursively":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_verifies_GitHub_sha256_webhook_signatures":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_normalizes_GitHub_repository_identifiers_for_private_repository_access_checks":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_defines_release_manager_schema__routes__permissions__and_system_status_integration_hooks":0.575,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_summarizes_failed_deployments_and_blocks_promotion_until_a_deployment_succeeds":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_the_selected_Coolify_project_and_resolves_server_UUID_from_the_instance_default":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_supports_isolated_stack_mode_and_names_new_Coolify_services_explicitly":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_Coolify_service_payloads_from_raw_compose_without_a_service_type":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_normalizes_Coolify_API_base_URLs_to_the_v1_API_root":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_parses_generated_env_files_for_Coolify_service_env_bulk_updates":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_prefers_public_Coolify_server_hosts_over_Docker_local_addresses":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_blocks_planned_downtime_operations_against_active_replication_primaries":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_allows_failed_Coolify_replica_targets_to_be_removed_after_the_service_disappears":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_retries_Coolify_maintenance_while_linked_replication_provisioning_is_still_incomplete":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_plans_Hetzner_load_balancer_target_and_service_drift_without_mutating_state":0.24,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_does_not_plan_removal_of_the_last_Hetzner_load_balancer_target":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_plans_removal_only_for_disabled_or_deleted_Hetzner_load_balancer_targets":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_defines_Coolify_schema__route_permissions__and_replication_integration_hooks":0.418,"P\\Tests\\Unit\\ErrorReports\\ErrorReportTest::__pest_evaluable_it_redacts_sensitive_error_report_payload_fields_recursively":0.001,"P\\Tests\\Unit\\ErrorReports\\ErrorReportTest::__pest_evaluable_it_validates_supported_screenshot_data_uris":0,"P\\Tests\\Unit\\ErrorReports\\ErrorReportTest::__pest_evaluable_it_defines_error_report_schema__routes__permissions__storage__and_OpenAPI_docs":0.04,"P\\Tests\\Unit\\Http\\ResponseRequestParametersTest::__pest_evaluable_it_reads_JSON_payloads_for_DELETE_request_parameter_arrays":0,"P\\Tests\\Unit\\Http\\ResponseRequestParametersTest::__pest_evaluable_it_keeps_DELETE_query_parameters_when_no_JSON_body_is_present":0,"P\\Tests\\Unit\\Infrastructure\\CorsReleaseHeadersTest::__pest_evaluable_it_allows_release_telemetry_headers_at_PHP_served_CORS_entry_points":0.424,"P\\Tests\\Unit\\Orders\\OrderBookingsCompletionDedupTest::__pest_evaluable_it_uses_the_linked_pos_order_wash_certificate_item_added_during_mobile_completion":0,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_normalizes_failover_config_defaults_and_per_kind_enablement":0,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_requires_strict_fresh_100_percent_replica_status_for_candidates":0,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_selects_the_freshest_eligible_replica_for_failover":0,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_promotes_enabled_startup_dependencies_from_snapshot_in_dependency_order":0.004,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_does_not_promote_a_disabled_dependency_during_startup_failover":0.001,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_wires_the_failover_module_config_endpoint_and_promotion_paths":0.312,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_can_embed_primary_admin_credentials_in_generated_MariaDB_replica_env_files":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_operational_and_derived_tables_schema_only_during_MariaDB_seeding_and_replication":0.021,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_supports_metadata_only_replication_host_renames":0.005,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_MinIO_backup_replicas_bounded_to_the_recent_backup_window":0.014,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_prefills_MinIO_replica_compose_primary_values_from_current_config_when_available":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_allows_the_MinIO_client_binary_to_be_configured_explicitly":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_supports_MinIO_client_runtime_fallback_configuration":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_extracts_host_specific_MariaDB_replication_account_denials":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_identifies_stopped_database_replication_threads_as_a_restartable_status":0,"P\\Tests\\Unit\\Scanner\\ModuleScannerRouteTest::__pest_evaluable_it_returns_no_plate_LPR_results_without_a_failed_HTTP_status":0.046,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_does_not_invent_GHCR_images_for_Coolify_service_payloads":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_release_branch_services_out_of_the_production_Coolify_environment_except_beta":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_Coolify_GitHub_App_application_payloads_so_pulls_use_the_app_token":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_does_not_treat_an_existing_Coolify_service_as_an_application_just_because_a_GitHub_App_UUID_is_stored":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_can_use_the_Coolify_instance_default_GitHub_App_when_source_targets_do_not_store_it_yet":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_updates_existing_frontend_Coolify_applications_away_from_legacy_Nixpacks_detection":0,"P\\Tests\\Unit\\Auth\\EconomicCreateCustomerResponseTest::__pest_evaluable_it_adds_supported_CVR_company_fields_to_the_e_conomic_customer_payload":0.014,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_release_channel_runtime_availability_independent_from_channel_URLs":0.007,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_chooses_a_requested_runtime_channel_only_when_it_is_available_to_the_principal":0.199,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_normalizes_release_assignment_subject_suggestions_without_leaking_private_fields":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_GitHub_commit_timestamps_in_public_release_manager_commit_payloads":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_requires_non_default_release_channel_runtime_URLs_and_preserves_load_balancer_paths":0.105,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_exposes_release_version_git_commit_metadata_for_runtime_channel_cards":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_builds_gateway_API_auto_provision_context_for_connected_Coolify_servers":0.001,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_adds_explicit_Coolify_application_route_labels_for_gateway_API_domains":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_builds_explicit_Coolify_application_route_labels_for_release_API_targets":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_the_self_contained_Coolify_API_Dockerfile_for_API_applications":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_isolates_and_restores_Hetzner_load_balancer_IP_targets_for_gateway_certificate_bootstrap":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_requires_gateway_ping_probes_to_return_the_API_ping_contract":0,"P\\Tests\\Unit\\Tooling\\ComposerEntrypointTest::__pest_evaluable_it_checks_PSR_HTTP_message_interfaces_before_trusting_a_Composer_vendor_tree":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_marks_a_channel_ready_when_all_release_services_are_healthy":0.001,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_reports_non_default_channels_missing_bundles__versions__and_URLs_as_blocking_missing_values":0.024,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_surfaces_failed_latest_deployments_as_critical_promotion_blockers":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_flags_isolated_stacks_that_are_missing_database_Redis_and_MinIO_services":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_maps_degraded_Coolify_targets_and_reconcile_failures_to_release_service_issues":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_plans_Hetzner_load_balancer_service_health_check_drift_updates":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_returns_structured_errors_for_failed_gateway_certificate_bootstrap_and_verification":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_captures_release_request_context_from_headers_and_runtime_query_parameters":0.004,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_normalizes_channel_prefixed_API_ingress_paths_before_route_dispatch":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_frontend_Coolify_GitHub_App_application_payloads_with_the_release_Dockerfile":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_builds_gateway_frontend_auto_provision_context_with_the_release_Dockerfile":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_allows_explicit_runtime_selection_of_any_enabled_release_channel":0.289,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_chooses_requested_runtime_channels_from_available_channels_or_enabled_channel_slugs":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_normalizes_gateway_probe_paths_for_release_gateway_health_checks":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_resolves_release_deployment_endpoints_from_manual_overrides__URLs__health_checks__and_gateway_defaults":0.001,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_does_not_report_frontend_or_API_URLs_missing_when_target_auto_endpoints_are_resolvable":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_offers_a_release_bundle_action_when_exactly_one_deployed_bundle_is_eligible":0.008,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_offers_application_target_preparation_for_path_routed_Coolify_failures":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_maps_the_public_master_API_prefix_to_the_stable_release_channel":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_master_as_the_public_route_slug_for_the_stable_release_channel":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_does_not_block_channels_on_unhealthy_data_targets_when_data_services_are_production_shared":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_ignores_missing_legacy_bundles_but_still_blocks_on_missing_versions_and_URLs":0.001,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_marks_a_branch_based_channel_ready_without_a_release_bundle_when_app_versions_and_URLs_exist":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_does_not_surface_legacy_release_bundle_actions_as_readiness_blockers":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_adds_exact_hidden_question__skipped_action__signal__and_button_decision_causes":0,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_rejects_standalone_order_booking_completion_outside_POS":5.621,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_allows_linked_POS_order_booking_completion_for_mobile_POS_compatibility":4.121,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_accepts_numeric_safety_seal_strings_when_completing_a_linked_POS_order_booking":4.366,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_disables_the_legacy_complete_wash_without_certificate_route":1.122,"P\\Tests\\Api\\OrderBookingsUpdateApiTest::__pest_evaluable_it_detaches_an_order_booking_and_clears_the_matching_order_link":5.559,"P\\Tests\\Api\\OrderBookingsUpdateApiTest::__pest_evaluable_it_keeps_the_linked_order_when_order__id_is_omitted_from_an_order_booking_update":5.22,"P\\Tests\\Unit\\DynamicImages\\DepartmentLaneDynamicImageRouteTest::__pest_evaluable_it_accepts_ordered_dynamic_image_button_tokens_including_program_picker_reset_start_and_zero":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_adds_program_picker_before_mapped_machine_buttons_in_simulator_debug_decisions":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_verifies_CI_release_gate_bearer_tokens_from_dedicated_release_credentials":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_only_chooses_requested_runtime_channels_from_channels_available_to_the_principal":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_ignores_explicit_runtime_selection_for_channels_outside_the_principal_channel_set":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_auto_prepares_path_routed_release_targets_for_Coolify_application_creation":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_offers_application_target_preparation_for_missing_Coolify_service_creation_failures":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_builds_API_Coolify_runtime_environment_from_allowed_process_variables":0.001,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_normalizes_URL_like_CORS_entries_to_origins":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_merges_required_release_and_existing_frontend_origins_into_configured_CORS":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_builds_credential_safe_normal_CORS_response_headers_for_allowed_origins":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_builds_preflight_CORS_response_headers_for_api_v2_release_URLs":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_rejects_unknown_CORS_origins":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_reflects_the_request_origin_for_wildcard_CORS_instead_of_sending_credentialed_wildcard_headers":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_allows_customers_with_own_self_serve_permission_to_use_enabled_self_serve_lanes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_blocks_customer_lane_mutations_without_own_self_serve_permission":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_blocks_customer_lane_mutations_when_the_lane_is_not_operationally_enabled":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_allows_active_wash_operations_when_the_lane_runtime_belongs_to_the_customer":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_falls_back_to_active_department_sessions_for_customer_active_wash_operations":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_blocks_active_wash_operations_for_other_customers":0,"P\\Tests\\Unit\\Selfserve\\SelfservePropertyGatePermissionBypassTest::__pest_evaluable_it_does_not_bypass_property_gate_permissions_without_customer_self_serve_permission":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_keeps_active_wash_operations_available_if_a_lane_is_disabled_after_start":0,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_requires_authentication_before_checking_in_progress_wash_permissions":0,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_reports_both_elevated_and_customer_self_serve_permissions_when_lane_polling_is_not_allowed":0,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_allows_customer_self_serve_permission_to_view_their_own_in_progress_wash_details":0,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_redacts_another_customers_in_progress_wash_from_customer_self_serve_lane_polling":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_beta_API_runtime_environment_on_production_database_target":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_treats_attach_existing_service_sets_without_data_target_ids_as_production_shared_ready":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_rejects_beta_release_bundles_that_resolve_to_isolated_cloned_or_fresh_data_services":0.01,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_detects_explicit_data_target_ids_so_beta_service_sets_can_stay_data_only":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_resolves_backend_commit_sha_from_API_runtime_environment_in_priority_order":0.009,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_injects_selected_API_commit_into_Coolify_runtime_env_unless_explicitly_set":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_allows_beta_production_service_bundles_only_when_data_services_stay_production_shared":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_release_branch_services_out_of_the_production_Coolify_environment":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_marks_beta_ready_from_the_production_frontend_and_API_services":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_uses_program_picker_button_numbers_as_thumb_selectors_in_simulator_debug_decisions":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_upserts_path_editor_answers_into_generated_condition_and_task_config_rows":0.021,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_marks_projected_path_confirmations_confirmed_or_stale_by_stable_signatures":0,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_creates_self_serve_studio_path_confirmation_storage":0.003,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_resolves_subuser_customer_names_without_external_lookups_during_list_requests":0.208}} \ No newline at end of file diff --git a/services/nginx/app/build/logs/api-server.err.log b/services/nginx/app/build/logs/api-server.err.log new file mode 100644 index 00000000..094e7aaf --- /dev/null +++ b/services/nginx/app/build/logs/api-server.err.log @@ -0,0 +1,12206 @@ +[Mon Apr 13 08:58:37 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Mon Apr 13 08:58:37 2026] 127.0.0.1:50196 Accepted +[Mon Apr 13 08:58:38 2026] 127.0.0.1:50196 Closing +[Mon Apr 13 08:58:38 2026] 127.0.0.1:50212 Accepted +[Mon Apr 13 08:58:39 2026] 127.0.0.1:50212 Closing +[Mon Apr 13 08:58:39 2026] 127.0.0.1:50216 Accepted +[Mon Apr 13 08:58:39 2026] 127.0.0.1:50216 Closing +[Mon Apr 13 08:58:40 2026] 127.0.0.1:50224 Accepted +[Mon Apr 13 08:58:40 2026] 127.0.0.1:50224 Closing +[Mon Apr 13 08:58:40 2026] 127.0.0.1:50230 Accepted +[Mon Apr 13 08:58:39 2026] 127.0.0.1:50230 Closing +[Mon Apr 13 09:00:35 2026] 127.0.0.1:48276 Accepted +[Mon Apr 13 09:00:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:00:36 2026] 127.0.0.1:48276 Closing +[Mon Apr 13 09:00:36 2026] 127.0.0.1:48284 Accepted +[Mon Apr 13 09:00:36 2026] 127.0.0.1:48284 Closing +[Mon Apr 13 09:00:36 2026] 127.0.0.1:53084 Accepted +[Mon Apr 13 09:00:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:00:37 2026] 127.0.0.1:53084 Closing +[Mon Apr 13 09:00:37 2026] 127.0.0.1:53092 Accepted +[Mon Apr 13 09:00:38 2026] 127.0.0.1:53092 Closing +[Mon Apr 13 09:00:38 2026] 127.0.0.1:53102 Accepted +[Mon Apr 13 09:00:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:00:39 2026] 127.0.0.1:53102 Closing +[Mon Apr 13 09:00:39 2026] 127.0.0.1:53104 Accepted +[Mon Apr 13 09:00:40 2026] 127.0.0.1:53104 Closing +[Mon Apr 13 09:00:40 2026] 127.0.0.1:53106 Accepted +[Mon Apr 13 09:00:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:00:41 2026] 127.0.0.1:53106 Closing +[Mon Apr 13 09:00:41 2026] 127.0.0.1:53110 Accepted +[Mon Apr 13 09:00:41 2026] 127.0.0.1:53110 Closing +[Mon Apr 13 09:03:27 2026] 127.0.0.1:37980 Accepted +[Mon Apr 13 09:03:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:27 2026] 127.0.0.1:37980 Closing +[Mon Apr 13 09:03:29 2026] 127.0.0.1:54664 Accepted +[Mon Apr 13 09:03:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:32 2026] 127.0.0.1:54664 Closing +[Mon Apr 13 09:03:33 2026] 127.0.0.1:54676 Accepted +[Mon Apr 13 09:03:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:33 2026] 127.0.0.1:54676 Closing +[Mon Apr 13 09:03:34 2026] 127.0.0.1:54692 Accepted +[Mon Apr 13 09:03:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:35 2026] 127.0.0.1:54692 Closing +[Mon Apr 13 09:03:35 2026] 127.0.0.1:54698 Accepted +[Mon Apr 13 09:03:37 2026] 127.0.0.1:54698 Closing +[Mon Apr 13 09:03:37 2026] 127.0.0.1:54700 Accepted +[Mon Apr 13 09:03:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:38 2026] 127.0.0.1:54700 Closing +[Mon Apr 13 09:03:38 2026] 127.0.0.1:40140 Accepted +[Mon Apr 13 09:03:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:39 2026] 127.0.0.1:40140 Closing +[Mon Apr 13 09:03:39 2026] 127.0.0.1:40144 Accepted +[Mon Apr 13 09:03:40 2026] 127.0.0.1:40144 Closing +[Mon Apr 13 09:03:40 2026] 127.0.0.1:40146 Accepted +[Mon Apr 13 09:03:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:42 2026] 127.0.0.1:40146 Closing +[Mon Apr 13 09:03:42 2026] 127.0.0.1:40152 Accepted +[Mon Apr 13 09:03:44 2026] 127.0.0.1:40152 Closing +[Mon Apr 13 09:03:44 2026] 127.0.0.1:40164 Accepted +[Mon Apr 13 09:03:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:46 2026] 127.0.0.1:40164 Closing +[Mon Apr 13 09:03:47 2026] 127.0.0.1:40170 Accepted +[Mon Apr 13 09:03:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:48 2026] 127.0.0.1:40170 Closing +[Mon Apr 13 09:03:48 2026] 127.0.0.1:33834 Accepted +[Mon Apr 13 09:03:51 2026] 127.0.0.1:33834 Closing +[Mon Apr 13 09:03:52 2026] 127.0.0.1:33836 Accepted +[Mon Apr 13 09:03:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:53 2026] 127.0.0.1:33836 Closing +[Mon Apr 13 09:03:53 2026] 127.0.0.1:33852 Accepted +[Mon Apr 13 09:03:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:54 2026] 127.0.0.1:33852 Closing +[Mon Apr 13 09:03:54 2026] 127.0.0.1:33862 Accepted +[Mon Apr 13 09:03:55 2026] 127.0.0.1:33862 Closing +[Mon Apr 13 09:03:55 2026] 127.0.0.1:33872 Accepted +[Mon Apr 13 09:03:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:55 2026] 127.0.0.1:33872 Closing +[Mon Apr 13 09:03:55 2026] 127.0.0.1:33886 Accepted +[Mon Apr 13 09:03:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:03:57 2026] 127.0.0.1:33886 Closing +[Mon Apr 13 09:03:57 2026] 127.0.0.1:33584 Accepted +[Mon Apr 13 09:03:58 2026] 127.0.0.1:33584 Closing +[Mon Apr 13 09:03:58 2026] 127.0.0.1:33598 Accepted +[Mon Apr 13 09:03:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:00 2026] 127.0.0.1:33598 Closing +[Mon Apr 13 09:04:00 2026] 127.0.0.1:33612 Accepted +[Mon Apr 13 09:04:02 2026] 127.0.0.1:33612 Closing +[Mon Apr 13 09:04:03 2026] 127.0.0.1:33622 Accepted +[Mon Apr 13 09:04:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:05 2026] 127.0.0.1:33622 Closing +[Mon Apr 13 09:04:05 2026] 127.0.0.1:33634 Accepted +[Mon Apr 13 09:04:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:06 2026] 127.0.0.1:33634 Closing +[Mon Apr 13 09:04:06 2026] 127.0.0.1:33650 Accepted +[Mon Apr 13 09:04:08 2026] 127.0.0.1:33650 Closing +[Mon Apr 13 09:04:08 2026] 127.0.0.1:49356 Accepted +[Mon Apr 13 09:04:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:10 2026] 127.0.0.1:49356 Closing +[Mon Apr 13 09:04:11 2026] 127.0.0.1:49372 Accepted +[Mon Apr 13 09:04:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:12 2026] 127.0.0.1:49372 Closing +[Mon Apr 13 09:04:12 2026] 127.0.0.1:49374 Accepted +[Mon Apr 13 09:04:14 2026] 127.0.0.1:49374 Closing +[Mon Apr 13 09:04:14 2026] 127.0.0.1:49378 Accepted +[Mon Apr 13 09:04:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:16 2026] 127.0.0.1:49378 Closing +[Mon Apr 13 09:04:16 2026] 127.0.0.1:34504 Accepted +[Mon Apr 13 09:04:20 2026] 127.0.0.1:34504 Closing +[Mon Apr 13 09:04:21 2026] 127.0.0.1:34506 Accepted +[Mon Apr 13 09:04:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:23 2026] 127.0.0.1:34506 Closing +[Mon Apr 13 09:04:23 2026] 127.0.0.1:34522 Accepted +[Mon Apr 13 09:04:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:23 2026] 127.0.0.1:34522 Closing +[Mon Apr 13 09:04:23 2026] 127.0.0.1:34532 Accepted +[Mon Apr 13 09:04:27 2026] 127.0.0.1:34532 Closing +[Mon Apr 13 09:04:28 2026] 127.0.0.1:53716 Accepted +[Mon Apr 13 09:04:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:28 2026] 127.0.0.1:53716 Closing +[Mon Apr 13 09:04:29 2026] 127.0.0.1:53722 Accepted +[Mon Apr 13 09:04:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:29 2026] 127.0.0.1:53722 Closing +[Mon Apr 13 09:04:29 2026] 127.0.0.1:53726 Accepted +[Mon Apr 13 09:04:33 2026] 127.0.0.1:53726 Closing +[Mon Apr 13 09:04:35 2026] 127.0.0.1:53730 Accepted +[Mon Apr 13 09:04:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:36 2026] 127.0.0.1:53730 Closing +[Mon Apr 13 09:04:37 2026] 127.0.0.1:50014 Accepted +[Mon Apr 13 09:04:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:40 2026] 127.0.0.1:50014 Closing +[Mon Apr 13 09:04:40 2026] 127.0.0.1:50026 Accepted +[Mon Apr 13 09:04:45 2026] 127.0.0.1:50026 Closing +[Mon Apr 13 09:04:45 2026] 127.0.0.1:50032 Accepted +[Mon Apr 13 09:04:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:48 2026] 127.0.0.1:50032 Closing +[Mon Apr 13 09:04:48 2026] 127.0.0.1:47332 Accepted +[Mon Apr 13 09:04:51 2026] 127.0.0.1:47332 Closing +[Mon Apr 13 09:04:52 2026] 127.0.0.1:47342 Accepted +[Mon Apr 13 09:04:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:53 2026] 127.0.0.1:47342 Closing +[Mon Apr 13 09:04:53 2026] 127.0.0.1:47350 Accepted +[Mon Apr 13 09:04:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:04:55 2026] 127.0.0.1:47350 Closing +[Mon Apr 13 09:04:55 2026] 127.0.0.1:49824 Accepted +[Mon Apr 13 09:04:58 2026] 127.0.0.1:49824 Closing +[Mon Apr 13 09:04:59 2026] 127.0.0.1:49826 Accepted +[Mon Apr 13 09:04:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:01 2026] 127.0.0.1:49826 Closing +[Mon Apr 13 09:05:01 2026] 127.0.0.1:49842 Accepted +[Mon Apr 13 09:05:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:04 2026] 127.0.0.1:49842 Closing +[Mon Apr 13 09:05:04 2026] 127.0.0.1:42036 Accepted +[Mon Apr 13 09:05:08 2026] 127.0.0.1:42036 Closing +[Mon Apr 13 09:05:08 2026] 127.0.0.1:42038 Accepted +[Mon Apr 13 09:05:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:09 2026] 127.0.0.1:42038 Closing +[Mon Apr 13 09:05:09 2026] 127.0.0.1:42048 Accepted +[Mon Apr 13 09:05:12 2026] 127.0.0.1:42048 Closing +[Mon Apr 13 09:05:14 2026] 127.0.0.1:38142 Accepted +[Mon Apr 13 09:05:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:16 2026] 127.0.0.1:38142 Closing +[Mon Apr 13 09:05:17 2026] 127.0.0.1:38158 Accepted +[Mon Apr 13 09:05:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:19 2026] 127.0.0.1:38158 Closing +[Mon Apr 13 09:05:19 2026] 127.0.0.1:38160 Accepted +[Mon Apr 13 09:05:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:21 2026] 127.0.0.1:38160 Closing +[Mon Apr 13 09:05:21 2026] 127.0.0.1:38170 Accepted +[Mon Apr 13 09:05:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:25 2026] 127.0.0.1:38170 Closing +[Mon Apr 13 09:05:25 2026] 127.0.0.1:46632 Accepted +[Mon Apr 13 09:05:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:28 2026] 127.0.0.1:46632 Closing +[Mon Apr 13 09:05:28 2026] 127.0.0.1:46646 Accepted +[Mon Apr 13 09:05:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:30 2026] 127.0.0.1:46646 Closing +[Mon Apr 13 09:05:30 2026] 127.0.0.1:46658 Accepted +[Mon Apr 13 09:05:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:34 2026] 127.0.0.1:46658 Closing +[Mon Apr 13 09:05:34 2026] 127.0.0.1:35952 Accepted +[Mon Apr 13 09:05:39 2026] 127.0.0.1:35952 Closing +[Mon Apr 13 09:05:39 2026] 127.0.0.1:35968 Accepted +[Mon Apr 13 09:05:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:44 2026] 127.0.0.1:35968 Closing +[Mon Apr 13 09:05:44 2026] 127.0.0.1:35982 Accepted +[Mon Apr 13 09:05:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:46 2026] 127.0.0.1:35982 Closing +[Mon Apr 13 09:05:46 2026] 127.0.0.1:46256 Accepted +[Mon Apr 13 09:05:46 2026] 127.0.0.1:46264 Accepted +[Mon Apr 13 09:05:48 2026] 127.0.0.1:46256 Closing +[Mon Apr 13 09:05:52 2026] 127.0.0.1:46264 Closing +[Mon Apr 13 09:05:52 2026] 127.0.0.1:46278 Accepted +[Mon Apr 13 09:05:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:55 2026] 127.0.0.1:46278 Closing +[Mon Apr 13 09:05:55 2026] 127.0.0.1:32898 Accepted +[Mon Apr 13 09:05:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:05:57 2026] 127.0.0.1:32898 Closing +[Mon Apr 13 09:05:57 2026] 127.0.0.1:32906 Accepted +[Mon Apr 13 09:05:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:00 2026] 127.0.0.1:32906 Closing +[Mon Apr 13 09:06:00 2026] 127.0.0.1:32916 Accepted +[Mon Apr 13 09:06:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:02 2026] 127.0.0.1:32916 Closing +[Mon Apr 13 09:06:02 2026] 127.0.0.1:59758 Accepted +[Mon Apr 13 09:06:02 2026] 127.0.0.1:59768 Accepted +[Mon Apr 13 09:06:04 2026] 127.0.0.1:59758 Closing +[Mon Apr 13 09:06:07 2026] 127.0.0.1:59768 Closing +[Mon Apr 13 09:06:07 2026] 127.0.0.1:59774 Accepted +[Mon Apr 13 09:06:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:15 2026] 127.0.0.1:59774 Closing +[Mon Apr 13 09:06:15 2026] 127.0.0.1:59780 Accepted +[Mon Apr 13 09:06:18 2026] 127.0.0.1:59780 Closing +[Mon Apr 13 09:06:18 2026] 127.0.0.1:50990 Accepted +[Mon Apr 13 09:06:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:18 2026] 127.0.0.1:50990 Closing +[Mon Apr 13 09:06:18 2026] 127.0.0.1:50994 Accepted +[Mon Apr 13 09:06:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:20 2026] 127.0.0.1:50994 Closing +[Mon Apr 13 09:06:20 2026] 127.0.0.1:57742 Accepted +[Mon Apr 13 09:06:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:23 2026] 127.0.0.1:57742 Closing +[Mon Apr 13 09:06:23 2026] 127.0.0.1:57758 Accepted +[Mon Apr 13 09:06:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:25 2026] 127.0.0.1:57758 Closing +[Mon Apr 13 09:06:25 2026] 127.0.0.1:57764 Accepted +[Mon Apr 13 09:06:28 2026] 127.0.0.1:57764 Closing +[Mon Apr 13 09:06:28 2026] 127.0.0.1:57770 Accepted +[Mon Apr 13 09:06:28 2026] 127.0.0.1:57776 Accepted +[Mon Apr 13 09:06:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:41 2026] 127.0.0.1:57770 Closing +[Mon Apr 13 09:06:41 2026] 127.0.0.1:58678 Accepted +[Mon Apr 13 09:06:45 2026] 127.0.0.1:57776 Closing +[Mon Apr 13 09:06:45 2026] 127.0.0.1:58694 Accepted +[Mon Apr 13 09:06:47 2026] 127.0.0.1:58678 Closing +[Mon Apr 13 09:06:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:48 2026] 127.0.0.1:58694 Closing +[Mon Apr 13 09:06:48 2026] 127.0.0.1:58708 Accepted +[Mon Apr 13 09:06:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:06:52 2026] 127.0.0.1:58708 Closing +[Mon Apr 13 09:06:52 2026] 127.0.0.1:37142 Accepted +[Mon Apr 13 09:06:55 2026] 127.0.0.1:37142 Closing +[Mon Apr 13 09:06:55 2026] 127.0.0.1:37146 Accepted +[Mon Apr 13 09:06:57 2026] 127.0.0.1:37146 Closing +[Mon Apr 13 09:06:57 2026] 127.0.0.1:37154 Accepted +[Mon Apr 13 09:06:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:00 2026] 127.0.0.1:37154 Closing +[Mon Apr 13 09:07:00 2026] 127.0.0.1:37170 Accepted +[Mon Apr 13 09:07:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:02 2026] 127.0.0.1:37170 Closing +[Mon Apr 13 09:07:02 2026] 127.0.0.1:41316 Accepted +[Mon Apr 13 09:07:05 2026] 127.0.0.1:41316 Closing +[Mon Apr 13 09:07:05 2026] 127.0.0.1:41320 Accepted +[Mon Apr 13 09:07:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:13 2026] 127.0.0.1:41320 Closing +[Mon Apr 13 09:07:13 2026] 127.0.0.1:41332 Accepted +[Mon Apr 13 09:07:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:15 2026] 127.0.0.1:41332 Closing +[Mon Apr 13 09:07:15 2026] 127.0.0.1:60894 Accepted +[Mon Apr 13 09:07:15 2026] 127.0.0.1:60902 Accepted +[Mon Apr 13 09:07:17 2026] 127.0.0.1:60894 Closing +[Mon Apr 13 09:07:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:21 2026] 127.0.0.1:60902 Closing +[Mon Apr 13 09:07:21 2026] 127.0.0.1:42384 Accepted +[Mon Apr 13 09:07:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:25 2026] 127.0.0.1:42384 Closing +[Mon Apr 13 09:07:25 2026] 127.0.0.1:42388 Accepted +[Mon Apr 13 09:07:25 2026] 127.0.0.1:42404 Accepted +[Mon Apr 13 09:07:28 2026] 127.0.0.1:42388 Closing +[Mon Apr 13 09:07:37 2026] 127.0.0.1:42404 Closing +[Mon Apr 13 09:07:37 2026] 127.0.0.1:46054 Accepted +[Mon Apr 13 09:07:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:39 2026] 127.0.0.1:46054 Closing +[Mon Apr 13 09:07:39 2026] 127.0.0.1:39844 Accepted +[Mon Apr 13 09:07:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:39 2026] 127.0.0.1:39844 Closing +[Mon Apr 13 09:07:39 2026] 127.0.0.1:39860 Accepted +[Mon Apr 13 09:07:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:40 2026] 127.0.0.1:39860 Closing +[Mon Apr 13 09:07:40 2026] 127.0.0.1:39868 Accepted +[Mon Apr 13 09:07:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:40 2026] 127.0.0.1:39868 Closing +[Mon Apr 13 09:07:40 2026] 127.0.0.1:39870 Accepted +[Mon Apr 13 09:07:41 2026] 127.0.0.1:39870 Closing +[Mon Apr 13 09:07:41 2026] 127.0.0.1:39874 Accepted +[Mon Apr 13 09:07:42 2026] 127.0.0.1:39874 Closing +[Mon Apr 13 09:07:42 2026] 127.0.0.1:39886 Accepted +[Mon Apr 13 09:07:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:43 2026] 127.0.0.1:39886 Closing +[Mon Apr 13 09:07:43 2026] 127.0.0.1:39888 Accepted +[Mon Apr 13 09:07:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:44 2026] 127.0.0.1:39888 Closing +[Mon Apr 13 09:07:44 2026] 127.0.0.1:39904 Accepted +[Mon Apr 13 09:07:43 2026] 127.0.0.1:39904 Closing +[Mon Apr 13 09:07:43 2026] 127.0.0.1:39918 Accepted +[Mon Apr 13 09:07:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:45 2026] 127.0.0.1:39918 Closing +[Mon Apr 13 09:07:45 2026] 127.0.0.1:39930 Accepted +[Mon Apr 13 09:07:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:47 2026] 127.0.0.1:39930 Closing +[Mon Apr 13 09:07:47 2026] 127.0.0.1:39982 Accepted +[Mon Apr 13 09:07:47 2026] 127.0.0.1:39992 Accepted +[Mon Apr 13 09:07:49 2026] 127.0.0.1:39982 Closing +[Mon Apr 13 09:07:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:57 2026] 127.0.0.1:39992 Closing +[Mon Apr 13 09:07:57 2026] 127.0.0.1:39996 Accepted +[Mon Apr 13 09:07:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:07:59 2026] 127.0.0.1:39996 Closing +[Mon Apr 13 09:07:59 2026] 127.0.0.1:57444 Accepted +[Mon Apr 13 09:07:59 2026] 127.0.0.1:57452 Accepted +[Mon Apr 13 09:08:00 2026] 127.0.0.1:57444 Closing +[Mon Apr 13 09:08:04 2026] 127.0.0.1:57452 Closing +[Mon Apr 13 09:08:04 2026] 127.0.0.1:57462 Accepted +[Mon Apr 13 09:08:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:09 2026] 127.0.0.1:57462 Closing +[Mon Apr 13 09:08:09 2026] 127.0.0.1:47136 Accepted +[Mon Apr 13 09:08:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:10 2026] 127.0.0.1:47136 Closing +[Mon Apr 13 09:08:10 2026] 127.0.0.1:47152 Accepted +[Mon Apr 13 09:08:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:12 2026] 127.0.0.1:47152 Closing +[Mon Apr 13 09:08:12 2026] 127.0.0.1:47166 Accepted +[Mon Apr 13 09:08:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:12 2026] 127.0.0.1:47166 Closing +[Mon Apr 13 09:08:12 2026] 127.0.0.1:47168 Accepted +[Mon Apr 13 09:08:12 2026] 127.0.0.1:47180 Accepted +[Mon Apr 13 09:08:13 2026] 127.0.0.1:47168 Closing +[Mon Apr 13 09:08:17 2026] 127.0.0.1:47180 Closing +[Mon Apr 13 09:08:17 2026] 127.0.0.1:47186 Accepted +[Mon Apr 13 09:08:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:20 2026] 127.0.0.1:47186 Closing +[Mon Apr 13 09:08:20 2026] 127.0.0.1:60496 Accepted +[Mon Apr 13 09:08:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:22 2026] 127.0.0.1:60496 Closing +[Mon Apr 13 09:08:22 2026] 127.0.0.1:60508 Accepted +[Mon Apr 13 09:08:22 2026] 127.0.0.1:60518 Accepted +[Mon Apr 13 09:08:23 2026] 127.0.0.1:60508 Closing +[Mon Apr 13 09:08:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:25 2026] 127.0.0.1:60518 Closing +[Mon Apr 13 09:08:25 2026] 127.0.0.1:60532 Accepted +[Mon Apr 13 09:08:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:26 2026] 127.0.0.1:60532 Closing +[Mon Apr 13 09:08:26 2026] 127.0.0.1:60306 Accepted +[Mon Apr 13 09:08:27 2026] 127.0.0.1:60306 Closing +[Mon Apr 13 09:08:27 2026] 127.0.0.1:60320 Accepted +[Mon Apr 13 09:08:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:29 2026] 127.0.0.1:60320 Closing +[Mon Apr 13 09:08:29 2026] 127.0.0.1:60324 Accepted +[Mon Apr 13 09:08:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:30 2026] 127.0.0.1:60324 Closing +[Mon Apr 13 09:08:30 2026] 127.0.0.1:60338 Accepted +[Mon Apr 13 09:08:31 2026] 127.0.0.1:60338 Closing +[Mon Apr 13 09:08:31 2026] 127.0.0.1:60354 Accepted +[Mon Apr 13 09:08:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:34 2026] 127.0.0.1:60354 Closing +[Mon Apr 13 09:08:34 2026] 127.0.0.1:60366 Accepted +[Mon Apr 13 09:08:34 2026] 127.0.0.1:60366 Closing +[Mon Apr 13 09:08:34 2026] 127.0.0.1:52934 Accepted +[Mon Apr 13 09:08:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:36 2026] 127.0.0.1:52934 Closing +[Mon Apr 13 09:08:36 2026] 127.0.0.1:52946 Accepted +[Mon Apr 13 09:08:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:37 2026] 127.0.0.1:52946 Closing +[Mon Apr 13 09:08:37 2026] 127.0.0.1:52960 Accepted +[Mon Apr 13 09:08:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:38 2026] 127.0.0.1:52960 Closing +[Mon Apr 13 09:08:38 2026] 127.0.0.1:52972 Accepted +[Mon Apr 13 09:08:38 2026] 127.0.0.1:52988 Accepted +[Mon Apr 13 09:08:38 2026] 127.0.0.1:52972 Closing +[Mon Apr 13 09:08:40 2026] 127.0.0.1:52988 Closing +[Mon Apr 13 09:08:40 2026] 127.0.0.1:53002 Accepted +[Mon Apr 13 09:08:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:40 2026] 127.0.0.1:53002 Closing +[Mon Apr 13 09:08:40 2026] 127.0.0.1:53016 Accepted +[Mon Apr 13 09:08:40 2026] 127.0.0.1:53016 Closing +[Mon Apr 13 09:08:41 2026] 127.0.0.1:53032 Accepted +[Mon Apr 13 09:08:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:41 2026] 127.0.0.1:53032 Closing +[Mon Apr 13 09:08:41 2026] 127.0.0.1:53036 Accepted +[Mon Apr 13 09:08:41 2026] 127.0.0.1:53050 Accepted +[Mon Apr 13 09:08:42 2026] 127.0.0.1:53036 Closing +[Mon Apr 13 09:08:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:44 2026] 127.0.0.1:53050 Closing +[Mon Apr 13 09:08:44 2026] 127.0.0.1:39542 Accepted +[Mon Apr 13 09:08:44 2026] 127.0.0.1:39542 Closing +[Mon Apr 13 09:08:44 2026] 127.0.0.1:39554 Accepted +[Mon Apr 13 09:08:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:47 2026] 127.0.0.1:39554 Closing +[Mon Apr 13 09:08:47 2026] 127.0.0.1:39570 Accepted +[Mon Apr 13 09:08:47 2026] 127.0.0.1:39570 Closing +[Mon Apr 13 09:08:48 2026] 127.0.0.1:39586 Accepted +[Mon Apr 13 09:08:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:48 2026] 127.0.0.1:39586 Closing +[Mon Apr 13 09:08:48 2026] 127.0.0.1:39590 Accepted +[Mon Apr 13 09:08:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:50 2026] 127.0.0.1:39590 Closing +[Mon Apr 13 09:08:50 2026] 127.0.0.1:39596 Accepted +[Mon Apr 13 09:08:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:50 2026] 127.0.0.1:39596 Closing +[Mon Apr 13 09:08:50 2026] 127.0.0.1:39612 Accepted +[Mon Apr 13 09:08:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:51 2026] 127.0.0.1:39612 Closing +[Mon Apr 13 09:08:51 2026] 127.0.0.1:39616 Accepted +[Mon Apr 13 09:08:51 2026] 127.0.0.1:39616 Closing +[Mon Apr 13 09:08:51 2026] 127.0.0.1:39632 Accepted +[Mon Apr 13 09:08:52 2026] 127.0.0.1:39632 Closing +[Mon Apr 13 09:08:52 2026] 127.0.0.1:39648 Accepted +[Mon Apr 13 09:08:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:54 2026] 127.0.0.1:39648 Closing +[Mon Apr 13 09:08:54 2026] 127.0.0.1:36530 Accepted +[Mon Apr 13 09:08:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:55 2026] 127.0.0.1:36530 Closing +[Mon Apr 13 09:08:55 2026] 127.0.0.1:36536 Accepted +[Mon Apr 13 09:08:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:55 2026] 127.0.0.1:36536 Closing +[Mon Apr 13 09:08:55 2026] 127.0.0.1:36552 Accepted +[Mon Apr 13 09:08:55 2026] 127.0.0.1:36560 Accepted +[Mon Apr 13 09:08:56 2026] 127.0.0.1:36552 Closing +[Mon Apr 13 09:08:57 2026] 127.0.0.1:36560 Closing +[Mon Apr 13 09:08:57 2026] 127.0.0.1:36562 Accepted +[Mon Apr 13 09:08:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:08:59 2026] 127.0.0.1:36562 Closing +[Mon Apr 13 09:08:59 2026] 127.0.0.1:36568 Accepted +[Mon Apr 13 09:09:00 2026] 127.0.0.1:36568 Closing +[Mon Apr 13 09:09:00 2026] 127.0.0.1:36572 Accepted +[Mon Apr 13 09:09:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:00 2026] 127.0.0.1:36572 Closing +[Mon Apr 13 09:09:00 2026] 127.0.0.1:36582 Accepted +[Mon Apr 13 09:09:01 2026] 127.0.0.1:36582 Closing +[Mon Apr 13 09:09:01 2026] 127.0.0.1:36586 Accepted +[Mon Apr 13 09:09:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:01 2026] 127.0.0.1:36586 Closing +[Mon Apr 13 09:09:01 2026] 127.0.0.1:36598 Accepted +[Mon Apr 13 09:09:01 2026] 127.0.0.1:36598 Closing +[Mon Apr 13 09:09:01 2026] 127.0.0.1:36602 Accepted +[Mon Apr 13 09:09:03 2026] 127.0.0.1:36602 Closing +[Mon Apr 13 09:09:03 2026] 127.0.0.1:33916 Accepted +[Mon Apr 13 09:09:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:04 2026] 127.0.0.1:33916 Closing +[Mon Apr 13 09:09:04 2026] 127.0.0.1:33920 Accepted +[Mon Apr 13 09:09:05 2026] 127.0.0.1:33920 Closing +[Mon Apr 13 09:09:06 2026] 127.0.0.1:33936 Accepted +[Mon Apr 13 09:09:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:07 2026] 127.0.0.1:33936 Closing +[Mon Apr 13 09:09:08 2026] 127.0.0.1:33952 Accepted +[Mon Apr 13 09:09:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:09 2026] 127.0.0.1:33952 Closing +[Mon Apr 13 09:09:09 2026] 127.0.0.1:33964 Accepted +[Mon Apr 13 09:09:12 2026] 127.0.0.1:33964 Closing +[Mon Apr 13 09:09:14 2026] 127.0.0.1:47998 Accepted +[Mon Apr 13 09:09:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:15 2026] 127.0.0.1:47998 Closing +[Mon Apr 13 09:09:16 2026] 127.0.0.1:48006 Accepted +[Mon Apr 13 09:09:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:16 2026] 127.0.0.1:48006 Closing +[Mon Apr 13 09:09:16 2026] 127.0.0.1:48018 Accepted +[Mon Apr 13 09:09:21 2026] 127.0.0.1:48018 Closing +[Mon Apr 13 09:09:23 2026] 127.0.0.1:57272 Accepted +[Mon Apr 13 09:09:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:24 2026] 127.0.0.1:57272 Closing +[Mon Apr 13 09:09:24 2026] 127.0.0.1:57286 Accepted +[Mon Apr 13 09:09:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:24 2026] 127.0.0.1:57286 Closing +[Mon Apr 13 09:09:24 2026] 127.0.0.1:57298 Accepted +[Mon Apr 13 09:09:25 2026] 127.0.0.1:57298 Closing +[Mon Apr 13 09:09:25 2026] 127.0.0.1:57306 Accepted +[Mon Apr 13 09:09:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:25 2026] 127.0.0.1:57306 Closing +[Mon Apr 13 09:09:25 2026] 127.0.0.1:57318 Accepted +[Mon Apr 13 09:09:29 2026] 127.0.0.1:57318 Closing +[Mon Apr 13 09:09:30 2026] 127.0.0.1:57320 Accepted +[Mon Apr 13 09:09:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:30 2026] 127.0.0.1:57320 Closing +[Mon Apr 13 09:09:31 2026] 127.0.0.1:57332 Accepted +[Mon Apr 13 09:09:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:31 2026] 127.0.0.1:57332 Closing +[Mon Apr 13 09:09:31 2026] 127.0.0.1:40714 Accepted +[Mon Apr 13 09:09:35 2026] 127.0.0.1:40714 Closing +[Mon Apr 13 09:09:36 2026] 127.0.0.1:40728 Accepted +[Mon Apr 13 09:09:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:36 2026] 127.0.0.1:40728 Closing +[Mon Apr 13 09:09:37 2026] 127.0.0.1:40734 Accepted +[Mon Apr 13 09:09:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:37 2026] 127.0.0.1:40734 Closing +[Mon Apr 13 09:09:37 2026] 127.0.0.1:40742 Accepted +[Mon Apr 13 09:09:38 2026] 127.0.0.1:40742 Closing +[Mon Apr 13 09:09:38 2026] 127.0.0.1:40758 Accepted +[Mon Apr 13 09:09:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:38 2026] 127.0.0.1:40758 Closing +[Mon Apr 13 09:09:38 2026] 127.0.0.1:40770 Accepted +[Mon Apr 13 09:09:40 2026] 127.0.0.1:40770 Closing +[Mon Apr 13 09:09:41 2026] 127.0.0.1:49784 Accepted +[Mon Apr 13 09:09:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:41 2026] 127.0.0.1:49784 Closing +[Mon Apr 13 09:09:42 2026] 127.0.0.1:49794 Accepted +[Mon Apr 13 09:09:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:43 2026] 127.0.0.1:49794 Closing +[Mon Apr 13 09:09:43 2026] 127.0.0.1:49796 Accepted +[Mon Apr 13 09:09:45 2026] 127.0.0.1:49796 Closing +[Mon Apr 13 09:09:47 2026] 127.0.0.1:49808 Accepted +[Mon Apr 13 09:09:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:47 2026] 127.0.0.1:49808 Closing +[Mon Apr 13 09:09:48 2026] 127.0.0.1:49822 Accepted +[Mon Apr 13 09:09:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:48 2026] 127.0.0.1:49822 Closing +[Mon Apr 13 09:09:48 2026] 127.0.0.1:49830 Accepted +[Mon Apr 13 09:09:50 2026] 127.0.0.1:49830 Closing +[Mon Apr 13 09:09:50 2026] 127.0.0.1:45652 Accepted +[Mon Apr 13 09:09:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:50 2026] 127.0.0.1:45652 Closing +[Mon Apr 13 09:09:50 2026] 127.0.0.1:45666 Accepted +[Mon Apr 13 09:09:52 2026] 127.0.0.1:45666 Closing +[Mon Apr 13 09:09:52 2026] 127.0.0.1:45668 Accepted +[Mon Apr 13 09:09:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:53 2026] 127.0.0.1:45668 Closing +[Mon Apr 13 09:09:54 2026] 127.0.0.1:45670 Accepted +[Mon Apr 13 09:09:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:54 2026] 127.0.0.1:45670 Closing +[Mon Apr 13 09:09:54 2026] 127.0.0.1:45674 Accepted +[Mon Apr 13 09:09:57 2026] 127.0.0.1:45674 Closing +[Mon Apr 13 09:09:58 2026] 127.0.0.1:45682 Accepted +[Mon Apr 13 09:09:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:09:59 2026] 127.0.0.1:45682 Closing +[Mon Apr 13 09:09:59 2026] 127.0.0.1:45686 Accepted +[Mon Apr 13 09:09:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:00 2026] 127.0.0.1:45686 Closing +[Mon Apr 13 09:10:00 2026] 127.0.0.1:40428 Accepted +[Mon Apr 13 09:10:01 2026] 127.0.0.1:40428 Closing +[Mon Apr 13 09:10:02 2026] 127.0.0.1:40436 Accepted +[Mon Apr 13 09:10:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:02 2026] 127.0.0.1:40436 Closing +[Mon Apr 13 09:10:03 2026] 127.0.0.1:40448 Accepted +[Mon Apr 13 09:10:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:04 2026] 127.0.0.1:40448 Closing +[Mon Apr 13 09:10:04 2026] 127.0.0.1:40460 Accepted +[Mon Apr 13 09:10:06 2026] 127.0.0.1:40460 Closing +[Mon Apr 13 09:10:06 2026] 127.0.0.1:40474 Accepted +[Mon Apr 13 09:10:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:07 2026] 127.0.0.1:40474 Closing +[Mon Apr 13 09:10:07 2026] 127.0.0.1:40478 Accepted +[Mon Apr 13 09:10:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:08 2026] 127.0.0.1:40478 Closing +[Mon Apr 13 09:10:08 2026] 127.0.0.1:40490 Accepted +[Mon Apr 13 09:10:09 2026] 127.0.0.1:40490 Closing +[Mon Apr 13 09:10:09 2026] 127.0.0.1:39202 Accepted +[Mon Apr 13 09:10:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:10 2026] 127.0.0.1:39202 Closing +[Mon Apr 13 09:10:10 2026] 127.0.0.1:39204 Accepted +[Mon Apr 13 09:10:11 2026] 127.0.0.1:39204 Closing +[Mon Apr 13 09:10:12 2026] 127.0.0.1:39212 Accepted +[Mon Apr 13 09:10:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:13 2026] 127.0.0.1:39212 Closing +[Mon Apr 13 09:10:13 2026] 127.0.0.1:39214 Accepted +[Mon Apr 13 09:10:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:13 2026] 127.0.0.1:39214 Closing +[Mon Apr 13 09:10:13 2026] 127.0.0.1:39226 Accepted +[Mon Apr 13 09:10:13 2026] 127.0.0.1:39226 Closing +[Mon Apr 13 09:10:22 2026] 127.0.0.1:46710 Accepted +[Mon Apr 13 09:10:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:22 2026] 127.0.0.1:46710 Closing +[Mon Apr 13 09:10:23 2026] 127.0.0.1:46726 Accepted +[Mon Apr 13 09:10:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:23 2026] 127.0.0.1:46726 Closing +[Mon Apr 13 09:10:23 2026] 127.0.0.1:46732 Accepted +[Mon Apr 13 09:10:24 2026] 127.0.0.1:46732 Closing +[Mon Apr 13 09:10:25 2026] 127.0.0.1:46746 Accepted +[Mon Apr 13 09:10:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:26 2026] 127.0.0.1:46746 Closing +[Mon Apr 13 09:10:26 2026] 127.0.0.1:46756 Accepted +[Mon Apr 13 09:10:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:26 2026] 127.0.0.1:46756 Closing +[Mon Apr 13 09:10:26 2026] 127.0.0.1:46764 Accepted +[Mon Apr 13 09:10:27 2026] 127.0.0.1:46764 Closing +[Mon Apr 13 09:10:27 2026] 127.0.0.1:46778 Accepted +[Mon Apr 13 09:10:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:27 2026] 127.0.0.1:46778 Closing +[Mon Apr 13 09:10:27 2026] 127.0.0.1:46794 Accepted +[Mon Apr 13 09:10:28 2026] 127.0.0.1:46794 Closing +[Mon Apr 13 09:10:29 2026] 127.0.0.1:55622 Accepted +[Mon Apr 13 09:10:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:29 2026] 127.0.0.1:55622 Closing +[Mon Apr 13 09:10:29 2026] 127.0.0.1:55628 Accepted +[Mon Apr 13 09:10:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:30 2026] 127.0.0.1:55628 Closing +[Mon Apr 13 09:10:30 2026] 127.0.0.1:55630 Accepted +[Mon Apr 13 09:10:30 2026] 127.0.0.1:55630 Closing +[Mon Apr 13 09:10:31 2026] 127.0.0.1:55632 Accepted +[Mon Apr 13 09:10:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:32 2026] 127.0.0.1:55632 Closing +[Mon Apr 13 09:10:32 2026] 127.0.0.1:55646 Accepted +[Mon Apr 13 09:10:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:32 2026] 127.0.0.1:55646 Closing +[Mon Apr 13 09:10:32 2026] 127.0.0.1:55652 Accepted +[Mon Apr 13 09:10:33 2026] 127.0.0.1:55652 Closing +[Mon Apr 13 09:10:33 2026] 127.0.0.1:55668 Accepted +[Mon Apr 13 09:10:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:34 2026] 127.0.0.1:55668 Closing +[Mon Apr 13 09:10:34 2026] 127.0.0.1:55670 Accepted +[Mon Apr 13 09:10:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:35 2026] 127.0.0.1:55670 Closing +[Mon Apr 13 09:10:35 2026] 127.0.0.1:55680 Accepted +[Mon Apr 13 09:10:36 2026] 127.0.0.1:55680 Closing +[Mon Apr 13 09:10:36 2026] 127.0.0.1:55690 Accepted +[Mon Apr 13 09:10:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:35 2026] 127.0.0.1:55690 Closing +[Mon Apr 13 09:10:35 2026] 127.0.0.1:55696 Accepted +[Mon Apr 13 09:10:35 2026] 127.0.0.1:55696 Closing +[Mon Apr 13 09:10:36 2026] 127.0.0.1:55698 Accepted +[Mon Apr 13 09:10:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:37 2026] 127.0.0.1:55698 Closing +[Mon Apr 13 09:10:37 2026] 127.0.0.1:55700 Accepted +[Mon Apr 13 09:10:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:37 2026] 127.0.0.1:55700 Closing +[Mon Apr 13 09:10:37 2026] 127.0.0.1:40572 Accepted +[Mon Apr 13 09:10:38 2026] 127.0.0.1:40572 Closing +[Mon Apr 13 09:10:49 2026] 127.0.0.1:43520 Accepted +[Mon Apr 13 09:10:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:50 2026] 127.0.0.1:43520 Closing +[Mon Apr 13 09:10:50 2026] 127.0.0.1:43536 Accepted +[Mon Apr 13 09:10:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:51 2026] 127.0.0.1:43536 Closing +[Mon Apr 13 09:10:51 2026] 127.0.0.1:43550 Accepted +[Mon Apr 13 09:10:52 2026] 127.0.0.1:43550 Closing +[Mon Apr 13 09:10:53 2026] 127.0.0.1:43566 Accepted +[Mon Apr 13 09:10:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:53 2026] 127.0.0.1:43566 Closing +[Mon Apr 13 09:10:54 2026] 127.0.0.1:43568 Accepted +[Mon Apr 13 09:10:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:54 2026] 127.0.0.1:43568 Closing +[Mon Apr 13 09:10:54 2026] 127.0.0.1:43574 Accepted +[Mon Apr 13 09:10:55 2026] 127.0.0.1:43574 Closing +[Mon Apr 13 09:10:55 2026] 127.0.0.1:43590 Accepted +[Mon Apr 13 09:10:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:55 2026] 127.0.0.1:43590 Closing +[Mon Apr 13 09:10:55 2026] 127.0.0.1:43604 Accepted +[Mon Apr 13 09:10:56 2026] 127.0.0.1:43604 Closing +[Mon Apr 13 09:10:57 2026] 127.0.0.1:43638 Accepted +[Mon Apr 13 09:10:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:58 2026] 127.0.0.1:43638 Closing +[Mon Apr 13 09:10:58 2026] 127.0.0.1:43640 Accepted +[Mon Apr 13 09:10:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:10:58 2026] 127.0.0.1:43640 Closing +[Mon Apr 13 09:10:58 2026] 127.0.0.1:43644 Accepted +[Mon Apr 13 09:10:59 2026] 127.0.0.1:43644 Closing +[Mon Apr 13 09:11:00 2026] 127.0.0.1:43660 Accepted +[Mon Apr 13 09:11:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:11:00 2026] 127.0.0.1:43660 Closing +[Mon Apr 13 09:11:00 2026] 127.0.0.1:43672 Accepted +[Mon Apr 13 09:11:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:11:01 2026] 127.0.0.1:43672 Closing +[Mon Apr 13 09:11:01 2026] 127.0.0.1:43680 Accepted +[Mon Apr 13 09:11:01 2026] 127.0.0.1:43680 Closing +[Mon Apr 13 09:11:02 2026] 127.0.0.1:43688 Accepted +[Mon Apr 13 09:11:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:11:02 2026] 127.0.0.1:43688 Closing +[Mon Apr 13 09:11:02 2026] 127.0.0.1:43700 Accepted +[Mon Apr 13 09:11:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:11:03 2026] 127.0.0.1:43700 Closing +[Mon Apr 13 09:11:03 2026] 127.0.0.1:43716 Accepted +[Mon Apr 13 09:11:04 2026] 127.0.0.1:43716 Closing +[Mon Apr 13 09:11:04 2026] 127.0.0.1:43728 Accepted +[Mon Apr 13 09:11:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:11:03 2026] 127.0.0.1:43728 Closing +[Mon Apr 13 09:11:03 2026] 127.0.0.1:43740 Accepted +[Mon Apr 13 09:11:04 2026] 127.0.0.1:43740 Closing +[Mon Apr 13 09:11:05 2026] 127.0.0.1:43744 Accepted +[Mon Apr 13 09:11:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:11:05 2026] 127.0.0.1:43744 Closing +[Mon Apr 13 09:11:05 2026] 127.0.0.1:36626 Accepted +[Mon Apr 13 09:11:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:11:06 2026] 127.0.0.1:36626 Closing +[Mon Apr 13 09:11:06 2026] 127.0.0.1:36632 Accepted +[Mon Apr 13 09:11:06 2026] 127.0.0.1:36632 Closing +[Mon Apr 13 09:12:18 2026] 127.0.0.1:50654 Accepted +[Mon Apr 13 09:12:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:20 2026] 127.0.0.1:50654 Closing +[Mon Apr 13 09:12:21 2026] 127.0.0.1:50664 Accepted +[Mon Apr 13 09:12:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:22 2026] 127.0.0.1:50664 Closing +[Mon Apr 13 09:12:22 2026] 127.0.0.1:50666 Accepted +[Mon Apr 13 09:12:26 2026] 127.0.0.1:50666 Closing +[Mon Apr 13 09:12:28 2026] 127.0.0.1:37320 Accepted +[Mon Apr 13 09:12:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:29 2026] 127.0.0.1:37320 Closing +[Mon Apr 13 09:12:29 2026] 127.0.0.1:37324 Accepted +[Mon Apr 13 09:12:32 2026] 127.0.0.1:37324 Closing +[Mon Apr 13 09:12:35 2026] 127.0.0.1:33164 Accepted +[Mon Apr 13 09:12:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:36 2026] 127.0.0.1:33164 Closing +[Mon Apr 13 09:12:36 2026] 127.0.0.1:33166 Accepted +[Mon Apr 13 09:12:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:37 2026] 127.0.0.1:33166 Closing +[Mon Apr 13 09:12:37 2026] 127.0.0.1:33174 Accepted +[Mon Apr 13 09:12:39 2026] 127.0.0.1:33174 Closing +[Mon Apr 13 09:12:41 2026] 127.0.0.1:33186 Accepted +[Mon Apr 13 09:12:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:42 2026] 127.0.0.1:33186 Closing +[Mon Apr 13 09:12:42 2026] 127.0.0.1:57846 Accepted +[Mon Apr 13 09:12:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:44 2026] 127.0.0.1:57846 Closing +[Mon Apr 13 09:12:44 2026] 127.0.0.1:57848 Accepted +[Mon Apr 13 09:12:48 2026] 127.0.0.1:57848 Closing +[Mon Apr 13 09:12:49 2026] 127.0.0.1:57858 Accepted +[Mon Apr 13 09:12:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:51 2026] 127.0.0.1:57858 Closing +[Mon Apr 13 09:12:51 2026] 127.0.0.1:43520 Accepted +[Mon Apr 13 09:12:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:54 2026] 127.0.0.1:43520 Closing +[Mon Apr 13 09:12:54 2026] 127.0.0.1:43532 Accepted +[Mon Apr 13 09:12:58 2026] 127.0.0.1:43532 Closing +[Mon Apr 13 09:12:58 2026] 127.0.0.1:43538 Accepted +[Mon Apr 13 09:12:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:12:59 2026] 127.0.0.1:43538 Closing +[Mon Apr 13 09:12:59 2026] 127.0.0.1:43542 Accepted +[Mon Apr 13 09:13:03 2026] 127.0.0.1:43542 Closing +[Mon Apr 13 09:13:05 2026] 127.0.0.1:46170 Accepted +[Mon Apr 13 09:13:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:07 2026] 127.0.0.1:46170 Closing +[Mon Apr 13 09:13:07 2026] 127.0.0.1:46182 Accepted +[Mon Apr 13 09:13:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:10 2026] 127.0.0.1:46182 Closing +[Mon Apr 13 09:13:10 2026] 127.0.0.1:41192 Accepted +[Mon Apr 13 09:13:14 2026] 127.0.0.1:41192 Closing +[Mon Apr 13 09:13:15 2026] 127.0.0.1:41198 Accepted +[Mon Apr 13 09:13:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:16 2026] 127.0.0.1:41198 Closing +[Mon Apr 13 09:13:17 2026] 127.0.0.1:41202 Accepted +[Mon Apr 13 09:13:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:18 2026] 127.0.0.1:41202 Closing +[Mon Apr 13 09:13:18 2026] 127.0.0.1:41204 Accepted +[Mon Apr 13 09:13:20 2026] 127.0.0.1:41204 Closing +[Mon Apr 13 09:13:20 2026] 127.0.0.1:56054 Accepted +[Mon Apr 13 09:13:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:21 2026] 127.0.0.1:56054 Closing +[Mon Apr 13 09:13:21 2026] 127.0.0.1:56060 Accepted +[Mon Apr 13 09:13:24 2026] 127.0.0.1:56060 Closing +[Mon Apr 13 09:13:25 2026] 127.0.0.1:56076 Accepted +[Mon Apr 13 09:13:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:27 2026] 127.0.0.1:56076 Closing +[Mon Apr 13 09:13:27 2026] 127.0.0.1:56090 Accepted +[Mon Apr 13 09:13:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:26 2026] 127.0.0.1:56090 Closing +[Mon Apr 13 09:13:26 2026] 127.0.0.1:56100 Accepted +[Mon Apr 13 09:13:29 2026] 127.0.0.1:56100 Closing +[Mon Apr 13 09:13:30 2026] 127.0.0.1:52478 Accepted +[Mon Apr 13 09:13:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:31 2026] 127.0.0.1:52478 Closing +[Mon Apr 13 09:13:31 2026] 127.0.0.1:52484 Accepted +[Mon Apr 13 09:13:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:32 2026] 127.0.0.1:52484 Closing +[Mon Apr 13 09:13:32 2026] 127.0.0.1:52488 Accepted +[Mon Apr 13 09:13:35 2026] 127.0.0.1:52488 Closing +[Mon Apr 13 09:13:35 2026] 127.0.0.1:52496 Accepted +[Mon Apr 13 09:13:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:13:36 2026] 127.0.0.1:52496 Closing +[Mon Apr 13 09:13:36 2026] 127.0.0.1:52502 Accepted +[Mon Apr 13 09:13:40 2026] 127.0.0.1:52502 Closing +[Mon Apr 13 09:15:21 2026] 127.0.0.1:55982 Accepted +[Mon Apr 13 09:15:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:15:23 2026] 127.0.0.1:55982 Closing +[Mon Apr 13 09:15:24 2026] 127.0.0.1:41550 Accepted +[Mon Apr 13 09:15:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:15:27 2026] 127.0.0.1:41550 Closing +[Mon Apr 13 09:15:27 2026] 127.0.0.1:41566 Accepted +[Mon Apr 13 09:15:31 2026] 127.0.0.1:41566 Closing +[Mon Apr 13 09:15:33 2026] 127.0.0.1:36692 Accepted +[Mon Apr 13 09:15:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:15:34 2026] 127.0.0.1:36692 Closing +[Mon Apr 13 09:15:34 2026] 127.0.0.1:36704 Accepted +[Mon Apr 13 09:15:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:15:37 2026] 127.0.0.1:36704 Closing +[Mon Apr 13 09:15:37 2026] 127.0.0.1:36720 Accepted +[Mon Apr 13 09:15:39 2026] 127.0.0.1:36720 Closing +[Mon Apr 13 09:15:39 2026] 127.0.0.1:36728 Accepted +[Mon Apr 13 09:15:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:15:41 2026] 127.0.0.1:36728 Closing +[Mon Apr 13 09:15:41 2026] 127.0.0.1:36738 Accepted +[Mon Apr 13 09:15:44 2026] 127.0.0.1:36738 Closing +[Mon Apr 13 09:15:45 2026] 127.0.0.1:43674 Accepted +[Mon Apr 13 09:15:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:15:48 2026] 127.0.0.1:43674 Closing +[Mon Apr 13 09:15:48 2026] 127.0.0.1:43686 Accepted +[Mon Apr 13 09:15:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:15:49 2026] 127.0.0.1:43686 Closing +[Mon Apr 13 09:15:49 2026] 127.0.0.1:43694 Accepted +[Mon Apr 13 09:15:52 2026] 127.0.0.1:43694 Closing +[Mon Apr 13 09:15:53 2026] 127.0.0.1:39074 Accepted +[Mon Apr 13 09:15:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:15:56 2026] 127.0.0.1:39074 Closing +[Mon Apr 13 09:15:56 2026] 127.0.0.1:39088 Accepted +[Mon Apr 13 09:15:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:15:58 2026] 127.0.0.1:39088 Closing +[Mon Apr 13 09:15:58 2026] 127.0.0.1:39092 Accepted +[Mon Apr 13 09:16:00 2026] 127.0.0.1:39092 Closing +[Mon Apr 13 09:16:02 2026] 127.0.0.1:53290 Accepted +[Mon Apr 13 09:16:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:03 2026] 127.0.0.1:53290 Closing +[Mon Apr 13 09:16:04 2026] 127.0.0.1:53296 Accepted +[Mon Apr 13 09:16:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:05 2026] 127.0.0.1:53296 Closing +[Mon Apr 13 09:16:05 2026] 127.0.0.1:53300 Accepted +[Mon Apr 13 09:16:06 2026] 127.0.0.1:53300 Closing +[Mon Apr 13 09:16:06 2026] 127.0.0.1:53308 Accepted +[Mon Apr 13 09:16:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:07 2026] 127.0.0.1:53308 Closing +[Mon Apr 13 09:16:07 2026] 127.0.0.1:53316 Accepted +[Mon Apr 13 09:16:08 2026] 127.0.0.1:53316 Closing +[Mon Apr 13 09:16:08 2026] 127.0.0.1:53324 Accepted +[Mon Apr 13 09:16:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:09 2026] 127.0.0.1:53324 Closing +[Mon Apr 13 09:16:09 2026] 127.0.0.1:53334 Accepted +[Mon Apr 13 09:16:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:10 2026] 127.0.0.1:53334 Closing +[Mon Apr 13 09:16:10 2026] 127.0.0.1:53342 Accepted +[Mon Apr 13 09:16:11 2026] 127.0.0.1:53342 Closing +[Mon Apr 13 09:16:26 2026] 127.0.0.1:46516 Accepted +[Mon Apr 13 09:16:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:28 2026] 127.0.0.1:46516 Closing +[Mon Apr 13 09:16:29 2026] 127.0.0.1:46520 Accepted +[Mon Apr 13 09:16:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:30 2026] 127.0.0.1:46520 Closing +[Mon Apr 13 09:16:30 2026] 127.0.0.1:46530 Accepted +[Mon Apr 13 09:16:34 2026] 127.0.0.1:46530 Closing +[Mon Apr 13 09:16:35 2026] 127.0.0.1:45178 Accepted +[Mon Apr 13 09:16:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:37 2026] 127.0.0.1:45178 Closing +[Mon Apr 13 09:16:37 2026] 127.0.0.1:45190 Accepted +[Mon Apr 13 09:16:41 2026] 127.0.0.1:45190 Closing +[Mon Apr 13 09:16:43 2026] 127.0.0.1:55562 Accepted +[Mon Apr 13 09:16:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:46 2026] 127.0.0.1:55562 Closing +[Mon Apr 13 09:16:46 2026] 127.0.0.1:55564 Accepted +[Mon Apr 13 09:16:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:47 2026] 127.0.0.1:55564 Closing +[Mon Apr 13 09:16:47 2026] 127.0.0.1:55578 Accepted +[Mon Apr 13 09:16:51 2026] 127.0.0.1:55578 Closing +[Mon Apr 13 09:16:52 2026] 127.0.0.1:41700 Accepted +[Mon Apr 13 09:16:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:53 2026] 127.0.0.1:41700 Closing +[Mon Apr 13 09:16:54 2026] 127.0.0.1:41716 Accepted +[Mon Apr 13 09:16:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:55 2026] 127.0.0.1:41716 Closing +[Mon Apr 13 09:16:55 2026] 127.0.0.1:41728 Accepted +[Mon Apr 13 09:16:57 2026] 127.0.0.1:41728 Closing +[Mon Apr 13 09:16:58 2026] 127.0.0.1:41734 Accepted +[Mon Apr 13 09:16:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:59 2026] 127.0.0.1:41734 Closing +[Mon Apr 13 09:16:59 2026] 127.0.0.1:33952 Accepted +[Mon Apr 13 09:16:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:16:59 2026] 127.0.0.1:33952 Closing +[Mon Apr 13 09:16:59 2026] 127.0.0.1:33962 Accepted +[Mon Apr 13 09:17:01 2026] 127.0.0.1:33962 Closing +[Mon Apr 13 09:17:01 2026] 127.0.0.1:33974 Accepted +[Mon Apr 13 09:17:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:02 2026] 127.0.0.1:33974 Closing +[Mon Apr 13 09:17:02 2026] 127.0.0.1:33984 Accepted +[Mon Apr 13 09:17:04 2026] 127.0.0.1:33984 Closing +[Mon Apr 13 09:17:05 2026] 127.0.0.1:33990 Accepted +[Mon Apr 13 09:17:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:05 2026] 127.0.0.1:33990 Closing +[Mon Apr 13 09:17:05 2026] 127.0.0.1:34004 Accepted +[Mon Apr 13 09:17:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:06 2026] 127.0.0.1:34004 Closing +[Mon Apr 13 09:17:06 2026] 127.0.0.1:34006 Accepted +[Mon Apr 13 09:17:08 2026] 127.0.0.1:34006 Closing +[Mon Apr 13 09:17:08 2026] 127.0.0.1:47674 Accepted +[Mon Apr 13 09:17:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:09 2026] 127.0.0.1:47674 Closing +[Mon Apr 13 09:17:09 2026] 127.0.0.1:47680 Accepted +[Mon Apr 13 09:17:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:09 2026] 127.0.0.1:47680 Closing +[Mon Apr 13 09:17:09 2026] 127.0.0.1:47694 Accepted +[Mon Apr 13 09:17:11 2026] 127.0.0.1:47694 Closing +[Mon Apr 13 09:17:11 2026] 127.0.0.1:47698 Accepted +[Mon Apr 13 09:17:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:12 2026] 127.0.0.1:47698 Closing +[Mon Apr 13 09:17:12 2026] 127.0.0.1:47710 Accepted +[Mon Apr 13 09:17:13 2026] 127.0.0.1:47710 Closing +[Mon Apr 13 09:17:14 2026] 127.0.0.1:47722 Accepted +[Mon Apr 13 09:17:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:15 2026] 127.0.0.1:47722 Closing +[Mon Apr 13 09:17:15 2026] 127.0.0.1:47736 Accepted +[Mon Apr 13 09:17:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:16 2026] 127.0.0.1:47736 Closing +[Mon Apr 13 09:17:16 2026] 127.0.0.1:47740 Accepted +[Mon Apr 13 09:17:21 2026] 127.0.0.1:47740 Closing +[Mon Apr 13 09:17:22 2026] 127.0.0.1:34456 Accepted +[Mon Apr 13 09:17:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:25 2026] 127.0.0.1:34456 Closing +[Mon Apr 13 09:17:26 2026] 127.0.0.1:34462 Accepted +[Mon Apr 13 09:17:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:27 2026] 127.0.0.1:34462 Closing +[Mon Apr 13 09:17:27 2026] 127.0.0.1:34470 Accepted +[Mon Apr 13 09:17:29 2026] 127.0.0.1:34470 Closing +[Mon Apr 13 09:17:30 2026] 127.0.0.1:51456 Accepted +[Mon Apr 13 09:17:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:31 2026] 127.0.0.1:51456 Closing +[Mon Apr 13 09:17:31 2026] 127.0.0.1:51468 Accepted +[Mon Apr 13 09:17:35 2026] 127.0.0.1:51468 Closing +[Mon Apr 13 09:17:57 2026] 127.0.0.1:43050 Accepted +[Mon Apr 13 09:17:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:17:59 2026] 127.0.0.1:43050 Closing +[Mon Apr 13 09:18:00 2026] 127.0.0.1:43056 Accepted +[Mon Apr 13 09:18:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:02 2026] 127.0.0.1:43056 Closing +[Mon Apr 13 09:18:02 2026] 127.0.0.1:43064 Accepted +[Mon Apr 13 09:18:10 2026] 127.0.0.1:43064 Closing +[Mon Apr 13 09:18:13 2026] 127.0.0.1:42348 Accepted +[Mon Apr 13 09:18:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:13 2026] 127.0.0.1:42348 Closing +[Mon Apr 13 09:18:14 2026] 127.0.0.1:42358 Accepted +[Mon Apr 13 09:18:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:15 2026] 127.0.0.1:42358 Closing +[Mon Apr 13 09:18:15 2026] 127.0.0.1:42898 Accepted +[Mon Apr 13 09:18:25 2026] 127.0.0.1:42898 Closing +[Mon Apr 13 09:18:27 2026] 127.0.0.1:42408 Accepted +[Mon Apr 13 09:18:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:30 2026] 127.0.0.1:42408 Closing +[Mon Apr 13 09:18:30 2026] 127.0.0.1:42424 Accepted +[Mon Apr 13 09:18:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:31 2026] 127.0.0.1:42424 Closing +[Mon Apr 13 09:18:31 2026] 127.0.0.1:42436 Accepted +[Mon Apr 13 09:18:33 2026] 127.0.0.1:42436 Closing +[Mon Apr 13 09:18:33 2026] 127.0.0.1:42448 Accepted +[Mon Apr 13 09:18:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:35 2026] 127.0.0.1:42448 Closing +[Mon Apr 13 09:18:35 2026] 127.0.0.1:35212 Accepted +[Mon Apr 13 09:18:38 2026] 127.0.0.1:35212 Closing +[Mon Apr 13 09:18:39 2026] 127.0.0.1:35216 Accepted +[Mon Apr 13 09:18:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:40 2026] 127.0.0.1:35216 Closing +[Mon Apr 13 09:18:40 2026] 127.0.0.1:35224 Accepted +[Mon Apr 13 09:18:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:41 2026] 127.0.0.1:35224 Closing +[Mon Apr 13 09:18:41 2026] 127.0.0.1:35230 Accepted +[Mon Apr 13 09:18:46 2026] 127.0.0.1:35230 Closing +[Mon Apr 13 09:18:47 2026] 127.0.0.1:51046 Accepted +[Mon Apr 13 09:18:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:49 2026] 127.0.0.1:51046 Closing +[Mon Apr 13 09:18:50 2026] 127.0.0.1:51058 Accepted +[Mon Apr 13 09:18:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:51 2026] 127.0.0.1:51058 Closing +[Mon Apr 13 09:18:51 2026] 127.0.0.1:51066 Accepted +[Mon Apr 13 09:18:54 2026] 127.0.0.1:51066 Closing +[Mon Apr 13 09:18:54 2026] 127.0.0.1:58016 Accepted +[Mon Apr 13 09:18:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:55 2026] 127.0.0.1:58016 Closing +[Mon Apr 13 09:18:55 2026] 127.0.0.1:58020 Accepted +[Mon Apr 13 09:18:57 2026] 127.0.0.1:58020 Closing +[Mon Apr 13 09:18:58 2026] 127.0.0.1:58034 Accepted +[Mon Apr 13 09:18:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:18:59 2026] 127.0.0.1:58034 Closing +[Mon Apr 13 09:19:00 2026] 127.0.0.1:58046 Accepted +[Mon Apr 13 09:19:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:01 2026] 127.0.0.1:58046 Closing +[Mon Apr 13 09:19:01 2026] 127.0.0.1:58058 Accepted +[Mon Apr 13 09:19:05 2026] 127.0.0.1:58058 Closing +[Mon Apr 13 09:19:06 2026] 127.0.0.1:41684 Accepted +[Mon Apr 13 09:19:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:07 2026] 127.0.0.1:41684 Closing +[Mon Apr 13 09:19:07 2026] 127.0.0.1:41696 Accepted +[Mon Apr 13 09:19:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:08 2026] 127.0.0.1:41696 Closing +[Mon Apr 13 09:19:08 2026] 127.0.0.1:41710 Accepted +[Mon Apr 13 09:19:10 2026] 127.0.0.1:41710 Closing +[Mon Apr 13 09:19:10 2026] 127.0.0.1:41718 Accepted +[Mon Apr 13 09:19:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:10 2026] 127.0.0.1:41718 Closing +[Mon Apr 13 09:19:10 2026] 127.0.0.1:41728 Accepted +[Mon Apr 13 09:19:11 2026] 127.0.0.1:41728 Closing +[Mon Apr 13 09:19:12 2026] 127.0.0.1:36684 Accepted +[Mon Apr 13 09:19:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:13 2026] 127.0.0.1:36684 Closing +[Mon Apr 13 09:19:13 2026] 127.0.0.1:36698 Accepted +[Mon Apr 13 09:19:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:15 2026] 127.0.0.1:36698 Closing +[Mon Apr 13 09:19:15 2026] 127.0.0.1:36710 Accepted +[Mon Apr 13 09:19:19 2026] 127.0.0.1:36710 Closing +[Mon Apr 13 09:19:21 2026] 127.0.0.1:36712 Accepted +[Mon Apr 13 09:19:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:22 2026] 127.0.0.1:36712 Closing +[Mon Apr 13 09:19:22 2026] 127.0.0.1:59712 Accepted +[Mon Apr 13 09:19:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:23 2026] 127.0.0.1:59712 Closing +[Mon Apr 13 09:19:23 2026] 127.0.0.1:59726 Accepted +[Mon Apr 13 09:19:25 2026] 127.0.0.1:59726 Closing +[Mon Apr 13 09:19:26 2026] 127.0.0.1:59738 Accepted +[Mon Apr 13 09:19:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:28 2026] 127.0.0.1:59738 Closing +[Mon Apr 13 09:19:29 2026] 127.0.0.1:59742 Accepted +[Mon Apr 13 09:19:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:30 2026] 127.0.0.1:59742 Closing +[Mon Apr 13 09:19:30 2026] 127.0.0.1:59744 Accepted +[Mon Apr 13 09:19:33 2026] 127.0.0.1:59744 Closing +[Mon Apr 13 09:19:35 2026] 127.0.0.1:49098 Accepted +[Mon Apr 13 09:19:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:35 2026] 127.0.0.1:49098 Closing +[Mon Apr 13 09:19:36 2026] 127.0.0.1:49106 Accepted +[Mon Apr 13 09:19:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:36 2026] 127.0.0.1:49106 Closing +[Mon Apr 13 09:19:36 2026] 127.0.0.1:49122 Accepted +[Mon Apr 13 09:19:38 2026] 127.0.0.1:49122 Closing +[Mon Apr 13 09:19:38 2026] 127.0.0.1:49134 Accepted +[Mon Apr 13 09:19:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:19:38 2026] 127.0.0.1:49134 Closing +[Mon Apr 13 09:19:38 2026] 127.0.0.1:49140 Accepted +[Mon Apr 13 09:19:40 2026] 127.0.0.1:49140 Closing +[Mon Apr 13 09:20:48 2026] 127.0.0.1:49146 Accepted +[Mon Apr 13 09:20:48 2026] 127.0.0.1:49134 Accepted +[Mon Apr 13 09:20:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:20:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:20:49 2026] 127.0.0.1:49146 Closing +[Mon Apr 13 09:20:49 2026] 127.0.0.1:49134 Closing +[Mon Apr 13 09:20:50 2026] 127.0.0.1:49158 Accepted +[Mon Apr 13 09:20:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:20:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:20:50 2026] 127.0.0.1:49158 Closing +[Mon Apr 13 09:20:50 2026] 127.0.0.1:49172 Accepted +[Mon Apr 13 09:20:51 2026] 127.0.0.1:49172 Closing +[Mon Apr 13 09:20:51 2026] 127.0.0.1:49182 Accepted +[Mon Apr 13 09:20:52 2026] 127.0.0.1:49182 Closing +[Mon Apr 13 09:20:52 2026] 127.0.0.1:49198 Accepted +[Mon Apr 13 09:20:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:20:56 2026] 127.0.0.1:49198 Closing +[Mon Apr 13 09:20:56 2026] 127.0.0.1:49206 Accepted +[Mon Apr 13 09:20:57 2026] 127.0.0.1:49206 Closing +[Mon Apr 13 09:20:57 2026] 127.0.0.1:54200 Accepted +[Mon Apr 13 09:20:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:20:58 2026] 127.0.0.1:54200 Closing +[Mon Apr 13 09:20:58 2026] 127.0.0.1:54214 Accepted +[Mon Apr 13 09:20:58 2026] 127.0.0.1:54214 Closing +[Mon Apr 13 09:20:58 2026] 127.0.0.1:54220 Accepted +[Mon Apr 13 09:20:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:20:58 2026] 127.0.0.1:54220 Closing +[Mon Apr 13 09:20:58 2026] 127.0.0.1:54228 Accepted +[Mon Apr 13 09:20:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:20:59 2026] 127.0.0.1:54228 Closing +[Mon Apr 13 09:20:59 2026] 127.0.0.1:54238 Accepted +[Mon Apr 13 09:21:00 2026] 127.0.0.1:54238 Closing +[Mon Apr 13 09:21:00 2026] 127.0.0.1:54250 Accepted +[Mon Apr 13 09:21:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:00 2026] 127.0.0.1:54250 Closing +[Mon Apr 13 09:21:00 2026] 127.0.0.1:54252 Accepted +[Mon Apr 13 09:21:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:01 2026] 127.0.0.1:54252 Closing +[Mon Apr 13 09:21:01 2026] 127.0.0.1:54262 Accepted +[Mon Apr 13 09:21:01 2026] 127.0.0.1:54268 Accepted +[Mon Apr 13 09:21:01 2026] 127.0.0.1:54262 Closing +[Mon Apr 13 09:21:10 2026] 127.0.0.1:54268 Closing +[Mon Apr 13 09:21:10 2026] 127.0.0.1:54282 Accepted +[Mon Apr 13 09:21:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:12 2026] 127.0.0.1:54282 Closing +[Mon Apr 13 09:21:12 2026] 127.0.0.1:56942 Accepted +[Mon Apr 13 09:21:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:14 2026] 127.0.0.1:56942 Closing +[Mon Apr 13 09:21:14 2026] 127.0.0.1:56948 Accepted +[Mon Apr 13 09:21:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:15 2026] 127.0.0.1:56948 Closing +[Mon Apr 13 09:21:15 2026] 127.0.0.1:56962 Accepted +[Mon Apr 13 09:21:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:17 2026] 127.0.0.1:56962 Closing +[Mon Apr 13 09:21:17 2026] 127.0.0.1:56964 Accepted +[Mon Apr 13 09:21:17 2026] 127.0.0.1:51714 Accepted +[Mon Apr 13 09:21:18 2026] 127.0.0.1:56964 Closing +[Mon Apr 13 09:21:19 2026] 127.0.0.1:51714 Closing +[Mon Apr 13 09:21:19 2026] 127.0.0.1:51722 Accepted +[Mon Apr 13 09:21:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:20 2026] 127.0.0.1:51722 Closing +[Mon Apr 13 09:21:20 2026] 127.0.0.1:51728 Accepted +[Mon Apr 13 09:21:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:22 2026] 127.0.0.1:51728 Closing +[Mon Apr 13 09:21:22 2026] 127.0.0.1:51734 Accepted +[Mon Apr 13 09:21:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:22 2026] 127.0.0.1:51734 Closing +[Mon Apr 13 09:21:22 2026] 127.0.0.1:51750 Accepted +[Mon Apr 13 09:21:23 2026] 127.0.0.1:51750 Closing +[Mon Apr 13 09:21:23 2026] 127.0.0.1:51760 Accepted +[Mon Apr 13 09:21:27 2026] 127.0.0.1:51760 Closing +[Mon Apr 13 09:21:27 2026] 127.0.0.1:51762 Accepted +[Mon Apr 13 09:21:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:29 2026] 127.0.0.1:51762 Closing +[Mon Apr 13 09:21:29 2026] 127.0.0.1:54596 Accepted +[Mon Apr 13 09:21:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:31 2026] 127.0.0.1:54596 Closing +[Mon Apr 13 09:21:31 2026] 127.0.0.1:54600 Accepted +[Mon Apr 13 09:21:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:32 2026] 127.0.0.1:54600 Closing +[Mon Apr 13 09:21:32 2026] 127.0.0.1:54604 Accepted +[Mon Apr 13 09:21:33 2026] 127.0.0.1:54604 Closing +[Mon Apr 13 09:21:33 2026] 127.0.0.1:54620 Accepted +[Mon Apr 13 09:21:33 2026] 127.0.0.1:54620 Closing +[Mon Apr 13 09:21:33 2026] 127.0.0.1:54634 Accepted +[Mon Apr 13 09:21:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:38 2026] 127.0.0.1:54634 Closing +[Mon Apr 13 09:21:38 2026] 127.0.0.1:54640 Accepted +[Mon Apr 13 09:21:39 2026] 127.0.0.1:54640 Closing +[Mon Apr 13 09:21:39 2026] 127.0.0.1:59524 Accepted +[Mon Apr 13 09:21:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:40 2026] 127.0.0.1:59524 Closing +[Mon Apr 13 09:21:40 2026] 127.0.0.1:59534 Accepted +[Mon Apr 13 09:21:41 2026] 127.0.0.1:59534 Closing +[Mon Apr 13 09:21:41 2026] 127.0.0.1:59538 Accepted +[Mon Apr 13 09:21:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:42 2026] 127.0.0.1:59538 Closing +[Mon Apr 13 09:21:42 2026] 127.0.0.1:59544 Accepted +[Mon Apr 13 09:21:44 2026] 127.0.0.1:59544 Closing +[Mon Apr 13 09:21:44 2026] 127.0.0.1:59550 Accepted +[Mon Apr 13 09:21:46 2026] 127.0.0.1:59550 Closing +[Mon Apr 13 09:21:46 2026] 127.0.0.1:36138 Accepted +[Mon Apr 13 09:21:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:48 2026] 127.0.0.1:36138 Closing +[Mon Apr 13 09:21:48 2026] 127.0.0.1:36154 Accepted +[Mon Apr 13 09:21:51 2026] 127.0.0.1:36154 Closing +[Mon Apr 13 09:21:51 2026] 127.0.0.1:36162 Accepted +[Mon Apr 13 09:21:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:52 2026] 127.0.0.1:36162 Closing +[Mon Apr 13 09:21:52 2026] 127.0.0.1:36168 Accepted +[Mon Apr 13 09:21:55 2026] 127.0.0.1:36168 Closing +[Mon Apr 13 09:21:57 2026] 127.0.0.1:58344 Accepted +[Mon Apr 13 09:21:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:21:58 2026] 127.0.0.1:58344 Closing +[Mon Apr 13 09:21:59 2026] 127.0.0.1:58348 Accepted +[Mon Apr 13 09:21:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:00 2026] 127.0.0.1:58348 Closing +[Mon Apr 13 09:22:00 2026] 127.0.0.1:58350 Accepted +[Mon Apr 13 09:22:02 2026] 127.0.0.1:58350 Closing +[Mon Apr 13 09:22:04 2026] 127.0.0.1:53244 Accepted +[Mon Apr 13 09:22:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:05 2026] 127.0.0.1:53244 Closing +[Mon Apr 13 09:22:06 2026] 127.0.0.1:53260 Accepted +[Mon Apr 13 09:22:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:07 2026] 127.0.0.1:53260 Closing +[Mon Apr 13 09:22:07 2026] 127.0.0.1:53264 Accepted +[Mon Apr 13 09:22:09 2026] 127.0.0.1:53264 Closing +[Mon Apr 13 09:22:09 2026] 127.0.0.1:53280 Accepted +[Mon Apr 13 09:22:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:09 2026] 127.0.0.1:53280 Closing +[Mon Apr 13 09:22:09 2026] 127.0.0.1:53288 Accepted +[Mon Apr 13 09:22:11 2026] 127.0.0.1:53288 Closing +[Mon Apr 13 09:22:11 2026] 127.0.0.1:53292 Accepted +[Mon Apr 13 09:22:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:12 2026] 127.0.0.1:53292 Closing +[Mon Apr 13 09:22:13 2026] 127.0.0.1:53304 Accepted +[Mon Apr 13 09:22:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:14 2026] 127.0.0.1:53304 Closing +[Mon Apr 13 09:22:14 2026] 127.0.0.1:55074 Accepted +[Mon Apr 13 09:22:18 2026] 127.0.0.1:55074 Closing +[Mon Apr 13 09:22:20 2026] 127.0.0.1:55082 Accepted +[Mon Apr 13 09:22:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:21 2026] 127.0.0.1:55082 Closing +[Mon Apr 13 09:22:22 2026] 127.0.0.1:55088 Accepted +[Mon Apr 13 09:22:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:23 2026] 127.0.0.1:55088 Closing +[Mon Apr 13 09:22:23 2026] 127.0.0.1:60588 Accepted +[Mon Apr 13 09:22:25 2026] 127.0.0.1:60588 Closing +[Mon Apr 13 09:22:26 2026] 127.0.0.1:60594 Accepted +[Mon Apr 13 09:22:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:27 2026] 127.0.0.1:60594 Closing +[Mon Apr 13 09:22:27 2026] 127.0.0.1:60608 Accepted +[Mon Apr 13 09:22:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:28 2026] 127.0.0.1:60608 Closing +[Mon Apr 13 09:22:28 2026] 127.0.0.1:60622 Accepted +[Mon Apr 13 09:22:31 2026] 127.0.0.1:60622 Closing +[Mon Apr 13 09:22:31 2026] 127.0.0.1:44192 Accepted +[Mon Apr 13 09:22:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:33 2026] 127.0.0.1:44192 Closing +[Mon Apr 13 09:22:34 2026] 127.0.0.1:44208 Accepted +[Mon Apr 13 09:22:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:35 2026] 127.0.0.1:44208 Closing +[Mon Apr 13 09:22:35 2026] 127.0.0.1:44216 Accepted +[Mon Apr 13 09:22:38 2026] 127.0.0.1:44216 Closing +[Mon Apr 13 09:22:38 2026] 127.0.0.1:44226 Accepted +[Mon Apr 13 09:22:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:22:39 2026] 127.0.0.1:44226 Closing +[Mon Apr 13 09:22:39 2026] 127.0.0.1:44228 Accepted +[Mon Apr 13 09:22:42 2026] 127.0.0.1:44228 Closing +[Mon Apr 13 09:23:52 2026] 127.0.0.1:33312 Accepted +[Mon Apr 13 09:23:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:23:56 2026] 127.0.0.1:33312 Closing +[Mon Apr 13 09:23:56 2026] 127.0.0.1:33320 Accepted +[Mon Apr 13 09:23:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:23:57 2026] 127.0.0.1:33320 Closing +[Mon Apr 13 09:23:57 2026] 127.0.0.1:52906 Accepted +[Mon Apr 13 09:24:00 2026] 127.0.0.1:52906 Closing +[Mon Apr 13 09:24:02 2026] 127.0.0.1:52920 Accepted +[Mon Apr 13 09:24:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:04 2026] 127.0.0.1:52920 Closing +[Mon Apr 13 09:24:05 2026] 127.0.0.1:52922 Accepted +[Mon Apr 13 09:24:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:07 2026] 127.0.0.1:52922 Closing +[Mon Apr 13 09:24:07 2026] 127.0.0.1:52924 Accepted +[Mon Apr 13 09:24:09 2026] 127.0.0.1:52924 Closing +[Mon Apr 13 09:24:09 2026] 127.0.0.1:36838 Accepted +[Mon Apr 13 09:24:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:11 2026] 127.0.0.1:36838 Closing +[Mon Apr 13 09:24:11 2026] 127.0.0.1:36854 Accepted +[Mon Apr 13 09:24:13 2026] 127.0.0.1:36854 Closing +[Mon Apr 13 09:24:14 2026] 127.0.0.1:36858 Accepted +[Mon Apr 13 09:24:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:15 2026] 127.0.0.1:36858 Closing +[Mon Apr 13 09:24:16 2026] 127.0.0.1:36866 Accepted +[Mon Apr 13 09:24:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:17 2026] 127.0.0.1:36866 Closing +[Mon Apr 13 09:24:17 2026] 127.0.0.1:36142 Accepted +[Mon Apr 13 09:24:20 2026] 127.0.0.1:36142 Closing +[Mon Apr 13 09:24:21 2026] 127.0.0.1:36154 Accepted +[Mon Apr 13 09:24:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:23 2026] 127.0.0.1:36154 Closing +[Mon Apr 13 09:24:23 2026] 127.0.0.1:36162 Accepted +[Mon Apr 13 09:24:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:24 2026] 127.0.0.1:36162 Closing +[Mon Apr 13 09:24:24 2026] 127.0.0.1:36166 Accepted +[Mon Apr 13 09:24:25 2026] 127.0.0.1:36166 Closing +[Mon Apr 13 09:24:25 2026] 127.0.0.1:36176 Accepted +[Mon Apr 13 09:24:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:26 2026] 127.0.0.1:36176 Closing +[Mon Apr 13 09:24:26 2026] 127.0.0.1:36074 Accepted +[Mon Apr 13 09:24:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:28 2026] 127.0.0.1:36074 Closing +[Mon Apr 13 09:24:28 2026] 127.0.0.1:36080 Accepted +[Mon Apr 13 09:24:30 2026] 127.0.0.1:36080 Closing +[Mon Apr 13 09:24:30 2026] 127.0.0.1:36090 Accepted +[Mon Apr 13 09:24:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:32 2026] 127.0.0.1:36090 Closing +[Mon Apr 13 09:24:32 2026] 127.0.0.1:36100 Accepted +[Mon Apr 13 09:24:34 2026] 127.0.0.1:36100 Closing +[Mon Apr 13 09:24:34 2026] 127.0.0.1:36116 Accepted +[Mon Apr 13 09:24:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:36 2026] 127.0.0.1:36116 Closing +[Mon Apr 13 09:24:36 2026] 127.0.0.1:37076 Accepted +[Mon Apr 13 09:24:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:24:38 2026] 127.0.0.1:37076 Closing +[Mon Apr 13 09:24:38 2026] 127.0.0.1:37086 Accepted +[Mon Apr 13 09:24:39 2026] 127.0.0.1:37086 Closing +[Mon Apr 13 09:25:05 2026] 127.0.0.1:58476 Accepted +[Mon Apr 13 09:25:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:25:08 2026] 127.0.0.1:58476 Closing +[Mon Apr 13 09:25:10 2026] 127.0.0.1:58478 Accepted +[Mon Apr 13 09:25:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:25:11 2026] 127.0.0.1:58478 Closing +[Mon Apr 13 09:25:11 2026] 127.0.0.1:58494 Accepted +[Mon Apr 13 09:25:20 2026] 127.0.0.1:58494 Closing +[Mon Apr 13 09:25:25 2026] 127.0.0.1:55652 Accepted +[Mon Apr 13 09:25:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:25:26 2026] 127.0.0.1:55652 Closing +[Mon Apr 13 09:25:28 2026] 127.0.0.1:55668 Accepted +[Mon Apr 13 09:25:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:25:29 2026] 127.0.0.1:55668 Closing +[Mon Apr 13 09:25:29 2026] 127.0.0.1:55672 Accepted +[Mon Apr 13 09:25:41 2026] 127.0.0.1:55672 Closing +[Mon Apr 13 09:25:43 2026] 127.0.0.1:55870 Accepted +[Mon Apr 13 09:25:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:25:45 2026] 127.0.0.1:55870 Closing +[Mon Apr 13 09:25:45 2026] 127.0.0.1:55872 Accepted +[Mon Apr 13 09:25:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:25:49 2026] 127.0.0.1:55872 Closing +[Mon Apr 13 09:25:49 2026] 127.0.0.1:55886 Accepted +[Mon Apr 13 09:25:51 2026] 127.0.0.1:55886 Closing +[Mon Apr 13 09:25:51 2026] 127.0.0.1:55888 Accepted +[Mon Apr 13 09:25:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:25:53 2026] 127.0.0.1:55888 Closing +[Mon Apr 13 09:25:53 2026] 127.0.0.1:51594 Accepted +[Mon Apr 13 09:25:59 2026] 127.0.0.1:51594 Closing +[Mon Apr 13 09:26:00 2026] 127.0.0.1:51608 Accepted +[Mon Apr 13 09:26:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:02 2026] 127.0.0.1:51608 Closing +[Mon Apr 13 09:26:03 2026] 127.0.0.1:44288 Accepted +[Mon Apr 13 09:26:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:04 2026] 127.0.0.1:44288 Closing +[Mon Apr 13 09:26:04 2026] 127.0.0.1:44294 Accepted +[Mon Apr 13 09:26:08 2026] 127.0.0.1:44294 Closing +[Mon Apr 13 09:26:09 2026] 127.0.0.1:44300 Accepted +[Mon Apr 13 09:26:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:09 2026] 127.0.0.1:44300 Closing +[Mon Apr 13 09:26:10 2026] 127.0.0.1:44312 Accepted +[Mon Apr 13 09:26:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:11 2026] 127.0.0.1:44312 Closing +[Mon Apr 13 09:26:11 2026] 127.0.0.1:44324 Accepted +[Mon Apr 13 09:26:12 2026] 127.0.0.1:44324 Closing +[Mon Apr 13 09:26:12 2026] 127.0.0.1:41600 Accepted +[Mon Apr 13 09:26:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:14 2026] 127.0.0.1:41600 Closing +[Mon Apr 13 09:26:14 2026] 127.0.0.1:41610 Accepted +[Mon Apr 13 09:26:16 2026] 127.0.0.1:41610 Closing +[Mon Apr 13 09:26:16 2026] 127.0.0.1:41612 Accepted +[Mon Apr 13 09:26:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:17 2026] 127.0.0.1:41612 Closing +[Mon Apr 13 09:26:17 2026] 127.0.0.1:41622 Accepted +[Mon Apr 13 09:26:19 2026] 127.0.0.1:41622 Closing +[Mon Apr 13 09:26:19 2026] 127.0.0.1:41626 Accepted +[Mon Apr 13 09:26:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:20 2026] 127.0.0.1:41626 Closing +[Mon Apr 13 09:26:20 2026] 127.0.0.1:41628 Accepted +[Mon Apr 13 09:26:22 2026] 127.0.0.1:41628 Closing +[Mon Apr 13 09:26:24 2026] 127.0.0.1:38980 Accepted +[Mon Apr 13 09:26:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:25 2026] 127.0.0.1:38980 Closing +[Mon Apr 13 09:26:26 2026] 127.0.0.1:38984 Accepted +[Mon Apr 13 09:26:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:27 2026] 127.0.0.1:38984 Closing +[Mon Apr 13 09:26:27 2026] 127.0.0.1:38994 Accepted +[Mon Apr 13 09:26:30 2026] 127.0.0.1:38994 Closing +[Mon Apr 13 09:26:32 2026] 127.0.0.1:33116 Accepted +[Mon Apr 13 09:26:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:32 2026] 127.0.0.1:33116 Closing +[Mon Apr 13 09:26:33 2026] 127.0.0.1:33118 Accepted +[Mon Apr 13 09:26:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:34 2026] 127.0.0.1:33118 Closing +[Mon Apr 13 09:26:34 2026] 127.0.0.1:33122 Accepted +[Mon Apr 13 09:26:37 2026] 127.0.0.1:33122 Closing +[Mon Apr 13 09:26:37 2026] 127.0.0.1:33138 Accepted +[Mon Apr 13 09:26:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:38 2026] 127.0.0.1:33138 Closing +[Mon Apr 13 09:26:38 2026] 127.0.0.1:33142 Accepted +[Mon Apr 13 09:26:40 2026] 127.0.0.1:33142 Closing +[Mon Apr 13 09:26:41 2026] 127.0.0.1:52710 Accepted +[Mon Apr 13 09:26:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:42 2026] 127.0.0.1:52710 Closing +[Mon Apr 13 09:26:43 2026] 127.0.0.1:52720 Accepted +[Mon Apr 13 09:26:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:44 2026] 127.0.0.1:52720 Closing +[Mon Apr 13 09:26:44 2026] 127.0.0.1:52732 Accepted +[Mon Apr 13 09:26:47 2026] 127.0.0.1:52732 Closing +[Mon Apr 13 09:26:49 2026] 127.0.0.1:45232 Accepted +[Mon Apr 13 09:26:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:52 2026] 127.0.0.1:45232 Closing +[Mon Apr 13 09:26:52 2026] 127.0.0.1:45234 Accepted +[Mon Apr 13 09:26:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:53 2026] 127.0.0.1:45234 Closing +[Mon Apr 13 09:26:53 2026] 127.0.0.1:45246 Accepted +[Mon Apr 13 09:26:56 2026] 127.0.0.1:45246 Closing +[Mon Apr 13 09:26:57 2026] 127.0.0.1:45260 Accepted +[Mon Apr 13 09:26:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:26:59 2026] 127.0.0.1:45260 Closing +[Mon Apr 13 09:27:00 2026] 127.0.0.1:57364 Accepted +[Mon Apr 13 09:27:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:27:02 2026] 127.0.0.1:57364 Closing +[Mon Apr 13 09:27:02 2026] 127.0.0.1:57372 Accepted +[Mon Apr 13 09:27:07 2026] 127.0.0.1:57372 Closing +[Mon Apr 13 09:27:09 2026] 127.0.0.1:44592 Accepted +[Mon Apr 13 09:27:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:27:10 2026] 127.0.0.1:44592 Closing +[Mon Apr 13 09:27:10 2026] 127.0.0.1:44604 Accepted +[Mon Apr 13 09:27:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:27:13 2026] 127.0.0.1:44604 Closing +[Mon Apr 13 09:27:13 2026] 127.0.0.1:44614 Accepted +[Mon Apr 13 09:27:16 2026] 127.0.0.1:44614 Closing +[Mon Apr 13 09:27:16 2026] 127.0.0.1:44622 Accepted +[Mon Apr 13 09:27:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:27:16 2026] 127.0.0.1:44622 Closing +[Mon Apr 13 09:27:16 2026] 127.0.0.1:44636 Accepted +[Mon Apr 13 09:27:19 2026] 127.0.0.1:44636 Closing +[Mon Apr 13 09:28:02 2026] 127.0.0.1:34760 Accepted +[Mon Apr 13 09:28:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:04 2026] 127.0.0.1:34760 Closing +[Mon Apr 13 09:28:07 2026] 127.0.0.1:41872 Accepted +[Mon Apr 13 09:28:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:08 2026] 127.0.0.1:41872 Closing +[Mon Apr 13 09:28:08 2026] 127.0.0.1:41886 Accepted +[Mon Apr 13 09:28:15 2026] 127.0.0.1:41886 Closing +[Mon Apr 13 09:28:18 2026] 127.0.0.1:55704 Accepted +[Mon Apr 13 09:28:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:19 2026] 127.0.0.1:55704 Closing +[Mon Apr 13 09:28:20 2026] 127.0.0.1:55718 Accepted +[Mon Apr 13 09:28:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:21 2026] 127.0.0.1:55718 Closing +[Mon Apr 13 09:28:21 2026] 127.0.0.1:55720 Accepted +[Mon Apr 13 09:28:30 2026] 127.0.0.1:55720 Closing +[Mon Apr 13 09:28:33 2026] 127.0.0.1:53068 Accepted +[Mon Apr 13 09:28:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:34 2026] 127.0.0.1:53068 Closing +[Mon Apr 13 09:28:34 2026] 127.0.0.1:51286 Accepted +[Mon Apr 13 09:28:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:36 2026] 127.0.0.1:51286 Closing +[Mon Apr 13 09:28:36 2026] 127.0.0.1:51298 Accepted +[Mon Apr 13 09:28:37 2026] 127.0.0.1:51298 Closing +[Mon Apr 13 09:28:38 2026] 127.0.0.1:51310 Accepted +[Mon Apr 13 09:28:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:42 2026] 127.0.0.1:51310 Closing +[Mon Apr 13 09:28:42 2026] 127.0.0.1:51316 Accepted +[Mon Apr 13 09:28:44 2026] 127.0.0.1:51316 Closing +[Mon Apr 13 09:28:45 2026] 127.0.0.1:49440 Accepted +[Mon Apr 13 09:28:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:46 2026] 127.0.0.1:49440 Closing +[Mon Apr 13 09:28:47 2026] 127.0.0.1:49442 Accepted +[Mon Apr 13 09:28:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:48 2026] 127.0.0.1:49442 Closing +[Mon Apr 13 09:28:48 2026] 127.0.0.1:49450 Accepted +[Mon Apr 13 09:28:56 2026] 127.0.0.1:49450 Closing +[Mon Apr 13 09:28:57 2026] 127.0.0.1:37020 Accepted +[Mon Apr 13 09:28:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:58 2026] 127.0.0.1:37020 Closing +[Mon Apr 13 09:28:58 2026] 127.0.0.1:37028 Accepted +[Mon Apr 13 09:28:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:28:59 2026] 127.0.0.1:37028 Closing +[Mon Apr 13 09:28:59 2026] 127.0.0.1:37030 Accepted +[Mon Apr 13 09:29:01 2026] 127.0.0.1:37030 Closing +[Mon Apr 13 09:29:01 2026] 127.0.0.1:37040 Accepted +[Mon Apr 13 09:29:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:02 2026] 127.0.0.1:37040 Closing +[Mon Apr 13 09:29:02 2026] 127.0.0.1:37048 Accepted +[Mon Apr 13 09:29:04 2026] 127.0.0.1:37048 Closing +[Mon Apr 13 09:29:04 2026] 127.0.0.1:45236 Accepted +[Mon Apr 13 09:29:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:05 2026] 127.0.0.1:45236 Closing +[Mon Apr 13 09:29:05 2026] 127.0.0.1:45244 Accepted +[Mon Apr 13 09:29:09 2026] 127.0.0.1:45244 Closing +[Mon Apr 13 09:29:09 2026] 127.0.0.1:45258 Accepted +[Mon Apr 13 09:29:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:09 2026] 127.0.0.1:45258 Closing +[Mon Apr 13 09:29:09 2026] 127.0.0.1:45268 Accepted +[Mon Apr 13 09:29:11 2026] 127.0.0.1:45268 Closing +[Mon Apr 13 09:29:10 2026] 127.0.0.1:45272 Accepted +[Mon Apr 13 09:29:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:11 2026] 127.0.0.1:45272 Closing +[Mon Apr 13 09:29:12 2026] 127.0.0.1:49642 Accepted +[Mon Apr 13 09:29:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:13 2026] 127.0.0.1:49642 Closing +[Mon Apr 13 09:29:13 2026] 127.0.0.1:49652 Accepted +[Mon Apr 13 09:29:17 2026] 127.0.0.1:49652 Closing +[Mon Apr 13 09:29:19 2026] 127.0.0.1:49660 Accepted +[Mon Apr 13 09:29:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:20 2026] 127.0.0.1:49660 Closing +[Mon Apr 13 09:29:20 2026] 127.0.0.1:49668 Accepted +[Mon Apr 13 09:29:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:21 2026] 127.0.0.1:49668 Closing +[Mon Apr 13 09:29:21 2026] 127.0.0.1:49680 Accepted +[Mon Apr 13 09:29:24 2026] 127.0.0.1:49680 Closing +[Mon Apr 13 09:29:24 2026] 127.0.0.1:41388 Accepted +[Mon Apr 13 09:29:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:26 2026] 127.0.0.1:41388 Closing +[Mon Apr 13 09:29:26 2026] 127.0.0.1:41402 Accepted +[Mon Apr 13 09:29:29 2026] 127.0.0.1:41402 Closing +[Mon Apr 13 09:29:29 2026] 127.0.0.1:41406 Accepted +[Mon Apr 13 09:29:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:33 2026] 127.0.0.1:41406 Closing +[Mon Apr 13 09:29:33 2026] 127.0.0.1:33850 Accepted +[Mon Apr 13 09:29:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:35 2026] 127.0.0.1:33850 Closing +[Mon Apr 13 09:29:35 2026] 127.0.0.1:33852 Accepted +[Mon Apr 13 09:29:38 2026] 127.0.0.1:33852 Closing +[Mon Apr 13 09:29:40 2026] 127.0.0.1:33860 Accepted +[Mon Apr 13 09:29:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:39 2026] 127.0.0.1:33860 Closing +[Mon Apr 13 09:29:40 2026] 127.0.0.1:33874 Accepted +[Mon Apr 13 09:29:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:40 2026] 127.0.0.1:33874 Closing +[Mon Apr 13 09:29:40 2026] 127.0.0.1:54978 Accepted +[Mon Apr 13 09:29:44 2026] 127.0.0.1:54978 Closing +[Mon Apr 13 09:29:45 2026] 127.0.0.1:54992 Accepted +[Mon Apr 13 09:29:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:45 2026] 127.0.0.1:54992 Closing +[Mon Apr 13 09:29:46 2026] 127.0.0.1:55008 Accepted +[Mon Apr 13 09:29:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:48 2026] 127.0.0.1:55008 Closing +[Mon Apr 13 09:29:48 2026] 127.0.0.1:55018 Accepted +[Mon Apr 13 09:29:55 2026] 127.0.0.1:55018 Closing +[Mon Apr 13 09:29:57 2026] 127.0.0.1:48694 Accepted +[Mon Apr 13 09:29:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:29:59 2026] 127.0.0.1:48694 Closing +[Mon Apr 13 09:30:00 2026] 127.0.0.1:48698 Accepted +[Mon Apr 13 09:30:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:30:02 2026] 127.0.0.1:48698 Closing +[Mon Apr 13 09:30:02 2026] 127.0.0.1:47166 Accepted +[Mon Apr 13 09:30:05 2026] 127.0.0.1:47166 Closing +[Mon Apr 13 09:30:05 2026] 127.0.0.1:47182 Accepted +[Mon Apr 13 09:30:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:30:08 2026] 127.0.0.1:47182 Closing +[Mon Apr 13 09:30:08 2026] 127.0.0.1:47196 Accepted +[Mon Apr 13 09:30:11 2026] 127.0.0.1:47196 Closing +[Mon Apr 13 09:31:31 2026] 127.0.0.1:56908 Accepted +[Mon Apr 13 09:31:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:32 2026] 127.0.0.1:56908 Closing +[Mon Apr 13 09:31:33 2026] 127.0.0.1:56916 Accepted +[Mon Apr 13 09:31:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:34 2026] 127.0.0.1:56916 Closing +[Mon Apr 13 09:31:36 2026] 127.0.0.1:50352 Accepted +[Mon Apr 13 09:31:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:39 2026] 127.0.0.1:50352 Closing +[Mon Apr 13 09:31:39 2026] 127.0.0.1:50358 Accepted +[Mon Apr 13 09:31:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:42 2026] 127.0.0.1:50358 Closing +[Mon Apr 13 09:31:42 2026] 127.0.0.1:50360 Accepted +[Mon Apr 13 09:31:45 2026] 127.0.0.1:50360 Closing +[Mon Apr 13 09:31:46 2026] 127.0.0.1:54154 Accepted +[Mon Apr 13 09:31:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:49 2026] 127.0.0.1:54154 Closing +[Mon Apr 13 09:31:49 2026] 127.0.0.1:54160 Accepted +[Mon Apr 13 09:31:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:50 2026] 127.0.0.1:54160 Closing +[Mon Apr 13 09:31:50 2026] 127.0.0.1:54168 Accepted +[Mon Apr 13 09:31:51 2026] 127.0.0.1:54168 Closing +[Mon Apr 13 09:31:51 2026] 127.0.0.1:54176 Accepted +[Mon Apr 13 09:31:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:52 2026] 127.0.0.1:54176 Closing +[Mon Apr 13 09:31:52 2026] 127.0.0.1:54180 Accepted +[Mon Apr 13 09:31:53 2026] 127.0.0.1:54180 Closing +[Mon Apr 13 09:31:54 2026] 127.0.0.1:54192 Accepted +[Mon Apr 13 09:31:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:55 2026] 127.0.0.1:54192 Closing +[Mon Apr 13 09:31:55 2026] 127.0.0.1:50600 Accepted +[Mon Apr 13 09:31:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:56 2026] 127.0.0.1:50600 Closing +[Mon Apr 13 09:31:56 2026] 127.0.0.1:50602 Accepted +[Mon Apr 13 09:31:56 2026] 127.0.0.1:50602 Closing +[Mon Apr 13 09:31:57 2026] 127.0.0.1:50612 Accepted +[Mon Apr 13 09:31:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:58 2026] 127.0.0.1:50612 Closing +[Mon Apr 13 09:31:58 2026] 127.0.0.1:50620 Accepted +[Mon Apr 13 09:31:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:31:58 2026] 127.0.0.1:50620 Closing +[Mon Apr 13 09:31:58 2026] 127.0.0.1:50636 Accepted +[Mon Apr 13 09:31:59 2026] 127.0.0.1:50636 Closing +[Mon Apr 13 09:31:59 2026] 127.0.0.1:50646 Accepted +[Mon Apr 13 09:31:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:00 2026] 127.0.0.1:50646 Closing +[Mon Apr 13 09:32:00 2026] 127.0.0.1:50662 Accepted +[Mon Apr 13 09:32:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:01 2026] 127.0.0.1:50662 Closing +[Mon Apr 13 09:32:01 2026] 127.0.0.1:50672 Accepted +[Mon Apr 13 09:32:01 2026] 127.0.0.1:50672 Closing +[Mon Apr 13 09:32:01 2026] 127.0.0.1:50676 Accepted +[Mon Apr 13 09:32:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:02 2026] 127.0.0.1:50676 Closing +[Mon Apr 13 09:32:02 2026] 127.0.0.1:50678 Accepted +[Mon Apr 13 09:32:03 2026] 127.0.0.1:50678 Closing +[Mon Apr 13 09:32:02 2026] 127.0.0.1:50688 Accepted +[Mon Apr 13 09:32:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:03 2026] 127.0.0.1:50688 Closing +[Mon Apr 13 09:32:03 2026] 127.0.0.1:50700 Accepted +[Mon Apr 13 09:32:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:04 2026] 127.0.0.1:50700 Closing +[Mon Apr 13 09:32:04 2026] 127.0.0.1:58416 Accepted +[Mon Apr 13 09:32:04 2026] 127.0.0.1:58416 Closing +[Mon Apr 13 09:32:05 2026] 127.0.0.1:58418 Accepted +[Mon Apr 13 09:32:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:05 2026] 127.0.0.1:58418 Closing +[Mon Apr 13 09:32:06 2026] 127.0.0.1:58428 Accepted +[Mon Apr 13 09:32:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:06 2026] 127.0.0.1:58428 Closing +[Mon Apr 13 09:32:06 2026] 127.0.0.1:58438 Accepted +[Mon Apr 13 09:32:09 2026] 127.0.0.1:58438 Closing +[Mon Apr 13 09:32:09 2026] 127.0.0.1:58452 Accepted +[Mon Apr 13 09:32:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:10 2026] 127.0.0.1:58452 Closing +[Mon Apr 13 09:32:10 2026] 127.0.0.1:58462 Accepted +[Mon Apr 13 09:32:12 2026] 127.0.0.1:58462 Closing +[Mon Apr 13 09:32:13 2026] 127.0.0.1:58072 Accepted +[Mon Apr 13 09:32:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:14 2026] 127.0.0.1:58072 Closing +[Mon Apr 13 09:32:14 2026] 127.0.0.1:58086 Accepted +[Mon Apr 13 09:32:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:15 2026] 127.0.0.1:58086 Closing +[Mon Apr 13 09:32:15 2026] 127.0.0.1:58098 Accepted +[Mon Apr 13 09:32:16 2026] 127.0.0.1:58098 Closing +[Mon Apr 13 09:32:17 2026] 127.0.0.1:58112 Accepted +[Mon Apr 13 09:32:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:18 2026] 127.0.0.1:58112 Closing +[Mon Apr 13 09:32:18 2026] 127.0.0.1:58128 Accepted +[Mon Apr 13 09:32:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:19 2026] 127.0.0.1:58128 Closing +[Mon Apr 13 09:32:19 2026] 127.0.0.1:58130 Accepted +[Mon Apr 13 09:32:21 2026] 127.0.0.1:58130 Closing +[Mon Apr 13 09:32:21 2026] 127.0.0.1:58134 Accepted +[Mon Apr 13 09:32:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:22 2026] 127.0.0.1:58134 Closing +[Mon Apr 13 09:32:22 2026] 127.0.0.1:58142 Accepted +[Mon Apr 13 09:32:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:23 2026] 127.0.0.1:58142 Closing +[Mon Apr 13 09:32:23 2026] 127.0.0.1:58152 Accepted +[Mon Apr 13 09:32:24 2026] 127.0.0.1:58152 Closing +[Mon Apr 13 09:32:25 2026] 127.0.0.1:45988 Accepted +[Mon Apr 13 09:32:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:26 2026] 127.0.0.1:45988 Closing +[Mon Apr 13 09:32:26 2026] 127.0.0.1:46002 Accepted +[Mon Apr 13 09:32:27 2026] 127.0.0.1:46002 Closing +[Mon Apr 13 09:32:28 2026] 127.0.0.1:46018 Accepted +[Mon Apr 13 09:32:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:29 2026] 127.0.0.1:46018 Closing +[Mon Apr 13 09:32:29 2026] 127.0.0.1:46030 Accepted +[Mon Apr 13 09:32:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:30 2026] 127.0.0.1:46030 Closing +[Mon Apr 13 09:32:30 2026] 127.0.0.1:46042 Accepted +[Mon Apr 13 09:32:32 2026] 127.0.0.1:46042 Closing +[Mon Apr 13 09:32:31 2026] 127.0.0.1:46048 Accepted +[Mon Apr 13 09:32:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:31 2026] 127.0.0.1:46048 Closing +[Mon Apr 13 09:32:32 2026] 127.0.0.1:46054 Accepted +[Mon Apr 13 09:32:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:32 2026] 127.0.0.1:46054 Closing +[Mon Apr 13 09:32:32 2026] 127.0.0.1:45666 Accepted +[Mon Apr 13 09:32:34 2026] 127.0.0.1:45666 Closing +[Mon Apr 13 09:32:34 2026] 127.0.0.1:45668 Accepted +[Mon Apr 13 09:32:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:35 2026] 127.0.0.1:45668 Closing +[Mon Apr 13 09:32:35 2026] 127.0.0.1:45670 Accepted +[Mon Apr 13 09:32:37 2026] 127.0.0.1:45670 Closing +[Mon Apr 13 09:32:38 2026] 127.0.0.1:45678 Accepted +[Mon Apr 13 09:32:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:38 2026] 127.0.0.1:45678 Closing +[Mon Apr 13 09:32:39 2026] 127.0.0.1:45684 Accepted +[Mon Apr 13 09:32:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:39 2026] 127.0.0.1:45684 Closing +[Mon Apr 13 09:32:39 2026] 127.0.0.1:45700 Accepted +[Mon Apr 13 09:32:42 2026] 127.0.0.1:45700 Closing +[Mon Apr 13 09:32:43 2026] 127.0.0.1:41582 Accepted +[Mon Apr 13 09:32:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:43 2026] 127.0.0.1:41582 Closing +[Mon Apr 13 09:32:44 2026] 127.0.0.1:41596 Accepted +[Mon Apr 13 09:32:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:44 2026] 127.0.0.1:41596 Closing +[Mon Apr 13 09:32:44 2026] 127.0.0.1:41606 Accepted +[Mon Apr 13 09:32:46 2026] 127.0.0.1:41606 Closing +[Mon Apr 13 09:32:46 2026] 127.0.0.1:41614 Accepted +[Mon Apr 13 09:32:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:47 2026] 127.0.0.1:41614 Closing +[Mon Apr 13 09:32:47 2026] 127.0.0.1:41630 Accepted +[Mon Apr 13 09:32:49 2026] 127.0.0.1:41630 Closing +[Mon Apr 13 09:32:51 2026] 127.0.0.1:41638 Accepted +[Mon Apr 13 09:32:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:51 2026] 127.0.0.1:41638 Closing +[Mon Apr 13 09:32:52 2026] 127.0.0.1:36086 Accepted +[Mon Apr 13 09:32:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:32:53 2026] 127.0.0.1:36086 Closing +[Mon Apr 13 09:32:53 2026] 127.0.0.1:36092 Accepted +[Mon Apr 13 09:32:59 2026] 127.0.0.1:36092 Closing +[Mon Apr 13 09:33:00 2026] 127.0.0.1:36098 Accepted +[Mon Apr 13 09:33:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:01 2026] 127.0.0.1:36098 Closing +[Mon Apr 13 09:33:01 2026] 127.0.0.1:53580 Accepted +[Mon Apr 13 09:33:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:03 2026] 127.0.0.1:53580 Closing +[Mon Apr 13 09:33:03 2026] 127.0.0.1:53592 Accepted +[Mon Apr 13 09:33:11 2026] 127.0.0.1:53592 Closing +[Mon Apr 13 09:33:13 2026] 127.0.0.1:40216 Accepted +[Mon Apr 13 09:33:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:14 2026] 127.0.0.1:40216 Closing +[Mon Apr 13 09:33:14 2026] 127.0.0.1:40228 Accepted +[Mon Apr 13 09:33:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:15 2026] 127.0.0.1:40228 Closing +[Mon Apr 13 09:33:15 2026] 127.0.0.1:40236 Accepted +[Mon Apr 13 09:33:16 2026] 127.0.0.1:40236 Closing +[Mon Apr 13 09:33:17 2026] 127.0.0.1:40250 Accepted +[Mon Apr 13 09:33:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:17 2026] 127.0.0.1:40250 Closing +[Mon Apr 13 09:33:17 2026] 127.0.0.1:40260 Accepted +[Mon Apr 13 09:33:20 2026] 127.0.0.1:40260 Closing +[Mon Apr 13 09:33:21 2026] 127.0.0.1:59548 Accepted +[Mon Apr 13 09:33:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:22 2026] 127.0.0.1:59548 Closing +[Mon Apr 13 09:33:22 2026] 127.0.0.1:59554 Accepted +[Mon Apr 13 09:33:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:24 2026] 127.0.0.1:59554 Closing +[Mon Apr 13 09:33:24 2026] 127.0.0.1:59556 Accepted +[Mon Apr 13 09:33:29 2026] 127.0.0.1:59556 Closing +[Mon Apr 13 09:33:30 2026] 127.0.0.1:51402 Accepted +[Mon Apr 13 09:33:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:31 2026] 127.0.0.1:51402 Closing +[Mon Apr 13 09:33:31 2026] 127.0.0.1:51418 Accepted +[Mon Apr 13 09:33:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:32 2026] 127.0.0.1:51418 Closing +[Mon Apr 13 09:33:32 2026] 127.0.0.1:51426 Accepted +[Mon Apr 13 09:33:34 2026] 127.0.0.1:51426 Closing +[Mon Apr 13 09:33:34 2026] 127.0.0.1:51432 Accepted +[Mon Apr 13 09:33:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:35 2026] 127.0.0.1:51432 Closing +[Mon Apr 13 09:33:35 2026] 127.0.0.1:51436 Accepted +[Mon Apr 13 09:33:36 2026] 127.0.0.1:51436 Closing +[Mon Apr 13 09:33:36 2026] 127.0.0.1:51450 Accepted +[Mon Apr 13 09:33:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:37 2026] 127.0.0.1:51450 Closing +[Mon Apr 13 09:33:37 2026] 127.0.0.1:51466 Accepted +[Mon Apr 13 09:33:39 2026] 127.0.0.1:51466 Closing +[Mon Apr 13 09:33:39 2026] 127.0.0.1:51476 Accepted +[Mon Apr 13 09:33:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:40 2026] 127.0.0.1:51476 Closing +[Mon Apr 13 09:33:40 2026] 127.0.0.1:52460 Accepted +[Mon Apr 13 09:33:42 2026] 127.0.0.1:52460 Closing +[Mon Apr 13 09:33:42 2026] 127.0.0.1:52468 Accepted +[Mon Apr 13 09:33:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:43 2026] 127.0.0.1:52468 Closing +[Mon Apr 13 09:33:44 2026] 127.0.0.1:52470 Accepted +[Mon Apr 13 09:33:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:44 2026] 127.0.0.1:52470 Closing +[Mon Apr 13 09:33:44 2026] 127.0.0.1:52486 Accepted +[Mon Apr 13 09:33:47 2026] 127.0.0.1:52486 Closing +[Mon Apr 13 09:33:49 2026] 127.0.0.1:44956 Accepted +[Mon Apr 13 09:33:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:50 2026] 127.0.0.1:44956 Closing +[Mon Apr 13 09:33:50 2026] 127.0.0.1:44970 Accepted +[Mon Apr 13 09:33:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:52 2026] 127.0.0.1:44970 Closing +[Mon Apr 13 09:33:52 2026] 127.0.0.1:44986 Accepted +[Mon Apr 13 09:33:55 2026] 127.0.0.1:44986 Closing +[Mon Apr 13 09:33:55 2026] 127.0.0.1:45002 Accepted +[Mon Apr 13 09:33:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:56 2026] 127.0.0.1:45002 Closing +[Mon Apr 13 09:33:56 2026] 127.0.0.1:45008 Accepted +[Mon Apr 13 09:33:56 2026] 127.0.0.1:45008 Closing +[Mon Apr 13 09:33:57 2026] 127.0.0.1:45010 Accepted +[Mon Apr 13 09:33:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:33:58 2026] 127.0.0.1:45010 Closing +[Mon Apr 13 09:33:59 2026] 127.0.0.1:36076 Accepted +[Mon Apr 13 09:33:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:00 2026] 127.0.0.1:36076 Closing +[Mon Apr 13 09:34:00 2026] 127.0.0.1:36092 Accepted +[Mon Apr 13 09:34:03 2026] 127.0.0.1:36092 Closing +[Mon Apr 13 09:34:04 2026] 127.0.0.1:36100 Accepted +[Mon Apr 13 09:34:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:06 2026] 127.0.0.1:36100 Closing +[Mon Apr 13 09:34:06 2026] 127.0.0.1:36102 Accepted +[Mon Apr 13 09:34:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:07 2026] 127.0.0.1:36102 Closing +[Mon Apr 13 09:34:07 2026] 127.0.0.1:36118 Accepted +[Mon Apr 13 09:34:10 2026] 127.0.0.1:36118 Closing +[Mon Apr 13 09:34:11 2026] 127.0.0.1:50928 Accepted +[Mon Apr 13 09:34:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:13 2026] 127.0.0.1:50928 Closing +[Mon Apr 13 09:34:14 2026] 127.0.0.1:50940 Accepted +[Mon Apr 13 09:34:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:15 2026] 127.0.0.1:50940 Closing +[Mon Apr 13 09:34:15 2026] 127.0.0.1:50952 Accepted +[Mon Apr 13 09:34:18 2026] 127.0.0.1:50952 Closing +[Mon Apr 13 09:34:20 2026] 127.0.0.1:34052 Accepted +[Mon Apr 13 09:34:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:21 2026] 127.0.0.1:34052 Closing +[Mon Apr 13 09:34:21 2026] 127.0.0.1:34068 Accepted +[Mon Apr 13 09:34:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:23 2026] 127.0.0.1:34068 Closing +[Mon Apr 13 09:34:23 2026] 127.0.0.1:34080 Accepted +[Mon Apr 13 09:34:26 2026] 127.0.0.1:34080 Closing +[Mon Apr 13 09:34:26 2026] 127.0.0.1:34092 Accepted +[Mon Apr 13 09:34:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:25 2026] 127.0.0.1:34092 Closing +[Mon Apr 13 09:34:25 2026] 127.0.0.1:34104 Accepted +[Mon Apr 13 09:34:28 2026] 127.0.0.1:34104 Closing +[Mon Apr 13 09:34:29 2026] 127.0.0.1:51020 Accepted +[Mon Apr 13 09:34:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:30 2026] 127.0.0.1:51020 Closing +[Mon Apr 13 09:34:30 2026] 127.0.0.1:51030 Accepted +[Mon Apr 13 09:34:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:34:31 2026] 127.0.0.1:51030 Closing +[Mon Apr 13 09:34:31 2026] 127.0.0.1:51040 Accepted +[Mon Apr 13 09:34:32 2026] 127.0.0.1:51040 Closing +[Mon Apr 13 09:45:27 2026] 127.0.0.1:51736 Accepted +[Mon Apr 13 09:45:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:45:30 2026] 127.0.0.1:51736 Closing +[Mon Apr 13 09:45:30 2026] 127.0.0.1:51740 Accepted +[Mon Apr 13 09:45:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:45:31 2026] 127.0.0.1:51740 Closing +[Mon Apr 13 09:45:32 2026] 127.0.0.1:51752 Accepted +[Mon Apr 13 09:45:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:45:33 2026] 127.0.0.1:51752 Closing +[Mon Apr 13 09:45:33 2026] 127.0.0.1:51766 Accepted +[Mon Apr 13 09:45:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:45:34 2026] 127.0.0.1:51766 Closing +[Mon Apr 13 09:45:34 2026] 127.0.0.1:42792 Accepted +[Mon Apr 13 09:45:37 2026] 127.0.0.1:42792 Closing +[Mon Apr 13 09:45:39 2026] 127.0.0.1:42806 Accepted +[Mon Apr 13 09:45:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:45:40 2026] 127.0.0.1:42806 Closing +[Mon Apr 13 09:45:40 2026] 127.0.0.1:42812 Accepted +[Mon Apr 13 09:45:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:45:41 2026] 127.0.0.1:42812 Closing +[Mon Apr 13 09:45:41 2026] 127.0.0.1:42814 Accepted +[Mon Apr 13 09:45:42 2026] 127.0.0.1:42814 Closing +[Mon Apr 13 09:45:42 2026] 127.0.0.1:42816 Accepted +[Mon Apr 13 09:45:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:45:45 2026] 127.0.0.1:42816 Closing +[Mon Apr 13 09:45:45 2026] 127.0.0.1:42130 Accepted +[Mon Apr 13 09:45:51 2026] 127.0.0.1:42130 Closing +[Mon Apr 13 09:45:52 2026] 127.0.0.1:42134 Accepted +[Mon Apr 13 09:45:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:45:53 2026] 127.0.0.1:42134 Closing +[Mon Apr 13 09:45:53 2026] 127.0.0.1:39550 Accepted +[Mon Apr 13 09:45:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:45:55 2026] 127.0.0.1:39550 Closing +[Mon Apr 13 09:45:55 2026] 127.0.0.1:39562 Accepted +[Mon Apr 13 09:45:57 2026] 127.0.0.1:39562 Closing +[Mon Apr 13 09:45:58 2026] 127.0.0.1:39568 Accepted +[Mon Apr 13 09:45:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:00 2026] 127.0.0.1:39568 Closing +[Mon Apr 13 09:46:00 2026] 127.0.0.1:39570 Accepted +[Mon Apr 13 09:46:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:04 2026] 127.0.0.1:39570 Closing +[Mon Apr 13 09:46:04 2026] 127.0.0.1:57650 Accepted +[Mon Apr 13 09:46:05 2026] 127.0.0.1:57650 Closing +[Mon Apr 13 09:46:06 2026] 127.0.0.1:57666 Accepted +[Mon Apr 13 09:46:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:07 2026] 127.0.0.1:57666 Closing +[Mon Apr 13 09:46:07 2026] 127.0.0.1:57678 Accepted +[Mon Apr 13 09:46:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:08 2026] 127.0.0.1:57678 Closing +[Mon Apr 13 09:46:08 2026] 127.0.0.1:57686 Accepted +[Mon Apr 13 09:46:10 2026] 127.0.0.1:57686 Closing +[Mon Apr 13 09:46:10 2026] 127.0.0.1:57690 Accepted +[Mon Apr 13 09:46:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:12 2026] 127.0.0.1:57690 Closing +[Mon Apr 13 09:46:12 2026] 127.0.0.1:57700 Accepted +[Mon Apr 13 09:46:14 2026] 127.0.0.1:57700 Closing +[Mon Apr 13 09:46:14 2026] 127.0.0.1:59682 Accepted +[Mon Apr 13 09:46:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:15 2026] 127.0.0.1:59682 Closing +[Mon Apr 13 09:46:15 2026] 127.0.0.1:59692 Accepted +[Mon Apr 13 09:46:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:16 2026] 127.0.0.1:59692 Closing +[Mon Apr 13 09:46:16 2026] 127.0.0.1:59706 Accepted +[Mon Apr 13 09:46:17 2026] 127.0.0.1:59706 Closing +[Mon Apr 13 09:46:18 2026] 127.0.0.1:59720 Accepted +[Mon Apr 13 09:46:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:20 2026] 127.0.0.1:59720 Closing +[Mon Apr 13 09:46:20 2026] 127.0.0.1:59736 Accepted +[Mon Apr 13 09:46:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:21 2026] 127.0.0.1:59736 Closing +[Mon Apr 13 09:46:21 2026] 127.0.0.1:59744 Accepted +[Mon Apr 13 09:46:23 2026] 127.0.0.1:59744 Closing +[Mon Apr 13 09:46:23 2026] 127.0.0.1:38076 Accepted +[Mon Apr 13 09:46:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:24 2026] 127.0.0.1:38076 Closing +[Mon Apr 13 09:46:24 2026] 127.0.0.1:38078 Accepted +[Mon Apr 13 09:46:27 2026] 127.0.0.1:38078 Closing +[Mon Apr 13 09:46:28 2026] 127.0.0.1:38094 Accepted +[Mon Apr 13 09:46:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:28 2026] 127.0.0.1:38094 Closing +[Mon Apr 13 09:46:29 2026] 127.0.0.1:38108 Accepted +[Mon Apr 13 09:46:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:29 2026] 127.0.0.1:38108 Closing +[Mon Apr 13 09:46:29 2026] 127.0.0.1:38118 Accepted +[Mon Apr 13 09:46:31 2026] 127.0.0.1:38118 Closing +[Mon Apr 13 09:46:32 2026] 127.0.0.1:52018 Accepted +[Mon Apr 13 09:46:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:33 2026] 127.0.0.1:52018 Closing +[Mon Apr 13 09:46:33 2026] 127.0.0.1:52030 Accepted +[Mon Apr 13 09:46:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:34 2026] 127.0.0.1:52030 Closing +[Mon Apr 13 09:46:34 2026] 127.0.0.1:52046 Accepted +[Mon Apr 13 09:46:37 2026] 127.0.0.1:52046 Closing +[Mon Apr 13 09:46:37 2026] 127.0.0.1:52062 Accepted +[Mon Apr 13 09:46:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:38 2026] 127.0.0.1:52062 Closing +[Mon Apr 13 09:46:39 2026] 127.0.0.1:52078 Accepted +[Mon Apr 13 09:46:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:39 2026] 127.0.0.1:52078 Closing +[Mon Apr 13 09:46:39 2026] 127.0.0.1:52090 Accepted +[Mon Apr 13 09:46:42 2026] 127.0.0.1:52090 Closing +[Mon Apr 13 09:46:42 2026] 127.0.0.1:42962 Accepted +[Mon Apr 13 09:46:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:44 2026] 127.0.0.1:42962 Closing +[Mon Apr 13 09:46:44 2026] 127.0.0.1:42966 Accepted +[Mon Apr 13 09:46:47 2026] 127.0.0.1:42966 Closing +[Mon Apr 13 09:46:48 2026] 127.0.0.1:42972 Accepted +[Mon Apr 13 09:46:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:50 2026] 127.0.0.1:42972 Closing +[Mon Apr 13 09:46:49 2026] 127.0.0.1:42974 Accepted +[Mon Apr 13 09:46:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:51 2026] 127.0.0.1:42974 Closing +[Mon Apr 13 09:46:51 2026] 127.0.0.1:41492 Accepted +[Mon Apr 13 09:46:54 2026] 127.0.0.1:41492 Closing +[Mon Apr 13 09:46:55 2026] 127.0.0.1:41500 Accepted +[Mon Apr 13 09:46:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:56 2026] 127.0.0.1:41500 Closing +[Mon Apr 13 09:46:56 2026] 127.0.0.1:41512 Accepted +[Mon Apr 13 09:46:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:46:57 2026] 127.0.0.1:41512 Closing +[Mon Apr 13 09:46:57 2026] 127.0.0.1:41520 Accepted +[Mon Apr 13 09:47:00 2026] 127.0.0.1:41520 Closing +[Mon Apr 13 09:47:00 2026] 127.0.0.1:53554 Accepted +[Mon Apr 13 09:47:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:02 2026] 127.0.0.1:53554 Closing +[Mon Apr 13 09:47:02 2026] 127.0.0.1:53566 Accepted +[Mon Apr 13 09:47:04 2026] 127.0.0.1:53566 Closing +[Mon Apr 13 09:47:05 2026] 127.0.0.1:53572 Accepted +[Mon Apr 13 09:47:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:06 2026] 127.0.0.1:53572 Closing +[Mon Apr 13 09:47:07 2026] 127.0.0.1:53578 Accepted +[Mon Apr 13 09:47:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:07 2026] 127.0.0.1:53578 Closing +[Mon Apr 13 09:47:07 2026] 127.0.0.1:53582 Accepted +[Mon Apr 13 09:47:11 2026] 127.0.0.1:53582 Closing +[Mon Apr 13 09:47:12 2026] 127.0.0.1:37956 Accepted +[Mon Apr 13 09:47:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:13 2026] 127.0.0.1:37956 Closing +[Mon Apr 13 09:47:13 2026] 127.0.0.1:37962 Accepted +[Mon Apr 13 09:47:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:15 2026] 127.0.0.1:37962 Closing +[Mon Apr 13 09:47:15 2026] 127.0.0.1:37972 Accepted +[Mon Apr 13 09:47:18 2026] 127.0.0.1:37972 Closing +[Mon Apr 13 09:47:18 2026] 127.0.0.1:37978 Accepted +[Mon Apr 13 09:47:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:18 2026] 127.0.0.1:37978 Closing +[Mon Apr 13 09:47:18 2026] 127.0.0.1:45688 Accepted +[Mon Apr 13 09:47:22 2026] 127.0.0.1:45688 Closing +[Mon Apr 13 09:47:23 2026] 127.0.0.1:45698 Accepted +[Mon Apr 13 09:47:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:24 2026] 127.0.0.1:45698 Closing +[Mon Apr 13 09:47:25 2026] 127.0.0.1:45710 Accepted +[Mon Apr 13 09:47:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:26 2026] 127.0.0.1:45710 Closing +[Mon Apr 13 09:47:26 2026] 127.0.0.1:45726 Accepted +[Mon Apr 13 09:47:31 2026] 127.0.0.1:45726 Closing +[Mon Apr 13 09:47:33 2026] 127.0.0.1:50440 Accepted +[Mon Apr 13 09:47:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:36 2026] 127.0.0.1:50440 Closing +[Mon Apr 13 09:47:37 2026] 127.0.0.1:50450 Accepted +[Mon Apr 13 09:47:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:37 2026] 127.0.0.1:50450 Closing +[Mon Apr 13 09:47:37 2026] 127.0.0.1:50452 Accepted +[Mon Apr 13 09:47:44 2026] 127.0.0.1:50452 Closing +[Mon Apr 13 09:47:46 2026] 127.0.0.1:37058 Accepted +[Mon Apr 13 09:47:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:47 2026] 127.0.0.1:37058 Closing +[Mon Apr 13 09:47:47 2026] 127.0.0.1:37068 Accepted +[Mon Apr 13 09:47:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:46 2026] 127.0.0.1:37068 Closing +[Mon Apr 13 09:47:46 2026] 127.0.0.1:37082 Accepted +[Mon Apr 13 09:47:47 2026] 127.0.0.1:37082 Closing +[Mon Apr 13 09:47:47 2026] 127.0.0.1:46052 Accepted +[Mon Apr 13 09:47:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:49 2026] 127.0.0.1:46052 Closing +[Mon Apr 13 09:47:49 2026] 127.0.0.1:46058 Accepted +[Mon Apr 13 09:47:52 2026] 127.0.0.1:46058 Closing +[Mon Apr 13 09:47:53 2026] 127.0.0.1:46070 Accepted +[Mon Apr 13 09:47:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:54 2026] 127.0.0.1:46070 Closing +[Mon Apr 13 09:47:55 2026] 127.0.0.1:46076 Accepted +[Mon Apr 13 09:47:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:47:55 2026] 127.0.0.1:46076 Closing +[Mon Apr 13 09:47:55 2026] 127.0.0.1:46080 Accepted +[Mon Apr 13 09:48:00 2026] 127.0.0.1:46080 Closing +[Mon Apr 13 09:48:01 2026] 127.0.0.1:39488 Accepted +[Mon Apr 13 09:48:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:02 2026] 127.0.0.1:39488 Closing +[Mon Apr 13 09:48:02 2026] 127.0.0.1:39498 Accepted +[Mon Apr 13 09:48:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:03 2026] 127.0.0.1:39498 Closing +[Mon Apr 13 09:48:03 2026] 127.0.0.1:39508 Accepted +[Mon Apr 13 09:48:05 2026] 127.0.0.1:39508 Closing +[Mon Apr 13 09:48:05 2026] 127.0.0.1:39518 Accepted +[Mon Apr 13 09:48:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:05 2026] 127.0.0.1:39518 Closing +[Mon Apr 13 09:48:05 2026] 127.0.0.1:39532 Accepted +[Mon Apr 13 09:48:07 2026] 127.0.0.1:39532 Closing +[Mon Apr 13 09:48:07 2026] 127.0.0.1:37596 Accepted +[Mon Apr 13 09:48:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:07 2026] 127.0.0.1:37596 Closing +[Mon Apr 13 09:48:07 2026] 127.0.0.1:37606 Accepted +[Mon Apr 13 09:48:09 2026] 127.0.0.1:37606 Closing +[Mon Apr 13 09:48:09 2026] 127.0.0.1:37610 Accepted +[Mon Apr 13 09:48:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:10 2026] 127.0.0.1:37610 Closing +[Mon Apr 13 09:48:10 2026] 127.0.0.1:37614 Accepted +[Mon Apr 13 09:48:11 2026] 127.0.0.1:37614 Closing +[Mon Apr 13 09:48:12 2026] 127.0.0.1:37624 Accepted +[Mon Apr 13 09:48:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:14 2026] 127.0.0.1:37624 Closing +[Mon Apr 13 09:48:14 2026] 127.0.0.1:37630 Accepted +[Mon Apr 13 09:48:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:15 2026] 127.0.0.1:37630 Closing +[Mon Apr 13 09:48:15 2026] 127.0.0.1:37640 Accepted +[Mon Apr 13 09:48:16 2026] 127.0.0.1:37640 Closing +[Mon Apr 13 09:48:18 2026] 127.0.0.1:53928 Accepted +[Mon Apr 13 09:48:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:19 2026] 127.0.0.1:53928 Closing +[Mon Apr 13 09:48:19 2026] 127.0.0.1:53942 Accepted +[Mon Apr 13 09:48:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:20 2026] 127.0.0.1:53942 Closing +[Mon Apr 13 09:48:20 2026] 127.0.0.1:53958 Accepted +[Mon Apr 13 09:48:22 2026] 127.0.0.1:53958 Closing +[Mon Apr 13 09:48:22 2026] 127.0.0.1:53970 Accepted +[Mon Apr 13 09:48:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:23 2026] 127.0.0.1:53970 Closing +[Mon Apr 13 09:48:23 2026] 127.0.0.1:53980 Accepted +[Mon Apr 13 09:48:25 2026] 127.0.0.1:53980 Closing +[Mon Apr 13 09:48:25 2026] 127.0.0.1:34312 Accepted +[Mon Apr 13 09:48:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:26 2026] 127.0.0.1:34312 Closing +[Mon Apr 13 09:48:27 2026] 127.0.0.1:34318 Accepted +[Mon Apr 13 09:48:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:28 2026] 127.0.0.1:34318 Closing +[Mon Apr 13 09:48:28 2026] 127.0.0.1:34328 Accepted +[Mon Apr 13 09:48:30 2026] 127.0.0.1:34328 Closing +[Mon Apr 13 09:48:32 2026] 127.0.0.1:34330 Accepted +[Mon Apr 13 09:48:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:32 2026] 127.0.0.1:34330 Closing +[Mon Apr 13 09:48:33 2026] 127.0.0.1:34332 Accepted +[Mon Apr 13 09:48:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:35 2026] 127.0.0.1:34332 Closing +[Mon Apr 13 09:48:35 2026] 127.0.0.1:59550 Accepted +[Mon Apr 13 09:48:38 2026] 127.0.0.1:59550 Closing +[Mon Apr 13 09:48:39 2026] 127.0.0.1:59556 Accepted +[Mon Apr 13 09:48:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:40 2026] 127.0.0.1:59556 Closing +[Mon Apr 13 09:48:40 2026] 127.0.0.1:59566 Accepted +[Mon Apr 13 09:48:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:41 2026] 127.0.0.1:59566 Closing +[Mon Apr 13 09:48:41 2026] 127.0.0.1:59574 Accepted +[Mon Apr 13 09:48:43 2026] 127.0.0.1:59574 Closing +[Mon Apr 13 09:48:43 2026] 127.0.0.1:59578 Accepted +[Mon Apr 13 09:48:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:44 2026] 127.0.0.1:59578 Closing +[Mon Apr 13 09:48:44 2026] 127.0.0.1:47552 Accepted +[Mon Apr 13 09:48:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:45 2026] 127.0.0.1:47552 Closing +[Mon Apr 13 09:48:45 2026] 127.0.0.1:47560 Accepted +[Mon Apr 13 09:48:46 2026] 127.0.0.1:47560 Closing +[Mon Apr 13 09:48:46 2026] 127.0.0.1:47568 Accepted +[Mon Apr 13 09:48:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:47 2026] 127.0.0.1:47568 Closing +[Mon Apr 13 09:48:47 2026] 127.0.0.1:47582 Accepted +[Mon Apr 13 09:48:48 2026] 127.0.0.1:47582 Closing +[Mon Apr 13 09:48:49 2026] 127.0.0.1:47584 Accepted +[Mon Apr 13 09:48:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:49 2026] 127.0.0.1:47584 Closing +[Mon Apr 13 09:48:49 2026] 127.0.0.1:47590 Accepted +[Mon Apr 13 09:48:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 09:48:50 2026] 127.0.0.1:47590 Closing +[Mon Apr 13 09:48:50 2026] 127.0.0.1:47604 Accepted +[Mon Apr 13 09:48:50 2026] 127.0.0.1:47604 Closing +[Mon Apr 13 10:03:37 2026] 127.0.0.1:57552 Accepted +[Mon Apr 13 10:03:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:39 2026] 127.0.0.1:57552 Closing +[Mon Apr 13 10:03:40 2026] 127.0.0.1:58904 Accepted +[Mon Apr 13 10:03:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:41 2026] 127.0.0.1:58904 Closing +[Mon Apr 13 10:03:42 2026] 127.0.0.1:58916 Accepted +[Mon Apr 13 10:03:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:43 2026] 127.0.0.1:58916 Closing +[Mon Apr 13 10:03:44 2026] 127.0.0.1:58930 Accepted +[Mon Apr 13 10:03:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:45 2026] 127.0.0.1:58930 Closing +[Mon Apr 13 10:03:45 2026] 127.0.0.1:58938 Accepted +[Mon Apr 13 10:03:46 2026] 127.0.0.1:58938 Closing +[Mon Apr 13 10:03:47 2026] 127.0.0.1:58950 Accepted +[Mon Apr 13 10:03:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:47 2026] 127.0.0.1:58950 Closing +[Mon Apr 13 10:03:48 2026] 127.0.0.1:58956 Accepted +[Mon Apr 13 10:03:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:48 2026] 127.0.0.1:58956 Closing +[Mon Apr 13 10:03:48 2026] 127.0.0.1:58960 Accepted +[Mon Apr 13 10:03:49 2026] 127.0.0.1:58960 Closing +[Mon Apr 13 10:03:49 2026] 127.0.0.1:58974 Accepted +[Mon Apr 13 10:03:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:49 2026] 127.0.0.1:58974 Closing +[Mon Apr 13 10:03:49 2026] 127.0.0.1:58980 Accepted +[Mon Apr 13 10:03:51 2026] 127.0.0.1:58980 Closing +[Mon Apr 13 10:03:52 2026] 127.0.0.1:37842 Accepted +[Mon Apr 13 10:03:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:52 2026] 127.0.0.1:37842 Closing +[Mon Apr 13 10:03:53 2026] 127.0.0.1:37844 Accepted +[Mon Apr 13 10:03:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:53 2026] 127.0.0.1:37844 Closing +[Mon Apr 13 10:03:53 2026] 127.0.0.1:37850 Accepted +[Mon Apr 13 10:03:54 2026] 127.0.0.1:37850 Closing +[Mon Apr 13 10:03:55 2026] 127.0.0.1:37860 Accepted +[Mon Apr 13 10:03:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:56 2026] 127.0.0.1:37860 Closing +[Mon Apr 13 10:03:56 2026] 127.0.0.1:37866 Accepted +[Mon Apr 13 10:03:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:56 2026] 127.0.0.1:37866 Closing +[Mon Apr 13 10:03:56 2026] 127.0.0.1:37878 Accepted +[Mon Apr 13 10:03:57 2026] 127.0.0.1:37878 Closing +[Mon Apr 13 10:03:57 2026] 127.0.0.1:37880 Accepted +[Mon Apr 13 10:03:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:58 2026] 127.0.0.1:37880 Closing +[Mon Apr 13 10:03:58 2026] 127.0.0.1:37886 Accepted +[Mon Apr 13 10:03:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:59 2026] 127.0.0.1:37886 Closing +[Mon Apr 13 10:03:59 2026] 127.0.0.1:37888 Accepted +[Mon Apr 13 10:03:58 2026] 127.0.0.1:37888 Closing +[Mon Apr 13 10:03:58 2026] 127.0.0.1:37900 Accepted +[Mon Apr 13 10:03:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:03:58 2026] 127.0.0.1:37900 Closing +[Mon Apr 13 10:03:58 2026] 127.0.0.1:34790 Accepted +[Mon Apr 13 10:03:59 2026] 127.0.0.1:34790 Closing +[Mon Apr 13 10:04:00 2026] 127.0.0.1:34800 Accepted +[Mon Apr 13 10:04:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:00 2026] 127.0.0.1:34800 Closing +[Mon Apr 13 10:04:00 2026] 127.0.0.1:34816 Accepted +[Mon Apr 13 10:04:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:01 2026] 127.0.0.1:34816 Closing +[Mon Apr 13 10:04:01 2026] 127.0.0.1:34818 Accepted +[Mon Apr 13 10:04:01 2026] 127.0.0.1:34818 Closing +[Mon Apr 13 10:04:02 2026] 127.0.0.1:34828 Accepted +[Mon Apr 13 10:04:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:02 2026] 127.0.0.1:34828 Closing +[Mon Apr 13 10:04:03 2026] 127.0.0.1:34844 Accepted +[Mon Apr 13 10:04:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:03 2026] 127.0.0.1:34844 Closing +[Mon Apr 13 10:04:03 2026] 127.0.0.1:34860 Accepted +[Mon Apr 13 10:04:05 2026] 127.0.0.1:34860 Closing +[Mon Apr 13 10:04:05 2026] 127.0.0.1:34874 Accepted +[Mon Apr 13 10:04:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:06 2026] 127.0.0.1:34874 Closing +[Mon Apr 13 10:04:06 2026] 127.0.0.1:34880 Accepted +[Mon Apr 13 10:04:09 2026] 127.0.0.1:34880 Closing +[Mon Apr 13 10:04:10 2026] 127.0.0.1:39476 Accepted +[Mon Apr 13 10:04:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:10 2026] 127.0.0.1:39476 Closing +[Mon Apr 13 10:04:10 2026] 127.0.0.1:39488 Accepted +[Mon Apr 13 10:04:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:11 2026] 127.0.0.1:39488 Closing +[Mon Apr 13 10:04:11 2026] 127.0.0.1:39504 Accepted +[Mon Apr 13 10:04:12 2026] 127.0.0.1:39504 Closing +[Mon Apr 13 10:04:13 2026] 127.0.0.1:39508 Accepted +[Mon Apr 13 10:04:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:14 2026] 127.0.0.1:39508 Closing +[Mon Apr 13 10:04:14 2026] 127.0.0.1:39516 Accepted +[Mon Apr 13 10:04:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:15 2026] 127.0.0.1:39516 Closing +[Mon Apr 13 10:04:15 2026] 127.0.0.1:39528 Accepted +[Mon Apr 13 10:04:16 2026] 127.0.0.1:39528 Closing +[Mon Apr 13 10:04:17 2026] 127.0.0.1:39538 Accepted +[Mon Apr 13 10:04:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:18 2026] 127.0.0.1:39538 Closing +[Mon Apr 13 10:04:18 2026] 127.0.0.1:55886 Accepted +[Mon Apr 13 10:04:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:19 2026] 127.0.0.1:55886 Closing +[Mon Apr 13 10:04:19 2026] 127.0.0.1:55902 Accepted +[Mon Apr 13 10:04:21 2026] 127.0.0.1:55902 Closing +[Mon Apr 13 10:04:22 2026] 127.0.0.1:55914 Accepted +[Mon Apr 13 10:04:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:24 2026] 127.0.0.1:55914 Closing +[Mon Apr 13 10:04:24 2026] 127.0.0.1:55928 Accepted +[Mon Apr 13 10:04:27 2026] 127.0.0.1:55928 Closing +[Mon Apr 13 10:04:28 2026] 127.0.0.1:34564 Accepted +[Mon Apr 13 10:04:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:31 2026] 127.0.0.1:34564 Closing +[Mon Apr 13 10:04:31 2026] 127.0.0.1:34568 Accepted +[Mon Apr 13 10:04:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:33 2026] 127.0.0.1:34568 Closing +[Mon Apr 13 10:04:33 2026] 127.0.0.1:34578 Accepted +[Mon Apr 13 10:04:36 2026] 127.0.0.1:34578 Closing +[Mon Apr 13 10:04:37 2026] 127.0.0.1:49970 Accepted +[Mon Apr 13 10:04:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:40 2026] 127.0.0.1:49970 Closing +[Mon Apr 13 10:04:40 2026] 127.0.0.1:49976 Accepted +[Mon Apr 13 10:04:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:42 2026] 127.0.0.1:49976 Closing +[Mon Apr 13 10:04:42 2026] 127.0.0.1:49990 Accepted +[Mon Apr 13 10:04:44 2026] 127.0.0.1:49990 Closing +[Mon Apr 13 10:04:44 2026] 127.0.0.1:49998 Accepted +[Mon Apr 13 10:04:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:44 2026] 127.0.0.1:49998 Closing +[Mon Apr 13 10:04:44 2026] 127.0.0.1:50008 Accepted +[Mon Apr 13 10:04:46 2026] 127.0.0.1:50008 Closing +[Mon Apr 13 10:04:47 2026] 127.0.0.1:57992 Accepted +[Mon Apr 13 10:04:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:48 2026] 127.0.0.1:57992 Closing +[Mon Apr 13 10:04:48 2026] 127.0.0.1:58006 Accepted +[Mon Apr 13 10:04:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:50 2026] 127.0.0.1:58006 Closing +[Mon Apr 13 10:04:50 2026] 127.0.0.1:58022 Accepted +[Mon Apr 13 10:04:53 2026] 127.0.0.1:58022 Closing +[Mon Apr 13 10:04:54 2026] 127.0.0.1:58036 Accepted +[Mon Apr 13 10:04:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:56 2026] 127.0.0.1:58036 Closing +[Mon Apr 13 10:04:57 2026] 127.0.0.1:47914 Accepted +[Mon Apr 13 10:04:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:04:58 2026] 127.0.0.1:47914 Closing +[Mon Apr 13 10:04:58 2026] 127.0.0.1:47926 Accepted +[Mon Apr 13 10:05:00 2026] 127.0.0.1:47926 Closing +[Mon Apr 13 10:05:01 2026] 127.0.0.1:47932 Accepted +[Mon Apr 13 10:05:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:05:02 2026] 127.0.0.1:47932 Closing +[Mon Apr 13 10:05:02 2026] 127.0.0.1:47940 Accepted +[Mon Apr 13 10:05:06 2026] 127.0.0.1:47940 Closing +[Mon Apr 13 10:05:07 2026] 127.0.0.1:49344 Accepted +[Mon Apr 13 10:05:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:05:08 2026] 127.0.0.1:49344 Closing +[Mon Apr 13 10:05:09 2026] 127.0.0.1:49358 Accepted +[Mon Apr 13 10:05:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:05:13 2026] 127.0.0.1:49358 Closing +[Mon Apr 13 10:05:13 2026] 127.0.0.1:49368 Accepted +[Mon Apr 13 10:05:21 2026] 127.0.0.1:49368 Closing +[Mon Apr 13 10:05:23 2026] 127.0.0.1:42082 Accepted +[Mon Apr 13 10:05:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:05:27 2026] 127.0.0.1:42082 Closing +[Mon Apr 13 10:05:28 2026] 127.0.0.1:37810 Accepted +[Mon Apr 13 10:05:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:05:31 2026] 127.0.0.1:37810 Closing +[Mon Apr 13 10:05:31 2026] 127.0.0.1:37814 Accepted +[Mon Apr 13 10:05:50 2026] 127.0.0.1:37814 Closing +[Mon Apr 13 10:05:52 2026] 127.0.0.1:32912 Accepted +[Mon Apr 13 10:05:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:05:56 2026] 127.0.0.1:32912 Closing +[Mon Apr 13 10:05:56 2026] 127.0.0.1:59102 Accepted +[Mon Apr 13 10:05:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:05:58 2026] 127.0.0.1:59102 Closing +[Mon Apr 13 10:05:58 2026] 127.0.0.1:59116 Accepted +[Mon Apr 13 10:06:02 2026] 127.0.0.1:59116 Closing +[Mon Apr 13 10:06:02 2026] 127.0.0.1:59118 Accepted +[Mon Apr 13 10:06:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:06:05 2026] 127.0.0.1:59118 Closing +[Mon Apr 13 10:06:05 2026] 127.0.0.1:41878 Accepted +[Mon Apr 13 10:06:13 2026] 127.0.0.1:41878 Closing +[Mon Apr 13 10:06:14 2026] 127.0.0.1:49498 Accepted +[Mon Apr 13 10:06:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:06:16 2026] 127.0.0.1:49498 Closing +[Mon Apr 13 10:06:17 2026] 127.0.0.1:49514 Accepted +[Mon Apr 13 10:06:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:06:18 2026] 127.0.0.1:49514 Closing +[Mon Apr 13 10:06:18 2026] 127.0.0.1:49518 Accepted +[Mon Apr 13 10:06:23 2026] 127.0.0.1:49518 Closing +[Mon Apr 13 10:06:25 2026] 127.0.0.1:42558 Accepted +[Mon Apr 13 10:06:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:06:30 2026] 127.0.0.1:42558 Closing +[Mon Apr 13 10:06:31 2026] 127.0.0.1:42564 Accepted +[Mon Apr 13 10:06:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:06:34 2026] 127.0.0.1:42564 Closing +[Mon Apr 13 10:06:34 2026] 127.0.0.1:49586 Accepted +[Mon Apr 13 10:06:41 2026] 127.0.0.1:49586 Closing +[Mon Apr 13 10:06:41 2026] 127.0.0.1:49590 Accepted +[Mon Apr 13 10:06:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:06:47 2026] 127.0.0.1:49590 Closing +[Mon Apr 13 10:06:47 2026] 127.0.0.1:56334 Accepted +[Mon Apr 13 10:06:51 2026] 127.0.0.1:56334 Closing +[Mon Apr 13 10:06:51 2026] 127.0.0.1:37708 Accepted +[Mon Apr 13 10:06:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:06:52 2026] 127.0.0.1:37708 Closing +[Mon Apr 13 10:06:52 2026] 127.0.0.1:37712 Accepted +[Mon Apr 13 10:06:55 2026] 127.0.0.1:37712 Closing +[Mon Apr 13 10:06:55 2026] 127.0.0.1:37724 Accepted +[Mon Apr 13 10:06:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:06:58 2026] 127.0.0.1:37724 Closing +[Mon Apr 13 10:06:58 2026] 127.0.0.1:37738 Accepted +[Mon Apr 13 10:07:03 2026] 127.0.0.1:37738 Closing +[Mon Apr 13 10:07:05 2026] 127.0.0.1:48400 Accepted +[Mon Apr 13 10:07:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:12 2026] 127.0.0.1:48400 Closing +[Mon Apr 13 10:07:13 2026] 127.0.0.1:59466 Accepted +[Mon Apr 13 10:07:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:15 2026] 127.0.0.1:59466 Closing +[Mon Apr 13 10:07:15 2026] 127.0.0.1:59468 Accepted +[Mon Apr 13 10:07:19 2026] 127.0.0.1:59468 Closing +[Mon Apr 13 10:07:19 2026] 127.0.0.1:38994 Accepted +[Mon Apr 13 10:07:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:20 2026] 127.0.0.1:38994 Closing +[Mon Apr 13 10:07:21 2026] 127.0.0.1:39004 Accepted +[Mon Apr 13 10:07:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:22 2026] 127.0.0.1:39004 Closing +[Mon Apr 13 10:07:22 2026] 127.0.0.1:39012 Accepted +[Mon Apr 13 10:07:24 2026] 127.0.0.1:39012 Closing +[Mon Apr 13 10:07:24 2026] 127.0.0.1:39018 Accepted +[Mon Apr 13 10:07:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:25 2026] 127.0.0.1:39018 Closing +[Mon Apr 13 10:07:25 2026] 127.0.0.1:39030 Accepted +[Mon Apr 13 10:07:27 2026] 127.0.0.1:39030 Closing +[Mon Apr 13 10:07:28 2026] 127.0.0.1:51764 Accepted +[Mon Apr 13 10:07:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:32 2026] 127.0.0.1:51764 Closing +[Mon Apr 13 10:07:32 2026] 127.0.0.1:51776 Accepted +[Mon Apr 13 10:07:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:33 2026] 127.0.0.1:51776 Closing +[Mon Apr 13 10:07:33 2026] 127.0.0.1:51792 Accepted +[Mon Apr 13 10:07:37 2026] 127.0.0.1:51792 Closing +[Mon Apr 13 10:07:39 2026] 127.0.0.1:58852 Accepted +[Mon Apr 13 10:07:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:41 2026] 127.0.0.1:58852 Closing +[Mon Apr 13 10:07:42 2026] 127.0.0.1:58858 Accepted +[Mon Apr 13 10:07:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:46 2026] 127.0.0.1:58858 Closing +[Mon Apr 13 10:07:46 2026] 127.0.0.1:58874 Accepted +[Mon Apr 13 10:07:48 2026] 127.0.0.1:58874 Closing +[Mon Apr 13 10:07:49 2026] 127.0.0.1:38152 Accepted +[Mon Apr 13 10:07:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:51 2026] 127.0.0.1:38152 Closing +[Mon Apr 13 10:07:52 2026] 127.0.0.1:38154 Accepted +[Mon Apr 13 10:07:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:07:54 2026] 127.0.0.1:38154 Closing +[Mon Apr 13 10:07:54 2026] 127.0.0.1:38156 Accepted +[Mon Apr 13 10:08:02 2026] 127.0.0.1:38156 Closing +[Mon Apr 13 10:08:05 2026] 127.0.0.1:57824 Accepted +[Mon Apr 13 10:08:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:08:10 2026] 127.0.0.1:57824 Closing +[Mon Apr 13 10:08:11 2026] 127.0.0.1:46928 Accepted +[Mon Apr 13 10:08:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:08:13 2026] 127.0.0.1:46928 Closing +[Mon Apr 13 10:08:13 2026] 127.0.0.1:46938 Accepted +[Mon Apr 13 10:08:16 2026] 127.0.0.1:46938 Closing +[Mon Apr 13 10:08:16 2026] 127.0.0.1:46940 Accepted +[Mon Apr 13 10:08:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:08:15 2026] 127.0.0.1:46940 Closing +[Mon Apr 13 10:08:15 2026] 127.0.0.1:47412 Accepted +[Mon Apr 13 10:08:19 2026] 127.0.0.1:47412 Closing +[Mon Apr 13 10:08:21 2026] 127.0.0.1:47414 Accepted +[Mon Apr 13 10:08:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:08:23 2026] 127.0.0.1:47414 Closing +[Mon Apr 13 10:08:23 2026] 127.0.0.1:47422 Accepted +[Mon Apr 13 10:08:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:08:29 2026] 127.0.0.1:47422 Closing +[Mon Apr 13 10:08:29 2026] 127.0.0.1:53668 Accepted +[Mon Apr 13 10:08:32 2026] 127.0.0.1:53668 Closing +[Mon Apr 13 10:57:44 2026] 127.0.0.1:33162 Accepted +[Mon Apr 13 10:57:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:57:45 2026] 127.0.0.1:33162 Closing +[Mon Apr 13 10:57:46 2026] 127.0.0.1:33168 Accepted +[Mon Apr 13 10:57:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:57:46 2026] 127.0.0.1:33168 Closing +[Mon Apr 13 10:57:46 2026] 127.0.0.1:33184 Accepted +[Mon Apr 13 10:57:51 2026] 127.0.0.1:33184 Closing +[Mon Apr 13 10:57:52 2026] 127.0.0.1:56752 Accepted +[Mon Apr 13 10:57:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:57:52 2026] 127.0.0.1:56752 Closing +[Mon Apr 13 10:57:53 2026] 127.0.0.1:56762 Accepted +[Mon Apr 13 10:57:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:57:53 2026] 127.0.0.1:56762 Closing +[Mon Apr 13 10:57:53 2026] 127.0.0.1:56770 Accepted +[Mon Apr 13 10:57:59 2026] 127.0.0.1:56770 Closing +[Mon Apr 13 10:58:01 2026] 127.0.0.1:46276 Accepted +[Mon Apr 13 10:58:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:01 2026] 127.0.0.1:46276 Closing +[Mon Apr 13 10:58:01 2026] 127.0.0.1:46284 Accepted +[Mon Apr 13 10:58:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:02 2026] 127.0.0.1:46284 Closing +[Mon Apr 13 10:58:02 2026] 127.0.0.1:46298 Accepted +[Mon Apr 13 10:58:03 2026] 127.0.0.1:46298 Closing +[Mon Apr 13 10:58:03 2026] 127.0.0.1:46300 Accepted +[Mon Apr 13 10:58:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:03 2026] 127.0.0.1:46300 Closing +[Mon Apr 13 10:58:03 2026] 127.0.0.1:46304 Accepted +[Mon Apr 13 10:58:06 2026] 127.0.0.1:46304 Closing +[Mon Apr 13 10:58:06 2026] 127.0.0.1:46308 Accepted +[Mon Apr 13 10:58:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:07 2026] 127.0.0.1:46308 Closing +[Mon Apr 13 10:58:07 2026] 127.0.0.1:46320 Accepted +[Mon Apr 13 10:58:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:08 2026] 127.0.0.1:46320 Closing +[Mon Apr 13 10:58:08 2026] 127.0.0.1:46328 Accepted +[Mon Apr 13 10:58:12 2026] 127.0.0.1:46328 Closing +[Mon Apr 13 10:58:14 2026] 127.0.0.1:37682 Accepted +[Mon Apr 13 10:58:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:14 2026] 127.0.0.1:37682 Closing +[Mon Apr 13 10:58:15 2026] 127.0.0.1:37698 Accepted +[Mon Apr 13 10:58:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:15 2026] 127.0.0.1:37698 Closing +[Mon Apr 13 10:58:15 2026] 127.0.0.1:37700 Accepted +[Mon Apr 13 10:58:18 2026] 127.0.0.1:37700 Closing +[Mon Apr 13 10:58:18 2026] 127.0.0.1:37702 Accepted +[Mon Apr 13 10:58:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:18 2026] 127.0.0.1:37702 Closing +[Mon Apr 13 10:58:18 2026] 127.0.0.1:37708 Accepted +[Mon Apr 13 10:58:20 2026] 127.0.0.1:37708 Closing +[Mon Apr 13 10:58:20 2026] 127.0.0.1:53950 Accepted +[Mon Apr 13 10:58:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:20 2026] 127.0.0.1:53950 Closing +[Mon Apr 13 10:58:20 2026] 127.0.0.1:53964 Accepted +[Mon Apr 13 10:58:20 2026] 127.0.0.1:53964 Closing +[Mon Apr 13 10:58:21 2026] 127.0.0.1:53966 Accepted +[Mon Apr 13 10:58:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:21 2026] 127.0.0.1:53966 Closing +[Mon Apr 13 10:58:21 2026] 127.0.0.1:53980 Accepted +[Mon Apr 13 10:58:23 2026] 127.0.0.1:53980 Closing +[Mon Apr 13 10:58:24 2026] 127.0.0.1:53982 Accepted +[Mon Apr 13 10:58:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:24 2026] 127.0.0.1:53982 Closing +[Mon Apr 13 10:58:25 2026] 127.0.0.1:53998 Accepted +[Mon Apr 13 10:58:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:26 2026] 127.0.0.1:53998 Closing +[Mon Apr 13 10:58:26 2026] 127.0.0.1:54004 Accepted +[Mon Apr 13 10:58:30 2026] 127.0.0.1:54004 Closing +[Mon Apr 13 10:58:31 2026] 127.0.0.1:49758 Accepted +[Mon Apr 13 10:58:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:32 2026] 127.0.0.1:49758 Closing +[Mon Apr 13 10:58:32 2026] 127.0.0.1:49768 Accepted +[Mon Apr 13 10:58:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:32 2026] 127.0.0.1:49768 Closing +[Mon Apr 13 10:58:32 2026] 127.0.0.1:49780 Accepted +[Mon Apr 13 10:58:34 2026] 127.0.0.1:49780 Closing +[Mon Apr 13 10:58:34 2026] 127.0.0.1:49788 Accepted +[Mon Apr 13 10:58:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:35 2026] 127.0.0.1:49788 Closing +[Mon Apr 13 10:58:35 2026] 127.0.0.1:49796 Accepted +[Mon Apr 13 10:58:36 2026] 127.0.0.1:49796 Closing +[Mon Apr 13 10:58:37 2026] 127.0.0.1:49798 Accepted +[Mon Apr 13 10:58:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:37 2026] 127.0.0.1:49798 Closing +[Mon Apr 13 10:58:38 2026] 127.0.0.1:49814 Accepted +[Mon Apr 13 10:58:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:38 2026] 127.0.0.1:49814 Closing +[Mon Apr 13 10:58:38 2026] 127.0.0.1:52854 Accepted +[Mon Apr 13 10:58:42 2026] 127.0.0.1:52854 Closing +[Mon Apr 13 10:58:44 2026] 127.0.0.1:52860 Accepted +[Mon Apr 13 10:58:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:44 2026] 127.0.0.1:52860 Closing +[Mon Apr 13 10:58:45 2026] 127.0.0.1:52864 Accepted +[Mon Apr 13 10:58:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:45 2026] 127.0.0.1:52864 Closing +[Mon Apr 13 10:58:45 2026] 127.0.0.1:52872 Accepted +[Mon Apr 13 10:58:47 2026] 127.0.0.1:52872 Closing +[Mon Apr 13 10:58:47 2026] 127.0.0.1:52876 Accepted +[Mon Apr 13 10:58:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:48 2026] 127.0.0.1:52876 Closing +[Mon Apr 13 10:58:49 2026] 127.0.0.1:46660 Accepted +[Mon Apr 13 10:58:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:49 2026] 127.0.0.1:46660 Closing +[Mon Apr 13 10:58:49 2026] 127.0.0.1:46662 Accepted +[Mon Apr 13 10:58:51 2026] 127.0.0.1:46662 Closing +[Mon Apr 13 10:58:52 2026] 127.0.0.1:46676 Accepted +[Mon Apr 13 10:58:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:53 2026] 127.0.0.1:46676 Closing +[Mon Apr 13 10:58:53 2026] 127.0.0.1:46678 Accepted +[Mon Apr 13 10:58:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:54 2026] 127.0.0.1:46678 Closing +[Mon Apr 13 10:58:54 2026] 127.0.0.1:46690 Accepted +[Mon Apr 13 10:58:56 2026] 127.0.0.1:46690 Closing +[Mon Apr 13 10:58:56 2026] 127.0.0.1:46702 Accepted +[Mon Apr 13 10:58:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 10:58:57 2026] 127.0.0.1:46702 Closing +[Mon Apr 13 10:58:57 2026] 127.0.0.1:46704 Accepted +[Mon Apr 13 10:58:58 2026] 127.0.0.1:46704 Closing +[Mon Apr 13 11:11:34 2026] 127.0.0.1:41660 Accepted +[Mon Apr 13 11:11:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:11:38 2026] 127.0.0.1:41660 Closing +[Mon Apr 13 11:11:41 2026] 127.0.0.1:41670 Accepted +[Mon Apr 13 11:11:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:11:43 2026] 127.0.0.1:41670 Closing +[Mon Apr 13 11:11:43 2026] 127.0.0.1:52652 Accepted +[Mon Apr 13 11:11:52 2026] 127.0.0.1:52652 Closing +[Mon Apr 13 11:11:55 2026] 127.0.0.1:55626 Accepted +[Mon Apr 13 11:11:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:11:56 2026] 127.0.0.1:55626 Closing +[Mon Apr 13 11:11:56 2026] 127.0.0.1:55638 Accepted +[Mon Apr 13 11:11:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:11:58 2026] 127.0.0.1:55638 Closing +[Mon Apr 13 11:11:58 2026] 127.0.0.1:55652 Accepted +[Mon Apr 13 11:12:12 2026] 127.0.0.1:55652 Closing +[Mon Apr 13 11:12:13 2026] 127.0.0.1:41982 Accepted +[Mon Apr 13 11:12:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:13 2026] 127.0.0.1:41982 Closing +[Mon Apr 13 11:12:13 2026] 127.0.0.1:41986 Accepted +[Mon Apr 13 11:12:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:14 2026] 127.0.0.1:41986 Closing +[Mon Apr 13 11:12:14 2026] 127.0.0.1:42002 Accepted +[Mon Apr 13 11:12:14 2026] 127.0.0.1:42002 Closing +[Mon Apr 13 11:12:15 2026] 127.0.0.1:42014 Accepted +[Mon Apr 13 11:12:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:15 2026] 127.0.0.1:42014 Closing +[Mon Apr 13 11:12:15 2026] 127.0.0.1:42020 Accepted +[Mon Apr 13 11:12:17 2026] 127.0.0.1:42020 Closing +[Mon Apr 13 11:12:18 2026] 127.0.0.1:42022 Accepted +[Mon Apr 13 11:12:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:18 2026] 127.0.0.1:42022 Closing +[Mon Apr 13 11:12:18 2026] 127.0.0.1:40834 Accepted +[Mon Apr 13 11:12:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:19 2026] 127.0.0.1:40834 Closing +[Mon Apr 13 11:12:19 2026] 127.0.0.1:40840 Accepted +[Mon Apr 13 11:12:23 2026] 127.0.0.1:40840 Closing +[Mon Apr 13 11:12:24 2026] 127.0.0.1:40846 Accepted +[Mon Apr 13 11:12:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:25 2026] 127.0.0.1:40846 Closing +[Mon Apr 13 11:12:25 2026] 127.0.0.1:40862 Accepted +[Mon Apr 13 11:12:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:25 2026] 127.0.0.1:40862 Closing +[Mon Apr 13 11:12:25 2026] 127.0.0.1:40872 Accepted +[Mon Apr 13 11:12:27 2026] 127.0.0.1:40872 Closing +[Mon Apr 13 11:12:27 2026] 127.0.0.1:40886 Accepted +[Mon Apr 13 11:12:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:27 2026] 127.0.0.1:40886 Closing +[Mon Apr 13 11:12:27 2026] 127.0.0.1:40898 Accepted +[Mon Apr 13 11:12:29 2026] 127.0.0.1:40898 Closing +[Mon Apr 13 11:12:29 2026] 127.0.0.1:46872 Accepted +[Mon Apr 13 11:12:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:29 2026] 127.0.0.1:46872 Closing +[Mon Apr 13 11:12:29 2026] 127.0.0.1:46884 Accepted +[Mon Apr 13 11:12:30 2026] 127.0.0.1:46884 Closing +[Mon Apr 13 11:12:31 2026] 127.0.0.1:46892 Accepted +[Mon Apr 13 11:12:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:31 2026] 127.0.0.1:46892 Closing +[Mon Apr 13 11:12:31 2026] 127.0.0.1:46900 Accepted +[Mon Apr 13 11:12:32 2026] 127.0.0.1:46900 Closing +[Mon Apr 13 11:12:33 2026] 127.0.0.1:46908 Accepted +[Mon Apr 13 11:12:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:33 2026] 127.0.0.1:46908 Closing +[Mon Apr 13 11:12:34 2026] 127.0.0.1:46918 Accepted +[Mon Apr 13 11:12:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:35 2026] 127.0.0.1:46918 Closing +[Mon Apr 13 11:12:35 2026] 127.0.0.1:46924 Accepted +[Mon Apr 13 11:12:39 2026] 127.0.0.1:46924 Closing +[Mon Apr 13 11:12:40 2026] 127.0.0.1:34170 Accepted +[Mon Apr 13 11:12:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:41 2026] 127.0.0.1:34170 Closing +[Mon Apr 13 11:12:41 2026] 127.0.0.1:34186 Accepted +[Mon Apr 13 11:12:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:40 2026] 127.0.0.1:34186 Closing +[Mon Apr 13 11:12:40 2026] 127.0.0.1:34200 Accepted +[Mon Apr 13 11:12:42 2026] 127.0.0.1:34200 Closing +[Mon Apr 13 11:12:42 2026] 127.0.0.1:34202 Accepted +[Mon Apr 13 11:12:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:42 2026] 127.0.0.1:34202 Closing +[Mon Apr 13 11:12:42 2026] 127.0.0.1:34204 Accepted +[Mon Apr 13 11:12:43 2026] 127.0.0.1:34204 Closing +[Mon Apr 13 11:12:44 2026] 127.0.0.1:34216 Accepted +[Mon Apr 13 11:12:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:44 2026] 127.0.0.1:34216 Closing +[Mon Apr 13 11:12:45 2026] 127.0.0.1:34218 Accepted +[Mon Apr 13 11:12:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:46 2026] 127.0.0.1:34218 Closing +[Mon Apr 13 11:12:46 2026] 127.0.0.1:34232 Accepted +[Mon Apr 13 11:12:50 2026] 127.0.0.1:34232 Closing +[Mon Apr 13 11:12:52 2026] 127.0.0.1:41936 Accepted +[Mon Apr 13 11:12:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:54 2026] 127.0.0.1:41936 Closing +[Mon Apr 13 11:12:55 2026] 127.0.0.1:41940 Accepted +[Mon Apr 13 11:12:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:12:57 2026] 127.0.0.1:41940 Closing +[Mon Apr 13 11:12:57 2026] 127.0.0.1:53224 Accepted +[Mon Apr 13 11:13:00 2026] 127.0.0.1:53224 Closing +[Mon Apr 13 11:13:01 2026] 127.0.0.1:53230 Accepted +[Mon Apr 13 11:13:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:13:04 2026] 127.0.0.1:53230 Closing +[Mon Apr 13 11:13:04 2026] 127.0.0.1:53238 Accepted +[Mon Apr 13 11:13:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:13:05 2026] 127.0.0.1:53238 Closing +[Mon Apr 13 11:13:05 2026] 127.0.0.1:53250 Accepted +[Mon Apr 13 11:13:08 2026] 127.0.0.1:53250 Closing +[Mon Apr 13 11:13:09 2026] 127.0.0.1:40296 Accepted +[Mon Apr 13 11:13:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:13:09 2026] 127.0.0.1:40296 Closing +[Mon Apr 13 11:13:09 2026] 127.0.0.1:40308 Accepted +[Mon Apr 13 11:13:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:13:10 2026] 127.0.0.1:40308 Closing +[Mon Apr 13 11:13:10 2026] 127.0.0.1:40322 Accepted +[Mon Apr 13 11:13:12 2026] 127.0.0.1:40322 Closing +[Mon Apr 13 11:13:12 2026] 127.0.0.1:40324 Accepted +[Mon Apr 13 11:13:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 11:13:13 2026] 127.0.0.1:40324 Closing +[Mon Apr 13 11:13:13 2026] 127.0.0.1:40340 Accepted +[Mon Apr 13 11:13:15 2026] 127.0.0.1:40340 Closing +[Mon Apr 13 12:10:46 2026] 127.0.0.1:46640 Accepted +[Mon Apr 13 12:10:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:10:49 2026] 127.0.0.1:46640 Closing +[Mon Apr 13 12:10:50 2026] 127.0.0.1:46644 Accepted +[Mon Apr 13 12:10:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:10:50 2026] 127.0.0.1:46644 Closing +[Mon Apr 13 12:10:50 2026] 127.0.0.1:46656 Accepted +[Mon Apr 13 12:10:53 2026] 127.0.0.1:46656 Closing +[Mon Apr 13 12:10:54 2026] 127.0.0.1:40484 Accepted +[Mon Apr 13 12:10:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:10:54 2026] 127.0.0.1:40484 Closing +[Mon Apr 13 12:10:55 2026] 127.0.0.1:40500 Accepted +[Mon Apr 13 12:10:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:10:55 2026] 127.0.0.1:40500 Closing +[Mon Apr 13 12:10:55 2026] 127.0.0.1:40514 Accepted +[Mon Apr 13 12:10:56 2026] 127.0.0.1:40514 Closing +[Mon Apr 13 12:10:57 2026] 127.0.0.1:40516 Accepted +[Mon Apr 13 12:10:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:10:58 2026] 127.0.0.1:40516 Closing +[Mon Apr 13 12:10:58 2026] 127.0.0.1:40528 Accepted +[Mon Apr 13 12:10:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:10:58 2026] 127.0.0.1:40528 Closing +[Mon Apr 13 12:11:29 2026] 127.0.0.1:56268 Accepted +[Mon Apr 13 12:11:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:11:30 2026] 127.0.0.1:56268 Closing +[Mon Apr 13 12:11:31 2026] 127.0.0.1:56272 Accepted +[Mon Apr 13 12:11:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:11:31 2026] 127.0.0.1:56272 Closing +[Mon Apr 13 12:11:31 2026] 127.0.0.1:34000 Accepted +[Mon Apr 13 12:11:34 2026] 127.0.0.1:34000 Closing +[Mon Apr 13 12:11:35 2026] 127.0.0.1:34010 Accepted +[Mon Apr 13 12:11:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:11:35 2026] 127.0.0.1:34010 Closing +[Mon Apr 13 12:11:35 2026] 127.0.0.1:34012 Accepted +[Mon Apr 13 12:11:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:11:36 2026] 127.0.0.1:34012 Closing +[Mon Apr 13 12:11:36 2026] 127.0.0.1:34020 Accepted +[Mon Apr 13 12:11:37 2026] 127.0.0.1:34020 Closing +[Mon Apr 13 12:11:37 2026] 127.0.0.1:34034 Accepted +[Mon Apr 13 12:11:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:11:38 2026] 127.0.0.1:34034 Closing +[Mon Apr 13 12:11:38 2026] 127.0.0.1:34048 Accepted +[Mon Apr 13 12:11:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:11:39 2026] 127.0.0.1:34048 Closing +[Mon Apr 13 12:14:59 2026] 127.0.0.1:44966 Accepted +[Mon Apr 13 12:14:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:01 2026] 127.0.0.1:44966 Closing +[Mon Apr 13 12:15:02 2026] 127.0.0.1:52260 Accepted +[Mon Apr 13 12:15:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:03 2026] 127.0.0.1:52260 Closing +[Mon Apr 13 12:15:03 2026] 127.0.0.1:52270 Accepted +[Mon Apr 13 12:15:09 2026] 127.0.0.1:52270 Closing +[Mon Apr 13 12:15:14 2026] 127.0.0.1:60550 Accepted +[Mon Apr 13 12:15:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:15 2026] 127.0.0.1:60550 Closing +[Mon Apr 13 12:15:16 2026] 127.0.0.1:60552 Accepted +[Mon Apr 13 12:15:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:17 2026] 127.0.0.1:60552 Closing +[Mon Apr 13 12:15:17 2026] 127.0.0.1:60562 Accepted +[Mon Apr 13 12:15:23 2026] 127.0.0.1:60562 Closing +[Mon Apr 13 12:15:26 2026] 127.0.0.1:59008 Accepted +[Mon Apr 13 12:15:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:27 2026] 127.0.0.1:59008 Closing +[Mon Apr 13 12:15:27 2026] 127.0.0.1:59014 Accepted +[Mon Apr 13 12:15:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:28 2026] 127.0.0.1:59014 Closing +[Mon Apr 13 12:15:28 2026] 127.0.0.1:59022 Accepted +[Mon Apr 13 12:15:29 2026] 127.0.0.1:59022 Closing +[Mon Apr 13 12:15:29 2026] 127.0.0.1:59028 Accepted +[Mon Apr 13 12:15:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:30 2026] 127.0.0.1:59028 Closing +[Mon Apr 13 12:15:30 2026] 127.0.0.1:50904 Accepted +[Mon Apr 13 12:15:33 2026] 127.0.0.1:50904 Closing +[Mon Apr 13 12:15:34 2026] 127.0.0.1:50918 Accepted +[Mon Apr 13 12:15:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:36 2026] 127.0.0.1:50918 Closing +[Mon Apr 13 12:15:36 2026] 127.0.0.1:50932 Accepted +[Mon Apr 13 12:15:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:37 2026] 127.0.0.1:50932 Closing +[Mon Apr 13 12:15:37 2026] 127.0.0.1:50938 Accepted +[Mon Apr 13 12:15:42 2026] 127.0.0.1:50938 Closing +[Mon Apr 13 12:15:44 2026] 127.0.0.1:43700 Accepted +[Mon Apr 13 12:15:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:46 2026] 127.0.0.1:43700 Closing +[Mon Apr 13 12:15:45 2026] 127.0.0.1:43708 Accepted +[Mon Apr 13 12:15:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:47 2026] 127.0.0.1:43708 Closing +[Mon Apr 13 12:15:47 2026] 127.0.0.1:43712 Accepted +[Mon Apr 13 12:15:50 2026] 127.0.0.1:43712 Closing +[Mon Apr 13 12:15:50 2026] 127.0.0.1:56252 Accepted +[Mon Apr 13 12:15:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:51 2026] 127.0.0.1:56252 Closing +[Mon Apr 13 12:15:51 2026] 127.0.0.1:56260 Accepted +[Mon Apr 13 12:15:52 2026] 127.0.0.1:56260 Closing +[Mon Apr 13 12:15:52 2026] 127.0.0.1:56270 Accepted +[Mon Apr 13 12:15:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:53 2026] 127.0.0.1:56270 Closing +[Mon Apr 13 12:15:53 2026] 127.0.0.1:56282 Accepted +[Mon Apr 13 12:15:55 2026] 127.0.0.1:56282 Closing +[Mon Apr 13 12:15:55 2026] 127.0.0.1:56288 Accepted +[Mon Apr 13 12:15:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:55 2026] 127.0.0.1:56288 Closing +[Mon Apr 13 12:15:55 2026] 127.0.0.1:56304 Accepted +[Mon Apr 13 12:15:57 2026] 127.0.0.1:56304 Closing +[Mon Apr 13 12:15:58 2026] 127.0.0.1:56314 Accepted +[Mon Apr 13 12:15:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:15:58 2026] 127.0.0.1:56314 Closing +[Mon Apr 13 12:15:59 2026] 127.0.0.1:58876 Accepted +[Mon Apr 13 12:15:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:00 2026] 127.0.0.1:58876 Closing +[Mon Apr 13 12:16:00 2026] 127.0.0.1:58888 Accepted +[Mon Apr 13 12:16:04 2026] 127.0.0.1:58888 Closing +[Mon Apr 13 12:16:06 2026] 127.0.0.1:58890 Accepted +[Mon Apr 13 12:16:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:06 2026] 127.0.0.1:58890 Closing +[Mon Apr 13 12:16:07 2026] 127.0.0.1:58900 Accepted +[Mon Apr 13 12:16:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:07 2026] 127.0.0.1:58900 Closing +[Mon Apr 13 12:16:07 2026] 127.0.0.1:58910 Accepted +[Mon Apr 13 12:16:09 2026] 127.0.0.1:58910 Closing +[Mon Apr 13 12:16:09 2026] 127.0.0.1:44060 Accepted +[Mon Apr 13 12:16:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:10 2026] 127.0.0.1:44060 Closing +[Mon Apr 13 12:16:10 2026] 127.0.0.1:44072 Accepted +[Mon Apr 13 12:16:11 2026] 127.0.0.1:44072 Closing +[Mon Apr 13 12:16:11 2026] 127.0.0.1:44076 Accepted +[Mon Apr 13 12:16:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:12 2026] 127.0.0.1:44076 Closing +[Mon Apr 13 12:16:12 2026] 127.0.0.1:44086 Accepted +[Mon Apr 13 12:16:14 2026] 127.0.0.1:44086 Closing +[Mon Apr 13 12:16:14 2026] 127.0.0.1:44100 Accepted +[Mon Apr 13 12:16:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:14 2026] 127.0.0.1:44100 Closing +[Mon Apr 13 12:16:14 2026] 127.0.0.1:44108 Accepted +[Mon Apr 13 12:16:15 2026] 127.0.0.1:44108 Closing +[Mon Apr 13 12:16:15 2026] 127.0.0.1:44124 Accepted +[Mon Apr 13 12:16:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:15 2026] 127.0.0.1:44124 Closing +[Mon Apr 13 12:16:15 2026] 127.0.0.1:44132 Accepted +[Mon Apr 13 12:16:18 2026] 127.0.0.1:44132 Closing +[Mon Apr 13 12:16:19 2026] 127.0.0.1:40210 Accepted +[Mon Apr 13 12:16:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:19 2026] 127.0.0.1:40210 Closing +[Mon Apr 13 12:16:20 2026] 127.0.0.1:40226 Accepted +[Mon Apr 13 12:16:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:20 2026] 127.0.0.1:40226 Closing +[Mon Apr 13 12:16:20 2026] 127.0.0.1:40234 Accepted +[Mon Apr 13 12:16:22 2026] 127.0.0.1:40234 Closing +[Mon Apr 13 12:16:22 2026] 127.0.0.1:40244 Accepted +[Mon Apr 13 12:16:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:22 2026] 127.0.0.1:40244 Closing +[Mon Apr 13 12:16:22 2026] 127.0.0.1:40256 Accepted +[Mon Apr 13 12:16:24 2026] 127.0.0.1:40256 Closing +[Mon Apr 13 12:16:24 2026] 127.0.0.1:40262 Accepted +[Mon Apr 13 12:16:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:25 2026] 127.0.0.1:40262 Closing +[Mon Apr 13 12:16:26 2026] 127.0.0.1:40274 Accepted +[Mon Apr 13 12:16:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:26 2026] 127.0.0.1:40274 Closing +[Mon Apr 13 12:16:26 2026] 127.0.0.1:40286 Accepted +[Mon Apr 13 12:16:30 2026] 127.0.0.1:40286 Closing +[Mon Apr 13 12:16:32 2026] 127.0.0.1:50706 Accepted +[Mon Apr 13 12:16:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:32 2026] 127.0.0.1:50706 Closing +[Mon Apr 13 12:16:33 2026] 127.0.0.1:50710 Accepted +[Mon Apr 13 12:16:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:34 2026] 127.0.0.1:50710 Closing +[Mon Apr 13 12:16:34 2026] 127.0.0.1:50726 Accepted +[Mon Apr 13 12:16:36 2026] 127.0.0.1:50726 Closing +[Mon Apr 13 12:16:36 2026] 127.0.0.1:50728 Accepted +[Mon Apr 13 12:16:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:36 2026] 127.0.0.1:50728 Closing +[Mon Apr 13 12:16:36 2026] 127.0.0.1:50738 Accepted +[Mon Apr 13 12:16:38 2026] 127.0.0.1:50738 Closing +[Mon Apr 13 12:16:38 2026] 127.0.0.1:58924 Accepted +[Mon Apr 13 12:16:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:39 2026] 127.0.0.1:58924 Closing +[Mon Apr 13 12:16:39 2026] 127.0.0.1:58928 Accepted +[Mon Apr 13 12:16:40 2026] 127.0.0.1:58928 Closing +[Mon Apr 13 12:16:40 2026] 127.0.0.1:58938 Accepted +[Mon Apr 13 12:16:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:41 2026] 127.0.0.1:58938 Closing +[Mon Apr 13 12:16:41 2026] 127.0.0.1:58946 Accepted +[Mon Apr 13 12:16:42 2026] 127.0.0.1:58946 Closing +[Mon Apr 13 12:16:42 2026] 127.0.0.1:58948 Accepted +[Mon Apr 13 12:16:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:43 2026] 127.0.0.1:58948 Closing +[Mon Apr 13 12:16:43 2026] 127.0.0.1:58964 Accepted +[Mon Apr 13 12:16:43 2026] 127.0.0.1:58964 Closing +[Mon Apr 13 12:16:45 2026] 127.0.0.1:58974 Accepted +[Mon Apr 13 12:16:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:45 2026] 127.0.0.1:58974 Closing +[Mon Apr 13 12:16:45 2026] 127.0.0.1:58986 Accepted +[Mon Apr 13 12:16:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:46 2026] 127.0.0.1:58986 Closing +[Mon Apr 13 12:16:46 2026] 127.0.0.1:58998 Accepted +[Mon Apr 13 12:16:47 2026] 127.0.0.1:58998 Closing +[Mon Apr 13 12:16:48 2026] 127.0.0.1:32908 Accepted +[Mon Apr 13 12:16:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:48 2026] 127.0.0.1:32908 Closing +[Mon Apr 13 12:16:49 2026] 127.0.0.1:32922 Accepted +[Mon Apr 13 12:16:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:50 2026] 127.0.0.1:32922 Closing +[Mon Apr 13 12:16:50 2026] 127.0.0.1:32934 Accepted +[Mon Apr 13 12:16:51 2026] 127.0.0.1:32934 Closing +[Mon Apr 13 12:16:51 2026] 127.0.0.1:32936 Accepted +[Mon Apr 13 12:16:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:52 2026] 127.0.0.1:32936 Closing +[Mon Apr 13 12:16:52 2026] 127.0.0.1:32938 Accepted +[Mon Apr 13 12:16:53 2026] 127.0.0.1:32938 Closing +[Mon Apr 13 12:16:54 2026] 127.0.0.1:32940 Accepted +[Mon Apr 13 12:16:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:55 2026] 127.0.0.1:32940 Closing +[Mon Apr 13 12:16:55 2026] 127.0.0.1:32950 Accepted +[Mon Apr 13 12:16:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:16:56 2026] 127.0.0.1:32950 Closing +[Mon Apr 13 12:16:56 2026] 127.0.0.1:32960 Accepted +[Mon Apr 13 12:16:58 2026] 127.0.0.1:32960 Closing +[Mon Apr 13 12:17:00 2026] 127.0.0.1:55920 Accepted +[Mon Apr 13 12:17:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:01 2026] 127.0.0.1:55920 Closing +[Mon Apr 13 12:17:01 2026] 127.0.0.1:55932 Accepted +[Mon Apr 13 12:17:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:02 2026] 127.0.0.1:55932 Closing +[Mon Apr 13 12:17:02 2026] 127.0.0.1:55944 Accepted +[Mon Apr 13 12:17:04 2026] 127.0.0.1:55944 Closing +[Mon Apr 13 12:17:04 2026] 127.0.0.1:55952 Accepted +[Mon Apr 13 12:17:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:04 2026] 127.0.0.1:55952 Closing +[Mon Apr 13 12:17:04 2026] 127.0.0.1:55954 Accepted +[Mon Apr 13 12:17:06 2026] 127.0.0.1:55954 Closing +[Mon Apr 13 12:17:10 2026] 127.0.0.1:58640 Accepted +[Mon Apr 13 12:17:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:11 2026] 127.0.0.1:58640 Closing +[Mon Apr 13 12:17:12 2026] 127.0.0.1:58654 Accepted +[Mon Apr 13 12:17:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:11 2026] 127.0.0.1:58654 Closing +[Mon Apr 13 12:17:11 2026] 127.0.0.1:58656 Accepted +[Mon Apr 13 12:17:16 2026] 127.0.0.1:58656 Closing +[Mon Apr 13 12:17:19 2026] 127.0.0.1:33114 Accepted +[Mon Apr 13 12:17:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:19 2026] 127.0.0.1:33114 Closing +[Mon Apr 13 12:17:20 2026] 127.0.0.1:33128 Accepted +[Mon Apr 13 12:17:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:21 2026] 127.0.0.1:33128 Closing +[Mon Apr 13 12:17:21 2026] 127.0.0.1:33138 Accepted +[Mon Apr 13 12:17:27 2026] 127.0.0.1:33138 Closing +[Mon Apr 13 12:17:29 2026] 127.0.0.1:57102 Accepted +[Mon Apr 13 12:17:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:30 2026] 127.0.0.1:57102 Closing +[Mon Apr 13 12:17:30 2026] 127.0.0.1:57114 Accepted +[Mon Apr 13 12:17:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:30 2026] 127.0.0.1:57114 Closing +[Mon Apr 13 12:17:30 2026] 127.0.0.1:57120 Accepted +[Mon Apr 13 12:17:31 2026] 127.0.0.1:57120 Closing +[Mon Apr 13 12:17:31 2026] 127.0.0.1:57126 Accepted +[Mon Apr 13 12:17:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:31 2026] 127.0.0.1:57126 Closing +[Mon Apr 13 12:17:31 2026] 127.0.0.1:57136 Accepted +[Mon Apr 13 12:17:34 2026] 127.0.0.1:57136 Closing +[Mon Apr 13 12:17:35 2026] 127.0.0.1:57152 Accepted +[Mon Apr 13 12:17:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:35 2026] 127.0.0.1:57152 Closing +[Mon Apr 13 12:17:35 2026] 127.0.0.1:52496 Accepted +[Mon Apr 13 12:17:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:36 2026] 127.0.0.1:52496 Closing +[Mon Apr 13 12:17:36 2026] 127.0.0.1:52512 Accepted +[Mon Apr 13 12:17:40 2026] 127.0.0.1:52512 Closing +[Mon Apr 13 12:17:40 2026] 127.0.0.1:52526 Accepted +[Mon Apr 13 12:17:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:40 2026] 127.0.0.1:52526 Closing +[Mon Apr 13 12:17:41 2026] 127.0.0.1:52540 Accepted +[Mon Apr 13 12:17:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:42 2026] 127.0.0.1:52540 Closing +[Mon Apr 13 12:17:42 2026] 127.0.0.1:52542 Accepted +[Mon Apr 13 12:17:45 2026] 127.0.0.1:52542 Closing +[Mon Apr 13 12:17:45 2026] 127.0.0.1:53442 Accepted +[Mon Apr 13 12:17:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:47 2026] 127.0.0.1:53442 Closing +[Mon Apr 13 12:17:47 2026] 127.0.0.1:53448 Accepted +[Mon Apr 13 12:17:53 2026] 127.0.0.1:53448 Closing +[Mon Apr 13 12:17:53 2026] 127.0.0.1:53458 Accepted +[Mon Apr 13 12:17:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:17:56 2026] 127.0.0.1:53458 Closing +[Mon Apr 13 12:17:56 2026] 127.0.0.1:53246 Accepted +[Mon Apr 13 12:18:00 2026] 127.0.0.1:53246 Closing +[Mon Apr 13 12:18:01 2026] 127.0.0.1:53260 Accepted +[Mon Apr 13 12:18:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:18:03 2026] 127.0.0.1:53260 Closing +[Mon Apr 13 12:18:03 2026] 127.0.0.1:53276 Accepted +[Mon Apr 13 12:18:07 2026] 127.0.0.1:53276 Closing +[Mon Apr 13 12:18:09 2026] 127.0.0.1:32864 Accepted +[Mon Apr 13 12:18:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:18:09 2026] 127.0.0.1:32864 Closing +[Mon Apr 13 12:18:10 2026] 127.0.0.1:32870 Accepted +[Mon Apr 13 12:18:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:18:12 2026] 127.0.0.1:32870 Closing +[Mon Apr 13 12:18:12 2026] 127.0.0.1:32872 Accepted +[Mon Apr 13 12:18:23 2026] 127.0.0.1:32872 Closing +[Mon Apr 13 12:18:25 2026] 127.0.0.1:57880 Accepted +[Mon Apr 13 12:18:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:18:27 2026] 127.0.0.1:57880 Closing +[Mon Apr 13 12:18:28 2026] 127.0.0.1:57894 Accepted +[Mon Apr 13 12:18:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:18:31 2026] 127.0.0.1:57894 Closing +[Mon Apr 13 12:18:31 2026] 127.0.0.1:57906 Accepted +[Mon Apr 13 12:18:38 2026] 127.0.0.1:57906 Closing +[Mon Apr 13 12:18:38 2026] 127.0.0.1:44490 Accepted +[Mon Apr 13 12:18:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:18:39 2026] 127.0.0.1:44490 Closing +[Mon Apr 13 12:18:39 2026] 127.0.0.1:44496 Accepted +[Mon Apr 13 12:18:43 2026] 127.0.0.1:44496 Closing +[Mon Apr 13 12:18:43 2026] 127.0.0.1:46010 Accepted +[Mon Apr 13 12:18:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:18:45 2026] 127.0.0.1:46010 Closing +[Mon Apr 13 12:18:45 2026] 127.0.0.1:46014 Accepted +[Mon Apr 13 12:18:50 2026] 127.0.0.1:46014 Closing +[Mon Apr 13 12:18:51 2026] 127.0.0.1:43000 Accepted +[Mon Apr 13 12:18:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:18:53 2026] 127.0.0.1:43000 Closing +[Mon Apr 13 12:18:53 2026] 127.0.0.1:43004 Accepted +[Mon Apr 13 12:18:58 2026] 127.0.0.1:43004 Closing +[Mon Apr 13 12:18:58 2026] 127.0.0.1:43008 Accepted +[Mon Apr 13 12:18:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:00 2026] 127.0.0.1:43008 Closing +[Mon Apr 13 12:19:00 2026] 127.0.0.1:43014 Accepted +[Mon Apr 13 12:19:04 2026] 127.0.0.1:43014 Closing +[Mon Apr 13 12:19:06 2026] 127.0.0.1:57742 Accepted +[Mon Apr 13 12:19:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:10 2026] 127.0.0.1:57742 Closing +[Mon Apr 13 12:19:11 2026] 127.0.0.1:60542 Accepted +[Mon Apr 13 12:19:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:13 2026] 127.0.0.1:60542 Closing +[Mon Apr 13 12:19:13 2026] 127.0.0.1:60552 Accepted +[Mon Apr 13 12:19:17 2026] 127.0.0.1:60552 Closing +[Mon Apr 13 12:19:17 2026] 127.0.0.1:60554 Accepted +[Mon Apr 13 12:19:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:18 2026] 127.0.0.1:60554 Closing +[Mon Apr 13 12:19:18 2026] 127.0.0.1:60562 Accepted +[Mon Apr 13 12:19:19 2026] 127.0.0.1:60562 Closing +[Mon Apr 13 12:19:20 2026] 127.0.0.1:56496 Accepted +[Mon Apr 13 12:19:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:21 2026] 127.0.0.1:56496 Closing +[Mon Apr 13 12:19:23 2026] 127.0.0.1:56500 Accepted +[Mon Apr 13 12:19:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:25 2026] 127.0.0.1:56500 Closing +[Mon Apr 13 12:19:25 2026] 127.0.0.1:56516 Accepted +[Mon Apr 13 12:19:32 2026] 127.0.0.1:56516 Closing +[Mon Apr 13 12:19:34 2026] 127.0.0.1:55480 Accepted +[Mon Apr 13 12:19:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:35 2026] 127.0.0.1:55480 Closing +[Mon Apr 13 12:19:35 2026] 127.0.0.1:55496 Accepted +[Mon Apr 13 12:19:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:37 2026] 127.0.0.1:55496 Closing +[Mon Apr 13 12:19:37 2026] 127.0.0.1:55512 Accepted +[Mon Apr 13 12:19:43 2026] 127.0.0.1:55512 Closing +[Mon Apr 13 12:19:43 2026] 127.0.0.1:57810 Accepted +[Mon Apr 13 12:19:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:46 2026] 127.0.0.1:57810 Closing +[Mon Apr 13 12:19:46 2026] 127.0.0.1:57822 Accepted +[Mon Apr 13 12:19:50 2026] 127.0.0.1:57822 Closing +[Mon Apr 13 12:19:50 2026] 127.0.0.1:44642 Accepted +[Mon Apr 13 12:19:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:53 2026] 127.0.0.1:44642 Closing +[Mon Apr 13 12:19:53 2026] 127.0.0.1:44654 Accepted +[Mon Apr 13 12:19:57 2026] 127.0.0.1:44654 Closing +[Mon Apr 13 12:19:57 2026] 127.0.0.1:44660 Accepted +[Mon Apr 13 12:19:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:19:58 2026] 127.0.0.1:44660 Closing +[Mon Apr 13 12:19:58 2026] 127.0.0.1:34492 Accepted +[Mon Apr 13 12:20:02 2026] 127.0.0.1:34492 Closing +[Mon Apr 13 12:20:02 2026] 127.0.0.1:34502 Accepted +[Mon Apr 13 12:20:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:20:03 2026] 127.0.0.1:34502 Closing +[Mon Apr 13 12:20:03 2026] 127.0.0.1:34516 Accepted +[Mon Apr 13 12:20:04 2026] 127.0.0.1:34516 Closing +[Mon Apr 13 12:20:06 2026] 127.0.0.1:34528 Accepted +[Mon Apr 13 12:20:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:20:08 2026] 127.0.0.1:34528 Closing +[Mon Apr 13 12:20:09 2026] 127.0.0.1:46434 Accepted +[Mon Apr 13 12:20:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:20:10 2026] 127.0.0.1:46434 Closing +[Mon Apr 13 12:20:10 2026] 127.0.0.1:46438 Accepted +[Mon Apr 13 12:20:16 2026] 127.0.0.1:46438 Closing +[Mon Apr 13 12:20:18 2026] 127.0.0.1:34648 Accepted +[Mon Apr 13 12:20:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:20:20 2026] 127.0.0.1:34648 Closing +[Mon Apr 13 12:20:22 2026] 127.0.0.1:34658 Accepted +[Mon Apr 13 12:20:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:20:24 2026] 127.0.0.1:34658 Closing +[Mon Apr 13 12:20:24 2026] 127.0.0.1:34674 Accepted +[Mon Apr 13 12:20:28 2026] 127.0.0.1:34674 Closing +[Mon Apr 13 12:20:28 2026] 127.0.0.1:45206 Accepted +[Mon Apr 13 12:20:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:20:30 2026] 127.0.0.1:45206 Closing +[Mon Apr 13 12:20:30 2026] 127.0.0.1:45220 Accepted +[Mon Apr 13 12:20:32 2026] 127.0.0.1:45220 Closing +[Mon Apr 13 12:20:33 2026] 127.0.0.1:45232 Accepted +[Mon Apr 13 12:20:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:20:34 2026] 127.0.0.1:45232 Closing +[Mon Apr 13 12:20:35 2026] 127.0.0.1:50288 Accepted +[Mon Apr 13 12:20:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:20:41 2026] 127.0.0.1:50288 Closing +[Mon Apr 13 12:20:41 2026] 127.0.0.1:50292 Accepted +[Mon Apr 13 12:21:01 2026] 127.0.0.1:50292 Closing +[Mon Apr 13 12:21:07 2026] 127.0.0.1:33438 Accepted +[Mon Apr 13 12:21:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:21:10 2026] 127.0.0.1:33438 Closing +[Mon Apr 13 12:21:13 2026] 127.0.0.1:33450 Accepted +[Mon Apr 13 12:21:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:21:33 2026] 127.0.0.1:33450 Closing +[Mon Apr 13 12:21:33 2026] 127.0.0.1:42116 Accepted +[Mon Apr 13 12:21:38 2026] 127.0.0.1:42116 Closing +[Mon Apr 13 12:21:38 2026] 127.0.0.1:42124 Accepted +[Mon Apr 13 12:21:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:21:40 2026] 127.0.0.1:42124 Closing +[Mon Apr 13 12:21:40 2026] 127.0.0.1:42140 Accepted +[Mon Apr 13 12:21:45 2026] 127.0.0.1:42140 Closing +[Mon Apr 13 12:36:42 2026] 127.0.0.1:51020 Accepted +[Mon Apr 13 12:36:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:36:44 2026] 127.0.0.1:51020 Closing +[Mon Apr 13 12:36:45 2026] 127.0.0.1:51024 Accepted +[Mon Apr 13 12:36:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:36:45 2026] 127.0.0.1:51024 Closing +[Mon Apr 13 12:36:45 2026] 127.0.0.1:51032 Accepted +[Mon Apr 13 12:36:50 2026] 127.0.0.1:51032 Closing +[Mon Apr 13 12:36:53 2026] 127.0.0.1:46348 Accepted +[Mon Apr 13 12:36:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:36:55 2026] 127.0.0.1:46348 Closing +[Mon Apr 13 12:36:56 2026] 127.0.0.1:46364 Accepted +[Mon Apr 13 12:36:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:36:57 2026] 127.0.0.1:46364 Closing +[Mon Apr 13 12:36:57 2026] 127.0.0.1:46368 Accepted +[Mon Apr 13 12:36:59 2026] 127.0.0.1:46368 Closing +[Mon Apr 13 12:37:00 2026] 127.0.0.1:37312 Accepted +[Mon Apr 13 12:37:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:37:01 2026] 127.0.0.1:37312 Closing +[Mon Apr 13 12:37:02 2026] 127.0.0.1:37322 Accepted +[Mon Apr 13 12:37:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:37:03 2026] 127.0.0.1:37322 Closing +[Mon Apr 13 12:37:03 2026] 127.0.0.1:37332 Accepted +[Mon Apr 13 12:37:10 2026] 127.0.0.1:37332 Closing +[Mon Apr 13 12:37:13 2026] 127.0.0.1:35040 Accepted +[Mon Apr 13 12:37:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:37:14 2026] 127.0.0.1:35040 Closing +[Mon Apr 13 12:37:14 2026] 127.0.0.1:35052 Accepted +[Mon Apr 13 12:37:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:37:15 2026] 127.0.0.1:35052 Closing +[Mon Apr 13 12:37:15 2026] 127.0.0.1:35058 Accepted +[Mon Apr 13 12:37:16 2026] 127.0.0.1:35058 Closing +[Mon Apr 13 12:37:47 2026] 127.0.0.1:34162 Accepted +[Mon Apr 13 12:37:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:37:48 2026] 127.0.0.1:34162 Closing +[Mon Apr 13 12:37:50 2026] 127.0.0.1:34164 Accepted +[Mon Apr 13 12:37:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:37:52 2026] 127.0.0.1:34164 Closing +[Mon Apr 13 12:37:52 2026] 127.0.0.1:34178 Accepted +[Mon Apr 13 12:38:00 2026] 127.0.0.1:34178 Closing +[Mon Apr 13 12:38:02 2026] 127.0.0.1:49874 Accepted +[Mon Apr 13 12:38:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:38:03 2026] 127.0.0.1:49874 Closing +[Mon Apr 13 12:38:03 2026] 127.0.0.1:49886 Accepted +[Mon Apr 13 12:38:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:38:04 2026] 127.0.0.1:49886 Closing +[Mon Apr 13 12:38:04 2026] 127.0.0.1:49902 Accepted +[Mon Apr 13 12:38:06 2026] 127.0.0.1:49902 Closing +[Mon Apr 13 12:38:08 2026] 127.0.0.1:37358 Accepted +[Mon Apr 13 12:38:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:38:10 2026] 127.0.0.1:37358 Closing +[Mon Apr 13 12:38:11 2026] 127.0.0.1:37362 Accepted +[Mon Apr 13 12:38:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:38:12 2026] 127.0.0.1:37362 Closing +[Mon Apr 13 12:38:12 2026] 127.0.0.1:37374 Accepted +[Mon Apr 13 12:38:20 2026] 127.0.0.1:37374 Closing +[Mon Apr 13 12:38:22 2026] 127.0.0.1:54028 Accepted +[Mon Apr 13 12:38:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:38:25 2026] 127.0.0.1:54028 Closing +[Mon Apr 13 12:38:26 2026] 127.0.0.1:54032 Accepted +[Mon Apr 13 12:38:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:38:27 2026] 127.0.0.1:54032 Closing +[Mon Apr 13 12:38:27 2026] 127.0.0.1:52306 Accepted +[Mon Apr 13 12:38:30 2026] 127.0.0.1:52306 Closing +[Mon Apr 13 12:46:22 2026] 127.0.0.1:38138 Accepted +[Mon Apr 13 12:46:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:46:25 2026] 127.0.0.1:38138 Closing +[Mon Apr 13 12:46:26 2026] 127.0.0.1:50608 Accepted +[Mon Apr 13 12:46:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:46:27 2026] 127.0.0.1:50608 Closing +[Mon Apr 13 12:46:27 2026] 127.0.0.1:50610 Accepted +[Mon Apr 13 12:46:31 2026] 127.0.0.1:50610 Closing +[Mon Apr 13 12:46:32 2026] 127.0.0.1:50626 Accepted +[Mon Apr 13 12:46:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:46:33 2026] 127.0.0.1:50626 Closing +[Mon Apr 13 12:46:34 2026] 127.0.0.1:47502 Accepted +[Mon Apr 13 12:46:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:46:34 2026] 127.0.0.1:47502 Closing +[Mon Apr 13 12:46:34 2026] 127.0.0.1:47508 Accepted +[Mon Apr 13 12:46:37 2026] 127.0.0.1:47508 Closing +[Mon Apr 13 12:46:38 2026] 127.0.0.1:47512 Accepted +[Mon Apr 13 12:46:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:46:39 2026] 127.0.0.1:47512 Closing +[Mon Apr 13 12:46:40 2026] 127.0.0.1:47518 Accepted +[Mon Apr 13 12:46:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:46:41 2026] 127.0.0.1:47518 Closing +[Mon Apr 13 12:46:41 2026] 127.0.0.1:47526 Accepted +[Mon Apr 13 12:46:47 2026] 127.0.0.1:47526 Closing +[Mon Apr 13 12:46:48 2026] 127.0.0.1:49436 Accepted +[Mon Apr 13 12:46:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:46:50 2026] 127.0.0.1:49436 Closing +[Mon Apr 13 12:46:50 2026] 127.0.0.1:49446 Accepted +[Mon Apr 13 12:46:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:46:51 2026] 127.0.0.1:49446 Closing +[Mon Apr 13 12:46:51 2026] 127.0.0.1:49454 Accepted +[Mon Apr 13 12:46:51 2026] 127.0.0.1:49454 Closing +[Mon Apr 13 12:49:59 2026] 127.0.0.1:60118 Accepted +[Mon Apr 13 12:49:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:50:01 2026] 127.0.0.1:60118 Closing +[Mon Apr 13 12:50:02 2026] 127.0.0.1:60120 Accepted +[Mon Apr 13 12:50:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:50:02 2026] 127.0.0.1:60120 Closing +[Mon Apr 13 12:50:02 2026] 127.0.0.1:60122 Accepted +[Mon Apr 13 12:50:05 2026] 127.0.0.1:60122 Closing +[Mon Apr 13 12:50:06 2026] 127.0.0.1:58884 Accepted +[Mon Apr 13 12:50:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:50:07 2026] 127.0.0.1:58884 Closing +[Mon Apr 13 12:50:08 2026] 127.0.0.1:58894 Accepted +[Mon Apr 13 12:50:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:50:08 2026] 127.0.0.1:58894 Closing +[Mon Apr 13 12:50:08 2026] 127.0.0.1:58910 Accepted +[Mon Apr 13 12:50:11 2026] 127.0.0.1:58910 Closing +[Mon Apr 13 12:50:12 2026] 127.0.0.1:58916 Accepted +[Mon Apr 13 12:50:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:50:11 2026] 127.0.0.1:58916 Closing +[Mon Apr 13 12:50:12 2026] 127.0.0.1:58930 Accepted +[Mon Apr 13 12:50:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:50:13 2026] 127.0.0.1:58930 Closing +[Mon Apr 13 12:50:13 2026] 127.0.0.1:34238 Accepted +[Mon Apr 13 12:50:20 2026] 127.0.0.1:34238 Closing +[Mon Apr 13 12:50:21 2026] 127.0.0.1:34246 Accepted +[Mon Apr 13 12:50:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:50:22 2026] 127.0.0.1:34246 Closing +[Mon Apr 13 12:50:22 2026] 127.0.0.1:34260 Accepted +[Mon Apr 13 12:50:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 12:50:23 2026] 127.0.0.1:34260 Closing +[Mon Apr 13 12:50:23 2026] 127.0.0.1:34270 Accepted +[Mon Apr 13 12:50:24 2026] 127.0.0.1:34270 Closing +[Mon Apr 13 13:00:00 2026] 127.0.0.1:54154 Accepted +[Mon Apr 13 13:00:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:02 2026] 127.0.0.1:54154 Closing +[Mon Apr 13 13:00:02 2026] 127.0.0.1:54162 Accepted +[Mon Apr 13 13:00:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:05 2026] 127.0.0.1:54162 Closing +[Mon Apr 13 13:00:05 2026] 127.0.0.1:54174 Accepted +[Mon Apr 13 13:00:09 2026] 127.0.0.1:54174 Closing +[Mon Apr 13 13:00:11 2026] 127.0.0.1:45638 Accepted +[Mon Apr 13 13:00:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:12 2026] 127.0.0.1:45638 Closing +[Mon Apr 13 13:00:12 2026] 127.0.0.1:45654 Accepted +[Mon Apr 13 13:00:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:13 2026] 127.0.0.1:45654 Closing +[Mon Apr 13 13:00:13 2026] 127.0.0.1:45658 Accepted +[Mon Apr 13 13:00:14 2026] 127.0.0.1:45658 Closing +[Mon Apr 13 13:00:14 2026] 127.0.0.1:45662 Accepted +[Mon Apr 13 13:00:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:15 2026] 127.0.0.1:45662 Closing +[Mon Apr 13 13:00:16 2026] 127.0.0.1:48842 Accepted +[Mon Apr 13 13:00:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:18 2026] 127.0.0.1:48842 Closing +[Mon Apr 13 13:00:18 2026] 127.0.0.1:48852 Accepted +[Mon Apr 13 13:00:20 2026] 127.0.0.1:48852 Closing +[Mon Apr 13 13:00:21 2026] 127.0.0.1:48864 Accepted +[Mon Apr 13 13:00:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:22 2026] 127.0.0.1:48864 Closing +[Mon Apr 13 13:00:23 2026] 127.0.0.1:48874 Accepted +[Mon Apr 13 13:00:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:23 2026] 127.0.0.1:48874 Closing +[Mon Apr 13 13:00:23 2026] 127.0.0.1:48882 Accepted +[Mon Apr 13 13:00:29 2026] 127.0.0.1:48882 Closing +[Mon Apr 13 13:00:30 2026] 127.0.0.1:33396 Accepted +[Mon Apr 13 13:00:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:31 2026] 127.0.0.1:33396 Closing +[Mon Apr 13 13:00:31 2026] 127.0.0.1:33410 Accepted +[Mon Apr 13 13:00:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:00:32 2026] 127.0.0.1:33410 Closing +[Mon Apr 13 13:00:32 2026] 127.0.0.1:33414 Accepted +[Mon Apr 13 13:00:32 2026] 127.0.0.1:33414 Closing +[Mon Apr 13 13:04:04 2026] 127.0.0.1:38888 Accepted +[Mon Apr 13 13:04:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:06 2026] 127.0.0.1:38888 Closing +[Mon Apr 13 13:04:09 2026] 127.0.0.1:54038 Accepted +[Mon Apr 13 13:04:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:11 2026] 127.0.0.1:54038 Closing +[Mon Apr 13 13:04:11 2026] 127.0.0.1:54048 Accepted +[Mon Apr 13 13:04:16 2026] 127.0.0.1:54048 Closing +[Mon Apr 13 13:04:22 2026] 127.0.0.1:50028 Accepted +[Mon Apr 13 13:04:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:24 2026] 127.0.0.1:50028 Closing +[Mon Apr 13 13:04:24 2026] 127.0.0.1:50030 Accepted +[Mon Apr 13 13:04:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:25 2026] 127.0.0.1:50030 Closing +[Mon Apr 13 13:04:25 2026] 127.0.0.1:45018 Accepted +[Mon Apr 13 13:04:30 2026] 127.0.0.1:45018 Closing +[Mon Apr 13 13:04:31 2026] 127.0.0.1:45028 Accepted +[Mon Apr 13 13:04:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:32 2026] 127.0.0.1:45028 Closing +[Mon Apr 13 13:04:32 2026] 127.0.0.1:45040 Accepted +[Mon Apr 13 13:04:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:33 2026] 127.0.0.1:45040 Closing +[Mon Apr 13 13:04:33 2026] 127.0.0.1:45054 Accepted +[Mon Apr 13 13:04:33 2026] 127.0.0.1:45054 Closing +[Mon Apr 13 13:04:34 2026] 127.0.0.1:49912 Accepted +[Mon Apr 13 13:04:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:35 2026] 127.0.0.1:49912 Closing +[Mon Apr 13 13:04:36 2026] 127.0.0.1:49916 Accepted +[Mon Apr 13 13:04:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:37 2026] 127.0.0.1:49916 Closing +[Mon Apr 13 13:04:37 2026] 127.0.0.1:49922 Accepted +[Mon Apr 13 13:04:44 2026] 127.0.0.1:49922 Closing +[Mon Apr 13 13:04:45 2026] 127.0.0.1:48650 Accepted +[Mon Apr 13 13:04:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:46 2026] 127.0.0.1:48650 Closing +[Mon Apr 13 13:04:46 2026] 127.0.0.1:48654 Accepted +[Mon Apr 13 13:04:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:04:47 2026] 127.0.0.1:48654 Closing +[Mon Apr 13 13:04:47 2026] 127.0.0.1:48662 Accepted +[Mon Apr 13 13:04:48 2026] 127.0.0.1:48662 Closing +[Mon Apr 13 13:33:10 2026] 127.0.0.1:60520 Accepted +[Mon Apr 13 13:33:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:11 2026] 127.0.0.1:60520 Closing +[Mon Apr 13 13:33:12 2026] 127.0.0.1:60532 Accepted +[Mon Apr 13 13:33:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:12 2026] 127.0.0.1:60532 Closing +[Mon Apr 13 13:33:12 2026] 127.0.0.1:60546 Accepted +[Mon Apr 13 13:33:13 2026] 127.0.0.1:60546 Closing +[Mon Apr 13 13:33:14 2026] 127.0.0.1:41550 Accepted +[Mon Apr 13 13:33:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:15 2026] 127.0.0.1:41550 Closing +[Mon Apr 13 13:33:15 2026] 127.0.0.1:41562 Accepted +[Mon Apr 13 13:33:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:16 2026] 127.0.0.1:41562 Closing +[Mon Apr 13 13:33:16 2026] 127.0.0.1:41578 Accepted +[Mon Apr 13 13:33:18 2026] 127.0.0.1:41578 Closing +[Mon Apr 13 13:33:18 2026] 127.0.0.1:41586 Accepted +[Mon Apr 13 13:33:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:19 2026] 127.0.0.1:41586 Closing +[Mon Apr 13 13:33:19 2026] 127.0.0.1:41596 Accepted +[Mon Apr 13 13:33:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:20 2026] 127.0.0.1:41596 Closing +[Mon Apr 13 13:33:20 2026] 127.0.0.1:41606 Accepted +[Mon Apr 13 13:33:21 2026] 127.0.0.1:41606 Closing +[Mon Apr 13 13:33:22 2026] 127.0.0.1:41614 Accepted +[Mon Apr 13 13:33:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:23 2026] 127.0.0.1:41614 Closing +[Mon Apr 13 13:33:23 2026] 127.0.0.1:41626 Accepted +[Mon Apr 13 13:33:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:23 2026] 127.0.0.1:41626 Closing +[Mon Apr 13 13:33:23 2026] 127.0.0.1:41642 Accepted +[Mon Apr 13 13:33:26 2026] 127.0.0.1:41642 Closing +[Mon Apr 13 13:33:26 2026] 127.0.0.1:60812 Accepted +[Mon Apr 13 13:33:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:26 2026] 127.0.0.1:60812 Closing +[Mon Apr 13 13:33:26 2026] 127.0.0.1:60816 Accepted +[Mon Apr 13 13:33:27 2026] 127.0.0.1:60816 Closing +[Mon Apr 13 13:33:29 2026] 127.0.0.1:60832 Accepted +[Mon Apr 13 13:33:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:30 2026] 127.0.0.1:60832 Closing +[Mon Apr 13 13:33:31 2026] 127.0.0.1:60842 Accepted +[Mon Apr 13 13:33:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:31 2026] 127.0.0.1:60842 Closing +[Mon Apr 13 13:33:31 2026] 127.0.0.1:60852 Accepted +[Mon Apr 13 13:33:37 2026] 127.0.0.1:60852 Closing +[Mon Apr 13 13:33:38 2026] 127.0.0.1:60870 Accepted +[Mon Apr 13 13:33:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:39 2026] 127.0.0.1:60870 Closing +[Mon Apr 13 13:33:39 2026] 127.0.0.1:60876 Accepted +[Mon Apr 13 13:33:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:33:39 2026] 127.0.0.1:60876 Closing +[Mon Apr 13 13:33:39 2026] 127.0.0.1:60886 Accepted +[Mon Apr 13 13:33:40 2026] 127.0.0.1:60886 Closing +[Mon Apr 13 13:36:56 2026] 127.0.0.1:46946 Accepted +[Mon Apr 13 13:36:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:00 2026] 127.0.0.1:46946 Closing +[Mon Apr 13 13:37:00 2026] 127.0.0.1:46952 Accepted +[Mon Apr 13 13:37:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:01 2026] 127.0.0.1:46952 Closing +[Mon Apr 13 13:37:01 2026] 127.0.0.1:46960 Accepted +[Mon Apr 13 13:37:04 2026] 127.0.0.1:46960 Closing +[Mon Apr 13 13:37:05 2026] 127.0.0.1:51750 Accepted +[Mon Apr 13 13:37:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:06 2026] 127.0.0.1:51750 Closing +[Mon Apr 13 13:37:07 2026] 127.0.0.1:51756 Accepted +[Mon Apr 13 13:37:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:09 2026] 127.0.0.1:51756 Closing +[Mon Apr 13 13:37:09 2026] 127.0.0.1:51758 Accepted +[Mon Apr 13 13:37:16 2026] 127.0.0.1:51758 Closing +[Mon Apr 13 13:37:18 2026] 127.0.0.1:54128 Accepted +[Mon Apr 13 13:37:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:20 2026] 127.0.0.1:54128 Closing +[Mon Apr 13 13:37:21 2026] 127.0.0.1:54138 Accepted +[Mon Apr 13 13:37:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:23 2026] 127.0.0.1:54138 Closing +[Mon Apr 13 13:37:23 2026] 127.0.0.1:54150 Accepted +[Mon Apr 13 13:37:24 2026] 127.0.0.1:54150 Closing +[Mon Apr 13 13:37:26 2026] 127.0.0.1:46162 Accepted +[Mon Apr 13 13:37:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:29 2026] 127.0.0.1:46162 Closing +[Mon Apr 13 13:37:29 2026] 127.0.0.1:46174 Accepted +[Mon Apr 13 13:37:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:31 2026] 127.0.0.1:46174 Closing +[Mon Apr 13 13:37:31 2026] 127.0.0.1:46178 Accepted +[Mon Apr 13 13:37:34 2026] 127.0.0.1:46178 Closing +[Mon Apr 13 13:37:34 2026] 127.0.0.1:34234 Accepted +[Mon Apr 13 13:37:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:36 2026] 127.0.0.1:34234 Closing +[Mon Apr 13 13:37:36 2026] 127.0.0.1:34238 Accepted +[Mon Apr 13 13:37:40 2026] 127.0.0.1:34238 Closing +[Mon Apr 13 13:37:44 2026] 127.0.0.1:58904 Accepted +[Mon Apr 13 13:37:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:47 2026] 127.0.0.1:58904 Closing +[Mon Apr 13 13:37:51 2026] 127.0.0.1:58916 Accepted +[Mon Apr 13 13:37:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:37:54 2026] 127.0.0.1:58916 Closing +[Mon Apr 13 13:37:54 2026] 127.0.0.1:54952 Accepted +[Mon Apr 13 13:38:04 2026] 127.0.0.1:54952 Closing +[Mon Apr 13 13:38:06 2026] 127.0.0.1:56028 Accepted +[Mon Apr 13 13:38:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:38:07 2026] 127.0.0.1:56028 Closing +[Mon Apr 13 13:38:07 2026] 127.0.0.1:56034 Accepted +[Mon Apr 13 13:38:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:38:08 2026] 127.0.0.1:56034 Closing +[Mon Apr 13 13:38:08 2026] 127.0.0.1:56036 Accepted +[Mon Apr 13 13:38:09 2026] 127.0.0.1:56036 Closing +[Mon Apr 13 13:39:51 2026] 127.0.0.1:42660 Accepted +[Mon Apr 13 13:39:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:39:53 2026] 127.0.0.1:42660 Closing +[Mon Apr 13 13:39:54 2026] 127.0.0.1:42668 Accepted +[Mon Apr 13 13:39:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:39:55 2026] 127.0.0.1:42668 Closing +[Mon Apr 13 13:39:55 2026] 127.0.0.1:42678 Accepted +[Mon Apr 13 13:39:59 2026] 127.0.0.1:42678 Closing +[Mon Apr 13 13:39:59 2026] 127.0.0.1:49062 Accepted +[Mon Apr 13 13:39:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:40:01 2026] 127.0.0.1:49062 Closing +[Mon Apr 13 13:40:01 2026] 127.0.0.1:49070 Accepted +[Mon Apr 13 13:40:03 2026] 127.0.0.1:49070 Closing +[Mon Apr 13 13:42:58 2026] 127.0.0.1:57606 Accepted +[Mon Apr 13 13:42:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:43:05 2026] 127.0.0.1:57606 Closing +[Mon Apr 13 13:43:08 2026] 127.0.0.1:54758 Accepted +[Mon Apr 13 13:43:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:43:14 2026] 127.0.0.1:54758 Closing +[Mon Apr 13 13:43:14 2026] 127.0.0.1:54768 Accepted +[Mon Apr 13 13:43:24 2026] 127.0.0.1:54768 Closing +[Mon Apr 13 13:43:30 2026] 127.0.0.1:34844 Accepted +[Mon Apr 13 13:43:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:43:32 2026] 127.0.0.1:34844 Closing +[Mon Apr 13 13:43:33 2026] 127.0.0.1:34860 Accepted +[Mon Apr 13 13:43:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:43:34 2026] 127.0.0.1:34860 Closing +[Mon Apr 13 13:43:34 2026] 127.0.0.1:34868 Accepted +[Mon Apr 13 13:43:39 2026] 127.0.0.1:34868 Closing +[Mon Apr 13 13:43:41 2026] 127.0.0.1:45674 Accepted +[Mon Apr 13 13:43:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:43:45 2026] 127.0.0.1:45674 Closing +[Mon Apr 13 13:43:46 2026] 127.0.0.1:59580 Accepted +[Mon Apr 13 13:43:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:43:50 2026] 127.0.0.1:59580 Closing +[Mon Apr 13 13:43:50 2026] 127.0.0.1:59588 Accepted +[Mon Apr 13 13:43:55 2026] 127.0.0.1:59588 Closing +[Mon Apr 13 13:43:57 2026] 127.0.0.1:44854 Accepted +[Mon Apr 13 13:43:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:44:02 2026] 127.0.0.1:44854 Closing +[Mon Apr 13 13:44:03 2026] 127.0.0.1:44870 Accepted +[Mon Apr 13 13:44:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:44:04 2026] 127.0.0.1:44870 Closing +[Mon Apr 13 13:44:04 2026] 127.0.0.1:58516 Accepted +[Mon Apr 13 13:44:07 2026] 127.0.0.1:58516 Closing +[Mon Apr 13 13:44:07 2026] 127.0.0.1:58532 Accepted +[Mon Apr 13 13:44:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:44:10 2026] 127.0.0.1:58532 Closing +[Mon Apr 13 13:44:10 2026] 127.0.0.1:58544 Accepted +[Mon Apr 13 13:44:12 2026] 127.0.0.1:58544 Closing +[Mon Apr 13 13:44:13 2026] 127.0.0.1:60048 Accepted +[Mon Apr 13 13:44:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:44:15 2026] 127.0.0.1:60048 Closing +[Mon Apr 13 13:44:17 2026] 127.0.0.1:60054 Accepted +[Mon Apr 13 13:44:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:44:19 2026] 127.0.0.1:60054 Closing +[Mon Apr 13 13:44:19 2026] 127.0.0.1:60068 Accepted +[Mon Apr 13 13:44:34 2026] 127.0.0.1:60068 Closing +[Mon Apr 13 13:44:37 2026] 127.0.0.1:32796 Accepted +[Mon Apr 13 13:44:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:44:50 2026] 127.0.0.1:32796 Closing +[Mon Apr 13 13:44:53 2026] 127.0.0.1:54266 Accepted +[Mon Apr 13 13:44:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:00 2026] 127.0.0.1:54266 Closing +[Mon Apr 13 13:45:01 2026] 127.0.0.1:54274 Accepted +[Mon Apr 13 13:45:08 2026] 127.0.0.1:54274 Closing +[Mon Apr 13 13:45:42 2026] 127.0.0.1:39274 Accepted +[Mon Apr 13 13:45:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:43 2026] 127.0.0.1:39274 Closing +[Mon Apr 13 13:45:43 2026] 127.0.0.1:39286 Accepted +[Mon Apr 13 13:45:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:44 2026] 127.0.0.1:39286 Closing +[Mon Apr 13 13:45:44 2026] 127.0.0.1:39298 Accepted +[Mon Apr 13 13:45:46 2026] 127.0.0.1:39298 Closing +[Mon Apr 13 13:45:47 2026] 127.0.0.1:39308 Accepted +[Mon Apr 13 13:45:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:48 2026] 127.0.0.1:39308 Closing +[Mon Apr 13 13:45:48 2026] 127.0.0.1:39324 Accepted +[Mon Apr 13 13:45:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:49 2026] 127.0.0.1:39324 Closing +[Mon Apr 13 13:45:49 2026] 127.0.0.1:39334 Accepted +[Mon Apr 13 13:45:51 2026] 127.0.0.1:39334 Closing +[Mon Apr 13 13:45:52 2026] 127.0.0.1:35742 Accepted +[Mon Apr 13 13:45:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:52 2026] 127.0.0.1:35742 Closing +[Mon Apr 13 13:45:52 2026] 127.0.0.1:35746 Accepted +[Mon Apr 13 13:45:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:53 2026] 127.0.0.1:35746 Closing +[Mon Apr 13 13:45:53 2026] 127.0.0.1:35760 Accepted +[Mon Apr 13 13:45:54 2026] 127.0.0.1:35760 Closing +[Mon Apr 13 13:45:55 2026] 127.0.0.1:35770 Accepted +[Mon Apr 13 13:45:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:55 2026] 127.0.0.1:35770 Closing +[Mon Apr 13 13:45:56 2026] 127.0.0.1:35772 Accepted +[Mon Apr 13 13:45:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:56 2026] 127.0.0.1:35772 Closing +[Mon Apr 13 13:45:56 2026] 127.0.0.1:35774 Accepted +[Mon Apr 13 13:45:59 2026] 127.0.0.1:35774 Closing +[Mon Apr 13 13:45:59 2026] 127.0.0.1:35788 Accepted +[Mon Apr 13 13:45:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:45:59 2026] 127.0.0.1:35788 Closing +[Mon Apr 13 13:45:59 2026] 127.0.0.1:48242 Accepted +[Mon Apr 13 13:46:00 2026] 127.0.0.1:48242 Closing +[Mon Apr 13 13:46:01 2026] 127.0.0.1:48244 Accepted +[Mon Apr 13 13:46:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:46:02 2026] 127.0.0.1:48244 Closing +[Mon Apr 13 13:46:03 2026] 127.0.0.1:48258 Accepted +[Mon Apr 13 13:46:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:46:03 2026] 127.0.0.1:48258 Closing +[Mon Apr 13 13:46:03 2026] 127.0.0.1:48266 Accepted +[Mon Apr 13 13:46:08 2026] 127.0.0.1:48266 Closing +[Mon Apr 13 13:46:09 2026] 127.0.0.1:42162 Accepted +[Mon Apr 13 13:46:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:46:10 2026] 127.0.0.1:42162 Closing +[Mon Apr 13 13:46:10 2026] 127.0.0.1:42174 Accepted +[Mon Apr 13 13:46:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 13:46:10 2026] 127.0.0.1:42174 Closing +[Mon Apr 13 13:46:10 2026] 127.0.0.1:42190 Accepted +[Mon Apr 13 13:46:11 2026] 127.0.0.1:42190 Closing +[Mon Apr 13 14:47:46 2026] 127.0.0.1:32986 Accepted +[Mon Apr 13 14:47:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:47:48 2026] 127.0.0.1:32986 Closing +[Mon Apr 13 14:47:48 2026] 127.0.0.1:40886 Accepted +[Mon Apr 13 14:47:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:47:50 2026] 127.0.0.1:40886 Closing +[Mon Apr 13 14:47:50 2026] 127.0.0.1:40898 Accepted +[Mon Apr 13 14:47:54 2026] 127.0.0.1:40898 Closing +[Mon Apr 13 14:47:55 2026] 127.0.0.1:60792 Accepted +[Mon Apr 13 14:47:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:47:56 2026] 127.0.0.1:60792 Closing +[Mon Apr 13 14:47:56 2026] 127.0.0.1:60802 Accepted +[Mon Apr 13 14:47:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:47:57 2026] 127.0.0.1:60802 Closing +[Mon Apr 13 14:47:57 2026] 127.0.0.1:60812 Accepted +[Mon Apr 13 14:48:00 2026] 127.0.0.1:60812 Closing +[Mon Apr 13 14:48:00 2026] 127.0.0.1:60828 Accepted +[Mon Apr 13 14:48:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:48:01 2026] 127.0.0.1:60828 Closing +[Mon Apr 13 14:48:02 2026] 127.0.0.1:60836 Accepted +[Mon Apr 13 14:48:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:48:03 2026] 127.0.0.1:60836 Closing +[Mon Apr 13 14:48:03 2026] 127.0.0.1:60850 Accepted +[Mon Apr 13 14:48:05 2026] 127.0.0.1:60850 Closing +[Mon Apr 13 14:48:06 2026] 127.0.0.1:43816 Accepted +[Mon Apr 13 14:48:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:48:07 2026] 127.0.0.1:43816 Closing +[Mon Apr 13 14:48:08 2026] 127.0.0.1:43830 Accepted +[Mon Apr 13 14:48:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:48:09 2026] 127.0.0.1:43830 Closing +[Mon Apr 13 14:48:09 2026] 127.0.0.1:43832 Accepted +[Mon Apr 13 14:48:11 2026] 127.0.0.1:43832 Closing +[Mon Apr 13 14:48:11 2026] 127.0.0.1:43834 Accepted +[Mon Apr 13 14:48:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:48:12 2026] 127.0.0.1:43834 Closing +[Mon Apr 13 14:48:12 2026] 127.0.0.1:43850 Accepted +[Mon Apr 13 14:48:14 2026] 127.0.0.1:43850 Closing +[Mon Apr 13 14:48:14 2026] 127.0.0.1:43852 Accepted +[Mon Apr 13 14:48:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:48:15 2026] 127.0.0.1:43852 Closing +[Mon Apr 13 14:48:16 2026] 127.0.0.1:33498 Accepted +[Mon Apr 13 14:48:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:48:17 2026] 127.0.0.1:33498 Closing +[Mon Apr 13 14:48:17 2026] 127.0.0.1:33514 Accepted +[Mon Apr 13 14:48:25 2026] 127.0.0.1:33514 Closing +[Mon Apr 13 14:48:27 2026] 127.0.0.1:49440 Accepted +[Mon Apr 13 14:48:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:48:27 2026] 127.0.0.1:49440 Closing +[Mon Apr 13 14:48:28 2026] 127.0.0.1:49442 Accepted +[Mon Apr 13 14:48:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 14:48:29 2026] 127.0.0.1:49442 Closing +[Mon Apr 13 14:48:29 2026] 127.0.0.1:49458 Accepted +[Mon Apr 13 14:48:30 2026] 127.0.0.1:49458 Closing +[Mon Apr 13 15:16:46 2026] 127.0.0.1:41640 Accepted +[Mon Apr 13 15:16:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:16:48 2026] 127.0.0.1:41640 Closing +[Mon Apr 13 15:16:48 2026] 127.0.0.1:41652 Accepted +[Mon Apr 13 15:16:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:16:49 2026] 127.0.0.1:41652 Closing +[Mon Apr 13 15:16:49 2026] 127.0.0.1:41664 Accepted +[Mon Apr 13 15:16:52 2026] 127.0.0.1:41664 Closing +[Mon Apr 13 15:16:53 2026] 127.0.0.1:41676 Accepted +[Mon Apr 13 15:16:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:16:54 2026] 127.0.0.1:41676 Closing +[Mon Apr 13 15:16:54 2026] 127.0.0.1:41682 Accepted +[Mon Apr 13 15:16:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:16:55 2026] 127.0.0.1:41682 Closing +[Mon Apr 13 15:16:55 2026] 127.0.0.1:41692 Accepted +[Mon Apr 13 15:16:57 2026] 127.0.0.1:41692 Closing +[Mon Apr 13 15:16:58 2026] 127.0.0.1:43044 Accepted +[Mon Apr 13 15:16:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:16:57 2026] 127.0.0.1:43044 Closing +[Mon Apr 13 15:16:58 2026] 127.0.0.1:43054 Accepted +[Mon Apr 13 15:16:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:16:58 2026] 127.0.0.1:43054 Closing +[Mon Apr 13 15:16:58 2026] 127.0.0.1:43058 Accepted +[Mon Apr 13 15:17:00 2026] 127.0.0.1:43058 Closing +[Mon Apr 13 15:17:01 2026] 127.0.0.1:43068 Accepted +[Mon Apr 13 15:17:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:02 2026] 127.0.0.1:43068 Closing +[Mon Apr 13 15:17:02 2026] 127.0.0.1:43084 Accepted +[Mon Apr 13 15:17:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:03 2026] 127.0.0.1:43084 Closing +[Mon Apr 13 15:17:03 2026] 127.0.0.1:43086 Accepted +[Mon Apr 13 15:17:06 2026] 127.0.0.1:43086 Closing +[Mon Apr 13 15:17:06 2026] 127.0.0.1:55450 Accepted +[Mon Apr 13 15:17:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:06 2026] 127.0.0.1:55450 Closing +[Mon Apr 13 15:17:06 2026] 127.0.0.1:55458 Accepted +[Mon Apr 13 15:17:08 2026] 127.0.0.1:55458 Closing +[Mon Apr 13 15:17:09 2026] 127.0.0.1:55474 Accepted +[Mon Apr 13 15:17:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:10 2026] 127.0.0.1:55474 Closing +[Mon Apr 13 15:17:10 2026] 127.0.0.1:55486 Accepted +[Mon Apr 13 15:17:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:11 2026] 127.0.0.1:55486 Closing +[Mon Apr 13 15:17:11 2026] 127.0.0.1:55500 Accepted +[Mon Apr 13 15:17:13 2026] 127.0.0.1:55500 Closing +[Mon Apr 13 15:17:13 2026] 127.0.0.1:55506 Accepted +[Mon Apr 13 15:17:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:13 2026] 127.0.0.1:55506 Closing +[Mon Apr 13 15:17:13 2026] 127.0.0.1:55518 Accepted +[Mon Apr 13 15:17:17 2026] 127.0.0.1:55518 Closing +[Mon Apr 13 15:17:18 2026] 127.0.0.1:54696 Accepted +[Mon Apr 13 15:17:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:19 2026] 127.0.0.1:54696 Closing +[Mon Apr 13 15:17:20 2026] 127.0.0.1:54700 Accepted +[Mon Apr 13 15:17:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:20 2026] 127.0.0.1:54700 Closing +[Mon Apr 13 15:17:20 2026] 127.0.0.1:54704 Accepted +[Mon Apr 13 15:17:26 2026] 127.0.0.1:54704 Closing +[Mon Apr 13 15:17:26 2026] 127.0.0.1:50622 Accepted +[Mon Apr 13 15:17:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:27 2026] 127.0.0.1:50622 Closing +[Mon Apr 13 15:17:27 2026] 127.0.0.1:50630 Accepted +[Mon Apr 13 15:17:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:17:28 2026] 127.0.0.1:50630 Closing +[Mon Apr 13 15:17:28 2026] 127.0.0.1:50646 Accepted +[Mon Apr 13 15:17:29 2026] 127.0.0.1:50646 Closing +[Mon Apr 13 15:18:03 2026] 127.0.0.1:48160 Accepted +[Mon Apr 13 15:18:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:04 2026] 127.0.0.1:48160 Closing +[Mon Apr 13 15:18:04 2026] 127.0.0.1:48174 Accepted +[Mon Apr 13 15:18:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:05 2026] 127.0.0.1:48174 Closing +[Mon Apr 13 15:18:05 2026] 127.0.0.1:48178 Accepted +[Mon Apr 13 15:18:08 2026] 127.0.0.1:48178 Closing +[Mon Apr 13 15:18:09 2026] 127.0.0.1:48180 Accepted +[Mon Apr 13 15:18:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:10 2026] 127.0.0.1:48180 Closing +[Mon Apr 13 15:18:10 2026] 127.0.0.1:48186 Accepted +[Mon Apr 13 15:18:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:11 2026] 127.0.0.1:48186 Closing +[Mon Apr 13 15:18:11 2026] 127.0.0.1:48198 Accepted +[Mon Apr 13 15:18:13 2026] 127.0.0.1:48198 Closing +[Mon Apr 13 15:18:13 2026] 127.0.0.1:34754 Accepted +[Mon Apr 13 15:18:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:14 2026] 127.0.0.1:34754 Closing +[Mon Apr 13 15:18:15 2026] 127.0.0.1:34756 Accepted +[Mon Apr 13 15:18:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:15 2026] 127.0.0.1:34756 Closing +[Mon Apr 13 15:18:15 2026] 127.0.0.1:34764 Accepted +[Mon Apr 13 15:18:17 2026] 127.0.0.1:34764 Closing +[Mon Apr 13 15:18:17 2026] 127.0.0.1:34780 Accepted +[Mon Apr 13 15:18:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:18 2026] 127.0.0.1:34780 Closing +[Mon Apr 13 15:18:19 2026] 127.0.0.1:34782 Accepted +[Mon Apr 13 15:18:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:19 2026] 127.0.0.1:34782 Closing +[Mon Apr 13 15:18:19 2026] 127.0.0.1:34794 Accepted +[Mon Apr 13 15:18:20 2026] 127.0.0.1:34794 Closing +[Mon Apr 13 15:18:22 2026] 127.0.0.1:44992 Accepted +[Mon Apr 13 15:18:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:22 2026] 127.0.0.1:44992 Closing +[Mon Apr 13 15:18:23 2026] 127.0.0.1:45008 Accepted +[Mon Apr 13 15:18:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:23 2026] 127.0.0.1:45008 Closing +[Mon Apr 13 15:18:23 2026] 127.0.0.1:45022 Accepted +[Mon Apr 13 15:18:24 2026] 127.0.0.1:45022 Closing +[Mon Apr 13 15:18:24 2026] 127.0.0.1:45032 Accepted +[Mon Apr 13 15:18:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:25 2026] 127.0.0.1:45032 Closing +[Mon Apr 13 15:18:25 2026] 127.0.0.1:45046 Accepted +[Mon Apr 13 15:18:28 2026] 127.0.0.1:45046 Closing +[Mon Apr 13 15:18:29 2026] 127.0.0.1:45058 Accepted +[Mon Apr 13 15:18:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:30 2026] 127.0.0.1:45058 Closing +[Mon Apr 13 15:18:31 2026] 127.0.0.1:40046 Accepted +[Mon Apr 13 15:18:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:31 2026] 127.0.0.1:40046 Closing +[Mon Apr 13 15:18:31 2026] 127.0.0.1:40054 Accepted +[Mon Apr 13 15:18:37 2026] 127.0.0.1:40054 Closing +[Mon Apr 13 15:18:39 2026] 127.0.0.1:40056 Accepted +[Mon Apr 13 15:18:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:39 2026] 127.0.0.1:40056 Closing +[Mon Apr 13 15:18:40 2026] 127.0.0.1:55238 Accepted +[Mon Apr 13 15:18:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:18:40 2026] 127.0.0.1:55238 Closing +[Mon Apr 13 15:18:40 2026] 127.0.0.1:55246 Accepted +[Mon Apr 13 15:18:41 2026] 127.0.0.1:55246 Closing +[Mon Apr 13 15:19:51 2026] 127.0.0.1:39312 Accepted +[Mon Apr 13 15:19:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:19:55 2026] 127.0.0.1:39312 Closing +[Mon Apr 13 15:19:56 2026] 127.0.0.1:51498 Accepted +[Mon Apr 13 15:19:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:19:59 2026] 127.0.0.1:51498 Closing +[Mon Apr 13 15:19:59 2026] 127.0.0.1:51514 Accepted +[Mon Apr 13 15:20:05 2026] 127.0.0.1:51514 Closing +[Mon Apr 13 15:20:08 2026] 127.0.0.1:56938 Accepted +[Mon Apr 13 15:20:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:10 2026] 127.0.0.1:56938 Closing +[Mon Apr 13 15:20:11 2026] 127.0.0.1:56952 Accepted +[Mon Apr 13 15:20:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:11 2026] 127.0.0.1:56952 Closing +[Mon Apr 13 15:20:11 2026] 127.0.0.1:56956 Accepted +[Mon Apr 13 15:20:13 2026] 127.0.0.1:56956 Closing +[Mon Apr 13 15:20:14 2026] 127.0.0.1:56958 Accepted +[Mon Apr 13 15:20:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:16 2026] 127.0.0.1:56958 Closing +[Mon Apr 13 15:20:16 2026] 127.0.0.1:48314 Accepted +[Mon Apr 13 15:20:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:18 2026] 127.0.0.1:48314 Closing +[Mon Apr 13 15:20:18 2026] 127.0.0.1:48316 Accepted +[Mon Apr 13 15:20:20 2026] 127.0.0.1:48316 Closing +[Mon Apr 13 15:20:21 2026] 127.0.0.1:48330 Accepted +[Mon Apr 13 15:20:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:21 2026] 127.0.0.1:48330 Closing +[Mon Apr 13 15:20:22 2026] 127.0.0.1:48342 Accepted +[Mon Apr 13 15:20:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:24 2026] 127.0.0.1:48342 Closing +[Mon Apr 13 15:20:24 2026] 127.0.0.1:38604 Accepted +[Mon Apr 13 15:20:28 2026] 127.0.0.1:38604 Closing +[Mon Apr 13 15:20:29 2026] 127.0.0.1:38620 Accepted +[Mon Apr 13 15:20:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:31 2026] 127.0.0.1:38620 Closing +[Mon Apr 13 15:20:32 2026] 127.0.0.1:38628 Accepted +[Mon Apr 13 15:20:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:34 2026] 127.0.0.1:38628 Closing +[Mon Apr 13 15:20:34 2026] 127.0.0.1:38638 Accepted +[Mon Apr 13 15:20:36 2026] 127.0.0.1:38638 Closing +[Mon Apr 13 15:20:36 2026] 127.0.0.1:56068 Accepted +[Mon Apr 13 15:20:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:37 2026] 127.0.0.1:56068 Closing +[Mon Apr 13 15:20:37 2026] 127.0.0.1:56070 Accepted +[Mon Apr 13 15:20:45 2026] 127.0.0.1:56070 Closing +[Mon Apr 13 15:20:46 2026] 127.0.0.1:44960 Accepted +[Mon Apr 13 15:20:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:47 2026] 127.0.0.1:44960 Closing +[Mon Apr 13 15:20:46 2026] 127.0.0.1:44964 Accepted +[Mon Apr 13 15:20:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:20:47 2026] 127.0.0.1:44964 Closing +[Mon Apr 13 15:20:47 2026] 127.0.0.1:44974 Accepted +[Mon Apr 13 15:20:57 2026] 127.0.0.1:44974 Closing +[Mon Apr 13 15:20:59 2026] 127.0.0.1:55434 Accepted +[Mon Apr 13 15:20:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:02 2026] 127.0.0.1:55434 Closing +[Mon Apr 13 15:21:02 2026] 127.0.0.1:55440 Accepted +[Mon Apr 13 15:21:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:03 2026] 127.0.0.1:55440 Closing +[Mon Apr 13 15:21:03 2026] 127.0.0.1:55446 Accepted +[Mon Apr 13 15:21:04 2026] 127.0.0.1:55446 Closing +[Mon Apr 13 15:21:18 2026] 127.0.0.1:41428 Accepted +[Mon Apr 13 15:21:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:19 2026] 127.0.0.1:41428 Closing +[Mon Apr 13 15:21:20 2026] 127.0.0.1:41440 Accepted +[Mon Apr 13 15:21:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:21 2026] 127.0.0.1:41440 Closing +[Mon Apr 13 15:21:21 2026] 127.0.0.1:41452 Accepted +[Mon Apr 13 15:21:23 2026] 127.0.0.1:41452 Closing +[Mon Apr 13 15:21:24 2026] 127.0.0.1:56420 Accepted +[Mon Apr 13 15:21:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:25 2026] 127.0.0.1:56420 Closing +[Mon Apr 13 15:21:26 2026] 127.0.0.1:56434 Accepted +[Mon Apr 13 15:21:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:26 2026] 127.0.0.1:56434 Closing +[Mon Apr 13 15:21:26 2026] 127.0.0.1:56436 Accepted +[Mon Apr 13 15:21:28 2026] 127.0.0.1:56436 Closing +[Mon Apr 13 15:21:29 2026] 127.0.0.1:56450 Accepted +[Mon Apr 13 15:21:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:30 2026] 127.0.0.1:56450 Closing +[Mon Apr 13 15:21:31 2026] 127.0.0.1:56464 Accepted +[Mon Apr 13 15:21:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:31 2026] 127.0.0.1:56464 Closing +[Mon Apr 13 15:21:31 2026] 127.0.0.1:56468 Accepted +[Mon Apr 13 15:21:32 2026] 127.0.0.1:56468 Closing +[Mon Apr 13 15:21:33 2026] 127.0.0.1:52602 Accepted +[Mon Apr 13 15:21:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:34 2026] 127.0.0.1:52602 Closing +[Mon Apr 13 15:21:34 2026] 127.0.0.1:52604 Accepted +[Mon Apr 13 15:21:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:35 2026] 127.0.0.1:52604 Closing +[Mon Apr 13 15:21:35 2026] 127.0.0.1:52608 Accepted +[Mon Apr 13 15:21:36 2026] 127.0.0.1:52608 Closing +[Mon Apr 13 15:21:37 2026] 127.0.0.1:52616 Accepted +[Mon Apr 13 15:21:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:38 2026] 127.0.0.1:52616 Closing +[Mon Apr 13 15:21:38 2026] 127.0.0.1:52632 Accepted +[Mon Apr 13 15:21:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:39 2026] 127.0.0.1:52632 Closing +[Mon Apr 13 15:21:39 2026] 127.0.0.1:52640 Accepted +[Mon Apr 13 15:21:41 2026] 127.0.0.1:52640 Closing +[Mon Apr 13 15:21:41 2026] 127.0.0.1:52646 Accepted +[Mon Apr 13 15:21:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:42 2026] 127.0.0.1:52646 Closing +[Mon Apr 13 15:21:42 2026] 127.0.0.1:53682 Accepted +[Mon Apr 13 15:21:45 2026] 127.0.0.1:53682 Closing +[Mon Apr 13 15:21:45 2026] 127.0.0.1:53692 Accepted +[Mon Apr 13 15:21:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:46 2026] 127.0.0.1:53692 Closing +[Mon Apr 13 15:21:47 2026] 127.0.0.1:53708 Accepted +[Mon Apr 13 15:21:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:48 2026] 127.0.0.1:53708 Closing +[Mon Apr 13 15:21:48 2026] 127.0.0.1:53712 Accepted +[Mon Apr 13 15:21:53 2026] 127.0.0.1:53712 Closing +[Mon Apr 13 15:21:55 2026] 127.0.0.1:53324 Accepted +[Mon Apr 13 15:21:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:56 2026] 127.0.0.1:53324 Closing +[Mon Apr 13 15:21:56 2026] 127.0.0.1:53338 Accepted +[Mon Apr 13 15:21:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:21:57 2026] 127.0.0.1:53338 Closing +[Mon Apr 13 15:21:57 2026] 127.0.0.1:53352 Accepted +[Mon Apr 13 15:21:59 2026] 127.0.0.1:53352 Closing +[Mon Apr 13 15:22:36 2026] 127.0.0.1:38566 Accepted +[Mon Apr 13 15:22:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:22:36 2026] 127.0.0.1:38566 Closing +[Mon Apr 13 15:22:37 2026] 127.0.0.1:38574 Accepted +[Mon Apr 13 15:22:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:22:37 2026] 127.0.0.1:38574 Closing +[Mon Apr 13 15:22:37 2026] 127.0.0.1:38582 Accepted +[Mon Apr 13 15:22:41 2026] 127.0.0.1:38582 Closing +[Mon Apr 13 15:22:41 2026] 127.0.0.1:35138 Accepted +[Mon Apr 13 15:22:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:22:42 2026] 127.0.0.1:35138 Closing +[Mon Apr 13 15:22:43 2026] 127.0.0.1:35144 Accepted +[Mon Apr 13 15:22:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:22:43 2026] 127.0.0.1:35144 Closing +[Mon Apr 13 15:22:43 2026] 127.0.0.1:35148 Accepted +[Mon Apr 13 15:22:46 2026] 127.0.0.1:35148 Closing +[Mon Apr 13 15:22:48 2026] 127.0.0.1:37902 Accepted +[Mon Apr 13 15:22:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:22:49 2026] 127.0.0.1:37902 Closing +[Mon Apr 13 15:22:49 2026] 127.0.0.1:37906 Accepted +[Mon Apr 13 15:22:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:22:49 2026] 127.0.0.1:37906 Closing +[Mon Apr 13 15:22:49 2026] 127.0.0.1:37910 Accepted +[Mon Apr 13 15:22:51 2026] 127.0.0.1:37910 Closing +[Mon Apr 13 15:22:51 2026] 127.0.0.1:37920 Accepted +[Mon Apr 13 15:22:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:22:52 2026] 127.0.0.1:37920 Closing +[Mon Apr 13 15:22:53 2026] 127.0.0.1:37924 Accepted +[Mon Apr 13 15:22:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:22:55 2026] 127.0.0.1:37924 Closing +[Mon Apr 13 15:22:55 2026] 127.0.0.1:37926 Accepted +[Mon Apr 13 15:22:57 2026] 127.0.0.1:37926 Closing +[Mon Apr 13 15:22:59 2026] 127.0.0.1:60420 Accepted +[Mon Apr 13 15:22:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:23:00 2026] 127.0.0.1:60420 Closing +[Mon Apr 13 15:23:00 2026] 127.0.0.1:60430 Accepted +[Mon Apr 13 15:23:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:23:01 2026] 127.0.0.1:60430 Closing +[Mon Apr 13 15:23:01 2026] 127.0.0.1:60434 Accepted +[Mon Apr 13 15:23:03 2026] 127.0.0.1:60434 Closing +[Mon Apr 13 15:23:03 2026] 127.0.0.1:60448 Accepted +[Mon Apr 13 15:23:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:23:04 2026] 127.0.0.1:60448 Closing +[Mon Apr 13 15:23:04 2026] 127.0.0.1:60458 Accepted +[Mon Apr 13 15:23:08 2026] 127.0.0.1:60458 Closing +[Mon Apr 13 15:23:09 2026] 127.0.0.1:39006 Accepted +[Mon Apr 13 15:23:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:23:10 2026] 127.0.0.1:39006 Closing +[Mon Apr 13 15:23:11 2026] 127.0.0.1:39010 Accepted +[Mon Apr 13 15:23:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:23:12 2026] 127.0.0.1:39010 Closing +[Mon Apr 13 15:23:12 2026] 127.0.0.1:39020 Accepted +[Mon Apr 13 15:23:18 2026] 127.0.0.1:39020 Closing +[Mon Apr 13 15:23:20 2026] 127.0.0.1:45806 Accepted +[Mon Apr 13 15:23:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:23:20 2026] 127.0.0.1:45806 Closing +[Mon Apr 13 15:23:20 2026] 127.0.0.1:45814 Accepted +[Mon Apr 13 15:23:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:23:21 2026] 127.0.0.1:45814 Closing +[Mon Apr 13 15:23:21 2026] 127.0.0.1:45818 Accepted +[Mon Apr 13 15:23:21 2026] 127.0.0.1:45818 Closing +[Mon Apr 13 15:24:36 2026] 127.0.0.1:38126 Accepted +[Mon Apr 13 15:24:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:24:39 2026] 127.0.0.1:38126 Closing +[Mon Apr 13 15:24:42 2026] 127.0.0.1:38134 Accepted +[Mon Apr 13 15:24:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:24:49 2026] 127.0.0.1:38134 Closing +[Mon Apr 13 15:24:49 2026] 127.0.0.1:34696 Accepted +[Mon Apr 13 15:24:57 2026] 127.0.0.1:34696 Closing +[Mon Apr 13 15:25:02 2026] 127.0.0.1:53600 Accepted +[Mon Apr 13 15:25:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:06 2026] 127.0.0.1:53600 Closing +[Mon Apr 13 15:25:07 2026] 127.0.0.1:32994 Accepted +[Mon Apr 13 15:25:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:09 2026] 127.0.0.1:32994 Closing +[Mon Apr 13 15:25:09 2026] 127.0.0.1:33008 Accepted +[Mon Apr 13 15:25:13 2026] 127.0.0.1:33008 Closing +[Mon Apr 13 15:25:13 2026] 127.0.0.1:40816 Accepted +[Mon Apr 13 15:25:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:16 2026] 127.0.0.1:40816 Closing +[Mon Apr 13 15:25:16 2026] 127.0.0.1:40828 Accepted +[Mon Apr 13 15:25:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:19 2026] 127.0.0.1:40828 Closing +[Mon Apr 13 15:25:19 2026] 127.0.0.1:40836 Accepted +[Mon Apr 13 15:25:24 2026] 127.0.0.1:40836 Closing +[Mon Apr 13 15:25:25 2026] 127.0.0.1:53908 Accepted +[Mon Apr 13 15:25:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:27 2026] 127.0.0.1:53908 Closing +[Mon Apr 13 15:25:28 2026] 127.0.0.1:53924 Accepted +[Mon Apr 13 15:25:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:33 2026] 127.0.0.1:53924 Closing +[Mon Apr 13 15:25:33 2026] 127.0.0.1:34184 Accepted +[Mon Apr 13 15:25:39 2026] 127.0.0.1:34184 Closing +[Mon Apr 13 15:25:39 2026] 127.0.0.1:34186 Accepted +[Mon Apr 13 15:25:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:42 2026] 127.0.0.1:34186 Closing +[Mon Apr 13 15:25:42 2026] 127.0.0.1:51638 Accepted +[Mon Apr 13 15:25:48 2026] 127.0.0.1:51638 Closing +[Mon Apr 13 15:25:49 2026] 127.0.0.1:51642 Accepted +[Mon Apr 13 15:25:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:52 2026] 127.0.0.1:51642 Closing +[Mon Apr 13 15:25:52 2026] 127.0.0.1:43734 Accepted +[Mon Apr 13 15:25:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:56 2026] 127.0.0.1:43734 Closing +[Mon Apr 13 15:25:56 2026] 127.0.0.1:43746 Accepted +[Mon Apr 13 15:25:59 2026] 127.0.0.1:43746 Closing +[Mon Apr 13 15:25:59 2026] 127.0.0.1:43748 Accepted +[Mon Apr 13 15:25:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:25:59 2026] 127.0.0.1:43748 Closing +[Mon Apr 13 15:25:59 2026] 127.0.0.1:43756 Accepted +[Mon Apr 13 15:26:03 2026] 127.0.0.1:43756 Closing +[Mon Apr 13 15:26:05 2026] 127.0.0.1:57098 Accepted +[Mon Apr 13 15:26:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:26:08 2026] 127.0.0.1:57098 Closing +[Mon Apr 13 15:26:10 2026] 127.0.0.1:43632 Accepted +[Mon Apr 13 15:26:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:26:13 2026] 127.0.0.1:43632 Closing +[Mon Apr 13 15:26:13 2026] 127.0.0.1:43640 Accepted +[Mon Apr 13 15:26:23 2026] 127.0.0.1:43640 Closing +[Mon Apr 13 15:26:24 2026] 127.0.0.1:54334 Accepted +[Mon Apr 13 15:26:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:26:25 2026] 127.0.0.1:54334 Closing +[Mon Apr 13 15:26:25 2026] 127.0.0.1:54348 Accepted +[Mon Apr 13 15:26:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:26:25 2026] 127.0.0.1:54348 Closing +[Mon Apr 13 15:26:25 2026] 127.0.0.1:54354 Accepted +[Mon Apr 13 15:26:26 2026] 127.0.0.1:54354 Closing +[Mon Apr 13 15:45:17 2026] 127.0.0.1:43916 Accepted +[Mon Apr 13 15:45:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:45:19 2026] 127.0.0.1:43916 Closing +[Mon Apr 13 15:45:20 2026] 127.0.0.1:43918 Accepted +[Mon Apr 13 15:45:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:45:20 2026] 127.0.0.1:43918 Closing +[Mon Apr 13 15:45:20 2026] 127.0.0.1:43934 Accepted +[Mon Apr 13 15:45:35 2026] 127.0.0.1:43934 Closing +[Mon Apr 13 15:45:37 2026] 127.0.0.1:43204 Accepted +[Mon Apr 13 15:45:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:45:38 2026] 127.0.0.1:43204 Closing +[Mon Apr 13 15:45:38 2026] 127.0.0.1:43208 Accepted +[Mon Apr 13 15:45:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:45:39 2026] 127.0.0.1:43208 Closing +[Mon Apr 13 15:45:39 2026] 127.0.0.1:43210 Accepted +[Mon Apr 13 15:45:44 2026] 127.0.0.1:43210 Closing +[Mon Apr 13 15:46:26 2026] 127.0.0.1:54722 Accepted +[Mon Apr 13 15:46:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:46:27 2026] 127.0.0.1:54722 Closing +[Mon Apr 13 15:46:28 2026] 127.0.0.1:54730 Accepted +[Mon Apr 13 15:46:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:46:28 2026] 127.0.0.1:54730 Closing +[Mon Apr 13 15:46:28 2026] 127.0.0.1:54732 Accepted +[Mon Apr 13 15:46:36 2026] 127.0.0.1:54732 Closing +[Mon Apr 13 15:46:36 2026] 127.0.0.1:55702 Accepted +[Mon Apr 13 15:46:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:46:36 2026] 127.0.0.1:55702 Closing +[Mon Apr 13 15:46:37 2026] 127.0.0.1:55710 Accepted +[Mon Apr 13 15:46:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 13 15:46:38 2026] 127.0.0.1:55710 Closing +[Mon Apr 13 15:46:38 2026] 127.0.0.1:55720 Accepted +[Mon Apr 13 15:46:45 2026] 127.0.0.1:55720 Closing +[Tue Apr 14 07:50:09 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 14 07:50:09 2026] 127.0.0.1:42514 Accepted +[Tue Apr 14 07:50:10 2026] 127.0.0.1:42514 Closing +[Tue Apr 14 07:50:12 2026] 127.0.0.1:42528 Accepted +[Tue Apr 14 07:50:13 2026] 127.0.0.1:42528 Closing +[Tue Apr 14 07:50:14 2026] 127.0.0.1:42536 Accepted +[Tue Apr 14 07:50:15 2026] 127.0.0.1:42536 Closing +[Tue Apr 14 07:50:15 2026] 127.0.0.1:42540 Accepted +[Tue Apr 14 07:50:16 2026] 127.0.0.1:42540 Closing +[Tue Apr 14 07:50:17 2026] 127.0.0.1:52270 Accepted +[Tue Apr 14 07:50:18 2026] 127.0.0.1:52270 Closing +[Tue Apr 14 07:50:19 2026] 127.0.0.1:52284 Accepted +[Tue Apr 14 07:50:19 2026] 127.0.0.1:52284 Closing +[Tue Apr 14 07:50:20 2026] 127.0.0.1:52286 Accepted +[Tue Apr 14 07:50:21 2026] 127.0.0.1:52286 Closing +[Tue Apr 14 07:50:21 2026] 127.0.0.1:52296 Accepted +[Tue Apr 14 07:50:22 2026] 127.0.0.1:52296 Closing +[Tue Apr 14 07:50:23 2026] 127.0.0.1:52298 Accepted +[Tue Apr 14 07:50:25 2026] 127.0.0.1:52298 Closing +[Tue Apr 14 07:50:26 2026] 127.0.0.1:52308 Accepted +[Tue Apr 14 07:50:26 2026] 127.0.0.1:52308 Closing +[Tue Apr 14 07:50:27 2026] 127.0.0.1:57948 Accepted +[Tue Apr 14 07:50:29 2026] 127.0.0.1:57948 Closing +[Tue Apr 14 07:50:30 2026] 127.0.0.1:57958 Accepted +[Tue Apr 14 07:50:32 2026] 127.0.0.1:57958 Closing +[Tue Apr 14 07:50:33 2026] 127.0.0.1:57974 Accepted +[Tue Apr 14 07:50:35 2026] 127.0.0.1:57974 Closing +[Tue Apr 14 07:50:36 2026] 127.0.0.1:52340 Accepted +[Tue Apr 14 07:50:39 2026] 127.0.0.1:52340 Closing +[Tue Apr 14 07:50:39 2026] 127.0.0.1:52352 Accepted +[Tue Apr 14 07:50:40 2026] 127.0.0.1:52352 Closing +[Tue Apr 14 07:50:42 2026] 127.0.0.1:52360 Accepted +[Tue Apr 14 07:50:44 2026] 127.0.0.1:52360 Closing +[Tue Apr 14 07:50:44 2026] 127.0.0.1:52372 Accepted +[Tue Apr 14 07:50:47 2026] 127.0.0.1:52372 Closing +[Tue Apr 14 07:50:49 2026] 127.0.0.1:43356 Accepted +[Tue Apr 14 07:50:55 2026] 127.0.0.1:43356 Closing +[Tue Apr 14 07:50:55 2026] 127.0.0.1:54012 Accepted +[Tue Apr 14 07:50:56 2026] 127.0.0.1:54012 Closing +[Tue Apr 14 07:50:57 2026] 127.0.0.1:54026 Accepted +[Tue Apr 14 07:50:59 2026] 127.0.0.1:54026 Closing +[Tue Apr 14 07:50:59 2026] 127.0.0.1:54030 Accepted +[Tue Apr 14 07:51:02 2026] 127.0.0.1:54030 Closing +[Tue Apr 14 07:51:03 2026] 127.0.0.1:54036 Accepted +[Tue Apr 14 07:51:04 2026] 127.0.0.1:54036 Closing +[Tue Apr 14 07:51:05 2026] 127.0.0.1:36238 Accepted +[Tue Apr 14 07:51:07 2026] 127.0.0.1:36238 Closing +[Tue Apr 14 07:51:08 2026] 127.0.0.1:36250 Accepted +[Tue Apr 14 07:51:10 2026] 127.0.0.1:36250 Closing +[Tue Apr 14 07:51:10 2026] 127.0.0.1:36260 Accepted +[Tue Apr 14 07:51:16 2026] 127.0.0.1:36260 Closing +[Tue Apr 14 07:51:17 2026] 127.0.0.1:45718 Accepted +[Tue Apr 14 07:51:20 2026] 127.0.0.1:45718 Closing +[Tue Apr 14 07:51:21 2026] 127.0.0.1:45722 Accepted +[Tue Apr 14 07:51:22 2026] 127.0.0.1:45722 Closing +[Tue Apr 14 07:51:23 2026] 127.0.0.1:45738 Accepted +[Tue Apr 14 07:51:24 2026] 127.0.0.1:45738 Closing +[Tue Apr 14 07:51:25 2026] 127.0.0.1:38140 Accepted +[Tue Apr 14 07:51:27 2026] 127.0.0.1:38140 Closing +[Tue Apr 14 07:51:28 2026] 127.0.0.1:38144 Accepted +[Tue Apr 14 07:51:30 2026] 127.0.0.1:38144 Closing +[Tue Apr 14 07:51:30 2026] 127.0.0.1:38148 Accepted +[Tue Apr 14 07:51:31 2026] 127.0.0.1:38148 Closing +[Tue Apr 14 07:51:33 2026] 127.0.0.1:41342 Accepted +[Tue Apr 14 07:51:38 2026] 127.0.0.1:41342 Closing +[Tue Apr 14 07:51:41 2026] 127.0.0.1:41344 Accepted +[Tue Apr 14 07:51:45 2026] 127.0.0.1:41344 Closing +[Tue Apr 14 07:51:47 2026] 127.0.0.1:55078 Accepted +[Tue Apr 14 07:51:48 2026] 127.0.0.1:55078 Closing +[Tue Apr 14 07:51:48 2026] 127.0.0.1:55094 Accepted +[Tue Apr 14 07:51:50 2026] 127.0.0.1:55094 Closing +[Tue Apr 14 07:51:51 2026] 127.0.0.1:55102 Accepted +[Tue Apr 14 07:51:54 2026] 127.0.0.1:55102 Closing +[Tue Apr 14 07:51:56 2026] 127.0.0.1:54608 Accepted +[Tue Apr 14 07:51:57 2026] 127.0.0.1:54608 Closing +[Tue Apr 14 07:51:57 2026] 127.0.0.1:54622 Accepted +[Tue Apr 14 07:51:59 2026] 127.0.0.1:54622 Closing +[Tue Apr 14 07:51:59 2026] 127.0.0.1:54630 Accepted +[Tue Apr 14 07:52:00 2026] 127.0.0.1:54630 Closing +[Tue Apr 14 07:52:00 2026] 127.0.0.1:54644 Accepted +[Tue Apr 14 07:52:02 2026] 127.0.0.1:54644 Closing +[Tue Apr 14 07:52:04 2026] 127.0.0.1:47154 Accepted +[Tue Apr 14 07:52:08 2026] 127.0.0.1:47154 Closing +[Tue Apr 14 07:52:10 2026] 127.0.0.1:47170 Accepted +[Tue Apr 14 07:52:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:13 2026] 127.0.0.1:47170 Closing +[Tue Apr 14 07:52:13 2026] 127.0.0.1:49786 Accepted +[Tue Apr 14 07:52:14 2026] 127.0.0.1:49786 Closing +[Tue Apr 14 07:52:14 2026] 127.0.0.1:49792 Accepted +[Tue Apr 14 07:52:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:16 2026] 127.0.0.1:49792 Closing +[Tue Apr 14 07:52:16 2026] 127.0.0.1:49802 Accepted +[Tue Apr 14 07:52:16 2026] 127.0.0.1:49802 Closing +[Tue Apr 14 07:52:16 2026] 127.0.0.1:49812 Accepted +[Tue Apr 14 07:52:18 2026] 127.0.0.1:49812 Closing +[Tue Apr 14 07:52:18 2026] 127.0.0.1:49814 Accepted +[Tue Apr 14 07:52:19 2026] 127.0.0.1:49814 Closing +[Tue Apr 14 07:52:19 2026] 127.0.0.1:49826 Accepted +[Tue Apr 14 07:52:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:21 2026] 127.0.0.1:49826 Closing +[Tue Apr 14 07:52:21 2026] 127.0.0.1:49836 Accepted +[Tue Apr 14 07:52:21 2026] 127.0.0.1:49836 Closing +[Tue Apr 14 07:52:21 2026] 127.0.0.1:49852 Accepted +[Tue Apr 14 07:52:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:22 2026] 127.0.0.1:49852 Closing +[Tue Apr 14 07:52:22 2026] 127.0.0.1:54106 Accepted +[Tue Apr 14 07:52:22 2026] 127.0.0.1:54106 Closing +[Tue Apr 14 07:52:22 2026] 127.0.0.1:54116 Accepted +[Tue Apr 14 07:52:22 2026] 127.0.0.1:54130 Accepted +[Tue Apr 14 07:52:24 2026] 127.0.0.1:54116 Closing +[Tue Apr 14 07:52:25 2026] 127.0.0.1:54130 Closing +[Tue Apr 14 07:52:25 2026] 127.0.0.1:54134 Accepted +[Tue Apr 14 07:52:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:25 2026] 127.0.0.1:54134 Closing +[Tue Apr 14 07:52:25 2026] 127.0.0.1:54144 Accepted +[Tue Apr 14 07:52:27 2026] 127.0.0.1:54144 Closing +[Tue Apr 14 07:52:27 2026] 127.0.0.1:54156 Accepted +[Tue Apr 14 07:52:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:28 2026] 127.0.0.1:54156 Closing +[Tue Apr 14 07:52:28 2026] 127.0.0.1:54170 Accepted +[Tue Apr 14 07:52:28 2026] 127.0.0.1:54170 Closing +[Tue Apr 14 07:52:28 2026] 127.0.0.1:54178 Accepted +[Tue Apr 14 07:52:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:30 2026] 127.0.0.1:54178 Closing +[Tue Apr 14 07:52:30 2026] 127.0.0.1:54182 Accepted +[Tue Apr 14 07:52:30 2026] 127.0.0.1:54182 Closing +[Tue Apr 14 07:52:30 2026] 127.0.0.1:54194 Accepted +[Tue Apr 14 07:52:31 2026] 127.0.0.1:54194 Closing +[Tue Apr 14 07:52:31 2026] 127.0.0.1:34648 Accepted +[Tue Apr 14 07:52:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:36 2026] 127.0.0.1:34648 Closing +[Tue Apr 14 07:52:36 2026] 127.0.0.1:34664 Accepted +[Tue Apr 14 07:52:36 2026] 127.0.0.1:34664 Closing +[Tue Apr 14 07:52:36 2026] 127.0.0.1:34666 Accepted +[Tue Apr 14 07:52:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:37 2026] 127.0.0.1:34666 Closing +[Tue Apr 14 07:52:37 2026] 127.0.0.1:34674 Accepted +[Tue Apr 14 07:52:37 2026] 127.0.0.1:34674 Closing +[Tue Apr 14 07:52:38 2026] 127.0.0.1:34688 Accepted +[Tue Apr 14 07:52:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:38 2026] 127.0.0.1:34688 Closing +[Tue Apr 14 07:52:38 2026] 127.0.0.1:34690 Accepted +[Tue Apr 14 07:52:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:41 2026] 127.0.0.1:34690 Closing +[Tue Apr 14 07:52:41 2026] 127.0.0.1:34702 Accepted +[Tue Apr 14 07:52:42 2026] 127.0.0.1:34702 Closing +[Tue Apr 14 07:52:42 2026] 127.0.0.1:60404 Accepted +[Tue Apr 14 07:52:43 2026] 127.0.0.1:60404 Closing +[Tue Apr 14 07:52:43 2026] 127.0.0.1:60414 Accepted +[Tue Apr 14 07:52:44 2026] 127.0.0.1:60414 Closing +[Tue Apr 14 07:52:44 2026] 127.0.0.1:60424 Accepted +[Tue Apr 14 07:52:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:46 2026] 127.0.0.1:60424 Closing +[Tue Apr 14 07:52:46 2026] 127.0.0.1:60434 Accepted +[Tue Apr 14 07:52:47 2026] 127.0.0.1:60434 Closing +[Tue Apr 14 07:52:47 2026] 127.0.0.1:60440 Accepted +[Tue Apr 14 07:52:49 2026] 127.0.0.1:60440 Closing +[Tue Apr 14 07:52:49 2026] 127.0.0.1:60444 Accepted +[Tue Apr 14 07:52:49 2026] 127.0.0.1:60444 Closing +[Tue Apr 14 07:52:49 2026] 127.0.0.1:60456 Accepted +[Tue Apr 14 07:52:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:51 2026] 127.0.0.1:60456 Closing +[Tue Apr 14 07:52:51 2026] 127.0.0.1:60460 Accepted +[Tue Apr 14 07:52:51 2026] 127.0.0.1:60460 Closing +[Tue Apr 14 07:52:51 2026] 127.0.0.1:51536 Accepted +[Tue Apr 14 07:52:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:52 2026] 127.0.0.1:51536 Closing +[Tue Apr 14 07:52:52 2026] 127.0.0.1:51548 Accepted +[Tue Apr 14 07:52:52 2026] 127.0.0.1:51548 Closing +[Tue Apr 14 07:52:52 2026] 127.0.0.1:51556 Accepted +[Tue Apr 14 07:52:54 2026] 127.0.0.1:51556 Closing +[Tue Apr 14 07:52:54 2026] 127.0.0.1:51568 Accepted +[Tue Apr 14 07:52:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:56 2026] 127.0.0.1:51568 Closing +[Tue Apr 14 07:52:56 2026] 127.0.0.1:51574 Accepted +[Tue Apr 14 07:52:56 2026] 127.0.0.1:51574 Closing +[Tue Apr 14 07:52:56 2026] 127.0.0.1:51578 Accepted +[Tue Apr 14 07:52:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:52:57 2026] 127.0.0.1:51578 Closing +[Tue Apr 14 07:52:57 2026] 127.0.0.1:51584 Accepted +[Tue Apr 14 07:52:58 2026] 127.0.0.1:51584 Closing +[Tue Apr 14 07:52:58 2026] 127.0.0.1:51592 Accepted +[Tue Apr 14 07:53:00 2026] 127.0.0.1:51592 Closing +[Tue Apr 14 07:53:00 2026] 127.0.0.1:44784 Accepted +[Tue Apr 14 07:53:01 2026] 127.0.0.1:44784 Closing +[Tue Apr 14 07:53:03 2026] 127.0.0.1:44790 Accepted +[Tue Apr 14 07:53:06 2026] 127.0.0.1:44790 Closing +[Tue Apr 14 07:53:08 2026] 127.0.0.1:44802 Accepted +[Tue Apr 14 07:53:09 2026] 127.0.0.1:44802 Closing +[Tue Apr 14 07:53:09 2026] 127.0.0.1:37094 Accepted +[Tue Apr 14 07:53:11 2026] 127.0.0.1:37094 Closing +[Tue Apr 14 07:53:11 2026] 127.0.0.1:37104 Accepted +[Tue Apr 14 07:53:12 2026] 127.0.0.1:37104 Closing +[Tue Apr 14 07:53:13 2026] 127.0.0.1:37112 Accepted +[Tue Apr 14 07:53:20 2026] 127.0.0.1:37112 Closing +[Tue Apr 14 07:53:20 2026] 127.0.0.1:47828 Accepted +[Tue Apr 14 07:53:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:26 2026] 127.0.0.1:47828 Closing +[Tue Apr 14 07:53:26 2026] 127.0.0.1:47832 Accepted +[Tue Apr 14 07:53:27 2026] 127.0.0.1:47832 Closing +[Tue Apr 14 07:53:27 2026] 127.0.0.1:47840 Accepted +[Tue Apr 14 07:53:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:27 2026] 127.0.0.1:47840 Closing +[Tue Apr 14 07:53:27 2026] 127.0.0.1:47854 Accepted +[Tue Apr 14 07:53:29 2026] 127.0.0.1:47854 Closing +[Tue Apr 14 07:53:29 2026] 127.0.0.1:52802 Accepted +[Tue Apr 14 07:53:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:30 2026] 127.0.0.1:52802 Closing +[Tue Apr 14 07:53:30 2026] 127.0.0.1:52804 Accepted +[Tue Apr 14 07:53:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:31 2026] 127.0.0.1:52804 Closing +[Tue Apr 14 07:53:31 2026] 127.0.0.1:52820 Accepted +[Tue Apr 14 07:53:31 2026] 127.0.0.1:52820 Closing +[Tue Apr 14 07:53:31 2026] 127.0.0.1:52832 Accepted +[Tue Apr 14 07:53:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:31 2026] 127.0.0.1:52832 Closing +[Tue Apr 14 07:53:31 2026] 127.0.0.1:52840 Accepted +[Tue Apr 14 07:53:32 2026] 127.0.0.1:52840 Closing +[Tue Apr 14 07:53:33 2026] 127.0.0.1:52852 Accepted +[Tue Apr 14 07:53:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:33 2026] 127.0.0.1:52852 Closing +[Tue Apr 14 07:53:33 2026] 127.0.0.1:52858 Accepted +[Tue Apr 14 07:53:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:34 2026] 127.0.0.1:52858 Closing +[Tue Apr 14 07:53:34 2026] 127.0.0.1:52870 Accepted +[Tue Apr 14 07:53:35 2026] 127.0.0.1:52870 Closing +[Tue Apr 14 07:53:35 2026] 127.0.0.1:52882 Accepted +[Tue Apr 14 07:53:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:36 2026] 127.0.0.1:52882 Closing +[Tue Apr 14 07:53:36 2026] 127.0.0.1:52888 Accepted +[Tue Apr 14 07:53:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:37 2026] 127.0.0.1:52888 Closing +[Tue Apr 14 07:53:37 2026] 127.0.0.1:52898 Accepted +[Tue Apr 14 07:53:37 2026] 127.0.0.1:52898 Closing +[Tue Apr 14 07:53:37 2026] 127.0.0.1:52912 Accepted +[Tue Apr 14 07:53:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:38 2026] 127.0.0.1:52912 Closing +[Tue Apr 14 07:53:38 2026] 127.0.0.1:50322 Accepted +[Tue Apr 14 07:53:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:39 2026] 127.0.0.1:50322 Closing +[Tue Apr 14 07:53:39 2026] 127.0.0.1:50328 Accepted +[Tue Apr 14 07:53:39 2026] 127.0.0.1:50328 Closing +[Tue Apr 14 07:53:39 2026] 127.0.0.1:50340 Accepted +[Tue Apr 14 07:53:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:39 2026] 127.0.0.1:50340 Closing +[Tue Apr 14 07:53:39 2026] 127.0.0.1:50348 Accepted +[Tue Apr 14 07:53:40 2026] 127.0.0.1:50348 Closing +[Tue Apr 14 07:53:41 2026] 127.0.0.1:50356 Accepted +[Tue Apr 14 07:53:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:41 2026] 127.0.0.1:50356 Closing +[Tue Apr 14 07:53:41 2026] 127.0.0.1:50360 Accepted +[Tue Apr 14 07:53:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:41 2026] 127.0.0.1:50360 Closing +[Tue Apr 14 07:53:41 2026] 127.0.0.1:50368 Accepted +[Tue Apr 14 07:53:43 2026] 127.0.0.1:50368 Closing +[Tue Apr 14 07:53:43 2026] 127.0.0.1:50376 Accepted +[Tue Apr 14 07:53:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:44 2026] 127.0.0.1:50376 Closing +[Tue Apr 14 07:53:44 2026] 127.0.0.1:50386 Accepted +[Tue Apr 14 07:53:44 2026] 127.0.0.1:50386 Closing +[Tue Apr 14 07:53:44 2026] 127.0.0.1:50390 Accepted +[Tue Apr 14 07:53:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:45 2026] 127.0.0.1:50390 Closing +[Tue Apr 14 07:53:45 2026] 127.0.0.1:50398 Accepted +[Tue Apr 14 07:53:45 2026] 127.0.0.1:50398 Closing +[Tue Apr 14 07:53:46 2026] 127.0.0.1:50414 Accepted +[Tue Apr 14 07:53:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:46 2026] 127.0.0.1:50414 Closing +[Tue Apr 14 07:53:46 2026] 127.0.0.1:50426 Accepted +[Tue Apr 14 07:53:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 07:53:46 2026] 127.0.0.1:50426 Closing +[Tue Apr 14 07:53:46 2026] 127.0.0.1:50442 Accepted +[Tue Apr 14 07:53:47 2026] 127.0.0.1:50442 Closing +[Tue Apr 14 08:06:40 2026] 127.0.0.1:57072 Accepted +[Tue Apr 14 08:06:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:06:40 2026] 127.0.0.1:57072 Closing +[Tue Apr 14 08:06:41 2026] 127.0.0.1:57076 Accepted +[Tue Apr 14 08:06:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:06:42 2026] 127.0.0.1:57076 Closing +[Tue Apr 14 08:06:42 2026] 127.0.0.1:57082 Accepted +[Tue Apr 14 08:06:45 2026] 127.0.0.1:57082 Closing +[Tue Apr 14 08:06:47 2026] 127.0.0.1:47976 Accepted +[Tue Apr 14 08:06:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:06:48 2026] 127.0.0.1:47976 Closing +[Tue Apr 14 08:06:49 2026] 127.0.0.1:47984 Accepted +[Tue Apr 14 08:06:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:06:49 2026] 127.0.0.1:47984 Closing +[Tue Apr 14 08:06:49 2026] 127.0.0.1:47992 Accepted +[Tue Apr 14 08:06:53 2026] 127.0.0.1:47992 Closing +[Tue Apr 14 08:06:55 2026] 127.0.0.1:54270 Accepted +[Tue Apr 14 08:06:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:06:56 2026] 127.0.0.1:54270 Closing +[Tue Apr 14 08:06:56 2026] 127.0.0.1:54274 Accepted +[Tue Apr 14 08:06:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:06:56 2026] 127.0.0.1:54274 Closing +[Tue Apr 14 08:06:56 2026] 127.0.0.1:54282 Accepted +[Tue Apr 14 08:06:56 2026] 127.0.0.1:54282 Closing +[Tue Apr 14 08:06:57 2026] 127.0.0.1:54296 Accepted +[Tue Apr 14 08:06:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:06:57 2026] 127.0.0.1:54296 Closing +[Tue Apr 14 08:06:57 2026] 127.0.0.1:54300 Accepted +[Tue Apr 14 08:06:59 2026] 127.0.0.1:54300 Closing +[Tue Apr 14 08:07:00 2026] 127.0.0.1:54312 Accepted +[Tue Apr 14 08:07:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:00 2026] 127.0.0.1:54312 Closing +[Tue Apr 14 08:07:00 2026] 127.0.0.1:54322 Accepted +[Tue Apr 14 08:07:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:01 2026] 127.0.0.1:54322 Closing +[Tue Apr 14 08:07:01 2026] 127.0.0.1:54334 Accepted +[Tue Apr 14 08:07:05 2026] 127.0.0.1:54334 Closing +[Tue Apr 14 08:07:07 2026] 127.0.0.1:52276 Accepted +[Tue Apr 14 08:07:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:07 2026] 127.0.0.1:52276 Closing +[Tue Apr 14 08:07:08 2026] 127.0.0.1:52282 Accepted +[Tue Apr 14 08:07:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:08 2026] 127.0.0.1:52282 Closing +[Tue Apr 14 08:07:08 2026] 127.0.0.1:52288 Accepted +[Tue Apr 14 08:07:10 2026] 127.0.0.1:52288 Closing +[Tue Apr 14 08:07:10 2026] 127.0.0.1:52294 Accepted +[Tue Apr 14 08:07:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:10 2026] 127.0.0.1:52294 Closing +[Tue Apr 14 08:07:10 2026] 127.0.0.1:52302 Accepted +[Tue Apr 14 08:07:11 2026] 127.0.0.1:52302 Closing +[Tue Apr 14 08:07:11 2026] 127.0.0.1:52304 Accepted +[Tue Apr 14 08:07:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:12 2026] 127.0.0.1:52304 Closing +[Tue Apr 14 08:07:12 2026] 127.0.0.1:52318 Accepted +[Tue Apr 14 08:07:13 2026] 127.0.0.1:52318 Closing +[Tue Apr 14 08:07:14 2026] 127.0.0.1:54524 Accepted +[Tue Apr 14 08:07:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:14 2026] 127.0.0.1:54524 Closing +[Tue Apr 14 08:07:14 2026] 127.0.0.1:54538 Accepted +[Tue Apr 14 08:07:14 2026] 127.0.0.1:54538 Closing +[Tue Apr 14 08:07:16 2026] 127.0.0.1:54544 Accepted +[Tue Apr 14 08:07:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:16 2026] 127.0.0.1:54544 Closing +[Tue Apr 14 08:07:17 2026] 127.0.0.1:54560 Accepted +[Tue Apr 14 08:07:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:18 2026] 127.0.0.1:54560 Closing +[Tue Apr 14 08:07:18 2026] 127.0.0.1:54572 Accepted +[Tue Apr 14 08:07:22 2026] 127.0.0.1:54572 Closing +[Tue Apr 14 08:07:23 2026] 127.0.0.1:34584 Accepted +[Tue Apr 14 08:07:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:24 2026] 127.0.0.1:34584 Closing +[Tue Apr 14 08:07:25 2026] 127.0.0.1:34592 Accepted +[Tue Apr 14 08:07:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:25 2026] 127.0.0.1:34592 Closing +[Tue Apr 14 08:07:25 2026] 127.0.0.1:34594 Accepted +[Tue Apr 14 08:07:28 2026] 127.0.0.1:34594 Closing +[Tue Apr 14 08:07:28 2026] 127.0.0.1:34610 Accepted +[Tue Apr 14 08:07:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:28 2026] 127.0.0.1:34610 Closing +[Tue Apr 14 08:07:28 2026] 127.0.0.1:34626 Accepted +[Tue Apr 14 08:07:30 2026] 127.0.0.1:34626 Closing +[Tue Apr 14 08:07:30 2026] 127.0.0.1:34638 Accepted +[Tue Apr 14 08:07:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:31 2026] 127.0.0.1:34638 Closing +[Tue Apr 14 08:07:31 2026] 127.0.0.1:34654 Accepted +[Tue Apr 14 08:07:33 2026] 127.0.0.1:34654 Closing +[Tue Apr 14 08:07:33 2026] 127.0.0.1:38116 Accepted +[Tue Apr 14 08:07:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:33 2026] 127.0.0.1:38116 Closing +[Tue Apr 14 08:07:33 2026] 127.0.0.1:38122 Accepted +[Tue Apr 14 08:07:35 2026] 127.0.0.1:38122 Closing +[Tue Apr 14 08:07:35 2026] 127.0.0.1:38134 Accepted +[Tue Apr 14 08:07:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:36 2026] 127.0.0.1:38134 Closing +[Tue Apr 14 08:07:36 2026] 127.0.0.1:38148 Accepted +[Tue Apr 14 08:07:38 2026] 127.0.0.1:38148 Closing +[Tue Apr 14 08:07:38 2026] 127.0.0.1:38152 Accepted +[Tue Apr 14 08:07:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:38 2026] 127.0.0.1:38152 Closing +[Tue Apr 14 08:07:38 2026] 127.0.0.1:38164 Accepted +[Tue Apr 14 08:07:40 2026] 127.0.0.1:38164 Closing +[Tue Apr 14 08:07:42 2026] 127.0.0.1:38174 Accepted +[Tue Apr 14 08:07:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:42 2026] 127.0.0.1:38174 Closing +[Tue Apr 14 08:07:42 2026] 127.0.0.1:35282 Accepted +[Tue Apr 14 08:07:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:42 2026] 127.0.0.1:35282 Closing +[Tue Apr 14 08:07:42 2026] 127.0.0.1:35292 Accepted +[Tue Apr 14 08:07:42 2026] 127.0.0.1:35292 Closing +[Tue Apr 14 08:07:42 2026] 127.0.0.1:35302 Accepted +[Tue Apr 14 08:07:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:43 2026] 127.0.0.1:35302 Closing +[Tue Apr 14 08:07:43 2026] 127.0.0.1:35312 Accepted +[Tue Apr 14 08:07:44 2026] 127.0.0.1:35312 Closing +[Tue Apr 14 08:07:45 2026] 127.0.0.1:35320 Accepted +[Tue Apr 14 08:07:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:45 2026] 127.0.0.1:35320 Closing +[Tue Apr 14 08:07:46 2026] 127.0.0.1:35330 Accepted +[Tue Apr 14 08:07:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:47 2026] 127.0.0.1:35330 Closing +[Tue Apr 14 08:07:47 2026] 127.0.0.1:35342 Accepted +[Tue Apr 14 08:07:51 2026] 127.0.0.1:35342 Closing +[Tue Apr 14 08:07:52 2026] 127.0.0.1:59374 Accepted +[Tue Apr 14 08:07:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:53 2026] 127.0.0.1:59374 Closing +[Tue Apr 14 08:07:53 2026] 127.0.0.1:59378 Accepted +[Tue Apr 14 08:07:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:54 2026] 127.0.0.1:59378 Closing +[Tue Apr 14 08:07:54 2026] 127.0.0.1:59380 Accepted +[Tue Apr 14 08:07:56 2026] 127.0.0.1:59380 Closing +[Tue Apr 14 08:07:56 2026] 127.0.0.1:59386 Accepted +[Tue Apr 14 08:07:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:56 2026] 127.0.0.1:59386 Closing +[Tue Apr 14 08:07:56 2026] 127.0.0.1:59398 Accepted +[Tue Apr 14 08:07:58 2026] 127.0.0.1:59398 Closing +[Tue Apr 14 08:07:58 2026] 127.0.0.1:59400 Accepted +[Tue Apr 14 08:07:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:07:59 2026] 127.0.0.1:59400 Closing +[Tue Apr 14 08:07:59 2026] 127.0.0.1:59408 Accepted +[Tue Apr 14 08:08:00 2026] 127.0.0.1:59408 Closing +[Tue Apr 14 08:08:00 2026] 127.0.0.1:54304 Accepted +[Tue Apr 14 08:08:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:01 2026] 127.0.0.1:54304 Closing +[Tue Apr 14 08:08:01 2026] 127.0.0.1:54316 Accepted +[Tue Apr 14 08:08:03 2026] 127.0.0.1:54316 Closing +[Tue Apr 14 08:08:03 2026] 127.0.0.1:54328 Accepted +[Tue Apr 14 08:08:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:03 2026] 127.0.0.1:54328 Closing +[Tue Apr 14 08:08:03 2026] 127.0.0.1:54332 Accepted +[Tue Apr 14 08:08:05 2026] 127.0.0.1:54332 Closing +[Tue Apr 14 08:08:05 2026] 127.0.0.1:54348 Accepted +[Tue Apr 14 08:08:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:05 2026] 127.0.0.1:54348 Closing +[Tue Apr 14 08:08:05 2026] 127.0.0.1:54364 Accepted +[Tue Apr 14 08:08:07 2026] 127.0.0.1:54364 Closing +[Tue Apr 14 08:08:09 2026] 127.0.0.1:54368 Accepted +[Tue Apr 14 08:08:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:09 2026] 127.0.0.1:54368 Closing +[Tue Apr 14 08:08:09 2026] 127.0.0.1:54378 Accepted +[Tue Apr 14 08:08:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:09 2026] 127.0.0.1:54378 Closing +[Tue Apr 14 08:08:09 2026] 127.0.0.1:54386 Accepted +[Tue Apr 14 08:08:11 2026] 127.0.0.1:54386 Closing +[Tue Apr 14 08:08:12 2026] 127.0.0.1:50512 Accepted +[Tue Apr 14 08:08:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:11 2026] 127.0.0.1:50512 Closing +[Tue Apr 14 08:08:11 2026] 127.0.0.1:50518 Accepted +[Tue Apr 14 08:08:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:12 2026] 127.0.0.1:50518 Closing +[Tue Apr 14 08:08:12 2026] 127.0.0.1:50532 Accepted +[Tue Apr 14 08:08:14 2026] 127.0.0.1:50532 Closing +[Tue Apr 14 08:08:14 2026] 127.0.0.1:50540 Accepted +[Tue Apr 14 08:08:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:14 2026] 127.0.0.1:50540 Closing +[Tue Apr 14 08:08:14 2026] 127.0.0.1:50544 Accepted +[Tue Apr 14 08:08:15 2026] 127.0.0.1:50544 Closing +[Tue Apr 14 08:08:17 2026] 127.0.0.1:50554 Accepted +[Tue Apr 14 08:08:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:17 2026] 127.0.0.1:50554 Closing +[Tue Apr 14 08:08:18 2026] 127.0.0.1:50566 Accepted +[Tue Apr 14 08:08:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:18 2026] 127.0.0.1:50566 Closing +[Tue Apr 14 08:08:18 2026] 127.0.0.1:50572 Accepted +[Tue Apr 14 08:08:21 2026] 127.0.0.1:50572 Closing +[Tue Apr 14 08:08:22 2026] 127.0.0.1:41020 Accepted +[Tue Apr 14 08:08:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:23 2026] 127.0.0.1:41020 Closing +[Tue Apr 14 08:08:23 2026] 127.0.0.1:41022 Accepted +[Tue Apr 14 08:08:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:24 2026] 127.0.0.1:41022 Closing +[Tue Apr 14 08:08:24 2026] 127.0.0.1:41026 Accepted +[Tue Apr 14 08:08:26 2026] 127.0.0.1:41026 Closing +[Tue Apr 14 08:08:26 2026] 127.0.0.1:41036 Accepted +[Tue Apr 14 08:08:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:08:26 2026] 127.0.0.1:41036 Closing +[Tue Apr 14 08:08:26 2026] 127.0.0.1:41042 Accepted +[Tue Apr 14 08:08:27 2026] 127.0.0.1:41042 Closing +[Tue Apr 14 08:46:21 2026] 127.0.0.1:51442 Accepted +[Tue Apr 14 08:46:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:46:22 2026] 127.0.0.1:51442 Closing +[Tue Apr 14 08:46:23 2026] 127.0.0.1:51452 Accepted +[Tue Apr 14 08:46:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:46:23 2026] 127.0.0.1:51452 Closing +[Tue Apr 14 08:46:23 2026] 127.0.0.1:51468 Accepted +[Tue Apr 14 08:46:26 2026] 127.0.0.1:51468 Closing +[Tue Apr 14 08:46:27 2026] 127.0.0.1:51044 Accepted +[Tue Apr 14 08:46:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:46:27 2026] 127.0.0.1:51044 Closing +[Tue Apr 14 08:46:28 2026] 127.0.0.1:51046 Accepted +[Tue Apr 14 08:46:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 08:46:28 2026] 127.0.0.1:51046 Closing +[Tue Apr 14 08:46:28 2026] 127.0.0.1:51062 Accepted +[Tue Apr 14 08:46:29 2026] 127.0.0.1:51062 Closing +[Tue Apr 14 11:07:42 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 14 11:07:42 2026] 127.0.0.1:59298 Accepted +[Tue Apr 14 11:07:44 2026] 127.0.0.1:59298 Closing +[Tue Apr 14 11:07:46 2026] 127.0.0.1:34790 Accepted +[Tue Apr 14 11:07:47 2026] 127.0.0.1:34790 Closing +[Tue Apr 14 11:07:49 2026] 127.0.0.1:34800 Accepted +[Tue Apr 14 11:07:49 2026] 127.0.0.1:34800 Closing +[Tue Apr 14 11:07:49 2026] 127.0.0.1:34808 Accepted +[Tue Apr 14 11:07:51 2026] 127.0.0.1:34808 Closing +[Tue Apr 14 11:07:53 2026] 127.0.0.1:34818 Accepted +[Tue Apr 14 11:07:54 2026] 127.0.0.1:34818 Closing +[Tue Apr 14 11:07:56 2026] 127.0.0.1:58298 Accepted +[Tue Apr 14 11:07:56 2026] 127.0.0.1:58298 Closing +[Tue Apr 14 11:07:57 2026] 127.0.0.1:58310 Accepted +[Tue Apr 14 11:07:58 2026] 127.0.0.1:58310 Closing +[Tue Apr 14 11:07:58 2026] 127.0.0.1:58322 Accepted +[Tue Apr 14 11:07:59 2026] 127.0.0.1:58322 Closing +[Tue Apr 14 11:08:01 2026] 127.0.0.1:58330 Accepted +[Tue Apr 14 11:08:05 2026] 127.0.0.1:58330 Closing +[Tue Apr 14 11:08:05 2026] 127.0.0.1:38384 Accepted +[Tue Apr 14 11:08:07 2026] 127.0.0.1:38384 Closing +[Tue Apr 14 11:08:07 2026] 127.0.0.1:38398 Accepted +[Tue Apr 14 11:08:08 2026] 127.0.0.1:38398 Closing +[Tue Apr 14 11:08:09 2026] 127.0.0.1:38400 Accepted +[Tue Apr 14 11:08:10 2026] 127.0.0.1:38400 Closing +[Tue Apr 14 11:08:12 2026] 127.0.0.1:38414 Accepted +[Tue Apr 14 11:08:16 2026] 127.0.0.1:38414 Closing +[Tue Apr 14 11:08:19 2026] 127.0.0.1:51174 Accepted +[Tue Apr 14 11:08:22 2026] 127.0.0.1:51174 Closing +[Tue Apr 14 11:08:25 2026] 127.0.0.1:51826 Accepted +[Tue Apr 14 11:08:29 2026] 127.0.0.1:51826 Closing +[Tue Apr 14 11:08:32 2026] 127.0.0.1:51842 Accepted +[Tue Apr 14 11:08:37 2026] 127.0.0.1:51842 Closing +[Tue Apr 14 11:08:37 2026] 127.0.0.1:35418 Accepted +[Tue Apr 14 11:08:40 2026] 127.0.0.1:35418 Closing +[Tue Apr 14 11:08:43 2026] 127.0.0.1:35420 Accepted +[Tue Apr 14 11:08:45 2026] 127.0.0.1:35420 Closing +[Tue Apr 14 11:08:45 2026] 127.0.0.1:49482 Accepted +[Tue Apr 14 11:08:52 2026] 127.0.0.1:49482 Closing +[Tue Apr 14 11:08:57 2026] 127.0.0.1:45374 Accepted +[Tue Apr 14 11:09:12 2026] 127.0.0.1:45374 Closing +[Tue Apr 14 11:09:15 2026] 127.0.0.1:50478 Accepted +[Tue Apr 14 11:09:16 2026] 127.0.0.1:50478 Closing +[Tue Apr 14 11:09:18 2026] 127.0.0.1:50482 Accepted +[Tue Apr 14 11:09:24 2026] 127.0.0.1:50482 Closing +[Tue Apr 14 11:09:24 2026] 127.0.0.1:51988 Accepted +[Tue Apr 14 11:09:29 2026] 127.0.0.1:51988 Closing +[Tue Apr 14 11:09:32 2026] 127.0.0.1:47258 Accepted +[Tue Apr 14 11:09:37 2026] 127.0.0.1:47258 Closing +[Tue Apr 14 11:09:39 2026] 127.0.0.1:47266 Accepted +[Tue Apr 14 11:09:42 2026] 127.0.0.1:47266 Closing +[Tue Apr 14 11:09:44 2026] 127.0.0.1:43398 Accepted +[Tue Apr 14 11:09:47 2026] 127.0.0.1:43398 Closing +[Tue Apr 14 11:09:47 2026] 127.0.0.1:43410 Accepted +[Tue Apr 14 11:09:50 2026] 127.0.0.1:43410 Closing +[Tue Apr 14 11:09:54 2026] 127.0.0.1:39034 Accepted +[Tue Apr 14 11:09:58 2026] 127.0.0.1:39034 Closing +[Tue Apr 14 11:10:01 2026] 127.0.0.1:51298 Accepted +[Tue Apr 14 11:10:05 2026] 127.0.0.1:51298 Closing +[Tue Apr 14 11:10:06 2026] 127.0.0.1:51304 Accepted +[Tue Apr 14 11:10:08 2026] 127.0.0.1:51304 Closing +[Tue Apr 14 11:10:12 2026] 127.0.0.1:46004 Accepted +[Tue Apr 14 11:10:16 2026] 127.0.0.1:46004 Closing +[Tue Apr 14 11:10:18 2026] 127.0.0.1:46014 Accepted +[Tue Apr 14 11:10:22 2026] 127.0.0.1:46014 Closing +[Tue Apr 14 11:10:23 2026] 127.0.0.1:33880 Accepted +[Tue Apr 14 11:10:26 2026] 127.0.0.1:33880 Closing +[Tue Apr 14 11:10:32 2026] 127.0.0.1:53934 Accepted +[Tue Apr 14 11:10:40 2026] 127.0.0.1:53934 Closing +[Tue Apr 14 11:10:46 2026] 127.0.0.1:58956 Accepted +[Tue Apr 14 11:10:55 2026] 127.0.0.1:58956 Closing +[Tue Apr 14 11:10:59 2026] 127.0.0.1:52248 Accepted +[Tue Apr 14 11:11:00 2026] 127.0.0.1:52248 Closing +[Tue Apr 14 11:11:01 2026] 127.0.0.1:52254 Accepted +[Tue Apr 14 11:11:05 2026] 127.0.0.1:52254 Closing +[Tue Apr 14 11:11:08 2026] 127.0.0.1:52260 Accepted +[Tue Apr 14 11:11:16 2026] 127.0.0.1:52260 Closing +[Tue Apr 14 11:11:19 2026] 127.0.0.1:57594 Accepted +[Tue Apr 14 11:11:23 2026] 127.0.0.1:57594 Closing +[Tue Apr 14 11:11:23 2026] 127.0.0.1:57606 Accepted +[Tue Apr 14 11:11:25 2026] 127.0.0.1:57606 Closing +[Tue Apr 14 11:11:25 2026] 127.0.0.1:57622 Accepted +[Tue Apr 14 11:11:28 2026] 127.0.0.1:57622 Closing +[Tue Apr 14 11:11:28 2026] 127.0.0.1:56858 Accepted +[Tue Apr 14 11:11:30 2026] 127.0.0.1:56858 Closing +[Tue Apr 14 11:11:35 2026] 127.0.0.1:56868 Accepted +[Tue Apr 14 11:11:42 2026] 127.0.0.1:56868 Closing +[Tue Apr 14 11:11:48 2026] 127.0.0.1:40892 Accepted +[Tue Apr 14 11:11:53 2026] 127.0.0.1:40892 Closing +[Tue Apr 14 11:11:53 2026] 127.0.0.1:40894 Accepted +[Tue Apr 14 11:11:56 2026] 127.0.0.1:40894 Closing +[Tue Apr 14 11:11:56 2026] 127.0.0.1:44320 Accepted +[Tue Apr 14 11:12:00 2026] 127.0.0.1:44320 Closing +[Tue Apr 14 11:12:00 2026] 127.0.0.1:44324 Accepted +[Tue Apr 14 11:12:04 2026] 127.0.0.1:44324 Closing +[Tue Apr 14 11:12:04 2026] 127.0.0.1:44340 Accepted +[Tue Apr 14 11:12:06 2026] 127.0.0.1:44340 Closing +[Tue Apr 14 11:12:06 2026] 127.0.0.1:60956 Accepted +[Tue Apr 14 11:12:10 2026] 127.0.0.1:60956 Closing +[Tue Apr 14 11:12:14 2026] 127.0.0.1:60966 Accepted +[Tue Apr 14 11:12:17 2026] 127.0.0.1:60966 Closing +[Tue Apr 14 11:12:17 2026] 127.0.0.1:40954 Accepted +[Tue Apr 14 11:12:20 2026] 127.0.0.1:40954 Closing +[Tue Apr 14 11:12:23 2026] 127.0.0.1:40966 Accepted +[Tue Apr 14 11:12:32 2026] 127.0.0.1:40966 Closing +[Tue Apr 14 11:12:37 2026] 127.0.0.1:60994 Accepted +[Tue Apr 14 11:12:42 2026] 127.0.0.1:60994 Closing +[Tue Apr 14 11:12:42 2026] 127.0.0.1:32774 Accepted +[Tue Apr 14 11:12:46 2026] 127.0.0.1:32774 Closing +[Tue Apr 14 11:12:46 2026] 127.0.0.1:40126 Accepted +[Tue Apr 14 11:12:49 2026] 127.0.0.1:40126 Closing +[Tue Apr 14 11:12:49 2026] 127.0.0.1:40142 Accepted +[Tue Apr 14 11:12:53 2026] 127.0.0.1:40142 Closing +[Tue Apr 14 11:12:53 2026] 127.0.0.1:40158 Accepted +[Tue Apr 14 11:12:57 2026] 127.0.0.1:40158 Closing +[Tue Apr 14 11:12:57 2026] 127.0.0.1:43102 Accepted +[Tue Apr 14 11:13:01 2026] 127.0.0.1:43102 Closing +[Tue Apr 14 11:13:05 2026] 127.0.0.1:39590 Accepted +[Tue Apr 14 11:13:07 2026] 127.0.0.1:39590 Closing +[Tue Apr 14 11:13:10 2026] 127.0.0.1:39598 Accepted +[Tue Apr 14 11:13:13 2026] 127.0.0.1:39598 Closing +[Tue Apr 14 11:13:13 2026] 127.0.0.1:35602 Accepted +[Tue Apr 14 11:13:15 2026] 127.0.0.1:35602 Closing +[Tue Apr 14 11:13:20 2026] 127.0.0.1:35606 Accepted +[Tue Apr 14 11:13:25 2026] 127.0.0.1:35606 Closing +[Tue Apr 14 11:13:30 2026] 127.0.0.1:47836 Accepted +[Tue Apr 14 11:13:33 2026] 127.0.0.1:47836 Closing +[Tue Apr 14 11:13:33 2026] 127.0.0.1:42664 Accepted +[Tue Apr 14 11:13:34 2026] 127.0.0.1:42664 Closing +[Tue Apr 14 11:13:36 2026] 127.0.0.1:42668 Accepted +[Tue Apr 14 11:13:36 2026] 127.0.0.1:42668 Closing +[Tue Apr 14 11:13:37 2026] 127.0.0.1:42682 Accepted +[Tue Apr 14 11:13:41 2026] 127.0.0.1:42682 Closing +[Tue Apr 14 11:13:44 2026] 127.0.0.1:48304 Accepted +[Tue Apr 14 11:13:48 2026] 127.0.0.1:48304 Closing +[Tue Apr 14 11:13:52 2026] 127.0.0.1:36746 Accepted +[Tue Apr 14 11:14:06 2026] 127.0.0.1:36746 Closing +[Tue Apr 14 11:14:10 2026] 127.0.0.1:50808 Accepted +[Tue Apr 14 11:14:25 2026] 127.0.0.1:50808 Closing +[Tue Apr 14 12:36:41 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 14 12:36:41 2026] 127.0.0.1:58544 Accepted +[Tue Apr 14 12:36:43 2026] 127.0.0.1:58544 Closing +[Tue Apr 14 12:36:45 2026] 127.0.0.1:58552 Accepted +[Tue Apr 14 12:36:47 2026] 127.0.0.1:58552 Closing +[Tue Apr 14 12:36:49 2026] 127.0.0.1:49616 Accepted +[Tue Apr 14 12:36:50 2026] 127.0.0.1:49616 Closing +[Tue Apr 14 12:36:50 2026] 127.0.0.1:49626 Accepted +[Tue Apr 14 12:36:51 2026] 127.0.0.1:49626 Closing +[Tue Apr 14 12:36:53 2026] 127.0.0.1:49638 Accepted +[Tue Apr 14 12:36:54 2026] 127.0.0.1:49638 Closing +[Tue Apr 14 12:36:56 2026] 127.0.0.1:49642 Accepted +[Tue Apr 14 12:36:57 2026] 127.0.0.1:49642 Closing +[Tue Apr 14 12:36:58 2026] 127.0.0.1:49644 Accepted +[Tue Apr 14 12:36:59 2026] 127.0.0.1:49644 Closing +[Tue Apr 14 12:36:59 2026] 127.0.0.1:47980 Accepted +[Tue Apr 14 12:37:00 2026] 127.0.0.1:47980 Closing +[Tue Apr 14 12:37:00 2026] 127.0.0.1:47986 Accepted +[Tue Apr 14 12:37:04 2026] 127.0.0.1:47986 Closing +[Tue Apr 14 12:37:04 2026] 127.0.0.1:47992 Accepted +[Tue Apr 14 12:37:05 2026] 127.0.0.1:47992 Closing +[Tue Apr 14 12:37:05 2026] 127.0.0.1:47998 Accepted +[Tue Apr 14 12:37:06 2026] 127.0.0.1:47998 Closing +[Tue Apr 14 12:37:08 2026] 127.0.0.1:46414 Accepted +[Tue Apr 14 12:37:08 2026] 127.0.0.1:46414 Closing +[Tue Apr 14 12:37:10 2026] 127.0.0.1:46418 Accepted +[Tue Apr 14 12:37:16 2026] 127.0.0.1:46418 Closing +[Tue Apr 14 12:37:19 2026] 127.0.0.1:58672 Accepted +[Tue Apr 14 12:37:22 2026] 127.0.0.1:58672 Closing +[Tue Apr 14 12:37:25 2026] 127.0.0.1:58684 Accepted +[Tue Apr 14 12:37:29 2026] 127.0.0.1:58684 Closing +[Tue Apr 14 12:37:30 2026] 127.0.0.1:57444 Accepted +[Tue Apr 14 12:37:36 2026] 127.0.0.1:57444 Closing +[Tue Apr 14 12:37:36 2026] 127.0.0.1:49332 Accepted +[Tue Apr 14 12:37:38 2026] 127.0.0.1:49332 Closing +[Tue Apr 14 12:37:41 2026] 127.0.0.1:49338 Accepted +[Tue Apr 14 12:37:45 2026] 127.0.0.1:49338 Closing +[Tue Apr 14 12:37:45 2026] 127.0.0.1:49350 Accepted +[Tue Apr 14 12:37:52 2026] 127.0.0.1:49350 Closing +[Tue Apr 14 12:37:56 2026] 127.0.0.1:55344 Accepted +[Tue Apr 14 12:38:07 2026] 127.0.0.1:55344 Closing +[Tue Apr 14 12:38:11 2026] 127.0.0.1:49130 Accepted +[Tue Apr 14 12:38:12 2026] 127.0.0.1:49130 Closing +[Tue Apr 14 12:38:15 2026] 127.0.0.1:33398 Accepted +[Tue Apr 14 12:38:19 2026] 127.0.0.1:33398 Closing +[Tue Apr 14 12:38:19 2026] 127.0.0.1:33402 Accepted +[Tue Apr 14 12:38:24 2026] 127.0.0.1:33402 Closing +[Tue Apr 14 12:38:26 2026] 127.0.0.1:34558 Accepted +[Tue Apr 14 12:38:27 2026] 127.0.0.1:34558 Closing +[Tue Apr 14 12:38:29 2026] 127.0.0.1:34574 Accepted +[Tue Apr 14 12:38:33 2026] 127.0.0.1:34574 Closing +[Tue Apr 14 12:38:35 2026] 127.0.0.1:56580 Accepted +[Tue Apr 14 12:38:38 2026] 127.0.0.1:56580 Closing +[Tue Apr 14 12:38:39 2026] 127.0.0.1:56596 Accepted +[Tue Apr 14 12:38:41 2026] 127.0.0.1:56596 Closing +[Tue Apr 14 12:38:45 2026] 127.0.0.1:43058 Accepted +[Tue Apr 14 12:38:49 2026] 127.0.0.1:43058 Closing +[Tue Apr 14 12:38:51 2026] 127.0.0.1:43062 Accepted +[Tue Apr 14 12:38:54 2026] 127.0.0.1:43062 Closing +[Tue Apr 14 12:38:55 2026] 127.0.0.1:53992 Accepted +[Tue Apr 14 12:38:56 2026] 127.0.0.1:53992 Closing +[Tue Apr 14 12:38:59 2026] 127.0.0.1:54006 Accepted +[Tue Apr 14 12:39:04 2026] 127.0.0.1:54006 Closing +[Tue Apr 14 12:39:06 2026] 127.0.0.1:40840 Accepted +[Tue Apr 14 12:39:09 2026] 127.0.0.1:40840 Closing +[Tue Apr 14 12:39:10 2026] 127.0.0.1:40856 Accepted +[Tue Apr 14 12:39:12 2026] 127.0.0.1:40856 Closing +[Tue Apr 14 12:39:17 2026] 127.0.0.1:37598 Accepted +[Tue Apr 14 12:39:24 2026] 127.0.0.1:37598 Closing +[Tue Apr 14 12:39:30 2026] 127.0.0.1:57182 Accepted +[Tue Apr 14 12:39:39 2026] 127.0.0.1:57182 Closing +[Tue Apr 14 12:39:43 2026] 127.0.0.1:46120 Accepted +[Tue Apr 14 12:39:44 2026] 127.0.0.1:46120 Closing +[Tue Apr 14 12:39:44 2026] 127.0.0.1:46124 Accepted +[Tue Apr 14 12:39:49 2026] 127.0.0.1:46124 Closing +[Tue Apr 14 12:39:50 2026] 127.0.0.1:42400 Accepted +[Tue Apr 14 12:39:59 2026] 127.0.0.1:42400 Closing +[Tue Apr 14 12:40:03 2026] 127.0.0.1:55116 Accepted +[Tue Apr 14 12:40:06 2026] 127.0.0.1:55116 Closing +[Tue Apr 14 12:40:06 2026] 127.0.0.1:55126 Accepted +[Tue Apr 14 12:40:08 2026] 127.0.0.1:55126 Closing +[Tue Apr 14 12:40:08 2026] 127.0.0.1:34492 Accepted +[Tue Apr 14 12:40:12 2026] 127.0.0.1:34492 Closing +[Tue Apr 14 12:40:12 2026] 127.0.0.1:34496 Accepted +[Tue Apr 14 12:40:15 2026] 127.0.0.1:34496 Closing +[Tue Apr 14 12:40:19 2026] 127.0.0.1:45982 Accepted +[Tue Apr 14 12:40:27 2026] 127.0.0.1:45982 Closing +[Tue Apr 14 12:40:32 2026] 127.0.0.1:52138 Accepted +[Tue Apr 14 12:40:38 2026] 127.0.0.1:52138 Closing +[Tue Apr 14 12:40:38 2026] 127.0.0.1:57918 Accepted +[Tue Apr 14 12:40:42 2026] 127.0.0.1:57918 Closing +[Tue Apr 14 12:40:42 2026] 127.0.0.1:57932 Accepted +[Tue Apr 14 12:40:45 2026] 127.0.0.1:57932 Closing +[Tue Apr 14 12:40:45 2026] 127.0.0.1:57936 Accepted +[Tue Apr 14 12:40:48 2026] 127.0.0.1:57936 Closing +[Tue Apr 14 12:40:48 2026] 127.0.0.1:59794 Accepted +[Tue Apr 14 12:40:52 2026] 127.0.0.1:59794 Closing +[Tue Apr 14 12:40:52 2026] 127.0.0.1:59802 Accepted +[Tue Apr 14 12:40:55 2026] 127.0.0.1:59802 Closing +[Tue Apr 14 12:41:00 2026] 127.0.0.1:39486 Accepted +[Tue Apr 14 12:41:03 2026] 127.0.0.1:39486 Closing +[Tue Apr 14 12:41:03 2026] 127.0.0.1:39496 Accepted +[Tue Apr 14 12:41:06 2026] 127.0.0.1:39496 Closing +[Tue Apr 14 12:41:09 2026] 127.0.0.1:58858 Accepted +[Tue Apr 14 12:41:17 2026] 127.0.0.1:58858 Closing +[Tue Apr 14 12:41:23 2026] 127.0.0.1:51058 Accepted +[Tue Apr 14 12:41:28 2026] 127.0.0.1:51058 Closing +[Tue Apr 14 12:41:28 2026] 127.0.0.1:51064 Accepted +[Tue Apr 14 12:41:32 2026] 127.0.0.1:51064 Closing +[Tue Apr 14 12:41:32 2026] 127.0.0.1:34742 Accepted +[Tue Apr 14 12:41:36 2026] 127.0.0.1:34742 Closing +[Tue Apr 14 12:41:36 2026] 127.0.0.1:34758 Accepted +[Tue Apr 14 12:41:40 2026] 127.0.0.1:34758 Closing +[Tue Apr 14 12:41:40 2026] 127.0.0.1:34760 Accepted +[Tue Apr 14 12:41:44 2026] 127.0.0.1:34760 Closing +[Tue Apr 14 12:41:44 2026] 127.0.0.1:49038 Accepted +[Tue Apr 14 12:41:46 2026] 127.0.0.1:49038 Closing +[Tue Apr 14 12:41:51 2026] 127.0.0.1:57018 Accepted +[Tue Apr 14 12:41:54 2026] 127.0.0.1:57018 Closing +[Tue Apr 14 12:41:58 2026] 127.0.0.1:57022 Accepted +[Tue Apr 14 12:42:01 2026] 127.0.0.1:57022 Closing +[Tue Apr 14 12:42:01 2026] 127.0.0.1:53176 Accepted +[Tue Apr 14 12:42:04 2026] 127.0.0.1:53176 Closing +[Tue Apr 14 12:42:09 2026] 127.0.0.1:53180 Accepted +[Tue Apr 14 12:42:16 2026] 127.0.0.1:53180 Closing +[Tue Apr 14 12:42:20 2026] 127.0.0.1:37644 Accepted +[Tue Apr 14 12:42:25 2026] 127.0.0.1:37644 Closing +[Tue Apr 14 12:42:25 2026] 127.0.0.1:37652 Accepted +[Tue Apr 14 12:42:33 2026] 127.0.0.1:37652 Closing +[Tue Apr 14 12:42:35 2026] 127.0.0.1:55724 Accepted +[Tue Apr 14 12:42:38 2026] 127.0.0.1:55724 Closing +[Tue Apr 14 12:42:40 2026] 127.0.0.1:43866 Accepted +[Tue Apr 14 12:42:46 2026] 127.0.0.1:43866 Closing +[Tue Apr 14 12:42:50 2026] 127.0.0.1:43808 Accepted +[Tue Apr 14 12:42:56 2026] 127.0.0.1:43808 Closing +[Tue Apr 14 12:43:01 2026] 127.0.0.1:44470 Accepted +[Tue Apr 14 12:43:18 2026] 127.0.0.1:44470 Closing +[Tue Apr 14 12:43:21 2026] 127.0.0.1:59976 Accepted +[Tue Apr 14 12:43:37 2026] 127.0.0.1:59976 Closing +[Tue Apr 14 13:13:23 2026] 127.0.0.1:41258 Accepted +[Tue Apr 14 13:13:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:13:24 2026] 127.0.0.1:41258 Closing +[Tue Apr 14 13:13:24 2026] 127.0.0.1:41268 Accepted +[Tue Apr 14 13:13:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:13:25 2026] 127.0.0.1:41268 Closing +[Tue Apr 14 13:13:25 2026] 127.0.0.1:41276 Accepted +[Tue Apr 14 13:13:25 2026] 127.0.0.1:41276 Closing +[Tue Apr 14 13:13:54 2026] 127.0.0.1:54714 Accepted +[Tue Apr 14 13:13:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:13:54 2026] 127.0.0.1:54714 Closing +[Tue Apr 14 13:13:55 2026] 127.0.0.1:54726 Accepted +[Tue Apr 14 13:13:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:13:56 2026] 127.0.0.1:54726 Closing +[Tue Apr 14 13:13:56 2026] 127.0.0.1:54730 Accepted +[Tue Apr 14 13:13:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:13:58 2026] 127.0.0.1:54730 Closing +[Tue Apr 14 13:14:07 2026] 127.0.0.1:38818 Accepted +[Tue Apr 14 13:14:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:14:07 2026] 127.0.0.1:38818 Closing +[Tue Apr 14 13:14:16 2026] 127.0.0.1:46276 Accepted +[Tue Apr 14 13:14:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:14:16 2026] 127.0.0.1:46276 Closing +[Tue Apr 14 13:14:23 2026] 127.0.0.1:41782 Accepted +[Tue Apr 14 13:14:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:14:24 2026] 127.0.0.1:41782 Closing +[Tue Apr 14 13:14:24 2026] 127.0.0.1:41798 Accepted +[Tue Apr 14 13:14:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:14:25 2026] 127.0.0.1:41798 Closing +[Tue Apr 14 13:14:25 2026] 127.0.0.1:41806 Accepted +[Tue Apr 14 13:14:26 2026] 127.0.0.1:41806 Closing +[Tue Apr 14 13:14:26 2026] 127.0.0.1:33484 Accepted +[Tue Apr 14 13:14:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:14:27 2026] 127.0.0.1:33484 Closing +[Tue Apr 14 13:14:35 2026] 127.0.0.1:33496 Accepted +[Tue Apr 14 13:14:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:14:36 2026] 127.0.0.1:33496 Closing +[Tue Apr 14 13:14:45 2026] 127.0.0.1:44360 Accepted +[Tue Apr 14 13:14:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:14:48 2026] 127.0.0.1:44360 Closing +[Tue Apr 14 13:14:48 2026] 127.0.0.1:58996 Accepted +[Tue Apr 14 13:14:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:14:48 2026] 127.0.0.1:58996 Closing +[Tue Apr 14 13:14:48 2026] 127.0.0.1:58998 Accepted +[Tue Apr 14 13:14:49 2026] 127.0.0.1:58998 Closing +[Tue Apr 14 13:14:49 2026] 127.0.0.1:59000 Accepted +[Tue Apr 14 13:14:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:14:50 2026] 127.0.0.1:59000 Closing +[Tue Apr 14 13:14:59 2026] 127.0.0.1:40932 Accepted +[Tue Apr 14 13:14:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:15:01 2026] 127.0.0.1:40932 Closing +[Tue Apr 14 13:15:10 2026] 127.0.0.1:43454 Accepted +[Tue Apr 14 13:15:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:15:11 2026] 127.0.0.1:43454 Closing +[Tue Apr 14 13:15:18 2026] 127.0.0.1:33914 Accepted +[Tue Apr 14 13:15:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:15:20 2026] 127.0.0.1:33914 Closing +[Tue Apr 14 13:15:29 2026] 127.0.0.1:45680 Accepted +[Tue Apr 14 13:15:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:15:37 2026] 127.0.0.1:45680 Closing +[Tue Apr 14 13:15:44 2026] 127.0.0.1:45360 Accepted +[Tue Apr 14 13:15:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:15:45 2026] 127.0.0.1:45360 Closing +[Tue Apr 14 13:15:53 2026] 127.0.0.1:43598 Accepted +[Tue Apr 14 13:15:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:15:54 2026] 127.0.0.1:43598 Closing +[Tue Apr 14 13:16:02 2026] 127.0.0.1:58902 Accepted +[Tue Apr 14 13:16:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:16:03 2026] 127.0.0.1:58902 Closing +[Tue Apr 14 13:16:11 2026] 127.0.0.1:34636 Accepted +[Tue Apr 14 13:16:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:16:12 2026] 127.0.0.1:34636 Closing +[Tue Apr 14 13:16:18 2026] 127.0.0.1:34644 Accepted +[Tue Apr 14 13:16:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:16:19 2026] 127.0.0.1:34644 Closing +[Tue Apr 14 13:16:27 2026] 127.0.0.1:42862 Accepted +[Tue Apr 14 13:16:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:16:28 2026] 127.0.0.1:42862 Closing +[Tue Apr 14 13:16:36 2026] 127.0.0.1:50822 Accepted +[Tue Apr 14 13:16:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:16:36 2026] 127.0.0.1:50822 Closing +[Tue Apr 14 13:16:43 2026] 127.0.0.1:39402 Accepted +[Tue Apr 14 13:16:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:16:44 2026] 127.0.0.1:39402 Closing +[Tue Apr 14 13:16:52 2026] 127.0.0.1:45906 Accepted +[Tue Apr 14 13:16:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:16:53 2026] 127.0.0.1:45906 Closing +[Tue Apr 14 13:17:01 2026] 127.0.0.1:58202 Accepted +[Tue Apr 14 13:17:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:02 2026] 127.0.0.1:58202 Closing +[Tue Apr 14 13:17:10 2026] 127.0.0.1:37568 Accepted +[Tue Apr 14 13:17:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:09 2026] 127.0.0.1:37568 Closing +[Tue Apr 14 13:17:09 2026] 127.0.0.1:37570 Accepted +[Tue Apr 14 13:17:09 2026] 127.0.0.1:37570 Closing +[Tue Apr 14 13:17:10 2026] 127.0.0.1:37576 Accepted +[Tue Apr 14 13:17:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:10 2026] 127.0.0.1:37576 Closing +[Tue Apr 14 13:17:11 2026] 127.0.0.1:37592 Accepted +[Tue Apr 14 13:17:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:11 2026] 127.0.0.1:37592 Closing +[Tue Apr 14 13:17:20 2026] 127.0.0.1:59192 Accepted +[Tue Apr 14 13:17:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:21 2026] 127.0.0.1:59192 Closing +[Tue Apr 14 13:17:25 2026] 127.0.0.1:54510 Accepted +[Tue Apr 14 13:17:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:26 2026] 127.0.0.1:54510 Closing +[Tue Apr 14 13:17:29 2026] 127.0.0.1:54518 Accepted +[Tue Apr 14 13:17:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:30 2026] 127.0.0.1:54518 Closing +[Tue Apr 14 13:17:38 2026] 127.0.0.1:55768 Accepted +[Tue Apr 14 13:17:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:37 2026] 127.0.0.1:55768 Closing +[Tue Apr 14 13:17:37 2026] 127.0.0.1:55784 Accepted +[Tue Apr 14 13:17:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:38 2026] 127.0.0.1:55784 Closing +[Tue Apr 14 13:17:38 2026] 127.0.0.1:55796 Accepted +[Tue Apr 14 13:17:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:38 2026] 127.0.0.1:55796 Closing +[Tue Apr 14 13:17:38 2026] 127.0.0.1:55798 Accepted +[Tue Apr 14 13:17:38 2026] 127.0.0.1:55800 Accepted +[Tue Apr 14 13:17:39 2026] 127.0.0.1:55798 Closing +[Tue Apr 14 13:17:40 2026] 127.0.0.1:55800 Closing +[Tue Apr 14 13:17:40 2026] 127.0.0.1:55804 Accepted +[Tue Apr 14 13:17:41 2026] 127.0.0.1:55804 Closing +[Tue Apr 14 13:17:41 2026] 127.0.0.1:55812 Accepted +[Tue Apr 14 13:17:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:41 2026] 127.0.0.1:55812 Closing +[Tue Apr 14 13:17:48 2026] 127.0.0.1:53310 Accepted +[Tue Apr 14 13:17:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:49 2026] 127.0.0.1:53310 Closing +[Tue Apr 14 13:17:50 2026] 127.0.0.1:53318 Accepted +[Tue Apr 14 13:17:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:50 2026] 127.0.0.1:53318 Closing +[Tue Apr 14 13:17:57 2026] 127.0.0.1:57048 Accepted +[Tue Apr 14 13:17:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:17:58 2026] 127.0.0.1:57048 Closing +[Tue Apr 14 13:17:59 2026] 127.0.0.1:57054 Accepted +[Tue Apr 14 13:17:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:00 2026] 127.0.0.1:57054 Closing +[Tue Apr 14 13:18:00 2026] 127.0.0.1:57056 Accepted +[Tue Apr 14 13:18:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:00 2026] 127.0.0.1:57056 Closing +[Tue Apr 14 13:18:00 2026] 127.0.0.1:57072 Accepted +[Tue Apr 14 13:18:01 2026] 127.0.0.1:57072 Closing +[Tue Apr 14 13:18:01 2026] 127.0.0.1:57078 Accepted +[Tue Apr 14 13:18:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:02 2026] 127.0.0.1:57078 Closing +[Tue Apr 14 13:18:06 2026] 127.0.0.1:39938 Accepted +[Tue Apr 14 13:18:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:07 2026] 127.0.0.1:39938 Closing +[Tue Apr 14 13:18:09 2026] 127.0.0.1:39954 Accepted +[Tue Apr 14 13:18:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:09 2026] 127.0.0.1:39954 Closing +[Tue Apr 14 13:18:17 2026] 127.0.0.1:50950 Accepted +[Tue Apr 14 13:18:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:18 2026] 127.0.0.1:50950 Closing +[Tue Apr 14 13:18:22 2026] 127.0.0.1:50954 Accepted +[Tue Apr 14 13:18:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:22 2026] 127.0.0.1:50954 Closing +[Tue Apr 14 13:18:26 2026] 127.0.0.1:53890 Accepted +[Tue Apr 14 13:18:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:27 2026] 127.0.0.1:53890 Closing +[Tue Apr 14 13:18:35 2026] 127.0.0.1:49068 Accepted +[Tue Apr 14 13:18:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:34 2026] 127.0.0.1:49068 Closing +[Tue Apr 14 13:18:34 2026] 127.0.0.1:49080 Accepted +[Tue Apr 14 13:18:35 2026] 127.0.0.1:49080 Closing +[Tue Apr 14 13:18:42 2026] 127.0.0.1:47640 Accepted +[Tue Apr 14 13:18:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:43 2026] 127.0.0.1:47640 Closing +[Tue Apr 14 13:18:43 2026] 127.0.0.1:47652 Accepted +[Tue Apr 14 13:18:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:44 2026] 127.0.0.1:47652 Closing +[Tue Apr 14 13:18:52 2026] 127.0.0.1:48990 Accepted +[Tue Apr 14 13:18:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:18:53 2026] 127.0.0.1:48990 Closing +[Tue Apr 14 13:18:59 2026] 127.0.0.1:49000 Accepted +[Tue Apr 14 13:18:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:00 2026] 127.0.0.1:49000 Closing +[Tue Apr 14 13:19:01 2026] 127.0.0.1:48680 Accepted +[Tue Apr 14 13:19:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:02 2026] 127.0.0.1:48680 Closing +[Tue Apr 14 13:19:09 2026] 127.0.0.1:59668 Accepted +[Tue Apr 14 13:19:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:09 2026] 127.0.0.1:59668 Closing +[Tue Apr 14 13:19:11 2026] 127.0.0.1:59674 Accepted +[Tue Apr 14 13:19:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:12 2026] 127.0.0.1:59674 Closing +[Tue Apr 14 13:19:18 2026] 127.0.0.1:59690 Accepted +[Tue Apr 14 13:19:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:19 2026] 127.0.0.1:59690 Closing +[Tue Apr 14 13:19:20 2026] 127.0.0.1:57832 Accepted +[Tue Apr 14 13:19:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:21 2026] 127.0.0.1:57832 Closing +[Tue Apr 14 13:19:27 2026] 127.0.0.1:57844 Accepted +[Tue Apr 14 13:19:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:28 2026] 127.0.0.1:57844 Closing +[Tue Apr 14 13:19:31 2026] 127.0.0.1:40506 Accepted +[Tue Apr 14 13:19:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:32 2026] 127.0.0.1:40506 Closing +[Tue Apr 14 13:19:34 2026] 127.0.0.1:40520 Accepted +[Tue Apr 14 13:19:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:35 2026] 127.0.0.1:40520 Closing +[Tue Apr 14 13:19:44 2026] 127.0.0.1:33682 Accepted +[Tue Apr 14 13:19:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:47 2026] 127.0.0.1:33682 Closing +[Tue Apr 14 13:19:47 2026] 127.0.0.1:33688 Accepted +[Tue Apr 14 13:19:48 2026] 127.0.0.1:33688 Closing +[Tue Apr 14 13:19:55 2026] 127.0.0.1:38306 Accepted +[Tue Apr 14 13:19:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:19:57 2026] 127.0.0.1:38306 Closing +[Tue Apr 14 13:19:59 2026] 127.0.0.1:53134 Accepted +[Tue Apr 14 13:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:00 2026] 127.0.0.1:53134 Closing +[Tue Apr 14 13:20:00 2026] 127.0.0.1:53138 Accepted +[Tue Apr 14 13:20:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:01 2026] 127.0.0.1:53138 Closing +[Tue Apr 14 13:20:01 2026] 127.0.0.1:53142 Accepted +[Tue Apr 14 13:20:01 2026] 127.0.0.1:53142 Closing +[Tue Apr 14 13:20:01 2026] 127.0.0.1:53156 Accepted +[Tue Apr 14 13:20:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:04 2026] 127.0.0.1:53156 Closing +[Tue Apr 14 13:20:04 2026] 127.0.0.1:53158 Accepted +[Tue Apr 14 13:20:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:06 2026] 127.0.0.1:53158 Closing +[Tue Apr 14 13:20:14 2026] 127.0.0.1:37172 Accepted +[Tue Apr 14 13:20:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:16 2026] 127.0.0.1:37172 Closing +[Tue Apr 14 13:20:17 2026] 127.0.0.1:52962 Accepted +[Tue Apr 14 13:20:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:18 2026] 127.0.0.1:52962 Closing +[Tue Apr 14 13:20:29 2026] 127.0.0.1:57870 Accepted +[Tue Apr 14 13:20:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:29 2026] 127.0.0.1:57870 Closing +[Tue Apr 14 13:20:31 2026] 127.0.0.1:57886 Accepted +[Tue Apr 14 13:20:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:32 2026] 127.0.0.1:57886 Closing +[Tue Apr 14 13:20:38 2026] 127.0.0.1:58578 Accepted +[Tue Apr 14 13:20:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:38 2026] 127.0.0.1:58578 Closing +[Tue Apr 14 13:20:44 2026] 127.0.0.1:60868 Accepted +[Tue Apr 14 13:20:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:45 2026] 127.0.0.1:60868 Closing +[Tue Apr 14 13:20:45 2026] 127.0.0.1:60884 Accepted +[Tue Apr 14 13:20:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:46 2026] 127.0.0.1:60884 Closing +[Tue Apr 14 13:20:46 2026] 127.0.0.1:60896 Accepted +[Tue Apr 14 13:20:47 2026] 127.0.0.1:60896 Closing +[Tue Apr 14 13:20:55 2026] 127.0.0.1:42512 Accepted +[Tue Apr 14 13:20:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:20:55 2026] 127.0.0.1:42512 Closing +[Tue Apr 14 13:21:02 2026] 127.0.0.1:35500 Accepted +[Tue Apr 14 13:21:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:21:03 2026] 127.0.0.1:35500 Closing +[Tue Apr 14 13:21:11 2026] 127.0.0.1:35504 Accepted +[Tue Apr 14 13:21:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:21:12 2026] 127.0.0.1:35504 Closing +[Tue Apr 14 13:21:27 2026] 127.0.0.1:41986 Accepted +[Tue Apr 14 13:21:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:21:27 2026] 127.0.0.1:41986 Closing +[Tue Apr 14 13:21:39 2026] 127.0.0.1:49552 Accepted +[Tue Apr 14 13:21:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:21:40 2026] 127.0.0.1:49552 Closing +[Tue Apr 14 13:21:48 2026] 127.0.0.1:35568 Accepted +[Tue Apr 14 13:21:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:21:50 2026] 127.0.0.1:35568 Closing +[Tue Apr 14 13:22:04 2026] 127.0.0.1:35798 Accepted +[Tue Apr 14 13:22:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:22:05 2026] 127.0.0.1:35798 Closing +[Tue Apr 14 13:22:17 2026] 127.0.0.1:58972 Accepted +[Tue Apr 14 13:22:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:22:18 2026] 127.0.0.1:58972 Closing +[Tue Apr 14 13:22:25 2026] 127.0.0.1:56172 Accepted +[Tue Apr 14 13:22:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:22:25 2026] 127.0.0.1:56172 Closing +[Tue Apr 14 13:22:38 2026] 127.0.0.1:54396 Accepted +[Tue Apr 14 13:22:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:22:38 2026] 127.0.0.1:54396 Closing +[Tue Apr 14 13:22:49 2026] 127.0.0.1:45158 Accepted +[Tue Apr 14 13:22:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:22:49 2026] 127.0.0.1:45158 Closing +[Tue Apr 14 13:23:02 2026] 127.0.0.1:51016 Accepted +[Tue Apr 14 13:23:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:23:02 2026] 127.0.0.1:51016 Closing +[Tue Apr 14 13:23:02 2026] 127.0.0.1:51030 Accepted +[Tue Apr 14 13:23:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:23:03 2026] 127.0.0.1:51030 Closing +[Tue Apr 14 13:23:03 2026] 127.0.0.1:51042 Accepted +[Tue Apr 14 13:23:04 2026] 127.0.0.1:51042 Closing +[Tue Apr 14 13:23:04 2026] 127.0.0.1:51052 Accepted +[Tue Apr 14 13:23:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:23:04 2026] 127.0.0.1:51052 Closing +[Tue Apr 14 13:23:16 2026] 127.0.0.1:37462 Accepted +[Tue Apr 14 13:23:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:23:17 2026] 127.0.0.1:37462 Closing +[Tue Apr 14 13:23:28 2026] 127.0.0.1:49862 Accepted +[Tue Apr 14 13:23:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:23:28 2026] 127.0.0.1:49862 Closing +[Tue Apr 14 13:23:36 2026] 127.0.0.1:34096 Accepted +[Tue Apr 14 13:23:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:23:37 2026] 127.0.0.1:34096 Closing +[Tue Apr 14 13:24:03 2026] 127.0.0.1:54558 Accepted +[Tue Apr 14 13:24:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:04 2026] 127.0.0.1:54558 Closing +[Tue Apr 14 13:24:05 2026] 127.0.0.1:54562 Accepted +[Tue Apr 14 13:24:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:05 2026] 127.0.0.1:54562 Closing +[Tue Apr 14 13:24:05 2026] 127.0.0.1:54576 Accepted +[Tue Apr 14 13:24:07 2026] 127.0.0.1:54576 Closing +[Tue Apr 14 13:24:07 2026] 127.0.0.1:54584 Accepted +[Tue Apr 14 13:24:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:08 2026] 127.0.0.1:54584 Closing +[Tue Apr 14 13:24:08 2026] 127.0.0.1:54598 Accepted +[Tue Apr 14 13:24:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:09 2026] 127.0.0.1:54598 Closing +[Tue Apr 14 13:24:09 2026] 127.0.0.1:54610 Accepted +[Tue Apr 14 13:24:09 2026] 127.0.0.1:54610 Closing +[Tue Apr 14 13:24:09 2026] 127.0.0.1:54624 Accepted +[Tue Apr 14 13:24:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:10 2026] 127.0.0.1:54624 Closing +[Tue Apr 14 13:24:10 2026] 127.0.0.1:54632 Accepted +[Tue Apr 14 13:24:12 2026] 127.0.0.1:54632 Closing +[Tue Apr 14 13:24:12 2026] 127.0.0.1:54644 Accepted +[Tue Apr 14 13:24:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:12 2026] 127.0.0.1:54644 Closing +[Tue Apr 14 13:24:12 2026] 127.0.0.1:40386 Accepted +[Tue Apr 14 13:24:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:13 2026] 127.0.0.1:40386 Closing +[Tue Apr 14 13:24:13 2026] 127.0.0.1:40398 Accepted +[Tue Apr 14 13:24:14 2026] 127.0.0.1:40398 Closing +[Tue Apr 14 13:24:14 2026] 127.0.0.1:40412 Accepted +[Tue Apr 14 13:24:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:14 2026] 127.0.0.1:40412 Closing +[Tue Apr 14 13:24:14 2026] 127.0.0.1:40422 Accepted +[Tue Apr 14 13:24:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:15 2026] 127.0.0.1:40422 Closing +[Tue Apr 14 13:24:15 2026] 127.0.0.1:40438 Accepted +[Tue Apr 14 13:24:14 2026] 127.0.0.1:40438 Closing +[Tue Apr 14 13:24:14 2026] 127.0.0.1:40444 Accepted +[Tue Apr 14 13:24:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:14 2026] 127.0.0.1:40444 Closing +[Tue Apr 14 13:24:15 2026] 127.0.0.1:40460 Accepted +[Tue Apr 14 13:24:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:15 2026] 127.0.0.1:40460 Closing +[Tue Apr 14 13:24:15 2026] 127.0.0.1:40464 Accepted +[Tue Apr 14 13:24:16 2026] 127.0.0.1:40464 Closing +[Tue Apr 14 13:24:16 2026] 127.0.0.1:40470 Accepted +[Tue Apr 14 13:24:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:17 2026] 127.0.0.1:40470 Closing +[Tue Apr 14 13:24:17 2026] 127.0.0.1:40484 Accepted +[Tue Apr 14 13:24:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:17 2026] 127.0.0.1:40484 Closing +[Tue Apr 14 13:24:17 2026] 127.0.0.1:40498 Accepted +[Tue Apr 14 13:24:18 2026] 127.0.0.1:40498 Closing +[Tue Apr 14 13:24:18 2026] 127.0.0.1:40504 Accepted +[Tue Apr 14 13:24:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:19 2026] 127.0.0.1:40504 Closing +[Tue Apr 14 13:24:19 2026] 127.0.0.1:40514 Accepted +[Tue Apr 14 13:24:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:24:19 2026] 127.0.0.1:40514 Closing +[Tue Apr 14 13:24:19 2026] 127.0.0.1:40526 Accepted +[Tue Apr 14 13:24:20 2026] 127.0.0.1:40526 Closing +[Tue Apr 14 13:33:21 2026] 127.0.0.1:38336 Accepted +[Tue Apr 14 13:33:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:22 2026] 127.0.0.1:38336 Closing +[Tue Apr 14 13:33:23 2026] 127.0.0.1:38348 Accepted +[Tue Apr 14 13:33:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:24 2026] 127.0.0.1:38348 Closing +[Tue Apr 14 13:33:25 2026] 127.0.0.1:38358 Accepted +[Tue Apr 14 13:33:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:25 2026] 127.0.0.1:38358 Closing +[Tue Apr 14 13:33:26 2026] 127.0.0.1:38366 Accepted +[Tue Apr 14 13:33:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:26 2026] 127.0.0.1:38366 Closing +[Tue Apr 14 13:33:27 2026] 127.0.0.1:38378 Accepted +[Tue Apr 14 13:33:29 2026] 127.0.0.1:38378 Closing +[Tue Apr 14 13:33:30 2026] 127.0.0.1:53900 Accepted +[Tue Apr 14 13:33:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:31 2026] 127.0.0.1:53900 Closing +[Tue Apr 14 13:33:31 2026] 127.0.0.1:53908 Accepted +[Tue Apr 14 13:33:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:32 2026] 127.0.0.1:53908 Closing +[Tue Apr 14 13:33:32 2026] 127.0.0.1:53912 Accepted +[Tue Apr 14 13:33:33 2026] 127.0.0.1:53912 Closing +[Tue Apr 14 13:33:33 2026] 127.0.0.1:53922 Accepted +[Tue Apr 14 13:33:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:33 2026] 127.0.0.1:53922 Closing +[Tue Apr 14 13:33:33 2026] 127.0.0.1:53932 Accepted +[Tue Apr 14 13:33:35 2026] 127.0.0.1:53932 Closing +[Tue Apr 14 13:33:36 2026] 127.0.0.1:53940 Accepted +[Tue Apr 14 13:33:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:37 2026] 127.0.0.1:53940 Closing +[Tue Apr 14 13:33:37 2026] 127.0.0.1:53956 Accepted +[Tue Apr 14 13:33:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:38 2026] 127.0.0.1:53956 Closing +[Tue Apr 14 13:33:38 2026] 127.0.0.1:53966 Accepted +[Tue Apr 14 13:33:39 2026] 127.0.0.1:53966 Closing +[Tue Apr 14 13:33:41 2026] 127.0.0.1:44786 Accepted +[Tue Apr 14 13:33:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:41 2026] 127.0.0.1:44786 Closing +[Tue Apr 14 13:33:41 2026] 127.0.0.1:44790 Accepted +[Tue Apr 14 13:33:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:42 2026] 127.0.0.1:44790 Closing +[Tue Apr 14 13:33:42 2026] 127.0.0.1:44794 Accepted +[Tue Apr 14 13:33:41 2026] 127.0.0.1:44794 Closing +[Tue Apr 14 13:33:41 2026] 127.0.0.1:44796 Accepted +[Tue Apr 14 13:33:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:42 2026] 127.0.0.1:44796 Closing +[Tue Apr 14 13:33:43 2026] 127.0.0.1:44812 Accepted +[Tue Apr 14 13:33:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:43 2026] 127.0.0.1:44812 Closing +[Tue Apr 14 13:33:43 2026] 127.0.0.1:44816 Accepted +[Tue Apr 14 13:33:44 2026] 127.0.0.1:44816 Closing +[Tue Apr 14 13:33:44 2026] 127.0.0.1:44822 Accepted +[Tue Apr 14 13:33:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:45 2026] 127.0.0.1:44822 Closing +[Tue Apr 14 13:33:45 2026] 127.0.0.1:44836 Accepted +[Tue Apr 14 13:33:46 2026] 127.0.0.1:44836 Closing +[Tue Apr 14 13:33:47 2026] 127.0.0.1:33488 Accepted +[Tue Apr 14 13:33:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:48 2026] 127.0.0.1:33488 Closing +[Tue Apr 14 13:33:48 2026] 127.0.0.1:33500 Accepted +[Tue Apr 14 13:33:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:49 2026] 127.0.0.1:33500 Closing +[Tue Apr 14 13:33:49 2026] 127.0.0.1:33506 Accepted +[Tue Apr 14 13:33:53 2026] 127.0.0.1:33506 Closing +[Tue Apr 14 13:33:53 2026] 127.0.0.1:33512 Accepted +[Tue Apr 14 13:33:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:54 2026] 127.0.0.1:33512 Closing +[Tue Apr 14 13:33:54 2026] 127.0.0.1:33526 Accepted +[Tue Apr 14 13:33:55 2026] 127.0.0.1:33526 Closing +[Tue Apr 14 13:33:55 2026] 127.0.0.1:33530 Accepted +[Tue Apr 14 13:33:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:55 2026] 127.0.0.1:33530 Closing +[Tue Apr 14 13:33:55 2026] 127.0.0.1:33546 Accepted +[Tue Apr 14 13:33:56 2026] 127.0.0.1:33546 Closing +[Tue Apr 14 13:33:58 2026] 127.0.0.1:43038 Accepted +[Tue Apr 14 13:33:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:58 2026] 127.0.0.1:43038 Closing +[Tue Apr 14 13:33:58 2026] 127.0.0.1:43042 Accepted +[Tue Apr 14 13:33:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:33:59 2026] 127.0.0.1:43042 Closing +[Tue Apr 14 13:33:59 2026] 127.0.0.1:43044 Accepted +[Tue Apr 14 13:34:00 2026] 127.0.0.1:43044 Closing +[Tue Apr 14 13:34:00 2026] 127.0.0.1:43046 Accepted +[Tue Apr 14 13:34:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:01 2026] 127.0.0.1:43046 Closing +[Tue Apr 14 13:34:02 2026] 127.0.0.1:43056 Accepted +[Tue Apr 14 13:34:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:03 2026] 127.0.0.1:43056 Closing +[Tue Apr 14 13:34:03 2026] 127.0.0.1:43062 Accepted +[Tue Apr 14 13:34:07 2026] 127.0.0.1:43062 Closing +[Tue Apr 14 13:34:09 2026] 127.0.0.1:42256 Accepted +[Tue Apr 14 13:34:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:10 2026] 127.0.0.1:42256 Closing +[Tue Apr 14 13:34:09 2026] 127.0.0.1:42264 Accepted +[Tue Apr 14 13:34:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:09 2026] 127.0.0.1:42264 Closing +[Tue Apr 14 13:34:09 2026] 127.0.0.1:42270 Accepted +[Tue Apr 14 13:34:13 2026] 127.0.0.1:42270 Closing +[Tue Apr 14 13:34:15 2026] 127.0.0.1:42276 Accepted +[Tue Apr 14 13:34:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:16 2026] 127.0.0.1:42276 Closing +[Tue Apr 14 13:34:17 2026] 127.0.0.1:53222 Accepted +[Tue Apr 14 13:34:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:17 2026] 127.0.0.1:53222 Closing +[Tue Apr 14 13:34:17 2026] 127.0.0.1:53226 Accepted +[Tue Apr 14 13:34:21 2026] 127.0.0.1:53226 Closing +[Tue Apr 14 13:34:23 2026] 127.0.0.1:53228 Accepted +[Tue Apr 14 13:34:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:24 2026] 127.0.0.1:53228 Closing +[Tue Apr 14 13:34:25 2026] 127.0.0.1:53230 Accepted +[Tue Apr 14 13:34:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:26 2026] 127.0.0.1:53230 Closing +[Tue Apr 14 13:34:26 2026] 127.0.0.1:54460 Accepted +[Tue Apr 14 13:34:31 2026] 127.0.0.1:54460 Closing +[Tue Apr 14 13:34:31 2026] 127.0.0.1:54476 Accepted +[Tue Apr 14 13:34:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:32 2026] 127.0.0.1:54476 Closing +[Tue Apr 14 13:34:32 2026] 127.0.0.1:54486 Accepted +[Tue Apr 14 13:34:35 2026] 127.0.0.1:54486 Closing +[Tue Apr 14 13:34:37 2026] 127.0.0.1:48736 Accepted +[Tue Apr 14 13:34:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:37 2026] 127.0.0.1:48736 Closing +[Tue Apr 14 13:34:38 2026] 127.0.0.1:48740 Accepted +[Tue Apr 14 13:34:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:37 2026] 127.0.0.1:48740 Closing +[Tue Apr 14 13:34:37 2026] 127.0.0.1:48750 Accepted +[Tue Apr 14 13:34:41 2026] 127.0.0.1:48750 Closing +[Tue Apr 14 13:34:41 2026] 127.0.0.1:48754 Accepted +[Tue Apr 14 13:34:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:42 2026] 127.0.0.1:48754 Closing +[Tue Apr 14 13:34:42 2026] 127.0.0.1:48768 Accepted +[Tue Apr 14 13:34:49 2026] 127.0.0.1:48768 Closing +[Tue Apr 14 13:34:51 2026] 127.0.0.1:53490 Accepted +[Tue Apr 14 13:34:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:52 2026] 127.0.0.1:53490 Closing +[Tue Apr 14 13:34:54 2026] 127.0.0.1:39346 Accepted +[Tue Apr 14 13:34:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:34:55 2026] 127.0.0.1:39346 Closing +[Tue Apr 14 13:34:55 2026] 127.0.0.1:39352 Accepted +[Tue Apr 14 13:35:07 2026] 127.0.0.1:39352 Closing +[Tue Apr 14 13:35:10 2026] 127.0.0.1:33910 Accepted +[Tue Apr 14 13:35:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:11 2026] 127.0.0.1:33910 Closing +[Tue Apr 14 13:35:12 2026] 127.0.0.1:43812 Accepted +[Tue Apr 14 13:35:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:12 2026] 127.0.0.1:43812 Closing +[Tue Apr 14 13:35:12 2026] 127.0.0.1:43816 Accepted +[Tue Apr 14 13:35:13 2026] 127.0.0.1:43816 Closing +[Tue Apr 14 13:35:15 2026] 127.0.0.1:43826 Accepted +[Tue Apr 14 13:35:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:16 2026] 127.0.0.1:43826 Closing +[Tue Apr 14 13:35:17 2026] 127.0.0.1:43836 Accepted +[Tue Apr 14 13:35:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:17 2026] 127.0.0.1:43836 Closing +[Tue Apr 14 13:35:17 2026] 127.0.0.1:43850 Accepted +[Tue Apr 14 13:35:22 2026] 127.0.0.1:43850 Closing +[Tue Apr 14 13:35:22 2026] 127.0.0.1:50868 Accepted +[Tue Apr 14 13:35:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:23 2026] 127.0.0.1:50868 Closing +[Tue Apr 14 13:35:23 2026] 127.0.0.1:50876 Accepted +[Tue Apr 14 13:35:28 2026] 127.0.0.1:50876 Closing +[Tue Apr 14 13:35:30 2026] 127.0.0.1:50890 Accepted +[Tue Apr 14 13:35:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:31 2026] 127.0.0.1:50890 Closing +[Tue Apr 14 13:35:31 2026] 127.0.0.1:50892 Accepted +[Tue Apr 14 13:35:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:32 2026] 127.0.0.1:50892 Closing +[Tue Apr 14 13:35:32 2026] 127.0.0.1:51596 Accepted +[Tue Apr 14 13:35:35 2026] 127.0.0.1:51596 Closing +[Tue Apr 14 13:35:35 2026] 127.0.0.1:51598 Accepted +[Tue Apr 14 13:35:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:35 2026] 127.0.0.1:51598 Closing +[Tue Apr 14 13:35:36 2026] 127.0.0.1:51608 Accepted +[Tue Apr 14 13:35:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:37 2026] 127.0.0.1:51608 Closing +[Tue Apr 14 13:35:37 2026] 127.0.0.1:51620 Accepted +[Tue Apr 14 13:35:40 2026] 127.0.0.1:51620 Closing +[Tue Apr 14 13:35:42 2026] 127.0.0.1:38616 Accepted +[Tue Apr 14 13:35:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:43 2026] 127.0.0.1:38616 Closing +[Tue Apr 14 13:35:43 2026] 127.0.0.1:38622 Accepted +[Tue Apr 14 13:35:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:44 2026] 127.0.0.1:38622 Closing +[Tue Apr 14 13:35:44 2026] 127.0.0.1:38628 Accepted +[Tue Apr 14 13:35:48 2026] 127.0.0.1:38628 Closing +[Tue Apr 14 13:35:48 2026] 127.0.0.1:38644 Accepted +[Tue Apr 14 13:35:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:49 2026] 127.0.0.1:38644 Closing +[Tue Apr 14 13:35:49 2026] 127.0.0.1:38658 Accepted +[Tue Apr 14 13:35:52 2026] 127.0.0.1:38658 Closing +[Tue Apr 14 13:35:54 2026] 127.0.0.1:33608 Accepted +[Tue Apr 14 13:35:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:55 2026] 127.0.0.1:33608 Closing +[Tue Apr 14 13:35:56 2026] 127.0.0.1:33620 Accepted +[Tue Apr 14 13:35:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:35:56 2026] 127.0.0.1:33620 Closing +[Tue Apr 14 13:35:56 2026] 127.0.0.1:33622 Accepted +[Tue Apr 14 13:36:00 2026] 127.0.0.1:33622 Closing +[Tue Apr 14 13:36:02 2026] 127.0.0.1:47520 Accepted +[Tue Apr 14 13:36:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:02 2026] 127.0.0.1:47520 Closing +[Tue Apr 14 13:36:03 2026] 127.0.0.1:47526 Accepted +[Tue Apr 14 13:36:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:02 2026] 127.0.0.1:47526 Closing +[Tue Apr 14 13:36:02 2026] 127.0.0.1:47530 Accepted +[Tue Apr 14 13:36:05 2026] 127.0.0.1:47530 Closing +[Tue Apr 14 13:36:06 2026] 127.0.0.1:47546 Accepted +[Tue Apr 14 13:36:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:06 2026] 127.0.0.1:47546 Closing +[Tue Apr 14 13:36:06 2026] 127.0.0.1:47548 Accepted +[Tue Apr 14 13:36:09 2026] 127.0.0.1:47548 Closing +[Tue Apr 14 13:36:11 2026] 127.0.0.1:41366 Accepted +[Tue Apr 14 13:36:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:12 2026] 127.0.0.1:41366 Closing +[Tue Apr 14 13:36:13 2026] 127.0.0.1:41376 Accepted +[Tue Apr 14 13:36:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:14 2026] 127.0.0.1:41376 Closing +[Tue Apr 14 13:36:14 2026] 127.0.0.1:41390 Accepted +[Tue Apr 14 13:36:18 2026] 127.0.0.1:41390 Closing +[Tue Apr 14 13:36:20 2026] 127.0.0.1:58822 Accepted +[Tue Apr 14 13:36:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:21 2026] 127.0.0.1:58822 Closing +[Tue Apr 14 13:36:21 2026] 127.0.0.1:58828 Accepted +[Tue Apr 14 13:36:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:22 2026] 127.0.0.1:58828 Closing +[Tue Apr 14 13:36:22 2026] 127.0.0.1:58844 Accepted +[Tue Apr 14 13:36:25 2026] 127.0.0.1:58844 Closing +[Tue Apr 14 13:36:26 2026] 127.0.0.1:58850 Accepted +[Tue Apr 14 13:36:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:27 2026] 127.0.0.1:58850 Closing +[Tue Apr 14 13:36:27 2026] 127.0.0.1:58866 Accepted +[Tue Apr 14 13:36:30 2026] 127.0.0.1:58866 Closing +[Tue Apr 14 13:36:31 2026] 127.0.0.1:33238 Accepted +[Tue Apr 14 13:36:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:31 2026] 127.0.0.1:33238 Closing +[Tue Apr 14 13:36:34 2026] 127.0.0.1:33240 Accepted +[Tue Apr 14 13:36:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:34 2026] 127.0.0.1:33240 Closing +[Tue Apr 14 13:36:34 2026] 127.0.0.1:33242 Accepted +[Tue Apr 14 13:36:42 2026] 127.0.0.1:33242 Closing +[Tue Apr 14 13:36:47 2026] 127.0.0.1:59684 Accepted +[Tue Apr 14 13:36:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:48 2026] 127.0.0.1:59684 Closing +[Tue Apr 14 13:36:49 2026] 127.0.0.1:59698 Accepted +[Tue Apr 14 13:36:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:36:50 2026] 127.0.0.1:59698 Closing +[Tue Apr 14 13:36:50 2026] 127.0.0.1:59710 Accepted +[Tue Apr 14 13:36:59 2026] 127.0.0.1:59710 Closing +[Tue Apr 14 13:37:01 2026] 127.0.0.1:54122 Accepted +[Tue Apr 14 13:37:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:02 2026] 127.0.0.1:54122 Closing +[Tue Apr 14 13:37:02 2026] 127.0.0.1:54134 Accepted +[Tue Apr 14 13:37:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:03 2026] 127.0.0.1:54134 Closing +[Tue Apr 14 13:37:03 2026] 127.0.0.1:54150 Accepted +[Tue Apr 14 13:37:04 2026] 127.0.0.1:54150 Closing +[Tue Apr 14 13:37:04 2026] 127.0.0.1:54156 Accepted +[Tue Apr 14 13:37:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:05 2026] 127.0.0.1:54156 Closing +[Tue Apr 14 13:37:05 2026] 127.0.0.1:37428 Accepted +[Tue Apr 14 13:37:10 2026] 127.0.0.1:37428 Closing +[Tue Apr 14 13:37:12 2026] 127.0.0.1:37440 Accepted +[Tue Apr 14 13:37:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:13 2026] 127.0.0.1:37440 Closing +[Tue Apr 14 13:37:14 2026] 127.0.0.1:37446 Accepted +[Tue Apr 14 13:37:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:14 2026] 127.0.0.1:37446 Closing +[Tue Apr 14 13:37:14 2026] 127.0.0.1:37460 Accepted +[Tue Apr 14 13:37:24 2026] 127.0.0.1:37460 Closing +[Tue Apr 14 13:37:26 2026] 127.0.0.1:33510 Accepted +[Tue Apr 14 13:37:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:28 2026] 127.0.0.1:33510 Closing +[Tue Apr 14 13:37:27 2026] 127.0.0.1:33522 Accepted +[Tue Apr 14 13:37:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:28 2026] 127.0.0.1:33522 Closing +[Tue Apr 14 13:37:28 2026] 127.0.0.1:33526 Accepted +[Tue Apr 14 13:37:31 2026] 127.0.0.1:33526 Closing +[Tue Apr 14 13:37:31 2026] 127.0.0.1:33530 Accepted +[Tue Apr 14 13:37:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:31 2026] 127.0.0.1:33530 Closing +[Tue Apr 14 13:37:31 2026] 127.0.0.1:33544 Accepted +[Tue Apr 14 13:37:34 2026] 127.0.0.1:33544 Closing +[Tue Apr 14 13:37:34 2026] 127.0.0.1:59288 Accepted +[Tue Apr 14 13:37:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:34 2026] 127.0.0.1:59288 Closing +[Tue Apr 14 13:37:34 2026] 127.0.0.1:59300 Accepted +[Tue Apr 14 13:37:38 2026] 127.0.0.1:59300 Closing +[Tue Apr 14 13:37:38 2026] 127.0.0.1:59308 Accepted +[Tue Apr 14 13:37:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:38 2026] 127.0.0.1:59308 Closing +[Tue Apr 14 13:37:38 2026] 127.0.0.1:59312 Accepted +[Tue Apr 14 13:37:41 2026] 127.0.0.1:59312 Closing +[Tue Apr 14 13:37:43 2026] 127.0.0.1:59314 Accepted +[Tue Apr 14 13:37:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:44 2026] 127.0.0.1:59314 Closing +[Tue Apr 14 13:37:46 2026] 127.0.0.1:56932 Accepted +[Tue Apr 14 13:37:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:47 2026] 127.0.0.1:56932 Closing +[Tue Apr 14 13:37:47 2026] 127.0.0.1:56948 Accepted +[Tue Apr 14 13:37:56 2026] 127.0.0.1:56948 Closing +[Tue Apr 14 13:37:59 2026] 127.0.0.1:48060 Accepted +[Tue Apr 14 13:37:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:37:59 2026] 127.0.0.1:48060 Closing +[Tue Apr 14 13:38:01 2026] 127.0.0.1:48066 Accepted +[Tue Apr 14 13:38:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:02 2026] 127.0.0.1:48066 Closing +[Tue Apr 14 13:38:02 2026] 127.0.0.1:52932 Accepted +[Tue Apr 14 13:38:07 2026] 127.0.0.1:52932 Closing +[Tue Apr 14 13:38:07 2026] 127.0.0.1:52940 Accepted +[Tue Apr 14 13:38:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:08 2026] 127.0.0.1:52940 Closing +[Tue Apr 14 13:38:08 2026] 127.0.0.1:52942 Accepted +[Tue Apr 14 13:38:11 2026] 127.0.0.1:52942 Closing +[Tue Apr 14 13:38:11 2026] 127.0.0.1:52944 Accepted +[Tue Apr 14 13:38:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:12 2026] 127.0.0.1:52944 Closing +[Tue Apr 14 13:38:12 2026] 127.0.0.1:38678 Accepted +[Tue Apr 14 13:38:16 2026] 127.0.0.1:38678 Closing +[Tue Apr 14 13:38:16 2026] 127.0.0.1:38690 Accepted +[Tue Apr 14 13:38:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:16 2026] 127.0.0.1:38690 Closing +[Tue Apr 14 13:38:16 2026] 127.0.0.1:38694 Accepted +[Tue Apr 14 13:38:20 2026] 127.0.0.1:38694 Closing +[Tue Apr 14 13:38:20 2026] 127.0.0.1:38702 Accepted +[Tue Apr 14 13:38:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:21 2026] 127.0.0.1:38702 Closing +[Tue Apr 14 13:38:21 2026] 127.0.0.1:38714 Accepted +[Tue Apr 14 13:38:24 2026] 127.0.0.1:38714 Closing +[Tue Apr 14 13:38:24 2026] 127.0.0.1:59972 Accepted +[Tue Apr 14 13:38:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:25 2026] 127.0.0.1:59972 Closing +[Tue Apr 14 13:38:25 2026] 127.0.0.1:59976 Accepted +[Tue Apr 14 13:38:27 2026] 127.0.0.1:59976 Closing +[Tue Apr 14 13:38:31 2026] 127.0.0.1:35826 Accepted +[Tue Apr 14 13:38:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:31 2026] 127.0.0.1:35826 Closing +[Tue Apr 14 13:38:32 2026] 127.0.0.1:35834 Accepted +[Tue Apr 14 13:38:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:33 2026] 127.0.0.1:35834 Closing +[Tue Apr 14 13:38:33 2026] 127.0.0.1:35840 Accepted +[Tue Apr 14 13:38:36 2026] 127.0.0.1:35840 Closing +[Tue Apr 14 13:38:36 2026] 127.0.0.1:35842 Accepted +[Tue Apr 14 13:38:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:37 2026] 127.0.0.1:35842 Closing +[Tue Apr 14 13:38:37 2026] 127.0.0.1:35858 Accepted +[Tue Apr 14 13:38:39 2026] 127.0.0.1:35858 Closing +[Tue Apr 14 13:38:41 2026] 127.0.0.1:51380 Accepted +[Tue Apr 14 13:38:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:42 2026] 127.0.0.1:51380 Closing +[Tue Apr 14 13:38:44 2026] 127.0.0.1:51388 Accepted +[Tue Apr 14 13:38:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:44 2026] 127.0.0.1:51388 Closing +[Tue Apr 14 13:38:44 2026] 127.0.0.1:51402 Accepted +[Tue Apr 14 13:38:53 2026] 127.0.0.1:51402 Closing +[Tue Apr 14 13:38:56 2026] 127.0.0.1:59706 Accepted +[Tue Apr 14 13:38:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:56 2026] 127.0.0.1:59706 Closing +[Tue Apr 14 13:38:58 2026] 127.0.0.1:59712 Accepted +[Tue Apr 14 13:38:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:38:59 2026] 127.0.0.1:59712 Closing +[Tue Apr 14 13:38:59 2026] 127.0.0.1:53662 Accepted +[Tue Apr 14 13:39:04 2026] 127.0.0.1:53662 Closing +[Tue Apr 14 13:39:04 2026] 127.0.0.1:53678 Accepted +[Tue Apr 14 13:39:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:04 2026] 127.0.0.1:53678 Closing +[Tue Apr 14 13:39:04 2026] 127.0.0.1:53684 Accepted +[Tue Apr 14 13:39:08 2026] 127.0.0.1:53684 Closing +[Tue Apr 14 13:39:08 2026] 127.0.0.1:53692 Accepted +[Tue Apr 14 13:39:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:09 2026] 127.0.0.1:53692 Closing +[Tue Apr 14 13:39:09 2026] 127.0.0.1:37256 Accepted +[Tue Apr 14 13:39:12 2026] 127.0.0.1:37256 Closing +[Tue Apr 14 13:39:12 2026] 127.0.0.1:37258 Accepted +[Tue Apr 14 13:39:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:13 2026] 127.0.0.1:37258 Closing +[Tue Apr 14 13:39:13 2026] 127.0.0.1:37264 Accepted +[Tue Apr 14 13:39:16 2026] 127.0.0.1:37264 Closing +[Tue Apr 14 13:39:16 2026] 127.0.0.1:37274 Accepted +[Tue Apr 14 13:39:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:17 2026] 127.0.0.1:37274 Closing +[Tue Apr 14 13:39:17 2026] 127.0.0.1:37282 Accepted +[Tue Apr 14 13:39:21 2026] 127.0.0.1:37282 Closing +[Tue Apr 14 13:39:21 2026] 127.0.0.1:43062 Accepted +[Tue Apr 14 13:39:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:21 2026] 127.0.0.1:43062 Closing +[Tue Apr 14 13:39:21 2026] 127.0.0.1:43074 Accepted +[Tue Apr 14 13:39:24 2026] 127.0.0.1:43074 Closing +[Tue Apr 14 13:39:27 2026] 127.0.0.1:56630 Accepted +[Tue Apr 14 13:39:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:28 2026] 127.0.0.1:56630 Closing +[Tue Apr 14 13:39:29 2026] 127.0.0.1:56632 Accepted +[Tue Apr 14 13:39:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:29 2026] 127.0.0.1:56632 Closing +[Tue Apr 14 13:39:29 2026] 127.0.0.1:56634 Accepted +[Tue Apr 14 13:39:33 2026] 127.0.0.1:56634 Closing +[Tue Apr 14 13:39:34 2026] 127.0.0.1:56638 Accepted +[Tue Apr 14 13:39:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:35 2026] 127.0.0.1:56638 Closing +[Tue Apr 14 13:39:36 2026] 127.0.0.1:56642 Accepted +[Tue Apr 14 13:39:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:37 2026] 127.0.0.1:56642 Closing +[Tue Apr 14 13:39:37 2026] 127.0.0.1:56596 Accepted +[Tue Apr 14 13:39:40 2026] 127.0.0.1:56596 Closing +[Tue Apr 14 13:39:40 2026] 127.0.0.1:56604 Accepted +[Tue Apr 14 13:39:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:41 2026] 127.0.0.1:56604 Closing +[Tue Apr 14 13:39:41 2026] 127.0.0.1:56610 Accepted +[Tue Apr 14 13:39:43 2026] 127.0.0.1:56610 Closing +[Tue Apr 14 13:39:47 2026] 127.0.0.1:56050 Accepted +[Tue Apr 14 13:39:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:47 2026] 127.0.0.1:56050 Closing +[Tue Apr 14 13:39:49 2026] 127.0.0.1:56060 Accepted +[Tue Apr 14 13:39:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:49 2026] 127.0.0.1:56060 Closing +[Tue Apr 14 13:39:49 2026] 127.0.0.1:56064 Accepted +[Tue Apr 14 13:39:53 2026] 127.0.0.1:56064 Closing +[Tue Apr 14 13:39:56 2026] 127.0.0.1:43542 Accepted +[Tue Apr 14 13:39:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:57 2026] 127.0.0.1:43542 Closing +[Tue Apr 14 13:39:58 2026] 127.0.0.1:43548 Accepted +[Tue Apr 14 13:39:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:39:58 2026] 127.0.0.1:43548 Closing +[Tue Apr 14 13:39:58 2026] 127.0.0.1:43556 Accepted +[Tue Apr 14 13:40:01 2026] 127.0.0.1:43556 Closing +[Tue Apr 14 13:40:01 2026] 127.0.0.1:43558 Accepted +[Tue Apr 14 13:40:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:02 2026] 127.0.0.1:43558 Closing +[Tue Apr 14 13:40:02 2026] 127.0.0.1:43562 Accepted +[Tue Apr 14 13:40:04 2026] 127.0.0.1:43562 Closing +[Tue Apr 14 13:40:06 2026] 127.0.0.1:32984 Accepted +[Tue Apr 14 13:40:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:06 2026] 127.0.0.1:32984 Closing +[Tue Apr 14 13:40:06 2026] 127.0.0.1:32990 Accepted +[Tue Apr 14 13:40:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:07 2026] 127.0.0.1:32990 Closing +[Tue Apr 14 13:40:07 2026] 127.0.0.1:33002 Accepted +[Tue Apr 14 13:40:07 2026] 127.0.0.1:33002 Closing +[Tue Apr 14 13:40:08 2026] 127.0.0.1:33004 Accepted +[Tue Apr 14 13:40:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:08 2026] 127.0.0.1:33004 Closing +[Tue Apr 14 13:40:09 2026] 127.0.0.1:33010 Accepted +[Tue Apr 14 13:40:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:10 2026] 127.0.0.1:33010 Closing +[Tue Apr 14 13:40:10 2026] 127.0.0.1:33022 Accepted +[Tue Apr 14 13:40:14 2026] 127.0.0.1:33022 Closing +[Tue Apr 14 13:40:16 2026] 127.0.0.1:35920 Accepted +[Tue Apr 14 13:40:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:16 2026] 127.0.0.1:35920 Closing +[Tue Apr 14 13:40:17 2026] 127.0.0.1:35932 Accepted +[Tue Apr 14 13:40:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:18 2026] 127.0.0.1:35932 Closing +[Tue Apr 14 13:40:18 2026] 127.0.0.1:35944 Accepted +[Tue Apr 14 13:40:20 2026] 127.0.0.1:35944 Closing +[Tue Apr 14 13:40:23 2026] 127.0.0.1:35946 Accepted +[Tue Apr 14 13:40:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:24 2026] 127.0.0.1:35946 Closing +[Tue Apr 14 13:40:25 2026] 127.0.0.1:52222 Accepted +[Tue Apr 14 13:40:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:26 2026] 127.0.0.1:52222 Closing +[Tue Apr 14 13:40:26 2026] 127.0.0.1:52232 Accepted +[Tue Apr 14 13:40:42 2026] 127.0.0.1:52232 Closing +[Tue Apr 14 13:40:45 2026] 127.0.0.1:33648 Accepted +[Tue Apr 14 13:40:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:46 2026] 127.0.0.1:33648 Closing +[Tue Apr 14 13:40:46 2026] 127.0.0.1:33652 Accepted +[Tue Apr 14 13:40:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:40:46 2026] 127.0.0.1:33652 Closing +[Tue Apr 14 13:40:46 2026] 127.0.0.1:33666 Accepted +[Tue Apr 14 13:41:01 2026] 127.0.0.1:33666 Closing +[Tue Apr 14 13:41:23 2026] 127.0.0.1:37194 Accepted +[Tue Apr 14 13:41:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:41:24 2026] 127.0.0.1:37194 Closing +[Tue Apr 14 13:41:24 2026] 127.0.0.1:37202 Accepted +[Tue Apr 14 13:41:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:41:24 2026] 127.0.0.1:37202 Closing +[Tue Apr 14 13:41:24 2026] 127.0.0.1:37218 Accepted +[Tue Apr 14 13:41:25 2026] 127.0.0.1:37218 Closing +[Tue Apr 14 13:43:31 2026] 127.0.0.1:44060 Accepted +[Tue Apr 14 13:43:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:43:33 2026] 127.0.0.1:44060 Closing +[Tue Apr 14 13:43:33 2026] 127.0.0.1:44072 Accepted +[Tue Apr 14 13:43:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 13:43:34 2026] 127.0.0.1:44072 Closing +[Tue Apr 14 13:43:34 2026] 127.0.0.1:40096 Accepted +[Tue Apr 14 13:43:34 2026] 127.0.0.1:40096 Closing +[Tue Apr 14 14:06:05 2026] 127.0.0.1:39378 Accepted +[Tue Apr 14 14:06:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:06 2026] 127.0.0.1:39378 Closing +[Tue Apr 14 14:06:07 2026] 127.0.0.1:39394 Accepted +[Tue Apr 14 14:06:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:07 2026] 127.0.0.1:39394 Closing +[Tue Apr 14 14:06:07 2026] 127.0.0.1:39396 Accepted +[Tue Apr 14 14:06:09 2026] 127.0.0.1:39396 Closing +[Tue Apr 14 14:06:09 2026] 127.0.0.1:39412 Accepted +[Tue Apr 14 14:06:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:10 2026] 127.0.0.1:39412 Closing +[Tue Apr 14 14:06:10 2026] 127.0.0.1:39424 Accepted +[Tue Apr 14 14:06:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:10 2026] 127.0.0.1:39424 Closing +[Tue Apr 14 14:06:10 2026] 127.0.0.1:39436 Accepted +[Tue Apr 14 14:06:11 2026] 127.0.0.1:39436 Closing +[Tue Apr 14 14:06:11 2026] 127.0.0.1:39448 Accepted +[Tue Apr 14 14:06:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:12 2026] 127.0.0.1:39448 Closing +[Tue Apr 14 14:06:12 2026] 127.0.0.1:39450 Accepted +[Tue Apr 14 14:06:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:12 2026] 127.0.0.1:39450 Closing +[Tue Apr 14 14:06:12 2026] 127.0.0.1:39452 Accepted +[Tue Apr 14 14:06:13 2026] 127.0.0.1:39452 Closing +[Tue Apr 14 14:06:13 2026] 127.0.0.1:39466 Accepted +[Tue Apr 14 14:06:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:14 2026] 127.0.0.1:39466 Closing +[Tue Apr 14 14:06:14 2026] 127.0.0.1:57972 Accepted +[Tue Apr 14 14:06:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:15 2026] 127.0.0.1:57972 Closing +[Tue Apr 14 14:06:15 2026] 127.0.0.1:57976 Accepted +[Tue Apr 14 14:06:16 2026] 127.0.0.1:57976 Closing +[Tue Apr 14 14:06:16 2026] 127.0.0.1:57988 Accepted +[Tue Apr 14 14:06:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:17 2026] 127.0.0.1:57988 Closing +[Tue Apr 14 14:06:17 2026] 127.0.0.1:57992 Accepted +[Tue Apr 14 14:06:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:16 2026] 127.0.0.1:57992 Closing +[Tue Apr 14 14:06:16 2026] 127.0.0.1:58004 Accepted +[Tue Apr 14 14:06:17 2026] 127.0.0.1:58004 Closing +[Tue Apr 14 14:06:17 2026] 127.0.0.1:58018 Accepted +[Tue Apr 14 14:06:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:18 2026] 127.0.0.1:58018 Closing +[Tue Apr 14 14:06:18 2026] 127.0.0.1:58032 Accepted +[Tue Apr 14 14:06:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:18 2026] 127.0.0.1:58032 Closing +[Tue Apr 14 14:06:18 2026] 127.0.0.1:58046 Accepted +[Tue Apr 14 14:06:19 2026] 127.0.0.1:58046 Closing +[Tue Apr 14 14:06:19 2026] 127.0.0.1:58050 Accepted +[Tue Apr 14 14:06:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:20 2026] 127.0.0.1:58050 Closing +[Tue Apr 14 14:06:21 2026] 127.0.0.1:58058 Accepted +[Tue Apr 14 14:06:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:21 2026] 127.0.0.1:58058 Closing +[Tue Apr 14 14:06:21 2026] 127.0.0.1:58070 Accepted +[Tue Apr 14 14:06:23 2026] 127.0.0.1:58070 Closing +[Tue Apr 14 14:06:23 2026] 127.0.0.1:38360 Accepted +[Tue Apr 14 14:06:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:23 2026] 127.0.0.1:38360 Closing +[Tue Apr 14 14:06:23 2026] 127.0.0.1:38370 Accepted +[Tue Apr 14 14:06:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:24 2026] 127.0.0.1:38370 Closing +[Tue Apr 14 14:06:24 2026] 127.0.0.1:38378 Accepted +[Tue Apr 14 14:06:25 2026] 127.0.0.1:38378 Closing +[Tue Apr 14 14:06:26 2026] 127.0.0.1:38380 Accepted +[Tue Apr 14 14:06:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:26 2026] 127.0.0.1:38380 Closing +[Tue Apr 14 14:06:26 2026] 127.0.0.1:38390 Accepted +[Tue Apr 14 14:06:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:27 2026] 127.0.0.1:38390 Closing +[Tue Apr 14 14:06:27 2026] 127.0.0.1:38392 Accepted +[Tue Apr 14 14:06:28 2026] 127.0.0.1:38392 Closing +[Tue Apr 14 14:06:28 2026] 127.0.0.1:38394 Accepted +[Tue Apr 14 14:06:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:29 2026] 127.0.0.1:38394 Closing +[Tue Apr 14 14:06:29 2026] 127.0.0.1:38402 Accepted +[Tue Apr 14 14:06:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:30 2026] 127.0.0.1:38402 Closing +[Tue Apr 14 14:06:30 2026] 127.0.0.1:38406 Accepted +[Tue Apr 14 14:06:31 2026] 127.0.0.1:38406 Closing +[Tue Apr 14 14:06:31 2026] 127.0.0.1:38420 Accepted +[Tue Apr 14 14:06:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:31 2026] 127.0.0.1:38420 Closing +[Tue Apr 14 14:06:31 2026] 127.0.0.1:38432 Accepted +[Tue Apr 14 14:06:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:31 2026] 127.0.0.1:38432 Closing +[Tue Apr 14 14:06:31 2026] 127.0.0.1:38434 Accepted +[Tue Apr 14 14:06:32 2026] 127.0.0.1:38434 Closing +[Tue Apr 14 14:06:32 2026] 127.0.0.1:49332 Accepted +[Tue Apr 14 14:06:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:33 2026] 127.0.0.1:49332 Closing +[Tue Apr 14 14:06:33 2026] 127.0.0.1:49334 Accepted +[Tue Apr 14 14:06:34 2026] 127.0.0.1:49334 Closing +[Tue Apr 14 14:06:34 2026] 127.0.0.1:49350 Accepted +[Tue Apr 14 14:06:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:35 2026] 127.0.0.1:49350 Closing +[Tue Apr 14 14:06:35 2026] 127.0.0.1:49360 Accepted +[Tue Apr 14 14:06:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:36 2026] 127.0.0.1:49360 Closing +[Tue Apr 14 14:06:36 2026] 127.0.0.1:49374 Accepted +[Tue Apr 14 14:06:37 2026] 127.0.0.1:49374 Closing +[Tue Apr 14 14:06:37 2026] 127.0.0.1:49378 Accepted +[Tue Apr 14 14:06:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:38 2026] 127.0.0.1:49378 Closing +[Tue Apr 14 14:06:38 2026] 127.0.0.1:49392 Accepted +[Tue Apr 14 14:06:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:38 2026] 127.0.0.1:49392 Closing +[Tue Apr 14 14:06:38 2026] 127.0.0.1:49394 Accepted +[Tue Apr 14 14:06:40 2026] 127.0.0.1:49394 Closing +[Tue Apr 14 14:06:40 2026] 127.0.0.1:49400 Accepted +[Tue Apr 14 14:06:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:40 2026] 127.0.0.1:49400 Closing +[Tue Apr 14 14:06:41 2026] 127.0.0.1:49410 Accepted +[Tue Apr 14 14:06:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:41 2026] 127.0.0.1:49410 Closing +[Tue Apr 14 14:06:41 2026] 127.0.0.1:49418 Accepted +[Tue Apr 14 14:06:42 2026] 127.0.0.1:49418 Closing +[Tue Apr 14 14:06:42 2026] 127.0.0.1:47588 Accepted +[Tue Apr 14 14:06:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:43 2026] 127.0.0.1:47588 Closing +[Tue Apr 14 14:06:43 2026] 127.0.0.1:47602 Accepted +[Tue Apr 14 14:06:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:44 2026] 127.0.0.1:47602 Closing +[Tue Apr 14 14:06:44 2026] 127.0.0.1:47608 Accepted +[Tue Apr 14 14:06:45 2026] 127.0.0.1:47608 Closing +[Tue Apr 14 14:06:45 2026] 127.0.0.1:47618 Accepted +[Tue Apr 14 14:06:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:44 2026] 127.0.0.1:47618 Closing +[Tue Apr 14 14:06:44 2026] 127.0.0.1:47626 Accepted +[Tue Apr 14 14:06:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:44 2026] 127.0.0.1:47626 Closing +[Tue Apr 14 14:06:44 2026] 127.0.0.1:47642 Accepted +[Tue Apr 14 14:06:45 2026] 127.0.0.1:47642 Closing +[Tue Apr 14 14:06:45 2026] 127.0.0.1:47658 Accepted +[Tue Apr 14 14:06:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:45 2026] 127.0.0.1:47658 Closing +[Tue Apr 14 14:06:46 2026] 127.0.0.1:47660 Accepted +[Tue Apr 14 14:06:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:46 2026] 127.0.0.1:47660 Closing +[Tue Apr 14 14:06:46 2026] 127.0.0.1:47670 Accepted +[Tue Apr 14 14:06:47 2026] 127.0.0.1:47670 Closing +[Tue Apr 14 14:06:47 2026] 127.0.0.1:47674 Accepted +[Tue Apr 14 14:06:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:48 2026] 127.0.0.1:47674 Closing +[Tue Apr 14 14:06:48 2026] 127.0.0.1:47688 Accepted +[Tue Apr 14 14:06:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:48 2026] 127.0.0.1:47688 Closing +[Tue Apr 14 14:06:48 2026] 127.0.0.1:47702 Accepted +[Tue Apr 14 14:06:49 2026] 127.0.0.1:47702 Closing +[Tue Apr 14 14:06:49 2026] 127.0.0.1:47710 Accepted +[Tue Apr 14 14:06:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:50 2026] 127.0.0.1:47710 Closing +[Tue Apr 14 14:06:50 2026] 127.0.0.1:35266 Accepted +[Tue Apr 14 14:06:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:51 2026] 127.0.0.1:35266 Closing +[Tue Apr 14 14:06:51 2026] 127.0.0.1:35278 Accepted +[Tue Apr 14 14:06:51 2026] 127.0.0.1:35278 Closing +[Tue Apr 14 14:06:51 2026] 127.0.0.1:35284 Accepted +[Tue Apr 14 14:06:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:52 2026] 127.0.0.1:35284 Closing +[Tue Apr 14 14:06:52 2026] 127.0.0.1:35294 Accepted +[Tue Apr 14 14:06:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:53 2026] 127.0.0.1:35294 Closing +[Tue Apr 14 14:06:53 2026] 127.0.0.1:35308 Accepted +[Tue Apr 14 14:06:54 2026] 127.0.0.1:35308 Closing +[Tue Apr 14 14:06:54 2026] 127.0.0.1:35318 Accepted +[Tue Apr 14 14:06:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:55 2026] 127.0.0.1:35318 Closing +[Tue Apr 14 14:06:55 2026] 127.0.0.1:35322 Accepted +[Tue Apr 14 14:06:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:56 2026] 127.0.0.1:35322 Closing +[Tue Apr 14 14:06:56 2026] 127.0.0.1:35338 Accepted +[Tue Apr 14 14:06:57 2026] 127.0.0.1:35338 Closing +[Tue Apr 14 14:06:57 2026] 127.0.0.1:35348 Accepted +[Tue Apr 14 14:06:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:57 2026] 127.0.0.1:35348 Closing +[Tue Apr 14 14:06:57 2026] 127.0.0.1:35352 Accepted +[Tue Apr 14 14:06:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:06:58 2026] 127.0.0.1:35352 Closing +[Tue Apr 14 14:06:58 2026] 127.0.0.1:35360 Accepted +[Tue Apr 14 14:06:59 2026] 127.0.0.1:35360 Closing +[Tue Apr 14 14:06:59 2026] 127.0.0.1:35370 Accepted +[Tue Apr 14 14:06:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:07:00 2026] 127.0.0.1:35370 Closing +[Tue Apr 14 14:07:00 2026] 127.0.0.1:35376 Accepted +[Tue Apr 14 14:07:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:07:01 2026] 127.0.0.1:35376 Closing +[Tue Apr 14 14:07:01 2026] 127.0.0.1:37678 Accepted +[Tue Apr 14 14:07:02 2026] 127.0.0.1:37678 Closing +[Tue Apr 14 14:07:02 2026] 127.0.0.1:37682 Accepted +[Tue Apr 14 14:07:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:07:03 2026] 127.0.0.1:37682 Closing +[Tue Apr 14 14:07:03 2026] 127.0.0.1:37684 Accepted +[Tue Apr 14 14:07:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:07:03 2026] 127.0.0.1:37684 Closing +[Tue Apr 14 14:07:03 2026] 127.0.0.1:37690 Accepted +[Tue Apr 14 14:07:04 2026] 127.0.0.1:37690 Closing +[Tue Apr 14 14:07:04 2026] 127.0.0.1:37692 Accepted +[Tue Apr 14 14:07:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:07:05 2026] 127.0.0.1:37692 Closing +[Tue Apr 14 14:07:05 2026] 127.0.0.1:37702 Accepted +[Tue Apr 14 14:07:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:07:05 2026] 127.0.0.1:37702 Closing +[Tue Apr 14 14:07:05 2026] 127.0.0.1:37704 Accepted +[Tue Apr 14 14:07:06 2026] 127.0.0.1:37704 Closing +[Tue Apr 14 14:07:06 2026] 127.0.0.1:37706 Accepted +[Tue Apr 14 14:07:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:07:07 2026] 127.0.0.1:37706 Closing +[Tue Apr 14 14:07:07 2026] 127.0.0.1:37722 Accepted +[Tue Apr 14 14:07:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:07:08 2026] 127.0.0.1:37722 Closing +[Tue Apr 14 14:07:08 2026] 127.0.0.1:37738 Accepted +[Tue Apr 14 14:07:09 2026] 127.0.0.1:37738 Closing +[Tue Apr 14 14:08:08 2026] 127.0.0.1:55508 Accepted +[Tue Apr 14 14:08:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:09 2026] 127.0.0.1:55508 Closing +[Tue Apr 14 14:08:09 2026] 127.0.0.1:55512 Accepted +[Tue Apr 14 14:08:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:10 2026] 127.0.0.1:55512 Closing +[Tue Apr 14 14:08:10 2026] 127.0.0.1:55524 Accepted +[Tue Apr 14 14:08:09 2026] 127.0.0.1:55524 Closing +[Tue Apr 14 14:08:10 2026] 127.0.0.1:55534 Accepted +[Tue Apr 14 14:08:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:10 2026] 127.0.0.1:55534 Closing +[Tue Apr 14 14:08:10 2026] 127.0.0.1:55542 Accepted +[Tue Apr 14 14:08:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:11 2026] 127.0.0.1:55542 Closing +[Tue Apr 14 14:08:11 2026] 127.0.0.1:55556 Accepted +[Tue Apr 14 14:08:12 2026] 127.0.0.1:55556 Closing +[Tue Apr 14 14:08:12 2026] 127.0.0.1:55570 Accepted +[Tue Apr 14 14:08:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:13 2026] 127.0.0.1:55570 Closing +[Tue Apr 14 14:08:13 2026] 127.0.0.1:55586 Accepted +[Tue Apr 14 14:08:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:13 2026] 127.0.0.1:55586 Closing +[Tue Apr 14 14:08:13 2026] 127.0.0.1:55592 Accepted +[Tue Apr 14 14:08:14 2026] 127.0.0.1:55592 Closing +[Tue Apr 14 14:08:14 2026] 127.0.0.1:55608 Accepted +[Tue Apr 14 14:08:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:15 2026] 127.0.0.1:55608 Closing +[Tue Apr 14 14:08:15 2026] 127.0.0.1:46608 Accepted +[Tue Apr 14 14:08:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:16 2026] 127.0.0.1:46608 Closing +[Tue Apr 14 14:08:16 2026] 127.0.0.1:46618 Accepted +[Tue Apr 14 14:08:17 2026] 127.0.0.1:46618 Closing +[Tue Apr 14 14:08:17 2026] 127.0.0.1:46634 Accepted +[Tue Apr 14 14:08:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:17 2026] 127.0.0.1:46634 Closing +[Tue Apr 14 14:08:17 2026] 127.0.0.1:46642 Accepted +[Tue Apr 14 14:08:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:18 2026] 127.0.0.1:46642 Closing +[Tue Apr 14 14:08:18 2026] 127.0.0.1:46658 Accepted +[Tue Apr 14 14:08:19 2026] 127.0.0.1:46658 Closing +[Tue Apr 14 14:08:19 2026] 127.0.0.1:46670 Accepted +[Tue Apr 14 14:08:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:19 2026] 127.0.0.1:46670 Closing +[Tue Apr 14 14:08:19 2026] 127.0.0.1:46672 Accepted +[Tue Apr 14 14:08:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:20 2026] 127.0.0.1:46672 Closing +[Tue Apr 14 14:08:20 2026] 127.0.0.1:46680 Accepted +[Tue Apr 14 14:08:21 2026] 127.0.0.1:46680 Closing +[Tue Apr 14 14:08:21 2026] 127.0.0.1:46682 Accepted +[Tue Apr 14 14:08:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:21 2026] 127.0.0.1:46682 Closing +[Tue Apr 14 14:08:22 2026] 127.0.0.1:46696 Accepted +[Tue Apr 14 14:08:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:23 2026] 127.0.0.1:46696 Closing +[Tue Apr 14 14:08:23 2026] 127.0.0.1:46710 Accepted +[Tue Apr 14 14:08:24 2026] 127.0.0.1:46710 Closing +[Tue Apr 14 14:08:24 2026] 127.0.0.1:46716 Accepted +[Tue Apr 14 14:08:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:24 2026] 127.0.0.1:46716 Closing +[Tue Apr 14 14:08:25 2026] 127.0.0.1:46722 Accepted +[Tue Apr 14 14:08:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:08:25 2026] 127.0.0.1:46722 Closing +[Tue Apr 14 14:08:25 2026] 127.0.0.1:40882 Accepted +[Tue Apr 14 14:08:27 2026] 127.0.0.1:40882 Closing +[Tue Apr 14 14:09:06 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 14 14:09:05 2026] 127.0.0.1:59540 Accepted +[Tue Apr 14 14:09:07 2026] 127.0.0.1:59540 Closing +[Tue Apr 14 14:09:07 2026] 127.0.0.1:59556 Accepted +[Tue Apr 14 14:09:08 2026] 127.0.0.1:59556 Closing +[Tue Apr 14 14:09:08 2026] 127.0.0.1:59564 Accepted +[Tue Apr 14 14:09:09 2026] 127.0.0.1:59564 Closing +[Tue Apr 14 14:09:09 2026] 127.0.0.1:59578 Accepted +[Tue Apr 14 14:09:10 2026] 127.0.0.1:59578 Closing +[Tue Apr 14 14:09:10 2026] 127.0.0.1:59582 Accepted +[Tue Apr 14 14:09:11 2026] 127.0.0.1:59582 Closing +[Tue Apr 14 14:09:11 2026] 127.0.0.1:59592 Accepted +[Tue Apr 14 14:09:12 2026] 127.0.0.1:59592 Closing +[Tue Apr 14 14:09:12 2026] 127.0.0.1:34304 Accepted +[Tue Apr 14 14:09:12 2026] 127.0.0.1:34304 Closing +[Tue Apr 14 14:09:13 2026] 127.0.0.1:34320 Accepted +[Tue Apr 14 14:09:13 2026] 127.0.0.1:34320 Closing +[Tue Apr 14 14:09:13 2026] 127.0.0.1:34330 Accepted +[Tue Apr 14 14:09:14 2026] 127.0.0.1:34330 Closing +[Tue Apr 14 14:09:14 2026] 127.0.0.1:34334 Accepted +[Tue Apr 14 14:09:15 2026] 127.0.0.1:34334 Closing +[Tue Apr 14 14:09:15 2026] 127.0.0.1:34342 Accepted +[Tue Apr 14 14:09:16 2026] 127.0.0.1:34342 Closing +[Tue Apr 14 14:09:16 2026] 127.0.0.1:34358 Accepted +[Tue Apr 14 14:09:17 2026] 127.0.0.1:34358 Closing +[Tue Apr 14 14:09:17 2026] 127.0.0.1:34372 Accepted +[Tue Apr 14 14:09:17 2026] 127.0.0.1:34372 Closing +[Tue Apr 14 14:09:43 2026] 127.0.0.1:36130 Accepted +[Tue Apr 14 14:09:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:43 2026] 127.0.0.1:36130 Closing +[Tue Apr 14 14:09:44 2026] 127.0.0.1:36134 Accepted +[Tue Apr 14 14:09:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:44 2026] 127.0.0.1:36134 Closing +[Tue Apr 14 14:09:44 2026] 127.0.0.1:36138 Accepted +[Tue Apr 14 14:09:45 2026] 127.0.0.1:36138 Closing +[Tue Apr 14 14:09:46 2026] 127.0.0.1:36142 Accepted +[Tue Apr 14 14:09:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:46 2026] 127.0.0.1:36142 Closing +[Tue Apr 14 14:09:47 2026] 127.0.0.1:36152 Accepted +[Tue Apr 14 14:09:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:47 2026] 127.0.0.1:36152 Closing +[Tue Apr 14 14:09:47 2026] 127.0.0.1:36164 Accepted +[Tue Apr 14 14:09:50 2026] 127.0.0.1:36164 Closing +[Tue Apr 14 14:09:50 2026] 127.0.0.1:54764 Accepted +[Tue Apr 14 14:09:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:50 2026] 127.0.0.1:54764 Closing +[Tue Apr 14 14:09:50 2026] 127.0.0.1:54776 Accepted +[Tue Apr 14 14:09:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:51 2026] 127.0.0.1:54776 Closing +[Tue Apr 14 14:09:51 2026] 127.0.0.1:54784 Accepted +[Tue Apr 14 14:09:51 2026] 127.0.0.1:54784 Closing +[Tue Apr 14 14:09:52 2026] 127.0.0.1:54796 Accepted +[Tue Apr 14 14:09:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:52 2026] 127.0.0.1:54796 Closing +[Tue Apr 14 14:09:52 2026] 127.0.0.1:54806 Accepted +[Tue Apr 14 14:09:53 2026] 127.0.0.1:54806 Closing +[Tue Apr 14 14:09:53 2026] 127.0.0.1:54820 Accepted +[Tue Apr 14 14:09:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:54 2026] 127.0.0.1:54820 Closing +[Tue Apr 14 14:09:54 2026] 127.0.0.1:54832 Accepted +[Tue Apr 14 14:09:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:54 2026] 127.0.0.1:54832 Closing +[Tue Apr 14 14:09:54 2026] 127.0.0.1:54840 Accepted +[Tue Apr 14 14:09:55 2026] 127.0.0.1:54840 Closing +[Tue Apr 14 14:09:55 2026] 127.0.0.1:54856 Accepted +[Tue Apr 14 14:09:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:56 2026] 127.0.0.1:54856 Closing +[Tue Apr 14 14:09:56 2026] 127.0.0.1:54868 Accepted +[Tue Apr 14 14:09:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:56 2026] 127.0.0.1:54868 Closing +[Tue Apr 14 14:09:56 2026] 127.0.0.1:54870 Accepted +[Tue Apr 14 14:09:57 2026] 127.0.0.1:54870 Closing +[Tue Apr 14 14:09:57 2026] 127.0.0.1:54880 Accepted +[Tue Apr 14 14:09:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:57 2026] 127.0.0.1:54880 Closing +[Tue Apr 14 14:09:57 2026] 127.0.0.1:54886 Accepted +[Tue Apr 14 14:09:58 2026] 127.0.0.1:54886 Closing +[Tue Apr 14 14:09:58 2026] 127.0.0.1:54892 Accepted +[Tue Apr 14 14:09:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:09:58 2026] 127.0.0.1:54892 Closing +[Tue Apr 14 14:09:58 2026] 127.0.0.1:54906 Accepted +[Tue Apr 14 14:09:59 2026] 127.0.0.1:54906 Closing +[Tue Apr 14 14:10:00 2026] 127.0.0.1:54916 Accepted +[Tue Apr 14 14:10:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:00 2026] 127.0.0.1:54916 Closing +[Tue Apr 14 14:10:00 2026] 127.0.0.1:38566 Accepted +[Tue Apr 14 14:10:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:01 2026] 127.0.0.1:38566 Closing +[Tue Apr 14 14:10:01 2026] 127.0.0.1:38568 Accepted +[Tue Apr 14 14:10:02 2026] 127.0.0.1:38568 Closing +[Tue Apr 14 14:10:02 2026] 127.0.0.1:38574 Accepted +[Tue Apr 14 14:10:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:02 2026] 127.0.0.1:38574 Closing +[Tue Apr 14 14:10:02 2026] 127.0.0.1:38588 Accepted +[Tue Apr 14 14:10:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:03 2026] 127.0.0.1:38588 Closing +[Tue Apr 14 14:10:03 2026] 127.0.0.1:38590 Accepted +[Tue Apr 14 14:10:03 2026] 127.0.0.1:38590 Closing +[Tue Apr 14 14:10:03 2026] 127.0.0.1:38600 Accepted +[Tue Apr 14 14:10:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:02 2026] 127.0.0.1:38600 Closing +[Tue Apr 14 14:10:02 2026] 127.0.0.1:38602 Accepted +[Tue Apr 14 14:10:03 2026] 127.0.0.1:38602 Closing +[Tue Apr 14 14:10:03 2026] 127.0.0.1:38616 Accepted +[Tue Apr 14 14:10:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:03 2026] 127.0.0.1:38616 Closing +[Tue Apr 14 14:10:03 2026] 127.0.0.1:38632 Accepted +[Tue Apr 14 14:10:04 2026] 127.0.0.1:38632 Closing +[Tue Apr 14 14:10:04 2026] 127.0.0.1:38636 Accepted +[Tue Apr 14 14:10:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:04 2026] 127.0.0.1:38636 Closing +[Tue Apr 14 14:10:04 2026] 127.0.0.1:38640 Accepted +[Tue Apr 14 14:10:05 2026] 127.0.0.1:38640 Closing +[Tue Apr 14 14:10:05 2026] 127.0.0.1:38646 Accepted +[Tue Apr 14 14:10:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:06 2026] 127.0.0.1:38646 Closing +[Tue Apr 14 14:10:06 2026] 127.0.0.1:38652 Accepted +[Tue Apr 14 14:10:06 2026] 127.0.0.1:38652 Closing +[Tue Apr 14 14:10:06 2026] 127.0.0.1:38668 Accepted +[Tue Apr 14 14:10:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:07 2026] 127.0.0.1:38668 Closing +[Tue Apr 14 14:10:07 2026] 127.0.0.1:38678 Accepted +[Tue Apr 14 14:10:07 2026] 127.0.0.1:38678 Closing +[Tue Apr 14 14:10:07 2026] 127.0.0.1:38684 Accepted +[Tue Apr 14 14:10:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:08 2026] 127.0.0.1:38684 Closing +[Tue Apr 14 14:10:08 2026] 127.0.0.1:38688 Accepted +[Tue Apr 14 14:10:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:08 2026] 127.0.0.1:38688 Closing +[Tue Apr 14 14:10:08 2026] 127.0.0.1:47864 Accepted +[Tue Apr 14 14:10:09 2026] 127.0.0.1:47864 Closing +[Tue Apr 14 14:10:09 2026] 127.0.0.1:47870 Accepted +[Tue Apr 14 14:10:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:09 2026] 127.0.0.1:47870 Closing +[Tue Apr 14 14:10:09 2026] 127.0.0.1:47876 Accepted +[Tue Apr 14 14:10:11 2026] 127.0.0.1:47876 Closing +[Tue Apr 14 14:10:11 2026] 127.0.0.1:47890 Accepted +[Tue Apr 14 14:10:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:11 2026] 127.0.0.1:47890 Closing +[Tue Apr 14 14:10:11 2026] 127.0.0.1:47904 Accepted +[Tue Apr 14 14:10:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:12 2026] 127.0.0.1:47904 Closing +[Tue Apr 14 14:10:12 2026] 127.0.0.1:47920 Accepted +[Tue Apr 14 14:10:13 2026] 127.0.0.1:47920 Closing +[Tue Apr 14 14:10:13 2026] 127.0.0.1:47924 Accepted +[Tue Apr 14 14:10:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:13 2026] 127.0.0.1:47924 Closing +[Tue Apr 14 14:10:14 2026] 127.0.0.1:47938 Accepted +[Tue Apr 14 14:10:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:14 2026] 127.0.0.1:47938 Closing +[Tue Apr 14 14:10:14 2026] 127.0.0.1:47940 Accepted +[Tue Apr 14 14:10:15 2026] 127.0.0.1:47940 Closing +[Tue Apr 14 14:10:15 2026] 127.0.0.1:47946 Accepted +[Tue Apr 14 14:10:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:15 2026] 127.0.0.1:47946 Closing +[Tue Apr 14 14:10:15 2026] 127.0.0.1:47956 Accepted +[Tue Apr 14 14:10:16 2026] 127.0.0.1:47956 Closing +[Tue Apr 14 14:10:16 2026] 127.0.0.1:47966 Accepted +[Tue Apr 14 14:10:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:16 2026] 127.0.0.1:47966 Closing +[Tue Apr 14 14:10:16 2026] 127.0.0.1:47978 Accepted +[Tue Apr 14 14:10:17 2026] 127.0.0.1:47978 Closing +[Tue Apr 14 14:10:17 2026] 127.0.0.1:47986 Accepted +[Tue Apr 14 14:10:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:17 2026] 127.0.0.1:47986 Closing +[Tue Apr 14 14:10:17 2026] 127.0.0.1:47996 Accepted +[Tue Apr 14 14:10:18 2026] 127.0.0.1:47996 Closing +[Tue Apr 14 14:10:18 2026] 127.0.0.1:49202 Accepted +[Tue Apr 14 14:10:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:19 2026] 127.0.0.1:49202 Closing +[Tue Apr 14 14:10:19 2026] 127.0.0.1:49208 Accepted +[Tue Apr 14 14:10:20 2026] 127.0.0.1:49208 Closing +[Tue Apr 14 14:10:20 2026] 127.0.0.1:49224 Accepted +[Tue Apr 14 14:10:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:20 2026] 127.0.0.1:49224 Closing +[Tue Apr 14 14:10:20 2026] 127.0.0.1:49238 Accepted +[Tue Apr 14 14:10:21 2026] 127.0.0.1:49238 Closing +[Tue Apr 14 14:10:21 2026] 127.0.0.1:49244 Accepted +[Tue Apr 14 14:10:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:21 2026] 127.0.0.1:49244 Closing +[Tue Apr 14 14:10:21 2026] 127.0.0.1:49252 Accepted +[Tue Apr 14 14:10:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:22 2026] 127.0.0.1:49252 Closing +[Tue Apr 14 14:10:22 2026] 127.0.0.1:49256 Accepted +[Tue Apr 14 14:10:22 2026] 127.0.0.1:49256 Closing +[Tue Apr 14 14:10:22 2026] 127.0.0.1:49258 Accepted +[Tue Apr 14 14:10:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:23 2026] 127.0.0.1:49258 Closing +[Tue Apr 14 14:10:23 2026] 127.0.0.1:49268 Accepted +[Tue Apr 14 14:10:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:23 2026] 127.0.0.1:49268 Closing +[Tue Apr 14 14:10:23 2026] 127.0.0.1:49284 Accepted +[Tue Apr 14 14:10:24 2026] 127.0.0.1:49284 Closing +[Tue Apr 14 14:10:24 2026] 127.0.0.1:49290 Accepted +[Tue Apr 14 14:10:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:24 2026] 127.0.0.1:49290 Closing +[Tue Apr 14 14:10:24 2026] 127.0.0.1:49292 Accepted +[Tue Apr 14 14:10:25 2026] 127.0.0.1:49292 Closing +[Tue Apr 14 14:10:25 2026] 127.0.0.1:49308 Accepted +[Tue Apr 14 14:10:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:25 2026] 127.0.0.1:49308 Closing +[Tue Apr 14 14:10:25 2026] 127.0.0.1:49320 Accepted +[Tue Apr 14 14:10:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:26 2026] 127.0.0.1:49320 Closing +[Tue Apr 14 14:10:26 2026] 127.0.0.1:49322 Accepted +[Tue Apr 14 14:10:26 2026] 127.0.0.1:49322 Closing +[Tue Apr 14 14:10:26 2026] 127.0.0.1:49338 Accepted +[Tue Apr 14 14:10:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:27 2026] 127.0.0.1:49338 Closing +[Tue Apr 14 14:10:27 2026] 127.0.0.1:49340 Accepted +[Tue Apr 14 14:10:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:27 2026] 127.0.0.1:49340 Closing +[Tue Apr 14 14:10:27 2026] 127.0.0.1:49352 Accepted +[Tue Apr 14 14:10:28 2026] 127.0.0.1:49352 Closing +[Tue Apr 14 14:10:28 2026] 127.0.0.1:49362 Accepted +[Tue Apr 14 14:10:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:28 2026] 127.0.0.1:49362 Closing +[Tue Apr 14 14:10:28 2026] 127.0.0.1:49378 Accepted +[Tue Apr 14 14:10:29 2026] 127.0.0.1:49378 Closing +[Tue Apr 14 14:10:29 2026] 127.0.0.1:42338 Accepted +[Tue Apr 14 14:10:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:29 2026] 127.0.0.1:42338 Closing +[Tue Apr 14 14:10:29 2026] 127.0.0.1:42346 Accepted +[Tue Apr 14 14:10:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:30 2026] 127.0.0.1:42346 Closing +[Tue Apr 14 14:10:30 2026] 127.0.0.1:42348 Accepted +[Tue Apr 14 14:10:30 2026] 127.0.0.1:42348 Closing +[Tue Apr 14 14:10:30 2026] 127.0.0.1:42350 Accepted +[Tue Apr 14 14:10:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:31 2026] 127.0.0.1:42350 Closing +[Tue Apr 14 14:10:31 2026] 127.0.0.1:42356 Accepted +[Tue Apr 14 14:10:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:31 2026] 127.0.0.1:42356 Closing +[Tue Apr 14 14:10:31 2026] 127.0.0.1:42358 Accepted +[Tue Apr 14 14:10:30 2026] 127.0.0.1:42358 Closing +[Tue Apr 14 14:10:30 2026] 127.0.0.1:42360 Accepted +[Tue Apr 14 14:10:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:31 2026] 127.0.0.1:42360 Closing +[Tue Apr 14 14:10:31 2026] 127.0.0.1:42368 Accepted +[Tue Apr 14 14:10:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:31 2026] 127.0.0.1:42368 Closing +[Tue Apr 14 14:10:31 2026] 127.0.0.1:42374 Accepted +[Tue Apr 14 14:10:32 2026] 127.0.0.1:42374 Closing +[Tue Apr 14 14:10:32 2026] 127.0.0.1:42378 Accepted +[Tue Apr 14 14:10:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:33 2026] 127.0.0.1:42378 Closing +[Tue Apr 14 14:10:33 2026] 127.0.0.1:42392 Accepted +[Tue Apr 14 14:10:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:10:33 2026] 127.0.0.1:42392 Closing +[Tue Apr 14 14:10:33 2026] 127.0.0.1:42400 Accepted +[Tue Apr 14 14:10:34 2026] 127.0.0.1:42400 Closing +[Tue Apr 14 14:11:24 2026] 127.0.0.1:53416 Accepted +[Tue Apr 14 14:11:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:25 2026] 127.0.0.1:53416 Closing +[Tue Apr 14 14:11:26 2026] 127.0.0.1:57288 Accepted +[Tue Apr 14 14:11:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:26 2026] 127.0.0.1:57288 Closing +[Tue Apr 14 14:11:26 2026] 127.0.0.1:57292 Accepted +[Tue Apr 14 14:11:27 2026] 127.0.0.1:57292 Closing +[Tue Apr 14 14:11:28 2026] 127.0.0.1:57298 Accepted +[Tue Apr 14 14:11:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:28 2026] 127.0.0.1:57298 Closing +[Tue Apr 14 14:11:28 2026] 127.0.0.1:57314 Accepted +[Tue Apr 14 14:11:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:27 2026] 127.0.0.1:57314 Closing +[Tue Apr 14 14:11:27 2026] 127.0.0.1:57330 Accepted +[Tue Apr 14 14:11:29 2026] 127.0.0.1:57330 Closing +[Tue Apr 14 14:11:29 2026] 127.0.0.1:57342 Accepted +[Tue Apr 14 14:11:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:30 2026] 127.0.0.1:57342 Closing +[Tue Apr 14 14:11:30 2026] 127.0.0.1:57356 Accepted +[Tue Apr 14 14:11:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:30 2026] 127.0.0.1:57356 Closing +[Tue Apr 14 14:11:30 2026] 127.0.0.1:57368 Accepted +[Tue Apr 14 14:11:31 2026] 127.0.0.1:57368 Closing +[Tue Apr 14 14:11:31 2026] 127.0.0.1:57380 Accepted +[Tue Apr 14 14:11:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:31 2026] 127.0.0.1:57380 Closing +[Tue Apr 14 14:11:31 2026] 127.0.0.1:57394 Accepted +[Tue Apr 14 14:11:32 2026] 127.0.0.1:57394 Closing +[Tue Apr 14 14:11:32 2026] 127.0.0.1:57410 Accepted +[Tue Apr 14 14:11:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:33 2026] 127.0.0.1:57410 Closing +[Tue Apr 14 14:11:33 2026] 127.0.0.1:57412 Accepted +[Tue Apr 14 14:11:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:33 2026] 127.0.0.1:57412 Closing +[Tue Apr 14 14:11:33 2026] 127.0.0.1:53070 Accepted +[Tue Apr 14 14:11:34 2026] 127.0.0.1:53070 Closing +[Tue Apr 14 14:11:34 2026] 127.0.0.1:53076 Accepted +[Tue Apr 14 14:11:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:34 2026] 127.0.0.1:53076 Closing +[Tue Apr 14 14:11:35 2026] 127.0.0.1:53078 Accepted +[Tue Apr 14 14:11:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:35 2026] 127.0.0.1:53078 Closing +[Tue Apr 14 14:11:35 2026] 127.0.0.1:53080 Accepted +[Tue Apr 14 14:11:36 2026] 127.0.0.1:53080 Closing +[Tue Apr 14 14:11:36 2026] 127.0.0.1:53082 Accepted +[Tue Apr 14 14:11:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:36 2026] 127.0.0.1:53082 Closing +[Tue Apr 14 14:11:36 2026] 127.0.0.1:53092 Accepted +[Tue Apr 14 14:11:37 2026] 127.0.0.1:53092 Closing +[Tue Apr 14 14:11:37 2026] 127.0.0.1:53108 Accepted +[Tue Apr 14 14:11:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:38 2026] 127.0.0.1:53108 Closing +[Tue Apr 14 14:11:38 2026] 127.0.0.1:53118 Accepted +[Tue Apr 14 14:11:39 2026] 127.0.0.1:53118 Closing +[Tue Apr 14 14:11:39 2026] 127.0.0.1:53120 Accepted +[Tue Apr 14 14:11:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:39 2026] 127.0.0.1:53120 Closing +[Tue Apr 14 14:11:39 2026] 127.0.0.1:53136 Accepted +[Tue Apr 14 14:11:40 2026] 127.0.0.1:53136 Closing +[Tue Apr 14 14:11:40 2026] 127.0.0.1:53138 Accepted +[Tue Apr 14 14:11:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:40 2026] 127.0.0.1:53138 Closing +[Tue Apr 14 14:11:41 2026] 127.0.0.1:53154 Accepted +[Tue Apr 14 14:11:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:41 2026] 127.0.0.1:53154 Closing +[Tue Apr 14 14:11:41 2026] 127.0.0.1:53166 Accepted +[Tue Apr 14 14:11:42 2026] 127.0.0.1:53166 Closing +[Tue Apr 14 14:11:42 2026] 127.0.0.1:53174 Accepted +[Tue Apr 14 14:11:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:42 2026] 127.0.0.1:53174 Closing +[Tue Apr 14 14:11:42 2026] 127.0.0.1:53190 Accepted +[Tue Apr 14 14:11:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:43 2026] 127.0.0.1:53190 Closing +[Tue Apr 14 14:11:43 2026] 127.0.0.1:53206 Accepted +[Tue Apr 14 14:11:44 2026] 127.0.0.1:53206 Closing +[Tue Apr 14 14:11:44 2026] 127.0.0.1:58232 Accepted +[Tue Apr 14 14:11:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:44 2026] 127.0.0.1:58232 Closing +[Tue Apr 14 14:11:44 2026] 127.0.0.1:58238 Accepted +[Tue Apr 14 14:11:45 2026] 127.0.0.1:58238 Closing +[Tue Apr 14 14:11:45 2026] 127.0.0.1:58250 Accepted +[Tue Apr 14 14:11:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:45 2026] 127.0.0.1:58250 Closing +[Tue Apr 14 14:11:45 2026] 127.0.0.1:58254 Accepted +[Tue Apr 14 14:11:46 2026] 127.0.0.1:58254 Closing +[Tue Apr 14 14:11:46 2026] 127.0.0.1:58262 Accepted +[Tue Apr 14 14:11:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:47 2026] 127.0.0.1:58262 Closing +[Tue Apr 14 14:11:47 2026] 127.0.0.1:58276 Accepted +[Tue Apr 14 14:11:47 2026] 127.0.0.1:58276 Closing +[Tue Apr 14 14:11:47 2026] 127.0.0.1:58286 Accepted +[Tue Apr 14 14:11:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:48 2026] 127.0.0.1:58286 Closing +[Tue Apr 14 14:11:48 2026] 127.0.0.1:58298 Accepted +[Tue Apr 14 14:11:49 2026] 127.0.0.1:58298 Closing +[Tue Apr 14 14:11:49 2026] 127.0.0.1:58308 Accepted +[Tue Apr 14 14:11:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:49 2026] 127.0.0.1:58308 Closing +[Tue Apr 14 14:11:49 2026] 127.0.0.1:58322 Accepted +[Tue Apr 14 14:11:50 2026] 127.0.0.1:58322 Closing +[Tue Apr 14 14:11:50 2026] 127.0.0.1:58326 Accepted +[Tue Apr 14 14:11:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:50 2026] 127.0.0.1:58326 Closing +[Tue Apr 14 14:11:50 2026] 127.0.0.1:58334 Accepted +[Tue Apr 14 14:11:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:51 2026] 127.0.0.1:58334 Closing +[Tue Apr 14 14:11:51 2026] 127.0.0.1:58336 Accepted +[Tue Apr 14 14:11:51 2026] 127.0.0.1:58336 Closing +[Tue Apr 14 14:11:51 2026] 127.0.0.1:58340 Accepted +[Tue Apr 14 14:11:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:52 2026] 127.0.0.1:58340 Closing +[Tue Apr 14 14:11:52 2026] 127.0.0.1:58352 Accepted +[Tue Apr 14 14:11:52 2026] 127.0.0.1:58352 Closing +[Tue Apr 14 14:11:52 2026] 127.0.0.1:58364 Accepted +[Tue Apr 14 14:11:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:53 2026] 127.0.0.1:58364 Closing +[Tue Apr 14 14:11:53 2026] 127.0.0.1:58366 Accepted +[Tue Apr 14 14:11:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:54 2026] 127.0.0.1:58366 Closing +[Tue Apr 14 14:11:54 2026] 127.0.0.1:37308 Accepted +[Tue Apr 14 14:11:54 2026] 127.0.0.1:37308 Closing +[Tue Apr 14 14:11:54 2026] 127.0.0.1:37318 Accepted +[Tue Apr 14 14:11:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:55 2026] 127.0.0.1:37318 Closing +[Tue Apr 14 14:11:55 2026] 127.0.0.1:37332 Accepted +[Tue Apr 14 14:11:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37332 Closing +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37336 Accepted +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37336 Closing +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37352 Accepted +[Tue Apr 14 14:11:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37352 Closing +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37356 Accepted +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37356 Closing +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37368 Accepted +[Tue Apr 14 14:11:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37368 Closing +[Tue Apr 14 14:11:56 2026] 127.0.0.1:37382 Accepted +[Tue Apr 14 14:11:57 2026] 127.0.0.1:37382 Closing +[Tue Apr 14 14:11:57 2026] 127.0.0.1:37398 Accepted +[Tue Apr 14 14:11:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:57 2026] 127.0.0.1:37398 Closing +[Tue Apr 14 14:11:57 2026] 127.0.0.1:37408 Accepted +[Tue Apr 14 14:11:58 2026] 127.0.0.1:37408 Closing +[Tue Apr 14 14:11:58 2026] 127.0.0.1:37422 Accepted +[Tue Apr 14 14:11:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:58 2026] 127.0.0.1:37422 Closing +[Tue Apr 14 14:11:58 2026] 127.0.0.1:37430 Accepted +[Tue Apr 14 14:11:59 2026] 127.0.0.1:37430 Closing +[Tue Apr 14 14:11:59 2026] 127.0.0.1:37436 Accepted +[Tue Apr 14 14:11:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:11:59 2026] 127.0.0.1:37436 Closing +[Tue Apr 14 14:11:59 2026] 127.0.0.1:37450 Accepted +[Tue Apr 14 14:12:00 2026] 127.0.0.1:37450 Closing +[Tue Apr 14 14:12:00 2026] 127.0.0.1:37460 Accepted +[Tue Apr 14 14:12:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:01 2026] 127.0.0.1:37460 Closing +[Tue Apr 14 14:12:01 2026] 127.0.0.1:37466 Accepted +[Tue Apr 14 14:12:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:01 2026] 127.0.0.1:37466 Closing +[Tue Apr 14 14:12:01 2026] 127.0.0.1:37478 Accepted +[Tue Apr 14 14:12:02 2026] 127.0.0.1:37478 Closing +[Tue Apr 14 14:12:02 2026] 127.0.0.1:54286 Accepted +[Tue Apr 14 14:12:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:02 2026] 127.0.0.1:54286 Closing +[Tue Apr 14 14:12:02 2026] 127.0.0.1:54292 Accepted +[Tue Apr 14 14:12:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:03 2026] 127.0.0.1:54292 Closing +[Tue Apr 14 14:12:03 2026] 127.0.0.1:54298 Accepted +[Tue Apr 14 14:12:04 2026] 127.0.0.1:54298 Closing +[Tue Apr 14 14:12:04 2026] 127.0.0.1:54314 Accepted +[Tue Apr 14 14:12:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:05 2026] 127.0.0.1:54314 Closing +[Tue Apr 14 14:12:05 2026] 127.0.0.1:54326 Accepted +[Tue Apr 14 14:12:05 2026] 127.0.0.1:54326 Closing +[Tue Apr 14 14:12:05 2026] 127.0.0.1:54340 Accepted +[Tue Apr 14 14:12:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:06 2026] 127.0.0.1:54340 Closing +[Tue Apr 14 14:12:06 2026] 127.0.0.1:54344 Accepted +[Tue Apr 14 14:12:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:06 2026] 127.0.0.1:54344 Closing +[Tue Apr 14 14:12:06 2026] 127.0.0.1:54352 Accepted +[Tue Apr 14 14:12:07 2026] 127.0.0.1:54352 Closing +[Tue Apr 14 14:12:07 2026] 127.0.0.1:54360 Accepted +[Tue Apr 14 14:12:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:08 2026] 127.0.0.1:54360 Closing +[Tue Apr 14 14:12:08 2026] 127.0.0.1:54364 Accepted +[Tue Apr 14 14:12:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:08 2026] 127.0.0.1:54364 Closing +[Tue Apr 14 14:12:08 2026] 127.0.0.1:54374 Accepted +[Tue Apr 14 14:12:09 2026] 127.0.0.1:54374 Closing +[Tue Apr 14 14:12:09 2026] 127.0.0.1:54382 Accepted +[Tue Apr 14 14:12:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:10 2026] 127.0.0.1:54382 Closing +[Tue Apr 14 14:12:10 2026] 127.0.0.1:54390 Accepted +[Tue Apr 14 14:12:10 2026] 127.0.0.1:54390 Closing +[Tue Apr 14 14:12:11 2026] 127.0.0.1:54396 Accepted +[Tue Apr 14 14:12:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:11 2026] 127.0.0.1:54396 Closing +[Tue Apr 14 14:12:11 2026] 127.0.0.1:53440 Accepted +[Tue Apr 14 14:12:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:12 2026] 127.0.0.1:53440 Closing +[Tue Apr 14 14:12:12 2026] 127.0.0.1:53450 Accepted +[Tue Apr 14 14:12:13 2026] 127.0.0.1:53450 Closing +[Tue Apr 14 14:12:13 2026] 127.0.0.1:53456 Accepted +[Tue Apr 14 14:12:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:13 2026] 127.0.0.1:53456 Closing +[Tue Apr 14 14:12:13 2026] 127.0.0.1:53468 Accepted +[Tue Apr 14 14:12:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 14:12:14 2026] 127.0.0.1:53468 Closing +[Tue Apr 14 14:12:14 2026] 127.0.0.1:53478 Accepted +[Tue Apr 14 14:12:15 2026] 127.0.0.1:53478 Closing +[Tue Apr 14 14:12:50 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 14 14:12:50 2026] 127.0.0.1:38888 Accepted +[Tue Apr 14 14:12:51 2026] 127.0.0.1:38888 Closing +[Tue Apr 14 14:12:52 2026] 127.0.0.1:38902 Accepted +[Tue Apr 14 14:12:53 2026] 127.0.0.1:38902 Closing +[Tue Apr 14 14:12:53 2026] 127.0.0.1:38908 Accepted +[Tue Apr 14 14:12:52 2026] 127.0.0.1:38908 Closing +[Tue Apr 14 14:12:52 2026] 127.0.0.1:38916 Accepted +[Tue Apr 14 14:12:53 2026] 127.0.0.1:38916 Closing +[Tue Apr 14 14:12:53 2026] 127.0.0.1:38918 Accepted +[Tue Apr 14 14:12:53 2026] 127.0.0.1:38918 Closing +[Tue Apr 14 14:12:53 2026] 127.0.0.1:38928 Accepted +[Tue Apr 14 14:12:54 2026] 127.0.0.1:38928 Closing +[Tue Apr 14 14:12:54 2026] 127.0.0.1:38940 Accepted +[Tue Apr 14 14:12:55 2026] 127.0.0.1:38940 Closing +[Tue Apr 14 14:12:55 2026] 127.0.0.1:38950 Accepted +[Tue Apr 14 14:12:55 2026] 127.0.0.1:38950 Closing +[Tue Apr 14 14:12:55 2026] 127.0.0.1:38952 Accepted +[Tue Apr 14 14:12:56 2026] 127.0.0.1:38952 Closing +[Tue Apr 14 14:12:56 2026] 127.0.0.1:38966 Accepted +[Tue Apr 14 14:12:57 2026] 127.0.0.1:38966 Closing +[Tue Apr 14 14:12:57 2026] 127.0.0.1:38976 Accepted +[Tue Apr 14 14:12:58 2026] 127.0.0.1:38976 Closing +[Tue Apr 14 14:12:58 2026] 127.0.0.1:38984 Accepted +[Tue Apr 14 14:12:58 2026] 127.0.0.1:38984 Closing +[Tue Apr 14 14:12:58 2026] 127.0.0.1:46776 Accepted +[Tue Apr 14 14:12:59 2026] 127.0.0.1:46776 Closing +[Tue Apr 14 14:12:59 2026] 127.0.0.1:46782 Accepted +[Tue Apr 14 14:13:00 2026] 127.0.0.1:46782 Closing +[Tue Apr 14 14:13:00 2026] 127.0.0.1:46786 Accepted +[Tue Apr 14 14:13:00 2026] 127.0.0.1:46786 Closing +[Tue Apr 14 14:13:00 2026] 127.0.0.1:46790 Accepted +[Tue Apr 14 14:13:01 2026] 127.0.0.1:46790 Closing +[Tue Apr 14 14:13:01 2026] 127.0.0.1:46794 Accepted +[Tue Apr 14 14:13:01 2026] 127.0.0.1:46794 Closing +[Tue Apr 14 14:13:02 2026] 127.0.0.1:46798 Accepted +[Tue Apr 14 14:13:02 2026] 127.0.0.1:46798 Closing +[Tue Apr 14 14:13:02 2026] 127.0.0.1:46806 Accepted +[Tue Apr 14 14:13:03 2026] 127.0.0.1:46806 Closing +[Tue Apr 14 14:13:03 2026] 127.0.0.1:46820 Accepted +[Tue Apr 14 14:13:04 2026] 127.0.0.1:46820 Closing +[Tue Apr 14 14:13:04 2026] 127.0.0.1:46830 Accepted +[Tue Apr 14 14:13:04 2026] 127.0.0.1:46830 Closing +[Tue Apr 14 14:13:05 2026] 127.0.0.1:46840 Accepted +[Tue Apr 14 14:13:05 2026] 127.0.0.1:46840 Closing +[Tue Apr 14 14:13:05 2026] 127.0.0.1:46846 Accepted +[Tue Apr 14 14:13:06 2026] 127.0.0.1:46846 Closing +[Tue Apr 14 14:13:06 2026] 127.0.0.1:46854 Accepted +[Tue Apr 14 14:13:06 2026] 127.0.0.1:46854 Closing +[Tue Apr 14 14:13:06 2026] 127.0.0.1:46870 Accepted +[Tue Apr 14 14:13:07 2026] 127.0.0.1:46870 Closing +[Tue Apr 14 14:13:07 2026] 127.0.0.1:46884 Accepted +[Tue Apr 14 14:13:08 2026] 127.0.0.1:46884 Closing +[Tue Apr 14 14:13:08 2026] 127.0.0.1:46900 Accepted +[Tue Apr 14 14:13:09 2026] 127.0.0.1:46900 Closing +[Tue Apr 14 14:13:09 2026] 127.0.0.1:48670 Accepted +[Tue Apr 14 14:13:10 2026] 127.0.0.1:48670 Closing +[Tue Apr 14 14:13:10 2026] 127.0.0.1:48672 Accepted +[Tue Apr 14 14:13:10 2026] 127.0.0.1:48672 Closing +[Tue Apr 14 14:13:11 2026] 127.0.0.1:48688 Accepted +[Tue Apr 14 14:13:11 2026] 127.0.0.1:48688 Closing +[Tue Apr 14 14:13:12 2026] 127.0.0.1:48698 Accepted +[Tue Apr 14 14:13:12 2026] 127.0.0.1:48698 Closing +[Tue Apr 14 14:13:12 2026] 127.0.0.1:48710 Accepted +[Tue Apr 14 14:13:13 2026] 127.0.0.1:48710 Closing +[Tue Apr 14 14:13:13 2026] 127.0.0.1:48720 Accepted +[Tue Apr 14 14:13:14 2026] 127.0.0.1:48720 Closing +[Tue Apr 14 14:13:14 2026] 127.0.0.1:48724 Accepted +[Tue Apr 14 14:13:15 2026] 127.0.0.1:48724 Closing +[Tue Apr 14 14:13:15 2026] 127.0.0.1:48726 Accepted +[Tue Apr 14 14:13:17 2026] 127.0.0.1:48726 Closing +[Tue Apr 14 14:13:17 2026] 127.0.0.1:48728 Accepted +[Tue Apr 14 14:13:18 2026] 127.0.0.1:48728 Closing +[Tue Apr 14 14:13:18 2026] 127.0.0.1:48738 Accepted +[Tue Apr 14 14:13:19 2026] 127.0.0.1:48738 Closing +[Tue Apr 14 14:13:19 2026] 127.0.0.1:58114 Accepted +[Tue Apr 14 14:13:19 2026] 127.0.0.1:58114 Closing +[Tue Apr 14 14:13:20 2026] 127.0.0.1:58130 Accepted +[Tue Apr 14 14:13:20 2026] 127.0.0.1:58130 Closing +[Tue Apr 14 14:13:20 2026] 127.0.0.1:58132 Accepted +[Tue Apr 14 14:13:21 2026] 127.0.0.1:58132 Closing +[Tue Apr 14 14:13:21 2026] 127.0.0.1:58146 Accepted +[Tue Apr 14 14:13:20 2026] 127.0.0.1:58146 Closing +[Tue Apr 14 14:13:20 2026] 127.0.0.1:58162 Accepted +[Tue Apr 14 14:13:21 2026] 127.0.0.1:58162 Closing +[Tue Apr 14 14:13:21 2026] 127.0.0.1:58168 Accepted +[Tue Apr 14 14:13:22 2026] 127.0.0.1:58168 Closing +[Tue Apr 14 14:13:22 2026] 127.0.0.1:58172 Accepted +[Tue Apr 14 14:13:23 2026] 127.0.0.1:58172 Closing +[Tue Apr 14 14:13:23 2026] 127.0.0.1:58174 Accepted +[Tue Apr 14 14:13:23 2026] 127.0.0.1:58174 Closing +[Tue Apr 14 14:13:23 2026] 127.0.0.1:58178 Accepted +[Tue Apr 14 14:13:24 2026] 127.0.0.1:58178 Closing +[Tue Apr 14 14:13:24 2026] 127.0.0.1:58184 Accepted +[Tue Apr 14 14:13:24 2026] 127.0.0.1:58184 Closing +[Tue Apr 14 14:13:24 2026] 127.0.0.1:58200 Accepted +[Tue Apr 14 14:13:25 2026] 127.0.0.1:58200 Closing +[Tue Apr 14 14:13:25 2026] 127.0.0.1:58206 Accepted +[Tue Apr 14 14:13:26 2026] 127.0.0.1:58206 Closing +[Tue Apr 14 14:13:26 2026] 127.0.0.1:58208 Accepted +[Tue Apr 14 14:13:26 2026] 127.0.0.1:58208 Closing +[Tue Apr 14 14:13:26 2026] 127.0.0.1:58222 Accepted +[Tue Apr 14 14:13:27 2026] 127.0.0.1:58222 Closing +[Tue Apr 14 14:13:27 2026] 127.0.0.1:41272 Accepted +[Tue Apr 14 14:13:28 2026] 127.0.0.1:41272 Closing +[Tue Apr 14 14:13:28 2026] 127.0.0.1:41278 Accepted +[Tue Apr 14 14:13:29 2026] 127.0.0.1:41278 Closing +[Tue Apr 14 14:13:29 2026] 127.0.0.1:41280 Accepted +[Tue Apr 14 14:13:29 2026] 127.0.0.1:41280 Closing +[Tue Apr 14 14:13:29 2026] 127.0.0.1:41282 Accepted +[Tue Apr 14 14:13:30 2026] 127.0.0.1:41282 Closing +[Tue Apr 14 14:13:30 2026] 127.0.0.1:41288 Accepted +[Tue Apr 14 14:13:31 2026] 127.0.0.1:41288 Closing +[Tue Apr 14 14:13:31 2026] 127.0.0.1:41300 Accepted +[Tue Apr 14 14:13:31 2026] 127.0.0.1:41300 Closing +[Tue Apr 14 14:13:31 2026] 127.0.0.1:41306 Accepted +[Tue Apr 14 14:13:32 2026] 127.0.0.1:41306 Closing +[Tue Apr 14 14:13:32 2026] 127.0.0.1:41314 Accepted +[Tue Apr 14 14:13:32 2026] 127.0.0.1:41314 Closing +[Tue Apr 14 14:13:33 2026] 127.0.0.1:41318 Accepted +[Tue Apr 14 14:13:33 2026] 127.0.0.1:41318 Closing +[Tue Apr 14 14:13:33 2026] 127.0.0.1:41326 Accepted +[Tue Apr 14 14:13:34 2026] 127.0.0.1:41326 Closing +[Tue Apr 14 14:13:34 2026] 127.0.0.1:41330 Accepted +[Tue Apr 14 14:13:35 2026] 127.0.0.1:41330 Closing +[Tue Apr 14 14:13:35 2026] 127.0.0.1:41336 Accepted +[Tue Apr 14 14:13:35 2026] 127.0.0.1:41336 Closing +[Tue Apr 14 14:13:35 2026] 127.0.0.1:41350 Accepted +[Tue Apr 14 14:13:36 2026] 127.0.0.1:41350 Closing +[Tue Apr 14 14:13:36 2026] 127.0.0.1:41358 Accepted +[Tue Apr 14 14:13:36 2026] 127.0.0.1:41358 Closing +[Tue Apr 14 14:13:36 2026] 127.0.0.1:41366 Accepted +[Tue Apr 14 14:13:37 2026] 127.0.0.1:41366 Closing +[Tue Apr 14 14:13:37 2026] 127.0.0.1:42606 Accepted +[Tue Apr 14 14:13:38 2026] 127.0.0.1:42606 Closing +[Tue Apr 14 14:13:38 2026] 127.0.0.1:42616 Accepted +[Tue Apr 14 14:13:39 2026] 127.0.0.1:42616 Closing +[Tue Apr 14 14:13:39 2026] 127.0.0.1:42626 Accepted +[Tue Apr 14 14:13:40 2026] 127.0.0.1:42626 Closing +[Tue Apr 14 18:09:43 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 14 18:09:43 2026] 127.0.0.1:35500 Accepted +[Tue Apr 14 18:09:43 2026] 127.0.0.1:35500 Closing +[Tue Apr 14 18:11:06 2026] 127.0.0.1:37840 Accepted +[Tue Apr 14 18:11:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 18:11:07 2026] 127.0.0.1:37840 Closing +[Tue Apr 14 18:11:32 2026] 127.0.0.1:60036 Accepted +[Tue Apr 14 18:11:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 14 18:11:33 2026] 127.0.0.1:60036 Closing +[Mon Apr 20 10:36:46 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Mon Apr 20 10:36:46 2026] 127.0.0.1:51922 Accepted +[Mon Apr 20 10:36:48 2026] 127.0.0.1:51922 Closing +[Mon Apr 20 10:36:48 2026] 127.0.0.1:51924 Accepted +[Mon Apr 20 10:36:50 2026] 127.0.0.1:51924 Closing +[Mon Apr 20 10:36:52 2026] 127.0.0.1:51928 Accepted +[Mon Apr 20 10:36:53 2026] 127.0.0.1:51928 Closing +[Mon Apr 20 10:36:53 2026] 127.0.0.1:51930 Accepted +[Mon Apr 20 10:36:54 2026] 127.0.0.1:51930 Closing +[Mon Apr 20 10:36:57 2026] 127.0.0.1:52668 Accepted +[Mon Apr 20 10:36:58 2026] 127.0.0.1:52668 Closing +[Mon Apr 20 10:37:01 2026] 127.0.0.1:52684 Accepted +[Mon Apr 20 10:37:02 2026] 127.0.0.1:52684 Closing +[Mon Apr 20 10:37:03 2026] 127.0.0.1:52694 Accepted +[Mon Apr 20 10:37:04 2026] 127.0.0.1:52694 Closing +[Mon Apr 20 10:37:04 2026] 127.0.0.1:52698 Accepted +[Mon Apr 20 10:37:05 2026] 127.0.0.1:52698 Closing +[Mon Apr 20 10:37:05 2026] 127.0.0.1:39668 Accepted +[Mon Apr 20 10:37:06 2026] 127.0.0.1:39668 Closing +[Mon Apr 20 10:37:08 2026] 127.0.0.1:39672 Accepted +[Mon Apr 20 10:37:10 2026] 127.0.0.1:39672 Closing +[Mon Apr 20 10:37:10 2026] 127.0.0.1:39682 Accepted +[Mon Apr 20 10:37:11 2026] 127.0.0.1:39682 Closing +[Mon Apr 20 10:37:11 2026] 127.0.0.1:39686 Accepted +[Mon Apr 20 10:37:12 2026] 127.0.0.1:39686 Closing +[Mon Apr 20 10:37:14 2026] 127.0.0.1:39698 Accepted +[Mon Apr 20 10:37:15 2026] 127.0.0.1:39698 Closing +[Mon Apr 20 10:37:16 2026] 127.0.0.1:41538 Accepted +[Mon Apr 20 10:37:19 2026] 127.0.0.1:41538 Closing +[Mon Apr 20 10:37:26 2026] 127.0.0.1:52864 Accepted +[Mon Apr 20 10:37:30 2026] 127.0.0.1:52864 Closing +[Mon Apr 20 10:37:34 2026] 127.0.0.1:60392 Accepted +[Mon Apr 20 10:37:34 2026] 127.0.0.1:60392 Closing +[Mon Apr 20 10:37:34 2026] 127.0.0.1:60398 Accepted +[Mon Apr 20 10:37:36 2026] 127.0.0.1:60398 Closing +[Mon Apr 20 10:37:39 2026] 127.0.0.1:60400 Accepted +[Mon Apr 20 10:37:43 2026] 127.0.0.1:60400 Closing +[Mon Apr 20 10:37:47 2026] 127.0.0.1:44546 Accepted +[Mon Apr 20 10:37:48 2026] 127.0.0.1:44546 Closing +[Mon Apr 20 10:37:48 2026] 127.0.0.1:44556 Accepted +[Mon Apr 20 10:37:50 2026] 127.0.0.1:44556 Closing +[Mon Apr 20 10:37:50 2026] 127.0.0.1:44564 Accepted +[Mon Apr 20 10:37:51 2026] 127.0.0.1:44564 Closing +[Mon Apr 20 10:37:51 2026] 127.0.0.1:44578 Accepted +[Mon Apr 20 10:37:53 2026] 127.0.0.1:44578 Closing +[Mon Apr 20 10:37:57 2026] 127.0.0.1:53556 Accepted +[Mon Apr 20 10:38:00 2026] 127.0.0.1:53556 Closing +[Mon Apr 20 10:38:07 2026] 127.0.0.1:36794 Accepted +[Mon Apr 20 10:38:10 2026] 127.0.0.1:36794 Closing +[Mon Apr 20 10:38:10 2026] 127.0.0.1:36804 Accepted +[Mon Apr 20 10:38:12 2026] 127.0.0.1:36804 Closing +[Mon Apr 20 10:38:20 2026] 127.0.0.1:32954 Accepted +[Mon Apr 20 10:38:22 2026] 127.0.0.1:32954 Closing +[Mon Apr 20 10:38:22 2026] 127.0.0.1:32956 Accepted +[Mon Apr 20 10:38:24 2026] 127.0.0.1:32956 Closing +[Mon Apr 20 10:38:24 2026] 127.0.0.1:51972 Accepted +[Mon Apr 20 10:38:25 2026] 127.0.0.1:51972 Closing +[Mon Apr 20 10:38:25 2026] 127.0.0.1:51988 Accepted +[Mon Apr 20 10:38:27 2026] 127.0.0.1:51988 Closing +[Mon Apr 20 10:38:27 2026] 127.0.0.1:51998 Accepted +[Mon Apr 20 10:38:27 2026] 127.0.0.1:51998 Closing +[Mon Apr 20 10:38:27 2026] 127.0.0.1:52012 Accepted +[Mon Apr 20 10:38:29 2026] 127.0.0.1:52012 Closing +[Mon Apr 20 10:38:34 2026] 127.0.0.1:40234 Accepted +[Mon Apr 20 10:38:36 2026] 127.0.0.1:40234 Closing +[Mon Apr 20 10:38:36 2026] 127.0.0.1:40250 Accepted +[Mon Apr 20 10:38:37 2026] 127.0.0.1:40250 Closing +[Mon Apr 20 10:38:40 2026] 127.0.0.1:40256 Accepted +[Mon Apr 20 10:38:44 2026] 127.0.0.1:40256 Closing +[Mon Apr 20 10:38:51 2026] 127.0.0.1:50848 Accepted +[Mon Apr 20 10:38:53 2026] 127.0.0.1:50848 Closing +[Mon Apr 20 10:38:53 2026] 127.0.0.1:50856 Accepted +[Mon Apr 20 10:38:55 2026] 127.0.0.1:50856 Closing +[Mon Apr 20 10:38:55 2026] 127.0.0.1:50870 Accepted +[Mon Apr 20 10:38:55 2026] 127.0.0.1:50870 Closing +[Mon Apr 20 10:38:55 2026] 127.0.0.1:50872 Accepted +[Mon Apr 20 10:38:57 2026] 127.0.0.1:50872 Closing +[Mon Apr 20 10:38:57 2026] 127.0.0.1:50874 Accepted +[Mon Apr 20 10:38:59 2026] 127.0.0.1:50874 Closing +[Mon Apr 20 10:38:59 2026] 127.0.0.1:50888 Accepted +[Mon Apr 20 10:39:01 2026] 127.0.0.1:50888 Closing +[Mon Apr 20 10:39:06 2026] 127.0.0.1:44640 Accepted +[Mon Apr 20 10:39:07 2026] 127.0.0.1:44640 Closing +[Mon Apr 20 10:39:10 2026] 127.0.0.1:42148 Accepted +[Mon Apr 20 10:39:12 2026] 127.0.0.1:42148 Closing +[Mon Apr 20 10:39:12 2026] 127.0.0.1:42156 Accepted +[Mon Apr 20 10:39:13 2026] 127.0.0.1:42156 Closing +[Mon Apr 20 10:39:18 2026] 127.0.0.1:42164 Accepted +[Mon Apr 20 10:39:21 2026] 127.0.0.1:42164 Closing +[Mon Apr 20 10:39:24 2026] 127.0.0.1:37084 Accepted +[Mon Apr 20 10:39:26 2026] 127.0.0.1:37084 Closing +[Mon Apr 20 10:39:26 2026] 127.0.0.1:37094 Accepted +[Mon Apr 20 10:39:27 2026] 127.0.0.1:37094 Closing +[Mon Apr 20 10:39:29 2026] 127.0.0.1:43890 Accepted +[Mon Apr 20 10:39:31 2026] 127.0.0.1:43890 Closing +[Mon Apr 20 10:39:34 2026] 127.0.0.1:43906 Accepted +[Mon Apr 20 10:39:36 2026] 127.0.0.1:43906 Closing +[Mon Apr 20 10:39:36 2026] 127.0.0.1:43910 Accepted +[Mon Apr 20 10:39:38 2026] 127.0.0.1:43910 Closing +[Mon Apr 20 10:39:38 2026] 127.0.0.1:43914 Accepted +[Mon Apr 20 10:39:39 2026] 127.0.0.1:43914 Closing +[Mon Apr 20 10:39:39 2026] 127.0.0.1:37710 Accepted +[Mon Apr 20 10:39:40 2026] 127.0.0.1:37710 Closing +[Mon Apr 20 10:39:43 2026] 127.0.0.1:37720 Accepted +[Mon Apr 20 10:39:45 2026] 127.0.0.1:37720 Closing +[Mon Apr 20 10:39:51 2026] 127.0.0.1:49542 Accepted +[Mon Apr 20 10:39:53 2026] 127.0.0.1:49542 Closing +[Mon Apr 20 10:39:56 2026] 127.0.0.1:49554 Accepted +[Mon Apr 20 10:39:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:39:56 2026] 127.0.0.1:49554 Closing +[Mon Apr 20 10:39:56 2026] 127.0.0.1:39002 Accepted +[Mon Apr 20 10:39:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:39:57 2026] 127.0.0.1:39002 Closing +[Mon Apr 20 10:39:57 2026] 127.0.0.1:39018 Accepted +[Mon Apr 20 10:39:58 2026] 127.0.0.1:39018 Closing +[Mon Apr 20 10:39:58 2026] 127.0.0.1:39020 Accepted +[Mon Apr 20 10:40:00 2026] 127.0.0.1:39020 Closing +[Mon Apr 20 10:40:21 2026] 127.0.0.1:37312 Accepted +[Mon Apr 20 10:40:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:22 2026] 127.0.0.1:37312 Closing +[Mon Apr 20 10:40:22 2026] 127.0.0.1:37316 Accepted +[Mon Apr 20 10:40:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:23 2026] 127.0.0.1:37316 Closing +[Mon Apr 20 10:40:23 2026] 127.0.0.1:37332 Accepted +[Mon Apr 20 10:40:25 2026] 127.0.0.1:37332 Closing +[Mon Apr 20 10:40:27 2026] 127.0.0.1:59592 Accepted +[Mon Apr 20 10:40:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:28 2026] 127.0.0.1:59592 Closing +[Mon Apr 20 10:40:28 2026] 127.0.0.1:59606 Accepted +[Mon Apr 20 10:40:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:29 2026] 127.0.0.1:59606 Closing +[Mon Apr 20 10:40:29 2026] 127.0.0.1:59614 Accepted +[Mon Apr 20 10:40:31 2026] 127.0.0.1:59614 Closing +[Mon Apr 20 10:40:31 2026] 127.0.0.1:59622 Accepted +[Mon Apr 20 10:40:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:32 2026] 127.0.0.1:59622 Closing +[Mon Apr 20 10:40:32 2026] 127.0.0.1:59638 Accepted +[Mon Apr 20 10:40:33 2026] 127.0.0.1:59638 Closing +[Mon Apr 20 10:40:33 2026] 127.0.0.1:59644 Accepted +[Mon Apr 20 10:40:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:34 2026] 127.0.0.1:59644 Closing +[Mon Apr 20 10:40:34 2026] 127.0.0.1:59646 Accepted +[Mon Apr 20 10:40:35 2026] 127.0.0.1:59646 Closing +[Mon Apr 20 10:40:35 2026] 127.0.0.1:47540 Accepted +[Mon Apr 20 10:40:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:36 2026] 127.0.0.1:47540 Closing +[Mon Apr 20 10:40:36 2026] 127.0.0.1:47546 Accepted +[Mon Apr 20 10:40:37 2026] 127.0.0.1:47546 Closing +[Mon Apr 20 10:41:12 2026] 127.0.0.1:59176 Accepted +[Mon Apr 20 10:41:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:13 2026] 127.0.0.1:59176 Closing +[Mon Apr 20 10:41:13 2026] 127.0.0.1:59184 Accepted +[Mon Apr 20 10:41:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:14 2026] 127.0.0.1:59184 Closing +[Mon Apr 20 10:41:14 2026] 127.0.0.1:60322 Accepted +[Mon Apr 20 10:41:15 2026] 127.0.0.1:60322 Closing +[Mon Apr 20 10:41:18 2026] 127.0.0.1:60334 Accepted +[Mon Apr 20 10:41:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:18 2026] 127.0.0.1:60334 Closing +[Mon Apr 20 10:41:19 2026] 127.0.0.1:60348 Accepted +[Mon Apr 20 10:41:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:19 2026] 127.0.0.1:60348 Closing +[Mon Apr 20 10:41:19 2026] 127.0.0.1:60362 Accepted +[Mon Apr 20 10:41:20 2026] 127.0.0.1:60362 Closing +[Mon Apr 20 10:41:20 2026] 127.0.0.1:60364 Accepted +[Mon Apr 20 10:41:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:20 2026] 127.0.0.1:60364 Closing +[Mon Apr 20 10:41:20 2026] 127.0.0.1:60374 Accepted +[Mon Apr 20 10:41:22 2026] 127.0.0.1:60374 Closing +[Mon Apr 20 10:41:22 2026] 127.0.0.1:60388 Accepted +[Mon Apr 20 10:41:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:22 2026] 127.0.0.1:60388 Closing +[Mon Apr 20 10:41:22 2026] 127.0.0.1:58906 Accepted +[Mon Apr 20 10:41:24 2026] 127.0.0.1:58906 Closing +[Mon Apr 20 10:41:24 2026] 127.0.0.1:58912 Accepted +[Mon Apr 20 10:41:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:24 2026] 127.0.0.1:58912 Closing +[Mon Apr 20 10:41:24 2026] 127.0.0.1:58922 Accepted +[Mon Apr 20 10:41:26 2026] 127.0.0.1:58922 Closing +[Mon Apr 20 10:41:28 2026] 127.0.0.1:58938 Accepted +[Mon Apr 20 10:41:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:28 2026] 127.0.0.1:58938 Closing +[Mon Apr 20 10:41:29 2026] 127.0.0.1:58952 Accepted +[Mon Apr 20 10:41:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:30 2026] 127.0.0.1:58952 Closing +[Mon Apr 20 10:41:30 2026] 127.0.0.1:58956 Accepted +[Mon Apr 20 10:41:32 2026] 127.0.0.1:58956 Closing +[Mon Apr 20 10:41:37 2026] 127.0.0.1:51662 Accepted +[Mon Apr 20 10:41:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:38 2026] 127.0.0.1:51662 Closing +[Mon Apr 20 10:41:38 2026] 127.0.0.1:51672 Accepted +[Mon Apr 20 10:41:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:39 2026] 127.0.0.1:51672 Closing +[Mon Apr 20 10:41:39 2026] 127.0.0.1:51680 Accepted +[Mon Apr 20 10:41:41 2026] 127.0.0.1:51680 Closing +[Mon Apr 20 10:41:46 2026] 127.0.0.1:37358 Accepted +[Mon Apr 20 10:41:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:46 2026] 127.0.0.1:37358 Closing +[Mon Apr 20 10:41:47 2026] 127.0.0.1:37368 Accepted +[Mon Apr 20 10:41:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:47 2026] 127.0.0.1:37368 Closing +[Mon Apr 20 10:41:47 2026] 127.0.0.1:37378 Accepted +[Mon Apr 20 10:41:48 2026] 127.0.0.1:37378 Closing +[Mon Apr 20 10:42:06 2026] 127.0.0.1:52434 Accepted +[Mon Apr 20 10:42:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:06 2026] 127.0.0.1:52434 Closing +[Mon Apr 20 10:42:06 2026] 127.0.0.1:52448 Accepted +[Mon Apr 20 10:42:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:07 2026] 127.0.0.1:52448 Closing +[Mon Apr 20 10:42:07 2026] 127.0.0.1:52456 Accepted +[Mon Apr 20 10:42:08 2026] 127.0.0.1:52456 Closing +[Mon Apr 20 10:42:10 2026] 127.0.0.1:52462 Accepted +[Mon Apr 20 10:42:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:11 2026] 127.0.0.1:52462 Closing +[Mon Apr 20 10:42:11 2026] 127.0.0.1:48668 Accepted +[Mon Apr 20 10:42:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:11 2026] 127.0.0.1:48668 Closing +[Mon Apr 20 10:42:11 2026] 127.0.0.1:48684 Accepted +[Mon Apr 20 10:42:12 2026] 127.0.0.1:48684 Closing +[Mon Apr 20 10:42:12 2026] 127.0.0.1:48686 Accepted +[Mon Apr 20 10:42:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:13 2026] 127.0.0.1:48686 Closing +[Mon Apr 20 10:42:13 2026] 127.0.0.1:48702 Accepted +[Mon Apr 20 10:42:14 2026] 127.0.0.1:48702 Closing +[Mon Apr 20 10:42:16 2026] 127.0.0.1:48716 Accepted +[Mon Apr 20 10:42:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:15 2026] 127.0.0.1:48716 Closing +[Mon Apr 20 10:42:16 2026] 127.0.0.1:48728 Accepted +[Mon Apr 20 10:42:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:16 2026] 127.0.0.1:48728 Closing +[Mon Apr 20 10:42:16 2026] 127.0.0.1:48736 Accepted +[Mon Apr 20 10:42:17 2026] 127.0.0.1:48736 Closing +[Mon Apr 20 10:42:19 2026] 127.0.0.1:32960 Accepted +[Mon Apr 20 10:42:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:20 2026] 127.0.0.1:32960 Closing +[Mon Apr 20 10:42:20 2026] 127.0.0.1:32966 Accepted +[Mon Apr 20 10:42:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:21 2026] 127.0.0.1:32966 Closing +[Mon Apr 20 10:42:21 2026] 127.0.0.1:32968 Accepted +[Mon Apr 20 10:42:23 2026] 127.0.0.1:32968 Closing +[Mon Apr 20 10:42:25 2026] 127.0.0.1:32982 Accepted +[Mon Apr 20 10:42:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:26 2026] 127.0.0.1:32982 Closing +[Mon Apr 20 10:42:26 2026] 127.0.0.1:32998 Accepted +[Mon Apr 20 10:42:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:26 2026] 127.0.0.1:32998 Closing +[Mon Apr 20 10:42:26 2026] 127.0.0.1:33014 Accepted +[Mon Apr 20 10:42:27 2026] 127.0.0.1:33014 Closing +[Mon Apr 20 10:42:27 2026] 127.0.0.1:33018 Accepted +[Mon Apr 20 10:42:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:28 2026] 127.0.0.1:33018 Closing +[Mon Apr 20 10:42:28 2026] 127.0.0.1:33028 Accepted +[Mon Apr 20 10:42:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:28 2026] 127.0.0.1:33028 Closing +[Mon Apr 20 10:42:28 2026] 127.0.0.1:33036 Accepted +[Mon Apr 20 10:42:29 2026] 127.0.0.1:33036 Closing +[Mon Apr 20 10:42:29 2026] 127.0.0.1:44814 Accepted +[Mon Apr 20 10:42:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:30 2026] 127.0.0.1:44814 Closing +[Mon Apr 20 10:42:30 2026] 127.0.0.1:44818 Accepted +[Mon Apr 20 10:42:31 2026] 127.0.0.1:44818 Closing +[Mon Apr 20 10:42:33 2026] 127.0.0.1:44832 Accepted +[Mon Apr 20 10:42:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:34 2026] 127.0.0.1:44832 Closing +[Mon Apr 20 10:42:34 2026] 127.0.0.1:44836 Accepted +[Mon Apr 20 10:42:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:34 2026] 127.0.0.1:44836 Closing +[Mon Apr 20 10:42:34 2026] 127.0.0.1:44840 Accepted +[Mon Apr 20 10:42:36 2026] 127.0.0.1:44840 Closing +[Mon Apr 20 10:42:36 2026] 127.0.0.1:44848 Accepted +[Mon Apr 20 10:42:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:37 2026] 127.0.0.1:44848 Closing +[Mon Apr 20 10:42:37 2026] 127.0.0.1:44858 Accepted +[Mon Apr 20 10:42:38 2026] 127.0.0.1:44858 Closing +[Mon Apr 20 10:42:38 2026] 127.0.0.1:44866 Accepted +[Mon Apr 20 10:42:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:38 2026] 127.0.0.1:44866 Closing +[Mon Apr 20 10:42:38 2026] 127.0.0.1:44876 Accepted +[Mon Apr 20 10:42:39 2026] 127.0.0.1:44876 Closing +[Mon Apr 20 10:42:41 2026] 127.0.0.1:52734 Accepted +[Mon Apr 20 10:42:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:42 2026] 127.0.0.1:52734 Closing +[Mon Apr 20 10:42:42 2026] 127.0.0.1:52744 Accepted +[Mon Apr 20 10:42:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:42 2026] 127.0.0.1:52744 Closing +[Mon Apr 20 10:42:42 2026] 127.0.0.1:52756 Accepted +[Mon Apr 20 10:42:43 2026] 127.0.0.1:52756 Closing +[Mon Apr 20 10:42:43 2026] 127.0.0.1:52772 Accepted +[Mon Apr 20 10:42:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:44 2026] 127.0.0.1:52772 Closing +[Mon Apr 20 10:42:45 2026] 127.0.0.1:52784 Accepted +[Mon Apr 20 10:42:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:44 2026] 127.0.0.1:52784 Closing +[Mon Apr 20 10:42:44 2026] 127.0.0.1:52792 Accepted +[Mon Apr 20 10:42:47 2026] 127.0.0.1:52792 Closing +[Mon Apr 20 10:42:53 2026] 127.0.0.1:40736 Accepted +[Mon Apr 20 10:42:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:53 2026] 127.0.0.1:40736 Closing +[Mon Apr 20 10:42:54 2026] 127.0.0.1:40744 Accepted +[Mon Apr 20 10:42:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:55 2026] 127.0.0.1:40744 Closing +[Mon Apr 20 10:42:55 2026] 127.0.0.1:40750 Accepted +[Mon Apr 20 10:42:59 2026] 127.0.0.1:40750 Closing +[Mon Apr 20 10:43:05 2026] 127.0.0.1:41804 Accepted +[Mon Apr 20 10:43:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:05 2026] 127.0.0.1:41804 Closing +[Mon Apr 20 10:43:05 2026] 127.0.0.1:41806 Accepted +[Mon Apr 20 10:43:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:06 2026] 127.0.0.1:41806 Closing +[Mon Apr 20 10:43:06 2026] 127.0.0.1:41822 Accepted +[Mon Apr 20 10:43:06 2026] 127.0.0.1:41822 Closing +[Mon Apr 20 10:43:07 2026] 127.0.0.1:41832 Accepted +[Mon Apr 20 10:43:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:07 2026] 127.0.0.1:41832 Closing +[Mon Apr 20 10:43:07 2026] 127.0.0.1:41840 Accepted +[Mon Apr 20 10:43:10 2026] 127.0.0.1:41840 Closing +[Mon Apr 20 10:43:12 2026] 127.0.0.1:54998 Accepted +[Mon Apr 20 10:43:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:13 2026] 127.0.0.1:54998 Closing +[Mon Apr 20 10:43:13 2026] 127.0.0.1:55014 Accepted +[Mon Apr 20 10:43:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:13 2026] 127.0.0.1:55014 Closing +[Mon Apr 20 10:43:13 2026] 127.0.0.1:55020 Accepted +[Mon Apr 20 10:43:17 2026] 127.0.0.1:55020 Closing +[Mon Apr 20 10:43:21 2026] 127.0.0.1:59684 Accepted +[Mon Apr 20 10:43:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:21 2026] 127.0.0.1:59684 Closing +[Mon Apr 20 10:43:22 2026] 127.0.0.1:59692 Accepted +[Mon Apr 20 10:43:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:22 2026] 127.0.0.1:59692 Closing +[Mon Apr 20 10:43:22 2026] 127.0.0.1:59706 Accepted +[Mon Apr 20 10:43:24 2026] 127.0.0.1:59706 Closing +[Mon Apr 20 10:43:24 2026] 127.0.0.1:59712 Accepted +[Mon Apr 20 10:43:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:24 2026] 127.0.0.1:59712 Closing +[Mon Apr 20 10:43:24 2026] 127.0.0.1:59726 Accepted +[Mon Apr 20 10:43:26 2026] 127.0.0.1:59726 Closing +[Mon Apr 20 10:43:26 2026] 127.0.0.1:59736 Accepted +[Mon Apr 20 10:43:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:26 2026] 127.0.0.1:59736 Closing +[Mon Apr 20 10:43:26 2026] 127.0.0.1:59740 Accepted +[Mon Apr 20 10:43:28 2026] 127.0.0.1:59740 Closing +[Mon Apr 20 10:43:28 2026] 127.0.0.1:47088 Accepted +[Mon Apr 20 10:43:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:29 2026] 127.0.0.1:47088 Closing +[Mon Apr 20 10:43:29 2026] 127.0.0.1:47102 Accepted +[Mon Apr 20 10:43:30 2026] 127.0.0.1:47102 Closing +[Mon Apr 20 10:43:33 2026] 127.0.0.1:47112 Accepted +[Mon Apr 20 10:43:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:34 2026] 127.0.0.1:47112 Closing +[Mon Apr 20 10:43:35 2026] 127.0.0.1:47128 Accepted +[Mon Apr 20 10:43:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:35 2026] 127.0.0.1:47128 Closing +[Mon Apr 20 10:43:35 2026] 127.0.0.1:47130 Accepted +[Mon Apr 20 10:43:40 2026] 127.0.0.1:47130 Closing +[Mon Apr 20 10:43:45 2026] 127.0.0.1:42296 Accepted +[Mon Apr 20 10:43:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:45 2026] 127.0.0.1:42296 Closing +[Mon Apr 20 10:43:46 2026] 127.0.0.1:49210 Accepted +[Mon Apr 20 10:43:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:47 2026] 127.0.0.1:49210 Closing +[Mon Apr 20 10:43:47 2026] 127.0.0.1:49220 Accepted +[Mon Apr 20 10:43:50 2026] 127.0.0.1:49220 Closing +[Mon Apr 20 10:43:50 2026] 127.0.0.1:49230 Accepted +[Mon Apr 20 10:43:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:51 2026] 127.0.0.1:49230 Closing +[Mon Apr 20 10:43:51 2026] 127.0.0.1:49246 Accepted +[Mon Apr 20 10:43:54 2026] 127.0.0.1:49246 Closing +[Mon Apr 20 10:44:00 2026] 127.0.0.1:51194 Accepted +[Mon Apr 20 10:44:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:01 2026] 127.0.0.1:51194 Closing +[Mon Apr 20 10:44:01 2026] 127.0.0.1:51208 Accepted +[Mon Apr 20 10:44:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:02 2026] 127.0.0.1:51208 Closing +[Mon Apr 20 10:44:02 2026] 127.0.0.1:51218 Accepted +[Mon Apr 20 10:44:04 2026] 127.0.0.1:51218 Closing +[Mon Apr 20 10:44:04 2026] 127.0.0.1:51232 Accepted +[Mon Apr 20 10:44:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:05 2026] 127.0.0.1:51232 Closing +[Mon Apr 20 10:44:05 2026] 127.0.0.1:44314 Accepted +[Mon Apr 20 10:44:07 2026] 127.0.0.1:44314 Closing +[Mon Apr 20 10:44:07 2026] 127.0.0.1:44326 Accepted +[Mon Apr 20 10:44:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:07 2026] 127.0.0.1:44326 Closing +[Mon Apr 20 10:44:07 2026] 127.0.0.1:44336 Accepted +[Mon Apr 20 10:44:09 2026] 127.0.0.1:44336 Closing +[Mon Apr 20 10:44:09 2026] 127.0.0.1:44352 Accepted +[Mon Apr 20 10:44:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:10 2026] 127.0.0.1:44352 Closing +[Mon Apr 20 10:44:10 2026] 127.0.0.1:44364 Accepted +[Mon Apr 20 10:44:10 2026] 127.0.0.1:44364 Closing +[Mon Apr 20 10:44:10 2026] 127.0.0.1:44366 Accepted +[Mon Apr 20 10:44:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:11 2026] 127.0.0.1:44366 Closing +[Mon Apr 20 10:44:11 2026] 127.0.0.1:44374 Accepted +[Mon Apr 20 10:44:13 2026] 127.0.0.1:44374 Closing +[Mon Apr 20 10:44:13 2026] 127.0.0.1:44390 Accepted +[Mon Apr 20 10:44:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:14 2026] 127.0.0.1:44390 Closing +[Mon Apr 20 10:44:14 2026] 127.0.0.1:46196 Accepted +[Mon Apr 20 10:44:16 2026] 127.0.0.1:46196 Closing +[Mon Apr 20 10:44:21 2026] 127.0.0.1:46212 Accepted +[Mon Apr 20 10:44:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:22 2026] 127.0.0.1:46212 Closing +[Mon Apr 20 10:44:22 2026] 127.0.0.1:46216 Accepted +[Mon Apr 20 10:44:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:23 2026] 127.0.0.1:46216 Closing +[Mon Apr 20 10:44:23 2026] 127.0.0.1:46220 Accepted +[Mon Apr 20 10:44:25 2026] 127.0.0.1:46220 Closing +[Mon Apr 20 10:44:25 2026] 127.0.0.1:56900 Accepted +[Mon Apr 20 10:44:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:25 2026] 127.0.0.1:56900 Closing +[Mon Apr 20 10:44:25 2026] 127.0.0.1:56916 Accepted +[Mon Apr 20 10:44:26 2026] 127.0.0.1:56916 Closing +[Mon Apr 20 10:44:29 2026] 127.0.0.1:56928 Accepted +[Mon Apr 20 10:44:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:29 2026] 127.0.0.1:56928 Closing +[Mon Apr 20 10:44:30 2026] 127.0.0.1:56940 Accepted +[Mon Apr 20 10:44:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:31 2026] 127.0.0.1:56940 Closing +[Mon Apr 20 10:44:31 2026] 127.0.0.1:56954 Accepted +[Mon Apr 20 10:44:35 2026] 127.0.0.1:56954 Closing +[Mon Apr 20 10:44:40 2026] 127.0.0.1:53172 Accepted +[Mon Apr 20 10:44:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:40 2026] 127.0.0.1:53172 Closing +[Mon Apr 20 10:44:41 2026] 127.0.0.1:53178 Accepted +[Mon Apr 20 10:44:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:42 2026] 127.0.0.1:53178 Closing +[Mon Apr 20 10:44:42 2026] 127.0.0.1:53186 Accepted +[Mon Apr 20 10:44:45 2026] 127.0.0.1:53186 Closing +[Mon Apr 20 10:44:45 2026] 127.0.0.1:42460 Accepted +[Mon Apr 20 10:44:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:45 2026] 127.0.0.1:42460 Closing +[Mon Apr 20 10:44:45 2026] 127.0.0.1:42472 Accepted +[Mon Apr 20 10:44:47 2026] 127.0.0.1:42472 Closing +[Mon Apr 20 10:44:47 2026] 127.0.0.1:42478 Accepted +[Mon Apr 20 10:44:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:47 2026] 127.0.0.1:42478 Closing +[Mon Apr 20 10:44:47 2026] 127.0.0.1:42494 Accepted +[Mon Apr 20 10:44:49 2026] 127.0.0.1:42494 Closing +[Mon Apr 20 10:44:49 2026] 127.0.0.1:42510 Accepted +[Mon Apr 20 10:44:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:50 2026] 127.0.0.1:42510 Closing +[Mon Apr 20 10:44:50 2026] 127.0.0.1:42526 Accepted +[Mon Apr 20 10:44:51 2026] 127.0.0.1:42526 Closing +[Mon Apr 20 10:44:51 2026] 127.0.0.1:42538 Accepted +[Mon Apr 20 10:44:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:52 2026] 127.0.0.1:42538 Closing +[Mon Apr 20 10:44:52 2026] 127.0.0.1:42540 Accepted +[Mon Apr 20 10:44:54 2026] 127.0.0.1:42540 Closing +[Mon Apr 20 10:44:54 2026] 127.0.0.1:48174 Accepted +[Mon Apr 20 10:44:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:54 2026] 127.0.0.1:48174 Closing +[Mon Apr 20 10:44:54 2026] 127.0.0.1:48190 Accepted +[Mon Apr 20 10:44:56 2026] 127.0.0.1:48190 Closing +[Mon Apr 20 10:45:01 2026] 127.0.0.1:48202 Accepted +[Mon Apr 20 10:45:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:01 2026] 127.0.0.1:48202 Closing +[Mon Apr 20 10:45:02 2026] 127.0.0.1:48206 Accepted +[Mon Apr 20 10:45:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:02 2026] 127.0.0.1:48206 Closing +[Mon Apr 20 10:45:02 2026] 127.0.0.1:57254 Accepted +[Mon Apr 20 10:45:04 2026] 127.0.0.1:57254 Closing +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57264 Accepted +[Mon Apr 20 10:45:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57264 Closing +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57280 Accepted +[Mon Apr 20 10:45:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57280 Closing +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57292 Accepted +[Mon Apr 20 10:45:09 2026] 127.0.0.1:57292 Closing +[Mon Apr 20 10:45:09 2026] 127.0.0.1:57308 Accepted +[Mon Apr 20 10:45:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:09 2026] 127.0.0.1:57308 Closing +[Mon Apr 20 10:45:09 2026] 127.0.0.1:57316 Accepted +[Mon Apr 20 10:45:10 2026] 127.0.0.1:57316 Closing +[Mon Apr 20 10:45:16 2026] 127.0.0.1:54576 Accepted +[Mon Apr 20 10:45:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:17 2026] 127.0.0.1:54576 Closing +[Mon Apr 20 10:45:19 2026] 127.0.0.1:54578 Accepted +[Mon Apr 20 10:45:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:20 2026] 127.0.0.1:54578 Closing +[Mon Apr 20 10:45:20 2026] 127.0.0.1:54584 Accepted +[Mon Apr 20 10:45:23 2026] 127.0.0.1:54584 Closing +[Mon Apr 20 10:45:28 2026] 127.0.0.1:33398 Accepted +[Mon Apr 20 10:45:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:29 2026] 127.0.0.1:33398 Closing +[Mon Apr 20 10:45:29 2026] 127.0.0.1:33400 Accepted +[Mon Apr 20 10:45:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:30 2026] 127.0.0.1:33400 Closing +[Mon Apr 20 10:45:30 2026] 127.0.0.1:33408 Accepted +[Mon Apr 20 10:45:32 2026] 127.0.0.1:33408 Closing +[Mon Apr 20 10:45:32 2026] 127.0.0.1:45670 Accepted +[Mon Apr 20 10:45:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:32 2026] 127.0.0.1:45670 Closing +[Mon Apr 20 10:45:32 2026] 127.0.0.1:45684 Accepted +[Mon Apr 20 10:45:34 2026] 127.0.0.1:45684 Closing +[Tue Apr 21 08:04:06 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 08:04:06 2026] 127.0.0.1:43372 Accepted +[Tue Apr 21 08:04:07 2026] 127.0.0.1:43372 Closing +[Tue Apr 21 08:04:09 2026] 127.0.0.1:33384 Accepted +[Tue Apr 21 08:04:12 2026] 127.0.0.1:33384 Closing +[Tue Apr 21 08:04:20 2026] 127.0.0.1:43330 Accepted +[Tue Apr 21 08:04:24 2026] 127.0.0.1:43330 Closing +[Tue Apr 21 08:04:29 2026] 127.0.0.1:54202 Accepted +[Tue Apr 21 08:04:30 2026] 127.0.0.1:54202 Closing +[Tue Apr 21 08:04:30 2026] 127.0.0.1:54210 Accepted +[Tue Apr 21 08:04:32 2026] 127.0.0.1:54210 Closing +[Tue Apr 21 08:04:33 2026] 127.0.0.1:54224 Accepted +[Tue Apr 21 08:04:38 2026] 127.0.0.1:54224 Closing +[Tue Apr 21 08:04:42 2026] 127.0.0.1:53938 Accepted +[Tue Apr 21 08:04:43 2026] 127.0.0.1:53938 Closing +[Tue Apr 21 08:04:43 2026] 127.0.0.1:53946 Accepted +[Tue Apr 21 08:04:44 2026] 127.0.0.1:53946 Closing +[Tue Apr 21 08:04:44 2026] 127.0.0.1:53954 Accepted +[Tue Apr 21 08:04:46 2026] 127.0.0.1:53954 Closing +[Tue Apr 21 08:04:46 2026] 127.0.0.1:55356 Accepted +[Tue Apr 21 08:04:47 2026] 127.0.0.1:55356 Closing +[Tue Apr 21 08:04:52 2026] 127.0.0.1:55362 Accepted +[Tue Apr 21 08:04:56 2026] 127.0.0.1:55362 Closing +[Tue Apr 21 08:05:01 2026] 127.0.0.1:49600 Accepted +[Tue Apr 21 08:05:05 2026] 127.0.0.1:49600 Closing +[Tue Apr 21 08:05:05 2026] 127.0.0.1:33086 Accepted +[Tue Apr 21 08:05:08 2026] 127.0.0.1:33086 Closing +[Tue Apr 21 08:05:15 2026] 127.0.0.1:48110 Accepted +[Tue Apr 21 08:05:18 2026] 127.0.0.1:48110 Closing +[Tue Apr 21 08:05:18 2026] 127.0.0.1:48122 Accepted +[Tue Apr 21 08:05:19 2026] 127.0.0.1:48122 Closing +[Tue Apr 21 08:05:19 2026] 127.0.0.1:48138 Accepted +[Tue Apr 21 08:05:21 2026] 127.0.0.1:48138 Closing +[Tue Apr 21 08:05:21 2026] 127.0.0.1:48152 Accepted +[Tue Apr 21 08:05:24 2026] 127.0.0.1:48152 Closing +[Tue Apr 21 08:05:24 2026] 127.0.0.1:48154 Accepted +[Tue Apr 21 08:05:26 2026] 127.0.0.1:48154 Closing +[Tue Apr 21 08:05:26 2026] 127.0.0.1:58460 Accepted +[Tue Apr 21 08:05:28 2026] 127.0.0.1:58460 Closing +[Tue Apr 21 08:05:31 2026] 127.0.0.1:58462 Accepted +[Tue Apr 21 08:05:33 2026] 127.0.0.1:58462 Closing +[Tue Apr 21 08:05:33 2026] 127.0.0.1:40606 Accepted +[Tue Apr 21 08:05:34 2026] 127.0.0.1:40606 Closing +[Tue Apr 21 08:05:37 2026] 127.0.0.1:40616 Accepted +[Tue Apr 21 08:05:42 2026] 127.0.0.1:40616 Closing +[Tue Apr 21 08:05:49 2026] 127.0.0.1:58290 Accepted +[Tue Apr 21 08:05:51 2026] 127.0.0.1:58290 Closing +[Tue Apr 21 08:05:51 2026] 127.0.0.1:58298 Accepted +[Tue Apr 21 08:05:53 2026] 127.0.0.1:58298 Closing +[Tue Apr 21 08:05:53 2026] 127.0.0.1:57162 Accepted +[Tue Apr 21 08:05:55 2026] 127.0.0.1:57162 Closing +[Tue Apr 21 08:05:55 2026] 127.0.0.1:57178 Accepted +[Tue Apr 21 08:05:55 2026] 127.0.0.1:57178 Closing +[Tue Apr 21 08:05:55 2026] 127.0.0.1:57188 Accepted +[Tue Apr 21 08:05:57 2026] 127.0.0.1:57188 Closing +[Tue Apr 21 08:05:57 2026] 127.0.0.1:57192 Accepted +[Tue Apr 21 08:05:59 2026] 127.0.0.1:57192 Closing +[Tue Apr 21 08:06:05 2026] 127.0.0.1:38046 Accepted +[Tue Apr 21 08:06:06 2026] 127.0.0.1:38046 Closing +[Tue Apr 21 08:06:09 2026] 127.0.0.1:38056 Accepted +[Tue Apr 21 08:06:11 2026] 127.0.0.1:38056 Closing +[Tue Apr 21 08:06:11 2026] 127.0.0.1:50508 Accepted +[Tue Apr 21 08:06:13 2026] 127.0.0.1:50508 Closing +[Tue Apr 21 08:06:18 2026] 127.0.0.1:50514 Accepted +[Tue Apr 21 08:06:21 2026] 127.0.0.1:50514 Closing +[Tue Apr 21 08:06:24 2026] 127.0.0.1:37216 Accepted +[Tue Apr 21 08:06:26 2026] 127.0.0.1:37216 Closing +[Tue Apr 21 08:06:26 2026] 127.0.0.1:37228 Accepted +[Tue Apr 21 08:06:27 2026] 127.0.0.1:37228 Closing +[Tue Apr 21 08:08:54 2026] 127.0.0.1:48696 Accepted +[Tue Apr 21 08:08:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:08:55 2026] 127.0.0.1:48696 Closing +[Tue Apr 21 08:08:56 2026] 127.0.0.1:48712 Accepted +[Tue Apr 21 08:08:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:08:57 2026] 127.0.0.1:48712 Closing +[Tue Apr 21 08:08:57 2026] 127.0.0.1:47894 Accepted +[Tue Apr 21 08:09:00 2026] 127.0.0.1:47894 Closing +[Tue Apr 21 08:09:07 2026] 127.0.0.1:37224 Accepted +[Tue Apr 21 08:09:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:07 2026] 127.0.0.1:37224 Closing +[Tue Apr 21 08:09:08 2026] 127.0.0.1:37236 Accepted +[Tue Apr 21 08:09:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:09 2026] 127.0.0.1:37236 Closing +[Tue Apr 21 08:09:09 2026] 127.0.0.1:37246 Accepted +[Tue Apr 21 08:09:11 2026] 127.0.0.1:37246 Closing +[Tue Apr 21 08:09:16 2026] 127.0.0.1:58096 Accepted +[Tue Apr 21 08:09:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:16 2026] 127.0.0.1:58096 Closing +[Tue Apr 21 08:09:16 2026] 127.0.0.1:58110 Accepted +[Tue Apr 21 08:09:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:17 2026] 127.0.0.1:58110 Closing +[Tue Apr 21 08:09:17 2026] 127.0.0.1:58112 Accepted +[Tue Apr 21 08:09:18 2026] 127.0.0.1:58112 Closing +[Tue Apr 21 08:09:18 2026] 127.0.0.1:58126 Accepted +[Tue Apr 21 08:09:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:19 2026] 127.0.0.1:58126 Closing +[Tue Apr 21 08:09:19 2026] 127.0.0.1:58134 Accepted +[Tue Apr 21 08:09:21 2026] 127.0.0.1:58134 Closing +[Tue Apr 21 08:09:23 2026] 127.0.0.1:58138 Accepted +[Tue Apr 21 08:09:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:24 2026] 127.0.0.1:58138 Closing +[Tue Apr 21 08:09:24 2026] 127.0.0.1:49622 Accepted +[Tue Apr 21 08:09:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:25 2026] 127.0.0.1:49622 Closing +[Tue Apr 21 08:09:25 2026] 127.0.0.1:49628 Accepted +[Tue Apr 21 08:09:29 2026] 127.0.0.1:49628 Closing +[Tue Apr 21 08:09:33 2026] 127.0.0.1:49630 Accepted +[Tue Apr 21 08:09:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:33 2026] 127.0.0.1:49630 Closing +[Tue Apr 21 08:09:34 2026] 127.0.0.1:49644 Accepted +[Tue Apr 21 08:09:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:34 2026] 127.0.0.1:49644 Closing +[Tue Apr 21 08:09:34 2026] 127.0.0.1:49652 Accepted +[Tue Apr 21 08:09:35 2026] 127.0.0.1:49652 Closing +[Tue Apr 21 08:09:35 2026] 127.0.0.1:49728 Accepted +[Tue Apr 21 08:09:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:36 2026] 127.0.0.1:49728 Closing +[Tue Apr 21 08:09:36 2026] 127.0.0.1:49736 Accepted +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49736 Closing +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49738 Accepted +[Tue Apr 21 08:09:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49738 Closing +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49740 Accepted +[Tue Apr 21 08:09:39 2026] 127.0.0.1:49740 Closing +[Tue Apr 21 08:09:39 2026] 127.0.0.1:49746 Accepted +[Tue Apr 21 08:09:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49746 Closing +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49760 Accepted +[Tue Apr 21 08:09:39 2026] 127.0.0.1:49760 Closing +[Tue Apr 21 08:09:42 2026] 127.0.0.1:38138 Accepted +[Tue Apr 21 08:09:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:43 2026] 127.0.0.1:38138 Closing +[Tue Apr 21 08:09:44 2026] 127.0.0.1:38140 Accepted +[Tue Apr 21 08:09:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:44 2026] 127.0.0.1:38140 Closing +[Tue Apr 21 08:09:44 2026] 127.0.0.1:38144 Accepted +[Tue Apr 21 08:09:49 2026] 127.0.0.1:38144 Closing +[Tue Apr 21 08:09:55 2026] 127.0.0.1:43494 Accepted +[Tue Apr 21 08:09:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:56 2026] 127.0.0.1:43494 Closing +[Tue Apr 21 08:09:57 2026] 127.0.0.1:43504 Accepted +[Tue Apr 21 08:09:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:57 2026] 127.0.0.1:43504 Closing +[Tue Apr 21 08:09:57 2026] 127.0.0.1:43508 Accepted +[Tue Apr 21 08:10:01 2026] 127.0.0.1:43508 Closing +[Tue Apr 21 08:10:01 2026] 127.0.0.1:43520 Accepted +[Tue Apr 21 08:10:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:01 2026] 127.0.0.1:43520 Closing +[Tue Apr 21 08:10:01 2026] 127.0.0.1:43536 Accepted +[Tue Apr 21 08:10:04 2026] 127.0.0.1:43536 Closing +[Tue Apr 21 08:10:08 2026] 127.0.0.1:56320 Accepted +[Tue Apr 21 08:10:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:09 2026] 127.0.0.1:56320 Closing +[Tue Apr 21 08:10:10 2026] 127.0.0.1:32938 Accepted +[Tue Apr 21 08:10:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:10 2026] 127.0.0.1:32938 Closing +[Tue Apr 21 08:10:10 2026] 127.0.0.1:32950 Accepted +[Tue Apr 21 08:10:12 2026] 127.0.0.1:32950 Closing +[Tue Apr 21 08:10:19 2026] 127.0.0.1:32952 Accepted +[Tue Apr 21 08:10:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:19 2026] 127.0.0.1:32952 Closing +[Tue Apr 21 08:10:20 2026] 127.0.0.1:56604 Accepted +[Tue Apr 21 08:10:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:20 2026] 127.0.0.1:56604 Closing +[Tue Apr 21 08:10:20 2026] 127.0.0.1:56610 Accepted +[Tue Apr 21 08:10:23 2026] 127.0.0.1:56610 Closing +[Tue Apr 21 08:10:28 2026] 127.0.0.1:56626 Accepted +[Tue Apr 21 08:10:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:28 2026] 127.0.0.1:56626 Closing +[Tue Apr 21 08:10:29 2026] 127.0.0.1:56632 Accepted +[Tue Apr 21 08:10:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:29 2026] 127.0.0.1:56632 Closing +[Tue Apr 21 08:10:29 2026] 127.0.0.1:56636 Accepted +[Tue Apr 21 08:10:32 2026] 127.0.0.1:56636 Closing +[Tue Apr 21 08:10:32 2026] 127.0.0.1:51558 Accepted +[Tue Apr 21 08:10:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:32 2026] 127.0.0.1:51558 Closing +[Tue Apr 21 08:10:32 2026] 127.0.0.1:51570 Accepted +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51570 Closing +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51582 Accepted +[Tue Apr 21 08:10:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51582 Closing +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51586 Accepted +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51586 Closing +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51590 Accepted +[Tue Apr 21 08:10:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:35 2026] 127.0.0.1:51590 Closing +[Tue Apr 21 08:10:35 2026] 127.0.0.1:51598 Accepted +[Tue Apr 21 08:10:37 2026] 127.0.0.1:51598 Closing +[Tue Apr 21 08:10:37 2026] 127.0.0.1:51612 Accepted +[Tue Apr 21 08:10:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:37 2026] 127.0.0.1:51612 Closing +[Tue Apr 21 08:10:37 2026] 127.0.0.1:51628 Accepted +[Tue Apr 21 08:10:40 2026] 127.0.0.1:51628 Closing +[Tue Apr 21 08:10:40 2026] 127.0.0.1:45048 Accepted +[Tue Apr 21 08:10:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:40 2026] 127.0.0.1:45048 Closing +[Tue Apr 21 08:10:40 2026] 127.0.0.1:45054 Accepted +[Tue Apr 21 08:10:42 2026] 127.0.0.1:45054 Closing +[Tue Apr 21 08:10:47 2026] 127.0.0.1:45062 Accepted +[Tue Apr 21 08:10:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:48 2026] 127.0.0.1:45062 Closing +[Tue Apr 21 08:10:48 2026] 127.0.0.1:48950 Accepted +[Tue Apr 21 08:10:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:48 2026] 127.0.0.1:48950 Closing +[Tue Apr 21 08:10:48 2026] 127.0.0.1:48958 Accepted +[Tue Apr 21 08:10:50 2026] 127.0.0.1:48958 Closing +[Tue Apr 21 08:10:50 2026] 127.0.0.1:48966 Accepted +[Tue Apr 21 08:10:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:51 2026] 127.0.0.1:48966 Closing +[Tue Apr 21 08:10:51 2026] 127.0.0.1:48982 Accepted +[Tue Apr 21 08:10:52 2026] 127.0.0.1:48982 Closing +[Tue Apr 21 08:10:54 2026] 127.0.0.1:48988 Accepted +[Tue Apr 21 08:10:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:55 2026] 127.0.0.1:48988 Closing +[Tue Apr 21 08:10:56 2026] 127.0.0.1:48994 Accepted +[Tue Apr 21 08:10:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:56 2026] 127.0.0.1:48994 Closing +[Tue Apr 21 08:10:56 2026] 127.0.0.1:48996 Accepted +[Tue Apr 21 08:11:01 2026] 127.0.0.1:48996 Closing +[Tue Apr 21 08:11:05 2026] 127.0.0.1:35086 Accepted +[Tue Apr 21 08:11:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:05 2026] 127.0.0.1:35086 Closing +[Tue Apr 21 08:11:06 2026] 127.0.0.1:52666 Accepted +[Tue Apr 21 08:11:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:06 2026] 127.0.0.1:52666 Closing +[Tue Apr 21 08:11:06 2026] 127.0.0.1:52670 Accepted +[Tue Apr 21 08:11:09 2026] 127.0.0.1:52670 Closing +[Tue Apr 21 08:11:09 2026] 127.0.0.1:52686 Accepted +[Tue Apr 21 08:11:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:09 2026] 127.0.0.1:52686 Closing +[Tue Apr 21 08:11:09 2026] 127.0.0.1:52698 Accepted +[Tue Apr 21 08:11:11 2026] 127.0.0.1:52698 Closing +[Tue Apr 21 08:11:11 2026] 127.0.0.1:52706 Accepted +[Tue Apr 21 08:11:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:12 2026] 127.0.0.1:52706 Closing +[Tue Apr 21 08:11:12 2026] 127.0.0.1:52708 Accepted +[Tue Apr 21 08:11:14 2026] 127.0.0.1:52708 Closing +[Tue Apr 21 08:11:14 2026] 127.0.0.1:52716 Accepted +[Tue Apr 21 08:11:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:14 2026] 127.0.0.1:52716 Closing +[Tue Apr 21 08:11:14 2026] 127.0.0.1:52722 Accepted +[Tue Apr 21 08:11:17 2026] 127.0.0.1:52722 Closing +[Tue Apr 21 08:11:17 2026] 127.0.0.1:40638 Accepted +[Tue Apr 21 08:11:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:17 2026] 127.0.0.1:40638 Closing +[Tue Apr 21 08:11:17 2026] 127.0.0.1:40644 Accepted +[Tue Apr 21 08:11:20 2026] 127.0.0.1:40644 Closing +[Tue Apr 21 08:11:20 2026] 127.0.0.1:40652 Accepted +[Tue Apr 21 08:11:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:20 2026] 127.0.0.1:40652 Closing +[Tue Apr 21 08:11:20 2026] 127.0.0.1:40658 Accepted +[Tue Apr 21 08:11:23 2026] 127.0.0.1:40658 Closing +[Tue Apr 21 08:11:28 2026] 127.0.0.1:57136 Accepted +[Tue Apr 21 08:11:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:28 2026] 127.0.0.1:57136 Closing +[Tue Apr 21 08:11:28 2026] 127.0.0.1:57142 Accepted +[Tue Apr 21 08:11:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:29 2026] 127.0.0.1:57142 Closing +[Tue Apr 21 08:11:29 2026] 127.0.0.1:57148 Accepted +[Tue Apr 21 08:11:31 2026] 127.0.0.1:57148 Closing +[Tue Apr 21 08:11:31 2026] 127.0.0.1:57164 Accepted +[Tue Apr 21 08:11:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:31 2026] 127.0.0.1:57164 Closing +[Tue Apr 21 08:11:32 2026] 127.0.0.1:57170 Accepted +[Tue Apr 21 08:11:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:33 2026] 127.0.0.1:57170 Closing +[Tue Apr 21 08:11:33 2026] 127.0.0.1:57172 Accepted +[Tue Apr 21 08:11:35 2026] 127.0.0.1:57172 Closing +[Tue Apr 21 08:11:35 2026] 127.0.0.1:46746 Accepted +[Tue Apr 21 08:11:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:35 2026] 127.0.0.1:46746 Closing +[Tue Apr 21 08:11:35 2026] 127.0.0.1:46756 Accepted +[Tue Apr 21 08:11:37 2026] 127.0.0.1:46756 Closing +[Tue Apr 21 08:11:42 2026] 127.0.0.1:46764 Accepted +[Tue Apr 21 08:11:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:42 2026] 127.0.0.1:46764 Closing +[Tue Apr 21 08:11:43 2026] 127.0.0.1:46768 Accepted +[Tue Apr 21 08:11:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:43 2026] 127.0.0.1:46768 Closing +[Tue Apr 21 08:11:43 2026] 127.0.0.1:41290 Accepted +[Tue Apr 21 08:11:46 2026] 127.0.0.1:41290 Closing +[Tue Apr 21 08:11:51 2026] 127.0.0.1:41292 Accepted +[Tue Apr 21 08:11:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:51 2026] 127.0.0.1:41292 Closing +[Tue Apr 21 08:11:52 2026] 127.0.0.1:41294 Accepted +[Tue Apr 21 08:11:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:52 2026] 127.0.0.1:41294 Closing +[Tue Apr 21 08:11:52 2026] 127.0.0.1:41306 Accepted +[Tue Apr 21 08:11:54 2026] 127.0.0.1:41306 Closing +[Tue Apr 21 08:11:54 2026] 127.0.0.1:59510 Accepted +[Tue Apr 21 08:11:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:54 2026] 127.0.0.1:59510 Closing +[Tue Apr 21 08:11:54 2026] 127.0.0.1:59520 Accepted +[Tue Apr 21 08:11:56 2026] 127.0.0.1:59520 Closing +[Tue Apr 21 08:12:26 2026] 127.0.0.1:55620 Accepted +[Tue Apr 21 08:12:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:26 2026] 127.0.0.1:55620 Closing +[Tue Apr 21 08:12:25 2026] 127.0.0.1:55636 Accepted +[Tue Apr 21 08:12:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:26 2026] 127.0.0.1:55636 Closing +[Tue Apr 21 08:12:26 2026] 127.0.0.1:55642 Accepted +[Tue Apr 21 08:12:29 2026] 127.0.0.1:55642 Closing +[Tue Apr 21 08:12:36 2026] 127.0.0.1:58532 Accepted +[Tue Apr 21 08:12:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:36 2026] 127.0.0.1:58532 Closing +[Tue Apr 21 08:12:37 2026] 127.0.0.1:58540 Accepted +[Tue Apr 21 08:12:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:37 2026] 127.0.0.1:58540 Closing +[Tue Apr 21 08:12:37 2026] 127.0.0.1:58550 Accepted +[Tue Apr 21 08:12:41 2026] 127.0.0.1:58550 Closing +[Tue Apr 21 08:12:47 2026] 127.0.0.1:50978 Accepted +[Tue Apr 21 08:12:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:47 2026] 127.0.0.1:50978 Closing +[Tue Apr 21 08:12:47 2026] 127.0.0.1:50988 Accepted +[Tue Apr 21 08:12:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:48 2026] 127.0.0.1:50988 Closing +[Tue Apr 21 08:12:48 2026] 127.0.0.1:51000 Accepted +[Tue Apr 21 08:12:48 2026] 127.0.0.1:51000 Closing +[Tue Apr 21 08:12:48 2026] 127.0.0.1:51002 Accepted +[Tue Apr 21 08:12:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:49 2026] 127.0.0.1:51002 Closing +[Tue Apr 21 08:12:49 2026] 127.0.0.1:51010 Accepted +[Tue Apr 21 08:12:51 2026] 127.0.0.1:51010 Closing +[Tue Apr 21 08:12:53 2026] 127.0.0.1:40380 Accepted +[Tue Apr 21 08:12:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:53 2026] 127.0.0.1:40380 Closing +[Tue Apr 21 08:12:54 2026] 127.0.0.1:40390 Accepted +[Tue Apr 21 08:12:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:52 2026] 127.0.0.1:40390 Closing +[Tue Apr 21 08:12:52 2026] 127.0.0.1:40402 Accepted +[Tue Apr 21 08:12:57 2026] 127.0.0.1:40402 Closing +[Tue Apr 21 08:13:00 2026] 127.0.0.1:38826 Accepted +[Tue Apr 21 08:13:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:01 2026] 127.0.0.1:38826 Closing +[Tue Apr 21 08:13:02 2026] 127.0.0.1:38834 Accepted +[Tue Apr 21 08:13:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:02 2026] 127.0.0.1:38834 Closing +[Tue Apr 21 08:13:02 2026] 127.0.0.1:38850 Accepted +[Tue Apr 21 08:13:04 2026] 127.0.0.1:38850 Closing +[Tue Apr 21 08:13:04 2026] 127.0.0.1:38852 Accepted +[Tue Apr 21 08:13:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:05 2026] 127.0.0.1:38852 Closing +[Tue Apr 21 08:13:05 2026] 127.0.0.1:38864 Accepted +[Tue Apr 21 08:13:06 2026] 127.0.0.1:38864 Closing +[Tue Apr 21 08:13:06 2026] 127.0.0.1:38874 Accepted +[Tue Apr 21 08:13:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:07 2026] 127.0.0.1:38874 Closing +[Tue Apr 21 08:13:07 2026] 127.0.0.1:38878 Accepted +[Tue Apr 21 08:13:08 2026] 127.0.0.1:38878 Closing +[Tue Apr 21 08:13:09 2026] 127.0.0.1:58064 Accepted +[Tue Apr 21 08:13:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:09 2026] 127.0.0.1:58064 Closing +[Tue Apr 21 08:13:09 2026] 127.0.0.1:58072 Accepted +[Tue Apr 21 08:13:12 2026] 127.0.0.1:58072 Closing +[Tue Apr 21 08:13:16 2026] 127.0.0.1:58080 Accepted +[Tue Apr 21 08:13:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:18 2026] 127.0.0.1:58080 Closing +[Tue Apr 21 08:13:19 2026] 127.0.0.1:55852 Accepted +[Tue Apr 21 08:13:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:19 2026] 127.0.0.1:55852 Closing +[Tue Apr 21 08:13:19 2026] 127.0.0.1:55856 Accepted +[Tue Apr 21 08:13:21 2026] 127.0.0.1:55856 Closing +[Tue Apr 21 08:13:28 2026] 127.0.0.1:50716 Accepted +[Tue Apr 21 08:13:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:28 2026] 127.0.0.1:50716 Closing +[Tue Apr 21 08:13:29 2026] 127.0.0.1:50730 Accepted +[Tue Apr 21 08:13:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:29 2026] 127.0.0.1:50730 Closing +[Tue Apr 21 08:13:29 2026] 127.0.0.1:50740 Accepted +[Tue Apr 21 08:13:33 2026] 127.0.0.1:50740 Closing +[Tue Apr 21 08:13:33 2026] 127.0.0.1:50742 Accepted +[Tue Apr 21 08:13:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:33 2026] 127.0.0.1:50742 Closing +[Tue Apr 21 08:13:33 2026] 127.0.0.1:50746 Accepted +[Tue Apr 21 08:13:36 2026] 127.0.0.1:50746 Closing +[Tue Apr 21 08:13:42 2026] 127.0.0.1:47172 Accepted +[Tue Apr 21 08:13:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:43 2026] 127.0.0.1:47172 Closing +[Tue Apr 21 08:13:44 2026] 127.0.0.1:47186 Accepted +[Tue Apr 21 08:13:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:44 2026] 127.0.0.1:47186 Closing +[Tue Apr 21 08:13:44 2026] 127.0.0.1:47202 Accepted +[Tue Apr 21 08:13:47 2026] 127.0.0.1:47202 Closing +[Tue Apr 21 08:13:51 2026] 127.0.0.1:53600 Accepted +[Tue Apr 21 08:13:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:51 2026] 127.0.0.1:53600 Closing +[Tue Apr 21 08:13:52 2026] 127.0.0.1:53616 Accepted +[Tue Apr 21 08:13:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:53 2026] 127.0.0.1:53616 Closing +[Tue Apr 21 08:13:53 2026] 127.0.0.1:53896 Accepted +[Tue Apr 21 08:13:55 2026] 127.0.0.1:53896 Closing +[Tue Apr 21 08:14:00 2026] 127.0.0.1:53900 Accepted +[Tue Apr 21 08:14:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:00 2026] 127.0.0.1:53900 Closing +[Tue Apr 21 08:14:01 2026] 127.0.0.1:53916 Accepted +[Tue Apr 21 08:14:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:01 2026] 127.0.0.1:53916 Closing +[Tue Apr 21 08:14:01 2026] 127.0.0.1:53930 Accepted +[Tue Apr 21 08:14:04 2026] 127.0.0.1:53930 Closing +[Tue Apr 21 08:14:04 2026] 127.0.0.1:53274 Accepted +[Tue Apr 21 08:14:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:04 2026] 127.0.0.1:53274 Closing +[Tue Apr 21 08:14:04 2026] 127.0.0.1:53290 Accepted +[Tue Apr 21 08:14:06 2026] 127.0.0.1:53290 Closing +[Tue Apr 21 08:14:06 2026] 127.0.0.1:53300 Accepted +[Tue Apr 21 08:14:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:07 2026] 127.0.0.1:53300 Closing +[Tue Apr 21 08:14:07 2026] 127.0.0.1:53316 Accepted +[Tue Apr 21 08:14:09 2026] 127.0.0.1:53316 Closing +[Tue Apr 21 08:14:09 2026] 127.0.0.1:53326 Accepted +[Tue Apr 21 08:14:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:09 2026] 127.0.0.1:53326 Closing +[Tue Apr 21 08:14:09 2026] 127.0.0.1:53330 Accepted +[Tue Apr 21 08:14:11 2026] 127.0.0.1:53330 Closing +[Tue Apr 21 08:14:11 2026] 127.0.0.1:53336 Accepted +[Tue Apr 21 08:14:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:11 2026] 127.0.0.1:53336 Closing +[Tue Apr 21 08:14:11 2026] 127.0.0.1:53338 Accepted +[Tue Apr 21 08:14:13 2026] 127.0.0.1:53338 Closing +[Tue Apr 21 08:14:13 2026] 127.0.0.1:37034 Accepted +[Tue Apr 21 08:14:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:14 2026] 127.0.0.1:37034 Closing +[Tue Apr 21 08:14:14 2026] 127.0.0.1:37050 Accepted +[Tue Apr 21 08:14:16 2026] 127.0.0.1:37050 Closing +[Tue Apr 21 08:14:19 2026] 127.0.0.1:37054 Accepted +[Tue Apr 21 08:14:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:19 2026] 127.0.0.1:37054 Closing +[Tue Apr 21 08:14:19 2026] 127.0.0.1:37058 Accepted +[Tue Apr 21 08:14:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:20 2026] 127.0.0.1:37058 Closing +[Tue Apr 21 08:14:20 2026] 127.0.0.1:37068 Accepted +[Tue Apr 21 08:14:21 2026] 127.0.0.1:37068 Closing +[Tue Apr 21 08:14:21 2026] 127.0.0.1:38562 Accepted +[Tue Apr 21 08:14:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:21 2026] 127.0.0.1:38562 Closing +[Tue Apr 21 08:14:21 2026] 127.0.0.1:38578 Accepted +[Tue Apr 21 08:14:23 2026] 127.0.0.1:38578 Closing +[Tue Apr 21 08:14:25 2026] 127.0.0.1:38584 Accepted +[Tue Apr 21 08:14:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:25 2026] 127.0.0.1:38584 Closing +[Tue Apr 21 08:14:26 2026] 127.0.0.1:38600 Accepted +[Tue Apr 21 08:14:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:27 2026] 127.0.0.1:38600 Closing +[Tue Apr 21 08:14:27 2026] 127.0.0.1:38608 Accepted +[Tue Apr 21 08:14:31 2026] 127.0.0.1:38608 Closing +[Tue Apr 21 08:14:37 2026] 127.0.0.1:38676 Accepted +[Tue Apr 21 08:14:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:37 2026] 127.0.0.1:38676 Closing +[Tue Apr 21 08:14:38 2026] 127.0.0.1:38678 Accepted +[Tue Apr 21 08:14:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:39 2026] 127.0.0.1:38678 Closing +[Tue Apr 21 08:14:39 2026] 127.0.0.1:38690 Accepted +[Tue Apr 21 08:14:41 2026] 127.0.0.1:38690 Closing +[Tue Apr 21 08:14:41 2026] 127.0.0.1:36158 Accepted +[Tue Apr 21 08:14:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:42 2026] 127.0.0.1:36158 Closing +[Tue Apr 21 08:14:42 2026] 127.0.0.1:36174 Accepted +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36174 Closing +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36190 Accepted +[Tue Apr 21 08:14:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36190 Closing +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36198 Accepted +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36198 Closing +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36210 Accepted +[Tue Apr 21 08:14:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36210 Closing +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36218 Accepted +[Tue Apr 21 08:14:46 2026] 127.0.0.1:36218 Closing +[Tue Apr 21 08:14:46 2026] 127.0.0.1:36234 Accepted +[Tue Apr 21 08:14:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:46 2026] 127.0.0.1:36234 Closing +[Tue Apr 21 08:14:46 2026] 127.0.0.1:36250 Accepted +[Tue Apr 21 08:14:48 2026] 127.0.0.1:36250 Closing +[Tue Apr 21 08:14:48 2026] 127.0.0.1:54868 Accepted +[Tue Apr 21 08:14:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:49 2026] 127.0.0.1:54868 Closing +[Tue Apr 21 08:14:49 2026] 127.0.0.1:54872 Accepted +[Tue Apr 21 08:14:51 2026] 127.0.0.1:54872 Closing +[Tue Apr 21 08:14:56 2026] 127.0.0.1:54874 Accepted +[Tue Apr 21 08:14:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:56 2026] 127.0.0.1:54874 Closing +[Tue Apr 21 08:14:56 2026] 127.0.0.1:54882 Accepted +[Tue Apr 21 08:14:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:57 2026] 127.0.0.1:54882 Closing +[Tue Apr 21 08:14:57 2026] 127.0.0.1:54890 Accepted +[Tue Apr 21 08:14:59 2026] 127.0.0.1:54890 Closing +[Tue Apr 21 08:15:01 2026] 127.0.0.1:32860 Accepted +[Tue Apr 21 08:15:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:02 2026] 127.0.0.1:32860 Closing +[Tue Apr 21 08:15:03 2026] 127.0.0.1:32876 Accepted +[Tue Apr 21 08:15:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:03 2026] 127.0.0.1:32876 Closing +[Tue Apr 21 08:15:03 2026] 127.0.0.1:32884 Accepted +[Tue Apr 21 08:15:05 2026] 127.0.0.1:32884 Closing +[Tue Apr 21 08:15:05 2026] 127.0.0.1:32898 Accepted +[Tue Apr 21 08:15:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:06 2026] 127.0.0.1:32898 Closing +[Tue Apr 21 08:15:06 2026] 127.0.0.1:32910 Accepted +[Tue Apr 21 08:15:07 2026] 127.0.0.1:32910 Closing +[Tue Apr 21 08:15:12 2026] 127.0.0.1:58180 Accepted +[Tue Apr 21 08:15:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:12 2026] 127.0.0.1:58180 Closing +[Tue Apr 21 08:15:13 2026] 127.0.0.1:58188 Accepted +[Tue Apr 21 08:15:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:12 2026] 127.0.0.1:58188 Closing +[Tue Apr 21 08:15:12 2026] 127.0.0.1:58196 Accepted +[Tue Apr 21 08:15:14 2026] 127.0.0.1:58196 Closing +[Tue Apr 21 08:15:19 2026] 127.0.0.1:40902 Accepted +[Tue Apr 21 08:15:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:20 2026] 127.0.0.1:40902 Closing +[Tue Apr 21 08:15:20 2026] 127.0.0.1:40910 Accepted +[Tue Apr 21 08:15:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:20 2026] 127.0.0.1:40910 Closing +[Tue Apr 21 08:15:20 2026] 127.0.0.1:40920 Accepted +[Tue Apr 21 08:15:22 2026] 127.0.0.1:40920 Closing +[Tue Apr 21 08:15:22 2026] 127.0.0.1:40934 Accepted +[Tue Apr 21 08:15:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:22 2026] 127.0.0.1:40934 Closing +[Tue Apr 21 08:15:22 2026] 127.0.0.1:40948 Accepted +[Tue Apr 21 08:15:23 2026] 127.0.0.1:40948 Closing +[Tue Apr 21 08:18:01 2026] 127.0.0.1:58648 Accepted +[Tue Apr 21 08:18:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:02 2026] 127.0.0.1:58648 Closing +[Tue Apr 21 08:18:03 2026] 127.0.0.1:49812 Accepted +[Tue Apr 21 08:18:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:04 2026] 127.0.0.1:49812 Closing +[Tue Apr 21 08:18:04 2026] 127.0.0.1:49820 Accepted +[Tue Apr 21 08:18:08 2026] 127.0.0.1:49820 Closing +[Tue Apr 21 08:18:15 2026] 127.0.0.1:59650 Accepted +[Tue Apr 21 08:18:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:15 2026] 127.0.0.1:59650 Closing +[Tue Apr 21 08:18:16 2026] 127.0.0.1:59662 Accepted +[Tue Apr 21 08:18:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:16 2026] 127.0.0.1:59662 Closing +[Tue Apr 21 08:18:16 2026] 127.0.0.1:59676 Accepted +[Tue Apr 21 08:18:21 2026] 127.0.0.1:59676 Closing +[Tue Apr 21 08:18:26 2026] 127.0.0.1:33508 Accepted +[Tue Apr 21 08:18:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:27 2026] 127.0.0.1:33508 Closing +[Tue Apr 21 08:18:27 2026] 127.0.0.1:33514 Accepted +[Tue Apr 21 08:18:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:27 2026] 127.0.0.1:33514 Closing +[Tue Apr 21 08:18:27 2026] 127.0.0.1:33524 Accepted +[Tue Apr 21 08:18:28 2026] 127.0.0.1:33524 Closing +[Tue Apr 21 08:18:28 2026] 127.0.0.1:33528 Accepted +[Tue Apr 21 08:18:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:26 2026] 127.0.0.1:33528 Closing +[Tue Apr 21 08:18:26 2026] 127.0.0.1:33534 Accepted +[Tue Apr 21 08:18:28 2026] 127.0.0.1:33534 Closing +[Tue Apr 21 08:18:31 2026] 127.0.0.1:43534 Accepted +[Tue Apr 21 08:18:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:31 2026] 127.0.0.1:43534 Closing +[Tue Apr 21 08:18:32 2026] 127.0.0.1:43542 Accepted +[Tue Apr 21 08:18:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:32 2026] 127.0.0.1:43542 Closing +[Tue Apr 21 08:18:32 2026] 127.0.0.1:43546 Accepted +[Tue Apr 21 08:18:36 2026] 127.0.0.1:43546 Closing +[Tue Apr 21 08:18:40 2026] 127.0.0.1:43560 Accepted +[Tue Apr 21 08:18:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:40 2026] 127.0.0.1:43560 Closing +[Tue Apr 21 08:18:41 2026] 127.0.0.1:40356 Accepted +[Tue Apr 21 08:18:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:41 2026] 127.0.0.1:40356 Closing +[Tue Apr 21 08:18:41 2026] 127.0.0.1:40370 Accepted +[Tue Apr 21 08:18:43 2026] 127.0.0.1:40370 Closing +[Tue Apr 21 08:18:43 2026] 127.0.0.1:40386 Accepted +[Tue Apr 21 08:18:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:43 2026] 127.0.0.1:40386 Closing +[Tue Apr 21 08:18:43 2026] 127.0.0.1:40402 Accepted +[Tue Apr 21 08:18:45 2026] 127.0.0.1:40402 Closing +[Tue Apr 21 08:18:45 2026] 127.0.0.1:40416 Accepted +[Tue Apr 21 08:18:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:45 2026] 127.0.0.1:40416 Closing +[Tue Apr 21 08:18:45 2026] 127.0.0.1:40428 Accepted +[Tue Apr 21 08:18:47 2026] 127.0.0.1:40428 Closing +[Tue Apr 21 08:18:47 2026] 127.0.0.1:40434 Accepted +[Tue Apr 21 08:18:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:47 2026] 127.0.0.1:40434 Closing +[Tue Apr 21 08:18:47 2026] 127.0.0.1:40444 Accepted +[Tue Apr 21 08:18:49 2026] 127.0.0.1:40444 Closing +[Tue Apr 21 08:18:52 2026] 127.0.0.1:40618 Accepted +[Tue Apr 21 08:18:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:53 2026] 127.0.0.1:40618 Closing +[Tue Apr 21 08:18:54 2026] 127.0.0.1:40632 Accepted +[Tue Apr 21 08:18:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:54 2026] 127.0.0.1:40632 Closing +[Tue Apr 21 08:18:54 2026] 127.0.0.1:40634 Accepted +[Tue Apr 21 08:18:57 2026] 127.0.0.1:40634 Closing +[Tue Apr 21 08:19:03 2026] 127.0.0.1:53008 Accepted +[Tue Apr 21 08:19:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:03 2026] 127.0.0.1:53008 Closing +[Tue Apr 21 08:19:04 2026] 127.0.0.1:53016 Accepted +[Tue Apr 21 08:19:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:05 2026] 127.0.0.1:53016 Closing +[Tue Apr 21 08:19:05 2026] 127.0.0.1:53030 Accepted +[Tue Apr 21 08:19:08 2026] 127.0.0.1:53030 Closing +[Tue Apr 21 08:19:08 2026] 127.0.0.1:55468 Accepted +[Tue Apr 21 08:19:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:09 2026] 127.0.0.1:55468 Closing +[Tue Apr 21 08:19:09 2026] 127.0.0.1:55478 Accepted +[Tue Apr 21 08:19:12 2026] 127.0.0.1:55478 Closing +[Tue Apr 21 08:19:18 2026] 127.0.0.1:39746 Accepted +[Tue Apr 21 08:19:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:18 2026] 127.0.0.1:39746 Closing +[Tue Apr 21 08:19:19 2026] 127.0.0.1:39760 Accepted +[Tue Apr 21 08:19:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:20 2026] 127.0.0.1:39760 Closing +[Tue Apr 21 08:19:20 2026] 127.0.0.1:39774 Accepted +[Tue Apr 21 08:19:22 2026] 127.0.0.1:39774 Closing +[Tue Apr 21 08:19:26 2026] 127.0.0.1:58184 Accepted +[Tue Apr 21 08:19:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:27 2026] 127.0.0.1:58184 Closing +[Tue Apr 21 08:19:27 2026] 127.0.0.1:58196 Accepted +[Tue Apr 21 08:19:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:28 2026] 127.0.0.1:58196 Closing +[Tue Apr 21 08:19:28 2026] 127.0.0.1:58210 Accepted +[Tue Apr 21 08:19:30 2026] 127.0.0.1:58210 Closing +[Tue Apr 21 08:19:35 2026] 127.0.0.1:58212 Accepted +[Tue Apr 21 08:19:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:35 2026] 127.0.0.1:58212 Closing +[Tue Apr 21 08:19:36 2026] 127.0.0.1:58216 Accepted +[Tue Apr 21 08:19:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:36 2026] 127.0.0.1:58216 Closing +[Tue Apr 21 08:19:36 2026] 127.0.0.1:34522 Accepted +[Tue Apr 21 08:19:38 2026] 127.0.0.1:34522 Closing +[Tue Apr 21 08:19:38 2026] 127.0.0.1:34530 Accepted +[Tue Apr 21 08:19:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:39 2026] 127.0.0.1:34530 Closing +[Tue Apr 21 08:19:39 2026] 127.0.0.1:34542 Accepted +[Tue Apr 21 08:19:41 2026] 127.0.0.1:34542 Closing +[Tue Apr 21 08:19:41 2026] 127.0.0.1:34544 Accepted +[Tue Apr 21 08:19:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:41 2026] 127.0.0.1:34544 Closing +[Tue Apr 21 08:19:41 2026] 127.0.0.1:34560 Accepted +[Tue Apr 21 08:19:43 2026] 127.0.0.1:34560 Closing +[Tue Apr 21 08:19:43 2026] 127.0.0.1:34572 Accepted +[Tue Apr 21 08:19:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:44 2026] 127.0.0.1:34572 Closing +[Tue Apr 21 08:19:44 2026] 127.0.0.1:34586 Accepted +[Tue Apr 21 08:19:46 2026] 127.0.0.1:34586 Closing +[Tue Apr 21 08:19:46 2026] 127.0.0.1:34588 Accepted +[Tue Apr 21 08:19:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:46 2026] 127.0.0.1:34588 Closing +[Tue Apr 21 08:19:46 2026] 127.0.0.1:53338 Accepted +[Tue Apr 21 08:19:48 2026] 127.0.0.1:53338 Closing +[Tue Apr 21 08:19:48 2026] 127.0.0.1:53354 Accepted +[Tue Apr 21 08:19:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:49 2026] 127.0.0.1:53354 Closing +[Tue Apr 21 08:19:49 2026] 127.0.0.1:53366 Accepted +[Tue Apr 21 08:19:50 2026] 127.0.0.1:53366 Closing +[Tue Apr 21 08:19:53 2026] 127.0.0.1:53380 Accepted +[Tue Apr 21 08:19:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:54 2026] 127.0.0.1:53380 Closing +[Tue Apr 21 08:19:54 2026] 127.0.0.1:38838 Accepted +[Tue Apr 21 08:19:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:54 2026] 127.0.0.1:38838 Closing +[Tue Apr 21 08:19:54 2026] 127.0.0.1:38846 Accepted +[Tue Apr 21 08:19:56 2026] 127.0.0.1:38846 Closing +[Tue Apr 21 08:19:56 2026] 127.0.0.1:38860 Accepted +[Tue Apr 21 08:19:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:56 2026] 127.0.0.1:38860 Closing +[Tue Apr 21 08:19:56 2026] 127.0.0.1:38874 Accepted +[Tue Apr 21 08:19:58 2026] 127.0.0.1:38874 Closing +[Tue Apr 21 08:20:00 2026] 127.0.0.1:38880 Accepted +[Tue Apr 21 08:20:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:01 2026] 127.0.0.1:38880 Closing +[Tue Apr 21 08:20:02 2026] 127.0.0.1:38886 Accepted +[Tue Apr 21 08:20:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:02 2026] 127.0.0.1:38886 Closing +[Tue Apr 21 08:20:02 2026] 127.0.0.1:38902 Accepted +[Tue Apr 21 08:20:07 2026] 127.0.0.1:38902 Closing +[Tue Apr 21 08:20:13 2026] 127.0.0.1:37734 Accepted +[Tue Apr 21 08:20:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:13 2026] 127.0.0.1:37734 Closing +[Tue Apr 21 08:20:14 2026] 127.0.0.1:55220 Accepted +[Tue Apr 21 08:20:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:15 2026] 127.0.0.1:55220 Closing +[Tue Apr 21 08:20:15 2026] 127.0.0.1:55228 Accepted +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55228 Closing +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55244 Accepted +[Tue Apr 21 08:20:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55244 Closing +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55258 Accepted +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55258 Closing +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55272 Accepted +[Tue Apr 21 08:20:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55272 Closing +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55276 Accepted +[Tue Apr 21 08:20:20 2026] 127.0.0.1:55276 Closing +[Tue Apr 21 08:20:20 2026] 127.0.0.1:55286 Accepted +[Tue Apr 21 08:20:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:21 2026] 127.0.0.1:55286 Closing +[Tue Apr 21 08:20:21 2026] 127.0.0.1:55300 Accepted +[Tue Apr 21 08:20:23 2026] 127.0.0.1:55300 Closing +[Tue Apr 21 08:20:23 2026] 127.0.0.1:35860 Accepted +[Tue Apr 21 08:20:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:23 2026] 127.0.0.1:35860 Closing +[Tue Apr 21 08:20:23 2026] 127.0.0.1:35870 Accepted +[Tue Apr 21 08:20:25 2026] 127.0.0.1:35870 Closing +[Tue Apr 21 08:20:25 2026] 127.0.0.1:35880 Accepted +[Tue Apr 21 08:20:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:25 2026] 127.0.0.1:35880 Closing +[Tue Apr 21 08:20:25 2026] 127.0.0.1:35888 Accepted +[Tue Apr 21 08:20:27 2026] 127.0.0.1:35888 Closing +[Tue Apr 21 08:20:32 2026] 127.0.0.1:39720 Accepted +[Tue Apr 21 08:20:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:32 2026] 127.0.0.1:39720 Closing +[Tue Apr 21 08:20:33 2026] 127.0.0.1:39732 Accepted +[Tue Apr 21 08:20:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:33 2026] 127.0.0.1:39732 Closing +[Tue Apr 21 08:20:33 2026] 127.0.0.1:39744 Accepted +[Tue Apr 21 08:20:35 2026] 127.0.0.1:39744 Closing +[Tue Apr 21 08:20:37 2026] 127.0.0.1:39750 Accepted +[Tue Apr 21 08:20:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:38 2026] 127.0.0.1:39750 Closing +[Tue Apr 21 08:20:38 2026] 127.0.0.1:39762 Accepted +[Tue Apr 21 08:20:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:39 2026] 127.0.0.1:39762 Closing +[Tue Apr 21 08:20:39 2026] 127.0.0.1:39764 Accepted +[Tue Apr 21 08:20:40 2026] 127.0.0.1:39764 Closing +[Tue Apr 21 08:20:40 2026] 127.0.0.1:39780 Accepted +[Tue Apr 21 08:20:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:41 2026] 127.0.0.1:39780 Closing +[Tue Apr 21 08:20:41 2026] 127.0.0.1:39786 Accepted +[Tue Apr 21 08:20:42 2026] 127.0.0.1:39786 Closing +[Tue Apr 21 08:20:45 2026] 127.0.0.1:50508 Accepted +[Tue Apr 21 08:20:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:46 2026] 127.0.0.1:50508 Closing +[Tue Apr 21 08:20:47 2026] 127.0.0.1:50520 Accepted +[Tue Apr 21 08:20:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:47 2026] 127.0.0.1:50520 Closing +[Tue Apr 21 08:20:47 2026] 127.0.0.1:50526 Accepted +[Tue Apr 21 08:20:50 2026] 127.0.0.1:50526 Closing +[Tue Apr 21 08:20:55 2026] 127.0.0.1:46300 Accepted +[Tue Apr 21 08:20:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:55 2026] 127.0.0.1:46300 Closing +[Tue Apr 21 08:20:56 2026] 127.0.0.1:46314 Accepted +[Tue Apr 21 08:20:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:56 2026] 127.0.0.1:46314 Closing +[Tue Apr 21 08:20:56 2026] 127.0.0.1:46316 Accepted +[Tue Apr 21 08:20:58 2026] 127.0.0.1:46316 Closing +[Tue Apr 21 08:20:58 2026] 127.0.0.1:46324 Accepted +[Tue Apr 21 08:20:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:59 2026] 127.0.0.1:46324 Closing +[Tue Apr 21 08:20:59 2026] 127.0.0.1:46334 Accepted +[Tue Apr 21 08:21:00 2026] 127.0.0.1:46334 Closing +[Tue Apr 21 08:21:38 2026] 127.0.0.1:55200 Accepted +[Tue Apr 21 08:21:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:21:39 2026] 127.0.0.1:55200 Closing +[Tue Apr 21 08:21:40 2026] 127.0.0.1:55204 Accepted +[Tue Apr 21 08:21:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:21:41 2026] 127.0.0.1:55204 Closing +[Tue Apr 21 08:21:41 2026] 127.0.0.1:55206 Accepted +[Tue Apr 21 08:21:42 2026] 127.0.0.1:55206 Closing +[Tue Apr 21 08:21:49 2026] 127.0.0.1:36834 Accepted +[Tue Apr 21 08:21:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:21:49 2026] 127.0.0.1:36834 Closing +[Tue Apr 21 08:21:50 2026] 127.0.0.1:36850 Accepted +[Tue Apr 21 08:21:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:21:51 2026] 127.0.0.1:36850 Closing +[Tue Apr 21 08:21:51 2026] 127.0.0.1:36860 Accepted +[Tue Apr 21 08:21:55 2026] 127.0.0.1:36860 Closing +[Tue Apr 21 08:22:00 2026] 127.0.0.1:32944 Accepted +[Tue Apr 21 08:22:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:01 2026] 127.0.0.1:32944 Closing +[Tue Apr 21 08:22:01 2026] 127.0.0.1:32950 Accepted +[Tue Apr 21 08:22:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:01 2026] 127.0.0.1:32950 Closing +[Tue Apr 21 08:22:01 2026] 127.0.0.1:32952 Accepted +[Tue Apr 21 08:22:02 2026] 127.0.0.1:32952 Closing +[Tue Apr 21 08:22:02 2026] 127.0.0.1:32962 Accepted +[Tue Apr 21 08:22:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:03 2026] 127.0.0.1:32962 Closing +[Tue Apr 21 08:22:03 2026] 127.0.0.1:32976 Accepted +[Tue Apr 21 08:22:06 2026] 127.0.0.1:32976 Closing +[Tue Apr 21 08:22:08 2026] 127.0.0.1:42952 Accepted +[Tue Apr 21 08:22:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:09 2026] 127.0.0.1:42952 Closing +[Tue Apr 21 08:22:09 2026] 127.0.0.1:42956 Accepted +[Tue Apr 21 08:22:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:10 2026] 127.0.0.1:42956 Closing +[Tue Apr 21 08:22:10 2026] 127.0.0.1:42968 Accepted +[Tue Apr 21 08:22:12 2026] 127.0.0.1:42968 Closing +[Tue Apr 21 08:22:16 2026] 127.0.0.1:51880 Accepted +[Tue Apr 21 08:22:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:16 2026] 127.0.0.1:51880 Closing +[Tue Apr 21 08:22:17 2026] 127.0.0.1:51888 Accepted +[Tue Apr 21 08:22:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:17 2026] 127.0.0.1:51888 Closing +[Tue Apr 21 08:22:17 2026] 127.0.0.1:51896 Accepted +[Tue Apr 21 08:22:19 2026] 127.0.0.1:51896 Closing +[Tue Apr 21 08:22:19 2026] 127.0.0.1:51902 Accepted +[Tue Apr 21 08:22:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:20 2026] 127.0.0.1:51902 Closing +[Tue Apr 21 08:22:20 2026] 127.0.0.1:51912 Accepted +[Tue Apr 21 08:22:21 2026] 127.0.0.1:51912 Closing +[Tue Apr 21 08:22:21 2026] 127.0.0.1:51924 Accepted +[Tue Apr 21 08:22:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:22 2026] 127.0.0.1:51924 Closing +[Tue Apr 21 08:22:22 2026] 127.0.0.1:51928 Accepted +[Tue Apr 21 08:22:24 2026] 127.0.0.1:51928 Closing +[Tue Apr 21 08:22:24 2026] 127.0.0.1:36168 Accepted +[Tue Apr 21 08:22:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:24 2026] 127.0.0.1:36168 Closing +[Tue Apr 21 08:22:24 2026] 127.0.0.1:36178 Accepted +[Tue Apr 21 08:22:26 2026] 127.0.0.1:36178 Closing +[Tue Apr 21 08:22:29 2026] 127.0.0.1:36182 Accepted +[Tue Apr 21 08:22:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:30 2026] 127.0.0.1:36182 Closing +[Tue Apr 21 08:22:31 2026] 127.0.0.1:36196 Accepted +[Tue Apr 21 08:22:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:31 2026] 127.0.0.1:36196 Closing +[Tue Apr 21 08:22:31 2026] 127.0.0.1:36204 Accepted +[Tue Apr 21 08:22:36 2026] 127.0.0.1:36204 Closing +[Tue Apr 21 08:22:40 2026] 127.0.0.1:45340 Accepted +[Tue Apr 21 08:22:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:40 2026] 127.0.0.1:45340 Closing +[Tue Apr 21 08:22:41 2026] 127.0.0.1:50354 Accepted +[Tue Apr 21 08:22:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:42 2026] 127.0.0.1:50354 Closing +[Tue Apr 21 08:22:42 2026] 127.0.0.1:50366 Accepted +[Tue Apr 21 08:22:46 2026] 127.0.0.1:50366 Closing +[Tue Apr 21 08:22:46 2026] 127.0.0.1:50374 Accepted +[Tue Apr 21 08:22:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:46 2026] 127.0.0.1:50374 Closing +[Tue Apr 21 08:22:46 2026] 127.0.0.1:50386 Accepted +[Tue Apr 21 08:22:49 2026] 127.0.0.1:50386 Closing +[Tue Apr 21 08:22:56 2026] 127.0.0.1:50472 Accepted +[Tue Apr 21 08:22:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:57 2026] 127.0.0.1:50472 Closing +[Tue Apr 21 08:22:58 2026] 127.0.0.1:50484 Accepted +[Tue Apr 21 08:22:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:59 2026] 127.0.0.1:50484 Closing +[Tue Apr 21 08:22:59 2026] 127.0.0.1:50488 Accepted +[Tue Apr 21 08:23:02 2026] 127.0.0.1:50488 Closing +[Tue Apr 21 08:23:07 2026] 127.0.0.1:53868 Accepted +[Tue Apr 21 08:23:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:08 2026] 127.0.0.1:53868 Closing +[Tue Apr 21 08:23:09 2026] 127.0.0.1:32884 Accepted +[Tue Apr 21 08:23:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:10 2026] 127.0.0.1:32884 Closing +[Tue Apr 21 08:23:10 2026] 127.0.0.1:32900 Accepted +[Tue Apr 21 08:23:12 2026] 127.0.0.1:32900 Closing +[Tue Apr 21 08:23:17 2026] 127.0.0.1:32902 Accepted +[Tue Apr 21 08:23:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:18 2026] 127.0.0.1:32902 Closing +[Tue Apr 21 08:23:19 2026] 127.0.0.1:40398 Accepted +[Tue Apr 21 08:23:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:19 2026] 127.0.0.1:40398 Closing +[Tue Apr 21 08:23:19 2026] 127.0.0.1:40408 Accepted +[Tue Apr 21 08:23:22 2026] 127.0.0.1:40408 Closing +[Tue Apr 21 08:23:22 2026] 127.0.0.1:40424 Accepted +[Tue Apr 21 08:23:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:22 2026] 127.0.0.1:40424 Closing +[Tue Apr 21 08:23:22 2026] 127.0.0.1:40432 Accepted +[Tue Apr 21 08:23:24 2026] 127.0.0.1:40432 Closing +[Tue Apr 21 08:23:24 2026] 127.0.0.1:40442 Accepted +[Tue Apr 21 08:23:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:25 2026] 127.0.0.1:40442 Closing +[Tue Apr 21 08:23:25 2026] 127.0.0.1:40446 Accepted +[Tue Apr 21 08:23:27 2026] 127.0.0.1:40446 Closing +[Tue Apr 21 08:23:27 2026] 127.0.0.1:40456 Accepted +[Tue Apr 21 08:23:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:28 2026] 127.0.0.1:40456 Closing +[Tue Apr 21 08:23:28 2026] 127.0.0.1:40460 Accepted +[Tue Apr 21 08:23:30 2026] 127.0.0.1:40460 Closing +[Tue Apr 21 08:23:30 2026] 127.0.0.1:52170 Accepted +[Tue Apr 21 08:23:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:30 2026] 127.0.0.1:52170 Closing +[Tue Apr 21 08:23:30 2026] 127.0.0.1:52172 Accepted +[Tue Apr 21 08:23:32 2026] 127.0.0.1:52172 Closing +[Tue Apr 21 08:23:32 2026] 127.0.0.1:52186 Accepted +[Tue Apr 21 08:23:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:33 2026] 127.0.0.1:52186 Closing +[Tue Apr 21 08:23:33 2026] 127.0.0.1:52196 Accepted +[Tue Apr 21 08:23:33 2026] 127.0.0.1:52196 Closing +[Tue Apr 21 08:23:38 2026] 127.0.0.1:52664 Accepted +[Tue Apr 21 08:23:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:38 2026] 127.0.0.1:52664 Closing +[Tue Apr 21 08:23:39 2026] 127.0.0.1:52678 Accepted +[Tue Apr 21 08:23:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:39 2026] 127.0.0.1:52678 Closing +[Tue Apr 21 08:23:39 2026] 127.0.0.1:52686 Accepted +[Tue Apr 21 08:23:41 2026] 127.0.0.1:52686 Closing +[Tue Apr 21 08:23:41 2026] 127.0.0.1:52702 Accepted +[Tue Apr 21 08:23:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:42 2026] 127.0.0.1:52702 Closing +[Tue Apr 21 08:23:42 2026] 127.0.0.1:52708 Accepted +[Tue Apr 21 08:23:43 2026] 127.0.0.1:52708 Closing +[Tue Apr 21 08:23:45 2026] 127.0.0.1:52714 Accepted +[Tue Apr 21 08:23:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:46 2026] 127.0.0.1:52714 Closing +[Tue Apr 21 08:23:47 2026] 127.0.0.1:33682 Accepted +[Tue Apr 21 08:23:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:47 2026] 127.0.0.1:33682 Closing +[Tue Apr 21 08:23:47 2026] 127.0.0.1:33696 Accepted +[Tue Apr 21 08:23:52 2026] 127.0.0.1:33696 Closing +[Tue Apr 21 08:23:58 2026] 127.0.0.1:42506 Accepted +[Tue Apr 21 08:23:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:59 2026] 127.0.0.1:42506 Closing +[Tue Apr 21 08:23:59 2026] 127.0.0.1:42522 Accepted +[Tue Apr 21 08:23:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:00 2026] 127.0.0.1:42522 Closing +[Tue Apr 21 08:24:00 2026] 127.0.0.1:42526 Accepted +[Tue Apr 21 08:24:00 2026] 127.0.0.1:42526 Closing +[Tue Apr 21 08:24:00 2026] 127.0.0.1:42540 Accepted +[Tue Apr 21 08:24:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:01 2026] 127.0.0.1:42540 Closing +[Tue Apr 21 08:24:01 2026] 127.0.0.1:42552 Accepted +[Tue Apr 21 08:24:03 2026] 127.0.0.1:42552 Closing +[Tue Apr 21 08:24:03 2026] 127.0.0.1:42560 Accepted +[Tue Apr 21 08:24:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:03 2026] 127.0.0.1:42560 Closing +[Tue Apr 21 08:24:03 2026] 127.0.0.1:42572 Accepted +[Tue Apr 21 08:24:05 2026] 127.0.0.1:42572 Closing +[Tue Apr 21 08:24:05 2026] 127.0.0.1:41178 Accepted +[Tue Apr 21 08:24:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:05 2026] 127.0.0.1:41178 Closing +[Tue Apr 21 08:24:05 2026] 127.0.0.1:41182 Accepted +[Tue Apr 21 08:24:08 2026] 127.0.0.1:41182 Closing +[Tue Apr 21 08:24:08 2026] 127.0.0.1:41188 Accepted +[Tue Apr 21 08:24:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:08 2026] 127.0.0.1:41188 Closing +[Tue Apr 21 08:24:08 2026] 127.0.0.1:41198 Accepted +[Tue Apr 21 08:24:10 2026] 127.0.0.1:41198 Closing +[Tue Apr 21 08:24:10 2026] 127.0.0.1:41202 Accepted +[Tue Apr 21 08:24:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:11 2026] 127.0.0.1:41202 Closing +[Tue Apr 21 08:24:11 2026] 127.0.0.1:41204 Accepted +[Tue Apr 21 08:24:13 2026] 127.0.0.1:41204 Closing +[Tue Apr 21 08:24:18 2026] 127.0.0.1:54386 Accepted +[Tue Apr 21 08:24:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:18 2026] 127.0.0.1:54386 Closing +[Tue Apr 21 08:24:19 2026] 127.0.0.1:54398 Accepted +[Tue Apr 21 08:24:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:19 2026] 127.0.0.1:54398 Closing +[Tue Apr 21 08:24:19 2026] 127.0.0.1:54410 Accepted +[Tue Apr 21 08:24:21 2026] 127.0.0.1:54410 Closing +[Tue Apr 21 08:24:24 2026] 127.0.0.1:54418 Accepted +[Tue Apr 21 08:24:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:24 2026] 127.0.0.1:54418 Closing +[Tue Apr 21 08:24:25 2026] 127.0.0.1:57742 Accepted +[Tue Apr 21 08:24:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:25 2026] 127.0.0.1:57742 Closing +[Tue Apr 21 08:24:25 2026] 127.0.0.1:57758 Accepted +[Tue Apr 21 08:24:27 2026] 127.0.0.1:57758 Closing +[Tue Apr 21 08:24:27 2026] 127.0.0.1:57770 Accepted +[Tue Apr 21 08:24:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:27 2026] 127.0.0.1:57770 Closing +[Tue Apr 21 08:24:27 2026] 127.0.0.1:57782 Accepted +[Tue Apr 21 08:24:29 2026] 127.0.0.1:57782 Closing +[Tue Apr 21 08:24:32 2026] 127.0.0.1:57784 Accepted +[Tue Apr 21 08:24:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:32 2026] 127.0.0.1:57784 Closing +[Tue Apr 21 08:24:33 2026] 127.0.0.1:50000 Accepted +[Tue Apr 21 08:24:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:34 2026] 127.0.0.1:50000 Closing +[Tue Apr 21 08:24:34 2026] 127.0.0.1:50006 Accepted +[Tue Apr 21 08:24:41 2026] 127.0.0.1:50006 Closing +[Tue Apr 21 08:24:47 2026] 127.0.0.1:58318 Accepted +[Tue Apr 21 08:24:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:53 2026] 127.0.0.1:58318 Closing +[Tue Apr 21 08:24:53 2026] 127.0.0.1:47462 Accepted +[Tue Apr 21 08:24:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:57 2026] 127.0.0.1:47462 Closing +[Tue Apr 21 08:24:57 2026] 127.0.0.1:47478 Accepted +[Tue Apr 21 08:25:03 2026] 127.0.0.1:47478 Closing +[Tue Apr 21 08:25:03 2026] 127.0.0.1:52486 Accepted +[Tue Apr 21 08:25:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:25:03 2026] 127.0.0.1:52486 Closing +[Tue Apr 21 08:25:03 2026] 127.0.0.1:52500 Accepted +[Tue Apr 21 08:25:05 2026] 127.0.0.1:52500 Closing +[Tue Apr 21 08:27:29 2026] 127.0.0.1:35928 Accepted +[Tue Apr 21 08:27:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:30 2026] 127.0.0.1:35928 Closing +[Tue Apr 21 08:27:32 2026] 127.0.0.1:35940 Accepted +[Tue Apr 21 08:27:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:32 2026] 127.0.0.1:35940 Closing +[Tue Apr 21 08:27:32 2026] 127.0.0.1:35946 Accepted +[Tue Apr 21 08:27:36 2026] 127.0.0.1:35946 Closing +[Tue Apr 21 08:27:43 2026] 127.0.0.1:39466 Accepted +[Tue Apr 21 08:27:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:44 2026] 127.0.0.1:39466 Closing +[Tue Apr 21 08:27:42 2026] 127.0.0.1:39478 Accepted +[Tue Apr 21 08:27:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:43 2026] 127.0.0.1:39478 Closing +[Tue Apr 21 08:27:43 2026] 127.0.0.1:39490 Accepted +[Tue Apr 21 08:27:47 2026] 127.0.0.1:39490 Closing +[Tue Apr 21 08:27:53 2026] 127.0.0.1:59140 Accepted +[Tue Apr 21 08:27:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:53 2026] 127.0.0.1:59140 Closing +[Tue Apr 21 08:27:53 2026] 127.0.0.1:59154 Accepted +[Tue Apr 21 08:27:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:54 2026] 127.0.0.1:59154 Closing +[Tue Apr 21 08:27:54 2026] 127.0.0.1:59170 Accepted +[Tue Apr 21 08:27:54 2026] 127.0.0.1:59170 Closing +[Tue Apr 21 08:27:54 2026] 127.0.0.1:59186 Accepted +[Tue Apr 21 08:27:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:55 2026] 127.0.0.1:59186 Closing +[Tue Apr 21 08:27:55 2026] 127.0.0.1:59202 Accepted +[Tue Apr 21 08:27:58 2026] 127.0.0.1:59202 Closing +[Tue Apr 21 08:28:00 2026] 127.0.0.1:53748 Accepted +[Tue Apr 21 08:28:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:01 2026] 127.0.0.1:53748 Closing +[Tue Apr 21 08:28:01 2026] 127.0.0.1:53754 Accepted +[Tue Apr 21 08:28:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:01 2026] 127.0.0.1:53754 Closing +[Tue Apr 21 08:28:01 2026] 127.0.0.1:53768 Accepted +[Tue Apr 21 08:28:06 2026] 127.0.0.1:53768 Closing +[Tue Apr 21 08:28:09 2026] 127.0.0.1:36940 Accepted +[Tue Apr 21 08:28:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:10 2026] 127.0.0.1:36940 Closing +[Tue Apr 21 08:28:10 2026] 127.0.0.1:36946 Accepted +[Tue Apr 21 08:28:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:11 2026] 127.0.0.1:36946 Closing +[Tue Apr 21 08:28:11 2026] 127.0.0.1:36948 Accepted +[Tue Apr 21 08:28:10 2026] 127.0.0.1:36948 Closing +[Tue Apr 21 08:28:10 2026] 127.0.0.1:36950 Accepted +[Tue Apr 21 08:28:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:11 2026] 127.0.0.1:36950 Closing +[Tue Apr 21 08:28:11 2026] 127.0.0.1:36962 Accepted +[Tue Apr 21 08:28:13 2026] 127.0.0.1:36962 Closing +[Tue Apr 21 08:28:13 2026] 127.0.0.1:36976 Accepted +[Tue Apr 21 08:28:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:13 2026] 127.0.0.1:36976 Closing +[Tue Apr 21 08:28:13 2026] 127.0.0.1:36986 Accepted +[Tue Apr 21 08:28:16 2026] 127.0.0.1:36986 Closing +[Tue Apr 21 08:28:16 2026] 127.0.0.1:33170 Accepted +[Tue Apr 21 08:28:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:17 2026] 127.0.0.1:33170 Closing +[Tue Apr 21 08:28:17 2026] 127.0.0.1:33182 Accepted +[Tue Apr 21 08:28:18 2026] 127.0.0.1:33182 Closing +[Tue Apr 21 08:28:22 2026] 127.0.0.1:33198 Accepted +[Tue Apr 21 08:28:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:22 2026] 127.0.0.1:33198 Closing +[Tue Apr 21 08:28:23 2026] 127.0.0.1:33202 Accepted +[Tue Apr 21 08:28:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:25 2026] 127.0.0.1:33202 Closing +[Tue Apr 21 08:28:25 2026] 127.0.0.1:33532 Accepted +[Tue Apr 21 08:28:30 2026] 127.0.0.1:33532 Closing +[Tue Apr 21 08:28:36 2026] 127.0.0.1:56376 Accepted +[Tue Apr 21 08:28:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:37 2026] 127.0.0.1:56376 Closing +[Tue Apr 21 08:28:38 2026] 127.0.0.1:56392 Accepted +[Tue Apr 21 08:28:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:38 2026] 127.0.0.1:56392 Closing +[Tue Apr 21 08:28:38 2026] 127.0.0.1:56396 Accepted +[Tue Apr 21 08:28:41 2026] 127.0.0.1:56396 Closing +[Tue Apr 21 08:28:41 2026] 127.0.0.1:56408 Accepted +[Tue Apr 21 08:28:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:42 2026] 127.0.0.1:56408 Closing +[Tue Apr 21 08:28:42 2026] 127.0.0.1:49498 Accepted +[Tue Apr 21 08:28:45 2026] 127.0.0.1:49498 Closing +[Tue Apr 21 08:28:51 2026] 127.0.0.1:49512 Accepted +[Tue Apr 21 08:28:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:52 2026] 127.0.0.1:49512 Closing +[Tue Apr 21 08:28:59 2026] 127.0.0.1:49834 Accepted +[Tue Apr 21 08:28:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:00 2026] 127.0.0.1:49834 Closing +[Tue Apr 21 08:29:05 2026] 127.0.0.1:44346 Accepted +[Tue Apr 21 08:29:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:06 2026] 127.0.0.1:44346 Closing +[Tue Apr 21 08:29:07 2026] 127.0.0.1:44352 Accepted +[Tue Apr 21 08:29:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:07 2026] 127.0.0.1:44352 Closing +[Tue Apr 21 08:29:07 2026] 127.0.0.1:44358 Accepted +[Tue Apr 21 08:29:08 2026] 127.0.0.1:44358 Closing +[Tue Apr 21 08:29:08 2026] 127.0.0.1:44368 Accepted +[Tue Apr 21 08:29:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:08 2026] 127.0.0.1:44368 Closing +[Tue Apr 21 08:29:08 2026] 127.0.0.1:44380 Accepted +[Tue Apr 21 08:29:10 2026] 127.0.0.1:44380 Closing +[Tue Apr 21 08:29:10 2026] 127.0.0.1:54756 Accepted +[Tue Apr 21 08:29:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:11 2026] 127.0.0.1:54756 Closing +[Tue Apr 21 08:29:11 2026] 127.0.0.1:54768 Accepted +[Tue Apr 21 08:29:13 2026] 127.0.0.1:54768 Closing +[Tue Apr 21 08:29:13 2026] 127.0.0.1:54774 Accepted +[Tue Apr 21 08:29:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:14 2026] 127.0.0.1:54774 Closing +[Tue Apr 21 08:29:14 2026] 127.0.0.1:54782 Accepted +[Tue Apr 21 08:29:16 2026] 127.0.0.1:54782 Closing +[Tue Apr 21 08:29:16 2026] 127.0.0.1:54796 Accepted +[Tue Apr 21 08:29:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:17 2026] 127.0.0.1:54796 Closing +[Tue Apr 21 08:29:17 2026] 127.0.0.1:54812 Accepted +[Tue Apr 21 08:29:19 2026] 127.0.0.1:54812 Closing +[Tue Apr 21 08:29:19 2026] 127.0.0.1:54820 Accepted +[Tue Apr 21 08:29:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:19 2026] 127.0.0.1:54820 Closing +[Tue Apr 21 08:29:19 2026] 127.0.0.1:54632 Accepted +[Tue Apr 21 08:29:22 2026] 127.0.0.1:54632 Closing +[Tue Apr 21 08:29:27 2026] 127.0.0.1:54648 Accepted +[Tue Apr 21 08:29:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:27 2026] 127.0.0.1:54648 Closing +[Tue Apr 21 08:29:28 2026] 127.0.0.1:54660 Accepted +[Tue Apr 21 08:29:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:28 2026] 127.0.0.1:54660 Closing +[Tue Apr 21 08:29:28 2026] 127.0.0.1:54664 Accepted +[Tue Apr 21 08:29:30 2026] 127.0.0.1:54664 Closing +[Tue Apr 21 08:29:30 2026] 127.0.0.1:53886 Accepted +[Tue Apr 21 08:29:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:31 2026] 127.0.0.1:53886 Closing +[Tue Apr 21 08:29:31 2026] 127.0.0.1:53898 Accepted +[Tue Apr 21 08:29:32 2026] 127.0.0.1:53898 Closing +[Tue Apr 21 08:29:35 2026] 127.0.0.1:53912 Accepted +[Tue Apr 21 08:29:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:33 2026] 127.0.0.1:53912 Closing +[Tue Apr 21 08:29:34 2026] 127.0.0.1:53924 Accepted +[Tue Apr 21 08:29:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:34 2026] 127.0.0.1:53924 Closing +[Tue Apr 21 08:29:34 2026] 127.0.0.1:53928 Accepted +[Tue Apr 21 08:29:39 2026] 127.0.0.1:53928 Closing +[Tue Apr 21 08:29:45 2026] 127.0.0.1:46778 Accepted +[Tue Apr 21 08:29:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:46 2026] 127.0.0.1:46778 Closing +[Tue Apr 21 08:29:47 2026] 127.0.0.1:42228 Accepted +[Tue Apr 21 08:29:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:47 2026] 127.0.0.1:42228 Closing +[Tue Apr 21 08:29:47 2026] 127.0.0.1:42240 Accepted +[Tue Apr 21 08:29:50 2026] 127.0.0.1:42240 Closing +[Tue Apr 21 08:29:50 2026] 127.0.0.1:42244 Accepted +[Tue Apr 21 08:29:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:51 2026] 127.0.0.1:42244 Closing +[Tue Apr 21 08:29:51 2026] 127.0.0.1:42256 Accepted +[Tue Apr 21 08:29:53 2026] 127.0.0.1:42256 Closing +[Tue Apr 21 08:29:53 2026] 127.0.0.1:42264 Accepted +[Tue Apr 21 08:29:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:53 2026] 127.0.0.1:42264 Closing +[Tue Apr 21 08:29:53 2026] 127.0.0.1:42274 Accepted +[Tue Apr 21 08:29:55 2026] 127.0.0.1:42274 Closing +[Tue Apr 21 08:29:55 2026] 127.0.0.1:42282 Accepted +[Tue Apr 21 08:29:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:56 2026] 127.0.0.1:42282 Closing +[Tue Apr 21 08:29:56 2026] 127.0.0.1:42288 Accepted +[Tue Apr 21 08:29:58 2026] 127.0.0.1:42288 Closing +[Tue Apr 21 08:29:58 2026] 127.0.0.1:57346 Accepted +[Tue Apr 21 08:29:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:59 2026] 127.0.0.1:57346 Closing +[Tue Apr 21 08:29:59 2026] 127.0.0.1:57354 Accepted +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57354 Closing +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57358 Accepted +[Tue Apr 21 08:30:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57358 Closing +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57374 Accepted +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57374 Closing +[Tue Apr 21 08:30:06 2026] 127.0.0.1:57612 Accepted +[Tue Apr 21 08:30:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:07 2026] 127.0.0.1:57612 Closing +[Tue Apr 21 08:30:07 2026] 127.0.0.1:57626 Accepted +[Tue Apr 21 08:30:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:08 2026] 127.0.0.1:57626 Closing +[Tue Apr 21 08:30:08 2026] 127.0.0.1:57636 Accepted +[Tue Apr 21 08:30:10 2026] 127.0.0.1:57636 Closing +[Tue Apr 21 08:30:12 2026] 127.0.0.1:57646 Accepted +[Tue Apr 21 08:30:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:13 2026] 127.0.0.1:57646 Closing +[Tue Apr 21 08:30:14 2026] 127.0.0.1:57660 Accepted +[Tue Apr 21 08:30:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:14 2026] 127.0.0.1:57660 Closing +[Tue Apr 21 08:30:14 2026] 127.0.0.1:57674 Accepted +[Tue Apr 21 08:30:16 2026] 127.0.0.1:57674 Closing +[Tue Apr 21 08:30:16 2026] 127.0.0.1:37684 Accepted +[Tue Apr 21 08:30:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:16 2026] 127.0.0.1:37684 Closing +[Tue Apr 21 08:30:16 2026] 127.0.0.1:37688 Accepted +[Tue Apr 21 08:30:17 2026] 127.0.0.1:37688 Closing +[Tue Apr 21 08:30:22 2026] 127.0.0.1:37694 Accepted +[Tue Apr 21 08:30:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:23 2026] 127.0.0.1:37694 Closing +[Tue Apr 21 08:30:24 2026] 127.0.0.1:37710 Accepted +[Tue Apr 21 08:30:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:25 2026] 127.0.0.1:37710 Closing +[Tue Apr 21 08:30:25 2026] 127.0.0.1:44306 Accepted +[Tue Apr 21 08:30:28 2026] 127.0.0.1:44306 Closing +[Tue Apr 21 08:30:33 2026] 127.0.0.1:48456 Accepted +[Tue Apr 21 08:30:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:33 2026] 127.0.0.1:48456 Closing +[Tue Apr 21 08:30:34 2026] 127.0.0.1:48462 Accepted +[Tue Apr 21 08:30:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:35 2026] 127.0.0.1:48462 Closing +[Tue Apr 21 08:30:35 2026] 127.0.0.1:48468 Accepted +[Tue Apr 21 08:30:37 2026] 127.0.0.1:48468 Closing +[Tue Apr 21 08:30:37 2026] 127.0.0.1:48474 Accepted +[Tue Apr 21 08:30:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:38 2026] 127.0.0.1:48474 Closing +[Tue Apr 21 08:30:38 2026] 127.0.0.1:48476 Accepted +[Tue Apr 21 08:30:39 2026] 127.0.0.1:48476 Closing +[Tue Apr 21 08:32:20 2026] 127.0.0.1:42026 Accepted +[Tue Apr 21 08:32:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:20 2026] 127.0.0.1:42026 Closing +[Tue Apr 21 08:32:21 2026] 127.0.0.1:42042 Accepted +[Tue Apr 21 08:32:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:22 2026] 127.0.0.1:42042 Closing +[Tue Apr 21 08:32:22 2026] 127.0.0.1:42050 Accepted +[Tue Apr 21 08:32:25 2026] 127.0.0.1:42050 Closing +[Tue Apr 21 08:32:33 2026] 127.0.0.1:56782 Accepted +[Tue Apr 21 08:32:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:34 2026] 127.0.0.1:56782 Closing +[Tue Apr 21 08:32:36 2026] 127.0.0.1:56796 Accepted +[Tue Apr 21 08:32:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:37 2026] 127.0.0.1:56796 Closing +[Tue Apr 21 08:32:37 2026] 127.0.0.1:56806 Accepted +[Tue Apr 21 08:32:42 2026] 127.0.0.1:56806 Closing +[Tue Apr 21 08:32:48 2026] 127.0.0.1:40416 Accepted +[Tue Apr 21 08:32:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:47 2026] 127.0.0.1:40416 Closing +[Tue Apr 21 08:32:47 2026] 127.0.0.1:40432 Accepted +[Tue Apr 21 08:32:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:47 2026] 127.0.0.1:40432 Closing +[Tue Apr 21 08:32:47 2026] 127.0.0.1:40446 Accepted +[Tue Apr 21 08:32:48 2026] 127.0.0.1:40446 Closing +[Tue Apr 21 08:32:49 2026] 127.0.0.1:40454 Accepted +[Tue Apr 21 08:32:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:49 2026] 127.0.0.1:40454 Closing +[Tue Apr 21 08:32:49 2026] 127.0.0.1:40458 Accepted +[Tue Apr 21 08:32:52 2026] 127.0.0.1:40458 Closing +[Tue Apr 21 08:32:54 2026] 127.0.0.1:48762 Accepted +[Tue Apr 21 08:32:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:55 2026] 127.0.0.1:48762 Closing +[Tue Apr 21 08:32:56 2026] 127.0.0.1:48778 Accepted +[Tue Apr 21 08:32:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:57 2026] 127.0.0.1:48778 Closing +[Tue Apr 21 08:32:57 2026] 127.0.0.1:48794 Accepted +[Tue Apr 21 08:33:02 2026] 127.0.0.1:48794 Closing +[Tue Apr 21 08:33:06 2026] 127.0.0.1:33506 Accepted +[Tue Apr 21 08:33:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:07 2026] 127.0.0.1:33506 Closing +[Tue Apr 21 08:33:07 2026] 127.0.0.1:33510 Accepted +[Tue Apr 21 08:33:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:08 2026] 127.0.0.1:33510 Closing +[Tue Apr 21 08:33:08 2026] 127.0.0.1:33524 Accepted +[Tue Apr 21 08:33:11 2026] 127.0.0.1:33524 Closing +[Tue Apr 21 08:33:11 2026] 127.0.0.1:52916 Accepted +[Tue Apr 21 08:33:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:12 2026] 127.0.0.1:52916 Closing +[Tue Apr 21 08:33:12 2026] 127.0.0.1:52920 Accepted +[Tue Apr 21 08:33:13 2026] 127.0.0.1:52920 Closing +[Tue Apr 21 08:33:13 2026] 127.0.0.1:52934 Accepted +[Tue Apr 21 08:33:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:15 2026] 127.0.0.1:52934 Closing +[Tue Apr 21 08:33:15 2026] 127.0.0.1:52936 Accepted +[Tue Apr 21 08:33:14 2026] 127.0.0.1:52936 Closing +[Tue Apr 21 08:33:14 2026] 127.0.0.1:52946 Accepted +[Tue Apr 21 08:33:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:15 2026] 127.0.0.1:52946 Closing +[Tue Apr 21 08:33:15 2026] 127.0.0.1:52950 Accepted +[Tue Apr 21 08:33:17 2026] 127.0.0.1:52950 Closing +[Tue Apr 21 08:33:21 2026] 127.0.0.1:38616 Accepted +[Tue Apr 21 08:33:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:22 2026] 127.0.0.1:38616 Closing +[Tue Apr 21 08:33:23 2026] 127.0.0.1:38630 Accepted +[Tue Apr 21 08:33:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:23 2026] 127.0.0.1:38630 Closing +[Tue Apr 21 08:33:23 2026] 127.0.0.1:38636 Accepted +[Tue Apr 21 08:33:29 2026] 127.0.0.1:38636 Closing +[Tue Apr 21 08:33:36 2026] 127.0.0.1:43520 Accepted +[Tue Apr 21 08:33:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:37 2026] 127.0.0.1:43520 Closing +[Tue Apr 21 08:33:38 2026] 127.0.0.1:43534 Accepted +[Tue Apr 21 08:33:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:38 2026] 127.0.0.1:43534 Closing +[Tue Apr 21 08:33:38 2026] 127.0.0.1:54204 Accepted +[Tue Apr 21 08:33:43 2026] 127.0.0.1:54204 Closing +[Tue Apr 21 08:33:43 2026] 127.0.0.1:54220 Accepted +[Tue Apr 21 08:33:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:43 2026] 127.0.0.1:54220 Closing +[Tue Apr 21 08:33:43 2026] 127.0.0.1:54234 Accepted +[Tue Apr 21 08:33:44 2026] 127.0.0.1:54234 Closing +[Tue Apr 21 08:33:50 2026] 127.0.0.1:43308 Accepted +[Tue Apr 21 08:33:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:51 2026] 127.0.0.1:43308 Closing +[Tue Apr 21 08:33:58 2026] 127.0.0.1:37758 Accepted +[Tue Apr 21 08:33:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:59 2026] 127.0.0.1:37758 Closing +[Tue Apr 21 08:34:04 2026] 127.0.0.1:37766 Accepted +[Tue Apr 21 08:34:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:05 2026] 127.0.0.1:37766 Closing +[Tue Apr 21 08:34:06 2026] 127.0.0.1:35176 Accepted +[Tue Apr 21 08:34:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:06 2026] 127.0.0.1:35176 Closing +[Tue Apr 21 08:34:06 2026] 127.0.0.1:35188 Accepted +[Tue Apr 21 08:34:09 2026] 127.0.0.1:35188 Closing +[Tue Apr 21 08:34:09 2026] 127.0.0.1:35204 Accepted +[Tue Apr 21 08:34:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35204 Closing +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35220 Accepted +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35220 Closing +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35236 Accepted +[Tue Apr 21 08:34:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35236 Closing +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35242 Accepted +[Tue Apr 21 08:34:13 2026] 127.0.0.1:35242 Closing +[Tue Apr 21 08:34:13 2026] 127.0.0.1:35258 Accepted +[Tue Apr 21 08:34:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:13 2026] 127.0.0.1:35258 Closing +[Tue Apr 21 08:34:13 2026] 127.0.0.1:35262 Accepted +[Tue Apr 21 08:34:15 2026] 127.0.0.1:35262 Closing +[Tue Apr 21 08:34:15 2026] 127.0.0.1:37582 Accepted +[Tue Apr 21 08:34:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:16 2026] 127.0.0.1:37582 Closing +[Tue Apr 21 08:34:16 2026] 127.0.0.1:37588 Accepted +[Tue Apr 21 08:34:18 2026] 127.0.0.1:37588 Closing +[Tue Apr 21 08:34:18 2026] 127.0.0.1:37604 Accepted +[Tue Apr 21 08:34:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:18 2026] 127.0.0.1:37604 Closing +[Tue Apr 21 08:34:18 2026] 127.0.0.1:37614 Accepted +[Tue Apr 21 08:34:20 2026] 127.0.0.1:37614 Closing +[Tue Apr 21 08:34:25 2026] 127.0.0.1:59704 Accepted +[Tue Apr 21 08:34:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:28 2026] 127.0.0.1:59704 Closing +[Tue Apr 21 08:34:28 2026] 127.0.0.1:59712 Accepted +[Tue Apr 21 08:34:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:29 2026] 127.0.0.1:59712 Closing +[Tue Apr 21 08:34:29 2026] 127.0.0.1:59720 Accepted +[Tue Apr 21 08:34:31 2026] 127.0.0.1:59720 Closing +[Tue Apr 21 08:34:31 2026] 127.0.0.1:59722 Accepted +[Tue Apr 21 08:34:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:31 2026] 127.0.0.1:59722 Closing +[Tue Apr 21 08:34:31 2026] 127.0.0.1:59728 Accepted +[Tue Apr 21 08:34:32 2026] 127.0.0.1:59728 Closing +[Tue Apr 21 08:34:35 2026] 127.0.0.1:55356 Accepted +[Tue Apr 21 08:34:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:36 2026] 127.0.0.1:55356 Closing +[Tue Apr 21 08:34:37 2026] 127.0.0.1:55366 Accepted +[Tue Apr 21 08:34:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:37 2026] 127.0.0.1:55366 Closing +[Tue Apr 21 08:34:37 2026] 127.0.0.1:55368 Accepted +[Tue Apr 21 08:34:40 2026] 127.0.0.1:55368 Closing +[Tue Apr 21 08:34:46 2026] 127.0.0.1:38064 Accepted +[Tue Apr 21 08:34:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:47 2026] 127.0.0.1:38064 Closing +[Tue Apr 21 08:34:48 2026] 127.0.0.1:38070 Accepted +[Tue Apr 21 08:34:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:49 2026] 127.0.0.1:38070 Closing +[Tue Apr 21 08:34:49 2026] 127.0.0.1:38074 Accepted +[Tue Apr 21 08:34:51 2026] 127.0.0.1:38074 Closing +[Tue Apr 21 08:34:51 2026] 127.0.0.1:58306 Accepted +[Tue Apr 21 08:34:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:52 2026] 127.0.0.1:58306 Closing +[Tue Apr 21 08:34:52 2026] 127.0.0.1:58318 Accepted +[Tue Apr 21 08:34:55 2026] 127.0.0.1:58318 Closing +[Tue Apr 21 08:34:55 2026] 127.0.0.1:58330 Accepted +[Tue Apr 21 08:34:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:56 2026] 127.0.0.1:58330 Closing +[Tue Apr 21 08:34:56 2026] 127.0.0.1:58346 Accepted +[Tue Apr 21 08:34:59 2026] 127.0.0.1:58346 Closing +[Tue Apr 21 08:34:59 2026] 127.0.0.1:58358 Accepted +[Tue Apr 21 08:34:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:01 2026] 127.0.0.1:58358 Closing +[Tue Apr 21 08:35:01 2026] 127.0.0.1:57072 Accepted +[Tue Apr 21 08:35:04 2026] 127.0.0.1:57072 Closing +[Tue Apr 21 08:35:04 2026] 127.0.0.1:57088 Accepted +[Tue Apr 21 08:35:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:05 2026] 127.0.0.1:57088 Closing +[Tue Apr 21 08:35:05 2026] 127.0.0.1:57100 Accepted +[Tue Apr 21 08:35:05 2026] 127.0.0.1:57100 Closing +[Tue Apr 21 08:35:05 2026] 127.0.0.1:57108 Accepted +[Tue Apr 21 08:35:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:06 2026] 127.0.0.1:57108 Closing +[Tue Apr 21 08:35:06 2026] 127.0.0.1:57124 Accepted +[Tue Apr 21 08:35:09 2026] 127.0.0.1:57124 Closing +[Tue Apr 21 08:35:15 2026] 127.0.0.1:45818 Accepted +[Tue Apr 21 08:35:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:15 2026] 127.0.0.1:45818 Closing +[Tue Apr 21 08:35:15 2026] 127.0.0.1:45830 Accepted +[Tue Apr 21 08:35:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:16 2026] 127.0.0.1:45830 Closing +[Tue Apr 21 08:35:16 2026] 127.0.0.1:45832 Accepted +[Tue Apr 21 08:35:18 2026] 127.0.0.1:45832 Closing +[Tue Apr 21 08:35:20 2026] 127.0.0.1:59276 Accepted +[Tue Apr 21 08:35:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:21 2026] 127.0.0.1:59276 Closing +[Tue Apr 21 08:35:22 2026] 127.0.0.1:59284 Accepted +[Tue Apr 21 08:35:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:22 2026] 127.0.0.1:59284 Closing +[Tue Apr 21 08:35:22 2026] 127.0.0.1:59300 Accepted +[Tue Apr 21 08:35:24 2026] 127.0.0.1:59300 Closing +[Tue Apr 21 08:35:24 2026] 127.0.0.1:59308 Accepted +[Tue Apr 21 08:35:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:25 2026] 127.0.0.1:59308 Closing +[Tue Apr 21 08:35:25 2026] 127.0.0.1:59310 Accepted +[Tue Apr 21 08:35:26 2026] 127.0.0.1:59310 Closing +[Tue Apr 21 08:35:32 2026] 127.0.0.1:37456 Accepted +[Tue Apr 21 08:35:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:32 2026] 127.0.0.1:37456 Closing +[Tue Apr 21 08:35:34 2026] 127.0.0.1:37470 Accepted +[Tue Apr 21 08:35:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:32 2026] 127.0.0.1:37470 Closing +[Tue Apr 21 08:35:32 2026] 127.0.0.1:37486 Accepted +[Tue Apr 21 08:35:35 2026] 127.0.0.1:37486 Closing +[Tue Apr 21 08:35:40 2026] 127.0.0.1:42040 Accepted +[Tue Apr 21 08:35:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:40 2026] 127.0.0.1:42040 Closing +[Tue Apr 21 08:35:40 2026] 127.0.0.1:42042 Accepted +[Tue Apr 21 08:35:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:41 2026] 127.0.0.1:42042 Closing +[Tue Apr 21 08:35:41 2026] 127.0.0.1:42056 Accepted +[Tue Apr 21 08:35:42 2026] 127.0.0.1:42056 Closing +[Tue Apr 21 08:35:42 2026] 127.0.0.1:42064 Accepted +[Tue Apr 21 08:35:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:43 2026] 127.0.0.1:42064 Closing +[Tue Apr 21 08:35:43 2026] 127.0.0.1:42080 Accepted +[Tue Apr 21 08:35:45 2026] 127.0.0.1:42080 Closing +[Tue Apr 21 08:36:32 2026] 127.0.0.1:56310 Accepted +[Tue Apr 21 08:36:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:33 2026] 127.0.0.1:56310 Closing +[Tue Apr 21 08:36:34 2026] 127.0.0.1:56316 Accepted +[Tue Apr 21 08:36:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:35 2026] 127.0.0.1:56316 Closing +[Tue Apr 21 08:36:35 2026] 127.0.0.1:56330 Accepted +[Tue Apr 21 08:36:38 2026] 127.0.0.1:56330 Closing +[Tue Apr 21 08:36:45 2026] 127.0.0.1:54024 Accepted +[Tue Apr 21 08:36:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:46 2026] 127.0.0.1:54024 Closing +[Tue Apr 21 08:36:47 2026] 127.0.0.1:54032 Accepted +[Tue Apr 21 08:36:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:47 2026] 127.0.0.1:54032 Closing +[Tue Apr 21 08:36:47 2026] 127.0.0.1:54038 Accepted +[Tue Apr 21 08:36:52 2026] 127.0.0.1:54038 Closing +[Tue Apr 21 08:36:55 2026] 127.0.0.1:58872 Accepted +[Tue Apr 21 08:36:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:55 2026] 127.0.0.1:58872 Closing +[Tue Apr 21 08:36:55 2026] 127.0.0.1:58874 Accepted +[Tue Apr 21 08:36:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:56 2026] 127.0.0.1:58874 Closing +[Tue Apr 21 08:36:56 2026] 127.0.0.1:58876 Accepted +[Tue Apr 21 08:36:57 2026] 127.0.0.1:58876 Closing +[Tue Apr 21 08:36:57 2026] 127.0.0.1:58888 Accepted +[Tue Apr 21 08:36:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:57 2026] 127.0.0.1:58888 Closing +[Tue Apr 21 08:36:57 2026] 127.0.0.1:48330 Accepted +[Tue Apr 21 08:37:00 2026] 127.0.0.1:48330 Closing +[Tue Apr 21 08:37:02 2026] 127.0.0.1:48340 Accepted +[Tue Apr 21 08:37:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:03 2026] 127.0.0.1:48340 Closing +[Tue Apr 21 08:37:03 2026] 127.0.0.1:48346 Accepted +[Tue Apr 21 08:37:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:04 2026] 127.0.0.1:48346 Closing +[Tue Apr 21 08:37:04 2026] 127.0.0.1:48352 Accepted +[Tue Apr 21 08:37:08 2026] 127.0.0.1:48352 Closing +[Tue Apr 21 08:37:12 2026] 127.0.0.1:51998 Accepted +[Tue Apr 21 08:37:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:13 2026] 127.0.0.1:51998 Closing +[Tue Apr 21 08:37:13 2026] 127.0.0.1:52004 Accepted +[Tue Apr 21 08:37:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:14 2026] 127.0.0.1:52004 Closing +[Tue Apr 21 08:37:14 2026] 127.0.0.1:52016 Accepted +[Tue Apr 21 08:37:16 2026] 127.0.0.1:52016 Closing +[Tue Apr 21 08:37:16 2026] 127.0.0.1:52024 Accepted +[Tue Apr 21 08:37:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:16 2026] 127.0.0.1:52024 Closing +[Tue Apr 21 08:37:16 2026] 127.0.0.1:52028 Accepted +[Tue Apr 21 08:37:18 2026] 127.0.0.1:52028 Closing +[Tue Apr 21 08:37:18 2026] 127.0.0.1:55842 Accepted +[Tue Apr 21 08:37:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:19 2026] 127.0.0.1:55842 Closing +[Tue Apr 21 08:37:19 2026] 127.0.0.1:55858 Accepted +[Tue Apr 21 08:37:20 2026] 127.0.0.1:55858 Closing +[Tue Apr 21 08:37:21 2026] 127.0.0.1:55866 Accepted +[Tue Apr 21 08:37:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:21 2026] 127.0.0.1:55866 Closing +[Tue Apr 21 08:37:21 2026] 127.0.0.1:55878 Accepted +[Tue Apr 21 08:37:23 2026] 127.0.0.1:55878 Closing +[Tue Apr 21 08:37:24 2026] 127.0.0.1:55890 Accepted +[Tue Apr 21 08:37:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:25 2026] 127.0.0.1:55890 Closing +[Tue Apr 21 08:37:26 2026] 127.0.0.1:52118 Accepted +[Tue Apr 21 08:37:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:26 2026] 127.0.0.1:52118 Closing +[Tue Apr 21 08:37:26 2026] 127.0.0.1:52132 Accepted +[Tue Apr 21 08:37:31 2026] 127.0.0.1:52132 Closing +[Tue Apr 21 08:37:37 2026] 127.0.0.1:44942 Accepted +[Tue Apr 21 08:37:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:38 2026] 127.0.0.1:44942 Closing +[Tue Apr 21 08:37:39 2026] 127.0.0.1:44958 Accepted +[Tue Apr 21 08:37:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:39 2026] 127.0.0.1:44958 Closing +[Tue Apr 21 08:37:39 2026] 127.0.0.1:44974 Accepted +[Tue Apr 21 08:37:43 2026] 127.0.0.1:44974 Closing +[Tue Apr 21 08:37:43 2026] 127.0.0.1:44988 Accepted +[Tue Apr 21 08:37:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:44 2026] 127.0.0.1:44988 Closing +[Tue Apr 21 08:37:44 2026] 127.0.0.1:44992 Accepted +[Tue Apr 21 08:37:47 2026] 127.0.0.1:44992 Closing +[Tue Apr 21 08:37:51 2026] 127.0.0.1:44304 Accepted +[Tue Apr 21 08:37:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:52 2026] 127.0.0.1:44304 Closing +[Tue Apr 21 08:37:59 2026] 127.0.0.1:46586 Accepted +[Tue Apr 21 08:37:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:00 2026] 127.0.0.1:46586 Closing +[Tue Apr 21 08:38:06 2026] 127.0.0.1:50020 Accepted +[Tue Apr 21 08:38:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:06 2026] 127.0.0.1:50020 Closing +[Tue Apr 21 08:38:07 2026] 127.0.0.1:50034 Accepted +[Tue Apr 21 08:38:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:07 2026] 127.0.0.1:50034 Closing +[Tue Apr 21 08:38:07 2026] 127.0.0.1:50036 Accepted +[Tue Apr 21 08:38:10 2026] 127.0.0.1:50036 Closing +[Tue Apr 21 08:38:10 2026] 127.0.0.1:50044 Accepted +[Tue Apr 21 08:38:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:10 2026] 127.0.0.1:50044 Closing +[Tue Apr 21 08:38:10 2026] 127.0.0.1:50054 Accepted +[Tue Apr 21 08:38:12 2026] 127.0.0.1:50054 Closing +[Tue Apr 21 08:38:12 2026] 127.0.0.1:41470 Accepted +[Tue Apr 21 08:38:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:13 2026] 127.0.0.1:41470 Closing +[Tue Apr 21 08:38:13 2026] 127.0.0.1:41472 Accepted +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41472 Closing +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41482 Accepted +[Tue Apr 21 08:38:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41482 Closing +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41494 Accepted +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41494 Closing +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41508 Accepted +[Tue Apr 21 08:38:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41508 Closing +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41522 Accepted +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41522 Closing +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41532 Accepted +[Tue Apr 21 08:38:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41532 Closing +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41548 Accepted +[Tue Apr 21 08:38:20 2026] 127.0.0.1:41548 Closing +[Tue Apr 21 08:38:26 2026] 127.0.0.1:50308 Accepted +[Tue Apr 21 08:38:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:26 2026] 127.0.0.1:50308 Closing +[Tue Apr 21 08:38:26 2026] 127.0.0.1:50310 Accepted +[Tue Apr 21 08:38:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:27 2026] 127.0.0.1:50310 Closing +[Tue Apr 21 08:38:27 2026] 127.0.0.1:50324 Accepted +[Tue Apr 21 08:38:29 2026] 127.0.0.1:50324 Closing +[Tue Apr 21 08:38:29 2026] 127.0.0.1:50336 Accepted +[Tue Apr 21 08:38:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:29 2026] 127.0.0.1:50336 Closing +[Tue Apr 21 08:38:29 2026] 127.0.0.1:50338 Accepted +[Tue Apr 21 08:38:30 2026] 127.0.0.1:50338 Closing +[Tue Apr 21 08:38:33 2026] 127.0.0.1:36982 Accepted +[Tue Apr 21 08:38:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:34 2026] 127.0.0.1:36982 Closing +[Tue Apr 21 08:38:35 2026] 127.0.0.1:36996 Accepted +[Tue Apr 21 08:38:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:35 2026] 127.0.0.1:36996 Closing +[Tue Apr 21 08:38:35 2026] 127.0.0.1:36998 Accepted +[Tue Apr 21 08:38:41 2026] 127.0.0.1:36998 Closing +[Tue Apr 21 08:38:44 2026] 127.0.0.1:54474 Accepted +[Tue Apr 21 08:38:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:45 2026] 127.0.0.1:54474 Closing +[Tue Apr 21 08:38:46 2026] 127.0.0.1:54482 Accepted +[Tue Apr 21 08:38:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:47 2026] 127.0.0.1:54482 Closing +[Tue Apr 21 08:38:47 2026] 127.0.0.1:48460 Accepted +[Tue Apr 21 08:38:50 2026] 127.0.0.1:48460 Closing +[Tue Apr 21 08:38:50 2026] 127.0.0.1:48462 Accepted +[Tue Apr 21 08:38:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:50 2026] 127.0.0.1:48462 Closing +[Tue Apr 21 08:38:50 2026] 127.0.0.1:48472 Accepted +[Tue Apr 21 08:38:52 2026] 127.0.0.1:48472 Closing +[Tue Apr 21 08:38:52 2026] 127.0.0.1:48484 Accepted +[Tue Apr 21 08:38:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:53 2026] 127.0.0.1:48484 Closing +[Tue Apr 21 08:38:53 2026] 127.0.0.1:48486 Accepted +[Tue Apr 21 08:38:56 2026] 127.0.0.1:48486 Closing +[Tue Apr 21 08:38:56 2026] 127.0.0.1:48498 Accepted +[Tue Apr 21 08:38:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:57 2026] 127.0.0.1:48498 Closing +[Tue Apr 21 08:38:57 2026] 127.0.0.1:47426 Accepted +[Tue Apr 21 08:38:59 2026] 127.0.0.1:47426 Closing +[Tue Apr 21 08:38:59 2026] 127.0.0.1:47438 Accepted +[Tue Apr 21 08:38:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:00 2026] 127.0.0.1:47438 Closing +[Tue Apr 21 08:39:00 2026] 127.0.0.1:47444 Accepted +[Tue Apr 21 08:39:02 2026] 127.0.0.1:47444 Closing +[Tue Apr 21 08:39:02 2026] 127.0.0.1:47456 Accepted +[Tue Apr 21 08:39:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:03 2026] 127.0.0.1:47456 Closing +[Tue Apr 21 08:39:03 2026] 127.0.0.1:47458 Accepted +[Tue Apr 21 08:39:06 2026] 127.0.0.1:47458 Closing +[Tue Apr 21 08:39:11 2026] 127.0.0.1:57526 Accepted +[Tue Apr 21 08:39:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:11 2026] 127.0.0.1:57526 Closing +[Tue Apr 21 08:39:12 2026] 127.0.0.1:57528 Accepted +[Tue Apr 21 08:39:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:12 2026] 127.0.0.1:57528 Closing +[Tue Apr 21 08:39:12 2026] 127.0.0.1:57540 Accepted +[Tue Apr 21 08:39:11 2026] 127.0.0.1:57540 Closing +[Tue Apr 21 08:39:14 2026] 127.0.0.1:57542 Accepted +[Tue Apr 21 08:39:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:14 2026] 127.0.0.1:57542 Closing +[Tue Apr 21 08:39:15 2026] 127.0.0.1:39198 Accepted +[Tue Apr 21 08:39:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:16 2026] 127.0.0.1:39198 Closing +[Tue Apr 21 08:39:16 2026] 127.0.0.1:39206 Accepted +[Tue Apr 21 08:39:17 2026] 127.0.0.1:39206 Closing +[Tue Apr 21 08:39:17 2026] 127.0.0.1:39220 Accepted +[Tue Apr 21 08:39:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:18 2026] 127.0.0.1:39220 Closing +[Tue Apr 21 08:39:18 2026] 127.0.0.1:39222 Accepted +[Tue Apr 21 08:39:19 2026] 127.0.0.1:39222 Closing +[Tue Apr 21 08:39:24 2026] 127.0.0.1:38660 Accepted +[Tue Apr 21 08:39:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:25 2026] 127.0.0.1:38660 Closing +[Tue Apr 21 08:39:26 2026] 127.0.0.1:38672 Accepted +[Tue Apr 21 08:39:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:26 2026] 127.0.0.1:38672 Closing +[Tue Apr 21 08:39:26 2026] 127.0.0.1:38684 Accepted +[Tue Apr 21 08:39:29 2026] 127.0.0.1:38684 Closing +[Tue Apr 21 08:39:34 2026] 127.0.0.1:42206 Accepted +[Tue Apr 21 08:39:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:35 2026] 127.0.0.1:42206 Closing +[Tue Apr 21 08:39:35 2026] 127.0.0.1:42214 Accepted +[Tue Apr 21 08:39:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:36 2026] 127.0.0.1:42214 Closing +[Tue Apr 21 08:39:36 2026] 127.0.0.1:42220 Accepted +[Tue Apr 21 08:39:38 2026] 127.0.0.1:42220 Closing +[Tue Apr 21 08:39:38 2026] 127.0.0.1:42222 Accepted +[Tue Apr 21 08:39:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:38 2026] 127.0.0.1:42222 Closing +[Tue Apr 21 08:39:38 2026] 127.0.0.1:42238 Accepted +[Tue Apr 21 08:39:40 2026] 127.0.0.1:42238 Closing +[Tue Apr 21 08:41:52 2026] 127.0.0.1:33936 Accepted +[Tue Apr 21 08:41:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:41:53 2026] 127.0.0.1:33936 Closing +[Tue Apr 21 08:41:55 2026] 127.0.0.1:33942 Accepted +[Tue Apr 21 08:41:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:41:55 2026] 127.0.0.1:33942 Closing +[Tue Apr 21 08:41:55 2026] 127.0.0.1:33954 Accepted +[Tue Apr 21 08:41:56 2026] 127.0.0.1:33954 Closing +[Tue Apr 21 08:42:04 2026] 127.0.0.1:38466 Accepted +[Tue Apr 21 08:42:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:04 2026] 127.0.0.1:38466 Closing +[Tue Apr 21 08:42:05 2026] 127.0.0.1:38482 Accepted +[Tue Apr 21 08:42:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:06 2026] 127.0.0.1:38482 Closing +[Tue Apr 21 08:42:06 2026] 127.0.0.1:38494 Accepted +[Tue Apr 21 08:42:10 2026] 127.0.0.1:38494 Closing +[Tue Apr 21 08:42:16 2026] 127.0.0.1:60048 Accepted +[Tue Apr 21 08:42:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:16 2026] 127.0.0.1:60048 Closing +[Tue Apr 21 08:42:16 2026] 127.0.0.1:60060 Accepted +[Tue Apr 21 08:42:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:17 2026] 127.0.0.1:60060 Closing +[Tue Apr 21 08:42:17 2026] 127.0.0.1:60076 Accepted +[Tue Apr 21 08:42:17 2026] 127.0.0.1:60076 Closing +[Tue Apr 21 08:42:17 2026] 127.0.0.1:60082 Accepted +[Tue Apr 21 08:42:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:18 2026] 127.0.0.1:60082 Closing +[Tue Apr 21 08:42:18 2026] 127.0.0.1:35288 Accepted +[Tue Apr 21 08:42:20 2026] 127.0.0.1:35288 Closing +[Tue Apr 21 08:42:23 2026] 127.0.0.1:35290 Accepted +[Tue Apr 21 08:42:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:23 2026] 127.0.0.1:35290 Closing +[Tue Apr 21 08:42:21 2026] 127.0.0.1:35292 Accepted +[Tue Apr 21 08:42:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:22 2026] 127.0.0.1:35292 Closing +[Tue Apr 21 08:42:22 2026] 127.0.0.1:35298 Accepted +[Tue Apr 21 08:42:26 2026] 127.0.0.1:35298 Closing +[Tue Apr 21 08:42:30 2026] 127.0.0.1:41052 Accepted +[Tue Apr 21 08:42:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:30 2026] 127.0.0.1:41052 Closing +[Tue Apr 21 08:42:31 2026] 127.0.0.1:41064 Accepted +[Tue Apr 21 08:42:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:31 2026] 127.0.0.1:41064 Closing +[Tue Apr 21 08:42:31 2026] 127.0.0.1:41076 Accepted +[Tue Apr 21 08:42:33 2026] 127.0.0.1:41076 Closing +[Tue Apr 21 08:42:33 2026] 127.0.0.1:41090 Accepted +[Tue Apr 21 08:42:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:33 2026] 127.0.0.1:41090 Closing +[Tue Apr 21 08:42:33 2026] 127.0.0.1:41098 Accepted +[Tue Apr 21 08:42:34 2026] 127.0.0.1:41098 Closing +[Tue Apr 21 08:42:34 2026] 127.0.0.1:41106 Accepted +[Tue Apr 21 08:42:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:35 2026] 127.0.0.1:41106 Closing +[Tue Apr 21 08:42:35 2026] 127.0.0.1:58110 Accepted +[Tue Apr 21 08:42:37 2026] 127.0.0.1:58110 Closing +[Tue Apr 21 08:42:37 2026] 127.0.0.1:58114 Accepted +[Tue Apr 21 08:42:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:37 2026] 127.0.0.1:58114 Closing +[Tue Apr 21 08:42:37 2026] 127.0.0.1:58124 Accepted +[Tue Apr 21 08:42:38 2026] 127.0.0.1:58124 Closing +[Tue Apr 21 08:42:42 2026] 127.0.0.1:58130 Accepted +[Tue Apr 21 08:42:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:43 2026] 127.0.0.1:58130 Closing +[Tue Apr 21 08:42:44 2026] 127.0.0.1:58132 Accepted +[Tue Apr 21 08:42:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:44 2026] 127.0.0.1:58132 Closing +[Tue Apr 21 08:42:44 2026] 127.0.0.1:58140 Accepted +[Tue Apr 21 08:42:48 2026] 127.0.0.1:58140 Closing +[Tue Apr 21 08:42:52 2026] 127.0.0.1:55692 Accepted +[Tue Apr 21 08:42:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:53 2026] 127.0.0.1:55692 Closing +[Tue Apr 21 08:42:54 2026] 127.0.0.1:42170 Accepted +[Tue Apr 21 08:42:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:54 2026] 127.0.0.1:42170 Closing +[Tue Apr 21 08:42:54 2026] 127.0.0.1:42178 Accepted +[Tue Apr 21 08:42:58 2026] 127.0.0.1:42178 Closing +[Tue Apr 21 08:42:58 2026] 127.0.0.1:42190 Accepted +[Tue Apr 21 08:42:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:59 2026] 127.0.0.1:42190 Closing +[Tue Apr 21 08:42:59 2026] 127.0.0.1:42204 Accepted +[Tue Apr 21 08:43:02 2026] 127.0.0.1:42204 Closing +[Tue Apr 21 08:43:08 2026] 127.0.0.1:60494 Accepted +[Tue Apr 21 08:43:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:09 2026] 127.0.0.1:60494 Closing +[Tue Apr 21 08:43:10 2026] 127.0.0.1:60504 Accepted +[Tue Apr 21 08:43:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:11 2026] 127.0.0.1:60504 Closing +[Tue Apr 21 08:43:11 2026] 127.0.0.1:60508 Accepted +[Tue Apr 21 08:43:14 2026] 127.0.0.1:60508 Closing +[Tue Apr 21 08:43:18 2026] 127.0.0.1:50612 Accepted +[Tue Apr 21 08:43:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:19 2026] 127.0.0.1:50612 Closing +[Tue Apr 21 08:43:20 2026] 127.0.0.1:43492 Accepted +[Tue Apr 21 08:43:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:20 2026] 127.0.0.1:43492 Closing +[Tue Apr 21 08:43:20 2026] 127.0.0.1:43494 Accepted +[Tue Apr 21 08:43:23 2026] 127.0.0.1:43494 Closing +[Tue Apr 21 08:43:29 2026] 127.0.0.1:43498 Accepted +[Tue Apr 21 08:43:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:29 2026] 127.0.0.1:43498 Closing +[Tue Apr 21 08:43:30 2026] 127.0.0.1:52050 Accepted +[Tue Apr 21 08:43:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:31 2026] 127.0.0.1:52050 Closing +[Tue Apr 21 08:43:31 2026] 127.0.0.1:52064 Accepted +[Tue Apr 21 08:43:36 2026] 127.0.0.1:52064 Closing +[Tue Apr 21 08:43:36 2026] 127.0.0.1:52078 Accepted +[Tue Apr 21 08:43:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:38 2026] 127.0.0.1:52078 Closing +[Tue Apr 21 08:43:38 2026] 127.0.0.1:52090 Accepted +[Tue Apr 21 08:43:42 2026] 127.0.0.1:52090 Closing +[Tue Apr 21 08:43:42 2026] 127.0.0.1:34256 Accepted +[Tue Apr 21 08:43:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:44 2026] 127.0.0.1:34256 Closing +[Tue Apr 21 08:43:44 2026] 127.0.0.1:34258 Accepted +[Tue Apr 21 08:43:44 2026] 127.0.0.1:34258 Closing +[Tue Apr 21 08:43:44 2026] 127.0.0.1:34264 Accepted +[Tue Apr 21 08:43:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:45 2026] 127.0.0.1:34264 Closing +[Tue Apr 21 08:43:45 2026] 127.0.0.1:34266 Accepted +[Tue Apr 21 08:43:47 2026] 127.0.0.1:34266 Closing +[Tue Apr 21 08:43:47 2026] 127.0.0.1:56920 Accepted +[Tue Apr 21 08:43:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:48 2026] 127.0.0.1:56920 Closing +[Tue Apr 21 08:43:48 2026] 127.0.0.1:56932 Accepted +[Tue Apr 21 08:43:50 2026] 127.0.0.1:56932 Closing +[Tue Apr 21 08:43:50 2026] 127.0.0.1:56940 Accepted +[Tue Apr 21 08:43:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:51 2026] 127.0.0.1:56940 Closing +[Tue Apr 21 08:43:51 2026] 127.0.0.1:56946 Accepted +[Tue Apr 21 08:43:53 2026] 127.0.0.1:56946 Closing +[Tue Apr 21 08:43:58 2026] 127.0.0.1:47898 Accepted +[Tue Apr 21 08:43:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:59 2026] 127.0.0.1:47898 Closing +[Tue Apr 21 08:43:59 2026] 127.0.0.1:47912 Accepted +[Tue Apr 21 08:43:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:00 2026] 127.0.0.1:47912 Closing +[Tue Apr 21 08:44:00 2026] 127.0.0.1:47916 Accepted +[Tue Apr 21 08:44:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:02 2026] 127.0.0.1:47916 Closing +[Tue Apr 21 08:44:02 2026] 127.0.0.1:47920 Accepted +[Tue Apr 21 08:44:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:02 2026] 127.0.0.1:47920 Closing +[Tue Apr 21 08:44:02 2026] 127.0.0.1:47922 Accepted +[Tue Apr 21 08:44:03 2026] 127.0.0.1:47922 Closing +[Tue Apr 21 08:44:03 2026] 127.0.0.1:47928 Accepted +[Tue Apr 21 08:44:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:05 2026] 127.0.0.1:47928 Closing +[Tue Apr 21 08:44:05 2026] 127.0.0.1:47940 Accepted +[Tue Apr 21 08:44:05 2026] 127.0.0.1:47940 Closing +[Tue Apr 21 08:44:05 2026] 127.0.0.1:47956 Accepted +[Tue Apr 21 08:44:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:09 2026] 127.0.0.1:47956 Closing +[Tue Apr 21 08:44:09 2026] 127.0.0.1:53268 Accepted +[Tue Apr 21 08:44:09 2026] 127.0.0.1:53268 Closing +[Tue Apr 21 08:44:10 2026] 127.0.0.1:53274 Accepted +[Tue Apr 21 08:44:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:11 2026] 127.0.0.1:53274 Closing +[Tue Apr 21 08:44:11 2026] 127.0.0.1:53276 Accepted +[Tue Apr 21 08:44:13 2026] 127.0.0.1:53276 Closing +[Tue Apr 21 08:44:13 2026] 127.0.0.1:53280 Accepted +[Tue Apr 21 08:44:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:14 2026] 127.0.0.1:53280 Closing +[Tue Apr 21 08:44:15 2026] 127.0.0.1:45126 Accepted +[Tue Apr 21 08:44:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:16 2026] 127.0.0.1:45126 Closing +[Tue Apr 21 08:44:16 2026] 127.0.0.1:45136 Accepted +[Tue Apr 21 08:44:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:21 2026] 127.0.0.1:45136 Closing +[Tue Apr 21 08:44:21 2026] 127.0.0.1:45146 Accepted +[Tue Apr 21 08:44:21 2026] 127.0.0.1:45146 Closing +[Tue Apr 21 08:44:22 2026] 127.0.0.1:45152 Accepted +[Tue Apr 21 08:44:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:22 2026] 127.0.0.1:45152 Closing +[Tue Apr 21 08:44:22 2026] 127.0.0.1:45168 Accepted +[Tue Apr 21 08:44:25 2026] 127.0.0.1:45168 Closing +[Tue Apr 21 08:44:25 2026] 127.0.0.1:39192 Accepted +[Tue Apr 21 08:44:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:26 2026] 127.0.0.1:39192 Closing +[Tue Apr 21 08:44:26 2026] 127.0.0.1:39208 Accepted +[Tue Apr 21 08:44:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:28 2026] 127.0.0.1:39208 Closing +[Tue Apr 21 08:44:28 2026] 127.0.0.1:39220 Accepted +[Tue Apr 21 08:44:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:28 2026] 127.0.0.1:39220 Closing +[Tue Apr 21 08:44:28 2026] 127.0.0.1:39226 Accepted +[Tue Apr 21 08:44:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:29 2026] 127.0.0.1:39226 Closing +[Tue Apr 21 08:44:29 2026] 127.0.0.1:39234 Accepted +[Tue Apr 21 08:44:29 2026] 127.0.0.1:39234 Closing +[Tue Apr 21 08:44:29 2026] 127.0.0.1:39240 Accepted +[Tue Apr 21 08:44:31 2026] 127.0.0.1:39240 Closing +[Tue Apr 21 08:44:31 2026] 127.0.0.1:39252 Accepted +[Tue Apr 21 08:44:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:32 2026] 127.0.0.1:39252 Closing +[Tue Apr 21 08:44:32 2026] 127.0.0.1:39254 Accepted +[Tue Apr 21 08:44:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:33 2026] 127.0.0.1:39254 Closing +[Tue Apr 21 08:44:33 2026] 127.0.0.1:39256 Accepted +[Tue Apr 21 08:44:33 2026] 127.0.0.1:39260 Accepted +[Tue Apr 21 08:44:33 2026] 127.0.0.1:39256 Closing +[Tue Apr 21 08:44:35 2026] 127.0.0.1:39260 Closing +[Tue Apr 21 08:44:35 2026] 127.0.0.1:39274 Accepted +[Tue Apr 21 08:44:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:38 2026] 127.0.0.1:39274 Closing +[Tue Apr 21 08:44:38 2026] 127.0.0.1:53182 Accepted +[Tue Apr 21 08:44:39 2026] 127.0.0.1:53182 Closing +[Tue Apr 21 08:44:39 2026] 127.0.0.1:53196 Accepted +[Tue Apr 21 08:44:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:38 2026] 127.0.0.1:53196 Closing +[Tue Apr 21 08:44:38 2026] 127.0.0.1:53200 Accepted +[Tue Apr 21 08:44:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:39 2026] 127.0.0.1:53200 Closing +[Tue Apr 21 08:44:39 2026] 127.0.0.1:53210 Accepted +[Tue Apr 21 08:44:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:40 2026] 127.0.0.1:53210 Closing +[Tue Apr 21 08:44:40 2026] 127.0.0.1:53218 Accepted +[Tue Apr 21 08:44:40 2026] 127.0.0.1:53218 Closing +[Tue Apr 21 08:44:40 2026] 127.0.0.1:53228 Accepted +[Tue Apr 21 08:44:43 2026] 127.0.0.1:53228 Closing +[Tue Apr 21 08:44:43 2026] 127.0.0.1:53238 Accepted +[Tue Apr 21 08:44:47 2026] 127.0.0.1:53238 Closing +[Tue Apr 21 08:44:48 2026] 127.0.0.1:58846 Accepted +[Tue Apr 21 08:44:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:48 2026] 127.0.0.1:58846 Closing +[Tue Apr 21 08:44:49 2026] 127.0.0.1:58848 Accepted +[Tue Apr 21 08:44:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:49 2026] 127.0.0.1:58848 Closing +[Tue Apr 21 08:44:49 2026] 127.0.0.1:58852 Accepted +[Tue Apr 21 08:44:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:51 2026] 127.0.0.1:58852 Closing +[Tue Apr 21 08:44:51 2026] 127.0.0.1:58866 Accepted +[Tue Apr 21 08:44:52 2026] 127.0.0.1:58866 Closing +[Tue Apr 21 08:44:53 2026] 127.0.0.1:45250 Accepted +[Tue Apr 21 08:44:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:53 2026] 127.0.0.1:45250 Closing +[Tue Apr 21 08:44:53 2026] 127.0.0.1:45264 Accepted +[Tue Apr 21 08:44:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:55 2026] 127.0.0.1:45264 Closing +[Tue Apr 21 08:44:55 2026] 127.0.0.1:45272 Accepted +[Tue Apr 21 08:44:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:55 2026] 127.0.0.1:45272 Closing +[Tue Apr 21 08:44:55 2026] 127.0.0.1:45276 Accepted +[Tue Apr 21 08:44:56 2026] 127.0.0.1:45276 Closing +[Tue Apr 21 08:44:56 2026] 127.0.0.1:45288 Accepted +[Tue Apr 21 08:44:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:58 2026] 127.0.0.1:45288 Closing +[Tue Apr 21 08:44:58 2026] 127.0.0.1:45300 Accepted +[Tue Apr 21 08:44:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:58 2026] 127.0.0.1:45300 Closing +[Tue Apr 21 08:44:58 2026] 127.0.0.1:45316 Accepted +[Tue Apr 21 08:44:59 2026] 127.0.0.1:45316 Closing +[Tue Apr 21 08:44:59 2026] 127.0.0.1:45318 Accepted +[Tue Apr 21 08:44:59 2026] 127.0.0.1:45326 Accepted +[Tue Apr 21 08:45:01 2026] 127.0.0.1:45318 Closing +[Tue Apr 21 08:45:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:02 2026] 127.0.0.1:45326 Closing +[Tue Apr 21 08:45:02 2026] 127.0.0.1:45338 Accepted +[Tue Apr 21 08:45:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:03 2026] 127.0.0.1:45338 Closing +[Tue Apr 21 08:45:03 2026] 127.0.0.1:35790 Accepted +[Tue Apr 21 08:45:03 2026] 127.0.0.1:35790 Closing +[Tue Apr 21 08:45:03 2026] 127.0.0.1:35804 Accepted +[Tue Apr 21 08:45:03 2026] 127.0.0.1:35812 Accepted +[Tue Apr 21 08:45:05 2026] 127.0.0.1:35804 Closing +[Tue Apr 21 08:45:07 2026] 127.0.0.1:35812 Closing +[Tue Apr 21 08:45:08 2026] 127.0.0.1:35820 Accepted +[Tue Apr 21 08:45:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:08 2026] 127.0.0.1:35820 Closing +[Tue Apr 21 08:45:08 2026] 127.0.0.1:35822 Accepted +[Tue Apr 21 08:45:09 2026] 127.0.0.1:35822 Closing +[Tue Apr 21 08:45:09 2026] 127.0.0.1:42350 Accepted +[Tue Apr 21 08:45:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:10 2026] 127.0.0.1:42350 Closing +[Tue Apr 21 08:45:10 2026] 127.0.0.1:42358 Accepted +[Tue Apr 21 08:45:11 2026] 127.0.0.1:42358 Closing +[Tue Apr 21 08:45:11 2026] 127.0.0.1:42374 Accepted +[Tue Apr 21 08:45:14 2026] 127.0.0.1:42374 Closing +[Tue Apr 21 08:45:14 2026] 127.0.0.1:42380 Accepted +[Tue Apr 21 08:45:19 2026] 127.0.0.1:42380 Closing +[Tue Apr 21 08:45:19 2026] 127.0.0.1:57698 Accepted +[Tue Apr 21 08:45:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:20 2026] 127.0.0.1:57698 Closing +[Tue Apr 21 08:45:20 2026] 127.0.0.1:57710 Accepted +[Tue Apr 21 08:45:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:21 2026] 127.0.0.1:57710 Closing +[Tue Apr 21 08:45:21 2026] 127.0.0.1:57722 Accepted +[Tue Apr 21 08:45:22 2026] 127.0.0.1:57722 Closing +[Tue Apr 21 08:45:22 2026] 127.0.0.1:57736 Accepted +[Tue Apr 21 08:45:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:23 2026] 127.0.0.1:57736 Closing +[Tue Apr 21 08:45:23 2026] 127.0.0.1:57738 Accepted +[Tue Apr 21 08:45:24 2026] 127.0.0.1:57738 Closing +[Tue Apr 21 08:45:25 2026] 127.0.0.1:57742 Accepted +[Tue Apr 21 08:45:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:26 2026] 127.0.0.1:57742 Closing +[Tue Apr 21 08:45:27 2026] 127.0.0.1:57752 Accepted +[Tue Apr 21 08:45:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:28 2026] 127.0.0.1:57752 Closing +[Tue Apr 21 08:45:28 2026] 127.0.0.1:57762 Accepted +[Tue Apr 21 08:45:32 2026] 127.0.0.1:57762 Closing +[Tue Apr 21 08:45:32 2026] 127.0.0.1:41970 Accepted +[Tue Apr 21 08:45:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:32 2026] 127.0.0.1:41970 Closing +[Tue Apr 21 08:45:32 2026] 127.0.0.1:41986 Accepted +[Tue Apr 21 08:45:32 2026] 127.0.0.1:41986 Closing +[Tue Apr 21 08:45:39 2026] 127.0.0.1:55186 Accepted +[Tue Apr 21 08:45:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:39 2026] 127.0.0.1:55186 Closing +[Tue Apr 21 08:45:40 2026] 127.0.0.1:55192 Accepted +[Tue Apr 21 08:45:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:41 2026] 127.0.0.1:55192 Closing +[Tue Apr 21 08:45:41 2026] 127.0.0.1:55202 Accepted +[Tue Apr 21 08:45:44 2026] 127.0.0.1:55202 Closing +[Tue Apr 21 08:45:51 2026] 127.0.0.1:39182 Accepted +[Tue Apr 21 08:45:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:52 2026] 127.0.0.1:39182 Closing +[Tue Apr 21 08:45:53 2026] 127.0.0.1:39194 Accepted +[Tue Apr 21 08:45:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:53 2026] 127.0.0.1:39194 Closing +[Tue Apr 21 08:45:53 2026] 127.0.0.1:39202 Accepted +[Tue Apr 21 08:45:55 2026] 127.0.0.1:39202 Closing +[Tue Apr 21 08:46:01 2026] 127.0.0.1:42394 Accepted +[Tue Apr 21 08:46:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:02 2026] 127.0.0.1:42394 Closing +[Tue Apr 21 08:46:00 2026] 127.0.0.1:42402 Accepted +[Tue Apr 21 08:46:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:00 2026] 127.0.0.1:42402 Closing +[Tue Apr 21 08:46:00 2026] 127.0.0.1:42410 Accepted +[Tue Apr 21 08:46:03 2026] 127.0.0.1:42410 Closing +[Tue Apr 21 08:46:03 2026] 127.0.0.1:42424 Accepted +[Tue Apr 21 08:46:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:03 2026] 127.0.0.1:42424 Closing +[Tue Apr 21 08:46:03 2026] 127.0.0.1:42438 Accepted +[Tue Apr 21 08:46:05 2026] 127.0.0.1:42438 Closing +[Tue Apr 21 08:46:05 2026] 127.0.0.1:56980 Accepted +[Tue Apr 21 08:46:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:05 2026] 127.0.0.1:56980 Closing +[Tue Apr 21 08:46:05 2026] 127.0.0.1:56990 Accepted +[Tue Apr 21 08:46:08 2026] 127.0.0.1:56990 Closing +[Tue Apr 21 08:46:08 2026] 127.0.0.1:56994 Accepted +[Tue Apr 21 08:46:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:08 2026] 127.0.0.1:56994 Closing +[Tue Apr 21 08:46:08 2026] 127.0.0.1:57008 Accepted +[Tue Apr 21 08:46:10 2026] 127.0.0.1:57008 Closing +[Tue Apr 21 08:46:10 2026] 127.0.0.1:57014 Accepted +[Tue Apr 21 08:46:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:11 2026] 127.0.0.1:57014 Closing +[Tue Apr 21 08:46:11 2026] 127.0.0.1:57018 Accepted +[Tue Apr 21 08:46:13 2026] 127.0.0.1:57018 Closing +[Tue Apr 21 08:46:13 2026] 127.0.0.1:56756 Accepted +[Tue Apr 21 08:46:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:14 2026] 127.0.0.1:56756 Closing +[Tue Apr 21 08:46:14 2026] 127.0.0.1:56768 Accepted +[Tue Apr 21 08:46:16 2026] 127.0.0.1:56768 Closing +[Tue Apr 21 08:46:21 2026] 127.0.0.1:56782 Accepted +[Tue Apr 21 08:46:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:21 2026] 127.0.0.1:56782 Closing +[Tue Apr 21 08:46:22 2026] 127.0.0.1:56792 Accepted +[Tue Apr 21 08:46:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:22 2026] 127.0.0.1:56792 Closing +[Tue Apr 21 08:46:22 2026] 127.0.0.1:56794 Accepted +[Tue Apr 21 08:46:24 2026] 127.0.0.1:56794 Closing +[Tue Apr 21 08:46:24 2026] 127.0.0.1:55956 Accepted +[Tue Apr 21 08:46:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:24 2026] 127.0.0.1:55956 Closing +[Tue Apr 21 08:46:24 2026] 127.0.0.1:55970 Accepted +[Tue Apr 21 08:46:26 2026] 127.0.0.1:55970 Closing +[Tue Apr 21 08:46:28 2026] 127.0.0.1:55980 Accepted +[Tue Apr 21 08:46:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:29 2026] 127.0.0.1:55980 Closing +[Tue Apr 21 08:46:27 2026] 127.0.0.1:55992 Accepted +[Tue Apr 21 08:46:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:27 2026] 127.0.0.1:55992 Closing +[Tue Apr 21 08:46:27 2026] 127.0.0.1:56000 Accepted +[Tue Apr 21 08:46:32 2026] 127.0.0.1:56000 Closing +[Tue Apr 21 08:46:38 2026] 127.0.0.1:49798 Accepted +[Tue Apr 21 08:46:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:39 2026] 127.0.0.1:49798 Closing +[Tue Apr 21 08:46:39 2026] 127.0.0.1:49812 Accepted +[Tue Apr 21 08:46:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:40 2026] 127.0.0.1:49812 Closing +[Tue Apr 21 08:46:40 2026] 127.0.0.1:49826 Accepted +[Tue Apr 21 08:46:42 2026] 127.0.0.1:49826 Closing +[Tue Apr 21 08:46:42 2026] 127.0.0.1:42242 Accepted +[Tue Apr 21 08:46:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:43 2026] 127.0.0.1:42242 Closing +[Tue Apr 21 08:46:43 2026] 127.0.0.1:42258 Accepted +[Tue Apr 21 08:46:45 2026] 127.0.0.1:42258 Closing +[Tue Apr 21 08:46:45 2026] 127.0.0.1:42270 Accepted +[Tue Apr 21 08:46:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:45 2026] 127.0.0.1:42270 Closing +[Tue Apr 21 08:46:45 2026] 127.0.0.1:42284 Accepted +[Tue Apr 21 08:46:47 2026] 127.0.0.1:42284 Closing +[Tue Apr 21 08:46:47 2026] 127.0.0.1:42294 Accepted +[Tue Apr 21 08:46:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:47 2026] 127.0.0.1:42294 Closing +[Tue Apr 21 08:46:47 2026] 127.0.0.1:42296 Accepted +[Tue Apr 21 08:46:49 2026] 127.0.0.1:42296 Closing +[Tue Apr 21 08:46:49 2026] 127.0.0.1:42312 Accepted +[Tue Apr 21 08:46:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:50 2026] 127.0.0.1:42312 Closing +[Tue Apr 21 08:46:50 2026] 127.0.0.1:42318 Accepted +[Tue Apr 21 08:46:52 2026] 127.0.0.1:42318 Closing +[Tue Apr 21 08:46:52 2026] 127.0.0.1:52466 Accepted +[Tue Apr 21 08:46:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:52 2026] 127.0.0.1:52466 Closing +[Tue Apr 21 08:46:52 2026] 127.0.0.1:52480 Accepted +[Tue Apr 21 08:46:55 2026] 127.0.0.1:52480 Closing +[Tue Apr 21 08:46:57 2026] 127.0.0.1:52492 Accepted +[Tue Apr 21 08:46:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:58 2026] 127.0.0.1:52492 Closing +[Tue Apr 21 08:46:58 2026] 127.0.0.1:58754 Accepted +[Tue Apr 21 08:46:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:59 2026] 127.0.0.1:58754 Closing +[Tue Apr 21 08:46:59 2026] 127.0.0.1:58758 Accepted +[Tue Apr 21 08:47:01 2026] 127.0.0.1:58758 Closing +[Tue Apr 21 08:47:03 2026] 127.0.0.1:58760 Accepted +[Tue Apr 21 08:47:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:04 2026] 127.0.0.1:58760 Closing +[Tue Apr 21 08:47:04 2026] 127.0.0.1:58770 Accepted +[Tue Apr 21 08:47:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:05 2026] 127.0.0.1:58770 Closing +[Tue Apr 21 08:47:05 2026] 127.0.0.1:58778 Accepted +[Tue Apr 21 08:47:06 2026] 127.0.0.1:58778 Closing +[Tue Apr 21 08:47:06 2026] 127.0.0.1:58788 Accepted +[Tue Apr 21 08:47:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:07 2026] 127.0.0.1:58788 Closing +[Tue Apr 21 08:47:07 2026] 127.0.0.1:58800 Accepted +[Tue Apr 21 08:47:08 2026] 127.0.0.1:58800 Closing +[Tue Apr 21 08:47:13 2026] 127.0.0.1:56006 Accepted +[Tue Apr 21 08:47:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:13 2026] 127.0.0.1:56006 Closing +[Tue Apr 21 08:47:14 2026] 127.0.0.1:56012 Accepted +[Tue Apr 21 08:47:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:14 2026] 127.0.0.1:56012 Closing +[Tue Apr 21 08:47:14 2026] 127.0.0.1:56018 Accepted +[Tue Apr 21 08:47:17 2026] 127.0.0.1:56018 Closing +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41548 Accepted +[Tue Apr 21 08:47:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41548 Closing +[Tue Apr 21 08:47:23 2026] 127.0.0.1:41556 Accepted +[Tue Apr 21 08:47:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:23 2026] 127.0.0.1:41556 Closing +[Tue Apr 21 08:47:23 2026] 127.0.0.1:41572 Accepted +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41572 Closing +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41578 Accepted +[Tue Apr 21 08:47:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41578 Closing +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41580 Accepted +[Tue Apr 21 08:47:24 2026] 127.0.0.1:41580 Closing +[Tue Apr 21 08:52:59 2026] 127.0.0.1:56616 Accepted +[Tue Apr 21 08:52:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:00 2026] 127.0.0.1:56616 Closing +[Tue Apr 21 08:53:02 2026] 127.0.0.1:56628 Accepted +[Tue Apr 21 08:53:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:02 2026] 127.0.0.1:56628 Closing +[Tue Apr 21 08:53:02 2026] 127.0.0.1:38048 Accepted +[Tue Apr 21 08:53:06 2026] 127.0.0.1:38048 Closing +[Tue Apr 21 08:53:13 2026] 127.0.0.1:37494 Accepted +[Tue Apr 21 08:53:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:14 2026] 127.0.0.1:37494 Closing +[Tue Apr 21 08:53:14 2026] 127.0.0.1:37508 Accepted +[Tue Apr 21 08:53:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:15 2026] 127.0.0.1:37508 Closing +[Tue Apr 21 08:53:15 2026] 127.0.0.1:37516 Accepted +[Tue Apr 21 08:53:19 2026] 127.0.0.1:37516 Closing +[Tue Apr 21 08:53:24 2026] 127.0.0.1:46902 Accepted +[Tue Apr 21 08:53:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:25 2026] 127.0.0.1:46902 Closing +[Tue Apr 21 08:53:25 2026] 127.0.0.1:46916 Accepted +[Tue Apr 21 08:53:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:25 2026] 127.0.0.1:46916 Closing +[Tue Apr 21 08:53:25 2026] 127.0.0.1:46920 Accepted +[Tue Apr 21 08:53:26 2026] 127.0.0.1:46920 Closing +[Tue Apr 21 08:53:26 2026] 127.0.0.1:46932 Accepted +[Tue Apr 21 08:53:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:27 2026] 127.0.0.1:46932 Closing +[Tue Apr 21 08:53:27 2026] 127.0.0.1:46938 Accepted +[Tue Apr 21 08:53:29 2026] 127.0.0.1:46938 Closing +[Tue Apr 21 08:53:32 2026] 127.0.0.1:48406 Accepted +[Tue Apr 21 08:53:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:32 2026] 127.0.0.1:48406 Closing +[Tue Apr 21 08:53:33 2026] 127.0.0.1:48418 Accepted +[Tue Apr 21 08:53:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:33 2026] 127.0.0.1:48418 Closing +[Tue Apr 21 08:53:33 2026] 127.0.0.1:48432 Accepted +[Tue Apr 21 08:53:38 2026] 127.0.0.1:48432 Closing +[Tue Apr 21 08:53:44 2026] 127.0.0.1:55198 Accepted +[Tue Apr 21 08:53:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:44 2026] 127.0.0.1:55198 Closing +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55210 Accepted +[Tue Apr 21 08:53:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:46 2026] 127.0.0.1:55210 Closing +[Tue Apr 21 08:53:46 2026] 127.0.0.1:55216 Accepted +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55216 Closing +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55230 Accepted +[Tue Apr 21 08:53:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55230 Closing +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55242 Accepted +[Tue Apr 21 08:53:47 2026] 127.0.0.1:55242 Closing +[Tue Apr 21 08:53:47 2026] 127.0.0.1:40070 Accepted +[Tue Apr 21 08:53:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:47 2026] 127.0.0.1:40070 Closing +[Tue Apr 21 08:53:47 2026] 127.0.0.1:40086 Accepted +[Tue Apr 21 08:53:49 2026] 127.0.0.1:40086 Closing +[Tue Apr 21 08:53:49 2026] 127.0.0.1:40090 Accepted +[Tue Apr 21 08:53:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:50 2026] 127.0.0.1:40090 Closing +[Tue Apr 21 08:53:50 2026] 127.0.0.1:40094 Accepted +[Tue Apr 21 08:53:52 2026] 127.0.0.1:40094 Closing +[Tue Apr 21 08:53:55 2026] 127.0.0.1:40100 Accepted +[Tue Apr 21 08:53:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:56 2026] 127.0.0.1:40100 Closing +[Tue Apr 21 08:53:57 2026] 127.0.0.1:47014 Accepted +[Tue Apr 21 08:53:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:58 2026] 127.0.0.1:47014 Closing +[Tue Apr 21 08:53:58 2026] 127.0.0.1:47018 Accepted +[Tue Apr 21 08:54:03 2026] 127.0.0.1:47018 Closing +[Tue Apr 21 08:54:09 2026] 127.0.0.1:47342 Accepted +[Tue Apr 21 08:54:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:10 2026] 127.0.0.1:47342 Closing +[Tue Apr 21 08:54:11 2026] 127.0.0.1:47348 Accepted +[Tue Apr 21 08:54:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:11 2026] 127.0.0.1:47348 Closing +[Tue Apr 21 08:54:11 2026] 127.0.0.1:47352 Accepted +[Tue Apr 21 08:54:12 2026] 127.0.0.1:47352 Closing +[Tue Apr 21 08:54:12 2026] 127.0.0.1:47354 Accepted +[Tue Apr 21 08:54:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:13 2026] 127.0.0.1:47354 Closing +[Tue Apr 21 08:54:13 2026] 127.0.0.1:47358 Accepted +[Tue Apr 21 08:54:16 2026] 127.0.0.1:47358 Closing +[Tue Apr 21 08:54:22 2026] 127.0.0.1:42382 Accepted +[Tue Apr 21 08:54:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:23 2026] 127.0.0.1:42382 Closing +[Tue Apr 21 08:54:30 2026] 127.0.0.1:44134 Accepted +[Tue Apr 21 08:54:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:31 2026] 127.0.0.1:44134 Closing +[Tue Apr 21 08:54:36 2026] 127.0.0.1:48842 Accepted +[Tue Apr 21 08:54:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:37 2026] 127.0.0.1:48842 Closing +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48848 Accepted +[Tue Apr 21 08:54:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48848 Closing +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48860 Accepted +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48860 Closing +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48866 Accepted +[Tue Apr 21 08:54:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:39 2026] 127.0.0.1:48866 Closing +[Tue Apr 21 08:54:39 2026] 127.0.0.1:48878 Accepted +[Tue Apr 21 08:54:40 2026] 127.0.0.1:48878 Closing +[Tue Apr 21 08:54:40 2026] 127.0.0.1:48886 Accepted +[Tue Apr 21 08:54:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:41 2026] 127.0.0.1:48886 Closing +[Tue Apr 21 08:54:41 2026] 127.0.0.1:48890 Accepted +[Tue Apr 21 08:54:43 2026] 127.0.0.1:48890 Closing +[Tue Apr 21 08:54:43 2026] 127.0.0.1:43942 Accepted +[Tue Apr 21 08:54:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:45 2026] 127.0.0.1:43942 Closing +[Tue Apr 21 08:54:45 2026] 127.0.0.1:43952 Accepted +[Tue Apr 21 08:54:48 2026] 127.0.0.1:43952 Closing +[Tue Apr 21 08:54:48 2026] 127.0.0.1:43962 Accepted +[Tue Apr 21 08:54:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:50 2026] 127.0.0.1:43962 Closing +[Tue Apr 21 08:54:50 2026] 127.0.0.1:43974 Accepted +[Tue Apr 21 08:54:52 2026] 127.0.0.1:43974 Closing +[Tue Apr 21 08:54:52 2026] 127.0.0.1:50514 Accepted +[Tue Apr 21 08:54:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:53 2026] 127.0.0.1:50514 Closing +[Tue Apr 21 08:54:53 2026] 127.0.0.1:50524 Accepted +[Tue Apr 21 08:54:56 2026] 127.0.0.1:50524 Closing +[Tue Apr 21 08:55:01 2026] 127.0.0.1:50536 Accepted +[Tue Apr 21 08:55:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:03 2026] 127.0.0.1:50536 Closing +[Tue Apr 21 08:55:03 2026] 127.0.0.1:56012 Accepted +[Tue Apr 21 08:55:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:04 2026] 127.0.0.1:56012 Closing +[Tue Apr 21 08:55:04 2026] 127.0.0.1:56014 Accepted +[Tue Apr 21 08:55:06 2026] 127.0.0.1:56014 Closing +[Tue Apr 21 08:55:06 2026] 127.0.0.1:56024 Accepted +[Tue Apr 21 08:55:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:06 2026] 127.0.0.1:56024 Closing +[Tue Apr 21 08:55:06 2026] 127.0.0.1:56032 Accepted +[Tue Apr 21 08:55:05 2026] 127.0.0.1:56032 Closing +[Tue Apr 21 08:55:07 2026] 127.0.0.1:56040 Accepted +[Tue Apr 21 08:55:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:08 2026] 127.0.0.1:56040 Closing +[Tue Apr 21 08:55:09 2026] 127.0.0.1:41424 Accepted +[Tue Apr 21 08:55:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:10 2026] 127.0.0.1:41424 Closing +[Tue Apr 21 08:55:10 2026] 127.0.0.1:41440 Accepted +[Tue Apr 21 08:55:14 2026] 127.0.0.1:41440 Closing +[Tue Apr 21 08:55:21 2026] 127.0.0.1:36150 Accepted +[Tue Apr 21 08:55:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:22 2026] 127.0.0.1:36150 Closing +[Tue Apr 21 08:55:22 2026] 127.0.0.1:36162 Accepted +[Tue Apr 21 08:55:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:23 2026] 127.0.0.1:36162 Closing +[Tue Apr 21 08:55:23 2026] 127.0.0.1:36176 Accepted +[Tue Apr 21 08:55:26 2026] 127.0.0.1:36176 Closing +[Tue Apr 21 08:55:26 2026] 127.0.0.1:36184 Accepted +[Tue Apr 21 08:55:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:27 2026] 127.0.0.1:36184 Closing +[Tue Apr 21 08:55:27 2026] 127.0.0.1:36192 Accepted +[Tue Apr 21 08:55:30 2026] 127.0.0.1:36192 Closing +[Tue Apr 21 08:55:30 2026] 127.0.0.1:59846 Accepted +[Tue Apr 21 08:55:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:31 2026] 127.0.0.1:59846 Closing +[Tue Apr 21 08:55:31 2026] 127.0.0.1:59848 Accepted +[Tue Apr 21 08:55:34 2026] 127.0.0.1:59848 Closing +[Tue Apr 21 08:55:34 2026] 127.0.0.1:59852 Accepted +[Tue Apr 21 08:55:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59852 Closing +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59854 Accepted +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59854 Closing +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59858 Accepted +[Tue Apr 21 08:55:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59858 Closing +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59870 Accepted +[Tue Apr 21 08:55:38 2026] 127.0.0.1:59870 Closing +[Tue Apr 21 08:55:38 2026] 127.0.0.1:56116 Accepted +[Tue Apr 21 08:55:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:38 2026] 127.0.0.1:56116 Closing +[Tue Apr 21 08:55:38 2026] 127.0.0.1:56132 Accepted +[Tue Apr 21 08:55:41 2026] 127.0.0.1:56132 Closing +[Tue Apr 21 08:55:46 2026] 127.0.0.1:41646 Accepted +[Tue Apr 21 08:55:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:46 2026] 127.0.0.1:41646 Closing +[Tue Apr 21 08:55:47 2026] 127.0.0.1:41658 Accepted +[Tue Apr 21 08:55:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:47 2026] 127.0.0.1:41658 Closing +[Tue Apr 21 08:55:47 2026] 127.0.0.1:41660 Accepted +[Tue Apr 21 08:55:49 2026] 127.0.0.1:41660 Closing +[Tue Apr 21 08:55:52 2026] 127.0.0.1:41670 Accepted +[Tue Apr 21 08:55:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:52 2026] 127.0.0.1:41670 Closing +[Tue Apr 21 08:55:53 2026] 127.0.0.1:41686 Accepted +[Tue Apr 21 08:55:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:54 2026] 127.0.0.1:41686 Closing +[Tue Apr 21 08:55:54 2026] 127.0.0.1:41690 Accepted +[Tue Apr 21 08:55:56 2026] 127.0.0.1:41690 Closing +[Tue Apr 21 08:55:56 2026] 127.0.0.1:41692 Accepted +[Tue Apr 21 08:55:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:56 2026] 127.0.0.1:41692 Closing +[Tue Apr 21 08:55:56 2026] 127.0.0.1:42780 Accepted +[Tue Apr 21 08:55:57 2026] 127.0.0.1:42780 Closing +[Tue Apr 21 08:56:00 2026] 127.0.0.1:42782 Accepted +[Tue Apr 21 08:56:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:00 2026] 127.0.0.1:42782 Closing +[Tue Apr 21 08:56:01 2026] 127.0.0.1:42784 Accepted +[Tue Apr 21 08:56:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:01 2026] 127.0.0.1:42784 Closing +[Tue Apr 21 08:56:01 2026] 127.0.0.1:42794 Accepted +[Tue Apr 21 08:56:04 2026] 127.0.0.1:42794 Closing +[Tue Apr 21 08:56:09 2026] 127.0.0.1:50462 Accepted +[Tue Apr 21 08:56:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:09 2026] 127.0.0.1:50462 Closing +[Tue Apr 21 08:56:10 2026] 127.0.0.1:50476 Accepted +[Tue Apr 21 08:56:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:10 2026] 127.0.0.1:50476 Closing +[Tue Apr 21 08:56:10 2026] 127.0.0.1:50482 Accepted +[Tue Apr 21 08:56:12 2026] 127.0.0.1:50482 Closing +[Tue Apr 21 08:56:12 2026] 127.0.0.1:50494 Accepted +[Tue Apr 21 08:56:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:12 2026] 127.0.0.1:50494 Closing +[Tue Apr 21 08:56:12 2026] 127.0.0.1:50506 Accepted +[Tue Apr 21 08:56:14 2026] 127.0.0.1:50506 Closing +[Tue Apr 21 08:57:18 2026] 127.0.0.1:60268 Accepted +[Tue Apr 21 08:57:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:57:18 2026] 127.0.0.1:60268 Closing +[Tue Apr 21 08:57:20 2026] 127.0.0.1:44562 Accepted +[Tue Apr 21 08:57:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:57:20 2026] 127.0.0.1:44562 Closing +[Tue Apr 21 08:57:20 2026] 127.0.0.1:44572 Accepted +[Tue Apr 21 08:57:23 2026] 127.0.0.1:44572 Closing +[Tue Apr 21 08:57:27 2026] 127.0.0.1:45256 Accepted +[Tue Apr 21 08:57:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:57:28 2026] 127.0.0.1:45256 Closing +[Tue Apr 21 08:57:29 2026] 127.0.0.1:45272 Accepted +[Tue Apr 21 08:57:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:57:29 2026] 127.0.0.1:45272 Closing +[Tue Apr 21 08:57:29 2026] 127.0.0.1:45276 Accepted +[Tue Apr 21 08:57:32 2026] 127.0.0.1:45276 Closing +[Tue Apr 21 08:59:20 2026] 127.0.0.1:42994 Accepted +[Tue Apr 21 08:59:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:59:20 2026] 127.0.0.1:42994 Closing +[Tue Apr 21 08:59:22 2026] 127.0.0.1:43004 Accepted +[Tue Apr 21 08:59:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:59:22 2026] 127.0.0.1:43004 Closing +[Tue Apr 21 08:59:22 2026] 127.0.0.1:43020 Accepted +[Tue Apr 21 08:59:26 2026] 127.0.0.1:43020 Closing +[Tue Apr 21 08:59:33 2026] 127.0.0.1:57628 Accepted +[Tue Apr 21 08:59:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:59:33 2026] 127.0.0.1:57628 Closing +[Tue Apr 21 08:59:34 2026] 127.0.0.1:58824 Accepted +[Tue Apr 21 08:59:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:59:34 2026] 127.0.0.1:58824 Closing +[Tue Apr 21 08:59:34 2026] 127.0.0.1:58826 Accepted +[Tue Apr 21 08:59:37 2026] 127.0.0.1:58826 Closing +[Tue Apr 21 09:00:26 2026] 127.0.0.1:33708 Accepted +[Tue Apr 21 09:00:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:00:26 2026] 127.0.0.1:33708 Closing +[Tue Apr 21 09:00:28 2026] 127.0.0.1:33724 Accepted +[Tue Apr 21 09:00:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:00:28 2026] 127.0.0.1:33724 Closing +[Tue Apr 21 09:00:28 2026] 127.0.0.1:33728 Accepted +[Tue Apr 21 09:00:31 2026] 127.0.0.1:33728 Closing +[Tue Apr 21 09:00:36 2026] 127.0.0.1:47244 Accepted +[Tue Apr 21 09:00:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:00:36 2026] 127.0.0.1:47244 Closing +[Tue Apr 21 09:00:37 2026] 127.0.0.1:44858 Accepted +[Tue Apr 21 09:00:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:00:38 2026] 127.0.0.1:44858 Closing +[Tue Apr 21 09:00:38 2026] 127.0.0.1:44874 Accepted +[Tue Apr 21 09:00:41 2026] 127.0.0.1:44874 Closing +[Tue Apr 21 09:05:04 2026] 127.0.0.1:34782 Accepted +[Tue Apr 21 09:05:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:05:05 2026] 127.0.0.1:34782 Closing +[Tue Apr 21 09:05:06 2026] 127.0.0.1:34794 Accepted +[Tue Apr 21 09:05:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:05:07 2026] 127.0.0.1:34794 Closing +[Tue Apr 21 09:05:07 2026] 127.0.0.1:34798 Accepted +[Tue Apr 21 09:05:07 2026] 127.0.0.1:34798 Closing +[Tue Apr 21 09:05:14 2026] 127.0.0.1:58062 Accepted +[Tue Apr 21 09:05:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:05:15 2026] 127.0.0.1:58062 Closing +[Tue Apr 21 09:05:16 2026] 127.0.0.1:58074 Accepted +[Tue Apr 21 09:05:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:05:16 2026] 127.0.0.1:58074 Closing +[Tue Apr 21 09:05:16 2026] 127.0.0.1:58078 Accepted +[Tue Apr 21 09:05:19 2026] 127.0.0.1:58078 Closing +[Tue Apr 21 09:06:31 2026] 127.0.0.1:43082 Accepted +[Tue Apr 21 09:06:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:06:32 2026] 127.0.0.1:43082 Closing +[Tue Apr 21 09:06:33 2026] 127.0.0.1:43086 Accepted +[Tue Apr 21 09:06:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:06:34 2026] 127.0.0.1:43086 Closing +[Tue Apr 21 09:06:34 2026] 127.0.0.1:43090 Accepted +[Tue Apr 21 09:06:37 2026] 127.0.0.1:43090 Closing +[Tue Apr 21 09:06:44 2026] 127.0.0.1:52170 Accepted +[Tue Apr 21 09:06:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:06:45 2026] 127.0.0.1:52170 Closing +[Tue Apr 21 09:06:46 2026] 127.0.0.1:52184 Accepted +[Tue Apr 21 09:06:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:06:46 2026] 127.0.0.1:52184 Closing +[Tue Apr 21 09:06:46 2026] 127.0.0.1:52190 Accepted +[Tue Apr 21 09:06:50 2026] 127.0.0.1:52190 Closing +[Tue Apr 21 09:09:15 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:09:15 2026] 127.0.0.1:37124 Accepted +[Tue Apr 21 09:09:16 2026] 127.0.0.1:37124 Closing +[Tue Apr 21 09:09:17 2026] 127.0.0.1:37132 Accepted +[Tue Apr 21 09:09:21 2026] 127.0.0.1:37132 Closing +[Tue Apr 21 09:09:29 2026] 127.0.0.1:39856 Accepted +[Tue Apr 21 09:09:32 2026] 127.0.0.1:39856 Closing +[Tue Apr 21 09:14:34 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:14:34 2026] 127.0.0.1:42430 Accepted +[Tue Apr 21 09:14:36 2026] 127.0.0.1:42430 Closing +[Tue Apr 21 09:14:37 2026] 127.0.0.1:42440 Accepted +[Tue Apr 21 09:14:39 2026] 127.0.0.1:42440 Closing +[Tue Apr 21 09:14:47 2026] 127.0.0.1:52492 Accepted +[Tue Apr 21 09:14:50 2026] 127.0.0.1:52492 Closing +[Tue Apr 21 09:20:28 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:20:28 2026] 127.0.0.1:36122 Accepted +[Tue Apr 21 09:20:30 2026] 127.0.0.1:36122 Closing +[Tue Apr 21 09:20:31 2026] 127.0.0.1:48862 Accepted +[Tue Apr 21 09:20:35 2026] 127.0.0.1:48862 Closing +[Tue Apr 21 09:20:40 2026] 127.0.0.1:41728 Accepted +[Tue Apr 21 09:20:43 2026] 127.0.0.1:41728 Closing +[Tue Apr 21 09:26:08 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:26:09 2026] 127.0.0.1:53492 Accepted +[Tue Apr 21 09:26:10 2026] 127.0.0.1:53492 Closing +[Tue Apr 21 09:26:12 2026] 127.0.0.1:53508 Accepted +[Tue Apr 21 09:26:16 2026] 127.0.0.1:53508 Closing +[Tue Apr 21 09:26:23 2026] 127.0.0.1:38754 Accepted +[Tue Apr 21 09:26:26 2026] 127.0.0.1:38754 Closing +[Tue Apr 21 09:29:08 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:29:08 2026] 127.0.0.1:42454 Accepted +[Tue Apr 21 09:29:09 2026] 127.0.0.1:42454 Closing +[Tue Apr 21 09:29:10 2026] 127.0.0.1:42464 Accepted +[Tue Apr 21 09:29:11 2026] 127.0.0.1:42464 Closing +[Tue Apr 21 09:29:19 2026] 127.0.0.1:49926 Accepted +[Tue Apr 21 09:29:22 2026] 127.0.0.1:49926 Closing +[Tue Apr 21 09:30:49 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:30:49 2026] 127.0.0.1:55198 Accepted +[Tue Apr 21 09:30:52 2026] 127.0.0.1:55198 Closing +[Tue Apr 21 09:30:53 2026] 127.0.0.1:55200 Accepted +[Tue Apr 21 09:30:58 2026] 127.0.0.1:55200 Closing +[Tue Apr 21 09:31:03 2026] 127.0.0.1:34268 Accepted +[Tue Apr 21 09:31:06 2026] 127.0.0.1:34268 Closing +[Tue Apr 21 09:33:17 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:33:17 2026] 127.0.0.1:59300 Accepted +[Tue Apr 21 09:33:19 2026] 127.0.0.1:59300 Closing +[Tue Apr 21 09:33:18 2026] 127.0.0.1:59302 Accepted +[Tue Apr 21 09:33:21 2026] 127.0.0.1:59302 Closing +[Tue Apr 21 09:33:29 2026] 127.0.0.1:45640 Accepted +[Tue Apr 21 09:33:32 2026] 127.0.0.1:45640 Closing +[Tue Apr 21 09:34:33 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:34:33 2026] 127.0.0.1:46450 Accepted +[Tue Apr 21 09:34:35 2026] 127.0.0.1:46450 Closing +[Tue Apr 21 09:34:36 2026] 127.0.0.1:46576 Accepted +[Tue Apr 21 09:34:40 2026] 127.0.0.1:46576 Closing +[Tue Apr 21 09:34:46 2026] 127.0.0.1:37792 Accepted +[Tue Apr 21 09:34:49 2026] 127.0.0.1:37792 Closing +[Tue Apr 21 09:36:48 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:36:48 2026] 127.0.0.1:35382 Accepted +[Tue Apr 21 09:36:49 2026] 127.0.0.1:35382 Closing +[Tue Apr 21 09:36:50 2026] 127.0.0.1:35406 Accepted +[Tue Apr 21 09:36:56 2026] 127.0.0.1:35406 Closing +[Tue Apr 21 09:37:00 2026] 127.0.0.1:50920 Accepted +[Tue Apr 21 09:37:06 2026] 127.0.0.1:50920 Closing +[Tue Apr 21 09:38:42 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:38:42 2026] 127.0.0.1:39810 Accepted +[Tue Apr 21 09:38:44 2026] 127.0.0.1:39810 Closing +[Tue Apr 21 09:39:47 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:39:47 2026] 127.0.0.1:40678 Accepted +[Tue Apr 21 09:39:49 2026] 127.0.0.1:40678 Closing +[Tue Apr 21 09:40:49 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:40:49 2026] 127.0.0.1:38806 Accepted +[Tue Apr 21 09:40:50 2026] 127.0.0.1:38806 Closing +[Tue Apr 21 09:42:47 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:42:48 2026] 127.0.0.1:47514 Accepted +[Tue Apr 21 09:42:49 2026] 127.0.0.1:47514 Closing +[Tue Apr 21 09:42:50 2026] 127.0.0.1:47528 Accepted +[Tue Apr 21 09:42:58 2026] 127.0.0.1:47528 Closing +[Tue Apr 21 09:43:06 2026] 127.0.0.1:52840 Accepted +[Tue Apr 21 09:43:16 2026] 127.0.0.1:52840 Closing +[Tue Apr 21 09:45:09 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:45:09 2026] 127.0.0.1:53508 Accepted +[Tue Apr 21 09:45:08 2026] 127.0.0.1:53508 Closing +[Tue Apr 21 09:45:09 2026] 127.0.0.1:52992 Accepted +[Tue Apr 21 09:45:15 2026] 127.0.0.1:52992 Closing +[Tue Apr 21 09:45:24 2026] 127.0.0.1:47224 Accepted +[Tue Apr 21 09:45:32 2026] 127.0.0.1:47224 Closing +[Tue Apr 21 09:47:02 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:47:02 2026] 127.0.0.1:48818 Accepted +[Tue Apr 21 09:47:03 2026] 127.0.0.1:48818 Closing +[Tue Apr 21 09:47:05 2026] 127.0.0.1:48828 Accepted +[Tue Apr 21 09:47:10 2026] 127.0.0.1:48828 Closing +[Tue Apr 21 09:47:18 2026] 127.0.0.1:34784 Accepted +[Tue Apr 21 09:47:23 2026] 127.0.0.1:34784 Closing +[Tue Apr 21 09:50:11 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:50:11 2026] 127.0.0.1:46780 Accepted +[Tue Apr 21 09:50:12 2026] 127.0.0.1:46780 Closing +[Tue Apr 21 09:50:13 2026] 127.0.0.1:46794 Accepted +[Tue Apr 21 09:50:18 2026] 127.0.0.1:46794 Closing +[Tue Apr 21 09:50:19 2026] 127.0.0.1:43952 Accepted +[Tue Apr 21 09:50:23 2026] 127.0.0.1:43952 Closing +[Tue Apr 21 09:50:30 2026] 127.0.0.1:55486 Accepted +[Tue Apr 21 09:50:34 2026] 127.0.0.1:55486 Closing +[Tue Apr 21 09:50:34 2026] 127.0.0.1:55496 Accepted +[Tue Apr 21 09:50:35 2026] 127.0.0.1:55496 Closing +[Tue Apr 21 09:53:07 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:53:07 2026] 127.0.0.1:44662 Accepted +[Tue Apr 21 09:53:08 2026] 127.0.0.1:44662 Closing +[Tue Apr 21 09:53:10 2026] 127.0.0.1:44678 Accepted +[Tue Apr 21 09:53:15 2026] 127.0.0.1:44678 Closing +[Tue Apr 21 09:53:15 2026] 127.0.0.1:52068 Accepted +[Tue Apr 21 09:53:23 2026] 127.0.0.1:52068 Closing +[Tue Apr 21 09:53:31 2026] 127.0.0.1:53252 Accepted +[Tue Apr 21 09:53:36 2026] 127.0.0.1:53252 Closing +[Tue Apr 21 09:53:36 2026] 127.0.0.1:53262 Accepted +[Tue Apr 21 09:53:42 2026] 127.0.0.1:53262 Closing +[Tue Apr 21 09:54:57 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:54:58 2026] 127.0.0.1:57352 Accepted +[Tue Apr 21 09:54:58 2026] 127.0.0.1:57352 Closing +[Tue Apr 21 09:55:00 2026] 127.0.0.1:57358 Accepted +[Tue Apr 21 09:55:12 2026] 127.0.0.1:57358 Closing +[Tue Apr 21 09:55:13 2026] 127.0.0.1:55050 Accepted +[Tue Apr 21 09:55:36 2026] 127.0.0.1:55050 Closing +[Tue Apr 21 09:55:45 2026] 127.0.0.1:36622 Accepted +[Tue Apr 21 09:56:02 2026] 127.0.0.1:36622 Closing +[Tue Apr 21 09:56:02 2026] 127.0.0.1:60100 Accepted +[Tue Apr 21 09:56:09 2026] 127.0.0.1:60100 Closing +[Tue Apr 21 10:00:16 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 10:00:16 2026] 127.0.0.1:59888 Accepted +[Tue Apr 21 10:00:23 2026] 127.0.0.1:59888 Closing +[Tue Apr 21 10:00:25 2026] 127.0.0.1:36474 Accepted +[Tue Apr 21 10:00:40 2026] 127.0.0.1:36474 Closing +[Tue Apr 21 10:00:41 2026] 127.0.0.1:41064 Accepted +[Tue Apr 21 10:01:10 2026] 127.0.0.1:41064 Closing +[Tue Apr 21 10:01:19 2026] 127.0.0.1:49872 Accepted +[Tue Apr 21 10:01:37 2026] 127.0.0.1:49872 Closing +[Tue Apr 21 10:01:37 2026] 127.0.0.1:59594 Accepted +[Tue Apr 21 10:01:49 2026] 127.0.0.1:59594 Closing +[Tue Apr 21 10:04:37 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 10:04:37 2026] 127.0.0.1:35028 Accepted +[Tue Apr 21 10:04:43 2026] 127.0.0.1:35028 Closing +[Tue Apr 21 10:04:45 2026] 127.0.0.1:48198 Accepted +[Tue Apr 21 10:04:58 2026] 127.0.0.1:48198 Closing +[Tue Apr 21 10:04:59 2026] 127.0.0.1:60746 Accepted +[Tue Apr 21 10:05:22 2026] 127.0.0.1:60746 Closing +[Tue Apr 21 10:05:34 2026] 127.0.0.1:36188 Accepted +[Tue Apr 21 10:05:50 2026] 127.0.0.1:36188 Closing +[Tue Apr 21 10:05:50 2026] 127.0.0.1:55030 Accepted +[Tue Apr 21 10:06:17 2026] 127.0.0.1:55030 Closing +[Tue Apr 21 10:07:03 2026] 127.0.0.1:32996 Accepted +[Tue Apr 21 10:07:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:07 2026] 127.0.0.1:32996 Closing +[Tue Apr 21 10:07:09 2026] 127.0.0.1:36834 Accepted +[Tue Apr 21 10:07:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:11 2026] 127.0.0.1:36834 Closing +[Tue Apr 21 10:07:11 2026] 127.0.0.1:36846 Accepted +[Tue Apr 21 10:07:18 2026] 127.0.0.1:36846 Closing +[Tue Apr 21 10:07:25 2026] 127.0.0.1:36098 Accepted +[Tue Apr 21 10:07:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:28 2026] 127.0.0.1:36098 Closing +[Tue Apr 21 10:07:29 2026] 127.0.0.1:36104 Accepted +[Tue Apr 21 10:07:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:32 2026] 127.0.0.1:36104 Closing +[Tue Apr 21 10:07:32 2026] 127.0.0.1:36106 Accepted +[Tue Apr 21 10:07:46 2026] 127.0.0.1:36106 Closing +[Tue Apr 21 10:07:53 2026] 127.0.0.1:52604 Accepted +[Tue Apr 21 10:07:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:56 2026] 127.0.0.1:52604 Closing +[Tue Apr 21 10:07:56 2026] 127.0.0.1:52620 Accepted +[Tue Apr 21 10:07:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:58 2026] 127.0.0.1:52620 Closing +[Tue Apr 21 10:07:58 2026] 127.0.0.1:52630 Accepted +[Tue Apr 21 10:08:03 2026] 127.0.0.1:52630 Closing +[Tue Apr 21 10:08:03 2026] 127.0.0.1:38358 Accepted +[Tue Apr 21 10:08:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:06 2026] 127.0.0.1:38358 Closing +[Tue Apr 21 10:08:06 2026] 127.0.0.1:38374 Accepted +[Tue Apr 21 10:08:12 2026] 127.0.0.1:38374 Closing +[Tue Apr 21 10:08:15 2026] 127.0.0.1:46864 Accepted +[Tue Apr 21 10:08:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:17 2026] 127.0.0.1:46864 Closing +[Tue Apr 21 10:08:18 2026] 127.0.0.1:46878 Accepted +[Tue Apr 21 10:08:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:20 2026] 127.0.0.1:46878 Closing +[Tue Apr 21 10:08:20 2026] 127.0.0.1:46880 Accepted +[Tue Apr 21 10:08:25 2026] 127.0.0.1:46880 Closing +[Tue Apr 21 10:08:29 2026] 127.0.0.1:57052 Accepted +[Tue Apr 21 10:08:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:32 2026] 127.0.0.1:57052 Closing +[Tue Apr 21 10:08:33 2026] 127.0.0.1:57058 Accepted +[Tue Apr 21 10:08:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:35 2026] 127.0.0.1:57058 Closing +[Tue Apr 21 10:08:35 2026] 127.0.0.1:57066 Accepted +[Tue Apr 21 10:08:40 2026] 127.0.0.1:57066 Closing +[Tue Apr 21 10:08:40 2026] 127.0.0.1:36648 Accepted +[Tue Apr 21 10:08:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:43 2026] 127.0.0.1:36648 Closing +[Tue Apr 21 10:08:43 2026] 127.0.0.1:36656 Accepted +[Tue Apr 21 10:08:47 2026] 127.0.0.1:36656 Closing +[Tue Apr 21 10:08:47 2026] 127.0.0.1:36666 Accepted +[Tue Apr 21 10:08:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:47 2026] 127.0.0.1:36666 Closing +[Tue Apr 21 10:08:47 2026] 127.0.0.1:47790 Accepted +[Tue Apr 21 10:08:52 2026] 127.0.0.1:47790 Closing +[Tue Apr 21 10:08:52 2026] 127.0.0.1:47804 Accepted +[Tue Apr 21 10:08:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:55 2026] 127.0.0.1:47804 Closing +[Tue Apr 21 10:08:55 2026] 127.0.0.1:47808 Accepted +[Tue Apr 21 10:08:58 2026] 127.0.0.1:47808 Closing +[Tue Apr 21 10:09:02 2026] 127.0.0.1:54830 Accepted +[Tue Apr 21 10:09:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:04 2026] 127.0.0.1:54830 Closing +[Tue Apr 21 10:09:05 2026] 127.0.0.1:54844 Accepted +[Tue Apr 21 10:09:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:07 2026] 127.0.0.1:54844 Closing +[Tue Apr 21 10:09:07 2026] 127.0.0.1:43012 Accepted +[Tue Apr 21 10:09:14 2026] 127.0.0.1:43012 Closing +[Tue Apr 21 10:09:19 2026] 127.0.0.1:39520 Accepted +[Tue Apr 21 10:09:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:22 2026] 127.0.0.1:39520 Closing +[Tue Apr 21 10:09:23 2026] 127.0.0.1:39522 Accepted +[Tue Apr 21 10:09:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:26 2026] 127.0.0.1:39522 Closing +[Tue Apr 21 10:09:26 2026] 127.0.0.1:38236 Accepted +[Tue Apr 21 10:09:34 2026] 127.0.0.1:38236 Closing +[Tue Apr 21 10:09:34 2026] 127.0.0.1:41048 Accepted +[Tue Apr 21 10:09:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:37 2026] 127.0.0.1:41048 Closing +[Tue Apr 21 10:09:37 2026] 127.0.0.1:41064 Accepted +[Tue Apr 21 10:09:40 2026] 127.0.0.1:41064 Closing +[Tue Apr 21 10:09:48 2026] 127.0.0.1:52060 Accepted +[Tue Apr 21 10:09:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:50 2026] 127.0.0.1:52060 Closing +[Tue Apr 21 10:09:51 2026] 127.0.0.1:56828 Accepted +[Tue Apr 21 10:09:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:54 2026] 127.0.0.1:56828 Closing +[Tue Apr 21 10:09:54 2026] 127.0.0.1:56842 Accepted +[Tue Apr 21 10:10:09 2026] 127.0.0.1:56842 Closing +[Tue Apr 21 10:10:09 2026] 127.0.0.1:58918 Accepted +[Tue Apr 21 10:10:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:10:14 2026] 127.0.0.1:58918 Closing +[Tue Apr 21 10:10:14 2026] 127.0.0.1:58920 Accepted +[Tue Apr 21 10:10:36 2026] 127.0.0.1:58920 Closing +[Tue Apr 21 10:10:43 2026] 127.0.0.1:46102 Accepted +[Tue Apr 21 10:10:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:10:48 2026] 127.0.0.1:46102 Closing +[Tue Apr 21 10:10:49 2026] 127.0.0.1:60944 Accepted +[Tue Apr 21 10:10:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:10:51 2026] 127.0.0.1:60944 Closing +[Tue Apr 21 10:10:51 2026] 127.0.0.1:60954 Accepted +[Tue Apr 21 10:11:07 2026] 127.0.0.1:60954 Closing +[Tue Apr 21 10:11:07 2026] 127.0.0.1:47990 Accepted +[Tue Apr 21 10:11:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:11:12 2026] 127.0.0.1:47990 Closing +[Tue Apr 21 10:11:12 2026] 127.0.0.1:48000 Accepted +[Tue Apr 21 10:11:33 2026] 127.0.0.1:48000 Closing +[Tue Apr 21 10:11:38 2026] 127.0.0.1:46856 Accepted +[Tue Apr 21 10:11:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:11:43 2026] 127.0.0.1:46856 Closing +[Tue Apr 21 10:11:44 2026] 127.0.0.1:51184 Accepted +[Tue Apr 21 10:11:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:11:47 2026] 127.0.0.1:51184 Closing +[Tue Apr 21 10:11:47 2026] 127.0.0.1:51196 Accepted +[Tue Apr 21 10:11:54 2026] 127.0.0.1:51196 Closing +[Tue Apr 21 10:11:54 2026] 127.0.0.1:48146 Accepted +[Tue Apr 21 10:11:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:11:58 2026] 127.0.0.1:48146 Closing +[Tue Apr 21 10:11:58 2026] 127.0.0.1:48156 Accepted +[Tue Apr 21 10:12:00 2026] 127.0.0.1:48156 Closing +[Tue Apr 21 10:12:00 2026] 127.0.0.1:50554 Accepted +[Tue Apr 21 10:12:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:04 2026] 127.0.0.1:50554 Closing +[Tue Apr 21 10:12:04 2026] 127.0.0.1:50562 Accepted +[Tue Apr 21 10:12:09 2026] 127.0.0.1:50562 Closing +[Tue Apr 21 10:12:09 2026] 127.0.0.1:33538 Accepted +[Tue Apr 21 10:12:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:12 2026] 127.0.0.1:33538 Closing +[Tue Apr 21 10:12:12 2026] 127.0.0.1:33548 Accepted +[Tue Apr 21 10:12:17 2026] 127.0.0.1:33548 Closing +[Tue Apr 21 10:12:17 2026] 127.0.0.1:33550 Accepted +[Tue Apr 21 10:12:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:19 2026] 127.0.0.1:33550 Closing +[Tue Apr 21 10:12:19 2026] 127.0.0.1:41146 Accepted +[Tue Apr 21 10:12:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:24 2026] 127.0.0.1:41146 Closing +[Tue Apr 21 10:12:24 2026] 127.0.0.1:41154 Accepted +[Tue Apr 21 10:12:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:24 2026] 127.0.0.1:41154 Closing +[Tue Apr 21 10:12:24 2026] 127.0.0.1:41170 Accepted +[Tue Apr 21 10:12:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:27 2026] 127.0.0.1:41170 Closing +[Tue Apr 21 10:12:27 2026] 127.0.0.1:57128 Accepted +[Tue Apr 21 10:12:27 2026] 127.0.0.1:57132 Accepted +[Tue Apr 21 10:12:31 2026] 127.0.0.1:57128 Closing +[Tue Apr 21 10:12:36 2026] 127.0.0.1:57132 Closing +[Tue Apr 21 10:12:36 2026] 127.0.0.1:57134 Accepted +[Tue Apr 21 10:12:41 2026] 127.0.0.1:57134 Closing +[Tue Apr 21 10:12:41 2026] 127.0.0.1:57154 Accepted +[Tue Apr 21 10:12:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:43 2026] 127.0.0.1:57154 Closing +[Tue Apr 21 10:12:44 2026] 127.0.0.1:57158 Accepted +[Tue Apr 21 10:12:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:46 2026] 127.0.0.1:57158 Closing +[Tue Apr 21 10:12:46 2026] 127.0.0.1:41420 Accepted +[Tue Apr 21 10:12:49 2026] 127.0.0.1:41420 Closing +[Tue Apr 21 10:12:49 2026] 127.0.0.1:41436 Accepted +[Tue Apr 21 10:12:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:51 2026] 127.0.0.1:41436 Closing +[Tue Apr 21 10:12:51 2026] 127.0.0.1:41438 Accepted +[Tue Apr 21 10:12:51 2026] 127.0.0.1:41452 Accepted +[Tue Apr 21 10:12:53 2026] 127.0.0.1:41438 Closing +[Tue Apr 21 10:12:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:54 2026] 127.0.0.1:41452 Closing +[Tue Apr 21 10:12:54 2026] 127.0.0.1:41464 Accepted +[Tue Apr 21 10:12:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:56 2026] 127.0.0.1:41464 Closing +[Tue Apr 21 10:12:56 2026] 127.0.0.1:52728 Accepted +[Tue Apr 21 10:12:56 2026] 127.0.0.1:52736 Accepted +[Tue Apr 21 10:12:58 2026] 127.0.0.1:52728 Closing +[Tue Apr 21 10:12:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:12 2026] 127.0.0.1:52736 Closing +[Tue Apr 21 10:13:12 2026] 127.0.0.1:52744 Accepted +[Tue Apr 21 10:13:15 2026] 127.0.0.1:52744 Closing +[Tue Apr 21 10:13:15 2026] 127.0.0.1:38398 Accepted +[Tue Apr 21 10:13:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:20 2026] 127.0.0.1:38398 Closing +[Tue Apr 21 10:13:20 2026] 127.0.0.1:38412 Accepted +[Tue Apr 21 10:13:23 2026] 127.0.0.1:38412 Closing +[Tue Apr 21 10:13:23 2026] 127.0.0.1:35616 Accepted +[Tue Apr 21 10:13:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:25 2026] 127.0.0.1:35616 Closing +[Tue Apr 21 10:13:25 2026] 127.0.0.1:35626 Accepted +[Tue Apr 21 10:13:28 2026] 127.0.0.1:35626 Closing +[Tue Apr 21 10:13:28 2026] 127.0.0.1:35642 Accepted +[Tue Apr 21 10:13:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:31 2026] 127.0.0.1:35642 Closing +[Tue Apr 21 10:13:31 2026] 127.0.0.1:35646 Accepted +[Tue Apr 21 10:13:34 2026] 127.0.0.1:35646 Closing +[Tue Apr 21 10:13:34 2026] 127.0.0.1:52314 Accepted +[Tue Apr 21 10:13:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:39 2026] 127.0.0.1:52314 Closing +[Tue Apr 21 10:13:39 2026] 127.0.0.1:52326 Accepted +[Tue Apr 21 10:13:41 2026] 127.0.0.1:52326 Closing +[Tue Apr 21 10:13:41 2026] 127.0.0.1:49312 Accepted +[Tue Apr 21 10:13:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:48 2026] 127.0.0.1:49312 Closing +[Tue Apr 21 10:13:48 2026] 127.0.0.1:49322 Accepted +[Tue Apr 21 10:13:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:47 2026] 127.0.0.1:49322 Closing +[Tue Apr 21 10:13:47 2026] 127.0.0.1:49326 Accepted +[Tue Apr 21 10:13:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:50 2026] 127.0.0.1:49326 Closing +[Tue Apr 21 10:13:50 2026] 127.0.0.1:46478 Accepted +[Tue Apr 21 10:13:50 2026] 127.0.0.1:46494 Accepted +[Tue Apr 21 10:13:53 2026] 127.0.0.1:46478 Closing +[Tue Apr 21 10:13:58 2026] 127.0.0.1:46494 Closing +[Tue Apr 21 10:13:58 2026] 127.0.0.1:46510 Accepted +[Tue Apr 21 10:13:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:06 2026] 127.0.0.1:46510 Closing +[Tue Apr 21 10:14:06 2026] 127.0.0.1:36746 Accepted +[Tue Apr 21 10:14:08 2026] 127.0.0.1:36746 Closing +[Tue Apr 21 10:14:08 2026] 127.0.0.1:44590 Accepted +[Tue Apr 21 10:14:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:12 2026] 127.0.0.1:44590 Closing +[Tue Apr 21 10:14:12 2026] 127.0.0.1:44594 Accepted +[Tue Apr 21 10:14:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:14 2026] 127.0.0.1:44594 Closing +[Tue Apr 21 10:14:14 2026] 127.0.0.1:44610 Accepted +[Tue Apr 21 10:14:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:16 2026] 127.0.0.1:44610 Closing +[Tue Apr 21 10:14:16 2026] 127.0.0.1:44616 Accepted +[Tue Apr 21 10:14:16 2026] 127.0.0.1:44628 Accepted +[Tue Apr 21 10:14:15 2026] 127.0.0.1:44616 Closing +[Tue Apr 21 10:14:20 2026] 127.0.0.1:44628 Closing +[Tue Apr 21 10:14:20 2026] 127.0.0.1:33896 Accepted +[Tue Apr 21 10:14:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:24 2026] 127.0.0.1:33896 Closing +[Tue Apr 21 10:14:24 2026] 127.0.0.1:33904 Accepted +[Tue Apr 21 10:14:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:27 2026] 127.0.0.1:33904 Closing +[Tue Apr 21 10:14:27 2026] 127.0.0.1:57434 Accepted +[Tue Apr 21 10:14:27 2026] 127.0.0.1:57442 Accepted +[Tue Apr 21 10:14:29 2026] 127.0.0.1:57434 Closing +[Tue Apr 21 10:14:33 2026] 127.0.0.1:57442 Closing +[Tue Apr 21 10:14:33 2026] 127.0.0.1:57448 Accepted +[Tue Apr 21 10:14:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:37 2026] 127.0.0.1:57448 Closing +[Tue Apr 21 10:14:37 2026] 127.0.0.1:57460 Accepted +[Tue Apr 21 10:14:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:40 2026] 127.0.0.1:57460 Closing +[Tue Apr 21 10:14:40 2026] 127.0.0.1:49038 Accepted +[Tue Apr 21 10:14:40 2026] 127.0.0.1:49052 Accepted +[Tue Apr 21 10:14:43 2026] 127.0.0.1:49038 Closing +[Tue Apr 21 10:14:46 2026] 127.0.0.1:49052 Closing +[Tue Apr 21 10:14:46 2026] 127.0.0.1:49062 Accepted +[Tue Apr 21 10:14:51 2026] 127.0.0.1:49062 Closing +[Tue Apr 21 10:14:51 2026] 127.0.0.1:43262 Accepted +[Tue Apr 21 10:14:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:55 2026] 127.0.0.1:43262 Closing +[Tue Apr 21 10:14:55 2026] 127.0.0.1:43272 Accepted +[Tue Apr 21 10:14:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:58 2026] 127.0.0.1:43272 Closing +[Tue Apr 21 10:14:58 2026] 127.0.0.1:43288 Accepted +[Tue Apr 21 10:14:58 2026] 127.0.0.1:43294 Accepted +[Tue Apr 21 10:15:01 2026] 127.0.0.1:43288 Closing +[Tue Apr 21 10:15:05 2026] 127.0.0.1:43294 Closing +[Tue Apr 21 10:15:05 2026] 127.0.0.1:43310 Accepted +[Tue Apr 21 10:15:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:08 2026] 127.0.0.1:43310 Closing +[Tue Apr 21 10:15:08 2026] 127.0.0.1:40804 Accepted +[Tue Apr 21 10:15:11 2026] 127.0.0.1:40804 Closing +[Tue Apr 21 10:15:11 2026] 127.0.0.1:32910 Accepted +[Tue Apr 21 10:15:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:14 2026] 127.0.0.1:32910 Closing +[Tue Apr 21 10:15:14 2026] 127.0.0.1:32912 Accepted +[Tue Apr 21 10:15:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:17 2026] 127.0.0.1:32912 Closing +[Tue Apr 21 10:15:17 2026] 127.0.0.1:32928 Accepted +[Tue Apr 21 10:15:17 2026] 127.0.0.1:32936 Accepted +[Tue Apr 21 10:15:20 2026] 127.0.0.1:32928 Closing +[Tue Apr 21 10:15:29 2026] 127.0.0.1:32936 Closing +[Tue Apr 21 10:15:29 2026] 127.0.0.1:43988 Accepted +[Tue Apr 21 10:15:34 2026] 127.0.0.1:43988 Closing +[Tue Apr 21 10:15:34 2026] 127.0.0.1:60440 Accepted +[Tue Apr 21 10:15:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:37 2026] 127.0.0.1:60440 Closing +[Tue Apr 21 10:15:37 2026] 127.0.0.1:60456 Accepted +[Tue Apr 21 10:15:37 2026] 127.0.0.1:60464 Accepted +[Tue Apr 21 10:15:37 2026] 127.0.0.1:60456 Closing +[Tue Apr 21 10:15:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:41 2026] 127.0.0.1:60464 Closing +[Tue Apr 21 10:15:41 2026] 127.0.0.1:43070 Accepted +[Tue Apr 21 10:15:45 2026] 127.0.0.1:43070 Closing +[Tue Apr 21 10:15:45 2026] 127.0.0.1:43076 Accepted +[Tue Apr 21 10:15:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:54 2026] 127.0.0.1:43076 Closing +[Tue Apr 21 10:15:54 2026] 127.0.0.1:46048 Accepted +[Tue Apr 21 10:15:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:57 2026] 127.0.0.1:46048 Closing +[Tue Apr 21 10:15:57 2026] 127.0.0.1:46060 Accepted +[Tue Apr 21 10:15:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:00 2026] 127.0.0.1:46060 Closing +[Tue Apr 21 10:16:00 2026] 127.0.0.1:58510 Accepted +[Tue Apr 21 10:16:02 2026] 127.0.0.1:58510 Closing +[Tue Apr 21 10:16:02 2026] 127.0.0.1:58526 Accepted +[Tue Apr 21 10:16:02 2026] 127.0.0.1:58528 Accepted +[Tue Apr 21 10:16:06 2026] 127.0.0.1:58526 Closing +[Tue Apr 21 10:16:12 2026] 127.0.0.1:58528 Closing +[Tue Apr 21 10:16:13 2026] 127.0.0.1:48308 Accepted +[Tue Apr 21 10:16:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:16 2026] 127.0.0.1:48308 Closing +[Tue Apr 21 10:16:17 2026] 127.0.0.1:48318 Accepted +[Tue Apr 21 10:16:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:20 2026] 127.0.0.1:48318 Closing +[Tue Apr 21 10:16:20 2026] 127.0.0.1:48322 Accepted +[Tue Apr 21 10:16:20 2026] 127.0.0.1:48336 Accepted +[Tue Apr 21 10:16:22 2026] 127.0.0.1:48322 Closing +[Tue Apr 21 10:16:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:34 2026] 127.0.0.1:48336 Closing +[Tue Apr 21 10:16:34 2026] 127.0.0.1:48348 Accepted +[Tue Apr 21 10:16:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:38 2026] 127.0.0.1:48348 Closing +[Tue Apr 21 10:16:38 2026] 127.0.0.1:38626 Accepted +[Tue Apr 21 10:16:38 2026] 127.0.0.1:38638 Accepted +[Tue Apr 21 10:16:41 2026] 127.0.0.1:38626 Closing +[Tue Apr 21 10:16:45 2026] 127.0.0.1:38638 Closing +[Tue Apr 21 10:16:45 2026] 127.0.0.1:37094 Accepted +[Tue Apr 21 10:16:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:17:05 2026] 127.0.0.1:37094 Closing +[Tue Apr 21 10:17:05 2026] 127.0.0.1:37106 Accepted +[Tue Apr 21 10:17:09 2026] 127.0.0.1:37106 Closing +[Tue Apr 21 10:17:09 2026] 127.0.0.1:51204 Accepted +[Tue Apr 21 10:17:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:17:13 2026] 127.0.0.1:51204 Closing +[Tue Apr 21 10:17:13 2026] 127.0.0.1:51210 Accepted +[Tue Apr 21 10:17:14 2026] 127.0.0.1:51210 Closing +[Tue Apr 21 10:17:15 2026] 127.0.0.1:51222 Accepted +[Tue Apr 21 10:17:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:17:17 2026] 127.0.0.1:51222 Closing +[Tue Apr 21 10:17:17 2026] 127.0.0.1:51224 Accepted +[Tue Apr 21 10:17:29 2026] 127.0.0.1:51224 Closing +[Tue Apr 21 10:17:29 2026] 127.0.0.1:51922 Accepted +[Tue Apr 21 10:17:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:17:34 2026] 127.0.0.1:51922 Closing +[Tue Apr 21 10:17:34 2026] 127.0.0.1:51936 Accepted +[Tue Apr 21 10:17:56 2026] 127.0.0.1:51936 Closing +[Tue Apr 21 10:18:02 2026] 127.0.0.1:42234 Accepted +[Tue Apr 21 10:18:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:07 2026] 127.0.0.1:42234 Closing +[Tue Apr 21 10:18:07 2026] 127.0.0.1:42250 Accepted +[Tue Apr 21 10:18:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:11 2026] 127.0.0.1:42250 Closing +[Tue Apr 21 10:18:11 2026] 127.0.0.1:42258 Accepted +[Tue Apr 21 10:18:17 2026] 127.0.0.1:42258 Closing +[Tue Apr 21 10:18:17 2026] 127.0.0.1:34972 Accepted +[Tue Apr 21 10:18:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:20 2026] 127.0.0.1:34972 Closing +[Tue Apr 21 10:18:20 2026] 127.0.0.1:34982 Accepted +[Tue Apr 21 10:18:23 2026] 127.0.0.1:34982 Closing +[Tue Apr 21 10:18:23 2026] 127.0.0.1:55706 Accepted +[Tue Apr 21 10:18:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:25 2026] 127.0.0.1:55706 Closing +[Tue Apr 21 10:18:25 2026] 127.0.0.1:55708 Accepted +[Tue Apr 21 10:18:30 2026] 127.0.0.1:55708 Closing +[Tue Apr 21 10:18:30 2026] 127.0.0.1:33844 Accepted +[Tue Apr 21 10:18:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:32 2026] 127.0.0.1:33844 Closing +[Tue Apr 21 10:18:32 2026] 127.0.0.1:33856 Accepted +[Tue Apr 21 10:18:37 2026] 127.0.0.1:33856 Closing +[Tue Apr 21 10:18:37 2026] 127.0.0.1:33858 Accepted +[Tue Apr 21 10:18:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:39 2026] 127.0.0.1:33858 Closing +[Tue Apr 21 10:18:39 2026] 127.0.0.1:44958 Accepted +[Tue Apr 21 10:18:43 2026] 127.0.0.1:44958 Closing +[Tue Apr 21 10:18:43 2026] 127.0.0.1:44972 Accepted +[Tue Apr 21 10:18:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:46 2026] 127.0.0.1:44972 Closing +[Tue Apr 21 10:18:46 2026] 127.0.0.1:44984 Accepted +[Tue Apr 21 10:18:47 2026] 127.0.0.1:44984 Closing +[Tue Apr 21 10:18:52 2026] 127.0.0.1:38568 Accepted +[Tue Apr 21 10:18:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:55 2026] 127.0.0.1:38568 Closing +[Tue Apr 21 10:18:55 2026] 127.0.0.1:38582 Accepted +[Tue Apr 21 10:18:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:57 2026] 127.0.0.1:38582 Closing +[Tue Apr 21 10:18:57 2026] 127.0.0.1:53292 Accepted +[Tue Apr 21 10:19:02 2026] 127.0.0.1:53292 Closing +[Tue Apr 21 10:19:02 2026] 127.0.0.1:53302 Accepted +[Tue Apr 21 10:19:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:04 2026] 127.0.0.1:53302 Closing +[Tue Apr 21 10:19:04 2026] 127.0.0.1:53318 Accepted +[Tue Apr 21 10:19:08 2026] 127.0.0.1:53318 Closing +[Tue Apr 21 10:19:11 2026] 127.0.0.1:33594 Accepted +[Tue Apr 21 10:19:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:13 2026] 127.0.0.1:33594 Closing +[Tue Apr 21 10:19:14 2026] 127.0.0.1:33606 Accepted +[Tue Apr 21 10:19:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:14 2026] 127.0.0.1:33606 Closing +[Tue Apr 21 10:19:14 2026] 127.0.0.1:53236 Accepted +[Tue Apr 21 10:19:22 2026] 127.0.0.1:53236 Closing +[Tue Apr 21 10:19:29 2026] 127.0.0.1:54442 Accepted +[Tue Apr 21 10:19:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:32 2026] 127.0.0.1:54442 Closing +[Tue Apr 21 10:19:33 2026] 127.0.0.1:54450 Accepted +[Tue Apr 21 10:19:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:35 2026] 127.0.0.1:54450 Closing +[Tue Apr 21 10:19:35 2026] 127.0.0.1:37744 Accepted +[Tue Apr 21 10:19:41 2026] 127.0.0.1:37744 Closing +[Tue Apr 21 10:19:41 2026] 127.0.0.1:37748 Accepted +[Tue Apr 21 10:19:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:41 2026] 127.0.0.1:37748 Closing +[Tue Apr 21 10:19:41 2026] 127.0.0.1:37760 Accepted +[Tue Apr 21 10:19:46 2026] 127.0.0.1:37760 Closing +[Tue Apr 21 10:19:46 2026] 127.0.0.1:45680 Accepted +[Tue Apr 21 10:19:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:49 2026] 127.0.0.1:45680 Closing +[Tue Apr 21 10:19:49 2026] 127.0.0.1:45682 Accepted +[Tue Apr 21 10:19:54 2026] 127.0.0.1:45682 Closing +[Tue Apr 21 10:19:54 2026] 127.0.0.1:49242 Accepted +[Tue Apr 21 10:19:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:56 2026] 127.0.0.1:49242 Closing +[Tue Apr 21 10:19:56 2026] 127.0.0.1:49254 Accepted +[Tue Apr 21 10:19:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:01 2026] 127.0.0.1:49254 Closing +[Tue Apr 21 10:20:01 2026] 127.0.0.1:49256 Accepted +[Tue Apr 21 10:20:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:04 2026] 127.0.0.1:49256 Closing +[Tue Apr 21 10:20:04 2026] 127.0.0.1:38612 Accepted +[Tue Apr 21 10:20:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:10 2026] 127.0.0.1:38612 Closing +[Tue Apr 21 10:20:10 2026] 127.0.0.1:38616 Accepted +[Tue Apr 21 10:20:10 2026] 127.0.0.1:38624 Accepted +[Tue Apr 21 10:20:10 2026] 127.0.0.1:38616 Closing +[Tue Apr 21 10:20:15 2026] 127.0.0.1:38624 Closing +[Tue Apr 21 10:20:15 2026] 127.0.0.1:36188 Accepted +[Tue Apr 21 10:20:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:20 2026] 127.0.0.1:36188 Closing +[Tue Apr 21 10:20:20 2026] 127.0.0.1:36202 Accepted +[Tue Apr 21 10:20:23 2026] 127.0.0.1:36202 Closing +[Tue Apr 21 10:20:23 2026] 127.0.0.1:53424 Accepted +[Tue Apr 21 10:20:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:29 2026] 127.0.0.1:53424 Closing +[Tue Apr 21 10:20:29 2026] 127.0.0.1:53434 Accepted +[Tue Apr 21 10:20:34 2026] 127.0.0.1:53434 Closing +[Tue Apr 21 10:20:34 2026] 127.0.0.1:34112 Accepted +[Tue Apr 21 10:20:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:37 2026] 127.0.0.1:34112 Closing +[Tue Apr 21 10:20:37 2026] 127.0.0.1:34118 Accepted +[Tue Apr 21 10:20:37 2026] 127.0.0.1:34126 Accepted +[Tue Apr 21 10:20:37 2026] 127.0.0.1:34118 Closing +[Tue Apr 21 10:20:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:42 2026] 127.0.0.1:34126 Closing +[Tue Apr 21 10:20:42 2026] 127.0.0.1:46576 Accepted +[Tue Apr 21 10:20:45 2026] 127.0.0.1:46576 Closing +[Tue Apr 21 10:20:45 2026] 127.0.0.1:46586 Accepted +[Tue Apr 21 10:20:50 2026] 127.0.0.1:46586 Closing +[Tue Apr 21 10:20:52 2026] 127.0.0.1:59508 Accepted +[Tue Apr 21 10:20:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:55 2026] 127.0.0.1:59508 Closing +[Tue Apr 21 10:20:56 2026] 127.0.0.1:50654 Accepted +[Tue Apr 21 10:20:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:59 2026] 127.0.0.1:50654 Closing +[Tue Apr 21 10:20:59 2026] 127.0.0.1:50666 Accepted +[Tue Apr 21 10:21:04 2026] 127.0.0.1:50666 Closing +[Tue Apr 21 10:21:04 2026] 127.0.0.1:50682 Accepted +[Tue Apr 21 10:21:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:04 2026] 127.0.0.1:50682 Closing +[Tue Apr 21 10:21:04 2026] 127.0.0.1:60888 Accepted +[Tue Apr 21 10:21:08 2026] 127.0.0.1:60888 Closing +[Tue Apr 21 10:21:13 2026] 127.0.0.1:58594 Accepted +[Tue Apr 21 10:21:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:16 2026] 127.0.0.1:58594 Closing +[Tue Apr 21 10:21:17 2026] 127.0.0.1:58606 Accepted +[Tue Apr 21 10:21:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:19 2026] 127.0.0.1:58606 Closing +[Tue Apr 21 10:21:19 2026] 127.0.0.1:58612 Accepted +[Tue Apr 21 10:21:26 2026] 127.0.0.1:58612 Closing +[Tue Apr 21 10:21:31 2026] 127.0.0.1:58168 Accepted +[Tue Apr 21 10:21:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:31 2026] 127.0.0.1:58168 Closing +[Tue Apr 21 10:21:32 2026] 127.0.0.1:40120 Accepted +[Tue Apr 21 10:21:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:35 2026] 127.0.0.1:40120 Closing +[Tue Apr 21 10:21:35 2026] 127.0.0.1:40132 Accepted +[Tue Apr 21 10:21:39 2026] 127.0.0.1:40132 Closing +[Tue Apr 21 10:21:39 2026] 127.0.0.1:40134 Accepted +[Tue Apr 21 10:21:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:42 2026] 127.0.0.1:40134 Closing +[Tue Apr 21 10:21:42 2026] 127.0.0.1:60180 Accepted +[Tue Apr 21 10:21:46 2026] 127.0.0.1:60180 Closing +[Tue Apr 21 12:19:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:19:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:20:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:20:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:20:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:20:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:21:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:21:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:21:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:21:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:21:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:21:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:21:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:22:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:23:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:25:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:28:04 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18083) started +[Tue Apr 21 12:28:04 2026] 127.0.0.1:36832 Accepted +[Tue Apr 21 12:28:06 2026] 127.0.0.1:36832 Closing +[Tue Apr 21 12:28:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:28:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:29:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 12:29:38 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18084) started +[Tue Apr 21 12:29:39 2026] 127.0.0.1:49800 Accepted +[Tue Apr 21 12:29:41 2026] 127.0.0.1:49800 Closing +[Tue Apr 21 12:31:10 2026] PHP 8.2.30 Development Server (http://127.0.0.1:33655) started +[Tue Apr 21 12:31:10 2026] 127.0.0.1:37066 Accepted +[Tue Apr 21 12:31:09 2026] 127.0.0.1:37066 Closing +[Tue Apr 21 12:31:09 2026] 127.0.0.1:37070 Accepted +[Tue Apr 21 12:31:10 2026] 127.0.0.1:37070 Closing +[Tue Apr 21 12:31:30 2026] PHP 8.2.30 Development Server (http://127.0.0.1:33509) started +[Tue Apr 21 12:31:30 2026] 127.0.0.1:57784 Accepted +[Tue Apr 21 12:31:31 2026] 127.0.0.1:57784 Closing +[Tue Apr 21 12:31:31 2026] 127.0.0.1:57788 Accepted +[Tue Apr 21 12:31:32 2026] 127.0.0.1:57788 Closing +[Tue Apr 21 12:31:33 2026] 127.0.0.1:59232 Accepted +[Tue Apr 21 12:31:33 2026] 127.0.0.1:59232 Closing +[Tue Apr 21 12:31:33 2026] 127.0.0.1:59246 Accepted +[Tue Apr 21 12:31:34 2026] 127.0.0.1:59246 Closing +[Tue Apr 21 12:31:34 2026] 127.0.0.1:59250 Accepted +[Tue Apr 21 12:31:35 2026] 127.0.0.1:59250 Closing +[Tue Apr 21 12:31:35 2026] 127.0.0.1:59260 Accepted +[Tue Apr 21 12:31:36 2026] 127.0.0.1:59260 Closing +[Tue Apr 21 12:31:36 2026] 127.0.0.1:59266 Accepted +[Tue Apr 21 12:31:37 2026] 127.0.0.1:59266 Closing +[Tue Apr 21 12:31:37 2026] 127.0.0.1:59274 Accepted +[Tue Apr 21 12:31:37 2026] 127.0.0.1:59274 Closing +[Tue Apr 21 12:31:37 2026] 127.0.0.1:59282 Accepted +[Tue Apr 21 12:31:38 2026] 127.0.0.1:59282 Closing +[Tue Apr 21 12:31:38 2026] 127.0.0.1:59292 Accepted +[Tue Apr 21 12:31:36 2026] 127.0.0.1:59292 Closing +[Tue Apr 21 12:31:36 2026] 127.0.0.1:59298 Accepted +[Tue Apr 21 12:31:36 2026] 127.0.0.1:59298 Closing +[Tue Apr 21 12:31:36 2026] 127.0.0.1:59314 Accepted +[Tue Apr 21 12:31:37 2026] 127.0.0.1:59314 Closing +[Tue Apr 21 12:31:37 2026] 127.0.0.1:59316 Accepted +[Tue Apr 21 12:31:38 2026] 127.0.0.1:59316 Closing +[Tue Apr 21 12:32:49 2026] PHP 8.2.30 Development Server (http://127.0.0.1:40003) started +[Tue Apr 21 12:32:50 2026] 127.0.0.1:56156 Accepted +[Tue Apr 21 12:32:51 2026] 127.0.0.1:56156 Closing +[Tue Apr 21 12:32:51 2026] 127.0.0.1:56158 Accepted +[Tue Apr 21 12:32:51 2026] 127.0.0.1:56158 Closing +[Tue Apr 21 12:32:52 2026] 127.0.0.1:56174 Accepted +[Tue Apr 21 12:32:52 2026] 127.0.0.1:56174 Closing +[Tue Apr 21 12:32:52 2026] 127.0.0.1:56178 Accepted +[Tue Apr 21 12:32:53 2026] 127.0.0.1:56178 Closing +[Tue Apr 21 12:32:53 2026] 127.0.0.1:56192 Accepted +[Tue Apr 21 12:32:53 2026] 127.0.0.1:56192 Closing +[Tue Apr 21 12:32:54 2026] 127.0.0.1:56196 Accepted +[Tue Apr 21 12:32:54 2026] 127.0.0.1:56196 Closing +[Tue Apr 21 12:32:55 2026] 127.0.0.1:59960 Accepted +[Tue Apr 21 12:32:55 2026] 127.0.0.1:59960 Closing +[Tue Apr 21 12:32:55 2026] 127.0.0.1:59964 Accepted +[Tue Apr 21 12:32:56 2026] 127.0.0.1:59964 Closing +[Tue Apr 21 12:32:56 2026] 127.0.0.1:59972 Accepted +[Tue Apr 21 12:32:56 2026] 127.0.0.1:59972 Closing +[Tue Apr 21 12:32:56 2026] 127.0.0.1:59982 Accepted +[Tue Apr 21 12:32:56 2026] 127.0.0.1:59982 Closing +[Tue Apr 21 12:32:56 2026] 127.0.0.1:59984 Accepted +[Tue Apr 21 12:32:57 2026] 127.0.0.1:59984 Closing +[Tue Apr 21 12:32:57 2026] 127.0.0.1:59994 Accepted +[Tue Apr 21 12:32:57 2026] 127.0.0.1:59994 Closing +[Tue Apr 21 12:32:57 2026] 127.0.0.1:60002 Accepted +[Tue Apr 21 12:32:58 2026] 127.0.0.1:60002 Closing +[Tue Apr 21 12:34:41 2026] PHP 8.2.30 Development Server (http://127.0.0.1:37999) started +[Tue Apr 21 12:34:41 2026] 127.0.0.1:42608 Accepted +[Tue Apr 21 12:34:42 2026] 127.0.0.1:42608 Closing +[Tue Apr 21 12:34:42 2026] 127.0.0.1:42610 Accepted +[Tue Apr 21 12:34:43 2026] 127.0.0.1:42610 Closing +[Tue Apr 21 12:34:43 2026] 127.0.0.1:42626 Accepted +[Tue Apr 21 12:34:44 2026] 127.0.0.1:42626 Closing +[Tue Apr 21 12:34:44 2026] 127.0.0.1:59522 Accepted +[Tue Apr 21 12:34:44 2026] 127.0.0.1:59522 Closing +[Tue Apr 21 12:34:44 2026] 127.0.0.1:59532 Accepted +[Tue Apr 21 12:34:45 2026] 127.0.0.1:59532 Closing +[Tue Apr 21 12:34:45 2026] 127.0.0.1:59544 Accepted +[Tue Apr 21 12:34:46 2026] 127.0.0.1:59544 Closing +[Tue Apr 21 12:34:46 2026] 127.0.0.1:59554 Accepted +[Tue Apr 21 12:34:47 2026] 127.0.0.1:59554 Closing +[Tue Apr 21 12:34:47 2026] 127.0.0.1:59568 Accepted +[Tue Apr 21 12:34:48 2026] 127.0.0.1:59568 Closing +[Tue Apr 21 12:34:48 2026] 127.0.0.1:59582 Accepted +[Tue Apr 21 12:34:49 2026] 127.0.0.1:59582 Closing +[Tue Apr 21 12:34:49 2026] 127.0.0.1:59594 Accepted +[Tue Apr 21 12:34:47 2026] 127.0.0.1:59594 Closing +[Tue Apr 21 12:34:47 2026] 127.0.0.1:59602 Accepted +[Tue Apr 21 12:34:48 2026] 127.0.0.1:59602 Closing +[Tue Apr 21 12:34:48 2026] 127.0.0.1:59608 Accepted +[Tue Apr 21 12:34:48 2026] 127.0.0.1:59608 Closing +[Tue Apr 21 12:34:48 2026] 127.0.0.1:59622 Accepted +[Tue Apr 21 12:34:49 2026] 127.0.0.1:59622 Closing +[Tue Apr 21 12:38:02 2026] PHP 8.2.30 Development Server (http://127.0.0.1:35141) started +[Tue Apr 21 12:38:02 2026] 127.0.0.1:56092 Accepted +[Tue Apr 21 12:38:03 2026] 127.0.0.1:56092 Closing +[Tue Apr 21 12:38:03 2026] 127.0.0.1:56094 Accepted +[Tue Apr 21 12:38:04 2026] 127.0.0.1:56094 Closing +[Tue Apr 21 12:38:05 2026] 127.0.0.1:56108 Accepted +[Tue Apr 21 12:38:05 2026] 127.0.0.1:56108 Closing +[Tue Apr 21 12:38:05 2026] 127.0.0.1:56118 Accepted +[Tue Apr 21 12:38:06 2026] 127.0.0.1:56118 Closing +[Tue Apr 21 12:38:06 2026] 127.0.0.1:56134 Accepted +[Tue Apr 21 12:38:06 2026] 127.0.0.1:56134 Closing +[Tue Apr 21 12:38:07 2026] 127.0.0.1:56136 Accepted +[Tue Apr 21 12:38:07 2026] 127.0.0.1:56136 Closing +[Tue Apr 21 12:38:07 2026] 127.0.0.1:56144 Accepted +[Tue Apr 21 12:38:07 2026] 127.0.0.1:56144 Closing +[Tue Apr 21 12:38:07 2026] 127.0.0.1:56156 Accepted +[Tue Apr 21 12:38:08 2026] 127.0.0.1:56156 Closing +[Tue Apr 21 12:38:08 2026] 127.0.0.1:56166 Accepted +[Tue Apr 21 12:38:08 2026] 127.0.0.1:56166 Closing +[Tue Apr 21 12:38:08 2026] 127.0.0.1:56172 Accepted +[Tue Apr 21 12:38:09 2026] 127.0.0.1:56172 Closing +[Tue Apr 21 12:38:09 2026] 127.0.0.1:56186 Accepted +[Tue Apr 21 12:38:09 2026] 127.0.0.1:56186 Closing +[Tue Apr 21 12:38:09 2026] 127.0.0.1:56188 Accepted +[Tue Apr 21 12:38:09 2026] 127.0.0.1:56188 Closing +[Tue Apr 21 12:38:09 2026] 127.0.0.1:56196 Accepted +[Tue Apr 21 12:38:10 2026] 127.0.0.1:56196 Closing +[Tue Apr 21 12:39:00 2026] PHP 8.2.30 Development Server (http://127.0.0.1:40709) started +[Tue Apr 21 12:39:00 2026] 127.0.0.1:52258 Accepted +[Tue Apr 21 12:39:01 2026] 127.0.0.1:52258 Closing +[Tue Apr 21 12:39:01 2026] 127.0.0.1:52268 Accepted +[Tue Apr 21 12:39:01 2026] 127.0.0.1:52268 Closing +[Tue Apr 21 12:39:01 2026] 127.0.0.1:52270 Accepted +[Tue Apr 21 12:39:01 2026] 127.0.0.1:52270 Closing +[Tue Apr 21 12:39:01 2026] 127.0.0.1:52286 Accepted +[Tue Apr 21 12:39:02 2026] 127.0.0.1:52286 Closing +[Tue Apr 21 12:39:02 2026] 127.0.0.1:52290 Accepted +[Tue Apr 21 12:39:02 2026] 127.0.0.1:52290 Closing +[Tue Apr 21 12:39:02 2026] 127.0.0.1:52298 Accepted +[Tue Apr 21 12:39:03 2026] 127.0.0.1:52298 Closing +[Tue Apr 21 12:39:03 2026] 127.0.0.1:52300 Accepted +[Tue Apr 21 12:39:03 2026] 127.0.0.1:52300 Closing +[Tue Apr 21 12:39:03 2026] 127.0.0.1:52312 Accepted +[Tue Apr 21 12:39:03 2026] 127.0.0.1:52312 Closing +[Tue Apr 21 12:39:03 2026] 127.0.0.1:52324 Accepted +[Tue Apr 21 12:39:04 2026] 127.0.0.1:52324 Closing +[Tue Apr 21 12:39:04 2026] 127.0.0.1:52326 Accepted +[Tue Apr 21 12:39:04 2026] 127.0.0.1:52326 Closing +[Tue Apr 21 12:39:04 2026] 127.0.0.1:52328 Accepted +[Tue Apr 21 12:39:05 2026] 127.0.0.1:52328 Closing +[Tue Apr 21 12:39:05 2026] 127.0.0.1:52330 Accepted +[Tue Apr 21 12:39:06 2026] 127.0.0.1:52330 Closing +[Tue Apr 21 12:39:06 2026] 127.0.0.1:52346 Accepted +[Tue Apr 21 12:39:06 2026] 127.0.0.1:52346 Closing +[Tue Apr 21 12:39:06 2026] 127.0.0.1:49132 Accepted +[Tue Apr 21 12:39:07 2026] 127.0.0.1:49132 Closing +[Tue Apr 21 12:39:07 2026] 127.0.0.1:49142 Accepted +[Tue Apr 21 12:39:07 2026] 127.0.0.1:49142 Closing +[Tue Apr 21 12:39:07 2026] 127.0.0.1:49150 Accepted +[Tue Apr 21 12:39:08 2026] 127.0.0.1:49150 Closing +[Tue Apr 21 12:39:08 2026] 127.0.0.1:49160 Accepted +[Tue Apr 21 12:39:08 2026] 127.0.0.1:49160 Closing +[Tue Apr 21 12:39:08 2026] 127.0.0.1:49176 Accepted +[Tue Apr 21 12:39:09 2026] 127.0.0.1:49176 Closing +[Tue Apr 21 12:39:09 2026] 127.0.0.1:49182 Accepted +[Tue Apr 21 12:39:10 2026] 127.0.0.1:49182 Closing +[Tue Apr 21 12:39:10 2026] 127.0.0.1:49194 Accepted +[Tue Apr 21 12:39:11 2026] 127.0.0.1:49194 Closing +[Tue Apr 21 12:39:11 2026] 127.0.0.1:49196 Accepted +[Tue Apr 21 12:39:11 2026] 127.0.0.1:49196 Closing +[Tue Apr 21 12:39:11 2026] 127.0.0.1:49210 Accepted +[Tue Apr 21 12:39:12 2026] 127.0.0.1:49210 Closing +[Tue Apr 21 12:39:12 2026] 127.0.0.1:49216 Accepted +[Tue Apr 21 12:39:13 2026] 127.0.0.1:49216 Closing +[Tue Apr 21 12:39:13 2026] 127.0.0.1:49232 Accepted +[Tue Apr 21 12:39:13 2026] 127.0.0.1:49232 Closing +[Tue Apr 21 12:39:13 2026] 127.0.0.1:49234 Accepted +[Tue Apr 21 12:39:14 2026] 127.0.0.1:49234 Closing +[Tue Apr 21 12:39:14 2026] 127.0.0.1:49238 Accepted +[Tue Apr 21 12:39:14 2026] 127.0.0.1:49238 Closing +[Tue Apr 21 12:39:14 2026] 127.0.0.1:49252 Accepted +[Tue Apr 21 12:39:15 2026] 127.0.0.1:49252 Closing +[Tue Apr 21 12:39:15 2026] 127.0.0.1:49262 Accepted +[Tue Apr 21 12:39:15 2026] 127.0.0.1:49262 Closing +[Tue Apr 21 12:39:15 2026] 127.0.0.1:49266 Accepted +[Tue Apr 21 12:39:16 2026] 127.0.0.1:49266 Closing +[Tue Apr 21 12:39:16 2026] 127.0.0.1:49278 Accepted +[Tue Apr 21 12:39:16 2026] 127.0.0.1:49278 Closing +[Tue Apr 21 12:39:16 2026] 127.0.0.1:49280 Accepted +[Tue Apr 21 12:39:16 2026] 127.0.0.1:49280 Closing +[Tue Apr 21 12:39:17 2026] 127.0.0.1:44144 Accepted +[Tue Apr 21 12:39:17 2026] 127.0.0.1:44144 Closing +[Tue Apr 21 12:39:17 2026] 127.0.0.1:44160 Accepted +[Tue Apr 21 12:39:18 2026] 127.0.0.1:44160 Closing +[Tue Apr 21 12:39:18 2026] 127.0.0.1:44168 Accepted +[Tue Apr 21 12:39:18 2026] 127.0.0.1:44168 Closing +[Tue Apr 21 12:39:18 2026] 127.0.0.1:44178 Accepted +[Tue Apr 21 12:39:19 2026] 127.0.0.1:44178 Closing +[Tue Apr 21 12:39:19 2026] 127.0.0.1:44182 Accepted +[Tue Apr 21 12:39:20 2026] 127.0.0.1:44182 Closing +[Tue Apr 21 12:39:20 2026] 127.0.0.1:44190 Accepted +[Tue Apr 21 12:39:21 2026] 127.0.0.1:44190 Closing +[Tue Apr 21 12:39:21 2026] 127.0.0.1:44206 Accepted +[Tue Apr 21 12:39:21 2026] 127.0.0.1:44206 Closing +[Tue Apr 21 12:39:21 2026] 127.0.0.1:44214 Accepted +[Tue Apr 21 12:39:22 2026] 127.0.0.1:44214 Closing +[Tue Apr 21 12:39:22 2026] 127.0.0.1:44220 Accepted +[Tue Apr 21 12:39:20 2026] 127.0.0.1:44220 Closing +[Tue Apr 21 12:39:20 2026] 127.0.0.1:44234 Accepted +[Tue Apr 21 12:39:20 2026] 127.0.0.1:44234 Closing +[Tue Apr 21 12:39:20 2026] 127.0.0.1:44244 Accepted +[Tue Apr 21 12:39:21 2026] 127.0.0.1:44244 Closing +[Tue Apr 21 12:39:21 2026] 127.0.0.1:44260 Accepted +[Tue Apr 21 12:39:21 2026] 127.0.0.1:44260 Closing +[Tue Apr 21 12:39:21 2026] 127.0.0.1:44272 Accepted +[Tue Apr 21 12:39:23 2026] 127.0.0.1:44272 Closing +[Tue Apr 21 12:39:23 2026] 127.0.0.1:44280 Accepted +[Tue Apr 21 12:39:24 2026] 127.0.0.1:44280 Closing +[Tue Apr 21 12:39:24 2026] 127.0.0.1:34634 Accepted +[Tue Apr 21 12:39:24 2026] 127.0.0.1:34634 Closing +[Tue Apr 21 12:39:24 2026] 127.0.0.1:34644 Accepted +[Tue Apr 21 12:39:25 2026] 127.0.0.1:34644 Closing +[Tue Apr 21 12:39:25 2026] 127.0.0.1:34658 Accepted +[Tue Apr 21 12:39:25 2026] 127.0.0.1:34658 Closing +[Tue Apr 21 12:39:25 2026] 127.0.0.1:34674 Accepted +[Tue Apr 21 12:39:26 2026] 127.0.0.1:34674 Closing +[Tue Apr 21 12:39:26 2026] 127.0.0.1:34688 Accepted +[Tue Apr 21 12:39:26 2026] 127.0.0.1:34688 Closing +[Tue Apr 21 12:39:26 2026] 127.0.0.1:34696 Accepted +[Tue Apr 21 12:39:27 2026] 127.0.0.1:34696 Closing +[Tue Apr 21 12:39:27 2026] 127.0.0.1:34702 Accepted +[Tue Apr 21 12:39:28 2026] 127.0.0.1:34702 Closing +[Tue Apr 21 12:39:28 2026] 127.0.0.1:34712 Accepted +[Tue Apr 21 12:39:29 2026] 127.0.0.1:34712 Closing +[Tue Apr 21 12:39:29 2026] 127.0.0.1:34726 Accepted +[Tue Apr 21 12:39:32 2026] 127.0.0.1:34726 Closing +[Tue Apr 21 12:39:32 2026] 127.0.0.1:34742 Accepted +[Tue Apr 21 12:39:34 2026] 127.0.0.1:34742 Closing +[Tue Apr 21 12:39:34 2026] 127.0.0.1:43282 Accepted +[Tue Apr 21 12:39:35 2026] 127.0.0.1:43282 Closing +[Tue Apr 21 12:39:35 2026] 127.0.0.1:43286 Accepted +[Tue Apr 21 12:39:36 2026] 127.0.0.1:43286 Closing +[Tue Apr 21 12:39:36 2026] 127.0.0.1:43290 Accepted +[Tue Apr 21 12:39:36 2026] 127.0.0.1:43290 Closing +[Tue Apr 21 12:39:36 2026] 127.0.0.1:43302 Accepted +[Tue Apr 21 12:39:37 2026] 127.0.0.1:43302 Closing +[Tue Apr 21 12:39:37 2026] 127.0.0.1:43310 Accepted +[Tue Apr 21 12:39:38 2026] 127.0.0.1:43310 Closing +[Tue Apr 21 12:39:38 2026] 127.0.0.1:43312 Accepted +[Tue Apr 21 12:39:39 2026] 127.0.0.1:43312 Closing +[Tue Apr 21 12:39:39 2026] 127.0.0.1:43322 Accepted +[Tue Apr 21 12:39:40 2026] 127.0.0.1:43322 Closing +[Tue Apr 21 12:39:40 2026] 127.0.0.1:43334 Accepted +[Tue Apr 21 12:39:40 2026] 127.0.0.1:43334 Closing +[Tue Apr 21 12:39:40 2026] 127.0.0.1:43336 Accepted +[Tue Apr 21 12:39:41 2026] 127.0.0.1:43336 Closing +[Tue Apr 21 12:39:41 2026] 127.0.0.1:43340 Accepted +[Tue Apr 21 12:39:41 2026] 127.0.0.1:43340 Closing +[Tue Apr 21 12:39:41 2026] 127.0.0.1:43346 Accepted +[Tue Apr 21 12:39:41 2026] 127.0.0.1:43346 Closing +[Tue Apr 21 12:39:41 2026] 127.0.0.1:43362 Accepted +[Tue Apr 21 12:39:42 2026] 127.0.0.1:43362 Closing +[Tue Apr 21 12:39:42 2026] 127.0.0.1:43364 Accepted +[Tue Apr 21 12:39:42 2026] 127.0.0.1:43364 Closing +[Tue Apr 21 12:39:42 2026] 127.0.0.1:43370 Accepted +[Tue Apr 21 12:39:42 2026] 127.0.0.1:43370 Closing +[Tue Apr 21 12:39:42 2026] 127.0.0.1:43386 Accepted +[Tue Apr 21 12:39:43 2026] 127.0.0.1:43386 Closing +[Tue Apr 21 12:39:43 2026] 127.0.0.1:43392 Accepted +[Tue Apr 21 12:39:43 2026] 127.0.0.1:43392 Closing +[Tue Apr 21 12:39:43 2026] 127.0.0.1:43404 Accepted +[Tue Apr 21 12:39:44 2026] 127.0.0.1:43404 Closing +[Tue Apr 21 12:39:44 2026] 127.0.0.1:41558 Accepted +[Tue Apr 21 12:39:44 2026] 127.0.0.1:41558 Closing +[Tue Apr 21 12:39:45 2026] 127.0.0.1:41564 Accepted +[Tue Apr 21 12:39:45 2026] 127.0.0.1:41564 Closing +[Tue Apr 21 12:39:45 2026] 127.0.0.1:41566 Accepted +[Tue Apr 21 12:39:46 2026] 127.0.0.1:41566 Closing +[Tue Apr 21 12:39:46 2026] 127.0.0.1:41570 Accepted +[Tue Apr 21 12:39:47 2026] 127.0.0.1:41570 Closing +[Tue Apr 21 12:39:47 2026] 127.0.0.1:41584 Accepted +[Tue Apr 21 12:39:47 2026] 127.0.0.1:41584 Closing +[Tue Apr 21 12:39:48 2026] 127.0.0.1:41586 Accepted +[Tue Apr 21 12:39:49 2026] 127.0.0.1:41586 Closing +[Tue Apr 21 12:39:49 2026] 127.0.0.1:41602 Accepted +[Tue Apr 21 12:39:49 2026] 127.0.0.1:41602 Closing +[Tue Apr 21 12:48:25 2026] PHP 8.2.30 Development Server (http://127.0.0.1:37693) started +[Tue Apr 21 12:48:26 2026] 127.0.0.1:56166 Accepted +[Tue Apr 21 12:48:26 2026] 127.0.0.1:56166 Closing +[Tue Apr 21 12:48:26 2026] 127.0.0.1:56182 Accepted +[Tue Apr 21 12:48:27 2026] 127.0.0.1:56182 Closing +[Tue Apr 21 12:48:27 2026] 127.0.0.1:56186 Accepted +[Tue Apr 21 12:48:29 2026] 127.0.0.1:56186 Closing +[Tue Apr 21 12:48:30 2026] 127.0.0.1:52486 Accepted +[Tue Apr 21 12:48:31 2026] 127.0.0.1:52486 Closing +[Tue Apr 21 12:48:31 2026] 127.0.0.1:52498 Accepted +[Tue Apr 21 12:48:32 2026] 127.0.0.1:52498 Closing +[Tue Apr 21 12:48:32 2026] 127.0.0.1:52508 Accepted +[Tue Apr 21 12:48:33 2026] 127.0.0.1:52508 Closing +[Tue Apr 21 12:48:33 2026] 127.0.0.1:52518 Accepted +[Tue Apr 21 12:48:34 2026] 127.0.0.1:52518 Closing +[Tue Apr 21 12:48:34 2026] 127.0.0.1:52532 Accepted +[Tue Apr 21 12:48:36 2026] 127.0.0.1:52532 Closing +[Tue Apr 21 12:48:36 2026] 127.0.0.1:52548 Accepted +[Tue Apr 21 12:48:37 2026] 127.0.0.1:52548 Closing +[Tue Apr 21 12:48:37 2026] 127.0.0.1:52560 Accepted +[Tue Apr 21 12:48:38 2026] 127.0.0.1:52560 Closing +[Tue Apr 21 12:48:38 2026] 127.0.0.1:52576 Accepted +[Tue Apr 21 12:48:39 2026] 127.0.0.1:52576 Closing +[Tue Apr 21 12:48:39 2026] 127.0.0.1:57582 Accepted +[Tue Apr 21 12:48:40 2026] 127.0.0.1:57582 Closing +[Tue Apr 21 12:48:40 2026] 127.0.0.1:57598 Accepted +[Tue Apr 21 12:48:41 2026] 127.0.0.1:57598 Closing +[Tue Apr 21 12:48:41 2026] 127.0.0.1:57614 Accepted +[Tue Apr 21 12:48:42 2026] 127.0.0.1:57614 Closing +[Tue Apr 21 12:48:42 2026] 127.0.0.1:57624 Accepted +[Tue Apr 21 12:48:43 2026] 127.0.0.1:57624 Closing +[Tue Apr 21 12:48:43 2026] 127.0.0.1:57628 Accepted +[Tue Apr 21 12:48:46 2026] 127.0.0.1:57628 Closing +[Tue Apr 21 12:48:46 2026] 127.0.0.1:57634 Accepted +[Tue Apr 21 12:48:46 2026] 127.0.0.1:57634 Closing +[Tue Apr 21 12:48:46 2026] 127.0.0.1:57646 Accepted +[Tue Apr 21 12:48:48 2026] 127.0.0.1:57646 Closing +[Tue Apr 21 12:49:51 2026] PHP 8.2.30 Development Server (http://127.0.0.1:41511) started +[Tue Apr 21 12:49:51 2026] 127.0.0.1:60470 Accepted +[Tue Apr 21 12:49:53 2026] 127.0.0.1:60470 Closing +[Tue Apr 21 12:49:53 2026] 127.0.0.1:60472 Accepted +[Tue Apr 21 12:49:53 2026] 127.0.0.1:60472 Closing +[Tue Apr 21 12:49:54 2026] 127.0.0.1:60480 Accepted +[Tue Apr 21 12:49:57 2026] 127.0.0.1:60480 Closing +[Tue Apr 21 12:49:57 2026] 127.0.0.1:60484 Accepted +[Tue Apr 21 12:49:59 2026] 127.0.0.1:60484 Closing +[Tue Apr 21 12:49:59 2026] 127.0.0.1:60494 Accepted +[Tue Apr 21 12:50:00 2026] 127.0.0.1:60494 Closing +[Tue Apr 21 12:50:00 2026] 127.0.0.1:60504 Accepted +[Tue Apr 21 12:50:02 2026] 127.0.0.1:60504 Closing +[Tue Apr 21 12:50:02 2026] 127.0.0.1:50656 Accepted +[Tue Apr 21 12:50:03 2026] 127.0.0.1:50656 Closing +[Tue Apr 21 12:50:04 2026] 127.0.0.1:50670 Accepted +[Tue Apr 21 12:50:05 2026] 127.0.0.1:50670 Closing +[Tue Apr 21 12:50:05 2026] 127.0.0.1:50676 Accepted +[Tue Apr 21 12:50:07 2026] 127.0.0.1:50676 Closing +[Tue Apr 21 12:50:07 2026] 127.0.0.1:50678 Accepted +[Tue Apr 21 12:50:09 2026] 127.0.0.1:50678 Closing +[Tue Apr 21 12:50:09 2026] 127.0.0.1:50686 Accepted +[Tue Apr 21 12:50:10 2026] 127.0.0.1:50686 Closing +[Tue Apr 21 12:50:10 2026] 127.0.0.1:50694 Accepted +[Tue Apr 21 12:50:11 2026] 127.0.0.1:50694 Closing +[Tue Apr 21 12:51:35 2026] PHP 8.2.30 Development Server (http://127.0.0.1:44745) started +[Tue Apr 21 12:51:35 2026] 127.0.0.1:56336 Accepted +[Tue Apr 21 12:51:36 2026] 127.0.0.1:56336 Closing +[Tue Apr 21 12:51:36 2026] 127.0.0.1:56342 Accepted +[Tue Apr 21 12:51:37 2026] 127.0.0.1:56342 Closing +[Tue Apr 21 12:51:37 2026] 127.0.0.1:56344 Accepted +[Tue Apr 21 12:51:37 2026] 127.0.0.1:56344 Closing +[Tue Apr 21 12:51:39 2026] 127.0.0.1:56354 Accepted +[Tue Apr 21 12:51:41 2026] 127.0.0.1:56354 Closing +[Tue Apr 21 12:51:41 2026] 127.0.0.1:37198 Accepted +[Tue Apr 21 12:51:42 2026] 127.0.0.1:37198 Closing +[Tue Apr 21 12:51:42 2026] 127.0.0.1:37212 Accepted +[Tue Apr 21 12:51:44 2026] 127.0.0.1:37212 Closing +[Tue Apr 21 12:51:44 2026] 127.0.0.1:37228 Accepted +[Tue Apr 21 12:51:46 2026] 127.0.0.1:37228 Closing +[Tue Apr 21 12:51:46 2026] 127.0.0.1:37236 Accepted +[Tue Apr 21 12:51:47 2026] 127.0.0.1:37236 Closing +[Tue Apr 21 12:51:47 2026] 127.0.0.1:37244 Accepted +[Tue Apr 21 12:51:49 2026] 127.0.0.1:37244 Closing +[Tue Apr 21 12:51:49 2026] 127.0.0.1:37252 Accepted +[Tue Apr 21 12:51:50 2026] 127.0.0.1:37252 Closing +[Tue Apr 21 12:51:50 2026] 127.0.0.1:52198 Accepted +[Tue Apr 21 12:51:52 2026] 127.0.0.1:52198 Closing +[Tue Apr 21 12:51:52 2026] 127.0.0.1:52208 Accepted +[Tue Apr 21 12:51:53 2026] 127.0.0.1:52208 Closing +[Tue Apr 21 12:52:37 2026] PHP 8.2.30 Development Server (http://127.0.0.1:45395) started +[Tue Apr 21 12:52:37 2026] 127.0.0.1:45928 Accepted +[Tue Apr 21 12:52:39 2026] 127.0.0.1:45928 Closing +[Tue Apr 21 12:52:39 2026] 127.0.0.1:45940 Accepted +[Tue Apr 21 12:52:40 2026] 127.0.0.1:45940 Closing +[Tue Apr 21 12:52:40 2026] 127.0.0.1:45956 Accepted +[Tue Apr 21 12:52:41 2026] 127.0.0.1:45956 Closing +[Tue Apr 21 12:52:41 2026] 127.0.0.1:45968 Accepted +[Tue Apr 21 12:52:42 2026] 127.0.0.1:45968 Closing +[Tue Apr 21 12:52:42 2026] 127.0.0.1:45980 Accepted +[Tue Apr 21 12:52:42 2026] 127.0.0.1:45980 Closing +[Tue Apr 21 12:52:43 2026] 127.0.0.1:45996 Accepted +[Tue Apr 21 12:52:44 2026] 127.0.0.1:45996 Closing +[Tue Apr 21 12:52:44 2026] 127.0.0.1:46000 Accepted +[Tue Apr 21 12:52:45 2026] 127.0.0.1:46000 Closing +[Tue Apr 21 12:52:45 2026] 127.0.0.1:37320 Accepted +[Tue Apr 21 12:52:45 2026] 127.0.0.1:37320 Closing +[Tue Apr 21 12:52:45 2026] 127.0.0.1:37334 Accepted +[Tue Apr 21 12:52:46 2026] 127.0.0.1:37334 Closing +[Tue Apr 21 12:52:46 2026] 127.0.0.1:37346 Accepted +[Tue Apr 21 12:52:47 2026] 127.0.0.1:37346 Closing +[Tue Apr 21 12:52:47 2026] 127.0.0.1:37360 Accepted +[Tue Apr 21 12:52:47 2026] 127.0.0.1:37360 Closing +[Tue Apr 21 12:52:47 2026] 127.0.0.1:37376 Accepted +[Tue Apr 21 12:52:48 2026] 127.0.0.1:37376 Closing +[Tue Apr 21 12:52:48 2026] 127.0.0.1:37378 Accepted +[Tue Apr 21 12:52:48 2026] 127.0.0.1:37378 Closing +[Tue Apr 21 12:52:48 2026] 127.0.0.1:37390 Accepted +[Tue Apr 21 12:52:49 2026] 127.0.0.1:37390 Closing +[Tue Apr 21 12:52:49 2026] 127.0.0.1:37406 Accepted +[Tue Apr 21 12:52:50 2026] 127.0.0.1:37406 Closing +[Tue Apr 21 12:52:50 2026] 127.0.0.1:37416 Accepted +[Tue Apr 21 12:52:50 2026] 127.0.0.1:37416 Closing +[Tue Apr 21 12:52:51 2026] 127.0.0.1:37428 Accepted +[Tue Apr 21 12:52:51 2026] 127.0.0.1:37428 Closing +[Tue Apr 21 12:52:51 2026] 127.0.0.1:37440 Accepted +[Tue Apr 21 12:52:52 2026] 127.0.0.1:37440 Closing +[Tue Apr 21 12:52:52 2026] 127.0.0.1:37450 Accepted +[Tue Apr 21 12:52:53 2026] 127.0.0.1:37450 Closing +[Tue Apr 21 12:52:53 2026] 127.0.0.1:37452 Accepted +[Tue Apr 21 12:52:54 2026] 127.0.0.1:37452 Closing +[Tue Apr 21 12:52:54 2026] 127.0.0.1:37458 Accepted +[Tue Apr 21 12:52:54 2026] 127.0.0.1:37458 Closing +[Tue Apr 21 12:52:54 2026] 127.0.0.1:37466 Accepted +[Tue Apr 21 12:52:55 2026] 127.0.0.1:37466 Closing +[Tue Apr 21 12:52:55 2026] 127.0.0.1:44262 Accepted +[Tue Apr 21 12:52:55 2026] 127.0.0.1:44262 Closing +[Tue Apr 21 12:52:55 2026] 127.0.0.1:44268 Accepted +[Tue Apr 21 12:52:56 2026] 127.0.0.1:44268 Closing +[Tue Apr 21 12:52:56 2026] 127.0.0.1:44284 Accepted +[Tue Apr 21 12:52:56 2026] 127.0.0.1:44284 Closing +[Tue Apr 21 12:52:56 2026] 127.0.0.1:44290 Accepted +[Tue Apr 21 12:52:57 2026] 127.0.0.1:44290 Closing +[Tue Apr 21 12:52:57 2026] 127.0.0.1:44296 Accepted +[Tue Apr 21 12:52:58 2026] 127.0.0.1:44296 Closing +[Tue Apr 21 12:52:58 2026] 127.0.0.1:44312 Accepted +[Tue Apr 21 12:52:58 2026] 127.0.0.1:44312 Closing +[Tue Apr 21 12:52:58 2026] 127.0.0.1:44328 Accepted +[Tue Apr 21 12:52:59 2026] 127.0.0.1:44328 Closing +[Tue Apr 21 12:52:59 2026] 127.0.0.1:44332 Accepted +[Tue Apr 21 12:53:00 2026] 127.0.0.1:44332 Closing +[Tue Apr 21 12:53:00 2026] 127.0.0.1:44334 Accepted +[Tue Apr 21 12:52:58 2026] 127.0.0.1:44334 Closing +[Tue Apr 21 12:52:58 2026] 127.0.0.1:44346 Accepted +[Tue Apr 21 12:52:59 2026] 127.0.0.1:44346 Closing +[Tue Apr 21 12:52:59 2026] 127.0.0.1:44360 Accepted +[Tue Apr 21 12:53:00 2026] 127.0.0.1:44360 Closing +[Tue Apr 21 12:53:00 2026] 127.0.0.1:44372 Accepted +[Tue Apr 21 12:53:00 2026] 127.0.0.1:44372 Closing +[Tue Apr 21 12:53:00 2026] 127.0.0.1:44384 Accepted +[Tue Apr 21 12:53:01 2026] 127.0.0.1:44384 Closing +[Tue Apr 21 12:53:01 2026] 127.0.0.1:44400 Accepted +[Tue Apr 21 12:53:02 2026] 127.0.0.1:44400 Closing +[Tue Apr 21 12:53:02 2026] 127.0.0.1:60944 Accepted +[Tue Apr 21 12:53:03 2026] 127.0.0.1:60944 Closing +[Tue Apr 21 12:53:03 2026] 127.0.0.1:60958 Accepted +[Tue Apr 21 12:53:04 2026] 127.0.0.1:60958 Closing +[Tue Apr 21 12:53:04 2026] 127.0.0.1:60974 Accepted +[Tue Apr 21 12:53:04 2026] 127.0.0.1:60974 Closing +[Tue Apr 21 12:53:05 2026] 127.0.0.1:60982 Accepted +[Tue Apr 21 12:53:05 2026] 127.0.0.1:60982 Closing +[Tue Apr 21 12:53:06 2026] 127.0.0.1:60984 Accepted +[Tue Apr 21 12:53:06 2026] 127.0.0.1:60984 Closing +[Tue Apr 21 12:53:07 2026] 127.0.0.1:60986 Accepted +[Tue Apr 21 12:53:07 2026] 127.0.0.1:60986 Closing +[Tue Apr 21 12:53:08 2026] 127.0.0.1:60990 Accepted +[Tue Apr 21 12:53:08 2026] 127.0.0.1:60990 Closing +[Tue Apr 21 12:53:09 2026] 127.0.0.1:32768 Accepted +[Tue Apr 21 12:53:11 2026] 127.0.0.1:32768 Closing +[Tue Apr 21 12:53:12 2026] 127.0.0.1:32772 Accepted +[Tue Apr 21 12:53:12 2026] 127.0.0.1:32772 Closing +[Tue Apr 21 12:53:12 2026] 127.0.0.1:60592 Accepted +[Tue Apr 21 12:53:13 2026] 127.0.0.1:60592 Closing +[Tue Apr 21 12:53:13 2026] 127.0.0.1:60600 Accepted +[Tue Apr 21 12:53:14 2026] 127.0.0.1:60600 Closing +[Tue Apr 21 12:53:14 2026] 127.0.0.1:60610 Accepted +[Tue Apr 21 12:53:15 2026] 127.0.0.1:60610 Closing +[Tue Apr 21 12:53:15 2026] 127.0.0.1:60626 Accepted +[Tue Apr 21 12:53:16 2026] 127.0.0.1:60626 Closing +[Tue Apr 21 12:53:16 2026] 127.0.0.1:60628 Accepted +[Tue Apr 21 12:53:17 2026] 127.0.0.1:60628 Closing +[Tue Apr 21 12:53:17 2026] 127.0.0.1:60634 Accepted +[Tue Apr 21 12:53:17 2026] 127.0.0.1:60634 Closing +[Tue Apr 21 12:53:18 2026] 127.0.0.1:60638 Accepted +[Tue Apr 21 12:53:18 2026] 127.0.0.1:60638 Closing +[Tue Apr 21 12:53:19 2026] 127.0.0.1:60640 Accepted +[Tue Apr 21 12:53:19 2026] 127.0.0.1:60640 Closing +[Tue Apr 21 12:53:19 2026] 127.0.0.1:60642 Accepted +[Tue Apr 21 12:53:20 2026] 127.0.0.1:60642 Closing +[Tue Apr 21 12:53:20 2026] 127.0.0.1:60658 Accepted +[Tue Apr 21 12:53:21 2026] 127.0.0.1:60658 Closing +[Tue Apr 21 12:53:21 2026] 127.0.0.1:60674 Accepted +[Tue Apr 21 12:53:24 2026] 127.0.0.1:60674 Closing +[Tue Apr 21 12:53:24 2026] 127.0.0.1:57922 Accepted +[Tue Apr 21 12:53:25 2026] 127.0.0.1:57922 Closing +[Tue Apr 21 12:53:25 2026] 127.0.0.1:57928 Accepted +[Tue Apr 21 12:53:27 2026] 127.0.0.1:57928 Closing +[Tue Apr 21 12:53:27 2026] 127.0.0.1:57942 Accepted +[Tue Apr 21 12:53:26 2026] 127.0.0.1:57942 Closing +[Tue Apr 21 12:53:26 2026] 127.0.0.1:57946 Accepted +[Tue Apr 21 12:53:27 2026] 127.0.0.1:57946 Closing +[Tue Apr 21 12:53:27 2026] 127.0.0.1:57954 Accepted +[Tue Apr 21 12:53:27 2026] 127.0.0.1:57954 Closing +[Tue Apr 21 12:53:27 2026] 127.0.0.1:57960 Accepted +[Tue Apr 21 12:53:28 2026] 127.0.0.1:57960 Closing +[Tue Apr 21 12:53:28 2026] 127.0.0.1:57972 Accepted +[Tue Apr 21 12:53:29 2026] 127.0.0.1:57972 Closing +[Tue Apr 21 12:53:29 2026] 127.0.0.1:57986 Accepted +[Tue Apr 21 12:53:30 2026] 127.0.0.1:57986 Closing +[Tue Apr 21 12:53:30 2026] 127.0.0.1:41580 Accepted +[Tue Apr 21 12:53:31 2026] 127.0.0.1:41580 Closing +[Tue Apr 21 12:53:31 2026] 127.0.0.1:41588 Accepted +[Tue Apr 21 12:53:31 2026] 127.0.0.1:41588 Closing +[Tue Apr 21 12:53:31 2026] 127.0.0.1:41602 Accepted +[Tue Apr 21 12:53:33 2026] 127.0.0.1:41602 Closing +[Tue Apr 21 12:53:33 2026] 127.0.0.1:41608 Accepted +[Tue Apr 21 12:53:34 2026] 127.0.0.1:41608 Closing +[Tue Apr 21 12:53:34 2026] 127.0.0.1:41624 Accepted +[Tue Apr 21 12:53:34 2026] 127.0.0.1:41624 Closing +[Tue Apr 21 12:53:34 2026] 127.0.0.1:41634 Accepted +[Tue Apr 21 12:53:35 2026] 127.0.0.1:41634 Closing +[Tue Apr 21 12:53:35 2026] 127.0.0.1:41638 Accepted +[Tue Apr 21 12:53:36 2026] 127.0.0.1:41638 Closing +[Tue Apr 21 12:53:36 2026] 127.0.0.1:41644 Accepted +[Tue Apr 21 12:53:36 2026] 127.0.0.1:41644 Closing +[Tue Apr 21 12:53:36 2026] 127.0.0.1:41650 Accepted +[Tue Apr 21 12:53:37 2026] 127.0.0.1:41650 Closing +[Tue Apr 21 12:53:37 2026] 127.0.0.1:41652 Accepted +[Tue Apr 21 12:53:37 2026] 127.0.0.1:41652 Closing +[Tue Apr 21 12:53:38 2026] 127.0.0.1:41654 Accepted +[Tue Apr 21 12:53:38 2026] 127.0.0.1:41654 Closing +[Tue Apr 21 12:53:38 2026] 127.0.0.1:41660 Accepted +[Tue Apr 21 12:53:38 2026] 127.0.0.1:41660 Closing +[Tue Apr 21 12:53:38 2026] 127.0.0.1:41664 Accepted +[Tue Apr 21 12:53:39 2026] 127.0.0.1:41664 Closing +[Tue Apr 21 12:53:39 2026] 127.0.0.1:36412 Accepted +[Tue Apr 21 12:53:40 2026] 127.0.0.1:36412 Closing +[Tue Apr 21 12:53:40 2026] 127.0.0.1:36420 Accepted +[Tue Apr 21 12:53:41 2026] 127.0.0.1:36420 Closing +[Tue Apr 21 12:53:41 2026] 127.0.0.1:36426 Accepted +[Tue Apr 21 12:53:42 2026] 127.0.0.1:36426 Closing +[Tue Apr 21 12:53:42 2026] 127.0.0.1:36442 Accepted +[Tue Apr 21 12:53:44 2026] 127.0.0.1:36442 Closing +[Tue Apr 21 12:53:44 2026] 127.0.0.1:36450 Accepted +[Tue Apr 21 12:53:47 2026] 127.0.0.1:36450 Closing +[Tue Apr 21 12:53:49 2026] 127.0.0.1:47160 Accepted +[Tue Apr 21 12:53:52 2026] 127.0.0.1:47160 Closing +[Tue Apr 21 12:53:52 2026] 127.0.0.1:47174 Accepted +[Tue Apr 21 12:53:54 2026] 127.0.0.1:47174 Closing +[Tue Apr 21 12:53:54 2026] 127.0.0.1:47178 Accepted +[Tue Apr 21 12:53:53 2026] 127.0.0.1:47178 Closing +[Tue Apr 21 12:53:53 2026] 127.0.0.1:47194 Accepted +[Tue Apr 21 12:53:55 2026] 127.0.0.1:47194 Closing +[Tue Apr 21 12:53:55 2026] 127.0.0.1:47204 Accepted +[Tue Apr 21 12:53:57 2026] 127.0.0.1:47204 Closing +[Tue Apr 21 12:53:57 2026] 127.0.0.1:46990 Accepted +[Tue Apr 21 12:53:59 2026] 127.0.0.1:46990 Closing +[Tue Apr 21 12:53:59 2026] 127.0.0.1:46994 Accepted +[Tue Apr 21 12:54:01 2026] 127.0.0.1:46994 Closing +[Tue Apr 21 12:54:01 2026] 127.0.0.1:46996 Accepted +[Tue Apr 21 12:54:03 2026] 127.0.0.1:46996 Closing +[Tue Apr 21 12:54:03 2026] 127.0.0.1:47008 Accepted +[Tue Apr 21 12:54:04 2026] 127.0.0.1:47008 Closing +[Tue Apr 21 12:54:04 2026] 127.0.0.1:47012 Accepted +[Tue Apr 21 12:54:05 2026] 127.0.0.1:47012 Closing +[Tue Apr 21 12:54:05 2026] 127.0.0.1:47026 Accepted +[Tue Apr 21 12:54:06 2026] 127.0.0.1:47026 Closing +[Thu Apr 23 11:09:21 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37467) started +[Thu Apr 23 11:09:21 2026] 127.0.0.1:45138 Accepted +[Thu Apr 23 11:09:23 2026] 127.0.0.1:45138 Closing +[Thu Apr 23 11:09:23 2026] 127.0.0.1:45152 Accepted +[Thu Apr 23 11:09:27 2026] 127.0.0.1:45152 Closing +[Thu Apr 23 11:09:28 2026] 127.0.0.1:45158 Accepted +[Thu Apr 23 11:09:32 2026] 127.0.0.1:45158 Closing +[Thu Apr 23 11:09:38 2026] 127.0.0.1:40568 Accepted +[Thu Apr 23 11:09:42 2026] 127.0.0.1:40568 Closing +[Thu Apr 23 11:10:39 2026] PHP 8.2.15 Development Server (http://127.0.0.1:42453) started +[Thu Apr 23 11:10:39 2026] 127.0.0.1:57264 Accepted +[Thu Apr 23 11:10:41 2026] 127.0.0.1:57264 Closing +[Thu Apr 23 11:10:41 2026] 127.0.0.1:57272 Accepted +[Thu Apr 23 11:10:45 2026] 127.0.0.1:57272 Closing +[Thu Apr 23 11:10:45 2026] 127.0.0.1:57278 Accepted +[Thu Apr 23 11:10:49 2026] 127.0.0.1:57278 Closing +[Thu Apr 23 11:10:49 2026] 127.0.0.1:41688 Accepted +[Thu Apr 23 11:10:55 2026] 127.0.0.1:41688 Closing +[Thu Apr 23 11:11:02 2026] 127.0.0.1:43002 Accepted +[Thu Apr 23 11:11:05 2026] 127.0.0.1:43002 Closing +[Thu Apr 23 11:11:27 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45343) started +[Thu Apr 23 11:11:27 2026] 127.0.0.1:47492 Accepted +[Thu Apr 23 11:11:29 2026] 127.0.0.1:47492 Closing +[Thu Apr 23 11:11:29 2026] 127.0.0.1:44236 Accepted +[Thu Apr 23 11:11:34 2026] 127.0.0.1:44236 Closing +[Thu Apr 23 11:11:34 2026] 127.0.0.1:44242 Accepted +[Thu Apr 23 11:11:38 2026] 127.0.0.1:44242 Closing +[Thu Apr 23 11:11:38 2026] 127.0.0.1:44250 Accepted +[Thu Apr 23 11:11:43 2026] 127.0.0.1:44250 Closing +[Thu Apr 23 11:11:49 2026] 127.0.0.1:52682 Accepted +[Thu Apr 23 11:11:53 2026] 127.0.0.1:52682 Closing +[Thu Apr 23 11:15:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45041) started +[Thu Apr 23 11:15:24 2026] 127.0.0.1:54634 Accepted +[Thu Apr 23 11:15:25 2026] 127.0.0.1:54634 Closing +[Thu Apr 23 11:15:25 2026] 127.0.0.1:54642 Accepted +[Thu Apr 23 11:15:30 2026] 127.0.0.1:54642 Closing +[Thu Apr 23 11:15:30 2026] 127.0.0.1:40348 Accepted +[Thu Apr 23 11:15:34 2026] 127.0.0.1:40348 Closing +[Thu Apr 23 11:15:34 2026] 127.0.0.1:40350 Accepted +[Thu Apr 23 11:15:39 2026] 127.0.0.1:40350 Closing +[Thu Apr 23 11:15:45 2026] 127.0.0.1:56004 Accepted +[Thu Apr 23 11:15:49 2026] 127.0.0.1:56004 Closing +[Thu Apr 23 13:36:44 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45583) started +[Thu Apr 23 13:36:44 2026] 127.0.0.1:45086 Accepted +[Thu Apr 23 13:36:46 2026] 127.0.0.1:45086 Closing +[Thu Apr 23 13:36:46 2026] 127.0.0.1:45094 Accepted +[Thu Apr 23 13:36:52 2026] 127.0.0.1:45094 Closing +[Thu Apr 23 13:36:52 2026] 127.0.0.1:56896 Accepted +[Thu Apr 23 13:36:58 2026] 127.0.0.1:56896 Closing +[Thu Apr 23 13:36:58 2026] 127.0.0.1:56900 Accepted +[Thu Apr 23 13:37:04 2026] 127.0.0.1:56900 Closing +[Thu Apr 23 13:37:12 2026] 127.0.0.1:37902 Accepted +[Thu Apr 23 13:37:29 2026] 127.0.0.1:37902 Closing +[Thu Apr 23 13:37:37 2026] 127.0.0.1:36712 Accepted +[Thu Apr 23 13:37:39 2026] 127.0.0.1:36712 Closing +[Thu Apr 23 13:37:39 2026] 127.0.0.1:36726 Accepted +[Thu Apr 23 13:37:44 2026] 127.0.0.1:36726 Closing +[Thu Apr 23 13:37:44 2026] 127.0.0.1:41824 Accepted +[Thu Apr 23 13:37:49 2026] 127.0.0.1:41824 Closing +[Thu Apr 23 13:37:53 2026] 127.0.0.1:58128 Accepted +[Thu Apr 23 13:38:06 2026] 127.0.0.1:58128 Closing +[Thu Apr 23 13:38:06 2026] 127.0.0.1:49382 Accepted +[Thu Apr 23 13:38:22 2026] 127.0.0.1:49382 Closing +[Thu Apr 23 13:38:22 2026] 127.0.0.1:46132 Accepted +[Thu Apr 23 13:38:40 2026] 127.0.0.1:46132 Closing +[Thu Apr 23 13:38:40 2026] 127.0.0.1:59512 Accepted +[Thu Apr 23 13:38:54 2026] 127.0.0.1:59512 Closing +[Thu Apr 23 13:38:54 2026] 127.0.0.1:37532 Accepted +[Thu Apr 23 13:39:04 2026] 127.0.0.1:37532 Closing +[Thu Apr 23 13:39:04 2026] 127.0.0.1:36894 Accepted +[Thu Apr 23 13:39:09 2026] 127.0.0.1:36894 Closing +[Thu Apr 23 13:39:09 2026] 127.0.0.1:36898 Accepted +[Thu Apr 23 13:39:31 2026] 127.0.0.1:36898 Closing +[Thu Apr 23 13:39:31 2026] 127.0.0.1:54246 Accepted +[Thu Apr 23 13:39:38 2026] 127.0.0.1:54246 Closing +[Thu Apr 23 13:39:38 2026] 127.0.0.1:44452 Accepted +[Thu Apr 23 13:39:49 2026] 127.0.0.1:44452 Closing +[Thu Apr 23 13:39:49 2026] 127.0.0.1:38148 Accepted +[Thu Apr 23 13:40:14 2026] 127.0.0.1:38148 Closing +[Thu Apr 23 13:40:14 2026] 127.0.0.1:51142 Accepted +[Thu Apr 23 13:40:38 2026] 127.0.0.1:51142 Closing +[Thu Apr 23 13:40:38 2026] 127.0.0.1:50796 Accepted +[Thu Apr 23 13:41:03 2026] 127.0.0.1:50796 Closing +[Thu Apr 23 13:41:12 2026] 127.0.0.1:40144 Accepted +[Thu Apr 23 13:41:30 2026] 127.0.0.1:40144 Closing +[Thu Apr 23 13:41:39 2026] 127.0.0.1:36966 Accepted +[Thu Apr 23 13:41:44 2026] 127.0.0.1:36966 Closing +[Thu Apr 23 13:41:47 2026] 127.0.0.1:44634 Accepted +[Thu Apr 23 13:41:58 2026] 127.0.0.1:44634 Closing +[Thu Apr 23 13:41:58 2026] 127.0.0.1:49428 Accepted +[Thu Apr 23 13:42:07 2026] 127.0.0.1:49428 Closing +[Thu Apr 23 13:42:07 2026] 127.0.0.1:47016 Accepted +[Thu Apr 23 13:42:13 2026] 127.0.0.1:47016 Closing +[Thu Apr 23 13:42:13 2026] 127.0.0.1:59722 Accepted +[Thu Apr 23 13:42:19 2026] 127.0.0.1:59722 Closing +[Thu Apr 23 13:42:19 2026] 127.0.0.1:59736 Accepted +[Thu Apr 23 13:42:27 2026] 127.0.0.1:59736 Closing +[Thu Apr 23 13:42:37 2026] 127.0.0.1:46138 Accepted +[Thu Apr 23 13:43:02 2026] 127.0.0.1:46138 Closing +[Thu Apr 23 13:43:12 2026] 127.0.0.1:34988 Accepted +[Thu Apr 23 13:43:23 2026] 127.0.0.1:34988 Closing +[Thu Apr 23 13:43:24 2026] 127.0.0.1:32898 Accepted +[Thu Apr 23 13:43:28 2026] 127.0.0.1:32898 Closing +[Thu Apr 23 13:43:29 2026] 127.0.0.1:32914 Accepted +[Thu Apr 23 13:43:41 2026] 127.0.0.1:32914 Closing +[Thu Apr 23 13:49:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33321) started +[Thu Apr 23 13:49:57 2026] 127.0.0.1:57324 Accepted +[Thu Apr 23 13:49:58 2026] 127.0.0.1:57324 Closing +[Thu Apr 23 13:49:58 2026] 127.0.0.1:57328 Accepted +[Thu Apr 23 13:50:09 2026] 127.0.0.1:57328 Closing +[Thu Apr 23 13:50:09 2026] 127.0.0.1:38058 Accepted +[Thu Apr 23 13:50:14 2026] 127.0.0.1:38058 Closing +[Thu Apr 23 13:50:14 2026] 127.0.0.1:54116 Accepted +[Thu Apr 23 13:50:20 2026] 127.0.0.1:54116 Closing +[Thu Apr 23 13:50:20 2026] 127.0.0.1:54122 Accepted +[Thu Apr 23 13:50:45 2026] 127.0.0.1:54122 Closing +[Thu Apr 23 13:50:45 2026] 127.0.0.1:34002 Accepted +[Thu Apr 23 13:51:00 2026] 127.0.0.1:34002 Closing +[Thu Apr 23 13:51:09 2026] 127.0.0.1:52556 Accepted +[Thu Apr 23 13:51:40 2026] 127.0.0.1:52556 Closing +[Thu Apr 23 13:51:48 2026] 127.0.0.1:37352 Accepted +[Thu Apr 23 13:51:49 2026] 127.0.0.1:37352 Closing +[Thu Apr 23 13:51:49 2026] 127.0.0.1:37366 Accepted +[Thu Apr 23 13:51:55 2026] 127.0.0.1:37366 Closing +[Thu Apr 23 13:51:55 2026] 127.0.0.1:42030 Accepted +[Thu Apr 23 13:52:01 2026] 127.0.0.1:42030 Closing +[Thu Apr 23 13:52:05 2026] 127.0.0.1:33022 Accepted +[Thu Apr 23 13:52:12 2026] 127.0.0.1:33022 Closing +[Thu Apr 23 13:52:12 2026] 127.0.0.1:33038 Accepted +[Thu Apr 23 13:52:26 2026] 127.0.0.1:33038 Closing +[Thu Apr 23 13:52:26 2026] 127.0.0.1:52610 Accepted +[Thu Apr 23 13:52:44 2026] 127.0.0.1:52610 Closing +[Thu Apr 23 13:52:44 2026] 127.0.0.1:44490 Accepted +[Thu Apr 23 13:52:59 2026] 127.0.0.1:44490 Closing +[Thu Apr 23 13:52:59 2026] 127.0.0.1:33932 Accepted +[Thu Apr 23 13:53:09 2026] 127.0.0.1:33932 Closing +[Thu Apr 23 13:53:09 2026] 127.0.0.1:42262 Accepted +[Thu Apr 23 13:53:14 2026] 127.0.0.1:42262 Closing +[Thu Apr 23 13:53:14 2026] 127.0.0.1:41128 Accepted +[Thu Apr 23 13:53:31 2026] 127.0.0.1:41128 Closing +[Thu Apr 23 13:53:31 2026] 127.0.0.1:44278 Accepted +[Thu Apr 23 13:53:40 2026] 127.0.0.1:44278 Closing +[Thu Apr 23 13:53:40 2026] 127.0.0.1:59288 Accepted +[Thu Apr 23 13:53:51 2026] 127.0.0.1:59288 Closing +[Thu Apr 23 13:53:51 2026] 127.0.0.1:37824 Accepted +[Thu Apr 23 13:54:11 2026] 127.0.0.1:37824 Closing +[Thu Apr 23 13:54:11 2026] 127.0.0.1:45690 Accepted +[Thu Apr 23 13:54:37 2026] 127.0.0.1:45690 Closing +[Thu Apr 23 13:54:37 2026] 127.0.0.1:59870 Accepted +[Thu Apr 23 13:55:04 2026] 127.0.0.1:59870 Closing +[Thu Apr 23 13:55:14 2026] 127.0.0.1:36046 Accepted +[Thu Apr 23 13:55:44 2026] 127.0.0.1:36046 Closing +[Thu Apr 23 13:55:52 2026] 127.0.0.1:50370 Accepted +[Thu Apr 23 13:55:56 2026] 127.0.0.1:50370 Closing +[Thu Apr 23 13:55:59 2026] 127.0.0.1:42868 Accepted +[Thu Apr 23 13:56:10 2026] 127.0.0.1:42868 Closing +[Thu Apr 23 13:56:10 2026] 127.0.0.1:42960 Accepted +[Thu Apr 23 13:56:19 2026] 127.0.0.1:42960 Closing +[Thu Apr 23 13:56:19 2026] 127.0.0.1:49142 Accepted +[Thu Apr 23 13:56:26 2026] 127.0.0.1:49142 Closing +[Thu Apr 23 13:56:26 2026] 127.0.0.1:60266 Accepted +[Thu Apr 23 13:56:32 2026] 127.0.0.1:60266 Closing +[Thu Apr 23 13:56:32 2026] 127.0.0.1:60274 Accepted +[Thu Apr 23 13:56:39 2026] 127.0.0.1:60274 Closing +[Thu Apr 23 13:56:39 2026] 127.0.0.1:37534 Accepted +[Thu Apr 23 13:56:57 2026] 127.0.0.1:37534 Closing +[Thu Apr 23 13:56:57 2026] 127.0.0.1:50514 Accepted +[Thu Apr 23 13:57:05 2026] 127.0.0.1:50514 Closing +[Thu Apr 23 13:57:05 2026] 127.0.0.1:39372 Accepted +[Thu Apr 23 13:57:18 2026] 127.0.0.1:39372 Closing +[Thu Apr 23 13:57:18 2026] 127.0.0.1:60748 Accepted +[Thu Apr 23 13:57:31 2026] 127.0.0.1:60748 Closing +[Thu Apr 23 13:57:38 2026] 127.0.0.1:43116 Accepted +[Thu Apr 23 13:58:03 2026] 127.0.0.1:43116 Closing +[Thu Apr 23 13:58:03 2026] 127.0.0.1:58396 Accepted +[Thu Apr 23 13:58:25 2026] 127.0.0.1:58396 Closing +[Thu Apr 23 13:58:25 2026] 127.0.0.1:32982 Accepted +[Thu Apr 23 13:58:57 2026] 127.0.0.1:32982 Closing +[Thu Apr 23 13:59:09 2026] 127.0.0.1:52680 Accepted +[Thu Apr 23 13:59:23 2026] 127.0.0.1:52680 Closing +[Thu Apr 23 13:59:24 2026] 127.0.0.1:54144 Accepted +[Thu Apr 23 13:59:28 2026] 127.0.0.1:54144 Closing +[Thu Apr 23 13:59:29 2026] 127.0.0.1:54150 Accepted +[Thu Apr 23 13:59:44 2026] 127.0.0.1:54150 Closing +[Thu Apr 23 14:02:03 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45419) started +[Thu Apr 23 14:02:03 2026] 127.0.0.1:33592 Accepted +[Thu Apr 23 14:02:06 2026] 127.0.0.1:33592 Closing +[Thu Apr 23 14:02:06 2026] 127.0.0.1:33598 Accepted +[Thu Apr 23 14:02:37 2026] 127.0.0.1:33598 Closing +[Thu Apr 23 14:05:07 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45591) started +[Thu Apr 23 14:05:08 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40275) started +[Thu Apr 23 14:05:08 2026] 127.0.0.1:35182 Accepted +[Thu Apr 23 14:05:08 2026] 127.0.0.1:55298 Accepted +[Thu Apr 23 14:05:09 2026] 127.0.0.1:35182 Closing +[Thu Apr 23 14:05:09 2026] 127.0.0.1:35192 Accepted +[Thu Apr 23 14:05:10 2026] 127.0.0.1:55298 Closing +[Thu Apr 23 14:05:10 2026] 127.0.0.1:55310 Accepted +[Thu Apr 23 14:05:21 2026] 127.0.0.1:35192 Closing +[Thu Apr 23 14:05:21 2026] 127.0.0.1:44534 Accepted +[Thu Apr 23 14:05:41 2026] 127.0.0.1:44534 Closing +[Thu Apr 23 14:05:41 2026] 127.0.0.1:46654 Accepted +[Thu Apr 23 14:05:55 2026] 127.0.0.1:46654 Closing +[Thu Apr 23 14:05:57 2026] 127.0.0.1:55310 Closing +[Thu Apr 23 14:06:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37967) started +[Thu Apr 23 14:06:26 2026] 127.0.0.1:45162 Accepted +[Thu Apr 23 14:06:28 2026] 127.0.0.1:45162 Closing +[Thu Apr 23 14:06:28 2026] 127.0.0.1:45174 Accepted +[Thu Apr 23 14:06:39 2026] 127.0.0.1:45174 Closing +[Thu Apr 23 14:06:39 2026] 127.0.0.1:46240 Accepted +[Thu Apr 23 14:06:54 2026] 127.0.0.1:46240 Closing +[Thu Apr 23 14:06:55 2026] 127.0.0.1:34928 Accepted +[Thu Apr 23 14:07:02 2026] 127.0.0.1:34928 Closing +[Thu Apr 23 14:08:37 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39635) started +[Thu Apr 23 14:08:37 2026] 127.0.0.1:35254 Accepted +[Thu Apr 23 14:08:39 2026] 127.0.0.1:35254 Closing +[Thu Apr 23 14:08:39 2026] 127.0.0.1:35268 Accepted +[Thu Apr 23 14:08:50 2026] 127.0.0.1:35268 Closing +[Thu Apr 23 14:08:50 2026] 127.0.0.1:53316 Accepted +[Thu Apr 23 14:09:10 2026] 127.0.0.1:53316 Closing +[Thu Apr 23 14:10:04 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35391) started +[Thu Apr 23 14:10:04 2026] 127.0.0.1:49938 Accepted +[Thu Apr 23 14:10:06 2026] 127.0.0.1:49938 Closing +[Thu Apr 23 14:10:06 2026] 127.0.0.1:49948 Accepted +[Thu Apr 23 14:10:17 2026] 127.0.0.1:49948 Closing +[Thu Apr 23 14:10:17 2026] 127.0.0.1:59102 Accepted +[Thu Apr 23 14:10:44 2026] 127.0.0.1:59102 Closing +[Thu Apr 23 14:10:45 2026] 127.0.0.1:37722 Accepted +[Thu Apr 23 14:10:53 2026] 127.0.0.1:37722 Closing +[Thu Apr 23 14:11:53 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38927) started +[Thu Apr 23 14:11:53 2026] 127.0.0.1:58010 Accepted +[Thu Apr 23 14:11:55 2026] 127.0.0.1:58010 Closing +[Thu Apr 23 14:11:55 2026] 127.0.0.1:58022 Accepted +[Thu Apr 23 14:12:06 2026] 127.0.0.1:58022 Closing +[Thu Apr 23 14:12:06 2026] 127.0.0.1:56360 Accepted +[Thu Apr 23 14:12:26 2026] 127.0.0.1:56360 Closing +[Thu Apr 23 14:12:52 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35653) started +[Thu Apr 23 14:12:52 2026] 127.0.0.1:35216 Accepted +[Thu Apr 23 14:12:54 2026] 127.0.0.1:35216 Closing +[Thu Apr 23 14:12:54 2026] 127.0.0.1:35220 Accepted +[Thu Apr 23 14:13:05 2026] 127.0.0.1:35220 Closing +[Thu Apr 23 14:13:05 2026] 127.0.0.1:53396 Accepted +[Thu Apr 23 14:13:24 2026] 127.0.0.1:53396 Closing +[Thu Apr 23 14:13:25 2026] 127.0.0.1:48076 Accepted +[Thu Apr 23 14:13:33 2026] 127.0.0.1:48076 Closing +[Thu Apr 23 14:15:08 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34531) started +[Thu Apr 23 14:15:08 2026] 127.0.0.1:38366 Accepted +[Thu Apr 23 14:15:10 2026] 127.0.0.1:38366 Closing +[Thu Apr 23 14:15:10 2026] 127.0.0.1:38378 Accepted +[Thu Apr 23 14:15:20 2026] 127.0.0.1:38378 Closing +[Thu Apr 23 14:15:20 2026] 127.0.0.1:53588 Accepted +[Thu Apr 23 14:15:28 2026] 127.0.0.1:53588 Closing +[Thu Apr 23 14:15:28 2026] 127.0.0.1:55382 Accepted +[Thu Apr 23 14:15:33 2026] 127.0.0.1:55382 Closing +[Thu Apr 23 14:15:33 2026] 127.0.0.1:34396 Accepted +[Thu Apr 23 14:15:54 2026] 127.0.0.1:34396 Closing +[Thu Apr 23 14:15:54 2026] 127.0.0.1:48612 Accepted +[Thu Apr 23 14:16:03 2026] 127.0.0.1:48612 Closing +[Thu Apr 23 14:16:03 2026] 127.0.0.1:51052 Accepted +[Thu Apr 23 14:16:11 2026] 127.0.0.1:51052 Closing +[Thu Apr 23 14:16:11 2026] 127.0.0.1:51056 Accepted +[Thu Apr 23 14:16:31 2026] 127.0.0.1:51056 Closing +[Thu Apr 23 14:16:31 2026] 127.0.0.1:39744 Accepted +[Thu Apr 23 14:16:40 2026] 127.0.0.1:39744 Closing +[Thu Apr 23 14:16:48 2026] 127.0.0.1:42566 Accepted +[Thu Apr 23 14:17:27 2026] 127.0.0.1:42566 Closing +[Thu Apr 23 14:17:27 2026] 127.0.0.1:47070 Accepted +[Thu Apr 23 14:18:25 2026] 127.0.0.1:47070 Closing +[Thu Apr 23 14:18:33 2026] 127.0.0.1:44700 Accepted +[Thu Apr 23 14:18:34 2026] 127.0.0.1:44700 Closing +[Thu Apr 23 14:18:34 2026] 127.0.0.1:44710 Accepted +[Thu Apr 23 14:18:39 2026] 127.0.0.1:44710 Closing +[Thu Apr 23 14:18:39 2026] 127.0.0.1:44716 Accepted +[Thu Apr 23 14:18:44 2026] 127.0.0.1:44716 Closing +[Thu Apr 23 14:18:48 2026] 127.0.0.1:59618 Accepted +[Thu Apr 23 14:18:55 2026] 127.0.0.1:59618 Closing +[Thu Apr 23 14:18:55 2026] 127.0.0.1:54692 Accepted +[Thu Apr 23 14:19:06 2026] 127.0.0.1:54692 Closing +[Thu Apr 23 14:19:06 2026] 127.0.0.1:56044 Accepted +[Thu Apr 23 14:19:24 2026] 127.0.0.1:56044 Closing +[Thu Apr 23 14:19:24 2026] 127.0.0.1:35210 Accepted +[Thu Apr 23 14:19:38 2026] 127.0.0.1:35210 Closing +[Thu Apr 23 14:19:38 2026] 127.0.0.1:46426 Accepted +[Thu Apr 23 14:19:49 2026] 127.0.0.1:46426 Closing +[Thu Apr 23 14:19:49 2026] 127.0.0.1:36636 Accepted +[Thu Apr 23 14:19:54 2026] 127.0.0.1:36636 Closing +[Thu Apr 23 14:19:54 2026] 127.0.0.1:51396 Accepted +[Thu Apr 23 14:20:10 2026] 127.0.0.1:51396 Closing +[Thu Apr 23 14:20:10 2026] 127.0.0.1:44200 Accepted +[Thu Apr 23 14:20:17 2026] 127.0.0.1:44200 Closing +[Thu Apr 23 14:20:17 2026] 127.0.0.1:58140 Accepted +[Thu Apr 23 14:20:27 2026] 127.0.0.1:58140 Closing +[Thu Apr 23 14:20:27 2026] 127.0.0.1:52568 Accepted +[Thu Apr 23 14:20:47 2026] 127.0.0.1:52568 Closing +[Thu Apr 23 14:20:47 2026] 127.0.0.1:41180 Accepted +[Thu Apr 23 14:21:09 2026] 127.0.0.1:41180 Closing +[Thu Apr 23 14:21:09 2026] 127.0.0.1:56896 Accepted +[Thu Apr 23 14:21:25 2026] 127.0.0.1:56896 Closing +[Thu Apr 23 14:21:34 2026] 127.0.0.1:34240 Accepted +[Thu Apr 23 14:22:08 2026] 127.0.0.1:34240 Closing +[Thu Apr 23 14:22:08 2026] 127.0.0.1:45122 Accepted +[Thu Apr 23 14:23:04 2026] 127.0.0.1:45122 Closing +[Thu Apr 23 14:23:12 2026] 127.0.0.1:59068 Accepted +[Thu Apr 23 14:23:17 2026] 127.0.0.1:59068 Closing +[Thu Apr 23 14:23:20 2026] 127.0.0.1:59082 Accepted +[Thu Apr 23 14:23:31 2026] 127.0.0.1:59082 Closing +[Thu Apr 23 14:23:31 2026] 127.0.0.1:36746 Accepted +[Thu Apr 23 14:23:40 2026] 127.0.0.1:36746 Closing +[Thu Apr 23 14:23:40 2026] 127.0.0.1:57316 Accepted +[Thu Apr 23 14:23:46 2026] 127.0.0.1:57316 Closing +[Thu Apr 23 14:23:46 2026] 127.0.0.1:41418 Accepted +[Thu Apr 23 14:23:52 2026] 127.0.0.1:41418 Closing +[Thu Apr 23 14:23:52 2026] 127.0.0.1:59298 Accepted +[Thu Apr 23 14:24:00 2026] 127.0.0.1:59298 Closing +[Thu Apr 23 14:24:00 2026] 127.0.0.1:59302 Accepted +[Thu Apr 23 14:24:20 2026] 127.0.0.1:59302 Closing +[Thu Apr 23 14:24:20 2026] 127.0.0.1:59050 Accepted +[Thu Apr 23 14:24:29 2026] 127.0.0.1:59050 Closing +[Thu Apr 23 14:24:29 2026] 127.0.0.1:37050 Accepted +[Thu Apr 23 14:24:43 2026] 127.0.0.1:37050 Closing +[Thu Apr 23 14:24:43 2026] 127.0.0.1:39184 Accepted +[Thu Apr 23 14:24:51 2026] 127.0.0.1:39184 Closing +[Thu Apr 23 14:25:00 2026] 127.0.0.1:42318 Accepted +[Thu Apr 23 14:25:27 2026] 127.0.0.1:42318 Closing +[Thu Apr 23 14:25:27 2026] 127.0.0.1:48952 Accepted +[Thu Apr 23 14:26:04 2026] 127.0.0.1:48952 Closing +[Thu Apr 23 14:26:04 2026] 127.0.0.1:48378 Accepted +[Thu Apr 23 14:26:43 2026] 127.0.0.1:48378 Closing +[Thu Apr 23 14:26:43 2026] 127.0.0.1:46482 Accepted +[Thu Apr 23 14:27:09 2026] 127.0.0.1:46482 Closing +[Thu Apr 23 14:27:09 2026] 127.0.0.1:50382 Accepted +[Thu Apr 23 14:27:31 2026] 127.0.0.1:50382 Closing +[Thu Apr 23 14:27:31 2026] 127.0.0.1:53126 Accepted +[Thu Apr 23 14:28:17 2026] 127.0.0.1:53126 Closing +[Thu Apr 23 14:28:17 2026] 127.0.0.1:49718 Accepted +[Thu Apr 23 14:28:25 2026] 127.0.0.1:49718 Closing +[Thu Apr 23 14:28:25 2026] 127.0.0.1:37870 Accepted +[Thu Apr 23 14:28:54 2026] 127.0.0.1:37870 Closing +[Thu Apr 23 14:28:54 2026] 127.0.0.1:51040 Accepted +[Thu Apr 23 14:29:40 2026] 127.0.0.1:51040 Closing +[Thu Apr 23 14:29:40 2026] 127.0.0.1:40370 Accepted +[Thu Apr 23 14:29:50 2026] 127.0.0.1:40370 Closing +[Thu Apr 23 14:29:50 2026] 127.0.0.1:48908 Accepted +[Thu Apr 23 14:30:16 2026] 127.0.0.1:48908 Closing +[Thu Apr 23 14:30:27 2026] 127.0.0.1:40420 Accepted +[Thu Apr 23 14:30:40 2026] 127.0.0.1:40420 Closing +[Thu Apr 23 14:30:41 2026] 127.0.0.1:43904 Accepted +[Thu Apr 23 14:30:45 2026] 127.0.0.1:43904 Closing +[Thu Apr 23 14:30:46 2026] 127.0.0.1:60520 Accepted +[Thu Apr 23 14:30:57 2026] 127.0.0.1:60520 Closing +[Thu Apr 23 14:33:20 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44673) started +[Thu Apr 23 14:33:21 2026] 127.0.0.1:51140 Accepted +[Thu Apr 23 14:33:22 2026] 127.0.0.1:51140 Closing +[Thu Apr 23 14:33:22 2026] 127.0.0.1:48778 Accepted +[Thu Apr 23 14:34:01 2026] 127.0.0.1:48778 Closing +[Thu Apr 23 14:34:01 2026] 127.0.0.1:48060 Accepted +[Thu Apr 23 14:34:33 2026] 127.0.0.1:48060 Closing +[Thu Apr 23 14:36:05 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37279) started +[Thu Apr 23 14:36:06 2026] 127.0.0.1:54914 Accepted +[Thu Apr 23 14:36:07 2026] 127.0.0.1:54914 Closing +[Thu Apr 23 14:36:07 2026] 127.0.0.1:54922 Accepted +[Thu Apr 23 14:36:18 2026] 127.0.0.1:54922 Closing +[Thu Apr 23 14:36:18 2026] 127.0.0.1:51316 Accepted +[Thu Apr 23 14:36:24 2026] 127.0.0.1:51316 Closing +[Thu Apr 23 14:36:24 2026] 127.0.0.1:38502 Accepted +[Thu Apr 23 14:36:29 2026] 127.0.0.1:38502 Closing +[Thu Apr 23 14:36:29 2026] 127.0.0.1:38506 Accepted +[Thu Apr 23 14:36:50 2026] 127.0.0.1:38506 Closing +[Thu Apr 23 14:36:51 2026] 127.0.0.1:46144 Accepted +[Thu Apr 23 14:36:58 2026] 127.0.0.1:46144 Closing +[Thu Apr 23 14:36:59 2026] 127.0.0.1:52248 Accepted +[Thu Apr 23 14:37:09 2026] 127.0.0.1:52248 Closing +[Thu Apr 23 14:37:09 2026] 127.0.0.1:48504 Accepted +[Thu Apr 23 14:37:29 2026] 127.0.0.1:48504 Closing +[Thu Apr 23 14:37:29 2026] 127.0.0.1:38458 Accepted +[Thu Apr 23 14:37:37 2026] 127.0.0.1:38458 Closing +[Thu Apr 23 14:37:45 2026] 127.0.0.1:52134 Accepted +[Thu Apr 23 14:38:16 2026] 127.0.0.1:52134 Closing +[Thu Apr 23 14:38:16 2026] 127.0.0.1:53408 Accepted +[Thu Apr 23 14:38:46 2026] 127.0.0.1:53408 Closing +[Thu Apr 23 14:38:46 2026] 127.0.0.1:39006 Accepted +[Thu Apr 23 14:39:14 2026] 127.0.0.1:39006 Closing +[Thu Apr 23 14:39:14 2026] 127.0.0.1:57264 Accepted +[Thu Apr 23 14:39:46 2026] 127.0.0.1:57264 Closing +[Thu Apr 23 14:39:46 2026] 127.0.0.1:35558 Accepted +[Thu Apr 23 14:39:52 2026] 127.0.0.1:35558 Closing +[Thu Apr 23 14:40:01 2026] 127.0.0.1:44762 Accepted +[Thu Apr 23 14:40:03 2026] 127.0.0.1:44762 Closing +[Thu Apr 23 14:40:03 2026] 127.0.0.1:57772 Accepted +[Thu Apr 23 14:40:08 2026] 127.0.0.1:57772 Closing +[Thu Apr 23 14:40:08 2026] 127.0.0.1:57782 Accepted +[Thu Apr 23 14:40:15 2026] 127.0.0.1:57782 Closing +[Thu Apr 23 14:40:18 2026] 127.0.0.1:47612 Accepted +[Thu Apr 23 14:40:25 2026] 127.0.0.1:47612 Closing +[Thu Apr 23 14:40:25 2026] 127.0.0.1:44892 Accepted +[Thu Apr 23 14:40:42 2026] 127.0.0.1:44892 Closing +[Thu Apr 23 14:40:42 2026] 127.0.0.1:41338 Accepted +[Thu Apr 23 14:40:58 2026] 127.0.0.1:41338 Closing +[Thu Apr 23 14:40:58 2026] 127.0.0.1:49090 Accepted +[Thu Apr 23 14:41:13 2026] 127.0.0.1:49090 Closing +[Thu Apr 23 14:41:13 2026] 127.0.0.1:43124 Accepted +[Thu Apr 23 14:41:25 2026] 127.0.0.1:43124 Closing +[Thu Apr 23 14:41:25 2026] 127.0.0.1:41382 Accepted +[Thu Apr 23 14:41:31 2026] 127.0.0.1:41382 Closing +[Thu Apr 23 14:41:31 2026] 127.0.0.1:41390 Accepted +[Thu Apr 23 14:41:49 2026] 127.0.0.1:41390 Closing +[Thu Apr 23 14:41:49 2026] 127.0.0.1:50602 Accepted +[Thu Apr 23 14:42:00 2026] 127.0.0.1:50602 Closing +[Thu Apr 23 14:42:00 2026] 127.0.0.1:35128 Accepted +[Thu Apr 23 14:42:10 2026] 127.0.0.1:35128 Closing +[Thu Apr 23 14:42:10 2026] 127.0.0.1:55744 Accepted +[Thu Apr 23 14:42:29 2026] 127.0.0.1:55744 Closing +[Thu Apr 23 14:42:29 2026] 127.0.0.1:35028 Accepted +[Thu Apr 23 14:42:58 2026] 127.0.0.1:35028 Closing +[Thu Apr 23 14:42:58 2026] 127.0.0.1:59852 Accepted +[Thu Apr 23 14:43:14 2026] 127.0.0.1:59852 Closing +[Thu Apr 23 14:43:23 2026] 127.0.0.1:44682 Accepted +[Thu Apr 23 14:43:55 2026] 127.0.0.1:44682 Closing +[Thu Apr 23 14:43:55 2026] 127.0.0.1:47170 Accepted +[Thu Apr 23 14:44:25 2026] 127.0.0.1:47170 Closing +[Thu Apr 23 14:44:25 2026] 127.0.0.1:40422 Accepted +[Thu Apr 23 14:44:57 2026] 127.0.0.1:40422 Closing +[Thu Apr 23 14:44:57 2026] 127.0.0.1:33924 Accepted +[Thu Apr 23 14:45:33 2026] 127.0.0.1:33924 Closing +[Thu Apr 23 14:45:33 2026] 127.0.0.1:38624 Accepted +[Thu Apr 23 14:45:48 2026] 127.0.0.1:38624 Closing +[Thu Apr 23 14:45:48 2026] 127.0.0.1:37586 Accepted +[Thu Apr 23 14:46:01 2026] 127.0.0.1:37586 Closing +[Thu Apr 23 14:46:01 2026] 127.0.0.1:52042 Accepted +[Thu Apr 23 14:46:49 2026] 127.0.0.1:52042 Closing +[Thu Apr 23 14:46:57 2026] 127.0.0.1:40174 Accepted +[Thu Apr 23 14:47:03 2026] 127.0.0.1:40174 Closing +[Thu Apr 23 14:47:06 2026] 127.0.0.1:51816 Accepted +[Thu Apr 23 14:47:18 2026] 127.0.0.1:51816 Closing +[Thu Apr 23 14:47:18 2026] 127.0.0.1:52972 Accepted +[Thu Apr 23 14:47:27 2026] 127.0.0.1:52972 Closing +[Thu Apr 23 14:47:27 2026] 127.0.0.1:52146 Accepted +[Thu Apr 23 14:47:33 2026] 127.0.0.1:52146 Closing +[Thu Apr 23 14:47:33 2026] 127.0.0.1:51832 Accepted +[Thu Apr 23 14:47:40 2026] 127.0.0.1:51832 Closing +[Thu Apr 23 14:47:40 2026] 127.0.0.1:51834 Accepted +[Thu Apr 23 14:47:51 2026] 127.0.0.1:51834 Closing +[Thu Apr 23 14:47:51 2026] 127.0.0.1:60714 Accepted +[Thu Apr 23 14:48:11 2026] 127.0.0.1:60714 Closing +[Thu Apr 23 14:48:11 2026] 127.0.0.1:35296 Accepted +[Thu Apr 23 14:48:19 2026] 127.0.0.1:35296 Closing +[Thu Apr 23 14:48:19 2026] 127.0.0.1:46974 Accepted +[Thu Apr 23 14:48:34 2026] 127.0.0.1:46974 Closing +[Thu Apr 23 14:48:34 2026] 127.0.0.1:51458 Accepted +[Thu Apr 23 14:48:42 2026] 127.0.0.1:51458 Closing +[Thu Apr 23 14:48:50 2026] 127.0.0.1:48894 Accepted +[Thu Apr 23 14:49:19 2026] 127.0.0.1:48894 Closing +[Thu Apr 23 14:49:19 2026] 127.0.0.1:36126 Accepted +[Thu Apr 23 14:49:57 2026] 127.0.0.1:36126 Closing +[Thu Apr 23 14:49:57 2026] 127.0.0.1:54916 Accepted +[Thu Apr 23 14:50:54 2026] 127.0.0.1:54916 Closing +[Thu Apr 23 14:50:54 2026] 127.0.0.1:34690 Accepted +[Thu Apr 23 14:51:17 2026] 127.0.0.1:34690 Closing +[Thu Apr 23 14:51:17 2026] 127.0.0.1:50256 Accepted +[Thu Apr 23 14:51:40 2026] 127.0.0.1:50256 Closing +[Thu Apr 23 14:51:40 2026] 127.0.0.1:35250 Accepted +[Thu Apr 23 14:52:41 2026] 127.0.0.1:35250 Closing +[Thu Apr 23 14:52:41 2026] 127.0.0.1:33290 Accepted +[Thu Apr 23 14:53:05 2026] 127.0.0.1:33290 Closing +[Thu Apr 23 14:53:05 2026] 127.0.0.1:56204 Accepted +[Thu Apr 23 14:53:35 2026] 127.0.0.1:56204 Closing +[Thu Apr 23 14:53:35 2026] 127.0.0.1:33938 Accepted +[Thu Apr 23 14:54:21 2026] 127.0.0.1:33938 Closing +[Thu Apr 23 14:54:21 2026] 127.0.0.1:55270 Accepted +[Thu Apr 23 14:54:32 2026] 127.0.0.1:55270 Closing +[Thu Apr 23 14:54:32 2026] 127.0.0.1:39582 Accepted +[Thu Apr 23 14:54:59 2026] 127.0.0.1:39582 Closing +[Thu Apr 23 14:55:09 2026] 127.0.0.1:50808 Accepted +[Thu Apr 23 14:55:19 2026] 127.0.0.1:50808 Closing +[Thu Apr 23 14:55:20 2026] 127.0.0.1:45048 Accepted +[Thu Apr 23 14:55:24 2026] 127.0.0.1:45048 Closing +[Thu Apr 23 14:55:24 2026] 127.0.0.1:33870 Accepted +[Thu Apr 23 14:55:34 2026] 127.0.0.1:33870 Closing +[Thu Apr 23 14:56:38 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41661) started +[Thu Apr 23 14:56:38 2026] 127.0.0.1:35020 Accepted +[Thu Apr 23 14:56:40 2026] 127.0.0.1:35020 Closing +[Thu Apr 23 14:56:40 2026] 127.0.0.1:35034 Accepted +[Thu Apr 23 14:56:46 2026] 127.0.0.1:35034 Closing +[Thu Apr 23 14:57:21 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39709) started +[Thu Apr 23 14:57:21 2026] 127.0.0.1:46828 Accepted +[Thu Apr 23 14:57:23 2026] 127.0.0.1:46828 Closing +[Thu Apr 23 14:57:23 2026] 127.0.0.1:35284 Accepted +[Thu Apr 23 14:57:31 2026] 127.0.0.1:35284 Closing +[Thu Apr 23 14:58:01 2026] PHP 8.2.15 Development Server (http://127.0.0.1:36173) started +[Thu Apr 23 14:58:02 2026] 127.0.0.1:46498 Accepted +[Thu Apr 23 14:58:03 2026] 127.0.0.1:46498 Closing +[Thu Apr 23 14:58:03 2026] 127.0.0.1:57220 Accepted +[Thu Apr 23 14:58:14 2026] 127.0.0.1:57220 Closing +[Thu Apr 23 14:58:14 2026] 127.0.0.1:49246 Accepted +[Thu Apr 23 14:58:25 2026] 127.0.0.1:49246 Closing +[Thu Apr 23 14:58:25 2026] 127.0.0.1:41236 Accepted +[Thu Apr 23 14:58:30 2026] 127.0.0.1:41236 Closing +[Thu Apr 23 14:58:30 2026] 127.0.0.1:41240 Accepted +[Thu Apr 23 14:58:50 2026] 127.0.0.1:41240 Closing +[Thu Apr 23 14:58:51 2026] 127.0.0.1:45464 Accepted +[Thu Apr 23 14:59:08 2026] 127.0.0.1:45464 Closing +[Thu Apr 23 14:59:09 2026] 127.0.0.1:55112 Accepted +[Thu Apr 23 14:59:23 2026] 127.0.0.1:55112 Closing +[Thu Apr 23 14:59:23 2026] 127.0.0.1:58932 Accepted +[Thu Apr 23 14:59:44 2026] 127.0.0.1:58932 Closing +[Thu Apr 23 14:59:44 2026] 127.0.0.1:43762 Accepted +[Thu Apr 23 14:59:58 2026] 127.0.0.1:43762 Closing +[Thu Apr 23 15:00:06 2026] 127.0.0.1:48048 Accepted +[Thu Apr 23 15:00:58 2026] 127.0.0.1:48048 Closing +[Thu Apr 23 15:00:58 2026] 127.0.0.1:53516 Accepted +[Thu Apr 23 15:01:30 2026] 127.0.0.1:53516 Closing +[Thu Apr 23 15:01:30 2026] 127.0.0.1:39228 Accepted +[Thu Apr 23 15:01:58 2026] 127.0.0.1:39228 Closing +[Thu Apr 23 15:01:58 2026] 127.0.0.1:37410 Accepted +[Thu Apr 23 15:02:29 2026] 127.0.0.1:37410 Closing +[Thu Apr 23 15:02:29 2026] 127.0.0.1:39422 Accepted +[Thu Apr 23 15:02:38 2026] 127.0.0.1:39422 Closing +[Thu Apr 23 15:02:38 2026] 127.0.0.1:38072 Accepted +[Thu Apr 23 15:03:00 2026] 127.0.0.1:38072 Closing +[Thu Apr 23 15:03:00 2026] 127.0.0.1:33248 Accepted +[Thu Apr 23 15:03:21 2026] 127.0.0.1:33248 Closing +[Thu Apr 23 15:03:21 2026] 127.0.0.1:45124 Accepted +[Thu Apr 23 15:03:54 2026] 127.0.0.1:45124 Closing +[Thu Apr 23 15:04:01 2026] 127.0.0.1:59100 Accepted +[Thu Apr 23 15:04:02 2026] 127.0.0.1:59100 Closing +[Thu Apr 23 15:04:02 2026] 127.0.0.1:38364 Accepted +[Thu Apr 23 15:04:07 2026] 127.0.0.1:38364 Closing +[Thu Apr 23 15:04:07 2026] 127.0.0.1:38374 Accepted +[Thu Apr 23 15:04:13 2026] 127.0.0.1:38374 Closing +[Thu Apr 23 15:06:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46451) started +[Thu Apr 23 15:06:50 2026] 127.0.0.1:42846 Accepted +[Thu Apr 23 15:06:52 2026] 127.0.0.1:42846 Closing +[Thu Apr 23 15:06:52 2026] 127.0.0.1:51552 Accepted +[Thu Apr 23 15:07:02 2026] 127.0.0.1:51552 Closing +[Thu Apr 23 15:07:02 2026] 127.0.0.1:60580 Accepted +[Thu Apr 23 15:07:08 2026] 127.0.0.1:60580 Closing +[Thu Apr 23 15:07:08 2026] 127.0.0.1:60590 Accepted +[Thu Apr 23 15:07:13 2026] 127.0.0.1:60590 Closing +[Thu Apr 23 15:07:13 2026] 127.0.0.1:41284 Accepted +[Thu Apr 23 15:07:35 2026] 127.0.0.1:41284 Closing +[Thu Apr 23 15:07:36 2026] 127.0.0.1:60252 Accepted +[Thu Apr 23 15:07:50 2026] 127.0.0.1:60252 Closing +[Thu Apr 23 15:07:51 2026] 127.0.0.1:49642 Accepted +[Thu Apr 23 15:08:04 2026] 127.0.0.1:49642 Closing +[Thu Apr 23 15:08:04 2026] 127.0.0.1:54206 Accepted +[Thu Apr 23 15:08:23 2026] 127.0.0.1:54206 Closing +[Thu Apr 23 15:08:23 2026] 127.0.0.1:45716 Accepted +[Thu Apr 23 15:08:38 2026] 127.0.0.1:45716 Closing +[Thu Apr 23 15:08:46 2026] 127.0.0.1:43532 Accepted +[Thu Apr 23 15:09:35 2026] 127.0.0.1:43532 Closing +[Thu Apr 23 15:09:35 2026] 127.0.0.1:56678 Accepted +[Thu Apr 23 15:10:07 2026] 127.0.0.1:56678 Closing +[Thu Apr 23 15:10:07 2026] 127.0.0.1:58178 Accepted +[Thu Apr 23 15:10:35 2026] 127.0.0.1:58178 Closing +[Thu Apr 23 15:10:35 2026] 127.0.0.1:34518 Accepted +[Thu Apr 23 15:11:08 2026] 127.0.0.1:34518 Closing +[Thu Apr 23 15:11:08 2026] 127.0.0.1:50516 Accepted +[Thu Apr 23 15:11:15 2026] 127.0.0.1:50516 Closing +[Thu Apr 23 15:11:15 2026] 127.0.0.1:38594 Accepted +[Thu Apr 23 15:11:38 2026] 127.0.0.1:38594 Closing +[Thu Apr 23 15:11:38 2026] 127.0.0.1:46710 Accepted +[Thu Apr 23 15:12:00 2026] 127.0.0.1:46710 Closing +[Thu Apr 23 15:12:00 2026] 127.0.0.1:45358 Accepted +[Thu Apr 23 15:12:31 2026] 127.0.0.1:45358 Closing +[Thu Apr 23 15:12:39 2026] 127.0.0.1:45026 Accepted +[Thu Apr 23 15:12:40 2026] 127.0.0.1:45026 Closing +[Thu Apr 23 15:12:40 2026] 127.0.0.1:45036 Accepted +[Thu Apr 23 15:12:46 2026] 127.0.0.1:45036 Closing +[Thu Apr 23 15:12:46 2026] 127.0.0.1:45238 Accepted +[Thu Apr 23 15:12:52 2026] 127.0.0.1:45238 Closing +[Thu Apr 23 15:12:55 2026] 127.0.0.1:48196 Accepted +[Thu Apr 23 15:13:01 2026] 127.0.0.1:48196 Closing +[Thu Apr 23 15:13:01 2026] 127.0.0.1:48212 Accepted +[Thu Apr 23 15:13:14 2026] 127.0.0.1:48212 Closing +[Thu Apr 23 15:13:14 2026] 127.0.0.1:43894 Accepted +[Thu Apr 23 15:13:31 2026] 127.0.0.1:43894 Closing +[Thu Apr 23 15:13:31 2026] 127.0.0.1:34974 Accepted +[Thu Apr 23 15:13:52 2026] 127.0.0.1:34974 Closing +[Thu Apr 23 15:13:52 2026] 127.0.0.1:44712 Accepted +[Thu Apr 23 15:14:09 2026] 127.0.0.1:44712 Closing +[Thu Apr 23 15:14:09 2026] 127.0.0.1:41550 Accepted +[Thu Apr 23 15:14:14 2026] 127.0.0.1:41550 Closing +[Thu Apr 23 15:14:14 2026] 127.0.0.1:42158 Accepted +[Thu Apr 23 15:14:37 2026] 127.0.0.1:42158 Closing +[Thu Apr 23 15:14:37 2026] 127.0.0.1:38470 Accepted +[Thu Apr 23 15:14:44 2026] 127.0.0.1:38470 Closing +[Thu Apr 23 15:14:44 2026] 127.0.0.1:49530 Accepted +[Thu Apr 23 15:14:54 2026] 127.0.0.1:49530 Closing +[Thu Apr 23 15:14:54 2026] 127.0.0.1:47804 Accepted +[Thu Apr 23 15:15:12 2026] 127.0.0.1:47804 Closing +[Thu Apr 23 15:15:12 2026] 127.0.0.1:35708 Accepted +[Thu Apr 23 15:15:40 2026] 127.0.0.1:35708 Closing +[Thu Apr 23 15:15:40 2026] 127.0.0.1:35648 Accepted +[Thu Apr 23 15:15:58 2026] 127.0.0.1:35648 Closing +[Thu Apr 23 15:16:07 2026] 127.0.0.1:45850 Accepted +[Thu Apr 23 15:17:00 2026] 127.0.0.1:45850 Closing +[Thu Apr 23 15:17:00 2026] 127.0.0.1:60996 Accepted +[Thu Apr 23 15:17:31 2026] 127.0.0.1:60996 Closing +[Thu Apr 23 15:17:31 2026] 127.0.0.1:58646 Accepted +[Thu Apr 23 15:18:01 2026] 127.0.0.1:58646 Closing +[Thu Apr 23 15:18:01 2026] 127.0.0.1:41492 Accepted +[Thu Apr 23 15:18:36 2026] 127.0.0.1:41492 Closing +[Thu Apr 23 15:18:36 2026] 127.0.0.1:34770 Accepted +[Thu Apr 23 15:19:03 2026] 127.0.0.1:34770 Closing +[Thu Apr 23 15:19:03 2026] 127.0.0.1:50524 Accepted +[Thu Apr 23 15:19:33 2026] 127.0.0.1:50524 Closing +[Thu Apr 23 15:19:33 2026] 127.0.0.1:38658 Accepted +[Thu Apr 23 15:20:17 2026] 127.0.0.1:38658 Closing +[Thu Apr 23 15:20:25 2026] 127.0.0.1:38576 Accepted +[Thu Apr 23 15:20:30 2026] 127.0.0.1:38576 Closing +[Thu Apr 23 15:20:34 2026] 127.0.0.1:60852 Accepted +[Thu Apr 23 15:20:45 2026] 127.0.0.1:60852 Closing +[Thu Apr 23 15:20:45 2026] 127.0.0.1:38058 Accepted +[Thu Apr 23 15:20:55 2026] 127.0.0.1:38058 Closing +[Thu Apr 23 15:20:55 2026] 127.0.0.1:59274 Accepted +[Thu Apr 23 15:21:01 2026] 127.0.0.1:59274 Closing +[Thu Apr 23 15:21:01 2026] 127.0.0.1:59286 Accepted +[Thu Apr 23 15:21:08 2026] 127.0.0.1:59286 Closing +[Thu Apr 23 15:21:08 2026] 127.0.0.1:37816 Accepted +[Thu Apr 23 15:21:16 2026] 127.0.0.1:37816 Closing +[Thu Apr 23 15:21:16 2026] 127.0.0.1:41436 Accepted +[Thu Apr 23 15:21:31 2026] 127.0.0.1:41436 Closing +[Thu Apr 23 15:21:31 2026] 127.0.0.1:57868 Accepted +[Thu Apr 23 15:21:41 2026] 127.0.0.1:57868 Closing +[Thu Apr 23 15:21:41 2026] 127.0.0.1:50932 Accepted +[Thu Apr 23 15:21:55 2026] 127.0.0.1:50932 Closing +[Thu Apr 23 15:21:55 2026] 127.0.0.1:43194 Accepted +[Thu Apr 23 15:22:08 2026] 127.0.0.1:43194 Closing +[Thu Apr 23 15:22:16 2026] 127.0.0.1:59712 Accepted +[Thu Apr 23 15:22:45 2026] 127.0.0.1:59712 Closing +[Thu Apr 23 15:22:45 2026] 127.0.0.1:41102 Accepted +[Thu Apr 23 15:23:23 2026] 127.0.0.1:41102 Closing +[Thu Apr 23 15:23:23 2026] 127.0.0.1:41596 Accepted +[Thu Apr 23 15:24:16 2026] 127.0.0.1:41596 Closing +[Thu Apr 23 15:24:16 2026] 127.0.0.1:39648 Accepted +[Thu Apr 23 15:24:40 2026] 127.0.0.1:39648 Closing +[Thu Apr 23 15:24:40 2026] 127.0.0.1:58204 Accepted +[Thu Apr 23 15:25:07 2026] 127.0.0.1:58204 Closing +[Thu Apr 23 15:25:07 2026] 127.0.0.1:59024 Accepted +[Thu Apr 23 15:26:09 2026] 127.0.0.1:59024 Closing +[Thu Apr 23 15:26:09 2026] 127.0.0.1:34336 Accepted +[Thu Apr 23 15:26:37 2026] 127.0.0.1:34336 Closing +[Thu Apr 23 15:26:37 2026] 127.0.0.1:40038 Accepted +[Thu Apr 23 15:27:09 2026] 127.0.0.1:40038 Closing +[Thu Apr 23 15:27:09 2026] 127.0.0.1:43726 Accepted +[Thu Apr 23 15:27:52 2026] 127.0.0.1:43726 Closing +[Thu Apr 23 15:27:52 2026] 127.0.0.1:33812 Accepted +[Thu Apr 23 15:28:01 2026] 127.0.0.1:33812 Closing +[Thu Apr 23 15:28:01 2026] 127.0.0.1:33814 Accepted +[Thu Apr 23 15:28:30 2026] 127.0.0.1:33814 Closing +[Thu Apr 23 15:28:40 2026] 127.0.0.1:35250 Accepted +[Thu Apr 23 15:28:53 2026] 127.0.0.1:35250 Closing +[Thu Apr 23 15:28:54 2026] 127.0.0.1:58330 Accepted +[Thu Apr 23 15:28:58 2026] 127.0.0.1:58330 Closing +[Thu Apr 23 15:28:58 2026] 127.0.0.1:58342 Accepted +[Thu Apr 23 15:29:09 2026] 127.0.0.1:58342 Closing +[Thu Apr 23 16:29:48 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41257) started +[Thu Apr 23 16:29:48 2026] 127.0.0.1:51702 Accepted +[Thu Apr 23 16:29:50 2026] 127.0.0.1:51702 Closing +[Thu Apr 23 16:29:50 2026] 127.0.0.1:51704 Accepted +[Thu Apr 23 16:30:01 2026] 127.0.0.1:51704 Closing +[Thu Apr 23 16:30:01 2026] 127.0.0.1:55696 Accepted +[Thu Apr 23 16:30:06 2026] 127.0.0.1:55696 Closing +[Thu Apr 23 16:30:06 2026] 127.0.0.1:37860 Accepted +[Thu Apr 23 16:30:12 2026] 127.0.0.1:37860 Closing +[Thu Apr 23 16:30:12 2026] 127.0.0.1:37874 Accepted +[Thu Apr 23 16:30:29 2026] 127.0.0.1:37874 Closing +[Thu Apr 23 16:30:30 2026] 127.0.0.1:60280 Accepted +[Thu Apr 23 16:30:44 2026] 127.0.0.1:60280 Closing +[Thu Apr 23 16:30:45 2026] 127.0.0.1:36696 Accepted +[Thu Apr 23 16:31:01 2026] 127.0.0.1:36696 Closing +[Thu Apr 23 16:31:01 2026] 127.0.0.1:34656 Accepted +[Thu Apr 23 16:31:20 2026] 127.0.0.1:34656 Closing +[Thu Apr 23 16:31:20 2026] 127.0.0.1:50884 Accepted +[Thu Apr 23 16:31:34 2026] 127.0.0.1:50884 Closing +[Thu Apr 23 16:31:42 2026] 127.0.0.1:51040 Accepted +[Thu Apr 23 16:32:27 2026] 127.0.0.1:51040 Closing +[Thu Apr 23 16:32:27 2026] 127.0.0.1:57304 Accepted +[Thu Apr 23 16:32:59 2026] 127.0.0.1:57304 Closing +[Thu Apr 23 16:33:08 2026] 127.0.0.1:60756 Accepted +[Thu Apr 23 16:33:09 2026] 127.0.0.1:60756 Closing +[Thu Apr 23 16:33:09 2026] 127.0.0.1:60768 Accepted +[Thu Apr 23 16:33:15 2026] 127.0.0.1:60768 Closing +[Thu Apr 23 16:33:15 2026] 127.0.0.1:35994 Accepted +[Thu Apr 23 16:33:22 2026] 127.0.0.1:35994 Closing +[Thu Apr 23 16:33:27 2026] 127.0.0.1:39992 Accepted +[Thu Apr 23 16:33:33 2026] 127.0.0.1:39992 Closing +[Thu Apr 23 16:33:33 2026] 127.0.0.1:60774 Accepted +[Thu Apr 23 16:33:46 2026] 127.0.0.1:60774 Closing +[Thu Apr 23 16:33:46 2026] 127.0.0.1:53024 Accepted +[Thu Apr 23 16:34:05 2026] 127.0.0.1:53024 Closing +[Thu Apr 23 16:34:05 2026] 127.0.0.1:57898 Accepted +[Thu Apr 23 16:34:21 2026] 127.0.0.1:57898 Closing +[Thu Apr 23 16:34:21 2026] 127.0.0.1:43424 Accepted +[Thu Apr 23 16:34:37 2026] 127.0.0.1:43424 Closing +[Thu Apr 23 16:34:37 2026] 127.0.0.1:35256 Accepted +[Thu Apr 23 16:34:43 2026] 127.0.0.1:35256 Closing +[Thu Apr 23 16:34:43 2026] 127.0.0.1:43286 Accepted +[Thu Apr 23 16:35:03 2026] 127.0.0.1:43286 Closing +[Thu Apr 23 16:35:03 2026] 127.0.0.1:54308 Accepted +[Thu Apr 23 16:35:11 2026] 127.0.0.1:54308 Closing +[Thu Apr 23 16:35:11 2026] 127.0.0.1:54324 Accepted +[Thu Apr 23 16:35:21 2026] 127.0.0.1:54324 Closing +[Thu Apr 23 16:35:21 2026] 127.0.0.1:59138 Accepted +[Thu Apr 23 16:35:45 2026] 127.0.0.1:59138 Closing +[Thu Apr 23 16:35:45 2026] 127.0.0.1:42138 Accepted +[Thu Apr 23 16:36:17 2026] 127.0.0.1:42138 Closing +[Thu Apr 23 16:36:17 2026] 127.0.0.1:48566 Accepted +[Thu Apr 23 16:36:42 2026] 127.0.0.1:48566 Closing +[Thu Apr 23 16:36:51 2026] 127.0.0.1:52284 Accepted +[Thu Apr 23 16:37:42 2026] 127.0.0.1:52284 Closing +[Thu Apr 23 16:37:42 2026] 127.0.0.1:44392 Accepted +[Thu Apr 23 16:38:06 2026] 127.0.0.1:44392 Closing +[Thu Apr 23 16:38:06 2026] 127.0.0.1:50070 Accepted +[Thu Apr 23 16:38:42 2026] 127.0.0.1:50070 Closing +[Thu Apr 23 16:38:42 2026] 127.0.0.1:36782 Accepted +[Thu Apr 23 16:39:55 2026] 127.0.0.1:36782 Closing +[Thu Apr 23 16:39:55 2026] 127.0.0.1:49324 Accepted +[Thu Apr 23 16:40:26 2026] 127.0.0.1:49324 Closing +[Thu Apr 23 16:40:26 2026] 127.0.0.1:56000 Accepted +[Thu Apr 23 16:40:49 2026] 127.0.0.1:56000 Closing +[Thu Apr 23 16:40:49 2026] 127.0.0.1:32934 Accepted +[Thu Apr 23 16:41:35 2026] 127.0.0.1:32934 Closing +[Thu Apr 23 16:41:45 2026] 127.0.0.1:53520 Accepted +[Thu Apr 23 16:41:50 2026] 127.0.0.1:53520 Closing +[Thu Apr 23 16:41:53 2026] 127.0.0.1:48142 Accepted +[Thu Apr 23 16:42:03 2026] 127.0.0.1:48142 Closing +[Thu Apr 23 16:42:03 2026] 127.0.0.1:40976 Accepted +[Thu Apr 23 16:42:14 2026] 127.0.0.1:40976 Closing +[Thu Apr 23 16:42:14 2026] 127.0.0.1:46732 Accepted +[Thu Apr 23 16:42:20 2026] 127.0.0.1:46732 Closing +[Thu Apr 23 16:42:20 2026] 127.0.0.1:46744 Accepted +[Thu Apr 23 16:42:27 2026] 127.0.0.1:46744 Closing +[Thu Apr 23 16:42:27 2026] 127.0.0.1:48126 Accepted +[Thu Apr 23 16:42:36 2026] 127.0.0.1:48126 Closing +[Thu Apr 23 16:42:36 2026] 127.0.0.1:54372 Accepted +[Thu Apr 23 16:42:55 2026] 127.0.0.1:54372 Closing +[Thu Apr 23 16:42:55 2026] 127.0.0.1:58710 Accepted +[Thu Apr 23 16:43:02 2026] 127.0.0.1:58710 Closing +[Thu Apr 23 16:43:02 2026] 127.0.0.1:58714 Accepted +[Thu Apr 23 16:43:16 2026] 127.0.0.1:58714 Closing +[Thu Apr 23 16:43:16 2026] 127.0.0.1:41036 Accepted +[Thu Apr 23 16:43:31 2026] 127.0.0.1:41036 Closing +[Thu Apr 23 16:43:39 2026] 127.0.0.1:36598 Accepted +[Thu Apr 23 16:44:08 2026] 127.0.0.1:36598 Closing +[Thu Apr 23 16:44:08 2026] 127.0.0.1:47208 Accepted +[Thu Apr 23 16:44:46 2026] 127.0.0.1:47208 Closing +[Thu Apr 23 16:44:46 2026] 127.0.0.1:53408 Accepted +[Thu Apr 23 16:45:47 2026] 127.0.0.1:53408 Closing +[Thu Apr 23 16:45:47 2026] 127.0.0.1:40990 Accepted +[Thu Apr 23 16:46:10 2026] 127.0.0.1:40990 Closing +[Thu Apr 23 16:46:10 2026] 127.0.0.1:53048 Accepted +[Thu Apr 23 16:46:32 2026] 127.0.0.1:53048 Closing +[Thu Apr 23 16:46:32 2026] 127.0.0.1:54226 Accepted +[Thu Apr 23 16:47:31 2026] 127.0.0.1:54226 Closing +[Thu Apr 23 16:47:31 2026] 127.0.0.1:33652 Accepted +[Thu Apr 23 16:47:52 2026] 127.0.0.1:33652 Closing +[Thu Apr 23 16:47:52 2026] 127.0.0.1:34806 Accepted +[Thu Apr 23 16:48:19 2026] 127.0.0.1:34806 Closing +[Thu Apr 23 16:48:19 2026] 127.0.0.1:37516 Accepted +[Thu Apr 23 16:49:01 2026] 127.0.0.1:37516 Closing +[Thu Apr 23 16:49:01 2026] 127.0.0.1:40672 Accepted +[Thu Apr 23 16:49:10 2026] 127.0.0.1:40672 Closing +[Thu Apr 23 16:49:10 2026] 127.0.0.1:51178 Accepted +[Thu Apr 23 16:49:34 2026] 127.0.0.1:51178 Closing +[Thu Apr 23 16:49:45 2026] 127.0.0.1:38770 Accepted +[Thu Apr 23 16:49:54 2026] 127.0.0.1:38770 Closing +[Thu Apr 23 16:49:55 2026] 127.0.0.1:51136 Accepted +[Thu Apr 23 16:49:59 2026] 127.0.0.1:51136 Closing +[Thu Apr 23 16:49:59 2026] 127.0.0.1:51138 Accepted +[Thu Apr 23 16:50:10 2026] 127.0.0.1:51138 Closing +[Thu Apr 23 16:50:11 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41741) started +[Thu Apr 23 16:50:11 2026] 127.0.0.1:34844 Accepted +[Thu Apr 23 16:50:13 2026] 127.0.0.1:34844 Closing +[Thu Apr 23 16:50:13 2026] 127.0.0.1:34856 Accepted +[Thu Apr 23 16:50:22 2026] 127.0.0.1:34856 Closing +[Thu Apr 23 16:50:22 2026] 127.0.0.1:34144 Accepted +[Thu Apr 23 16:50:27 2026] 127.0.0.1:34144 Closing +[Thu Apr 23 16:50:27 2026] 127.0.0.1:57894 Accepted +[Thu Apr 23 16:50:32 2026] 127.0.0.1:57894 Closing +[Thu Apr 23 16:50:32 2026] 127.0.0.1:57906 Accepted +[Thu Apr 23 16:50:52 2026] 127.0.0.1:57906 Closing +[Thu Apr 23 16:50:53 2026] 127.0.0.1:57810 Accepted +[Thu Apr 23 16:51:07 2026] 127.0.0.1:57810 Closing +[Thu Apr 23 16:51:08 2026] 127.0.0.1:55126 Accepted +[Thu Apr 23 16:51:21 2026] 127.0.0.1:55126 Closing +[Thu Apr 23 16:51:21 2026] 127.0.0.1:54094 Accepted +[Thu Apr 23 16:51:39 2026] 127.0.0.1:54094 Closing +[Thu Apr 23 16:51:39 2026] 127.0.0.1:45210 Accepted +[Thu Apr 23 16:51:53 2026] 127.0.0.1:45210 Closing +[Thu Apr 23 16:52:02 2026] 127.0.0.1:58524 Accepted +[Thu Apr 23 16:53:00 2026] 127.0.0.1:58524 Closing +[Thu Apr 23 16:53:00 2026] 127.0.0.1:50266 Accepted +[Thu Apr 23 16:53:26 2026] 127.0.0.1:50266 Closing +[Thu Apr 23 16:53:34 2026] 127.0.0.1:53900 Accepted +[Thu Apr 23 16:53:36 2026] 127.0.0.1:53900 Closing +[Thu Apr 23 16:53:36 2026] 127.0.0.1:53914 Accepted +[Thu Apr 23 16:53:41 2026] 127.0.0.1:53914 Closing +[Thu Apr 23 16:53:41 2026] 127.0.0.1:53916 Accepted +[Thu Apr 23 16:53:46 2026] 127.0.0.1:53916 Closing +[Thu Apr 23 16:53:50 2026] 127.0.0.1:39310 Accepted +[Thu Apr 23 16:53:56 2026] 127.0.0.1:39310 Closing +[Thu Apr 23 16:53:56 2026] 127.0.0.1:55622 Accepted +[Thu Apr 23 16:54:07 2026] 127.0.0.1:55622 Closing +[Thu Apr 23 16:54:07 2026] 127.0.0.1:40312 Accepted +[Thu Apr 23 16:54:22 2026] 127.0.0.1:40312 Closing +[Thu Apr 23 16:54:22 2026] 127.0.0.1:48834 Accepted +[Thu Apr 23 16:54:36 2026] 127.0.0.1:48834 Closing +[Thu Apr 23 16:54:36 2026] 127.0.0.1:34410 Accepted +[Thu Apr 23 16:54:45 2026] 127.0.0.1:34410 Closing +[Thu Apr 23 16:54:45 2026] 127.0.0.1:45498 Accepted +[Thu Apr 23 16:54:50 2026] 127.0.0.1:45498 Closing +[Thu Apr 23 16:54:50 2026] 127.0.0.1:45504 Accepted +[Thu Apr 23 16:55:03 2026] 127.0.0.1:45504 Closing +[Thu Apr 23 16:55:03 2026] 127.0.0.1:52868 Accepted +[Thu Apr 23 16:55:11 2026] 127.0.0.1:52868 Closing +[Thu Apr 23 16:55:11 2026] 127.0.0.1:52876 Accepted +[Thu Apr 23 16:55:20 2026] 127.0.0.1:52876 Closing +[Thu Apr 23 16:55:20 2026] 127.0.0.1:56850 Accepted +[Thu Apr 23 16:55:37 2026] 127.0.0.1:56850 Closing +[Thu Apr 23 16:55:37 2026] 127.0.0.1:37698 Accepted +[Thu Apr 23 16:56:02 2026] 127.0.0.1:37698 Closing +[Thu Apr 23 16:56:02 2026] 127.0.0.1:53640 Accepted +[Thu Apr 23 16:56:24 2026] 127.0.0.1:53640 Closing +[Thu Apr 23 16:56:33 2026] 127.0.0.1:45584 Accepted +[Thu Apr 23 16:57:23 2026] 127.0.0.1:45584 Closing +[Thu Apr 23 16:57:23 2026] 127.0.0.1:55062 Accepted +[Thu Apr 23 16:57:46 2026] 127.0.0.1:55062 Closing +[Thu Apr 23 16:57:46 2026] 127.0.0.1:53504 Accepted +[Thu Apr 23 16:58:12 2026] 127.0.0.1:53504 Closing +[Thu Apr 23 16:58:12 2026] 127.0.0.1:49428 Accepted +[Thu Apr 23 16:58:26 2026] 127.0.0.1:49428 Closing +[Thu Apr 23 17:34:29 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35935) started +[Thu Apr 23 17:34:29 2026] 127.0.0.1:60308 Accepted +[Thu Apr 23 17:34:30 2026] 127.0.0.1:60308 Closing +[Thu Apr 23 17:34:30 2026] 127.0.0.1:60314 Accepted +[Thu Apr 23 17:34:41 2026] 127.0.0.1:60314 Closing +[Thu Apr 23 17:34:41 2026] 127.0.0.1:54476 Accepted +[Thu Apr 23 17:34:46 2026] 127.0.0.1:54476 Closing +[Thu Apr 23 17:34:46 2026] 127.0.0.1:33284 Accepted +[Thu Apr 23 17:34:51 2026] 127.0.0.1:33284 Closing +[Thu Apr 23 17:34:51 2026] 127.0.0.1:33298 Accepted +[Thu Apr 23 17:35:09 2026] 127.0.0.1:33298 Closing +[Thu Apr 23 17:35:10 2026] 127.0.0.1:51530 Accepted +[Thu Apr 23 17:35:22 2026] 127.0.0.1:51530 Closing +[Thu Apr 23 17:35:23 2026] 127.0.0.1:36436 Accepted +[Thu Apr 23 17:35:36 2026] 127.0.0.1:36436 Closing +[Thu Apr 23 17:35:36 2026] 127.0.0.1:43464 Accepted +[Thu Apr 23 17:35:58 2026] 127.0.0.1:43464 Closing +[Thu Apr 23 17:35:58 2026] 127.0.0.1:45374 Accepted +[Thu Apr 23 17:36:11 2026] 127.0.0.1:45374 Closing +[Thu Apr 23 17:36:18 2026] 127.0.0.1:35106 Accepted +[Thu Apr 23 17:37:07 2026] 127.0.0.1:35106 Closing +[Thu Apr 23 17:37:07 2026] 127.0.0.1:47924 Accepted +[Thu Apr 23 17:37:30 2026] 127.0.0.1:47924 Closing +[Thu Apr 23 17:37:38 2026] 127.0.0.1:36920 Accepted +[Thu Apr 23 17:37:39 2026] 127.0.0.1:36920 Closing +[Thu Apr 23 17:37:39 2026] 127.0.0.1:36924 Accepted +[Thu Apr 23 17:37:44 2026] 127.0.0.1:36924 Closing +[Thu Apr 23 17:37:44 2026] 127.0.0.1:53966 Accepted +[Thu Apr 23 17:37:50 2026] 127.0.0.1:53966 Closing +[Thu Apr 23 17:37:54 2026] 127.0.0.1:53978 Accepted +[Thu Apr 23 17:38:00 2026] 127.0.0.1:53978 Closing +[Thu Apr 23 17:38:00 2026] 127.0.0.1:57754 Accepted +[Thu Apr 23 17:38:16 2026] 127.0.0.1:57754 Closing +[Thu Apr 23 17:38:16 2026] 127.0.0.1:59546 Accepted +[Thu Apr 23 17:38:34 2026] 127.0.0.1:59546 Closing +[Thu Apr 23 17:38:34 2026] 127.0.0.1:38580 Accepted +[Thu Apr 23 17:38:48 2026] 127.0.0.1:38580 Closing +[Thu Apr 23 17:38:48 2026] 127.0.0.1:34802 Accepted +[Thu Apr 23 17:39:03 2026] 127.0.0.1:34802 Closing +[Thu Apr 23 17:39:03 2026] 127.0.0.1:35208 Accepted +[Thu Apr 23 17:39:08 2026] 127.0.0.1:35208 Closing +[Thu Apr 23 17:39:08 2026] 127.0.0.1:32948 Accepted +[Thu Apr 23 17:39:28 2026] 127.0.0.1:32948 Closing +[Thu Apr 23 17:39:28 2026] 127.0.0.1:52062 Accepted +[Thu Apr 23 17:39:36 2026] 127.0.0.1:52062 Closing +[Thu Apr 23 17:39:36 2026] 127.0.0.1:51650 Accepted +[Thu Apr 23 17:39:45 2026] 127.0.0.1:51650 Closing +[Thu Apr 23 17:39:45 2026] 127.0.0.1:52346 Accepted +[Thu Apr 23 17:40:04 2026] 127.0.0.1:52346 Closing +[Thu Apr 23 17:40:04 2026] 127.0.0.1:53802 Accepted +[Thu Apr 23 17:40:25 2026] 127.0.0.1:53802 Closing +[Thu Apr 23 17:40:25 2026] 127.0.0.1:49256 Accepted +[Thu Apr 23 17:40:49 2026] 127.0.0.1:49256 Closing +[Thu Apr 23 17:40:58 2026] 127.0.0.1:32896 Accepted +[Thu Apr 23 17:41:48 2026] 127.0.0.1:32896 Closing +[Thu Apr 23 17:41:48 2026] 127.0.0.1:51168 Accepted +[Thu Apr 23 17:42:12 2026] 127.0.0.1:51168 Closing +[Thu Apr 23 17:42:12 2026] 127.0.0.1:56192 Accepted +[Thu Apr 23 17:42:45 2026] 127.0.0.1:56192 Closing +[Thu Apr 23 17:42:45 2026] 127.0.0.1:46764 Accepted +[Thu Apr 23 17:43:19 2026] 127.0.0.1:46764 Closing +[Thu Apr 23 17:43:19 2026] 127.0.0.1:33404 Accepted +[Thu Apr 23 17:43:45 2026] 127.0.0.1:33404 Closing +[Thu Apr 23 17:43:45 2026] 127.0.0.1:47704 Accepted +[Thu Apr 23 17:44:09 2026] 127.0.0.1:47704 Closing +[Thu Apr 23 17:44:09 2026] 127.0.0.1:52974 Accepted +[Thu Apr 23 17:44:51 2026] 127.0.0.1:52974 Closing +[Thu Apr 23 17:44:59 2026] 127.0.0.1:56812 Accepted +[Thu Apr 23 17:45:05 2026] 127.0.0.1:56812 Closing +[Thu Apr 23 17:45:08 2026] 127.0.0.1:57078 Accepted +[Thu Apr 23 17:45:18 2026] 127.0.0.1:57078 Closing +[Thu Apr 23 17:45:18 2026] 127.0.0.1:40360 Accepted +[Thu Apr 23 17:45:28 2026] 127.0.0.1:40360 Closing +[Thu Apr 23 17:45:28 2026] 127.0.0.1:37716 Accepted +[Thu Apr 23 17:45:34 2026] 127.0.0.1:37716 Closing +[Thu Apr 23 17:45:34 2026] 127.0.0.1:37718 Accepted +[Thu Apr 23 17:45:41 2026] 127.0.0.1:37718 Closing +[Thu Apr 23 17:45:41 2026] 127.0.0.1:48720 Accepted +[Thu Apr 23 17:45:49 2026] 127.0.0.1:48720 Closing +[Thu Apr 23 17:45:49 2026] 127.0.0.1:55446 Accepted +[Thu Apr 23 17:46:07 2026] 127.0.0.1:55446 Closing +[Thu Apr 23 17:46:07 2026] 127.0.0.1:52554 Accepted +[Thu Apr 23 17:46:17 2026] 127.0.0.1:52554 Closing +[Thu Apr 23 17:46:17 2026] 127.0.0.1:49864 Accepted +[Thu Apr 23 17:46:31 2026] 127.0.0.1:49864 Closing +[Thu Apr 23 17:46:31 2026] 127.0.0.1:38452 Accepted +[Thu Apr 23 17:46:44 2026] 127.0.0.1:38452 Closing +[Thu Apr 23 17:46:51 2026] 127.0.0.1:40280 Accepted +[Thu Apr 23 17:47:17 2026] 127.0.0.1:40280 Closing +[Thu Apr 23 17:47:17 2026] 127.0.0.1:43396 Accepted +[Thu Apr 23 17:47:53 2026] 127.0.0.1:43396 Closing +[Thu Apr 23 17:47:53 2026] 127.0.0.1:33792 Accepted +[Thu Apr 23 17:48:48 2026] 127.0.0.1:33792 Closing +[Thu Apr 23 17:48:48 2026] 127.0.0.1:45232 Accepted +[Thu Apr 23 17:49:14 2026] 127.0.0.1:45232 Closing +[Thu Apr 23 17:49:14 2026] 127.0.0.1:49274 Accepted +[Thu Apr 23 17:49:35 2026] 127.0.0.1:49274 Closing +[Thu Apr 23 17:49:35 2026] 127.0.0.1:33334 Accepted +[Thu Apr 23 17:50:35 2026] 127.0.0.1:33334 Closing +[Thu Apr 23 17:50:35 2026] 127.0.0.1:43560 Accepted +[Thu Apr 23 17:50:59 2026] 127.0.0.1:43560 Closing +[Thu Apr 23 17:50:59 2026] 127.0.0.1:59932 Accepted +[Thu Apr 23 17:51:26 2026] 127.0.0.1:59932 Closing +[Thu Apr 23 17:51:26 2026] 127.0.0.1:47106 Accepted +[Thu Apr 23 17:52:07 2026] 127.0.0.1:47106 Closing +[Thu Apr 23 17:52:07 2026] 127.0.0.1:34998 Accepted +[Thu Apr 23 17:52:16 2026] 127.0.0.1:34998 Closing +[Thu Apr 23 17:52:16 2026] 127.0.0.1:52942 Accepted +[Thu Apr 23 17:52:41 2026] 127.0.0.1:52942 Closing +[Thu Apr 23 17:52:51 2026] 127.0.0.1:39964 Accepted +[Thu Apr 23 17:53:02 2026] 127.0.0.1:39964 Closing +[Thu Apr 23 17:53:03 2026] 127.0.0.1:44838 Accepted +[Thu Apr 23 17:53:07 2026] 127.0.0.1:44838 Closing +[Thu Apr 23 17:53:08 2026] 127.0.0.1:37536 Accepted +[Thu Apr 23 17:53:18 2026] 127.0.0.1:37536 Closing +[Thu Apr 23 17:55:02 2026] PHP 8.2.15 Development Server (http://127.0.0.1:42847) started +[Thu Apr 23 17:55:02 2026] 127.0.0.1:48776 Accepted +[Thu Apr 23 17:55:04 2026] 127.0.0.1:48776 Closing +[Thu Apr 23 17:55:04 2026] 127.0.0.1:48786 Accepted +[Thu Apr 23 17:55:14 2026] 127.0.0.1:48786 Closing +[Thu Apr 23 17:55:14 2026] 127.0.0.1:57238 Accepted +[Thu Apr 23 17:55:20 2026] 127.0.0.1:57238 Closing +[Thu Apr 23 17:55:20 2026] 127.0.0.1:60128 Accepted +[Thu Apr 23 17:55:25 2026] 127.0.0.1:60128 Closing +[Thu Apr 23 17:55:25 2026] 127.0.0.1:56066 Accepted +[Thu Apr 23 17:55:44 2026] 127.0.0.1:56066 Closing +[Thu Apr 23 17:55:44 2026] 127.0.0.1:49446 Accepted +[Thu Apr 23 17:55:57 2026] 127.0.0.1:49446 Closing +[Thu Apr 23 17:55:58 2026] 127.0.0.1:34624 Accepted +[Thu Apr 23 17:56:11 2026] 127.0.0.1:34624 Closing +[Thu Apr 23 17:56:11 2026] 127.0.0.1:46778 Accepted +[Thu Apr 23 17:56:30 2026] 127.0.0.1:46778 Closing +[Thu Apr 23 17:56:30 2026] 127.0.0.1:60988 Accepted +[Thu Apr 23 17:56:44 2026] 127.0.0.1:60988 Closing +[Thu Apr 23 17:56:51 2026] 127.0.0.1:60360 Accepted +[Thu Apr 23 17:57:38 2026] 127.0.0.1:60360 Closing +[Thu Apr 23 17:57:38 2026] 127.0.0.1:58672 Accepted +[Thu Apr 23 17:58:11 2026] 127.0.0.1:58672 Closing +[Thu Apr 23 17:58:30 2026] 127.0.0.1:45072 Accepted +[Thu Apr 23 17:58:42 2026] 127.0.0.1:45072 Closing +[Thu Apr 23 17:58:42 2026] 127.0.0.1:40946 Accepted +[Thu Apr 23 17:58:55 2026] 127.0.0.1:40946 Closing +[Thu Apr 23 17:58:55 2026] 127.0.0.1:34572 Accepted +[Thu Apr 23 17:59:00 2026] 127.0.0.1:34572 Closing +[Thu Apr 23 17:59:04 2026] 127.0.0.1:34574 Accepted +[Thu Apr 23 17:59:10 2026] 127.0.0.1:34574 Closing +[Thu Apr 23 17:59:10 2026] 127.0.0.1:54946 Accepted +[Thu Apr 23 17:59:21 2026] 127.0.0.1:54946 Closing +[Thu Apr 23 17:59:21 2026] 127.0.0.1:45168 Accepted +[Thu Apr 23 17:59:38 2026] 127.0.0.1:45168 Closing +[Thu Apr 23 17:59:38 2026] 127.0.0.1:39232 Accepted +[Thu Apr 23 17:59:53 2026] 127.0.0.1:39232 Closing +[Thu Apr 23 17:59:53 2026] 127.0.0.1:42022 Accepted +[Thu Apr 23 18:00:03 2026] 127.0.0.1:42022 Closing +[Thu Apr 23 18:00:03 2026] 127.0.0.1:38432 Accepted +[Thu Apr 23 18:00:09 2026] 127.0.0.1:38432 Closing +[Thu Apr 23 18:00:09 2026] 127.0.0.1:57840 Accepted +[Thu Apr 23 18:00:25 2026] 127.0.0.1:57840 Closing +[Thu Apr 23 18:00:25 2026] 127.0.0.1:49268 Accepted +[Thu Apr 23 18:00:32 2026] 127.0.0.1:49268 Closing +[Thu Apr 23 18:00:32 2026] 127.0.0.1:49276 Accepted +[Thu Apr 23 18:00:44 2026] 127.0.0.1:49276 Closing +[Thu Apr 23 18:00:44 2026] 127.0.0.1:40646 Accepted +[Thu Apr 23 18:01:05 2026] 127.0.0.1:40646 Closing +[Thu Apr 23 18:01:05 2026] 127.0.0.1:42362 Accepted +[Thu Apr 23 18:01:42 2026] 127.0.0.1:42362 Closing +[Thu Apr 23 18:01:42 2026] 127.0.0.1:57864 Accepted +[Thu Apr 23 18:02:07 2026] 127.0.0.1:57864 Closing +[Thu Apr 23 18:02:16 2026] 127.0.0.1:46332 Accepted +[Thu Apr 23 18:03:07 2026] 127.0.0.1:46332 Closing +[Thu Apr 23 18:03:07 2026] 127.0.0.1:34212 Accepted +[Thu Apr 23 18:03:30 2026] 127.0.0.1:34212 Closing +[Thu Apr 23 18:03:30 2026] 127.0.0.1:46988 Accepted +[Thu Apr 23 18:04:00 2026] 127.0.0.1:46988 Closing +[Thu Apr 23 18:04:00 2026] 127.0.0.1:50404 Accepted +[Thu Apr 23 18:04:36 2026] 127.0.0.1:50404 Closing +[Thu Apr 23 18:04:36 2026] 127.0.0.1:57696 Accepted +[Thu Apr 23 18:05:02 2026] 127.0.0.1:57696 Closing +[Thu Apr 23 18:05:02 2026] 127.0.0.1:36782 Accepted +[Thu Apr 23 18:05:25 2026] 127.0.0.1:36782 Closing +[Thu Apr 23 18:05:25 2026] 127.0.0.1:42020 Accepted +[Thu Apr 23 18:06:06 2026] 127.0.0.1:42020 Closing +[Thu Apr 23 18:06:14 2026] 127.0.0.1:38318 Accepted +[Thu Apr 23 18:06:19 2026] 127.0.0.1:38318 Closing +[Thu Apr 23 18:06:22 2026] 127.0.0.1:58512 Accepted +[Thu Apr 23 18:06:32 2026] 127.0.0.1:58512 Closing +[Thu Apr 23 18:06:32 2026] 127.0.0.1:42470 Accepted +[Thu Apr 23 18:06:40 2026] 127.0.0.1:42470 Closing +[Thu Apr 23 18:06:40 2026] 127.0.0.1:37050 Accepted +[Thu Apr 23 18:06:46 2026] 127.0.0.1:37050 Closing +[Thu Apr 23 18:06:46 2026] 127.0.0.1:37288 Accepted +[Thu Apr 23 18:06:52 2026] 127.0.0.1:37288 Closing +[Thu Apr 23 18:06:52 2026] 127.0.0.1:37292 Accepted +[Thu Apr 23 18:07:00 2026] 127.0.0.1:37292 Closing +[Thu Apr 23 18:07:00 2026] 127.0.0.1:45458 Accepted +[Thu Apr 23 18:07:20 2026] 127.0.0.1:45458 Closing +[Thu Apr 23 18:07:20 2026] 127.0.0.1:57564 Accepted +[Thu Apr 23 18:07:28 2026] 127.0.0.1:57564 Closing +[Thu Apr 23 18:07:28 2026] 127.0.0.1:35094 Accepted +[Thu Apr 23 18:07:43 2026] 127.0.0.1:35094 Closing +[Thu Apr 23 18:07:43 2026] 127.0.0.1:55512 Accepted +[Thu Apr 23 18:07:55 2026] 127.0.0.1:55512 Closing +[Thu Apr 23 18:08:03 2026] 127.0.0.1:54864 Accepted +[Thu Apr 23 18:08:30 2026] 127.0.0.1:54864 Closing +[Thu Apr 23 18:08:30 2026] 127.0.0.1:42640 Accepted +[Thu Apr 23 18:09:04 2026] 127.0.0.1:42640 Closing +[Thu Apr 23 18:09:04 2026] 127.0.0.1:40376 Accepted +[Thu Apr 23 18:09:55 2026] 127.0.0.1:40376 Closing +[Thu Apr 23 18:09:55 2026] 127.0.0.1:50502 Accepted +[Thu Apr 23 18:10:17 2026] 127.0.0.1:50502 Closing +[Thu Apr 23 18:10:17 2026] 127.0.0.1:54996 Accepted +[Thu Apr 23 18:10:39 2026] 127.0.0.1:54996 Closing +[Thu Apr 23 18:10:39 2026] 127.0.0.1:53248 Accepted +[Thu Apr 23 18:11:39 2026] 127.0.0.1:53248 Closing +[Thu Apr 23 18:11:39 2026] 127.0.0.1:56988 Accepted +[Thu Apr 23 18:12:04 2026] 127.0.0.1:56988 Closing +[Thu Apr 23 18:12:04 2026] 127.0.0.1:46952 Accepted +[Thu Apr 23 18:12:33 2026] 127.0.0.1:46952 Closing +[Thu Apr 23 18:12:33 2026] 127.0.0.1:35236 Accepted +[Thu Apr 23 18:13:13 2026] 127.0.0.1:35236 Closing +[Thu Apr 23 18:13:13 2026] 127.0.0.1:47052 Accepted +[Thu Apr 23 18:13:26 2026] 127.0.0.1:47052 Closing +[Thu Apr 23 18:13:26 2026] 127.0.0.1:55014 Accepted +[Thu Apr 23 18:13:51 2026] 127.0.0.1:55014 Closing +[Thu Apr 23 18:14:01 2026] 127.0.0.1:42998 Accepted +[Thu Apr 23 18:14:12 2026] 127.0.0.1:42998 Closing +[Thu Apr 23 18:14:13 2026] 127.0.0.1:46392 Accepted +[Thu Apr 23 18:14:17 2026] 127.0.0.1:46392 Closing +[Thu Apr 23 18:14:18 2026] 127.0.0.1:33766 Accepted +[Thu Apr 23 18:14:27 2026] 127.0.0.1:33766 Closing +[Thu Apr 23 18:17:50 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40131) started +[Thu Apr 23 18:17:50 2026] 127.0.0.1:53678 Accepted +[Thu Apr 23 18:17:52 2026] 127.0.0.1:53678 Closing +[Thu Apr 23 18:17:52 2026] 127.0.0.1:53686 Accepted +[Thu Apr 23 18:18:43 2026] 127.0.0.1:53686 Closing +[Thu Apr 23 18:18:43 2026] 127.0.0.1:44116 Accepted +[Thu Apr 23 18:19:09 2026] 127.0.0.1:44116 Closing +[Thu Apr 23 18:22:10 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39239) started +[Thu Apr 23 18:22:10 2026] 127.0.0.1:44596 Accepted +[Thu Apr 23 18:22:15 2026] 127.0.0.1:44596 Closing +[Thu Apr 23 18:22:15 2026] 127.0.0.1:44600 Accepted +[Thu Apr 23 18:27:02 2026] 127.0.0.1:44600 Closing +[Thu Apr 23 18:31:01 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41183) started +[Thu Apr 23 18:31:01 2026] 127.0.0.1:56136 Accepted +[Thu Apr 23 18:31:04 2026] 127.0.0.1:56136 Closing +[Thu Apr 23 18:31:04 2026] 127.0.0.1:56144 Accepted +[Thu Apr 23 18:32:27 2026] 127.0.0.1:56144 Closing +[Thu Apr 23 18:32:27 2026] 127.0.0.1:49316 Accepted +[Thu Apr 23 18:32:39 2026] 127.0.0.1:49316 Closing +[Thu Apr 23 18:32:39 2026] 127.0.0.1:46660 Accepted +[Thu Apr 23 18:33:06 2026] 127.0.0.1:46660 Closing +[Thu Apr 23 18:33:06 2026] 127.0.0.1:55386 Accepted +[Thu Apr 23 18:33:41 2026] 127.0.0.1:55386 Closing +[Thu Apr 23 18:33:41 2026] 127.0.0.1:57176 Accepted +[Thu Apr 23 18:33:48 2026] 127.0.0.1:57176 Closing +[Thu Apr 23 18:33:48 2026] 127.0.0.1:56314 Accepted +[Thu Apr 23 18:34:14 2026] 127.0.0.1:56314 Closing +[Thu Apr 23 18:34:14 2026] 127.0.0.1:47476 Accepted +[Thu Apr 23 18:34:33 2026] 127.0.0.1:47476 Closing +[Thu Apr 23 18:34:33 2026] 127.0.0.1:44954 Accepted +[Thu Apr 23 18:35:16 2026] 127.0.0.1:44954 Closing +[Thu Apr 23 18:36:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34427) started +[Thu Apr 23 18:36:43 2026] 127.0.0.1:42898 Accepted +[Thu Apr 23 18:36:44 2026] 127.0.0.1:42898 Closing +[Thu Apr 23 18:36:44 2026] 127.0.0.1:42914 Accepted +[Thu Apr 23 18:36:54 2026] 127.0.0.1:42914 Closing +[Thu Apr 23 18:36:54 2026] 127.0.0.1:34152 Accepted +[Thu Apr 23 18:37:00 2026] 127.0.0.1:34152 Closing +[Thu Apr 23 18:37:00 2026] 127.0.0.1:59834 Accepted +[Thu Apr 23 18:37:05 2026] 127.0.0.1:59834 Closing +[Thu Apr 23 18:37:05 2026] 127.0.0.1:59850 Accepted +[Thu Apr 23 18:37:24 2026] 127.0.0.1:59850 Closing +[Thu Apr 23 18:37:25 2026] 127.0.0.1:54652 Accepted +[Thu Apr 23 18:37:38 2026] 127.0.0.1:54652 Closing +[Thu Apr 23 18:37:39 2026] 127.0.0.1:56422 Accepted +[Thu Apr 23 18:37:52 2026] 127.0.0.1:56422 Closing +[Thu Apr 23 18:37:52 2026] 127.0.0.1:42424 Accepted +[Thu Apr 23 18:38:10 2026] 127.0.0.1:42424 Closing +[Thu Apr 23 18:38:10 2026] 127.0.0.1:50608 Accepted +[Thu Apr 23 18:38:26 2026] 127.0.0.1:50608 Closing +[Thu Apr 23 18:38:33 2026] 127.0.0.1:58258 Accepted +[Thu Apr 23 18:39:22 2026] 127.0.0.1:58258 Closing +[Thu Apr 23 18:39:22 2026] 127.0.0.1:35408 Accepted +[Thu Apr 23 18:39:39 2026] 127.0.0.1:35408 Closing +[Thu Apr 23 18:39:39 2026] 127.0.0.1:41744 Accepted +[Thu Apr 23 18:40:04 2026] 127.0.0.1:41744 Closing +[Thu Apr 23 18:40:04 2026] 127.0.0.1:57372 Accepted +[Thu Apr 23 18:40:39 2026] 127.0.0.1:57372 Closing +[Thu Apr 23 18:40:39 2026] 127.0.0.1:57162 Accepted +[Thu Apr 23 18:40:46 2026] 127.0.0.1:57162 Closing +[Thu Apr 23 18:40:46 2026] 127.0.0.1:57168 Accepted +[Thu Apr 23 18:41:12 2026] 127.0.0.1:57168 Closing +[Thu Apr 23 18:41:12 2026] 127.0.0.1:53532 Accepted +[Thu Apr 23 18:41:31 2026] 127.0.0.1:53532 Closing +[Thu Apr 23 18:41:31 2026] 127.0.0.1:40840 Accepted +[Thu Apr 23 18:42:15 2026] 127.0.0.1:40840 Closing +[Thu Apr 23 18:42:23 2026] 127.0.0.1:47480 Accepted +[Thu Apr 23 18:42:24 2026] 127.0.0.1:47480 Closing +[Thu Apr 23 18:42:24 2026] 127.0.0.1:47488 Accepted +[Thu Apr 23 18:42:28 2026] 127.0.0.1:47488 Closing +[Thu Apr 23 18:42:28 2026] 127.0.0.1:55942 Accepted +[Thu Apr 23 18:42:34 2026] 127.0.0.1:55942 Closing +[Thu Apr 23 18:42:38 2026] 127.0.0.1:40468 Accepted +[Thu Apr 23 18:42:44 2026] 127.0.0.1:40468 Closing +[Thu Apr 23 18:42:44 2026] 127.0.0.1:40476 Accepted +[Thu Apr 23 18:42:56 2026] 127.0.0.1:40476 Closing +[Thu Apr 23 18:42:56 2026] 127.0.0.1:55030 Accepted +[Thu Apr 23 18:43:14 2026] 127.0.0.1:55030 Closing +[Thu Apr 23 18:43:14 2026] 127.0.0.1:50948 Accepted +[Thu Apr 23 18:43:27 2026] 127.0.0.1:50948 Closing +[Thu Apr 23 18:43:27 2026] 127.0.0.1:59736 Accepted +[Thu Apr 23 18:43:42 2026] 127.0.0.1:59736 Closing +[Thu Apr 23 18:43:42 2026] 127.0.0.1:51854 Accepted +[Thu Apr 23 18:43:47 2026] 127.0.0.1:51854 Closing +[Thu Apr 23 18:43:47 2026] 127.0.0.1:43558 Accepted +[Thu Apr 23 18:44:06 2026] 127.0.0.1:43558 Closing +[Thu Apr 23 18:44:06 2026] 127.0.0.1:50044 Accepted +[Thu Apr 23 18:44:13 2026] 127.0.0.1:50044 Closing +[Thu Apr 23 18:44:13 2026] 127.0.0.1:48218 Accepted +[Thu Apr 23 18:44:22 2026] 127.0.0.1:48218 Closing +[Thu Apr 23 18:44:22 2026] 127.0.0.1:48152 Accepted +[Thu Apr 23 18:44:46 2026] 127.0.0.1:48152 Closing +[Thu Apr 23 18:44:46 2026] 127.0.0.1:51336 Accepted +[Thu Apr 23 18:45:15 2026] 127.0.0.1:51336 Closing +[Thu Apr 23 18:45:15 2026] 127.0.0.1:60894 Accepted +[Thu Apr 23 18:45:39 2026] 127.0.0.1:60894 Closing +[Thu Apr 23 18:45:48 2026] 127.0.0.1:41026 Accepted +[Thu Apr 23 18:47:04 2026] 127.0.0.1:41026 Closing +[Thu Apr 23 18:47:04 2026] 127.0.0.1:36518 Accepted +[Thu Apr 23 18:47:20 2026] 127.0.0.1:36518 Closing +[Thu Apr 23 18:47:20 2026] 127.0.0.1:50432 Accepted +[Thu Apr 23 18:47:49 2026] 127.0.0.1:50432 Closing +[Thu Apr 23 18:47:49 2026] 127.0.0.1:54614 Accepted +[Thu Apr 23 18:48:30 2026] 127.0.0.1:54614 Closing +[Thu Apr 23 18:48:30 2026] 127.0.0.1:52304 Accepted +[Thu Apr 23 18:48:57 2026] 127.0.0.1:52304 Closing +[Thu Apr 23 18:48:57 2026] 127.0.0.1:36360 Accepted +[Thu Apr 23 18:49:29 2026] 127.0.0.1:36360 Closing +[Thu Apr 23 18:49:29 2026] 127.0.0.1:45652 Accepted +[Thu Apr 23 18:50:21 2026] 127.0.0.1:45652 Closing +[Thu Apr 23 18:50:29 2026] 127.0.0.1:53770 Accepted +[Thu Apr 23 18:50:37 2026] 127.0.0.1:53770 Closing +[Thu Apr 23 18:50:40 2026] 127.0.0.1:51906 Accepted +[Thu Apr 23 18:50:50 2026] 127.0.0.1:51906 Closing +[Thu Apr 23 18:50:50 2026] 127.0.0.1:55442 Accepted +[Thu Apr 23 18:51:05 2026] 127.0.0.1:55442 Closing +[Thu Apr 23 18:51:05 2026] 127.0.0.1:33880 Accepted +[Thu Apr 23 18:51:11 2026] 127.0.0.1:33880 Closing +[Thu Apr 23 18:51:11 2026] 127.0.0.1:44528 Accepted +[Thu Apr 23 18:51:18 2026] 127.0.0.1:44528 Closing +[Thu Apr 23 18:51:18 2026] 127.0.0.1:38460 Accepted +[Thu Apr 23 18:51:31 2026] 127.0.0.1:38460 Closing +[Thu Apr 23 18:51:31 2026] 127.0.0.1:36100 Accepted +[Thu Apr 23 18:51:53 2026] 127.0.0.1:36100 Closing +[Thu Apr 23 18:51:53 2026] 127.0.0.1:40628 Accepted +[Thu Apr 23 18:52:02 2026] 127.0.0.1:40628 Closing +[Thu Apr 23 18:52:02 2026] 127.0.0.1:50874 Accepted +[Thu Apr 23 18:52:20 2026] 127.0.0.1:50874 Closing +[Thu Apr 23 18:52:20 2026] 127.0.0.1:50372 Accepted +[Thu Apr 23 18:52:37 2026] 127.0.0.1:50372 Closing +[Thu Apr 23 18:52:45 2026] 127.0.0.1:52518 Accepted +[Thu Apr 23 18:53:16 2026] 127.0.0.1:52518 Closing +[Thu Apr 23 18:53:16 2026] 127.0.0.1:43456 Accepted +[Thu Apr 23 18:53:56 2026] 127.0.0.1:43456 Closing +[Thu Apr 23 18:53:56 2026] 127.0.0.1:45154 Accepted +[Thu Apr 23 18:54:55 2026] 127.0.0.1:45154 Closing +[Thu Apr 23 18:54:55 2026] 127.0.0.1:58606 Accepted +[Thu Apr 23 18:55:22 2026] 127.0.0.1:58606 Closing +[Thu Apr 23 18:55:22 2026] 127.0.0.1:47492 Accepted +[Thu Apr 23 18:55:50 2026] 127.0.0.1:47492 Closing +[Thu Apr 23 18:55:50 2026] 127.0.0.1:38644 Accepted +[Thu Apr 23 18:57:02 2026] 127.0.0.1:38644 Closing +[Thu Apr 23 18:57:02 2026] 127.0.0.1:46294 Accepted +[Thu Apr 23 18:57:23 2026] 127.0.0.1:46294 Closing +[Thu Apr 23 18:57:23 2026] 127.0.0.1:57114 Accepted +[Thu Apr 23 18:57:55 2026] 127.0.0.1:57114 Closing +[Thu Apr 23 18:57:55 2026] 127.0.0.1:40714 Accepted +[Thu Apr 23 18:58:50 2026] 127.0.0.1:40714 Closing +[Thu Apr 23 18:58:50 2026] 127.0.0.1:51770 Accepted +[Thu Apr 23 18:58:59 2026] 127.0.0.1:51770 Closing +[Thu Apr 23 18:58:59 2026] 127.0.0.1:40902 Accepted +[Thu Apr 23 18:59:27 2026] 127.0.0.1:40902 Closing +[Thu Apr 23 18:59:43 2026] 127.0.0.1:43738 Accepted +[Thu Apr 23 18:59:57 2026] 127.0.0.1:43738 Closing +[Thu Apr 23 18:59:58 2026] 127.0.0.1:47412 Accepted +[Thu Apr 23 19:00:02 2026] 127.0.0.1:47412 Closing +[Thu Apr 23 19:00:03 2026] 127.0.0.1:47422 Accepted +[Thu Apr 23 19:00:14 2026] 127.0.0.1:47422 Closing +[Thu Apr 23 19:23:38 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41635) started +[Thu Apr 23 19:23:38 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34429) started +[Thu Apr 23 19:23:38 2026] 127.0.0.1:53904 Accepted +[Thu Apr 23 19:23:38 2026] 127.0.0.1:41596 Accepted +[Thu Apr 23 19:23:39 2026] 127.0.0.1:53904 Closing +[Thu Apr 23 19:23:39 2026] 127.0.0.1:41898 Accepted +[Thu Apr 23 19:23:39 2026] 127.0.0.1:41596 Closing +[Thu Apr 23 19:23:39 2026] 127.0.0.1:33838 Accepted +[Thu Apr 23 19:23:46 2026] 127.0.0.1:33838 Closing +[Thu Apr 23 19:23:46 2026] 127.0.0.1:33852 Accepted +[Thu Apr 23 19:23:49 2026] 127.0.0.1:41898 Closing +[Thu Apr 23 19:23:49 2026] 127.0.0.1:40306 Accepted +[Thu Apr 23 19:23:56 2026] 127.0.0.1:40306 Closing +[Thu Apr 23 19:23:56 2026] 127.0.0.1:40322 Accepted +[Thu Apr 23 19:24:02 2026] 127.0.0.1:33852 Closing +[Thu Apr 23 19:24:02 2026] 127.0.0.1:47950 Accepted +[Thu Apr 23 19:24:03 2026] 127.0.0.1:40322 Closing +[Thu Apr 23 19:24:03 2026] 127.0.0.1:42110 Accepted +[Thu Apr 23 19:24:19 2026] 127.0.0.1:47950 Closing +[Thu Apr 23 19:24:19 2026] 127.0.0.1:37590 Accepted +[Thu Apr 23 19:24:23 2026] 127.0.0.1:42110 Closing +[Thu Apr 23 19:24:24 2026] 127.0.0.1:53044 Accepted +[Thu Apr 23 19:24:33 2026] 127.0.0.1:37590 Closing +[Thu Apr 23 19:24:33 2026] 127.0.0.1:45346 Accepted +[Thu Apr 23 19:24:37 2026] 127.0.0.1:53044 Closing +[Thu Apr 23 19:24:38 2026] 127.0.0.1:44010 Accepted +[Thu Apr 23 19:24:47 2026] 127.0.0.1:45346 Closing +[Thu Apr 23 19:24:47 2026] 127.0.0.1:47316 Accepted +[Thu Apr 23 19:24:51 2026] 127.0.0.1:44010 Closing +[Thu Apr 23 19:24:51 2026] 127.0.0.1:40232 Accepted +[Thu Apr 23 19:24:52 2026] 127.0.0.1:47316 Closing +[Thu Apr 23 19:24:52 2026] 127.0.0.1:40934 Accepted +[Thu Apr 23 19:25:11 2026] 127.0.0.1:40232 Closing +[Thu Apr 23 19:25:11 2026] 127.0.0.1:47766 Accepted +[Thu Apr 23 19:25:13 2026] 127.0.0.1:40934 Closing +[Thu Apr 23 19:25:13 2026] 127.0.0.1:51356 Accepted +[Thu Apr 23 19:25:19 2026] 127.0.0.1:51356 Closing +[Thu Apr 23 19:25:19 2026] 127.0.0.1:52100 Accepted +[Thu Apr 23 19:25:24 2026] 127.0.0.1:47766 Closing +[Thu Apr 23 19:25:28 2026] 127.0.0.1:52100 Closing +[Thu Apr 23 19:25:28 2026] 127.0.0.1:52112 Accepted +[Thu Apr 23 19:25:32 2026] 127.0.0.1:43250 Accepted +[Thu Apr 23 19:25:51 2026] 127.0.0.1:52112 Closing +[Thu Apr 23 19:25:51 2026] 127.0.0.1:34940 Accepted +[Thu Apr 23 19:26:25 2026] 127.0.0.1:34940 Closing +[Thu Apr 23 19:26:25 2026] 127.0.0.1:52990 Accepted +[Thu Apr 23 19:26:27 2026] 127.0.0.1:43250 Closing +[Thu Apr 23 19:26:27 2026] 127.0.0.1:53240 Accepted +[Thu Apr 23 19:26:39 2026] 127.0.0.1:53240 Closing +[Thu Apr 23 19:26:39 2026] 127.0.0.1:37316 Accepted +[Thu Apr 23 19:26:49 2026] 127.0.0.1:52990 Closing +[Thu Apr 23 19:26:51 2026] 127.0.0.1:37316 Closing +[Thu Apr 23 19:26:51 2026] 127.0.0.1:39238 Accepted +[Thu Apr 23 19:26:57 2026] 127.0.0.1:53744 Accepted +[Thu Apr 23 19:27:07 2026] 127.0.0.1:39238 Closing +[Thu Apr 23 19:27:07 2026] 127.0.0.1:44008 Accepted +[Thu Apr 23 19:27:14 2026] 127.0.0.1:44008 Closing +[Thu Apr 23 19:27:14 2026] 127.0.0.1:60690 Accepted +[Thu Apr 23 19:27:44 2026] 127.0.0.1:60690 Closing +[Thu Apr 23 19:27:44 2026] 127.0.0.1:42122 Accepted +[Thu Apr 23 19:27:46 2026] 127.0.0.1:53744 Closing +[Thu Apr 23 19:27:46 2026] 127.0.0.1:45324 Accepted +[Thu Apr 23 19:27:58 2026] 127.0.0.1:45324 Closing +[Thu Apr 23 19:27:58 2026] 127.0.0.1:55480 Accepted +[Thu Apr 23 19:28:04 2026] 127.0.0.1:42122 Closing +[Thu Apr 23 19:28:04 2026] 127.0.0.1:37980 Accepted +[Thu Apr 23 19:28:11 2026] 127.0.0.1:55480 Closing +[Thu Apr 23 19:28:11 2026] 127.0.0.1:33676 Accepted +[Thu Apr 23 19:28:28 2026] 127.0.0.1:33676 Closing +[Thu Apr 23 19:28:28 2026] 127.0.0.1:51506 Accepted +[Thu Apr 23 19:28:46 2026] 127.0.0.1:37980 Closing +[Thu Apr 23 19:28:57 2026] 127.0.0.1:37352 Accepted +[Thu Apr 23 19:28:58 2026] 127.0.0.1:51506 Closing +[Thu Apr 23 19:28:58 2026] 127.0.0.1:50856 Accepted +[Thu Apr 23 19:28:58 2026] 127.0.0.1:37352 Closing +[Thu Apr 23 19:28:58 2026] 127.0.0.1:37358 Accepted +[Thu Apr 23 19:29:04 2026] 127.0.0.1:37358 Closing +[Thu Apr 23 19:29:04 2026] 127.0.0.1:45058 Accepted +[Thu Apr 23 19:29:10 2026] 127.0.0.1:45058 Closing +[Thu Apr 23 19:29:22 2026] 127.0.0.1:50856 Closing +[Thu Apr 23 19:29:22 2026] 127.0.0.1:38996 Accepted +[Thu Apr 23 19:30:02 2026] 127.0.0.1:38996 Closing +[Thu Apr 23 19:30:10 2026] 127.0.0.1:59878 Accepted +[Thu Apr 23 19:30:16 2026] 127.0.0.1:59878 Closing +[Mon Apr 27 15:43:00 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39017) started +[Mon Apr 27 15:43:00 2026] 127.0.0.1:43538 Accepted +[Mon Apr 27 15:43:02 2026] 127.0.0.1:43538 Closing +[Mon Apr 27 15:43:02 2026] 127.0.0.1:59780 Accepted +[Mon Apr 27 15:43:09 2026] 127.0.0.1:59780 Closing +[Mon Apr 27 15:43:09 2026] 127.0.0.1:59790 Accepted +[Mon Apr 27 15:43:21 2026] 127.0.0.1:59790 Closing +[Mon Apr 27 15:43:21 2026] 127.0.0.1:55448 Accepted +[Mon Apr 27 15:43:37 2026] 127.0.0.1:55448 Closing +[Mon Apr 27 15:43:37 2026] 127.0.0.1:39854 Accepted +[Mon Apr 27 15:43:49 2026] 127.0.0.1:39854 Closing +[Mon Apr 27 15:43:49 2026] 127.0.0.1:40450 Accepted +[Mon Apr 27 15:44:00 2026] 127.0.0.1:40450 Closing +[Mon Apr 27 15:44:00 2026] 127.0.0.1:37108 Accepted +[Mon Apr 27 15:44:04 2026] 127.0.0.1:37108 Closing +[Mon Apr 27 15:44:04 2026] 127.0.0.1:51378 Accepted +[Mon Apr 27 15:44:20 2026] 127.0.0.1:51378 Closing +[Mon Apr 27 15:44:20 2026] 127.0.0.1:57924 Accepted +[Mon Apr 27 15:44:26 2026] 127.0.0.1:57924 Closing +[Mon Apr 27 15:44:26 2026] 127.0.0.1:36924 Accepted +[Mon Apr 27 15:44:33 2026] 127.0.0.1:36924 Closing +[Mon Apr 27 15:44:33 2026] 127.0.0.1:41488 Accepted +[Mon Apr 27 15:44:50 2026] 127.0.0.1:41488 Closing +[Mon Apr 27 15:44:50 2026] 127.0.0.1:42138 Accepted +[Mon Apr 27 15:45:06 2026] 127.0.0.1:42138 Closing +[Mon Apr 27 15:45:06 2026] 127.0.0.1:56450 Accepted +[Mon Apr 27 15:45:24 2026] 127.0.0.1:56450 Closing +[Mon Apr 27 15:45:31 2026] 127.0.0.1:50392 Accepted +[Mon Apr 27 15:45:45 2026] 127.0.0.1:50392 Closing +[Mon Apr 27 15:45:51 2026] 127.0.0.1:44926 Accepted +[Mon Apr 27 15:46:21 2026] 127.0.0.1:44926 Closing +[Mon Apr 27 15:46:21 2026] 127.0.0.1:34068 Accepted +[Mon Apr 27 15:46:29 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39779) started +[Mon Apr 27 15:46:29 2026] 127.0.0.1:36812 Accepted +[Mon Apr 27 15:46:30 2026] 127.0.0.1:34068 Closing +[Mon Apr 27 15:46:30 2026] 127.0.0.1:45262 Accepted +[Mon Apr 27 15:46:31 2026] 127.0.0.1:36812 Closing +[Mon Apr 27 15:46:31 2026] 127.0.0.1:53156 Accepted +[Mon Apr 27 15:46:39 2026] 127.0.0.1:45262 Closing +[Mon Apr 27 15:46:39 2026] 127.0.0.1:45266 Accepted +[Mon Apr 27 15:46:44 2026] 127.0.0.1:53156 Closing +[Mon Apr 27 15:46:52 2026] 127.0.0.1:45266 Closing +[Mon Apr 27 15:46:52 2026] 127.0.0.1:37766 Accepted +[Mon Apr 27 15:47:10 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39511) started +[Mon Apr 27 15:47:11 2026] 127.0.0.1:41892 Accepted +[Mon Apr 27 15:47:14 2026] 127.0.0.1:41892 Closing +[Mon Apr 27 15:47:14 2026] 127.0.0.1:41904 Accepted +[Mon Apr 27 15:47:17 2026] 127.0.0.1:37766 Closing +[Mon Apr 27 15:47:17 2026] 127.0.0.1:36058 Accepted +[Mon Apr 27 15:47:19 2026] 127.0.0.1:41904 Closing +[Mon Apr 27 15:47:19 2026] 127.0.0.1:36924 Accepted +[Mon Apr 27 15:47:26 2026] 127.0.0.1:36058 Closing +[Mon Apr 27 15:47:26 2026] 127.0.0.1:51896 Accepted +[Mon Apr 27 15:47:31 2026] 127.0.0.1:36924 Closing +[Mon Apr 27 15:47:31 2026] 127.0.0.1:34276 Accepted +[Mon Apr 27 15:47:46 2026] 127.0.0.1:34276 Closing +[Mon Apr 27 15:47:46 2026] 127.0.0.1:37246 Accepted +[Mon Apr 27 15:47:57 2026] 127.0.0.1:51896 Closing +[Mon Apr 27 15:47:59 2026] 127.0.0.1:37246 Closing +[Mon Apr 27 15:47:59 2026] 127.0.0.1:36472 Accepted +[Mon Apr 27 15:48:03 2026] 127.0.0.1:33350 Accepted +[Mon Apr 27 15:48:07 2026] 127.0.0.1:36472 Closing +[Mon Apr 27 15:48:07 2026] 127.0.0.1:56178 Accepted +[Mon Apr 27 15:48:07 2026] 127.0.0.1:33350 Closing +[Mon Apr 27 15:48:10 2026] 127.0.0.1:40096 Accepted +[Mon Apr 27 15:48:13 2026] 127.0.0.1:56178 Closing +[Mon Apr 27 15:48:13 2026] 127.0.0.1:55944 Accepted +[Mon Apr 27 15:48:19 2026] 127.0.0.1:40096 Closing +[Mon Apr 27 15:48:19 2026] 127.0.0.1:40104 Accepted +[Mon Apr 27 15:48:28 2026] 127.0.0.1:40104 Closing +[Mon Apr 27 15:48:28 2026] 127.0.0.1:55654 Accepted +[Mon Apr 27 15:48:30 2026] 127.0.0.1:55944 Closing +[Mon Apr 27 15:48:30 2026] 127.0.0.1:41444 Accepted +[Mon Apr 27 15:48:33 2026] 127.0.0.1:55654 Closing +[Mon Apr 27 15:48:33 2026] 127.0.0.1:54932 Accepted +[Mon Apr 27 15:48:36 2026] 127.0.0.1:41444 Closing +[Mon Apr 27 15:48:36 2026] 127.0.0.1:41450 Accepted +[Mon Apr 27 15:48:39 2026] 127.0.0.1:54932 Closing +[Mon Apr 27 15:48:39 2026] 127.0.0.1:54942 Accepted +[Mon Apr 27 15:48:46 2026] 127.0.0.1:41450 Closing +[Mon Apr 27 15:48:46 2026] 127.0.0.1:42874 Accepted +[Mon Apr 27 15:48:46 2026] 127.0.0.1:54942 Closing +[Mon Apr 27 15:48:46 2026] 127.0.0.1:49554 Accepted +[Mon Apr 27 15:49:03 2026] 127.0.0.1:49554 Closing +[Mon Apr 27 15:49:03 2026] 127.0.0.1:51798 Accepted +[Mon Apr 27 15:49:04 2026] 127.0.0.1:42874 Closing +[Mon Apr 27 15:49:04 2026] 127.0.0.1:56176 Accepted +[Mon Apr 27 15:49:11 2026] 127.0.0.1:51798 Closing +[Mon Apr 27 15:49:11 2026] 127.0.0.1:47548 Accepted +[Mon Apr 27 15:49:22 2026] 127.0.0.1:56176 Closing +[Mon Apr 27 15:49:22 2026] 127.0.0.1:52542 Accepted +[Mon Apr 27 15:49:24 2026] 127.0.0.1:47548 Closing +[Mon Apr 27 15:49:24 2026] 127.0.0.1:39848 Accepted +[Mon Apr 27 15:49:36 2026] 127.0.0.1:39848 Closing +[Mon Apr 27 15:49:43 2026] 127.0.0.1:60636 Accepted +[Mon Apr 27 15:49:43 2026] 127.0.0.1:52542 Closing +[Mon Apr 27 15:49:50 2026] 127.0.0.1:43864 Accepted +[Mon Apr 27 15:50:03 2026] 127.0.0.1:43864 Closing +[Mon Apr 27 15:50:07 2026] 127.0.0.1:60636 Closing +[Mon Apr 27 15:50:07 2026] 127.0.0.1:48092 Accepted +[Mon Apr 27 15:50:11 2026] 127.0.0.1:38378 Accepted +[Mon Apr 27 15:50:26 2026] 127.0.0.1:48092 Closing +[Mon Apr 27 15:50:26 2026] 127.0.0.1:51390 Accepted +[Mon Apr 27 15:50:41 2026] 127.0.0.1:38378 Closing +[Mon Apr 27 15:50:49 2026] 127.0.0.1:38158 Accepted +[Mon Apr 27 15:50:54 2026] 127.0.0.1:51390 Closing +[Mon Apr 27 15:50:54 2026] 127.0.0.1:33842 Accepted +[Mon Apr 27 15:50:56 2026] 127.0.0.1:38158 Closing +[Mon Apr 27 15:51:00 2026] 127.0.0.1:36832 Accepted +[Mon Apr 27 15:51:13 2026] 127.0.0.1:36832 Closing +[Mon Apr 27 15:51:13 2026] 127.0.0.1:39172 Accepted +[Mon Apr 27 15:51:19 2026] 127.0.0.1:33842 Closing +[Mon Apr 27 15:51:19 2026] 127.0.0.1:47144 Accepted +[Mon Apr 27 15:51:25 2026] 127.0.0.1:39172 Closing +[Mon Apr 27 15:51:25 2026] 127.0.0.1:33908 Accepted +[Mon Apr 27 15:51:32 2026] 127.0.0.1:47144 Closing +[Mon Apr 27 15:51:32 2026] 127.0.0.1:35586 Accepted +[Mon Apr 27 15:51:39 2026] 127.0.0.1:33908 Closing +[Mon Apr 27 15:51:39 2026] 127.0.0.1:49634 Accepted +[Mon Apr 27 15:51:49 2026] 127.0.0.1:49634 Closing +[Mon Apr 27 15:51:49 2026] 127.0.0.1:35846 Accepted +[Mon Apr 27 15:52:02 2026] 127.0.0.1:35846 Closing +[Mon Apr 27 15:52:02 2026] 127.0.0.1:40002 Accepted +[Mon Apr 27 15:52:03 2026] 127.0.0.1:35586 Closing +[Mon Apr 27 15:52:09 2026] 127.0.0.1:38974 Accepted +[Mon Apr 27 15:52:22 2026] 127.0.0.1:40002 Closing +[Mon Apr 27 15:52:22 2026] 127.0.0.1:39160 Accepted +[Mon Apr 27 15:52:22 2026] 127.0.0.1:38974 Closing +[Mon Apr 27 15:52:23 2026] 127.0.0.1:50508 Accepted +[Mon Apr 27 15:52:31 2026] 127.0.0.1:50508 Closing +[Mon Apr 27 15:52:31 2026] 127.0.0.1:34868 Accepted +[Mon Apr 27 15:52:33 2026] 127.0.0.1:39160 Closing +[Mon Apr 27 15:52:33 2026] 127.0.0.1:51124 Accepted +[Mon Apr 27 15:52:45 2026] 127.0.0.1:34868 Closing +[Mon Apr 27 15:52:51 2026] 127.0.0.1:51124 Closing +[Mon Apr 27 15:52:51 2026] 127.0.0.1:38142 Accepted +[Mon Apr 27 15:52:58 2026] 127.0.0.1:40286 Accepted +[Mon Apr 27 15:53:08 2026] 127.0.0.1:38142 Closing +[Mon Apr 27 15:53:15 2026] 127.0.0.1:47064 Accepted +[Mon Apr 27 15:53:43 2026] 127.0.0.1:47064 Closing +[Mon Apr 27 15:53:43 2026] 127.0.0.1:40368 Accepted +[Mon Apr 27 15:54:10 2026] 127.0.0.1:40286 Closing +[Mon Apr 27 15:54:12 2026] 127.0.0.1:40368 Closing +[Mon Apr 27 15:54:12 2026] 127.0.0.1:59302 Accepted +[Mon Apr 27 15:54:44 2026] 127.0.0.1:59302 Closing +[Mon Apr 27 15:54:51 2026] 127.0.0.1:55188 Accepted +[Mon Apr 27 15:55:05 2026] 127.0.0.1:55188 Closing +[Mon Apr 27 15:55:06 2026] 127.0.0.1:54998 Accepted +[Mon Apr 27 15:55:14 2026] 127.0.0.1:54998 Closing +[Mon Apr 27 15:55:14 2026] 127.0.0.1:55388 Accepted +[Mon Apr 27 15:55:27 2026] 127.0.0.1:55388 Closing +[Mon Apr 27 15:55:38 2026] 127.0.0.1:58096 Accepted +[Mon Apr 27 15:56:40 2026] 127.0.0.1:58096 Closing +[Mon Apr 27 15:57:41 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43345) started +[Mon Apr 27 15:57:41 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45337) started +[Mon Apr 27 15:57:41 2026] 127.0.0.1:54434 Accepted +[Mon Apr 27 15:57:41 2026] 127.0.0.1:42766 Accepted +[Mon Apr 27 15:57:46 2026] 127.0.0.1:42766 Closing +[Mon Apr 27 15:57:46 2026] 127.0.0.1:54434 Closing +[Mon Apr 27 15:57:46 2026] 127.0.0.1:42782 Accepted +[Mon Apr 27 15:57:46 2026] 127.0.0.1:54438 Accepted +[Mon Apr 27 15:57:54 2026] 127.0.0.1:54438 Closing +[Mon Apr 27 15:57:54 2026] 127.0.0.1:43210 Accepted +[Mon Apr 27 15:58:08 2026] 127.0.0.1:43210 Closing +[Mon Apr 27 15:58:08 2026] 127.0.0.1:37286 Accepted +[Mon Apr 27 15:58:10 2026] 127.0.0.1:42782 Closing +[Mon Apr 27 15:58:10 2026] 127.0.0.1:57164 Accepted +[Mon Apr 27 15:58:25 2026] 127.0.0.1:37286 Closing +[Mon Apr 27 15:58:25 2026] 127.0.0.1:33692 Accepted +[Mon Apr 27 15:58:31 2026] 127.0.0.1:57164 Closing +[Mon Apr 27 15:58:31 2026] 127.0.0.1:41728 Accepted +[Mon Apr 27 15:58:41 2026] 127.0.0.1:33692 Closing +[Mon Apr 27 15:58:41 2026] 127.0.0.1:44756 Accepted +[Mon Apr 27 15:58:52 2026] 127.0.0.1:44756 Closing +[Mon Apr 27 15:58:52 2026] 127.0.0.1:41988 Accepted +[Mon Apr 27 15:58:59 2026] 127.0.0.1:41988 Closing +[Mon Apr 27 15:58:59 2026] 127.0.0.1:34238 Accepted +[Mon Apr 27 15:59:01 2026] 127.0.0.1:41728 Closing +[Mon Apr 27 15:59:01 2026] 127.0.0.1:41632 Accepted +[Mon Apr 27 15:59:10 2026] 127.0.0.1:41632 Closing +[Mon Apr 27 15:59:10 2026] 127.0.0.1:56798 Accepted +[Mon Apr 27 15:59:17 2026] 127.0.0.1:34238 Closing +[Mon Apr 27 15:59:17 2026] 127.0.0.1:51968 Accepted +[Mon Apr 27 15:59:24 2026] 127.0.0.1:51968 Closing +[Mon Apr 27 15:59:24 2026] 127.0.0.1:38554 Accepted +[Mon Apr 27 15:59:28 2026] 127.0.0.1:56798 Closing +[Mon Apr 27 15:59:28 2026] 127.0.0.1:37742 Accepted +[Mon Apr 27 15:59:33 2026] 127.0.0.1:38554 Closing +[Mon Apr 27 15:59:33 2026] 127.0.0.1:60924 Accepted +[Mon Apr 27 15:59:52 2026] 127.0.0.1:60924 Closing +[Mon Apr 27 15:59:52 2026] 127.0.0.1:43252 Accepted +[Mon Apr 27 15:59:55 2026] 127.0.0.1:37742 Closing +[Mon Apr 27 15:59:55 2026] 127.0.0.1:43598 Accepted +[Mon Apr 27 16:00:08 2026] 127.0.0.1:43598 Closing +[Mon Apr 27 16:00:08 2026] 127.0.0.1:45400 Accepted +[Mon Apr 27 16:00:15 2026] 127.0.0.1:43252 Closing +[Mon Apr 27 16:00:15 2026] 127.0.0.1:33680 Accepted +[Mon Apr 27 16:00:28 2026] 127.0.0.1:45400 Closing +[Mon Apr 27 16:00:28 2026] 127.0.0.1:58188 Accepted +[Mon Apr 27 16:00:38 2026] 127.0.0.1:33680 Closing +[Mon Apr 27 16:00:43 2026] 127.0.0.1:58188 Closing +[Mon Apr 27 16:00:43 2026] 127.0.0.1:52096 Accepted +[Mon Apr 27 16:01:17 2026] 127.0.0.1:52096 Closing +[Mon Apr 27 16:02:10 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37323) started +[Mon Apr 27 16:02:10 2026] 127.0.0.1:35510 Accepted +[Mon Apr 27 16:02:14 2026] 127.0.0.1:35510 Closing +[Mon Apr 27 16:02:14 2026] 127.0.0.1:35512 Accepted +[Mon Apr 27 16:02:34 2026] 127.0.0.1:35512 Closing +[Mon Apr 27 16:02:35 2026] 127.0.0.1:50544 Accepted +[Mon Apr 27 16:02:54 2026] 127.0.0.1:50544 Closing +[Mon Apr 27 16:02:54 2026] 127.0.0.1:34284 Accepted +[Mon Apr 27 16:03:23 2026] 127.0.0.1:34284 Closing +[Mon Apr 27 16:03:23 2026] 127.0.0.1:58504 Accepted +[Mon Apr 27 16:03:35 2026] 127.0.0.1:58504 Closing +[Mon Apr 27 16:03:35 2026] 127.0.0.1:57200 Accepted +[Mon Apr 27 16:03:55 2026] 127.0.0.1:57200 Closing +[Mon Apr 27 16:03:56 2026] 127.0.0.1:37528 Accepted +[Mon Apr 27 16:04:24 2026] 127.0.0.1:37528 Closing +[Mon Apr 27 16:04:24 2026] 127.0.0.1:41972 Accepted +[Mon Apr 27 16:04:48 2026] 127.0.0.1:41972 Closing +[Mon Apr 27 16:04:48 2026] 127.0.0.1:40764 Accepted +[Mon Apr 27 16:05:10 2026] 127.0.0.1:40764 Closing +[Mon Apr 27 16:05:10 2026] 127.0.0.1:51052 Accepted +[Mon Apr 27 16:05:25 2026] 127.0.0.1:51052 Closing +[Mon Apr 27 16:05:25 2026] 127.0.0.1:49526 Accepted +[Mon Apr 27 16:05:58 2026] 127.0.0.1:49526 Closing +[Mon Apr 27 16:05:58 2026] 127.0.0.1:54380 Accepted +[Mon Apr 27 16:06:10 2026] 127.0.0.1:54380 Closing +[Mon Apr 27 16:06:10 2026] 127.0.0.1:38446 Accepted +[Mon Apr 27 16:06:33 2026] 127.0.0.1:38446 Closing +[Mon Apr 27 16:07:01 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46263) started +[Mon Apr 27 16:07:01 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44107) started +[Mon Apr 27 16:07:01 2026] 127.0.0.1:57266 Accepted +[Mon Apr 27 16:07:01 2026] 127.0.0.1:47356 Accepted +[Mon Apr 27 16:07:05 2026] 127.0.0.1:57266 Closing +[Mon Apr 27 16:07:05 2026] 127.0.0.1:57282 Accepted +[Mon Apr 27 16:07:05 2026] 127.0.0.1:47356 Closing +[Mon Apr 27 16:07:05 2026] 127.0.0.1:47362 Accepted +[Mon Apr 27 16:07:21 2026] 127.0.0.1:47362 Closing +[Mon Apr 27 16:07:37 2026] 127.0.0.1:57282 Closing +[Mon Apr 27 16:07:38 2026] 127.0.0.1:41372 Accepted +[Mon Apr 27 16:07:53 2026] 127.0.0.1:41372 Closing +[Mon Apr 27 16:07:53 2026] 127.0.0.1:39536 Accepted +[Mon Apr 27 16:08:09 2026] 127.0.0.1:39536 Closing +[Mon Apr 27 16:08:09 2026] 127.0.0.1:33514 Accepted +[Mon Apr 27 16:08:28 2026] 127.0.0.1:33514 Closing +[Mon Apr 27 16:08:28 2026] 127.0.0.1:59450 Accepted +[Mon Apr 27 16:08:58 2026] 127.0.0.1:59450 Closing +[Mon Apr 27 16:08:58 2026] 127.0.0.1:40070 Accepted +[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing +[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted +[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing +[Tue May 26 09:18:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35029) started +[Tue May 26 09:18:49 2026] 127.0.0.1:35080 Accepted +[Tue May 26 09:18:53 2026] 127.0.0.1:35080 Closing +[Tue May 26 09:18:53 2026] 127.0.0.1:35088 Accepted +[Tue May 26 09:18:56 2026] 127.0.0.1:35088 Closing +[Tue May 26 09:18:58 2026] 127.0.0.1:39708 Accepted +[Tue May 26 09:19:01 2026] 127.0.0.1:39708 Closing +[Tue May 26 09:19:02 2026] 127.0.0.1:39714 Accepted +[Tue May 26 09:19:06 2026] 127.0.0.1:39714 Closing +[Tue May 26 09:19:05 2026] 127.0.0.1:38256 Accepted +[Tue May 26 09:19:08 2026] 127.0.0.1:38256 Closing +[Tue May 26 09:19:09 2026] 127.0.0.1:38268 Accepted +[Tue May 26 09:19:12 2026] 127.0.0.1:38268 Closing +[Tue May 26 09:19:13 2026] 127.0.0.1:37926 Accepted +[Tue May 26 09:19:17 2026] 127.0.0.1:37926 Closing +[Tue May 26 09:21:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33343) started +[Tue May 26 09:21:25 2026] 127.0.0.1:53732 Accepted +[Tue May 26 09:21:27 2026] 127.0.0.1:53732 Closing +[Tue May 26 09:21:27 2026] 127.0.0.1:55472 Accepted +[Tue May 26 09:21:30 2026] 127.0.0.1:55472 Closing +[Tue May 26 09:21:32 2026] 127.0.0.1:55484 Accepted +[Tue May 26 09:21:35 2026] 127.0.0.1:55484 Closing +[Tue May 26 09:21:36 2026] 127.0.0.1:44478 Accepted +[Tue May 26 09:21:40 2026] 127.0.0.1:44478 Closing +[Tue May 26 09:21:41 2026] 127.0.0.1:44486 Accepted +[Tue May 26 09:21:46 2026] 127.0.0.1:44486 Closing +[Tue May 26 09:21:47 2026] 127.0.0.1:50508 Accepted +[Tue May 26 09:21:53 2026] 127.0.0.1:50508 Closing +[Tue May 26 09:21:55 2026] 127.0.0.1:42034 Accepted +[Tue May 26 09:22:00 2026] 127.0.0.1:42034 Closing +[Tue May 26 09:23:59 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41083) started +[Tue May 26 09:23:59 2026] 127.0.0.1:45896 Accepted +[Tue May 26 09:24:04 2026] 127.0.0.1:45896 Closing +[Tue May 26 09:24:04 2026] 127.0.0.1:45908 Accepted +[Tue May 26 09:24:07 2026] 127.0.0.1:45908 Closing +[Tue May 26 09:25:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38485) started +[Tue May 26 09:25:43 2026] 127.0.0.1:34288 Accepted +[Tue May 26 09:25:47 2026] 127.0.0.1:34288 Closing +[Tue May 26 09:25:47 2026] 127.0.0.1:34290 Accepted +[Tue May 26 09:25:51 2026] 127.0.0.1:34290 Closing +[Tue May 26 09:25:51 2026] 127.0.0.1:41802 Accepted +[Tue May 26 09:25:55 2026] 127.0.0.1:41802 Closing +[Tue May 26 09:27:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37463) started +[Tue May 26 09:27:24 2026] 127.0.0.1:43206 Accepted +[Tue May 26 09:27:26 2026] 127.0.0.1:43206 Closing +[Tue May 26 09:27:26 2026] 127.0.0.1:43212 Accepted +[Tue May 26 09:27:33 2026] 127.0.0.1:43212 Closing +[Tue May 26 09:27:34 2026] 127.0.0.1:51102 Accepted +[Tue May 26 09:27:39 2026] 127.0.0.1:51102 Closing +[Tue May 26 09:28:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46559) started +[Tue May 26 09:28:26 2026] 127.0.0.1:59372 Accepted +[Tue May 26 09:28:28 2026] 127.0.0.1:59372 Closing +[Tue May 26 09:28:28 2026] 127.0.0.1:59378 Accepted +[Tue May 26 09:28:30 2026] 127.0.0.1:59378 Closing +[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Accepted +[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Closing +[Tue May 26 09:28:33 2026] 127.0.0.1:54850 Accepted +[Tue May 26 09:28:34 2026] 127.0.0.1:54850 Closing +[Tue May 26 09:28:35 2026] 127.0.0.1:54854 Accepted +[Tue May 26 09:28:36 2026] 127.0.0.1:54854 Closing +[Tue May 26 09:28:36 2026] 127.0.0.1:54864 Accepted +[Tue May 26 09:28:41 2026] 127.0.0.1:54864 Closing +[Tue May 26 09:28:42 2026] 127.0.0.1:52868 Accepted +[Tue May 26 09:28:46 2026] 127.0.0.1:52868 Closing +[Tue May 26 09:30:14 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41205) started +[Tue May 26 09:30:14 2026] 127.0.0.1:56928 Accepted +[Tue May 26 09:30:15 2026] 127.0.0.1:56928 Closing +[Tue May 26 09:30:15 2026] 127.0.0.1:56938 Accepted +[Tue May 26 09:30:18 2026] 127.0.0.1:56938 Closing +[Tue May 26 09:30:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44523) started +[Tue May 26 09:30:49 2026] 127.0.0.1:54004 Accepted +[Tue May 26 09:30:52 2026] 127.0.0.1:54004 Closing +[Tue May 26 09:30:52 2026] 127.0.0.1:38720 Accepted +[Tue May 26 09:30:51 2026] 127.0.0.1:38720 Closing +[Tue May 26 09:30:53 2026] 127.0.0.1:38734 Accepted +[Tue May 26 09:30:56 2026] 127.0.0.1:38734 Closing +[Tue May 26 09:30:57 2026] 127.0.0.1:38738 Accepted +[Tue May 26 09:31:00 2026] 127.0.0.1:38738 Closing +[Tue May 26 09:31:01 2026] 127.0.0.1:38278 Accepted +[Tue May 26 09:31:02 2026] 127.0.0.1:38278 Closing +[Tue May 26 09:31:03 2026] 127.0.0.1:38284 Accepted +[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing +[Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted +[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing diff --git a/services/nginx/app/build/logs/api-server.out.log b/services/nginx/app/build/logs/api-server.out.log new file mode 100644 index 00000000..c070cd55 --- /dev/null +++ b/services/nginx/app/build/logs/api-server.out.log @@ -0,0 +1,2016 @@ +
+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 +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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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 +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
+>Unknown on line 0
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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 +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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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
+
+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 + + + + it respects the 1 request per second Shelly gate for back-to-back requestsException: Shelly rate limit gate wait timed out +at modules\selfserve\traits\selfserve_lane_relay_controller_t.php:489 +at modules\selfserve\traits\selfserve_lane_relay_controller_t.php:472 +at modules\selfserve\traits\selfserve_lane_relay_controller_t.php:689 +at modules\selfserve\traits\selfserve_lane_relay_controller_t.php:651 +at modules\selfserve\traits\selfserve_lane_relay_controller_t.php:119 +at modules\selfserve\traits\selfserve_lane_relay_controller_t.php:83 +at tests\Unit\Selfserve\SelfserveLaneRelayShellyBatchingTest.php:311 + + + diff --git a/services/nginx/app/build/selfserve-stop-suite.xml b/services/nginx/app/build/selfserve-stop-suite.xml new file mode 100644 index 00000000..e69de29b diff --git a/services/nginx/app/classes/application_write_freeze.php b/services/nginx/app/classes/application_write_freeze.php new file mode 100644 index 00000000..1f65c201 --- /dev/null +++ b/services/nginx/app/classes/application_write_freeze.php @@ -0,0 +1,110 @@ + $reason, + 'owner' => $owner, + 'created_at' => date('c'), + 'expires_at' => date('c', time() + max(30, $ttlSeconds)), + ]; + + self::writeState($payload); + } + + public static function unfreeze(?string $owner = null): void + { + $state = self::state(); + if ($owner !== null && isset($state['owner']) && $state['owner'] !== $owner) { + return; + } + + $path = self::statePath(); + if (is_file($path)) { + @unlink($path); + } + } + + public static function state(): array + { + $path = self::statePath(); + if (!is_file($path)) { + return []; + } + + $state = json_decode((string)file_get_contents($path), true); + if (!is_array($state)) { + @unlink($path); + return []; + } + + $expiresAt = strtotime((string)($state['expires_at'] ?? '')); + if ($expiresAt !== false && $expiresAt < time()) { + @unlink($path); + return []; + } + + return $state; + } + + public static function isFrozen(): bool + { + return self::state() !== []; + } + + public static function shouldBlock(string $method, string $uri, bool $isCronOrCli): bool + { + if (!self::isFrozen()) { + return false; + } + + if ($isCronOrCli) { + return true; + } + + $method = strtoupper($method); + if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) { + return false; + } + + $path = parse_url($uri, PHP_URL_PATH) ?: ''; + return !str_starts_with($path, '/superuser/replication'); + } + + private static function writeState(array $state): void + { + $path = self::statePath(); + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) { + throw new RuntimeException('Could not create write-freeze directory.'); + } + + $tempPath = tempnam($dir, 'write-freeze-'); + if ($tempPath === false) { + throw new RuntimeException('Could not create write-freeze temp file.'); + } + + try { + file_put_contents($tempPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL, LOCK_EX); + if (!rename($tempPath, $path)) { + throw new RuntimeException('Could not atomically replace write-freeze state.'); + } + } finally { + if (is_file($tempPath)) { + @unlink($tempPath); + } + } + } + + private static function statePath(): string + { + $root = defined('WD') ? WD : dirname(__DIR__); + return $root . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'application-write-freeze.json'; + } +} diff --git a/services/nginx/app/classes/attachment_store.php b/services/nginx/app/classes/attachment_store.php index 2ccffd60..aa9fe4e2 100644 --- a/services/nginx/app/classes/attachment_store.php +++ b/services/nginx/app/classes/attachment_store.php @@ -80,10 +80,9 @@ class attachment_store implements minio_uploads_i public function generateDirectDownloadUrl(string $fileName): string { - $host = $_SERVER['HTTP_HOST'] ?? 'api.truckwash.dk'; - $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['SERVER_PORT'] ?? 0) == 443 ? "https://" : "http://"; + $host = 'https://api.truckwash.io'; // Generate a direct download URL for the given file name - return $protocol . $host . '/files/' . $fileName; + return $host . '/files/' . $fileName; } } \ No newline at end of file diff --git a/services/nginx/app/classes/attachments.php b/services/nginx/app/classes/attachments.php index 1a24a11d..cbf84b64 100644 --- a/services/nginx/app/classes/attachments.php +++ b/services/nginx/app/classes/attachments.php @@ -27,18 +27,40 @@ class attachments implements attachments_i */ public function list(string $type, int $object_id, array $options = []): array { - if (count($options) === 0) { - $options = ['id', 'content', 'created_at', 'object_id', 'object_type', 'created_at', 'updated_at']; // Default fields to return + $rows = $this->fetchAttachmentRows($type, [$object_id], $options); + return array_map(fn(array $row): attachment => $this->toAttachment($row), $rows); + } + + /** + * List attachments for multiple objects in one query. + * + * @param string $type + * @param int[] $object_ids + * @param array $options + * @return array + */ + public function listMany(string $type, array $object_ids, array $options = []): array + { + $object_ids = array_values(array_unique(array_filter(array_map('intval', $object_ids), static fn(int $id): bool => $id > 0))); + if (empty($object_ids)) { + return []; } - return array_map(function ($item) { - $item = (object)$item; - $item->content = json_decode($item->content, true); // Decode JSON content - return (new attachment())->populate((object)$item); - }, (new object_attachments_o())->getFieldsWhere([ - 'object_type' => $type, - 'object_id' => $object_id, - 'deleted_at' => null - ], $options)); + + $rows = $this->fetchAttachmentRows($type, $object_ids, $options); + $grouped = []; + foreach ($object_ids as $object_id) { + $grouped[$object_id] = []; + } + + foreach ($rows as $row) { + $object_id = (int)($row['object_id'] ?? 0); + if (!isset($grouped[$object_id])) { + $grouped[$object_id] = []; + } + $grouped[$object_id][] = $this->toAttachment($row); + } + + return $grouped; } /** @@ -107,4 +129,29 @@ class attachments implements attachments_i } return false; } -} \ No newline at end of file + + protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array + { + $options = $this->normalizeAttachmentOptions($options); + return (new object_attachments_o())->getFieldsWhereIn([ + 'object_type' => $type, + 'object_id' => $object_ids, + 'deleted_at' => null + ], $options); + } + + protected function toAttachment(array $item): attachment + { + $payload = (object)$item; + $payload->content = json_decode((string)$payload->content, true); + return (new attachment())->populate($payload); + } + + protected function normalizeAttachmentOptions(array $options): array + { + if (count($options) === 0) { + return ['id', 'content', 'created_at', 'object_id', 'object_type', 'created_at', 'updated_at']; + } + return $options; + } +} diff --git a/services/nginx/app/classes/authentication.php b/services/nginx/app/classes/authentication.php index 0eebf25c..2a75ee8b 100644 --- a/services/nginx/app/classes/authentication.php +++ b/services/nginx/app/classes/authentication.php @@ -6,12 +6,38 @@ use classes\totp; use Exception; use interfaces\authentication_i; use objects\plate_scanners_o; +use objects\subuser_grants_o; use objects\tokens_o; use objects\users_o; use objects\subusers_o; class authentication implements authentication_i { + private function touchResolvedUserSession(users_o $user, string $token): void + { + if (trim($token) === '') { + return; + } + + try { + (new system_session_activity_tracker())->touchUser($user, $token); + } catch (\Throwable) { + // Session tracking must never block authentication resolution. + } + } + + private function touchResolvedSubuserSession(subusers_o $subuser, string $token, int|null $customerNumberContext = null): void + { + if (trim($token) === '') { + return; + } + + try { + (new system_session_activity_tracker())->touchSubuser($subuser, $token, $customerNumberContext); + } catch (\Throwable) { + // Session tracking must never block authentication resolution. + } + } /** * @throws Exception @@ -100,9 +126,16 @@ class authentication implements authentication_i public function validate_token(string $token): bool { // First: try validating as a classic user auth token - $dbToken = (new tokens_o())->getToken($token); - if ($dbToken && $dbToken->id) { - return true; + try { + $dbToken = (new tokens_o())->getToken($token); + if ($dbToken && $dbToken->id) { + $type = $dbToken->type->value(); + if ($type === 'AUTH_TOKEN' || $type === 'AUTH_TOKEN_SUBUSER') { + return true; + } + } + } catch (Exception) { + // Ignore and continue to subuser session validation } // Fallback: try validating as a subuser session token $subuser = (new subusers_o())->getSubuserBySessionToken($token); @@ -125,26 +158,44 @@ class authentication implements authentication_i if (!isset($headers['Authorization'])) { return false; } - $token = $headers['Authorization']; + $rawToken = $headers['Authorization']; // Strip the Bearer prefix - $token = str_replace('Bearer ', '', $token); + $rawToken = str_replace('Bearer ', '', $rawToken); // Get the token from the database - $token = (new tokens_o())->getToken($token); + try { + $token = (new tokens_o())->getToken($rawToken); + } catch (Exception) { + return false; + } // Check if the token exists if (!$token->id) { return false; } + if ($token->type->value() !== 'AUTH_TOKEN' && $token->type->value() !== 'AUTH_TOKEN_SUBUSER') { + return false; + } if ($token->type->value() === "AUTH_TOKEN_SUBUSER") { // Get the customer number from the headers if (!isset($headers['X-Customer-Number'])) { return false; } $customer_number = (int)$headers['X-Customer-Number']; + // Resolve and validate subuser grant for the requested customer context + $subuser = (new subusers_o())->getSubuserBySessionToken($token->token->value()); + if ($subuser === null) { + return false; + } + $grants = (new subuser_grants_o())->getGrantsForSubuserAndCustomer((int)$subuser->id, $customer_number); + if (count($grants) === 0) { + return false; + } // Get the user by the customer number return (new users_o())->getUserByCustomerNumber($customer_number); } // Get the user from the database - return (new users_o())->getUserById($token->user_id->value()); + $user = (new users_o())->getUserById($token->user_id->value()); + $this->touchResolvedUserSession($user, $rawToken); + return $user; } public function get_plate_scanner(): plate_scanners_o|false @@ -197,6 +248,11 @@ class authentication implements authentication_i if ($subuser === null) { return false; } + $customerNumberContext = null; + if (isset($headers['X-Customer-Number'])) { + $customerNumberContext = (int)$headers['X-Customer-Number']; + } + $this->touchResolvedSubuserSession($subuser, $token, $customerNumberContext); return $subuser; } @@ -232,4 +288,4 @@ class authentication implements authentication_i } return (int)$headers['X-Customer-Number']; } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/bird.php b/services/nginx/app/classes/bird.php index cd589477..2255dfc0 100644 --- a/services/nginx/app/classes/bird.php +++ b/services/nginx/app/classes/bird.php @@ -2,28 +2,87 @@ namespace classes; +require_once WD . '/interfaces/bird_i.php'; +require_once WD . '/modules/bird/bird_c.php'; +require_once WD . '/modules/bird/classes/bird_api_client.php'; +require_once WD . '/modules/bird/classes/bird_voice_calls_client.php'; +require_once WD . '/modules/bird/classes/bird_voice_recordings_client.php'; +require_once WD . '/modules/bird/classes/bird_voice_insights_client.php'; +require_once WD . '/modules/bird/classes/bird_flash_calls_client.php'; + use bird\bird_c; +use bird\classes\bird_flash_calls_client; +use bird\classes\bird_voice_calls_client; +use bird\classes\bird_voice_insights_client; +use bird\classes\bird_voice_recordings_client; use Exception; +use interfaces\bird_i; use objects\logs_o; -class bird +class bird implements bird_i { public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28'; public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128'; - private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy', 'completed']; + public const OUTGOING_NUMBER_E164 = '+4532330288'; + public const OUTGOING_NUMBER_RAW = '+45 32 33 02 88'; + + public const OUTGOING_NUMBER = '+4532330288'; + + private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy']; + private const ACCEPTED_CALL_STATUSES = ['accepted', 'ongoing']; + private const TERMINAL_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer', 'completed']; + private const FLASH_GATE_SUCCESS_STATUSES = ['accepted', 'ongoing', 'completed']; + private const FLASH_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer']; /** * Configuration of the Bird module - * @var bird_c|object + * @var bird_c */ - public $config; + public bird_c $config; + + private ?bird_voice_calls_client $voice_calls_client = null; + private ?bird_voice_recordings_client $voice_recordings_client = null; + private ?bird_voice_insights_client $voice_insights_client = null; + private ?bird_flash_calls_client $flash_calls_client = null; public function __construct() { $this->config = new bird_c(); } + private function voiceCallsClient(): bird_voice_calls_client + { + if ($this->voice_calls_client === null) { + $this->voice_calls_client = new bird_voice_calls_client($this); + } + return $this->voice_calls_client; + } + + private function voiceRecordingsClient(): bird_voice_recordings_client + { + if ($this->voice_recordings_client === null) { + $this->voice_recordings_client = new bird_voice_recordings_client($this); + } + return $this->voice_recordings_client; + } + + private function voiceInsightsClient(): bird_voice_insights_client + { + if ($this->voice_insights_client === null) { + $this->voice_insights_client = new bird_voice_insights_client($this); + } + return $this->voice_insights_client; + } + + private function flashCallsClient(): bird_flash_calls_client + { + if ($this->flash_calls_client === null) { + $this->flash_calls_client = new bird_flash_calls_client($this); + } + return $this->flash_calls_client; + } + /** * Ensure module is enabled * @throws Exception @@ -153,6 +212,19 @@ class bird throw new Exception('cURL error: ' . $err); } curl_close($ch); + // Debug slack + $data = json_decode($body, true) ?? []; + $resp = $resp === false ? 'cURL error with no response' : $resp; + $slack_debug_message = "*Bird API Request Debug:*" + . "\nEndpoint: $url" + . "\nMethod: $method" + . "\nStatus: $code" + . "\nPayload Keys: " . implode(',', array_keys($data)) + . "\nResponse: $resp"; + + // Send slack notification for every request for easier debugging of issues in production (can be removed later if too noisy) + $slack = new \classes\slack(); + $slack->send_message($slack_debug_message); return [ 'status_code' => (int)$code, 'body' => $resp, @@ -270,30 +342,50 @@ class bird public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null { - $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId); - return $this->sendPostRequest($base, $payload); + return $this->voiceCallsClient()->createVoiceCall($workspaceId, $channelId, $payload); } public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null { - $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId); - return $this->sendGetRequest($base, $query); + return $this->voiceCallsClient()->listVoiceCalls($workspaceId, $channelId, $query); } public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null { - $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); - return $this->sendGetRequest($base . '/' . rawurlencode($callId)); + return $this->voiceCallsClient()->getVoiceCall($workspaceId, $channelId, $callId); + } + + public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->updateVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_ANSWER', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->answerVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RINGING', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->ringVoiceCall($workspaceId, $channelId, $callId, $payload); } public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null { - $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); - return $this->sendPostRequest($base . '/' . rawurlencode($callId) . '/hangup', $payload); + return $this->voiceCallsClient()->hangupVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_PLAYBACK', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->playbackVoiceCall($workspaceId, $channelId, $callId, $payload); } public function sayMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null @@ -321,7 +413,7 @@ class bird 'timeout' => 1, ...$payload, ]; - return $this->sendPostRequest($this->sayBase($workspaceId, $channelId, $callId), $tmp); + return $this->voiceCallsClient()->sayVoiceCall($workspaceId, $channelId, $callId, $tmp); } public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null @@ -374,7 +466,55 @@ class bird 'input' => 'dtmf', ...$payload, ]; - return $this->sendPostRequest($this->gatherBase($workspaceId, $channelId, $callId), $tmp); + return $this->voiceCallsClient()->gatherVoiceCall($workspaceId, $channelId, $callId, $tmp); + } + + public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_BRIDGE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->bridgeVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORD', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceCallsClient()->recordVoiceCall($workspaceId, $channelId, $callId, $payload); + } + + public function createVoiceCallRecordingSession(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceRecordingsClient()->createVoiceCallRecordingSession($workspaceId, $channelId, $callId, $payload); + } + + public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceRecordingsClient()->listVoiceCallRecordings($workspaceId, $channelId, $callId, $query); + } + + public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId); + return $this->voiceRecordingsClient()->getVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId); + } + + public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId); + return $this->voiceRecordingsClient()->updateVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId, $payload); + } + + public function getVoiceCallInsights(string $workspaceId, string $channelId, string $callId): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_INSIGHTS_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->voiceInsightsClient()->getVoiceCallInsights($workspaceId, $channelId, $callId); + } + + public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null + { + $this->logBirdAction('BIRD_VOICE_CALL_LOG_LIST', 'workspace=' . $workspaceId); + return $this->voiceInsightsClient()->getVoiceCallsLog($workspaceId, $query); } public function listNumbers(string $workspaceId, array $query = []): array|object|null @@ -395,6 +535,36 @@ class bird return $this->sendDeleteRequest('/workspaces/' . rawurlencode($workspaceId) . '/numbers/' . rawurlencode($numberId)); } + public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null + { + $this->logBirdAction('BIRD_FLASH_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId); + return $this->flashCallsClient()->createFlashCall($workspaceId, $channelId, $payload); + } + + public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null + { + $this->logBirdAction('BIRD_FLASH_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId); + return $this->flashCallsClient()->listFlashCalls($workspaceId, $channelId, $query); + } + + public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null + { + $this->logBirdAction('BIRD_FLASH_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->flashCallsClient()->getFlashCall($workspaceId, $channelId, $callId); + } + + public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + $this->logBirdAction('BIRD_FLASH_CALL_END', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->flashCallsClient()->endFlashCall($workspaceId, $channelId, $callId, $payload); + } + + public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null + { + $this->logBirdAction('BIRD_FLASH_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId); + return $this->flashCallsClient()->hangupFlashCall($workspaceId, $channelId, $payload); + } + public function createOutboundTestCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options = []): array { return $this->executeCallAndHangupWhenAccepted($workspaceId, $channelId, $options, 'BIRD_TEST_OUTBOUND_CALL'); @@ -418,11 +588,7 @@ class bird $targetNumber = self::TEST_OUTBOUND_NUMBER_E164; } $payload['to'] = $targetNumber; - - // Ensure Bird terminates an unanswered call after we've stopped polling for it. - if (!isset($payload['timeout'])) { - $payload['timeout'] = $maxPollSeconds; - } + $payload = $this->normalizeCreateVoiceCallPayload($payload, $maxPollSeconds); $this->logBirdAction( $logPrefix . '_START', @@ -443,18 +609,18 @@ class bird } $lastCall = null; - $acceptedStates = ['accepted', 'ongoing']; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { $current = $this->getVoiceCall($workspaceId, $channelId, $callId); $lastCall = $current; $status = $this->extractStatus($current); + $normalizedStatus = $status === null ? null : strtolower($status); $this->logBirdAction( $logPrefix . '_POLL', 'call=' . $callId . ' attempt=' . $attempt . '/' . $maxAttempts . ' status=' . ($status ?? 'unknown') ); - if ($status !== null && in_array(strtolower($status), $acceptedStates, true)) { + if ($normalizedStatus !== null && in_array($normalizedStatus, self::ACCEPTED_CALL_STATUSES, true)) { $hangupPayload = []; if (isset($options['hangupCause']) && is_string($options['hangupCause']) && $options['hangupCause'] !== '') { $normalizedCause = strtolower(trim($options['hangupCause'])); @@ -476,8 +642,26 @@ class bird ]; } + if ($normalizedStatus !== null && in_array($normalizedStatus, self::TERMINAL_GATE_FAILURE_STATUSES, true)) { + $this->logBirdAction( + $logPrefix . '_TERMINAL', + 'call=' . $callId . ' status=' . $status + ); + return [ + 'to' => $targetNumber, + 'to_e164' => $targetNumber, + 'call_id' => $callId, + 'final_status' => $status, + 'hangup_sent' => false, + 'terminal_failure' => true, + 'created_call' => $createResponse, + 'last_call_snapshot' => $lastCall, + 'message' => 'Call reached terminal status before acceptance', + ]; + } + if ($attempt < $maxAttempts) { - sleep($pollIntervalSeconds); + $this->waitForCallPollInterval($pollIntervalSeconds); } } @@ -499,11 +683,24 @@ class bird ]; } + /** + * Hook point for tests to avoid real waiting during call polling. + */ + protected function waitForCallPollInterval(int $pollIntervalSeconds): void + { + sleep($pollIntervalSeconds); + } + private function voiceBase(string $workspaceId, string $channelId): string { return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls'; } + private function flashBase(string $workspaceId, string $channelId): string + { + return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/flashcalls'; + } + private function sayBase(string $workspaceId, string $channelId, string $callId): string { // https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/say @@ -538,6 +735,23 @@ class bird return null; } + protected function extractFrom(array|object|string|null $response): ?string + { + if (is_object($response)) { + if (isset($response->from) && is_string($response->from)) { + return $this->normalizePhoneIdentifier($response->from); + } + return null; + } + if (is_array($response)) { + if (array_key_exists('from', $response) && is_string($response['from'])) { + return $this->normalizePhoneIdentifier($response['from']); + } + return null; + } + return null; + } + private function buildHttpErrorMessage(int $status, string|false|null $response): string { $base = 'Bird API request failed with status ' . $status; @@ -626,12 +840,24 @@ class bird public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout) { - $ws = $this->config->workplaceId->getVariableValue(); - $ch = $this->config->channelId->getVariableValue(); + $ws = $this->getConfiguredWorkspaceId(); + $ch = $this->getConfiguredChannelId(); + if ($ws === '') { + throw new Exception('Bird workspaceId is not configured for gate calls'); + } + if ($ch === '') { + throw new Exception('Bird channelId is not configured for gate calls'); + } + $normalizedRingTimeout = $this->normalizeRingTimeoutValue($timeout); + if ($normalizedRingTimeout === null) { + $normalizedRingTimeout = 30; + } $options = [ + 'from' => self::OUTGOING_NUMBER_E164, 'to' => '+' . $countryCode . $phone, - 'maxPollSeconds' => (int)$timeout, + 'maxPollSeconds' => max(5, (int)$timeout), + 'ringTimeout' => $normalizedRingTimeout, ]; $result = $this->executeCallAndHangupWhenAccepted($ws, $ch, $options, 'BIRD_GATE_CALL'); @@ -640,10 +866,211 @@ class bird $msg = $result['message'] ?? 'Failed to call gate and hangup when accepted'; if ($result['timed_out_waiting_for_accepted'] ?? false) { $msg = 'Timed out waiting for gate to accept call'; + } elseif ($result['terminal_failure'] ?? false) { + $status = isset($result['final_status']) ? (string)$result['final_status'] : 'unknown'; + $msg = 'Gate call reached terminal status: ' . $status; } throw new Exception("Failed to call gate (+{$countryCode}{$phone}): " . $msg); } } + + public function callGateViaFlashCall(int $countryCode, int $phone, int $ringTimeout): void + { + $ws = $this->getConfiguredWorkspaceId(); + $ch = $this->getConfiguredChannelId(); + if ($ws === '') { + throw new Exception('Bird workspaceId is not configured for gate flash calls'); + } + if ($ch === '') { + throw new Exception('Bird channelId is not configured for gate flash calls'); + } + + $normalizedRingTimeout = $this->normalizeRingTimeoutValue($ringTimeout); + if ($normalizedRingTimeout === null) { + $normalizedRingTimeout = 30; + } + + $createResponse = $this->createFlashCall($ws, $ch, [ + 'from' => self::OUTGOING_NUMBER_E164, + 'to' => '+' . $countryCode . $phone, + 'ringTimeout' => $normalizedRingTimeout, + ]); + + $expectedFrom = $this->normalizePhoneIdentifier(self::OUTGOING_NUMBER_E164); + $flashFrom = $this->extractFrom($createResponse); + if ($flashFrom === null) { + throw new Exception('Gate flash call did not confirm caller id'); + } + if ($expectedFrom !== null && $flashFrom !== $expectedFrom) { + throw new Exception('Gate flash call used unexpected caller id: ' . $flashFrom); + } + + $initialStatus = $this->extractStatus($createResponse); + $normalizedInitialStatus = $initialStatus === null ? null : strtolower($initialStatus); + if ($normalizedInitialStatus !== null && in_array($normalizedInitialStatus, self::FLASH_GATE_FAILURE_STATUSES, true)) { + throw new Exception('Gate flash call failed with status: ' . $initialStatus); + } + if ($normalizedInitialStatus !== null && in_array($normalizedInitialStatus, self::FLASH_GATE_SUCCESS_STATUSES, true)) { + return; + } + + $callId = $this->extractId($createResponse); + if ($callId === null) { + throw new Exception('Failed to create gate flash call: no call id returned'); + } + + $pollIntervalSeconds = 5; + $maxPollSeconds = max(5, $normalizedRingTimeout + 5); + $maxAttempts = (int)max(1, floor($maxPollSeconds / $pollIntervalSeconds)); + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $current = $this->getFlashCall($ws, $ch, $callId); + $currentFrom = $this->extractFrom($current); + if ($expectedFrom !== null && $currentFrom !== null && $currentFrom !== $expectedFrom) { + throw new Exception('Gate flash call switched to unexpected caller id: ' . $currentFrom); + } + $status = $this->extractStatus($current); + $normalizedStatus = $status === null ? null : strtolower($status); + if ($normalizedStatus !== null && in_array($normalizedStatus, self::FLASH_GATE_SUCCESS_STATUSES, true)) { + return; + } + if ($normalizedStatus !== null && in_array($normalizedStatus, self::FLASH_GATE_FAILURE_STATUSES, true)) { + throw new Exception('Gate flash call failed with status: ' . $status); + } + if ($attempt < $maxAttempts) { + $this->waitForCallPollInterval($pollIntervalSeconds); + } + } + + throw new Exception('Timed out waiting for gate flash call completion'); + } + + public function callGatePreferringFlashCall(int $countryCode, int $phone, int $timeout): void + { + try { + $this->callGateViaFlashCall($countryCode, $phone, $timeout); + return; + } catch (\Throwable $flashError) { + $this->logBirdAction( + 'BIRD_GATE_FLASH_FALLBACK', + 'Flash gate call failed, falling back to regular call. reason=' . $flashError->getMessage(), + 0 + ); + } + + $this->callGateAndHangupWhenAccepted($countryCode, $phone, $timeout); + } + + protected function getConfiguredWorkspaceId(): string + { + if (!is_object($this->config)) { + return ''; + } + $workspaceConfig = null; + if (property_exists($this->config, 'workspaceId')) { + $workspaceConfig = $this->config->workspaceId; + } elseif (property_exists($this->config, 'workplaceId')) { + // Backward compatibility with existing Bird module variable naming. + $workspaceConfig = $this->config->workplaceId; + } + if (!is_object($workspaceConfig) || !method_exists($workspaceConfig, 'getVariableValue')) { + return ''; + } + return $this->normalizeOptionalString($workspaceConfig->getVariableValue()); + } + + protected function getConfiguredChannelId(): string + { + if (!is_object($this->config)) { + return ''; + } + $channelConfig = null; + if (property_exists($this->config, 'channelId')) { + $channelConfig = $this->config->channelId; + } + if (!is_object($channelConfig) || !method_exists($channelConfig, 'getVariableValue')) { + return ''; + } + return $this->normalizeOptionalString($channelConfig->getVariableValue()); + } + + protected function normalizeOptionalString(mixed $value): string + { + if (!is_scalar($value)) { + return ''; + } + $normalized = trim((string)$value); + if ($normalized === '') { + return ''; + } + $lower = strtolower($normalized); + if ($lower === 'undefined' || $lower === 'null') { + return ''; + } + return $normalized; + } + + /** + * Align create-call payload with Bird voice call schema. + * - Map legacy `timeout` to documented `ringTimeout`. + * - Clamp `ringTimeout` to documented [3,120] range. + */ + protected function normalizeCreateVoiceCallPayload(array $payload, int $fallbackRingTimeout): array + { + if (array_key_exists('timeout', $payload) && !array_key_exists('ringTimeout', $payload)) { + $payload['ringTimeout'] = $payload['timeout']; + } + unset($payload['timeout']); + + $normalizedRingTimeout = null; + if (array_key_exists('ringTimeout', $payload)) { + $normalizedRingTimeout = $this->normalizeRingTimeoutValue($payload['ringTimeout']); + } + if ($normalizedRingTimeout === null) { + $normalizedRingTimeout = $this->normalizeRingTimeoutValue($fallbackRingTimeout); + } + if ($normalizedRingTimeout !== null) { + $payload['ringTimeout'] = $normalizedRingTimeout; + } else { + unset($payload['ringTimeout']); + } + + return $payload; + } + + protected function normalizeRingTimeoutValue(mixed $value): ?int + { + if (!is_numeric($value)) { + return null; + } + $timeout = (int)$value; + if ($timeout < 3) { + $timeout = 3; + } + if ($timeout > 120) { + $timeout = 120; + } + return $timeout; + } + + protected function normalizePhoneIdentifier(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '') { + return null; + } + + $digits = preg_replace('/\D+/', '', $trimmed); + if (!is_string($digits) || $digits === '') { + return null; + } + + return '+' . $digits; + } } diff --git a/services/nginx/app/classes/cloud_shelly_transport.php b/services/nginx/app/classes/cloud_shelly_transport.php new file mode 100644 index 00000000..5276cb45 --- /dev/null +++ b/services/nginx/app/classes/cloud_shelly_transport.php @@ -0,0 +1,80 @@ +client ?? new shelly(); + } + + public function requireModuleEnabled(): void + { + $this->client()->requireModuleEnabled(); + } + + public function requireValidSecretKey(): void + { + $this->client()->requireValidSecretKey(); + } + + public function sendPostRequest(string $endpoint, array $data, ?int $department_id = null): array|object|null + { + try { + $response = $this->client()->sendPostRequest($endpoint, $data); + $this->logRelaySignal($endpoint, $data, $department_id, $response, null); + return $response; + } catch (\Throwable $exception) { + $this->logRelaySignal($endpoint, $data, $department_id, null, $exception); + throw $exception; + } + } + + private function logRelaySignal( + string $endpoint, + array $data, + ?int $department_id, + array|object|null $response, + ?\Throwable $exception + ): void { + if (!$this->logRelaySignals || $department_id === null || $department_id <= 0 || !$this->isRelayEndpoint($endpoint)) { + return; + } + + try { + $this->manager()->appendRelayTransportLog( + $department_id, + $endpoint, + $data, + $response, + 'cloud', + $exception?->getMessage() + ); + } catch (\Throwable) { + } + } + + private function isRelayEndpoint(string $endpoint): bool + { + return in_array($endpoint, ['/v2/devices/api/get', '/v2/devices/api/set/switch'], true); + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); + } +} diff --git a/services/nginx/app/classes/coolify.php b/services/nginx/app/classes/coolify.php new file mode 100644 index 00000000..c8840b40 --- /dev/null +++ b/services/nginx/app/classes/coolify.php @@ -0,0 +1,39 @@ +config = new coolify_c(); + } + + /** + * @throws Exception + */ + public function requireModuleEnabled(): void + { + if (!$this->config->enabled->isTrue()) { + throw new Exception('The Coolify module is not enabled'); + } + } + + public function isEnabled(): bool + { + try { + return $this->config->enabled->isTrue(); + } catch (Exception) { + return false; + } + } +} diff --git a/services/nginx/app/classes/coolify_api_client.php b/services/nginx/app/classes/coolify_api_client.php new file mode 100644 index 00000000..cfdeb9ee --- /dev/null +++ b/services/nginx/app/classes/coolify_api_client.php @@ -0,0 +1,264 @@ +baseUrl = self::normalizeBaseUrl($baseUrl); + $this->token = trim($token); + $this->timeoutSeconds = max(1, $timeoutSeconds); + if ($this->baseUrl === '' || $this->token === '') { + throw new RuntimeException('Coolify base URL and API token are required.'); + } + } + + public static function normalizeBaseUrl(string $baseUrl): string + { + $baseUrl = rtrim(trim($baseUrl), '/'); + if ($baseUrl === '') { + return ''; + } + + if (preg_match('#/api/v[0-9]+$#i', $baseUrl) === 1) { + return $baseUrl; + } + + return $baseUrl . '/api/v1'; + } + + public function healthcheck(): array + { + return $this->request('GET', '/health', null, false); + } + + public function version(): array + { + return $this->request('GET', '/version'); + } + + public function listServers(): array + { + return $this->request('GET', '/servers'); + } + + public function listProjects(): array + { + return $this->request('GET', '/projects'); + } + + public function listProjectEnvironments(string $projectUuid): array + { + return $this->request('GET', '/projects/' . rawurlencode($projectUuid) . '/environments'); + } + + public function listServices(): array + { + return $this->request('GET', '/services'); + } + + public function listGithubApps(): array + { + return $this->request('GET', '/github-apps'); + } + + public function getService(string $uuid): array + { + return $this->request('GET', '/services/' . rawurlencode($uuid)); + } + + public function createService(array $payload): array + { + return $this->request('POST', '/services', $payload); + } + + public function createPrivateGithubAppApplication(array $payload): array + { + return $this->request('POST', '/applications/private-github-app', $payload); + } + + public function getApplication(string $uuid): array + { + return $this->request('GET', '/applications/' . rawurlencode($uuid)); + } + + public function updateApplication(string $uuid, array $payload): array + { + return $this->request('PATCH', '/applications/' . rawurlencode($uuid), $payload); + } + + public function updateService(string $uuid, array $payload): array + { + return $this->request('PATCH', '/services/' . rawurlencode($uuid), $payload); + } + + public function updateServiceEnvsBulk(string $uuid, array $env): array + { + if ($env === []) { + return []; + } + + return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [ + 'data' => self::bulkEnvData($env), + ]); + } + + public function updateApplicationEnvsBulk(string $uuid, array $env): array + { + if ($env === []) { + return []; + } + + return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [ + 'data' => self::bulkEnvData($env), + ]); + } + + private static function bulkEnvData(array $env): array + { + $data = []; + foreach ($env as $key => $value) { + $data[] = [ + 'key' => (string)$key, + 'value' => (string)$value, + 'is_preview' => false, + 'is_literal' => true, + 'is_multiline' => str_contains((string)$value, "\n"), + 'is_shown_once' => false, + ]; + } + + return $data; + } + + public function deployResource(string $uuid, bool $force = false): array + { + $path = '/deploy?uuid=' . rawurlencode($uuid) . '&force=' . ($force ? 'true' : 'false'); + return $this->request('GET', $path); + } + + public function startService(string $uuid): array + { + return $this->request('GET', '/services/' . rawurlencode($uuid) . '/start'); + } + + public function restartService(string $uuid): array + { + return $this->request('GET', '/services/' . rawurlencode($uuid) . '/restart'); + } + + public function restartApplication(string $uuid): array + { + return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart'); + } + + public function deleteService(string $uuid): array + { + return $this->request('DELETE', '/services/' . rawurlencode($uuid)); + } + + public function listDeployments(): array + { + return $this->request('GET', '/deployments'); + } + + protected function request(string $method, string $path, ?array $payload = null, bool $versionedApi = true): array + { + $url = ($versionedApi ? $this->baseUrl : $this->apiRootUrl()) . '/' . ltrim($path, '/'); + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize Coolify API request.'); + } + + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->token, + ]; + + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(2, $this->timeoutSeconds)); + curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds); + curl_setopt($curl, CURLOPT_NOSIGNAL, true); + curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); + + if ($payload !== null) { + $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($body === false) { + throw new RuntimeException('Could not encode Coolify API payload.'); + } + $headers[] = 'Content-Type: application/json'; + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + + $raw = curl_exec($curl); + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if ($raw === false) { + throw new RuntimeException('Coolify API request failed: ' . $error); + } + + $decoded = null; + if (trim((string)$raw) !== '') { + $decoded = json_decode((string)$raw, true); + if (!is_array($decoded)) { + $decoded = ['raw' => (string)$raw]; + } + } + + if ($status < 200 || $status >= 300) { + $message = is_array($decoded) + ? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status)) + : ('HTTP ' . $status); + if (is_array($decoded)) { + $details = self::validationErrorSummary($decoded); + if ($details !== '') { + $message .= ': ' . $details; + } + } + throw new RuntimeException('Coolify API request failed: ' . $message); + } + + return is_array($decoded) ? $decoded : []; + } + + private static function validationErrorSummary(array $decoded): string + { + $errors = $decoded['errors'] ?? $decoded['data']['errors'] ?? null; + if (!is_array($errors)) { + return ''; + } + + $parts = []; + foreach ($errors as $field => $messages) { + $fieldName = trim((string)$field); + $fieldPrefix = $fieldName !== '' ? $fieldName . ': ' : ''; + if (is_array($messages)) { + $messages = implode(', ', array_filter(array_map(static fn(mixed $message): string => trim((string)$message), $messages))); + } else { + $messages = trim((string)$messages); + } + if ($messages !== '') { + $parts[] = $fieldPrefix . $messages; + } + } + + return implode('; ', array_slice($parts, 0, 5)); + } + + private function apiRootUrl(): string + { + return preg_replace('#/v[0-9]+$#i', '', $this->baseUrl) ?: $this->baseUrl; + } +} diff --git a/services/nginx/app/classes/coolify_manager.php b/services/nginx/app/classes/coolify_manager.php new file mode 100644 index 00000000..f174ac1f --- /dev/null +++ b/services/nginx/app/classes/coolify_manager.php @@ -0,0 +1,4779 @@ + 'http', + 'listen_port' => 80, + 'destination_port' => 80, + 'health_check' => ['protocol' => 'tcp', 'port' => 80, 'interval' => 15, 'timeout' => 10, 'retries' => 3], + 'http' => ['redirect_http' => false, 'sticky_sessions' => false, 'cookie_name' => 'HCLBSTICKY', 'cookie_lifetime' => 300], + ], + [ + 'protocol' => 'tcp', + 'listen_port' => 443, + 'destination_port' => 443, + 'health_check' => ['protocol' => 'tcp', 'port' => 443, 'interval' => 15, 'timeout' => 10, 'retries' => 3], + ], + ]; + + /** @var callable|null */ + private $clientFactory; + /** @var callable|null */ + private $hetznerClientFactory; + private bool $schemaEnsured = false; + + public function __construct(?callable $clientFactory = null, ?callable $hetznerClientFactory = null) + { + $this->clientFactory = $clientFactory; + $this->hetznerClientFactory = $hetznerClientFactory; + } + + public function summary(): array + { + $this->ensureSchema(); + + return [ + 'generated_at' => date('c'), + 'instances' => $this->listInstances(), + 'targets' => $this->listTargets(), + 'availability' => $this->availabilitySummary(), + 'load_balancer' => $this->loadBalancerSummary(), + ]; + } + + public function listInstances(): array + { + if (!coolify_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $instance): array => $this->publicInstance($instance), + $this->selectRows('SELECT * FROM coolify_instances WHERE deleted_at IS NULL ORDER BY id') + ); + } + + public function createInstance(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $label = trim((string)($input['label'] ?? 'Coolify')); + $baseUrl = trim((string)($input['base_url'] ?? $input['url'] ?? '')); + $apiToken = (string)($input['api_token'] ?? $input['token'] ?? ''); + if ($label === '' || $baseUrl === '' || trim($apiToken) === '') { + throw new RuntimeException('Coolify label, base URL, and API token are required.'); + } + + $this->execute( + "INSERT INTO coolify_instances ( + label, base_url, api_token_secret, default_project_uuid, default_environment_uuid, + default_environment_name, default_server_uuid, default_destination_uuid + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + 'ssssssss', + [ + $label, + rtrim($baseUrl, '/'), + replication_secret_box::encrypt($apiToken), + null, + null, + null, + null, + null, + ] + ); + + $id = $this->insertId(); + $this->setModuleEnabled(true); + $this->audit(null, $id, null, 'instance_created', $actorUserId, 'info', [ + 'label' => $label, + 'base_url' => $baseUrl, + ]); + + return $this->publicInstance($this->getInstance($id)); + } + + public function testInstance(int $instanceId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $instance = $this->getInstance($instanceId); + $startedAt = microtime(true); + + try { + $client = $this->clientForInstance($instance); + $health = $client->healthcheck(); + $version = []; + try { + $version = $client->version(); + } catch (Throwable) { + } + + $result = [ + 'ok' => true, + 'status' => 'ok', + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'health' => $health, + 'version' => $version, + 'checked_at' => date('c'), + ]; + + $this->execute( + "UPDATE coolify_instances SET status = 'ok', last_checked_at = NOW(), last_error = NULL WHERE id = ?", + 'i', + [$instanceId] + ); + $this->audit(null, $instanceId, null, 'instance_tested', $actorUserId, 'info', $result); + + return [ + 'instance' => $this->publicInstance($this->getInstance($instanceId)), + 'test' => $result, + ]; + } catch (Throwable $throwable) { + $result = [ + 'ok' => false, + 'status' => 'down', + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'error' => $throwable->getMessage(), + 'checked_at' => date('c'), + ]; + $this->execute( + "UPDATE coolify_instances SET status = 'down', last_checked_at = NOW(), last_error = ? WHERE id = ?", + 'si', + [$throwable->getMessage(), $instanceId] + ); + $this->audit(null, $instanceId, null, 'instance_test_failed', $actorUserId, 'warning', $result); + + return [ + 'instance' => $this->publicInstance($this->getInstance($instanceId)), + 'test' => $result, + ]; + } + } + + public function discoverInstancePlacement(int $instanceId): array + { + $this->ensureSchema(); + $instance = $this->getInstance($instanceId); + $client = $this->clientForInstance($instance); + $errors = []; + + $servers = []; + try { + $servers = array_map( + fn(array $server): array => $this->publicPlacementServer($server), + $this->coolifyCollection($client->listServers()) + ); + } catch (Throwable $throwable) { + $errors['servers'] = $throwable->getMessage(); + } + + $projects = []; + $environments = []; + try { + $projects = array_map( + fn(array $project): array => $this->publicPlacementProject($project), + $this->coolifyCollection($client->listProjects()) + ); + + foreach ($projects as $project) { + $projectUuid = (string)($project['uuid'] ?? ''); + if ($projectUuid === '') { + continue; + } + + try { + foreach ($this->coolifyCollection($client->listProjectEnvironments($projectUuid)) as $environment) { + $environments[] = $this->publicPlacementEnvironment($environment, $project); + } + } catch (Throwable $throwable) { + $errors['environments'][$projectUuid] = $throwable->getMessage(); + } + } + } catch (Throwable $throwable) { + $errors['projects'] = $throwable->getMessage(); + } + + return [ + 'generated_at' => date('c'), + 'instance' => $this->publicInstance($instance), + 'servers' => array_values(array_filter($servers, static fn(array $server): bool => (string)($server['uuid'] ?? '') !== '')), + 'projects' => array_values(array_filter($projects, static fn(array $project): bool => (string)($project['uuid'] ?? '') !== '')), + 'environments' => array_values(array_filter($environments, static fn(array $environment): bool => (string)($environment['name'] ?? $environment['uuid'] ?? '') !== '')), + 'destination_discovery_supported' => false, + 'errors' => $errors, + ]; + } + + public function listTargets(?string $kind = null): array + { + if (!coolify_schema_bootstrap::tablesExist()) { + return []; + } + + $types = ''; + $params = []; + $where = ['t.deleted_at IS NULL']; + if ($kind !== null && trim($kind) !== '') { + $where[] = 't.kind = ?'; + $types .= 's'; + $params[] = replication_manager::normalizeKind($kind); + } + + $targets = $this->selectRows( + "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url, h.label AS replication_label, + h.host AS replication_host, h.port AS replication_port, h.role AS replication_role, + h.status AS replication_status, h.last_status_json AS replication_last_status_json, + h.last_checked_at AS replication_last_checked_at + FROM coolify_targets t + INNER JOIN coolify_instances i ON i.id = t.instance_id + LEFT JOIN replication_hosts h ON h.id = t.replication_host_id + WHERE " . implode(' AND ', $where) . ' + ORDER BY FIELD(t.kind, \'database\', \'redis\', \'minio\'), t.id', + $types, + $params + ); + + return array_map(fn(array $target): array => $this->publicTarget($target), $targets); + } + + public function createTarget(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $kind = replication_manager::normalizeKind((string)($input['kind'] ?? '')); + $role = strtolower(trim((string)($input['role'] ?? 'replica'))); + if ($role !== 'replica') { + throw new RuntimeException('Coolify-managed targets must be deployed as replicas first to avoid planned downtime.'); + } + + $instanceId = (int)($input['instance_id'] ?? 0); + if ($instanceId <= 0) { + $instanceId = $this->defaultInstanceId(); + } + $isolatedStack = $this->isIsolatedStackTargetRequest($input); + $instance = $this->getInstance($instanceId); + $input = $this->applyCoolifyDeploymentDefaults($input, $instance); + $input = $this->applyCoolifyPortDefaults($kind, $input, $instance); + + $composeInput = $this->composeInputFromRequest($kind, $input, $instance); + $template = replication_manager::composeTemplate($composeInput); + $hostPayload = $this->hostPayloadFromTemplate($kind, $input, $template); + $hostPayload['options'] = array_replace( + is_array($hostPayload['options'] ?? null) ? $hostPayload['options'] : [], + [ + 'deployment_provider' => 'coolify', + 'coolify_instance_id' => $instanceId, + ] + ); + + $replicationHost = (new replication_manager())->addHost($kind, $hostPayload, $actorUserId); + $replicationHostId = (int)$replicationHost['id']; + $label = trim((string)($input['label'] ?? $replicationHost['label'] ?? $template['service_name'] ?? 'Coolify target')); + $resourceName = self::resourceName($kind, (string)($template['service_name'] ?? $label), $replicationHostId); + $targetOptions = $this->targetOptions($input, $template, $composeInput); + + $this->execute( + "INSERT INTO coolify_targets ( + instance_id, replication_host_id, kind, label, role, server_uuid, project_uuid, + environment_uuid, environment_name, destination_uuid, resource_name, deployment_status, + availability_state, desired_compose_hash, options_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 'degraded', ?, ?)", + 'iisssssssssss', + [ + $instanceId, + $replicationHostId, + $kind, + $label, + $role, + $this->targetMapping($input, $instance, 'server_uuid'), + $this->targetMapping($input, $instance, 'project_uuid'), + $this->targetMapping($input, $instance, 'environment_uuid'), + $this->targetMapping($input, $instance, 'environment_name') ?: 'production', + $this->targetMapping($input, $instance, 'destination_uuid'), + $resourceName, + $this->composeHash($template), + self::jsonEncode($targetOptions), + ] + ); + + $targetId = $this->insertId(); + $this->attachTargetToReplicationHost($kind, $replicationHostId, $targetId, $instanceId); + if (!$isolatedStack) { + $this->ensureFailoverEnabled($kind); + } + $this->audit($targetId, $instanceId, $replicationHostId, 'target_created', $actorUserId, 'info', [ + 'kind' => $kind, + 'role' => $role, + 'resource_name' => $resourceName, + 'isolated_stack' => $isolatedStack, + ]); + + $target = $this->getTarget($targetId); + $deploy = $this->toBool($input['deploy'] ?? false, false); + if ($deploy) { + try { + $this->deployTarget($targetId, $actorUserId); + } catch (Throwable $throwable) { + $this->markTargetFailure($targetId, 'reconcile_failed', $throwable->getMessage(), [ + 'stage' => 'create_target_deploy', + ]); + } + } + + return [ + 'target' => $this->publicTarget($this->getTarget($targetId)), + 'host' => $replicationHost, + ]; + } + + public function reconcileTarget(int $targetId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getTarget($targetId); + $host = $this->replicationHost((int)$target['replication_host_id']); + if (self::blocksPrimaryMutation($host, 'deploy')) { + return $this->blockedTargetOperation($target, $host, 'deploy', $actorUserId); + } + + $operationId = $this->startOperation($targetId, (int)$target['instance_id'], 'reconcile', $actorUserId); + + try { + $instance = $this->getInstance((int)$target['instance_id']); + $client = $this->clientForInstance($instance); + $host = $this->syncReplicationHostPortsForTarget($target, $host); + $host = $this->syncReplicationHostEndpointForTarget($target, $host, $instance); + $template = $this->composeTemplateForTarget($target, $host); + $env = self::parseEnvFile((string)($template['env'] ?? '')); + $hash = $this->composeHash($template); + $payload = $this->servicePayload($target, $template, false); + $resourceUuid = trim((string)($target['resource_uuid'] ?? '')); + $action = 'in_sync'; + $apiResult = []; + $shouldStart = in_array((string)($target['deployment_status'] ?? ''), ['pending', 'reconcile_failed', 'created', 'deploying', 'provision_blocked'], true); + + if ($resourceUuid === '') { + $apiResult = $client->createService($payload); + $resourceUuid = (string)($apiResult['uuid'] ?? ''); + if ($resourceUuid === '') { + throw new RuntimeException('Coolify did not return a service UUID.'); + } + $this->recordCreatedResource($targetId, $resourceUuid, $hash); + $action = 'created'; + $shouldStart = true; + } elseif ($hash !== (string)($target['desired_compose_hash'] ?? '')) { + $apiResult = $client->updateService($resourceUuid, $this->servicePayload($target, $template, true)); + $action = 'updated'; + $shouldStart = true; + } else { + try { + $apiResult = $client->getService($resourceUuid); + } catch (Throwable) { + $apiResult = []; + } + $action = $shouldStart ? 'start_requested' : 'in_sync'; + } + + if ($env !== []) { + $client->updateServiceEnvsBulk($resourceUuid, $env); + } + $startResult = null; + if ($shouldStart) { + $startResult = $this->startOrRestartService($client, $resourceUuid, $action === 'updated'); + } + + $context = [ + 'action' => $action, + 'resource_uuid' => $resourceUuid, + 'compose_hash' => $hash, + 'coolify' => self::redactCoolifyResponse($apiResult), + 'start' => self::redactCoolifyResponse(is_array($startResult) ? $startResult : []), + ]; + $availabilityState = $this->availabilityStateForHost($host); + $this->execute( + "UPDATE coolify_targets + SET resource_uuid = ?, deployment_status = ?, availability_state = ?, desired_compose_hash = ?, + last_reconcile_status = ?, last_reconcile_json = ?, last_reconciled_at = NOW() + WHERE id = ?", + 'ssssssi', + [ + $resourceUuid, + $action === 'in_sync' ? 'in_sync' : 'deploying', + $availabilityState, + $hash, + $action, + self::jsonEncode($context), + $targetId, + ] + ); + $this->finishOperation($operationId, 'completed', 'Coolify reconcile completed.', []); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_reconciled', $actorUserId, 'info', $context); + + return [ + 'ok' => true, + 'status' => $action, + 'target' => $this->publicTarget($this->getTarget($targetId)), + 'context' => $context, + ]; + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', null, [$throwable->getMessage()]); + $this->markTargetFailure($targetId, 'reconcile_failed', $throwable->getMessage()); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_reconcile_failed', $actorUserId, 'error', [ + 'error' => $throwable->getMessage(), + ]); + throw $throwable; + } + } + + public function deployTarget(int $targetId, ?int $actorUserId = null): array + { + $reconcile = $this->reconcileTarget($targetId, $actorUserId); + $target = $this->getTarget($targetId); + $hostId = (int)($target['replication_host_id'] ?? 0); + $provision = null; + + if ($hostId > 0) { + if ($this->targetSkipsReplicationProvisioning($target)) { + $provision = [ + 'ok' => true, + 'skipped' => true, + 'status' => 'isolated_stack_empty_data_service', + 'message' => 'Isolated stack data services are intentionally not attached to production replication.', + ]; + + return [ + 'ok' => true, + 'reconcile' => $reconcile, + 'provision' => $provision, + 'target' => $this->publicTarget($this->getTarget($targetId)), + ]; + } + + $reconcileAction = (string)($reconcile['status'] ?? $reconcile['context']['action'] ?? ''); + if (in_array($reconcileAction, ['created', 'updated'], true)) { + $provision = (string)($target['kind'] ?? '') === 'minio' + ? (new replication_manager())->provisionHost((string)$target['kind'], $hostId, $actorUserId, true) + : $this->deferredProvisionResult(null); + $this->setTargetProvisionState($targetId, $hostId, 'deploying', 'provision_deferred'); + } else { + $provision = $this->attemptTargetProvision($targetId, $target, $hostId, $actorUserId, true); + } + } + + return [ + 'ok' => ($provision['ok'] ?? true) !== false, + 'reconcile' => $reconcile, + 'provision' => $provision, + 'target' => $this->publicTarget($this->getTarget($targetId)), + ]; + } + + private function attemptTargetProvision( + int $targetId, + array $target, + int $hostId, + ?int $actorUserId, + bool $deferLongRunning = false + ): array + { + try { + $provision = (new replication_manager())->provisionHost( + (string)$target['kind'], + $hostId, + $actorUserId, + $deferLongRunning + ); + if (($provision['ok'] ?? false) === false && $this->isTransientProvisionBlock($provision)) { + $this->setTargetProvisionState($targetId, $hostId, 'deploying', 'provision_deferred'); + return $this->deferredProvisionResult($provision); + } + + $completed = (($provision['ok'] ?? false) === true) + && (($provision['operation']['status'] ?? null) !== 'running'); + $this->setTargetProvisionState( + $targetId, + $hostId, + $completed ? 'provisioned' : ((($provision['ok'] ?? false) === true) ? 'deploying' : 'provision_blocked'), + $completed ? 'provisioned' : ((($provision['ok'] ?? false) === true) ? 'provisioning' : 'provision_blocked') + ); + + return $provision; + } catch (Throwable $throwable) { + $this->markTargetFailure($targetId, 'provision_blocked', $throwable->getMessage()); + return [ + 'ok' => false, + 'message' => $throwable->getMessage(), + 'blockers' => [$throwable->getMessage()], + ]; + } + } + + private function setTargetProvisionState(int $targetId, int $hostId, string $deploymentStatus, string $lastReconcileStatus): void + { + $this->execute( + "UPDATE coolify_targets + SET availability_state = ?, deployment_status = ?, last_reconcile_status = ?, last_reconciled_at = NOW() + WHERE id = ?", + 'sssi', + [ + $this->availabilityStateForHost($this->replicationHost($hostId, true)), + $deploymentStatus, + $lastReconcileStatus, + $targetId, + ] + ); + } + + private function deferredProvisionResult(?array $provision): array + { + $blockers = array_values(array_unique(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + is_array($provision['blockers'] ?? null) ? $provision['blockers'] : [] + )))); + + return array_replace($provision ?? [], [ + 'ok' => true, + 'deferred' => true, + 'status' => 'waiting_for_coolify', + 'message' => 'Coolify deployment has started. Replication provisioning will continue after the service port becomes reachable.', + 'blockers' => $blockers, + ]); + } + + private function isTransientProvisionBlock(array $provision): bool + { + $blockers = is_array($provision['blockers'] ?? null) ? $provision['blockers'] : []; + if ($blockers === []) { + return false; + } + + $matched = false; + foreach ($blockers as $blocker) { + $message = strtolower(trim((string)$blocker)); + if ($message === '') { + continue; + } + $isTransient = false; + foreach ([ + 'connection refused', + 'connection timed out', + 'timed out', + 'timeout', + 'failed to connect', + 'could not connect', + 'no route to host', + 'network is unreachable', + 'connection reset', + 'temporarily unavailable', + 'temporary failure', + 'name or service not known', + ] as $needle) { + if (str_contains($message, $needle)) { + $isTransient = true; + $matched = true; + break; + } + } + if (!$isTransient) { + return false; + } + } + + return $matched; + } + + public function restartTarget(int $targetId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getTarget($targetId); + $host = $this->replicationHost((int)$target['replication_host_id']); + if (self::blocksPrimaryMutation($host, 'restart')) { + return $this->blockedTargetOperation($target, $host, 'restart', $actorUserId); + } + + $resourceUuid = trim((string)($target['resource_uuid'] ?? '')); + if ($resourceUuid === '') { + throw new RuntimeException('Coolify target has no resource UUID yet. Reconcile it first.'); + } + + $operationId = $this->startOperation($targetId, (int)$target['instance_id'], 'restart', $actorUserId); + try { + $result = $this->clientForInstance($this->getInstance((int)$target['instance_id']))->restartService($resourceUuid); + $this->execute( + "UPDATE coolify_targets SET deployment_status = 'restarting', last_reconcile_status = 'restart_requested', last_reconciled_at = NOW() WHERE id = ?", + 'i', + [$targetId] + ); + $this->finishOperation($operationId, 'completed', 'Coolify restart requested.', []); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_restart_requested', $actorUserId, 'warning', [ + 'resource_uuid' => $resourceUuid, + 'coolify' => self::redactCoolifyResponse($result), + ]); + + return [ + 'ok' => true, + 'target' => $this->publicTarget($this->getTarget($targetId)), + 'coolify' => self::redactCoolifyResponse($result), + ]; + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', null, [$throwable->getMessage()]); + $this->markTargetFailure($targetId, 'restart_failed', $throwable->getMessage()); + throw $throwable; + } + } + + public function failoverTarget(int $targetId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getTarget($targetId); + $host = $this->replicationHost((int)$target['replication_host_id']); + $kind = (string)$target['kind']; + + if (($host['role'] ?? '') === 'primary') { + $result = (new replication_manager())->runAutomaticFailoverMonitor($actorUserId); + } else { + $result = (new replication_manager())->promoteHost($kind, (int)$host['id'], $actorUserId); + } + + $this->execute( + "UPDATE coolify_targets SET availability_state = ?, last_reconcile_status = 'failover_checked', last_reconciled_at = NOW() WHERE id = ?", + 'si', + [$this->availabilityStateForHost($this->replicationHost((int)$host['id'], true)), $targetId] + ); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_failover_requested', $actorUserId, 'critical', [ + 'result' => $result, + ]); + + return [ + 'ok' => true, + 'target' => $this->publicTarget($this->getTarget($targetId)), + 'failover' => $result, + ]; + } + + public function deleteTarget(int $targetId, array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getTarget($targetId); + $host = $this->replicationHost((int)$target['replication_host_id'], true); + if (($host['role'] ?? '') === 'primary') { + throw new RuntimeException('Coolify cannot delete an active primary target. Promote a healthy replica first.'); + } + + $confirmation = trim((string)($input['confirm'] ?? $input['confirmation'] ?? '')); + $expected = 'delete-coolify-target-' . $targetId; + if ($confirmation !== $expected) { + throw new RuntimeException('Destructive confirmation is required. Send confirm="' . $expected . '".'); + } + + $deleteResource = $this->toBool($input['delete_resource'] ?? false, false); + $resourceUuid = trim((string)($target['resource_uuid'] ?? '')); + $coolifyResult = null; + if ($deleteResource && $resourceUuid !== '') { + $coolifyResult = $this->clientForInstance($this->getInstance((int)$target['instance_id']))->deleteService($resourceUuid); + } + + $hostRemoved = false; + $canRemoveHost = replication_manager::replicationHostCanBeRemoved($host) + || self::targetAllowsReplicaRemoval($target); + if ((int)($host['id'] ?? 0) > 0 && $canRemoveHost) { + (new replication_manager())->removeHost((string)$target['kind'], (int)$host['id'], $actorUserId, false); + $hostRemoved = true; + } + + $this->execute( + "UPDATE coolify_targets SET deleted_at = NOW(), deployment_status = 'removed', availability_state = 'degraded' WHERE id = ?", + 'i', + [$targetId] + ); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_deleted', $actorUserId, 'warning', [ + 'delete_resource' => $deleteResource, + 'host_removed' => $hostRemoved, + 'coolify' => self::redactCoolifyResponse(is_array($coolifyResult) ? $coolifyResult : []), + ]); + + return [ + 'ok' => true, + 'id' => $targetId, + 'host_removed' => $hostRemoved, + 'coolify' => self::redactCoolifyResponse(is_array($coolifyResult) ? $coolifyResult : []), + ]; + } + + public function runAvailabilityMaintenance(?int $actorUserId = null): array + { + $this->ensureSchema(); + $failover = (new replication_manager())->runAutomaticFailoverMonitor($actorUserId); + $updated = []; + foreach ($this->selectRows('SELECT id, kind, replication_host_id, resource_uuid, deployment_status FROM coolify_targets WHERE deleted_at IS NULL') as $target) { + $hostId = (int)($target['replication_host_id'] ?? 0); + if ($hostId <= 0) { + continue; + } + try { + $provision = null; + $host = $this->replicationHost($hostId, true); + if ($this->shouldRetryProvisioning($target, $host) + || $this->hasRunningReplicationProvisionOperation((string)$target['kind'], $hostId)) { + $provision = $this->attemptTargetProvision((int)$target['id'], $target, $hostId, $actorUserId); + } + $state = $this->availabilityStateForHost($this->replicationHost($hostId, true)); + $this->execute('UPDATE coolify_targets SET availability_state = ? WHERE id = ?', 'si', [$state, (int)$target['id']]); + $updated[] = [ + 'id' => (int)$target['id'], + 'availability_state' => $state, + 'deployment_status' => $this->getTargetDeploymentStatus((int)$target['id']), + 'provision' => $provision, + ]; + } catch (Throwable) { + } + } + + return [ + 'ok' => true, + 'failover' => $failover, + 'targets' => $updated, + ]; + } + + public function listLoadBalancerGateways(bool $includeDeleted = false): array + { + $this->ensureSchema(); + $where = $includeDeleted ? '1=1' : 'deleted_at IS NULL'; + return array_map( + fn(array $gateway): array => $this->publicGateway($gateway), + $this->selectRows( + "SELECT * FROM coolify_instance_gateways WHERE $where ORDER BY priority ASC, id ASC" + ) + ); + } + + public function saveLoadBalancerGateway(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $id = (int)($input['id'] ?? 0); + $hostname = trim((string)($input['hostname'] ?? '')); + $targetIp = trim((string)($input['target_ip'] ?? $input['ip'] ?? '')); + $enabled = $this->toBool($input['enabled'] ?? true, true) ? 1 : 0; + $priority = max(0, (int)($input['priority'] ?? 100)); + $instanceId = (int)($input['instance_id'] ?? 0); + $instanceIdValue = $instanceId > 0 ? $instanceId : null; + + if ($hostname === '' || $targetIp === '') { + throw new RuntimeException('Gateway hostname and target IP are required.'); + } + + if (filter_var($targetIp, FILTER_VALIDATE_IP) === false) { + throw new RuntimeException('Gateway target IP must be a valid IPv4 or IPv6 address.'); + } + + if ($id > 0) { + $this->execute( + "UPDATE coolify_instance_gateways + SET instance_id = ?, hostname = ?, target_ip = ?, enabled = ?, priority = ?, deleted_at = NULL + WHERE id = ?", + 'issiii', + [$instanceIdValue, $hostname, $targetIp, $enabled, $priority, $id] + ); + $action = 'load_balancer_gateway_updated'; + } else { + $this->execute( + "INSERT INTO coolify_instance_gateways (instance_id, hostname, target_ip, enabled, priority) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + instance_id = VALUES(instance_id), + hostname = VALUES(hostname), + enabled = VALUES(enabled), + priority = VALUES(priority), + deleted_at = NULL", + 'issii', + [$instanceIdValue, $hostname, $targetIp, $enabled, $priority] + ); + $id = $this->insertId(); + if ($id <= 0) { + $row = $this->selectOne('SELECT id FROM coolify_instance_gateways WHERE target_ip = ? LIMIT 1', 's', [$targetIp]); + $id = (int)($row['id'] ?? 0); + } + $action = 'load_balancer_gateway_saved'; + } + + $gateway = $this->getGateway($id); + $this->audit(null, $instanceIdValue, null, $action, $actorUserId, 'info', [ + 'gateway_id' => $id, + 'hostname' => $hostname, + 'target_ip' => $targetIp, + 'enabled' => (bool)$enabled, + 'priority' => $priority, + ]); + + return $this->publicGateway($gateway); + } + + public function testLoadBalancerGateway(int $gatewayId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $gateway = $this->getGateway($gatewayId); + $publicHost = $this->coolifyConfigValue('public_gateway_host', self::DEFAULT_PUBLIC_GATEWAY_HOST); + $result = $this->probeGatewayTarget((string)$gateway['target_ip'], $publicHost); + $state = ($result['ok'] ?? false) === true ? 'ok' : 'down'; + $this->recordGatewayProbe($gatewayId, $result); + $this->audit(null, isset($gateway['instance_id']) ? (int)$gateway['instance_id'] : null, null, 'load_balancer_gateway_tested', $actorUserId, $state === 'ok' ? 'info' : 'warning', [ + 'gateway_id' => $gatewayId, + 'target_ip' => $gateway['target_ip'] ?? null, + 'result' => $result, + ]); + + return [ + 'gateway' => $this->publicGateway($this->getGateway($gatewayId)), + 'test' => $result, + ]; + } + + public function loadBalancerSummary(): array + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + $gateways = $this->listLoadBalancerGateways(); + $base = [ + 'configured' => $config['load_balancer_id'] !== '' && $config['token_set'], + 'status' => 'not_configured', + 'config' => $this->publicLoadBalancerConfig($config), + 'gateways' => $gateways, + 'load_balancer' => null, + 'drift' => [], + 'last_error' => null, + ]; + + if (!$base['configured']) { + return $base; + } + + try { + $loadBalancer = $this->hetznerClient($config['token'])->getLoadBalancer($config['load_balancer_id']); + $drift = $this->planLoadBalancerReconcile($loadBalancer, $gateways); + $this->syncGatewayLoadBalancerStates($gateways, $drift['actual_target_ips']); + + return array_replace($base, [ + 'status' => $drift['has_drift'] ? 'degraded' : 'ok', + 'gateways' => $this->listLoadBalancerGateways(), + 'load_balancer' => $this->publicLoadBalancer($loadBalancer), + 'drift' => $drift, + ]); + } catch (Throwable $throwable) { + return array_replace($base, [ + 'status' => 'down', + 'last_error' => $throwable->getMessage(), + ]); + } + } + + public function reconcileLoadBalancer(bool $dryRun = true, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + if ($config['load_balancer_id'] === '' || !$config['token_set']) { + throw new RuntimeException('Hetzner Load Balancer ID and API token are required.'); + } + + $client = $this->hetznerClient($config['token']); + $loadBalancer = $client->getLoadBalancer($config['load_balancer_id']); + $gateways = $this->listLoadBalancerGateways(true); + $plan = $this->planLoadBalancerReconcile($loadBalancer, $gateways); + $canMutate = !$dryRun && $config['automation_enabled'] && $config['automation_mode'] === 'enforce'; + $applied = []; + $skipped = []; + $errors = []; + + foreach ($plan['actions'] as $action) { + $type = (string)($action['type'] ?? ''); + if ($type === 'skip_remove_target') { + $skipped[] = $action; + continue; + } + + if (!$canMutate) { + $skipped[] = array_replace($action, ['reason' => $action['reason'] ?? 'report_only']); + continue; + } + + try { + if ($type === 'add_target') { + $client->addIpTarget($config['load_balancer_id'], (string)$action['target_ip']); + } elseif ($type === 'remove_target') { + $client->removeIpTarget($config['load_balancer_id'], (string)$action['target_ip']); + } elseif ($type === 'add_service') { + $client->addService( + $config['load_balancer_id'], + (string)$action['protocol'], + (int)$action['listen_port'], + (int)$action['destination_port'], + $action + ); + } elseif ($type === 'update_service') { + $client->updateService( + $config['load_balancer_id'], + (string)$action['protocol'], + (int)$action['listen_port'], + (int)$action['destination_port'], + $action + ); + } else { + $skipped[] = array_replace($action, ['reason' => 'unknown_action']); + continue; + } + $applied[] = $action; + } catch (hetzner_cloud_api_exception $exception) { + if (($action['type'] ?? '') === 'add_target' && $exception->apiCode() === 'target_already_defined') { + $applied[] = array_replace($action, ['already_defined' => true]); + continue; + } + $errors[] = array_replace($action, [ + 'error' => $exception->getMessage(), + 'api_code' => $exception->apiCode(), + ]); + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + $this->audit(null, null, null, $canMutate ? 'load_balancer_reconcile_applied' : 'load_balancer_reconcile_planned', $actorUserId, $errors === [] ? 'info' : 'warning', [ + 'dry_run' => $dryRun, + 'can_mutate' => $canMutate, + 'automation_enabled' => $config['automation_enabled'], + 'automation_mode' => $config['automation_mode'], + 'load_balancer_id' => $config['load_balancer_id'], + 'actions' => $plan['actions'], + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + ]); + + $freshLoadBalancer = $loadBalancer; + if ($canMutate && $errors === []) { + $freshLoadBalancer = $client->getLoadBalancer($config['load_balancer_id']); + } + $freshPlan = $this->planLoadBalancerReconcile($freshLoadBalancer, $this->listLoadBalancerGateways(true)); + $this->syncGatewayLoadBalancerStates($this->listLoadBalancerGateways(), $freshPlan['actual_target_ips']); + + return [ + 'ok' => $errors === [], + 'dry_run' => $dryRun, + 'mutated' => $canMutate, + 'config' => $this->publicLoadBalancerConfig($config), + 'load_balancer' => $this->publicLoadBalancer($freshLoadBalancer), + 'drift' => $freshPlan, + 'planned' => $plan['actions'], + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'gateways' => $this->listLoadBalancerGateways(), + ]; + } + + public function deployGatewayApplicationRoutes(bool $dryRun = true, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + $publicHost = trim((string)$config['public_gateway_host']); + if ($publicHost === '') { + throw new RuntimeException('Public gateway host is required before deploying application routes.'); + } + if (!self::isPublicDnsName($publicHost)) { + throw new RuntimeException('Public gateway host must be a DNS name.'); + } + + $publicUrl = 'https://' . $publicHost; + $targets = $this->loadBalancerReleaseGatewayTargets(); + $planned = []; + $applied = []; + $skipped = []; + $errors = []; + $warnings = []; + $coveredTargetIpsByApp = []; + $targetsByApp = []; + $deploymentWaitItems = []; + $gatewayRows = $this->listLoadBalancerGateways(true); + $enabledGatewayIps = array_values(array_unique(array_map( + static fn(array $gateway): string => (string)$gateway['target_ip'], + array_filter( + $gatewayRows, + static fn(array $gateway): bool => !empty($gateway['enabled']) && empty($gateway['deleted_at']) + ) + ))); + + if ($targets === []) { + $warnings[] = 'No Coolify-backed API release target is configured for the gateway host.'; + } + + foreach ($targets as $target) { + $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $instanceId = (int)($target['coolify_instance_id'] ?? 0); + $app = self::gatewayRouteApp((string)($target['app'] ?? 'api')); + $resourceType = $this->gatewayRouteResourceType($target); + $targetPublicUrl = self::gatewayRouteTargetPublicUrl($publicHost, $target); + $action = [ + 'type' => 'deploy_gateway_route', + 'target_id' => (int)($target['id'] ?? 0), + 'channel_slug' => $target['channel_slug'] ?? null, + 'app' => $app, + 'resource_uuid' => $resourceUuid, + 'resource_type' => $resourceType, + 'public_url' => $targetPublicUrl, + 'deploy' => true, + ]; + $targetsByApp[$app] ??= []; + $targetsByApp[$app][] = $target; + $coveredTargetIpsByApp[$app] ??= []; + + if ($instanceId <= 0 || $resourceUuid === '') { + $skipped[] = array_replace($action, ['reason' => 'missing_coolify_resource']); + continue; + } + + try { + $instance = $this->getInstance($instanceId); + $client = $this->clientForInstance($instance); + $resource = $resourceType === 'service' + ? $client->getService($resourceUuid) + : $client->getApplication($resourceUuid); + $targetIp = self::resourceServerIp($resource); + if ($targetIp !== null) { + $coveredTargetIpsByApp[$app][] = $targetIp; + $action['target_ip'] = $targetIp; + } + $action['current_public_url'] = self::resourcePublicUrl($resource); + $planned[] = $action; + + if ($dryRun) { + continue; + } + + $updatePayload = $resourceType === 'service' + ? self::gatewayRouteServicePayload( + $targetPublicUrl, + $app, + self::resourceFirstExposedPort($resource, $target) + ) + : self::gatewayRouteApplicationPayload( + $targetPublicUrl, + $resourceUuid, + self::resourceFirstExposedPort($resource, $target), + $resource['custom_labels'] ?? null + ); + $update = $resourceType === 'service' + ? $client->updateService($resourceUuid, $updatePayload) + : $client->updateApplication($resourceUuid, $updatePayload); + $deployment = $client->deployResource($resourceUuid, false); + $deploymentWaitItems = array_merge( + $deploymentWaitItems, + self::coolifyDeploymentWaitItems($deployment, $instanceId, $resourceUuid, [ + 'target_id' => (int)($target['id'] ?? 0), + 'target_ip' => $targetIp, + 'resource_type' => $resourceType, + ]) + ); + $this->persistGatewayRouteTargetContext((int)$target['id'], $target, $publicHost, $targetPublicUrl); + $applied[] = array_replace($action, [ + 'updated' => self::redactCoolifyResponse($update), + 'deployment' => self::redactCoolifyResponse($deployment), + ]); + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + foreach ($targetsByApp as $app => $appTargets) { + $appCoveredTargetIps = array_values(array_unique(array_filter($coveredTargetIpsByApp[$app] ?? []))); + $appUncoveredGatewayIps = array_values(array_diff($enabledGatewayIps, $appCoveredTargetIps)); + + if ($appUncoveredGatewayIps !== [] && $appTargets !== []) { + $provisioned = $this->provisionMissingGatewayRouteTargets( + $appUncoveredGatewayIps, + $appTargets, + $publicHost, + $publicUrl, + $dryRun, + $actorUserId + ); + $planned = array_merge($planned, $provisioned['planned']); + $applied = array_merge($applied, $provisioned['applied']); + $skipped = array_merge($skipped, $provisioned['skipped']); + $errors = array_merge($errors, $provisioned['errors']); + $warnings = array_merge($warnings, $provisioned['warnings']); + $deploymentWaitItems = array_merge($deploymentWaitItems, $provisioned['deployment_wait_items'] ?? []); + $appCoveredTargetIps = array_values(array_unique(array_merge($appCoveredTargetIps, $provisioned['covered_target_ips']))); + $appUncoveredGatewayIps = array_values(array_diff($enabledGatewayIps, $appCoveredTargetIps)); + } + + $coveredTargetIpsByApp[$app] = $appCoveredTargetIps; + if ($appUncoveredGatewayIps !== [] && $appCoveredTargetIps !== []) { + $warnings[] = 'No managed Coolify ' . $app . ' application route was found for gateway targets: ' . implode(', ', $appUncoveredGatewayIps) . '.'; + } + } + + $coveredTargetIps = array_values(array_unique(array_merge(...array_values($coveredTargetIpsByApp ?: [[]])))); + $uncoveredGatewayIps = array_values(array_diff($enabledGatewayIps, $coveredTargetIps)); + + $certificateBootstrap = null; + $verification = null; + $deploymentWait = null; + if (!$dryRun && $deploymentWaitItems !== []) { + $deploymentWait = $this->waitForCoolifyDeployments($deploymentWaitItems); + if (($deploymentWait['ok'] ?? false) !== true) { + $warnings[] = 'Coolify deployments are still running; Let\'s Encrypt bootstrap was deferred until the next route deploy.'; + } + } + if (!$dryRun && $coveredTargetIps !== [] && ($deploymentWait === null || ($deploymentWait['ok'] ?? false) === true)) { + $certificateBootstrap = $this->bootstrapGatewayCertificates($coveredTargetIps, $publicHost, $config); + $warnings = array_merge($warnings, $certificateBootstrap['warnings'] ?? []); + $verification = $this->verifyGatewayRoutes($gatewayRows, $coveredTargetIps, $publicHost); + if (($verification['ok'] ?? false) !== true) { + $warnings[] = "Gateway route and Let's Encrypt certificate verification is still failing for: " . implode(', ', $verification['failed_target_ips'] ?? []) . '.'; + } + } + $errors = array_merge($errors, self::gatewayRouteHealthErrors($certificateBootstrap, $verification)); + + $this->audit(null, null, null, $dryRun ? 'gateway_application_routes_planned' : 'gateway_application_routes_deployed', $actorUserId, $errors === [] ? 'info' : 'warning', [ + 'dry_run' => $dryRun, + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'certificate_bootstrap' => $certificateBootstrap, + 'verification' => $verification, + ]); + + return [ + 'ok' => $errors === [], + 'dry_run' => $dryRun, + 'mutated' => !$dryRun && $errors === [], + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'certificate_bootstrap' => $certificateBootstrap, + 'verification' => $verification, + 'coverage' => [ + 'enabled_gateway_ips' => $enabledGatewayIps, + 'covered_target_ips' => $coveredTargetIps, + 'uncovered_gateway_ips' => $uncoveredGatewayIps, + 'apps' => array_map( + static fn(array $ips): array => [ + 'covered_target_ips' => array_values(array_unique(array_filter($ips))), + 'uncovered_gateway_ips' => array_values(array_diff( + $enabledGatewayIps, + array_values(array_unique(array_filter($ips))) + )), + ], + $coveredTargetIpsByApp + ), + ], + 'gateways' => $this->listLoadBalancerGateways(), + ]; + } + + public function deployGatewayApiCode(bool $dryRun = true, bool $deployRoutes = true, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + $publicHost = trim((string)$config['public_gateway_host']); + if ($publicHost === '') { + throw new RuntimeException('Public gateway host is required before deploying API code.'); + } + if (!self::isPublicDnsName($publicHost)) { + throw new RuntimeException('Public gateway host must be a DNS name.'); + } + + $publicUrl = 'https://' . $publicHost; + $targets = $this->loadBalancerReleaseApiTargets(); + $planned = []; + $applied = []; + $skipped = []; + $errors = []; + $warnings = []; + $deploymentWaitItems = []; + + if ($targets === []) { + $warnings[] = 'No Coolify-backed API release target is configured for the gateway host.'; + } + + if (!$dryRun) { + if (!class_exists(release_manager::class) && function_exists('app_require')) { + app_require('classes/release_manager.php'); + } + if (!class_exists(release_manager::class)) { + throw new RuntimeException('Release Manager is required to deploy gateway API code.'); + } + } + + $releaseManager = !$dryRun ? new release_manager() : null; + foreach ($targets as $target) { + $repository = trim((string)($target['repository'] ?? '')); + $branch = trim((string)($target['branch'] ?? 'master')) ?: 'master'; + $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $instanceId = (int)($target['coolify_instance_id'] ?? 0); + $targetPublicUrl = self::gatewayRouteTargetPublicUrl($publicHost, $target); + $action = [ + 'type' => 'deploy_gateway_api_code', + 'target_id' => (int)($target['id'] ?? 0), + 'channel_id' => (int)($target['channel_id'] ?? 0), + 'channel_slug' => $target['channel_slug'] ?? null, + 'app' => 'api', + 'repository' => $repository, + 'branch' => $branch, + 'resource_uuid' => $resourceUuid, + 'resource_type' => $this->gatewayRouteResourceType($target), + 'public_url' => $targetPublicUrl, + 'commit_mode' => 'latest', + ]; + + if ((int)($target['id'] ?? 0) <= 0 || (int)($target['channel_id'] ?? 0) <= 0) { + $skipped[] = array_replace($action, ['reason' => 'missing_release_target']); + continue; + } + if ($instanceId <= 0 || $resourceUuid === '') { + $skipped[] = array_replace($action, ['reason' => 'missing_coolify_resource']); + continue; + } + if ($repository === '') { + $skipped[] = array_replace($action, ['reason' => 'missing_repository']); + continue; + } + + $planned[] = $action; + if ($dryRun) { + continue; + } + + try { + $deployment = $releaseManager->startDeployment([ + 'target_id' => (int)$target['id'], + 'channel_id' => (int)$target['channel_id'], + 'app' => 'api', + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => 'latest', + 'version_label' => $this->gatewayApiCodeVersionLabel($target), + 'deployed_url' => $targetPublicUrl, + 'metadata' => [ + 'gateway_api_code_deploy' => true, + 'public_host' => $publicHost, + 'previous_deployment_id' => isset($target['latest_deployment_id']) ? (int)$target['latest_deployment_id'] : null, + 'previous_commit_sha' => $target['latest_deployment_commit_sha'] ?? null, + ], + ], $actorUserId); + + if ((string)($deployment['status'] ?? '') !== 'deployed') { + $errors[] = array_replace($action, [ + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'status' => $deployment['status'] ?? null, + 'error' => (string)($deployment['error_message'] ?? 'API code deployment did not complete.'), + 'deployment' => $deployment, + ]); + continue; + } + + $deploymentResult = is_array($deployment['result'] ?? null) ? $deployment['result'] : []; + $deploymentWaitItems = array_merge( + $deploymentWaitItems, + self::coolifyDeploymentWaitItems($deploymentResult['deployment'] ?? [], $instanceId, (string)($deploymentResult['service_uuid'] ?? $resourceUuid), [ + 'target_id' => (int)$target['id'], + 'target_ip' => self::targetIpFromDeploymentResult($deploymentResult), + 'resource_type' => (string)($deploymentResult['resource_type'] ?? $action['resource_type']), + ]) + ); + + $applied[] = array_replace($action, [ + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'version_id' => $deployment['version_id'] ?? null, + 'commit_sha' => $deployment['commit_sha'] ?? null, + 'deployment' => $deployment, + ]); + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + $deploymentWait = null; + if (!$dryRun && $deploymentWaitItems !== []) { + $deploymentWait = $this->waitForCoolifyDeployments($deploymentWaitItems); + if (($deploymentWait['ok'] ?? false) !== true) { + $warnings[] = 'Coolify API code deployments are still running; route and certificate deploy was deferred until the next run.'; + } + } + + $routeDeploy = null; + if (!$dryRun + && $deployRoutes + && $errors === [] + && ($deploymentWait === null || ($deploymentWait['ok'] ?? false) === true)) { + $routeDeploy = $this->deployGatewayApplicationRoutes(false, $actorUserId); + $warnings = array_merge($warnings, $routeDeploy['warnings'] ?? []); + if (($routeDeploy['ok'] ?? false) !== true) { + $errors[] = [ + 'type' => 'deploy_gateway_route_after_code', + 'error' => 'Gateway API code deployed, but route and certificate deployment did not complete.', + 'route_deploy' => $routeDeploy, + ]; + } + } + + $ok = $errors === []; + $this->audit(null, null, null, $dryRun ? 'gateway_api_code_deploy_planned' : 'gateway_api_code_deployed', $actorUserId, $ok ? 'info' : 'warning', [ + 'dry_run' => $dryRun, + 'deploy_routes' => $deployRoutes, + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'route_deploy' => $routeDeploy, + ]); + + return [ + 'ok' => $ok, + 'dry_run' => $dryRun, + 'mutated' => !$dryRun && $ok, + 'deploy_routes' => $deployRoutes, + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'route_deploy' => $routeDeploy, + 'gateways' => $this->listLoadBalancerGateways(), + ]; + } + + private function gatewayApiCodeVersionLabel(array $target): string + { + $channelSlug = trim((string)($target['channel_slug'] ?? 'gateway')); + $channelSlug = strtolower($channelSlug); + $channelSlug = preg_replace('/[^a-z0-9]+/', '-', $channelSlug) ?: ''; + $channelSlug = trim($channelSlug, '-') ?: 'gateway'; + return $channelSlug . '-api-' . date('Y-m-d-His'); + } + + private static function targetIpFromDeploymentResult(array $deploymentResult): ?string + { + $candidates = [ + $deploymentResult['target_ip'] ?? null, + $deploymentResult['server_ip'] ?? null, + ]; + foreach (['server', 'created', 'updated'] as $key) { + $row = is_array($deploymentResult[$key] ?? null) ? $deploymentResult[$key] : []; + $server = is_array($row['server'] ?? null) ? $row['server'] : ($key === 'server' ? $row : []); + $candidates[] = $server['ip'] ?? null; + $candidates[] = $server['public_ip'] ?? null; + } + + foreach ($candidates as $value) { + $value = trim((string)$value); + if ($value !== '' && filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $value; + } + } + + return null; + } + + private static function coolifyDeploymentWaitItems(array $deploymentResponse, int $instanceId, string $resourceUuid, array $context = []): array + { + if ($instanceId <= 0) { + return []; + } + + $items = []; + $deployments = is_array($deploymentResponse['deployments'] ?? null) + ? $deploymentResponse['deployments'] + : [$deploymentResponse]; + foreach ($deployments as $deployment) { + if (!is_array($deployment)) { + continue; + } + $deploymentUuid = trim((string)($deployment['deployment_uuid'] ?? $deployment['uuid'] ?? '')); + if ($deploymentUuid === '') { + continue; + } + $items[] = array_replace($context, [ + 'instance_id' => $instanceId, + 'resource_uuid' => trim((string)($deployment['resource_uuid'] ?? $resourceUuid)), + 'deployment_uuid' => $deploymentUuid, + ]); + } + + return $items; + } + + private function waitForCoolifyDeployments(array $items): array + { + $pending = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $deploymentUuid = trim((string)($item['deployment_uuid'] ?? '')); + $instanceId = (int)($item['instance_id'] ?? 0); + if ($deploymentUuid === '' || $instanceId <= 0) { + continue; + } + $pending[$deploymentUuid] = array_replace($item, [ + 'deployment_uuid' => $deploymentUuid, + 'instance_id' => $instanceId, + 'status' => 'queued', + 'last_seen' => null, + ]); + } + + $results = []; + $startedAt = microtime(true); + if ($pending === []) { + return [ + 'ok' => true, + 'skipped' => true, + 'results' => [], + 'pending' => [], + ]; + } + + for ($attempt = 1; $attempt <= self::GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS && $pending !== []; $attempt++) { + $runningByInstance = []; + foreach (array_unique(array_map(static fn(array $item): int => (int)$item['instance_id'], $pending)) as $instanceId) { + try { + $runningByInstance[$instanceId] = $this->coolifyCollection( + $this->clientForInstance($this->getInstance($instanceId))->listDeployments() + ); + } catch (Throwable $throwable) { + foreach ($pending as $uuid => $item) { + if ((int)$item['instance_id'] !== $instanceId) { + continue; + } + $pending[$uuid]['status'] = 'unknown'; + $pending[$uuid]['error'] = $throwable->getMessage(); + } + $runningByInstance[$instanceId] = []; + } + } + + foreach ($pending as $uuid => $item) { + $running = self::findCoolifyDeployment($runningByInstance[(int)$item['instance_id']] ?? [], $uuid); + if ($running === null) { + $results[$uuid] = array_replace($item, [ + 'status' => 'finished_or_not_running', + 'attempt' => $attempt, + ]); + unset($pending[$uuid]); + continue; + } + + $status = strtolower(trim((string)($running['status'] ?? 'running'))); + $pending[$uuid]['status'] = $status; + $pending[$uuid]['last_seen'] = self::redactCoolifyResponse($running); + $pending[$uuid]['attempt'] = $attempt; + + if (in_array($status, ['finished', 'success', 'succeeded', 'failed', 'cancelled', 'canceled'], true)) { + $results[$uuid] = $pending[$uuid]; + unset($pending[$uuid]); + } + } + + if ($pending !== [] && $attempt < self::GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS) { + sleep(self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS); + } + } + + return [ + 'ok' => $pending === [], + 'skipped' => false, + 'attempts' => self::GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS, + 'delay_seconds' => self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS, + 'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'results' => array_values($results), + 'pending' => array_values($pending), + ]; + } + + private static function findCoolifyDeployment(array $deployments, string $deploymentUuid): ?array + { + foreach ($deployments as $deployment) { + if (!is_array($deployment)) { + continue; + } + if (trim((string)($deployment['deployment_uuid'] ?? $deployment['uuid'] ?? '')) === $deploymentUuid) { + return $deployment; + } + } + + return null; + } + + private function verifyGatewayRoutes(array $gatewayRows, array $targetIps, string $publicHost): array + { + $targetIps = array_values(array_unique(array_filter(array_map('strval', $targetIps)))); + $pending = []; + $results = []; + $startedAt = microtime(true); + + foreach ($gatewayRows as $gateway) { + if (empty($gateway['enabled']) || !empty($gateway['deleted_at'])) { + continue; + } + $targetIp = (string)($gateway['target_ip'] ?? ''); + if ($targetIp === '' || !in_array($targetIp, $targetIps, true)) { + continue; + } + $pending[$targetIp] = $gateway; + } + + for ($attempt = 1; $attempt <= self::GATEWAY_ROUTE_VERIFY_ATTEMPTS && $pending !== []; $attempt++) { + foreach ($pending as $targetIp => $gateway) { + $probe = $this->probeGatewayTarget($targetIp, $publicHost); + $probe['attempt'] = $attempt; + $probe['max_attempts'] = self::GATEWAY_ROUTE_VERIFY_ATTEMPTS; + $this->recordGatewayProbe((int)($gateway['id'] ?? 0), $probe); + $results[$targetIp] = [ + 'gateway_id' => (int)($gateway['id'] ?? 0), + 'hostname' => $gateway['hostname'] ?? null, + 'target_ip' => $targetIp, + 'ok' => (bool)($probe['ok'] ?? false), + 'probe' => $probe, + ]; + + if (($probe['ok'] ?? false) === true) { + unset($pending[$targetIp]); + } + } + + if ($pending !== [] && $attempt < self::GATEWAY_ROUTE_VERIFY_ATTEMPTS) { + sleep(self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS); + } + } + + return [ + 'ok' => $pending === [], + 'attempts' => self::GATEWAY_ROUTE_VERIFY_ATTEMPTS, + 'delay_seconds' => self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS, + 'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'results' => array_values($results), + 'failed_target_ips' => array_values(array_keys($pending)), + ]; + } + + private static function gatewayRouteHealthErrors(?array $certificateBootstrap, ?array $verification): array + { + $errors = []; + + if (is_array($certificateBootstrap) && ($certificateBootstrap['ok'] ?? true) !== true) { + $errors[] = [ + 'type' => 'certificate_bootstrap_failed', + 'error' => "Let's Encrypt certificate bootstrap failed for one or more gateway targets.", + 'failed_target_ips' => self::failedCertificateBootstrapTargetIps($certificateBootstrap), + 'reason' => $certificateBootstrap['reason'] ?? null, + 'certificate_bootstrap' => $certificateBootstrap, + ]; + } + + if (is_array($verification) && ($verification['ok'] ?? true) !== true) { + $failedTargetIps = array_values(array_unique(array_filter(array_map( + static fn(mixed $targetIp): string => trim((string)$targetIp), + $verification['failed_target_ips'] ?? [] + )))); + $errors[] = [ + 'type' => 'gateway_route_verification_failed', + 'error' => "Gateway route and Let's Encrypt certificate verification is still failing.", + 'failed_target_ips' => $failedTargetIps, + 'verification' => $verification, + ]; + } + + return $errors; + } + + private static function failedCertificateBootstrapTargetIps(array $certificateBootstrap): array + { + $failedTargetIps = []; + foreach (($certificateBootstrap['results'] ?? []) as $result) { + if (!is_array($result) || ($result['ok'] ?? false) === true) { + continue; + } + $targetIp = trim((string)($result['target_ip'] ?? '')); + if ($targetIp !== '') { + $failedTargetIps[] = $targetIp; + } + } + + return array_values(array_unique($failedTargetIps)); + } + + private function bootstrapGatewayCertificates(array $targetIps, string $publicHost, array $config): array + { + $targetIps = array_values(array_unique(array_filter(array_map('strval', $targetIps)))); + $result = [ + 'ok' => true, + 'skipped' => false, + 'results' => [], + 'warnings' => [], + 'restored' => false, + ]; + + if (count($targetIps) < 2) { + $result['skipped'] = true; + $result['reason'] = 'single_target'; + return $result; + } + + if (empty($config['automation_enabled']) + || ($config['automation_mode'] ?? '') !== 'enforce' + || trim((string)($config['load_balancer_id'] ?? '')) === '' + || trim((string)($config['token'] ?? '')) === '') { + $result['skipped'] = true; + $result['ok'] = false; + $result['reason'] = 'load_balancer_enforce_required'; + $result['warnings'][] = "Let's Encrypt certificate bootstrap requires Hetzner load balancer automation in enforce mode."; + return $result; + } + + $client = $this->hetznerClient((string)$config['token']); + $loadBalancerId = (string)$config['load_balancer_id']; + $originalTargetIps = self::loadBalancerIpTargets($client->getLoadBalancer($loadBalancerId)); + $restoreTargetIps = $originalTargetIps !== [] ? $originalTargetIps : $targetIps; + + try { + foreach ($targetIps as $targetIp) { + $targetResult = [ + 'target_ip' => $targetIp, + 'ok' => false, + 'isolated' => false, + 'attempts' => self::GATEWAY_CERT_BOOTSTRAP_ATTEMPTS, + 'last_probe' => null, + ]; + + try { + $this->setLoadBalancerIpTargets($client, $loadBalancerId, [$targetIp]); + $targetResult['isolated'] = true; + + for ($attempt = 1; $attempt <= self::GATEWAY_CERT_BOOTSTRAP_ATTEMPTS; $attempt++) { + $probe = $this->probeGatewayPublicHost($publicHost); + $probe['attempt'] = $attempt; + $probe['target_ip'] = $targetIp; + $targetResult['last_probe'] = $probe; + if (($probe['ok'] ?? false) === true) { + $targetResult['ok'] = true; + break; + } + if ($attempt < self::GATEWAY_CERT_BOOTSTRAP_ATTEMPTS) { + sleep(self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS); + } + } + } catch (Throwable $throwable) { + $targetResult['error'] = $throwable->getMessage(); + } + + if (($targetResult['ok'] ?? false) !== true) { + $result['ok'] = false; + $result['warnings'][] = "Let's Encrypt certificate bootstrap failed for gateway target {$targetIp}."; + } + $result['results'][] = $targetResult; + } + } finally { + try { + $this->setLoadBalancerIpTargets($client, $loadBalancerId, $restoreTargetIps); + $result['restored'] = true; + } catch (Throwable $throwable) { + $result['ok'] = false; + $result['restore_error'] = $throwable->getMessage(); + $result['warnings'][] = 'Failed to restore Hetzner load balancer targets after certificate bootstrap: ' . $throwable->getMessage(); + } + } + + return $result; + } + + private function setLoadBalancerIpTargets(object $client, string $loadBalancerId, array $desiredIps): array + { + $desiredIps = array_values(array_unique(array_filter(array_map('strval', $desiredIps)))); + if ($desiredIps === []) { + throw new RuntimeException('At least one load balancer target must remain attached.'); + } + + $loadBalancer = $client->getLoadBalancer($loadBalancerId); + $currentIps = self::loadBalancerIpTargets($loadBalancer); + $actions = []; + + foreach (array_diff($desiredIps, $currentIps) as $ip) { + try { + $client->addIpTarget($loadBalancerId, $ip); + $actions[] = ['type' => 'add_target', 'target_ip' => $ip]; + } catch (hetzner_cloud_api_exception $exception) { + if ($exception->apiCode() !== 'target_already_defined') { + throw $exception; + } + $actions[] = ['type' => 'add_target', 'target_ip' => $ip, 'already_defined' => true]; + } + } + + $this->waitForLoadBalancerIpTargetsToInclude($client, $loadBalancerId, $desiredIps); + $loadBalancer = $client->getLoadBalancer($loadBalancerId); + $currentIps = self::loadBalancerIpTargets($loadBalancer); + + foreach (array_diff($currentIps, $desiredIps) as $ip) { + $client->removeIpTarget($loadBalancerId, $ip); + $actions[] = ['type' => 'remove_target', 'target_ip' => $ip]; + } + + $this->waitForLoadBalancerIpTargets($client, $loadBalancerId, $desiredIps); + return $actions; + } + + private function waitForLoadBalancerIpTargetsToInclude(object $client, string $loadBalancerId, array $requiredIps): void + { + $requiredIps = array_values(array_unique(array_filter(array_map('strval', $requiredIps)))); + for ($attempt = 1; $attempt <= self::GATEWAY_LOAD_BALANCER_TARGET_WAIT_ATTEMPTS; $attempt++) { + $currentIps = self::loadBalancerIpTargets($client->getLoadBalancer($loadBalancerId)); + if (array_diff($requiredIps, $currentIps) === []) { + return; + } + sleep(1); + } + + throw new RuntimeException('Timed out waiting for Hetzner load balancer targets to attach.'); + } + + private function waitForLoadBalancerIpTargets(object $client, string $loadBalancerId, array $desiredIps): void + { + $desiredIps = array_values(array_unique(array_filter(array_map('strval', $desiredIps)))); + sort($desiredIps); + + for ($attempt = 1; $attempt <= self::GATEWAY_LOAD_BALANCER_TARGET_WAIT_ATTEMPTS; $attempt++) { + $currentIps = self::loadBalancerIpTargets($client->getLoadBalancer($loadBalancerId)); + sort($currentIps); + if ($currentIps === $desiredIps) { + return; + } + sleep(1); + } + + throw new RuntimeException('Timed out waiting for Hetzner load balancer target changes.'); + } + + public function loadBalancerAutomationEnabled(): bool + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + return $config['automation_enabled'] + && $config['load_balancer_id'] !== '' + && $config['token_set']; + } + + private function loadBalancerReleaseApiTargets(): array + { + return array_values(array_filter( + $this->loadBalancerReleaseGatewayTargets(), + static fn(array $target): bool => self::gatewayRouteApp((string)($target['app'] ?? '')) === 'api' + )); + } + + private function loadBalancerReleaseGatewayTargets(): array + { + foreach (['release_deployment_targets', 'release_channels', 'release_deployments'] as $table) { + if (!$this->tableExists($table)) { + return []; + } + } + + return $this->selectRows( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, + c.default_channel AS channel_default_channel, + d.id AS latest_deployment_id, d.status AS latest_deployment_status, + d.completed_at AS latest_deployment_completed_at, + d.commit_sha AS latest_deployment_commit_sha, + v.version_label AS latest_version_label + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + LEFT JOIN ( + SELECT d1.* + FROM release_deployments d1 + INNER JOIN ( + SELECT target_id, MAX(id) AS id + FROM release_deployments + WHERE status IN ('active', 'deployed') + GROUP BY target_id + ) latest ON latest.id = d1.id + ) d ON d.target_id = t.id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE t.deleted_at IS NULL + AND c.deleted_at IS NULL + AND c.enabled = 1 + AND t.app IN ('api', 'frontend') + AND t.coolify_instance_id IS NOT NULL + AND t.coolify_service_uuid IS NOT NULL + AND TRIM(t.coolify_service_uuid) <> '' + AND d.id IS NOT NULL + ORDER BY CASE WHEN d.status = 'active' THEN 0 WHEN d.status = 'deployed' THEN 1 ELSE 2 END, + d.completed_at DESC, t.id DESC" + ); + } + + private function provisionMissingGatewayRouteTargets( + array $uncoveredGatewayIps, + array $targets, + string $publicHost, + string $publicUrl, + bool $dryRun, + ?int $actorUserId + ): array { + $planned = []; + $applied = []; + $skipped = []; + $errors = []; + $warnings = []; + $coveredTargetIps = []; + $deploymentWaitItems = []; + $sourceTarget = $this->gatewayRouteProvisionSourceTarget($targets); + $app = self::gatewayRouteApp((string)($sourceTarget['app'] ?? $targets[0]['app'] ?? 'api')); + $sourcePublicUrl = $sourceTarget === null + ? $publicUrl + : self::gatewayRouteTargetPublicUrl($publicHost, $sourceTarget); + + foreach ($uncoveredGatewayIps as $targetIp) { + $action = [ + 'type' => 'provision_gateway_' . $app . '_target', + 'app' => $app, + 'target_ip' => $targetIp, + 'public_url' => $sourcePublicUrl, + 'dry_run' => $dryRun, + ]; + + if ($sourceTarget === null) { + $skipped[] = array_replace($action, ['reason' => 'missing_source_api_target']); + continue; + } + + $action['source_target_id'] = (int)($sourceTarget['id'] ?? 0); + $serverMatch = $this->gatewayRouteServerForTargetIp($sourceTarget, $targetIp); + if (($serverMatch['error'] ?? '') !== '') { + $errors[] = array_replace($action, ['error' => $serverMatch['error']]); + continue; + } + if (($serverMatch['ambiguous'] ?? false) === true) { + $skipped[] = array_replace($action, ['reason' => 'ambiguous_coolify_server', 'matches' => $serverMatch['matches'] ?? []]); + continue; + } + $server = is_array($serverMatch['server'] ?? null) ? $serverMatch['server'] : null; + $serverUuid = trim((string)($server['uuid'] ?? '')); + if ($server === null || $serverUuid === '') { + $skipped[] = array_replace($action, ['reason' => 'coolify_server_not_found']); + continue; + } + + $action['coolify_instance_id'] = (int)($serverMatch['instance_id'] ?? $sourceTarget['coolify_instance_id'] ?? 0); + $action['server_uuid'] = $serverUuid; + $action['server_name'] = $server['name'] ?? null; + $planned[] = $action; + + if ($dryRun) { + $coveredTargetIps[] = $targetIp; + continue; + } + + try { + if (!class_exists(release_manager::class) && function_exists('app_require')) { + app_require('classes/release_manager.php'); + } + if (!class_exists(release_manager::class)) { + throw new RuntimeException('Release Manager is required to auto-provision gateway release targets.'); + } + + $releaseManager = new release_manager(); + $deploymentTarget = $this->gatewayRouteExistingDeploymentTargetForServer($sourceTarget, $action['coolify_instance_id'], $serverUuid, $app); + if ($deploymentTarget === null) { + $deploymentTarget = $releaseManager->upsertDeploymentTarget([ + 'channel_id' => (int)$sourceTarget['channel_id'], + 'app' => $app, + 'coolify_instance_id' => $action['coolify_instance_id'], + 'coolify_service_uuid' => '', + 'repository' => (string)($sourceTarget['repository'] ?? ''), + 'branch' => (string)($sourceTarget['branch'] ?? 'master'), + 'auto_deploy' => !isset($sourceTarget['auto_deploy']) || (int)$sourceTarget['auto_deploy'] === 1, + 'health_url' => $app === 'api' ? $sourcePublicUrl . '/ping' : $sourcePublicUrl . '/release-entry.json', + 'deploy_context' => $this->gatewayRouteProvisionDeployContext($sourceTarget, $server, $targetIp, $publicHost, $sourcePublicUrl), + ], $actorUserId); + } + + $sourceCommitSha = trim((string)($sourceTarget['latest_deployment_commit_sha'] ?? '')); + $deploymentInput = [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'channel_id' => (int)$sourceTarget['channel_id'], + 'app' => $app, + 'repository' => (string)($sourceTarget['repository'] ?? ''), + 'branch' => (string)($sourceTarget['branch'] ?? 'master'), + 'commit_mode' => $sourceCommitSha === '' ? 'latest' : 'specific', + 'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget), + 'deployed_url' => $sourcePublicUrl, + 'metadata' => [ + 'gateway_route_autoprovision' => true, + 'source_target_id' => (int)($sourceTarget['id'] ?? 0), + 'target_ip' => $targetIp, + 'server_uuid' => $serverUuid, + 'app' => $app, + ], + ]; + if ($sourceCommitSha !== '') { + $deploymentInput['commit_sha'] = $sourceCommitSha; + } + $deployment = $releaseManager->startDeployment($deploymentInput, $actorUserId); + + if ((string)($deployment['status'] ?? '') !== 'deployed') { + $errors[] = array_replace($action, [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'error' => (string)($deployment['error_message'] ?? 'Auto-provisioned release target deployment did not complete.'), + ]); + continue; + } + + $applied[] = array_replace($action, [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'status' => $deployment['status'] ?? null, + ]); + $deploymentResult = is_array($deployment['result'] ?? null) ? $deployment['result'] : []; + $deploymentWaitItems = array_merge( + $deploymentWaitItems, + self::coolifyDeploymentWaitItems($deploymentResult['deployment'] ?? [], $action['coolify_instance_id'], (string)($deploymentResult['service_uuid'] ?? ''), [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'target_ip' => $targetIp, + 'resource_type' => (string)($deploymentResult['resource_type'] ?? 'application'), + ]) + ); + $coveredTargetIps[] = $targetIp; + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + return [ + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'covered_target_ips' => $coveredTargetIps, + 'deployment_wait_items' => $deploymentWaitItems, + ]; + } + + private function gatewayRouteProvisionSourceTarget(array $targets): ?array + { + foreach ($targets as $target) { + if ((int)($target['coolify_instance_id'] ?? 0) <= 0) { + continue; + } + if (trim((string)($target['repository'] ?? '')) === '') { + continue; + } + return $target; + } + + return null; + } + + private function gatewayRouteServerForTargetIp(array $sourceTarget, string $targetIp): array + { + $instanceId = (int)($sourceTarget['coolify_instance_id'] ?? 0); + if ($instanceId <= 0) { + return ['server' => null]; + } + + try { + $instance = $this->getInstance($instanceId); + $servers = $this->coolifyCollection($this->clientForInstance($instance)->listServers()); + } catch (Throwable $throwable) { + return ['server' => null, 'error' => $throwable->getMessage()]; + } + + $matches = []; + foreach ($servers as $server) { + if (!is_array($server)) { + continue; + } + if (!self::gatewayRouteServerIsUsable($server)) { + continue; + } + if (self::gatewayRouteServerPublicIp($server) === $targetIp) { + $matches[] = $server; + } + } + + if (count($matches) > 1) { + return [ + 'server' => null, + 'ambiguous' => true, + 'matches' => array_map(static fn(array $server): array => [ + 'uuid' => $server['uuid'] ?? null, + 'name' => $server['name'] ?? null, + 'ip' => self::gatewayRouteServerPublicIp($server), + ], $matches), + ]; + } + + return [ + 'server' => $matches[0] ?? null, + 'instance_id' => $instanceId, + ]; + } + + private function gatewayRouteExistingDeploymentTargetForServer(array $sourceTarget, int $instanceId, string $serverUuid, string $app = 'api'): ?array + { + if ($instanceId <= 0 || $serverUuid === '') { + return null; + } + + $rows = $this->selectRows( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + WHERE t.deleted_at IS NULL + AND t.channel_id = ? + AND t.app = ? + AND t.coolify_instance_id = ? + AND t.repository = ? + AND t.branch = ? + ORDER BY t.id DESC", + 'isiss', + [ + (int)$sourceTarget['channel_id'], + self::gatewayRouteApp($app), + $instanceId, + (string)($sourceTarget['repository'] ?? ''), + (string)($sourceTarget['branch'] ?? ''), + ] + ); + + foreach ($rows as $row) { + $context = self::jsonDecode($row['deploy_context_json'] ?? null); + $candidateUuid = trim((string)($context['coolify_server_uuid'] ?? $context['server_uuid'] ?? '')); + if ($candidateUuid === $serverUuid) { + return $row; + } + } + + return null; + } + + private function gatewayRouteProvisionDeployContext(array $sourceTarget, array $server, string $targetIp, string $publicHost, string $publicUrl): array + { + $context = self::jsonDecode($sourceTarget['deploy_context_json'] ?? null); + $serverUuid = trim((string)($server['uuid'] ?? '')); + $channelSlug = self::gatewayRouteSlug((string)($sourceTarget['channel_slug'] ?? $sourceTarget['channel_id'] ?? 'release'), 'release'); + $app = self::gatewayRouteApp((string)($sourceTarget['app'] ?? 'api')); + $serverSlug = self::gatewayRouteSlug((string)($server['name'] ?? $targetIp), 'server'); + $serviceName = substr('release-' . $channelSlug . '-' . $app . '-' . $serverSlug, 0, 64); + + $context['coolify_auto_create'] = true; + $context['coolify_enable_ssl'] = true; + $context['coolify_deploy_now'] = true; + $context['coolify_ports_exposes'] = '80'; + $context['coolify_port'] = '80'; + $context['coolify_domain'] = $publicHost; + $context['coolify_public_url'] = $publicUrl; + $context['coolify_server_uuid'] = $serverUuid; + $context['server_uuid'] = $serverUuid; + $context['coolify_destination_uuid'] = ''; + $context['destination_uuid'] = ''; + if ($app === 'api') { + $context['coolify_build_pack'] = 'dockerfile'; + $context['coolify_dockerfile_location'] = '/Dockerfile.coolify-api'; + } else { + $context['coolify_build_pack'] = 'dockerfile'; + $context['coolify_dockerfile_location'] = '/Dockerfile.coolify-frontend'; + unset( + $context['coolify_install_command'], + $context['install_command'], + $context['coolify_build_command'], + $context['build_command'], + $context['coolify_publish_directory'], + $context['publish_directory'], + $context['coolify_is_static'], + $context['is_static'], + $context['coolify_is_spa'], + $context['is_spa'] + ); + } + + unset( + $context['coolify_base_directory'], + $context['base_directory'], + $context['coolify_docker_compose_location'], + $context['docker_compose_location'], + $context['coolify_dockerfile'], + $context['dockerfile'], + $context['coolify_git_commit_sha'], + $context['git_commit_sha'], + $context['commit_sha'], + $context['commit'], + $context['coolify_start_command'], + $context['start_command'] + ); + if ($app === 'api' || $app === 'frontend') { + unset( + $context['coolify_is_static'], + $context['is_static'], + $context['coolify_is_spa'], + $context['is_spa'], + $context['coolify_publish_directory'], + $context['publish_directory'] + ); + } + $context['coolify_service_name'] = $serviceName; + $context['coolify_application_name'] = $serviceName; + $context['gateway_route_autoprovision'] = true; + $context['gateway_route_source_target_id'] = (int)($sourceTarget['id'] ?? 0); + $context['gateway_route_target_ip'] = $targetIp; + + return $context; + } + + private function gatewayRouteProvisionVersionLabel(array $sourceTarget): string + { + $label = trim((string)($sourceTarget['latest_version_label'] ?? $sourceTarget['version_label'] ?? '')); + if ($label !== '') { + return $label; + } + + $channelSlug = self::gatewayRouteSlug((string)($sourceTarget['channel_slug'] ?? 'release'), 'release'); + $app = self::gatewayRouteApp((string)($sourceTarget['app'] ?? 'api')); + return $channelSlug . '-' . $app . '-gateway-' . date('Ymd-His'); + } + + private static function gatewayRouteApp(string $app): string + { + $app = strtolower(trim($app)); + return in_array($app, ['api', 'frontend'], true) ? $app : 'api'; + } + + private static function gatewayRouteServerIsUsable(array $server): bool + { + $settings = is_array($server['settings'] ?? null) ? $server['settings'] : []; + return ($settings['is_reachable'] ?? true) !== false && ($settings['is_usable'] ?? true) !== false; + } + + private static function gatewayRouteServerPublicIp(array $server): ?string + { + foreach ([ + 'public_ip', + 'publicIp', + 'public_ipv4', + 'publicIpv4', + 'ip', + 'address', + 'hostname', + 'fqdn', + 'domain', + 'name', + ] as $key) { + $ip = self::publicIpFromHost($server[$key] ?? null); + if ($ip !== null) { + return $ip; + } + } + + return self::publicIpFromHost(self::publicServerHostFromCoolifyServer($server)); + } + + private static function gatewayRouteSlug(string $value, string $fallback): string + { + $slug = strtolower(trim($value)); + $slug = preg_replace('/[^a-z0-9-]+/', '-', $slug) ?: ''; + $slug = trim($slug, '-'); + return substr($slug !== '' ? $slug : $fallback, 0, 40); + } + + private static function gatewayRouteTargetPublicUrl(string $publicHost, array $target): string + { + $baseUrl = 'https://' . strtolower(trim($publicHost)); + $channelSlug = self::gatewayRouteSlug((string)($target['channel_slug'] ?? ''), ''); + $appSlug = self::gatewayRouteSlug((string)($target['app'] ?? 'api'), 'api'); + $defaultChannel = (int)($target['channel_default_channel'] ?? $target['default_channel'] ?? 0) === 1 + || $channelSlug === 'stable' + || $channelSlug === ''; + + if ($defaultChannel || !in_array($appSlug, ['api', 'frontend'], true)) { + return $baseUrl; + } + + return $baseUrl . '/' . $channelSlug . '/' . $appSlug; + } + + private function gatewayRouteResourceType(array $target): string + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $type = strtolower(trim((string)($context['coolify_resource_type'] ?? $context['resource_type'] ?? ''))); + if (in_array($type, ['service', 'docker-compose', 'compose'], true)) { + return 'service'; + } + + return 'application'; + } + + private static function resourceServerIp(array $resource): ?string + { + $servers = []; + foreach ([ + $resource['destination']['server'] ?? null, + $resource['server'] ?? null, + $resource['server_details'] ?? null, + ] as $server) { + if (is_array($server)) { + $servers[] = $server; + } + } + + foreach ($servers as $server) { + $host = self::publicServerHostFromCoolifyServer($server); + $ip = self::publicIpFromHost($host); + if ($ip !== null) { + return $ip; + } + } + + foreach ([ + 'public_ip', + 'publicIp', + 'public_ipv4', + 'publicIpv4', + 'server_ip', + 'serverIp', + 'ip', + ] as $key) { + $ip = self::publicIpFromHost($resource[$key] ?? null); + if ($ip !== null) { + return $ip; + } + } + + return null; + } + + private static function resourcePublicUrl(array $resource): ?string + { + foreach (['fqdn', 'domains', 'domain', 'url'] as $key) { + $url = self::firstPublicUrl($resource[$key] ?? null); + if ($url !== null) { + return $url; + } + } + + return self::firstPublicUrl($resource['urls'] ?? null); + } + + private static function gatewayRouteApplicationPayload( + string $publicUrl, + string $resourceUuid = '', + ?int $port = null, + mixed $existingLabels = null + ): array + { + $decodedLabels = self::decodeCoolifyLabels($existingLabels); + $routePort = $port ?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid); + $payload = [ + 'domains' => self::coolifyProxyUrl($publicUrl, $routePort), + 'is_force_https_enabled' => true, + 'force_domain_override' => true, + ]; + + $labels = self::gatewayRouteApplicationLabels( + $publicUrl, + $resourceUuid, + $routePort, + self::gatewayRouteDefaultCertResolver($publicUrl) + ); + if ($labels !== []) { + $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( + $decodedLabels, + $labels + ))); + } + + return $payload; + } + + private static function gatewayRouteServicePayload(string $publicUrl, string $app, ?int $port = null): array + { + return [ + 'urls' => [ + [ + 'name' => trim($app) !== '' ? $app : 'api', + 'url' => self::coolifyProxyUrl($publicUrl, $port), + ], + ], + 'force_domain_override' => true, + ]; + } + + private static function coolifyProxyUrl(string $publicUrl, ?int $port): string + { + if ($port === null || $port <= 0) { + return $publicUrl; + } + + $parts = parse_url($publicUrl); + if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) { + return $publicUrl; + } + + $scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https'; + $host = trim((string)$parts['host']); + $path = (string)($parts['path'] ?? ''); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; + return "{$scheme}://{$host}:{$port}{$path}{$query}{$fragment}"; + } + + private static function gatewayRouteApplicationLabels( + string $publicUrl, + string $resourceUuid, + ?int $port = null, + ?string $certResolver = null + ): array + { + $resourceUuid = self::gatewayRouteLabelId($resourceUuid); + if ($resourceUuid === '') { + return []; + } + + $parts = parse_url($publicUrl); + $host = trim((string)($parts['host'] ?? '')); + if ($host === '') { + return []; + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + $path = trim((string)($parts['path'] ?? '/')); + $path = $path !== '' ? $path : '/'; + if ($path[0] !== '/') { + $path = '/' . $path; + } + + $routePort = $port ?? self::firstInteger($parts['port'] ?? null); + $certResolver = trim((string)($certResolver ?? '')); + $httpLabel = 'http-0-' . $resourceUuid; + $httpsLabel = 'https-0-' . $resourceUuid; + $labels = [ + 'traefik.enable=true', + 'traefik.http.middlewares.gzip.compress=true', + 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https', + ]; + + if ($scheme === 'https') { + $labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; + $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; + } + if ($path !== '/') { + $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; + } else { + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip"; + } + $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; + if ($certResolver !== '') { + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; + } + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}"; + $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; + $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; + } + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=redirect-to-https"; + } else { + $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; + $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; + } + if ($path !== '/') { + $labels[] = "traefik.http.middlewares.{$httpLabel}-stripprefix.stripprefix.prefixes={$path}"; + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares={$httpLabel}-stripprefix,gzip"; + } else { + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=gzip"; + } + } + + sort($labels); + return $labels; + } + + private static function mergeCoolifyLabels(array $existingLabels, array $generatedLabels): array + { + $merged = []; + foreach (array_merge($existingLabels, $generatedLabels) as $label) { + $label = trim((string)$label); + if ($label === '') { + continue; + } + $merged[self::coolifyLabelKey($label)] = $label; + } + + return array_values($merged); + } + + private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int + { + $resourceUuid = self::gatewayRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $port = (int)$matches[2]; + if ($port <= 0) { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $port; + } + $fallback ??= $port; + } + + return $fallback; + } + + private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string + { + $resourceUuid = self::gatewayRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $resolver = trim((string)$matches[2]); + if ($resolver === '') { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $resolver; + } + $fallback ??= $resolver; + } + + return $fallback; + } + + private static function gatewayRouteDefaultCertResolver(string $publicUrl): string + { + return 'letsencrypt'; + } + + private static function decodeCoolifyLabels(mixed $labels): array + { + if (!is_scalar($labels)) { + return []; + } + + $raw = trim((string)$labels); + if ($raw === '') { + return []; + } + + $decoded = base64_decode($raw, true); + $content = $decoded !== false ? $decoded : $raw; + return array_values(array_filter( + preg_split('/\r\n|\r|\n/', (string)$content) ?: [], + static fn(string $label): bool => trim($label) !== '' + )); + } + + private static function coolifyLabelKey(string $label): string + { + $position = strpos($label, '='); + return $position === false ? trim($label) : trim(substr($label, 0, $position)); + } + + private static function gatewayRouteLabelId(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?: ''; + return trim($value, '-'); + } + + private static function resourceFirstExposedPort(array $resource, array $target = []): ?int + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + foreach ([ + $resource['ports_exposes'] ?? null, + $resource['portsExposes'] ?? null, + $context['coolify_ports_exposes'] ?? null, + $context['ports_exposes'] ?? null, + $context['coolify_port'] ?? null, + $context['port'] ?? null, + ] as $value) { + $port = self::firstInteger($value); + if ($port !== null) { + return $port; + } + } + + return null; + } + + private static function firstInteger(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + if (is_float($value)) { + return $value > 0 ? (int)$value : null; + } + if (is_array($value)) { + foreach ($value as $item) { + $integer = self::firstInteger($item); + if ($integer !== null) { + return $integer; + } + } + return null; + } + if (!is_scalar($value)) { + return null; + } + if (preg_match('/\d+/', (string)$value, $matches) !== 1) { + return null; + } + + $integer = (int)$matches[0]; + return $integer > 0 ? $integer : null; + } + + private function persistGatewayRouteTargetContext(int $targetId, array $target, string $publicHost, string $publicUrl): void + { + if ($targetId <= 0) { + return; + } + + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $context['coolify_enable_ssl'] = true; + $context['coolify_domain'] = $publicHost; + $context['coolify_public_url'] = $publicUrl; + + $this->execute( + 'UPDATE release_deployment_targets SET deploy_context_json = ? WHERE id = ?', + 'si', + [self::jsonEncode($context), $targetId] + ); + + if (!$this->tableExists('release_deployments')) { + return; + } + + $deployment = $this->selectOne( + "SELECT id FROM release_deployments + WHERE target_id = ? AND app = 'api' AND status IN ('active', 'deployed') + ORDER BY id DESC + LIMIT 1", + 'i', + [$targetId] + ); + if ($deployment === null) { + return; + } + + $this->execute( + 'UPDATE release_deployments SET deployment_url = ? WHERE id = ?', + 'si', + [$publicUrl, (int)$deployment['id']] + ); + } + + private function shouldRetryProvisioning(array $target, ?array $host = null): bool + { + if (trim((string)($target['resource_uuid'] ?? '')) === '') { + return false; + } + + if (in_array((string)($target['deployment_status'] ?? ''), ['created', 'deploying', 'provision_blocked'], true)) { + return true; + } + + return $host !== null && self::replicationHostStillNeedsProvisioning($host); + } + + private function hasRunningReplicationProvisionOperation(string $kind, int $hostId): bool + { + if ($hostId <= 0) { + return false; + } + + return $this->selectOne( + "SELECT id FROM replication_operations + WHERE kind = ? AND host_id = ? AND operation = 'provision' AND status = 'running' + LIMIT 1", + 'si', + [$kind, $hostId] + ) !== null; + } + + private static function replicationHostStillNeedsProvisioning(array $host): bool + { + if ((string)($host['role'] ?? '') === 'primary') { + return false; + } + + $status = self::jsonDecode($host['last_status_json'] ?? null); + $effectiveStatus = (string)($status['status'] ?? $host['status'] ?? 'unknown'); + $percent = round((float)($status['replication_percent'] ?? 0), 2); + $blockers = array_values(array_filter($status['blockers'] ?? [])); + + return $effectiveStatus !== 'ok' || $percent < 100.0 || $blockers !== []; + } + + private function getTargetDeploymentStatus(int $targetId): string + { + try { + $target = $this->selectOne('SELECT deployment_status FROM coolify_targets WHERE id = ? LIMIT 1', 'i', [$targetId]); + return (string)($target['deployment_status'] ?? 'unknown'); + } catch (Throwable) { + return 'unknown'; + } + } + + public static function parseEnvFile(string $env): array + { + $values = []; + foreach (preg_split('/\r\n|\r|\n/', $env) ?: [] as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + if ($key === '') { + continue; + } + $values[$key] = trim($value); + } + return $values; + } + + public static function blocksPrimaryMutation(array $host, string $operation): bool + { + return in_array($operation, ['deploy', 'restart', 'delete', 'stop', 'replace'], true) + && (string)($host['role'] ?? '') === 'primary'; + } + + public static function targetAllowsReplicaRemoval(?array $target): bool + { + if ($target === null || (string)($target['role'] ?? $target['replication_role'] ?? '') === 'primary') { + return false; + } + + $deploymentStatus = (string)($target['deployment_status'] ?? ''); + $lastReconcileStatus = (string)($target['last_reconcile_status'] ?? ''); + if (in_array($deploymentStatus, ['reconcile_failed', 'removed', 'delete_failed'], true) + || in_array($lastReconcileStatus, ['reconcile_failed', 'delete_failed'], true)) { + return true; + } + + $lastReconcile = self::jsonDecode($target['last_reconcile_json'] ?? null); + $message = strtolower((string)($lastReconcile['message'] ?? $lastReconcile['error'] ?? '')); + return $message !== '' && (str_contains($message, 'not found') || str_contains($message, '404')); + } + + public static function replicationHostCanBeRemoved(array $host): bool + { + if ((string)($host['role'] ?? '') === 'primary') { + return false; + } + + $hostId = (int)($host['id'] ?? 0); + if ($hostId <= 0) { + return false; + } + + try { + if (!coolify_schema_bootstrap::tablesExist()) { + return false; + } + + $manager = new self(); + $target = $manager->selectOne( + 'SELECT * FROM coolify_targets WHERE replication_host_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT 1', + 'i', + [$hostId] + ); + if ($target === null) { + return self::hostHasCoolifyMetadata($host); + } + + return self::targetAllowsReplicaRemoval($target); + } catch (Throwable) { + return false; + } + } + + public static function markTargetsRemovedForReplicationHost(int $hostId, ?int $actorUserId = null): void + { + if ($hostId <= 0) { + return; + } + + try { + if (!coolify_schema_bootstrap::tablesExist()) { + return; + } + + $manager = new self(); + $targets = $manager->selectRows( + 'SELECT id, instance_id, replication_host_id FROM coolify_targets WHERE replication_host_id = ? AND deleted_at IS NULL', + 'i', + [$hostId] + ); + if ($targets === []) { + return; + } + + $manager->execute( + "UPDATE coolify_targets + SET deleted_at = NOW(), deployment_status = 'removed', availability_state = 'degraded' + WHERE replication_host_id = ? AND deleted_at IS NULL", + 'i', + [$hostId] + ); + + foreach ($targets as $target) { + $manager->audit( + (int)$target['id'], + (int)$target['instance_id'], + (int)$target['replication_host_id'], + 'target_removed_with_replication_host', + $actorUserId, + 'warning', + ['host_id' => $hostId] + ); + } + } catch (Throwable) { + // Removing the replication host should not be blocked by optional Coolify metadata cleanup. + } + } + + public static function deploymentMetadataForReplicationHost(int $hostId): ?array + { + if ($hostId <= 0) { + return null; + } + + try { + global $db; + if (!coolify_schema_bootstrap::tablesExist()) { + return null; + } + $stmt = $db->prepare( + "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url + FROM coolify_targets t + INNER JOIN coolify_instances i ON i.id = t.instance_id + WHERE t.replication_host_id = ? AND t.deleted_at IS NULL + ORDER BY t.id DESC LIMIT 1" + ); + if ($stmt === false) { + return null; + } + $stmt->bind_param('i', $hostId); + $stmt->execute(); + $result = $stmt->get_result(); + $target = $result ? $result->fetch_assoc() : null; + if (!is_array($target)) { + return null; + } + + return [ + 'target_id' => (int)$target['id'], + 'instance_id' => (int)$target['instance_id'], + 'instance_label' => (string)($target['instance_label'] ?? ''), + 'base_url' => (string)($target['instance_base_url'] ?? ''), + 'server_uuid' => $target['server_uuid'] ?? null, + 'project_uuid' => $target['project_uuid'] ?? null, + 'environment_uuid' => $target['environment_uuid'] ?? null, + 'environment_name' => $target['environment_name'] ?? null, + 'destination_uuid' => $target['destination_uuid'] ?? null, + 'resource_uuid' => $target['resource_uuid'] ?? null, + 'resource_type' => (string)($target['resource_type'] ?? self::RESOURCE_TYPE_SERVICE), + 'resource_name' => $target['resource_name'] ?? null, + 'deployment_status' => (string)($target['deployment_status'] ?? 'unknown'), + 'last_reconcile_status' => $target['last_reconcile_status'] ?? null, + 'last_reconciled_at' => $target['last_reconciled_at'] ?? null, + 'availability_state' => (string)($target['availability_state'] ?? 'degraded'), + ]; + } catch (Throwable) { + return null; + } + } + + public static function syncDeploymentStateForReplicationHost(int $hostId): void + { + if ($hostId <= 0) { + return; + } + + try { + if (!coolify_schema_bootstrap::tablesExist()) { + return; + } + + (new self())->syncTargetsForReplicationHost($hostId); + } catch (Throwable) { + // Replication health checks must not fail just because Coolify metadata cannot be updated. + } + } + + public static function syncLabelForReplicationHost(int $hostId, string $label): void + { + if ($hostId <= 0 || trim($label) === '') { + return; + } + + try { + if (!coolify_schema_bootstrap::tablesExist()) { + return; + } + + (new self())->execute( + 'UPDATE coolify_targets SET label = ? WHERE replication_host_id = ? AND deleted_at IS NULL', + 'si', + [$label, $hostId] + ); + } catch (Throwable) { + // Renaming a replication host should not fail because optional Coolify metadata is unavailable. + } + } + + private function syncTargetsForReplicationHost(int $hostId): void + { + $host = $this->replicationHost($hostId, true); + $availabilityState = $this->availabilityStateForHost($host); + $hostIsReady = $this->replicationHostIsReady($host); + + foreach ($this->selectRows( + 'SELECT id, deployment_status FROM coolify_targets WHERE replication_host_id = ? AND deleted_at IS NULL', + 'i', + [$hostId] + ) as $target) { + $deploymentStatus = (string)($target['deployment_status'] ?? 'unknown'); + $nextDeploymentStatus = $deploymentStatus; + if ($hostIsReady && in_array($deploymentStatus, ['pending', 'created', 'deploying', 'provision_blocked', 'restarting'], true)) { + $nextDeploymentStatus = 'provisioned'; + } + + $this->execute( + 'UPDATE coolify_targets SET availability_state = ?, deployment_status = ? WHERE id = ?', + 'ssi', + [$availabilityState, $nextDeploymentStatus, (int)$target['id']] + ); + } + } + + private function ensureSchema(): void + { + if ($this->schemaEnsured) { + return; + } + + coolify_schema_bootstrap::ensureTables(); + $this->schemaEnsured = true; + } + + private function composeInputFromRequest(string $kind, array $input, array $instance): array + { + $composeRole = $this->isIsolatedStackTargetRequest($input) ? 'primary' : 'replica'; + $hostPort = (int)($input['host_port'] ?? $input['port'] ?? match ($kind) { + 'database' => 3307, + 'redis' => 6380, + default => 9010, + }); + + $base = [ + 'kind' => $kind, + 'role' => $composeRole, + 'service_name' => $input['service_name'] ?? $input['resource_name'] ?? null, + 'host_port' => $hostPort, + ]; + + if ($kind === 'database') { + $base['database'] = (string)($input['database'] ?? $input['database_name'] ?? 'nnks_db'); + $base['username'] = (string)($input['username'] ?? 'nnks_db_user'); + $base['server_id'] = (int)($input['server_id'] ?? max(2, time() % 4294967295)); + [$base['primary_host'], $base['primary_port']] = $this->primaryAddress('database'); + } elseif ($kind === 'redis') { + [$base['primary_host'], $base['primary_port']] = $this->primaryAddress('redis'); + } else { + $base['host'] = (string)($input['host'] ?? ''); + $base['scheme'] = (string)($input['scheme'] ?? 'http'); + $base['console_port'] = (int)($input['console_port'] ?? ($hostPort + 1)); + $base['buckets'] = $this->normalizeBuckets($input['buckets'] ?? null); + $base['replication_transfer_limit'] = (string)($input['replication_transfer_limit'] + ?? ($input['options']['replication_transfer_limit'] ?? '')); + [$base['primary_host'], $base['primary_port']] = $this->primaryAddress('minio'); + } + + return $base; + } + + private function hostPayloadFromTemplate(string $kind, array $input, array $template): array + { + $credentials = is_array($template['credentials'] ?? null) ? $template['credentials'] : []; + $host = trim((string)($input['host'] ?? $input['endpoint'] ?? '')); + if ($host === '') { + throw new RuntimeException('Target host is required so replication can reach the Coolify-managed container.'); + } + $isolatedStack = $this->isIsolatedStackTargetRequest($input); + + $payload = [ + 'label' => trim((string)($input['label'] ?? $credentials['label'] ?? $template['service_name'] ?? '')), + 'host' => $host, + 'port' => (int)($credentials['port'] ?? $input['port'] ?? $template['host_port'] ?? 0), + 'username' => (string)($credentials['username'] ?? $input['username'] ?? ''), + 'password' => (string)($credentials['password'] ?? $input['password'] ?? ''), + ]; + + if ($kind === 'database') { + $payload['database'] = (string)($credentials['database'] ?? $input['database'] ?? $input['database_name'] ?? ''); + $payload['admin_username'] = (string)($credentials['admin_username'] ?? $input['admin_username'] ?? 'root'); + $payload['admin_password'] = (string)($credentials['admin_password'] ?? $input['admin_password'] ?? ''); + $payload['replication_username'] = (string)($credentials['replication_username'] ?? $input['replication_username'] ?? 'replication'); + $payload['replication_password'] = (string)($credentials['replication_password'] ?? $input['replication_password'] ?? ''); + $payload['ssl_mode'] = (string)($credentials['ssl_mode'] ?? $input['ssl_mode'] ?? 'DISABLED'); + $payload['options'] = [ + 'allow_preseeded_replica' => !$isolatedStack, + 'isolated_stack' => $isolatedStack, + 'skip_replication_provisioning' => $isolatedStack, + 'production_data_attached' => false, + ]; + } elseif ($kind === 'redis') { + $payload['database'] = (int)($credentials['database'] ?? $input['database'] ?? 0); + $payload['options'] = [ + 'isolated_stack' => $isolatedStack, + 'skip_replication_provisioning' => $isolatedStack, + 'production_data_attached' => false, + ]; + } else { + $payload['scheme'] = (string)($credentials['scheme'] ?? $input['scheme'] ?? 'http'); + $payload['buckets'] = $credentials['buckets'] ?? $this->normalizeBuckets($input['buckets'] ?? null); + $payload['console_port'] = (int)($credentials['console_port'] ?? $input['console_port'] ?? 9001); + $payload['replication_transfer_limit'] = (string)($credentials['replication_transfer_limit'] + ?? $input['replication_transfer_limit'] + ?? ($input['options']['replication_transfer_limit'] ?? '')); + $payload['options'] = [ + 'scheme' => $payload['scheme'], + 'buckets' => $payload['buckets'], + 'console_port' => $payload['console_port'], + 'replication_transfer_limit' => $payload['replication_transfer_limit'], + 'space_headroom_percent' => (float)($credentials['space_headroom_percent'] ?? 20.0), + 'isolated_stack' => $isolatedStack, + 'skip_replication_provisioning' => $isolatedStack, + 'production_data_attached' => false, + ]; + } + + return $payload; + } + + private function composeTemplateForTarget(array $target, array $host): array + { + $kind = (string)$target['kind']; + $options = self::jsonDecode($target['options_json'] ?? null); + $credentials = $this->hostCredentials($host); + $input = is_array($options['compose_input'] ?? null) ? $options['compose_input'] : []; + $input['kind'] = $kind; + $input['role'] = $this->targetComposeRole($target, $options); + $input['service_name'] = $target['resource_name'] ?? $target['label'] ?? null; + $input['host_port'] = (int)($host['port'] ?? $input['host_port'] ?? 0); + + if ($kind === 'database') { + $input['database'] = (string)($host['database_name'] ?? $input['database'] ?? ''); + $input['username'] = (string)($host['username'] ?? $input['username'] ?? ''); + $input['password'] = $credentials['password']; + $input['admin_username'] = $credentials['admin_username'] ?: 'root'; + $input['admin_password'] = $credentials['admin_password']; + $input['replication_username'] = $credentials['replication_username'] ?: 'replication'; + $input['replication_password'] = $credentials['replication_password']; + [$input['primary_host'], $input['primary_port']] = $this->primaryAddress('database'); + $primaryCredentials = $this->primaryCredentials('database'); + if ($primaryCredentials !== []) { + $input['primary_admin_username'] = $primaryCredentials['admin_username'] + ?: ($primaryCredentials['username'] ?: 'root'); + $input['primary_admin_password'] = $primaryCredentials['admin_password'] + ?: $primaryCredentials['password']; + } + } elseif ($kind === 'redis') { + $input['password'] = $credentials['password']; + [$input['primary_host'], $input['primary_port']] = $this->primaryAddress('redis'); + } else { + $hostOptions = self::jsonDecode($host['options_json'] ?? null); + $input['host'] = (string)($host['host'] ?? $input['host'] ?? ''); + $input['scheme'] = (string)($hostOptions['scheme'] ?? $input['scheme'] ?? 'http'); + $input['username'] = $credentials['username']; + $input['password'] = $credentials['password']; + $input['buckets'] = $hostOptions['buckets'] ?? $input['buckets'] ?? []; + $input['console_port'] = (int)($hostOptions['console_port'] ?? $input['console_port'] ?? 9001); + $input['replication_transfer_limit'] = (string)($hostOptions['replication_transfer_limit'] + ?? $input['replication_transfer_limit'] + ?? ''); + [$input['primary_host'], $input['primary_port']] = $this->primaryAddress('minio'); + } + + return replication_manager::composeTemplate($input); + } + + private function primaryCredentials(string $kind): array + { + $primary = $this->selectOne( + "SELECT * FROM replication_hosts WHERE kind = ? AND role = 'primary' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1", + 's', + [$kind] + ); + if ($primary === null) { + return []; + } + + return $this->hostCredentials($primary); + } + + private function startOrRestartService(coolify_api_client $client, string $resourceUuid, bool $restartIfRunning): array + { + try { + return $client->startService($resourceUuid); + } catch (Throwable $throwable) { + if (!str_contains(strtolower($throwable->getMessage()), 'already running')) { + throw $throwable; + } + + if ($restartIfRunning) { + return array_replace( + ['already_running' => true, 'action' => 'restart_requested'], + $client->restartService($resourceUuid) + ); + } + + return [ + 'already_running' => true, + 'action' => 'start_noop', + 'message' => 'Service is already running.', + ]; + } + } + + private function recordCreatedResource(int $targetId, string $resourceUuid, string $hash): void + { + $this->execute( + "UPDATE coolify_targets + SET resource_uuid = ?, deployment_status = 'created', desired_compose_hash = ?, + last_reconcile_status = 'created', last_reconciled_at = NOW() + WHERE id = ?", + 'ssi', + [$resourceUuid, $hash, $targetId] + ); + } + + private function servicePayload(array $target, array $template, bool $update): array + { + $payload = [ + 'name' => (string)($target['resource_name'] ?? $target['label']), + 'description' => 'Truckwash managed ' . $target['kind'] . ' replication target. Do not stop the active primary here.', + 'instant_deploy' => false, + 'docker_compose_raw' => $this->encodedDockerCompose($template), + 'force_domain_override' => false, + ]; + + if (!$update) { + $payload = array_replace($payload, [ + 'project_uuid' => $target['project_uuid'] ?? null, + 'environment_name' => $target['environment_name'] ?: 'production', + 'environment_uuid' => $target['environment_uuid'] ?? null, + 'server_uuid' => $target['server_uuid'] ?? null, + 'destination_uuid' => $target['destination_uuid'] ?? null, + ]); + } + + return array_filter($payload, static fn($value): bool => $value !== null && $value !== ''); + } + + private function encodedDockerCompose(array $template): string + { + return base64_encode((string)($template['compose'] ?? '')); + } + + private function targetOptions(array $input, array $template, array $composeInput): array + { + $isolatedStack = $this->isIsolatedStackTargetRequest($input); + $options = [ + 'compose_input' => $composeInput, + 'compose_role' => (string)($composeInput['role'] ?? 'replica'), + 'compose_service_name' => (string)($template['service_name'] ?? ''), + 'engine' => (string)($template['engine'] ?? ''), + 'coolify_docs' => [ + 'services_endpoint' => '/api/v1/services', + 'envs_bulk_endpoint' => '/api/v1/services/{uuid}/envs/bulk', + ], + ]; + + if ($isolatedStack) { + $options['isolated_stack'] = true; + $options['skip_replication_provisioning'] = true; + $options['production_data_attached'] = false; + } + + return $options; + } + + private function isIsolatedStackTargetRequest(array $input): bool + { + $options = is_array($input['options'] ?? null) ? $input['options'] : []; + + return $this->toBool( + $input['isolated_stack'] + ?? $input['isolated_empty_service'] + ?? $input['skip_replication_provisioning'] + ?? $options['isolated_stack'] + ?? $options['skip_replication_provisioning'] + ?? false, + false + ); + } + + private function targetComposeRole(array $target, array $options): string + { + $composeInput = is_array($options['compose_input'] ?? null) ? $options['compose_input'] : []; + $composeRole = strtolower(trim((string)($options['compose_role'] ?? $composeInput['role'] ?? ''))); + if (in_array($composeRole, ['primary', 'replica'], true)) { + return $composeRole; + } + + return $this->toBool($options['isolated_stack'] ?? $options['skip_replication_provisioning'] ?? false, false) + ? 'primary' + : 'replica'; + } + + private function targetSkipsReplicationProvisioning(array $target): bool + { + $options = self::jsonDecode($target['options_json'] ?? null); + + return $this->toBool($options['skip_replication_provisioning'] ?? $options['isolated_stack'] ?? false, false); + } + + private function attachTargetToReplicationHost(string $kind, int $hostId, int $targetId, int $instanceId): void + { + $host = $this->replicationHost($hostId, true); + $options = self::jsonDecode($host['options_json'] ?? null); + $options['deployment_provider'] = 'coolify'; + $options['coolify_instance_id'] = $instanceId; + $options['coolify_target_id'] = $targetId; + $this->execute( + 'UPDATE replication_hosts SET options_json = ? WHERE id = ? AND kind = ?', + 'sis', + [self::jsonEncode($options), $hostId, $kind] + ); + } + + private function blockedTargetOperation(array $target, array $host, string $operation, ?int $actorUserId): array + { + $context = [ + 'operation' => $operation, + 'reason' => 'active_primary_guard', + 'message' => 'Coolify will not mutate the active primary. Promote a healthy replica first.', + ]; + $this->execute( + "UPDATE coolify_targets SET availability_state = 'destructive_action_required', last_reconcile_status = ?, last_reconcile_json = ?, last_reconciled_at = NOW() WHERE id = ?", + 'ssi', + ['blocked', self::jsonEncode($context), (int)$target['id']] + ); + $this->audit((int)$target['id'], (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_operation_blocked', $actorUserId, 'warning', $context); + + return [ + 'ok' => false, + 'status' => 'destructive_action_required', + 'message' => $context['message'], + 'target' => $this->publicTarget($this->getTarget((int)$target['id'])), + 'host' => [ + 'id' => (int)($host['id'] ?? 0), + 'role' => (string)($host['role'] ?? ''), + 'status' => (string)($host['status'] ?? ''), + ], + ]; + } + + private function availabilitySummary(): array + { + $summary = []; + foreach (self::KINDS as $kind) { + $targets = $this->listTargets($kind); + $states = array_map(static fn(array $target): string => (string)($target['availability_state'] ?? 'degraded'), $targets); + $summary[$kind] = [ + 'status' => in_array('protected', $states, true) ? 'protected' : ($targets === [] ? 'not_configured' : 'degraded'), + 'targets' => count($targets), + 'protected' => count(array_filter($states, static fn(string $state): bool => $state === 'protected' || $state === 'failover_ready')), + 'blocked' => count(array_filter($states, static fn(string $state): bool => str_contains($state, 'blocked') || $state === 'destructive_action_required')), + ]; + } + return $summary; + } + + private function loadBalancerConfig(): array + { + $mode = $this->coolifyConfigValue('lb_automation_mode', 'report_only'); + $mode = in_array($mode, ['report_only', 'enforce'], true) ? $mode : 'report_only'; + $token = $this->hetznerCloudToken(); + $tokenSource = trim((string)(getenv('HETZNER_CLOUD_API_TOKEN') ?: '')) !== '' ? 'env' : 'config'; + + return [ + 'automation_enabled' => $this->coolifyConfigBool('lb_automation_enabled', false), + 'automation_mode' => $mode, + 'load_balancer_id' => $this->coolifyConfigValue('hetzner_load_balancer_id', ''), + 'public_gateway_host' => $this->coolifyConfigValue('public_gateway_host', self::DEFAULT_PUBLIC_GATEWAY_HOST), + 'token' => $token, + 'token_set' => trim($token) !== '', + 'token_source' => trim($token) !== '' ? $tokenSource : null, + 'required_services' => self::REQUIRED_LOAD_BALANCER_SERVICES, + ]; + } + + private function publicLoadBalancerConfig(array $config): array + { + unset($config['token']); + return $config; + } + + private function coolifyConfigValue(string $variable, string $default = ''): string + { + $row = $this->selectOne( + "SELECT value FROM module_config WHERE module = 'Coolify' AND variable = ? LIMIT 1", + 's', + [$variable] + ); + $value = trim((string)($row['value'] ?? '')); + return $value !== '' ? $value : $default; + } + + private function coolifyConfigBool(string $variable, bool $default = false): bool + { + $row = $this->selectOne( + "SELECT value FROM module_config WHERE module = 'Coolify' AND variable = ? LIMIT 1", + 's', + [$variable] + ); + if ($row === null) { + return $default; + } + return $this->toBool($row['value'] ?? null, $default); + } + + private function hetznerCloudToken(): string + { + $envToken = trim((string)(getenv('HETZNER_CLOUD_API_TOKEN') ?: '')); + if ($envToken !== '') { + return $envToken; + } + + $stored = $this->coolifyConfigValue('hetzner_cloud_api_token', ''); + if ($stored === '') { + return ''; + } + + return replication_secret_box::decrypt($stored); + } + + private function hetznerClient(string $token): object + { + if ($this->hetznerClientFactory !== null) { + $client = call_user_func($this->hetznerClientFactory, $token); + foreach (['getLoadBalancer', 'addIpTarget', 'removeIpTarget', 'addService'] as $method) { + if (!is_object($client) || !method_exists($client, $method)) { + throw new RuntimeException('Hetzner client factory returned an invalid client.'); + } + } + return $client; + } + + return new hetzner_cloud_client($token); + } + + private function planLoadBalancerReconcile(array $loadBalancer, array $gateways): array + { + $actualTargetIps = self::loadBalancerIpTargets($loadBalancer); + $actualServices = self::loadBalancerServices($loadBalancer); + $enabledIps = []; + $actions = []; + $missingTargets = []; + $disabledPresentTargets = []; + $missingServices = []; + + foreach ($gateways as $gateway) { + $targetIp = trim((string)($gateway['target_ip'] ?? '')); + if ($targetIp === '') { + continue; + } + + if (empty($gateway['deleted_at']) && !empty($gateway['enabled'])) { + $enabledIps[] = $targetIp; + if (!in_array($targetIp, $actualTargetIps, true)) { + $missingTargets[] = $targetIp; + $actions[] = [ + 'type' => 'add_target', + 'target_ip' => $targetIp, + 'hostname' => $gateway['hostname'] ?? null, + ]; + } + continue; + } + + if (in_array($targetIp, $actualTargetIps, true)) { + $disabledPresentTargets[] = $targetIp; + $actions[] = [ + 'type' => 'remove_target', + 'target_ip' => $targetIp, + 'hostname' => $gateway['hostname'] ?? null, + ]; + } + } + + foreach (self::REQUIRED_LOAD_BALANCER_SERVICES as $requiredService) { + $actualService = self::matchingLoadBalancerService($actualServices, $requiredService); + if ($actualService === null) { + $missingServices[] = $requiredService; + $actions[] = array_replace(['type' => 'add_service'], $requiredService); + continue; + } + + if (!self::loadBalancerServiceHealthCheckMatches($actualService, $requiredService)) { + $actions[] = array_replace([ + 'type' => 'update_service', + 'reason' => 'health_check_drift', + 'actual_health_check' => $actualService['health_check'] ?? null, + ], $requiredService); + } + } + + $actions = $this->guardLastLoadBalancerTarget($actions, $actualTargetIps); + + return [ + 'has_drift' => $actions !== [], + 'actions' => array_values($actions), + 'missing_targets' => array_values($missingTargets), + 'disabled_present_targets' => array_values($disabledPresentTargets), + 'missing_services' => array_values($missingServices), + 'actual_target_ips' => $actualTargetIps, + 'expected_target_ips' => array_values(array_unique($enabledIps)), + 'actual_services' => $actualServices, + 'required_services' => self::REQUIRED_LOAD_BALANCER_SERVICES, + ]; + } + + private function guardLastLoadBalancerTarget(array $actions, array $actualTargetIps): array + { + $remainingTargets = count($actualTargetIps); + $guarded = []; + + foreach ($actions as $action) { + if (($action['type'] ?? '') !== 'remove_target') { + $guarded[] = $action; + continue; + } + + if ($remainingTargets <= 1) { + $guarded[] = array_replace($action, [ + 'type' => 'skip_remove_target', + 'reason' => 'last_reachable_target_guard', + ]); + continue; + } + + $remainingTargets--; + $guarded[] = $action; + } + + return $guarded; + } + + private function syncGatewayLoadBalancerStates(array $gateways, array $actualTargetIps): void + { + foreach ($gateways as $gateway) { + $targetIp = trim((string)($gateway['target_ip'] ?? '')); + if ($targetIp === '') { + continue; + } + $enabled = !empty($gateway['enabled']); + $present = in_array($targetIp, $actualTargetIps, true); + $state = match (true) { + $enabled && $present => 'in_lb', + $enabled && !$present => 'missing', + !$enabled && $present => 'disabled_present', + default => 'disabled_absent', + }; + $this->execute( + 'UPDATE coolify_instance_gateways SET lb_state = ?, last_reconciled_at = NOW() WHERE id = ?', + 'si', + [$state, (int)$gateway['id']] + ); + } + } + + private static function loadBalancerIpTargets(array $loadBalancer): array + { + $ips = []; + foreach (($loadBalancer['targets'] ?? []) as $target) { + if (!is_array($target)) { + continue; + } + $type = strtolower((string)($target['type'] ?? '')); + $ip = ''; + if ($type === 'ip') { + $ipPayload = is_array($target['ip'] ?? null) ? $target['ip'] : []; + $ip = (string)($ipPayload['ip'] ?? ''); + } elseif (isset($target['server']['public_net']['ipv4']['ip'])) { + $ip = (string)$target['server']['public_net']['ipv4']['ip']; + } + $ip = trim($ip); + if ($ip !== '') { + $ips[] = $ip; + } + } + + return array_values(array_unique($ips)); + } + + private static function loadBalancerServices(array $loadBalancer): array + { + $services = []; + foreach (($loadBalancer['services'] ?? []) as $service) { + if (!is_array($service)) { + continue; + } + $services[] = [ + 'protocol' => strtolower((string)($service['protocol'] ?? '')), + 'listen_port' => (int)($service['listen_port'] ?? 0), + 'destination_port' => (int)($service['destination_port'] ?? 0), + 'proxyprotocol' => (bool)($service['proxyprotocol'] ?? false), + 'health_check' => is_array($service['health_check'] ?? null) ? self::normalizeLoadBalancerHealthCheck($service['health_check']) : null, + ]; + } + return $services; + } + + private static function matchingLoadBalancerService(array $services, array $required): ?array + { + foreach ($services as $service) { + if ((string)$service['protocol'] === (string)$required['protocol'] + && (int)$service['listen_port'] === (int)$required['listen_port'] + && (int)$service['destination_port'] === (int)$required['destination_port'] + && empty($service['proxyprotocol'])) { + return $service; + } + } + + return null; + } + + private static function loadBalancerServiceHealthCheckMatches(array $actual, array $required): bool + { + $requiredHealthCheck = is_array($required['health_check'] ?? null) + ? self::normalizeLoadBalancerHealthCheck($required['health_check']) + : null; + if ($requiredHealthCheck === null) { + return true; + } + + $actualHealthCheck = is_array($actual['health_check'] ?? null) + ? self::normalizeLoadBalancerHealthCheck($actual['health_check']) + : null; + + return $actualHealthCheck === $requiredHealthCheck; + } + + private static function normalizeLoadBalancerHealthCheck(array $healthCheck): array + { + $normalized = [ + 'protocol' => strtolower((string)($healthCheck['protocol'] ?? '')), + 'port' => (int)($healthCheck['port'] ?? 0), + 'interval' => (int)($healthCheck['interval'] ?? 0), + 'timeout' => (int)($healthCheck['timeout'] ?? 0), + 'retries' => (int)($healthCheck['retries'] ?? 0), + ]; + + if (is_array($healthCheck['http'] ?? null)) { + $http = $healthCheck['http']; + $normalized['http'] = [ + 'domain' => (string)($http['domain'] ?? ''), + 'path' => (string)($http['path'] ?? ''), + 'response' => (string)($http['response'] ?? ''), + 'status_codes' => array_values(array_map('strval', is_array($http['status_codes'] ?? null) ? $http['status_codes'] : [])), + 'tls' => (bool)($http['tls'] ?? false), + ]; + } + + return $normalized; + } + + private function publicLoadBalancer(array $loadBalancer): array + { + return [ + 'id' => isset($loadBalancer['id']) ? (int)$loadBalancer['id'] : null, + 'name' => (string)($loadBalancer['name'] ?? ''), + 'ipv4' => $loadBalancer['public_net']['ipv4']['ip'] ?? null, + 'ipv6' => $loadBalancer['public_net']['ipv6']['ip'] ?? null, + 'location' => $loadBalancer['location']['name'] ?? null, + 'algorithm' => $loadBalancer['algorithm']['type'] ?? null, + 'targets' => self::loadBalancerIpTargets($loadBalancer), + 'services' => self::loadBalancerServices($loadBalancer), + ]; + } + + private function publicGateway(array $gateway): array + { + return [ + 'id' => (int)$gateway['id'], + 'instance_id' => isset($gateway['instance_id']) ? (int)$gateway['instance_id'] : null, + 'hostname' => (string)$gateway['hostname'], + 'target_ip' => (string)$gateway['target_ip'], + 'enabled' => (bool)$gateway['enabled'], + 'priority' => (int)$gateway['priority'], + 'health_state' => (string)($gateway['health_state'] ?? 'unknown'), + 'lb_state' => (string)($gateway['lb_state'] ?? 'unknown'), + 'last_probe' => self::jsonDecode($gateway['last_probe_json'] ?? null), + 'last_probed_at' => $gateway['last_probed_at'] ?? null, + 'last_reconciled_at' => $gateway['last_reconciled_at'] ?? null, + 'deleted_at' => $gateway['deleted_at'] ?? null, + 'created_at' => $gateway['created_at'] ?? null, + 'updated_at' => $gateway['updated_at'] ?? null, + ]; + } + + private function getGateway(int $id): array + { + $gateway = $this->selectOne( + 'SELECT * FROM coolify_instance_gateways WHERE id = ? AND deleted_at IS NULL LIMIT 1', + 'i', + [$id] + ); + if ($gateway === null) { + throw new RuntimeException('Coolify gateway target was not found.'); + } + return $gateway; + } + + private function probeGatewayTarget(string $targetIp, string $publicHost): array + { + return $this->probeGatewayEndpoint($publicHost, $targetIp); + } + + private function probeGatewayPublicHost(string $publicHost): array + { + return $this->probeGatewayEndpoint($publicHost, null); + } + + private function probeGatewayEndpoint(string $publicHost, ?string $targetIp): array + { + $startedAt = microtime(true); + $path = $this->gatewayProbePath($publicHost); + $url = 'https://' . $publicHost . $path; + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize gateway probe.'); + } + + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3); + curl_setopt($curl, CURLOPT_TIMEOUT, 5); + curl_setopt($curl, CURLOPT_NOSIGNAL, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, ['Accept: application/json']); + if ($targetIp !== null && $targetIp !== '') { + curl_setopt($curl, CURLOPT_RESOLVE, [$publicHost . ':443:' . $targetIp]); + } + curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true); + if (defined('CURLOPT_CERTINFO')) { + curl_setopt($curl, CURLOPT_CERTINFO, true); + } + + $raw = curl_exec($curl); + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $sslVerifyResult = (int)curl_getinfo($curl, CURLINFO_SSL_VERIFYRESULT); + $certificateInfo = defined('CURLINFO_CERTINFO') + ? curl_getinfo($curl, CURLINFO_CERTINFO) + : []; + $certificate = self::gatewayProbeCertificate($certificateInfo); + curl_close($curl); + + $trustedCertificate = $sslVerifyResult === 0; + $letsencryptCertificate = (bool)($certificate['is_letsencrypt'] ?? false); + $ping = self::gatewayProbePingContract($raw); + $probeError = $raw === false ? $error : null; + if ($probeError === null && !$trustedCertificate) { + $probeError = 'Gateway TLS certificate verification failed.'; + } + if ($probeError === null && !$letsencryptCertificate) { + $probeError = "Gateway TLS certificate was not issued by Let's Encrypt."; + } + if ($probeError === null && !($ping['ok'] ?? false)) { + $probeError = 'Gateway ping response did not match the expected API contract.'; + } + + return [ + 'ok' => $raw !== false && $status >= 200 && $status < 300 && $trustedCertificate && $letsencryptCertificate && ($ping['ok'] ?? false), + 'status_code' => $status ?: null, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'host' => $publicHost, + 'target_ip' => $targetIp, + 'path' => $path, + 'error' => $probeError, + 'ping' => $ping, + 'tls' => [ + 'verified' => $trustedCertificate, + 'ssl_verify_result' => $sslVerifyResult, + 'is_letsencrypt' => $letsencryptCertificate, + 'certificate' => $certificate, + ], + 'checked_at' => date('c'), + ]; + } + + private function gatewayProbePath(string $publicHost): string + { + $configured = self::normalizeGatewayProbePath($this->coolifyConfigValue('public_gateway_probe_path', '')); + if ($configured !== '') { + return $configured; + } + + foreach ($this->loadBalancerReleaseApiTargets() as $target) { + $targetUrl = self::gatewayRouteTargetPublicUrl($publicHost, $target); + $path = self::normalizeGatewayProbePath((string)(parse_url($targetUrl, PHP_URL_PATH) ?: '')); + if ($path !== '') { + return rtrim($path, '/') . '/ping'; + } + } + + return '/ping'; + } + + private static function normalizeGatewayProbePath(string $path): string + { + $path = trim($path); + if ($path === '') { + return ''; + } + + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + $path = (string)(parse_url($path, PHP_URL_PATH) ?: ''); + } + $path = trim($path); + if ($path === '') { + return ''; + } + + $path = '/' . ltrim($path, '/'); + $path = preg_replace('#/+#', '/', $path) ?: '/'; + return rtrim($path, '/') ?: '/'; + } + + private static function gatewayProbePingContract(mixed $raw): array + { + if (!is_string($raw) || trim($raw) === '') { + return ['ok' => false, 'reason' => 'empty_response']; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return ['ok' => false, 'reason' => 'invalid_json']; + } + + $data = is_array($decoded['data'] ?? null) ? $decoded['data'] : []; + $message = strtolower(trim((string)($data['message'] ?? ''))); + return [ + 'ok' => ($decoded['success'] ?? false) === true && $message === 'pong', + 'message' => $data['message'] ?? null, + ]; + } + + private static function gatewayProbeCertificate(mixed $certificateInfo): ?array + { + if (!is_array($certificateInfo) || !is_array($certificateInfo[0] ?? null)) { + return null; + } + + $leaf = $certificateInfo[0]; + $issuer = self::certificateInfoValue($leaf, ['Issuer', 'issuer']); + $subject = self::certificateInfoValue($leaf, ['Subject', 'subject']); + $startDate = self::certificateInfoValue($leaf, ['Start date', 'Start Date', 'start date', 'startDate']); + $expireDate = self::certificateInfoValue($leaf, ['Expire date', 'Expire Date', 'expire date', 'expireDate']); + $expiresAt = self::certificateTimestamp($expireDate); + + return [ + 'subject' => $subject, + 'issuer' => $issuer, + 'start_date' => $startDate, + 'expire_date' => $expireDate, + 'expires_at' => $expiresAt !== null ? date('c', $expiresAt) : null, + 'days_until_expiry' => $expiresAt !== null ? (int)floor(($expiresAt - time()) / 86400) : null, + 'is_letsencrypt' => stripos((string)$issuer, "Let's Encrypt") !== false, + ]; + } + + private static function certificateInfoValue(array $certificate, array $keys): ?string + { + foreach ($keys as $key) { + if (isset($certificate[$key]) && is_scalar($certificate[$key])) { + $value = trim((string)$certificate[$key]); + if ($value !== '') { + return $value; + } + } + } + + return null; + } + + private static function certificateTimestamp(?string $value): ?int + { + if ($value === null || trim($value) === '') { + return null; + } + + $timestamp = strtotime($value); + return $timestamp === false ? null : $timestamp; + } + + private function recordGatewayProbe(int $gatewayId, array $probe): void + { + if ($gatewayId <= 0) { + return; + } + + $state = ($probe['ok'] ?? false) === true ? 'ok' : 'down'; + $this->execute( + "UPDATE coolify_instance_gateways + SET health_state = ?, last_probe_json = ?, last_probed_at = NOW() + WHERE id = ?", + 'ssi', + [$state, self::jsonEncode($probe), $gatewayId] + ); + } + + private function availabilityStateForHost(array $host): string + { + $role = (string)($host['role'] ?? ''); + $status = self::jsonDecode($host['last_status_json'] ?? null); + $effectiveStatus = (string)($status['status'] ?? $host['status'] ?? 'unknown'); + $percent = round((float)($status['replication_percent'] ?? ($role === 'primary' ? 100 : 0)), 2); + $blockers = is_array($status['blockers'] ?? null) ? $status['blockers'] : []; + + if ($role === 'primary') { + return $this->hasHealthyReplica((string)$host['kind'], (int)$host['id']) ? 'protected' : 'degraded'; + } + if ($effectiveStatus === 'ok' && $percent >= 100.0 && $blockers === []) { + return 'failover_ready'; + } + if (in_array($effectiveStatus, ['down', 'removed'], true)) { + return 'degraded'; + } + return 'failover_blocked'; + } + + private function replicationHostIsReady(array $host): bool + { + $status = self::jsonDecode($host['last_status_json'] ?? null); + $effectiveStatus = (string)($status['status'] ?? $host['status'] ?? 'unknown'); + $percent = round((float)($status['replication_percent'] ?? (($host['role'] ?? '') === 'primary' ? 100 : 0)), 2); + $blockers = is_array($status['blockers'] ?? null) ? $status['blockers'] : []; + + return $effectiveStatus === 'ok' && $percent >= 100.0 && $blockers === []; + } + + private function hasHealthyReplica(string $kind, int $primaryId): bool + { + foreach ($this->selectRows( + "SELECT * FROM replication_hosts WHERE kind = ? AND role = 'replica' AND deleted_at IS NULL AND id <> ?", + 'si', + [$kind, $primaryId] + ) as $host) { + $status = self::jsonDecode($host['last_status_json'] ?? null); + if (($status['status'] ?? '') === 'ok' + && round((float)($status['replication_percent'] ?? 0), 2) >= 100.0 + && (is_array($status['blockers'] ?? null) ? $status['blockers'] : []) === []) { + return true; + } + } + return false; + } + + private function coolifyCollection(array $response): array + { + if (self::isListArray($response)) { + return array_values(array_filter($response, 'is_array')); + } + + foreach (['data', 'items', 'servers', 'projects', 'environments', 'resources'] as $key) { + if (!is_array($response[$key] ?? null)) { + continue; + } + + $collection = $response[$key]; + if (self::isListArray($collection)) { + return array_values(array_filter($collection, 'is_array')); + } + + return array_values(array_filter($collection, 'is_array')); + } + + return []; + } + + private function publicPlacementServer(array $server): array + { + $settings = is_array($server['settings'] ?? null) ? $server['settings'] : []; + $publicHost = self::publicServerHostFromCoolifyServer($server, null, false) + ?? self::resolvedPublicDnsServerHostFromCoolifyServer($server); + + return [ + 'id' => isset($server['id']) ? (int)$server['id'] : null, + 'uuid' => $this->placementString($server['uuid'] ?? ''), + 'name' => $this->placementString($server['name'] ?? $server['uuid'] ?? ''), + 'description' => $this->placementString($server['description'] ?? ''), + 'ip' => $this->placementString($server['ip'] ?? $server['public_ip'] ?? $server['address'] ?? ''), + 'public_host' => $publicHost, + 'user' => $this->placementString($server['user'] ?? ''), + 'port' => isset($server['port']) ? (int)$server['port'] : null, + 'proxy_type' => $this->placementString($server['proxy_type'] ?? ''), + 'swarm_cluster' => $this->placementString($server['swarm_cluster'] ?? ''), + 'is_reachable' => array_key_exists('is_reachable', $settings) ? (bool)$settings['is_reachable'] : null, + 'is_usable' => array_key_exists('is_usable', $settings) ? (bool)$settings['is_usable'] : null, + ]; + } + + private function publicPlacementProject(array $project): array + { + return [ + 'id' => isset($project['id']) ? (int)$project['id'] : null, + 'uuid' => $this->placementString($project['uuid'] ?? ''), + 'name' => $this->placementString($project['name'] ?? $project['uuid'] ?? ''), + 'description' => $this->placementString($project['description'] ?? ''), + ]; + } + + private function publicPlacementEnvironment(array $environment, array $project): array + { + return [ + 'id' => isset($environment['id']) ? (int)$environment['id'] : null, + 'uuid' => $this->placementString($environment['uuid'] ?? ''), + 'name' => $this->placementString($environment['name'] ?? $environment['uuid'] ?? ''), + 'description' => $this->placementString($environment['description'] ?? ''), + 'project_id' => isset($environment['project_id']) ? (int)$environment['project_id'] : null, + 'project_uuid' => $this->placementString($project['uuid'] ?? ''), + 'project_name' => $this->placementString($project['name'] ?? ''), + ]; + } + + private function placementString(mixed $value): string + { + return trim((string)($value ?? '')); + } + + private function publicInstance(array $instance): array + { + return [ + 'id' => (int)$instance['id'], + 'label' => (string)$instance['label'], + 'base_url' => (string)$instance['base_url'], + 'api_token_set' => trim((string)($instance['api_token_secret'] ?? '')) !== '', + 'default_project_uuid' => $instance['default_project_uuid'] ?? null, + 'default_environment_uuid' => $instance['default_environment_uuid'] ?? null, + 'default_environment_name' => $instance['default_environment_name'] ?? null, + 'default_server_uuid' => $instance['default_server_uuid'] ?? null, + 'default_destination_uuid' => $instance['default_destination_uuid'] ?? null, + 'status' => (string)($instance['status'] ?? 'unknown'), + 'last_checked_at' => $instance['last_checked_at'] ?? null, + 'last_error' => $instance['last_error'] ?? null, + 'created_at' => $instance['created_at'] ?? null, + 'updated_at' => $instance['updated_at'] ?? null, + ]; + } + + private function publicTarget(array $target): array + { + $replication = [ + 'host_id' => isset($target['replication_host_id']) ? (int)$target['replication_host_id'] : null, + 'label' => $target['replication_label'] ?? null, + 'host' => $target['replication_host'] ?? null, + 'port' => isset($target['replication_port']) ? (int)$target['replication_port'] : null, + 'role' => $target['replication_role'] ?? null, + 'status' => $target['replication_status'] ?? null, + 'last_status' => self::jsonDecode($target['replication_last_status_json'] ?? null), + 'last_checked_at' => $target['replication_last_checked_at'] ?? null, + ]; + + return [ + 'id' => (int)$target['id'], + 'instance_id' => (int)$target['instance_id'], + 'instance_label' => (string)($target['instance_label'] ?? ''), + 'kind' => (string)$target['kind'], + 'label' => (string)$target['label'], + 'role' => (string)$target['role'], + 'server_uuid' => $target['server_uuid'] ?? null, + 'project_uuid' => $target['project_uuid'] ?? null, + 'environment_uuid' => $target['environment_uuid'] ?? null, + 'environment_name' => $target['environment_name'] ?? null, + 'destination_uuid' => $target['destination_uuid'] ?? null, + 'resource_uuid' => $target['resource_uuid'] ?? null, + 'resource_type' => (string)($target['resource_type'] ?? self::RESOURCE_TYPE_SERVICE), + 'resource_name' => $target['resource_name'] ?? null, + 'deployment_status' => (string)($target['deployment_status'] ?? 'unknown'), + 'availability_state' => (string)($target['availability_state'] ?? 'degraded'), + 'last_reconcile_status' => $target['last_reconcile_status'] ?? null, + 'last_reconcile' => self::jsonDecode($target['last_reconcile_json'] ?? null), + 'last_reconciled_at' => $target['last_reconciled_at'] ?? null, + 'replication' => $replication, + 'created_at' => $target['created_at'] ?? null, + 'updated_at' => $target['updated_at'] ?? null, + ]; + } + + private function clientForInstance(array $instance): coolify_api_client + { + $token = replication_secret_box::decrypt($instance['api_token_secret'] ?? ''); + if ($this->clientFactory !== null) { + $client = call_user_func($this->clientFactory, $instance, $token); + if (!$client instanceof coolify_api_client) { + throw new RuntimeException('Coolify client factory returned an invalid client.'); + } + return $client; + } + return new coolify_api_client((string)$instance['base_url'], $token); + } + + private function getInstance(int $id): array + { + $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL LIMIT 1', 'i', [$id]); + if ($instance === null) { + throw new RuntimeException('Coolify instance was not found.'); + } + return $instance; + } + + private function getTarget(int $id): array + { + $target = $this->selectOne( + "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url, h.label AS replication_label, + h.host AS replication_host, h.port AS replication_port, h.role AS replication_role, + h.status AS replication_status, h.last_status_json AS replication_last_status_json, + h.last_checked_at AS replication_last_checked_at + FROM coolify_targets t + INNER JOIN coolify_instances i ON i.id = t.instance_id + LEFT JOIN replication_hosts h ON h.id = t.replication_host_id + WHERE t.id = ? AND t.deleted_at IS NULL LIMIT 1", + 'i', + [$id] + ); + if ($target === null) { + throw new RuntimeException('Coolify target was not found.'); + } + return $target; + } + + private function replicationHost(int $id, bool $includeDeleted = false): array + { + $sql = 'SELECT * FROM replication_hosts WHERE id = ?'; + if (!$includeDeleted) { + $sql .= ' AND deleted_at IS NULL'; + } + $host = $this->selectOne($sql . ' LIMIT 1', 'i', [$id]); + if ($host === null) { + throw new RuntimeException('Linked replication host was not found.'); + } + return $host; + } + + private function hostCredentials(array $host): array + { + return [ + 'username' => (string)($host['username'] ?? ''), + 'password' => replication_secret_box::decrypt($host['password_secret'] ?? ''), + 'admin_username' => (string)($host['admin_username'] ?? ''), + 'admin_password' => replication_secret_box::decrypt($host['admin_password_secret'] ?? ''), + 'replication_username' => (string)($host['replication_username'] ?? ''), + 'replication_password' => replication_secret_box::decrypt($host['replication_password_secret'] ?? ''), + ]; + } + + private function primaryAddress(string $kind): array + { + $primary = $this->selectOne( + "SELECT * FROM replication_hosts WHERE kind = ? AND role = 'primary' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1", + 's', + [$kind] + ); + if ($primary === null) { + return match ($kind) { + 'database' => ['', 3306], + 'redis' => ['redis-primary', 6379], + default => ['http://minio-primary:9000', 9000], + }; + } + + if ($kind === 'minio') { + $options = self::jsonDecode($primary['options_json'] ?? null); + $endpoint = (string)($options['endpoint'] ?? (($options['scheme'] ?? 'http') . '://' . $primary['host'] . ':' . $primary['port'])); + return [$endpoint, (int)$primary['port']]; + } + + return [(string)$primary['host'], (int)$primary['port']]; + } + + private function defaultInstanceId(): int + { + $instance = $this->selectOne('SELECT id FROM coolify_instances WHERE deleted_at IS NULL ORDER BY id LIMIT 1'); + if ($instance === null) { + throw new RuntimeException('No Coolify instance is configured.'); + } + return (int)$instance['id']; + } + + private function applyCoolifyDeploymentDefaults(array $input, array $instance): array + { + $serverUuid = $this->targetMapping($input, $instance, 'server_uuid'); + if ($serverUuid === null) { + return $input; + } + + $serverHost = $this->resolveCoolifyServerHost( + $instance, + $serverUuid, + (int)($input['host_port'] ?? $input['port'] ?? 0), + 0 + ); + if ($serverHost !== null) { + $input['host'] = $serverHost; + } + + return $input; + } + + private function applyCoolifyPortDefaults(string $kind, array $input, array $instance): array + { + $serverUuid = $this->targetMapping($input, $instance, 'server_uuid'); + if ($serverUuid === null) { + return $input; + } + + $port = (int)($input['host_port'] ?? $input['port'] ?? match ($kind) { + 'database' => 3307, + 'redis' => 6380, + default => 9010, + }); + $consolePort = $kind === 'minio' ? (int)($input['console_port'] ?? ($port + 1)) : null; + [$nextPort, $nextConsolePort] = $this->nextAvailablePublicPorts( + $kind, + $port, + $consolePort, + $this->usedPublicPortsForCoolifyServer($serverUuid, 0) + ); + + $input['host_port'] = $nextPort; + $input['port'] = $nextPort; + if ($kind === 'minio' && $nextConsolePort !== null) { + $input['console_port'] = $nextConsolePort; + } + + return $input; + } + + private function resolveCoolifyServerHost(array $instance, string $serverUuid, ?int $port = null, int $excludeHostId = 0): ?string + { + try { + foreach ($this->coolifyCollection($this->clientForInstance($instance)->listServers()) as $server) { + if ($this->placementString($server['uuid'] ?? '') !== $serverUuid) { + continue; + } + + $publicHost = self::publicServerHostFromCoolifyServer($server, $port, false); + if ($publicHost !== null) { + return $publicHost; + } + + $knownHost = $this->knownPublicHostForCoolifyServer($serverUuid, $port, $excludeHostId); + if ($knownHost !== null) { + return $knownHost; + } + + return self::resolvedPublicDnsServerHostFromCoolifyServer($server); + } + } catch (Throwable) { + } + + return $this->knownPublicHostForCoolifyServer($serverUuid, $port, $excludeHostId); + } + + private function syncReplicationHostEndpointForTarget(array $target, array $host, array $instance): array + { + $serverUuid = trim((string)($target['server_uuid'] ?? '')); + $hostId = (int)($host['id'] ?? 0); + if ($serverUuid === '' || $hostId <= 0) { + return $host; + } + + $port = (int)($host['port'] ?? 0); + $publicHost = $this->resolveCoolifyServerHost($instance, $serverUuid, $port, $hostId); + if ($publicHost === null || $publicHost === trim((string)($host['host'] ?? ''))) { + return $host; + } + + $options = self::jsonDecode($host['options_json'] ?? null); + if ((string)($target['kind'] ?? '') === 'minio') { + $scheme = strtolower(trim((string)($options['scheme'] ?? 'http'))) ?: 'http'; + $options['endpoint'] = $scheme . '://' . $publicHost . ':' . $port; + } + + $optionsJson = self::jsonEncode($options); + $this->execute( + 'UPDATE replication_hosts SET host = ?, options_json = ? WHERE id = ?', + 'ssi', + [$publicHost, $optionsJson, $hostId] + ); + + $host['host'] = $publicHost; + $host['options_json'] = $optionsJson; + return $host; + } + + private function syncReplicationHostPortsForTarget(array $target, array $host): array + { + $serverUuid = trim((string)($target['server_uuid'] ?? '')); + $hostId = (int)($host['id'] ?? 0); + $kind = (string)($target['kind'] ?? ''); + if ($serverUuid === '' || $hostId <= 0 || (string)($host['role'] ?? '') === 'primary') { + return $host; + } + + $options = self::jsonDecode($host['options_json'] ?? null); + $port = (int)($host['port'] ?? 0); + $consolePort = $kind === 'minio' ? (int)($options['console_port'] ?? ($port + 1)) : null; + if ($port <= 0) { + return $host; + } + + $usedPorts = $this->usedPublicPortsForCoolifyServer($serverUuid, $hostId); + [$nextPort, $nextConsolePort] = $this->nextAvailablePublicPorts($kind, $port, $consolePort, $usedPorts); + if ($nextPort === $port && ($kind !== 'minio' || $nextConsolePort === $consolePort)) { + return $host; + } + + if ($kind === 'minio') { + $scheme = strtolower(trim((string)($options['scheme'] ?? 'http'))) ?: 'http'; + $options['console_port'] = $nextConsolePort; + $options['endpoint'] = $scheme . '://' . (string)$host['host'] . ':' . $nextPort; + } + + $optionsJson = self::jsonEncode($options); + $this->execute( + 'UPDATE replication_hosts SET port = ?, options_json = ? WHERE id = ?', + 'isi', + [$nextPort, $optionsJson, $hostId] + ); + + $host['port'] = $nextPort; + $host['options_json'] = $optionsJson; + return $host; + } + + private function usedPublicPortsForCoolifyServer(string $serverUuid, int $excludeHostId): array + { + $rows = $this->selectRows( + "SELECT h.port, h.options_json, t.last_reconcile_json + FROM coolify_targets t + INNER JOIN replication_hosts h ON h.id = t.replication_host_id + WHERE t.server_uuid = ? AND h.id <> ? AND t.deleted_at IS NULL AND h.deleted_at IS NULL + LIMIT 100", + 'si', + [$serverUuid, $excludeHostId] + ); + + $ports = []; + foreach ($rows as $row) { + $port = (int)($row['port'] ?? 0); + if ($port > 0) { + $ports[$port] = true; + } + $options = self::jsonDecode($row['options_json'] ?? null); + $consolePort = (int)($options['console_port'] ?? 0); + if ($consolePort > 0) { + $ports[$consolePort] = true; + } + foreach (self::coolifyApplicationPortsFromContext(self::jsonDecode($row['last_reconcile_json'] ?? null)) as $applicationPort) { + $ports[$applicationPort] = true; + } + } + + return array_keys($ports); + } + + private function nextAvailablePublicPorts(string $kind, int $port, ?int $consolePort, array $usedPorts): array + { + $used = array_fill_keys(array_map('intval', $usedPorts), true); + if ($kind !== 'minio') { + while (isset($used[$port]) && $port < 65535) { + $port++; + } + return [$port, null]; + } + + $consolePort = $consolePort !== null && $consolePort > 0 ? $consolePort : ($port + 1); + while ((isset($used[$port]) || isset($used[$consolePort])) && $consolePort < 65535) { + $port += 2; + $consolePort = $port + 1; + } + + return [$port, $consolePort]; + } + + private static function coolifyApplicationPortsFromContext(array $context): array + { + $ports = []; + $applications = $context['coolify']['applications'] ?? []; + if (!is_array($applications)) { + return []; + } + + foreach ($applications as $application) { + if (!is_array($application)) { + continue; + } + foreach (preg_split('/\s*,\s*/', (string)($application['ports'] ?? '')) ?: [] as $mapping) { + if (preg_match('/^(\d+)\s*:/', trim($mapping), $matches) === 1) { + $ports[] = (int)$matches[1]; + } + } + } + + return array_values(array_unique(array_filter($ports))); + } + + private function knownPublicHostForCoolifyServer(string $serverUuid, ?int $port = null, int $excludeHostId = 0): ?string + { + if ($serverUuid === '') { + return null; + } + + $where = 't.server_uuid = ? AND t.deleted_at IS NULL AND h.deleted_at IS NULL'; + $types = 's'; + $params = [$serverUuid]; + if ($excludeHostId > 0) { + $where .= ' AND h.id <> ?'; + $types .= 'i'; + $params[] = $excludeHostId; + } + + $rows = $this->selectRows( + "SELECT h.host, h.status, h.last_status_json + FROM coolify_targets t + INNER JOIN replication_hosts h ON h.id = t.replication_host_id + WHERE $where + ORDER BY (h.status = 'ok') DESC, h.last_checked_at DESC, h.updated_at DESC, h.id DESC + LIMIT 20", + $types, + $params + ); + + $fallback = null; + foreach ($rows as $row) { + $host = self::publicServerHostCandidate($row['host'] ?? null); + if ($host === null) { + continue; + } + $lastStatus = self::jsonDecode($row['last_status_json'] ?? null); + $isHealthy = (string)($row['status'] ?? '') === 'ok' || (string)($lastStatus['status'] ?? '') === 'ok'; + if ($fallback === null && $isHealthy) { + $fallback = $host; + } + if ($port !== null && $port > 0 && self::tcpPortIsOpen($host, $port)) { + return $host; + } + } + + return $fallback; + } + + public static function publicServerHostFromCoolifyServer(array $server, ?int $port = null, bool $includeDisplayName = true): ?string + { + $candidates = []; + foreach ([ + 'public_host', + 'publicHost', + 'public_ip', + 'publicIp', + 'public_ipv4', + 'publicIpv4', + 'public_ipv6', + 'publicIpv6', + 'address', + 'hostname', + 'fqdn', + 'domain', + 'ip', + ] as $key) { + $host = self::publicServerHostCandidate($server[$key] ?? null); + if ($host !== null && !in_array($host, $candidates, true)) { + $candidates[] = $host; + } + } + + if ($includeDisplayName) { + $host = self::publicServerHostCandidate($server['name'] ?? null); + if ($host !== null && !in_array($host, $candidates, true)) { + $candidates[] = $host; + } + } + + if ($port !== null && $port > 0) { + foreach ($candidates as $host) { + if (self::tcpPortIsOpen($host, $port)) { + return $host; + } + } + } + + return $candidates[0] ?? null; + } + + public static function publicDnsServerNameFromCoolifyServer(array $server): ?string + { + $host = self::publicServerHostCandidate($server['name'] ?? null); + if ($host === null || !self::isPublicDnsName($host)) { + return null; + } + + return $host; + } + + private static function resolvedPublicDnsServerHostFromCoolifyServer(array $server): ?string + { + $host = self::publicDnsServerNameFromCoolifyServer($server); + if ($host === null) { + return null; + } + + foreach (@gethostbynamel($host) ?: [] as $address) { + $address = self::publicServerHostCandidate($address); + if ($address !== null) { + return $address; + } + } + + return $host; + } + + private static function isPublicDnsName(string $host): bool + { + $host = strtolower(trim($host, '.')); + return str_contains($host, '.') + && preg_match('/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/', $host) === 1 + && preg_match('/[a-z]/', $host) === 1 + && !str_contains($host, '..'); + } + + private static function tcpPortIsOpen(string $host, int $port): bool + { + if ($port <= 0 || $port > 65535) { + return false; + } + + $errno = 0; + $errstr = ''; + $socket = @fsockopen($host, $port, $errno, $errstr, 0.4); + if (is_resource($socket)) { + fclose($socket); + return true; + } + + return false; + } + + private static function publicServerHostCandidate(mixed $value): ?string + { + $host = trim((string)($value ?? '')); + if ($host === '') { + return null; + } + + if (str_contains($host, '://')) { + $parsed = parse_url($host, PHP_URL_HOST); + $host = is_string($parsed) ? $parsed : $host; + } + + $host = trim($host); + if (str_contains($host, '/')) { + $host = strtok($host, '/') ?: ''; + } + if (str_contains($host, ':') && substr_count($host, ':') === 1) { + $host = explode(':', $host, 2)[0]; + } + + $host = trim($host, " \t\n\r\0\x0B[]"); + if ($host === '' || preg_match('/\s/', $host) === 1 || self::isDockerLocalOrLoopbackHost($host)) { + return null; + } + + return $host; + } + + private static function publicIpFromHost(mixed $value): ?string + { + $host = self::publicServerHostCandidate($value); + if ($host === null) { + return null; + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return $host; + } + + foreach (@gethostbynamel($host) ?: [] as $address) { + $address = self::publicServerHostCandidate($address); + if ($address !== null && filter_var($address, FILTER_VALIDATE_IP) !== false) { + return $address; + } + } + + return null; + } + + private static function firstPublicUrl(mixed $value): ?string + { + if (is_string($value)) { + foreach (preg_split('/[\s,]+/', trim($value)) ?: [] as $candidate) { + $candidate = trim($candidate); + if ($candidate !== '') { + return $candidate; + } + } + return null; + } + + if (!is_array($value)) { + return null; + } + + foreach (['url', 'fqdn', 'domain', 'domains'] as $key) { + if (array_key_exists($key, $value)) { + $candidate = self::firstPublicUrl($value[$key]); + if ($candidate !== null) { + return $candidate; + } + } + } + + foreach ($value as $entry) { + $candidate = self::firstPublicUrl($entry); + if ($candidate !== null) { + return $candidate; + } + } + + return null; + } + + private static function isDockerLocalOrLoopbackHost(string $host): bool + { + $normalized = strtolower(trim($host, '[]')); + if (in_array($normalized, [ + 'localhost', + 'host.docker.internal', + 'host.containers.internal', + 'docker.for.win.localhost', + 'docker.for.mac.localhost', + '0.0.0.0', + '::', + '::1', + '0:0:0:0:0:0:0:1', + ], true)) { + return true; + } + + return str_starts_with($normalized, '127.') + || str_starts_with($normalized, '169.254.') + || str_starts_with($normalized, 'fe80:'); + } + + private function targetMapping(array $input, array $instance, string $key): ?string + { + $defaultKey = 'default_' . $key; + return $this->nullableString($input[$key] ?? $instance[$defaultKey] ?? null); + } + + private function nullableString(mixed $value): ?string + { + $value = trim((string)($value ?? '')); + return $value === '' ? null : $value; + } + + private function normalizeBuckets(mixed $value): array + { + if (is_array($value)) { + return array_values(array_filter(array_map('strval', $value))); + } + return array_values(array_filter(array_map('trim', preg_split('/[,\s]+/', (string)$value) ?: []))); + } + + private static function resourceName(string $kind, string $name, int $hostId): string + { + $name = strtolower(trim($name)); + $name = preg_replace('/[^a-z0-9-]+/', '-', $name) ?: ''; + $name = trim($name, '-'); + if ($name === '') { + $name = 'truckwash-' . $kind . '-replica'; + } + return substr($name . '-' . $hostId, 0, 120); + } + + private function composeHash(array $template): string + { + return hash('sha256', (string)($template['compose'] ?? '') . "\n---env---\n" . (string)($template['env'] ?? '')); + } + + private function startOperation(?int $targetId, ?int $instanceId, string $operation, ?int $actorUserId): int + { + $this->execute( + "INSERT INTO coolify_operations (target_id, instance_id, operation, status, actor_user_id) + VALUES (?, ?, ?, 'running', ?)", + 'iisi', + [$targetId, $instanceId, $operation, $actorUserId] + ); + return $this->insertId(); + } + + private function finishOperation(int $operationId, string $status, ?string $message, array $errors): void + { + $this->execute( + "UPDATE coolify_operations SET status = ?, message = ?, error_message = ?, completed_at = NOW() WHERE id = ?", + 'sssi', + [$status, $message, implode("\n", $errors), $operationId] + ); + } + + private function markTargetFailure(int $targetId, string $status, string $message, array $context = []): void + { + $payload = array_replace($context, ['message' => $message, 'status' => $status]); + $this->execute( + "UPDATE coolify_targets + SET deployment_status = ?, availability_state = 'degraded', last_reconcile_status = ?, last_reconcile_json = ?, last_reconciled_at = NOW() + WHERE id = ?", + 'sssi', + [$status, $status, self::jsonEncode($payload), $targetId] + ); + } + + private function audit(?int $targetId, ?int $instanceId, ?int $hostId, string $action, ?int $actorUserId, string $severity, array $context): void + { + $this->execute( + "INSERT INTO coolify_audit_logs (target_id, instance_id, replication_host_id, action, actor_user_id, severity, context_json) + VALUES (?, ?, ?, ?, ?, ?, ?)", + 'iiisiss', + [$targetId, $instanceId, $hostId, $action, $actorUserId, $severity, self::jsonEncode($context)] + ); + } + + private function setModuleEnabled(bool $enabled): void + { + $value = $enabled ? 'true' : 'false'; + $row = $this->selectOne("SELECT value FROM module_config WHERE module = 'Coolify' AND variable = 'enabled' LIMIT 1"); + if ($row === null) { + $this->execute("INSERT INTO module_config (module, variable, value, type) VALUES ('Coolify', 'enabled', ?, 'bool')", 's', [$value]); + return; + } + $this->execute("UPDATE module_config SET value = ? WHERE module = 'Coolify' AND variable = 'enabled'", 's', [$value]); + } + + private function ensureFailoverEnabled(string $kind): void + { + $this->setModuleConfigValue('Failover', 'enabled', 'true', 'bool'); + $this->setModuleConfigValue('Failover', $kind . '_enabled', 'true', 'bool'); + } + + private function setModuleConfigValue(string $module, string $variable, string $value, string $type): void + { + $row = $this->selectOne( + 'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1', + 'ss', + [$module, $variable] + ); + if ($row === null) { + $this->execute( + 'INSERT INTO module_config (module, variable, value, type) VALUES (?, ?, ?, ?)', + 'ssss', + [$module, $variable, $value, $type] + ); + return; + } + $this->execute( + 'UPDATE module_config SET value = ?, type = ? WHERE module = ? AND variable = ?', + 'ssss', + [$value, $type, $module, $variable] + ); + } + + private function tableExists(string $table): bool + { + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; + if ($table === '') { + return false; + } + + try { + return $this->selectOne( + 'SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1', + 's', + [$table] + ) !== null; + } catch (Throwable) { + return false; + } + } + + private function selectOne(string $sql, string $types = '', array $params = []): ?array + { + $rows = $this->selectRows($sql, $types, $params); + return $rows[0] ?? null; + } + + private function selectRows(string $sql, string $types = '', array $params = []): array + { + global $db; + if ($types === '') { + $result = $db->query($sql); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } + + $stmt = $db->prepare($sql); + if ($stmt === false) { + throw new RuntimeException('Could not prepare Coolify query.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $result = $stmt->get_result(); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } + + private function execute(string $sql, string $types = '', array $params = []): void + { + global $db; + if ($types === '') { + $db->query($sql); + return; + } + + $stmt = $db->prepare($sql); + if ($stmt === false) { + throw new RuntimeException('Could not prepare Coolify statement.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + } + + private function insertId(): int + { + global $db; + return (int)$db->insert_id(); + } + + private function toBool(mixed $value, bool $default): bool + { + if (is_bool($value)) { + return $value; + } + if ($value === null) { + return $default; + } + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + return $default; + } + + private static function hostHasCoolifyMetadata(array $host): bool + { + $options = isset($host['options']) && is_array($host['options']) + ? $host['options'] + : self::jsonDecode($host['options_json'] ?? null); + + return (string)($options['deployment_provider'] ?? '') === 'coolify' + || isset($options['coolify_target_id']) + || isset($options['coolify_instance_id']); + } + + private static function redactCoolifyResponse(array $response): array + { + foreach (['token', 'api_token', 'password', 'secret', 'real_value'] as $key) { + if (array_key_exists($key, $response)) { + $response[$key] = '[redacted]'; + } + } + return $response; + } + + private static function jsonEncode(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Could not encode Coolify JSON payload.'); + } + return $json; + } + + private static function jsonDecode(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + + private static function isListArray(array $value): bool + { + if ($value === []) { + return true; + } + + return array_keys($value) === range(0, count($value) - 1); + } +} diff --git a/services/nginx/app/classes/coolify_schema_bootstrap.php b/services/nginx/app/classes/coolify_schema_bootstrap.php new file mode 100644 index 00000000..1d349625 --- /dev/null +++ b/services/nginx/app/classes/coolify_schema_bootstrap.php @@ -0,0 +1,237 @@ +query($sql); + } + + self::ensureColumn('coolify_instances', 'default_destination_uuid', 'VARCHAR(128) NULL'); + self::ensureColumn('coolify_targets', 'availability_state', "VARCHAR(32) NOT NULL DEFAULT 'degraded'"); + self::ensureColumn('coolify_targets', 'desired_compose_hash', 'CHAR(64) NULL'); + self::ensureColumn('coolify_targets', 'last_reconcile_json', 'LONGTEXT NULL'); + self::ensureColumn('coolify_operations', 'guarded', 'TINYINT(1) NOT NULL DEFAULT 1'); + self::ensureColumn('coolify_instance_gateways', 'last_reconciled_at', 'DATETIME NULL'); + + self::ensureModuleConfigDefault('Coolify', 'enabled', 'false', 'bool'); + self::ensureModuleConfigDefault('Coolify', 'lb_automation_enabled', 'false', 'bool'); + self::ensureModuleConfigDefault('Coolify', 'lb_automation_mode', 'report_only', 'string'); + self::ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id', '', 'string'); + self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string'); + self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string'); + self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string'); + + self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10); + self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20); + self::ensureDefaultGateway('node3.truckwash.io', '23.88.23.183', 30); + + self::$initialized = true; + self::$tablesExist = true; + } + + public static function tablesExist(): bool + { + if (self::$tablesExist !== null) { + return self::$tablesExist; + } + + global $db; + + foreach (['coolify_instances', 'coolify_targets', 'coolify_operations', 'coolify_audit_logs', 'coolify_instance_gateways'] as $table) { + $tableSql = $db->escape_string($table); + $result = $db->query("SHOW TABLES LIKE '$tableSql'"); + if ($result === false || $result->num_rows === 0) { + self::$tablesExist = false; + return false; + } + } + + self::$tablesExist = true; + return self::$tablesExist; + } + + private static function ensureColumn(string $table, string $column, string $definition): void + { + global $db; + + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + $column = preg_replace('/[^a-zA-Z0-9_]/', '', $column); + if ($table === '' || $column === '') { + return; + } + + $result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'"); + if ($result !== false && $result->num_rows > 0) { + return; + } + + $db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition"); + } + + private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void + { + global $db; + + $moduleSql = $db->escape_string($module); + $variableSql = $db->escape_string($variable); + $result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1"); + if ($result !== false && $result->num_rows > 0) { + return; + } + + $valueSql = $db->escape_string($value); + $typeSql = $db->escape_string($type); + $db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')"); + } + + private static function ensureDefaultGateway(string $hostname, string $targetIp, int $priority): void + { + global $db; + + $targetIpSql = $db->escape_string($targetIp); + $result = $db->query("SELECT id FROM coolify_instance_gateways WHERE target_ip = '$targetIpSql' LIMIT 1"); + if ($result !== false && $result->num_rows > 0) { + return; + } + + $hostnameSql = $db->escape_string($hostname); + $db->query( + "INSERT INTO coolify_instance_gateways (hostname, target_ip, enabled, priority) + VALUES ('$hostnameSql', '$targetIpSql', 1, " . (int)$priority . ")" + ); + } +} diff --git a/services/nginx/app/classes/cors_policy.php b/services/nginx/app/classes/cors_policy.php new file mode 100644 index 00000000..592d39bc --- /dev/null +++ b/services/nginx/app/classes/cors_policy.php @@ -0,0 +1,188 @@ + + */ + public static function requiredAllowedOrigins(): array + { + return self::REQUIRED_ALLOWED_ORIGINS; + } + + /** + * @return array + */ + public static function allowedOrigins(string $corsConfig): array + { + $origins = []; + foreach (self::splitOrigins($corsConfig) as $configuredOrigin) { + if ($configuredOrigin === '*') { + return ['*']; + } + + $origin = self::normalizeOrigin($configuredOrigin); + if ($origin !== '') { + $origins[$origin] = true; + } + } + + foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) { + $origin = self::normalizeOrigin($requiredOrigin); + if ($origin !== '') { + $origins[$origin] = true; + } + } + + return array_keys($origins); + } + + public static function withRequiredOrigins(string $corsConfig): string + { + $allowedOrigins = self::allowedOrigins($corsConfig); + if ($allowedOrigins === ['*']) { + return '*'; + } + + return implode(',', $allowedOrigins); + } + + public static function isOriginAllowed(?string $origin, string $corsConfig): bool + { + $origin = self::normalizeOrigin($origin); + if ($origin === '' || $origin === '*') { + return false; + } + + $allowedOrigins = self::allowedOrigins($corsConfig); + return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true); + } + + /** + * @return array + */ + public static function responseHeaders(?string $origin, string $corsConfig): array + { + $origin = self::normalizeOrigin($origin); + if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) { + return []; + } + + return [ + 'Access-Control-Allow-Origin' => $origin, + 'Access-Control-Allow-Credentials' => 'true', + 'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS, + 'Access-Control-Allow-Methods' => self::ALLOWED_METHODS, + 'Access-Control-Max-Age' => self::MAX_AGE_SECONDS, + 'Vary' => 'Origin', + ]; + } + + /** + * @return array{allowed:bool,status:int,headers:array,body:string} + */ + public static function preflightResponse(?string $origin, string $corsConfig): array + { + $headers = self::responseHeaders($origin, $corsConfig); + if ($headers === []) { + return [ + 'allowed' => false, + 'status' => 403, + 'headers' => ['Content-Type' => 'application/json'], + 'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '', + ]; + } + + $headers['Content-Type'] = 'application/json'; + return [ + 'allowed' => true, + 'status' => 200, + 'headers' => $headers, + 'body' => '', + ]; + } + + public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool + { + $headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig); + if ($headers === []) { + return false; + } + + self::emitHeaders($headers); + return true; + } + + /** + * @param array $headers + */ + public static function emitHeaders(array $headers): void + { + foreach ($headers as $name => $value) { + header($name . ': ' . $value, strtolower((string)$name) !== 'vary'); + } + } + + /** + * @return array + */ + private static function splitOrigins(string $corsConfig): array + { + return array_values(array_filter( + array_map('trim', explode(',', $corsConfig)), + static fn(string $origin): bool => $origin !== '' + )); + } +} diff --git a/services/nginx/app/classes/customer_mass_import_service.php b/services/nginx/app/classes/customer_mass_import_service.php new file mode 100644 index 00000000..c9088b88 --- /dev/null +++ b/services/nginx/app/classes/customer_mass_import_service.php @@ -0,0 +1,497 @@ +normalizePayload($payload); + $this->assertValidNormalizedPayload($normalized); + + $customerNumber = (int)$normalized['customer_number']; + $cvr = (string)$normalized['cvr']; + $warnings = []; + + $economicCustomers = $this->searchEconomicCustomersByCvr($cvr); + $localUserExistsBefore = $this->localCustomerNumberExists($customerNumber); + $localUser = $localUserExistsBefore ? $this->loadLocalCustomerByNumber($customerNumber) : null; + $matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economicCustomers, $customerNumber); + + if ($matchingEconomicCustomer !== null) { + $customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser); + $this->syncLocalCustomer($customer, $normalized, $warnings); + + [$action, $message] = $this->resolveExistingCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer)); + + return $this->buildSuccessResult( + $normalized, + $customer, + $action, + $message, + $localUserExistsBefore, + true, + false, + $warnings + ); + } + + if (count($economicCustomers) > 0) { + $existingEconomicCustomerNumber = $this->extractEconomicCustomerNumber($economicCustomers[0]); + $this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [ + 'phase' => 'search', + 'cvr' => $cvr, + 'requestedCustomerNumber' => $customerNumber, + 'existingCustomerNumber' => $existingEconomicCustomerNumber, + ]); + + throw new \RuntimeException( + 'CVR already registered under customer number ' + . $existingEconomicCustomerNumber + . '. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.', + 409 + ); + } + + $normalized['name'] = $this->resolveCreateName($normalized); + $normalized['email'] = $this->resolveCreateEmail($normalized, $warnings); + + $createResponse = $this->createEconomicCustomer($normalized); + $createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse); + + if ($createdCustomerNumber !== $customerNumber) { + $this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [ + 'phase' => 'create', + 'cvr' => $cvr, + 'requestedCustomerNumber' => $customerNumber, + 'createdCustomerNumber' => $createdCustomerNumber, + 'response' => $createResponse, + ]); + + throw new \RuntimeException( + 'E-conomic created the customer under customer number ' + . $createdCustomerNumber + . ' instead of the submitted phone number ' + . $customerNumber + . '. Manual cleanup or reassignment is required before retrying.', + 409 + ); + } + + $customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser); + $this->syncLocalCustomer($customer, $normalized, $warnings); + + [$action, $message] = $this->resolveCreatedCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer)); + + return $this->buildSuccessResult( + $normalized, + $customer, + $action, + $message, + $localUserExistsBefore, + false, + true, + $warnings + ); + } + + protected function normalizePayload(array $payload): array + { + return [ + 'customer_number' => $this->normalizePositiveInt($payload['customer_number'] ?? $payload['phone'] ?? null), + 'phone' => $this->normalizePositiveInt($payload['phone'] ?? $payload['customer_number'] ?? null), + 'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null), + 'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null), + 'email' => $this->normalizeEmail($payload['email'] ?? null), + 'ean' => $this->normalizeDigitString($payload['ean'] ?? null), + ]; + } + + protected function assertValidNormalizedPayload(array $normalized): void + { + $customerNumber = $normalized['customer_number']; + $cvr = $normalized['cvr']; + + if ($customerNumber === null) { + throw new \RuntimeException('Phone number is required.', 400); + } + + $customerNumberLength = strlen((string)$customerNumber); + if ($customerNumberLength < 8 || $customerNumberLength > 10) { + throw new \RuntimeException('Phone number must be between 8 and 10 digits.', 400); + } + + if ($cvr === null) { + throw new \RuntimeException('CVR is required.', 400); + } + + $cvrLength = strlen($cvr); + if ($cvrLength < 8 || $cvrLength > 20) { + throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400); + } + } + + protected function normalizePositiveInt(mixed $value): ?int + { + $digits = $this->normalizeDigitString($value); + if ($digits === null) { + return null; + } + + $normalized = (int)$digits; + return $normalized > 0 ? $normalized : null; + } + + protected function normalizeDigitString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $digits = preg_replace('/\D+/', '', (string)$value); + if (!is_string($digits)) { + return null; + } + + $digits = trim($digits); + return $digits !== '' ? $digits : null; + } + + protected function normalizeText(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + return $normalized !== '' ? $normalized : null; + } + + protected function normalizeEmail(mixed $value): ?string + { + $email = $this->normalizeText($value); + if ($email === null) { + return null; + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \RuntimeException('Invalid email address.', 400); + } + + return $email; + } + + protected function resolveCreateName(array $normalized): string + { + if ($normalized['name'] !== null) { + return $normalized['name']; + } + + $name = trim($this->fetchCompanyNameByCvr((string)$normalized['cvr'])); + if ($name === '') { + throw new \RuntimeException('Customer name is required to create a new company.', 400); + } + + return $name; + } + + protected function resolveCreateEmail(array $normalized, array &$warnings): string + { + if ($normalized['email'] !== null) { + return $normalized['email']; + } + + $warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.'; + return 'jb@truckwash.dk'; + } + + protected function searchEconomicCustomersByCvr(string $cvr): array + { + $response = (new economic())->customers->customers->search([ + 'corporateIdentificationNumber' => $cvr, + ], [ + 'skipPages' => 0, + 'pageSize' => 1000, + ])->collection ?? []; + + return is_array($response) ? $response : []; + } + + protected function createEconomicCustomer(array $normalized): object + { + $payload = [ + 'customerNumber' => (int)$normalized['customer_number'], + 'corporateIdentificationNumber' => (string)$normalized['cvr'], + 'customerGroup' => [ + 'customerGroupNumber' => 1, + ], + 'paymentTerms' => [ + 'paymentTermsNumber' => 12, + ], + 'name' => (string)$normalized['name'], + 'email' => (string)$normalized['email'], + 'phone' => (int)$normalized['phone'], + 'telephoneAndFaxNumber' => (string)$normalized['phone'], + 'mobilePhone' => (string)$normalized['phone'], + 'currency' => 'DKK', + 'vatZone' => [ + 'vatZoneNumber' => 1, + ], + ]; + + if ($normalized['ean'] !== null) { + $payload['ean'] = (string)$normalized['ean']; + } + + return (new economic())->customers->customers->create($payload); + } + + protected function localCustomerNumberExists(int $customerNumber): bool + { + $rows = (new users_o())->getFieldsWhere([ + 'customer_number' => (string)$customerNumber, + ], ['id']); + + return count($rows) > 0; + } + + protected function loadLocalCustomerByNumber(int $customerNumber): ?object + { + $customer = (new users_o())->getUserByCustomerNumber($customerNumber); + return $this->localUserExists($customer) ? $customer : null; + } + + protected function resolveLocalCustomer(int $customerNumber, bool $localUserExistsBefore, ?object $localUser): object + { + if ($localUserExistsBefore && $this->localUserExists($localUser)) { + return $localUser; + } + + return $this->bootstrapLocalCustomerOrFail($customerNumber); + } + + protected function bootstrapLocalCustomerOrFail(int $customerNumber): object + { + $customer = (new users_o())->getUserByCustomerNumber($customerNumber); + if ($this->localUserExists($customer)) { + return $customer; + } + + $this->logIssue('CUSTOMER_MASS_IMPORT_LOCAL_BOOTSTRAP_FAILED', [ + 'customerNumber' => $customerNumber, + ]); + + throw new \RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500); + } + + protected function fetchCompanyNameByCvr(string $cvr): string + { + return (string)((new virkdata())->getCompanyInformation($cvr, '', [])->name ?? ''); + } + + protected function logIssue(string $action, array $context): void + { + $message = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($message === false) { + $message = 'Unable to encode customer mass import context'; + } + + (new logs_o())->add('customers', 'global', 0, 0, $action, $message); + } + + protected function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object + { + foreach ($customers as $customer) { + if (!is_object($customer)) { + continue; + } + + if ($this->extractEconomicCustomerNumber($customer) === $customerNumber) { + return $customer; + } + } + + return null; + } + + protected function extractEconomicCustomerNumber(object $customer): int + { + if (!isset($customer->customerNumber) || !is_numeric($customer->customerNumber)) { + return 0; + } + + return (int)$customer->customerNumber; + } + + protected function resolveExistingCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array + { + if ($localUserExistsBefore && $hasAccount) { + return [ + 'account_already_exists', + 'Customer already exists locally and already has a login account.', + ]; + } + + if ($localUserExistsBefore) { + return [ + 'customer_already_exists', + 'Customer already exists locally but does not have a login password yet.', + ]; + } + + return [ + 'imported_existing_customer', + 'Imported an existing e-conomic customer into the local customer database.', + ]; + } + + protected function resolveCreatedCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array + { + if ($localUserExistsBefore && $hasAccount) { + return [ + 'economic_customer_created_for_existing_account', + 'Created the e-conomic customer for an existing local login account.', + ]; + } + + if ($localUserExistsBefore) { + return [ + 'economic_customer_created_for_existing_customer', + 'Created the e-conomic customer for an existing local customer record.', + ]; + } + + return [ + 'created_customer', + 'Created the customer in e-conomic and imported it locally.', + ]; + } + + protected function buildSuccessResult( + array $normalized, + object $customer, + string $action, + string $message, + bool $existingLocalCustomer, + bool $existingEconomicCustomer, + bool $createdEconomicCustomer, + array $warnings + ): array { + $customerName = $this->extractLocalUserDisplayName($customer) ?? $normalized['name']; + + return [ + 'customer_number' => (int)$normalized['customer_number'], + 'cvr' => (string)$normalized['cvr'], + 'name' => $customerName, + 'email' => $normalized['email'], + 'ean' => $normalized['ean'], + 'action' => $action, + 'message' => $message, + 'user_id' => $this->extractLocalUserId($customer), + 'has_account' => $this->hasLocalAccount($customer), + 'existing_local_customer' => $existingLocalCustomer, + 'existing_economic_customer' => $existingEconomicCustomer, + 'created_economic_customer' => $createdEconomicCustomer, + 'warnings' => array_values(array_filter($warnings, static fn(mixed $warning): bool => is_string($warning) && trim($warning) !== '')), + ]; + } + + protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void + { + if (!$customer instanceof users_o || !$customer->exists()) { + return; + } + + $name = $normalized['name'] ?? null; + $email = $normalized['email'] ?? null; + $phone = $normalized['phone'] ?? null; + + $displayName = trim((string)($customer->display_name->value() ?? '')); + if ($name !== null && ($displayName === '' || strtolower($displayName) === 'unnamed')) { + $customer->display_name->set($name); + } + + if ($email !== null && trim((string)($customer->email->value() ?? '')) === '') { + try { + $customer->setEmail($email); + } catch (\Throwable $throwable) { + $warnings[] = 'Unable to update local email: ' . $throwable->getMessage(); + } + } + + if ($phone !== null && empty($customer->phone->value())) { + try { + $customer->setPhoneNumber((int)$phone); + } catch (\Throwable $throwable) { + $warnings[] = 'Unable to update local phone number: ' . $throwable->getMessage(); + } + } + } + + protected function localUserExists(?object $user): bool + { + if (!is_object($user)) { + return false; + } + + if (method_exists($user, 'exists')) { + try { + return (bool)$user->exists(); + } catch (\Throwable) { + return false; + } + } + + return isset($user->id) && is_numeric($user->id) && (int)$user->id > 0; + } + + protected function hasLocalAccount(?object $user): bool + { + if (!$this->localUserExists($user)) { + return false; + } + + if (method_exists($user, 'hasPassword')) { + try { + return (bool)$user->hasPassword(); + } catch (\Throwable) { + return false; + } + } + + return (bool)($user->has_password ?? false); + } + + protected function extractLocalUserId(?object $user): ?int + { + if (!is_object($user) || !isset($user->id) || !is_numeric($user->id)) { + return null; + } + + $userId = (int)$user->id; + return $userId > 0 ? $userId : null; + } + + protected function extractLocalUserDisplayName(?object $user): ?string + { + if (!is_object($user)) { + return null; + } + + if ($user instanceof users_o) { + $name = trim((string)($user->display_name->value() ?? '')); + return $name !== '' ? $name : null; + } + + $name = trim((string)($user->display_name ?? $user->name ?? '')); + return $name !== '' ? $name : null; + } +} diff --git a/services/nginx/app/classes/customer_name_cache_payload_builder.php b/services/nginx/app/classes/customer_name_cache_payload_builder.php new file mode 100644 index 00000000..510ec2cd --- /dev/null +++ b/services/nginx/app/classes/customer_name_cache_payload_builder.php @@ -0,0 +1,94 @@ + $name]; + } + + $fallback_name = self::normalizeName($fallback_name); + if ($fallback_name !== null) { + return ['name' => $fallback_name]; + } + + return null; + } + + private static function normalizePayload(mixed $payload): mixed + { + if (!is_string($payload)) { + return $payload; + } + + $trimmed = trim($payload); + if ($trimmed === '') { + return null; + } + + $decoded = json_decode($trimmed); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + return $trimmed; + } + + private static function extractName(mixed $payload): ?string + { + if (is_string($payload)) { + return self::normalizeName($payload); + } + + if (!is_object($payload) && !is_array($payload)) { + return null; + } + + foreach (['name', 'customerName', 'customer_name', 'displayName', 'display_name'] as $key) { + $name = self::normalizeName(self::payloadValue($payload, $key)); + if ($name !== null) { + return $name; + } + } + + foreach (['customer', 'data', 'economic_customer'] as $key) { + $name = self::extractName(self::payloadValue($payload, $key)); + if ($name !== null) { + return $name; + } + } + + return null; + } + + private static function payloadValue(mixed $payload, string $key): mixed + { + if (is_object($payload) && property_exists($payload, $key)) { + return $payload->{$key}; + } + + if (is_array($payload) && array_key_exists($key, $payload)) { + return $payload[$key]; + } + + return null; + } + + private static function normalizeName(mixed $name): ?string + { + if (!is_string($name)) { + return null; + } + + $name = trim($name); + return $name === '' ? null : $name; + } +} diff --git a/services/nginx/app/classes/db.php b/services/nginx/app/classes/db.php index 0d62b6cf..0a313420 100644 --- a/services/nginx/app/classes/db.php +++ b/services/nginx/app/classes/db.php @@ -13,6 +13,7 @@ class db private string $user; private string $password; private string $database; + private int $port = 3306; private string $ssl_mode = 'DISABLED'; // mysqldump SSL mode (e.g., DISABLED, PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY) public function __construct(array $config) @@ -21,6 +22,9 @@ class db $this->user = $config['user']; $this->password = $config['password']; $this->database = $config['database']; + if (isset($config['port']) && is_numeric($config['port'])) { + $this->port = (int)$config['port']; + } if (isset($config['ssl_mode']) && is_string($config['ssl_mode']) && $config['ssl_mode'] !== '') { $this->ssl_mode = $config['ssl_mode']; } @@ -28,22 +32,46 @@ class db public static function getPDO(): \PDO { - global $config; - $dsn = "mysql:host={$config['db']['host']};dbname={$config['db']['database']};charset=utf8mb4"; - return new \PDO($dsn, $config['db']['user'], $config['db']['password'], [ + global $CONFIG_DB; + $port = $CONFIG_DB['port'] ?? 3306; + $dsn = "mysql:host={$CONFIG_DB['host']};port={$port};dbname={$CONFIG_DB['database']};charset=utf8mb4"; + return new \PDO($dsn, $CONFIG_DB['user'], $CONFIG_DB['password'], [ \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, \PDO::ATTR_EMULATE_PREPARES => false, ]); } + public function testConnection(): bool + { + try { + $conn = new mysqli($this->host, $this->user, $this->password, $this->database, $this->port); + if ($conn->connect_error) { + return false; + } + $this->conn = $conn; + return true; + } catch (Exception) { + return false; + } + } + public function connect(): void { global $response; try { - $this->conn = new mysqli($this->host, $this->user, $this->password, $this->database); + // Enable error reporting for mysqli to catch connection issues via exceptions + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + $this->conn = new mysqli($this->host, $this->user, $this->password, $this->database, $this->port); + if ($this->conn->connect_error) { + throw new Exception($this->conn->connect_error); + } } catch (Exception $e) { - $response->internal_server_error($e->getMessage()); + if ($response) { + $response->internal_server_error("Database connection failed: " . $e->getMessage()); + } else { + throw $e; + } } } @@ -54,7 +82,14 @@ class db public function close(): void { - $this->conn->close(); + if (!isset($this->conn)) { + return; + } + + try { + $this->conn->close(); + } catch (\Throwable) { + } } public function get(string $table, int $id) @@ -170,9 +205,10 @@ class db $user = escapeshellarg($this->user); $pass = escapeshellarg($this->password); $db = escapeshellarg($this->database); + $port = (int)$this->port; $outfile = escapeshellarg($path); $sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : ''; - $command = "mysqldump {$sslPart}-h $host -u $user --password=$pass $db > $outfile 2>&1"; + $command = "mysqldump {$sslPart}-h $host -P $port -u $user --password=$pass $db > $outfile 2>&1"; exec($command, $output, $return); // Check if the command was successful return $return === 0; diff --git a/services/nginx/app/classes/department_daily_report_complaints_schema_bootstrap.php b/services/nginx/app/classes/department_daily_report_complaints_schema_bootstrap.php new file mode 100644 index 00000000..716320c8 --- /dev/null +++ b/services/nginx/app/classes/department_daily_report_complaints_schema_bootstrap.php @@ -0,0 +1,152 @@ +query( + "CREATE TABLE IF NOT EXISTS department_daily_report_complaints ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL, + customer_number INT NULL, + wash_date DATE NULL, + category VARCHAR(64) NULL, + description TEXT NOT NULL, + created_by INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_department_daily_report_complaints_department_created (department_id, created_at), + INDEX " . self::WASH_DATE_INDEX . " (department_id, wash_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + if (!self::columnExists(self::TABLE, 'wash_date')) { + $db->query( + "ALTER TABLE " . self::TABLE . " + ADD COLUMN wash_date DATE NULL + AFTER customer_number" + ); + } + + if (!self::columnExists(self::TABLE, 'category')) { + $db->query( + "ALTER TABLE " . self::TABLE . " + ADD COLUMN category VARCHAR(64) NULL + AFTER wash_date" + ); + } + + if (!self::indexExists(self::TABLE, self::WASH_DATE_INDEX)) { + $db->query( + "ALTER TABLE " . self::TABLE . " + ADD INDEX " . self::WASH_DATE_INDEX . " (department_id, wash_date)" + ); + } + + self::$initialized = true; + } + + private static function ensureSupportingTables(): void + { + global $db; + + $db->query( + "CREATE TABLE IF NOT EXISTS departments ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + economic_department_id INT NOT NULL DEFAULT 0, + slack_webhook TEXT NULL, + dimension INT NOT NULL DEFAULT 0, + branding INT NOT NULL DEFAULT 0, + visible TINYINT(1) NOT NULL DEFAULT 1, + archived TINYINT(1) NOT NULL DEFAULT 0, + longitude DECIMAL(10,7) NOT NULL DEFAULT 0, + latitude DECIMAL(10,7) NOT NULL DEFAULT 0, + order_priority INT NOT NULL DEFAULT 0, + created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_departments_archived (archived) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS department_variables ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL, + variable VARCHAR(191) NOT NULL, + value TEXT NULL, + created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_department_variables_department_id (department_id), + KEY idx_department_variables_variable (variable) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + customer_number INT NOT NULL, + display_name VARCHAR(255) NULL, + email VARCHAR(255) NULL, + phone_country_code INT NULL, + phone BIGINT NULL, + password VARCHAR(255) NULL, + group_id INT NOT NULL DEFAULT 0, + xlvask_customer_id VARCHAR(255) NULL, + sms_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0, + email_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0, + wash_certificate_email VARCHAR(255) NULL, + two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0, + two_factor_secret VARCHAR(255) NULL, + created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + KEY idx_users_customer_number (customer_number), + KEY idx_users_group_id (group_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } + + private static function indexExists(string $table, string $index): bool + { + global $db; + + $table_sql = $db->escape_string($table); + $index_sql = $db->escape_string($index); + $result = $db->query( + "SHOW INDEX FROM `$table_sql` WHERE Key_name = '$index_sql'" + ); + + return $result !== false && $result->num_rows > 0; + } + + private static function columnExists(string $table, string $column): bool + { + global $db; + + $table_sql = $db->escape_string($table); + $column_sql = $db->escape_string($column); + $result = $db->query( + "SHOW COLUMNS FROM `$table_sql` LIKE '$column_sql'" + ); + + return $result !== false && $result->num_rows > 0; + } +} diff --git a/services/nginx/app/classes/department_gate_config.php b/services/nginx/app/classes/department_gate_config.php index ec7c1f77..6fcbe199 100644 --- a/services/nginx/app/classes/department_gate_config.php +++ b/services/nginx/app/classes/department_gate_config.php @@ -9,15 +9,19 @@ class department_gate_config public string $type; public ?string $phone_number = null; public ?int $call_duration_threshold = null; + public ?string $relay_id = null; + public ?int $pulse_seconds = null; /** * @param array $config */ public function __construct(array $config = []) { - $this->type = (string)($config['type'] ?? ''); + $this->type = strtoupper(trim((string)($config['type'] ?? ''))); $this->phone_number = isset($config['phone_number']) ? (string)$config['phone_number'] : null; $this->call_duration_threshold = isset($config['call_duration_threshold']) ? (int)$config['call_duration_threshold'] : null; + $this->relay_id = isset($config['relay_id']) ? trim((string)$config['relay_id']) : null; + $this->pulse_seconds = isset($config['pulse_seconds']) ? (int)$config['pulse_seconds'] : null; } /** @@ -37,6 +41,14 @@ class department_gate_config $array['call_duration_threshold'] = $this->call_duration_threshold; } + if ($this->relay_id !== null) { + $array['relay_id'] = $this->relay_id; + } + + if ($this->pulse_seconds !== null) { + $array['pulse_seconds'] = $this->pulse_seconds; + } + return $array; } @@ -58,6 +70,19 @@ class department_gate_config if ($this->call_duration_threshold === null) { throw new Exception('Call duration threshold is required for PHONE_CALL gate type'); } + return; } + + if ($this->type === 'RELAY') { + if ($this->relay_id === null || $this->relay_id === '') { + throw new Exception('relay_id is required for RELAY gate type'); + } + if ($this->pulse_seconds !== null && $this->pulse_seconds < 0) { + throw new Exception('pulse_seconds must be a positive integer for RELAY gate type'); + } + return; + } + + throw new Exception('Unsupported gate config type: ' . $this->type); } } diff --git a/services/nginx/app/classes/department_outside_hours_statistics_service.php b/services/nginx/app/classes/department_outside_hours_statistics_service.php new file mode 100644 index 00000000..900b2ca0 --- /dev/null +++ b/services/nginx/app/classes/department_outside_hours_statistics_service.php @@ -0,0 +1,1057 @@ + + */ + private array $source_priority = [ + self::SOURCE_ORDERS => 1, + self::SOURCE_XLVASK => 2, + self::SOURCE_SELFSERVE => 3, + ]; + + private DateTimeZone $timezone; + + public function __construct(?DateTimeZone $timezone = null) + { + $this->timezone = $timezone ?? new DateTimeZone('Europe/Copenhagen'); + } + + /** + * @param array|int|string $department_ids + * @return array{ + * department_ids:array, + * date:string, + * date_to:string, + * total:int, + * by_source:array{orders:int,xlvask:int,selfserve:int}, + * has_missing_opening_hours:bool, + * missing_department_ids:array + * } + * @throws Exception + */ + public function getSummary(string $date, array|int|string $department_ids, ?string $date_to = null): array + { + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + $resolved_date_to = $date_to ?? $date; + + if ($normalized_department_ids === []) { + return $this->emptySummary($date, $resolved_date_to, []); + } + + return $this->summarizeCandidates( + $this->fetchCandidates($date, $resolved_date_to, $normalized_department_ids), + $this->fetchOpeningHoursByDepartmentId($normalized_department_ids), + $normalized_department_ids, + $date, + $resolved_date_to + ); + } + + /** + * @param array|int|string $department_ids + * @return array{ + * department_ids:array, + * date:string, + * date_to:string, + * points:array + * }>, + * has_missing_opening_hours:bool, + * missing_department_ids:array + * } + * @throws Exception + */ + public function getTrend(string $date, string $date_to, array|int|string $department_ids): array + { + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + if ($normalized_department_ids === []) { + return [ + 'department_ids' => [], + 'date' => $date, + 'date_to' => $date_to, + 'points' => [], + 'has_missing_opening_hours' => false, + 'missing_department_ids' => [], + ]; + } + + return $this->buildTrendFromCandidates( + $this->fetchCandidates($date, $date_to, $normalized_department_ids), + $this->fetchOpeningHoursByDepartmentId($normalized_department_ids), + $normalized_department_ids, + $date, + $date_to + ); + } + + /** + * @param array{ + * total?:int, + * by_source?:array{orders?:int,xlvask?:int,selfserve?:int}, + * has_missing_opening_hours?:bool, + * missing_department_ids?:array + * } $summary + * @return array + */ + public function toOverviewMetric(array $summary): array + { + return [ + 'state' => 'ready', + 'value' => (int)($summary['total'] ?? 0), + 'out_of' => null, + 'message' => null, + 'by_source' => [ + self::SOURCE_ORDERS => (int)($summary['by_source'][self::SOURCE_ORDERS] ?? 0), + self::SOURCE_XLVASK => (int)($summary['by_source'][self::SOURCE_XLVASK] ?? 0), + self::SOURCE_SELFSERVE => (int)($summary['by_source'][self::SOURCE_SELFSERVE] ?? 0), + ], + 'has_missing_opening_hours' => (bool)($summary['has_missing_opening_hours'] ?? false), + 'missing_department_ids' => array_values(array_map('intval', $summary['missing_department_ids'] ?? [])), + ]; + } + + /** + * @param array> $candidates + * @param array> $opening_hours_by_department_id + * @param array|int|string $department_ids + * @return array{ + * department_ids:array, + * date:string, + * date_to:string, + * total:int, + * by_source:array{orders:int,xlvask:int,selfserve:int}, + * has_missing_opening_hours:bool, + * missing_department_ids:array + * } + * @throws Exception + */ + public function summarizeCandidates( + array $candidates, + array $opening_hours_by_department_id, + array|int|string $department_ids, + string $date, + string $date_to + ): array { + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + $summary = $this->emptySummary($date, $date_to, $normalized_department_ids); + $missing_diagnostics = $this->buildMissingOpeningHoursDiagnostics($normalized_department_ids, $opening_hours_by_department_id, $date, $date_to); + $missing_lookup = $this->missingDepartmentLookupByDay($missing_diagnostics['by_day']); + + foreach ($this->deduplicateCandidates($candidates) as $candidate) { + $classification = $this->classifyCandidateAgainstOpeningHours( + $candidate, + $opening_hours_by_department_id, + $missing_lookup, + $date, + $date_to + ); + if (($classification['counted'] ?? false) !== true) { + continue; + } + + $source = (string)$candidate['source']; + $summary['total']++; + $summary['by_source'][$source] = (int)($summary['by_source'][$source] ?? 0) + 1; + } + + $summary['has_missing_opening_hours'] = (bool)$missing_diagnostics['has_missing_opening_hours']; + $summary['missing_department_ids'] = $missing_diagnostics['missing_department_ids']; + + return $summary; + } + + /** + * @param array> $candidates + * @param array> $opening_hours_by_department_id + * @param array|int|string $department_ids + * @return array{ + * department_ids:array, + * date:string, + * date_to:string, + * points:array + * }>, + * has_missing_opening_hours:bool, + * missing_department_ids:array + * } + * @throws Exception + */ + public function buildTrendFromCandidates( + array $candidates, + array $opening_hours_by_department_id, + array|int|string $department_ids, + string $date, + string $date_to + ): array { + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + $missing_diagnostics = $this->buildMissingOpeningHoursDiagnostics($normalized_department_ids, $opening_hours_by_department_id, $date, $date_to); + $missing_lookup = $this->missingDepartmentLookupByDay($missing_diagnostics['by_day']); + + $points = []; + foreach ($this->dateRange($date, $date_to) as $current_date) { + $point_date = $current_date->format('Y-m-d'); + $points[$point_date] = [ + 'date' => $point_date, + 'total' => 0, + 'by_source' => $this->emptyBySource(), + 'has_missing_opening_hours' => isset($missing_lookup[$point_date]), + 'missing_department_ids' => array_values(array_map( + 'intval', + array_keys($missing_lookup[$point_date] ?? []) + )), + ]; + } + + foreach ($this->deduplicateCandidates($candidates) as $candidate) { + $classification = $this->classifyCandidateAgainstOpeningHours( + $candidate, + $opening_hours_by_department_id, + $missing_lookup, + $date, + $date_to + ); + if (($classification['counted'] ?? false) !== true) { + continue; + } + + $point_date = (string)$classification['candidate_date']; + $source = (string)$candidate['source']; + if (!isset($points[$point_date])) { + $points[$point_date] = [ + 'date' => $point_date, + 'total' => 0, + 'by_source' => $this->emptyBySource(), + 'has_missing_opening_hours' => false, + 'missing_department_ids' => [], + ]; + } + + $points[$point_date]['total']++; + $points[$point_date]['by_source'][$source] = (int)($points[$point_date]['by_source'][$source] ?? 0) + 1; + } + + return [ + 'department_ids' => $normalized_department_ids, + 'date' => $date, + 'date_to' => $date_to, + 'points' => array_values($points), + 'has_missing_opening_hours' => (bool)$missing_diagnostics['has_missing_opening_hours'], + 'missing_department_ids' => $missing_diagnostics['missing_department_ids'], + ]; + } + + /** + * @param array> $candidates + * @return array> + */ + public function deduplicateCandidates(array $candidates): array + { + $deduplicated = []; + foreach ($candidates as $candidate) { + $dedupe_key = trim((string)($candidate['dedupe_key'] ?? '')); + if ($dedupe_key === '') { + continue; + } + + if (!isset($deduplicated[$dedupe_key])) { + $deduplicated[$dedupe_key] = $candidate; + continue; + } + + if ($this->shouldReplaceDeduplicatedCandidate($deduplicated[$dedupe_key], $candidate)) { + $deduplicated[$dedupe_key] = $candidate; + } + } + + uasort($deduplicated, function (array $left, array $right): int { + $left_start = trim((string)($left['start_at'] ?? '')); + $right_start = trim((string)($right['start_at'] ?? '')); + return strcmp($left_start, $right_start); + }); + + return array_values($deduplicated); + } + + /** + * @param array $candidate + * @param array> $opening_hours_by_department_id + * @param array>|null $missing_lookup_by_day + * @return array{ + * counted:bool, + * reason:string, + * candidate_date:?string, + * department_id:int + * } + */ + public function classifyCandidateAgainstOpeningHours( + array $candidate, + array $opening_hours_by_department_id, + ?array $missing_lookup_by_day, + string $date, + string $date_to + ): array { + $department_id = (int)($candidate['department_id'] ?? 0); + $timestamp = $this->parseTimestamp($candidate['start_at'] ?? null); + if ($timestamp === null || $department_id < 1) { + return [ + 'counted' => false, + 'reason' => 'invalid_candidate', + 'candidate_date' => null, + 'department_id' => $department_id, + ]; + } + + $candidate_date = $timestamp->format('Y-m-d'); + if ($candidate_date < $date || $candidate_date > $date_to) { + return [ + 'counted' => false, + 'reason' => 'outside_selected_range', + 'candidate_date' => $candidate_date, + 'department_id' => $department_id, + ]; + } + + if (isset($missing_lookup_by_day[$candidate_date][$department_id])) { + return [ + 'counted' => false, + 'reason' => 'missing_opening_hours', + 'candidate_date' => $candidate_date, + 'department_id' => $department_id, + ]; + } + + $outside_opening_hours = $this->isOutsideOpeningHours( + $timestamp, + $opening_hours_by_department_id[$department_id] ?? null + ); + + if ($outside_opening_hours !== true) { + return [ + 'counted' => false, + 'reason' => 'inside_opening_hours', + 'candidate_date' => $candidate_date, + 'department_id' => $department_id, + ]; + } + + return [ + 'counted' => true, + 'reason' => 'outside_opening_hours', + 'candidate_date' => $candidate_date, + 'department_id' => $department_id, + ]; + } + + /** + * @param array|int|string $department_ids + * @param array> $opening_hours_by_department_id + * @return array{ + * by_day:array>, + * has_missing_opening_hours:bool, + * missing_department_ids:array + * } + * @throws Exception + */ + public function buildMissingOpeningHoursDiagnostics( + array|int|string $department_ids, + array $opening_hours_by_department_id, + string $date, + string $date_to + ): array { + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + $missing_by_day = []; + $missing_department_ids = []; + + foreach ($this->dateRange($date, $date_to) as $current_date) { + $weekday = strtolower($current_date->format('l')); + $point_date = $current_date->format('Y-m-d'); + + foreach ($normalized_department_ids as $department_id) { + $opening_hours = $opening_hours_by_department_id[$department_id] ?? null; + if ($this->hasOpeningHoursForWeekday($opening_hours, $weekday)) { + continue; + } + + $missing_by_day[$point_date][] = (int)$department_id; + $missing_department_ids[$department_id] = (int)$department_id; + } + } + + foreach ($missing_by_day as $point_date => $department_list) { + $unique_ids = array_values(array_unique(array_map('intval', $department_list))); + sort($unique_ids); + $missing_by_day[$point_date] = $unique_ids; + } + + $missing_department_ids = array_values($missing_department_ids); + sort($missing_department_ids); + + return [ + 'by_day' => $missing_by_day, + 'has_missing_opening_hours' => $missing_department_ids !== [], + 'missing_department_ids' => $missing_department_ids, + ]; + } + + /** + * @param array|int|string $department_ids + * @return array + */ + private function normalizeDepartmentIds(array|int|string $department_ids): array + { + $queue = is_array($department_ids) ? $department_ids : [$department_ids]; + $normalized = []; + + while ($queue !== []) { + $value = array_shift($queue); + if (is_array($value)) { + foreach ($value as $nested) { + $queue[] = $nested; + } + continue; + } + + if (is_string($value) && str_contains($value, ',')) { + foreach (explode(',', $value) as $segment) { + $queue[] = trim($segment); + } + continue; + } + + $department_id = (int)$value; + if ($department_id > 0) { + $normalized[$department_id] = $department_id; + } + } + + return array_values($normalized); + } + + /** + * @param array $department_ids + * @return array> + * @throws Exception + */ + private function fetchCandidates(string $date, string $date_to, array $department_ids): array + { + $order_candidates = $this->fetchOrderCandidatesInRange($date, $date_to, $department_ids); + $selfserve_candidates = $this->fetchSelfserveCandidatesInRange($date, $date_to, $department_ids); + $xlvask_candidates = $this->fetchXlvaskCandidatesInRange($date, $date_to, $department_ids); + + $order_ids_for_selfserve = array_values(array_unique(array_filter(array_merge( + array_map(static fn(array $candidate): int => (int)($candidate['entity_id'] ?? 0), $order_candidates), + array_map(static fn(array $candidate): int => (int)($candidate['linked_order_id'] ?? 0), $xlvask_candidates) + )))); + + $wash_ids_for_xlvask = array_values(array_unique(array_filter(array_map( + static fn(array $candidate): string => trim((string)($candidate['wash_id'] ?? '')), + $order_candidates + )))); + + return array_merge( + $order_candidates, + $selfserve_candidates, + $xlvask_candidates, + $this->fetchSelfserveCandidatesByOrderIds($order_ids_for_selfserve), + $this->fetchXlvaskCandidatesByWashIds($wash_ids_for_xlvask, $department_ids) + ); + } + + /** + * @param array $department_ids + * @return array> + * @throws Exception + */ + private function fetchOrderCandidatesInRange(string $date, string $date_to, array $department_ids): array + { + global $db; + + if ($department_ids === []) { + return []; + } + + [$date_start, $date_end] = $this->resolveSqlDateRange($date, $date_to); + $department_ids_sql = implode(',', array_map('intval', $department_ids)); + $escaped_start = $db->escape_string($date_start); + $escaped_end = $db->escape_string($date_end); + + $sql = "SELECT DISTINCT o.id, o.department_id, o.created_at, o.wash_id + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + JOIN products p ON p.id = oi.product_id + WHERE o.department_id IN ($department_ids_sql) + AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end' + AND o.deleted_at IS NULL + AND oi.deleted_at IS NULL + AND p.is_wash = 1"; + + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + $candidates = []; + while ($row = $result->fetch_assoc()) { + $order_id = (int)($row['id'] ?? 0); + $department_id = (int)($row['department_id'] ?? 0); + $start_at = trim((string)($row['created_at'] ?? '')); + if ($order_id < 1 || $department_id < 1 || $start_at === '') { + continue; + } + + $candidates[] = [ + 'source' => self::SOURCE_ORDERS, + 'dedupe_key' => 'order:' . $order_id, + 'department_id' => $department_id, + 'start_at' => $start_at, + 'entity_id' => $order_id, + 'wash_id' => trim((string)($row['wash_id'] ?? '')), + ]; + } + + return $candidates; + } + + /** + * @param array $department_ids + * @return array> + * @throws Exception + */ + private function fetchSelfserveCandidatesInRange(string $date, string $date_to, array $department_ids): array + { + global $db; + + if ($department_ids === []) { + return []; + } + + [$date_start, $date_end] = $this->resolveSqlDateRange($date, $date_to); + $department_ids_sql = implode(',', array_map('intval', $department_ids)); + $escaped_start = $db->escape_string($date_start); + $escaped_end = $db->escape_string($date_end); + + $sql = "SELECT id, department_id, order_id, wash_started_at, machine_start_triggered_at + FROM selfserve_wash_sessions + WHERE department_id IN ($department_ids_sql) + AND deleted_at IS NULL + AND COALESCE(wash_started_at, machine_start_triggered_at) IS NOT NULL + AND COALESCE(wash_started_at, machine_start_triggered_at) BETWEEN '$escaped_start' AND '$escaped_end'"; + + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + return $this->mapSelfserveRowsToCandidates($db->fetch_all($result)); + } + + /** + * @param array $order_ids + * @return array> + */ + private function fetchSelfserveCandidatesByOrderIds(array $order_ids): array + { + global $db; + + $normalized_order_ids = array_values(array_unique(array_filter(array_map('intval', $order_ids)))); + if ($normalized_order_ids === []) { + return []; + } + + $order_ids_sql = implode(',', $normalized_order_ids); + $sql = "SELECT id, department_id, order_id, wash_started_at, machine_start_triggered_at + FROM selfserve_wash_sessions + WHERE order_id IN ($order_ids_sql) + AND deleted_at IS NULL + AND COALESCE(wash_started_at, machine_start_triggered_at) IS NOT NULL"; + + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + return $this->mapSelfserveRowsToCandidates($db->fetch_all($result)); + } + + /** + * @param array> $rows + * @return array> + */ + private function mapSelfserveRowsToCandidates(array $rows): array + { + $candidates = []; + foreach ($rows as $row) { + $session_id = (int)($row['id'] ?? 0); + $department_id = (int)($row['department_id'] ?? 0); + $order_id = (int)($row['order_id'] ?? 0); + $start_at = trim((string)($row['wash_started_at'] ?? '')); + if ($start_at === '') { + $start_at = trim((string)($row['machine_start_triggered_at'] ?? '')); + } + + if ($session_id < 1 || $department_id < 1 || $start_at === '') { + continue; + } + + $candidates[] = [ + 'source' => self::SOURCE_SELFSERVE, + 'dedupe_key' => $order_id > 0 ? 'order:' . $order_id : 'selfserve:' . $session_id, + 'department_id' => $department_id, + 'start_at' => $start_at, + 'entity_id' => $session_id, + 'linked_order_id' => $order_id > 0 ? $order_id : null, + ]; + } + + return $candidates; + } + + /** + * @param array $department_ids + * @return array> + * @throws Exception + */ + private function fetchXlvaskCandidatesInRange(string $date, string $date_to, array $department_ids): array + { + global $db; + + if ($department_ids === []) { + return []; + } + + [$iso_start, $iso_end] = $this->resolveXlvaskDateRange($date, $date_to); + $escaped_start = $db->escape_string($iso_start); + $escaped_end = $db->escape_string($iso_end); + + $sql = "SELECT x.WashId, x.CustomerId, x.Customer, x.Hall, x.StartTime, x.FinishStatus, + o.id AS order_id, o.department_id AS order_department_id + FROM xlvask_usage_logs x + LEFT JOIN orders o + ON o.wash_id = x.WashId + AND o.deleted_at IS NULL + WHERE x.StartTime BETWEEN '$escaped_start' AND '$escaped_end'"; + + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + return $this->mapXlvaskRowsToCandidates($db->fetch_all($result), $department_ids); + } + + /** + * @param array $wash_ids + * @param array $department_ids + * @return array> + * @throws Exception + */ + private function fetchXlvaskCandidatesByWashIds(array $wash_ids, array $department_ids): array + { + global $db; + + $normalized_wash_ids = array_values(array_filter(array_map( + static fn(mixed $value): string => trim((string)$value), + $wash_ids + ))); + + if ($normalized_wash_ids === []) { + return []; + } + + $escaped_wash_ids = array_map(static fn(string $wash_id): string => "'" . $db->escape_string($wash_id) . "'", $normalized_wash_ids); + $wash_ids_sql = implode(',', $escaped_wash_ids); + + $sql = "SELECT x.WashId, x.CustomerId, x.Customer, x.Hall, x.StartTime, x.FinishStatus, + o.id AS order_id, o.department_id AS order_department_id + FROM xlvask_usage_logs x + LEFT JOIN orders o + ON o.wash_id = x.WashId + AND o.deleted_at IS NULL + WHERE x.WashId IN ($wash_ids_sql)"; + + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + return $this->mapXlvaskRowsToCandidates($db->fetch_all($result), $department_ids); + } + + /** + * @param array> $rows + * @param array $department_ids + * @return array> + * @throws Exception + */ + private function mapXlvaskRowsToCandidates(array $rows, array $department_ids): array + { + $selected_departments = $this->fetchDepartmentsByIds($department_ids); + $department_name_lookup = []; + foreach ($selected_departments as $department) { + $department_id = (int)($department['id'] ?? 0); + $department_name = (string)($department['name'] ?? ''); + if ($department_id < 1 || trim($department_name) === '') { + continue; + } + + $department_name_lookup[$this->normalizeDepartmentName($department_name)] = $department_id; + } + + $allowed_department_lookup = []; + foreach ($department_ids as $department_id) { + $allowed_department_lookup[(int)$department_id] = true; + } + + $candidates = []; + foreach ($rows as $row) { + $wash_id = trim((string)($row['WashId'] ?? '')); + $start_at = $this->normalizeTimestamp($row['StartTime'] ?? null); + $order_id = (int)($row['order_id'] ?? 0); + $department_id = (int)($row['order_department_id'] ?? 0); + + if ($wash_id === '' || $start_at === null || !$this->isBillableCompletedXlvaskRow($row)) { + continue; + } + + if ($department_id < 1) { + $department_id = $this->resolveDepartmentIdFromHall((string)($row['Hall'] ?? ''), $department_name_lookup); + } + + if ($department_id < 1 || !isset($allowed_department_lookup[$department_id])) { + continue; + } + + $candidates[] = [ + 'source' => self::SOURCE_XLVASK, + 'dedupe_key' => $order_id > 0 ? 'order:' . $order_id : 'xlvask:' . $wash_id, + 'department_id' => $department_id, + 'start_at' => $start_at, + 'entity_id' => $wash_id, + 'linked_order_id' => $order_id > 0 ? $order_id : null, + ]; + } + + return $candidates; + } + + /** + * @param array $row + */ + private function isBillableCompletedXlvaskRow(array $row): bool + { + $finish_status = trim((string)($row['FinishStatus'] ?? '')); + $customer = (string)($row['Customer'] ?? ''); + $customer_id = trim((string)($row['CustomerId'] ?? '')); + + return $finish_status === '1' + && $customer_id !== '' + && $customer_id !== '0' + && !in_array($customer, xlvask_usage_log::$default_customers, true); + } + + /** + * @param array $department_ids + * @return array> + */ + private function fetchOpeningHoursByDepartmentId(array $department_ids): array + { + global $db; + + if ($department_ids === []) { + return []; + } + + $department_ids_sql = implode(',', array_map('intval', $department_ids)); + $sql = "SELECT * FROM department_time_bookings_opening_hours WHERE department IN ($department_ids_sql)"; + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[(int)($row['department'] ?? 0)] = $row; + } + + return $rows; + } + + /** + * @param array $department_ids + * @return array + */ + private function fetchDepartmentsByIds(array $department_ids): array + { + global $db; + + if ($department_ids === []) { + return []; + } + + $department_ids_sql = implode(',', array_map('intval', $department_ids)); + $sql = "SELECT id, name FROM departments WHERE id IN ($department_ids_sql)"; + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + return array_map(static function (array $row): array { + return [ + 'id' => (int)($row['id'] ?? 0), + 'name' => (string)($row['name'] ?? ''), + ]; + }, $db->fetch_all($result)); + } + + /** + * @param array|null $opening_hours + */ + private function hasOpeningHoursForWeekday(?array $opening_hours, string $weekday): bool + { + if (!is_array($opening_hours)) { + return false; + } + + $opening_start = $opening_hours[$weekday . '_start'] ?? null; + $opening_end = $opening_hours[$weekday . '_end'] ?? null; + + return is_string($opening_start) + && trim($opening_start) !== '' + && is_string($opening_end) + && trim($opening_end) !== ''; + } + + /** + * @param array|null $opening_hours + */ + private function isOutsideOpeningHours(DateTimeInterface $timestamp, ?array $opening_hours): ?bool + { + $weekday = strtolower($timestamp->format('l')); + if (!$this->hasOpeningHoursForWeekday($opening_hours, $weekday)) { + return null; + } + + $opening_start = trim((string)$opening_hours[$weekday . '_start']); + $opening_end = trim((string)$opening_hours[$weekday . '_end']); + $wash_time = $timestamp->format('H:i'); + $opening_start_time = date('H:i', strtotime($opening_start)); + $opening_end_time = date('H:i', strtotime($opening_end)); + + return !($wash_time >= $opening_start_time && $wash_time <= $opening_end_time); + } + + private function shouldReplaceDeduplicatedCandidate(array $existing, array $candidate): bool + { + $existing_priority = $this->source_priority[(string)($existing['source'] ?? '')] ?? 0; + $candidate_priority = $this->source_priority[(string)($candidate['source'] ?? '')] ?? 0; + + if ($candidate_priority !== $existing_priority) { + return $candidate_priority > $existing_priority; + } + + $existing_timestamp = $this->parseTimestamp($existing['start_at'] ?? null); + $candidate_timestamp = $this->parseTimestamp($candidate['start_at'] ?? null); + + if ($existing_timestamp === null && $candidate_timestamp !== null) { + return true; + } + + if ($existing_timestamp !== null && $candidate_timestamp !== null) { + return $candidate_timestamp < $existing_timestamp; + } + + return false; + } + + /** + * @param array> $by_day + * @return array> + */ + private function missingDepartmentLookupByDay(array $by_day): array + { + $lookup = []; + foreach ($by_day as $point_date => $department_ids) { + foreach ($department_ids as $department_id) { + $lookup[$point_date][(int)$department_id] = true; + } + } + + return $lookup; + } + + /** + * @return array{orders:int,xlvask:int,selfserve:int} + */ + private function emptyBySource(): array + { + return [ + self::SOURCE_ORDERS => 0, + self::SOURCE_XLVASK => 0, + self::SOURCE_SELFSERVE => 0, + ]; + } + + /** + * @param array $department_ids + * @return array{ + * department_ids:array, + * date:string, + * date_to:string, + * total:int, + * by_source:array{orders:int,xlvask:int,selfserve:int}, + * has_missing_opening_hours:bool, + * missing_department_ids:array + * } + */ + private function emptySummary(string $date, string $date_to, array $department_ids): array + { + return [ + 'department_ids' => array_values(array_map('intval', $department_ids)), + 'date' => $date, + 'date_to' => $date_to, + 'total' => 0, + 'by_source' => $this->emptyBySource(), + 'has_missing_opening_hours' => false, + 'missing_department_ids' => [], + ]; + } + + /** + * @return array{0:string,1:string} + * @throws Exception + */ + private function resolveSqlDateRange(string $date, string $date_to): array + { + $start = $this->parseDate($date)->setTime(0, 0, 0); + $end = $this->parseDate($date_to)->setTime(23, 59, 59); + + return [ + $start->format('Y-m-d H:i:s'), + $end->format('Y-m-d H:i:s'), + ]; + } + + /** + * @return array{0:string,1:string} + * @throws Exception + */ + private function resolveXlvaskDateRange(string $date, string $date_to): array + { + $start = $this->parseDate($date)->setTime(0, 0, 0, 0); + $end = $this->parseDate($date_to)->setTime(23, 59, 59, 999000); + + return [ + $start->format('Y-m-d\TH:i:s.v'), + $end->format('Y-m-d\TH:i:s.v'), + ]; + } + + /** + * @return DatePeriod + * @throws Exception + */ + private function dateRange(string $date, string $date_to): DatePeriod + { + $start = $this->parseDate($date)->setTime(0, 0, 0); + $end = $this->parseDate($date_to)->setTime(0, 0, 0)->add(new DateInterval('P1D')); + return new DatePeriod($start, new DateInterval('P1D'), $end); + } + + /** + * @throws Exception + */ + private function parseDate(string $date): DateTimeImmutable + { + $parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date, $this->timezone); + if ($parsed === false) { + throw new Exception('Invalid date: ' . $date); + } + + return $parsed; + } + + private function parseTimestamp(mixed $value): ?DateTimeImmutable + { + $normalized = $this->normalizeTimestamp($value); + if ($normalized === null) { + return null; + } + + try { + return new DateTimeImmutable($normalized, $this->timezone); + } catch (Exception) { + return null; + } + } + + private function normalizeTimestamp(mixed $value): ?string + { + if (!is_string($value) && !is_numeric($value)) { + return null; + } + + $candidate = trim((string)$value); + if ($candidate === '') { + return null; + } + + try { + return (new DateTimeImmutable($candidate, $this->timezone))->setTimezone($this->timezone)->format('Y-m-d H:i:s'); + } catch (Exception) { + return null; + } + } + + private function normalizeDepartmentName(string $name): string + { + $normalized = mb_strtolower(trim($name), 'UTF-8'); + $normalized = str_replace( + ['æ', 'ø', 'å', 'ä', 'ö', 'ü'], + ['ae', 'oe', 'aa', 'ae', 'oe', 'ue'], + $normalized + ); + $normalized = preg_replace('/[^a-z0-9]+/u', '', $normalized) ?? ''; + return $normalized; + } + + /** + * Hall names are typically stored as DepartmentName_Lane. + */ + private function resolveDepartmentIdFromHall(string $hall, array $department_name_lookup): int + { + $hall = trim($hall); + if ($hall === '') { + return 0; + } + + $department_name = explode('_', $hall)[0] ?? ''; + $normalized_name = $this->normalizeDepartmentName($department_name); + + return (int)($department_name_lookup[$normalized_name] ?? 0); + } +} diff --git a/services/nginx/app/classes/departments_schema_bootstrap.php b/services/nginx/app/classes/departments_schema_bootstrap.php new file mode 100644 index 00000000..c6e0eed6 --- /dev/null +++ b/services/nginx/app/classes/departments_schema_bootstrap.php @@ -0,0 +1,89 @@ +query( + "ALTER TABLE departments + ADD COLUMN archived TINYINT(1) NOT NULL DEFAULT 0 + AFTER visible" + ); + } + + if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) { + $db->query( + "ALTER TABLE departments + ADD INDEX " . self::ARCHIVED_INDEX . " (archived)" + ); + } + + self::$initialized = true; + } + + private static function tableExists(object $db, string $table): bool + { + $table = self::escapeIdentifier($table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function columnExists(object $db, string $table, string $column): bool + { + $table = self::escapeIdentifier($table); + $column = self::escapeIdentifier($column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function indexExists(object $db, string $table, string $index): bool + { + $table = self::escapeIdentifier($table); + $index = self::escapeIdentifier($index); + $result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function escapeIdentifier(string $value): string + { + return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); + } +} diff --git a/services/nginx/app/classes/economic.php b/services/nginx/app/classes/economic.php index 1cf124c9..5a44b1e9 100644 --- a/services/nginx/app/classes/economic.php +++ b/services/nginx/app/classes/economic.php @@ -29,6 +29,9 @@ 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.'; + public const DEFAULT_DISTRIBUTION_DEPARTMENT_ID = 1; + /** * Configuration of the economic module * @var economic_c @@ -120,9 +123,59 @@ class economic implements economic_i return new $this->helpers->economic_tasks(); } - public function createCustomer(int $customer_number, string $name, int $cvr_number, string $email, int $phone): economic_customer + public function getTransactionDraftCustomerNumber(): ?int { - $result = $this->customers->customers->create([ + $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 getDefaultDistributionDepartmentId(): int + { + $value = $this->config->default_department_id->getVariableValue(); + $department_id = (int)$value; + + return $department_id > 0 ? $department_id : self::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; + } + + 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. + */ + public function createCustomer( + int $customer_number, + string $name, + int $cvr_number, + string $email, + int $phone, + ?int $mobile_phone = null, + object|array|null $company_information = null + ): object + { + $payload = [ 'customerNumber' => $customer_number, 'corporateIdentificationNumber' => (string)$cvr_number, 'customerGroup' => [ @@ -134,13 +187,60 @@ class economic implements economic_i 'name' => $name, 'email' => $email, 'phone' => $phone, + 'telephoneAndFaxNumber' => (string)$phone, + 'mobilePhone' => (string)($mobile_phone ?? $phone), 'currency' => 'DKK', 'vatZone' => [ 'vatZoneNumber' => 1, ] - ]); + ]; - return new $this->helpers->economic_customer($customer_number); + $payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information)); + + return $this->customers->customers->create($payload); + } + + private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array + { + if ($company_information === null) { + return []; + } + + $payload = []; + $field_map = [ + 'address' => 'address', + 'zipcode' => 'zip', + 'city' => 'city', + 'website' => 'website', + ]; + + foreach ($field_map as $source_field => $economic_field) { + $value = $this->companyInformationValue($company_information, $source_field); + if ($value === null) { + continue; + } + + $payload[$economic_field] = $value; + } + + return $payload; + } + + private function companyInformationValue(object|array $company_information, string $field): ?string + { + if (is_array($company_information)) { + $value = $company_information[$field] ?? null; + } else { + $value = $company_information->{$field} ?? null; + } + + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + + return $normalized !== '' ? $normalized : null; } /** @@ -153,4 +253,4 @@ class economic implements economic_i // Return the booked invoice helper return (new economic_invoice_booked($raw)); } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/economic_transfer_executor.php b/services/nginx/app/classes/economic_transfer_executor.php new file mode 100644 index 00000000..3b1d75c3 --- /dev/null +++ b/services/nginx/app/classes/economic_transfer_executor.php @@ -0,0 +1,442 @@ +getOrderById($order_id); + if (!$order->exists()) { + throw new Exception('Order not found'); + } + + $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); + $invoice_id = (int)$economic_module_orders->economic_invoice_id->value(); + if ($invoice_id > 0) { + throw new Exception('An invoice has already been created, invoice ID: ' . $invoice_id); + } + + $order_items = (new orders_o())->getOrderItems($order_id); + $order_items = (new orders_o())->applyDepartmentPrices($order_items, $order->department_id->value()); + if (count($order_items) === 0) { + throw new Exception('No order items found'); + } + + $customer = (new orders_o())->getCustomerByOrderId($order_id); + 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(); + $economic_invoice_draft->setCustomerNumber((int)$customer_economic->customer_number); + $economic_invoice_draft->setRecipient( + $customer_economic->name ?? 'Ukendt', + $customer_economic->address ?? 'Ukendt', + $customer_economic->zip ?? 'Ukendt', + $customer_economic->city ?? 'Ukendt' + ); + + $department = (new departments_o())->getDepartmentById($order->department_id->value()); + $this->addTheDepartmentDateReference($economic_invoice_draft, $department['name'], $order); + + $billable_order_items = 0; + foreach ($order_items as $order_item) { + $added = $this->addOrderItemToInvoice( + $customer, + $order, + $order_item, + $economic_invoice_draft, + (int)($order_item['quantity'] ?? 1) + ); + if ($added) { + $billable_order_items++; + } + } + + if ($billable_order_items < 1) { + throw new Exception('No billable order items found'); + } + + $result = null; + if ($customer->hasOpenInvoiceDraft() && !$customer->invoicePerOrder()) { + $open_invoice_draft = (int)$customer->getOpenInvoiceDraft(); + $result = $this->addOrderToInvoiceDraft($open_invoice_draft, $order, $customer, $order_items); + } + if ($result === null) { + $result = $economic_invoice_draft->createInvoiceDraftExample(); + } + + if (!isset($result->draftInvoiceNumber) && !isset($result->lines[0])) { + (new logs_o())->add( + 'economic_invoice_draft', + 'global', + 3, + $user_id, + 'ECONOMIC_INVOICE_DRAFT_EXPORT', + 'Failed to create economic invoice draft' + ); + $message = $result->message ?? 'Failed to create economic invoice draft'; + throw new Exception((string)$message); + } + + if ($economic_module_orders->economic_invoice_draft_id->value() > 0) { + $economic_invoice_draft->deleteInvoiceDraft($economic_module_orders->economic_invoice_draft_id->value()); + } + + $new_draft_id = (int)($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft()); + $economic_module_orders->economic_invoice_draft_id->set($new_draft_id); + if (!$customer->invoicePerOrder()) { + $customer->setOpenInvoiceDraft($new_draft_id); + } else { + $customer->unsetOpenInvoiceDraft(); + } + + (new logs_o())->add( + 'economic_invoice_draft', + 'global', + 1, + $user_id, + 'ECONOMIC_INVOICE_DRAFT_EXPORT', + 'Successfully exported an economic invoice draft' + ); + + return $economic_module_orders->getArray(); + } + + /** + * Export booked invoice from existing draft. + * @throws Exception + */ + 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'); + } + + $customer = (new orders_o())->getCustomerByOrderId($order_id); + 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) { + throw new Exception('No economic invoice draft found'); + } + + $invoice_id = (int)$economic_module_orders->economic_invoice_id->value(); + if ($invoice_id > 0) { + throw new Exception('An invoice has already been created, invoice ID: ' . $invoice_id); + } + + $invoice_draft_id = (int)$economic_module_orders->economic_invoice_draft_id->value(); + $economic_invoice_draft = new economic_invoice_draft_mo(); + $result = $economic_invoice_draft->publishInvoiceDraft($invoice_draft_id); + + if (!isset($result->bookedInvoiceNumber)) { + (new logs_o())->add( + 'economic_invoice', + 'global', + 3, + $user_id, + 'ECONOMIC_INVOICE_EXPORT', + 'Failed to create economic invoice from draft: ' . $invoice_draft_id + ); + $message = $result->message ?? 'Failed to create economic invoice'; + throw new Exception((string)$message); + } + + $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); + $economic_module_orders->economic_invoice_id->set((int)$result->bookedInvoiceNumber); + $customer->unsetOpenInvoiceDraft(); + + (new logs_o())->add( + 'economic_invoice', + 'global', + 1, + $user_id, + 'ECONOMIC_INVOICE_EXPORT', + 'Successfully exported an economic invoice' + ); + + return $economic_module_orders->getArray(); + } + + /** + * Export collected invoice to e-conomic. + * @throws Exception + */ + public function exportCollectedInvoice(int $collected_invoice_id, bool $send_as_is = false, int $user_id = 0): array + { + $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) { + $customer_fixed_pricing_o = new customer_fixed_pricing_o(); + if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { + $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); + $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); + $collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price); + } else { + $collected_order_invoices->addVehicleSubscriptionsTransaction(); + } + } else { + $collected_order_invoices->removeSpecialArrangements(); + $collected_order_invoices->setAllItemsToBeIncludedInInvoice(); + } + + $collected_order_invoices->addToEconomic(); + } else { + if ($collected_order_invoices->booked_invoice_id->value() !== null) { + throw new Exception('Invoice has already been booked'); + } + if ($collected_order_invoices->isDraftExisting()) { + throw new Exception('Invoice draft already exists in E-Conomic'); + } + + $customer_fixed_pricing_o = new customer_fixed_pricing_o(); + if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { + $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); + $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); + $collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price); + } else { + $collected_order_invoices->addVehicleSubscriptionsTransaction(); + } + + $collected_order_invoices->addToEconomic(true); + } + + (new logs_o())->add( + 'orderInvoices', + 'global', + 1, + $user_id, + 'ADD_COLLECTED_INVOICE_ECONOMIC', + 'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id + ); + + return $collected_order_invoices->asArray(); + } + + /** + * @throws Exception + */ + public function addTheDepartmentDateReference(economic_invoice_draft_mo $economic_invoice_draft, mixed $department_name, orders_o $order): void + { + $parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value())); + $economic_invoice_draft->addLineTEXT("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]"); + if ($order->reference->value() !== '') { + $economic_invoice_draft->addLineTEXT('Reference:'); + if (str_contains($order->reference->value(), "\n")) { + foreach (explode("\n", $order->reference->value()) as $line) { + $economic_invoice_draft->addLineTEXT('# ' . $line); + } + } else { + $economic_invoice_draft->addLineTEXT('# ' . $order->reference->value()); + } + } + + $line_reg = ''; + if ($order->reg_1->value() !== '') { + $line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value()); + } + if ($order->reg_2->value() !== '') { + $line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value()); + } + if ($order->reg_3->value() !== '') { + $line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value()); + } + $economic_invoice_draft->addLineTEXT($line_reg); + + if ($order->notes->value() !== '') { + $economic_invoice_draft->addLineTEXT('Notat:'); + if (str_contains($order->notes->value(), "\n")) { + foreach (explode("\n", $order->notes->value()) as $line) { + $economic_invoice_draft->addLineTEXT('# ' . $line); + } + } else { + $economic_invoice_draft->addLineTEXT('# ' . $order->notes->value()); + } + } + } + + /** + * Returns true when a product line was added, false when skipped. + * @throws Exception + */ + public function addOrderItemToInvoice( + users_o $customer, + orders_o $order, + mixed $order_item, + economic_invoice_draft_mo $economic_invoice_draft, + int $quantity = 1 + ): bool { + if (self::shouldSkipOrderItemForInvoice($order_item, $quantity)) { + return false; + } + + if (!is_array($order_item)) { + throw new Exception('Order item payload must be an array'); + } + + $product_number = trim((string)($order_item['product']['economic_product_id'] ?? '')); + if ($product_number === '') { + throw new Exception('Order item is missing economic product id'); + } + + $product_name = trim((string)($order_item['product']['name'] ?? '')); + if ($product_name === '') { + $product_name = 'Ukendt produkt'; + } + + $reference = isset($order_item['reference']) ? (string)$order_item['reference'] : ''; + $notes = isset($order_item['notes']) ? (string)$order_item['notes'] : ''; + + $department = $order->getDepartmentByOrderId($order->id); + $economic_department_id = $department['economic_department_id']; + $economic_dimension_id = $department['economic_dimension_id'] ?? 0; + $order_item_price = (float)($order_item['price'] ?? 0); + $product_price = (float)($order_item['product']['price'] ?? 0); + + $economic_invoice_draft->addLine( + $product_number, + $product_name, + $quantity, + $order_item_price, + 0, + (int)$economic_department_id ?? 0, + (int)$economic_dimension_id ?? 0 + ); + + $show_discount = abs($order_item_price - $product_price) > 0.00001; + if ($show_discount && abs($product_price) > 0.00001) { + $discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 0); + $economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item_price - $product_price) . ' DKK (' . $discount_percentage . '%)'); + } + + if ($reference !== '') { + $economic_invoice_draft->addLineTEXT('Reference:'); + if (str_contains($reference, "\n")) { + foreach (explode("\n", $reference) as $line) { + $economic_invoice_draft->addLineTEXT('# ' . $line); + } + } else { + $economic_invoice_draft->addLineTEXT('# ' . $reference); + } + } + + if ($customer->doesUserHaveAttribute('requiresRegistrationNumbersInvoice')) { + $line_reg = ''; + if ($order->reg_1->value() !== '') { + $line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value()); + } + if ($order->reg_2->value() !== '') { + $line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value()); + } + if ($order->reg_3->value() !== '') { + $line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value()); + } + $economic_invoice_draft->addLineTEXT($line_reg); + } + + if ($notes !== '') { + $economic_invoice_draft->addLineTEXT('Notat:'); + if (str_contains($notes, "\n")) { + foreach (explode("\n", $notes) as $line) { + $economic_invoice_draft->addLineTEXT('# ' . $line); + } + } else { + $economic_invoice_draft->addLineTEXT('# ' . $notes); + } + } + + return true; + } + + /** + * @throws Exception + */ + public function addOrderToInvoiceDraft(int $economic_invoice_draft_id, orders_o $order, users_o $customer, array $order_items): object + { + $has_billable_items = false; + foreach ($order_items as $order_item) { + if (!self::shouldSkipOrderItemForInvoice($order_item, (int)($order_item['quantity'] ?? 1))) { + $has_billable_items = true; + break; + } + } + if (!$has_billable_items) { + throw new Exception('No billable order items found'); + } + + $economic_invoice_draft = new economic_invoice_draft_mo(); + $economic_invoice_draft->addLineTEXT(''); + $economic_invoice_draft->addLineTEXT(''); + $this->addTheDepartmentDateReference($economic_invoice_draft, $order->getDepartmentByOrderId($order->id)['name'], $order); + + foreach ($order_items as $order_item) { + $this->addOrderItemToInvoice($customer, $order, $order_item, $economic_invoice_draft, (int)($order_item['quantity'] ?? 1)); + } + + return $economic_invoice_draft->addLinesToInvoiceDraft($economic_invoice_draft_id); + } + + /** + * Skip item lines that are not billable in e-conomic export (0 quantity or 0 unit cost). + */ + public static function shouldSkipOrderItemForInvoice(mixed $order_item, int $quantity = 1): bool + { + if (!is_array($order_item)) { + return true; + } + + $line_quantity = $quantity; + if (isset($order_item['quantity']) && is_numeric($order_item['quantity'])) { + $line_quantity = (int)$order_item['quantity']; + } + + if ($line_quantity <= 0) { + return true; + } + + $line_price = null; + if (isset($order_item['price']) && is_numeric($order_item['price'])) { + $line_price = (float)$order_item['price']; + } + + if ($line_price === null) { + return true; + } + + if (abs($line_price) < 0.00001) { + return true; + } + + return false; + } +} diff --git a/services/nginx/app/classes/economic_transfer_queue.php b/services/nginx/app/classes/economic_transfer_queue.php new file mode 100644 index 00000000..48392921 --- /dev/null +++ b/services/nginx/app/classes/economic_transfer_queue.php @@ -0,0 +1,867 @@ +executor = $executor ?? new economic_transfer_executor(); + economic_transfer_queue_schema_bootstrap::ensureTables(); + } + + /** + * @throws Exception + */ + public function enqueue(string $transfer_type, array $payload, int $created_by = 0, int $max_attempts = 3): array + { + global $db; + + $created_by = max(0, $created_by); + $max_attempts = max(1, min(10, $max_attempts)); + $transfer_type = $this->validateTransferType($transfer_type); + $payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by); + + $active_job = $this->findActiveJobByTarget($transfer_type, $payload); + if ($active_job !== null) { + $target_label = $this->buildTargetLabel($transfer_type, $payload); + $this->logQueueEvent( + 1, + $created_by, + 'ECONOMIC_TRANSFER_JOB_DEDUPED', + 'Transfer job deduped for active target (' . $target_label . '), returning existing job #' . (int)($active_job['id'] ?? 0) + ); + return $active_job; + } + + $payload_json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($payload_json === false) { + throw new Exception('Failed to serialize queue payload'); + } + + $stmt = $db->prepare( + "INSERT INTO economic_transfer_queue_jobs + (transfer_type, payload_json, status, progress_percent, progress_message, attempts, max_attempts, created_by) + VALUES (?, ?, ?, 0, 'Queued', 0, ?, ?)" + ); + if (!$stmt) { + throw new Exception('Failed to prepare queue insert statement'); + } + + $status = self::STATUS_QUEUED; + $stmt->bind_param('sssii', $transfer_type, $payload_json, $status, $max_attempts, $created_by); + if (!$stmt->execute()) { + throw new Exception('Failed to enqueue transfer job'); + } + $job_id = (int)$db->insert_id(); + $stmt->close(); + + $this->logQueueEvent( + 1, + $created_by, + 'ECONOMIC_TRANSFER_JOB_ENQUEUED', + 'Transfer job #' . $job_id . ' queued: ' . $transfer_type + ); + + $job = $this->getJobById($job_id); + if ($job === null) { + throw new Exception('Failed to load queued transfer job'); + } + return $job; + } + + public function getJobById(int $job_id): ?array + { + global $db; + + $stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? LIMIT 1"); + if (!$stmt) { + return null; + } + + $stmt->bind_param('i', $job_id); + if (!$stmt->execute()) { + $stmt->close(); + return null; + } + + $result = $stmt->get_result(); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + $stmt->close(); + + if (!$row) { + return null; + } + return $this->normalizeJobRow($row); + } + + public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array + { + global $db; + + $limit = max(1, min(500, $limit)); + $offset = max(0, $offset); + + $where = $this->buildListJobsWhereClause($statuses, $transfer_type); + $sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset"; + $result = $db->query($sql); + if (!$result instanceof mysqli_result) { + return []; + } + + $jobs = []; + while ($row = $result->fetch_assoc()) { + $jobs[] = $this->normalizeJobRow($row); + } + return $jobs; + } + + public function countJobs(array $statuses = [], ?string $transfer_type = null): int + { + global $db; + + $where = $this->buildListJobsWhereClause($statuses, $transfer_type); + $sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where"; + $result = $db->query($sql); + if (!$result instanceof mysqli_result) { + return 0; + } + + $row = $result->fetch_assoc(); + if (!is_array($row) || !isset($row['total'])) { + return 0; + } + + return max(0, (int)$row['total']); + } + + public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array + { + global $db; + + $user_id = max(0, $user_id); + $limit = max(1, min(100, $limit)); + try { + $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' + ? $this->validateTransferType($transfer_type) + : null; + } catch (Exception) { + return []; + } + + $transfer_condition = ''; + if ($normalized_transfer_type !== null) { + $transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; + } + + $sql = "SELECT q.* + FROM economic_transfer_queue_jobs q + LEFT JOIN economic_transfer_queue_job_dismissals d + ON d.queue_job_id = q.id + AND d.user_id = $user_id + AND d.dismissed_status = q.status + WHERE 1 = 1 + $transfer_condition + AND ( + q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') + OR d.queue_job_id IS NULL + ) + ORDER BY + CASE WHEN q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') THEN 0 ELSE 1 END, + q.id DESC + LIMIT $limit"; + $result = $db->query($sql); + if (!$result instanceof mysqli_result) { + return []; + } + + $jobs = []; + while ($row = $result->fetch_assoc()) { + $jobs[] = $this->normalizeJobRow($row); + } + return $jobs; + } + + /** + * @throws Exception + */ + public function dismissTerminalJobForUser(int $job_id, int $user_id): array + { + global $db; + + $job_id = max(0, $job_id); + $user_id = max(0, $user_id); + if ($job_id < 1 || $user_id < 1) { + throw new Exception('Queue job and user are required'); + } + + $job = $this->getJobById($job_id); + if ($job === null) { + throw new Exception('Queue job not found'); + } + + $status = strtoupper((string)($job['status'] ?? '')); + if (!in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) { + throw new Exception('Only completed or failed queue jobs can be dismissed'); + } + + $stmt = $db->prepare( + "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()" + ); + if (!$stmt) { + throw new Exception('Failed to prepare queue dismissal statement'); + } + + $stmt->bind_param('iis', $job_id, $user_id, $status); + if (!$stmt->execute()) { + $stmt->close(); + throw new Exception('Failed to dismiss queue job'); + } + $stmt->close(); + + return $job; + } + + public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int + { + global $db; + + $user_id = max(0, $user_id); + if ($user_id < 1) { + return 0; + } + + try { + $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' + ? $this->validateTransferType($transfer_type) + : null; + } catch (Exception) { + return 0; + } + + $transfer_condition = ''; + if ($normalized_transfer_type !== null) { + $transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; + } + + $sql = "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at) + SELECT q.id, $user_id, q.status, NOW() + FROM economic_transfer_queue_jobs q + LEFT JOIN economic_transfer_queue_job_dismissals d + ON d.queue_job_id = q.id + AND d.user_id = $user_id + AND d.dismissed_status = q.status + WHERE q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "') + $transfer_condition + AND d.queue_job_id IS NULL + ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()"; + $db->query($sql); + return max(0, (int)($db->affected_rows ?? 0)); + } + + /** + * @throws Exception + */ + public function retryJob(int $job_id): array + { + global $db; + + $existing_job = $this->getJobById($job_id); + if ($existing_job === null) { + throw new Exception('Queue job not found'); + } + if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) { + throw new Exception('Only failed jobs can be retried'); + } + if ((int)($existing_job['attempts'] ?? 0) >= (int)($existing_job['max_attempts'] ?? 1)) { + throw new Exception('Queue job reached max retry attempts'); + } + + $stmt = $db->prepare( + "UPDATE economic_transfer_queue_jobs + SET status = ?, progress_percent = 0, progress_message = 'Queued for retry', + error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL + WHERE id = ? AND status = ?" + ); + if (!$stmt) { + throw new Exception('Failed to prepare retry statement'); + } + + $queued = self::STATUS_QUEUED; + $failed = self::STATUS_FAILED; + $stmt->bind_param('sis', $queued, $job_id, $failed); + $stmt->execute(); + $affected = $stmt->affected_rows; + $stmt->close(); + + if ($affected < 1) { + throw new Exception('Failed to retry queue job'); + } + + $this->clearDismissalsForJob($job_id); + + $job = $this->getJobById($job_id); + if ($job === null) { + throw new Exception('Retry updated job could not be loaded'); + } + return $job; + } + + public function processPending(int $limit = 5): array + { + return $this->processPendingInternal($limit); + } + + /** + * @throws Exception + */ + public function processPendingByTransferType(string $transfer_type, int $limit = 10): array + { + return $this->processPendingInternal($limit, $this->validateTransferType($transfer_type)); + } + + private function processPendingInternal(int $limit = 5, ?string $transfer_type = null): array + { + $limit = max(1, min(100, $limit)); + $this->releaseStaleProcessingLocks(); + + $processed = 0; + $completed = 0; + $failed = 0; + $jobs = []; + $empty_claims = 0; + + for ($i = 0; $i < $limit; $i++) { + $job = $this->claimNextJob($transfer_type); + if ($job === null) { + $empty_claims++; + if ($empty_claims >= 3) { + break; + } + continue; + } + $empty_claims = 0; + + $processed++; + $jobs[] = $job['id']; + $this->updateProgress((int)$job['id'], 15, 'Running transfer'); + + try { + $result = $this->executeJob($job); + $this->markCompleted((int)$job['id'], $result); + $completed++; + } catch (Exception $e) { + $this->markFailed((int)$job['id'], $e->getMessage()); + $failed++; + } catch (\Throwable $e) { + $this->markFailed((int)$job['id'], $e->getMessage()); + $failed++; + } + } + + return [ + 'processed' => $processed, + 'completed' => $completed, + 'failed' => $failed, + 'jobs' => $jobs, + ]; + } + + private function claimNextJob(?string $transfer_type = null): ?array + { + global $db; + + $sql = "SELECT id + FROM economic_transfer_queue_jobs + WHERE status = ? + AND attempts < max_attempts + AND (next_retry_at IS NULL OR next_retry_at <= NOW())"; + if ($transfer_type !== null) { + $sql .= " AND transfer_type = ?"; + } + $sql .= " ORDER BY id ASC LIMIT 1"; + + $stmt = $db->prepare($sql); + if (!$stmt) { + return null; + } + + $queued = self::STATUS_QUEUED; + if ($transfer_type !== null) { + $stmt->bind_param('ss', $queued, $transfer_type); + } else { + $stmt->bind_param('s', $queued); + } + + if (!$stmt->execute()) { + $stmt->close(); + return null; + } + + $result = $stmt->get_result(); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + $stmt->close(); + if (!$row || !isset($row['id'])) { + return null; + } + + $job_id = (int)$row['id']; + $stmt = $db->prepare( + "UPDATE economic_transfer_queue_jobs + SET status = ?, progress_percent = 5, progress_message = 'Processing', started_at = NOW(), locked_at = NOW() + WHERE id = ? AND status = ?" + ); + if (!$stmt) { + return null; + } + + $processing = self::STATUS_PROCESSING; + $stmt->bind_param('sis', $processing, $job_id, $queued); + $stmt->execute(); + $affected = $stmt->affected_rows; + $stmt->close(); + + if ($affected < 1) { + return null; + } + + return $this->getJobById($job_id); + } + + /** + * @throws Exception + */ + private function executeJob(array $job): array + { + $payload = (array)($job['payload'] ?? []); + $transfer_type = (string)($job['transfer_type'] ?? ''); + $requested_by = (int)($payload['requested_by'] ?? ($job['created_by'] ?? 0)); + + $this->updateProgress((int)$job['id'], 40, 'Validating job payload'); + + return match ($transfer_type) { + self::TYPE_ORDER_DRAFT_EXPORT => $this->executeOrderDraftExport($job, $payload, $requested_by), + self::TYPE_ORDER_INVOICE_EXPORT => $this->executeOrderInvoiceExport($job, $payload, $requested_by), + self::TYPE_COLLECTED_INVOICE_EXPORT => $this->executeCollectedInvoiceExport($job, $payload, $requested_by), + default => throw new Exception('Unsupported transfer type: ' . $transfer_type), + }; + } + + /** + * @throws Exception + */ + private function executeOrderDraftExport(array $job, array $payload, int $requested_by): array + { + $order_id = (int)($payload['order_id'] ?? 0); + if ($order_id < 1) { + throw new Exception('order_id is required'); + } + $this->updateProgress((int)$job['id'], 65, 'Exporting order draft invoice'); + return $this->executor->exportOrderDraftInvoice($order_id, $requested_by); + } + + /** + * @throws Exception + */ + private function executeOrderInvoiceExport(array $job, array $payload, int $requested_by): array + { + $order_id = (int)($payload['order_id'] ?? 0); + if ($order_id < 1) { + throw new Exception('order_id is required'); + } + $this->updateProgress((int)$job['id'], 65, 'Exporting booked invoice'); + return $this->executor->exportOrderInvoice($order_id, $requested_by); + } + + /** + * @throws Exception + */ + private function executeCollectedInvoiceExport(array $job, array $payload, int $requested_by): array + { + $collected_invoice_id = (int)($payload['collected_invoice_id'] ?? 0); + if ($collected_invoice_id < 1) { + throw new Exception('collected_invoice_id is required'); + } + $send_as_is = (bool)($payload['send_as_is'] ?? false); + $this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice'); + return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by); + } + + private function updateProgress(int $job_id, int $percent, string $message): void + { + global $db; + + $percent = max(0, min(100, $percent)); + $escaped_message = $db->escape_string($message); + $sql = "UPDATE economic_transfer_queue_jobs + SET progress_percent = $percent, progress_message = '$escaped_message' + WHERE id = $job_id"; + $db->query($sql); + } + + private function markCompleted(int $job_id, array $result): void + { + global $db; + + $result_json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($result_json === false) { + $result_json = json_encode(['result' => 'serialization_error']); + } + $escaped_result = $db->escape_string((string)$result_json); + + $sql = "UPDATE economic_transfer_queue_jobs + SET status = '" . self::STATUS_COMPLETED . "', + progress_percent = 100, + progress_message = 'Completed', + result_json = '$escaped_result', + error_message = NULL, + completed_at = NOW(), + locked_at = NULL + WHERE id = $job_id"; + $db->query($sql); + + $this->logQueueEvent( + 1, + 0, + 'ECONOMIC_TRANSFER_JOB_COMPLETED', + 'Transfer job #' . $job_id . ' completed' + ); + } + + private function markFailed(int $job_id, string $error_message): void + { + global $db; + + $error_message = trim($error_message); + if ($error_message === '') { + $error_message = 'Unknown transfer queue error'; + } + $escaped_error = $db->escape_string($error_message); + + $sql = "UPDATE economic_transfer_queue_jobs + SET status = '" . self::STATUS_FAILED . "', + progress_message = 'Failed', + error_message = '$escaped_error', + attempts = attempts + 1, + completed_at = NOW(), + locked_at = NULL + WHERE id = $job_id"; + $db->query($sql); + + $this->logQueueEvent( + 3, + 0, + 'ECONOMIC_TRANSFER_JOB_FAILED', + 'Transfer job #' . $job_id . ' failed: ' . $error_message + ); + } + + private function decodeJsonValue(mixed $json): mixed + { + if (!is_string($json) || trim($json) === '') { + return null; + } + $decoded = json_decode($json, true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } + + private function normalizeJobRow(array $row): array + { + return [ + 'id' => (int)$row['id'], + 'transfer_type' => (string)($row['transfer_type'] ?? ''), + 'status' => (string)($row['status'] ?? self::STATUS_QUEUED), + 'progress_percent' => (int)($row['progress_percent'] ?? 0), + 'progress_message' => $row['progress_message'] ?? null, + 'attempts' => (int)($row['attempts'] ?? 0), + 'max_attempts' => (int)($row['max_attempts'] ?? 0), + 'error_message' => $row['error_message'] ?? null, + 'payload' => $this->decodeJsonValue($row['payload_json'] ?? null), + 'result' => $this->decodeJsonValue($row['result_json'] ?? null), + 'created_by' => isset($row['created_by']) ? (int)$row['created_by'] : null, + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + 'started_at' => $row['started_at'] ?? null, + 'completed_at' => $row['completed_at'] ?? null, + 'next_retry_at' => $row['next_retry_at'] ?? null, + ]; + } + + private function clearDismissalsForJob(int $job_id): void + { + global $db; + + $job_id = max(0, $job_id); + if ($job_id < 1) { + return; + } + + $db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id"); + } + + /** + * Release jobs stuck in PROCESSING due to crashes or killed workers. + */ + private function releaseStaleProcessingLocks(): void + { + global $db; + + $timeout = (int)self::STALE_PROCESSING_LOCK_SECONDS; + $sql = "UPDATE economic_transfer_queue_jobs + SET status = '" . self::STATUS_QUEUED . "', + progress_message = 'Re-queued after stale processing lock', + locked_at = NULL, + started_at = NULL + WHERE status = '" . self::STATUS_PROCESSING . "' + AND locked_at IS NOT NULL + AND locked_at < (NOW() - INTERVAL $timeout SECOND)"; + $db->query($sql); + } + + /** + * @throws Exception + */ + private function normalizePayloadForTransferType(string $transfer_type, array $payload, int $created_by): array + { + $normalized_payload = $payload; + if (isset($normalized_payload['requested_by'])) { + $normalized_payload['requested_by'] = max(0, (int)$normalized_payload['requested_by']); + } + + return match ($transfer_type) { + self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->normalizeOrderPayload($normalized_payload, $created_by), + self::TYPE_COLLECTED_INVOICE_EXPORT => $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by), + default => $this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type), + }; + } + + /** + * @throws Exception + */ + private function normalizeOrderPayload(array $payload, int $created_by): array + { + $order_id = $payload['order_id'] ?? null; + if ($order_id === null || !is_numeric($order_id) || (int)$order_id < 1) { + return $this->rejectPayload($created_by, 'order_id is required and must be a positive number'); + } + $payload['order_id'] = (int)$order_id; + return $payload; + } + + /** + * @throws Exception + */ + private function normalizeCollectedInvoicePayload(array $payload, int $created_by): array + { + $collected_invoice_id = $payload['collected_invoice_id'] ?? null; + if ($collected_invoice_id === null || !is_numeric($collected_invoice_id) || (int)$collected_invoice_id < 1) { + return $this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number'); + } + + $payload['collected_invoice_id'] = (int)$collected_invoice_id; + $payload['send_as_is'] = $this->normalizeBooleanPayloadValue($payload['send_as_is'] ?? false, 'send_as_is', $created_by); + return $payload; + } + + /** + * @throws Exception + */ + private function normalizeBooleanPayloadValue(mixed $value, string $field_name, int $created_by): bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { + $numeric = (int)$value; + if ($numeric === 0 || $numeric === 1) { + return $numeric === 1; + } + return $this->rejectPayload($created_by, $field_name . ' must be a boolean'); + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['true', 'false', '1', '0'], true)) { + return in_array($normalized, ['true', '1'], true); + } + return $this->rejectPayload($created_by, $field_name . ' must be a boolean'); + } + + return $this->rejectPayload($created_by, $field_name . ' must be a boolean'); + } + + private function findActiveJobByTarget(string $transfer_type, array $payload): ?array + { + return match ($transfer_type) { + self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget( + $transfer_type, + '$.order_id', + (int)($payload['order_id'] ?? 0) + ), + self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget( + $transfer_type, + '$.collected_invoice_id', + (int)($payload['collected_invoice_id'] ?? 0) + ), + default => null, + }; + } + + private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array + { + global $db; + + if ($target_value < 1) { + return null; + } + + $stmt = $db->prepare( + "SELECT id + FROM economic_transfer_queue_jobs + WHERE transfer_type = ? + AND status IN (?, ?) + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ? + ORDER BY id DESC + LIMIT 1" + ); + if (!$stmt) { + return null; + } + + $queued = self::STATUS_QUEUED; + $processing = self::STATUS_PROCESSING; + $stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value); + if (!$stmt->execute()) { + $stmt->close(); + return null; + } + + $result = $stmt->get_result(); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + $stmt->close(); + if (!$row || !isset($row['id'])) { + return null; + } + + return $this->getJobById((int)$row['id']); + } + + private function buildTargetLabel(string $transfer_type, array $payload): string + { + return match ($transfer_type) { + self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => 'order_id=' . (int)($payload['order_id'] ?? 0), + self::TYPE_COLLECTED_INVOICE_EXPORT => 'collected_invoice_id=' . (int)($payload['collected_invoice_id'] ?? 0), + default => 'unknown', + }; + } + + /** + * @throws Exception + */ + private function rejectPayload(int $created_by, string $message): never + { + $this->logQueueEvent( + 3, + max(0, $created_by), + 'ECONOMIC_TRANSFER_JOB_VALIDATION_REJECTED', + 'Transfer job enqueue rejected: ' . $message + ); + throw new Exception($message); + } + + private function logQueueEvent(int $status_code, int $user_id, string $event, string $message): void + { + try { + (new logs_o())->add( + 'economic_transfer_queue', + 'global', + $status_code, + $user_id, + $event, + $message + ); + } catch (\Throwable) { + // Logging is best effort for queue operations. + } + } + + /** + * @throws Exception + */ + private function validateTransferType(string $transfer_type): string + { + $transfer_type = strtoupper(trim($transfer_type)); + if (!in_array($transfer_type, [ + self::TYPE_ORDER_DRAFT_EXPORT, + self::TYPE_ORDER_INVOICE_EXPORT, + self::TYPE_COLLECTED_INVOICE_EXPORT, + ], true)) { + throw new Exception('Unsupported transfer type: ' . $transfer_type); + } + return $transfer_type; + } + + private function sanitizeStatuses(array $statuses): array + { + return array_values(array_unique(array_filter(array_map(static function ($status): string { + return strtoupper(trim((string)$status)); + }, $statuses), static function ($status): bool { + return in_array($status, [ + self::STATUS_QUEUED, + self::STATUS_PROCESSING, + self::STATUS_COMPLETED, + self::STATUS_FAILED, + ], true); + }))); + } + + private function buildListJobsWhereClause(array $statuses = [], ?string $transfer_type = null): string + { + global $db; + + $conditions = []; + + $clean_statuses = $this->sanitizeStatuses($statuses); + if (!empty($clean_statuses)) { + $escaped_statuses = array_map(static function ($status) use ($db): string { + return "'" . $db->escape_string($status) . "'"; + }, $clean_statuses); + $conditions[] = 'status IN (' . implode(',', $escaped_statuses) . ')'; + } + + if ($transfer_type !== null && trim($transfer_type) !== '') { + try { + $normalized_transfer_type = $this->validateTransferType($transfer_type); + } catch (Exception) { + return 'WHERE 1 = 0'; + } + $conditions[] = "transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; + } + + if (empty($conditions)) { + return ''; + } + + return 'WHERE ' . implode(' AND ', $conditions); + } +} diff --git a/services/nginx/app/classes/economic_transfer_queue_details_summary.php b/services/nginx/app/classes/economic_transfer_queue_details_summary.php new file mode 100644 index 00000000..f0fbaae6 --- /dev/null +++ b/services/nginx/app/classes/economic_transfer_queue_details_summary.php @@ -0,0 +1,220 @@ + self::resolveMessage($job, $result), + 'target' => [ + 'collected_invoice_id' => $collected_invoice_id, + 'send_as_is' => self::toNullableBool($payload['send_as_is'] ?? null), + 'requested_by' => self::toNonNegativeInt($payload['requested_by'] ?? $job['created_by'] ?? null), + ], + 'customer' => [ + 'customer_number' => self::toPositiveInt( + $result['customer_number'] + ?? $result['user']['customer_number'] + ?? $payload['customer_number'] + ?? $payload['customer']['customer_number'] + ?? null + ), + 'name' => self::toNonEmptyString( + $result['customer_name'] + ?? $result['user']['customer_name'] + ?? $result['user']['display_name'] + ?? $result['user']['name'] + ?? $result['user']['company_name'] + ?? $payload['customer_name'] + ?? $payload['customer']['customer_name'] + ?? $payload['customer']['display_name'] + ?? $payload['customer']['name'] + ?? null + ), + ], + 'outcome' => [ + 'economic_invoice_draft_id' => self::toPositiveInt( + $result['economic_invoice_draft_id'] + ?? $result['draft_invoice_id'] + ?? null + ), + 'economic_invoice_booked_id' => self::toPositiveInt( + $result['economic_invoice_booked_id'] + ?? $result['booked_invoice_id'] + ?? null + ), + 'external_id' => self::toNonEmptyString($result['external_id'] ?? null), + 'total_net_amount' => self::toNullableFloat($result['total_net_amount'] ?? null), + 'order_count' => self::toOrderCount($result['orders'] ?? null), + ], + 'raw_available' => [ + 'payload' => self::hasRawValue($job['payload'] ?? null), + 'result' => self::hasRawValue($job['result'] ?? null), + ], + ]; + + return $summary; + } + + private static function resolveMessage(array $job, array $result): ?string + { + $error_message = self::toNonEmptyString($job['error_message'] ?? null); + if ($error_message !== null) { + return $error_message; + } + + $result_message = self::toNonEmptyString($result['message'] ?? null); + if ($result_message !== null) { + return $result_message; + } + + $progress_message = self::toNonEmptyString($job['progress_message'] ?? null); + if ($progress_message !== null) { + return $progress_message; + } + + $status = strtoupper(trim((string)($job['status'] ?? ''))); + + return match ($status) { + economic_transfer_queue::STATUS_COMPLETED => 'Completed', + economic_transfer_queue::STATUS_FAILED => 'Failed', + economic_transfer_queue::STATUS_PROCESSING => 'Processing', + economic_transfer_queue::STATUS_QUEUED => 'Queued', + default => null, + }; + } + + private static function toArray(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (is_object($value)) { + $decoded = json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true); + return is_array($decoded) ? $decoded : []; + } + + return []; + } + + private static function toPositiveInt(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + if (!is_numeric($value)) { + return null; + } + + $parsed = (int)$value; + return $parsed > 0 ? $parsed : null; + } + + private static function toNonNegativeInt(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + if (!is_numeric($value)) { + return null; + } + + $parsed = (int)$value; + return $parsed >= 0 ? $parsed : null; + } + + private static function toNullableBool(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + + if ($value === null) { + return null; + } + + if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { + $numeric = (int)$value; + if ($numeric === 0 || $numeric === 1) { + return $numeric === 1; + } + return null; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['true', '1'], true)) { + return true; + } + if (in_array($normalized, ['false', '0'], true)) { + return false; + } + } + + return null; + } + + private static function toNonEmptyString(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $trimmed = trim($value); + return $trimmed !== '' ? $trimmed : null; + } + + private static function toNullableFloat(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + + if (!is_numeric($value)) { + return null; + } + + return (float)$value; + } + + private static function toOrderCount(mixed $orders): ?int + { + if (!is_array($orders)) { + return null; + } + + return count($orders); + } + + private static function hasRawValue(mixed $value): bool + { + if ($value === null) { + return false; + } + + if (is_string($value)) { + return trim($value) !== ''; + } + + if (is_array($value) || is_object($value)) { + return true; + } + + return true; + } +} diff --git a/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php new file mode 100644 index 00000000..c0abfc56 --- /dev/null +++ b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php @@ -0,0 +1,59 @@ +query( + "CREATE TABLE IF NOT EXISTS economic_transfer_queue_jobs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + transfer_type VARCHAR(64) NOT NULL, + payload_json JSON NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'QUEUED', + progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0, + progress_message VARCHAR(255) NULL, + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 3, + error_message TEXT NULL, + result_json JSON NULL, + created_by INT NULL, + started_at DATETIME NULL, + completed_at DATETIME NULL, + next_retry_at DATETIME NULL, + locked_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_economic_transfer_queue_jobs_status_created (status, created_at), + INDEX idx_economic_transfer_queue_jobs_next_retry (next_retry_at), + INDEX idx_economic_transfer_queue_jobs_transfer_type (transfer_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals ( + queue_job_id BIGINT UNSIGNED NOT NULL, + user_id INT NOT NULL, + dismissed_status VARCHAR(32) NOT NULL, + dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (queue_job_id, user_id), + INDEX idx_economic_transfer_queue_job_dismissals_user_status (user_id, dismissed_status), + INDEX idx_economic_transfer_queue_job_dismissals_job (queue_job_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + self::$initialized = true; + } +} diff --git a/services/nginx/app/classes/economic_v2_compare_engine.php b/services/nginx/app/classes/economic_v2_compare_engine.php new file mode 100644 index 00000000..413ef341 --- /dev/null +++ b/services/nginx/app/classes/economic_v2_compare_engine.php @@ -0,0 +1,369 @@ + [ + 'internal_net_total' => (float)($internal['totals']['net_total'] ?? 0.0), + ], + 'targets' => [ + 'draft' => $draft_result, + 'booked' => $booked_result, + ], + 'warnings' => array_values(array_unique(array_merge( + (array)($internal['warnings'] ?? []), + (array)($draft_result['warnings'] ?? []), + (array)($booked_result['warnings'] ?? []) + ))), + ]; + } + + public static function compareTarget(array $internal, ?array $target, string $target_name): array + { + if ($target === null) { + return [ + 'target' => $target_name, + 'status' => 'missing_target', + 'overall_match' => false, + 'totals' => [ + 'internal_net_total' => (float)($internal['totals']['net_total'] ?? 0.0), + 'target_net_total' => null, + 'difference' => null, + 'abs_difference' => null, + 'matches' => false, + ], + 'lines' => [ + 'summary' => [ + 'internal_billable_count' => (int)($internal['totals']['billable_line_count'] ?? 0), + 'target_billable_count' => 0, + 'mismatch_count' => (int)($internal['totals']['billable_line_count'] ?? 0), + ], + 'diff' => [], + ], + 'departments' => [ + 'matches' => false, + 'diff' => [], + ], + 'mismatch_reasons' => ['missing_target'], + 'warnings' => ['Missing ' . $target_name . ' invoice target'], + ]; + } + + $totals = self::compareTotals( + (float)($internal['totals']['net_total'] ?? 0.0), + (float)($target['totals']['net_total'] ?? 0.0) + ); + + $lines = self::compareLines($internal['lines'] ?? [], $target['lines'] ?? []); + $departments = self::compareDepartments($internal['departments'] ?? [], $target['departments'] ?? []); + + $mismatch_reasons = array_values(array_unique(array_merge( + $lines['mismatch_reasons'], + $departments['mismatch_reasons'], + $totals['matches'] ? [] : ['total_mismatch'] + ))); + + $overall_match = $totals['matches'] && $lines['summary']['mismatch_count'] === 0 && $departments['matches']; + $status = $overall_match + ? 'exact_match' + : ($totals['matches'] ? 'partial_mismatch' : 'total_mismatch'); + + return [ + 'target' => $target_name, + 'status' => $status, + 'overall_match' => $overall_match, + 'totals' => $totals, + 'lines' => [ + 'summary' => $lines['summary'], + 'diff' => $lines['diff'], + ], + 'departments' => [ + 'matches' => $departments['matches'], + 'diff' => $departments['diff'], + ], + 'mismatch_reasons' => $mismatch_reasons, + 'warnings' => array_values(array_unique(array_merge( + (array)($target['warnings'] ?? []), + (array)$lines['warnings'], + (array)$departments['warnings'] + ))), + ]; + } + + private static function compareTotals(float $internal_total, float $target_total): array + { + $difference = $target_total - $internal_total; + $abs = abs($difference); + return [ + 'internal_net_total' => round($internal_total, 5), + 'target_net_total' => round($target_total, 5), + 'difference' => round($difference, 5), + 'abs_difference' => round($abs, 5), + 'matches' => $abs <= self::TOLERANCE, + ]; + } + + private static function compareLines(array $internal_lines, array $target_lines): array + { + $internal_billable = array_values(array_filter($internal_lines, static fn($l) => (bool)($l['billable'] ?? false))); + $target_billable = array_values(array_filter($target_lines, static fn($l) => (bool)($l['billable'] ?? false))); + + $internal_grouped = self::groupByKey($internal_billable, 'match_key'); + $target_grouped = self::groupByKey($target_billable, 'match_key'); + + $keys = array_values(array_unique(array_merge(array_keys($internal_grouped), array_keys($target_grouped)))); + sort($keys); + + $diff = []; + $mismatch_reasons = []; + $warnings = []; + $unmatched_internal = []; + $unmatched_target = []; + + foreach ($keys as $key) { + $left = $internal_grouped[$key] ?? []; + $right = $target_grouped[$key] ?? []; + $max = max(count($left), count($right)); + + for ($i = 0; $i < $max; $i++) { + $internal_line = $left[$i] ?? null; + $target_line = $right[$i] ?? null; + + if ($internal_line === null) { + $unmatched_target[] = $target_line; + continue; + } + if ($target_line === null) { + $unmatched_internal[] = $internal_line; + continue; + } + + $reasons = self::lineMismatchReasons($internal_line, $target_line); + if (!empty($reasons)) { + $diff[] = [ + 'match_key' => $key, + 'reasons' => $reasons, + 'internal_line' => $internal_line, + 'target_line' => $target_line, + ]; + $mismatch_reasons = array_merge($mismatch_reasons, $reasons); + } + } + } + + // Secondary pairing by reference/description to convert missing/extra into explicit product mismatch when possible. + [$paired_diff, $still_unmatched_internal, $still_unmatched_target] = self::secondaryPairAndCompare($unmatched_internal, $unmatched_target); + $diff = array_merge($diff, $paired_diff); + foreach ($paired_diff as $entry) { + $mismatch_reasons = array_merge($mismatch_reasons, $entry['reasons']); + } + + foreach ($still_unmatched_internal as $line) { + $diff[] = [ + 'match_key' => (string)($line['match_key'] ?? ''), + 'reasons' => ['missing_in_target'], + 'internal_line' => $line, + 'target_line' => null, + ]; + $mismatch_reasons[] = 'missing_in_target'; + } + foreach ($still_unmatched_target as $line) { + $diff[] = [ + 'match_key' => (string)($line['match_key'] ?? ''), + 'reasons' => ['extra_in_target'], + 'internal_line' => null, + 'target_line' => $line, + ]; + $mismatch_reasons[] = 'extra_in_target'; + } + + $internal_non_billable = count($internal_lines) - count($internal_billable); + $target_non_billable = count($target_lines) - count($target_billable); + if ($internal_non_billable !== $target_non_billable) { + $warnings[] = 'Non-billable line count differs: internal=' . $internal_non_billable . ', target=' . $target_non_billable; + } + + return [ + 'summary' => [ + 'internal_billable_count' => count($internal_billable), + 'target_billable_count' => count($target_billable), + 'mismatch_count' => count($diff), + ], + 'diff' => $diff, + 'mismatch_reasons' => array_values(array_unique($mismatch_reasons)), + 'warnings' => $warnings, + ]; + } + + private static function secondaryPairAndCompare(array $unmatched_internal, array $unmatched_target): array + { + $left_by_secondary = self::groupByKey(array_values($unmatched_internal), 'secondary_key'); + $right_by_secondary = self::groupByKey(array_values($unmatched_target), 'secondary_key'); + + $secondary_keys = array_values(array_unique(array_merge(array_keys($left_by_secondary), array_keys($right_by_secondary)))); + sort($secondary_keys); + + $paired_diff = []; + $left_remainder = []; + $right_remainder = []; + + foreach ($secondary_keys as $secondary_key) { + $left = $left_by_secondary[$secondary_key] ?? []; + $right = $right_by_secondary[$secondary_key] ?? []; + $max = max(count($left), count($right)); + for ($i = 0; $i < $max; $i++) { + $internal_line = $left[$i] ?? null; + $target_line = $right[$i] ?? null; + + if ($internal_line === null) { + if ($target_line !== null) { + $right_remainder[] = $target_line; + } + continue; + } + if ($target_line === null) { + $left_remainder[] = $internal_line; + continue; + } + + $reasons = self::lineMismatchReasons($internal_line, $target_line); + if ((string)($internal_line['product_number'] ?? '') !== (string)($target_line['product_number'] ?? '')) { + $reasons[] = 'product_mismatch'; + } + $reasons = array_values(array_unique($reasons)); + $paired_diff[] = [ + 'match_key' => (string)($internal_line['match_key'] ?? ''), + 'reasons' => $reasons, + 'internal_line' => $internal_line, + 'target_line' => $target_line, + ]; + } + } + + return [$paired_diff, $left_remainder, $right_remainder]; + } + + private static function lineMismatchReasons(array $internal_line, array $target_line): array + { + $reasons = []; + + if ((string)($internal_line['product_number'] ?? '') !== (string)($target_line['product_number'] ?? '')) { + $reasons[] = 'product_mismatch'; + } + + if (self::normalizeText((string)($internal_line['description'] ?? '')) !== self::normalizeText((string)($target_line['description'] ?? ''))) { + $reasons[] = 'description_mismatch'; + } + + if (!self::matchesNumber((float)($internal_line['quantity'] ?? 0), (float)($target_line['quantity'] ?? 0))) { + $reasons[] = 'quantity_mismatch'; + } + + if (!self::matchesNumber((float)($internal_line['unit_net_price'] ?? 0), (float)($target_line['unit_net_price'] ?? 0))) { + $reasons[] = 'unit_price_mismatch'; + } + + if (!self::matchesNumber((float)($internal_line['line_net_amount'] ?? 0), (float)($target_line['line_net_amount'] ?? 0))) { + $reasons[] = 'line_total_mismatch'; + } + + if (!self::departmentDistributionMatches( + (array)($internal_line['department_distribution'] ?? []), + (array)($target_line['department_distribution'] ?? []) + )) { + $reasons[] = 'departmental_distribution_mismatch'; + } + + return $reasons; + } + + private static function compareDepartments(array $internal_departments, array $target_departments): array + { + $keys = array_values(array_unique(array_merge(array_keys($internal_departments), array_keys($target_departments)))); + sort($keys); + + $diff = []; + $mismatch_reasons = []; + + foreach ($keys as $key) { + $internal_amount = (float)($internal_departments[$key] ?? 0.0); + $target_amount = (float)($target_departments[$key] ?? 0.0); + $difference = $target_amount - $internal_amount; + $matches = abs($difference) <= self::TOLERANCE; + + $diff[] = [ + 'department_key' => (string)$key, + 'internal_amount' => round($internal_amount, 5), + 'target_amount' => round($target_amount, 5), + 'difference' => round($difference, 5), + 'matches' => $matches, + ]; + if (!$matches) { + $mismatch_reasons[] = 'department_total_mismatch'; + } + } + + return [ + 'matches' => empty($mismatch_reasons), + 'diff' => $diff, + 'mismatch_reasons' => array_values(array_unique($mismatch_reasons)), + 'warnings' => [], + ]; + } + + private static function groupByKey(array $lines, string $preferred_key): array + { + $grouped = []; + foreach ($lines as $line) { + $secondary_key = self::normalizeText((string)($line['description'] ?? '')) . + '|ref:' . self::normalizeText((string)($line['reference'] ?? '')); + $line['secondary_key'] = $secondary_key; + $key = (string)($line[$preferred_key] ?? $secondary_key); + if (!isset($grouped[$key])) { + $grouped[$key] = []; + } + $grouped[$key][] = $line; + } + + foreach ($grouped as &$bucket) { + usort($bucket, static function ($a, $b) { + return ((int)($a['source_line_id'] ?? 0)) <=> ((int)($b['source_line_id'] ?? 0)); + }); + } + + return $grouped; + } + + private static function departmentDistributionMatches(array $left, array $right): bool + { + $keys = array_values(array_unique(array_merge(array_keys($left), array_keys($right)))); + foreach ($keys as $key) { + if (!self::matchesNumber((float)($left[$key] ?? 0.0), (float)($right[$key] ?? 0.0))) { + return false; + } + } + return true; + } + + private static function matchesNumber(float $left, float $right): bool + { + return abs($left - $right) <= self::TOLERANCE; + } + + private static function normalizeText(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/\s+/', ' ', $value); + return $value ?? ''; + } +} + diff --git a/services/nginx/app/classes/economic_v2_distribution_service.php b/services/nginx/app/classes/economic_v2_distribution_service.php new file mode 100644 index 00000000..162d51fa --- /dev/null +++ b/services/nginx/app/classes/economic_v2_distribution_service.php @@ -0,0 +1,1750 @@ +versioning = $versioning ?? new economic_v2_versioning_service(); + $this->economic = $economic; + } + + public function getAllDistributions(string $date_from, string $date_to): array + { + $this->ensureVersionHistoryAvailable([ + 'fixed_pricing', + 'vehicle_subscriptions', + 'discount_overrides', + ]); + + $fixed_pricing = $this->getFixedPricingDistribution($date_from, $date_to); + $wash_subscriptions = $this->getWashSubscriptionsDistribution($date_from, $date_to); + + return [ + 'fixed_pricing' => $fixed_pricing, + 'wash_subscriptions' => $wash_subscriptions, + 'customer_prices' => $this->getCustomerPricesDistribution($date_from, $date_to), + 'booked_department_75' => $this->buildBookedDepartment75Distribution( + $date_from, + $date_to, + $fixed_pricing, + $wash_subscriptions + ), + ]; + } + + public function getBookedDepartment75Distribution(string $date_from, string $date_to): array + { + $this->ensureVersionHistoryAvailable([ + 'fixed_pricing', + 'vehicle_subscriptions', + ]); + + return $this->buildBookedDepartment75Distribution( + $date_from, + $date_to, + $this->getFixedPricingDistribution($date_from, $date_to), + $this->getWashSubscriptionsDistribution($date_from, $date_to) + ); + } + + public function getFixedPricingDistribution(string $date_from, string $date_to): array + { + $this->ensureVersionHistoryAvailable(['fixed_pricing']); + + [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); + $orders = $this->fetchOrdersInRange($from_ts, $to_ts); + $order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders)); + $collected = $this->collectFixedPricingData($orders, $order_items); + if (empty($collected['groups'])) { + $fallback = $this->collectFixedPricingData($orders, $order_items, true); + if (!empty($fallback['groups'])) { + $fallback['warnings'][] = 'System order fallback used for fixed pricing (department 10).'; + $collected = $fallback; + } + } + + $groups = $collected['groups']; + $customer_transactions = $collected['customer_transactions']; + $warnings = $collected['warnings']; + + $customers = []; + $collective = [ + 'total_fixed_price' => 0.0, + 'total_original_price' => 0.0, + 'total_department_totals' => [], + 'total_department_totals_relative' => [], + ]; + + foreach ($groups as $group) { + $customer_number = (int)$group['customer_number']; + if (!isset($customers[$customer_number])) { + $customers[$customer_number] = $this->buildCustomerEnvelope($customer_number, $customer_transactions[$customer_number] ?? []); + $customers[$customer_number]['meta']['fixed_pricing'] = [ + 'price' => 0.0, + 'original_price' => 0.0, + 'department_totals' => [], + 'department_totals_relative' => [], + 'version_groups' => [], + ]; + } + + $group_original = (float)$group['original_price']; + $group_price = (float)$group['price']; + $relative_department_totals = []; + $group_total_department_amount = array_sum($group['department_totals']); + + foreach ($group['department_totals'] as $department_id => $department_amount) { + $department_id = (int)$department_id; + $relative_amount = 0.0; + if ($group_total_department_amount > 0.0) { + $relative_amount = ((float)$department_amount / $group_total_department_amount) * $group_price; + } + $relative_department_totals[$department_id] = $relative_amount; + + if (!isset($customers[$customer_number]['meta']['fixed_pricing']['department_totals'][$department_id])) { + $customers[$customer_number]['meta']['fixed_pricing']['department_totals'][$department_id] = 0.0; + } + if (!isset($customers[$customer_number]['meta']['fixed_pricing']['department_totals_relative'][$department_id])) { + $customers[$customer_number]['meta']['fixed_pricing']['department_totals_relative'][$department_id] = 0.0; + } + $customers[$customer_number]['meta']['fixed_pricing']['department_totals'][$department_id] += (float)$department_amount; + $customers[$customer_number]['meta']['fixed_pricing']['department_totals_relative'][$department_id] += (float)$relative_amount; + + if (!isset($collective['total_department_totals'][$department_id])) { + $collective['total_department_totals'][$department_id] = 0.0; + } + if (!isset($collective['total_department_totals_relative'][$department_id])) { + $collective['total_department_totals_relative'][$department_id] = 0.0; + } + $collective['total_department_totals'][$department_id] += (float)$department_amount; + $collective['total_department_totals_relative'][$department_id] += (float)$relative_amount; + } + + if ($group_total_department_amount <= 0.0) { + $warnings[] = 'Fixed pricing group has no transaction basis for customer ' . $customer_number . ' in ' . $group['month']; + } + + $customers[$customer_number]['meta']['fixed_pricing']['price'] += $group_price; + $customers[$customer_number]['meta']['fixed_pricing']['original_price'] += $group_original; + $customers[$customer_number]['meta']['fixed_pricing']['version_groups'][] = [ + 'version_id' => (int)$group['version_id'], + 'month' => (string)$group['month'], + 'price' => round($group_price, 5), + 'description' => (string)$group['description'], + 'source' => (string)$group['source'], + 'confidence' => round((float)$group['confidence'], 5), + 'inferred' => (bool)$group['inferred'], + 'effective_from' => (string)$group['effective_from'], + 'effective_to' => $group['effective_to'] !== null ? (string)$group['effective_to'] : null, + 'original_price' => round($group_original, 5), + 'department_totals' => $this->roundMap($group['department_totals']), + 'department_totals_relative' => $this->roundMap($relative_department_totals), + 'order_ids' => array_values(array_unique(array_map('intval', $group['order_ids']))), + ]; + + $collective['total_fixed_price'] += $group_price; + $collective['total_original_price'] += $group_original; + } + + $customers = array_values(array_map(function ($customer) { + if (isset($customer['meta']['fixed_pricing'])) { + $customer['meta']['fixed_pricing']['price'] = round((float)$customer['meta']['fixed_pricing']['price'], 5); + $customer['meta']['fixed_pricing']['original_price'] = round((float)$customer['meta']['fixed_pricing']['original_price'], 5); + $customer['meta']['fixed_pricing']['department_totals'] = $this->roundMap($customer['meta']['fixed_pricing']['department_totals']); + $customer['meta']['fixed_pricing']['department_totals_relative'] = $this->roundMap($customer['meta']['fixed_pricing']['department_totals_relative']); + } + return $customer; + }, $customers)); + + return [ + 'customers' => $customers, + 'collective_results' => [ + 'total_fixed_price' => round((float)$collective['total_fixed_price'], 5), + 'total_original_price' => round((float)$collective['total_original_price'], 5), + 'total_department_totals' => $this->roundMap($collective['total_department_totals']), + 'total_department_totals_relative' => $this->roundMap($collective['total_department_totals_relative']), + 'total_department_totals_parsed' => $this->parseDepartmentMap($collective['total_department_totals']), + 'total_department_totals_relative_parsed' => $this->parseDepartmentMap($collective['total_department_totals_relative']), + ], + 'warnings' => array_values(array_unique($warnings)), + ]; + } + + public function getWashSubscriptionsDistribution(string $date_from, string $date_to): array + { + $this->ensureVersionHistoryAvailable(['vehicle_subscriptions']); + + [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); + $orders = $this->fetchOrdersInRange($from_ts, $to_ts); + $order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders)); + $months = $this->listMonthKeys($from_ts, $to_ts); + $collected = $this->collectWashSubscriptionData($orders, $order_items); + if (empty($collected['groups'])) { + $fallback = $this->collectWashSubscriptionData($orders, $order_items, true); + if (!empty($fallback['groups'])) { + $fallback['warnings'][] = 'System order fallback used for wash subscriptions (department 10).'; + $collected = $fallback; + } + } + + $groups = $collected['groups']; + $customer_transactions = $collected['customer_transactions']; + $customer_department_month_map = $collected['customer_department_month_map']; + $warnings = $collected['warnings']; + + $version_rows = $this->fetchVehicleSubscriptionVersionRows($from_ts, $to_ts); + foreach ($version_rows as $row) { + $customer_number = (int)$row['customer_number']; + if (!$this->shouldIncludeCustomerNumber($customer_number)) { + continue; + } + $reg = (string)$row['reg']; + $version_id = (int)$row['id']; + $vehicle_type = (int)$row['vehicle_type']; + $monthly_price = $this->getSubscriptionMonthlyPrice($vehicle_type); + if ($monthly_price <= 0.0) { + continue; + } + + foreach ($months as $month_key) { + $month_start = $month_key . '-01 00:00:00'; + $month_end = date('Y-m-t 23:59:59', strtotime($month_start)); + if (!$this->versionOverlaps($row, $month_start, $month_end)) { + continue; + } + + $group_key = $customer_number . '|' . $reg . '|' . $version_id . '|' . $month_key; + if (isset($groups[$group_key])) { + continue; + } + + $fallback_distribution = $this->buildSubscriptionFallbackDistribution( + $customer_number, + $month_key, + $monthly_price, + $customer_department_month_map + ); + $groups[$group_key] = [ + 'customer_number' => $customer_number, + 'reg' => $reg, + 'vehicle_type' => $vehicle_type, + 'version_id' => $version_id, + 'month' => $month_key, + 'monthly_price' => $monthly_price, + 'source' => (string)($row['source'] ?? 'unknown'), + 'confidence' => (float)($row['confidence'] ?? 0), + 'inferred' => (bool)($row['inferred'] ?? false), + 'distribution' => $fallback_distribution, + 'order_ids' => [], + 'fallback' => true, + ]; + $warnings[] = 'Fallback allocation used for subscription ' . $reg . ' customer ' . $customer_number . ' in ' . $month_key; + } + } + + $customers = []; + $collective = [ + 'total_subscription_price' => 0.0, + 'subscription_price_department_distribution' => [], + ]; + + foreach ($groups as $group) { + $customer_number = (int)$group['customer_number']; + if (!isset($customers[$customer_number])) { + $customers[$customer_number] = $this->buildCustomerEnvelope($customer_number, $customer_transactions[$customer_number] ?? []); + $customers[$customer_number]['meta']['subscription'] = [ + 'subscription_total' => 0.0, + 'subscription_price_department_distribution' => [], + 'version_groups' => [], + ]; + } + + $allocation = $this->normalizeSubscriptionGroupAllocation($group['distribution'], (float)$group['monthly_price']); + foreach ($allocation as $department_id => $amount) { + if (!isset($customers[$customer_number]['meta']['subscription']['subscription_price_department_distribution'][$department_id])) { + $customers[$customer_number]['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0.0; + } + $customers[$customer_number]['meta']['subscription']['subscription_price_department_distribution'][$department_id] += $amount; + + if (!isset($collective['subscription_price_department_distribution'][$department_id])) { + $collective['subscription_price_department_distribution'][$department_id] = 0.0; + } + $collective['subscription_price_department_distribution'][$department_id] += $amount; + } + + $customers[$customer_number]['meta']['subscription']['subscription_total'] += (float)$group['monthly_price']; + $customers[$customer_number]['meta']['subscription']['version_groups'][] = [ + 'version_id' => (int)$group['version_id'], + 'month' => (string)$group['month'], + 'reg' => (string)$group['reg'], + 'vehicle_type' => (int)$group['vehicle_type'], + 'monthly_price' => round((float)$group['monthly_price'], 5), + 'source' => (string)$group['source'], + 'confidence' => round((float)$group['confidence'], 5), + 'inferred' => (bool)$group['inferred'], + 'fallback' => (bool)$group['fallback'], + 'department_distribution' => $this->roundMap($allocation), + 'order_ids' => array_values(array_unique(array_map('intval', $group['order_ids']))), + ]; + + $collective['total_subscription_price'] += (float)$group['monthly_price']; + } + + $customers = array_values(array_map(function ($customer) { + if (isset($customer['meta']['subscription'])) { + $customer['meta']['subscription']['subscription_total'] = round((float)$customer['meta']['subscription']['subscription_total'], 5); + $customer['meta']['subscription']['subscription_price_department_distribution'] = $this->roundMap( + $customer['meta']['subscription']['subscription_price_department_distribution'] + ); + } + return $customer; + }, $customers)); + + return [ + 'customers' => $customers, + 'collective_results' => [ + 'total_subscription_price' => round((float)$collective['total_subscription_price'], 5), + 'subscription_price_department_distribution' => $this->roundMap($collective['subscription_price_department_distribution']), + 'subscription_price_department_distribution_parsed' => $this->parseDepartmentMap($collective['subscription_price_department_distribution']), + ], + 'warnings' => array_values(array_unique($warnings)), + ]; + } + + public function getCustomerPricesDistribution(string $date_from, string $date_to): array + { + $this->ensureVersionHistoryAvailable(['discount_overrides']); + + [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); + $orders = $this->fetchOrdersInRange($from_ts, $to_ts); + $order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders)); + + $customers = []; + $collective = [ + 'total_discount_amount' => 0.0, + 'department_discount_totals' => [], + ]; + + foreach ($orders as $order) { + $order_id = (int)$order['id']; + $customer_number = (int)$order['customer_id']; + if (!$this->shouldIncludeCustomerNumber($customer_number)) { + continue; + } + $department_id = (int)$order['department_id']; + $created_at = (string)$order['created_at']; + + $order_is_included = $this->isOrderEligible($order); + if (!$order_is_included) { + continue; + } + + $order_discount_total = 0.0; + foreach (($order_items[$order_id] ?? []) as $item) { + $product_id = (int)($item['product_id'] ?? 0); + $quantity = (float)($item['quantity'] ?? 0); + if ($product_id <= 0 || $quantity <= 0) { + continue; + } + + $base_price = (float)$this->getProductDepartmentPrice($product_id, $department_id); + if ($base_price <= 0) { + continue; + } + + $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at); + $discount_percentage = (float)($discount_row['discount'] ?? 0); + if ($discount_percentage <= 0) { + continue; + } + + $discount_amount = ($base_price * $quantity) * ($discount_percentage / 100); + $order_discount_total += $discount_amount; + } + + if (!isset($customers[$customer_number])) { + $customers[$customer_number] = $this->buildCustomerEnvelope($customer_number, []); + $customers[$customer_number]['meta']['customer_prices'] = [ + 'discount_total' => 0.0, + 'department_discount_totals' => [], + ]; + } + + $customers[$customer_number]['transactions'][] = $this->buildTransactionObject($order_id, $created_at, $department_id, $order_discount_total, $order_is_included); + $customers[$customer_number]['meta']['customer_prices']['discount_total'] += $order_discount_total; + if (!isset($customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id])) { + $customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id] = 0.0; + } + $customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id] += $order_discount_total; + + $collective['total_discount_amount'] += $order_discount_total; + if (!isset($collective['department_discount_totals'][$department_id])) { + $collective['department_discount_totals'][$department_id] = 0.0; + } + $collective['department_discount_totals'][$department_id] += $order_discount_total; + } + + $customers = array_values(array_map(function ($customer) { + if (isset($customer['meta']['customer_prices'])) { + $customer['meta']['customer_prices']['discount_total'] = round((float)$customer['meta']['customer_prices']['discount_total'], 5); + $customer['meta']['customer_prices']['department_discount_totals'] = $this->roundMap( + $customer['meta']['customer_prices']['department_discount_totals'] + ); + } + return $customer; + }, $customers)); + + return [ + 'customers' => $customers, + 'collective_results' => [ + 'total_discount_amount' => round((float)$collective['total_discount_amount'], 5), + 'department_discount_totals' => $this->roundMap($collective['department_discount_totals']), + 'department_discount_totals_parsed' => $this->parseDepartmentMap($collective['department_discount_totals']), + ], + 'warnings' => [], + ]; + } + + private function buildBookedDepartment75Distribution( + string $date_from, + string $date_to, + array $fixed_pricing, + array $wash_subscriptions + ): array { + $weight_index = $this->buildBookedDepartment75WeightIndex($fixed_pricing, $wash_subscriptions); + $transaction_map = $this->buildBookedDepartment75TransactionMap($fixed_pricing, $wash_subscriptions); + $warnings = []; + $groups = $this->collectBookedDepartment75Groups($date_from, $date_to, $warnings); + + $customers = []; + $collective = [ + 'booked_net_amount' => 0.0, + 'distributed_net_amount' => 0.0, + 'undistributed_net_amount' => 0.0, + 'department_distribution' => [], + ]; + + foreach ($groups as $group) { + $customer_number = (int)$group['customer_number']; + if (!isset($customers[$customer_number])) { + $customers[$customer_number] = $this->buildBookedDepartment75CustomerEnvelope( + $customer_number, + $transaction_map[$customer_number] ?? [] + ); + $customers[$customer_number]['meta']['booked_department_75'] = [ + 'booked_net_amount' => 0.0, + 'distributed_net_amount' => 0.0, + 'undistributed_net_amount' => 0.0, + 'department_distribution' => [], + 'booked_groups' => [], + ]; + } + + $redistributed = $this->redistributeBookedDepartment75Group($group, $weight_index, $warnings); + $distributed_amount = array_sum($redistributed['department_distribution']); + $booked_amount = (float)$group['booked_net_amount']; + $invoice_ids = array_values(array_unique(array_map('intval', $group['invoice_ids'] ?? []))); + sort($invoice_ids); + + $customers[$customer_number]['meta']['booked_department_75']['booked_net_amount'] += $booked_amount; + $customers[$customer_number]['meta']['booked_department_75']['distributed_net_amount'] += $distributed_amount; + $customers[$customer_number]['meta']['booked_department_75']['undistributed_net_amount'] += (float)$redistributed['undistributed_net_amount']; + foreach ($redistributed['department_distribution'] as $department_id => $amount) { + $department_id = (int)$department_id; + if (!isset($customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id])) { + $customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id] = 0.0; + } + $customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id] += (float)$amount; + + if (!isset($collective['department_distribution'][$department_id])) { + $collective['department_distribution'][$department_id] = 0.0; + } + $collective['department_distribution'][$department_id] += (float)$amount; + } + + $customers[$customer_number]['meta']['booked_department_75']['booked_groups'][] = [ + 'month' => (string)$group['month'], + 'source_category' => (string)$group['source_category'], + 'invoice_ids' => $invoice_ids, + 'booked_net_amount' => round($booked_amount, 5), + 'department_distribution' => $this->roundMap($redistributed['department_distribution']), + 'undistributed_net_amount' => round((float)$redistributed['undistributed_net_amount'], 5), + ]; + + $collective['booked_net_amount'] += $booked_amount; + $collective['distributed_net_amount'] += $distributed_amount; + $collective['undistributed_net_amount'] += (float)$redistributed['undistributed_net_amount']; + } + + $customers = array_values(array_map(function (array $customer): array { + if (!isset($customer['meta']['booked_department_75'])) { + return $customer; + } + + $customer['meta']['booked_department_75']['booked_net_amount'] = round( + (float)$customer['meta']['booked_department_75']['booked_net_amount'], + 5 + ); + $customer['meta']['booked_department_75']['distributed_net_amount'] = round( + (float)$customer['meta']['booked_department_75']['distributed_net_amount'], + 5 + ); + $customer['meta']['booked_department_75']['undistributed_net_amount'] = round( + (float)$customer['meta']['booked_department_75']['undistributed_net_amount'], + 5 + ); + $customer['meta']['booked_department_75']['department_distribution'] = $this->roundMap( + $customer['meta']['booked_department_75']['department_distribution'] + ); + usort($customer['meta']['booked_department_75']['booked_groups'], static function (array $left, array $right): int { + $month_compare = strcmp((string)$left['month'], (string)$right['month']); + if ($month_compare !== 0) { + return $month_compare; + } + + return strcmp((string)$left['source_category'], (string)$right['source_category']); + }); + + return $customer; + }, $customers)); + usort($customers, static fn(array $left, array $right): int => ((int)$left['customer_number']) <=> ((int)$right['customer_number'])); + + return [ + 'customers' => $customers, + 'collective_results' => [ + 'booked_net_amount' => round((float)$collective['booked_net_amount'], 5), + 'distributed_net_amount' => round((float)$collective['distributed_net_amount'], 5), + 'undistributed_net_amount' => round((float)$collective['undistributed_net_amount'], 5), + 'department_distribution' => $this->roundMap($collective['department_distribution']), + 'department_distribution_parsed' => $this->parseDepartmentMap($collective['department_distribution']), + ], + 'warnings' => array_values(array_unique($warnings)), + ]; + } + + private function buildBookedDepartment75WeightIndex(array $fixed_pricing, array $wash_subscriptions): array + { + $weights = []; + + foreach (($fixed_pricing['customers'] ?? []) as $customer) { + $customer_number = (int)($customer['customer_number'] ?? 0); + foreach ((array)($customer['meta']['fixed_pricing']['version_groups'] ?? []) as $group) { + $this->accumulateBookedDepartment75Weights( + $weights, + $customer_number, + (string)($group['month'] ?? ''), + 'fixed_pricing', + (array)($group['department_totals_relative'] ?? []) + ); + } + } + + foreach (($wash_subscriptions['customers'] ?? []) as $customer) { + $customer_number = (int)($customer['customer_number'] ?? 0); + foreach ((array)($customer['meta']['subscription']['version_groups'] ?? []) as $group) { + $this->accumulateBookedDepartment75Weights( + $weights, + $customer_number, + (string)($group['month'] ?? ''), + 'wash_subscriptions', + (array)($group['department_distribution'] ?? []) + ); + } + } + + return $weights; + } + + private function accumulateBookedDepartment75Weights( + array &$weights, + int $customer_number, + string $month_key, + string $source_category, + array $distribution + ): void { + if ($customer_number <= 0 || $month_key === '' || $source_category === '') { + return; + } + + foreach ($distribution as $department_id => $amount) { + $department_id = (int)$department_id; + if (!$this->isDepartmentEligible($department_id)) { + continue; + } + + if (!isset($weights[$customer_number][$month_key][$source_category][$department_id])) { + $weights[$customer_number][$month_key][$source_category][$department_id] = 0.0; + } + $weights[$customer_number][$month_key][$source_category][$department_id] += (float)$amount; + } + } + + private function buildBookedDepartment75TransactionMap(array $fixed_pricing, array $wash_subscriptions): array + { + $transaction_map = []; + foreach ([$fixed_pricing, $wash_subscriptions] as $response) { + foreach (($response['customers'] ?? []) as $customer) { + $customer_number = (int)($customer['customer_number'] ?? 0); + if ($customer_number <= 0) { + continue; + } + + foreach ((array)($customer['transactions'] ?? []) as $transaction) { + $transaction_id = (int)($transaction['id'] ?? 0); + if ($transaction_id > 0) { + $transaction_map[$customer_number][$transaction_id] = $transaction; + continue; + } + + $transaction_map[$customer_number][] = $transaction; + } + } + } + + return $transaction_map; + } + + private function buildBookedDepartment75CustomerEnvelope(int $customer_number, array $transaction_map): array + { + try { + return $this->buildCustomerEnvelope($customer_number, $transaction_map); + } catch (\Throwable $e) { + return [ + 'id' => null, + 'customer_number' => $customer_number, + 'customer_name' => $this->customer_name_cache[$customer_number] ?? 'Unknown Customer', + 'transactions' => array_values($transaction_map), + 'requires_action' => false, + 'meta' => [], + ]; + } + } + + private function collectBookedDepartment75Groups(string $date_from, string $date_to, array &$warnings): array + { + $booked_invoices = $this->fetchBookedInvoicesInDateRange($date_from, $date_to, $warnings); + $invoice_ids = []; + foreach ($booked_invoices as $invoice_raw) { + $invoice = $this->toArray($invoice_raw); + $invoice_id = (int)($invoice['bookedInvoiceNumber'] ?? $invoice['booked_invoice_number'] ?? 0); + if ($invoice_id > 0) { + $invoice_ids[] = $invoice_id; + } + } + + $invoice_lines = $this->fetchBookedInvoiceLines($invoice_ids, $warnings); + $groups = []; + + foreach ($booked_invoices as $invoice_raw) { + $invoice = $this->toArray($invoice_raw); + $invoice_id = (int)($invoice['bookedInvoiceNumber'] ?? $invoice['booked_invoice_number'] ?? 0); + $customer_number = (int)($invoice['customer']['customerNumber'] ?? $invoice['customer']['customer_number'] ?? 0); + $invoice_date = (string)($invoice['date'] ?? ''); + + if ($invoice_id <= 0 || $customer_number <= 0 || $invoice_date === '') { + continue; + } + if (!$this->shouldIncludeCustomerNumber($customer_number)) { + continue; + } + + $month_key = substr($invoice_date, 0, 7); + foreach ($this->parseBookedDepartment75InvoiceLines($invoice_id, (array)($invoice_lines[$invoice_id] ?? []), $warnings) as $line) { + if (abs((float)$line['booked_net_amount']) <= self::EPSILON) { + continue; + } + + $source_category = (string)($line['source_category'] ?? 'unclassified'); + $group_key = $customer_number . '|' . $month_key . '|' . $source_category; + if (!isset($groups[$group_key])) { + $groups[$group_key] = [ + 'customer_number' => $customer_number, + 'month' => $month_key, + 'source_category' => $source_category, + 'invoice_ids' => [], + 'booked_net_amount' => 0.0, + ]; + } + + $groups[$group_key]['invoice_ids'][$invoice_id] = true; + $groups[$group_key]['booked_net_amount'] += (float)$line['booked_net_amount']; + } + } + + foreach ($groups as &$group) { + $group['invoice_ids'] = array_values(array_map('intval', array_keys((array)$group['invoice_ids']))); + sort($group['invoice_ids']); + } + unset($group); + + return $groups; + } + + private function parseBookedDepartment75InvoiceLines(int $invoice_id, array $invoice_lines, array &$warnings): array + { + $parsed = []; + $active_category = null; + + foreach ($invoice_lines as $line_raw) { + $line = $this->normalizeBookedDepartment75Line($line_raw); + $description = trim((string)($line['description'] ?? '')); + if ($description !== '' && $this->isBookedDepartment75TransactionHeader($description)) { + $active_category = null; + continue; + } + + $marker = $this->resolveBookedDepartment75Marker($description); + if ($marker !== null) { + $active_category = $marker; + continue; + } + + if (!(bool)($line['billable'] ?? false)) { + continue; + } + + $department_share = (float)( + $line['department_distribution'][(string)self::BOOKED_DEPARTMENT_75] + ?? $line['department_distribution'][self::BOOKED_DEPARTMENT_75] + ?? 0.0 + ); + if (abs($department_share) <= self::EPSILON) { + continue; + } + + $booked_net_amount = (float)($line['line_net_amount'] ?? 0.0) * ($department_share / 100.0); + $source_category = $active_category; + if ($source_category === null) { + $warnings[] = 'Unable to classify booked department 75 line on invoice ' + . $invoice_id + . ' line ' + . (int)($line['source_line_id'] ?? 0) + . '; amount remains undistributed.'; + $source_category = 'unclassified'; + } + + $parsed[] = [ + 'source_category' => $source_category, + 'booked_net_amount' => $booked_net_amount, + ]; + } + + return $parsed; + } + + private function normalizeBookedDepartment75Line(mixed $raw_line): array + { + $line = $this->toArray($raw_line); + $product_number = $line['product']['productNumber'] + ?? $line['productNumber'] + ?? $line['product']['product_number'] + ?? null; + $quantity = isset($line['quantity']) ? (float)$line['quantity'] : 0.0; + $unit_net_price = isset($line['unitNetPrice']) + ? (float)$line['unitNetPrice'] + : (isset($line['unit_net_price']) ? (float)$line['unit_net_price'] : 0.0); + $line_net_amount = isset($line['totalNetAmount']) + ? (float)$line['totalNetAmount'] + : (isset($line['total_net_amount']) ? (float)$line['total_net_amount'] : $quantity * $unit_net_price); + + return [ + 'description' => (string)($line['description'] ?? ''), + 'product_number' => $product_number !== null ? (string)$product_number : null, + 'quantity' => $quantity, + 'line_net_amount' => $line_net_amount, + 'billable' => ($product_number !== null) || abs($line_net_amount) > self::EPSILON || abs($quantity) > self::EPSILON, + 'source_line_id' => (int)($line['lineNumber'] ?? $line['line_number'] ?? $line['number'] ?? $line['userInterfaceNumber'] ?? 0), + 'department_distribution' => $this->extractBookedDepartment75Distribution($line), + ]; + } + + private function extractBookedDepartment75Distribution(array $line): array + { + $distribution = []; + $departmental_distribution = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null; + if (is_array($departmental_distribution)) { + $distributions = $departmental_distribution['distributions'] ?? null; + if (is_array($distributions)) { + foreach ($distributions as $entry_raw) { + $entry = $this->toArray($entry_raw); + $department_number = $entry['department']['departmentNumber'] + ?? $entry['department']['department_number'] + ?? null; + if ($department_number === null) { + continue; + } + + $distribution[(string)$department_number] = (float)($entry['percentage'] ?? 0.0); + } + } + + if (empty($distribution)) { + $fallback_number = $departmental_distribution['departmentalDistributionNumber'] + ?? $departmental_distribution['departmental_distribution_number'] + ?? null; + if ($fallback_number !== null) { + $distribution[(string)$fallback_number] = 100.0; + } + } + } + + if (empty($distribution)) { + $department_number = $line['departmentNumber'] ?? $line['department_number'] ?? null; + if ($department_number !== null) { + $distribution[(string)$department_number] = 100.0; + } + } + + return $distribution; + } + + private function resolveBookedDepartment75Marker(string $description): ?string + { + $normalized = strtolower(trim(preg_replace('/\s+/', ' ', $description))); + + return match ($normalized) { + '# fast pris aftale', 'fast pris aftale' => 'fixed_pricing', + '# vaskeabonnementer', 'vaskeabonnementer' => 'wash_subscriptions', + default => null, + }; + } + + private function isBookedDepartment75TransactionHeader(string $description): bool + { + return str_starts_with($description, '[') && str_ends_with($description, ']'); + } + + private function redistributeBookedDepartment75Group(array $group, array $weight_index, array &$warnings): array + { + $booked_amount = (float)($group['booked_net_amount'] ?? 0.0); + if (abs($booked_amount) <= self::EPSILON) { + return [ + 'department_distribution' => [], + 'undistributed_net_amount' => 0.0, + ]; + } + + $customer_number = (int)($group['customer_number'] ?? 0); + $month_key = (string)($group['month'] ?? ''); + $source_category = (string)($group['source_category'] ?? 'unclassified'); + $invoice_ids = implode(', ', array_map('intval', (array)($group['invoice_ids'] ?? []))); + + if ($source_category === 'unclassified') { + $warnings[] = 'Booked department 75 amount for customer ' + . $customer_number + . ' in ' + . $month_key + . ' on invoice(s) ' + . $invoice_ids + . ' could not be classified and remains undistributed.'; + + return [ + 'department_distribution' => [], + 'undistributed_net_amount' => $booked_amount, + ]; + } + + $weights = $weight_index[$customer_number][$month_key][$source_category] ?? []; + $eligible_weights = []; + foreach ($weights as $department_id => $amount) { + $department_id = (int)$department_id; + if (!$this->isDepartmentEligible($department_id)) { + continue; + } + $eligible_weights[$department_id] = (float)$amount; + } + + $weight_total = array_sum($eligible_weights); + if (abs($weight_total) <= self::EPSILON) { + $fallback_department_id = $this->getFallbackDistributionDepartmentId(); + $warnings[] = 'Booked department 75 ' + . $source_category + . ' amount for customer ' + . $customer_number + . ' in ' + . $month_key + . ' on invoice(s) ' + . $invoice_ids + . ' has no redistribution basis and was assigned to fallback department ' + . $fallback_department_id + . '.'; + + return [ + 'department_distribution' => [ + $fallback_department_id => $booked_amount, + ], + 'undistributed_net_amount' => 0.0, + ]; + } + + $distribution = []; + foreach ($eligible_weights as $department_id => $weight) { + $distribution[$department_id] = $booked_amount * ($weight / $weight_total); + } + + return [ + 'department_distribution' => $distribution, + 'undistributed_net_amount' => 0.0, + ]; + } + + protected function fetchBookedInvoicesInDateRange(string $date_from, string $date_to, array &$warnings): array + { + $cache_key = $date_from . '|' . $date_to; + if (isset($this->booked_invoices_cache[$cache_key])) { + return $this->booked_invoices_cache[$cache_key]; + } + + $filters = [ + '(date$gte:' . $date_from . '$and:date$lte:' . $date_to . ')' => '', + ]; + $all = []; + + for ($page = 0; $page < self::BOOKED_INVOICE_MAX_PAGES; $page++) { + try { + $response = $this->getEconomicClient()->invoices->booked->get( + $filters, + [ + 'skipPages' => $page, + 'pageSize' => self::BOOKED_INVOICE_PAGE_SIZE, + ] + ); + } catch (\Throwable $e) { + $warnings[] = 'Failed to fetch booked e-conomic invoices for department 75 distribution: ' . $e->getMessage(); + break; + } + + $collection = is_array($response->collection ?? null) ? $response->collection : []; + $all = array_merge($all, $collection); + + if (count($collection) < self::BOOKED_INVOICE_PAGE_SIZE) { + break; + } + + if ($page + 1 >= self::BOOKED_INVOICE_MAX_PAGES) { + $warnings[] = 'Reached pagination safety limit (max_pages=' + . self::BOOKED_INVOICE_MAX_PAGES + . ') while fetching booked department 75 invoices.'; + } + } + + $this->booked_invoices_cache[$cache_key] = $all; + return $this->booked_invoices_cache[$cache_key]; + } + + protected function fetchBookedInvoiceLines(array $invoice_ids, array &$warnings): array + { + $invoice_ids = array_values(array_unique(array_filter(array_map('intval', $invoice_ids), static fn(int $id): bool => $id > 0))); + sort($invoice_ids); + if (empty($invoice_ids)) { + return []; + } + + $cache_key = implode(',', $invoice_ids); + if (isset($this->booked_invoice_lines_cache[$cache_key])) { + return $this->booked_invoice_lines_cache[$cache_key]; + } + + try { + $this->booked_invoice_lines_cache[$cache_key] = $this->getEconomicClient()->invoices->booked->get_invoice_lines($invoice_ids); + } catch (\Throwable $e) { + $warnings[] = 'Failed to fetch booked invoice lines for department 75 distribution: ' . $e->getMessage(); + $this->booked_invoice_lines_cache[$cache_key] = []; + } + + return $this->booked_invoice_lines_cache[$cache_key]; + } + + protected function ensureVersionHistoryAvailable(array $areas): void + { + if ($this->best_effort_backfill_attempted) { + return; + } + + foreach ($areas as $area) { + $table = $this->getVersionTableForArea($area); + if ($table === null || $this->versionTableHasRows($table)) { + continue; + } + + $this->best_effort_backfill_attempted = true; + $this->versioning->runBestEffortBackfill(); + $this->version_table_has_rows_cache = []; + return; + } + } + + protected function getVersionTableForArea(string $area): ?string + { + return match ($area) { + 'fixed_pricing' => 'customer_fixed_pricing_versions', + 'vehicle_subscriptions' => 'customer_vehicle_subscription_versions', + 'discount_overrides' => 'customer_discount_override_versions', + default => null, + }; + } + + protected function versionTableHasRows(string $table): bool + { + if (array_key_exists($table, $this->version_table_has_rows_cache)) { + return $this->version_table_has_rows_cache[$table]; + } + + global $db; + $allowed_tables = [ + 'customer_fixed_pricing_versions' => true, + 'customer_vehicle_subscription_versions' => true, + 'customer_discount_override_versions' => true, + ]; + if (!isset($allowed_tables[$table])) { + return $this->version_table_has_rows_cache[$table] = true; + } + + $result = $db->query("SELECT 1 FROM $table LIMIT 1"); + if (!$result) { + return $this->version_table_has_rows_cache[$table] = false; + } + + return $this->version_table_has_rows_cache[$table] = $result->num_rows > 0; + } + + protected function collectFixedPricingData(array $orders, array $order_items_by_order_id, bool $system_order_fallback = false): array + { + $groups = []; + $customer_transactions = []; + $warnings = []; + + foreach ($orders as $order) { + $order_id = (int)$order['id']; + $customer_number = (int)$order['customer_id']; + if (!$this->shouldIncludeCustomerNumber($customer_number)) { + continue; + } + $department_id = (int)$order['department_id']; + $created_at = (string)$order['created_at']; + + if ($system_order_fallback) { + if (!$this->isSystemOrderCandidate($order, self::FIXED_PRICING_SYSTEM_ORDER_REFERENCE)) { + continue; + } + } elseif (!$this->isOrderEligible($order)) { + continue; + } + $distribution_department_id = $system_order_fallback + ? $this->getFallbackDistributionDepartmentId() + : $department_id; + + $fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at); + if ($fixed_version === null) { + continue; + } + + $month_key = substr($created_at, 0, 7); + $group_key = $customer_number . '|' . (int)$fixed_version['id'] . '|' . $month_key; + if (!isset($groups[$group_key])) { + $groups[$group_key] = [ + 'customer_number' => $customer_number, + 'version_id' => (int)$fixed_version['id'], + 'month' => $month_key, + 'price' => (float)($fixed_version['price'] ?? 0), + 'description' => (string)($fixed_version['description'] ?? ''), + 'source' => (string)($fixed_version['source'] ?? 'unknown'), + 'confidence' => (float)($fixed_version['confidence'] ?? 0), + 'inferred' => (bool)($fixed_version['inferred'] ?? false), + 'effective_from' => (string)($fixed_version['effective_from'] ?? ''), + 'effective_to' => $fixed_version['effective_to'] ?? null, + 'original_price' => 0.0, + 'department_totals' => [], + 'order_ids' => [], + ]; + } + + $order_original_price = $this->calculateOrderOriginalPrice( + $order_items_by_order_id[$order_id] ?? [], + $customer_number, + $distribution_department_id, + $created_at + ); + $groups[$group_key]['original_price'] += $order_original_price; + if (!isset($groups[$group_key]['department_totals'][$distribution_department_id])) { + $groups[$group_key]['department_totals'][$distribution_department_id] = 0.0; + } + $groups[$group_key]['department_totals'][$distribution_department_id] += $order_original_price; + $groups[$group_key]['order_ids'][] = $order_id; + + if (!isset($customer_transactions[$customer_number][$order_id])) { + $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject( + $order_id, + $created_at, + $department_id, + null, + $this->isOrderEligible($order) + ); + } + } + + return [ + 'groups' => $groups, + 'customer_transactions' => $customer_transactions, + 'warnings' => $warnings, + ]; + } + + protected function collectWashSubscriptionData(array $orders, array $order_items_by_order_id, bool $system_order_fallback = false): array + { + $groups = []; + $customer_transactions = []; + $customer_department_month_map = []; + $warnings = []; + + foreach ($orders as $order) { + $order_id = (int)$order['id']; + $customer_number = (int)$order['customer_id']; + if (!$this->shouldIncludeCustomerNumber($customer_number)) { + continue; + } + $department_id = (int)$order['department_id']; + $created_at = (string)$order['created_at']; + + if ($system_order_fallback) { + if (!$this->isSystemOrderCandidate($order, self::WASH_SUBSCRIPTION_SYSTEM_ORDER_REFERENCE)) { + continue; + } + } elseif (!$this->isOrderEligible($order)) { + continue; + } + $distribution_department_id = $system_order_fallback + ? $this->getFallbackDistributionDepartmentId() + : $department_id; + + $month_key = substr($created_at, 0, 7); + if (!isset($customer_department_month_map[$customer_number][$month_key][$distribution_department_id])) { + $customer_department_month_map[$customer_number][$month_key][$distribution_department_id] = 0; + } + $customer_department_month_map[$customer_number][$month_key][$distribution_department_id]++; + + $candidates = $this->buildWashSubscriptionCandidates( + $order, + $order_items_by_order_id[$order_id] ?? [], + $system_order_fallback + ); + if (empty($candidates)) { + continue; + } + + $active_subscriptions = $this->versioning->resolveVehicleSubscriptionVersionsAt($customer_number, $created_at); + $matched_order = false; + foreach ($candidates as $candidate) { + $matching_version = $this->findMatchingSubscriptionVersion( + $active_subscriptions, + (string)$candidate['reg'], + (int)$candidate['vehicle_type'] + ); + if ($matching_version === null) { + continue; + } + + $monthly_price = $this->getSubscriptionMonthlyPrice((int)$matching_version['vehicle_type']); + if ($monthly_price <= 0.0) { + $warnings[] = 'Subscription type ' . (int)$matching_version['vehicle_type'] . ' has no monthly price for customer ' . $customer_number; + continue; + } + + $group_key = $customer_number . '|' . (string)$matching_version['reg'] . '|' . (int)$matching_version['id'] . '|' . $month_key; + if (!isset($groups[$group_key])) { + $groups[$group_key] = [ + 'customer_number' => $customer_number, + 'reg' => (string)$matching_version['reg'], + 'vehicle_type' => (int)$matching_version['vehicle_type'], + 'version_id' => (int)$matching_version['id'], + 'month' => $month_key, + 'monthly_price' => $monthly_price, + 'source' => (string)($matching_version['source'] ?? 'unknown'), + 'confidence' => (float)($matching_version['confidence'] ?? 0), + 'inferred' => (bool)($matching_version['inferred'] ?? false), + 'distribution' => [], + 'order_ids' => [], + 'fallback' => false, + ]; + } + + if (!isset($groups[$group_key]['distribution'][$distribution_department_id])) { + $groups[$group_key]['distribution'][$distribution_department_id] = 0; + } + $groups[$group_key]['distribution'][$distribution_department_id]++; + $groups[$group_key]['order_ids'][] = $order_id; + $matched_order = true; + } + + if ($matched_order && !isset($customer_transactions[$customer_number][$order_id])) { + $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject( + $order_id, + $created_at, + $department_id, + null, + $this->isOrderEligible($order) + ); + } + } + + return [ + 'groups' => $groups, + 'customer_transactions' => $customer_transactions, + 'customer_department_month_map' => $customer_department_month_map, + 'warnings' => $warnings, + ]; + } + + protected function buildDateRange(string $date_from, string $date_to): array + { + $from = date('Y-m-d 00:00:00', strtotime($date_from)); + $to = date('Y-m-d 23:59:59', strtotime($date_to)); + return [$from, $to]; + } + + protected function fetchOrdersInRange(string $from_ts, string $to_ts): array + { + $cache_key = $from_ts . '|' . $to_ts; + if (isset($this->orders_in_range_cache[$cache_key])) { + return $this->orders_in_range_cache[$cache_key]; + } + + global $db; + $from = $db->escape_string($from_ts); + $to = $db->escape_string($to_ts); + $sql = "SELECT id, customer_id, department_id, created_at, include_in_invoice, reg_1, reference + FROM orders + WHERE deleted_at IS NULL + AND created_at >= '$from' + AND created_at <= '$to'"; + $result = $db->query($sql); + if (!$result) { + $this->orders_in_range_cache[$cache_key] = []; + return $this->orders_in_range_cache[$cache_key]; + } + $this->orders_in_range_cache[$cache_key] = $db->fetch_all($result); + return $this->orders_in_range_cache[$cache_key]; + } + + protected function fetchOrderItemsByOrderIds(array $order_ids): array + { + global $db; + $order_ids = array_values(array_unique(array_filter(array_map('intval', $order_ids), static fn($id) => $id > 0))); + sort($order_ids); + if (empty($order_ids)) { + return []; + } + + $cache_key = implode(',', $order_ids); + if (isset($this->order_items_by_order_ids_cache[$cache_key])) { + return $this->order_items_by_order_ids_cache[$cache_key]; + } + + $sql = "SELECT order_id, product_id, price, quantity, reference + FROM order_items + WHERE deleted_at IS NULL + AND order_id IN (" . implode(',', $order_ids) . ")"; + $result = $db->query($sql); + if (!$result) { + $this->order_items_by_order_ids_cache[$cache_key] = []; + return $this->order_items_by_order_ids_cache[$cache_key]; + } + $rows = $db->fetch_all($result); + $grouped = []; + foreach ($rows as $row) { + $order_id = (int)$row['order_id']; + if (!isset($grouped[$order_id])) { + $grouped[$order_id] = []; + } + $grouped[$order_id][] = $row; + } + $this->order_items_by_order_ids_cache[$cache_key] = $grouped; + return $this->order_items_by_order_ids_cache[$cache_key]; + } + + protected function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array + { + $cache_key = $from_ts . '|' . $to_ts; + if (isset($this->vehicle_subscription_versions_in_range_cache[$cache_key])) { + return $this->vehicle_subscription_versions_in_range_cache[$cache_key]; + } + + global $db; + $from = $db->escape_string($from_ts); + $to = $db->escape_string($to_ts); + $sql = "SELECT * + FROM customer_vehicle_subscription_versions + WHERE wash_subscription = 1 + AND effective_from <= '$to' + AND (effective_to IS NULL OR effective_to >= '$from')"; + $result = $db->query($sql); + if (!$result) { + $this->vehicle_subscription_versions_in_range_cache[$cache_key] = []; + return $this->vehicle_subscription_versions_in_range_cache[$cache_key]; + } + $this->vehicle_subscription_versions_in_range_cache[$cache_key] = $db->fetch_all($result); + return $this->vehicle_subscription_versions_in_range_cache[$cache_key]; + } + + private function versionOverlaps(array $version_row, string $from_ts, string $to_ts): bool + { + $version_from = (string)$version_row['effective_from']; + $version_to = $version_row['effective_to'] !== null ? (string)$version_row['effective_to'] : null; + if ($version_from > $to_ts) { + return false; + } + if ($version_to !== null && $version_to < $from_ts) { + return false; + } + return true; + } + + protected function buildWashSubscriptionCandidates(array $order, array $order_items, bool $system_order_fallback = false): array + { + if (!$system_order_fallback) { + $reg = trim((string)($order['reg_1'] ?? '')); + if ($reg === '') { + return []; + } + + return [[ + 'reg' => $reg, + 'vehicle_type' => 0, + ]]; + } + + $candidates = []; + foreach ($order_items as $item) { + $reg = trim((string)($item['reference'] ?? '')); + if ($reg === '') { + continue; + } + + $vehicle_type = (int)($item['product_id'] ?? 0); + $candidate_key = strtoupper($reg) . '|' . $vehicle_type; + if (isset($candidates[$candidate_key])) { + continue; + } + + $candidates[$candidate_key] = [ + 'reg' => $reg, + 'vehicle_type' => $vehicle_type, + ]; + } + + return array_values($candidates); + } + + protected function findMatchingSubscriptionVersion(array $active_subscriptions, string $reg, int $vehicle_type = 0): ?array + { + $fallback_match = null; + foreach ($active_subscriptions as $candidate) { + if (strcasecmp((string)$candidate['reg'], $reg) !== 0) { + continue; + } + + if ($vehicle_type > 0 && (int)($candidate['vehicle_type'] ?? 0) === $vehicle_type) { + return $candidate; + } + + if ($fallback_match === null) { + $fallback_match = $candidate; + } + } + + return $fallback_match; + } + + protected function isSystemOrderCandidate(array $order, string $reference): bool + { + return (int)($order['department_id'] ?? 0) === self::SYSTEM_ORDER_DEPARTMENT_ID + && strcasecmp(trim((string)($order['reference'] ?? '')), $reference) === 0; + } + + private function buildSubscriptionFallbackDistribution( + int $customer_number, + string $month_key, + float $monthly_price, + array $customer_department_month_map + ): array { + $distribution = []; + $department_counts = $customer_department_month_map[$customer_number][$month_key] ?? []; + if (!empty($department_counts)) { + $department_counts = $this->normalizeFallbackDepartmentCounts($department_counts); + } + if (!empty($department_counts)) { + $total = (float)array_sum($department_counts); + foreach ($department_counts as $department_id => $count) { + $distribution[(int)$department_id] = $monthly_price * ((float)$count / max($total, 1.0)); + } + return $distribution; + } + + $customer_default_department_id = $this->getCustomerDefaultDepartmentId($customer_number); + $default_department = $customer_default_department_id !== null && $customer_default_department_id > 0 + ? $customer_default_department_id + : $this->getFallbackDistributionDepartmentId(); + + $distribution[$default_department] = $monthly_price; + return $distribution; + } + + /** + * @param array $department_counts + * @return array + */ + private function normalizeFallbackDepartmentCounts(array $department_counts): array + { + $normalized = []; + foreach ($department_counts as $department_id => $count) { + $department_id = (int)$department_id; + if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) { + $department_id = $this->getFallbackDistributionDepartmentId(); + } + if (!isset($normalized[$department_id])) { + $normalized[$department_id] = 0; + } + $normalized[$department_id] += $count; + } + + return $normalized; + } + + protected function getCustomerDefaultDepartmentId(int $customer_number): ?int + { + try { + $default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment(); + return !empty($default) ? (int)$default : null; + } catch (Exception $e) { + return null; + } + } + + protected function getFallbackDistributionDepartmentId(): int + { + try { + $department_id = (new economic())->getDefaultDistributionDepartmentId(); + } catch (\Throwable $e) { + $department_id = economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; + } + + if ($department_id <= 0 || $department_id === self::SYSTEM_ORDER_DEPARTMENT_ID) { + return economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; + } + + return $department_id; + } + + private function normalizeSubscriptionGroupAllocation(array $distribution, float $monthly_price): array + { + if (empty($distribution)) { + return []; + } + + $has_fractional = false; + foreach ($distribution as $v) { + if (abs((float)$v - round((float)$v)) > 0.00001) { + $has_fractional = true; + break; + } + } + + if (!$has_fractional) { + $departments = array_keys($distribution); + $count = count($departments); + if ($count === 0) { + return []; + } + $per_department = $monthly_price / $count; + $out = []; + foreach ($departments as $department_id) { + $out[(int)$department_id] = $per_department; + } + return $out; + } + + return array_map(static fn($amount) => (float)$amount, $distribution); + } + + protected function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float + { + $total = 0.0; + foreach ($order_items as $item) { + $product_id = (int)($item['product_id'] ?? 0); + $quantity = (float)($item['quantity'] ?? 0); + if ($product_id <= 0 || $quantity <= 0) { + continue; + } + + $explicit_price = (float)($item['price'] ?? 0); + if ($explicit_price > 0) { + $line_price = $explicit_price * $quantity; + } else { + $line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity; + } + + $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp); + $discount_percentage = (float)($discount_row['discount'] ?? 0); + if ($discount_percentage > 0) { + $line_price *= (1 - ($discount_percentage / 100)); + } + $total += $line_price; + } + return $total; + } + + protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array + { + $cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19); + if (array_key_exists($cache_key, $this->discount_resolution_cache)) { + return $this->discount_resolution_cache[$cache_key]; + } + + $direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp); + if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) { + return $this->discount_resolution_cache[$cache_key] = $direct; + } + + $product = $this->getProduct($product_id); + if ($product !== null) { + $category = (string)$product->category->value(); + if ($category !== '') { + $category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp); + if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) { + return $this->discount_resolution_cache[$cache_key] = $category_discount; + } + } + } + + return $this->discount_resolution_cache[$cache_key] = null; + } + + protected function getProductDepartmentPrice(int $product_id, int $department_id): float + { + if (!isset($this->product_department_price_cache[$department_id][$product_id])) { + $product = $this->getProduct($product_id); + if ($product === null) { + $this->product_department_price_cache[$department_id][$product_id] = 0.0; + } else { + $this->product_department_price_cache[$department_id][$product_id] = (float)$product->getDepartmentPrice($department_id); + } + } + return (float)$this->product_department_price_cache[$department_id][$product_id]; + } + + protected function getSubscriptionMonthlyPrice(int $vehicle_type): float + { + $product = $this->getProduct($vehicle_type); + if ($product === null) { + return 0.0; + } + return (float)$product->getSubscriptionMonthlyPrice(); + } + + private function getEconomicClient(): economic + { + if ($this->economic === null) { + $this->economic = new economic(); + } + + return $this->economic; + } + + protected function toArray(mixed $value): array + { + if (is_array($value)) { + return $value; + } + if (!is_object($value)) { + return []; + } + + return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true) ?: []; + } + + private function getProduct(int $product_id): ?products_o + { + if (!isset($this->product_cache[$product_id])) { + $product = new products_o(); + $product->select($product_id); + if (!$product->exists()) { + $this->product_cache[$product_id] = null; + } else { + $this->product_cache[$product_id] = $product; + } + } + return $this->product_cache[$product_id]; + } + + protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array + { + return [ + 'id' => (new users_o())->getUserByCustomerNumber($customer_number)->id, + 'customer_number' => $customer_number, + 'customer_name' => $this->getCustomerName($customer_number), + 'transactions' => array_values($transaction_map), + 'requires_action' => false, + 'meta' => [], + ]; + } + + protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null, ?bool $included = null): array + { + $order = (new orders_o())->select($order_id); + return [ + 'id' => $order_id, + 'date' => $created_at, + 'amount' => round((float)($amount ?? (float)$order->getNetAmount()), 5), + 'booked' => $order->isBooked(true), + 'department_id' => $department_id, + 'excluded' => !($included ?? $this->isDepartmentEligible($department_id)), + ]; + } + + protected function isOrderEligible(array $order): bool + { + $department_id = (int)($order['department_id'] ?? 0); + if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) { + return false; + } + + $override = orders_o::normalizeNullableBooleanValue($order['include_in_invoice'] ?? null); + if ($override !== null) { + return $override; + } + + return $this->isDepartmentEligible($department_id); + } + + private function getCustomerName(int $customer_number): string + { + if (!isset($this->customer_name_cache[$customer_number])) { + $this->customer_name_cache[$customer_number] = (new users_o())->getCustomerName($customer_number) ?? 'Unknown Customer'; + } + return (string)$this->customer_name_cache[$customer_number]; + } + + protected function shouldIncludeCustomerNumber(int $customer_number): bool + { + if ($customer_number <= 0) { + return false; + } + if (array_key_exists($customer_number, $this->customer_inclusion_cache)) { + return (bool)$this->customer_inclusion_cache[$customer_number]; + } + + $rows = (new users_o())->getFieldsWhere(['customer_number' => $customer_number], ['id']); + if (empty($rows)) { + $this->customer_inclusion_cache[$customer_number] = false; + return false; + } + + try { + $this->getCustomerName($customer_number); + } catch (\Throwable $e) { + $this->customer_inclusion_cache[$customer_number] = false; + return false; + } + + $this->customer_inclusion_cache[$customer_number] = true; + return true; + } + + protected function isDepartmentEligible(int $department_id): bool + { + if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) { + return false; + } + if (!array_key_exists($department_id, $this->department_excluded_cache)) { + try { + $this->department_excluded_cache[$department_id] = (new departments_o())->select($department_id)->isExcludedFromInvoicing(); + } catch (Exception $e) { + $this->department_excluded_cache[$department_id] = false; + } + } + return !$this->department_excluded_cache[$department_id]; + } + + protected function parseDepartmentMap(array $department_map): array + { + $parsed = []; + foreach ($department_map as $department_id => $amount) { + $parsed[$this->getDepartmentName((int)$department_id)] = round((float)$amount, 5); + } + return $parsed; + } + + private function getDepartmentName(int $department_id): string + { + if (!isset($this->department_name_cache[$department_id])) { + try { + $name = (new departments_o())->select($department_id)->name->value(); + $this->department_name_cache[$department_id] = !empty($name) + ? (string)$name + : 'Unknown Department (' . $department_id . ')'; + } catch (Exception $e) { + $this->department_name_cache[$department_id] = 'Unknown Department (' . $department_id . ')'; + } + } + return (string)$this->department_name_cache[$department_id]; + } + + private function roundMap(array $map): array + { + $out = []; + foreach ($map as $key => $value) { + $out[(string)$key] = round((float)$value, 5); + } + return $out; + } + + private function listMonthKeys(string $from_ts, string $to_ts): array + { + $start = new DateTime(date('Y-m-01 00:00:00', strtotime($from_ts))); + $end = new DateTime(date('Y-m-01 00:00:00', strtotime($to_ts))); + $end->modify('+1 month'); + + $period = new DatePeriod($start, new DateInterval('P1M'), $end); + $months = []; + foreach ($period as $dt) { + $months[] = $dt->format('Y-m'); + } + return $months; + } +} diff --git a/services/nginx/app/classes/economic_v2_line_normalizer.php b/services/nginx/app/classes/economic_v2_line_normalizer.php new file mode 100644 index 00000000..e4ec69da --- /dev/null +++ b/services/nginx/app/classes/economic_v2_line_normalizer.php @@ -0,0 +1,290 @@ +getOrders() as $order_row) { + $order = (new orders_o())->select((int)$order_row['id']); + $department_id = (int)$order->department_id->value(); + $order_items = (new order_items_o())->getAllItemsAsArray( + (int)$order->id, + ['id', 'product_id', 'reference', 'notes', 'price', 'quantity', 'include_in_invoice'] + ); + + foreach ($order_items as $row) { + if (!(bool)($row['include_in_invoice'] ?? false)) { + continue; + } + + $product = (new products_o())->getProductById((int)$row['product_id']); + if (!$product->exists()) { + $warnings[] = 'Missing product for internal order item id ' . (int)$row['id']; + continue; + } + + $product_number = $product->economic_product_id->value(); + $quantity = (float)$row['quantity']; + $unit_net_price = (float)$row['price']; + $line_net_amount = $quantity * $unit_net_price; + $department_distribution = [$department_id => 100.0]; + + $line = [ + 'index' => count($lines), + 'source' => 'internal', + 'source_order_id' => (int)$order->id, + 'source_line_id' => (int)$row['id'], + 'line_type' => self::detectLineType($product_number, $unit_net_price, true), + 'billable' => true, + 'product_number' => $product_number !== null ? (string)$product_number : null, + 'product_id' => (int)$row['product_id'], + 'description' => (string)$product->name->value(), + 'reference' => (string)($row['reference'] ?? ''), + 'quantity' => $quantity, + 'unit_net_price' => $unit_net_price, + 'line_net_amount' => $line_net_amount, + 'department_distribution' => $department_distribution, + ]; + $line['match_key'] = self::buildMatchKey($line); + $lines[] = $line; + } + } + + return self::wrap('internal', $lines, $warnings); + } + + public static function normalizeDraftInvoice(object|array|null $draft_invoice): array + { + if ($draft_invoice === null) { + return self::wrap('draft', [], ['Draft invoice missing']); + } + + $data = self::toArray($draft_invoice); + $raw_lines = is_array($data['lines'] ?? null) ? $data['lines'] : []; + $lines = []; + + foreach ($raw_lines as $raw_line) { + $line = self::normalizeEconomicLine($raw_line, 'draft'); + $line['index'] = count($lines); + $line['source_line_id'] = isset($raw_line['lineNumber']) ? (int)$raw_line['lineNumber'] : (int)($raw_line['line_number'] ?? count($lines) + 1); + $line['match_key'] = self::buildMatchKey($line); + $lines[] = $line; + } + + $wrapped = self::wrap('draft', $lines, []); + if (isset($data['netAmount'])) { + $wrapped['totals']['net_total'] = (float)$data['netAmount']; + } elseif (isset($data['net_amount'])) { + $wrapped['totals']['net_total'] = (float)$data['net_amount']; + } + $wrapped['totals']['difference_from_line_sum'] = round( + (float)$wrapped['totals']['net_total'] - (float)$wrapped['totals']['line_net_total'], + 5 + ); + + return $wrapped; + } + + public static function normalizeBookedInvoice(object|array|null $booked_invoice): array + { + if ($booked_invoice === null) { + return self::wrap('booked', [], ['Booked invoice missing']); + } + + if ($booked_invoice instanceof economic_invoice_booked) { + $data = $booked_invoice->toArray(); + } else { + $data = self::toArray($booked_invoice); + } + + $raw_lines = is_array($data['lines'] ?? null) ? $data['lines'] : []; + $lines = []; + foreach ($raw_lines as $raw_line) { + $line = self::normalizeEconomicLine($raw_line, 'booked'); + $line['index'] = count($lines); + $line['source_line_id'] = (int)($raw_line['lineNumber'] ?? $raw_line['line_number'] ?? count($lines) + 1); + $line['match_key'] = self::buildMatchKey($line); + $lines[] = $line; + } + + $wrapped = self::wrap('booked', $lines, []); + if (isset($data['netAmount'])) { + $wrapped['totals']['net_total'] = (float)$data['netAmount']; + } elseif (isset($data['net_amount'])) { + $wrapped['totals']['net_total'] = (float)$data['net_amount']; + } + $wrapped['totals']['difference_from_line_sum'] = round( + (float)$wrapped['totals']['net_total'] - (float)$wrapped['totals']['line_net_total'], + 5 + ); + + return $wrapped; + } + + private static function normalizeEconomicLine(array $raw_line, string $source): array + { + $line = self::toArray($raw_line); + + $product_number = $line['product']['productNumber'] + ?? $line['product']['product_number'] + ?? null; + $description = (string)($line['description'] ?? ''); + $quantity = isset($line['quantity']) ? (float)$line['quantity'] : 0.0; + $unit_net_price = isset($line['unitNetPrice']) + ? (float)$line['unitNetPrice'] + : (isset($line['unit_net_price']) ? (float)$line['unit_net_price'] : 0.0); + $line_net_amount = isset($line['totalNetAmount']) + ? (float)$line['totalNetAmount'] + : (isset($line['total_net_amount']) ? (float)$line['total_net_amount'] : $quantity * $unit_net_price); + + $department_distribution = self::extractDepartmentDistribution($line); + $billable = ($product_number !== null) || abs($line_net_amount) > 0.00001 || abs($quantity) > 0.00001; + + return [ + 'source' => $source, + 'source_order_id' => null, + 'line_type' => self::detectLineType($product_number, $unit_net_price, $billable), + 'billable' => $billable, + 'product_number' => $product_number !== null ? (string)$product_number : null, + 'product_id' => null, + 'description' => $description, + 'reference' => '', + 'quantity' => $quantity, + 'unit_net_price' => $unit_net_price, + 'line_net_amount' => $line_net_amount, + 'department_distribution' => $department_distribution, + ]; + } + + private static function extractDepartmentDistribution(array $line): array + { + $distribution = []; + $dd = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null; + if (is_array($dd)) { + $distributions = $dd['distributions'] ?? null; + if (is_array($distributions)) { + foreach ($distributions as $entry) { + $department_number = $entry['department']['departmentNumber'] + ?? $entry['department']['department_number'] + ?? null; + if ($department_number === null) { + continue; + } + $distribution[(string)$department_number] = (float)($entry['percentage'] ?? 0.0); + } + } + + if (empty($distribution) && isset($dd['departmentalDistributionNumber'])) { + $distribution[(string)$dd['departmentalDistributionNumber']] = 100.0; + } elseif (empty($distribution) && isset($dd['departmental_distribution_number'])) { + $distribution[(string)$dd['departmental_distribution_number']] = 100.0; + } + } + + if (empty($distribution)) { + $distribution['unassigned'] = 100.0; + } + + return $distribution; + } + + private static function wrap(string $source, array $lines, array $warnings): array + { + $net_total = 0.0; + $line_net_total = 0.0; + $billable_count = 0; + $departments = []; + + foreach ($lines as $line) { + $line_net_total += (float)$line['line_net_amount']; + if (!(bool)$line['billable']) { + continue; + } + $billable_count++; + $line_amount = (float)$line['line_net_amount']; + $net_total += $line_amount; + + foreach ($line['department_distribution'] as $department_key => $percentage) { + if (!isset($departments[$department_key])) { + $departments[$department_key] = 0.0; + } + $departments[$department_key] += $line_amount * ((float)$percentage / 100); + } + } + + return [ + 'source' => $source, + 'totals' => [ + 'net_total' => round($net_total, 5), + 'line_net_total' => round($line_net_total, 5), + 'line_count' => count($lines), + 'billable_line_count' => $billable_count, + ], + 'departments' => self::roundMap($departments), + 'lines' => $lines, + 'warnings' => $warnings, + ]; + } + + private static function buildMatchKey(array $line): string + { + if (!empty($line['product_number'])) { + return 'product:' . strtolower(trim((string)$line['product_number'])) . + '|ref:' . strtolower(trim((string)($line['reference'] ?? ''))); + } + + return 'text:' . self::normalizeText((string)$line['description']); + } + + private static function detectLineType(mixed $product_number, float $unit_net_price, bool $billable): string + { + if (!$billable) { + return 'text'; + } + + if ($product_number !== null && strtolower((string)$product_number) === 'totdiscount') { + return 'discount'; + } + + if ($unit_net_price < 0) { + return 'discount'; + } + + return $product_number !== null ? 'product' : 'text'; + } + + private static function normalizeText(string $text): string + { + $text = trim(strtolower($text)); + $text = preg_replace('/\s+/', ' ', $text); + return $text ?? ''; + } + + private static function roundMap(array $map): array + { + $rounded = []; + foreach ($map as $k => $v) { + $rounded[(string)$k] = round((float)$v, 5); + } + return $rounded; + } + + private static function toArray(object|array $value): array + { + if (is_array($value)) { + return $value; + } + return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE), true) ?: []; + } +} diff --git a/services/nginx/app/classes/economic_v2_revenue_statistics_service.php b/services/nginx/app/classes/economic_v2_revenue_statistics_service.php new file mode 100644 index 00000000..a17a5107 --- /dev/null +++ b/services/nginx/app/classes/economic_v2_revenue_statistics_service.php @@ -0,0 +1,503 @@ + */ + private array $customer_cache = []; + + public function __construct(?economic $economic = null) + { + $this->economic = $economic ?? new economic(); + } + + public function getBookedRevenueStatistics(array $filters = []): array + { + $normalized_filters = $this->normalizeFilters($filters); + $warnings = []; + + $booked_invoices = $this->fetchBookedInvoices($normalized_filters, $warnings); + $invoice_ids = []; + foreach ($booked_invoices as $invoice) { + $invoice_id = (int)($invoice->bookedInvoiceNumber ?? 0); + if ($invoice_id > 0) { + $invoice_ids[] = $invoice_id; + } + } + + $invoice_lines_map = []; + if (!empty($invoice_ids)) { + try { + $invoice_lines_map = $this->economic->invoices->booked->get_invoice_lines($invoice_ids); + } catch (\Throwable $e) { + $warnings[] = 'Unable to fetch booked invoice lines in bulk: ' . $e->getMessage(); + } + } + + $summary = [ + 'invoice_count' => 0, + 'line_count' => 0, + 'unique_customers' => 0, + 'net_amount' => 0.0, + 'vat_amount' => 0.0, + 'gross_amount' => 0.0, + 'average_invoice_net_amount' => 0.0, + ]; + + $customers = []; + $departments = []; + $currencies = []; + $seen_customers = []; + + $customer_filter_map = array_fill_keys($normalized_filters['customer_numbers'], true); + $department_filter_map = array_fill_keys($normalized_filters['department_numbers'], true); + $has_customer_filter = !empty($customer_filter_map); + $has_department_filter = !empty($department_filter_map); + + foreach ($booked_invoices as $invoice) { + $invoice_id = (int)($invoice->bookedInvoiceNumber ?? 0); + if ($invoice_id <= 0) { + continue; + } + + $customer_number = (int)($invoice->customer->customerNumber ?? 0); + if ($has_customer_filter && !isset($customer_filter_map[$customer_number])) { + continue; + } + + $customer_snapshot = $this->resolveCustomerSnapshot($customer_number, $warnings); + if (!$this->passesBarredFilter($customer_snapshot['barred'], $normalized_filters['barred'])) { + continue; + } + + $invoice_currency = strtoupper((string)($invoice->currency ?? '')); + if ($normalized_filters['currency'] !== null && $invoice_currency !== $normalized_filters['currency']) { + continue; + } + + $invoice_lines = $invoice_lines_map[$invoice_id] ?? []; + $line_reduction = $this->reduceInvoiceLines( + $invoice_id, + $invoice_lines, + $departments, + $department_filter_map, + $has_department_filter + ); + + if ($has_department_filter && !$line_reduction['has_matching_departments']) { + continue; + } + + $invoice_net = $has_department_filter + ? (float)$line_reduction['net_amount'] + : (float)($invoice->netAmount ?? $invoice->net_amount ?? $line_reduction['net_amount']); + $invoice_vat = $has_department_filter + ? (float)$line_reduction['vat_amount'] + : (float)($invoice->vatAmount ?? $invoice->vat_amount ?? $line_reduction['vat_amount']); + $invoice_gross = $has_department_filter + ? (float)$line_reduction['gross_amount'] + : (float)($invoice->grossAmount ?? $invoice->gross_amount ?? ($invoice_net + $invoice_vat)); + + if ( + $has_department_filter && + abs($invoice_net) < self::EPSILON && + abs($invoice_vat) < self::EPSILON && + abs($invoice_gross) < self::EPSILON + ) { + continue; + } + + $summary['invoice_count']++; + $summary['line_count'] += (int)$line_reduction['line_count']; + $summary['net_amount'] += $invoice_net; + $summary['vat_amount'] += $invoice_vat; + $summary['gross_amount'] += $invoice_gross; + + if (!isset($seen_customers[$customer_number])) { + $seen_customers[$customer_number] = true; + } + + if (!isset($customers[$customer_number])) { + $customers[$customer_number] = [ + 'customer_number' => $customer_number, + 'customer_name' => $customer_snapshot['name'], + 'barred' => $customer_snapshot['barred'], + 'invoice_count' => 0, + 'net_amount' => 0.0, + 'vat_amount' => 0.0, + 'gross_amount' => 0.0, + ]; + } + $customers[$customer_number]['invoice_count']++; + $customers[$customer_number]['net_amount'] += $invoice_net; + $customers[$customer_number]['vat_amount'] += $invoice_vat; + $customers[$customer_number]['gross_amount'] += $invoice_gross; + + $currency_key = $invoice_currency !== '' ? $invoice_currency : 'UNKNOWN'; + if (!isset($currencies[$currency_key])) { + $currencies[$currency_key] = [ + 'currency' => $currency_key, + 'invoice_count' => 0, + 'net_amount' => 0.0, + 'vat_amount' => 0.0, + 'gross_amount' => 0.0, + ]; + } + $currencies[$currency_key]['invoice_count']++; + $currencies[$currency_key]['net_amount'] += $invoice_net; + $currencies[$currency_key]['vat_amount'] += $invoice_vat; + $currencies[$currency_key]['gross_amount'] += $invoice_gross; + } + + $summary['unique_customers'] = count($seen_customers); + if ($summary['invoice_count'] > 0) { + $summary['average_invoice_net_amount'] = $summary['net_amount'] / $summary['invoice_count']; + } + + $customer_rows = array_values($customers); + usort($customer_rows, static function (array $a, array $b): int { + return $b['invoice_count'] <=> $a['invoice_count']; + }); + + $department_rows = []; + foreach ($departments as $department_key => $row) { + $department_rows[] = [ + 'department_key' => $department_key, + 'department_number' => is_numeric((string)$department_key) ? (int)$department_key : null, + 'invoice_count' => count($row['invoice_ids']), + 'line_count' => $row['line_count'], + 'net_amount' => $row['net_amount'], + 'vat_amount' => $row['vat_amount'], + 'gross_amount' => $row['gross_amount'], + ]; + } + usort($department_rows, static function (array $a, array $b): int { + return abs((float)$b['net_amount']) <=> abs((float)$a['net_amount']); + }); + + $currency_rows = array_values($currencies); + usort($currency_rows, static function (array $a, array $b): int { + return $b['invoice_count'] <=> $a['invoice_count']; + }); + + return [ + 'filters' => [ + 'dateFrom' => $normalized_filters['dateFrom'], + 'dateTo' => $normalized_filters['dateTo'], + 'customer_numbers' => array_values($normalized_filters['customer_numbers']), + 'department_numbers' => array_values($normalized_filters['department_numbers']), + 'currency' => $normalized_filters['currency'], + 'barred' => $normalized_filters['barred'], + 'max_pages' => $normalized_filters['max_pages'], + ], + 'summary' => $this->roundNumericValues($summary), + 'customers' => $this->roundRows($customer_rows), + 'departments' => $this->roundRows($department_rows), + 'currencies' => $this->roundRows($currency_rows), + 'warnings' => array_values(array_unique($warnings)), + ]; + } + + private function normalizeFilters(array $filters): array + { + $date_from = (string)($filters['dateFrom'] ?? date('Y-m-01')); + $date_to = (string)($filters['dateTo'] ?? date('Y-m-d')); + + $customer_numbers = $this->normalizeIntegerList($filters['customer_numbers'] ?? []); + $department_numbers = $this->normalizeIntegerList($filters['department_numbers'] ?? []); + + $currency = isset($filters['currency']) && trim((string)$filters['currency']) !== '' + ? strtoupper(trim((string)$filters['currency'])) + : null; + + $barred = strtolower(trim((string)($filters['barred'] ?? 'all'))); + if (!in_array($barred, ['all', 'barred', 'active'], true)) { + $barred = 'all'; + } + + $max_pages = (int)($filters['max_pages'] ?? self::DEFAULT_MAX_PAGES); + $max_pages = max(1, min(200, $max_pages)); + + return [ + 'dateFrom' => $date_from, + 'dateTo' => $date_to, + 'customer_numbers' => $customer_numbers, + 'department_numbers' => $department_numbers, + 'currency' => $currency, + 'barred' => $barred, + 'max_pages' => $max_pages, + ]; + } + + private function fetchBookedInvoices(array $normalized_filters, array &$warnings): array + { + $filters = [ + '(date$gte:' . $normalized_filters['dateFrom'] . '$and:date$lte:' . $normalized_filters['dateTo'] . ')' => '', + ]; + if ($normalized_filters['currency'] !== null) { + $filters['currency'] = '$eq:' . $normalized_filters['currency']; + } + if (count($normalized_filters['customer_numbers']) === 1) { + $filters['customer.customerNumber'] = '$eq:' . $normalized_filters['customer_numbers'][0]; + } + + $all = []; + for ($page = 0; $page < $normalized_filters['max_pages']; $page++) { + $response = $this->economic->invoices->booked->get( + $filters, + [ + 'skipPages' => $page, + 'pageSize' => self::DEFAULT_PAGE_SIZE, + ] + ); + $collection = is_array($response->collection ?? null) ? $response->collection : []; + $all = array_merge($all, $collection); + + if (count($collection) < self::DEFAULT_PAGE_SIZE) { + break; + } + if ($page + 1 >= $normalized_filters['max_pages']) { + $warnings[] = 'Reached pagination safety limit (max_pages=' . $normalized_filters['max_pages'] . ').'; + } + } + + return $all; + } + + /** + * @param array $department_totals + * @param array $department_filter_map + * @return array{net_amount:float,vat_amount:float,gross_amount:float,line_count:int,has_matching_departments:bool} + */ + private function reduceInvoiceLines( + int $invoice_id, + array $invoice_lines, + array &$department_totals, + array $department_filter_map, + bool $has_department_filter + ): array { + $invoice_net = 0.0; + $invoice_vat = 0.0; + $invoice_gross = 0.0; + $line_count = 0; + $has_matching_departments = false; + + foreach ($invoice_lines as $line_raw) { + $line = $this->toArray($line_raw); + + $quantity = isset($line['quantity']) ? (float)$line['quantity'] : 0.0; + $unit_net_price = isset($line['unitNetPrice']) + ? (float)$line['unitNetPrice'] + : (isset($line['unit_net_price']) ? (float)$line['unit_net_price'] : 0.0); + $line_net_amount = isset($line['totalNetAmount']) + ? (float)$line['totalNetAmount'] + : (isset($line['total_net_amount']) ? (float)$line['total_net_amount'] : $quantity * $unit_net_price); + $line_vat_amount = isset($line['vatAmount']) + ? (float)$line['vatAmount'] + : (isset($line['vat_amount']) ? (float)$line['vat_amount'] : $line_net_amount * ((float)($line['vatRate'] ?? 0.0) / 100)); + $line_gross_amount = $line_net_amount + $line_vat_amount; + + $is_billable = abs($line_net_amount) > self::EPSILON || abs($quantity) > self::EPSILON; + if (!$is_billable) { + continue; + } + + $distribution = $this->extractDepartmentDistribution($line); + $matching_percentage_total = 0.0; + foreach ($distribution as $department_key => $percentage) { + if ($has_department_filter && !isset($department_filter_map[(int)$department_key])) { + continue; + } + $matching_percentage_total += (float)$percentage; + $has_matching_departments = true; + + if (!isset($department_totals[$department_key])) { + $department_totals[$department_key] = [ + 'invoice_ids' => [], + 'line_count' => 0, + 'net_amount' => 0.0, + 'vat_amount' => 0.0, + 'gross_amount' => 0.0, + ]; + } + + $ratio = ((float)$percentage / 100.0); + $department_totals[$department_key]['invoice_ids'][$invoice_id] = true; + $department_totals[$department_key]['line_count']++; + $department_totals[$department_key]['net_amount'] += $line_net_amount * $ratio; + $department_totals[$department_key]['vat_amount'] += $line_vat_amount * $ratio; + $department_totals[$department_key]['gross_amount'] += $line_gross_amount * $ratio; + } + + if ($has_department_filter && $matching_percentage_total <= self::EPSILON) { + continue; + } + + $factor = $has_department_filter ? ($matching_percentage_total / 100.0) : 1.0; + $invoice_net += $line_net_amount * $factor; + $invoice_vat += $line_vat_amount * $factor; + $invoice_gross += $line_gross_amount * $factor; + $line_count++; + } + + return [ + 'net_amount' => $invoice_net, + 'vat_amount' => $invoice_vat, + 'gross_amount' => $invoice_gross, + 'line_count' => $line_count, + 'has_matching_departments' => $has_matching_departments, + ]; + } + + /** + * @return array + */ + private function extractDepartmentDistribution(array $line): array + { + $distribution = []; + $departmental_distribution = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null; + if (!is_array($departmental_distribution)) { + return ['unassigned' => 100.0]; + } + + $distributions = $departmental_distribution['distributions'] ?? null; + if (is_array($distributions)) { + foreach ($distributions as $entry_raw) { + $entry = $this->toArray($entry_raw); + $department_number = $entry['department']['departmentNumber'] + ?? $entry['department']['department_number'] + ?? null; + if ($department_number === null) { + continue; + } + $distribution[(int)$department_number] = (float)($entry['percentage'] ?? 0.0); + } + } + + if (empty($distribution)) { + $fallback_number = $departmental_distribution['departmentalDistributionNumber'] + ?? $departmental_distribution['departmental_distribution_number'] + ?? null; + if ($fallback_number !== null) { + $distribution[(int)$fallback_number] = 100.0; + } + } + + if (empty($distribution)) { + $distribution['unassigned'] = 100.0; + } + + return $distribution; + } + + /** + * @return array{customer_number:int,name:?string,barred:?bool,status:string} + */ + private function resolveCustomerSnapshot(int $customer_number, array &$warnings): array + { + if (isset($this->customer_cache[$customer_number])) { + return $this->customer_cache[$customer_number]; + } + + $snapshot = [ + 'customer_number' => $customer_number, + 'name' => null, + 'barred' => null, + 'status' => 'unknown', + ]; + + if ($customer_number <= 0) { + $this->customer_cache[$customer_number] = $snapshot; + return $snapshot; + } + + try { + $raw = $this->economic->customers->customers->get($customer_number); + if (isset($raw->customerNumber)) { + $snapshot['name'] = isset($raw->name) ? (string)$raw->name : null; + $snapshot['barred'] = isset($raw->barred) ? (bool)$raw->barred : null; + $snapshot['status'] = 'resolved'; + } else { + $warnings[] = 'Unable to resolve e-conomic customer ' . $customer_number . ' while evaluating barred filter.'; + } + } catch (\Throwable $e) { + $warnings[] = 'Failed to fetch e-conomic customer ' . $customer_number . ': ' . $e->getMessage(); + } + + $this->customer_cache[$customer_number] = $snapshot; + return $snapshot; + } + + private function passesBarredFilter(?bool $barred, string $mode): bool + { + return match ($mode) { + 'barred' => $barred === true, + 'active' => $barred !== true, + default => true, + }; + } + + private function toArray(mixed $value): array + { + if (is_array($value)) { + return $value; + } + if (!is_object($value)) { + return []; + } + return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true) ?: []; + } + + private function normalizeIntegerList(mixed $raw): array + { + $values = []; + if (is_array($raw)) { + $values = $raw; + } elseif (is_string($raw)) { + $values = explode(',', $raw); + } elseif (is_numeric($raw)) { + $values = [$raw]; + } + + $normalized = []; + foreach ($values as $value) { + $int_value = (int)$value; + if ($int_value > 0) { + $normalized[$int_value] = true; + } + } + + return array_map('intval', array_keys($normalized)); + } + + private function roundRows(array $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[] = $this->roundNumericValues($row); + } + return $result; + } + + private function roundNumericValues(array $data): array + { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->roundNumericValues($value); + continue; + } + if (is_float($value)) { + $data[$key] = round($value, 5); + } + } + return $data; + } +} + diff --git a/services/nginx/app/classes/economic_v2_schema_bootstrap.php b/services/nginx/app/classes/economic_v2_schema_bootstrap.php new file mode 100644 index 00000000..655ebf7d --- /dev/null +++ b/services/nginx/app/classes/economic_v2_schema_bootstrap.php @@ -0,0 +1,109 @@ +query($sql); + } + + self::$initialized = true; + } + + public static function tableHasColumn(string $table, string $column): bool + { + global $db; + $table = $db->escape_string($table); + $column = $db->escape_string($column); + $database = $db->escape_string($db->getDatabase()); + + $sql = "SELECT COUNT(*) AS c + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = '$database' + AND TABLE_NAME = '$table' + AND COLUMN_NAME = '$column'"; + $result = $db->query($sql); + if (!$result) { + return false; + } + $row = $result->fetch_assoc(); + return ((int)($row['c'] ?? 0)) > 0; + } +} + diff --git a/services/nginx/app/classes/economic_v2_versioning_service.php b/services/nginx/app/classes/economic_v2_versioning_service.php new file mode 100644 index 00000000..13c9ab50 --- /dev/null +++ b/services/nginx/app/classes/economic_v2_versioning_service.php @@ -0,0 +1,710 @@ +closeActiveFixedPricingVersion( + $customer_number, + $effective_from, + $source, + $confidence, + $inferred, + $metadata + ); + } + + return $this->upsertVersion( + 'customer_fixed_pricing_versions', + [ + 'customer_number' => $customer_number, + ], + [ + 'price' => (int)$price, + 'description' => $description ?? '', + ], + $this->normalizeDatetime($effective_from), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function closeActiveFixedPricingVersion( + int $customer_number, + ?string $effective_to = null, + string $source = 'live.fixed_pricing', + float $confidence = 1.0, + bool $inferred = false, + array $metadata = [] + ): array { + return $this->closeActiveVersion( + 'customer_fixed_pricing_versions', + [ + 'customer_number' => $customer_number, + ], + $this->normalizeDatetime($effective_to), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function recordVehicleSubscriptionVersion( + array $state, + ?string $effective_from = null, + string $source = 'live.vehicle', + float $confidence = 1.0, + bool $inferred = false, + array $metadata = [] + ): array { + if (!isset($state['customer_number'], $state['reg'], $state['vehicle_type'], $state['wash_subscription'])) { + throw new Exception('Missing required vehicle version state keys'); + } + + return $this->upsertVersion( + 'customer_vehicle_subscription_versions', + [ + 'customer_number' => (int)$state['customer_number'], + 'reg' => (string)$state['reg'], + ], + [ + 'vehicle_id' => isset($state['vehicle_id']) ? (int)$state['vehicle_id'] : null, + 'vehicle_type' => (int)$state['vehicle_type'], + 'wash_subscription' => (int)((bool)$state['wash_subscription']), + ], + $this->normalizeDatetime($effective_from), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function closeActiveVehicleSubscriptionVersion( + int $customer_number, + string $reg, + ?string $effective_to = null, + string $source = 'live.vehicle', + float $confidence = 1.0, + bool $inferred = false, + array $metadata = [] + ): array { + return $this->closeActiveVersion( + 'customer_vehicle_subscription_versions', + [ + 'customer_number' => $customer_number, + 'reg' => $reg, + ], + $this->normalizeDatetime($effective_to), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function recordDiscountOverrideVersion( + int $user_id, + int $customer_number, + bool $is_category, + int|string $object_id, + ?int $discount, + ?string $effective_from = null, + string $source = 'live.discount_override', + float $confidence = 1.0, + bool $inferred = false, + array $metadata = [] + ): array { + $identity = [ + 'user_id' => $user_id, + 'customer_number' => $customer_number, + 'is_category' => (int)$is_category, + 'object_id' => (string)$object_id, + ]; + + if ($discount === null || (int)$discount === 0) { + return $this->closeActiveVersion( + 'customer_discount_override_versions', + $identity, + $this->normalizeDatetime($effective_from), + $source, + $confidence, + $inferred, + $metadata + ); + } + + return $this->upsertVersion( + 'customer_discount_override_versions', + $identity, + [ + 'discount' => (int)$discount, + ], + $this->normalizeDatetime($effective_from), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function listFixedPricingVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array + { + return $this->listVersions( + 'customer_fixed_pricing_versions', + ['customer_number' => $customer_number], + $date_from, + $date_to + ); + } + + public function listVehicleSubscriptionVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array + { + return $this->listVersions( + 'customer_vehicle_subscription_versions', + ['customer_number' => $customer_number], + $date_from, + $date_to + ); + } + + public function listDiscountOverrideVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array + { + return $this->listVersions( + 'customer_discount_override_versions', + ['customer_number' => $customer_number], + $date_from, + $date_to + ); + } + + public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array + { + $rows = $this->resolveActiveVersions( + 'customer_fixed_pricing_versions', + ['customer_number' => $customer_number], + $timestamp, + 'effective_from DESC, id DESC', + 1 + ); + return $rows[0] ?? null; + } + + public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array + { + $rows = $this->resolveActiveVersions( + 'customer_vehicle_subscription_versions', + [ + 'customer_number' => $customer_number, + 'wash_subscription' => 1, + ], + $timestamp, + 'reg ASC, effective_from DESC, id DESC' + ); + + $unique = []; + foreach ($rows as $row) { + $reg = (string)$row['reg']; + if (!isset($unique[$reg])) { + $unique[$reg] = $row; + } + } + return array_values($unique); + } + + public function resolveDiscountOverrideAt( + int $customer_number, + bool $is_category, + int|string $object_id, + string $timestamp + ): ?array { + $rows = $this->resolveActiveVersions( + 'customer_discount_override_versions', + [ + 'customer_number' => $customer_number, + 'is_category' => (int)$is_category, + 'object_id' => (string)$object_id, + ], + $timestamp, + 'effective_from DESC, id DESC', + 1 + ); + return $rows[0] ?? null; + } + + public function runBestEffortBackfill(): array + { + global $db; + economic_v2_schema_bootstrap::ensureTables(); + + $report = [ + 'fixed_pricing' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'vehicle_subscriptions' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'discount_overrides' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'inferred' => ['fixed_pricing' => 0, 'vehicle_subscriptions' => 0], + 'warnings' => [], + ]; + + // Fixed pricing current state. + $has_fixed_created_at = economic_v2_schema_bootstrap::tableHasColumn('customer_fixed_pricing', 'created_at'); + $fixed_cols = $has_fixed_created_at + ? 'customer_number, price, description, created_at' + : 'customer_number, price, description'; + $fixed_rows = $this->fetchAll("SELECT $fixed_cols FROM customer_fixed_pricing"); + foreach ($fixed_rows as $row) { + $effective_from = $has_fixed_created_at + ? $this->normalizeDatetime((string)$row['created_at']) + : $this->normalizeDatetime(null); + $confidence = $has_fixed_created_at ? 0.8 : 0.6; + $result = $this->recordFixedPricingVersion( + (int)$row['customer_number'], + (int)$row['price'], + (string)($row['description'] ?? ''), + $effective_from, + 'backfill.current_fixed_pricing', + $confidence, + true, + ['table' => 'customer_fixed_pricing'] + ); + $this->incrementReportAction($report['fixed_pricing'], $result['action'] ?? 'noop'); + } + + // Infer fixed pricing start from synthetic fixed-price orders when no timeline exists. + $fixed_inferred = $this->fetchAll( + "SELECT o.customer_id AS customer_number, MIN(o.created_at) AS first_seen, MAX(oi.price) AS inferred_price + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + WHERE o.deleted_at IS NULL + AND oi.deleted_at IS NULL + AND o.reference = 'Fast pris aftale' + AND oi.product_id = 61 + GROUP BY o.customer_id" + ); + foreach ($fixed_inferred as $row) { + $customer_number = (int)$row['customer_number']; + if ($this->resolveFixedPricingVersionAt($customer_number, (string)$row['first_seen']) !== null) { + continue; + } + $price = (int)($row['inferred_price'] ?? 0); + if ($price <= 0) { + continue; + } + $this->recordFixedPricingVersion( + $customer_number, + $price, + 'Inferred from fixed-pricing invoice order', + $this->normalizeDatetime((string)$row['first_seen']), + 'backfill.inferred_fixed_pricing_order', + 0.55, + true, + ['reference' => 'Fast pris aftale', 'product_id' => 61] + ); + $report['inferred']['fixed_pricing']++; + } + + // Vehicle subscriptions current state. + $has_vehicle_created_at = economic_v2_schema_bootstrap::tableHasColumn('customer_vehicles', 'created_at'); + $has_vehicle_deleted_at = economic_v2_schema_bootstrap::tableHasColumn('customer_vehicles', 'deleted_at'); + $vehicle_cols = 'id, customer_id, reg, type, wash_subscription' . + ($has_vehicle_created_at ? ', created_at' : '') . + ($has_vehicle_deleted_at ? ', deleted_at' : ''); + $vehicle_rows = $this->fetchAll("SELECT $vehicle_cols FROM customer_vehicles"); + foreach ($vehicle_rows as $row) { + $effective_from = $has_vehicle_created_at + ? $this->normalizeDatetime((string)$row['created_at']) + : $this->normalizeDatetime(null); + $confidence = $has_vehicle_created_at ? 0.75 : 0.55; + $result = $this->recordVehicleSubscriptionVersion( + [ + 'vehicle_id' => (int)$row['id'], + 'customer_number' => (int)$row['customer_id'], + 'reg' => (string)$row['reg'], + 'vehicle_type' => (int)$row['type'], + 'wash_subscription' => (bool)$row['wash_subscription'], + ], + $effective_from, + 'backfill.current_vehicle', + $confidence, + true, + ['table' => 'customer_vehicles'] + ); + $this->incrementReportAction($report['vehicle_subscriptions'], $result['action'] ?? 'noop'); + + if ($has_vehicle_deleted_at && !empty($row['deleted_at'])) { + $close_result = $this->closeActiveVehicleSubscriptionVersion( + (int)$row['customer_id'], + (string)$row['reg'], + $this->normalizeDatetime((string)$row['deleted_at']), + 'backfill.current_vehicle_deleted', + 0.9, + true, + ['table' => 'customer_vehicles'] + ); + $this->incrementReportAction($report['vehicle_subscriptions'], $close_result['action'] ?? 'noop'); + } + } + + // Infer subscriptions from synthetic subscription orders. + $subscription_inferred = $this->fetchAll( + "SELECT o.customer_id AS customer_number, + oi.reference AS reg, + oi.product_id AS vehicle_type, + MIN(o.created_at) AS first_seen + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + WHERE o.deleted_at IS NULL + AND oi.deleted_at IS NULL + AND o.reference = 'Vaskeabonnementer' + AND oi.reference <> '' + AND oi.quantity > 0 + GROUP BY o.customer_id, oi.reference, oi.product_id" + ); + foreach ($subscription_inferred as $row) { + $resolved = $this->resolveVehicleSubscriptionVersionsAt((int)$row['customer_number'], (string)$row['first_seen']); + $already = false; + foreach ($resolved as $active) { + if ((string)$active['reg'] === (string)$row['reg']) { + $already = true; + break; + } + } + if ($already) { + continue; + } + $this->recordVehicleSubscriptionVersion( + [ + 'vehicle_id' => null, + 'customer_number' => (int)$row['customer_number'], + 'reg' => (string)$row['reg'], + 'vehicle_type' => (int)$row['vehicle_type'], + 'wash_subscription' => true, + ], + $this->normalizeDatetime((string)$row['first_seen']), + 'backfill.inferred_subscription_order', + 0.5, + true, + ['reference' => 'Vaskeabonnementer'] + ); + $report['inferred']['vehicle_subscriptions']++; + } + + // Discount overrides current state. + $has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at'); + $discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' . + ($has_override_created_at ? ', po.created_at' : ''); + $discount_rows = $this->fetchAll( + "SELECT $discount_cols + FROM price_overrides po + JOIN users u ON u.id = po.user_id" + ); + foreach ($discount_rows as $row) { + $effective_from = $has_override_created_at + ? $this->normalizeDatetime((string)$row['created_at']) + : $this->normalizeDatetime(null); + $confidence = $has_override_created_at ? 0.85 : 0.6; + $result = $this->recordDiscountOverrideVersion( + (int)$row['user_id'], + (int)$row['customer_number'], + (bool)$row['is_category'], + (string)$row['product_or_category_id'], + (int)$row['percentage'], + $effective_from, + 'backfill.current_discount_override', + $confidence, + true, + ['table' => 'price_overrides'] + ); + $this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop'); + } + + return $report; + } + + private function upsertVersion( + string $table, + array $identity, + array $values, + string $effective_from, + string $source, + float $confidence, + bool $inferred, + array $metadata + ): array { + global $db; + + $confidence = $this->normalizeConfidence($confidence); + $metadata_json = $db->escape_string(json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $source = $db->escape_string($source); + $effective_from = $db->escape_string($effective_from); + + // Close the previous active interval when a new one starts. + $close_to = $db->escape_string($this->minusOneSecond($effective_from)); + $identity_where = $this->buildWhereClause($identity); + $db->query( + "UPDATE $table + SET effective_to = '$close_to' + WHERE $identity_where + AND effective_from < '$effective_from' + AND (effective_to IS NULL OR effective_to >= '$effective_from')" + ); + + $existing = $this->fetchOne( + "SELECT id + FROM $table + WHERE $identity_where + AND effective_from = '$effective_from' + ORDER BY id DESC + LIMIT 1" + ); + + if ($existing !== null) { + $id = (int)$existing['id']; + $set_parts = []; + foreach ($values as $k => $v) { + $set_parts[] = $this->buildSetFragment($k, $v); + } + $set_parts[] = "source = '$source'"; + $set_parts[] = "confidence = $confidence"; + $set_parts[] = "inferred = " . ((int)$inferred); + $set_parts[] = "metadata_json = '$metadata_json'"; + $db->query("UPDATE $table SET " . implode(', ', $set_parts) . " WHERE id = $id"); + return [ + 'action' => 'updated', + 'row' => $this->fetchOne("SELECT * FROM $table WHERE id = $id"), + ]; + } + + $next_start = $this->fetchOne( + "SELECT effective_from + FROM $table + WHERE $identity_where + AND effective_from > '$effective_from' + ORDER BY effective_from ASC + LIMIT 1" + ); + $effective_to_value = null; + if ($next_start !== null && !empty($next_start['effective_from'])) { + $effective_to_value = $this->minusOneSecond((string)$next_start['effective_from']); + } + + $insert_data = [ + ...$identity, + ...$values, + 'effective_from' => $effective_from, + 'effective_to' => $effective_to_value, + 'source' => $source, + 'confidence' => $confidence, + 'inferred' => (int)$inferred, + 'metadata_json' => $metadata_json, + ]; + + $columns = []; + $values_sql = []; + foreach ($insert_data as $k => $v) { + $columns[] = $k; + $values_sql[] = $this->buildValueFragment($v); + } + + $db->query( + "INSERT INTO $table (" . implode(', ', $columns) . ") + VALUES (" . implode(', ', $values_sql) . ")" + ); + $id = (int)$db->insert_id(); + + return [ + 'action' => 'inserted', + 'row' => $this->fetchOne("SELECT * FROM $table WHERE id = $id"), + ]; + } + + private function closeActiveVersion( + string $table, + array $identity, + string $effective_to, + string $source, + float $confidence, + bool $inferred, + array $metadata + ): array { + global $db; + + $confidence = $this->normalizeConfidence($confidence); + $source = $db->escape_string($source); + $metadata_json = $db->escape_string(json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $effective_to = $db->escape_string($effective_to); + $identity_where = $this->buildWhereClause($identity); + + $result = $db->query( + "UPDATE $table + SET effective_to = '$effective_to', + source = '$source', + confidence = $confidence, + inferred = " . ((int)$inferred) . ", + metadata_json = '$metadata_json' + WHERE $identity_where + AND effective_from <= '$effective_to' + AND (effective_to IS NULL OR effective_to > '$effective_to')" + ); + + if ($result && $db->conn()->affected_rows > 0) { + return ['action' => 'closed']; + } + + return ['action' => 'noop']; + } + + private function listVersions(string $table, array $identity, ?string $date_from, ?string $date_to): array + { + $where = $this->buildWhereClause($identity); + if ($date_from !== null) { + $date_from = $this->normalizeDatetime($date_from); + $where .= " AND (effective_to IS NULL OR effective_to >= '" . $this->escape($date_from) . "')"; + } + if ($date_to !== null) { + $date_to = $this->normalizeDatetime($date_to); + $where .= " AND effective_from <= '" . $this->escape($date_to) . "'"; + } + return $this->fetchAll("SELECT * FROM $table WHERE $where ORDER BY effective_from ASC, id ASC"); + } + + private function resolveActiveVersions( + string $table, + array $identity, + string $timestamp, + string $order_by, + ?int $limit = null + ): array { + $timestamp = $this->normalizeDatetime($timestamp); + $where = $this->buildWhereClause($identity); + $where .= " AND effective_from <= '" . $this->escape($timestamp) . "'"; + $where .= " AND (effective_to IS NULL OR effective_to >= '" . $this->escape($timestamp) . "')"; + $sql = "SELECT * FROM $table WHERE $where ORDER BY $order_by"; + if ($limit !== null) { + $sql .= " LIMIT " . ((int)$limit); + } + return $this->fetchAll($sql); + } + + private function buildWhereClause(array $identity): string + { + $parts = []; + foreach ($identity as $k => $v) { + if ($v === null) { + $parts[] = "$k IS NULL"; + continue; + } + if (is_bool($v)) { + $parts[] = "$k = " . ((int)$v); + continue; + } + if (is_int($v) || is_float($v)) { + $parts[] = "$k = $v"; + continue; + } + $parts[] = "$k = '" . $this->escape((string)$v) . "'"; + } + return implode(' AND ', $parts); + } + + private function buildSetFragment(string $key, mixed $value): string + { + return "$key = " . $this->buildValueFragment($value); + } + + private function buildValueFragment(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + if (is_bool($value)) { + return (string)((int)$value); + } + if (is_int($value) || is_float($value)) { + return (string)$value; + } + return "'" . $this->escape((string)$value) . "'"; + } + + private function normalizeDatetime(?string $value): string + { + if ($value === null || trim($value) === '') { + return date('Y-m-d H:i:s'); + } + $dt = new DateTime($value); + return $dt->format('Y-m-d H:i:s'); + } + + private function minusOneSecond(string $datetime): string + { + $dt = new DateTime($datetime); + $dt->modify('-1 second'); + return $dt->format('Y-m-d H:i:s'); + } + + private function normalizeConfidence(float $confidence): float + { + if ($confidence < 0) { + return 0.0; + } + if ($confidence > 1) { + return 1.0; + } + return round($confidence, 5); + } + + private function escape(string $value): string + { + global $db; + return $db->escape_string($value); + } + + private function fetchAll(string $sql): array + { + global $db; + $result = $db->query($sql); + if (!$result) { + return []; + } + return $db->fetch_all($result); + } + + private function fetchOne(string $sql): ?array + { + $rows = $this->fetchAll($sql); + if (empty($rows)) { + return null; + } + return $rows[0]; + } + + private function incrementReportAction(array &$bucket, string $action): void + { + if (!isset($bucket[$action])) { + $bucket[$action] = 0; + } + $bucket[$action]++; + } +} + diff --git a/services/nginx/app/classes/edge_broker_client.php b/services/nginx/app/classes/edge_broker_client.php new file mode 100644 index 00000000..ff8d2cba --- /dev/null +++ b/services/nginx/app/classes/edge_broker_client.php @@ -0,0 +1,145 @@ +curlErrno; + } +} + +class edge_broker_http_exception extends Exception +{ + public function __construct(string $message, private readonly int $statusCode, int $code = 0, ?Exception $previous = null) + { + parent::__construct($message, $code, $previous); + } + + public function statusCode(): int + { + return $this->statusCode; + } +} + +class edge_broker_client +{ + private const DEFAULT_BROKER_URL = 'http://edge-broker:4300'; + + public function __construct( + private readonly ?string $baseUrl = null, + private readonly ?string $sharedSecret = null, + private readonly int $timeoutSeconds = 10 + ) { + } + + public function isConfigured(): bool + { + return trim((string)$this->resolveBaseUrl()) !== ''; + } + + public function dispatchCommand(int $gatewayId, string $commandType, array $payload): array + { + $url = rtrim($this->resolveBaseUrl(), '/') . '/api/gateways/' . $gatewayId . '/commands'; + $response = $this->request('POST', $url, [ + 'commandType' => $commandType, + 'payload' => $payload, + ]); + + return is_array($response) ? $response : ['ok' => false, 'response' => $response]; + } + + public function validateAgent(int $gatewayId, string $agentToken): array + { + $url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/agent/auth'; + $response = $this->request('POST', $url, [ + 'gatewayId' => $gatewayId, + 'agentToken' => $agentToken, + ]); + + return is_array($response) ? $response : []; + } + + public function validateShellSession(string $sessionToken): array + { + $url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell/auth'; + $response = $this->request('POST', $url, [ + 'sessionToken' => $sessionToken, + ]); + + return is_array($response) ? $response : []; + } + + public function closeShellSession(int $sessionId, string $sessionToken, string $transcript, string $closedReason): array + { + $url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell-sessions/' . $sessionId . '/close'; + $response = $this->request('POST', $url, [ + 'sessionToken' => $sessionToken, + 'transcript' => $transcript, + 'closedReason' => $closedReason, + ]); + + return is_array($response) ? $response : []; + } + + private function resolveBaseUrl(): string + { + return trim((string)($this->baseUrl ?? getenv('EDGE_BROKER_URL') ?: self::DEFAULT_BROKER_URL)); + } + + private function resolveSharedSecret(): string + { + return trim((string)($this->sharedSecret + ?? getenv('EDGE_BROKER_SHARED_SECRET') + ?: getenv('EDGE_INTERNAL_SECRET') + ?: '')); + } + + /** + * @throws Exception + */ + private function request(string $method, string $url, array $payload): array|object|null + { + if (trim($url) === '') { + throw new Exception('Edge broker URL is not configured'); + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds); + curl_setopt($ch, CURLOPT_HTTPHEADER, array_values(array_filter([ + 'Content-Type: application/json', + $this->resolveSharedSecret() !== '' ? 'X-Edge-Broker-Secret: ' . $this->resolveSharedSecret() : null, + ]))); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); + + $rawResponse = curl_exec($ch); + $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($rawResponse === false) { + throw new edge_broker_transport_exception('Edge broker request failed: ' . $curlError, $curlErrno); + } + + $decoded = json_decode((string)$rawResponse, true); + if ($statusCode >= 400) { + $message = is_array($decoded) + ? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed') + : 'Edge broker request failed'; + throw new edge_broker_http_exception($message, $statusCode); + } + + return $decoded; + } +} diff --git a/services/nginx/app/classes/edgegateway.php b/services/nginx/app/classes/edgegateway.php new file mode 100644 index 00000000..67cc2202 --- /dev/null +++ b/services/nginx/app/classes/edgegateway.php @@ -0,0 +1,89 @@ +config = new edgegateway_c(); + } + + /** + * @throws Exception + */ + public function requireModuleEnabled(): void + { + if (!$this->config->enabled->isTrue()) { + throw new Exception('The edge gateway module is not enabled'); + } + } + + public function isEnabled(): bool + { + try { + return $this->config->enabled->isTrue(); + } catch (Exception $exception) { + return false; + } + } + + public function defaultReleaseChannel(): string + { + $configured = trim((string)$this->config->default_release_channel->getVariableValue()); + return $configured !== '' ? $configured : 'stable'; + } + + public function defaultUpdateWindow(): string + { + $configured = trim((string)$this->config->default_update_window->getVariableValue()); + return $configured !== '' ? $configured : '02:00-04:00'; + } + + public function brokerUrl(): string + { + $configured = trim((string)$this->config->broker_url->getVariableValue()); + if ($configured !== '') { + return rtrim($configured, '/'); + } + + $fallback = trim((string)(getenv('EDGE_BROKER_URL') ?: '')); + return $fallback !== '' ? rtrim($fallback, '/') : ''; + } + + public function publicBrokerUrl(): string + { + $configured = trim((string)$this->config->public_broker_url->getVariableValue()); + if ($configured !== '') { + return rtrim($configured, '/'); + } + + $fallback = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')); + return $fallback !== '' ? rtrim($fallback, '/') : ''; + } + + public function brokerAuthMode(): string + { + $configured = trim((string)$this->config->broker_auth_mode->getVariableValue()); + return $configured !== '' ? $configured : 'manager'; + } + + public function brokerSharedSecret(): string + { + $configured = trim((string)$this->config->broker_shared_secret->getVariableValue()); + if ($configured !== '') { + return $configured; + } + + return trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); + } +} diff --git a/services/nginx/app/classes/email.php b/services/nginx/app/classes/email.php index a67d8d6a..53e1e0b1 100644 --- a/services/nginx/app/classes/email.php +++ b/services/nginx/app/classes/email.php @@ -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 @@ -488,4 +511,4 @@ use Psr\Http\Client\ClientExceptionInterface; $this->attachments ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/error_report_schema_bootstrap.php b/services/nginx/app/classes/error_report_schema_bootstrap.php new file mode 100644 index 00000000..3f2ad1f7 --- /dev/null +++ b/services/nginx/app/classes/error_report_schema_bootstrap.php @@ -0,0 +1,76 @@ +query("CREATE TABLE IF NOT EXISTS error_reports ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + status VARCHAR(16) NOT NULL DEFAULT 'open', + reporter_type VARCHAR(16) NOT NULL, + reporter_user_id INT NULL, + reporter_subuser_id INT NULL, + reporter_customer_number INT NULL, + reporter_customer_number_context INT NULL, + reporter_name VARCHAR(255) NULL, + reporter_email VARCHAR(255) NULL, + route_path VARCHAR(512) NULL, + page_url VARCHAR(1024) NULL, + release_trace_id VARCHAR(64) NULL, + frontend_version VARCHAR(128) NULL, + api_version VARCHAR(128) NULL, + screenshot_object_key VARCHAR(512) NOT NULL, + screenshot_mime_type VARCHAR(64) NOT NULL, + screenshot_size_bytes INT UNSIGNED NOT NULL DEFAULT 0, + before_error TEXT NOT NULL, + expected TEXT NOT NULL, + actual TEXT NOT NULL, + request_error_count INT UNSIGNED NOT NULL DEFAULT 0, + vue_error_count INT UNSIGNED NOT NULL DEFAULT 0, + request_errors_json LONGTEXT NULL, + vue_errors_json LONGTEXT NULL, + runtime_context_json LONGTEXT NULL, + data_collection_accepted TINYINT(1) NOT NULL DEFAULT 0, + data_collection_accepted_at DATETIME NOT NULL, + data_collection_policy_version VARCHAR(64) NOT NULL DEFAULT 'error-report-v1', + resolved_at DATETIME NULL, + resolved_by_user_id INT NULL, + resolution_note TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_error_reports_status_created (status, created_at), + INDEX idx_error_reports_reporter_user (reporter_user_id, created_at), + INDEX idx_error_reports_reporter_subuser (reporter_subuser_id, created_at), + INDEX idx_error_reports_customer (reporter_customer_number, reporter_customer_number_context), + INDEX idx_error_reports_trace (release_trace_id), + INDEX idx_error_reports_route (route_path) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + self::$initialized = true; + self::$tablesExist = true; + } + + public static function tablesExist(): bool + { + if (self::$tablesExist !== null) { + return self::$tablesExist; + } + + global $db; + + $result = $db->query("SHOW TABLES LIKE 'error_reports'"); + self::$tablesExist = $result !== false && $result->num_rows > 0; + return self::$tablesExist; + } +} diff --git a/services/nginx/app/classes/error_report_service.php b/services/nginx/app/classes/error_report_service.php new file mode 100644 index 00000000..10507955 --- /dev/null +++ b/services/nginx/app/classes/error_report_service.php @@ -0,0 +1,567 @@ +store = $store ?? new error_report_store(); + } + + public static function redactPayload(mixed $value, int $depth = 0): mixed + { + if ($depth > 8) { + return '[depth-limit]'; + } + + if (is_array($value)) { + $redacted = []; + $index = 0; + foreach ($value as $key => $item) { + $index++; + if ($index > 80) { + $redacted['[truncated]'] = 'More than 80 keys omitted.'; + break; + } + + $keyString = (string)$key; + if (preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $keyString) === 1) { + $redacted[$key] = '[redacted]'; + continue; + } + + $redacted[$key] = self::redactPayload($item, $depth + 1); + } + return $redacted; + } + + if (is_object($value)) { + return self::redactPayload((array)$value, $depth + 1); + } + + if (is_string($value) && strlen($value) > 4000) { + return substr($value, 0, 4000) . "\n... [truncated]"; + } + + return $value; + } + + public static function decodeScreenshotDataUri(string $dataUri): array + { + if (!preg_match('/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+\/=\r\n]+)$/', trim($dataUri), $matches)) { + throw new RuntimeException('Screenshot must be a PNG, JPEG, or WebP data URI.'); + } + + $contents = base64_decode(preg_replace('/\s+/', '', $matches[2]) ?? '', true); + if ($contents === false || $contents === '') { + throw new RuntimeException('Screenshot could not be decoded.'); + } + + if (strlen($contents) > self::SCREENSHOT_MAX_BYTES) { + throw new RuntimeException('Screenshot is too large.'); + } + + return [ + 'mime_type' => $matches[1], + 'contents' => $contents, + 'size_bytes' => strlen($contents), + ]; + } + + public function createFromCurrentPrincipal(array $payload): array + { + $this->ensureSchema(); + $principal = $this->resolvePrincipal(); + $answers = $this->validatedAnswers($payload); + + if (!$this->acceptedDataCollection($payload['data_collection_accepted'] ?? null)) { + throw new RuntimeException('Data collection acceptance is required.'); + } + + $screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? '')); + $storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']); + $context = is_array($payload['context'] ?? null) ? $payload['context'] : []; + $requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25); + $vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25); + $runtimeContext = $this->runtimeContext($payload, $context); + + $this->execute( + "INSERT INTO error_reports ( + status, + reporter_type, + reporter_user_id, + reporter_subuser_id, + reporter_customer_number, + reporter_customer_number_context, + reporter_name, + reporter_email, + route_path, + page_url, + release_trace_id, + frontend_version, + api_version, + screenshot_object_key, + screenshot_mime_type, + screenshot_size_bytes, + before_error, + expected, + actual, + request_error_count, + vue_error_count, + request_errors_json, + vue_errors_json, + runtime_context_json, + data_collection_accepted, + data_collection_accepted_at, + data_collection_policy_version + ) VALUES ( + 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NOW(), ? + )", + 'siiiisssssssssisssiissss', + [ + $principal['type'], + $principal['user_id'], + $principal['subuser_id'], + $principal['customer_number'], + $principal['customer_number_context'], + $principal['name'], + $principal['email'], + $runtimeContext['route_path'], + $runtimeContext['page_url'], + $runtimeContext['release_trace_id'], + $runtimeContext['frontend_version'], + $runtimeContext['api_version'], + $storedScreenshot['key'], + $storedScreenshot['mime_type'], + (int)$storedScreenshot['size_bytes'], + $answers['before_error'], + $answers['expected'], + $answers['actual'], + count($requestErrors), + count($vueErrors), + $this->jsonEncodeLimited(self::redactPayload($requestErrors)), + $this->jsonEncodeLimited(self::redactPayload($vueErrors)), + $this->jsonEncodeLimited(self::redactPayload($runtimeContext)), + $runtimeContext['data_collection_policy_version'], + ] + ); + + return $this->get($this->insertId()); + } + + public function list(array $filters = []): array + { + $this->ensureSchema(); + + $where = ['1 = 1']; + $types = ''; + $params = []; + $status = $this->statusFilter($filters['status'] ?? self::STATUS_OPEN); + if ($status !== 'all') { + $where[] = 'status = ?'; + $types .= 's'; + $params[] = $status; + } + + $search = trim((string)($filters['q'] ?? $filters['search'] ?? '')); + if ($search !== '') { + $where[] = '(route_path LIKE ? OR page_url LIKE ? OR before_error LIKE ? OR actual LIKE ? OR reporter_name LIKE ? OR reporter_email LIKE ?)'; + $types .= 'ssssss'; + $like = '%' . $search . '%'; + array_push($params, $like, $like, $like, $like, $like, $like); + } + + $limit = min(200, max(1, (int)($filters['limit'] ?? 50))); + $offset = max(0, (int)($filters['offset'] ?? 0)); + $types .= 'ii'; + $params[] = $limit; + $params[] = $offset; + + $items = $this->selectRows( + "SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id, + reporter_customer_number, reporter_customer_number_context, reporter_name, reporter_email, + route_path, page_url, release_trace_id, frontend_version, api_version, + screenshot_mime_type, screenshot_size_bytes, before_error, expected, actual, + request_error_count, vue_error_count, resolved_at, resolved_by_user_id, created_at, updated_at + FROM error_reports + WHERE " . implode(' AND ', $where) . " + ORDER BY created_at DESC + LIMIT ? OFFSET ?", + $types, + $params + ); + + return [ + 'items' => array_map(fn(array $row): array => $this->publicReport($row, false), $items), + 'counts' => $this->counts(), + 'limit' => $limit, + 'offset' => $offset, + ]; + } + + public function get(int $id): array + { + $this->ensureSchema(); + $row = $this->selectOne('SELECT * FROM error_reports WHERE id = ? LIMIT 1', 'i', [$id]); + if ($row === null) { + throw new RuntimeException('Error report not found.'); + } + + return $this->publicReport($row, true); + } + + public function updateStatus(int $id, string $status, ?string $resolutionNote, ?int $actorUserId): array + { + $this->ensureSchema(); + $status = self::normalizeStatus($status); + $note = $resolutionNote !== null ? $this->trimmedString($resolutionNote, self::NOTE_MAX_LENGTH, false) : null; + + if ($status === self::STATUS_RESOLVED) { + $this->execute( + 'UPDATE error_reports SET status = ?, resolved_at = NOW(), resolved_by_user_id = ?, resolution_note = ? WHERE id = ?', + 'sisi', + [$status, $actorUserId, $note, $id] + ); + } else { + $this->execute( + 'UPDATE error_reports SET status = ?, resolved_at = NULL, resolved_by_user_id = NULL, resolution_note = ? WHERE id = ?', + 'ssi', + [$status, $note, $id] + ); + } + + return $this->get($id); + } + + public static function normalizeStatus(string $status): string + { + $status = strtolower(trim($status)); + if (!in_array($status, [self::STATUS_OPEN, self::STATUS_RESOLVED], true)) { + throw new RuntimeException('Invalid error report status.'); + } + return $status; + } + + private function validatedAnswers(array $payload): array + { + return [ + 'before_error' => $this->requiredAnswer($payload, ['before_error', 'what_were_you_doing_before_error_occurred']), + 'expected' => $this->requiredAnswer($payload, ['expected', 'what_did_you_expect_would_happen']), + 'actual' => $this->requiredAnswer($payload, ['actual', 'what_actually_happened']), + ]; + } + + private function requiredAnswer(array $payload, array $keys): string + { + foreach ($keys as $key) { + if (array_key_exists($key, $payload)) { + return $this->trimmedString((string)$payload[$key], self::ANSWER_MAX_LENGTH, true); + } + } + + throw new RuntimeException('Missing required answer.'); + } + + private function trimmedString(string $value, int $maxLength, bool $required): string + { + $value = trim($value); + if ($required && $value === '') { + throw new RuntimeException('Required text fields must not be empty.'); + } + + if (strlen($value) > $maxLength) { + return substr($value, 0, $maxLength); + } + + return $value; + } + + private function acceptedDataCollection(mixed $value): bool + { + return $value === true || $value === 1 || $value === '1' || $value === 'true'; + } + + private function runtimeContext(array $payload, array $context): array + { + return [ + 'route_path' => $this->nullableString($payload['route_path'] ?? $context['route_path'] ?? $context['route'] ?? null, 512), + 'page_url' => $this->nullableString($payload['page_url'] ?? $context['page_url'] ?? $context['url'] ?? null, 1024), + 'release_trace_id' => $this->nullableString($payload['release_trace_id'] ?? $context['release_trace_id'] ?? $context['trace_id'] ?? $this->releaseRequestContext('trace_id'), 64), + 'frontend_version' => $this->nullableString($payload['frontend_version'] ?? $context['frontend_version'] ?? $this->releaseRequestContext('frontend_version'), 128), + 'api_version' => $this->nullableString($payload['api_version'] ?? $context['api_version'] ?? $this->releaseRequestContext('backend_version'), 128), + 'viewport' => is_array($context['viewport'] ?? null) ? $context['viewport'] : null, + 'user_agent' => $this->nullableString($context['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? null), 1024), + 'captured_at' => $this->nullableString($context['captured_at'] ?? null, 64), + 'data_collection_policy_version' => $this->nullableString($payload['data_collection_policy_version'] ?? $context['data_collection_policy_version'] ?? 'error-report-v1', 64) ?? 'error-report-v1', + ]; + } + + private function releaseRequestContext(string $key): ?string + { + $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] : []; + return isset($context[$key]) ? (string)$context[$key] : null; + } + + private function nullableString(mixed $value, int $maxLength): ?string + { + if ($value === null) { + return null; + } + $value = trim((string)$value); + if ($value === '') { + return null; + } + return substr($value, 0, $maxLength); + } + + private function boundedArray(mixed $value, int $limit): array + { + return is_array($value) ? array_slice(array_values($value), 0, $limit) : []; + } + + private function statusFilter(mixed $status): string + { + $status = strtolower(trim((string)$status)); + if ($status === '' || $status === self::STATUS_OPEN) { + return self::STATUS_OPEN; + } + if ($status === self::STATUS_RESOLVED || $status === 'all') { + return $status; + } + return self::STATUS_OPEN; + } + + private function counts(): array + { + $rows = $this->selectRows('SELECT status, COUNT(*) AS count FROM error_reports GROUP BY status'); + $counts = [ + self::STATUS_OPEN => 0, + self::STATUS_RESOLVED => 0, + 'all' => 0, + ]; + foreach ($rows as $row) { + $status = (string)($row['status'] ?? ''); + $count = (int)($row['count'] ?? 0); + if (isset($counts[$status])) { + $counts[$status] = $count; + } + $counts['all'] += $count; + } + return $counts; + } + + private function resolvePrincipal(): array + { + $auth = new authentication(); + + try { + $subuser = $auth->get_subuser(); + if ($subuser !== false) { + return [ + 'type' => 'subuser', + 'user_id' => null, + 'subuser_id' => (int)$subuser->id, + 'customer_number' => null, + 'customer_number_context' => $this->headerInt('X-Customer-Number'), + 'name' => $this->safeObjectValue($subuser, 'name') ?: $this->safeObjectValue($subuser, 'username'), + 'email' => $this->safeObjectValue($subuser, 'email'), + ]; + } + } catch (Throwable) { + } + + try { + $user = $auth->get_user(); + if ($user !== false) { + return [ + 'type' => 'user', + 'user_id' => (int)$user->id, + 'subuser_id' => null, + 'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null, + 'customer_number_context' => null, + 'name' => $this->safeObjectValue($user, 'display_name'), + 'email' => $this->safeObjectValue($user, 'email'), + ]; + } + } catch (Throwable) { + } + + throw new RuntimeException('Authentication failed. Invalid or missing token.'); + } + + private function headerInt(string $name): ?int + { + $headers = function_exists('getallheaders') ? getallheaders() : []; + foreach ($headers as $key => $value) { + if (strcasecmp((string)$key, $name) === 0) { + $int = (int)$value; + return $int > 0 ? $int : null; + } + } + $serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + $int = (int)($_SERVER[$serverKey] ?? 0); + return $int > 0 ? $int : null; + } + + private function safeObjectValue(object $object, string $property): ?string + { + try { + if (!isset($object->{$property}) || !method_exists($object->{$property}, 'value')) { + return null; + } + $value = $object->{$property}->value(); + return $value === null ? null : substr((string)$value, 0, 255); + } catch (Throwable) { + return null; + } + } + + private function publicReport(array $row, bool $includeDetail): array + { + $report = [ + 'id' => (int)$row['id'], + 'status' => (string)$row['status'], + 'reporter' => [ + 'type' => $row['reporter_type'] ?? null, + 'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null, + 'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null, + 'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null, + 'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null, + 'name' => $row['reporter_name'] ?? null, + 'email' => $row['reporter_email'] ?? null, + ], + 'route_path' => $row['route_path'] ?? null, + 'page_url' => $row['page_url'] ?? null, + 'release_trace_id' => $row['release_trace_id'] ?? null, + 'frontend_version' => $row['frontend_version'] ?? null, + 'api_version' => $row['api_version'] ?? null, + 'screenshot' => [ + 'mime_type' => $row['screenshot_mime_type'] ?? null, + 'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0, + ], + 'answers' => [ + 'before_error' => $row['before_error'] ?? '', + 'expected' => $row['expected'] ?? '', + 'actual' => $row['actual'] ?? '', + ], + 'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0, + 'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0, + 'resolved_at' => $row['resolved_at'] ?? null, + 'resolved_by_user_id' => isset($row['resolved_by_user_id']) ? (int)$row['resolved_by_user_id'] : null, + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + ]; + + if ($includeDetail) { + $report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? '')); + $report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null; + $report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null); + $report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null); + $report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null); + $report['data_collection'] = [ + 'accepted' => (bool)($row['data_collection_accepted'] ?? false), + 'accepted_at' => $row['data_collection_accepted_at'] ?? null, + 'policy_version' => $row['data_collection_policy_version'] ?? null, + ]; + $report['resolution_note'] = $row['resolution_note'] ?? null; + } + + return $report; + } + + private function ensureSchema(): void + { + if ($this->schemaEnsured) { + return; + } + error_report_schema_bootstrap::ensureTables(); + $this->schemaEnsured = true; + } + + private function selectOne(string $sql, string $types = '', array $params = []): ?array + { + $rows = $this->selectRows($sql, $types, $params); + return $rows[0] ?? null; + } + + private function selectRows(string $sql, string $types = '', array $params = []): array + { + global $db; + if ($types === '') { + $result = $db->query($sql); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } + + $stmt = $db->prepare($sql); + if ($stmt === false) { + throw new RuntimeException('Could not prepare error report query.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $result = $stmt->get_result(); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } + + private function execute(string $sql, string $types = '', array $params = []): void + { + global $db; + if ($types === '') { + $db->query($sql); + return; + } + + $stmt = $db->prepare($sql); + if ($stmt === false) { + throw new RuntimeException('Could not prepare error report statement.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + } + + private function insertId(): int + { + global $db; + return (int)$db->insert_id(); + } + + private function jsonEncodeLimited(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Could not encode error report JSON payload.'); + } + if (strlen($json) <= self::JSON_MAX_LENGTH) { + return $json; + } + + $truncated = [ + '[truncated]' => 'Payload exceeded ' . self::JSON_MAX_LENGTH . ' bytes.', + 'preview' => substr($json, 0, self::JSON_MAX_LENGTH), + ]; + $encoded = json_encode($truncated, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return $encoded === false ? '{}' : $encoded; + } + + private function jsonDecode(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/services/nginx/app/classes/error_report_store.php b/services/nginx/app/classes/error_report_store.php new file mode 100644 index 00000000..7dc93fc0 --- /dev/null +++ b/services/nginx/app/classes/error_report_store.php @@ -0,0 +1,47 @@ + 'webp', + 'image/jpeg' => 'jpg', + default => 'png', + }; + + $datePath = date('Y/m'); + $key = sprintf('error-reports/%s/%s.%s', $datePath, bin2hex(random_bytes(16)), $extension); + + if (!self::createObject($key, $contents)) { + throw new \RuntimeException('Could not store error report screenshot.'); + } + + return [ + 'key' => $key, + 'mime_type' => $mimeType, + 'size_bytes' => strlen($contents), + ]; + } + + public function screenshotUrl(string $key): ?string + { + $key = trim($key); + if ($key === '') { + return null; + } + + return self::getPresignedUrl($key, 1200, false); + } +} diff --git a/services/nginx/app/classes/failover.php b/services/nginx/app/classes/failover.php new file mode 100644 index 00000000..65f7c14f --- /dev/null +++ b/services/nginx/app/classes/failover.php @@ -0,0 +1,17 @@ +config = new failover_c(); + } +} diff --git a/services/nginx/app/classes/form.php b/services/nginx/app/classes/form.php index 9a3a6132..e2c5ac9e 100644 --- a/services/nginx/app/classes/form.php +++ b/services/nginx/app/classes/form.php @@ -12,8 +12,6 @@ use Exception; use forms\form_helper_c; use forms\objects\book_interior_wash_f; use forms\objects\book_wash_f; -use forms\objects\complete_booking_f; -use forms\objects\generate_booking_wash_certificate_f; use objects\form_submissions_o; use traits\form_t; @@ -30,25 +28,12 @@ class form * @var book_wash_f $book_wash The BOOK_WASH form */ public book_wash_f $book_wash; - /** - * The GENERATE_BOOKING_CERTIFICATE form - * @var generate_booking_wash_certificate_f $generate_booking_wash_certificate The GENERATE_BOOKING_CERTIFICATE form - */ - public generate_booking_wash_certificate_f $generate_booking_wash_certificate; - - /** - * The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form - * @var complete_booking_f $complete_booking_without_wash_certificate The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form - */ - public complete_booking_f $complete_booking_without_wash_certificate; public $last_submitted_form; public form_submissions_o $form_submission; public function __construct() { $this->book_wash = new book_wash_f(); - $this->generate_booking_wash_certificate = new generate_booking_wash_certificate_f(); - $this->complete_booking_without_wash_certificate = new complete_booking_f(); } /** @@ -109,4 +94,4 @@ class form } throw new Exception('The form was not found'); } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/gateway_shelly_transport.php b/services/nginx/app/classes/gateway_shelly_transport.php new file mode 100644 index 00000000..77e0b53e --- /dev/null +++ b/services/nginx/app/classes/gateway_shelly_transport.php @@ -0,0 +1,137 @@ + $this->handleGetStates($department_id, $data), + '/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data), + default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint), + }; + } + + /** + * @return array> + * @throws Exception + */ + private function handleGetStates(int $departmentId, array $data): array + { + $ids = array_values(array_filter( + array_map(static fn(mixed $value): string => trim((string)$value), (array)($data['ids'] ?? [])), + static fn(string $value): bool => $value !== '' + )); + + $result = []; + foreach ($ids as $logicalRelayId) { + $status = $this->localOnly + ? $this->manager()->dispatchRelayStatusLocalOnly($departmentId, $logicalRelayId) + : $this->manager()->dispatchRelayStatus($departmentId, $logicalRelayId); + $result[] = $this->normalizeRelayPayload($logicalRelayId, $status); + } + + return $result; + } + + /** + * @throws Exception + */ + private function handleSetSwitch(int $departmentId, array $data): array + { + $logicalRelayId = trim((string)($data['id'] ?? '')); + if ($logicalRelayId === '') { + throw new Exception('Shelly gateway switch requests require an id'); + } + + $toggleAfter = $this->normalizeToggleAfter( + $data['toggle_after'] ?? $data['toggleAfter'] ?? $data['timer'] ?? null + ); + + $status = $this->localOnly + ? $this->manager()->dispatchRelaySwitchLocalOnlyWithTimer( + $departmentId, + $logicalRelayId, + (bool)($data['on'] ?? false), + $toggleAfter + ) + : $this->manager()->dispatchRelaySwitchWithTimer( + $departmentId, + $logicalRelayId, + (bool)($data['on'] ?? false), + $toggleAfter + ); + + return [$this->normalizeRelayPayload($logicalRelayId, $status)]; + } + + /** + * @param array $status + * @return array + */ + private function normalizeRelayPayload(string $logicalRelayId, array $status): array + { + $on = (bool)($status['on'] ?? $status['output'] ?? false); + $online = (bool)($status['online'] ?? true); + + return [ + 'id' => $logicalRelayId, + 'relay_id' => $logicalRelayId, + 'online' => $online, + 'on' => $on, + 'status' => [ + 'switch:0' => [ + 'output' => $on, + ], + ], + 'binding' => (array)($status['binding'] ?? []), + 'execution' => (array)($status['execution'] ?? []), + 'raw' => (array)($status['raw'] ?? []), + ]; + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); + } + + private function normalizeToggleAfter(mixed $toggleAfter): ?int + { + if ($toggleAfter === null || $toggleAfter === '') { + return null; + } + + $seconds = (int)$toggleAfter; + return $seconds > 0 ? $seconds : null; + } +} diff --git a/services/nginx/app/classes/hetzner_cloud_client.php b/services/nginx/app/classes/hetzner_cloud_client.php new file mode 100644 index 00000000..cae49d60 --- /dev/null +++ b/services/nginx/app/classes/hetzner_cloud_client.php @@ -0,0 +1,151 @@ +statusCode; + } + + public function apiCode(): string + { + return $this->apiCode; + } +} + +class hetzner_cloud_client +{ + private const BASE_URL = 'https://api.hetzner.cloud/v1'; + + public function __construct(private readonly string $token, private readonly int $timeoutSeconds = 8) + { + if (trim($token) === '') { + throw new RuntimeException('Hetzner Cloud API token is required.'); + } + } + + public function getLoadBalancer(int|string $id): array + { + return $this->request('GET', '/load_balancers/' . rawurlencode((string)$id))['load_balancer'] ?? []; + } + + public function addIpTarget(int|string $loadBalancerId, string $ip): array + { + return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_target', [ + 'type' => 'ip', + 'ip' => ['ip' => $ip], + ]); + } + + public function removeIpTarget(int|string $loadBalancerId, string $ip): array + { + return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/remove_target', [ + 'type' => 'ip', + 'ip' => ['ip' => $ip], + ]); + } + + public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array + { + return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', self::servicePayload( + $protocol, + $listenPort, + $destinationPort, + $options + )); + } + + public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array + { + return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/update_service', self::servicePayload( + $protocol, + $listenPort, + $destinationPort, + $options + )); + } + + private static function servicePayload(string $protocol, int $listenPort, int $destinationPort, array $options): array + { + $payload = [ + 'protocol' => strtolower($protocol), + 'listen_port' => $listenPort, + 'destination_port' => $destinationPort, + 'proxyprotocol' => false, + ]; + + foreach (['health_check', 'http'] as $key) { + if (isset($options[$key]) && is_array($options[$key])) { + $payload[$key] = $options[$key]; + } + } + + return $payload; + } + + private function request(string $method, string $path, ?array $payload = null): array + { + $curl = curl_init(self::BASE_URL . $path); + if ($curl === false) { + throw new RuntimeException('Could not initialize Hetzner Cloud API request.'); + } + + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . trim($this->token), + ]; + + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(3, $this->timeoutSeconds)); + curl_setopt($curl, CURLOPT_TIMEOUT, max(1, $this->timeoutSeconds)); + curl_setopt($curl, CURLOPT_NOSIGNAL, true); + curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); + + if ($payload !== null) { + $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($body === false) { + throw new RuntimeException('Could not encode Hetzner Cloud API payload.'); + } + $headers[] = 'Content-Type: application/json'; + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + + $raw = curl_exec($curl); + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if ($raw === false) { + throw new RuntimeException('Hetzner Cloud API request failed: ' . $error); + } + + $decoded = trim((string)$raw) === '' ? [] : json_decode((string)$raw, true); + if (!is_array($decoded)) { + $decoded = ['raw' => (string)$raw]; + } + + if ($status < 200 || $status >= 300) { + $errorPayload = is_array($decoded['error'] ?? null) ? $decoded['error'] : []; + $apiCode = (string)($errorPayload['code'] ?? $decoded['code'] ?? ''); + $message = (string)($errorPayload['message'] ?? $decoded['message'] ?? ('HTTP ' . $status)); + throw new hetzner_cloud_api_exception('Hetzner Cloud API request failed: ' . $message, $status, $apiCode); + } + + return $decoded; + } +} diff --git a/services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php b/services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php new file mode 100644 index 00000000..ee25fd5f --- /dev/null +++ b/services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php @@ -0,0 +1,64 @@ +query( + "CREATE TABLE IF NOT EXISTS invoice_period_flags ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + source VARCHAR(32) NOT NULL, + severity VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + target_type VARCHAR(64) NOT NULL, + target_id BIGINT NOT NULL, + field VARCHAR(64) NULL, + customer_number INT NULL, + order_id BIGINT NULL, + order_item_id BIGINT NULL, + invoice_collection_id BIGINT NULL, + xlvask_usage_log_id BIGINT NULL, + definition_key VARCHAR(128) NULL, + fingerprint VARCHAR(191) NULL, + reason TEXT NULL, + status_reason TEXT NULL, + context_json JSON NULL, + created_by INT NULL, + status_changed_by INT NULL, + status_changed_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_invoice_period_flags_auto_fingerprint (source, fingerprint), + KEY idx_invoice_period_flags_target (target_type, target_id, status), + KEY idx_invoice_period_flags_customer_status (customer_number, status), + KEY idx_invoice_period_flags_source_status (source, status), + KEY idx_invoice_period_flags_order (order_id), + KEY idx_invoice_period_flags_order_item (order_item_id), + KEY idx_invoice_period_flags_invoice_collection (invoice_collection_id), + KEY idx_invoice_period_flags_xlvask (xlvask_usage_log_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + products_schema_bootstrap::ensureTables(); + xlvask_usage_logs_schema_bootstrap::ensureTables(); + + self::$initialized = true; + } +} diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php new file mode 100644 index 00000000..99e99eb8 --- /dev/null +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -0,0 +1,2105 @@ +normalizeField($targetType, $payload['field'] ?? null); + $reason = trim((string)($payload['reason'] ?? '')); + + if (!in_array($targetType, self::VALID_TARGETS, true)) { + throw new \InvalidArgumentException('Invalid flag target type.'); + } + if ($targetId < 1) { + throw new \InvalidArgumentException('Flag target id is required.'); + } + if ($reason === '') { + throw new \InvalidArgumentException('Manual flag reason is required.'); + } + + $context = $this->resolveTargetContext($targetType, $targetId, $field); + $contextJson = $this->jsonSql($context); + + $sql = sprintf( + "INSERT INTO invoice_period_flags + (source, severity, status, target_type, target_id, field, customer_number, order_id, order_item_id, + invoice_collection_id, xlvask_usage_log_id, reason, context_json, created_by) + VALUES + ('%s', 'red', 'active', '%s', %d, %s, %s, %s, %s, %s, %s, '%s', %s, %s)", + self::SOURCE_MANUAL, + $db->escape_string($targetType), + $targetId, + $this->nullableStringSql($field), + $this->nullableIntSql($context['customer_number'] ?? null), + $this->nullableIntSql($context['order_id'] ?? null), + $this->nullableIntSql($context['order_item_id'] ?? null), + $this->nullableIntSql($context['invoice_collection_id'] ?? null), + $this->nullableIntSql($context['xlvask_usage_log_id'] ?? null), + $db->escape_string($reason), + $contextJson, + $this->nullableIntSql($userId > 0 ? $userId : null) + ); + $db->query($sql); + + return $this->getStoredFlag((int)$db->insert_id()); + } + + public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array + { + global $db; + + $status = trim($status); + if (!in_array($status, self::VALID_STATUSES, true) || $status === self::STATUS_ACTIVE) { + throw new \InvalidArgumentException('Invalid manual flag status.'); + } + if ($id < 1) { + throw new \InvalidArgumentException('Flag id is required.'); + } + + $sql = sprintf( + "UPDATE invoice_period_flags + SET status = '%s', + status_reason = %s, + status_changed_by = %s, + status_changed_at = NOW() + WHERE id = %d AND source = '%s'", + $db->escape_string($status), + $this->nullableStringSql($reason), + $this->nullableIntSql($userId > 0 ? $userId : null), + $id, + self::SOURCE_MANUAL + ); + $db->query($sql); + + return $this->getStoredFlag($id); + } + + public function updateAutomaticFlagStatus(array $payload, int $userId): array + { + global $db; + + $fingerprint = trim((string)($payload['fingerprint'] ?? '')); + $status = trim((string)($payload['status'] ?? '')); + $targetType = trim((string)($payload['target_type'] ?? '')); + $targetId = (int)($payload['target_id'] ?? 0); + $field = $this->normalizeField($targetType, $payload['field'] ?? null); + $definitionKey = trim((string)($payload['definition_key'] ?? '')); + $reason = isset($payload['reason']) ? trim((string)$payload['reason']) : null; + + if ($fingerprint === '') { + throw new \InvalidArgumentException('Automatic flag fingerprint is required.'); + } + if (!in_array($status, self::VALID_STATUSES, true) || $status === self::STATUS_ACTIVE) { + throw new \InvalidArgumentException('Invalid automatic flag status.'); + } + if (!in_array($targetType, self::VALID_TARGETS, true)) { + throw new \InvalidArgumentException('Invalid automatic flag target type.'); + } + if ($targetId < 1) { + throw new \InvalidArgumentException('Automatic flag target id is required.'); + } + + $context = $this->resolveTargetContext($targetType, $targetId, $field); + $contextJson = $this->jsonSql($context); + + $sql = sprintf( + "INSERT INTO invoice_period_flags + (source, severity, status, target_type, target_id, field, customer_number, order_id, order_item_id, + invoice_collection_id, xlvask_usage_log_id, definition_key, fingerprint, status_reason, context_json, + created_by, status_changed_by, status_changed_at) + VALUES + ('%s', 'yellow', '%s', '%s', %d, %s, %s, %s, %s, %s, %s, %s, '%s', %s, %s, %s, %s, NOW()) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + target_type = VALUES(target_type), + target_id = VALUES(target_id), + field = VALUES(field), + customer_number = VALUES(customer_number), + order_id = VALUES(order_id), + order_item_id = VALUES(order_item_id), + invoice_collection_id = VALUES(invoice_collection_id), + xlvask_usage_log_id = VALUES(xlvask_usage_log_id), + definition_key = VALUES(definition_key), + status_reason = VALUES(status_reason), + context_json = VALUES(context_json), + status_changed_by = VALUES(status_changed_by), + status_changed_at = NOW()", + self::SOURCE_AUTOMATIC, + $db->escape_string($status), + $db->escape_string($targetType), + $targetId, + $this->nullableStringSql($field), + $this->nullableIntSql($context['customer_number'] ?? null), + $this->nullableIntSql($context['order_id'] ?? null), + $this->nullableIntSql($context['order_item_id'] ?? null), + $this->nullableIntSql($context['invoice_collection_id'] ?? null), + $this->nullableIntSql($context['xlvask_usage_log_id'] ?? null), + $this->nullableStringSql($definitionKey !== '' ? $definitionKey : null), + $db->escape_string($fingerprint), + $this->nullableStringSql($reason), + $contextJson, + $this->nullableIntSql($userId > 0 ? $userId : null), + $this->nullableIntSql($userId > 0 ? $userId : null) + ); + $db->query($sql); + + return $this->getStoredAutomaticFlag($fingerprint); + } + + /** + * @param array>> $types + * @param int[]|null $onlyCustomerNumbers + * @return array>> + */ + public function applyFlagsToPeriodTypes(array $types, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array + { + global $response; + $context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers); + $manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers); + $automaticFlags = $this->getAutomaticFlagsForPeriod($dateFrom, $dateTo, $onlyCustomerNumbers); + $automaticFlags = $this->filterSuppressedAutomaticFlags($automaticFlags); + $allFlags = array_merge($manualFlags, $automaticFlags); + + $types = $this->ensureFlagOnlyCustomers($types, $allFlags); + $flagsByCustomerNumber = []; + foreach ($allFlags as $flag) { + $customerNumber = (int)($flag['customer_number'] ?? 0); + if ($customerNumber < 1) { + continue; + } + $flagsByCustomerNumber[$customerNumber][] = $flag; + } + + foreach ($types as $typeName => $customers) { + foreach ($customers as $index => $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + $flags = $this->flagsForCustomerCard( + $customer, + $flagsByCustomerNumber[$customerNumber] ?? [], + (string)$typeName + ); + usort($flags, [$this, 'sortFlags']); + $types[$typeName][$index]['flags'] = array_values($flags); + $types[$typeName][$index]['flag_counts'] = $this->countFlags($flags); + $types[$typeName][$index]['status_indicator'] = $this->statusIndicatorForCustomer( + $types[$typeName][$index], + $flags + ); + } + } + + return $types; + } + + private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array + { + $transactionIds = []; + $invoiceCollectionIds = []; + foreach (($customer['transactions'] ?? []) as $transaction) { + $orderId = (int)($transaction['id'] ?? 0); + if ($orderId > 0) { + $transactionIds[$orderId] = true; + } + + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = true; + } + } + + return array_values(array_filter($flags, function (array $flag) use ($customer, $transactionIds, $invoiceCollectionIds, $typeName): bool { + return $this->flagBelongsToCustomerCard($customer, $flag, $transactionIds, $invoiceCollectionIds, $typeName); + })); + } + + private function flagBelongsToCustomerCard( + array $customer, + array $flag, + array $transactionIds, + array $invoiceCollectionIds, + string $typeName = '' + ): bool { + if ((string)($flag['status'] ?? self::STATUS_ACTIVE) !== self::STATUS_ACTIVE) { + return false; + } + + $customerNumber = (int)($customer['customer_number'] ?? 0); + $targetType = (string)($flag['target_type'] ?? ''); + if ($targetType === 'customer') { + return (int)($flag['customer_number'] ?? $flag['target_id'] ?? 0) === $customerNumber; + } + + if ($targetType === 'xlvask_usage_log') { + return $typeName === 'all' && (int)($flag['customer_number'] ?? 0) === $customerNumber; + } + + if (in_array($targetType, ['order', 'order_field', 'order_item', 'order_item_field'], true)) { + $orderId = (int)($flag['order_id'] ?? $flag['context']['order_id'] ?? 0); + if ($orderId < 1 && in_array($targetType, ['order', 'order_field'], true)) { + $orderId = (int)($flag['target_id'] ?? 0); + } + return $orderId > 0 && isset($transactionIds[$orderId]); + } + + if ($targetType === 'collected_order_invoice') { + $invoiceCollectionId = (int)( + $flag['invoice_collection_id'] + ?? $flag['context']['invoice_collection_id'] + ?? $flag['target_id'] + ?? 0 + ); + return $invoiceCollectionId > 0 && isset($invoiceCollectionIds[$invoiceCollectionId]); + } + + return false; + } + + private function getStoredFlag(int $id): array + { + global $db; + + $result = $db->query("SELECT * FROM invoice_period_flags WHERE id = {$id} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + if (!$row) { + throw new \RuntimeException('Flag not found.'); + } + + return $this->formatStoredFlag($row); + } + + private function getStoredAutomaticFlag(string $fingerprint): array + { + global $db; + + $fingerprint = $db->escape_string($fingerprint); + $result = $db->query( + "SELECT * FROM invoice_period_flags + WHERE source = '" . self::SOURCE_AUTOMATIC . "' AND fingerprint = '{$fingerprint}' + LIMIT 1" + ); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + if (!$row) { + throw new \RuntimeException('Automatic flag decision not found.'); + } + + return $this->formatStoredFlag($row); + } + + public function warmManualFlagsCache(): void + { + global $db; + + $result = $db->query( + "SELECT * FROM invoice_period_flags + WHERE source = '" . self::SOURCE_MANUAL . "' + AND status = '" . self::STATUS_ACTIVE . "' + ORDER BY created_at ASC, id ASC" + ); + + $flags = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $flags[] = $this->formatStoredFlag($row); + } + } + + try { + (new redis())->cache_invoice_period_manual_flags($flags); + } catch (Throwable) { + } + } + + private function formatStoredFlag(array $row): array + { + $context = []; + if (!empty($row['context_json'])) { + $decoded = json_decode((string)$row['context_json'], true); + $context = is_array($decoded) ? $decoded : []; + } + + return [ + 'id' => (int)$row['id'], + 'source' => (string)$row['source'], + 'severity' => (string)$row['severity'], + 'status' => (string)$row['status'], + 'target_type' => (string)$row['target_type'], + 'target_id' => (int)$row['target_id'], + 'field' => $row['field'], + 'customer_number' => $row['customer_number'] === null ? null : (int)$row['customer_number'], + 'order_id' => $row['order_id'] === null ? null : (int)$row['order_id'], + 'order_item_id' => $row['order_item_id'] === null ? null : (int)$row['order_item_id'], + 'invoice_collection_id' => $row['invoice_collection_id'] === null ? null : (int)$row['invoice_collection_id'], + 'xlvask_usage_log_id' => $row['xlvask_usage_log_id'] === null ? null : (int)$row['xlvask_usage_log_id'], + 'definition_key' => $row['definition_key'], + 'fingerprint' => $row['fingerprint'], + 'reason' => $row['reason'], + 'status_reason' => $row['status_reason'], + 'context' => $context, + 'created_by' => $row['created_by'] === null ? null : (int)$row['created_by'], + 'created_by_name' => $this->getUserDisplayName($row['created_by'] === null ? null : (int)$row['created_by']), + 'status_changed_by' => $row['status_changed_by'] === null ? null : (int)$row['status_changed_by'], + 'status_changed_at' => $row['status_changed_at'], + 'created_at' => $row['created_at'], + 'updated_at' => $row['updated_at'], + 'message' => (string)($row['reason'] ?? ''), + ]; + } + + private function getCachedManualFlags(): array + { + try { + $flags = (new redis())->get_invoice_period_manual_flags(); + } catch (Throwable) { + $flags = null; + } + + if (!is_array($flags)) { + // Cache miss — warm on demand and re-fetch + $this->warmManualFlagsCache(); + try { + $flags = (new redis())->get_invoice_period_manual_flags(); + } catch (Throwable) { + return []; + } + if (!is_array($flags)) { + return []; + } + } + + return array_values(array_filter($flags, static function ($flag): bool { + return is_array($flag); + })); + } + + private function buildPeriodContext(array $types, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + $customerNumbers = []; + $orderIds = []; + $invoiceCollectionIds = []; + $orderToCustomer = []; + $invoiceCollectionToCustomer = []; + + foreach ($types as $customers) { + foreach ($customers as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0) { + $customerNumbers[$customerNumber] = true; + } + foreach (($customer['transactions'] ?? []) as $transaction) { + $orderId = (int)($transaction['id'] ?? 0); + if ($orderId > 0) { + $orderIds[$orderId] = true; + $orderToCustomer[$orderId] = $customerNumber; + } + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = true; + $invoiceCollectionToCustomer[$invoiceCollectionId] = $customerNumber; + } + } + } + } + + $orderItemToOrder = $this->getOrderItemToOrderMap(array_keys($orderIds)); + $xlvaskPeriodRows = $this->getXlVaskPeriodRows($dateFrom, $dateTo, $onlyCustomerNumbers); + + return [ + 'customer_numbers' => array_keys($customerNumbers), + 'order_ids' => array_keys($orderIds), + 'invoice_collection_ids' => array_keys($invoiceCollectionIds), + 'order_to_customer' => $orderToCustomer, + 'invoice_collection_to_customer' => $invoiceCollectionToCustomer, + 'order_item_to_order' => $orderItemToOrder, + 'xlvask_period_rows' => $xlvaskPeriodRows, + ]; + } + + private function getManualFlagsForPeriod(array $context, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + if ($this->manualFlagsInstanceCache === null) { + $this->manualFlagsInstanceCache = $this->getCachedManualFlags(); + } + $cachedFlags = $this->manualFlagsInstanceCache; + if (empty($cachedFlags)) { + return []; + } + + $allowedCustomerNumbers = $onlyCustomerNumbers !== null + ? array_fill_keys(array_map('intval', $onlyCustomerNumbers), true) + : null; + $periodCustomerNumbers = array_fill_keys(array_map('intval', $context['customer_numbers']), true); + $orderToCustomer = $context['order_to_customer']; + $invoiceCollectionToCustomer = $context['invoice_collection_to_customer']; + $orderItemToOrder = $context['order_item_to_order']; + $xlvaskRows = []; + foreach ($context['xlvask_period_rows'] as $row) { + $xlvaskRows[(int)$row['id']] = $row; + } + + $flags = []; + foreach ($cachedFlags as $row) { + $targetType = (string)$row['target_type']; + $targetId = (int)$row['target_id']; + $customerNumber = null; + + if ($targetType === 'customer') { + if (!isset($periodCustomerNumbers[$targetId])) { + continue; + } + $customerNumber = $targetId; + } elseif ($targetType === 'order' || $targetType === 'order_field') { + if (!isset($orderToCustomer[$targetId])) { + continue; + } + $customerNumber = (int)$orderToCustomer[$targetId]; + } elseif ($targetType === 'order_item' || $targetType === 'order_item_field') { + $orderId = (int)($orderItemToOrder[$targetId] ?? 0); + if ($orderId < 1 || !isset($orderToCustomer[$orderId])) { + continue; + } + $customerNumber = (int)$orderToCustomer[$orderId]; + } elseif ($targetType === 'collected_order_invoice') { + if (!isset($invoiceCollectionToCustomer[$targetId])) { + continue; + } + $customerNumber = (int)$invoiceCollectionToCustomer[$targetId]; + } elseif ($targetType === 'xlvask_usage_log') { + if (!isset($xlvaskRows[$targetId])) { + continue; + } + $customerNumber = (int)($xlvaskRows[$targetId]['customer_number'] ?? 0); + } + + if ($customerNumber === null || $customerNumber < 1) { + continue; + } + if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customerNumber])) { + continue; + } + + $flag = $row; + $flag['customer_number'] = $customerNumber; + $flag['message'] = (string)$flag['reason']; + $flags[] = $flag; + } + + return $flags; + } + + private function getAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + try { + $flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo); + } catch (Throwable) { + return []; + } + + if (!is_array($flags)) { + // Cache miss — enqueue for warming on the next cron run + try { + (new redis())->enqueue_invoice_period_warming($dateFrom, $dateTo); + } catch (Throwable) { + } + return []; + } + + if ($onlyCustomerNumbers === null) { + return $flags; + } + + $allowed = array_fill_keys(array_map('intval', $onlyCustomerNumbers), true); + return array_values(array_filter($flags, static function (array $flag) use ($allowed): bool { + return isset($allowed[(int)($flag['customer_number'] ?? 0)]); + })); + } + + public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void + { + $rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, null); + $attributes = $this->getCustomerAttributes(null); + + $flags = array_merge( + $this->detectCustomerRuleViolations($rows, $attributes), + $this->detectPriceMismatches($rows), + $this->detectAbnormalQuantities($rows, $dateFrom, $dateTo), + $this->detectVehicleTypeMismatches($rows, $dateFrom), + $this->detectMissingXlVaskLinks($dateFrom, $dateTo, null) + ); + + try { + (new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags); + } catch (Throwable) { + } + } + + private function filterSuppressedAutomaticFlags(array $flags): array + { + global $db; + + $fingerprints = array_values(array_unique(array_filter(array_map( + static fn(array $flag): string => (string)($flag['fingerprint'] ?? ''), + $flags + )))); + + if (empty($fingerprints)) { + return $flags; + } + + $in = implode(',', array_map(static function (string $fingerprint) use ($db): string { + return "'" . $db->escape_string($fingerprint) . "'"; + }, $fingerprints)); + + $suppressed = []; + $result = $db->query( + "SELECT fingerprint FROM invoice_period_flags + WHERE source = '" . self::SOURCE_AUTOMATIC . "' + AND status IN ('resolved', 'ignored', 'false_positive') + AND fingerprint IN ({$in})" + ); + if ($result) { + while ($row = $result->fetch_assoc()) { + $suppressed[(string)$row['fingerprint']] = true; + } + } + + return array_values(array_filter($flags, static function (array $flag) use ($suppressed): bool { + return !isset($suppressed[(string)($flag['fingerprint'] ?? '')]); + })); + } + + private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + try { + $rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo); + } catch (Throwable) { + return []; + } + + if (!is_array($rows)) { + return []; + } + + $this->seedOrderItemsPreviewCacheFromRows($rows); + + if ($onlyCustomerNumbers === null) { + return $rows; + } + + $allowed = array_fill_keys(array_map('intval', $onlyCustomerNumbers), true); + return array_values(array_filter($rows, static function (array $row) use ($allowed): bool { + return isset($allowed[(int)($row['customer_number'] ?? 0)]); + })); + } + + public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void + { + $rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo); + try { + (new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows); + } catch (Throwable) { + } + } + + private function fetchOrderItemRowsFromDb(string $dateFrom, string $dateTo): array + { + global $db; + + $escapedDateFrom = $db->escape_string($dateFrom); + $escapedDateTo = $db->escape_string($dateTo); + + $sql = " + SELECT + o.id AS order_id, + o.customer_id AS customer_number, + u.id AS user_id, + u.display_name AS customer_name, + o.reference AS order_reference, + o.po AS order_po, + o.notes AS order_notes, + o.department_id, + o.reg_1, + o.invoice_collection_id, + o.wash_id, + o.safety_seal, + o.created_at AS order_created_at, + oi.id AS order_item_id, + oi.product_id, + oi.reference AS item_reference, + oi.notes AS item_notes, + oi.price AS item_price, + oi.quantity AS item_quantity, + oi.related_item_id, + oi.include_in_invoice AS item_include_in_invoice, + p.name AS product_name, + p.price AS product_base_price, + p.category AS product_category, + p.apply_category_discount, + p.is_wash, + p.subscription_allowed, + p.max_quantity_per_order, + c.name AS category_name, + pdp.price AS department_price, + product_discount.percentage AS product_discount_percentage, + category_discount.percentage AS category_discount_percentage + FROM orders o + LEFT JOIN ( + SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name + FROM users + WHERE customer_number IS NOT NULL AND customer_number <> 0 + GROUP BY customer_number + ) u ON u.customer_number = o.customer_id + LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + LEFT JOIN products p ON p.id = oi.product_id + LEFT JOIN categories c ON c.id = p.category + LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id + LEFT JOIN ( + SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage + FROM price_overrides po + INNER JOIN users discount_user ON discount_user.id = po.user_id + WHERE po.is_category = 0 + GROUP BY discount_user.customer_number, po.product_or_category_id + ) product_discount + ON product_discount.customer_number = o.customer_id + AND product_discount.product_or_category_id = p.id + LEFT JOIN ( + SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage + FROM price_overrides po + INNER JOIN users discount_user ON discount_user.id = po.user_id + WHERE po.is_category = 1 + GROUP BY discount_user.customer_number, po.product_or_category_id + ) category_discount + ON category_discount.customer_number = o.customer_id + AND category_discount.product_or_category_id = p.category + WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}' + AND o.deleted_at IS NULL + ORDER BY o.customer_id, o.id, oi.id"; + + $result = $db->query($sql); + $rows = $result ? $db->fetch_all($result) : []; + $certificateAttachmentOrderIds = $this->getWashCertificateAttachmentOrderIds(array_column($rows, 'order_id')); + + foreach ($rows as &$row) { + $orderId = (int)($row['order_id'] ?? 0); + $row['has_wash_certificate_attachment'] = isset($certificateAttachmentOrderIds[$orderId]) ? 1 : 0; + } + unset($row); + $this->seedOrderItemsPreviewCacheFromRows($rows); + + return $rows; + } + + private function getWashCertificateAttachmentOrderIds(array $orderIds): array + { + global $db; + + $orderIds = array_values(array_unique(array_filter( + array_map('intval', $orderIds), + static fn(int $orderId): bool => $orderId > 0 + ))); + if (empty($orderIds) || !$this->tableExists('object_attachments')) { + return []; + } + + $objectTypes = []; + foreach (['orders', '`orders`'] as $type) { + $objectTypes[] = "'" . $db->escape_string($type) . "'"; + } + $in = implode(',', $orderIds); + $result = $db->query( + "SELECT object_id, content + FROM object_attachments + WHERE object_type IN (" . implode(',', $objectTypes) . ") + AND object_id IN ({$in}) + AND deleted_at IS NULL" + ); + + $attached = []; + if (!$result) { + return $attached; + } + + 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(trim($other)) === 'wash_certificate') { + $attached[(int)$row['object_id']] = true; + } + } + + return $attached; + } + + private function getCustomerAttributes(?array $onlyCustomerNumbers): array + { + global $db; + + $customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers); + $result = $db->query( + "SELECT u.customer_number, ca.attribute + FROM customer_attributes ca + JOIN users u ON u.id = ca.user_id + WHERE 1=1 {$customerFilter}" + ); + + $attributes = []; + if (!$result) { + return $attributes; + } + + while ($row = $result->fetch_assoc()) { + $customerNumber = (int)$row['customer_number']; + $attributes[$customerNumber][(string)$row['attribute']] = true; + } + + return $attributes; + } + + private function detectCustomerRuleViolations(array $rows, array $attributes): array + { + $flags = []; + $orders = []; + $collectionOrders = []; + + foreach ($rows as $row) { + $customerNumber = (int)$row['customer_number']; + $orderId = (int)$row['order_id']; + if ($orderId > 0 && !isset($orders[$orderId])) { + $orders[$orderId] = $row; + } + $invoiceCollectionId = (int)($row['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $collectionOrders[$customerNumber][$invoiceCollectionId][$orderId] = true; + } + + if (!$this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices') + && !$this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') + && !$this->hasAttribute($attributes, $customerNumber, 'restrictSpotFree') + && !$this->hasAttribute($attributes, $customerNumber, 'restrictInteriorCleaning') + && !$this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee') + && !$this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning')) { + continue; + } + + if ((int)($row['order_item_id'] ?? 0) < 1) { + continue; + } + + $isTankCleaningProduct = $this->rowIsTankCleaningProduct($row); + + if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices') + && (int)($row['related_item_id'] ?? 0) > 0 + && (int)($row['item_price'] ?? 0) > 0) { + $flags[] = $this->automaticFlag( + 'customer_rule_restrict_addon_services', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + + if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) { + $flags[] = $this->automaticFlag( + 'customer_rule_restrict_tank_cleaning', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + + if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) { + $flags[] = $this->automaticFlag( + 'customer_rule_only_tank_cleaning', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + + $restrictedProducts = [ + 'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']], + 'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']], + 'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']], + ]; + + foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) { + if ($this->hasAttribute($attributes, $customerNumber, $attribute) + && $this->rowMatchesProductTerms($row, $terms)) { + $flags[] = $this->automaticFlag( + $definitionKey, + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + } + } + + foreach ($orders as $orderId => $row) { + $customerNumber = (int)$row['customer_number']; + if ($this->hasAttribute($attributes, $customerNumber, 'requiresReferenceNumber') + && trim((string)($row['order_reference'] ?? '')) === '') { + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$orderId); + $flags[] = $this->automaticFlag( + 'customer_rule_requires_reference', + 'order_field', + $orderId, + 'reference', + $row, + [], + $context + ); + } + if ($this->hasAttribute($attributes, $customerNumber, 'usePONumbers') + && trim((string)($row['order_po'] ?? '')) === '') { + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$orderId); + $flags[] = $this->automaticFlag( + 'customer_rule_requires_po_number', + 'order_field', + $orderId, + 'po', + $row, + [], + $context + ); + } + } + + foreach ($collectionOrders as $customerNumber => $collections) { + if (!$this->hasAttribute($attributes, (int)$customerNumber, 'invoiceAllOrdersIndividually')) { + continue; + } + foreach ($collections as $invoiceCollectionId => $orderSet) { + if (count($orderSet) <= 1) { + continue; + } + $row = $orders[(int)array_key_first($orderSet)] ?? ['customer_number' => $customerNumber, 'invoice_collection_id' => $invoiceCollectionId]; + $flags[] = $this->automaticFlag( + 'customer_rule_invoice_all_orders_individually', + 'collected_order_invoice', + (int)$invoiceCollectionId, + null, + $row, + ['count' => count($orderSet)], + $this->invoiceCollectionContext($row) + ); + } + } + + return $this->dedupeAutomaticFlags($flags); + } + + private function detectPriceMismatches(array $rows): array + { + $this->preloadEconomicCustomerDiscounts($rows); + + $flags = []; + foreach ($rows as $row) { + $orderItemId = (int)($row['order_item_id'] ?? 0); + if ($orderItemId < 1 || !$this->isIncludedOrderItem($row)) { + continue; + } + + $expected = $this->calculateExpectedPrice($row); + $actual = (int)($row['item_price'] ?? 0); + if ($actual === $expected) { + continue; + } + + $context = $this->orderItemContext($row); + $context['actual_price'] = $actual; + $context['expected_price'] = $expected; + $context['expected_price_breakdown'] = $this->priceBreakdown($row, $expected); + $context['order_items'] = $this->getOrderItemsForPreview((int)$row['order_id']); + + $flags[] = $this->automaticFlag( + 'price_mismatch', + 'order_item_field', + $orderItemId, + 'price', + $row, + [ + 'product' => $this->productLabel($row), + 'expected' => 'expected', + 'actual_price' => $actual, + 'expected_price' => $expected, + ], + $context + ); + } + + return $flags; + } + + private function detectAbnormalQuantities(array $rows, string $dateFrom, string $dateTo): array + { + $flags = []; + $primaryByOrderProduct = []; + $orders = []; + $washCertificateByOrder = []; + $hasWashCertificateAttachmentByOrder = []; + $fixedPricingGroups = []; + $subscriptionGroups = []; + + foreach ($rows as $row) { + $orderId = (int)$row['order_id']; + $orderItemId = (int)($row['order_item_id'] ?? 0); + if ($orderId > 0 && !isset($orders[$orderId])) { + $orders[$orderId] = $row; + } + if ($orderId > 0 && $this->rowHasWashCertificateAttachment($row)) { + $hasWashCertificateAttachmentByOrder[$orderId] = true; + } + if ($orderItemId < 1) { + continue; + } + + if ($this->isPrimaryVehicleItem($row)) { + $primaryByOrderProduct[$orderId][(int)$row['product_id']][$orderItemId] ??= $row; + } + + $limit = (int)($row['max_quantity_per_order'] ?? 0); + if ($limit > 0 && (int)($row['item_quantity'] ?? 0) > $limit) { + $flags[] = $this->automaticFlag( + 'quantity_exceeds_product_limit', + 'order_item_field', + $orderItemId, + 'quantity', + $row, + [ + 'product' => $this->productLabel($row), + 'quantity' => (int)$row['item_quantity'], + 'limit' => $limit, + ], + $this->orderItemContext($row) + ['quantity_limit' => $limit] + ); + } + + if ($this->isWashCertificateProduct($row)) { + $washCertificateByOrder[$orderId][] = $row; + if (!$this->rowHasWashCertificateAttachment($row)) { + $context = $this->orderItemContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$row['order_id']); + $flags[] = $this->automaticFlag( + 'wash_certificate_item_without_certificate', + 'order_item', + $orderItemId, + null, + $row, + ['product' => $this->productLabel($row)], + $context + ); + } + } + + $monthKey = date('Y-m', strtotime((string)$row['order_created_at'])); + if ($this->rowMatchesProductTerms($row, ['fixed pricing', 'fastpris', 'fixed price'])) { + $fixedPricingGroups[(int)$row['customer_number']][$monthKey][] = $row; + } + if ($this->rowMatchesProductTerms($row, ['subscription', 'abonnement', 'vaskeabonnement'])) { + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + $subscriptionGroups[(int)$row['customer_number']][$reg][$monthKey][(int)$row['product_id']][] = $row; + } + } + + foreach ($primaryByOrderProduct as $orderId => $products) { + foreach ($products as $productId => $items) { + if (count($items) <= 1) { + continue; + } + $items = array_values($items); + $row = $items[0]; + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$row['order_id']); + $flags[] = $this->automaticFlag( + 'multiple_identical_primary_vehicle_items', + 'order', + (int)$orderId, + null, + $row, + ['product' => $this->productLabel($row), 'count' => count($items)], + $context + ); + } + } + + foreach ($orders as $orderId => $row) { + if (isset($hasWashCertificateAttachmentByOrder[$orderId]) && empty($washCertificateByOrder[$orderId])) { + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$orderId); + $flags[] = $this->automaticFlag( + 'wash_certificate_attached_without_item', + 'order', + (int)$orderId, + null, + $row, + [], + $context + ); + } + } + + foreach ($fixedPricingGroups as $customerGroups) { + foreach ($customerGroups as $items) { + if (count($items) <= 1) { + continue; + } + $row = $items[0]; + $flags[] = $this->automaticFlag( + 'multiple_fixed_pricing_items_same_month', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['count' => count($items)], + $this->orderItemContext($row) + ); + } + } + + foreach ($subscriptionGroups as $customerGroups) { + foreach ($customerGroups as $regGroups) { + foreach ($regGroups as $monthGroups) { + foreach ($monthGroups as $items) { + if (count($items) <= 1) { + continue; + } + $row = $items[0]; + $flags[] = $this->automaticFlag( + 'duplicate_vehicle_subscription_charge_same_month', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row), 'count' => count($items)], + $this->orderItemContext($row) + ); + } + } + } + } + + return $this->dedupeAutomaticFlags($flags); + } + + private function detectVehicleTypeMismatches(array $rows, string $dateFrom): array + { + global $db; + + $flags = []; + $primaryRows = array_values(array_filter($rows, fn(array $row): bool => $this->isPrimaryVehicleItem($row))); + if (empty($primaryRows)) { + return []; + } + + $vehicleTypeByCustomerReg = $this->getVehicleSubscriptionTypeMap($primaryRows); + foreach ($primaryRows as $row) { + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + $key = (int)$row['customer_number'] . '|' . $reg; + $expectedProductId = (int)($vehicleTypeByCustomerReg[$key]['product_id'] ?? 0); + $expectedProductName = (string)($vehicleTypeByCustomerReg[$key]['product_name'] ?? ''); + if ($expectedProductId > 0 + && !$this->primaryVehicleProductsMatch( + (int)$row['product_id'], + $this->productLabel($row), + $expectedProductId, + $expectedProductName + )) { + $flags[] = $this->automaticFlag( + 'vehicle_subscription_type_mismatch', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + [ + 'product' => $this->productLabel($row), + 'expected_product' => $expectedProductName !== '' ? $expectedProductName : (string)$expectedProductId, + ], + $this->orderItemContext($row) + ['expected_product_id' => $expectedProductId] + ); + } + } + + $history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1')); + foreach ($primaryRows as $row) { + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + if ($reg === '' || !isset($history[$reg])) { + continue; + } + $expectedProductId = (int)$history[$reg]['product_id']; + if ($this->primaryVehicleProductsMatch( + (int)$row['product_id'], + $this->productLabel($row), + $expectedProductId, + (string)$history[$reg]['product_name'] + )) { + continue; + } + $flags[] = $this->automaticFlag( + 'historical_primary_product_mismatch', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + [ + 'product' => $this->productLabel($row), + 'expected_product' => (string)$history[$reg]['product_name'], + ], + $this->orderItemContext($row) + [ + 'expected_product_id' => $expectedProductId, + 'expected_product_name' => $history[$reg]['product_name'], + 'history_count' => (int)$history[$reg]['count'], + ] + ); + } + + return $this->dedupeAutomaticFlags($flags); + } + + private function detectMissingXlVaskLinks(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + $flags = []; + foreach ($this->getXlVaskPeriodRows($dateFrom, $dateTo, $onlyCustomerNumbers, true) as $row) { + $flags[] = $this->automaticFlag( + 'xlvask_missing_order_link', + 'xlvask_usage_log', + (int)$row['id'], + null, + [ + 'customer_number' => (int)$row['customer_number'], + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'order_id' => null, + 'order_item_id' => null, + 'invoice_collection_id' => null, + 'xlvask_usage_log_id' => (int)$row['id'], + ], + [ + 'wash_id' => (string)$row['wash_id'], + 'registration_number' => (string)($row['registration_number'] ?? ''), + ], + [ + 'customer_number' => (int)$row['customer_number'], + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'xlvask_usage_log_id' => (int)$row['id'], + 'wash_id' => (string)$row['wash_id'], + 'registration_number' => (string)($row['registration_number'] ?? ''), + 'start_time' => (string)($row['start_time'] ?? ''), + ] + ); + } + + return $flags; + } + + private function getXlVaskPeriodRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers, bool $onlyMissingLinks = false): array + { + global $db; + + if (!$this->tableExists('xlvask_usage_logs')) { + return []; + } + + $dateFrom = $db->escape_string($dateFrom); + $dateTo = $db->escape_string($dateTo); + $customerFilter = $this->customerFilterSql('CAST(x.CustomerId AS UNSIGNED)', $onlyCustomerNumbers); + $missingFilter = $onlyMissingLinks + ? "AND linked_order.id IS NULL" + : ''; + + $sql = " + SELECT + x.id, + x.WashId AS wash_id, + CAST(x.CustomerId AS UNSIGNED) AS customer_number, + COALESCE(u.display_name, x.Customer) AS customer_name, + x.RegistrationNumber AS registration_number, + x.StartTime AS start_time + FROM xlvask_usage_logs x + LEFT JOIN users u ON u.customer_number = CAST(x.CustomerId AS UNSIGNED) + LEFT JOIN orders linked_order + ON linked_order.wash_id = x.WashId + AND linked_order.deleted_at IS NULL + AND linked_order.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}' + WHERE STR_TO_DATE(REPLACE(SUBSTRING(x.StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s') + BETWEEN '{$dateFrom}' AND '{$dateTo}' + AND COALESCE(x.ignored_at, '') = '' + AND COALESCE(x.FinishStatus, '') = '1' + AND CAST(x.CustomerId AS UNSIGNED) > 0 + {$customerFilter} + {$missingFilter}"; + + try { + $result = $db->query($sql); + return $result ? $db->fetch_all($result) : []; + } catch (Throwable) { + return []; + } + } + + private function automaticFlag( + string $definitionKey, + string $targetType, + int $targetId, + ?string $field, + array $row, + array $messageParams, + array $context + ): array { + $customerNumber = (int)($row['customer_number'] ?? $context['customer_number'] ?? 0); + $orderId = isset($context['order_id']) ? (int)$context['order_id'] : (isset($row['order_id']) ? (int)$row['order_id'] : null); + $orderItemId = isset($context['order_item_id']) ? (int)$context['order_item_id'] : (isset($row['order_item_id']) ? (int)$row['order_item_id'] : null); + $invoiceCollectionId = isset($context['invoice_collection_id']) ? (int)$context['invoice_collection_id'] : (isset($row['invoice_collection_id']) ? (int)$row['invoice_collection_id'] : null); + $xlvaskUsageLogId = isset($context['xlvask_usage_log_id']) ? (int)$context['xlvask_usage_log_id'] : null; + + $fingerprint = sha1(json_encode([ + $definitionKey, + $targetType, + $targetId, + $field, + $messageParams['actual_price'] ?? null, + $messageParams['expected_price'] ?? null, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + $message = $this->automaticMessage($definitionKey, $messageParams); + + return [ + 'id' => 'auto:' . $fingerprint, + 'source' => self::SOURCE_AUTOMATIC, + 'severity' => 'yellow', + 'status' => self::STATUS_ACTIVE, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'field' => $field, + 'customer_number' => $customerNumber, + 'customer_name' => (string)($row['customer_name'] ?? $context['customer_name'] ?? ''), + 'order_id' => $orderId, + 'order_item_id' => $orderItemId, + 'invoice_collection_id' => $invoiceCollectionId, + 'xlvask_usage_log_id' => $xlvaskUsageLogId, + 'definition_key' => $definitionKey, + 'fingerprint' => $fingerprint, + 'reason' => null, + 'message_key' => 'invoice_period.flags.automatic.' . $definitionKey, + 'message_params' => $messageParams, + 'message' => $message, + 'message_parts' => $this->messageParts($definitionKey, $messageParams), + 'context' => $context, + ]; + } + + private function automaticMessage(string $definitionKey, array $params): string + { + $product = (string)($params['product'] ?? 'Item'); + $expectedProduct = (string)($params['expected_product'] ?? 'expected product'); + return match ($definitionKey) { + 'price_mismatch' => "{$product} product price differs from expected.", + 'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.", + 'customer_rule_restrict_tank_cleaning' => "{$product} violates restricted tank cleaning.", + 'customer_rule_restrict_spot_free' => "{$product} violates restricted Spot Free.", + 'customer_rule_restrict_interior_cleaning' => "{$product} violates restricted interior wash.", + 'customer_rule_exempt_from_administration_fees' => "{$product} is an administration fee for an exempt customer.", + 'customer_rule_only_tank_cleaning' => "{$product} violates the only tank cleaning rule.", + 'customer_rule_requires_reference' => "Order is missing a required reference.", + 'customer_rule_requires_po_number' => "Order is missing a required PO number.", + 'customer_rule_invoice_all_orders_individually' => "Invoice collection contains multiple orders for a customer requiring individual invoices.", + 'quantity_exceeds_product_limit' => "{$product} quantity exceeds the product limit.", + 'multiple_identical_primary_vehicle_items' => "Order contains multiple identical primary vehicle items.", + 'wash_certificate_item_without_certificate' => "Wash certificate item is present without a wash certificate.", + 'wash_certificate_attached_without_item' => "Wash certificate is attached without a wash certificate item.", + 'multiple_fixed_pricing_items_same_month' => "Multiple fixed pricing items exist in the same month.", + 'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.", + 'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.", + 'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.", + 'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.", + default => "Automatically detected invoice-period issue.", + }; + } + + private function messageParts(string $definitionKey, array $params): array + { + return match ($definitionKey) { + 'price_mismatch' => [ + ['type' => 'order_item', 'text' => (string)($params['product'] ?? 'Item')], + ['type' => 'text', 'text' => ' product price differs from '], + ['type' => 'expected_price', 'text' => 'expected'], + ['type' => 'text', 'text' => '.'], + ], + 'multiple_identical_primary_vehicle_items' => [ + ['type' => 'order', 'text' => 'Order'], + ['type' => 'text', 'text' => ' contains multiple identical primary vehicle items.'], + ], + 'wash_certificate_item_without_certificate' => [ + ['type' => 'order_item', 'text' => 'Wash certificate item'], + ['type' => 'text', 'text' => ' is present without a wash certificate.'], + ], + 'wash_certificate_attached_without_item' => [ + ['type' => 'order', 'text' => 'Wash certificate'], + ['type' => 'text', 'text' => ' is attached without a wash certificate item.'], + ], + 'xlvask_missing_order_link' => [ + ['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], + ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'], + ], + default => [], + }; + } + + private function resolveTargetContext(string $targetType, int $targetId, ?string $field): array + { + global $db; + + if ($targetType === 'customer') { + return ['customer_number' => $targetId]; + } + + if ($targetType === 'order' || $targetType === 'order_field') { + $result = $db->query("SELECT id, customer_id, invoice_collection_id, department_id FROM orders WHERE id = {$targetId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'order_id' => $targetId, + 'invoice_collection_id' => isset($row['invoice_collection_id']) ? (int)$row['invoice_collection_id'] : null, + 'department_id' => isset($row['department_id']) ? (int)$row['department_id'] : null, + 'field' => $field, + ]; + } + + if ($targetType === 'order_item' || $targetType === 'order_item_field') { + $result = $db->query( + "SELECT oi.id, oi.order_id, o.customer_id, o.invoice_collection_id, o.department_id + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.id = {$targetId} + LIMIT 1" + ); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'order_id' => isset($row['order_id']) ? (int)$row['order_id'] : null, + 'order_item_id' => $targetId, + 'invoice_collection_id' => isset($row['invoice_collection_id']) ? (int)$row['invoice_collection_id'] : null, + 'department_id' => isset($row['department_id']) ? (int)$row['department_id'] : null, + 'field' => $field, + ]; + } + + if ($targetType === 'collected_order_invoice') { + $result = $db->query("SELECT id, customer_number FROM collected_order_invoices WHERE id = {$targetId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'invoice_collection_id' => $targetId, + ]; + } + + if ($targetType === 'xlvask_usage_log') { + $result = $db->query("SELECT id, CustomerId, WashId, RegistrationNumber, StartTime FROM xlvask_usage_logs WHERE id = {$targetId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['CustomerId']) ? (int)$row['CustomerId'] : null, + 'xlvask_usage_log_id' => $targetId, + 'wash_id' => (string)($row['WashId'] ?? ''), + 'registration_number' => (string)($row['RegistrationNumber'] ?? ''), + 'start_time' => (string)($row['StartTime'] ?? ''), + ]; + } + + return []; + } + + private function normalizeField(string $targetType, mixed $field): ?string + { + $field = trim((string)($field ?? '')); + if ($field === '') { + return null; + } + if ($targetType === 'order_field' && !in_array($field, self::ORDER_FIELDS, true)) { + throw new \InvalidArgumentException('Invalid order flag field.'); + } + if ($targetType === 'order_item_field' && !in_array($field, self::ORDER_ITEM_FIELDS, true)) { + throw new \InvalidArgumentException('Invalid order item flag field.'); + } + return $field; + } + + private function statusIndicatorForCustomer(array $customer, array $flags): string + { + $counts = $this->countFlags($flags); + if ($counts['manual'] > 0) { + return 'flag_red'; + } + if ($counts['automatic'] > 0) { + return 'flag_yellow'; + } + if (($customer['draft']['is_action_blocked'] ?? false) === true) { + return 'circle_yellow'; + } + return ($customer['requires_action'] ?? false) ? 'circle_red' : 'circle_green'; + } + + private function countFlags(array $flags): array + { + $manual = 0; + $automatic = 0; + foreach ($flags as $flag) { + if (($flag['status'] ?? self::STATUS_ACTIVE) !== self::STATUS_ACTIVE) { + continue; + } + if (($flag['source'] ?? '') === self::SOURCE_MANUAL) { + $manual++; + } elseif (($flag['source'] ?? '') === self::SOURCE_AUTOMATIC) { + $automatic++; + } + } + return [ + 'manual' => $manual, + 'automatic' => $automatic, + 'total' => $manual + $automatic, + ]; + } + + private function sortFlags(array $a, array $b): int + { + $sourceOrder = [self::SOURCE_MANUAL => 0, self::SOURCE_AUTOMATIC => 1]; + $sourceCompare = ($sourceOrder[$a['source'] ?? ''] ?? 99) <=> ($sourceOrder[$b['source'] ?? ''] ?? 99); + if ($sourceCompare !== 0) { + return $sourceCompare; + } + return strcmp((string)($a['created_at'] ?? $a['fingerprint'] ?? ''), (string)($b['created_at'] ?? $b['fingerprint'] ?? '')); + } + + private function ensureFlagOnlyCustomers(array $types, array $flags): array + { + if (!isset($types['all']) || !is_array($types['all'])) { + $types['all'] = []; + } + + $existing = []; + foreach ($types['all'] as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0) { + $existing[$customerNumber] = true; + } + } + + foreach ($flags as $flag) { + $customerNumber = (int)($flag['customer_number'] ?? 0); + if ($customerNumber < 1 || isset($existing[$customerNumber])) { + continue; + } + $types['all'][] = [ + 'id' => null, + 'customer_number' => $customerNumber, + 'customer_name' => (string)($flag['customer_name'] ?? $this->getCustomerName($customerNumber)), + 'transactions' => [], + 'requires_action' => false, + 'meta' => ['flag_only' => true], + 'queue' => ['has_active_job' => false, 'statuses' => [], 'invoice_collection_ids' => [], 'is_action_blocked' => false], + 'draft' => ['has_valid_draft' => false, 'invoice_collection_ids' => [], 'is_action_blocked' => false], + ]; + $existing[$customerNumber] = true; + } + + return $types; + } + + private function getCustomerName(int $customerNumber): string + { + global $db; + $result = $db->query("SELECT display_name FROM users WHERE customer_number = {$customerNumber} LIMIT 1"); + if ($result && $result->num_rows > 0) { + $row = $result->fetch_assoc(); + $displayName = trim((string)($row['display_name'] ?? '')); + return $displayName !== '' ? $displayName : '#' . $customerNumber; + } + return '#' . $customerNumber; + } + + private function getUserDisplayName(?int $userId): ?string + { + global $db; + if ($userId === null || $userId < 1) { + return null; + } + if (array_key_exists($userId, $this->userDisplayNameCache)) { + return $this->userDisplayNameCache[$userId]; + } + + $result = $db->query("SELECT display_name FROM users WHERE id = {$userId} LIMIT 1"); + if (!$result || $result->num_rows === 0) { + $this->userDisplayNameCache[$userId] = null; + return null; + } + + $row = $result->fetch_assoc(); + $displayName = trim((string)($row['display_name'] ?? '')); + $this->userDisplayNameCache[$userId] = $displayName === '' ? null : $displayName; + return $this->userDisplayNameCache[$userId]; + } + + private function getOrderItemToOrderMap(array $orderIds): array + { + global $db; + $orderIds = array_values(array_filter(array_map('intval', $orderIds))); + if (empty($orderIds)) { + return []; + } + $in = implode(',', $orderIds); + $result = $db->query("SELECT id, order_id FROM order_items WHERE order_id IN ({$in})"); + $map = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $map[(int)$row['id']] = (int)$row['order_id']; + } + } + return $map; + } + + private function getVehicleSubscriptionTypeMap(array $primaryRows): array + { + global $db; + $pairs = []; + foreach ($primaryRows as $row) { + $customerNumber = (int)$row['customer_number']; + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + if ($customerNumber > 0 && $reg !== '') { + $pairs[$customerNumber . '|' . $reg] = [$customerNumber, $reg]; + } + } + if (empty($pairs)) { + return []; + } + + $customerNumbers = implode(',', array_unique(array_map(static fn($pair): int => (int)$pair[0], $pairs))); + $deletedFilter = $this->columnExists('customer_vehicles', 'deleted_at') + ? "AND cv.deleted_at IS NULL" + : ""; + $result = $db->query( + "SELECT cv.customer_id, UPPER(TRIM(cv.reg)) AS reg, cv.type AS product_id, p.name AS product_name + FROM customer_vehicles cv + LEFT JOIN products p ON p.id = cv.type + WHERE cv.customer_id IN ({$customerNumbers}) + AND cv.wash_subscription = 1 + {$deletedFilter}" + ); + $map = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $key = (int)$row['customer_id'] . '|' . strtoupper(trim((string)$row['reg'])); + if (isset($pairs[$key])) { + $map[$key] = [ + 'product_id' => (int)$row['product_id'], + 'product_name' => (string)($row['product_name'] ?? ''), + ]; + } + } + } + return $map; + } + + private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array + { + global $db; + + $registrations = []; + foreach ($registrationNumbers as $registrationNumber) { + $registrationNumber = preg_replace('/[^A-Z0-9]/', '', strtoupper(trim((string)$registrationNumber))); + $registrationNumber = is_string($registrationNumber) ? $registrationNumber : ''; + if ($registrationNumber !== '') { + $registrations[$registrationNumber] = true; + } + } + if (empty($registrations)) { + return []; + } + + $dateFrom = $db->escape_string($dateFrom); + $historyStart = $db->escape_string(date('Y-m-d H:i:s', strtotime($dateFrom . ' -18 months'))); + $registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string { + return "'" . $db->escape_string($registrationNumber) . "'"; + }, array_keys($registrations))); + $result = $db->query( + "SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + JOIN products p ON p.id = oi.product_id + WHERE o.created_at >= '{$historyStart}' + AND o.created_at < '{$dateFrom}' + AND o.deleted_at IS NULL + AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + AND p.is_wash = 1 + AND COALESCE(oi.related_item_id, 0) = 0 + AND COALESCE(o.reg_1, '') <> '' + AND o.reg_1 IN ({$registrationFilter}) + GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name + ORDER BY reg, usage_count DESC, oi.product_id ASC" + ); + + $history = []; + $seenCounts = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $reg = (string)$row['reg']; + $item = [ + 'product_id' => (int)$row['product_id'], + 'product_name' => (string)($row['product_name'] ?? ''), + 'count' => (int)$row['usage_count'], + ]; + + if (!isset($seenCounts[$reg])) { + $seenCounts[$reg] = 1; + if ($item['count'] >= 3) { + $history[$reg] = $item; + } + continue; + } + + if ($seenCounts[$reg] === 1) { + $seenCounts[$reg] = 2; + if (isset($history[$reg]) && $item['count'] >= (int)$history[$reg]['count']) { + unset($history[$reg]); + } + } + } + } + return $history; + } + + private function primaryVehicleProductsMatch( + int $currentProductId, + string $currentProductName, + int $expectedProductId, + string $expectedProductName + ): bool { + if ($expectedProductId > 0 && $currentProductId === $expectedProductId) { + return true; + } + + $currentVehicleType = $this->normalizePrimaryVehicleProductName($currentProductName); + $expectedVehicleType = $this->normalizePrimaryVehicleProductName($expectedProductName); + if ($currentVehicleType === '' || $expectedVehicleType === '') { + return false; + } + if ($currentVehicleType === $expectedVehicleType) { + return true; + } + // Allow a match if one normalized name's tokens are a subset of the other. + // E.g. "Indvendig vask Kassevogn" → "kassevogn" is a subset of + // "Kassevogn/varevogn" → "kassevogn varevogn", meaning the same vehicle type. + $currentTokens = explode(' ', $currentVehicleType); + $expectedTokens = explode(' ', $expectedVehicleType); + if (count($currentTokens) <= count($expectedTokens)) { + return array_diff($currentTokens, $expectedTokens) === []; + } + return array_diff($expectedTokens, $currentTokens) === []; + } + + private function normalizePrimaryVehicleProductName(string $productName): string + { + $normalized = strtolower(strtr($productName, [ + 'Æ' => 'ae', + 'Ø' => 'oe', + 'Å' => 'aa', + 'æ' => 'ae', + 'ø' => 'oe', + 'å' => 'aa', + ])); + $normalized = (string)preg_replace('/[^a-z0-9]+/', ' ', $normalized); + $tokens = array_values(array_filter( + explode(' ', trim($normalized)), + static fn(string $token): bool => $token !== '' + && !in_array($token, [ + 'indvendig', + 'indv', + 'interior', + 'internal', + 'vask', + 'wash', + ], true) + )); + + return implode(' ', $tokens); + } + + private function getOrderItemsForPreview(int $orderId): array + { + global $db; + if ($orderId < 1) { + return []; + } + if (array_key_exists($orderId, $this->orderItemsPreviewCache)) { + return $this->orderItemsPreviewCache[$orderId]; + } + + $result = $db->query( + "SELECT oi.id, oi.product_id, oi.price, oi.quantity, p.name AS product_name + FROM order_items oi + LEFT JOIN products p ON p.id = oi.product_id + WHERE oi.order_id = {$orderId} + AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + ORDER BY oi.related_item_id IS NOT NULL, oi.id" + ); + $rows = $result ? $db->fetch_all($result) : []; + $this->orderItemsPreviewCache[$orderId] = array_map(static function (array $row): array { + return [ + 'id' => (int)($row['id'] ?? 0), + 'product_id' => (int)($row['product_id'] ?? 0), + 'product_name' => (string)($row['product_name'] ?? ''), + 'quantity' => (int)($row['quantity'] ?? 0), + 'price' => (int)($row['price'] ?? 0), + ]; + }, $rows); + + return $this->orderItemsPreviewCache[$orderId]; + } + + private function seedOrderItemsPreviewCacheFromRows(array $rows): void + { + $grouped = []; + foreach ($rows as $row) { + $orderId = (int)($row['order_id'] ?? 0); + if ($orderId < 1) { + continue; + } + $grouped[$orderId] ??= []; + + $orderItemId = (int)($row['order_item_id'] ?? 0); + if ($orderItemId < 1) { + continue; + } + + $grouped[$orderId][] = [ + 'id' => $orderItemId, + 'product_id' => (int)($row['product_id'] ?? 0), + 'product_name' => (string)($row['product_name'] ?? ''), + 'quantity' => (int)($row['item_quantity'] ?? 0), + 'price' => (int)($row['item_price'] ?? 0), + '_related_sort' => (int)($row['related_item_id'] ?? 0) > 0 ? 1 : 0, + ]; + } + + foreach ($grouped as $orderId => $items) { + usort($items, static function (array $a, array $b): int { + return ((int)$a['_related_sort'] <=> (int)$b['_related_sort']) + ?: ((int)$a['id'] <=> (int)$b['id']); + }); + $this->orderItemsPreviewCache[(int)$orderId] = array_map(static function (array $item): array { + unset($item['_related_sort']); + return $item; + }, $items); + } + } + + private function preloadEconomicCustomerDiscounts(array $rows): void + { + $customerUserIds = []; + foreach ($rows as $row) { + if ((int)($row['apply_category_discount'] ?? 0) !== 1) { + continue; + } + + $customerNumber = (int)($row['customer_number'] ?? 0); + $userId = (int)($row['user_id'] ?? 0); + if ($customerNumber < 1 || $userId < 1 || array_key_exists($customerNumber, $this->economicCustomerDiscountCache)) { + continue; + } + + $customerUserIds[$customerNumber] = $userId; + } + + foreach ($customerUserIds as $customerNumber => $userId) { + $discount = $this->getCachedEconomicCustomerDiscount($userId); + if ($discount === null) { + $discount = $this->loadEconomicCustomerDiscount((int)$customerNumber, $userId); + } + + $this->economicCustomerDiscountCache[(int)$customerNumber] = $discount; + } + } + + private function getCachedEconomicCustomerDiscount(int $userId): ?int + { + if ($userId < 1 || !defined('redis')) { + return null; + } + + try { + $cachedDiscount = constant('redis')->get_economic_customer_discount_percentage($userId); + return $cachedDiscount === null ? null : (int)$cachedDiscount; + } catch (Throwable $e) { + return null; + } + } + + private function loadEconomicCustomerDiscount(int $customerNumber, int $userId): int + { + if ($customerNumber < 1 || $userId < 1 || !defined('redis')) { + return 0; + } + + try { + $discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customerNumber); + constant('redis')->cache_economic_customer_discount_percentage($userId, $discount); + return $discount; + } catch (Throwable $e) { + return 0; + } + } + + private function calculateExpectedPrice(array $row): int + { + $base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0); + $discount = $this->discountBreakdown($row)['applied_discount_percentage']; + return (int)round($base * (1 - ($discount / 100))); + } + + private function priceBreakdown(array $row, int $expected): array + { + $departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null; + $base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0); + $discount = $this->discountBreakdown($row); + + return [ + 'product_price' => (int)($row['product_base_price'] ?? 0), + 'department_price' => $departmentPrice, + 'effective_base_price' => $base, + 'product_discount_percentage' => $discount['product_discount_percentage'], + 'category_discount_percentage' => $discount['category_discount_percentage'], + 'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'], + 'applied_discount_percentage' => $discount['applied_discount_percentage'], + 'expected_price' => $expected, + ]; + } + + private function discountBreakdown(array $row): array + { + $productDiscount = (int)($row['product_discount_percentage'] ?? 0); + $categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1; + $categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0; + $economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0; + + return [ + 'product_discount_percentage' => $productDiscount, + 'category_discount_percentage' => $categoryDiscount, + 'economic_customer_discount_percentage' => $economicDiscount, + 'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount), + ]; + } + + private function economicCustomerDiscountPercentage(array $row): int + { + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($customerNumber < 1) { + return 0; + } + if (array_key_exists($customerNumber, $this->economicCustomerDiscountCache)) { + return $this->economicCustomerDiscountCache[$customerNumber]; + } + + $discount = 0; + $userId = (int)($row['user_id'] ?? 0); + if ($userId > 0) { + $discount = $this->getCachedEconomicCustomerDiscount($userId) ?? 0; + } + + $this->economicCustomerDiscountCache[$customerNumber] = $discount; + return $discount; + } + + private function orderContext(array $row): array + { + return [ + 'customer_number' => (int)($row['customer_number'] ?? 0), + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'order_id' => (int)($row['order_id'] ?? 0), + 'invoice_collection_id' => (int)($row['invoice_collection_id'] ?? 0) ?: null, + 'department_id' => (int)($row['department_id'] ?? 0) ?: null, + 'reg_1' => (string)($row['reg_1'] ?? ''), + ]; + } + + private function orderItemContext(array $row): array + { + return $this->orderContext($row) + [ + 'order_item_id' => (int)($row['order_item_id'] ?? 0), + 'product_id' => (int)($row['product_id'] ?? 0), + 'product_name' => $this->productLabel($row), + ]; + } + + private function invoiceCollectionContext(array $row): array + { + return [ + 'customer_number' => (int)($row['customer_number'] ?? 0), + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'invoice_collection_id' => (int)($row['invoice_collection_id'] ?? 0), + ]; + } + + private function productLabel(array $row): string + { + $name = trim((string)($row['product_name'] ?? '')); + return $name !== '' ? $name : 'Item #' . (int)($row['product_id'] ?? 0); + } + + private function hasAttribute(array $attributes, int $customerNumber, string $attribute): bool + { + return isset($attributes[$customerNumber][$attribute]); + } + + private function rowMatchesProductTerms(array $row, array $terms): bool + { + $haystack = strtolower(trim( + (string)($row['product_name'] ?? '') . ' ' . + (string)($row['category_name'] ?? '') + )); + foreach ($terms as $term) { + if ($term !== '' && str_contains($haystack, strtolower($term))) { + return true; + } + } + return false; + } + + private function rowIsTankCleaningProduct(array $row): bool + { + return (int)($row['product_category'] ?? 0) === 5 + || $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']); + } + + private function isIncludedOrderItem(array $row): bool + { + $value = $row['item_include_in_invoice'] ?? 1; + return $value === null || $value === '' || (int)$value === 1; + } + + private function isPrimaryVehicleItem(array $row): bool + { + return (int)($row['order_item_id'] ?? 0) > 0 + && (int)($row['is_wash'] ?? 0) === 1 + && (int)($row['related_item_id'] ?? 0) === 0; + } + + private function isWashCertificateProduct(array $row): bool + { + return (int)($row['product_id'] ?? 0) === self::WASH_CERTIFICATE_PRODUCT_ID + || $this->rowMatchesProductTerms($row, ['wash certificate', 'vaskecertifikat']); + } + + private function rowHasWashCertificateAttachment(array $row): bool + { + return (int)($row['has_wash_certificate_attachment'] ?? 0) === 1; + } + + private function dedupeAutomaticFlags(array $flags): array + { + $deduped = []; + foreach ($flags as $flag) { + $deduped[(string)$flag['fingerprint']] = $flag; + } + return array_values($deduped); + } + + private function customerFilterSql(string $column, ?array $onlyCustomerNumbers): string + { + if ($onlyCustomerNumbers === null) { + return ''; + } + $numbers = array_values(array_filter(array_map('intval', $onlyCustomerNumbers), static fn(int $value): bool => $value > 0)); + if (empty($numbers)) { + return ' AND 1=0'; + } + return ' AND ' . $column . ' IN (' . implode(',', array_unique($numbers)) . ')'; + } + + private function nullableIntSql(mixed $value): string + { + if ($value === null || $value === '') { + return 'NULL'; + } + return (string)(int)$value; + } + + private function nullableStringSql(?string $value): string + { + global $db; + if ($value === null || trim($value) === '') { + return 'NULL'; + } + return "'" . $db->escape_string($value) . "'"; + } + + private function jsonSql(array $value): string + { + global $db; + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + return 'NULL'; + } + return "'" . $db->escape_string($json) . "'"; + } + + private function tableExists(string $table): bool + { + global $db; + $table = str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + return $result !== false && is_object($result) && property_exists($result, 'num_rows') && (int)$result->num_rows > 0; + } + + private function columnExists(string $table, string $column): bool + { + global $db; + $table = str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $table); + $column = str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + return $result !== false && is_object($result) && property_exists($result, 'num_rows') && (int)$result->num_rows > 0; + } +} diff --git a/services/nginx/app/classes/invoicing_period_utils.php b/services/nginx/app/classes/invoicing_period_utils.php new file mode 100644 index 00000000..60f225cc --- /dev/null +++ b/services/nginx/app/classes/invoicing_period_utils.php @@ -0,0 +1,107 @@ + strtotime($dateTo)) { + throw new InvalidArgumentException('Invalid date range. dateFrom must be before or equal to dateTo'); + } + + return [ + 'dateFrom' => date('Y-m-d 00:00:00', strtotime($dateFrom)), + 'dateTo' => date('Y-m-d 23:59:59', strtotime($dateTo)), + ]; + } + + /** + * Filter grouped orders down to orders that have at least one neighbor within the time window. + * + * @param array>> $ordersByRegistration + * @return array>> + */ + public static function filterPossibleDuplicates(array $ordersByRegistration, int $windowSeconds = 86400): array + { + $possibleDuplicates = []; + + foreach ( $ordersByRegistration as $registration => $orderList ) { + if (count($orderList) < 2) { + continue; + } + + $normalizedOrders = []; + foreach ( $orderList as $order ) { + $createdAt = (string)($order['created_at'] ?? ''); + $timestamp = strtotime($createdAt); + if ($timestamp === false) { + continue; + } + $order['_timestamp'] = $timestamp; + $normalizedOrders[] = $order; + } + + if (count($normalizedOrders) < 2) { + continue; + } + + usort($normalizedOrders, function (array $a, array $b) { + return (int)$a['_timestamp'] <=> (int)$b['_timestamp']; + }); + + $duplicateIndexes = []; + $count = count($normalizedOrders); + for ( $i = 0; $i < $count; $i++ ) { + $currentTimestamp = (int)$normalizedOrders[$i]['_timestamp']; + for ( $j = $i - 1; $j >= 0; $j-- ) { + $delta = $currentTimestamp - (int)$normalizedOrders[$j]['_timestamp']; + if ($delta > $windowSeconds) { + break; + } + $duplicateIndexes[$i] = true; + $duplicateIndexes[$j] = true; + } + } + + if (count($duplicateIndexes) < 2) { + continue; + } + + $possibleDuplicates[$registration] = []; + $indexes = array_keys($duplicateIndexes); + sort($indexes); + foreach ( $indexes as $index ) { + $order = $normalizedOrders[$index]; + unset($order['_timestamp']); + $possibleDuplicates[$registration][] = $order; + } + } + + return $possibleDuplicates; + } + + private static function isValidDate(string $date): bool + { + $parsed = \DateTime::createFromFormat('Y-m-d', $date); + return $parsed !== false && $parsed->format('Y-m-d') === $date; + } +} diff --git a/services/nginx/app/classes/motorapi.php b/services/nginx/app/classes/motorapi.php index 7abca203..6c96f7bf 100644 --- a/services/nginx/app/classes/motorapi.php +++ b/services/nginx/app/classes/motorapi.php @@ -155,7 +155,7 @@ class motorapi implements motorapi_i // Get the cached result from the log/local database/cache $motorapi_lookups = new motorapi_lookups_o(); // Add the cached value to the meta - $response->add_meta('cached', true); + self::addCachedMetaIfPossible($response); $cleaned_result = self::cleanJSON($motorapi_lookups->getCachedResult($licensePlate)->result->value()); $object = json_decode($cleaned_result); if ($object === null) { @@ -165,6 +165,13 @@ class motorapi implements motorapi_i return json_decode($cleaned_result); } + public static function addCachedMetaIfPossible(mixed $response): void + { + if (is_object($response) && method_exists($response, 'add_meta')) { + $response->add_meta('cached', true); + } + } + /** * @inheritDoc * @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid @@ -385,4 +392,4 @@ class motorapi implements motorapi_i $motorapi_lookups = new motorapi_lookups_o(); $motorapi_lookups->add($licensePlate, json_encode($response), $endpoint); } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/n8n.php b/services/nginx/app/classes/n8n.php new file mode 100644 index 00000000..ef60119c --- /dev/null +++ b/services/nginx/app/classes/n8n.php @@ -0,0 +1,587 @@ +config = new n8n_c(); + } + + /** + * @throws Exception + */ + public function requireModuleEnabled(): void + { + if (!$this->config->enabled->isTrue()) { + throw new Exception('The n8n module is not enabled.'); + } + } + + /** + * @throws Exception + */ + public function listWorkflows(array $filters = []): object + { + return $this->sendApiRequest('GET', '/workflows', $this->filterAllowed($filters, [ + 'active', + 'tags', + 'name', + 'projectId', + 'excludePinnedData', + 'limit', + 'cursor', + ])); + } + + /** + * @throws Exception + */ + public function getWorkflow(string $id, bool $excludePinnedData = false): object + { + $this->requireValidIdentifier($id, 'workflow id'); + + $query = []; + if ($excludePinnedData) { + $query['excludePinnedData'] = true; + } + + return $this->sendApiRequest('GET', '/workflows/' . rawurlencode($id), $query); + } + + /** + * @throws Exception + */ + public function createWorkflow(object $workflow): object + { + return $this->sendApiRequest('POST', '/workflows', [], $this->sanitizeWorkflowPayload($workflow)); + } + + /** + * @throws Exception + */ + public function updateWorkflow(string $id, object $changes): object + { + $this->requireValidIdentifier($id, 'workflow id'); + + $existing = $this->getWorkflow($id); + $merged = $this->mergeWorkflowPayload($existing, $changes); + + return $this->sendApiRequest('PUT', '/workflows/' . rawurlencode($id), [], $merged); + } + + /** + * @throws Exception + */ + public function publishWorkflow(string $id, ?object $options = null): object + { + $this->requireValidIdentifier($id, 'workflow id'); + + return $this->sendApiRequest( + 'POST', + '/workflows/' . rawurlencode($id) . '/activate', + [], + $options !== null ? $this->filterPublishOptions($options) : null + ); + } + + /** + * @throws Exception + */ + public function deactivateWorkflow(string $id): object + { + $this->requireValidIdentifier($id, 'workflow id'); + + return $this->sendApiRequest('POST', '/workflows/' . rawurlencode($id) . '/deactivate'); + } + + /** + * @throws Exception + */ + public function runWebhook(string $webhookTarget, mixed $payload = null, string $method = 'POST', array $query = []): object + { + $this->requireModuleEnabled(); + + $url = $this->resolveWebhookUrl($webhookTarget); + $normalizedMethod = $this->normalizeMethod($method); + + return $this->sendWebhookRequest($normalizedMethod, $url, $query, $payload); + } + + /** + * @throws Exception + */ + public function listExecutions(array $filters = []): object + { + return $this->sendApiRequest('GET', '/executions', $this->filterAllowed($filters, [ + 'includeData', + 'status', + 'workflowId', + 'projectId', + 'limit', + 'cursor', + ])); + } + + /** + * @throws Exception + */ + public function getExecution(int $id, bool $includeData = false): object + { + $this->requirePositiveInteger($id, 'execution id'); + + $query = []; + if ($includeData) { + $query['includeData'] = true; + } + + return $this->sendApiRequest('GET', '/executions/' . $id, $query); + } + + /** + * @throws Exception + */ + public function retryExecution(int $id, bool $loadWorkflow = false): object + { + $this->requirePositiveInteger($id, 'execution id'); + + $payload = null; + if ($loadWorkflow) { + $payload = (object)['loadWorkflow' => true]; + } + + return $this->sendApiRequest('POST', '/executions/' . $id . '/retry', [], $payload); + } + + /** + * @throws Exception + */ + public function stopExecution(int $id): object + { + $this->requirePositiveInteger($id, 'execution id'); + + return $this->sendApiRequest('POST', '/executions/' . $id . '/stop'); + } + + /** + * @throws Exception + */ + private function sendApiRequest(string $method, string $path, array $query = [], ?object $body = null): object + { + $this->requireModuleEnabled(); + $this->requireConfiguredApiUrl(); + $this->requireConfiguredApiKey(); + + $url = $this->buildUrl($this->config->api_url->getVariableValue(), $path, $query); + $headers = [ + 'Accept: application/json', + 'X-N8N-API-KEY: ' . trim((string)$this->config->api_key->getVariableValue()), + ]; + + return $this->executeJsonRequest($method, $url, $headers, $body); + } + + /** + * @throws Exception + */ + private function sendWebhookRequest(string $method, string $url, array $query = [], mixed $body = null): object + { + $headers = ['Accept: application/json']; + $payload = null; + + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + $payload = json_encode($body, JSON_UNESCAPED_UNICODE); + if ($payload === false) { + throw new Exception('Unable to encode n8n webhook payload as JSON.'); + } + } + + $response = $this->executeRequest($method, $this->buildUrl($url, '', $query), $headers, $payload); + if ($response['status'] >= 400) { + throw new Exception($this->extractErrorMessage($response['body'], $response['status'], 'Webhook request failed')); + } + + $decoded = json_decode($response['body']); + if (json_last_error() === JSON_ERROR_NONE) { + if (is_object($decoded)) { + $decoded->status_code = $response['status']; + return $decoded; + } + + return (object)[ + 'status_code' => $response['status'], + 'data' => $decoded, + ]; + } + + return (object)[ + 'status_code' => $response['status'], + 'body' => $response['body'], + ]; + } + + /** + * @throws Exception + */ + private function executeJsonRequest(string $method, string $url, array $headers, ?object $body = null): object + { + $payload = null; + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + $payload = json_encode($body, JSON_UNESCAPED_UNICODE); + if ($payload === false) { + throw new Exception('Unable to encode n8n request body as JSON.'); + } + } + + $response = $this->executeRequest($method, $url, $headers, $payload); + $decoded = json_decode($response['body']); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('Invalid JSON response from n8n (HTTP ' . $response['status'] . ').'); + } + + if ($response['status'] >= 400) { + throw new Exception($this->extractErrorMessage($decoded, $response['status'], 'n8n API request failed')); + } + + if (is_object($decoded)) { + return $decoded; + } + + return (object)[ + 'data' => $decoded, + ]; + } + + /** + * @throws Exception + */ + private function executeRequest(string $method, string $url, array $headers, ?string $body = null): array + { + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + ]); + + if ($body !== null) { + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($curl); + $statusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_error($curl); + curl_close($curl); + + if ($error !== '') { + throw new Exception('cURL request to n8n failed: ' . $error); + } + + if ($responseBody === false) { + throw new Exception('n8n request returned an empty response.'); + } + + return [ + 'status' => $statusCode, + 'body' => (string)$responseBody, + ]; + } + + private function buildUrl(string $baseUrl, string $path = '', array $query = []): string + { + $url = rtrim(trim($baseUrl), '/'); + if ($path !== '') { + $url .= '/' . ltrim($path, '/'); + } + + $query = array_filter($query, static function (mixed $value): bool { + return $value !== null && $value !== ''; + }); + + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + return $url; + } + + /** + * @throws Exception + */ + private function resolveWebhookUrl(string $target): string + { + $target = trim($target); + if ($target === '') { + throw new Exception('Webhook target must not be empty.'); + } + + if (filter_var($target, FILTER_VALIDATE_URL) !== false) { + return $target; + } + + $baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue()); + if ($baseUrl === '') { + throw new Exception('n8n webhook base URL is not configured.'); + } + + return rtrim($baseUrl, '/') . '/' . ltrim($target, '/'); + } + + /** + * @throws Exception + */ + private function sanitizeWorkflowPayload(object $workflow): object + { + $payload = $this->cloneObject($workflow); + + foreach (self::WORKFLOW_READ_ONLY_FIELDS as $field) { + if (property_exists($payload, $field)) { + unset($payload->{$field}); + } + } + + if (!property_exists($payload, 'name') || !is_string($payload->name) || trim($payload->name) === '') { + throw new Exception('Workflow name is required.'); + } + + if (!property_exists($payload, 'nodes') || !is_array($payload->nodes)) { + throw new Exception('Workflow nodes are required and must be an array.'); + } + + if (!property_exists($payload, 'connections')) { + throw new Exception('Workflow connections are required.'); + } + + if (!property_exists($payload, 'settings') || $payload->settings === null) { + $payload->settings = new stdClass(); + } + + $payload->connections = $this->normalizeObjectValue($payload->connections, 'connections'); + $payload->settings = $this->normalizeObjectValue($payload->settings, 'settings'); + + if (property_exists($payload, 'staticData') && is_array($payload->staticData) && !array_is_list($payload->staticData)) { + $payload->staticData = $this->arrayToObject($payload->staticData); + } + + return $payload; + } + + private function mergeWorkflowPayload(object $existing, object $changes): object + { + $merged = $this->cloneObject($existing); + + foreach (get_object_vars($changes) as $key => $value) { + if (in_array($key, self::WORKFLOW_READ_ONLY_FIELDS, true)) { + continue; + } + + if (property_exists($merged, $key) && is_object($merged->{$key}) && is_object($value)) { + $merged->{$key} = $this->mergeObjects($merged->{$key}, $value); + continue; + } + + $merged->{$key} = $value; + } + + return $this->sanitizeWorkflowPayload($merged); + } + + private function mergeObjects(object $base, object $changes): object + { + foreach (get_object_vars($changes) as $key => $value) { + if (property_exists($base, $key) && is_object($base->{$key}) && is_object($value)) { + $base->{$key} = $this->mergeObjects($base->{$key}, $value); + continue; + } + + $base->{$key} = $value; + } + + return $base; + } + + /** + * @throws Exception + */ + private function normalizeObjectValue(mixed $value, string $field): object + { + if ($value instanceof stdClass || is_object($value)) { + return $value; + } + + if (is_array($value) && !array_is_list($value)) { + return $this->arrayToObject($value); + } + + if ($value === [] && $field === 'settings') { + return new stdClass(); + } + + throw new Exception('Workflow ' . $field . ' must be an object.'); + } + + private function arrayToObject(array $value): object + { + $object = new stdClass(); + + foreach ($value as $key => $item) { + $object->{$key} = $this->normalizeMixedValue($item); + } + + return $object; + } + + private function normalizeMixedValue(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + if (array_is_list($value)) { + return array_map(fn (mixed $item): mixed => $this->normalizeMixedValue($item), $value); + } + + return $this->arrayToObject($value); + } + + private function cloneObject(object $value): object + { + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE); + if ($encoded === false) { + return clone $value; + } + + $decoded = json_decode($encoded); + return is_object($decoded) ? $decoded : clone $value; + } + + private function filterPublishOptions(object $options): object + { + $filtered = new stdClass(); + + foreach (['versionId', 'name', 'description'] as $field) { + if (property_exists($options, $field) && $options->{$field} !== null && $options->{$field} !== '') { + $filtered->{$field} = $options->{$field}; + } + } + + return $filtered; + } + + private function filterAllowed(array $filters, array $allowedKeys): array + { + $allowed = array_flip($allowedKeys); + $filtered = []; + + foreach ($filters as $key => $value) { + if (isset($allowed[$key])) { + $filtered[$key] = $value; + } + } + + return $filtered; + } + + /** + * @throws Exception + */ + private function requireConfiguredApiUrl(): void + { + $url = trim((string)$this->config->api_url->getVariableValue()); + if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) { + throw new Exception('Invalid n8n API URL configured.'); + } + } + + /** + * @throws Exception + */ + private function requireConfiguredApiKey(): void + { + if (trim((string)$this->config->api_key->getVariableValue()) === '') { + throw new Exception('Invalid n8n API key configured.'); + } + } + + private function normalizeUrlForValidation(string $url): string + { + if (preg_match('#^https?://#i', $url)) { + return $url; + } + + return 'http://' . ltrim($url, '/'); + } + + /** + * @throws Exception + */ + private function requireValidIdentifier(string $value, string $label): void + { + if (trim($value) === '') { + throw new Exception('Invalid ' . $label . '.'); + } + } + + /** + * @throws Exception + */ + private function requirePositiveInteger(int $value, string $label): void + { + if ($value <= 0) { + throw new Exception('Invalid ' . $label . '.'); + } + } + + private function normalizeMethod(string $method): string + { + $normalized = strtoupper(trim($method)); + if (!in_array($normalized, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return 'POST'; + } + + return $normalized; + } + + private function extractErrorMessage(mixed $decoded, int $statusCode, string $fallback): string + { + if (is_object($decoded)) { + if (isset($decoded->message) && is_string($decoded->message)) { + return $decoded->message; + } + if (isset($decoded->error) && is_string($decoded->error)) { + return $decoded->error; + } + } + + if (is_string($decoded) && trim($decoded) !== '') { + return $decoded; + } + + return $fallback . ' (HTTP ' . $statusCode . ').'; + } +} diff --git a/services/nginx/app/classes/object_property.php b/services/nginx/app/classes/object_property.php index cae4ca53..3f858d44 100644 --- a/services/nginx/app/classes/object_property.php +++ b/services/nginx/app/classes/object_property.php @@ -155,6 +155,10 @@ class object_property if (defined('redis')) { redis->delete($this->getCacheKey()); } + try { + system_search_cache::markDirtyTable($this->table); + } catch (\Throwable) { + } } /** @@ -184,5 +188,9 @@ class object_property if (defined('redis')) { redis->delete($this->getCacheKey()); } + try { + system_search_cache::markDirtyTable($this->table); + } catch (\Throwable) { + } } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/openai.php b/services/nginx/app/classes/openai.php index a6f24800..5fdc9696 100644 --- a/services/nginx/app/classes/openai.php +++ b/services/nginx/app/classes/openai.php @@ -34,11 +34,58 @@ class openai implements openai_i */ public function requireModuleEnabled(): void { - if (!(bool)$this->config->enabled->getVariableValue()) { + if (!$this->config->enabled->isTrue()) { throw new Exception('OpenAI module is not enabled.'); } } + /** + * Send a structured JSON text task to the OpenAI Responses API. + * + * @throws Exception + */ + public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array + { + $this->requireModuleEnabled(); + + $data = [ + 'model' => $this->model, + 'input' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'input_text', + 'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ], + ], + ], + ], + 'text' => [ + 'format' => [ + 'type' => 'json_schema', + 'name' => $schemaName, + 'schema' => $schema, + 'strict' => true, + ], + ], + 'temperature' => $temperature, + ]; + + $response = $this->sendRequest($data); + $output = $response['output'][0]['content'][0]['text'] ?? null; + if (!is_string($output) || $output === '') { + throw new Exception('Invalid response format from OpenAI API. (Missing text field)'); + } + + $decoded = json_decode($output, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { + throw new Exception('Error parsing JSON response: ' . json_last_error_msg()); + } + + return $decoded; + } + protected function getLPRSchema(): array { return [ @@ -248,4 +295,4 @@ class openai implements openai_i //print_r($responseData); return $responseData; } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/order_bookings_counts_cache.php b/services/nginx/app/classes/order_bookings_counts_cache.php new file mode 100644 index 00000000..dacdc693 --- /dev/null +++ b/services/nginx/app/classes/order_bookings_counts_cache.php @@ -0,0 +1,201 @@ + 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; + } + } +} diff --git a/services/nginx/app/classes/order_bookings_list_cache.php b/services/nginx/app/classes/order_bookings_list_cache.php new file mode 100644 index 00000000..caa5986f --- /dev/null +++ b/services/nginx/app/classes/order_bookings_list_cache.php @@ -0,0 +1,190 @@ +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; + } + } +} diff --git a/services/nginx/app/classes/order_reference_suggestions_service.php b/services/nginx/app/classes/order_reference_suggestions_service.php new file mode 100644 index 00000000..c0daf1a9 --- /dev/null +++ b/services/nginx/app/classes/order_reference_suggestions_service.php @@ -0,0 +1,517 @@ + + */ + private array $columnExistsCache = []; + + /** + * @param array{ + * search?: mixed, + * department_id?: mixed, + * customer_id?: mixed, + * reg_1?: mixed, + * reg_2?: mixed, + * reg_3?: mixed, + * limit?: mixed + * } $criteria + * @return array> + */ + public function suggest(array $criteria): array + { + $departmentId = $this->toPositiveInt($criteria['department_id'] ?? null); + if ($departmentId === null) { + return []; + } + + $search = $this->normalizeText($criteria['search'] ?? ''); + $customerId = $this->toPositiveInt($criteria['customer_id'] ?? null); + $plates = $this->normalizePlates([ + $criteria['reg_1'] ?? '', + $criteria['reg_2'] ?? '', + $criteria['reg_3'] ?? '', + ]); + $limit = $this->clampLimit($criteria['limit'] ?? self::DEFAULT_LIMIT); + + $rows = [ + ...$this->fetchBookingRows($departmentId, $search), + ...$this->fetchOrderRows($departmentId, $search), + ...$this->fetchVehicleRows($customerId, $plates, $search), + ]; + + $suggestions = $this->aggregateRows($rows, $search, $customerId, $plates); + usort($suggestions, [$this, 'sortSuggestions']); + + return array_slice($suggestions, 0, $limit); + } + + /** + * @return array> + */ + private function fetchBookingRows(int $departmentId, string $search): array + { + $where = [ + 'department = :department_id', + 'reference IS NOT NULL', + "TRIM(reference) <> ''", + ]; + if ($this->tableHasColumn('order_bookings', 'deleted_at')) { + array_unshift($where, 'deleted_at IS NULL'); + } + $params = ['department_id' => $departmentId]; + + if ($search !== '') { + $where[] = 'LOWER(reference) LIKE :search'; + $params['search'] = '%' . $this->lower($search) . '%'; + } + + $sql = "SELECT + 'booking' AS source, + id AS origin_id, + TRIM(reference) AS reference, + datetime AS source_created_at, + datetime AS used_at, + customer_number AS customer_id, + department AS department_id, + reg_1, + reg_2, + reg_3 + FROM order_bookings + WHERE " . implode(' AND ', $where) . " + ORDER BY datetime DESC, id DESC + LIMIT :source_limit"; + + return $this->fetchRows($sql, $params); + } + + /** + * @return array> + */ + private function fetchOrderRows(int $departmentId, string $search): array + { + $where = [ + 'department_id = :department_id', + 'reference IS NOT NULL', + "TRIM(reference) <> ''", + ]; + if ($this->tableHasColumn('orders', 'deleted_at')) { + array_unshift($where, 'deleted_at IS NULL'); + } + $params = ['department_id' => $departmentId]; + + if ($search !== '') { + $where[] = 'LOWER(reference) LIKE :search'; + $params['search'] = '%' . $this->lower($search) . '%'; + } + + $sql = "SELECT + 'order' AS source, + id AS origin_id, + TRIM(reference) AS reference, + created_at AS source_created_at, + created_at AS used_at, + customer_id, + department_id, + reg_1, + reg_2, + reg_3 + FROM orders + WHERE " . implode(' AND ', $where) . " + ORDER BY created_at DESC, id DESC + LIMIT :source_limit"; + + return $this->fetchRows($sql, $params); + } + + /** + * @param array $plates + * @return array> + */ + private function fetchVehicleRows(?int $customerId, array $plates, string $search): array + { + $contextWhere = []; + $params = []; + + if ($customerId !== null) { + $contextWhere[] = 'customer_id = :customer_id'; + $params['customer_id'] = $customerId; + } + + foreach ($plates as $index => $plate) { + $key = 'plate_' . $index; + $contextWhere[] = "UPPER(REPLACE(reg, ' ', '')) = :$key"; + $params[$key] = $plate; + } + + if ($contextWhere === []) { + return []; + } + + $where = [ + 'reference IS NOT NULL', + "TRIM(reference) <> ''", + '(' . implode(' OR ', $contextWhere) . ')', + ]; + if ($this->tableHasColumn('customer_vehicles', 'deleted_at')) { + array_unshift($where, 'deleted_at IS NULL'); + } + + if ($search !== '') { + $where[] = 'LOWER(reference) LIKE :search'; + $params['search'] = '%' . $this->lower($search) . '%'; + } + + $sql = "SELECT + 'vehicle' AS source, + id AS origin_id, + TRIM(reference) AS reference, + created_at AS source_created_at, + created_at AS used_at, + customer_id, + NULL AS department_id, + reg AS reg_1, + '' AS reg_2, + '' AS reg_3 + FROM customer_vehicles + WHERE " . implode(' AND ', $where) . " + ORDER BY created_at DESC, id DESC + LIMIT :source_limit"; + + return $this->fetchRows($sql, $params); + } + + /** + * @param array $params + * @return array> + */ + private function fetchRows(string $sql, array $params): array + { + $pdo = db::getPDO(); + $statement = $pdo->prepare($sql); + + foreach ($params as $key => $value) { + $statement->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $statement->bindValue(':source_limit', self::MAX_SOURCE_ROWS, PDO::PARAM_INT); + $statement->execute(); + + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + return is_array($rows) ? $rows : []; + } + + private function tableHasColumn(string $table, string $column): bool + { + $cacheKey = $table . '.' . $column; + if (array_key_exists($cacheKey, $this->columnExistsCache)) { + return $this->columnExistsCache[$cacheKey]; + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'SELECT COUNT(*) AS total + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table_name + AND COLUMN_NAME = :column_name' + ); + $statement->bindValue(':table_name', $table, PDO::PARAM_STR); + $statement->bindValue(':column_name', $column, PDO::PARAM_STR); + $statement->execute(); + + $this->columnExistsCache[$cacheKey] = ((int)$statement->fetchColumn()) > 0; + return $this->columnExistsCache[$cacheKey]; + } + + /** + * @param array> $rows + * @param array $plates + * @return array> + */ + private function aggregateRows(array $rows, string $search, ?int $customerId, array $plates): array + { + $groups = []; + + foreach ($rows as $row) { + $reference = $this->normalizeText($row['reference'] ?? ''); + if ($reference === '') { + continue; + } + + $key = $this->lower($reference); + if (!isset($groups[$key])) { + $groups[$key] = [ + 'reference' => $reference, + 'rows' => [], + 'usage_count' => 0, + 'last_used_at' => null, + 'context_boost' => 0, + 'section' => 'other', + ]; + } + + $section = $this->contextSection($row, $customerId, $plates); + $groups[$key]['usage_count']++; + $groups[$key]['rows'][] = $row; + $groups[$key]['last_used_at'] = $this->maxDate( + $groups[$key]['last_used_at'], + $this->normalizeDate($row['used_at'] ?? null) + ); + $groups[$key]['context_boost'] = max( + $groups[$key]['context_boost'], + $this->contextBoost($row, $customerId, $plates) + ); + $groups[$key]['section'] = $this->bestSection( + (string)$groups[$key]['section'], + $section + ); + } + + $suggestions = []; + foreach ($groups as $group) { + $bestRow = $this->bestOriginRow($group['rows']); + if ($bestRow === null) { + continue; + } + + $source = (string)($bestRow['source'] ?? 'order'); + $usageCount = (int)$group['usage_count']; + $score = $this->matchScore((string)$group['reference'], $search) + + (int)$group['context_boost'] + + $this->sectionScore((string)$group['section']) + + $this->sourceScore($source) + + min($usageCount, 20) * 5; + + $suggestions[] = [ + 'source' => $source, + 'section' => (string)$group['section'], + 'reference' => (string)$group['reference'], + 'source_created_at' => $this->normalizeDate($bestRow['source_created_at'] ?? null), + 'last_used_at' => $group['last_used_at'], + 'usage_count' => $usageCount, + 'origin_id' => (int)($bestRow['origin_id'] ?? 0), + 'score' => $score, + ]; + } + + return $suggestions; + } + + /** + * @param array> $rows + */ + private function bestOriginRow(array $rows): ?array + { + usort($rows, function (array $left, array $right): int { + $sourceCompare = $this->sourceScore((string)($right['source'] ?? '')) + <=> $this->sourceScore((string)($left['source'] ?? '')); + if ($sourceCompare !== 0) { + return $sourceCompare; + } + + $dateCompare = strcmp( + (string)$this->normalizeDate($right['source_created_at'] ?? null), + (string)$this->normalizeDate($left['source_created_at'] ?? null) + ); + if ($dateCompare !== 0) { + return $dateCompare; + } + + return ((int)($right['origin_id'] ?? 0)) <=> ((int)($left['origin_id'] ?? 0)); + }); + + return $rows[0] ?? null; + } + + private function sortSuggestions(array $left, array $right): int + { + $scoreCompare = ((int)($right['score'] ?? 0)) <=> ((int)($left['score'] ?? 0)); + if ($scoreCompare !== 0) { + return $scoreCompare; + } + + $usageCompare = ((int)($right['usage_count'] ?? 0)) <=> ((int)($left['usage_count'] ?? 0)); + if ($usageCompare !== 0) { + return $usageCompare; + } + + $sectionCompare = $this->sectionScore((string)($right['section'] ?? '')) + <=> $this->sectionScore((string)($left['section'] ?? '')); + if ($sectionCompare !== 0) { + return $sectionCompare; + } + + $dateCompare = strcmp((string)($right['last_used_at'] ?? ''), (string)($left['last_used_at'] ?? '')); + if ($dateCompare !== 0) { + return $dateCompare; + } + + $referenceCompare = strcmp((string)($left['reference'] ?? ''), (string)($right['reference'] ?? '')); + if ($referenceCompare !== 0) { + return $referenceCompare; + } + + return $this->sourceScore((string)($right['source'] ?? '')) <=> $this->sourceScore((string)($left['source'] ?? '')); + } + + /** + * @param array $plates + */ + private function contextBoost(array $row, ?int $customerId, array $plates): int + { + $score = 0; + if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) { + $score += 80; + } + + if ($this->rowMatchesAnyPlate($row, $plates)) { + $score += 90; + } + + return $score; + } + + /** + * @param array $plates + */ + private function contextSection(array $row, ?int $customerId, array $plates): string + { + if ($this->rowMatchesAnyPlate($row, $plates)) { + return 'this_vehicle'; + } + + if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) { + return 'other_customer_vehicle'; + } + + return 'other'; + } + + /** + * @param array $plates + */ + private function rowMatchesAnyPlate(array $row, array $plates): bool + { + $rowPlates = $this->normalizePlates([ + $row['reg_1'] ?? '', + $row['reg_2'] ?? '', + $row['reg_3'] ?? '', + ]); + + return $plates !== [] && array_intersect($plates, $rowPlates) !== []; + } + + private function matchScore(string $reference, string $search): int + { + if ($search === '') { + return 0; + } + + $referenceKey = $this->lower($reference); + $searchKey = $this->lower($search); + + if ($referenceKey === $searchKey) { + return 1000; + } + + if (str_starts_with($referenceKey, $searchKey)) { + return 600; + } + + if (str_contains($referenceKey, $searchKey)) { + return 300; + } + + return 0; + } + + private function sourceScore(string $source): int + { + return match ($source) { + 'booking' => 30, + 'order' => 20, + 'vehicle' => 10, + default => 0, + }; + } + + private function sectionScore(string $section): int + { + return match ($section) { + 'this_vehicle' => 40, + 'other_customer_vehicle' => 20, + default => 0, + }; + } + + private function bestSection(string $left, string $right): string + { + return $this->sectionScore($right) > $this->sectionScore($left) ? $right : $left; + } + + private function clampLimit(mixed $value): int + { + $limit = $this->toPositiveInt($value) ?? self::DEFAULT_LIMIT; + return max(1, min($limit, self::MAX_LIMIT)); + } + + private function toPositiveInt(mixed $value): ?int + { + $parsed = filter_var($value, FILTER_VALIDATE_INT); + return is_int($parsed) && $parsed > 0 ? $parsed : null; + } + + private function normalizeText(mixed $value): string + { + return trim((string)($value ?? '')); + } + + private function lower(string $value): string + { + return function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value); + } + + /** + * @param array $values + * @return array + */ + private function normalizePlates(array $values): array + { + $plates = []; + foreach ($values as $value) { + $plate = strtoupper(preg_replace('/\s+/', '', (string)($value ?? ''))); + if ($plate !== '') { + $plates[] = $plate; + } + } + + return array_values(array_unique($plates)); + } + + private function normalizeDate(mixed $value): ?string + { + $date = trim((string)($value ?? '')); + return $date === '' || $date === '0000-00-00 00:00:00' ? null : $date; + } + + private function maxDate(?string $left, ?string $right): ?string + { + if ($left === null) { + return $right; + } + if ($right === null) { + return $left; + } + + return strcmp($right, $left) > 0 ? $right : $left; + } +} diff --git a/services/nginx/app/classes/orders_input_normalizer.php b/services/nginx/app/classes/orders_input_normalizer.php new file mode 100644 index 00000000..92dc666b --- /dev/null +++ b/services/nginx/app/classes/orders_input_normalizer.php @@ -0,0 +1,86 @@ +format('Y-m-d H:i:s'); + } + + if (!is_string($value)) { + throw new InvalidArgumentException('created_at must be a string'); + } + + $trimmed = trim($value); + if ($trimmed === '') { + throw new InvalidArgumentException('created_at cannot be empty'); + } + + foreach (['Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i'] as $format) { + $parsed = DateTimeImmutable::createFromFormat($format, $trimmed); + if ($parsed instanceof DateTimeImmutable && $parsed->format($format) === $trimmed) { + return $parsed->format('Y-m-d H:i:s'); + } + } + + throw new InvalidArgumentException('created_at must be a valid datetime'); + } + + public static function normalizeIncludeInInvoice(mixed $value): ?bool + { + if ($value === null) { + return null; + } + + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + if ($value === 1) { + return true; + } + if ($value === 0) { + return false; + } + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + return match ($normalized) { + '', 'null', 'use_department' => null, + '1', 'true', 'include', 'included', 'yes' => true, + '0', 'false', 'exclude', 'excluded', 'no' => false, + default => throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude'), + }; + } + + throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude'); + } +} diff --git a/services/nginx/app/classes/orders_schema_bootstrap.php b/services/nginx/app/classes/orders_schema_bootstrap.php new file mode 100644 index 00000000..d05fff0d --- /dev/null +++ b/services/nginx/app/classes/orders_schema_bootstrap.php @@ -0,0 +1,146 @@ +query( + "ALTER TABLE orders + ADD COLUMN include_in_invoice TINYINT(1) NULL DEFAULT NULL + AFTER created_at" + ); + } + + if (!self::columnExists($db, 'orders', 'safety_seal')) { + $db->query( + "ALTER TABLE orders + ADD COLUMN safety_seal VARCHAR(255) NULL DEFAULT NULL + AFTER po" + ); + } + + self::backfillBookingPoDefaults($db); + + self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at'); + self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id'); + self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at'); + self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id'); + + self::$initialized = true; + } + + private static function backfillBookingPoDefaults(object $db): void + { + if ( + !self::tableExists($db, 'order_bookings') + || !self::columnExists($db, 'orders', 'booking_id') + || !self::columnExists($db, 'orders', 'po') + || !self::columnExists($db, 'order_bookings', 'po') + ) { + return; + } + + $db->query( + "UPDATE orders o + INNER JOIN order_bookings b ON b.id = o.booking_id + SET o.po = b.po + WHERE o.booking_id IS NOT NULL + AND o.booking_id > 0 + AND (o.po IS NULL OR TRIM(o.po) = '') + AND b.po IS NOT NULL + AND TRIM(b.po) <> ''" + ); + } + + private static function tableExists(object $db, string $table): bool + { + $table = self::escapeIdentifier($table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function columnExists(object $db, string $table, string $column): bool + { + $table = self::escapeIdentifier($table); + $column = self::escapeIdentifier($column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function ensureIndex(object $db, string $table, string $index, string $columns): void + { + if ( + !self::tableExists($db, $table) + || self::indexExists($db, $table, $index) + || !self::columnsExist($db, $table, $columns) + ) { + return; + } + + $table = self::escapeIdentifier($table); + $index = self::escapeIdentifier($index); + $db->query("ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})"); + } + + private static function columnsExist(object $db, string $table, string $columns): bool + { + foreach (explode(',', $columns) as $column) { + $column = trim($column, " \t\n\r\0\x0B`"); + if ($column === '' || !self::columnExists($db, $table, $column)) { + return false; + } + } + + return true; + } + + private static function indexExists(object $db, string $table, string $index): bool + { + $table = self::escapeIdentifier($table); + $index = self::escapeIdentifier($index); + $result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function escapeIdentifier(string $value): string + { + return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); + } +} diff --git a/services/nginx/app/classes/products_schema_bootstrap.php b/services/nginx/app/classes/products_schema_bootstrap.php new file mode 100644 index 00000000..3963f1d9 --- /dev/null +++ b/services/nginx/app/classes/products_schema_bootstrap.php @@ -0,0 +1,68 @@ +query( + "ALTER TABLE products + ADD COLUMN max_quantity_per_order INT NULL DEFAULT NULL + AFTER order_priority" + ); + } + + self::$initialized = true; + } + + private static function tableExists(object $db, string $table): bool + { + $table = self::escapeIdentifier($table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function columnExists(object $db, string $table, string $column): bool + { + $table = self::escapeIdentifier($table); + $column = self::escapeIdentifier($column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function escapeIdentifier(string $value): string + { + return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); + } +} diff --git a/services/nginx/app/classes/redis.php b/services/nginx/app/classes/redis.php index d247dbdd..76c0c96a 100644 --- a/services/nginx/app/classes/redis.php +++ b/services/nginx/app/classes/redis.php @@ -17,8 +17,12 @@ class redis implements redis_i } // Apply the Redis configuration $this->redis_host = $REDIS_CONFIG['host']; + $this->redis_user = $REDIS_CONFIG['user'] ?? ''; $this->redis_database = $REDIS_CONFIG['database']; $this->redis_password = $REDIS_CONFIG['password']; + if (isset($REDIS_CONFIG['port']) && is_numeric($REDIS_CONFIG['port'])) { + $this->redis_port = (int)$REDIS_CONFIG['port']; + } } @@ -360,6 +364,178 @@ class redis implements redis_i return $this; } + /** + * @inheritDoc + */ + public function cache_invoice_period_manual_flags(array $flags): self + { + $this->set_array('invoice_period_manual_flags', $flags); + return $this; + } + + /** + * @inheritDoc + */ + public function get_invoice_period_manual_flags(): array|null + { + return $this->get_array('invoice_period_manual_flags'); + } + + /** + * @inheritDoc + */ + public function clear_invoice_period_manual_flags(): self + { + $this->delete('invoice_period_manual_flags'); + return $this; + } + + private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string + { + return $prefix . ':' . $dateFrom . ':' . $dateTo; + } + + private function workfeedEmployeeNameCacheKey(string $employeeId): string + { + return 'workfeed_employee_name:' . rawurlencode($employeeId); + } + + /** + * @inheritDoc + */ + public function cache_invoice_period_automatic_flags(string $dateFrom, string $dateTo, array $flags): self + { + $this->set_array($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo), $flags); + return $this; + } + + /** + * @inheritDoc + */ + public function get_invoice_period_automatic_flags(string $dateFrom, string $dateTo): array|null + { + return $this->get_array($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo)); + } + + /** + * @inheritDoc + */ + public function clear_invoice_period_automatic_flags(string $dateFrom, string $dateTo): self + { + $this->delete($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo)); + return $this; + } + + /** + * @inheritDoc + */ + public function cache_invoice_period_order_item_rows(string $dateFrom, string $dateTo, array $rows): self + { + $this->set_array($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo), $rows); + return $this; + } + + /** + * @inheritDoc + */ + public function get_invoice_period_order_item_rows(string $dateFrom, string $dateTo): array|null + { + return $this->get_array($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo)); + } + + /** + * @inheritDoc + */ + public function clear_invoice_period_order_item_rows(string $dateFrom, string $dateTo): self + { + $this->delete($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo)); + return $this; + } + + /** + * @inheritDoc + */ + public function cache_workfeed_employee_name(string $employeeId, string $employeeName, int $ttl = 86400): self + { + $normalizedEmployeeId = trim($employeeId); + if ($normalizedEmployeeId === '') { + return $this; + } + + $normalizedEmployeeName = trim($employeeName); + if ($normalizedEmployeeName === '') { + return $this; + } + + $key = $this->workfeedEmployeeNameCacheKey($normalizedEmployeeId); + $this->set($key, $normalizedEmployeeName); + $this->expire($key, $ttl); + + return $this; + } + + /** + * @inheritDoc + */ + public function get_workfeed_employee_name(string $employeeId): string|null + { + $normalizedEmployeeId = trim($employeeId); + if ($normalizedEmployeeId === '') { + return null; + } + + $value = $this->get($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId)); + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + return $normalized !== '' ? $normalized : null; + } + + /** + * @inheritDoc + */ + public function clear_workfeed_employee_name(string $employeeId): self + { + $normalizedEmployeeId = trim($employeeId); + if ($normalizedEmployeeId === '') { + return $this; + } + + $this->delete($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId)); + return $this; + } + + /** + * @inheritDoc + */ + public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self + { + $this->get_client()->sadd('invoice_period_warming_queue', [$dateFrom . '|' . $dateTo]); + return $this; + } + + /** + * @inheritDoc + */ + public function consume_invoice_period_warming_queue(): array + { + $client = $this->get_client(); + $members = $client->smembers('invoice_period_warming_queue'); + if (!empty($members)) { + $client->del('invoice_period_warming_queue'); + } + $periods = []; + foreach ($members as $member) { + $parts = explode('|', (string)$member, 2); + if (count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '') { + $periods[] = ['dateFrom' => $parts[0], 'dateTo' => $parts[1]]; + } + } + return $periods; + } + /** * @inheritDoc */ @@ -450,8 +626,27 @@ class redis implements redis_i return 'temporary_cache_' . uniqid(); } + /** + * Atomically set a key with TTL only when it does not already exist. + */ + public function set_if_absent_with_expiration(string $key, string $value, int $seconds): bool + { + if (!self::is_connected()) { + self::connect(); + } + + $seconds = max(1, $seconds); + $result = $this->redis->set($key, $value, 'EX', $seconds, 'NX'); + + return $result === true || strtoupper((string)$result) === 'OK'; + } + public function mget(array $array_map): array { + if (empty($array_map)) { + return []; + } + // Get multiple keys from Redis return $this->redis->mget($array_map); } @@ -485,4 +680,18 @@ class redis implements redis_i $this->delete('perm:' . $cache_key); return $this; } -} \ No newline at end of file + + /** + * @throws \Exception + */ + public function ping(): bool + { + if (!self::is_connected()) { + self::connect(); + } + if (!self::is_connected()) { + return false; + } + return true; + } +} diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php new file mode 100644 index 00000000..6e59bb57 --- /dev/null +++ b/services/nginx/app/classes/release_manager.php @@ -0,0 +1,10802 @@ + 'master', + 'master' => 'master', + 'beta' => 'beta', + 'canary' => 'canary', + 'internal' => 'internal', + ]; + private const RELEASE_ROUTE_CHANNELS = [ + 'master' => 'stable', + 'beta' => 'beta', + 'canary' => 'canary', + 'internal' => 'internal', + ]; + private const SERVICE_SET_MODES = ['attach_existing', 'clone_existing', 'fresh_empty', 'isolated_stack']; + private const STACK_DATA_KINDS = ['database', 'redis', 'minio']; + private const PRODUCTION_DATA_POLICY = 'production_shared'; + private const BETA_PRODUCTION_DATA_SOURCE_CHANNELS = ['stable', 'master', 'production', 'prod']; + private const PRODUCTION_SERVICE_POLICY = 'production_shared'; + private const PRODUCTION_SERVICE_CHANNELS = ['beta']; + private const RELEASE_STATUS_SERVICES = ['frontend', 'api', 'database', 'redis', 'minio']; + private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod']; + private const DEFAULT_COOLIFY_APPLICATION_PORT = '80'; + private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api'; + private const RELEASE_API_RUNTIME_ENV_KEYS = [ + 'USE_ENV', + 'DEBUG', + 'ENCRYPTION_KEY', + 'CORS', + 'CONFIG_TIMEZONE', + 'CONFIG_DB_TARGET', + 'CONFIG_DB_HOST', + 'CONFIG_DB_USER', + 'CONFIG_DB_PASSWORD', + 'CONFIG_DB_DATABASE', + 'CONFIG_DB_PORT', + 'CONFIG_DB_SSL_MODE', + 'CONFIG_DB_DEBUG_HOST', + 'CONFIG_DB_DEBUG_USER', + 'CONFIG_DB_DEBUG_PASSWORD', + 'CONFIG_DB_DEBUG_DATABASE', + 'CONFIG_DB_DEBUG_PORT', + 'CONFIG_DB_DEBUG_SSL_MODE', + 'REDIS_CONFIG_HOST', + 'REDIS_CONFIG_USER', + 'REDIS_CONFIG_PASSWORD', + 'REDIS_CONFIG_DATABASE', + 'REDIS_CONFIG_PORT', + 'REDIS_CONFIG_DEBUG_HOST', + 'REDIS_CONFIG_DEBUG_USER', + 'REDIS_CONFIG_DEBUG_PASSWORD', + 'REDIS_CONFIG_DEBUG_DATABASE', + 'REDIS_CONFIG_DEBUG_PORT', + 'ECONOMIC_API_APP_ACCESS_GRANT', + 'ECONOMIC_API_APP_ACCESS_GRANT2', + 'ECONOMIC_API_APP_SECRET_TOKEN', + 'WORDPRESS_STATIC_TOKEN', + 'EMAIL_WASH_CERTIFICATE_TOKEN', + 'WORDPRESS_API_URL', + 'MINIO_ENDPOINT', + 'MINIO_ACCESS_KEY', + 'MINIO_SECRET_KEY', + 'SLACK_DEFAULT_WEBHOOK', + 'API_COMMIT_SHA', + 'COMMIT_SHA', + 'GITHUB_SHA', + 'RELEASE_COMMIT_SHA', + ]; + private const RELEASE_API_RUNTIME_ENV_PREFIXES = [ + 'EDGE_', + 'RELEASE_MANAGER_', + 'COOLIFY_', + 'HETZNER_', + 'OPENAI_', + 'STRIPE_', + 'FXRATES_', + 'WEATHER_', + 'MOTOR_', + 'BIRD_', + 'OCR_', + 'LICENSE_', + 'VIRK_', + 'LIMBLE_', + 'ENTRA_', + 'REQUEST_QUEUE_', + 'WORKFEED_', + ]; + private const SUBJECT_TYPES = ['user', 'subuser', 'customer']; + private const CAPTURE_LEVELS = ['metadata', 'full_redacted', 'full']; + private const MODULE_KEYS = [ + 'economic', + 'reCAPTCHA', + 'email', + 'backups', + 'motorapi', + 'stripe', + 'fxratesapi', + 'weatherapi', + 'workfeed', + 'gatewayapi', + 'xlvask', + 'entra', + 'limble', + 'ocrspace', + 'openai', + 'licenseplaterecognizer', + 'virkdata', + 'shelly', + 'coolify', + 'failover', + 'edgegateway', + 'selfserve', + 'bird', + 'auth', + 'worker', + 'requestqueue', + 'moduleactionlogs', + 'releasemanager', + ]; + + private bool $schemaEnsured = false; + private array $inProcessPassedReleaseGates = []; + + public static function initializeRequestContext(): array + { + $traceId = self::safeIdentifier( + self::requestHeaderValue('X-Release-Trace') ?: (string)($_GET['release_trace'] ?? ''), + 64 + ); + if ($traceId === '') { + $traceId = bin2hex(random_bytes(16)); + } + + $requestedChannel = self::safeSlug((string)( + self::requestHeaderValue('X-Release-Channel') + ?: ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? '') + )); + $frontendVersion = self::safeIdentifier( + (string)(self::requestHeaderValue('X-Frontend-Version') ?: ($_GET['frontend_version'] ?? '')), + 128 + ); + + $context = [ + 'trace_id' => $traceId, + 'requested_channel' => $requestedChannel, + 'frontend_version' => $frontendVersion, + 'backend_version' => self::backendVersion(), + 'request_started_at' => date('c'), + 'original_request_uri' => (string)($_SERVER['REQUEST_URI'] ?? ''), + 'normalized_request_uri' => '', + 'ingress_prefix_stripped' => false, + ]; + + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + return $context; + } + + public static function normalizeReleaseApiIngressPath(array $enabledChannelSlugs): array + { + $requestUri = (string)($_SERVER['REQUEST_URI'] ?? ''); + $parts = parse_url($requestUri); + $path = is_array($parts) ? (string)($parts['path'] ?? '') : ''; + if ($path === '') { + return []; + } + + if (preg_match('#^/([A-Za-z0-9_-]{1,64})/api(?:/|$)(.*)$#', $path, $matches) !== 1) { + return []; + } + + $routeSlug = self::safeSlug((string)$matches[1]); + $channelSlug = self::channelSlugForRoute($routeSlug); + $enabled = array_flip(array_values(array_filter(array_map( + static fn(mixed $value): string => self::safeSlug((string)$value), + $enabledChannelSlugs + )))); + if ($routeSlug === '' || $channelSlug === '' || !isset($enabled[$channelSlug])) { + return []; + } + + $suffix = (string)($matches[2] ?? ''); + $normalizedPath = '/' . ltrim($suffix, '/'); + if ($normalizedPath === '/') { + $normalizedPath = '/'; + } + + $query = is_array($parts) && isset($parts['query']) && $parts['query'] !== '' + ? '?' . (string)$parts['query'] + : ''; + $normalizedUri = $normalizedPath . $query; + $_SERVER['REQUEST_URI'] = $normalizedUri; + $_SERVER['PATH_INFO'] = $normalizedPath; + $_GET['release_channel'] = $channelSlug; + + $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) + ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] + : self::initializeRequestContext(); + $context['original_request_uri'] = $context['original_request_uri'] ?: $requestUri; + $context['normalized_request_uri'] = $normalizedUri; + $context['requested_channel'] = $channelSlug; + $context['release_route_slug'] = $routeSlug; + $context['ingress_prefix_stripped'] = true; + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + + return [ + 'channel_slug' => $channelSlug, + 'route_slug' => $routeSlug, + 'original_request_uri' => $requestUri, + 'normalized_request_uri' => $normalizedUri, + 'normalized_path' => $normalizedPath, + ]; + } + + public static function routeSlugForChannel(string $channelSlug): string + { + $slug = self::safeSlug($channelSlug); + if ($slug === '') { + return ''; + } + return self::RELEASE_ROUTE_SLUGS[$slug] ?? $slug; + } + + public static function channelSlugForRoute(string $routeSlug): string + { + $slug = self::safeSlug($routeSlug); + if ($slug === '') { + return ''; + } + return self::RELEASE_ROUTE_CHANNELS[$slug] ?? $slug; + } + + public static function moduleKeys(): array + { + return self::MODULE_KEYS; + } + + public static function backendVersion(): string + { + foreach (['RELEASE_VERSION', 'GITHUB_SHA', 'COMMIT_SHA', 'VITE_COMMIT_HASH'] as $key) { + $value = self::runtimeEnvValue($key); + if ($value !== '') { + return self::safeIdentifier($value, 128); + } + } + return 'unknown'; + } + + public static function backendCommitSha(): string + { + foreach (['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'] as $key) { + $sha = self::normalizeCommitSha(self::runtimeEnvValue($key)); + if ($sha !== '') { + return $sha; + } + } + + $sha = self::localGitCommitSha(); + return $sha !== '' ? $sha : 'unknown'; + } + + private static function runtimeEnvValue(string $key): string + { + $value = getenv($key); + if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_ENV ?? [])) { + $value = $_ENV[$key]; + } + if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_SERVER ?? [])) { + $value = $_SERVER[$key]; + } + + return is_scalar($value) ? trim((string)$value) : ''; + } + + private static function normalizeCommitSha(string $value): string + { + $value = strtolower(trim($value)); + return preg_match('/^[a-f0-9]{7,40}$/', $value) === 1 ? $value : ''; + } + + private static function localGitCommitSha(): string + { + $base = defined('WD') ? (string)WD : dirname(__DIR__); + $candidates = []; + $current = $base; + + for ($i = 0; $i < 6; $i++) { + if ($current === '' || isset($candidates[$current])) { + break; + } + $candidates[$current] = true; + $parent = dirname($current); + if ($parent === $current) { + break; + } + $current = $parent; + } + + foreach (array_keys($candidates) as $directory) { + $gitPath = $directory . DIRECTORY_SEPARATOR . '.git'; + if (!is_dir($directory) || (!is_dir($gitPath) && !is_file($gitPath))) { + continue; + } + + $output = []; + $exitCode = 1; + @exec('git -C ' . escapeshellarg($directory) . ' rev-parse HEAD 2>&1', $output, $exitCode); + if ($exitCode !== 0 || !isset($output[0])) { + continue; + } + + $sha = self::normalizeCommitSha((string)$output[0]); + if ($sha !== '') { + return $sha; + } + } + + return ''; + } + + public static function verifyGithubSignature(string $secret, string $payload, string $signatureHeader): bool + { + $secret = trim($secret); + $signatureHeader = trim($signatureHeader); + if ($secret === '' || $signatureHeader === '' || !str_starts_with($signatureHeader, 'sha256=')) { + return false; + } + + $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret); + return hash_equals($expected, $signatureHeader); + } + + public function verifyReleaseGateToken(string $token): bool + { + $token = trim($token); + if ($token === '') { + return false; + } + + $expected = trim((string)(getenv('RELEASE_MANAGER_GATE_TOKEN') ?: ($_SERVER['RELEASE_MANAGER_GATE_TOKEN'] ?? ''))); + if ($expected === '') { + $expected = trim((string)$this->moduleConfigValue('ReleaseManager', 'release_gate_token', '')); + } + if ($expected !== '' && str_starts_with($expected, 'twsec:v1:') && class_exists(replication_secret_box::class)) { + try { + $expected = replication_secret_box::decrypt($expected); + } catch (Throwable) { + $expected = ''; + } + } + + return $expected !== '' && hash_equals($expected, $token); + } + + public static function normalizeGithubRepositoryName(string $value): string + { + $repository = trim($value); + if ($repository === '') { + return ''; + } + + if (preg_match('#^git@github\.com:(.+)$#i', $repository, $matches) === 1) { + $repository = $matches[1]; + } elseif (preg_match('#^https?://#i', $repository) === 1) { + $path = parse_url($repository, PHP_URL_PATH); + $repository = is_string($path) ? ltrim($path, '/') : $repository; + } else { + $repository = preg_replace('#^github\.com/#i', '', $repository) ?? $repository; + } + + $repository = preg_replace('#\.git$#i', '', $repository) ?? $repository; + $repository = trim($repository, "/ \t\n\r\0\x0B"); + return preg_match('/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/', $repository) === 1 ? $repository : ''; + } + + public static function redactPayload(mixed $value, int $depth = 0): mixed + { + if ($depth > 8) { + return '[depth-limit]'; + } + + if (is_array($value)) { + $redacted = []; + $index = 0; + foreach ($value as $key => $item) { + $index++; + if ($index > 80) { + $redacted['[truncated]'] = 'More than 80 keys omitted.'; + break; + } + + $keyString = (string)$key; + if (self::isSensitiveKey($keyString)) { + $redacted[$key] = '[redacted]'; + continue; + } + $redacted[$key] = self::redactPayload($item, $depth + 1); + } + return $redacted; + } + + if (is_object($value)) { + return self::redactPayload((array)$value, $depth + 1); + } + + if (is_string($value)) { + if (strlen($value) > 4000) { + return substr($value, 0, 4000) . "\n... [truncated]"; + } + return $value; + } + + return $value; + } + + public static function deploymentCanBePromoted(string $status): bool + { + return in_array(strtolower(trim($status)), ['deployed'], true); + } + + public static function deploymentPromotionBlockedReason(array $deployment): string + { + $status = strtolower(trim((string)($deployment['status'] ?? 'unknown'))) ?: 'unknown'; + $result = self::jsonDecode($deployment['result_json'] ?? null); + if ($result === [] && is_array($deployment['result'] ?? null)) { + $result = $deployment['result']; + } + $failure = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : []; + $rootCause = trim((string)($failure['root_cause'] ?? $deployment['error_message'] ?? '')); + + if ($status === 'active') { + return 'Deployment is already active.'; + } + + if ($status === 'failed') { + return 'Deployment failed and cannot be promoted.' . ($rootCause !== '' ? ' Cause: ' . $rootCause : ''); + } + + return 'Only successfully deployed release deployments can be promoted. Current status: ' . $status . '.'; + } + + public static function deploymentFailureSummary(Throwable $throwable, array $context = []): array + { + $message = trim($throwable->getMessage()) ?: 'Deployment failed without an error message.'; + $normalized = strtolower($message); + $category = 'unknown'; + $stage = trim((string)($context['stage'] ?? 'deployment')) ?: 'deployment'; + $nextAction = 'Open the Coolify deployment logs for the service and compare the failing commit with the last successful deployment.'; + + if (str_contains($normalized, 'github repository access') || str_contains($normalized, 'github api')) { + $category = 'github_access'; + $stage = 'source_access'; + $nextAction = 'Verify the Release Manager GitHub token, repository, branch, and selected commit before deploying again.'; + } elseif ( + str_contains($normalized, 'coolify instance') + || str_contains($normalized, 'base url') + || str_contains($normalized, 'api token') + ) { + $category = 'coolify_connection'; + $stage = 'provider_connection'; + $nextAction = 'Test the configured Coolify instance and API token from Release Manager settings.'; + } elseif ( + str_contains($normalized, 'service uuid') + || str_contains($normalized, 'select an existing coolify service') + || str_contains($normalized, 'http 404') + ) { + $category = 'coolify_target'; + $stage = 'provider_target'; + $nextAction = 'Check that the saved deployment target points at the correct Coolify service UUID and instance.'; + } elseif ( + str_contains($normalized, 'docker_compose') + || str_contains($normalized, 'explicit image') + || str_contains($normalized, 'image/repository') + || str_contains($normalized, 'pull access denied') + || str_contains($normalized, 'manifest') + || str_contains($normalized, 'denied') + || str_contains($normalized, 'validation') + ) { + $category = 'configuration'; + $stage = 'provider_configuration'; + $nextAction = 'Review the deployment target image, registry access, compose payload, and required environment variables.'; + } elseif (str_contains($normalized, 'health') || str_contains($normalized, 'smoke')) { + $category = 'smoke_test'; + $stage = 'post_deploy_smoke_test'; + $nextAction = 'Check container startup logs and the configured health URL before promoting the deployment.'; + } elseif ( + str_contains($normalized, 'timeout') + || str_contains($normalized, 'timed out') + || str_contains($normalized, 'could not connect') + || str_contains($normalized, 'network') + ) { + $category = 'network'; + $stage = 'provider_connection'; + $nextAction = 'Check network access from the API container to GitHub and Coolify, then retry the deployment.'; + } + + $evidence = array_filter([ + 'message' => $message, + 'app' => $context['app'] ?? null, + 'repository' => $context['repository'] ?? null, + 'branch' => $context['branch'] ?? null, + 'commit_sha' => $context['commit_sha'] ?? null, + 'target_id' => $context['target_id'] ?? null, + 'coolify_instance_id' => $context['coolify_instance_id'] ?? null, + 'coolify_service_uuid' => $context['coolify_service_uuid'] ?? null, + ], static fn(mixed $value): bool => $value !== null && $value !== ''); + + return [ + 'category' => $category, + 'stage' => $stage, + 'root_cause' => $message, + 'next_action' => $nextAction, + 'promotion_blocked' => true, + 'captured_at' => date('c'), + 'evidence' => self::redactPayload($evidence), + ]; + } + + public static function recordBackendFailure(bool $success, mixed $data, ?int $status): void + { + if ($success || ($status !== null && $status < 400)) { + return; + } + + $uri = (string)($_SERVER['REQUEST_URI'] ?? ''); + if (str_starts_with($uri, '/release/timeline/events')) { + return; + } + + try { + if (!isset($GLOBALS['db']) || !release_manager_schema_bootstrap::tablesExist()) { + return; + } + + $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) + ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] + : self::initializeRequestContext(); + + $manager = new self(); + $principalContext = $manager->currentPrincipalContext(); + $channel = $manager->resolveChannel($principalContext); + $manager->ingestTimelineEvents([ + [ + 'type' => 'backend_response_failed', + 'severity' => ($status ?? 500) >= 500 ? 'error' : 'warning', + 'module_key' => $manager->inferModuleKeyFromUri($uri), + 'route' => explode('?', $uri)[0] ?: '/', + 'occurred_at' => date('c'), + 'payload' => [ + 'status' => $status, + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'response' => $data, + ], + ], + ], [ + 'trace_id' => $context['trace_id'] ?? '', + 'channel_slug' => $channel['slug'] ?? '', + 'principal_type' => $principalContext['principal_type'] ?? null, + 'principal_id' => $principalContext['principal_id'] ?? null, + 'customer_number' => $principalContext['customer_number'] ?? null, + ], false); + } catch (Throwable) { + // Release telemetry must never block API responses. + } + } + + public function bootstrap(): array + { + $this->ensureSchema(); + $channel = $this->defaultChannel(); + $versions = $this->currentVersionsForChannel((int)$channel['id']); + $urls = $this->releaseRuntimeUrls($channel, $versions); + + return [ + 'source' => 'deployment', + 'generated_at' => date('c'), + 'trace_id' => $this->requestTraceId(), + 'channel' => $this->publicRuntimeChannel($channel), + 'versions' => $versions, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'urls' => $urls, + 'availability' => $this->channelAvailability($channel), + 'capture_policy' => [ + 'enabled' => false, + 'capture_level' => 'metadata', + 'all_failure_metadata' => true, + 'retention_days' => (int)($channel['retention_days'] ?? 14), + ], + 'available_channels' => $this->publicRuntimeChannelOptions([$channel]), + 'selected_channel_slug' => (string)($channel['slug'] ?? ''), + ]; + } + + public function runtimeForPayload(array $payload, array $input = []): array + { + $this->ensureSchema(); + + $context = [ + 'principal_type' => 'user', + 'principal_id' => isset($payload['id']) ? (string)$payload['id'] : null, + 'customer_number' => isset($payload['customer_number']) ? (int)$payload['customer_number'] : null, + ]; + + $resolvedChannel = $this->resolveChannel($context); + $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel); + $channel = $this->chooseRuntimeChannel( + $resolvedChannel, + $availableChannels, + $this->requestedRuntimeChannelSlug($input) + ); + $serviceChannel = $this->runtimeServiceChannelFor($channel); + $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']); + $capturePolicy = $this->capturePolicyFor($context, $channel); + $urls = $this->releaseRuntimeUrls($serviceChannel, $versions); + + return [ + 'source' => 'deployment', + 'generated_at' => date('c'), + 'trace_id' => $this->requestTraceId(), + 'channel' => $this->publicRuntimeChannel($channel), + 'service_channel' => $this->publicRuntimeChannel($serviceChannel), + 'versions' => $versions, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'urls' => $urls, + 'availability' => $this->channelAvailability($channel), + 'capture_policy' => $capturePolicy, + 'available_channels' => $this->publicRuntimeChannelOptions($availableChannels), + 'selected_channel_slug' => (string)($channel['slug'] ?? ''), + 'selected_service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + 'module_keys' => self::MODULE_KEYS, + ]; + } + + public function runtimeForCurrentPrincipal(array $input = []): array + { + $this->ensureSchema(); + $context = $this->currentPrincipalContext(); + $resolvedChannel = $this->resolveChannel($context); + $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel); + $channel = $this->chooseRuntimeChannel( + $resolvedChannel, + $availableChannels, + $this->requestedRuntimeChannelSlug($input) + ); + + $serviceChannel = $this->runtimeServiceChannelFor($channel); + $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']); + $urls = $this->releaseRuntimeUrls($serviceChannel, $versions); + + return [ + 'source' => 'deployment', + 'generated_at' => date('c'), + 'trace_id' => $this->requestTraceId(), + 'channel' => $this->publicRuntimeChannel($channel), + 'service_channel' => $this->publicRuntimeChannel($serviceChannel), + 'versions' => $versions, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'urls' => $urls, + 'availability' => $this->channelAvailability($channel), + 'capture_policy' => $this->capturePolicyFor($context, $channel), + 'available_channels' => $this->publicRuntimeChannelOptions($availableChannels), + 'selected_channel_slug' => (string)($channel['slug'] ?? ''), + 'selected_service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + 'module_keys' => self::MODULE_KEYS, + ]; + } + + public function enabledReleaseChannelSlugs(): array + { + $this->ensureSchema(); + return array_values(array_filter(array_map( + static fn(array $channel): string => self::safeSlug((string)($channel['slug'] ?? '')), + $this->selectRows("SELECT slug FROM release_channels WHERE deleted_at IS NULL AND enabled = 1") + ))); + } + + public function summary(): array + { + $this->ensureSchema(); + $this->cleanupExpiredReplayData(); + + $summary = [ + 'generated_at' => date('c'), + 'channels' => $this->listChannels(), + 'assignments' => $this->listAssignments(), + 'deployment_targets' => $this->listDeploymentTargets(), + 'service_sets' => $this->listServiceSets(), + 'bundles' => $this->listBundles(25), + 'deployments' => $this->listDeployments(25), + 'operations' => $this->listOperations(['limit' => 20]), + 'data_services' => $this->releaseDataServicesSummary(), + 'replication_policy' => $this->releaseReplicationPolicySummary(), + 'coolify' => $this->releaseCoolifySummary(), + 'failover' => $this->releaseFailoverSummary(), + 'timeline' => $this->timelineSummary(), + 'module_health' => $this->latestModuleHealth(), + 'module_keys' => self::MODULE_KEYS, + 'suggestions' => $this->releaseSuggestions(), + ]; + $summary['status_overview'] = $this->releaseStatusOverview($summary); + + return $summary; + } + + public function suggestions(): array + { + $this->ensureSchema(); + return $this->releaseSuggestions(); + } + + public function listOperations(array $filters = []): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + $limit = max(1, min(100, (int)($filters['limit'] ?? 50))); + $where = []; + $types = ''; + $params = []; + + $channelId = $this->nullablePositiveInt($filters['channel_id'] ?? null); + if ($channelId !== null) { + $where[] = 'r.channel_id = ?'; + $types .= 'i'; + $params[] = $channelId; + } + + $operationType = self::safeIdentifier((string)($filters['operation_type'] ?? $filters['type'] ?? ''), 64); + if ($operationType !== '') { + $where[] = 'r.operation_type = ?'; + $types .= 's'; + $params[] = $operationType; + } + + $status = self::safeIdentifier((string)($filters['status'] ?? ''), 32); + if ($status !== '') { + $where[] = 'r.status = ?'; + $types .= 's'; + $params[] = $status; + } + + $whereSql = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : ''; + $rows = $this->selectRows( + "SELECT r.*, c.slug AS channel_slug, c.name AS channel_name, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count + FROM release_operation_runs r + LEFT JOIN release_channels c ON c.id = r.channel_id + $whereSql + ORDER BY r.created_at DESC, r.id DESC + LIMIT $limit", + $types, + $params + ); + + return array_map(fn(array $row): array => $this->publicOperationRun($row, false), $rows); + } + + public function operationDetail(int $id): array + { + $this->ensureSchema(); + $operation = $this->getOperationRun($id); + return $this->publicOperationRun($operation, true); + } + + public function runReleaseTest(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $channel = null; + try { + if ($this->nullablePositiveInt($input['channel_id'] ?? null) !== null || trim((string)($input['channel_slug'] ?? $input['channel'] ?? '')) !== '') { + $channel = $this->channelFromInput($input); + } + } catch (Throwable $throwable) { + $channel = null; + } + $gateInput = $this->normalizeReleaseGateInput($input, $channel); + + $operationId = $this->createOperationRun('release_test', [ + 'subject_type' => $channel !== null ? 'channel' : 'release_manager', + 'subject_id' => $channel !== null ? (string)$channel['id'] : null, + 'channel_id' => $channel !== null ? (int)$channel['id'] : null, + 'title' => $channel !== null + ? sprintf('Release checks for %s', (string)($channel['name'] ?? $channel['slug'] ?? 'channel')) + : 'Release Manager checks', + 'actor_user_id' => $actorUserId, + 'context' => array_replace(self::redactPayload($input), [ + 'release_gate' => self::redactPayload($gateInput), + ]), + ]); + + $statuses = []; + $this->recordOperationStep($operationId, 'dashboard_contract', 'Dashboard data contract', 'passed', 'Release Manager exposes channels, operations, Coolify, failover, and data-service state.', null, null, [ + 'summary_keys' => ['channels', 'operations', 'coolify', 'failover', 'data_services'], + ]); + $statuses[] = 'passed'; + + if ($this->releaseGateAutoSyncRequested($gateInput)) { + foreach ($this->releaseGateAutoSyncValidationSteps($gateInput, $channel) as $autoSyncStep) { + $this->recordOperationStep( + $operationId, + (string)$autoSyncStep['step_key'], + (string)$autoSyncStep['label'], + (string)$autoSyncStep['status'], + $autoSyncStep['message'] ?? null, + $autoSyncStep['diagnostic'] ?? null, + $autoSyncStep['solution_hint'] ?? null, + is_array($autoSyncStep['context'] ?? null) ? $autoSyncStep['context'] : [] + ); + $statuses[] = (string)$autoSyncStep['status']; + } + } + + if (($gateInput['required_checks'] ?? []) !== []) { + $this->recordOperationStep( + $operationId, + 'release_gate_inputs', + 'Release gate payload', + 'passed', + 'Release gate payload includes CI deploy metadata and required checks.', + null, + null, + $gateInput + ); + $statuses[] = 'passed'; + + foreach ($this->runReleaseGateChecks($gateInput) as $gateStep) { + $this->recordOperationStep( + $operationId, + (string)$gateStep['step_key'], + (string)$gateStep['label'], + (string)$gateStep['status'], + $gateStep['message'] ?? null, + $gateStep['diagnostic'] ?? null, + $gateStep['solution_hint'] ?? null, + is_array($gateStep['context'] ?? null) ? $gateStep['context'] : [] + ); + $statuses[] = (string)$gateStep['status']; + } + } + + $appsToCheck = $this->releaseTestAppsFromInput($input); + $channels = $channel !== null ? [$channel] : $this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 ORDER BY default_channel DESC, slug"); + foreach ($channels as $testChannel) { + foreach ($appsToCheck as $app) { + $target = $this->deploymentTargetForChannelApp((int)$testChannel['id'], $app); + $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); + $branch = self::releaseBranchForChannel($testChannel); + if ($target === null) { + $this->recordOperationStep( + $operationId, + sprintf('%s_%s_target', (string)$testChannel['slug'], $app), + sprintf('%s %s target', (string)$testChannel['name'], strtoupper($app)), + 'warning', + 'No Coolify deployment target is configured for this app/channel pair.', + 'Release Manager cannot deploy this app until a target exists.', + 'Create or repair the channel deployment target, then run the test again.', + ['channel_slug' => $testChannel['slug'], 'app' => $app, 'retry_action' => 'configure_target'] + ); + $statuses[] = 'warning'; + continue; + } + + $access = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => 'latest', + ]); + $ok = (bool)($access['ok'] ?? false); + $status = $ok ? 'passed' : 'warning'; + $statuses[] = $status; + $this->recordOperationStep( + $operationId, + sprintf('%s_%s_branch', (string)$testChannel['slug'], $app), + sprintf('%s %s branch', (string)$testChannel['name'], strtoupper($app)), + $status, + $ok + ? sprintf('Branch %s is reachable and resolves to the latest commit.', $branch) + : sprintf('Branch %s could not be verified and will be skipped by sync.', $branch), + $ok ? null : (string)($access['message'] ?? 'GitHub branch access failed.'), + $ok ? null : 'Create the missing branch or repair the Release Manager GitHub token, then use Retry.', + [ + 'channel_slug' => $testChannel['slug'], + 'route_slug' => self::routeSlugForChannel((string)$testChannel['slug']), + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'github_access' => $access, + 'retry_action' => 'retry_branch_check', + ] + ); + } + } + + $dataSummary = $channel !== null + ? $this->channelDataServicesSummary($channel) + : $this->releaseDataServicesSummary(); + $this->recordOperationStep($operationId, 'data_services', 'Production-shared data services', 'passed', 'Normal channel sync keeps MariaDB, Redis, and MinIO on production_shared unless an explicit data-service action changes that mode.', null, null, [ + 'data_services' => $dataSummary, + ]); + $statuses[] = 'passed'; + + $finalStatus = in_array('failed', $statuses, true) + ? 'failed' + : (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed'); + + if ($finalStatus === 'passed' && $this->releaseGateAutoSyncRequested($gateInput)) { + try { + $this->inProcessPassedReleaseGates[$operationId] = [ + 'channel_id' => $channel !== null ? (int)$channel['id'] : null, + 'release_gate' => $gateInput, + ]; + $autoSyncResult = $this->processReleaseGateAutoSync($gateInput, $channel, $operationId, $actorUserId); + $autoSyncStepStatus = (string)($autoSyncResult['step_status'] ?? 'passed'); + $this->recordOperationStep( + $operationId, + 'auto_sync', + 'Automatic container update', + $autoSyncStepStatus, + (string)($autoSyncResult['message'] ?? 'Automatic container update completed.'), + $autoSyncResult['diagnostic'] ?? null, + $autoSyncResult['solution_hint'] ?? null, + $autoSyncResult + ); + $statuses[] = $autoSyncStepStatus; + } catch (Throwable $throwable) { + $this->recordOperationStep( + $operationId, + 'auto_sync', + 'Automatic container update', + 'failed', + 'Automatic container update failed after the release gate passed.', + $throwable->getMessage(), + 'Open the channel sync operation or Release Manager target diagnostics, fix the failure, then rerun the gate.', + $gateInput + ); + $statuses[] = 'failed'; + } + + $finalStatus = in_array('failed', $statuses, true) + ? 'failed' + : (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed'); + } + + $this->completeOperationRun( + $operationId, + $finalStatus, + match ($finalStatus) { + 'passed' => 'Release Manager tests completed without detected issues.', + 'failed' => 'Release Manager tests failed. Promotion is blocked until the failed gate checks pass.', + default => 'Release Manager tests completed with warnings. Open the failed or warning steps for fixes.', + }, + $finalStatus === 'passed' ? null : 'Resolve the failed or warning steps, then run the test again.' + ); + + return $this->operationDetail($operationId); + } + + private function releaseTestAppsFromInput(array $input): array + { + $raw = $input['apps'] ?? $input['app'] ?? null; + $values = $this->releaseGateStringArray($raw); + $apps = []; + foreach ($values as $value) { + try { + $app = $this->normalizeApp($value); + } catch (Throwable) { + continue; + } + if (!in_array($app, $apps, true)) { + $apps[] = $app; + } + } + + return $apps !== [] ? $apps : self::APPS; + } + + private function normalizeReleaseGateInput(array $input, ?array $channel): array + { + $channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ($channel['slug'] ?? ''))); + $environmentUrl = $this->normalizeReleaseGateUrl((string)($input['environment_url'] ?? $input['frontend_url'] ?? '')); + $apiBaseUrl = $this->normalizeReleaseGateUrl((string)($input['api_base_url'] ?? 'https://api-v2.truckwash.io')); + $requiredChecks = $this->normalizeReleaseGateChecks($input, $environmentUrl); + $routeSlug = self::routeSlugForChannel($channelSlug ?: 'stable') ?: 'master'; + $app = ''; + if (trim((string)($input['app'] ?? '')) !== '') { + try { + $app = $this->normalizeApp((string)$input['app']); + } catch (Throwable) { + $app = ''; + } + } + $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? $input['repo'] ?? '')); + $branch = trim((string)($input['branch'] ?? '')); + $workflowUrl = $this->normalizeReleaseGateUrl((string)($input['workflow_url'] ?? $input['build_url'] ?? '')); + + return [ + 'environment_url' => $environmentUrl, + 'channel_slug' => $channelSlug, + 'route_slug' => $routeSlug, + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'auto_sync' => $this->toBool($input['auto_sync'] ?? false), + 'workflow_url' => $workflowUrl, + 'expected_commit' => self::safeIdentifier((string)($input['expected_commit'] ?? $input['commit_sha'] ?? ''), 128), + 'build_id' => substr(trim((string)($input['build_id'] ?? '')), 0, 128), + 'wait_timeout_seconds' => max(0, min(300, (int)($input['wait_timeout_seconds'] ?? 300))), + 'poll_interval_seconds' => max(1, min(60, (int)($input['poll_interval_seconds'] ?? 10))), + 'required_checks' => $requiredChecks, + 'api_base_url' => $apiBaseUrl, + 'api_ping_paths' => $this->releaseGateStringArray( + $input['api_ping_paths'] + ?? $input['api_paths'] + ?? ['/master/api/ping'] + ), + 'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash']), + ]; + } + + private function normalizeReleaseGateChecks(array $input, string $environmentUrl): array + { + $checks = $this->releaseGateStringArray($input['required_checks'] ?? []); + if ($checks === [] && $environmentUrl !== '') { + $checks = ['static_artifact']; + } + + $allowed = ['static_artifact', 'api_gateway']; + $normalized = []; + foreach ($checks as $check) { + $check = self::safeIdentifier(strtolower($check), 64); + if (in_array($check, $allowed, true) && !in_array($check, $normalized, true)) { + $normalized[] = $check; + } + } + + return $normalized; + } + + private function releaseGateAutoSyncRequested(array $gateInput): bool + { + return (bool)($gateInput['auto_sync'] ?? false); + } + + private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array + { + $steps = []; + $requiredChecks = is_array($gateInput['required_checks'] ?? null) ? $gateInput['required_checks'] : []; + $context = [ + 'channel_slug' => $gateInput['channel_slug'] ?? null, + 'app' => $gateInput['app'] ?? null, + 'repository' => $gateInput['repository'] ?? null, + 'branch' => $gateInput['branch'] ?? null, + 'expected_commit' => $gateInput['expected_commit'] ?? null, + 'workflow_url' => $gateInput['workflow_url'] ?? null, + 'required_checks' => $requiredChecks, + ]; + + if ($channel === null) { + $steps[] = [ + 'step_key' => 'auto_sync_channel', + 'label' => 'Automatic update channel', + 'status' => 'failed', + 'message' => 'Automatic container updates require a release channel.', + 'diagnostic' => 'The gate payload did not resolve to a configured release channel.', + 'solution_hint' => 'Pass channel_slug from CI, for example stable for master.', + 'context' => $context, + ]; + } + if (trim((string)($gateInput['app'] ?? '')) === '') { + $steps[] = [ + 'step_key' => 'auto_sync_app', + 'label' => 'Automatic update app', + 'status' => 'failed', + 'message' => 'Automatic container updates require an app.', + 'diagnostic' => 'The gate payload must identify frontend or api so Release Manager updates exactly one container.', + 'solution_hint' => 'Pass app=frontend from the frontend workflow or app=api from the backend workflow.', + 'context' => $context, + ]; + } + if (trim((string)($gateInput['expected_commit'] ?? '')) === '') { + $steps[] = [ + 'step_key' => 'auto_sync_commit', + 'label' => 'Automatic update commit', + 'status' => 'failed', + 'message' => 'Automatic container updates require the CI-verified commit SHA.', + 'diagnostic' => 'expected_commit was empty.', + 'solution_hint' => 'Pass github.sha as expected_commit in the release gate payload.', + 'context' => $context, + ]; + } + if ($requiredChecks === []) { + $steps[] = [ + 'step_key' => 'auto_sync_required_checks', + 'label' => 'Automatic update required checks', + 'status' => 'failed', + 'message' => 'Automatic container updates require at least one release gate check.', + 'diagnostic' => 'required_checks was empty.', + 'solution_hint' => 'Include required_checks (for example static_artifact and/or api_gateway) in the release gate payload.', + 'context' => $context, + ]; + } + + if ($steps === []) { + $steps[] = [ + 'step_key' => 'auto_sync_inputs', + 'label' => 'Automatic update inputs', + 'status' => 'passed', + 'message' => 'Release gate payload includes app, channel, and exact commit metadata for automatic container updates.', + 'context' => $context, + ]; + } + + return $steps; + } + + private function releaseGateStringArray(mixed $value): array + { + if (is_string($value)) { + $value = preg_split('/\s*,\s*/', trim($value)) ?: []; + } + if (!is_array($value)) { + return []; + } + + $values = []; + foreach ($value as $item) { + $item = trim((string)$item); + if ($item !== '' && !in_array($item, $values, true)) { + $values[] = $item; + } + } + return $values; + } + + private function normalizeReleaseGateUrl(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + if (preg_match('#^https?://#i', $value) !== 1) { + $value = 'https://' . ltrim($value, '/'); + } + $parts = parse_url($value); + if (!is_array($parts) || empty($parts['host'])) { + return ''; + } + + return rtrim($value, '/'); + } + + private function runReleaseGateChecks(array $gateInput): array + { + $steps = []; + foreach ($gateInput['required_checks'] as $check) { + $steps[] = match ($check) { + 'static_artifact' => $this->verifyReleaseStaticArtifact($gateInput), + 'api_gateway' => $this->verifyReleaseApiGateway($gateInput), + default => [ + 'step_key' => $check, + 'label' => 'Unknown release gate check', + 'status' => 'skipped', + 'message' => 'Unknown release gate check was skipped.', + 'context' => ['check' => $check], + ], + }; + } + + return $steps; + } + + private function assertReleaseGatePassedForPromotion(int $channelId, ?string $expectedCommit = null, ?string $buildId = null, ?string $app = null): void + { + if (!$this->releaseGateRequiredForPromotion()) { + return; + } + + $expectedCommit = trim((string)$expectedCommit); + $buildId = trim((string)$buildId); + $app = trim((string)$app) !== '' ? $this->normalizeApp((string)$app) : ''; + foreach ($this->inProcessPassedReleaseGates as $inProcessGate) { + if ((int)($inProcessGate['channel_id'] ?? 0) !== $channelId) { + continue; + } + $gate = is_array($inProcessGate['release_gate'] ?? null) ? $inProcessGate['release_gate'] : []; + if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) { + continue; + } + if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) { + continue; + } + if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) { + continue; + } + return; + } + + $rows = $this->selectRows( + "SELECT id, context_json, completed_at + FROM release_operation_runs + WHERE operation_type = 'release_test' + AND channel_id = ? + AND status = 'passed' + AND completed_at >= DATE_SUB(NOW(), INTERVAL 12 HOUR) + ORDER BY completed_at DESC, id DESC + LIMIT 20", + 'i', + [$channelId] + ); + + foreach ($rows as $row) { + $context = json_decode((string)($row['context_json'] ?? ''), true); + $gate = is_array($context['release_gate'] ?? null) ? $context['release_gate'] : []; + if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) { + continue; + } + if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) { + continue; + } + if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) { + continue; + } + + return; + } + + throw new RuntimeException('A passing Release Manager gate is required before promotion. Run the dev upload, public live smoke, credentialed smoke, and api-v2 health checks, then retry promotion.'); + } + + private function releaseGateAppMatches(array $gate, string $app): bool + { + $app = $this->normalizeApp($app); + $gateApp = trim((string)($gate['app'] ?? '')); + if ($gateApp !== '') { + try { + return $this->normalizeApp($gateApp) === $app; + } catch (Throwable) { + return false; + } + } + + $gateApps = $this->releaseGateStringArray($gate['apps'] ?? []); + if ($gateApps !== []) { + foreach ($gateApps as $value) { + try { + if ($this->normalizeApp($value) === $app) { + return true; + } + } catch (Throwable) { + } + } + return false; + } + + // Gates recorded before app-specific payloads existed were frontend release gates. + return $app === 'frontend'; + } + + private function releaseGateRequiredForPromotion(): bool + { + $envValue = trim((string)(getenv('RELEASE_GATE_REQUIRED_FOR_PROMOTION') ?: ($_SERVER['RELEASE_GATE_REQUIRED_FOR_PROMOTION'] ?? ''))); + if ($envValue !== '') { + return $this->toBool($envValue); + } + + return $this->toBool($this->moduleConfigValue('ReleaseManager', 'release_gate_required_for_promotion', 'true')); + } + + private function verifyReleaseStaticArtifact(array $gateInput): array + { + if (($gateInput['environment_url'] ?? '') === '') { + return [ + 'step_key' => 'static_artifact', + 'label' => 'Static artifact deployment', + 'status' => 'failed', + 'message' => 'environment_url is required for static artifact verification.', + 'solution_hint' => 'Pass the dev or channel frontend URL from CI.', + 'context' => $gateInput, + ]; + } + + $deadline = time() + (int)$gateInput['wait_timeout_seconds']; + $pollInterval = (int)$gateInput['poll_interval_seconds']; + $attempts = 0; + $lastMessage = 'Static artifact verification did not run.'; + do { + $attempts++; + try { + $context = $this->releaseStaticArtifactAttempt($gateInput); + $context['attempts'] = $attempts; + return [ + 'step_key' => 'static_artifact', + 'label' => 'Static artifact deployment', + 'status' => 'passed', + 'message' => 'The exact release manifest, app shell, JS, CSS, PWA assets, and release entry are reachable.', + 'context' => $context, + ]; + } catch (Throwable $throwable) { + $lastMessage = $throwable->getMessage(); + if (time() >= $deadline) { + break; + } + sleep($pollInterval); + } + } while (true); + + return [ + 'step_key' => 'static_artifact', + 'label' => 'Static artifact deployment', + 'status' => 'failed', + 'message' => 'The uploaded frontend artifact is not ready or does not match the expected build.', + 'diagnostic' => $lastMessage, + 'solution_hint' => 'Upload hashed assets first, keep old hashed assets, upload release-entry.json and index.html last, then rerun the gate.', + 'context' => [ + 'environment_url' => $gateInput['environment_url'], + 'attempts' => $attempts, + 'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'], + 'poll_interval_seconds' => $gateInput['poll_interval_seconds'], + ], + ]; + } + + private function releaseStaticArtifactAttempt(array $gateInput): array + { + $baseUrl = (string)$gateInput['environment_url']; + $manifest = $this->releaseGateFetchJson($baseUrl, 'release-manifest.json'); + $releaseEntry = $this->releaseGateFetchJson($baseUrl, 'release-entry.json'); + $manifestData = $manifest['json']; + $releaseEntryData = $releaseEntry['json']; + + if (trim((string)($manifestData['build_id'] ?? '')) === '') { + throw new RuntimeException('release-manifest.json is missing build_id.'); + } + if (!$this->releaseGateCommitMatches((string)($manifestData['commit_sha'] ?? ''), (string)$gateInput['expected_commit'])) { + throw new RuntimeException(sprintf( + 'release-manifest.json commit_sha %s did not match expected commit %s.', + (string)($manifestData['commit_sha'] ?? '(missing)'), + (string)$gateInput['expected_commit'] + )); + } + if ((string)$gateInput['build_id'] !== '' && (string)($manifestData['build_id'] ?? '') !== (string)$gateInput['build_id']) { + throw new RuntimeException(sprintf( + 'release-manifest.json build_id %s did not match expected build_id %s.', + (string)($manifestData['build_id'] ?? '(missing)'), + (string)$gateInput['build_id'] + )); + } + if ((string)($releaseEntryData['entry'] ?? '') !== (string)($manifestData['entry'] ?? '')) { + throw new RuntimeException('release-entry.json entry does not match release-manifest.json.'); + } + if (json_encode($releaseEntryData['css'] ?? []) !== json_encode($manifestData['css'] ?? [])) { + throw new RuntimeException('release-entry.json css does not match release-manifest.json.'); + } + + foreach ($gateInput['shell_paths'] as $shellPath) { + $shell = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $shellPath)); + if (($shell['status'] ?? 0) !== 200) { + throw new RuntimeException(sprintf('%s returned HTTP %d.', $shellPath, (int)($shell['status'] ?? 0))); + } + if (!str_contains(strtolower((string)($shell['content_type'] ?? '')), 'text/html')) { + throw new RuntimeException(sprintf('%s did not return HTML.', $shellPath)); + } + $body = (string)($shell['body'] ?? ''); + if (strlen(trim(preg_replace('/\s+/', '', $body) ?? '')) < 40) { + throw new RuntimeException(sprintf('%s returned an empty app shell.', $shellPath)); + } + if (!str_contains($body, '
')) { + throw new RuntimeException(sprintf('%s did not include the Vue app root.', $shellPath)); + } + } + + $assetUrls = $this->releaseGateUniqueStrings(array_merge( + ['release-manifest.json', 'release-entry.json'], + [(string)($manifestData['entry'] ?? '')], + is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [], + is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [], + is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [], + is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : [] + )); + $verifiedAssets = 0; + foreach ($assetUrls as $assetUrl) { + if ($assetUrl === '/index.html') { + continue; + } + $this->releaseGateVerifyStaticAsset($baseUrl, $assetUrl, is_array($manifestData['asset_hashes'] ?? null) ? $manifestData['asset_hashes'] : []); + $verifiedAssets++; + } + + return [ + 'environment_url' => $baseUrl, + 'build_id' => (string)$manifestData['build_id'], + 'commit_sha' => (string)($manifestData['commit_sha'] ?? ''), + 'entry' => (string)$manifestData['entry'], + 'verified_assets' => $verifiedAssets, + ]; + } + + private function verifyReleaseApiGateway(array $gateInput): array + { + $apiBaseUrl = (string)($gateInput['api_base_url'] ?? ''); + if ($apiBaseUrl === '') { + return [ + 'step_key' => 'api_gateway', + 'label' => 'api-v2 channel health', + 'status' => 'failed', + 'message' => 'api_base_url is required for API gateway verification.', + 'solution_hint' => 'Pass the api-v2 base URL from CI.', + 'context' => $gateInput, + ]; + } + + $checked = []; + try { + foreach ($gateInput['api_ping_paths'] as $path) { + $json = $this->releaseGateFetchJson($apiBaseUrl, $path); + $payload = $json['json']; + if (array_key_exists('success', $payload) && $payload['success'] !== true) { + throw new RuntimeException(sprintf('%s returned success=false.', $path)); + } + $checked[] = [ + 'path' => $path, + 'status' => $json['status'], + ]; + } + } catch (Throwable $throwable) { + return [ + 'step_key' => 'api_gateway', + 'label' => 'api-v2 channel health', + 'status' => 'failed', + 'message' => 'api-v2 gateway or channel API health failed.', + 'diagnostic' => $throwable->getMessage(), + 'solution_hint' => 'Repair api-v2 routing so the configured channel API ping endpoints return 200 JSON before frontend promotion.', + 'context' => [ + 'api_base_url' => $apiBaseUrl, + 'checked' => $checked, + 'api_ping_paths' => $gateInput['api_ping_paths'], + ], + ]; + } + + return [ + 'step_key' => 'api_gateway', + 'label' => 'api-v2 channel health', + 'status' => 'passed', + 'message' => 'api-v2 gateway and channel API prefixes returned 200 JSON.', + 'context' => [ + 'api_base_url' => $apiBaseUrl, + 'checked' => $checked, + ], + ]; + } + + private function releaseGateFetchJson(string $baseUrl, string $path): array + { + $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path)); + if (($result['status'] ?? 0) !== 200) { + throw new RuntimeException(sprintf('%s returned HTTP %d.', $path, (int)($result['status'] ?? 0))); + } + if (str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) { + throw new RuntimeException(sprintf('%s was served as HTML.', $path)); + } + + $decoded = json_decode((string)($result['body'] ?? ''), true); + if (!is_array($decoded)) { + throw new RuntimeException(sprintf('%s did not return valid JSON.', $path)); + } + + $result['json'] = $decoded; + return $result; + } + + private function releaseGateVerifyStaticAsset(string $baseUrl, string $assetUrl, array $assetHashes): void + { + $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $assetUrl)); + if (($result['status'] ?? 0) !== 200) { + throw new RuntimeException(sprintf('%s returned HTTP %d.', $assetUrl, (int)($result['status'] ?? 0))); + } + + $body = (string)($result['body'] ?? ''); + if ($body === '') { + throw new RuntimeException(sprintf('%s returned an empty body.', $assetUrl)); + } + if ($this->releaseGateRejectsHtml($assetUrl) && str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) { + throw new RuntimeException(sprintf('%s was served as HTML.', $assetUrl)); + } + + $hashKey = str_starts_with($assetUrl, '/') ? $assetUrl : '/' . ltrim($assetUrl, '/'); + if (is_array($assetHashes[$hashKey] ?? null) && !empty($assetHashes[$hashKey]['sha256'])) { + $actualHash = hash('sha256', $body); + if (!hash_equals((string)$assetHashes[$hashKey]['sha256'], $actualHash)) { + throw new RuntimeException(sprintf('%s sha256 hash mismatch.', $assetUrl)); + } + } + } + + private function releaseGateFetch(string $url): array + { + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize release gate request.'); + } + + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($curl, CURLOPT_TIMEOUT, 15); + curl_setopt($curl, CURLOPT_NOSIGNAL, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, [ + 'Accept: application/json, text/html, */*', + 'Cache-Control: no-cache', + 'Pragma: no-cache', + 'User-Agent: Truckwash-Release-Gate', + ]); + + $body = curl_exec($curl); + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $contentType = (string)curl_getinfo($curl, CURLINFO_CONTENT_TYPE); + curl_close($curl); + + if ($body === false) { + throw new RuntimeException('Release gate request failed: ' . $error); + } + + return [ + 'url' => $url, + 'status' => $status, + 'content_type' => $contentType, + 'body' => (string)$body, + ]; + } + + private function releaseGateJoinUrl(string $baseUrl, string $path): string + { + if (preg_match('#^https?://#i', $path) === 1) { + return $path; + } + + return rtrim($baseUrl, '/') . '/' . ltrim($path, '/'); + } + + private function releaseGateCommitMatches(string $actual, string $expected): bool + { + $expected = strtolower(trim($expected)); + if ($expected === '') { + return true; + } + $actual = strtolower(trim($actual)); + return $actual !== '' && ($actual === $expected || str_starts_with($actual, $expected)); + } + + private function verifyReleaseDeploymentReadiness(array $deployment, array $target, string $app, string $expectedCommit, array $options = []): array + { + $app = $this->normalizeApp($app); + $baseUrl = $this->normalizeReleasePublicBaseUrl($deployment['deployment_url'] ?? null, $app) + ?? $this->releaseTargetPublicBaseUrl($target); + if ($baseUrl === null || $baseUrl === '') { + throw new RuntimeException(sprintf('%s deployment has no public URL for readiness verification.', strtoupper($app))); + } + + $timeout = max(0, min(300, (int)($options['wait_timeout_seconds'] ?? 300))); + $pollInterval = max(1, min(60, (int)($options['poll_interval_seconds'] ?? 10))); + $deadline = time() + $timeout; + $attempts = 0; + $lastMessage = 'Readiness verification did not run.'; + + do { + $attempts++; + try { + if ($app === 'frontend') { + $context = $this->releaseStaticArtifactAttempt([ + 'environment_url' => $baseUrl, + 'expected_commit' => $expectedCommit, + 'build_id' => (string)($options['build_id'] ?? ''), + 'shell_paths' => $this->releaseGateStringArray($options['shell_paths'] ?? ['/', '/guest/book/wash']), + ]); + $context['app'] = $app; + $context['base_url'] = $baseUrl; + $context['attempts'] = $attempts; + return $context; + } + + $json = $this->releaseGateFetchJson($baseUrl, 'ping'); + $payload = $json['json']; + if (array_key_exists('success', $payload) && $payload['success'] !== true) { + throw new RuntimeException('API ping returned success=false.'); + } + $data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload; + $actualCommit = (string)( + $data['api_commit_sha'] + ?? $data['backend_commit_sha'] + ?? $data['commit_sha'] + ?? $data['backend_version'] + ?? '' + ); + if (!$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) { + throw new RuntimeException(sprintf( + 'API ping commit %s did not match expected commit %s.', + $actualCommit !== '' ? $actualCommit : '(missing)', + $expectedCommit + )); + } + + return [ + 'app' => $app, + 'base_url' => $baseUrl, + 'path' => 'ping', + 'status' => $json['status'] ?? null, + 'commit_sha' => $actualCommit, + 'attempts' => $attempts, + ]; + } catch (Throwable $throwable) { + $lastMessage = $throwable->getMessage(); + if (time() >= $deadline) { + break; + } + sleep($pollInterval); + } + } while (time() <= $deadline); + + throw new RuntimeException(sprintf( + '%s container readiness did not match commit %s after %d attempts: %s', + strtoupper($app), + $expectedCommit, + $attempts, + $lastMessage + )); + } + + private function releaseGateRejectsHtml(string $assetUrl): bool + { + return preg_match('/\.(js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i', parse_url($assetUrl, PHP_URL_PATH) ?: '') === 1; + } + + private function releaseGateUniqueStrings(array $values): array + { + $unique = []; + foreach ($values as $value) { + $value = trim((string)$value); + if ($value !== '' && !in_array($value, $unique, true)) { + $unique[] = $value; + } + } + + return $unique; + } + + public function syncChannel(int $channelId, ?int $actorUserId = null, array $options = []): array + { + $this->ensureSchema(); + $channel = $this->getChannel($channelId); + if ((int)($channel['enabled'] ?? 0) !== 1) { + throw new RuntimeException('Release channel is disabled.'); + } + + $requestedApp = ''; + if (trim((string)($options['app'] ?? '')) !== '') { + $requestedApp = $this->normalizeApp((string)$options['app']); + } + $apps = $requestedApp !== '' ? [$requestedApp] : self::APPS; + $branch = trim((string)($options['branch'] ?? '')) ?: self::releaseBranchForChannel($channel); + $routeSlug = self::routeSlugForChannel((string)$channel['slug']); + $requestedCommitSha = self::normalizeCommitSha((string)($options['commit_sha'] ?? $options['commit'] ?? '')); + $commitMode = $requestedCommitSha !== '' ? 'specific' : 'latest'; + $requireReadiness = $this->toBool($options['require_readiness'] ?? false); + + $operationId = $this->createOperationRun('channel_sync', [ + 'subject_type' => 'channel', + 'subject_id' => (string)$channelId, + 'channel_id' => $channelId, + 'app' => $requestedApp !== '' ? $requestedApp : null, + 'title' => sprintf('Sync %s release channel', (string)($channel['name'] ?? $channel['slug'])), + 'actor_user_id' => $actorUserId, + 'context' => [ + 'channel_slug' => $channel['slug'], + 'route_slug' => $routeSlug, + 'branch' => $branch, + 'apps' => $apps, + 'source' => $options['source'] ?? 'manual', + 'repository' => $options['repository'] ?? null, + 'commit_mode' => $commitMode, + 'commit_sha' => $requestedCommitSha !== '' ? $requestedCommitSha : null, + 'workflow_url' => $options['workflow_url'] ?? $options['build_url'] ?? null, + 'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null, + 'auto_sync_event' => $options['auto_sync_event'] ?? null, + 'gate_operation_id' => $options['gate_operation_id'] ?? null, + ], + ]); + + $statuses = []; + $deployments = []; + $this->recordOperationStep( + $operationId, + 'channel_mapping', + 'Channel route and branch mapping', + 'passed', + sprintf('Channel %s syncs from branch %s and publishes under /%s/{api|frontend}.', (string)$channel['slug'], $branch, $routeSlug), + null, + null, + ['channel_slug' => $channel['slug'], 'route_slug' => $routeSlug, 'branch' => $branch] + ); + $statuses[] = 'passed'; + + $this->recordOperationStep( + $operationId, + 'data_services_guard', + 'Data services guard', + 'passed', + 'Code sync will not deploy or replace MariaDB, Redis, or MinIO.', + null, + null, + ['data_services' => $this->channelDataServicesSummary($channel)] + ); + $statuses[] = 'passed'; + + if ($this->channelUsesProductionServices($channel)) { + $serviceChannel = $this->productionServiceChannel(); + $this->recordOperationStep( + $operationId, + 'production_services', + 'Production frontend and API services', + 'passed', + sprintf( + '%s uses the %s production frontend and API services; channel sync does not deploy separate release services.', + (string)($channel['name'] ?? $channel['slug']), + (string)($serviceChannel['name'] ?? $serviceChannel['slug']) + ), + null, + null, + [ + 'channel_slug' => (string)($channel['slug'] ?? ''), + 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + 'service_policy' => self::PRODUCTION_SERVICE_POLICY, + ] + ); + $statuses[] = 'passed'; + + $this->completeOperationRun( + $operationId, + 'passed', + 'Channel sync completed; production services remain active.', + null + ); + $operation = $this->operationDetail($operationId); + $operation['deployments'] = []; + $operation['channel'] = $this->publicChannel($this->getChannel($channelId)); + return $operation; + } + + foreach ($apps as $app) { + $target = $this->deploymentTargetForChannelApp($channelId, $app); + if ($target === null) { + $this->recordOperationStep( + $operationId, + $app . '_target', + strtoupper($app) . ' Coolify target', + 'failed', + 'No Coolify deployment target exists for this channel/app.', + 'The channel cannot receive a new ' . $app . ' deployment.', + 'Create the missing deployment target and use Retry.', + ['channel_id' => $channelId, 'channel_slug' => $channel['slug'], 'app' => $app, 'retry_action' => 'configure_target'] + ); + $statuses[] = 'failed'; + continue; + } + $target = $this->prepareChannelSyncApplicationTarget($target, $actorUserId); + if (($target['_release_auto_prepared_application'] ?? false) === true) { + $this->recordOperationStep( + $operationId, + $app . '_target_prepared', + strtoupper($app) . ' Coolify application target', + 'passed', + sprintf('%s target was prepared to create a path-routed Coolify application.', strtoupper($app)), + null, + null, + ['target_id' => (int)$target['id'], 'app' => $app] + ); + $statuses[] = 'passed'; + } + + $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); + $requestedRepository = self::normalizeGithubRepositoryName((string)($options['repository'] ?? '')); + if ($requestedRepository !== '') { + $repository = $requestedRepository; + } + if ($repository === '') { + $repository = self::defaultRepositoryForApp($app); + } + $access = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $requestedCommitSha, + 'commit_mode' => $commitMode, + ]); + if (!($access['ok'] ?? false)) { + $this->recordOperationStep( + $operationId, + $app . '_branch', + strtoupper($app) . ' branch', + 'warning', + sprintf('%s branch %s was not deployed because it could not be verified.', strtoupper($app), $branch), + (string)($access['message'] ?? 'GitHub branch access failed.'), + 'Create the branch from master or repair GitHub access, then use Retry.', + [ + 'channel_slug' => $channel['slug'], + 'route_slug' => $routeSlug, + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'github_access' => $access, + 'retry_action' => 'retry_sync', + ] + ); + $statuses[] = 'warning'; + continue; + } + + $commitSha = trim((string)($access['commit_sha'] ?? $access['latest_commit_sha'] ?? '')); + $current = $this->currentDeploymentForChannelApp($channelId, $app); + if ($commitSha !== '' && $current !== null && trim((string)($current['commit_sha'] ?? '')) === $commitSha) { + $this->recordOperationStep( + $operationId, + $app . '_already_current', + strtoupper($app) . ' deployment', + 'skipped', + sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)), + null, + null, + ['deployment' => $this->publicDeployment($current), 'github_access' => $access] + ); + $statuses[] = 'skipped'; + continue; + } + + try { + $deployment = $this->startDeployment([ + 'target_id' => (int)$target['id'], + 'channel_id' => $channelId, + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => $commitMode, + 'commit_sha' => $commitSha, + 'version_label' => $commitSha !== '' ? substr($commitSha, 0, 12) : date('Ymd-His'), + 'build_url' => $options['build_url'] ?? null, + 'metadata' => [ + 'release_operation_id' => $operationId, + 'sync_source' => $options['source'] ?? 'manual', + 'webhook_commit_sha' => $options['commit_sha'] ?? null, + 'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null, + 'gate_operation_id' => $options['gate_operation_id'] ?? null, + 'workflow_url' => $options['workflow_url'] ?? null, + ], + ], $actorUserId); + if (($deployment['status'] ?? '') === 'deployed' && !empty($deployment['id'])) { + if ($requireReadiness) { + $readiness = $this->verifyReleaseDeploymentReadiness($deployment, $target, $app, $commitSha, $options); + $this->recordOperationStep( + $operationId, + $app . '_readiness', + strtoupper($app) . ' container readiness', + 'passed', + sprintf('%s container readiness matched commit %s.', strtoupper($app), substr($commitSha, 0, 12)), + null, + null, + $readiness + ); + } + $promoted = $this->promoteDeployment((int)$deployment['id'], $actorUserId); + $deployment = $promoted['deployment'] ?? $deployment; + } + $deployments[] = $deployment; + $this->recordOperationStep( + $operationId, + $app . '_deploy', + strtoupper($app) . ' deploy and activate', + (($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed', + (($deployment['status'] ?? '') === 'failed') + ? sprintf('%s deployment failed before activation.', strtoupper($app)) + : sprintf('%s deployment was recorded and the latest deployed revision is active for this channel.', strtoupper($app)), + (($deployment['status'] ?? '') === 'failed') ? (string)($deployment['error_message'] ?? 'Deployment failed.') : null, + (($deployment['status'] ?? '') === 'failed') ? 'Open the deployment result diagnostics, fix the provider error, then use Retry.' : null, + ['deployment' => $deployment, 'github_access' => $access] + ); + $statuses[] = (($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed'; + } catch (Throwable $throwable) { + $this->recordOperationStep( + $operationId, + $app . '_deploy', + strtoupper($app) . ' deploy and activate', + 'failed', + sprintf('%s deployment failed before activation.', strtoupper($app)), + $throwable->getMessage(), + 'Review the deployment target, Coolify service, and GitHub branch, then use Retry.', + ['app' => $app, 'repository' => $repository, 'branch' => $branch] + ); + $statuses[] = 'failed'; + } + } + + $finalStatus = in_array('failed', $statuses, true) + ? 'failed' + : (count(array_intersect($statuses, ['warning'])) > 0 ? 'warning' : 'passed'); + $this->completeOperationRun( + $operationId, + $finalStatus, + $finalStatus === 'passed' + ? 'Channel sync completed.' + : 'Channel sync finished with failures or warnings. Open the operation steps for exact diagnostics.', + $finalStatus === 'passed' ? null : 'Use the step retry action after fixing the reported target, branch, or provider issue.' + ); + + $operation = $this->operationDetail($operationId); + $operation['deployments'] = $deployments; + $operation['channel'] = $this->publicChannel($this->getChannel($channelId)); + return $operation; + } + + private function processReleaseGateAutoSync(array $gateInput, ?array $channel, int $gateOperationId, ?int $actorUserId): array + { + if ($channel === null) { + throw new RuntimeException('Automatic container update requires a release channel.'); + } + + $channelId = (int)$channel['id']; + $app = $this->normalizeApp((string)($gateInput['app'] ?? '')); + $commitSha = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? '')); + if ($commitSha === '') { + throw new RuntimeException('Automatic container update requires a 7-40 character Git commit SHA.'); + } + + $branch = trim((string)($gateInput['branch'] ?? '')) ?: self::releaseBranchForChannel($channel); + $repository = self::normalizeGithubRepositoryName((string)($gateInput['repository'] ?? '')); + if ($repository === '') { + $repository = self::defaultRepositoryForApp($app); + } + + $target = $this->deploymentTargetForChannelApp($channelId, $app); + if ($target === null) { + throw new RuntimeException(sprintf('No %s deployment target is configured for %s.', strtoupper($app), (string)$channel['slug'])); + } + if (!$this->toBool($target['auto_deploy'] ?? false)) { + throw new RuntimeException(sprintf('%s automatic deployments are disabled for %s.', strtoupper($app), (string)$channel['slug'])); + } + + $targetRepository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? '')); + $targetBranch = trim((string)($target['branch'] ?? '')); + if ($targetRepository !== '' && $repository !== $targetRepository) { + throw new RuntimeException(sprintf('Gate repository %s does not match target repository %s.', $repository, $targetRepository)); + } + if ($targetBranch !== '' && $branch !== $targetBranch) { + throw new RuntimeException(sprintf('Gate branch %s does not match target branch %s.', $branch, $targetBranch)); + } + + $event = $this->upsertReleaseAutoSyncEvent([ + 'channel_id' => $channelId, + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'status' => 'gate_passed', + 'source' => 'release_gate', + 'workflow_url' => $gateInput['workflow_url'] ?? null, + 'gate_operation_id' => $gateOperationId, + 'metadata' => [ + 'release_gate' => $gateInput, + ], + ]); + + $eventId = (int)$event['id']; + if (!$this->acquireReleaseAutoSyncLock($eventId)) { + return [ + 'step_status' => 'passed', + 'message' => 'Automatic container update is already being processed for this commit.', + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + ]; + } + + try { + $event = $this->releaseAutoSyncEventById($eventId) ?? $event; + if (in_array((string)($event['status'] ?? ''), ['promoted', 'deployed'], true)) { + return [ + 'step_status' => 'passed', + 'message' => 'Automatic container update was already completed for this commit.', + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + ]; + } + + $current = $this->currentDeploymentForChannelApp($channelId, $app); + if ($current !== null && $this->releaseGateCommitMatches((string)($current['commit_sha'] ?? ''), $commitSha)) { + $event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [ + 'deployment_id' => (int)$current['id'], + 'metadata' => ['already_current' => true], + ]); + return [ + 'step_status' => 'passed', + 'message' => sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)), + 'deployment' => $this->publicDeployment($current), + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + ]; + } + + $event = $this->updateReleaseAutoSyncEvent($eventId, 'syncing'); + $operation = $this->syncChannel($channelId, $actorUserId, [ + 'app' => $app, + 'source' => 'release_gate', + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => 'specific', + 'commit_sha' => $commitSha, + 'build_url' => $gateInput['workflow_url'] ?? null, + 'workflow_url' => $gateInput['workflow_url'] ?? null, + 'gate_operation_id' => $gateOperationId, + 'auto_sync_event_id' => $eventId, + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + 'require_readiness' => true, + 'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'] ?? 300, + 'poll_interval_seconds' => $gateInput['poll_interval_seconds'] ?? 10, + 'build_id' => $gateInput['build_id'] ?? '', + 'shell_paths' => $gateInput['shell_paths'] ?? [], + ]); + + if ((string)($operation['status'] ?? '') !== 'passed') { + throw new RuntimeException((string)($operation['summary'] ?? 'Automatic channel sync did not pass.')); + } + + $deployment = $this->currentDeploymentForChannelApp($channelId, $app); + if ($deployment === null || !$this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) { + throw new RuntimeException(sprintf('%s was deployed but was not promoted as the active %s release.', strtoupper($app), (string)$channel['slug'])); + } + + $event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [ + 'sync_operation_id' => (int)($operation['id'] ?? 0) ?: null, + 'deployment_id' => (int)$deployment['id'], + 'metadata' => [ + 'sync_operation_id' => $operation['id'] ?? null, + 'deployment_id' => $deployment['id'] ?? null, + ], + ]); + + return [ + 'step_status' => 'passed', + 'message' => sprintf('%s container was deployed and promoted at %s.', strtoupper($app), substr($commitSha, 0, 12)), + 'sync_operation' => $operation, + 'deployment' => $this->publicDeployment($deployment), + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + ]; + } catch (Throwable $throwable) { + $this->updateReleaseAutoSyncEvent($eventId, 'failed', [ + 'error_message' => $throwable->getMessage(), + ]); + throw $throwable; + } finally { + $this->releaseReleaseAutoSyncLock($eventId); + } + } + + private function upsertReleaseAutoSyncEvent(array $input): array + { + $channelId = (int)$input['channel_id']; + $app = $this->normalizeApp((string)$input['app']); + $repository = self::normalizeGithubRepositoryName((string)$input['repository']); + $branch = trim((string)$input['branch']); + $commitSha = self::normalizeCommitSha((string)$input['commit_sha']); + $status = self::safeIdentifier((string)($input['status'] ?? 'pending'), 32) ?: 'pending'; + $source = self::safeIdentifier((string)($input['source'] ?? ''), 64) ?: null; + $workflowUrl = $this->nullableString($input['workflow_url'] ?? null, 512); + $gateOperationId = $this->nullablePositiveInt($input['gate_operation_id'] ?? null); + $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : []; + + if ($channelId <= 0 || $repository === '' || $branch === '' || $commitSha === '') { + throw new RuntimeException('Automatic sync event requires channel, app, repository, branch, and commit.'); + } + + $existing = $this->releaseAutoSyncEventFor($channelId, $app, $repository, $branch, $commitSha); + if ($existing === null) { + $this->execute( + "INSERT INTO release_auto_sync_events ( + channel_id, app, repository, branch, commit_sha, status, source, + workflow_url, gate_operation_id, metadata_json, gate_passed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = 'gate_passed' THEN NOW() ELSE NULL END)", + 'isssssssiss', + [ + $channelId, + $app, + $repository, + $branch, + $commitSha, + $status, + $source, + $workflowUrl, + $gateOperationId, + self::jsonEncode(self::redactPayload($metadata)), + $status, + ] + ); + return $this->releaseAutoSyncEventById($this->insertId()) ?? []; + } + + $existingStatus = (string)($existing['status'] ?? 'pending'); + if ($status === 'pending' && !in_array($existingStatus, ['pending', 'failed'], true)) { + $status = $existingStatus; + } + if ($status === 'gate_passed' && in_array($existingStatus, ['syncing', 'promoted', 'deployed'], true)) { + $status = $existingStatus; + } + + $this->execute( + "UPDATE release_auto_sync_events + SET status = ?, + source = COALESCE(?, source), + workflow_url = COALESCE(?, workflow_url), + gate_operation_id = COALESCE(NULLIF(?, 0), gate_operation_id), + error_message = NULL, + metadata_json = ?, + gate_passed_at = CASE WHEN ? = 'gate_passed' THEN COALESCE(gate_passed_at, NOW()) ELSE gate_passed_at END, + updated_at = NOW() + WHERE id = ?", + 'sssissi', + [ + $status, + $source, + $workflowUrl, + $gateOperationId ?? 0, + self::jsonEncode(self::redactPayload($metadata)), + $status, + (int)$existing['id'], + ] + ); + + return $this->releaseAutoSyncEventById((int)$existing['id']) ?? []; + } + + private function updateReleaseAutoSyncEvent(int $id, string $status, array $input = []): array + { + $status = self::safeIdentifier($status, 32) ?: 'pending'; + $syncOperationId = $this->nullablePositiveInt($input['sync_operation_id'] ?? null); + $deploymentId = $this->nullablePositiveInt($input['deployment_id'] ?? null); + $errorMessage = isset($input['error_message']) ? substr((string)$input['error_message'], 0, 4096) : null; + $metadata = is_array($input['metadata'] ?? null) ? self::jsonEncode(self::redactPayload($input['metadata'])) : null; + $this->execute( + "UPDATE release_auto_sync_events + SET status = ?, + sync_operation_id = COALESCE(NULLIF(?, 0), sync_operation_id), + deployment_id = COALESCE(NULLIF(?, 0), deployment_id), + error_message = ?, + metadata_json = COALESCE(?, metadata_json), + synced_at = CASE WHEN ? IN ('deployed', 'promoted') THEN COALESCE(synced_at, NOW()) ELSE synced_at END, + promoted_at = CASE WHEN ? = 'promoted' THEN COALESCE(promoted_at, NOW()) ELSE promoted_at END, + failed_at = CASE WHEN ? = 'failed' THEN NOW() ELSE failed_at END, + updated_at = NOW() + WHERE id = ?", + 'siisssssi', + [$status, $syncOperationId ?? 0, $deploymentId ?? 0, $errorMessage, $metadata, $status, $status, $status, $id] + ); + + return $this->releaseAutoSyncEventById($id) ?? []; + } + + private function releaseAutoSyncEventFor(int $channelId, string $app, string $repository, string $branch, string $commitSha): ?array + { + return $this->selectOne( + "SELECT e.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_auto_sync_events e + INNER JOIN release_channels c ON c.id = e.channel_id + WHERE e.channel_id = ? AND e.app = ? AND e.repository = ? AND e.branch = ? AND e.commit_sha = ? + LIMIT 1", + 'issss', + [$channelId, $app, $repository, $branch, $commitSha] + ); + } + + private function releaseAutoSyncEventById(int $id): ?array + { + return $this->selectOne( + "SELECT e.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_auto_sync_events e + INNER JOIN release_channels c ON c.id = e.channel_id + WHERE e.id = ? + LIMIT 1", + 'i', + [$id] + ); + } + + private function publicReleaseAutoSyncEvent(array $event): array + { + return [ + 'id' => (int)($event['id'] ?? 0), + 'channel_id' => (int)($event['channel_id'] ?? 0), + 'channel_slug' => $event['channel_slug'] ?? null, + 'channel_name' => $event['channel_name'] ?? null, + 'app' => (string)($event['app'] ?? ''), + 'repository' => (string)($event['repository'] ?? ''), + 'branch' => (string)($event['branch'] ?? ''), + 'commit_sha' => (string)($event['commit_sha'] ?? ''), + 'status' => (string)($event['status'] ?? 'unknown'), + 'source' => $event['source'] ?? null, + 'workflow_url' => $event['workflow_url'] ?? null, + 'gate_operation_id' => isset($event['gate_operation_id']) ? (int)$event['gate_operation_id'] : null, + 'sync_operation_id' => isset($event['sync_operation_id']) ? (int)$event['sync_operation_id'] : null, + 'deployment_id' => isset($event['deployment_id']) ? (int)$event['deployment_id'] : null, + 'error_message' => $event['error_message'] ?? null, + 'metadata' => self::jsonDecode($event['metadata_json'] ?? null), + 'received_at' => $event['received_at'] ?? null, + 'gate_passed_at' => $event['gate_passed_at'] ?? null, + 'synced_at' => $event['synced_at'] ?? null, + 'promoted_at' => $event['promoted_at'] ?? null, + 'failed_at' => $event['failed_at'] ?? null, + ]; + } + + private function acquireReleaseAutoSyncLock(int $eventId): bool + { + $lockName = 'release_auto_sync:' . $eventId; + $row = $this->selectOne('SELECT GET_LOCK(?, 0) AS acquired', 's', [$lockName]); + return (int)($row['acquired'] ?? 0) === 1; + } + + private function releaseReleaseAutoSyncLock(int $eventId): void + { + try { + $this->selectOne('SELECT RELEASE_LOCK(?) AS released', 's', ['release_auto_sync:' . $eventId]); + } catch (Throwable) { + } + } + + public function runIssueAction(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $issueKey = trim((string)($input['issue_key'] ?? $input['key'] ?? '')); + $actionId = self::safeIdentifier((string)($input['action_id'] ?? $input['action'] ?? ''), 64); + $actionInputs = is_array($input['inputs'] ?? null) ? $input['inputs'] : []; + $confirmed = $this->toBool($input['confirm'] ?? false); + $summary = $this->summary(); + + $issue = $this->releaseStatusIssueByKey($summary, $issueKey); + if ($issue === null) { + $this->audit(null, null, 'release_issue_action_attempted', $actorUserId, 'warning', [ + 'issue_key' => $issueKey, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + 'status' => 'stale_issue', + ]); + + return [ + 'status' => 'failed', + 'message' => 'This release issue is no longer active. Refresh Release Manager and review the current state.', + 'result' => null, + 'summary' => $summary, + ]; + } + + $action = $this->releaseStatusActionById($issue, $actionId); + if ($action === null) { + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_attempted', + $actorUserId, + 'warning', + [ + 'issue_key' => $issueKey, + 'issue' => $issue, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + 'status' => 'unavailable_action', + ] + ); + + return [ + 'status' => 'failed', + 'message' => 'This release issue action is no longer available.', + 'issue' => $issue, + 'result' => null, + 'summary' => $summary, + ]; + } + + if (trim((string)($action['disabled_reason'] ?? '')) !== '') { + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_attempted', + $actorUserId, + 'warning', + [ + 'issue_key' => $issueKey, + 'issue' => $issue, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + 'status' => 'disabled', + 'disabled_reason' => (string)$action['disabled_reason'], + ] + ); + + return [ + 'status' => 'needs_input', + 'message' => (string)$action['disabled_reason'], + 'issue' => $issue, + 'action' => $action, + 'result' => null, + 'summary' => $summary, + ]; + } + + if (($action['requires_confirmation'] ?? false) && !$confirmed) { + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_attempted', + $actorUserId, + 'warning', + [ + 'issue_key' => $issueKey, + 'issue' => $issue, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + 'status' => 'confirmation_required', + ] + ); + + return [ + 'status' => 'needs_input', + 'message' => 'Confirm this release issue action before Release Manager changes deployment state.', + 'issue' => $issue, + 'action' => $action, + 'result' => null, + 'summary' => $summary, + ]; + } + + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_attempted', + $actorUserId, + 'info', + [ + 'issue_key' => $issueKey, + 'issue' => $issue, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + ] + ); + + try { + $result = $this->executeReleaseIssueAction($issue, $actionId, $actionInputs, $actorUserId); + $status = (string)($result['status'] ?? 'completed'); + $message = (string)($result['message'] ?? 'Release issue action completed.'); + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_completed', + $actorUserId, + $status === 'failed' ? 'error' : 'info', + [ + 'issue_key' => $issueKey, + 'action_id' => $actionId, + 'status' => $status, + 'result' => $result['result'] ?? null, + ] + ); + + return [ + 'status' => $status, + 'message' => $message, + 'issue' => $issue, + 'action' => $action, + 'result' => $result['result'] ?? null, + 'summary' => $this->summary(), + ]; + } catch (Throwable $throwable) { + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_failed', + $actorUserId, + 'error', + [ + 'issue_key' => $issueKey, + 'action_id' => $actionId, + 'error' => $throwable->getMessage(), + ] + ); + + return [ + 'status' => 'failed', + 'message' => $throwable->getMessage(), + 'issue' => $issue, + 'action' => $action, + 'result' => null, + 'summary' => $this->summary(), + ]; + } + } + + private function executeReleaseIssueAction(array $issue, string $actionId, array $inputs, ?int $actorUserId): array + { + return match ($actionId) { + 'retry_deployment' => $this->retryReleaseIssueDeployment($issue, $inputs, $actorUserId), + 'deploy_missing_version' => $this->deployReleaseIssueMissingVersion($issue, $inputs, $actorUserId), + 'set_bundle' => $this->setReleaseIssueBundle($issue, $inputs, $actorUserId), + 'complete_data_services' => $this->completeReleaseIssueDataServices($issue, $inputs, $actorUserId), + 'reconcile_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'reconcile', $actorUserId), + 'redeploy_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'deploy', $actorUserId), + 'restart_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'restart', $actorUserId), + 'prepare_application_target' => $this->prepareReleaseIssueApplicationTarget($issue, $actorUserId), + 'refresh_status' => [ + 'status' => 'completed', + 'message' => 'Release status refreshed.', + 'result' => null, + ], + default => throw new RuntimeException('Unknown release issue action.'), + }; + } + + private function retryReleaseIssueDeployment(array $issue, array $inputs, ?int $actorUserId): array + { + $deploymentId = $this->nullablePositiveInt($issue['deployment_id'] ?? null); + if ($deploymentId === null) { + throw new RuntimeException('The failed deployment record is missing.'); + } + + $deployment = $this->getDeployment($deploymentId); + $payload = self::jsonDecode($deployment['requested_payload_json'] ?? null); + $payload = is_array($payload) ? $payload : []; + foreach (['version_id'] as $key) { + if (array_key_exists($key, $inputs)) { + $payload[$key] = $inputs[$key]; + } + } + $payload = array_replace($payload, [ + 'channel_id' => (int)$deployment['channel_id'], + 'target_id' => $this->nullablePositiveInt($deployment['target_id'] ?? null), + 'app' => (string)$deployment['app'], + 'repository' => (string)($deployment['repository'] ?? ''), + 'branch' => (string)($deployment['branch'] ?? self::DEFAULT_BRANCH), + 'commit_mode' => trim((string)($deployment['commit_sha'] ?? '')) !== '' ? 'specific' : 'latest', + 'commit_sha' => (string)($deployment['commit_sha'] ?? ''), + 'service_set_id' => $this->nullablePositiveInt($deployment['service_set_id'] ?? null), + 'bundle_id' => $this->nullablePositiveInt($deployment['bundle_id'] ?? null), + 'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'), + ]); + + $newDeployment = $this->startDeployment($payload, $actorUserId); + $status = strtolower((string)($newDeployment['status'] ?? '')); + return [ + 'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'), + 'message' => $status === 'failed' ? 'Deployment retry failed.' : 'Deployment retry started.', + 'result' => ['deployment' => $newDeployment], + ]; + } + + private function deployReleaseIssueMissingVersion(array $issue, array $inputs, ?int $actorUserId): array + { + $targetId = $this->nullablePositiveInt($inputs['target_id'] ?? $issue['target_id'] ?? null); + if ($targetId === null) { + return [ + 'status' => 'needs_input', + 'message' => 'Select or create a deployment target before deploying the missing version.', + 'result' => [ + 'required_inputs' => ['target_id'], + 'channel_id' => $issue['channel_id'] ?? null, + 'app' => $issue['service_key'] ?? null, + ], + ]; + } + + $target = $this->getDeploymentTarget($targetId); + $deployment = $this->startDeployment([ + 'channel_id' => (int)$target['channel_id'], + 'target_id' => $targetId, + 'app' => (string)$target['app'], + 'repository' => (string)$target['repository'], + 'branch' => (string)$target['branch'], + 'commit_mode' => (string)($inputs['commit_mode'] ?? 'latest'), + 'commit_sha' => (string)($inputs['commit_sha'] ?? ''), + 'version_label' => (string)($inputs['version_label'] ?? ''), + ], $actorUserId); + + $status = strtolower((string)($deployment['status'] ?? '')); + return [ + 'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'), + 'message' => $status === 'failed' ? 'Missing version deployment failed.' : 'Missing version deployment started.', + 'result' => ['deployment' => $deployment], + ]; + } + + private function setReleaseIssueBundle(array $issue, array $inputs, ?int $actorUserId): array + { + $channelId = $this->nullablePositiveInt($issue['channel_id'] ?? null); + if ($channelId === null) { + throw new RuntimeException('Release channel is missing.'); + } + + $bundleId = $this->nullablePositiveInt($inputs['bundle_id'] ?? null); + if ($bundleId === null) { + $eligible = self::releaseStatusEligibleBundles(array_filter( + $this->listBundles(250), + static fn(array $bundle): bool => (int)($bundle['channel_id'] ?? 0) === $channelId + )); + if (count($eligible) !== 1) { + return [ + 'status' => 'needs_input', + 'message' => $eligible === [] ? 'No deployed bundle is available for this channel.' : 'Choose which deployed bundle to set.', + 'result' => [ + 'required_inputs' => ['bundle_id'], + 'bundle_choices' => $eligible, + ], + ]; + } + $bundleId = (int)$eligible[0]['id']; + } + + return [ + 'status' => 'completed', + 'message' => 'Release bundle set for channel.', + 'result' => $this->setChannelBundle($channelId, ['bundle_id' => $bundleId], $actorUserId), + ]; + } + + private function completeReleaseIssueDataServices(array $issue, array $inputs, ?int $actorUserId): array + { + $serviceSetId = $this->nullablePositiveInt($inputs['service_set_id'] ?? $issue['service_set_id'] ?? null); + if ($serviceSetId === null) { + return [ + 'status' => 'needs_input', + 'message' => 'Select the isolated service set before creating missing data services.', + 'result' => ['required_inputs' => ['service_set_id']], + ]; + } + + return [ + 'status' => 'queued', + 'message' => 'Missing isolated data services were requested.', + 'result' => $this->completeIsolatedStackDataServices($serviceSetId, ['deploy_data_targets' => true], $actorUserId), + ]; + } + + private function runReleaseIssueCoolifyTargetAction(array $issue, string $operation, ?int $actorUserId): array + { + $targetId = $this->nullablePositiveInt($issue['coolify_target_id'] ?? null); + if ($targetId === null) { + throw new RuntimeException('Coolify target is missing.'); + } + if (!class_exists(coolify_manager::class) && function_exists('app_require')) { + app_require('classes/coolify_manager.php'); + } + if (!class_exists(coolify_manager::class)) { + throw new RuntimeException('Coolify manager is not available.'); + } + + $manager = new coolify_manager(); + $result = match ($operation) { + 'reconcile' => $manager->reconcileTarget($targetId, $actorUserId), + 'deploy' => $manager->deployTarget($targetId, $actorUserId), + 'restart' => $manager->restartTarget($targetId, $actorUserId), + default => throw new RuntimeException('Unknown Coolify target action.'), + }; + + return [ + 'status' => 'queued', + 'message' => 'Coolify target action requested.', + 'result' => $result, + ]; + } + + private function prepareReleaseIssueApplicationTarget(array $issue, ?int $actorUserId): array + { + $targetId = $this->nullablePositiveInt($issue['target_id'] ?? null); + if ($targetId === null) { + throw new RuntimeException('Release deployment target is missing.'); + } + + return $this->prepareDeploymentTargetAsApplication($targetId, $actorUserId, 'warning'); + } + + private function prepareDeploymentTargetAsApplication(int $targetId, ?int $actorUserId, string $severity = 'warning'): array + { + $target = $this->getDeploymentTarget($targetId); + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $context = is_array($context) ? $context : []; + $context['coolify_resource_type'] = 'application'; + $context['coolify_auto_create'] = true; + $context['coolify_enable_ssl'] = $this->toBool($context['coolify_enable_ssl'] ?? true); + $replacedLegacyUuid = trim((string)($target['coolify_service_uuid'] ?? '')) !== ''; + + $this->execute( + 'UPDATE release_deployment_targets SET coolify_service_uuid = NULL, deploy_context_json = ? WHERE id = ?', + 'si', + [self::jsonEncode($context), $targetId] + ); + $this->audit((int)$target['channel_id'], null, 'deployment_target_prepared_as_application', $actorUserId, $severity, [ + 'target_id' => $targetId, + 'replaced_legacy_service_uuid' => $replacedLegacyUuid, + 'severity' => $severity, + ]); + + return [ + 'status' => 'completed', + 'message' => 'Deployment target will create a Coolify application on the next deployment.', + 'result' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)), + ]; + } + + private function prepareChannelSyncApplicationTarget(array $target, ?int $actorUserId): array + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $context = is_array($context) ? $context : []; + if (!$this->releaseTargetNeedsApplicationAutoCreate($target, $context)) { + return $target; + } + + $this->prepareDeploymentTargetAsApplication((int)$target['id'], $actorUserId, 'info'); + $prepared = $this->getDeploymentTarget((int)$target['id']); + $prepared['_release_auto_prepared_application'] = true; + return $prepared; + } + + private function releaseStatusIssueByKey(array $summary, string $issueKey): ?array + { + foreach (is_array($summary['status_overview']['issues'] ?? null) ? $summary['status_overview']['issues'] : [] as $issue) { + if (is_array($issue) && (string)($issue['key'] ?? '') === $issueKey) { + return $issue; + } + } + return null; + } + + private function releaseStatusActionById(array $issue, string $actionId): ?array + { + foreach (is_array($issue['actions'] ?? null) ? $issue['actions'] : [] as $action) { + if (is_array($action) && (string)($action['id'] ?? '') === $actionId) { + return $action; + } + } + return null; + } + + private function releaseStatusOverview(array $summary): array + { + $channels = array_values(array_filter( + is_array($summary['channels'] ?? null) ? $summary['channels'] : [], + static fn(mixed $channel): bool => is_array($channel) + )); + $targetsByChannelApp = $this->releaseStatusTargetsByChannelApp( + is_array($summary['deployment_targets'] ?? null) ? $summary['deployment_targets'] : [] + ); + $deployments = array_values(array_filter( + is_array($summary['deployments'] ?? null) ? $summary['deployments'] : [], + static fn(mixed $deployment): bool => is_array($deployment) + )); + $deploymentsByChannelApp = $this->releaseStatusLatestDeploymentsByChannelApp($deployments); + $serviceSetsByChannel = $this->releaseStatusServiceSetsByChannel( + is_array($summary['service_sets'] ?? null) ? $summary['service_sets'] : [] + ); + $bundlesByChannel = $this->releaseStatusBundlesByChannel( + is_array($summary['bundles'] ?? null) ? $summary['bundles'] : [] + ); + $productionServiceChannel = $this->releaseStatusProductionServiceChannel($channels); + + $channelRows = []; + $issues = []; + foreach ($channels as $channel) { + $row = $this->releaseStatusChannelRow( + $channel, + $productionServiceChannel, + $targetsByChannelApp, + $deployments, + $deploymentsByChannelApp, + $serviceSetsByChannel, + $bundlesByChannel + ); + $channelRows[] = $row; + foreach ($row['issues'] as $issue) { + $issues[] = $issue; + } + } + + usort($issues, static function (array $a, array $b): int { + $rank = self::releaseStatusSeverityRank($b['severity'] ?? 'ok') + <=> self::releaseStatusSeverityRank($a['severity'] ?? 'ok'); + if ($rank !== 0) { + return $rank; + } + return strcmp((string)($a['channel_slug'] ?? ''), (string)($b['channel_slug'] ?? '')); + }); + + $affectedChannels = []; + $serviceCount = 0; + $unhealthyServiceCount = 0; + $missingValueCount = 0; + $criticalCount = 0; + $warningCount = 0; + foreach ($channelRows as $row) { + foreach ($row['services'] as $service) { + $serviceCount++; + if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') > 0) { + $unhealthyServiceCount++; + } + } + } + foreach ($issues as $issue) { + $severity = (string)($issue['severity'] ?? 'ok'); + if ($severity === 'critical') { + $criticalCount++; + } elseif ($severity === 'warning') { + $warningCount++; + } + if (($issue['type'] ?? '') === 'missing_value') { + $missingValueCount++; + } + if (self::releaseStatusSeverityRank($severity) > 0 && !empty($issue['channel_slug'])) { + $affectedChannels[(string)$issue['channel_slug']] = true; + } + } + + return [ + 'generated_at' => $summary['generated_at'] ?? date('c'), + 'state' => $criticalCount > 0 ? 'blocked' : ($warningCount > 0 ? 'attention' : 'ready'), + 'totals' => [ + 'channels' => count($channelRows), + 'ready_channels' => count(array_filter( + $channelRows, + static fn(array $row): bool => ($row['readiness'] ?? '') === 'ready' + )), + 'affected_channels' => count($affectedChannels), + 'issues' => count($issues), + 'critical' => $criticalCount, + 'warning' => $warningCount, + 'services' => $serviceCount, + 'unhealthy_services' => $unhealthyServiceCount, + 'missing_values' => $missingValueCount, + ], + 'issues' => $issues, + 'channels' => $channelRows, + ]; + } + + private function releaseStatusChannelRow( + array $channel, + ?array $productionServiceChannel, + array $targetsByChannelApp, + array $deployments, + array $deploymentsByChannelApp, + array $serviceSetsByChannel, + array $bundlesByChannel + ): array { + $channelId = (int)($channel['id'] ?? 0); + $channelSlug = (string)($channel['slug'] ?? ''); + $channelName = (string)($channel['name'] ?? $channelSlug); + $channel = $this->releaseStatusChannelWithProductionServices($channel, $productionServiceChannel, $targetsByChannelApp); + $channelWithTargetEndpoints = $this->releaseStatusChannelWithTargetEndpoints($channel, $targetsByChannelApp); + $availability = $this->releaseStatusChannelAvailability($channelWithTargetEndpoints); + $services = $this->releaseStatusServicesForChannel( + $channelWithTargetEndpoints, + $availability, + $targetsByChannelApp, + $deploymentsByChannelApp, + $serviceSetsByChannel + ); + + $issues = []; + $missingValues = []; + foreach ($availability['missing'] as $missingKey) { + $missing = [ + 'key' => $missingKey, + 'label' => self::releaseStatusMissingValueLabel($missingKey), + 'service_key' => self::releaseStatusMissingServiceKey($missingKey), + 'target_tab' => self::releaseStatusMissingTargetTab($missingKey), + ]; + $missingValues[] = $missing; + $issues[] = self::releaseStatusIssue([ + 'severity' => 'critical', + 'type' => 'missing_value', + 'channel_id' => $channelId, + 'channel_slug' => $channelSlug, + 'service_key' => $missing['service_key'], + 'label' => $missing['label'], + 'message' => $channelName . ' is missing ' . $missing['label'] . '.', + 'next_action' => self::releaseStatusMissingNextAction($missingKey), + 'target_tab' => $missing['target_tab'], + 'missing_key' => $missingKey, + ]); + } + + foreach ($services as $service) { + if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') === 0 || empty($service['issue_type'])) { + continue; + } + $issues[] = self::releaseStatusIssue([ + 'severity' => (string)$service['severity'], + 'type' => (string)$service['issue_type'], + 'channel_id' => $channelId, + 'channel_slug' => $channelSlug, + 'service_key' => (string)$service['service_key'], + 'label' => (string)$service['label'], + 'message' => (string)$service['message'], + 'next_action' => (string)$service['next_action'], + 'target_tab' => (string)$service['target_tab'], + 'target_id' => $service['target_id'] ?? null, + 'deployment_id' => $service['deployment_id'] ?? null, + 'coolify_target_id' => $service['coolify_target_id'] ?? null, + 'missing_key' => $service['missing_key'] ?? null, + 'service_set_id' => $service['service_set_id'] ?? null, + ]); + } + + $issues = array_map( + fn(array $issue): array => $this->releaseStatusIssueWithActions( + $issue, + $channel, + $services, + $bundlesByChannel[$channelId] ?? [] + ), + $issues + ); + + $severity = 'ok'; + foreach ($issues as $issue) { + $severity = self::releaseStatusMaxSeverity($severity, (string)($issue['severity'] ?? 'ok')); + } + $readiness = $severity === 'critical' ? 'blocked' : ($severity === 'warning' ? 'attention' : 'ready'); + $latestDeployments = array_slice(array_values(array_filter( + $deployments, + static fn(array $deployment): bool => (int)($deployment['channel_id'] ?? 0) === $channelId + )), 0, 5); + + return [ + 'channel_id' => $channelId, + 'channel_slug' => $channelSlug, + 'channel_name' => $channelName, + 'default_channel' => (bool)($channelWithTargetEndpoints['default_channel'] ?? false), + 'enabled' => (bool)($channelWithTargetEndpoints['enabled'] ?? true), + 'service_policy' => (string)($channelWithTargetEndpoints['service_policy'] ?? 'channel'), + 'service_channel_id' => $channelWithTargetEndpoints['_service_channel_id'] ?? $channelId, + 'service_channel_slug' => $channelWithTargetEndpoints['_service_channel_slug'] ?? $channelSlug, + 'severity' => $severity, + 'readiness' => $readiness, + 'message' => self::releaseStatusChannelMessage($readiness, count($issues)), + 'availability' => $availability, + 'missing_values' => $missingValues, + 'services' => $services, + 'versions' => is_array($channelWithTargetEndpoints['versions'] ?? null) ? $channelWithTargetEndpoints['versions'] : [], + 'replay' => [ + 'enabled' => (bool)($channelWithTargetEndpoints['replay_enabled'] ?? false), + 'capture_level' => (string)($channelWithTargetEndpoints['capture_level'] ?? 'metadata'), + ], + 'latest_deployments' => $latestDeployments, + 'issues' => $issues, + ]; + } + + private function releaseStatusProductionServiceChannel(array $channels): ?array + { + $normalized = array_values(array_filter($channels, static fn(mixed $channel): bool => is_array($channel))); + + foreach ($normalized as $channel) { + if ( + ((bool)($channel['default_channel'] ?? false) || (int)($channel['default_channel'] ?? 0) === 1) + && !$this->channelUsesProductionServices($channel) + ) { + return $channel; + } + } + + foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) { + foreach ($normalized as $channel) { + if ( + self::safeSlug((string)($channel['slug'] ?? '')) === $slug + && !$this->channelUsesProductionServices($channel) + ) { + return $channel; + } + } + } + + foreach ($normalized as $channel) { + if (!$this->channelUsesProductionServices($channel)) { + return $channel; + } + } + + return null; + } + + private function releaseStatusChannelWithProductionServices( + array $channel, + ?array $productionServiceChannel, + array $targetsByChannelApp + ): array { + if (!$this->channelUsesProductionServices($channel) || $productionServiceChannel === null) { + return $channel; + } + + $source = $this->releaseStatusChannelWithTargetEndpoints($productionServiceChannel, $targetsByChannelApp); + $channel['_uses_production_services'] = true; + $channel['_service_channel_id'] = (int)($source['id'] ?? 0); + $channel['_service_channel_slug'] = (string)($source['slug'] ?? ''); + $channel['service_policy'] = self::PRODUCTION_SERVICE_POLICY; + $channel['versions'] = is_array($source['versions'] ?? null) ? $source['versions'] : []; + + foreach (['frontend_base_url', 'api_base_url'] as $field) { + if (!empty($source[$field])) { + $channel[$field] = $source[$field]; + } + } + + return $channel; + } + + private function releaseStatusChannelWithTargetEndpoints(array $channel, array $targetsByChannelApp): array + { + $channelId = (int)($channel['id'] ?? 0); + if ($channelId <= 0) { + return $channel; + } + + foreach (self::APPS as $app) { + $field = $app === 'frontend' ? 'frontend_base_url' : 'api_base_url'; + if (!empty($channel[$field])) { + continue; + } + $target = $targetsByChannelApp[$channelId . ':' . $app] ?? null; + if (!is_array($target)) { + continue; + } + $endpointUrl = is_array($target['endpoint'] ?? null) + ? $this->normalizeReleasePublicBaseUrl($target['endpoint']['url'] ?? null, $app) + : null; + $endpointUrl ??= $this->releaseTargetPublicBaseUrl($target + [ + 'channel_slug' => $channel['slug'] ?? $target['channel_slug'] ?? '', + ]); + if ($endpointUrl !== null) { + $channel[$field] = $endpointUrl; + } + } + + return $channel; + } + + private function releaseStatusServicesForChannel( + array $channel, + array $availability, + array $targetsByChannelApp, + array $deploymentsByChannelApp, + array $serviceSetsByChannel + ): array { + $channelId = (int)($channel['id'] ?? 0); + $appServiceChannelId = (int)($channel['_service_channel_id'] ?? $channelId); + $versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : []; + $serviceSet = is_array($versions['service_set'] ?? null) + ? $versions['service_set'] + : ($serviceSetsByChannel[$channelId][0] ?? null); + $missingLookup = array_fill_keys($availability['missing'] ?? [], true); + + $services = []; + foreach (self::APPS as $app) { + $key = $appServiceChannelId . ':' . $app; + $services[] = $this->releaseStatusAppServiceRow( + $app, + $channel, + is_array($versions[$app] ?? null) ? $versions[$app] : null, + is_array($targetsByChannelApp[$key] ?? null) ? $targetsByChannelApp[$key] : null, + is_array($deploymentsByChannelApp[$key] ?? null) ? $deploymentsByChannelApp[$key] : null, + $missingLookup + ); + } + + foreach (self::STACK_DATA_KINDS as $kind) { + $services[] = $this->releaseStatusDataServiceRow($kind, $channel, is_array($serviceSet) ? $serviceSet : null); + } + + return $services; + } + + private function releaseStatusAppServiceRow( + string $app, + array $channel, + ?array $version, + ?array $target, + ?array $deployment, + array $missingLookup + ): array { + $serviceLabel = self::releaseStatusServiceLabel($app); + $missingKeys = $app === 'frontend' + ? ['frontend_version', 'frontend_base_url'] + : ['api_version', 'api_base_url']; + $missingKey = null; + foreach ($missingKeys as $key) { + if (isset($missingLookup[$key])) { + $missingKey = $key; + break; + } + } + + $row = [ + 'service_key' => $app, + 'label' => $serviceLabel, + 'status' => (string)($deployment['status'] ?? $version['status'] ?? 'ready'), + 'state' => 'ready', + 'severity' => 'ok', + 'message' => $serviceLabel . ' release service is ready.', + 'next_action' => '', + 'target_tab' => 'overview', + 'target_id' => $target['id'] ?? null, + 'deployment_id' => $deployment['id'] ?? null, + 'version_label' => $version['version_label'] ?? null, + 'commit_sha' => $version['commit_sha'] ?? $deployment['commit_sha'] ?? null, + 'repository' => $target['repository'] ?? $deployment['repository'] ?? $version['repository'] ?? null, + 'branch' => $target['branch'] ?? $deployment['branch'] ?? $version['branch'] ?? null, + 'health_url' => $target['health_url'] ?? null, + 'issue_type' => null, + ]; + + $deploymentStatus = strtolower((string)($deployment['status'] ?? '')); + if (in_array($deploymentStatus, ['failed', 'error'], true)) { + return array_replace($row, [ + 'state' => 'failed', + 'severity' => 'critical', + 'message' => trim((string)( + $deployment['failure_summary']['root_cause'] + ?? $deployment['error_message'] + ?? ($serviceLabel . ' deployment failed.') + )), + 'next_action' => trim((string)( + $deployment['failure_summary']['next_action'] + ?? 'Open the deployment details and fix the failing release before promotion.' + )), + 'target_tab' => 'deployments', + 'issue_type' => 'failed_deployment', + ]); + } + + if (in_array($deploymentStatus, ['queued', 'running', 'deploying', 'building', 'pending'], true)) { + return array_replace($row, [ + 'state' => 'deployment_in_progress', + 'severity' => 'warning', + 'message' => $serviceLabel . ' deployment is still in progress.', + 'next_action' => 'Wait for the deployment to finish, then refresh Release Manager.', + 'target_tab' => 'deployments', + 'issue_type' => 'deployment_in_progress', + ]); + } + + if ($missingKey !== null) { + return array_replace($row, [ + 'state' => 'missing_value', + 'status' => 'missing', + 'severity' => 'critical', + 'message' => $serviceLabel . ' is missing ' . self::releaseStatusMissingValueLabel($missingKey) . '.', + 'next_action' => self::releaseStatusMissingNextAction($missingKey), + 'target_tab' => self::releaseStatusMissingTargetTab($missingKey), + 'missing_key' => $missingKey, + ]); + } + + if (($channel['_uses_production_services'] ?? false) === true) { + return array_replace($row, [ + 'status' => self::PRODUCTION_SERVICE_POLICY, + 'service_policy' => self::PRODUCTION_SERVICE_POLICY, + 'service_channel_slug' => (string)($channel['_service_channel_slug'] ?? ''), + 'message' => $serviceLabel . ' uses the production service for this channel.', + ]); + } + + $isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable'; + if (!$isDefaultChannel && is_array($target) && trim((string)($target['coolify_service_uuid'] ?? '')) === '') { + return array_replace($row, [ + 'state' => 'stale_unknown', + 'status' => 'missing_coolify_service', + 'severity' => 'warning', + 'message' => $serviceLabel . ' target is missing its Coolify service UUID.', + 'next_action' => 'Open Integrations and connect or create the Coolify service.', + 'target_tab' => 'integrations', + 'issue_type' => 'stale_unknown', + 'missing_key' => $app . '_coolify_service_uuid', + ]); + } + + return $row; + } + + private function releaseStatusDataServiceRow(string $kind, array $channel, ?array $serviceSet): array + { + $serviceLabel = self::releaseStatusServiceLabel($kind); + $isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable'; + $mode = (string)($serviceSet['mode'] ?? ''); + $dataPolicy = $this->serviceSetDataPolicy($serviceSet); + $usesSharedProduction = $dataPolicy === self::PRODUCTION_DATA_POLICY; + $stack = is_array($serviceSet['stack'] ?? null) ? $serviceSet['stack'] : []; + $dataServices = is_array($serviceSet['data_services'] ?? null) ? $serviceSet['data_services'] : []; + $service = is_array($stack[$kind] ?? null) ? $stack[$kind] : (is_array($dataServices[$kind] ?? null) ? $dataServices[$kind] : null); + $row = [ + 'service_key' => $kind, + 'label' => $serviceLabel, + 'status' => $usesSharedProduction ? 'production_shared' : 'ready', + 'data_policy' => $dataPolicy, + 'data_service_mode' => $dataPolicy, + 'state' => 'ready', + 'severity' => 'ok', + 'message' => $usesSharedProduction + ? $serviceLabel . ' uses the production-shared service and is not replaced by channel sync.' + : $serviceLabel . ' release service is ready.', + 'next_action' => '', + 'target_tab' => 'data-services', + 'service_set_id' => isset($serviceSet['id']) ? (int)$serviceSet['id'] : null, + 'coolify_target_id' => $service['id'] ?? null, + 'resource_uuid' => $service['resource_uuid'] ?? null, + 'resource_name' => $service['resource_name'] ?? $service['label'] ?? null, + 'issue_type' => null, + ]; + + if ($usesSharedProduction) { + return $row; + } + + if ($service === null) { + $critical = $mode === 'isolated_stack'; + return array_replace($row, [ + 'status' => 'missing', + 'state' => 'missing_value', + 'severity' => $critical ? 'critical' : 'warning', + 'message' => $serviceLabel . ' is not assigned to this service set.', + 'next_action' => $critical + ? 'Add the missing isolated data service before deploying or promoting this bundle.' + : 'Review the service set and attach the data service if this channel needs isolated data.', + 'issue_type' => 'missing_value', + 'missing_key' => $kind . '_service', + ]); + } + + $deploymentStatus = strtolower((string)($service['deployment_status'] ?? '')); + $availabilityState = strtolower((string)($service['availability_state'] ?? '')); + $replication = is_array($service['replication'] ?? null) ? $service['replication'] : []; + $replicationLastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : []; + $replicationStatus = strtolower((string)($replicationLastStatus['status'] ?? $replication['status'] ?? '')); + $blockers = array_values(array_filter( + is_array($replicationLastStatus['blockers'] ?? null) ? $replicationLastStatus['blockers'] : [] + )); + + $row['status'] = $deploymentStatus ?: ($availabilityState ?: ($replicationStatus ?: 'ready')); + + if ( + in_array($deploymentStatus, ['failed', 'reconcile_failed', 'restart_failed', 'provision_blocked'], true) + || in_array($availabilityState, ['degraded', 'failover_blocked', 'destructive_action_required'], true) + ) { + return array_replace($row, [ + 'state' => 'service_unhealthy', + 'severity' => 'critical', + 'message' => $serviceLabel . ' Coolify target is unhealthy.', + 'next_action' => 'Open Bundles or Integrations and inspect the Coolify target before promotion.', + 'issue_type' => 'service_unhealthy', + ]); + } + + if (in_array($deploymentStatus, ['created', 'deploying', 'restarting', 'waiting_for_coolify', 'provisioning'], true)) { + return array_replace($row, [ + 'state' => 'deployment_in_progress', + 'severity' => 'warning', + 'message' => $serviceLabel . ' service provisioning is still in progress.', + 'next_action' => 'Wait for Coolify provisioning to finish, then refresh Release Manager.', + 'issue_type' => 'deployment_in_progress', + ]); + } + + if ($replicationStatus !== '' && !in_array($replicationStatus, ['ok', 'ready', 'protected', 'healthy'], true)) { + return array_replace($row, [ + 'state' => 'service_unhealthy', + 'severity' => $blockers === [] ? 'warning' : 'critical', + 'message' => $blockers[0] ?? ($serviceLabel . ' replication is not healthy.'), + 'next_action' => 'Check replication status before promoting this release bundle.', + 'issue_type' => 'service_unhealthy', + ]); + } + + return $row; + } + + private function releaseStatusTargetsByChannelApp(array $targets): array + { + $indexed = []; + foreach ($targets as $target) { + if (!is_array($target)) { + continue; + } + $channelId = (int)($target['channel_id'] ?? 0); + $app = (string)($target['app'] ?? ''); + if ($channelId > 0 && in_array($app, self::APPS, true)) { + $indexed[$channelId . ':' . $app] = $target; + } + } + return $indexed; + } + + private function releaseStatusLatestDeploymentsByChannelApp(array $deployments): array + { + $indexed = []; + foreach ($deployments as $deployment) { + $channelId = (int)($deployment['channel_id'] ?? 0); + $app = (string)($deployment['app'] ?? ''); + $key = $channelId . ':' . $app; + if ($channelId > 0 && in_array($app, self::APPS, true) && !isset($indexed[$key])) { + $indexed[$key] = $deployment; + } + } + return $indexed; + } + + private function releaseStatusServiceSetsByChannel(array $serviceSets): array + { + $indexed = []; + foreach ($serviceSets as $set) { + if (!is_array($set)) { + continue; + } + $channelId = (int)($set['channel_id'] ?? 0); + if ($channelId > 0) { + $indexed[$channelId][] = $set; + } + } + return $indexed; + } + + private function releaseStatusBundlesByChannel(array $bundles): array + { + $indexed = []; + foreach ($bundles as $bundle) { + if (!is_array($bundle)) { + continue; + } + $channelId = (int)($bundle['channel_id'] ?? 0); + if ($channelId > 0) { + $indexed[$channelId][] = $bundle; + } + } + return $indexed; + } + + private function releaseStatusChannelAvailability(array $channel): array + { + if (($channel['_uses_production_services'] ?? false) !== true && is_array($channel['availability'] ?? null)) { + $availability = $channel['availability']; + $missing = self::releaseStatusReadinessMissingValues( + is_array($availability['missing'] ?? null) ? $availability['missing'] : [] + ); + $status = (string)($availability['status'] ?? ''); + if ($status === '' || ($missing === [] && in_array($status, ['unconfigured', 'missing_target'], true))) { + $status = $missing === [] ? 'ready' : 'unconfigured'; + } + return [ + 'configured' => $missing === [] + ? true + : (($availability['configured'] ?? null) === null ? false : (bool)$availability['configured']), + 'missing' => $missing, + 'status' => $status, + 'bundle_id' => $availability['bundle_id'] ?? null, + 'frontend_base_url' => $availability['frontend_base_url'] ?? $channel['frontend_base_url'] ?? null, + 'api_base_url' => $availability['api_base_url'] ?? $channel['api_base_url'] ?? null, + ]; + } + + $isDefault = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable'; + if ($isDefault) { + return [ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + 'frontend_base_url' => $channel['frontend_base_url'] ?? null, + 'api_base_url' => $channel['api_base_url'] ?? null, + ]; + } + + $versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : []; + $missing = []; + if (empty($versions['frontend'])) { + $missing[] = 'frontend_version'; + } elseif (empty($channel['frontend_base_url'])) { + $missing[] = 'frontend_base_url'; + } + if (empty($versions['api'])) { + $missing[] = 'api_version'; + } elseif (empty($channel['api_base_url'])) { + $missing[] = 'api_base_url'; + } + + return [ + 'configured' => $missing === [], + 'missing' => $missing, + 'status' => $missing === [] ? 'ready' : 'unconfigured', + 'bundle_id' => $versions['bundle_id'] ?? null, + 'frontend_base_url' => $channel['frontend_base_url'] ?? null, + 'api_base_url' => $channel['api_base_url'] ?? null, + ]; + } + + private static function releaseStatusReadinessMissingValues(array $missing): array + { + $normalized = []; + foreach ($missing as $key) { + $value = trim((string)$key); + if ($value === '' || $value === 'release_bundle') { + continue; + } + $normalized[] = $value; + } + + return array_values(array_unique($normalized)); + } + + private static function releaseStatusIssue(array $issue): array + { + $normalized = [ + 'severity' => (string)($issue['severity'] ?? 'warning'), + 'type' => (string)($issue['type'] ?? 'stale_unknown'), + 'channel_id' => isset($issue['channel_id']) ? (int)$issue['channel_id'] : null, + 'channel_slug' => (string)($issue['channel_slug'] ?? ''), + 'service_key' => $issue['service_key'] ?? null, + 'label' => (string)($issue['label'] ?? ''), + 'message' => (string)($issue['message'] ?? ''), + 'next_action' => (string)($issue['next_action'] ?? ''), + 'target_tab' => (string)($issue['target_tab'] ?? 'overview'), + 'target_id' => $issue['target_id'] ?? null, + 'deployment_id' => $issue['deployment_id'] ?? null, + 'coolify_target_id' => $issue['coolify_target_id'] ?? null, + 'service_set_id' => $issue['service_set_id'] ?? null, + 'missing_key' => $issue['missing_key'] ?? null, + 'impact' => (string)($issue['impact'] ?? ''), + 'resolution_state' => (string)($issue['resolution_state'] ?? 'open'), + 'actions' => is_array($issue['actions'] ?? null) ? $issue['actions'] : [], + ]; + $normalized['key'] = (string)($issue['key'] ?? self::releaseStatusIssueKey($normalized)); + if ($normalized['impact'] === '') { + $normalized['impact'] = self::releaseStatusIssueImpact($normalized); + } + return $normalized; + } + + private function releaseStatusIssueWithActions(array $issue, array $channel, array $services, array $channelBundles): array + { + $issue = self::releaseStatusIssue($issue); + $service = null; + foreach ($services as $candidate) { + if ((string)($candidate['service_key'] ?? '') === (string)($issue['service_key'] ?? '')) { + $service = $candidate; + break; + } + } + + if (($issue['target_id'] ?? null) === null && isset($service['target_id'])) { + $issue['target_id'] = $service['target_id']; + } + if (($issue['deployment_id'] ?? null) === null && isset($service['deployment_id'])) { + $issue['deployment_id'] = $service['deployment_id']; + } + if (($issue['coolify_target_id'] ?? null) === null && isset($service['coolify_target_id'])) { + $issue['coolify_target_id'] = $service['coolify_target_id']; + } + if (($issue['service_set_id'] ?? null) === null && isset($service['service_set_id'])) { + $issue['service_set_id'] = $service['service_set_id']; + } + $issue['key'] = self::releaseStatusIssueKey($issue); + $issue['impact'] = $issue['impact'] !== '' ? $issue['impact'] : self::releaseStatusIssueImpact($issue); + $issue['resolution_state'] = self::releaseStatusResolutionState($issue); + $issue['actions'] = $this->releaseStatusIssueActions($issue, $channel, $service, $channelBundles); + return $issue; + } + + private function releaseStatusIssueActions(array $issue, array $channel, ?array $service, array $channelBundles): array + { + $type = (string)($issue['type'] ?? ''); + $missingKey = (string)($issue['missing_key'] ?? ''); + $serviceKey = (string)($issue['service_key'] ?? ''); + $actions = []; + + if ($type === 'failed_deployment') { + $actions[] = self::releaseStatusAction( + 'retry_deployment', + 'Retry deployment', + 'mutation', + true, + empty($issue['deployment_id']), + empty($issue['deployment_id']) ? 'The failed deployment record is missing.' : '' + ); + if (self::releaseStatusIssueNeedsApplicationTarget($issue)) { + $actions[] = self::releaseStatusAction( + 'prepare_application_target', + 'Prepare application target', + 'mutation', + true, + empty($issue['target_id']), + empty($issue['target_id']) ? 'The deployment target is missing.' : '' + ); + } + } + + if ($type === 'missing_value' && in_array($missingKey, ['frontend_version', 'api_version'], true)) { + $actions[] = self::releaseStatusAction( + 'deploy_missing_version', + 'Deploy missing version', + 'mutation', + true, + empty($issue['target_id']), + empty($issue['target_id']) ? 'Select or create a deployment target first.' : '' + ); + } + + if ($type === 'missing_value' && $missingKey === 'release_bundle') { + $eligibleBundles = self::releaseStatusEligibleBundles($channelBundles); + $actions[] = self::releaseStatusAction( + 'set_bundle', + count($eligibleBundles) === 1 ? 'Set available bundle' : 'Choose release bundle', + 'mutation', + true, + count($eligibleBundles) !== 1, + $eligibleBundles === [] ? 'No deployed bundle is available for this channel.' : '', + ['bundle_choices' => $eligibleBundles] + ); + } + + if ($type === 'missing_value' && in_array($missingKey, ['database_service', 'redis_service', 'minio_service'], true)) { + $actions[] = self::releaseStatusAction( + 'complete_data_services', + 'Create missing data services', + 'mutation', + true, + empty($issue['service_set_id']), + empty($issue['service_set_id']) ? 'The isolated service set is missing.' : '' + ); + } + + if ($type === 'service_unhealthy' && in_array($serviceKey, self::STACK_DATA_KINDS, true)) { + foreach ([ + 'reconcile_coolify_target' => 'Reconcile target', + 'redeploy_coolify_target' => 'Redeploy target', + 'restart_coolify_target' => 'Restart target', + ] as $id => $label) { + $actions[] = self::releaseStatusAction( + $id, + $label, + 'mutation', + true, + empty($issue['coolify_target_id']), + empty($issue['coolify_target_id']) ? 'The Coolify target is missing.' : '' + ); + } + } + + if ($type === 'deployment_in_progress') { + $actions[] = self::releaseStatusAction('refresh_status', 'Refresh status', 'refresh', false, false); + } + + return $actions; + } + + private static function releaseStatusAction( + string $id, + string $label, + string $kind, + bool $requiresConfirmation, + bool $requiresInput, + string $disabledReason = '', + array $extra = [] + ): array { + return array_replace([ + 'id' => $id, + 'label' => $label, + 'kind' => $kind, + 'requires_confirmation' => $requiresConfirmation, + 'requires_input' => $requiresInput, + 'disabled_reason' => $disabledReason, + 'permission' => 'superuser_release_manager_deploy', + ], $extra); + } + + private static function releaseStatusIssueKey(array $issue): string + { + return implode(':', [ + self::safeIdentifier((string)($issue['type'] ?? 'unknown'), 32) ?: 'unknown', + self::safeIdentifier((string)($issue['channel_id'] ?? $issue['channel_slug'] ?? ''), 64), + self::safeIdentifier((string)($issue['service_key'] ?? ''), 32), + self::safeIdentifier((string)($issue['missing_key'] ?? ''), 64), + self::safeIdentifier((string)($issue['deployment_id'] ?? ''), 64), + self::safeIdentifier((string)($issue['coolify_target_id'] ?? ''), 64), + ]); + } + + private static function releaseStatusIssueImpact(array $issue): string + { + return match ((string)($issue['type'] ?? '')) { + 'failed_deployment' => 'This channel cannot be promoted until the failed deployment is replaced by a successful one.', + 'missing_value' => 'This channel is incomplete and cannot receive traffic safely.', + 'service_unhealthy' => 'This channel has an unhealthy runtime service and should not be promoted.', + 'deployment_in_progress' => 'Promotion should wait until the deployment or provisioning job finishes.', + default => 'Review this release issue before publishing or promoting the channel.', + }; + } + + private static function releaseStatusResolutionState(array $issue): string + { + if ((string)($issue['severity'] ?? '') === 'critical') { + return 'blocked'; + } + if ((string)($issue['severity'] ?? '') === 'warning') { + return 'action_available'; + } + return 'open'; + } + + private static function releaseStatusIssueNeedsApplicationTarget(array $issue): bool + { + $text = strtolower(trim((string)($issue['message'] ?? '') . ' ' . (string)($issue['next_action'] ?? ''))); + return str_contains($text, 'stripprefix') + || str_contains($text, 'path-routed') + || str_contains($text, 'service creation') + || str_contains($text, 'coolify service'); + } + + private static function releaseStatusEligibleBundles(array $bundles): array + { + $eligible = []; + foreach ($bundles as $bundle) { + $status = strtolower((string)($bundle['status'] ?? '')); + if (!in_array($status, ['deployed', 'promoted', 'active'], true)) { + continue; + } + $eligible[] = [ + 'id' => (int)($bundle['id'] ?? 0), + 'label' => (string)($bundle['version_label'] ?? ('Bundle #' . (int)($bundle['id'] ?? 0))), + 'status' => (string)($bundle['status'] ?? ''), + ]; + } + return $eligible; + } + + private static function releaseStatusSeverityRank(string $severity): int + { + return match ($severity) { + 'critical' => 2, + 'warning' => 1, + default => 0, + }; + } + + private static function releaseStatusMaxSeverity(string $a, string $b): string + { + return self::releaseStatusSeverityRank($b) > self::releaseStatusSeverityRank($a) ? $b : $a; + } + + private static function releaseStatusChannelMessage(string $readiness, int $issueCount): string + { + if ($readiness === 'ready') { + return 'All release services are ready.'; + } + if ($readiness === 'blocked') { + return $issueCount . ' blocker' . ($issueCount === 1 ? '' : 's') . ' need attention before promotion.'; + } + return $issueCount . ' warning' . ($issueCount === 1 ? '' : 's') . ' should be reviewed.'; + } + + private static function releaseStatusServiceLabel(string $service): string + { + return match ($service) { + 'frontend' => 'Frontend', + 'api' => 'API', + 'database' => 'Database', + 'redis' => 'Redis', + 'minio' => 'MinIO', + default => ucfirst(str_replace('_', ' ', $service)), + }; + } + + private static function releaseStatusMissingValueLabel(string $key): string + { + return match ($key) { + 'release_bundle' => 'release bundle', + 'frontend_version' => 'frontend version', + 'frontend_base_url' => 'frontend URL', + 'api_version' => 'API version', + 'api_base_url' => 'API URL', + 'database_service' => 'database service', + 'redis_service' => 'Redis service', + 'minio_service' => 'MinIO service', + default => str_replace('_', ' ', $key), + }; + } + + private static function releaseStatusMissingServiceKey(string $key): ?string + { + return match ($key) { + 'frontend_version', 'frontend_base_url', 'frontend_coolify_service_uuid' => 'frontend', + 'api_version', 'api_base_url', 'api_coolify_service_uuid' => 'api', + 'database_service' => 'database', + 'redis_service' => 'redis', + 'minio_service' => 'minio', + default => null, + }; + } + + private static function releaseStatusMissingTargetTab(string $key): string + { + return match ($key) { + 'release_bundle', 'database_service', 'redis_service', 'minio_service' => 'bundles', + 'frontend_version', 'api_version' => 'deployments', + 'frontend_base_url', 'api_base_url', 'frontend_coolify_service_uuid', 'api_coolify_service_uuid' => 'integrations', + default => 'overview', + }; + } + + private static function releaseStatusMissingNextAction(string $key): string + { + return match ($key) { + 'release_bundle' => 'Create or deploy a release bundle, then attach it to the channel.', + 'frontend_version', 'api_version' => 'Deploy the missing application version for this channel.', + 'frontend_base_url', 'api_base_url' => 'Set the public release URL from the deployment target or channel configuration.', + 'database_service', 'redis_service', 'minio_service' => 'Add the missing data service to the isolated service set.', + default => 'Open Release Manager details and complete the missing value.', + }; + } + + public function releaseConfig(): array + { + $this->ensureSchema(); + $storedToken = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', '')); + $storedWebhookSecret = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', '')); + + return [ + 'github_token_configured' => $this->hasGithubApiToken(), + 'github_token_env_configured' => $this->githubEnvToken() !== '', + 'github_token_module_configured' => $storedToken !== '', + 'github_token_variable' => 'ReleaseManager.github_token', + 'github_token_env_variable' => 'RELEASE_MANAGER_GITHUB_TOKEN', + 'github_api_url' => $this->githubApiBaseUrl(), + 'github_api_url_variable' => 'ReleaseManager.github_api_url', + 'github_webhook_secret_configured' => $storedWebhookSecret !== '', + 'github_webhook_secret_variable' => 'ReleaseManager.github_webhook_secret', + ]; + } + + public function updateReleaseConfig(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $updated = []; + + if (array_key_exists('github_api_url', $input)) { + $apiUrl = rtrim(trim((string)$input['github_api_url']), '/'); + if ($apiUrl === '') { + $apiUrl = 'https://api.github.com'; + } + if (preg_match('#^https?://#i', $apiUrl) !== 1) { + throw new RuntimeException('GitHub API URL must start with http:// or https://.'); + } + $this->upsertModuleConfigValue('ReleaseManager', 'github_api_url', $apiUrl, 'string'); + $updated[] = 'github_api_url'; + } + + if (array_key_exists('github_token', $input)) { + $token = trim((string)$input['github_token']); + if ($token !== '' && $token !== '[redacted]' && !str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) { + $token = replication_secret_box::encrypt($token); + } + if ($token !== '' && $token !== '[redacted]') { + $this->upsertModuleConfigValue('ReleaseManager', 'github_token', $token, 'string'); + $updated[] = 'github_token'; + } + } + + if ($this->toBool($input['clear_github_token'] ?? false)) { + $this->upsertModuleConfigValue('ReleaseManager', 'github_token', '', 'string'); + $updated[] = 'github_token'; + } + + if (array_key_exists('github_webhook_secret', $input)) { + $secret = trim((string)$input['github_webhook_secret']); + if ($secret !== '' && $secret !== '[redacted]') { + $this->upsertModuleConfigValue('ReleaseManager', 'github_webhook_secret', $secret, 'string'); + $updated[] = 'github_webhook_secret'; + } + } + + $this->audit(null, null, 'release_config_updated', $actorUserId, 'info', [ + 'updated' => array_values(array_unique($updated)), + ]); + + return $this->releaseConfig(); + } + + public function listGithubRepositories(array $filters = []): array + { + $this->ensureSchema(); + if (!$this->hasGithubApiToken()) { + return $this->githubTokenMissingResponse(); + } + + $query = strtolower(trim((string)($filters['query'] ?? $filters['search'] ?? ''))); + $repositories = []; + for ($page = 1; $page <= 5; $page++) { + $rows = $this->githubRequest('GET', '/user/repos', [ + 'visibility' => 'all', + 'affiliation' => 'owner,collaborator,organization_member', + 'sort' => 'updated', + 'direction' => 'desc', + 'per_page' => 100, + 'page' => $page, + ]); + if (!is_array($rows)) { + break; + } + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $repository = $this->publicGithubRepository($row); + if ($query !== '') { + $haystack = strtolower(($repository['full_name'] ?? '') . ' ' . ($repository['description'] ?? '')); + if (!str_contains($haystack, $query)) { + continue; + } + } + $repositories[$repository['full_name']] = $repository; + } + + if (count($rows) < 100) { + break; + } + } + + return [ + 'token_configured' => true, + 'github_api_url' => $this->githubApiBaseUrl(), + 'repositories' => array_values($repositories), + ]; + } + + public function listGithubBranches(array $input): array + { + $this->ensureSchema(); + $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? '')); + if ($repository === '') { + throw new RuntimeException('GitHub repository must use owner/repo format.'); + } + if (!$this->hasGithubApiToken()) { + return $this->githubTokenMissingResponse($repository); + } + + $branches = []; + for ($page = 1; $page <= 5; $page++) { + $rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/branches', [ + 'per_page' => 100, + 'page' => $page, + ]); + if (!is_array($rows)) { + break; + } + + foreach ($rows as $row) { + if (is_array($row)) { + $branch = $this->publicGithubBranch($row); + $branches[$branch['name']] = $branch; + } + } + + if (count($rows) < 100) { + break; + } + } + + return [ + 'token_configured' => true, + 'repository' => $repository, + 'branches' => array_values($branches), + ]; + } + + public function listGithubCommits(array $input): array + { + $this->ensureSchema(); + $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? '')); + if ($repository === '') { + throw new RuntimeException('GitHub repository must use owner/repo format.'); + } + if (!$this->hasGithubApiToken()) { + return $this->githubTokenMissingResponse($repository, (string)($input['branch'] ?? '')); + } + + $branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + $commit = trim((string)($input['commit_sha'] ?? $input['commit'] ?? '')); + $query = strtolower(trim((string)($input['query'] ?? $input['search'] ?? ''))); + + if ($commit !== '' && !in_array(strtolower($commit), ['latest', 'head'], true)) { + $row = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($commit)); + $publicCommit = is_array($row) ? $this->publicGithubCommit($row) : []; + return [ + 'token_configured' => true, + 'repository' => $repository, + 'branch' => $branch, + 'commits' => $publicCommit !== [] ? [$publicCommit] : [], + 'latest' => $publicCommit !== [] ? $publicCommit : null, + ]; + } + + $rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits', [ + 'sha' => $branch, + 'per_page' => 25, + ]); + $commits = []; + foreach (is_array($rows) ? $rows : [] as $row) { + if (!is_array($row)) { + continue; + } + $publicCommit = $this->publicGithubCommit($row); + if ($query !== '') { + $haystack = strtolower(($publicCommit['sha'] ?? '') . ' ' . ($publicCommit['message'] ?? '') . ' ' . ($publicCommit['author_name'] ?? '')); + if (!str_contains($haystack, $query)) { + continue; + } + } + $commits[] = $publicCommit; + } + + return [ + 'token_configured' => true, + 'repository' => $repository, + 'branch' => $branch, + 'commits' => $commits, + 'latest' => $commits[0] ?? null, + ]; + } + + public function testGithubRepositoryAccess(array $input): array + { + $this->ensureSchema(); + return $this->githubRepositoryAccess($input); + } + + public function listChannels(): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + $channels = $this->selectRows( + "SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug" + ); + + return array_map(function (array $channel): array { + $public = $this->publicChannel($channel); + $serviceChannel = $this->runtimeServiceChannelFor($channel); + $public['service_channel'] = $this->publicChannel($serviceChannel); + $public['service_policy'] = $this->channelUsesProductionServices($channel) + ? self::PRODUCTION_SERVICE_POLICY + : 'channel'; + $public['versions'] = $this->currentVersionsForChannel((int)$serviceChannel['id']); + $public['current_deployments'] = $this->channelCurrentDeployments((int)$serviceChannel['id']); + $public['branch_status'] = $this->channelBranchStatus($channel); + $public['data_services'] = $this->channelDataServicesSummary($channel); + $public['replication_policy'] = $public['data_services']['policy'] ?? $this->replicationPolicyForMode('production_shared'); + return $public; + }, $channels); + } + + public function createChannel(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $normalized = $this->normalizeChannelInput($input, true); + + $this->execute( + "INSERT INTO release_channels ( + slug, name, description, enabled, default_channel, rollout_percent, + frontend_base_url, api_base_url, replay_enabled, capture_level, retention_days, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'sssiidssisis', + [ + $normalized['slug'], + $normalized['name'], + $normalized['description'], + $normalized['enabled'], + $normalized['default_channel'], + $normalized['rollout_percent'], + $normalized['frontend_base_url'], + $normalized['api_base_url'], + $normalized['replay_enabled'], + $normalized['capture_level'], + $normalized['retention_days'], + self::jsonEncode($normalized['metadata']), + ] + ); + + $id = $this->insertId(); + if ($normalized['default_channel'] === 1) { + $this->clearOtherDefaultChannels($id); + } + $this->audit($id, null, 'channel_created', $actorUserId, 'info', $normalized); + + return $this->publicChannel($this->getChannel($id)); + } + + public function updateChannel(int $id, array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $channel = $this->getChannel($id); + $normalized = $this->normalizeChannelInput(array_replace($channel, $input), false); + + $this->execute( + "UPDATE release_channels + SET slug = ?, name = ?, description = ?, enabled = ?, default_channel = ?, rollout_percent = ?, + frontend_base_url = ?, api_base_url = ?, replay_enabled = ?, capture_level = ?, + retention_days = ?, metadata_json = ? + WHERE id = ?", + 'sssiidssisisi', + [ + $normalized['slug'], + $normalized['name'], + $normalized['description'], + $normalized['enabled'], + $normalized['default_channel'], + $normalized['rollout_percent'], + $normalized['frontend_base_url'], + $normalized['api_base_url'], + $normalized['replay_enabled'], + $normalized['capture_level'], + $normalized['retention_days'], + self::jsonEncode($normalized['metadata']), + $id, + ] + ); + + if ($normalized['default_channel'] === 1) { + $this->clearOtherDefaultChannels($id); + } + $this->audit($id, null, 'channel_updated', $actorUserId, 'info', $normalized); + + return $this->publicChannel($this->getChannel($id)); + } + + public function listAssignments(): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $row): array => $this->publicAssignment($row), + $this->selectRows( + "SELECT a.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_assignments a + INNER JOIN release_channels c ON c.id = a.channel_id + WHERE a.deleted_at IS NULL AND (a.expires_at IS NULL OR a.expires_at > NOW()) + ORDER BY a.created_at DESC + LIMIT 250" + ) + ); + } + + public function searchAssignmentSubjects(array $input): array + { + $query = self::normalizeAssignmentSubjectSearch($input['search'] ?? $input['query'] ?? ''); + if ($query === '') { + return []; + } + + $limit = self::normalizeAssignmentSubjectLimit($input['limit'] ?? 5); + $subjects = array_merge( + $this->searchAssignmentUsers($query, $limit), + $this->searchAssignmentSubusers($query, $limit), + $this->searchAssignmentCustomers($query, $limit) + ); + + $seen = []; + $normalized = []; + foreach ($subjects as $subject) { + $item = self::publicAssignmentSubjectSuggestion($subject); + if ($item === null) { + continue; + } + $key = $item['subject_type'] . ':' . $item['subject_id']; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $normalized[] = $item; + } + + return $normalized; + } + + public function createAssignment(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $subjectType = strtolower(trim((string)($input['subject_type'] ?? ''))); + if (!in_array($subjectType, self::SUBJECT_TYPES, true)) { + throw new RuntimeException('Invalid release assignment subject type.'); + } + + $subjectId = trim((string)($input['subject_id'] ?? '')); + if ($subjectId === '') { + throw new RuntimeException('Release assignment subject_id is required.'); + } + + $channel = $this->channelFromInput($input); + $reason = trim((string)($input['reason'] ?? '')); + $expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null); + + $this->execute( + "INSERT INTO release_assignments (subject_type, subject_id, channel_id, reason, expires_at, actor_user_id) + VALUES (?, ?, ?, ?, ?, ?)", + 'ssissi', + [$subjectType, $subjectId, (int)$channel['id'], $reason !== '' ? $reason : null, $expiresAt, $actorUserId] + ); + + $id = $this->insertId(); + $this->clearAssignmentCache($subjectType, $subjectId); + $this->audit((int)$channel['id'], null, 'assignment_created', $actorUserId, 'info', [ + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'channel_slug' => $channel['slug'], + ]); + + $row = $this->selectOne( + "SELECT a.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_assignments a INNER JOIN release_channels c ON c.id = a.channel_id + WHERE a.id = ?", + 'i', + [$id] + ); + return $this->publicAssignment($row ?? []); + } + + public function deleteAssignment(int $id, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $assignment = $this->selectOne('SELECT * FROM release_assignments WHERE id = ? AND deleted_at IS NULL', 'i', [$id]); + if ($assignment === null) { + throw new RuntimeException('Release assignment not found.'); + } + + $this->execute('UPDATE release_assignments SET deleted_at = NOW() WHERE id = ?', 'i', [$id]); + $this->clearAssignmentCache((string)$assignment['subject_type'], (string)$assignment['subject_id']); + $this->audit((int)$assignment['channel_id'], null, 'assignment_deleted', $actorUserId, 'info', [ + 'assignment_id' => $id, + ]); + + return ['deleted' => true, 'id' => $id]; + } + + public function listDeploymentTargets(): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $row): array => $this->publicDeploymentTarget($row), + $this->selectRows( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id + WHERE t.deleted_at IS NULL + ORDER BY c.slug, FIELD(t.app, 'frontend', 'api'), t.repository" + ) + ); + } + + public function upsertDeploymentTarget(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $id = (int)($input['id'] ?? 0); + $channel = $this->channelFromInput($input); + $app = $this->normalizeApp((string)($input['app'] ?? '')); + $repository = trim((string)($input['repository'] ?? '')); + $branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + if ($repository === '') { + throw new RuntimeException('Repository is required for release deployment targets.'); + } + $normalizedRepository = self::normalizeGithubRepositoryName($repository); + if ($normalizedRepository !== '') { + $repository = $normalizedRepository; + } + + $githubAccess = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => 'latest', + ]); + if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) { + throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.')); + } + + $deployContext = is_array($input['deploy_context'] ?? null) ? $input['deploy_context'] : []; + foreach (['coolify_auto_create', 'coolify_enable_ssl', 'coolify_deploy_now'] as $key) { + if (array_key_exists($key, $input)) { + $deployContext[$key] = $this->toBool($input[$key]); + } + } + foreach (['coolify_domain', 'coolify_public_url', 'coolify_url_name', 'manual_endpoint_host', 'coolify_ports_exposes'] as $key) { + if (array_key_exists($key, $input)) { + $deployContext[$key] = trim((string)$input[$key]); + } + } + if (array_key_exists('endpoint_mode', $input)) { + $mode = strtolower(trim((string)$input['endpoint_mode'])); + $deployContext['endpoint_mode'] = $mode === 'manual' ? 'manual' : 'auto'; + } elseif (!isset($deployContext['endpoint_mode'])) { + $deployContext['endpoint_mode'] = 'auto'; + } + if (array_key_exists('manual_endpoint_port', $input)) { + $port = trim((string)$input['manual_endpoint_port']); + $deployContext['manual_endpoint_port'] = $port; + } + $endpointMode = strtolower(trim((string)($deployContext['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto'; + $deployContext['endpoint_mode'] = $endpointMode; + if ($endpointMode === 'manual') { + $manualHost = self::normalizeEndpointHost($deployContext['manual_endpoint_host'] ?? ''); + if ($manualHost === '') { + throw new RuntimeException('Manual endpoint mode requires a public host.'); + } + $deployContext['manual_endpoint_host'] = $manualHost; + } + if (array_key_exists('manual_endpoint_port', $deployContext)) { + $manualPort = trim((string)$deployContext['manual_endpoint_port']); + if ($manualPort !== '' && (filter_var($manualPort, FILTER_VALIDATE_INT) === false || (int)$manualPort < 1 || (int)$manualPort > 65535)) { + throw new RuntimeException('Manual endpoint port must be between 1 and 65535.'); + } + $deployContext['manual_endpoint_port'] = $manualPort; + } + foreach ([ + 'coolify_project_uuid', + 'project_uuid', + 'coolify_environment_uuid', + 'environment_uuid', + 'coolify_environment_name', + 'environment_name', + 'coolify_github_app_uuid', + 'github_app_uuid', + 'coolify_git_app_uuid', + 'git_app_uuid', + 'coolify_build_pack', + 'build_pack', + ] as $key) { + if (array_key_exists($key, $input)) { + $deployContext[$key] = trim((string)$input[$key]); + } + } + + $payload = [ + 'channel_id' => (int)$channel['id'], + 'app' => $app, + 'coolify_instance_id' => $this->nullablePositiveInt($input['coolify_instance_id'] ?? null), + 'coolify_service_uuid' => trim((string)($input['coolify_service_uuid'] ?? '')) ?: null, + 'repository' => $repository, + 'branch' => $branch, + 'auto_deploy' => $this->toBool($input['auto_deploy'] ?? true) ? 1 : 0, + 'health_url' => trim((string)($input['health_url'] ?? '')) ?: null, + 'deploy_context' => $deployContext, + ]; + + if ($id > 0) { + $this->execute( + "UPDATE release_deployment_targets + SET channel_id = ?, app = ?, coolify_instance_id = ?, coolify_service_uuid = ?, + repository = ?, branch = ?, auto_deploy = ?, health_url = ?, deploy_context_json = ? + WHERE id = ? AND deleted_at IS NULL", + 'isisssissi', + [ + $payload['channel_id'], + $payload['app'], + $payload['coolify_instance_id'], + $payload['coolify_service_uuid'], + $payload['repository'], + $payload['branch'], + $payload['auto_deploy'], + $payload['health_url'], + self::jsonEncode($payload['deploy_context']), + $id, + ] + ); + $targetId = $id; + $action = 'deployment_target_updated'; + } else { + $this->execute( + "INSERT INTO release_deployment_targets ( + channel_id, app, coolify_instance_id, coolify_service_uuid, + repository, branch, auto_deploy, health_url, deploy_context_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'isisssiss', + [ + $payload['channel_id'], + $payload['app'], + $payload['coolify_instance_id'], + $payload['coolify_service_uuid'], + $payload['repository'], + $payload['branch'], + $payload['auto_deploy'], + $payload['health_url'], + self::jsonEncode($payload['deploy_context']), + ] + ); + $targetId = $this->insertId(); + $action = 'deployment_target_created'; + } + + $this->audit((int)$channel['id'], null, $action, $actorUserId, 'info', $payload + [ + 'github_access' => $githubAccess, + ]); + return $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)); + } + + public function deleteDeploymentTarget(int $id, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getDeploymentTarget($id); + $this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$id]); + $this->audit((int)$target['channel_id'], null, 'deployment_target_deleted', $actorUserId, 'warning', [ + 'target_id' => $id, + ]); + return ['deleted' => true, 'id' => $id]; + } + + public function listServiceSets(): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $row): array => $this->publicServiceSet($row), + $this->selectRows( + "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_service_sets s + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE s.deleted_at IS NULL + ORDER BY s.updated_at DESC, s.created_at DESC, s.id DESC" + ) + ); + } + + public function createServiceSet(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $mode = $this->normalizeServiceSetMode((string)($input['mode'] ?? $input['dataset_mode'] ?? 'attach_existing')); + $sourceId = $this->nullablePositiveInt($input['source_service_set_id'] ?? $input['source_id'] ?? null); + if ($mode === 'isolated_stack') { + $sourceId = null; + } + $source = $sourceId !== null ? $this->getServiceSet($sourceId) : null; + $dataSourceId = $mode === 'isolated_stack' + ? null + : $this->nullablePositiveInt($input['data_source_service_set_id'] ?? $input['data_source_id'] ?? null); + $dataSource = $dataSourceId !== null ? $this->getServiceSet($dataSourceId) : null; + $channel = $this->channelFromInputOrDefault($input, $source); + $isBetaChannel = $this->isBetaChannel($channel); + if ($isBetaChannel && $mode !== 'attach_existing') { + throw new RuntimeException('Beta release service sets must use production-shared data services.'); + } + if ($isBetaChannel && $this->serviceSetInputHasExplicitDataTargets($input)) { + throw new RuntimeException('Beta data-only service sets must copy data targets from Stable/Master or leave them production_shared.'); + } + + $frontendTargetId = $this->serviceSetTargetIdFromInput($input, 'frontend', $source); + $apiTargetId = $this->serviceSetTargetIdFromInput($input, 'api', $source); + $dataTargets = []; + $dataTargetSource = $isBetaChannel ? $dataSource : ($dataSource ?? $source); + foreach (self::STACK_DATA_KINDS as $kind) { + $dataTargets[$kind] = $this->serviceSetDataTargetIdFromInput($input, $kind, $dataTargetSource); + } + if ($isBetaChannel) { + $this->assertBetaDataSourceChannel($dataSource); + } + + $name = trim((string)($input['name'] ?? '')); + if ($name === '') { + $name = $source !== null + ? sprintf('%s %s', (string)($source['name'] ?? 'Release service set'), str_replace('_', ' ', $mode)) + : sprintf('%s service set', ucfirst(str_replace('_', ' ', $mode))); + } + + if ($mode === 'isolated_stack') { + $this->assertIsolatedStackTarget($frontendTargetId, 'frontend'); + $this->assertIsolatedStackTarget($apiTargetId, 'api'); + $createDataTargets = $this->toBool( + $input['create_data_targets'] + ?? $input['create_isolated_data_targets'] + ?? $input['create_empty_data_targets'] + ?? true + ); + foreach (self::STACK_DATA_KINDS as $kind) { + if ($dataTargets[$kind] !== null) { + $this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind); + continue; + } + if ($createDataTargets) { + $dataTargets[$kind] = $this->createIsolatedStackDataTarget( + $kind, + $input, + $channel, + $name, + $frontendTargetId, + $apiTargetId, + $actorUserId + ); + } + } + } + + if (!$isBetaChannel && !in_array($mode, ['fresh_empty', 'isolated_stack'], true) && $source === null && $frontendTargetId === null && $apiTargetId === null) { + throw new RuntimeException('Select an existing release deployment or target before creating a reusable service set.'); + } + + $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null); + $metadata['dataset_mode'] = $mode; + $metadata['source_service_set_id'] = $sourceId; + $metadata['data_source_service_set_id'] = $dataSourceId; + if ($dataSource !== null) { + $metadata['data_source_channel_slug'] = (string)($dataSource['channel_slug'] ?? ''); + } + if ($mode === 'attach_existing') { + $metadata['data_policy'] = self::PRODUCTION_DATA_POLICY; + $metadata['data_service_mode'] = self::PRODUCTION_DATA_POLICY; + } + $metadata['replica_integration'] = $this->replicaProvisioningPlan($mode, $dataTargetSource, $dataTargets); + if ($mode === 'fresh_empty') { + $metadata['isolated_empty_services'] = true; + $metadata['production_replication_attached'] = false; + } + if ($mode === 'isolated_stack') { + $metadata['isolated_stack'] = true; + $metadata['isolated_empty_services'] = true; + $metadata['production_replication_attached'] = false; + $metadata['production_code_targets_attached'] = false; + } + + $slug = $this->uniqueServiceSetSlug(self::safeSlug((string)($input['slug'] ?? $name))); + $status = $isBetaChannel ? 'ready' : $this->serviceSetStatus($mode, $frontendTargetId, $apiTargetId, $dataTargets); + $stackComplete = $isBetaChannel || ($frontendTargetId !== null + && $apiTargetId !== null + && (!in_array(null, $dataTargets, true) || $mode === 'attach_existing')); + $health = [ + 'status' => $status, + 'stack_complete' => $stackComplete, + 'data_policy' => $this->serviceSetDataPolicy([ + 'mode' => $mode, + 'metadata_json' => self::jsonEncode($metadata), + ]), + 'checked_at' => date('c'), + ]; + + $this->execute( + "INSERT INTO release_service_sets ( + channel_id, name, slug, mode, source_service_set_id, + frontend_target_id, api_target_id, + database_coolify_target_id, redis_coolify_target_id, minio_coolify_target_id, + status, health_json, metadata_json, actor_user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'isssiiiiiisssi', + [ + (int)$channel['id'], + substr($name, 0, 128), + $slug, + $mode, + $sourceId, + $frontendTargetId, + $apiTargetId, + $dataTargets['database'], + $dataTargets['redis'], + $dataTargets['minio'], + $status, + self::jsonEncode($health), + self::jsonEncode($metadata), + $actorUserId, + ] + ); + $id = $this->insertId(); + + $this->audit((int)$channel['id'], null, 'service_set_created', $actorUserId, 'info', [ + 'service_set_id' => $id, + 'mode' => $mode, + 'source_service_set_id' => $sourceId, + 'data_source_service_set_id' => $dataSourceId, + 'data_policy' => $this->serviceSetDataPolicy($this->getServiceSet($id)), + 'data_targets' => $dataTargets, + ]); + + return $this->publicServiceSet($this->getServiceSet($id)); + } + + public function deleteServiceSet(int $id, array $input = [], ?int $actorUserId = null): array + { + $this->ensureSchema(); + $serviceSet = $this->getServiceSet($id); + if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') { + throw new RuntimeException('Only isolated stack service sets can be removed from Release Manager.'); + } + if ($this->serviceSetIsActive($id)) { + throw new RuntimeException('The active release service set cannot be removed.'); + } + + $frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null); + $apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null); + $dataTargetIds = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $dataTargetIds[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null); + } + + $this->execute( + "UPDATE release_bundles + SET status = 'removed', deleted_at = NOW() + WHERE service_set_id = ? AND deleted_at IS NULL", + 'i', + [$id] + ); + $this->execute( + "UPDATE release_deployments + SET status = 'removed' + WHERE service_set_id = ?", + 'i', + [$id] + ); + $this->execute( + "UPDATE release_service_sets + SET status = 'removed', deleted_at = NOW(), actor_user_id = ? + WHERE id = ?", + 'ii', + [$actorUserId, $id] + ); + + foreach ([$frontendTargetId, $apiTargetId] as $targetId) { + if ($this->isolatedDeploymentTargetCanBeForgotten($targetId, $id)) { + $this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]); + } + } + foreach ($dataTargetIds as $targetId) { + if ($this->isolatedCoolifyTargetCanBeForgotten($targetId, $id)) { + $this->execute('UPDATE coolify_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]); + } + } + + $this->audit((int)$serviceSet['channel_id'], null, 'service_set_removed', $actorUserId, 'warning', [ + 'service_set_id' => $id, + 'mode' => 'isolated_stack', + 'provider_resources_deleted' => false, + 'frontend_target_id' => $frontendTargetId, + 'api_target_id' => $apiTargetId, + 'data_target_ids' => $dataTargetIds, + ]); + + return [ + 'id' => $id, + 'removed' => true, + 'provider_resources_deleted' => false, + ]; + } + + public function completeIsolatedStackDataServices(int $serviceSetId, array $input = [], ?int $actorUserId = null): array + { + $this->ensureSchema(); + $serviceSet = $this->getServiceSet($serviceSetId); + if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') { + throw new RuntimeException('Only isolated stack service sets can create isolated data services.'); + } + + $frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null); + $apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null); + $this->assertIsolatedStackTarget($frontendTargetId, 'frontend', true); + $this->assertIsolatedStackTarget($apiTargetId, 'api', true); + + $channel = $this->getChannel((int)$serviceSet['channel_id']); + $name = trim((string)($input['name'] ?? $serviceSet['name'] ?? 'Isolated stack')); + $dataTargets = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $dataTargets[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null); + if ($dataTargets[$kind] !== null) { + $this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind); + continue; + } + + $dataTargets[$kind] = $this->createIsolatedStackDataTarget( + $kind, + $input, + $channel, + $name, + $frontendTargetId, + $apiTargetId, + $actorUserId + ); + } + + $metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null); + $metadata['dataset_mode'] = 'isolated_stack'; + $metadata['isolated_stack'] = true; + $metadata['isolated_empty_services'] = true; + $metadata['production_replication_attached'] = false; + $metadata['production_code_targets_attached'] = false; + $metadata['replica_integration'] = $this->replicaProvisioningPlan('isolated_stack', null, $dataTargets); + + $status = $this->serviceSetStatus('isolated_stack', $frontendTargetId, $apiTargetId, $dataTargets); + $health = [ + 'status' => $status, + 'stack_complete' => $frontendTargetId !== null && $apiTargetId !== null && !in_array(null, $dataTargets, true), + 'checked_at' => date('c'), + ]; + + $this->execute( + "UPDATE release_service_sets + SET database_coolify_target_id = ?, redis_coolify_target_id = ?, minio_coolify_target_id = ?, + status = ?, health_json = ?, metadata_json = ?, actor_user_id = ? + WHERE id = ?", + 'iiisssii', + [ + $dataTargets['database'], + $dataTargets['redis'], + $dataTargets['minio'], + $status, + self::jsonEncode($health), + self::jsonEncode($metadata), + $actorUserId, + $serviceSetId, + ] + ); + + $this->audit((int)$channel['id'], null, 'isolated_stack_data_services_created', $actorUserId, 'info', [ + 'service_set_id' => $serviceSetId, + 'data_targets' => $dataTargets, + ]); + + return $this->publicServiceSet($this->getServiceSet($serviceSetId)); + } + + public function listBundles(int $limit = 50): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + $limit = max(1, min(250, $limit)); + return array_map( + fn(array $row): array => $this->publicBundle($row), + $this->selectRows( + "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug + FROM release_bundles b + INNER JOIN release_channels c ON c.id = b.channel_id + INNER JOIN release_service_sets s ON s.id = b.service_set_id + WHERE b.deleted_at IS NULL + ORDER BY b.created_at DESC, b.id DESC + LIMIT $limit" + ) + ); + } + + public function createBundle(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null); + if ($serviceSetId === null) { + throw new RuntimeException('A release service set is required before creating a bundle.'); + } + $serviceSet = $this->getServiceSet($serviceSetId); + $channel = $this->channelFromInputOrDefault($input, $serviceSet); + if ($this->channelUsesProductionServices($channel)) { + throw new RuntimeException('Beta release channel uses production services and does not create separate release bundles.'); + } + $this->assertBetaProductionDataPolicy($channel, $serviceSet); + + $versionLabel = trim((string)($input['version_label'] ?? '')); + if ($versionLabel === '') { + $versionLabel = sprintf('%s-bundle-%s', (string)($channel['slug'] ?? 'release'), date('Ymd-His')); + } + + $frontendTarget = $this->nullableDeploymentTarget((int)($serviceSet['frontend_target_id'] ?? 0) ?: null); + $apiTarget = $this->nullableDeploymentTarget((int)($serviceSet['api_target_id'] ?? 0) ?: null); + $frontend = $this->bundleAppInput($input, 'frontend', $frontendTarget, $versionLabel); + $api = $this->bundleAppInput($input, 'api', $apiTarget, $versionLabel); + + $frontendVersionId = $this->createBundleVersion($frontend, 'frontend'); + $apiVersionId = $this->createBundleVersion($api, 'api'); + + $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null); + $metadata['service_set_id'] = $serviceSetId; + $metadata['stack_services'] = array_merge(['frontend', 'api'], self::STACK_DATA_KINDS); + $metadata['promotion_policy'] = 'attach_code_and_service_set_only'; + + $this->execute( + "INSERT INTO release_bundles ( + channel_id, service_set_id, version_label, + frontend_version_id, api_version_id, + frontend_repository, frontend_branch, frontend_commit_sha, + api_repository, api_branch, api_commit_sha, + metadata_json, actor_user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'iisiisssssssi', + [ + (int)$channel['id'], + $serviceSetId, + $versionLabel, + $frontendVersionId, + $apiVersionId, + $frontend['repository'], + $frontend['branch'], + $frontend['commit_sha'], + $api['repository'], + $api['branch'], + $api['commit_sha'], + self::jsonEncode($metadata), + $actorUserId, + ] + ); + $id = $this->insertId(); + + $this->audit((int)$channel['id'], null, 'bundle_created', $actorUserId, 'info', [ + 'bundle_id' => $id, + 'service_set_id' => $serviceSetId, + 'frontend_repository' => $frontend['repository'], + 'api_repository' => $api['repository'], + ]); + + return $this->publicBundle($this->getBundle($id)); + } + + public function deployBundle(int $bundleId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $bundle = $this->getBundle($bundleId); + $serviceSet = $this->getServiceSet((int)$bundle['service_set_id']); + $channel = $this->getChannel((int)$bundle['channel_id']); + if ($this->channelUsesProductionServices($channel)) { + throw new RuntimeException('Beta release channel uses production services and does not deploy separate release bundles.'); + } + $this->assertBetaProductionDataPolicy($channel, $serviceSet); + $results = []; + $deploymentIds = ['frontend' => null, 'api' => null]; + + foreach (['frontend', 'api'] as $app) { + $versionId = $this->nullablePositiveInt($bundle[$app . '_version_id'] ?? null); + if ($versionId === null) { + $results[$app] = ['status' => 'skipped', 'message' => 'No release version is attached to this app.']; + continue; + } + + $deployment = $this->startDeployment([ + 'channel_id' => (int)$bundle['channel_id'], + 'target_id' => $this->nullablePositiveInt($serviceSet[$app . '_target_id'] ?? null), + 'version_id' => $versionId, + 'service_set_id' => (int)$bundle['service_set_id'], + 'bundle_id' => $bundleId, + 'deployment_kind' => 'bundle_member', + 'app' => $app, + 'repository' => $bundle[$app . '_repository'] ?? '', + 'branch' => $bundle[$app . '_branch'] ?? self::DEFAULT_BRANCH, + 'commit_mode' => trim((string)($bundle[$app . '_commit_sha'] ?? '')) !== '' ? 'specific' : 'latest', + 'commit_sha' => $bundle[$app . '_commit_sha'] ?? '', + 'version_label' => $bundle['version_label'] ?? null, + ], $actorUserId); + $deploymentIds[$app] = (int)($deployment['id'] ?? 0) ?: null; + $results[$app] = $deployment; + } + + $statuses = array_map(static fn(array $result): string => strtolower((string)($result['status'] ?? 'unknown')), $results); + $status = in_array('failed', $statuses, true) + ? 'failed' + : (count(array_intersect($statuses, ['queued', 'deploying', 'unknown', 'skipped'])) > 0 ? 'deploying' : 'deployed'); + + $this->execute( + "UPDATE release_bundles + SET status = ?, frontend_deployment_id = ?, api_deployment_id = ?, + deployment_result_json = ?, deployed_at = CASE WHEN ? IN ('deployed', 'deploying') THEN NOW() ELSE deployed_at END + WHERE id = ?", + 'siissi', + [ + $status, + $deploymentIds['frontend'], + $deploymentIds['api'], + self::jsonEncode(self::redactPayload($results)), + $status, + $bundleId, + ] + ); + + $this->audit((int)$bundle['channel_id'], null, 'bundle_deployed', $actorUserId, $status === 'failed' ? 'error' : 'info', [ + 'bundle_id' => $bundleId, + 'service_set_id' => (int)$bundle['service_set_id'], + 'status' => $status, + ]); + + return $this->publicBundle($this->getBundle($bundleId)); + } + + public function promoteBundle(int $bundleId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $bundle = $this->getBundle($bundleId); + $status = strtolower((string)($bundle['status'] ?? '')); + if (!in_array($status, ['deployed', 'active', 'promoted'], true)) { + throw new RuntimeException('Only deployed release bundles can be promoted.'); + } + + $channelId = (int)$bundle['channel_id']; + $channel = $this->getChannel($channelId); + $serviceSetId = (int)$bundle['service_set_id']; + $serviceSet = $this->getServiceSet($serviceSetId); + if ($this->channelUsesProductionServices($channel)) { + throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.'); + } + $this->assertBetaProductionDataPolicy($channel, $serviceSet); + $this->assertReleaseGatePassedForPromotion( + $channelId, + (string)($bundle['frontend_commit_sha'] ?? ''), + null, + 'frontend' + ); + if (trim((string)($bundle['api_commit_sha'] ?? '')) !== '') { + $this->assertReleaseGatePassedForPromotion( + $channelId, + (string)$bundle['api_commit_sha'], + null, + 'api' + ); + } + $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null); + $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null); + $deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null) + ?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null); + + $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]); + $this->execute( + "UPDATE release_bundles + SET status = 'superseded' + WHERE channel_id = ? AND id <> ? AND status = 'promoted' AND deleted_at IS NULL", + 'ii', + [$channelId, $bundleId] + ); + $this->execute( + "UPDATE release_deployments + SET status = 'superseded', active_channel_app_key = NULL + WHERE channel_id = ? AND bundle_id IS NOT NULL AND bundle_id <> ? AND status = 'active'", + 'ii', + [$channelId, $bundleId] + ); + $this->execute( + "INSERT INTO release_channel_versions ( + channel_id, frontend_version_id, api_version_id, deployment_id, + service_set_id, bundle_id, actor_user_id, active + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + 'iiiiiii', + [$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $serviceSetId, $bundleId, $actorUserId] + ); + + $this->execute( + "UPDATE release_bundles SET status = 'promoted', promoted_at = NOW() WHERE id = ?", + 'i', + [$bundleId] + ); + foreach ($this->selectRows('SELECT id, app FROM release_deployments WHERE bundle_id = ?', 'i', [$bundleId]) as $bundleDeployment) { + $bundleDeploymentId = (int)($bundleDeployment['id'] ?? 0); + $app = (string)($bundleDeployment['app'] ?? ''); + if ($bundleDeploymentId > 0 && in_array($app, self::APPS, true)) { + $this->activateDeploymentForChannelApp($bundleDeploymentId, $channelId, $app); + } + } + foreach ([$frontendVersionId, $apiVersionId] as $versionId) { + if ($versionId !== null) { + $this->execute( + "UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", + 'i', + [$versionId] + ); + } + } + + $this->audit($channelId, $deploymentId, 'bundle_promoted', $actorUserId, 'info', [ + 'bundle_id' => $bundleId, + 'service_set_id' => $serviceSetId, + 'data_promotion' => false, + 'replica_failover' => false, + ]); + + return [ + 'channel' => $this->publicChannel($this->getChannel($channelId)), + 'versions' => $this->currentVersionsForChannel($channelId), + 'service_set' => $this->publicServiceSet($this->getServiceSet($serviceSetId)), + 'bundle' => $this->publicBundle($this->getBundle($bundleId)), + ]; + } + + public function setChannelBundle(int $channelId, array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $channel = $this->getChannel($channelId); + $bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null); + if ($bundleId === null) { + throw new RuntimeException('A release bundle is required.'); + } + + $bundle = $this->getBundle($bundleId); + if ((int)$bundle['channel_id'] !== (int)$channel['id']) { + throw new RuntimeException('Release bundle does not belong to this channel.'); + } + + $status = strtolower((string)($bundle['status'] ?? '')); + if (!in_array($status, ['deployed', 'promoted', 'active'], true)) { + throw new RuntimeException('Only deployed release bundles can be set on a channel.'); + } + + $previous = $this->currentChannelVersionRow($channelId); + $result = $this->promoteBundle($bundleId, $actorUserId); + $this->audit($channelId, null, 'channel_bundle_set', $actorUserId, 'info', [ + 'bundle_id' => $bundleId, + 'previous_bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null), + ]); + + return $result; + } + + public function listDeployments(int $limit = 50): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + $limit = max(1, min(250, $limit)); + return array_map( + fn(array $row): array => $this->publicDeployment($row), + $this->selectRows( + "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url + FROM release_deployments d + INNER JOIN release_channels c ON c.id = d.channel_id + LEFT JOIN release_versions v ON v.id = d.version_id + ORDER BY d.created_at DESC + LIMIT $limit" + ) + ); + } + + public function startDeployment(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $channel = $this->channelFromInput($input); + if ($this->channelUsesProductionServices($channel)) { + throw new RuntimeException('Beta release channel uses production services and cannot deploy separate frontend or API services.'); + } + $app = $this->normalizeApp((string)($input['app'] ?? '')); + $target = $this->deploymentTargetFromInput($input, (int)$channel['id'], $app); + + $repository = trim((string)($input['repository'] ?? $target['repository'] ?? '')); + $branch = trim((string)($input['branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + $normalizedRepository = self::normalizeGithubRepositoryName($repository); + if ($normalizedRepository !== '') { + $repository = $normalizedRepository; + } + $rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? '')); + $commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha); + $commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null; + $githubAccess = null; + if ($repository !== '') { + $githubAccess = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'commit_mode' => $commitMode, + ]); + if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) { + throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.')); + } + if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) { + $commitSha = (string)$githubAccess['commit_sha']; + $branch = (string)($githubAccess['branch'] ?? $branch); + } + } + $versionLabel = trim((string)($input['version_label'] ?? $input['tag'] ?? $commitSha ?? date('Ymd-His'))) ?: date('Ymd-His'); + $targetPublicUrl = is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null; + $deployedUrl = $this->normalizeReleasePublicBaseUrl( + $input['deployed_url'] ?? $targetPublicUrl ?? $target['health_url'] ?? null, + $app + ); + + $versionId = $this->nullablePositiveInt($input['version_id'] ?? null); + if ($versionId !== null) { + $version = $this->getVersion($versionId); + if ((string)($version['app'] ?? '') !== $app) { + throw new RuntimeException('Release bundle version does not match the deployment app.'); + } + $this->execute( + "UPDATE release_versions + SET repository = COALESCE(NULLIF(?, ''), repository), + branch = COALESCE(NULLIF(?, ''), branch), + commit_sha = COALESCE(?, commit_sha), + version_label = COALESCE(NULLIF(?, ''), version_label), + deployed_url = COALESCE(?, deployed_url), + status = 'deploying' + WHERE id = ?", + 'sssssi', + [$repository, $branch, $commitSha, $versionLabel, $deployedUrl, $versionId] + ); + } else { + $versionId = $this->createVersion([ + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'tag' => trim((string)($input['tag'] ?? '')) ?: null, + 'version_label' => $versionLabel, + 'build_url' => trim((string)($input['build_url'] ?? '')) ?: null, + 'artifact_url' => trim((string)($input['artifact_url'] ?? '')) ?: null, + 'deployed_url' => $deployedUrl, + 'status' => 'deploying', + 'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : [], + ]); + } + + $requestedPayload = self::redactPayload($input); + if (is_array($requestedPayload)) { + $requestedPayload['commit_mode'] = $commitMode; + $requestedPayload['github_access'] = $githubAccess; + } + $targetId = isset($target['id']) ? (int)$target['id'] : null; + $serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null); + $bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null); + $deploymentKind = self::safeIdentifier((string)($input['deployment_kind'] ?? 'single_app'), 32) ?: 'single_app'; + $this->execute( + "INSERT INTO release_deployments ( + channel_id, target_id, version_id, service_set_id, bundle_id, deployment_kind, app, provider, repository, branch, + commit_sha, status, actor_user_id, requested_payload_json, started_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())", + 'iiiiisssssis', + [ + (int)$channel['id'], + $targetId, + $versionId, + $serviceSetId, + $bundleId, + $deploymentKind, + $app, + $repository, + $branch, + $commitSha, + $actorUserId, + self::jsonEncode($requestedPayload), + ] + ); + $deploymentId = $this->insertId(); + + try { + $result = ['message' => 'Deployment recorded; no Coolify service target is configured.']; + $status = 'queued'; + if ($target !== null && !empty($target['coolify_instance_id'])) { + $coolifyTarget = array_replace($target, [ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha ?? '', + ]); + $result = $this->deployCoolifyReleaseTarget($coolifyTarget); + $status = 'deployed'; + } + $effectiveDeployedUrl = $this->normalizeReleasePublicBaseUrl($result['public_url'] ?? $deployedUrl, $app); + + $this->execute( + "UPDATE release_deployments + SET status = ?, result_json = ?, deployment_url = ?, completed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE NULL END + WHERE id = ?", + 'ssssi', + [$status, self::jsonEncode(self::redactPayload($result)), $effectiveDeployedUrl, $status, $deploymentId] + ); + $this->execute( + "UPDATE release_versions + SET status = ?, + deployed_url = COALESCE(?, deployed_url), + deployed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE deployed_at END + WHERE id = ?", + 'sssi', + [$status === 'deployed' ? 'deployed' : 'deploying', $effectiveDeployedUrl, $status, $versionId] + ); + $this->audit((int)$channel['id'], $deploymentId, 'deployment_started', $actorUserId, 'info', [ + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'commit_mode' => $commitMode, + 'github_access_status' => $githubAccess['status'] ?? null, + 'status' => $status, + ]); + } catch (Throwable $throwable) { + $failureSummary = self::deploymentFailureSummary($throwable, [ + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'target_id' => $targetId, + 'coolify_instance_id' => is_array($target) ? ($target['coolify_instance_id'] ?? null) : null, + 'coolify_service_uuid' => is_array($target) ? ($target['coolify_service_uuid'] ?? null) : null, + ]); + $failureResult = [ + 'message' => 'Deployment failed before promotion. A successful deployment is required before promotion.', + 'failure_summary' => $failureSummary, + ]; + $this->execute( + "UPDATE release_deployments SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW() WHERE id = ?", + 'ssi', + [self::jsonEncode($failureResult), $throwable->getMessage(), $deploymentId] + ); + $this->execute("UPDATE release_versions SET status = 'failed' WHERE id = ?", 'i', [$versionId]); + $this->audit((int)$channel['id'], $deploymentId, 'deployment_failed', $actorUserId, 'error', [ + 'error' => $throwable->getMessage(), + 'failure_summary' => $failureSummary, + 'app' => $app, + ]); + } + + return $this->publicDeployment($this->getDeployment($deploymentId)); + } + + public function promoteDeployment(int $deploymentId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $deployment = $this->getDeployment($deploymentId); + if (!self::deploymentCanBePromoted((string)($deployment['status'] ?? ''))) { + throw new RuntimeException(self::deploymentPromotionBlockedReason($deployment)); + } + $versionId = (int)($deployment['version_id'] ?? 0); + if ($versionId <= 0) { + throw new RuntimeException('Deployment has no release version to promote.'); + } + + $channelId = (int)$deployment['channel_id']; + if ($this->channelUsesProductionServices($this->getChannel($channelId))) { + throw new RuntimeException('Beta release channel uses production services and does not promote separate deployments.'); + } + $this->assertReleaseGatePassedForPromotion( + $channelId, + (string)($deployment['commit_sha'] ?? ''), + null, + (string)($deployment['app'] ?? '') + ); + $current = $this->currentChannelVersionRow($channelId); + $frontendVersionId = (int)($current['frontend_version_id'] ?? 0) ?: null; + $apiVersionId = (int)($current['api_version_id'] ?? 0) ?: null; + if ((string)$deployment['app'] === 'frontend') { + $frontendVersionId = $versionId; + } else { + $apiVersionId = $versionId; + } + + $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]); + $this->execute( + "INSERT INTO release_channel_versions (channel_id, frontend_version_id, api_version_id, deployment_id, actor_user_id, active) + VALUES (?, ?, ?, ?, ?, 1)", + 'iiiii', + [$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $actorUserId] + ); + $this->activateDeploymentForChannelApp($deploymentId, $channelId, (string)$deployment['app']); + $this->execute("UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", 'i', [$versionId]); + + $this->audit($channelId, $deploymentId, 'deployment_promoted', $actorUserId, 'info', [ + 'app' => $deployment['app'], + 'version_id' => $versionId, + ]); + + return [ + 'channel' => $this->publicChannel($this->getChannel($channelId)), + 'versions' => $this->currentVersionsForChannel($channelId), + 'deployment' => $this->publicDeployment($this->getDeployment($deploymentId)), + ]; + } + + public function rollbackChannel(int $channelId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $channel = $this->getChannel($channelId); + $previous = $this->selectOne( + "SELECT * FROM release_channel_versions + WHERE channel_id = ? AND active = 0 + ORDER BY activated_at DESC, id DESC + LIMIT 1", + 'i', + [$channelId] + ); + if ($previous === null) { + throw new RuntimeException('No previous release version exists for this channel.'); + } + + $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]); + $this->execute( + "INSERT INTO release_channel_versions ( + channel_id, frontend_version_id, api_version_id, deployment_id, + service_set_id, bundle_id, actor_user_id, active + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + 'iiiiiii', + [ + $channelId, + (int)($previous['frontend_version_id'] ?? 0) ?: null, + (int)($previous['api_version_id'] ?? 0) ?: null, + (int)($previous['deployment_id'] ?? 0) ?: null, + (int)($previous['service_set_id'] ?? 0) ?: null, + (int)($previous['bundle_id'] ?? 0) ?: null, + $actorUserId, + ] + ); + foreach (self::APPS as $app) { + $versionId = $this->nullablePositiveInt($previous[$app . '_version_id'] ?? null); + if ($versionId === null) { + continue; + } + $deployment = $this->selectOne( + "SELECT id FROM release_deployments + WHERE channel_id = ? AND app = ? AND version_id = ? + ORDER BY completed_at DESC, id DESC + LIMIT 1", + 'isi', + [$channelId, $app, $versionId] + ); + if ($deployment !== null) { + $this->activateDeploymentForChannelApp((int)$deployment['id'], $channelId, $app); + } + $this->execute( + "UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", + 'i', + [$versionId] + ); + } + + $this->audit($channelId, (int)($previous['deployment_id'] ?? 0) ?: null, 'channel_rolled_back', $actorUserId, 'warning', [ + 'previous_channel_version_id' => $previous['id'] ?? null, + 'frontend_version_id' => $this->nullablePositiveInt($previous['frontend_version_id'] ?? null), + 'api_version_id' => $this->nullablePositiveInt($previous['api_version_id'] ?? null), + 'service_set_id' => $this->nullablePositiveInt($previous['service_set_id'] ?? null), + 'bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null), + 'public_smoke_required' => true, + ]); + + return [ + 'channel' => $this->publicChannel($channel), + 'versions' => $this->currentVersionsForChannel($channelId), + ]; + } + + public function setReplayTarget(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $targetType = strtolower(trim((string)($input['target_type'] ?? ''))); + if (!in_array($targetType, ['user', 'subuser', 'customer', 'channel'], true)) { + throw new RuntimeException('Invalid replay target type.'); + } + + $targetId = trim((string)($input['target_id'] ?? '')) ?: null; + $channel = null; + if ($targetType === 'channel' || isset($input['channel_id']) || isset($input['channel_slug'])) { + $channel = $this->channelFromInput($input); + $targetId = $targetId ?: (string)$channel['slug']; + } + + $captureLevel = $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'full_redacted')); + $enabled = $this->toBool($input['enabled'] ?? true) ? 1 : 0; + $expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null); + + $this->execute( + "INSERT INTO release_replay_targets (target_type, target_id, channel_id, capture_level, enabled, expires_at, actor_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?)", + 'ssisisi', + [$targetType, $targetId, $channel['id'] ?? null, $captureLevel, $enabled, $expiresAt, $actorUserId] + ); + + $id = $this->insertId(); + $this->audit($channel !== null ? (int)$channel['id'] : null, null, 'replay_target_created', $actorUserId, 'warning', [ + 'target_type' => $targetType, + 'target_id' => $targetId, + 'capture_level' => $captureLevel, + 'enabled' => (bool)$enabled, + ]); + + return $this->selectOne('SELECT * FROM release_replay_targets WHERE id = ?', 'i', [$id]) ?? []; + } + + public function ingestTimelineEvents(array $events, array $context = [], bool $ensureSchema = true): array + { + if ($ensureSchema) { + $this->ensureSchema(); + } + if ($events === [] || !isset($events[0])) { + $events = [$events]; + } + + $traceId = self::safeIdentifier((string)($context['trace_id'] ?? $this->requestTraceId()), 64); + if ($traceId === '') { + $traceId = $this->requestTraceId(); + } + + $principalContext = $this->currentPrincipalContext(); + $context = array_replace($principalContext, array_filter($context, static fn(mixed $value): bool => $value !== null && $value !== '')); + + $channelSlug = self::safeSlug((string)($context['channel_slug'] ?? $context['release_channel'] ?? '')); + $channel = $channelSlug !== '' ? $this->findChannelBySlug($channelSlug) : null; + if ($channel === null) { + $channel = $this->resolveChannel($context); + } + if (empty($context['route_path']) && empty($context['route'])) { + foreach (array_reverse($events) as $eventForRoute) { + if (!is_array($eventForRoute)) { + continue; + } + $route = trim((string)($eventForRoute['route_path'] ?? $eventForRoute['route'] ?? '')); + if ($route !== '') { + $context['route_path'] = $route; + break; + } + } + } + $sessionId = $this->timelineSessionId($traceId, $context, $channel); + $accepted = 0; + + foreach ($events as $event) { + if (!is_array($event)) { + continue; + } + $eventType = self::safeIdentifier((string)($event['type'] ?? $event['event_type'] ?? 'event'), 64) ?: 'event'; + $severity = self::safeIdentifier((string)($event['severity'] ?? 'info'), 16) ?: 'info'; + $payload = self::redactPayload($event['payload'] ?? $event); + $occurredAt = $this->normalizeDateTime($event['occurred_at'] ?? null) ?? date('Y-m-d H:i:s'); + + $this->execute( + "INSERT INTO release_timeline_events ( + timeline_session_id, trace_id, event_type, severity, module_key, + route_path, component, request_id, occurred_at, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'isssssssss', + [ + $sessionId, + $traceId, + $eventType, + $severity, + self::safeIdentifier((string)($event['module_key'] ?? ''), 64) ?: null, + trim((string)($event['route'] ?? $event['route_path'] ?? '')) ?: null, + trim((string)($event['component'] ?? '')) ?: null, + trim((string)($event['request_id'] ?? '')) ?: null, + $occurredAt, + self::jsonEncode($payload), + ] + ); + $accepted++; + } + + return ['accepted' => $accepted, 'trace_id' => $traceId, 'timeline_session_id' => $sessionId]; + } + + public function searchTimeline(array $filters = []): array + { + $this->ensureSchema(); + $this->cleanupExpiredReplayData(); + $types = ''; + $params = []; + $where = ['1 = 1']; + + foreach ([ + 'trace_id' => 'e.trace_id', + 'event_type' => 'e.event_type', + 'severity' => 'e.severity', + 'module_key' => 'e.module_key', + 'channel_slug' => 's.channel_slug', + 'principal_type' => 's.principal_type', + 'principal_id' => 's.principal_id', + ] as $filterKey => $column) { + $value = trim((string)($filters[$filterKey] ?? '')); + if ($value === '') { + continue; + } + $where[] = "$column = ?"; + $types .= 's'; + $params[] = $value; + } + + if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) { + $where[] = 's.customer_number = ?'; + $types .= 'i'; + $params[] = (int)$filters['customer_number']; + } + + $limit = max(1, min(500, (int)($filters['limit'] ?? 100))); + $rows = $this->selectRows( + "SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug + FROM release_timeline_events e + LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id + WHERE " . implode(' AND ', $where) . " + ORDER BY e.occurred_at DESC, e.id DESC + LIMIT $limit", + $types, + $params + ); + + return array_map(fn(array $row): array => $this->publicTimelineEvent($row), $rows); + } + + public function listTimelineSessions(array $filters = []): array + { + $this->ensureSchema(); + $this->cleanupExpiredReplayData(); + + $types = ''; + $params = []; + $where = ['1 = 1']; + + foreach ([ + 'trace_id' => 's.trace_id', + 'principal_type' => 's.principal_type', + 'principal_id' => 's.principal_id', + 'device_type' => 's.device_type', + 'channel_slug' => 's.channel_slug', + 'frontend_version' => 's.frontend_version_label', + 'api_version' => 's.api_version_label', + 'event_type' => 'e.event_type', + 'severity' => 'e.severity', + 'module_key' => 'e.module_key', + ] as $filterKey => $column) { + $value = trim((string)($filters[$filterKey] ?? '')); + if ($value === '') { + continue; + } + $where[] = "$column = ?"; + $types .= 's'; + $params[] = $value; + } + + if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) { + $where[] = 's.customer_number = ?'; + $types .= 'i'; + $params[] = (int)$filters['customer_number']; + } + + foreach (['date_from' => '>=', 'date_to' => '<='] as $filterKey => $operator) { + $date = $this->normalizeDateTime($filters[$filterKey] ?? null); + if ($date === null) { + continue; + } + $where[] = "s.last_seen_at $operator ?"; + $types .= 's'; + $params[] = $date; + } + + $hasErrorReport = $this->toBool($filters['has_error_report'] ?? false); + $hasErrorReportTable = $this->tableExists('error_reports'); + if ($hasErrorReport && $hasErrorReportTable) { + $where[] = 'EXISTS (SELECT 1 FROM error_reports er_filter WHERE er_filter.release_trace_id = s.trace_id)'; + } elseif ($hasErrorReport) { + $where[] = '1 = 0'; + } + + $errorReportCountSelect = $hasErrorReportTable + ? "(SELECT COUNT(*) FROM error_reports er_count WHERE er_count.release_trace_id = s.trace_id) AS error_report_count" + : '0 AS error_report_count'; + + $limit = max(1, min(500, (int)($filters['limit'] ?? 100))); + $rows = $this->selectRows( + "SELECT + s.*, + COUNT(e.id) AS event_count, + SUM(CASE WHEN e.severity = 'error' THEN 1 ELSE 0 END) AS error_count, + MIN(e.occurred_at) AS first_event_at, + MAX(e.occurred_at) AS last_event_at, + GROUP_CONCAT(DISTINCT e.module_key ORDER BY e.module_key SEPARATOR ',') AS module_keys, + $errorReportCountSelect + FROM release_timeline_sessions s + LEFT JOIN release_timeline_events e ON e.timeline_session_id = s.id + WHERE " . implode(' AND ', $where) . " + GROUP BY s.id + ORDER BY COALESCE(MAX(e.occurred_at), s.last_seen_at) DESC, s.id DESC + LIMIT $limit", + $types, + $params + ); + + return array_map(fn(array $row): array => $this->publicTimelineSession($row), $rows); + } + + public function timelineSessionDetail(string $traceId): array + { + $this->ensureSchema(); + $this->cleanupExpiredReplayData(); + + $traceId = self::safeIdentifier($traceId, 64); + if ($traceId === '') { + throw new RuntimeException('Invalid timeline trace id.'); + } + + $session = $this->selectOne( + 'SELECT * FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1', + 's', + [$traceId] + ); + if ($session === null) { + throw new RuntimeException('Timeline session not found.'); + } + + $events = $this->selectRows( + "SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug + FROM release_timeline_events e + LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id + WHERE e.trace_id = ? + ORDER BY e.occurred_at ASC, e.id ASC", + 's', + [$traceId] + ); + + $channel = isset($session['channel_id']) ? $this->selectOne('SELECT * FROM release_channels WHERE id = ? LIMIT 1', 'i', [(int)$session['channel_id']]) : null; + + return [ + 'session' => $this->publicTimelineSession($session), + 'events' => array_map(fn(array $row): array => $this->publicTimelineEvent($row), $events), + 'error_reports' => $this->timelineErrorReports($traceId), + 'release' => $this->timelineReleaseContext($session, $channel), + ]; + } + + public function handleGithubWebhook(array $headers, string $rawBody): array + { + $this->ensureSchema(); + $secret = (string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', ''); + $signature = self::headerValue($headers, 'X-Hub-Signature-256'); + if (!self::verifyGithubSignature($secret, $rawBody, $signature)) { + throw new RuntimeException('Invalid GitHub webhook signature.'); + } + + $event = self::headerValue($headers, 'X-GitHub-Event') ?: 'unknown'; + $payload = json_decode($rawBody, true); + if (!is_array($payload)) { + throw new RuntimeException('Invalid GitHub webhook JSON payload.'); + } + + if ($event !== 'push') { + $this->audit(null, null, 'github_webhook_ignored', null, 'info', ['event' => $event]); + return ['event' => $event, 'deployments' => [], 'ignored' => true]; + } + + $repository = (string)($payload['repository']['full_name'] ?? $payload['repository']['name'] ?? ''); + $branch = preg_replace('#^refs/heads/#', '', (string)($payload['ref'] ?? '')); + $commitSha = (string)($payload['after'] ?? ''); + if ($repository === '' || $branch === '' || $commitSha === '') { + throw new RuntimeException('GitHub push payload is missing repository, branch, or commit.'); + } + + $normalizedRepository = self::normalizeGithubRepositoryName($repository); + if ($normalizedRepository !== '') { + $repository = $normalizedRepository; + } + + $mappedChannelSlug = self::channelSlugForRoute($branch); + $mappedChannel = $this->findChannelBySlug($mappedChannelSlug); + $mappedApp = ''; + if ($repository === self::defaultRepositoryForApp('frontend')) { + $mappedApp = 'frontend'; + } elseif ($repository === self::defaultRepositoryForApp('api')) { + $mappedApp = 'api'; + } + + $targets = $this->selectRows( + "SELECT * FROM release_deployment_targets + WHERE deleted_at IS NULL AND auto_deploy = 1 AND repository = ? AND branch = ?", + 'ss', + [$repository, $branch] + ); + + $autoSyncEvents = []; + foreach ($targets as $target) { + $autoSyncEvents[] = $this->publicReleaseAutoSyncEvent($this->upsertReleaseAutoSyncEvent([ + 'channel_id' => (int)$target['channel_id'], + 'app' => (string)$target['app'], + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'status' => 'pending', + 'source' => 'github_webhook', + 'workflow_url' => (string)($payload['compare'] ?? ''), + 'metadata' => [ + 'github_event' => $event, + 'head_commit' => self::redactPayload($payload['head_commit'] ?? []), + ], + ])); + } + + $this->audit(null, null, 'github_webhook_processed', null, 'info', [ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'auto_sync_event_count' => count($autoSyncEvents), + ]); + + return [ + 'event' => $event, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'mapped_channel_slug' => $mappedChannelSlug, + 'mapped_app' => $mappedApp !== '' ? $mappedApp : null, + 'auto_sync_events' => $autoSyncEvents, + 'operations' => [], + 'deployments' => [], + ]; + } + + public function healthProbe(): array + { + $startedAt = microtime(true); + try { + $summary = $this->summary(); + $channels = $summary['channels'] ?? []; + $deployments = $summary['deployments'] ?? []; + $failedDeployments = array_values(array_filter($deployments, static fn(array $deployment): bool => ($deployment['status'] ?? '') === 'failed')); + $channelsWithoutVersions = array_values(array_filter($channels, static function (array $channel): bool { + if (($channel['enabled'] ?? false) !== true) { + return false; + } + $versions = $channel['versions'] ?? []; + return empty($versions['frontend']) && empty($versions['api']); + })); + + $status = 'ok'; + $reason = 'Release channels and deployment telemetry are available.'; + $reasonKey = 'release_manager_available'; + if ($failedDeployments !== []) { + $status = 'degraded'; + $reason = 'One or more recent release deployments failed.'; + $reasonKey = 'release_deployments_failed'; + } elseif ($channelsWithoutVersions !== []) { + $status = 'degraded'; + $reason = 'One or more enabled release channels have no active versions yet.'; + $reasonKey = 'release_channels_without_versions'; + } + + return [ + 'status' => $status, + 'status_reason' => $reason, + 'status_reason_key' => $reasonKey, + 'status_reason_params' => [ + 'channels' => count($channels), + 'deployment_targets' => count($summary['deployment_targets'] ?? []), + 'recent_failed_deployments' => count($failedDeployments), + ], + 'checked_at' => date('c'), + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } catch (Throwable $throwable) { + return [ + 'status' => 'down', + 'status_reason' => 'Release manager probe failed: ' . $throwable->getMessage(), + 'status_reason_key' => 'release_manager_probe_failed', + 'status_reason_params' => ['error' => $throwable->getMessage()], + 'checked_at' => date('c'), + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } + } + + private function ensureSchema(): void + { + if ($this->schemaEnsured) { + return; + } + release_manager_schema_bootstrap::ensureTables(); + $this->schemaEnsured = true; + } + + private function resolveChannel(array $context): array + { + $cacheKey = $this->assignmentCacheKey($context); + if ($cacheKey !== '' && defined('redis')) { + try { + $cached = redis->get($cacheKey); + if (is_string($cached) && $cached !== '') { + $decoded = json_decode($cached, true); + if (is_array($decoded) && !empty($decoded['id'])) { + return $decoded; + } + } + } catch (Throwable) { + } + } + + $candidates = []; + if (!empty($context['principal_type']) && !empty($context['principal_id'])) { + $candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']]; + } + if (!empty($context['customer_number'])) { + $candidates[] = ['customer', (string)(int)$context['customer_number']]; + } + + foreach ($candidates as [$subjectType, $subjectId]) { + $row = $this->selectOne( + "SELECT c.* + FROM release_assignments a + INNER JOIN release_channels c ON c.id = a.channel_id + WHERE a.deleted_at IS NULL + AND c.deleted_at IS NULL + AND c.enabled = 1 + AND a.subject_type = ? + AND a.subject_id = ? + AND (a.expires_at IS NULL OR a.expires_at > NOW()) + ORDER BY a.created_at DESC, a.id DESC + LIMIT 1", + 'ss', + [$subjectType, $subjectId] + ); + if ($row !== null) { + $this->cacheResolvedChannel($cacheKey, $row); + return $row; + } + } + + $rolloutChannel = $this->rolloutChannelForContext($context); + if ($rolloutChannel !== null) { + $this->cacheResolvedChannel($cacheKey, $rolloutChannel); + return $rolloutChannel; + } + + $default = $this->defaultChannel(); + $this->cacheResolvedChannel($cacheKey, $default); + return $default; + } + + private function runtimeChannelsForContext(array $context, ?array $resolvedChannel = null): array + { + $channelsByKey = []; + + $this->addRuntimeChannel($channelsByKey, $this->defaultChannel()); + + foreach ($this->assignmentCandidates($context) as [$subjectType, $subjectId]) { + $rows = $this->selectRows( + "SELECT c.* + FROM release_assignments a + INNER JOIN release_channels c ON c.id = a.channel_id + WHERE a.deleted_at IS NULL + AND c.deleted_at IS NULL + AND c.enabled = 1 + AND a.subject_type = ? + AND a.subject_id = ? + AND (a.expires_at IS NULL OR a.expires_at > NOW()) + ORDER BY a.created_at DESC, a.id DESC", + 'ss', + [$subjectType, $subjectId] + ); + + foreach ($rows as $row) { + $this->addRuntimeChannel($channelsByKey, $row); + } + } + + if ($resolvedChannel !== null) { + $this->addRuntimeChannel($channelsByKey, $resolvedChannel); + } + + return array_values($channelsByKey); + } + + private function assignmentCandidates(array $context): array + { + $candidates = []; + if (!empty($context['principal_type']) && !empty($context['principal_id'])) { + $candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']]; + } + if (!empty($context['customer_number'])) { + $candidates[] = ['customer', (string)(int)$context['customer_number']]; + } + return $candidates; + } + + private function addRuntimeChannel(array &$channelsByKey, array $channel): void + { + $id = (int)($channel['id'] ?? 0); + $slug = self::safeSlug((string)($channel['slug'] ?? '')); + $key = $id > 0 ? 'id:' . $id : ($slug !== '' ? 'slug:' . $slug : ''); + if ($key === '' || isset($channelsByKey[$key])) { + return; + } + $channelsByKey[$key] = $channel; + } + + private function chooseRuntimeChannel(array $resolvedChannel, array $availableChannels, string $requestedSlug): array + { + if ($requestedSlug === '') { + return $resolvedChannel; + } + + foreach ($availableChannels as $channel) { + if (self::safeSlug((string)($channel['slug'] ?? '')) === $requestedSlug) { + return $channel; + } + } + + return $resolvedChannel; + } + + private function runtimeServiceChannelFor(array $channel): array + { + if (!$this->channelUsesProductionServices($channel)) { + return $channel; + } + + return $this->productionServiceChannel(); + } + + private function productionServiceChannel(): array + { + $channel = $this->selectOne( + "SELECT * + FROM release_channels + WHERE deleted_at IS NULL + AND enabled = 1 + AND default_channel = 1 + AND slug <> 'beta' + ORDER BY id + LIMIT 1" + ); + if ($channel !== null) { + return $channel; + } + + foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) { + $channel = $this->findChannelBySlug($slug); + if ($channel !== null && (int)($channel['enabled'] ?? 0) === 1 && !$this->channelUsesProductionServices($channel)) { + return $channel; + } + } + + $default = $this->defaultChannel(); + if ($this->channelUsesProductionServices($default)) { + throw new RuntimeException('No production release channel is configured for beta services.'); + } + + return $default; + } + + private function requestedRuntimeChannelSlug(array $input = []): string + { + $value = $input['release_channel'] + ?? $input['channel_slug'] + ?? ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? ''); + return self::safeSlug((string)$value); + } + + private function rolloutChannelForContext(array $context): ?array + { + $seed = (string)($context['principal_id'] ?? $context['customer_number'] ?? ''); + if ($seed === '') { + return null; + } + + $bucket = (hexdec(substr(hash('sha256', $seed), 0, 8)) % 10000) / 100; + $channels = $this->selectRows( + "SELECT * FROM release_channels + WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 0 AND rollout_percent > 0 + ORDER BY rollout_percent DESC, slug" + ); + + foreach ($channels as $channel) { + if ($bucket < (float)$channel['rollout_percent']) { + return $channel; + } + } + + return null; + } + + private function capturePolicyFor(array $context, array $channel): array + { + $enabled = (bool)((int)($channel['replay_enabled'] ?? 0)); + $captureLevel = $this->normalizeCaptureLevel((string)($channel['capture_level'] ?? 'metadata')); + $retentionDays = max(1, (int)($channel['retention_days'] ?? 14)); + + $targets = []; + if (!empty($context['principal_type']) && !empty($context['principal_id'])) { + $targets[] = [(string)$context['principal_type'], (string)$context['principal_id']]; + } + if (!empty($context['customer_number'])) { + $targets[] = ['customer', (string)(int)$context['customer_number']]; + } + $targets[] = ['channel', (string)$channel['slug']]; + + foreach ($targets as [$targetType, $targetId]) { + $row = $this->selectOne( + "SELECT capture_level + FROM release_replay_targets + WHERE deleted_at IS NULL + AND enabled = 1 + AND target_type = ? + AND (target_id = ? OR (target_type = 'channel' AND channel_id = ?)) + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC, id DESC + LIMIT 1", + 'ssi', + [$targetType, $targetId, (int)$channel['id']] + ); + if ($row !== null) { + $enabled = true; + $captureLevel = $this->normalizeCaptureLevel((string)$row['capture_level']); + break; + } + } + + return [ + 'enabled' => $enabled, + 'capture_level' => $captureLevel, + 'all_failure_metadata' => true, + 'retention_days' => $retentionDays, + ]; + } + + private function currentPrincipalContext(): array + { + try { + $auth = new authentication(); + $subuser = $auth->get_subuser(); + if ($subuser !== false) { + return [ + 'principal_type' => 'subuser', + 'principal_id' => (string)$subuser->id, + 'customer_number' => $auth->get_subuser_customer_number_target() ?: null, + ]; + } + $user = $auth->get_user(); + if ($user !== false) { + return [ + 'principal_type' => 'user', + 'principal_id' => (string)$user->id, + 'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null, + ]; + } + } catch (Throwable) { + } + + return [ + 'principal_type' => null, + 'principal_id' => null, + 'customer_number' => null, + ]; + } + + private function defaultChannel(): array + { + $channel = $this->selectOne( + "SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 1 ORDER BY id LIMIT 1" + ); + if ($channel !== null) { + return $channel; + } + + $channel = $this->selectOne( + "SELECT * FROM release_channels WHERE deleted_at IS NULL AND slug = 'stable' ORDER BY id LIMIT 1" + ); + if ($channel !== null) { + return $channel; + } + + throw new RuntimeException('No release channel is configured.'); + } + + private function currentVersionsForChannel(int $channelId): array + { + $current = $this->currentChannelVersionRow($channelId); + $frontend = null; + $api = null; + if (!empty($current['frontend_version_id'])) { + $frontend = $this->publicVersion($this->getVersion((int)$current['frontend_version_id'])); + } + if (!empty($current['api_version_id'])) { + $api = $this->publicVersion($this->getVersion((int)$current['api_version_id'])); + } + $serviceSet = null; + if (!empty($current['service_set_id'])) { + try { + $serviceSet = $this->publicServiceSet($this->getServiceSet((int)$current['service_set_id']), false); + } catch (Throwable) { + $serviceSet = null; + } + } + $bundle = null; + if (!empty($current['bundle_id'])) { + try { + $bundle = $this->publicBundle($this->getBundle((int)$current['bundle_id']), false); + } catch (Throwable) { + $bundle = null; + } + } + + return [ + 'frontend' => $frontend, + 'api' => $api, + 'service_set' => $serviceSet, + 'bundle_id' => isset($current['bundle_id']) ? (int)$current['bundle_id'] : null, + 'bundle' => $bundle, + ]; + } + + private function channelAvailability(array $channel): array + { + $channelId = (int)($channel['id'] ?? 0); + if ($this->channelUsesProductionServices($channel)) { + $serviceChannel = $this->productionServiceChannel(); + $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']); + $urls = $this->releaseRuntimeUrls($serviceChannel, $versions); + $availability = $this->channelAvailability($serviceChannel); + + return array_replace($availability, [ + 'configured' => (bool)($availability['configured'] ?? false), + 'missing' => is_array($availability['missing'] ?? null) ? $availability['missing'] : [], + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'service_policy' => self::PRODUCTION_SERVICE_POLICY, + 'service_channel_id' => (int)($serviceChannel['id'] ?? 0), + 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + ]); + } + + $isDefault = ((int)($channel['default_channel'] ?? 0) === 1) || (string)($channel['slug'] ?? '') === 'stable'; + if ($isDefault || $channelId <= 0) { + return [ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]; + } + + $versions = $this->currentVersionsForChannel($channelId); + $urls = $this->releaseRuntimeUrls($channel, $versions); + $missing = []; + if (empty($versions['frontend'])) { + $missing[] = 'frontend_version'; + } elseif (empty($urls['frontend_base_url'])) { + $missing[] = 'frontend_base_url'; + } + if (empty($versions['api'])) { + $missing[] = 'api_version'; + } elseif (empty($urls['api_base_url'])) { + $missing[] = 'api_base_url'; + } + + return [ + 'configured' => count($missing) === 0, + 'missing' => $missing, + 'bundle_id' => $versions['bundle_id'] ?? null, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'status' => count($missing) === 0 ? 'ready' : 'unconfigured', + ]; + } + + private function releaseRuntimeUrls(array $channel, array $versions): array + { + $frontend = is_array($versions['frontend'] ?? null) ? $versions['frontend'] : []; + $api = is_array($versions['api'] ?? null) ? $versions['api'] : []; + $serviceSet = is_array($versions['service_set'] ?? null) ? $versions['service_set'] : []; + $targets = is_array($serviceSet['targets'] ?? null) ? $serviceSet['targets'] : []; + $frontendTarget = is_array($targets['frontend'] ?? null) ? $targets['frontend'] : null; + $apiTarget = is_array($targets['api'] ?? null) ? $targets['api'] : null; + + return [ + 'frontend_base_url' => $this->normalizeReleasePublicBaseUrl($frontend['deployed_url'] ?? null, 'frontend') + ?? $this->normalizeReleasePublicBaseUrl($channel['frontend_base_url'] ?? null, 'frontend') + ?? (is_array($frontendTarget) ? $this->releaseTargetPublicBaseUrl($frontendTarget) : null), + 'api_base_url' => $this->normalizeReleasePublicBaseUrl($api['deployed_url'] ?? null, 'api') + ?? $this->normalizeReleasePublicBaseUrl($channel['api_base_url'] ?? null, 'api') + ?? (is_array($apiTarget) ? $this->releaseTargetPublicBaseUrl($apiTarget) : null), + ]; + } + + private function normalizeReleasePublicBaseUrl(mixed $value, string $app = ''): ?string + { + $raw = trim((string)$value); + if ($raw === '') { + return null; + } + + $raw = preg_replace('#/(health|ping)$#i', '', rtrim($raw, '/')) ?: $raw; + if (preg_match('#^https?://#i', $raw) !== 1) { + $raw = 'https://' . ltrim($raw, '/'); + } + + $parts = parse_url($raw); + if (!is_array($parts) || empty($parts['host'])) { + return null; + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + if (!in_array($scheme, ['http', 'https'], true)) { + return null; + } + + $path = isset($parts['path']) ? '/' . trim((string)$parts['path'], '/') : ''; + $port = isset($parts['port']) ? ':' . (int)$parts['port'] : ''; + return rtrim($scheme . '://' . strtolower((string)$parts['host']) . $port . $path, '/'); + } + + private function currentChannelVersionRow(int $channelId): ?array + { + return $this->selectOne( + "SELECT * FROM release_channel_versions + WHERE channel_id = ? AND active = 1 + ORDER BY activated_at DESC, id DESC + LIMIT 1", + 'i', + [$channelId] + ); + } + + private function createVersion(array $input): int + { + $this->execute( + "INSERT INTO release_versions ( + app, repository, branch, commit_sha, tag, version_label, + build_url, artifact_url, deployed_url, status, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'sssssssssss', + [ + $input['app'], + $input['repository'] ?? null, + $input['branch'] ?? null, + $input['commit_sha'] ?? null, + $input['tag'] ?? null, + $input['version_label'] ?? null, + $input['build_url'] ?? null, + $input['artifact_url'] ?? null, + $input['deployed_url'] ?? null, + $input['status'] ?? 'discovered', + self::jsonEncode($input['metadata'] ?? []), + ] + ); + return $this->insertId(); + } + + private function restartCoolifyService(int $instanceId, string $serviceUuid): array + { + $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]); + if ($instance === null) { + throw new RuntimeException('Coolify instance for release deployment was not found.'); + } + $token = replication_secret_box::decrypt((string)$instance['api_token_secret']); + $client = new coolify_api_client((string)$instance['base_url'], $token, 20); + return $client->restartService($serviceUuid); + } + + private function deployCoolifyReleaseTarget(array $target): array + { + $instanceId = (int)($target['coolify_instance_id'] ?? 0); + $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]); + if ($instance === null) { + throw new RuntimeException('Coolify instance for release deployment was not found.'); + } + + $token = replication_secret_box::decrypt((string)$instance['api_token_secret']); + $client = new coolify_api_client((string)$instance['base_url'], $token, 20); + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $serviceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $resourceType = $this->releaseCoolifyResourceType($context, $serviceUuid); + $created = null; + $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); + $runtimeEnvUpdate = null; + + if ($serviceUuid === '' && $this->toBool($context['coolify_auto_create'] ?? false)) { + $githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true); + if ($githubAppUuid !== '') { + $context['coolify_github_app_uuid'] = $githubAppUuid; + $applicationPayload = $this->releaseCoolifyApplicationPayload($target, $context, $instance); + $applicationPayload['instant_deploy'] = false; + $created = $client->createPrivateGithubAppApplication($applicationPayload); + $resourceType = 'application'; + } else { + if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) { + throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.'); + } + if ($this->releaseCoolifyServiceSourceIsMissing($context)) { + throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App for this private source repository. Configure one GitHub App in Coolify or set coolify_github_app_uuid on the target; no source-code credentials are required in Release Manager.'); + } + $servicePayload = $this->releaseCoolifyServicePayload($target, $context, $instance); + $servicePayload['instant_deploy'] = false; + $created = $client->createService($servicePayload); + $resourceType = 'service'; + } + $serviceUuid = trim((string)($created['uuid'] ?? '')); + if ($serviceUuid === '') { + throw new RuntimeException('Coolify did not return a resource UUID for the release target.'); + } + $context['coolify_resource_type'] = $resourceType; + $this->execute( + 'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?', + 'ssi', + [$serviceUuid, self::jsonEncode($context), (int)$target['id']] + ); + } + + if ($serviceUuid === '') { + throw new RuntimeException('Select an existing Coolify service or enable Coolify service creation before deployment.'); + } + + $update = null; + if ($resourceType === 'application') { + $applicationUpdate = $this->releaseCoolifyApplicationUpdatePayload($target, $context); + if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) { + $resource = []; + try { + $resource = $client->getApplication($serviceUuid); + } catch (Throwable) { + $resource = is_array($created) ? $created : []; + } + $applicationUpdate = array_replace( + $applicationUpdate, + $this->releaseCoolifyApplicationRoutePayload( + $target, + $context, + $publicUrl, + $serviceUuid, + $resource['custom_labels'] ?? null + ) + ); + } + if ($applicationUpdate !== []) { + $update = $client->updateApplication($serviceUuid, $applicationUpdate); + } + $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'application', $target, $context); + } elseif ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) { + if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) { + throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.'); + } + $update = $client->updateService($serviceUuid, [ + 'urls' => [ + [ + 'name' => (string)($target['app'] ?? 'release'), + 'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)), + ], + ], + 'force_domain_override' => true, + ]); + $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'service', $target, $context); + } + + if ($runtimeEnvUpdate === null) { + $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, $resourceType, $target, $context); + } + + $deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context)); + return [ + 'service_uuid' => $serviceUuid, + 'resource_type' => $resourceType, + 'ssl_enabled' => $this->toBool($context['coolify_enable_ssl'] ?? false), + 'public_url' => $publicUrl, + 'created' => self::redactPayload($created ?? []), + 'updated' => self::redactPayload($update ?? []), + 'runtime_env' => $runtimeEnvUpdate, + 'deployment' => self::redactPayload($deployment), + ]; + } + + private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array + { + $env = $this->releaseCoolifyRuntimeEnv($target, $context); + if ($env === []) { + return null; + } + + if ($resourceType === 'application') { + $client->updateApplicationEnvsBulk($resourceUuid, $env); + } else { + $client->updateServiceEnvsBulk($resourceUuid, $env); + } + + return [ + 'resource_type' => $resourceType, + 'count' => count($env), + 'keys' => array_keys($env), + ]; + } + + private function releaseCoolifyRuntimeEnv(array $target, array $context): array + { + $contextEnv = $this->releaseCoolifyContextEnv($context); + $env = $contextEnv; + $app = strtolower(trim((string)($target['app'] ?? ''))); + if ($app !== 'api') { + return $env; + } + + $env['USE_ENV'] = $env['USE_ENV'] ?? 'true'; + foreach (self::RELEASE_API_RUNTIME_ENV_KEYS as $key) { + $this->appendRuntimeEnvValue($env, $key); + } + + $runtime = array_replace( + is_array($_ENV ?? null) ? $_ENV : [], + is_array($_SERVER ?? null) ? $_SERVER : [], + is_array(getenv()) ? getenv() : [] + ); + foreach ($runtime as $key => $value) { + $key = (string)$key; + if (!$this->releaseRuntimeEnvKeyAllowed($key)) { + continue; + } + $this->appendRuntimeEnvValue($env, $key, $value); + } + + $deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context)); + if ($deploymentCommitSha !== '') { + foreach (['API_COMMIT_SHA', 'COMMIT_SHA'] as $key) { + if (!array_key_exists($key, $contextEnv)) { + $env[$key] = $deploymentCommitSha; + } + } + } + + $env = array_replace($env, $contextEnv); + $env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true'; + $env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? '')); + return $this->normalizeCoolifyRuntimeEnv($env); + } + + private function releaseCoolifyContextEnv(array $context): array + { + $env = []; + foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) { + if (is_array($context[$key] ?? null)) { + foreach ($context[$key] as $envKey => $value) { + $this->appendRuntimeEnvValue($env, (string)$envKey, $value); + } + } + } + + foreach (['coolify_env_file', 'env'] as $key) { + $raw = $context[$key] ?? null; + if (!is_string($raw) || trim($raw) === '') { + continue; + } + foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) { + $line = trim((string)$line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$envKey, $value] = explode('=', $line, 2); + $this->appendRuntimeEnvValue($env, trim($envKey), $value); + } + } + + return $this->normalizeCoolifyRuntimeEnv($env); + } + + private function appendRuntimeEnvValue(array &$env, string $key, mixed $value = null): void + { + $key = trim($key); + if ($key === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { + return; + } + if ($value === null) { + $value = getenv($key); + if ($value === false && array_key_exists($key, $_ENV ?? [])) { + $value = $_ENV[$key]; + } + if ($value === false && array_key_exists($key, $_SERVER ?? [])) { + $value = $_SERVER[$key]; + } + } + if ($value === false || $value === null || is_array($value) || is_object($value)) { + return; + } + + $env[$key] = (string)$value; + } + + private function releaseRuntimeEnvKeyAllowed(string $key): bool + { + if (in_array($key, self::RELEASE_API_RUNTIME_ENV_KEYS, true)) { + return true; + } + + foreach (self::RELEASE_API_RUNTIME_ENV_PREFIXES as $prefix) { + if (str_starts_with($key, $prefix)) { + return true; + } + } + + return false; + } + + private function normalizeCoolifyRuntimeEnv(array $env): array + { + $normalized = []; + foreach ($env as $key => $value) { + $this->appendRuntimeEnvValue($normalized, (string)$key, $value); + } + ksort($normalized); + return $normalized; + } + + private function releaseCoolifyApplicationPayload(array $target, array $context, array $instance): array + { + $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); + $projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? '')); + if ($projectUuid === '') { + throw new RuntimeException('Select a Coolify project for this release target before creating an application.'); + } + + $githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true); + if ($githubAppUuid === '') { + throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App UUID so Coolify can pull with the app token.'); + } + + $serverUuid = $this->releaseCoolifyServerUuid($context, $instance); + if ($serverUuid === '') { + throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.'); + } + + $repository = trim((string)($target['repository'] ?? '')); + if ($repository === '') { + throw new RuntimeException('Repository is required before creating a Coolify GitHub App application.'); + } + + $environment = $this->releaseCoolifyEnvironment($target, $context, $instance); + $payload = [ + 'name' => $this->releaseCoolifyResourceName($target, $context), + 'description' => 'Truckwash release manager target for ' . $repository, + 'project_uuid' => $projectUuid, + 'environment_name' => $environment['name'], + 'environment_uuid' => $environment['uuid'], + 'server_uuid' => $serverUuid, + 'destination_uuid' => trim((string)($context['coolify_destination_uuid'] ?? $context['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')), + 'github_app_uuid' => $githubAppUuid, + 'git_repository' => $repository, + 'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH, + 'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context), + 'build_pack' => $this->releaseCoolifyBuildPack($target, $context), + 'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context), + 'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true), + 'is_auto_deploy_enabled' => $this->toBool($target['auto_deploy'] ?? true), + 'force_domain_override' => true, + ]; + + foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) { + $payload[$key] = $value; + } + + if ($publicUrl !== null) { + $payload['domains'] = self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)); + $payload['is_force_https_enabled'] = $this->toBool($context['coolify_enable_ssl'] ?? false); + } + + foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) { + $payload[$key] = $value; + } + + return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== ''); + } + + private function releaseCoolifyApplicationRoutePayload( + array $target, + array $context, + string $publicUrl, + string $resourceUuid, + mixed $existingLabels = null + ): array + { + $decodedLabels = self::decodeCoolifyLabels($existingLabels); + $routePort = $this->releaseCoolifyProxyPort($target, $context) + ?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid); + $payload = [ + 'domains' => self::coolifyProxyUrl($publicUrl, $routePort), + 'is_force_https_enabled' => true, + 'force_domain_override' => true, + ]; + + $labels = self::releaseCoolifyApplicationLabels( + $publicUrl, + $resourceUuid, + $routePort, + self::gatewayRouteDefaultCertResolver($publicUrl) + ); + if ($labels !== []) { + $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( + $decodedLabels, + $labels + ))); + } + + return $payload; + } + + private function releaseCoolifyApplicationUpdatePayload(array $target, array $context): array + { + $app = strtolower(trim((string)($target['app'] ?? ''))); + $buildPack = $this->releaseCoolifyBuildPack($target, $context); + $payload = [ + 'git_repository' => trim((string)($target['repository'] ?? '')), + 'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH, + 'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context), + 'build_pack' => $buildPack, + 'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context), + ]; + + foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) { + $payload[$key] = $value; + } + + foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) { + $payload[$key] = $value; + } + + if ($app === 'frontend' && $buildPack === 'dockerfile') { + $payload['install_command'] = ''; + $payload['build_command'] = ''; + $payload['start_command'] = ''; + $payload['publish_directory'] = ''; + $payload['is_static'] = false; + $payload['is_spa'] = false; + } + + return array_filter( + $payload, + static fn(mixed $value, string $key): bool => $value !== null + && ($value !== '' || in_array($key, ['install_command', 'build_command', 'start_command', 'publish_directory'], true)), + ARRAY_FILTER_USE_BOTH + ); + } + + private static function releaseCoolifyApplicationLabels( + string $publicUrl, + string $resourceUuid, + ?int $port = null, + ?string $certResolver = null + ): array + { + $resourceUuid = self::coolifyRouteLabelId($resourceUuid); + if ($resourceUuid === '') { + return []; + } + + $parts = parse_url($publicUrl); + $host = trim((string)($parts['host'] ?? '')); + if ($host === '') { + return []; + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + $path = trim((string)($parts['path'] ?? '/')); + $path = $path !== '' ? $path : '/'; + if ($path[0] !== '/') { + $path = '/' . $path; + } + + $routePort = $port ?? self::firstInteger($parts['port'] ?? null); + $certResolver = trim((string)($certResolver ?? '')); + $httpLabel = 'http-0-' . $resourceUuid; + $httpsLabel = 'https-0-' . $resourceUuid; + $priority = (string)(1000 + strlen($path)); + $labels = [ + 'traefik.enable=true', + 'traefik.http.middlewares.gzip.compress=true', + 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https', + ]; + + if ($scheme === 'https') { + $labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https"; + $labels[] = "traefik.http.routers.{$httpsLabel}.priority={$priority}"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; + $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; + } + if ($path !== '/') { + $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; + } else { + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip"; + } + $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; + if ($certResolver !== '') { + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; + } + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}"; + $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; + $labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; + $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; + } + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=redirect-to-https"; + } else { + $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; + $labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; + $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; + } + if ($path !== '/') { + $labels[] = "traefik.http.middlewares.{$httpLabel}-stripprefix.stripprefix.prefixes={$path}"; + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares={$httpLabel}-stripprefix,gzip"; + } else { + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=gzip"; + } + } + + sort($labels); + return $labels; + } + + private static function mergeCoolifyLabels(array $existingLabels, array $generatedLabels): array + { + $merged = []; + foreach (array_merge($existingLabels, $generatedLabels) as $label) { + $label = trim((string)$label); + if ($label === '') { + continue; + } + $merged[self::coolifyLabelKey($label)] = $label; + } + + return array_values($merged); + } + + private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int + { + $resourceUuid = self::coolifyRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $port = (int)$matches[2]; + if ($port <= 0) { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $port; + } + $fallback ??= $port; + } + + return $fallback; + } + + private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string + { + $resourceUuid = self::coolifyRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $resolver = trim((string)$matches[2]); + if ($resolver === '') { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $resolver; + } + $fallback ??= $resolver; + } + + return $fallback; + } + + private static function gatewayRouteDefaultCertResolver(string $publicUrl): string + { + return 'letsencrypt'; + } + + private static function decodeCoolifyLabels(mixed $labels): array + { + if (!is_scalar($labels)) { + return []; + } + + $raw = trim((string)$labels); + if ($raw === '') { + return []; + } + + $decoded = base64_decode($raw, true); + $content = $decoded !== false ? $decoded : $raw; + return array_values(array_filter( + preg_split('/\r\n|\r|\n/', (string)$content) ?: [], + static fn(string $label): bool => trim($label) !== '' + )); + } + + private static function coolifyLabelKey(string $label): string + { + $position = strpos($label, '='); + return $position === false ? trim($label) : trim(substr($label, 0, $position)); + } + + private static function coolifyRouteLabelId(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?: ''; + return trim($value, '-'); + } + + private static function firstInteger(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + if (is_float($value)) { + return $value > 0 ? (int)$value : null; + } + if (is_array($value)) { + foreach ($value as $item) { + $integer = self::firstInteger($item); + if ($integer !== null) { + return $integer; + } + } + return null; + } + if (!is_scalar($value)) { + return null; + } + if (preg_match('/\d+/', (string)$value, $matches) !== 1) { + return null; + } + + $integer = (int)$matches[0]; + return $integer > 0 ? $integer : null; + } + + private static function coolifyProxyUrl(string $publicUrl, ?int $port): string + { + if ($port === null || $port <= 0) { + return $publicUrl; + } + + $parts = parse_url($publicUrl); + if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) { + return $publicUrl; + } + + $scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https'; + $host = trim((string)$parts['host']); + $path = (string)($parts['path'] ?? ''); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; + return "{$scheme}://{$host}:{$port}{$path}{$query}{$fragment}"; + } + + private static function releaseCoolifyPublicUrlNeedsStripPrefixLabels(?string $publicUrl): bool + { + if ($publicUrl === null || trim($publicUrl) === '') { + return false; + } + + $parts = parse_url($publicUrl); + if (!is_array($parts)) { + return false; + } + + return trim((string)($parts['path'] ?? ''), '/') !== ''; + } + + private function releaseCoolifyServicePayload(array $target, array $context, array $instance): array + { + $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); + $projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? '')); + if ($projectUuid === '') { + throw new RuntimeException('Select a Coolify project for this release target before creating a service.'); + } + $environment = $this->releaseCoolifyEnvironment($target, $context, $instance); + $serverUuid = $this->releaseCoolifyServerUuid($context, $instance); + if ($serverUuid === '') { + throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.'); + } + + $name = $this->releaseCoolifyResourceName($target, $context); + $compose = (string)($context['docker_compose_raw'] ?? ''); + if (trim($compose) === '') { + $image = $this->releaseCoolifyExplicitImage($context); + if ($image === '' && $this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false)) { + $repository = strtolower(trim((string)($target['repository'] ?? ''))); + $branch = self::safeIdentifier((string)($target['branch'] ?? self::DEFAULT_BRANCH), 64); + if ($repository !== '') { + $image = 'ghcr.io/' . $repository . ':' . ($branch !== '' ? $branch : self::DEFAULT_BRANCH); + } + } + if ($image === '') { + throw new RuntimeException('Coolify service creation needs docker_compose_raw or an explicit image in deploy_context. Release Manager will not assume a GHCR image from repository and branch.'); + } + $compose = "services:\n app:\n image: " . $image . "\n restart: unless-stopped\n"; + } + + $payload = [ + 'name' => $name, + 'description' => 'Truckwash release manager target for ' . (string)($target['repository'] ?? ''), + 'project_uuid' => $projectUuid, + 'environment_name' => $environment['name'], + 'environment_uuid' => $environment['uuid'], + 'server_uuid' => $serverUuid, + 'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true), + 'docker_compose_raw' => base64_encode($compose), + 'force_domain_override' => true, + ]; + + if ($publicUrl !== null) { + $payload['urls'] = [ + [ + 'name' => (string)($target['app'] ?? 'release'), + 'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)), + ], + ]; + } + + return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== ''); + } + + private function releaseCoolifyResourceName(array $target, array $context): string + { + $requestedName = trim((string)($context['coolify_service_name'] ?? $context['service_name'] ?? $context['coolify_application_name'] ?? $context['application_name'] ?? '')); + return self::safeIdentifier( + $requestedName !== '' + ? $requestedName + : 'release-' . (string)($target['channel_slug'] ?? $target['channel_id'] ?? 'channel') . '-' . (string)($target['app'] ?? 'app'), + 64 + ); + } + + private function releaseCoolifyServiceSourceIsMissing(array $context): bool + { + return trim((string)($context['docker_compose_raw'] ?? '')) === '' + && $this->releaseCoolifyExplicitImage($context) === '' + && !$this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false); + } + + private function releaseCoolifyGithubAppUuid(array $context, array $target = [], array $instance = [], bool $discover = false): string + { + foreach ([ + 'coolify_github_app_uuid', + 'github_app_uuid', + 'coolify_git_app_uuid', + 'git_app_uuid', + 'default_github_app_uuid', + 'default_coolify_github_app_uuid', + ] as $key) { + $value = trim((string)($context[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + if (!$discover) { + return ''; + } + + return $this->releaseCoolifyDefaultGithubAppUuid($target, $context, $instance); + } + + private function releaseCoolifyDefaultGithubAppUuid(array $target, array $context, array $instance): string + { + foreach ([ + 'default_github_app_uuid', + 'default_coolify_github_app_uuid', + 'coolify_github_app_uuid', + 'github_app_uuid', + ] as $key) { + $value = trim((string)($instance[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + foreach ([ + getenv('RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID'] ?? null), + getenv('COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['COOLIFY_GITHUB_APP_UUID'] ?? null), + $this->moduleConfigValue('ReleaseManager', 'coolify_github_app_uuid', ''), + $this->moduleConfigValue('Coolify', 'github_app_uuid', ''), + ] as $value) { + $value = trim((string)$value); + if ($value !== '') { + return $value; + } + } + + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return ''; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps(); + } catch (Throwable) { + return ''; + } + + $rows = array_values(array_filter($this->payloadRows($apps), static function (mixed $row): bool { + return is_array($row) && trim((string)($row['uuid'] ?? '')) !== ''; + })); + if (count($rows) === 1) { + return trim((string)$rows[0]['uuid']); + } + + $repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? '')); + $owner = strtolower(trim(strtok($repository, '/') ?: '')); + if ($owner === '') { + return ''; + } + + $matches = array_values(array_filter($rows, static function (array $row) use ($owner): bool { + foreach (['organization', 'name', 'custom_user', 'html_url'] as $key) { + $value = strtolower(trim((string)($row[$key] ?? ''))); + if ($value === '') { + continue; + } + if ($value === $owner || str_contains($value, '/' . $owner) || str_contains($value, $owner . '-')) { + return true; + } + } + return false; + })); + + return count($matches) === 1 ? trim((string)$matches[0]['uuid']) : ''; + } + + private function releaseCoolifyResourceType(array $context, string $serviceUuid = ''): string + { + $type = strtolower(trim((string)($context['coolify_resource_type'] ?? $context['resource_type'] ?? ''))); + if (in_array($type, ['application', 'app'], true)) { + return 'application'; + } + if ($type === '' && trim($serviceUuid) === '' && $this->releaseCoolifyGithubAppUuid($context) !== '') { + return 'application'; + } + + return 'service'; + } + + private function releaseTargetNeedsApplicationAutoCreate(array $target, array $context): bool + { + if (!in_array(strtolower(trim((string)($target['app'] ?? ''))), self::APPS, true)) { + return false; + } + if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) { + return false; + } + if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '') { + return false; + } + if ($this->toBool($context['coolify_auto_create'] ?? false)) { + return false; + } + + $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); + return self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl) + || $this->releaseCoolifyResourceType($context, '') === 'application'; + } + + private function releaseCoolifyBuildPack(array $target, array $context): string + { + $app = strtolower(trim((string)($target['app'] ?? ''))); + $buildPack = strtolower(trim((string)($context['coolify_build_pack'] ?? $context['build_pack'] ?? ''))); + if ($buildPack !== '') { + if ($app === 'frontend' && $buildPack === 'nixpacks') { + return 'dockerfile'; + } + return $buildPack; + } + + return in_array($app, ['api', 'frontend'], true) ? 'dockerfile' : 'static'; + } + + private function releaseCoolifyPortsExposes(array $target, array $context): string + { + foreach ([ + 'coolify_ports_exposes', + 'ports_exposes', + 'coolify_exposed_port', + 'exposed_port', + 'coolify_port', + 'port', + ] as $key) { + $value = trim((string)($context[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + $app = strtolower(trim((string)($target['app'] ?? ''))); + $envKeys = $app === 'api' + ? ['RELEASE_MANAGER_API_PORTS_EXPOSES', 'RELEASE_API_PORTS_EXPOSES', 'API_PORTS_EXPOSES'] + : ['RELEASE_MANAGER_FRONTEND_PORTS_EXPOSES', 'RELEASE_FRONTEND_PORTS_EXPOSES', 'FRONTEND_PORTS_EXPOSES']; + foreach ($envKeys as $key) { + $value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? ''))); + if ($value !== '') { + return $value; + } + } + + return self::DEFAULT_COOLIFY_APPLICATION_PORT; + } + + private function releaseCoolifyProxyPort(array $target, array $context): ?int + { + foreach ([ + 'coolify_ports_exposes', + 'ports_exposes', + 'coolify_exposed_port', + 'exposed_port', + 'coolify_port', + 'port', + ] as $key) { + $port = self::firstInteger($context[$key] ?? null); + if ($port !== null) { + return $port; + } + } + + $app = strtolower(trim((string)($target['app'] ?? ''))); + if ($app !== 'api') { + return null; + } + + return self::firstInteger($this->releaseCoolifyPortsExposes($target, $context)); + } + + private function releaseCoolifyGitCommitSha(array $target, array $context): string + { + foreach ([ + 'coolify_git_commit_sha', + 'git_commit_sha', + 'commit_sha', + 'commit', + ] as $key) { + $value = trim((string)($context[$key] ?? $target[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function releaseCoolifyForceRebuild(array $context): bool + { + if (array_key_exists('coolify_force_rebuild', $context) || array_key_exists('force_rebuild', $context)) { + return $this->toBool($context['coolify_force_rebuild'] ?? $context['force_rebuild'] ?? false); + } + + return true; + } + + private function releaseCoolifyApplicationDefaultFields(array $target, array $context): array + { + $app = strtolower(trim((string)($target['app'] ?? ''))); + $buildPack = $this->releaseCoolifyBuildPack($target, $context); + + if ($app === 'api' && $buildPack === 'dockerfile') { + return [ + 'dockerfile_location' => self::DEFAULT_COOLIFY_API_DOCKERFILE, + ]; + } + + if ($app === 'frontend' && $buildPack === 'dockerfile') { + return [ + 'dockerfile_location' => '/Dockerfile.coolify-frontend', + ]; + } + + if ($app !== 'frontend' || $buildPack !== 'static') { + return []; + } + + return [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'publish_directory' => 'dist', + 'is_static' => true, + 'is_spa' => true, + ]; + } + + private function releaseCoolifyApplicationOptionalFields(array $context): array + { + $fields = []; + foreach ([ + 'base_directory', + 'publish_directory', + 'dockerfile', + 'dockerfile_location', + 'docker_compose_location', + 'ports_exposes', + 'ports_mappings', + 'install_command', + 'build_command', + 'start_command', + ] as $key) { + $value = trim((string)($context['coolify_' . $key] ?? $context[$key] ?? '')); + if ($value !== '') { + $fields[$key] = $value; + } + } + + foreach ([ + 'is_static', + 'is_spa', + 'is_force_https_enabled', + 'is_auto_deploy_enabled', + ] as $key) { + if (array_key_exists('coolify_' . $key, $context) || array_key_exists($key, $context)) { + $fields[$key] = $this->toBool($context['coolify_' . $key] ?? $context[$key] ?? false); + } + } + + return $fields; + } + + private function releaseCoolifyEnvironment(array $target, array $context, array $instance): array + { + $explicitUuid = trim((string)($context['coolify_environment_uuid'] ?? $context['environment_uuid'] ?? '')); + $explicitName = trim((string)($context['coolify_environment_name'] ?? $context['environment_name'] ?? '')); + $releaseEnvironmentName = $this->releaseBranchCoolifyEnvironmentName($target); + if ($explicitUuid !== '' || ($explicitName !== '' && !($releaseEnvironmentName !== null && strtolower($explicitName) === 'production'))) { + return [ + 'uuid' => $explicitUuid !== '' ? $explicitUuid : null, + 'name' => $explicitName !== '' ? $explicitName : (trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production'), + ]; + } + + if ($releaseEnvironmentName !== null) { + return [ + 'uuid' => null, + 'name' => $releaseEnvironmentName, + ]; + } + + return [ + 'uuid' => trim((string)($instance['default_environment_uuid'] ?? '')) ?: null, + 'name' => trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production', + ]; + } + + private function releaseBranchCoolifyEnvironmentName(array $target): ?string + { + $channelSlug = self::safeSlug((string)($target['channel_slug'] ?? $target['channel'] ?? '')); + if ($channelSlug !== '' && in_array($channelSlug, self::DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS, true)) { + return null; + } + + $branchSlug = self::safeSlug(preg_replace('#^refs/heads/#', '', (string)($target['branch'] ?? '')) ?? ''); + if ($channelSlug === '' && $branchSlug === '') { + return null; + } + + if ($branchSlug !== '' && !in_array($branchSlug, ['main', 'master'], true)) { + return $branchSlug; + } + + return $channelSlug !== '' ? $channelSlug : null; + } + + private function releaseCoolifyExplicitImage(array $context): string + { + foreach (['image', 'docker_image', 'coolify_image', 'coolify_docker_image', 'registry_image'] as $key) { + $value = $context[$key] ?? null; + if (is_scalar($value)) { + $image = trim((string)$value); + if ($image !== '') { + return $image; + } + } + } + + return ''; + } + + private function releaseCoolifyServerUuid(array $context, array $instance): string + { + $explicit = trim((string)($context['coolify_server_uuid'] ?? $context['server_uuid'] ?? '')); + if ($explicit !== '') { + return $explicit; + } + + $default = trim((string)($instance['default_server_uuid'] ?? '')); + if ($default !== '') { + return $default; + } + + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return ''; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $servers = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServers(); + } catch (Throwable) { + return ''; + } + + $firstServerUuid = ''; + foreach ($this->payloadRows($servers) as $server) { + if (!is_array($server)) { + continue; + } + $uuid = trim((string)($server['uuid'] ?? '')); + if ($uuid === '') { + continue; + } + if ($firstServerUuid === '') { + $firstServerUuid = $uuid; + } + $settings = is_array($server['settings'] ?? null) ? $server['settings'] : []; + if (($settings['is_reachable'] ?? true) !== false && ($settings['is_usable'] ?? true) !== false) { + return $uuid; + } + } + + return $firstServerUuid; + } + + private function releaseDeploymentEndpoint(array $target): array + { + $context = is_array($target['deploy_context'] ?? null) + ? $target['deploy_context'] + : self::jsonDecode($target['deploy_context_json'] ?? null); + $mode = strtolower(trim((string)($context['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto'; + $app = (string)($target['app'] ?? ''); + + if ($mode === 'manual') { + $host = self::normalizeEndpointHost($context['manual_endpoint_host'] ?? ''); + $port = $this->releaseEndpointPort($context['manual_endpoint_port'] ?? null); + if ($host !== '') { + return $this->releaseEndpointFromParts( + 'manual', + 'resolved', + $host, + $port, + 'manual', + 'Manual endpoint override is configured.' + ); + } + + return self::releasePendingEndpoint( + 'manual', + 'manual', + 'Manual endpoint mode needs a public host before deployment.' + ); + } + + foreach ([ + 'coolify_public_url' => 'coolify_public_url', + 'health_url' => 'health_url', + 'coolify_domain' => 'coolify_domain', + ] as $key => $source) { + $value = $key === 'health_url' + ? ($target['health_url'] ?? null) + : ($context[$key] ?? null); + $url = $key === 'coolify_domain' + ? $this->releaseRoutedPublicBaseUrl($value, $target, $context) + : ($key === 'coolify_public_url' + ? $this->releaseRoutedPublicBaseUrl($value, $target, $context) + : $this->normalizeReleasePublicBaseUrl($value, $app)); + if ($url !== null) { + return $this->releaseEndpointFromUrl( + $url, + 'auto', + 'resolved', + $source, + 'Automatic endpoint resolved from ' . str_replace('_', ' ', $source) . '.' + ); + } + } + + $gatewayUrl = $this->releaseAutoGatewayPublicBaseUrl($target, $context); + if ($gatewayUrl !== null) { + return $this->releaseEndpointFromUrl( + $gatewayUrl, + 'auto', + 'pending', + 'auto_gateway', + 'Automatic gateway endpoint will be used when Coolify routing is ready.' + ); + } + + foreach ([ + 'coolify_deployed_public_url', + 'deployed_public_url', + 'resource_public_url', + 'public_url', + 'coolify_deployed_url', + 'deployed_url', + ] as $key) { + $url = $this->normalizeReleasePublicBaseUrl($context[$key] ?? null, $app); + if ($url !== null) { + return $this->releaseEndpointFromUrl( + $url, + 'auto', + 'resolved', + 'coolify_resource_metadata', + 'Automatic endpoint resolved from deployed Coolify resource metadata.' + ); + } + } + + return self::releasePendingEndpoint( + 'auto', + 'auto', + 'Automatic endpoint resolution is pending deployment metadata.' + ); + } + + private static function releasePendingEndpoint(string $mode, string $source, string $message): array + { + return [ + 'mode' => $mode, + 'status' => 'pending', + 'host' => null, + 'port' => null, + 'url' => null, + 'source' => $source, + 'message' => $message, + ]; + } + + private function releaseEndpointFromUrl(string $url, string $mode, string $status, string $source, string $message): array + { + $normalized = $this->normalizeReleasePublicBaseUrl($url); + if ($normalized === null) { + return self::releasePendingEndpoint($mode, $source, $message); + } + + $parts = parse_url($normalized); + if (!is_array($parts) || empty($parts['host'])) { + return self::releasePendingEndpoint($mode, $source, $message); + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + $port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'http' ? 80 : 443); + return [ + 'mode' => $mode, + 'status' => $status, + 'host' => strtolower((string)$parts['host']), + 'port' => $port, + 'url' => $normalized, + 'source' => $source, + 'message' => $message, + ]; + } + + private function releaseEndpointFromParts( + string $mode, + string $status, + string $host, + ?int $port, + string $source, + string $message + ): array { + $host = self::normalizeEndpointHost($host); + if ($host === '') { + return self::releasePendingEndpoint($mode, $source, $message); + } + + $url = 'https://' . $host . ($port !== null && $port !== 443 ? ':' . $port : ''); + return [ + 'mode' => $mode, + 'status' => $status, + 'host' => strtolower($host), + 'port' => $port, + 'url' => $url, + 'source' => $source, + 'message' => $message, + ]; + } + + private function releaseEndpointPort(mixed $value): ?int + { + $raw = trim((string)$value); + if ($raw === '') { + return null; + } + if (filter_var($raw, FILTER_VALIDATE_INT) === false) { + return null; + } + $port = (int)$raw; + return $port >= 1 && $port <= 65535 ? $port : null; + } + + private static function normalizeEndpointHost(mixed $value): string + { + $raw = trim((string)$value); + if ($raw === '') { + return ''; + } + if (preg_match('#^https?://#i', $raw) === 1) { + $parts = parse_url($raw); + $raw = is_array($parts) ? (string)($parts['host'] ?? '') : ''; + } + $raw = trim($raw); + $raw = preg_replace('#[/\s].*$#', '', $raw) ?? ''; + if (str_contains($raw, ':') && preg_match('/^\[[^\]]+\]:(\d+)$/', $raw) !== 1) { + $parts = parse_url('https://' . $raw); + if (is_array($parts) && !empty($parts['host'])) { + $raw = (string)$parts['host']; + } + } + return strtolower(trim($raw, " \t\n\r\0\x0B[]")); + } + + private function releaseAutoGatewayPublicBaseUrl(array $target, array $context): ?string + { + $host = $this->releasePublicGatewayHost($context); + if ($host === '') { + return null; + } + + return $this->releaseRoutedPublicBaseUrl('https://' . $host, $target, array_replace($context, [ + 'gateway_route_autoprovision' => false, + ])); + } + + private function releasePublicGatewayHost(array $context = []): string + { + foreach ([ + $context['public_gateway_host'] ?? null, + $context['coolify_public_gateway_host'] ?? null, + ] as $value) { + $host = self::normalizeEndpointHost($value); + if ($host !== '') { + return $host; + } + } + + foreach ([ + getenv('RELEASE_MANAGER_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['RELEASE_MANAGER_PUBLIC_GATEWAY_HOST'] ?? null), + getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null), + ] as $value) { + $host = self::normalizeEndpointHost($value); + if ($host !== '') { + return $host; + } + } + + try { + if ($this->tableExists('module_config')) { + $host = self::normalizeEndpointHost($this->moduleConfigValue('Coolify', 'public_gateway_host', '')); + if ($host !== '') { + return $host; + } + } + } catch (Throwable) { + } + + return 'api-v2.truckwash.io'; + } + + private function releaseCoolifyPublicUrl(array $target, array $context): ?string + { + $endpoint = $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]); + if (!empty($endpoint['url']) && in_array((string)($endpoint['source'] ?? ''), [ + 'manual', + 'coolify_public_url', + 'health_url', + 'coolify_domain', + 'auto_gateway', + 'coolify_resource_metadata', + ], true)) { + return (string)$endpoint['url']; + } + + $explicitPublicUrl = $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context); + if ($explicitPublicUrl !== null) { + return $explicitPublicUrl; + } + + $raw = trim((string)($context['coolify_domain'] ?? '')); + $hasCoolifyDomain = $raw !== ''; + if ($raw === '') { + $raw = trim((string)($target['health_url'] ?? '')); + } + if ($raw === '') { + return null; + } + if ($this->toBool($context['coolify_enable_ssl'] ?? false)) { + $domain = self::domainSuggestionHost($raw); + if ($domain === null) { + throw new RuntimeException('Coolify SSL requires a DNS domain routed to the load balancer.'); + } + return $hasCoolifyDomain + ? $this->releaseRoutedPublicBaseUrl('https://' . $domain, $target, $context) + : $this->normalizeReleasePublicBaseUrl('https://' . $domain, (string)($target['app'] ?? '')); + } + if (preg_match('#^https?://#i', $raw) !== 1) { + $raw = 'http://' . $raw; + } + return $hasCoolifyDomain + ? $this->releaseRoutedPublicBaseUrl($raw, $target, $context) + : $this->normalizeReleasePublicBaseUrl($raw, (string)($target['app'] ?? '')); + } + + private function releaseRoutedPublicBaseUrl(mixed $value, array $target, array $context = []): ?string + { + $app = (string)($target['app'] ?? ''); + $baseUrl = $this->normalizeReleasePublicBaseUrl($value, $app); + if ($baseUrl === null) { + return null; + } + + $parts = parse_url($baseUrl); + if (!is_array($parts) || empty($parts['host'])) { + return $baseUrl; + } + + if ($this->toBool($context['gateway_route_autoprovision'] ?? false)) { + return $baseUrl; + } + + $path = trim((string)($parts['path'] ?? ''), '/'); + if ($path !== '') { + return $baseUrl; + } + + $channelSlug = self::safeSlug((string)( + $target['channel_slug'] + ?? $context['channel_slug'] + ?? $target['release_channel'] + ?? $context['release_channel'] + ?? $target['channel'] + ?? $context['channel'] + ?? '' + )); + $appSlug = self::safeSlug($app); + if ($channelSlug === '' || $appSlug === '') { + return $baseUrl; + } + $routeSlug = self::routeSlugForChannel($channelSlug); + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + $host = strtolower((string)$parts['host']); + $port = isset($parts['port']) ? ':' . (int)$parts['port'] : ''; + + return sprintf('%s://%s%s/%s/%s', $scheme, $host, $port, $routeSlug, $appSlug); + } + + private function releaseTargetPublicBaseUrl(array $target): ?string + { + $context = is_array($target['deploy_context'] ?? null) + ? $target['deploy_context'] + : self::jsonDecode($target['deploy_context_json'] ?? null); + $app = (string)($target['app'] ?? ''); + $endpoint = is_array($target['endpoint'] ?? null) + ? $target['endpoint'] + : $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]); + return $this->normalizeReleasePublicBaseUrl($endpoint['url'] ?? null, $app) + ?? $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context) + ?? $this->normalizeReleasePublicBaseUrl($target['health_url'] ?? null, $app) + ?? $this->releaseRoutedPublicBaseUrl($context['coolify_domain'] ?? null, $target, $context) + ?? (is_array($target['endpoint'] ?? null) ? ($target['endpoint']['url'] ?? null) : null) + ?? ($this->releaseDeploymentEndpoint($target)['url'] ?? null); + } + + private function timelineSessionContext(array $context): array + { + $device = is_array($context['device'] ?? null) ? $context['device'] : []; + $browser = is_array($context['browser'] ?? null) ? $context['browser'] : []; + $os = is_array($context['os'] ?? null) ? $context['os'] : []; + $viewport = is_array($context['viewport'] ?? null) ? $context['viewport'] : []; + $frontend = is_array($context['frontend'] ?? null) ? $context['frontend'] : []; + $api = is_array($context['api'] ?? null) ? $context['api'] : []; + + return [ + 'device_type' => $this->nullableIdentifier($context['device_type'] ?? $device['type'] ?? null, 16), + 'browser_name' => $this->nullableString($context['browser_name'] ?? $browser['name'] ?? null, 64), + 'browser_version' => $this->nullableString($context['browser_version'] ?? $browser['version'] ?? null, 64), + 'os_name' => $this->nullableString($context['os_name'] ?? $os['name'] ?? null, 64), + 'os_version' => $this->nullableString($context['os_version'] ?? $os['version'] ?? null, 64), + 'viewport_width' => $this->nullableInt($context['viewport_width'] ?? $viewport['width'] ?? null), + 'viewport_height' => $this->nullableInt($context['viewport_height'] ?? $viewport['height'] ?? null), + 'device_pixel_ratio' => $this->nullableFloat($context['device_pixel_ratio'] ?? $viewport['device_pixel_ratio'] ?? null), + 'frontend_version_label' => $this->nullableString( + $context['frontend_version_label'] ?? $frontend['version_label'] ?? $context['frontend_version'] ?? null, + 128 + ), + 'frontend_commit_sha' => $this->nullableString( + $context['frontend_commit_sha'] ?? $frontend['commit_sha'] ?? $context['frontend_commit'] ?? null, + 128 + ), + 'api_version_label' => $this->nullableString( + $context['api_version_label'] ?? $api['version_label'] ?? $context['api_version'] ?? null, + 128 + ), + 'api_commit_sha' => $this->nullableString( + $context['api_commit_sha'] ?? $api['commit_sha'] ?? $context['backend_version'] ?? null, + 128 + ), + 'last_route_path' => $this->nullableString($context['route_path'] ?? $context['route'] ?? null, 255), + ]; + } + + private function latestRouteFromContext(array $context): ?string + { + foreach (['route_path', 'route'] as $key) { + $value = $this->nullableString($context[$key] ?? null, 255); + if ($value !== null) { + return $value; + } + } + return null; + } + + private function timelineSessionId(string $traceId, array $context, ?array $channel): int + { + $existing = $this->selectOne('SELECT id FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1', 's', [$traceId]); + $userAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512); + $principalType = trim((string)($context['principal_type'] ?? '')) ?: null; + $principalId = trim((string)($context['principal_id'] ?? '')) ?: null; + $customerNumber = isset($context['customer_number']) && is_numeric($context['customer_number']) + ? (int)$context['customer_number'] + : null; + $sessionContext = $this->timelineSessionContext($context); + $lastRoutePath = $sessionContext['last_route_path'] ?? null; + if ($lastRoutePath === null) { + $lastRoutePath = $this->latestRouteFromContext($context); + } + + if ($existing !== null) { + $this->execute( + "UPDATE release_timeline_sessions + SET last_seen_at = NOW(), channel_id = COALESCE(?, channel_id), channel_slug = COALESCE(?, channel_slug), + principal_type = COALESCE(?, principal_type), principal_id = COALESCE(?, principal_id), + customer_number = COALESCE(?, customer_number), + device_type = COALESCE(?, device_type), browser_name = COALESCE(?, browser_name), + browser_version = COALESCE(?, browser_version), os_name = COALESCE(?, os_name), + os_version = COALESCE(?, os_version), viewport_width = COALESCE(?, viewport_width), + viewport_height = COALESCE(?, viewport_height), device_pixel_ratio = COALESCE(?, device_pixel_ratio), + frontend_version_label = COALESCE(?, frontend_version_label), + frontend_commit_sha = COALESCE(?, frontend_commit_sha), + api_version_label = COALESCE(?, api_version_label), + api_commit_sha = COALESCE(?, api_commit_sha), + last_route_path = COALESCE(?, last_route_path), + user_agent = COALESCE(?, user_agent) + WHERE id = ?", + 'isssisssssiidssssssi', + [ + $channel['id'] ?? null, + $channel['slug'] ?? null, + $principalType, + $principalId, + $customerNumber, + $sessionContext['device_type'], + $sessionContext['browser_name'], + $sessionContext['browser_version'], + $sessionContext['os_name'], + $sessionContext['os_version'], + $sessionContext['viewport_width'], + $sessionContext['viewport_height'], + $sessionContext['device_pixel_ratio'], + $sessionContext['frontend_version_label'], + $sessionContext['frontend_commit_sha'], + $sessionContext['api_version_label'], + $sessionContext['api_commit_sha'], + $lastRoutePath, + $userAgent !== '' ? $userAgent : null, + (int)$existing['id'], + ] + ); + return (int)$existing['id']; + } + + $this->execute( + "INSERT INTO release_timeline_sessions ( + trace_id, session_hash, principal_type, principal_id, customer_number, + channel_id, channel_slug, device_type, browser_name, browser_version, os_name, + os_version, viewport_width, viewport_height, device_pixel_ratio, + frontend_version_label, frontend_commit_sha, api_version_label, api_commit_sha, + last_route_path, user_agent + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'ssssiisssssiidsssssss', + [ + $traceId, + $this->sessionHash(), + $principalType, + $principalId, + $customerNumber, + $channel['id'] ?? null, + $channel['slug'] ?? null, + $sessionContext['device_type'], + $sessionContext['browser_name'], + $sessionContext['browser_version'], + $sessionContext['os_name'], + $sessionContext['os_version'], + $sessionContext['viewport_width'], + $sessionContext['viewport_height'], + $sessionContext['device_pixel_ratio'], + $sessionContext['frontend_version_label'], + $sessionContext['frontend_commit_sha'], + $sessionContext['api_version_label'], + $sessionContext['api_commit_sha'], + $lastRoutePath, + $userAgent !== '' ? $userAgent : null, + ] + ); + + return $this->insertId(); + } + + private function sessionHash(): ?string + { + $authorization = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''); + if ($authorization === '') { + return null; + } + return hash('sha256', str_replace('Bearer ', '', $authorization)); + } + + private function inferModuleKeyFromUri(string $uri): ?string + { + $path = strtolower(explode('?', $uri)[0] ?? ''); + $map = [ + '/auth' => 'auth', + '/superuser/releases' => 'releasemanager', + '/release' => 'releasemanager', + '/superuser/coolify' => 'coolify', + '/coolify' => 'coolify', + '/failover' => 'failover', + '/edge-gateway' => 'edgegateway', + '/edgegateway' => 'edgegateway', + '/modules/action-logs' => 'moduleactionlogs', + '/worker' => 'worker', + '/economic' => 'economic', + '/stripe' => 'stripe', + '/selfserve' => 'selfserve', + '/bird' => 'bird', + '/xlvask' => 'xlvask', + ]; + + foreach ($map as $prefix => $moduleKey) { + if (str_starts_with($path, $prefix)) { + return $moduleKey; + } + } + + return null; + } + + private function timelineSummary(): array + { + $events = $this->selectOne( + "SELECT COUNT(*) AS total, + SUM(CASE WHEN severity = 'error' THEN 1 ELSE 0 END) AS errors, + MAX(created_at) AS last_event_at + FROM release_timeline_events" + ) ?? []; + $sessions = $this->selectOne('SELECT COUNT(*) AS total FROM release_timeline_sessions') ?? []; + + return [ + 'sessions' => (int)($sessions['total'] ?? 0), + 'events' => (int)($events['total'] ?? 0), + 'errors' => (int)($events['errors'] ?? 0), + 'last_event_at' => $events['last_event_at'] ?? null, + ]; + } + + private function latestModuleHealth(): array + { + return $this->selectRows( + "SELECT h.* + FROM release_module_health_snapshots h + INNER JOIN ( + SELECT module_key, MAX(checked_at) AS checked_at + FROM release_module_health_snapshots + GROUP BY module_key + ) latest ON latest.module_key = h.module_key AND latest.checked_at = h.checked_at + ORDER BY h.module_key" + ); + } + + private static function releaseBranchForChannel(array|string $channel): string + { + $slug = is_array($channel) ? (string)($channel['slug'] ?? '') : $channel; + $routeSlug = self::routeSlugForChannel($slug); + return $routeSlug !== '' ? $routeSlug : self::DEFAULT_BRANCH; + } + + private static function defaultRepositoryForApp(string $app): string + { + return match (strtolower(trim($app))) { + 'frontend' => 'copenhagentruckwash/pleno-vue', + 'api' => 'copenhagentruckwash/api', + default => '', + }; + } + + private function getOperationRun(int $id): array + { + $row = $this->selectOne( + "SELECT r.*, c.slug AS channel_slug, c.name AS channel_name, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count + FROM release_operation_runs r + LEFT JOIN release_channels c ON c.id = r.channel_id + WHERE r.id = ? + LIMIT 1", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release operation not found.'); + } + return $row; + } + + private function createOperationRun(string $operationType, array $input): int + { + $operationType = self::safeIdentifier($operationType, 64) ?: 'operation'; + $subjectType = self::safeIdentifier((string)($input['subject_type'] ?? ''), 64) ?: null; + $subjectId = trim((string)($input['subject_id'] ?? '')) ?: null; + $channelId = $this->nullablePositiveInt($input['channel_id'] ?? null); + $app = trim((string)($input['app'] ?? '')) !== '' ? $this->normalizeApp((string)$input['app']) : null; + $title = substr(trim((string)($input['title'] ?? $operationType)), 0, 255); + $context = is_array($input['context'] ?? null) ? $input['context'] : []; + $actorUserId = $this->nullablePositiveInt($input['actor_user_id'] ?? null); + + $this->execute( + "INSERT INTO release_operation_runs ( + operation_type, subject_type, subject_id, channel_id, app, status, + title, actor_user_id, context_json, started_at + ) VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?, NOW())", + 'sssissis', + [ + $operationType, + $subjectType, + $subjectId, + $channelId, + $app, + $title, + $actorUserId, + self::jsonEncode(self::redactPayload($context)), + ] + ); + + return $this->insertId(); + } + + private function recordOperationStep( + int $operationId, + string $stepKey, + string $label, + string $status, + ?string $message = null, + ?string $diagnostic = null, + ?string $solutionHint = null, + array $context = [] + ): void { + $stepKey = self::safeIdentifier($stepKey, 64) ?: 'step'; + $status = self::safeIdentifier($status, 32) ?: 'queued'; + $completedSql = in_array($status, ['passed', 'deployed', 'failed', 'warning', 'skipped'], true) ? 'NOW()' : 'NULL'; + $this->execute( + "INSERT INTO release_operation_steps ( + operation_run_id, step_key, label, status, message, diagnostic, + solution_hint, context_json, started_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), $completedSql)", + 'isssssss', + [ + $operationId, + $stepKey, + substr($label, 0, 255), + $status, + $message, + $diagnostic, + $solutionHint, + self::jsonEncode(self::redactPayload($context)), + ] + ); + } + + private function completeOperationRun(int $operationId, string $status, string $summary, ?string $solutionHint = null): void + { + $status = self::safeIdentifier($status, 32) ?: 'completed'; + $this->execute( + "UPDATE release_operation_runs + SET status = ?, summary = ?, solution_hint = ?, completed_at = NOW() + WHERE id = ?", + 'sssi', + [$status, $summary, $solutionHint, $operationId] + ); + } + + private function publicOperationRun(array $operation, bool $includeSteps = true): array + { + $operationId = (int)($operation['id'] ?? 0); + $steps = []; + if ($includeSteps && $operationId > 0) { + $steps = array_map( + fn(array $row): array => $this->publicOperationStep($row), + $this->selectRows( + 'SELECT * FROM release_operation_steps WHERE operation_run_id = ? ORDER BY id ASC', + 'i', + [$operationId] + ) + ); + } + + return [ + 'id' => $operationId, + 'operation_type' => (string)($operation['operation_type'] ?? ''), + 'subject_type' => $operation['subject_type'] ?? null, + 'subject_id' => $operation['subject_id'] ?? null, + 'channel_id' => isset($operation['channel_id']) ? (int)$operation['channel_id'] : null, + 'channel_slug' => $operation['channel_slug'] ?? null, + 'channel_name' => $operation['channel_name'] ?? null, + 'app' => $operation['app'] ?? null, + 'status' => (string)($operation['status'] ?? 'unknown'), + 'title' => $operation['title'] ?? null, + 'summary' => $operation['summary'] ?? null, + 'solution_hint' => $operation['solution_hint'] ?? null, + 'context' => self::jsonDecode($operation['context_json'] ?? null), + 'step_count' => (int)($operation['step_count'] ?? count($steps)), + 'passed_step_count' => (int)($operation['passed_step_count'] ?? 0), + 'failed_step_count' => (int)($operation['failed_step_count'] ?? 0), + 'warning_step_count' => (int)($operation['warning_step_count'] ?? 0), + 'steps' => $includeSteps ? $steps : null, + 'actor_user_id' => isset($operation['actor_user_id']) ? (int)$operation['actor_user_id'] : null, + 'started_at' => $operation['started_at'] ?? null, + 'completed_at' => $operation['completed_at'] ?? null, + 'created_at' => $operation['created_at'] ?? null, + 'updated_at' => $operation['updated_at'] ?? null, + ]; + } + + private function publicOperationStep(array $step): array + { + return [ + 'id' => (int)($step['id'] ?? 0), + 'operation_run_id' => (int)($step['operation_run_id'] ?? 0), + 'step_key' => (string)($step['step_key'] ?? ''), + 'label' => (string)($step['label'] ?? ''), + 'status' => (string)($step['status'] ?? 'unknown'), + 'message' => $step['message'] ?? null, + 'diagnostic' => $step['diagnostic'] ?? null, + 'solution_hint' => $step['solution_hint'] ?? null, + 'context' => self::jsonDecode($step['context_json'] ?? null), + 'started_at' => $step['started_at'] ?? null, + 'completed_at' => $step['completed_at'] ?? null, + 'created_at' => $step['created_at'] ?? null, + 'updated_at' => $step['updated_at'] ?? null, + ]; + } + + private function activeDeploymentKey(int $channelId, string $app): string + { + return $channelId . ':' . $this->normalizeApp($app); + } + + private function activateDeploymentForChannelApp(int $deploymentId, int $channelId, string $app): void + { + $app = $this->normalizeApp($app); + $key = $this->activeDeploymentKey($channelId, $app); + $this->execute( + "UPDATE release_deployments + SET status = 'superseded', active_channel_app_key = NULL + WHERE active_channel_app_key = ? AND id <> ?", + 'si', + [$key, $deploymentId] + ); + $this->execute( + "UPDATE release_deployments + SET status = 'superseded', active_channel_app_key = NULL + WHERE channel_id = ? AND app = ? AND id <> ? AND status = 'active'", + 'isi', + [$channelId, $app, $deploymentId] + ); + $this->execute( + "UPDATE release_deployments + SET status = 'active', active_channel_app_key = ?, completed_at = COALESCE(completed_at, NOW()) + WHERE id = ?", + 'si', + [$key, $deploymentId] + ); + } + + private function currentDeploymentForChannelApp(int $channelId, string $app): ?array + { + $app = $this->normalizeApp($app); + $key = $this->activeDeploymentKey($channelId, $app); + $row = $this->selectOne( + "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url + FROM release_deployments d + INNER JOIN release_channels c ON c.id = d.channel_id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE d.active_channel_app_key = ? + LIMIT 1", + 's', + [$key] + ); + if ($row !== null) { + return $row; + } + + return $this->selectOne( + "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url + FROM release_deployments d + INNER JOIN release_channels c ON c.id = d.channel_id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE d.channel_id = ? AND d.app = ? AND d.status = 'active' + ORDER BY d.completed_at DESC, d.id DESC + LIMIT 1", + 'is', + [$channelId, $app] + ); + } + + private function deploymentTargetForChannelApp(int $channelId, string $app): ?array + { + return $this->selectOne( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id + WHERE t.deleted_at IS NULL AND t.channel_id = ? AND t.app = ? + ORDER BY t.auto_deploy DESC, t.id DESC + LIMIT 1", + 'is', + [$channelId, $this->normalizeApp($app)] + ); + } + + private function channelCurrentDeployments(int $channelId): array + { + $deployments = []; + foreach (self::APPS as $app) { + $row = $this->currentDeploymentForChannelApp($channelId, $app); + $deployments[$app] = $row !== null ? $this->publicDeployment($row) : null; + } + return $deployments; + } + + private function channelBranchStatus(array $channel): array + { + $channelId = (int)($channel['id'] ?? 0); + if ($this->channelUsesProductionServices($channel)) { + $serviceChannel = $this->productionServiceChannel(); + $serviceChannelId = (int)($serviceChannel['id'] ?? 0); + $branch = self::releaseBranchForChannel($serviceChannel); + $status = []; + foreach (self::APPS as $app) { + $target = $this->deploymentTargetForChannelApp($serviceChannelId, $app); + $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); + $status[$app] = [ + 'repository' => $repository, + 'branch' => $branch, + 'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')), + 'target_configured' => true, + 'target_branch' => $target['branch'] ?? $branch, + 'state' => self::PRODUCTION_SERVICE_POLICY, + 'retry_action' => null, + 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + ]; + } + return $status; + } + + $branch = self::releaseBranchForChannel($channel); + $status = []; + foreach (self::APPS as $app) { + $target = $this->deploymentTargetForChannelApp($channelId, $app); + $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); + $status[$app] = [ + 'repository' => $repository, + 'branch' => $branch, + 'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')), + 'target_configured' => $target !== null, + 'target_branch' => $target['branch'] ?? null, + 'state' => $target !== null ? 'ready_to_check' : 'missing_target', + 'retry_action' => $target !== null ? 'sync_channel' : 'configure_target', + ]; + } + return $status; + } + + private function releaseDataServicesSummary(): array + { + $summary = []; + foreach ($this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug") as $channel) { + $summary[(string)$channel['slug']] = $this->channelDataServicesSummary($channel); + } + return $summary; + } + + private function channelDataServicesSummary(array $channel): array + { + $serviceChannel = $this->channelUsesProductionServices($channel) + ? $this->productionServiceChannel() + : $channel; + $serviceSet = $this->activeServiceSetForChannel((int)($serviceChannel['id'] ?? 0)); + $serviceSetMode = $serviceSet !== null ? (string)($serviceSet['mode'] ?? 'attach_existing') : self::PRODUCTION_DATA_POLICY; + $mode = $this->serviceSetDataPolicy($serviceSet); + $policy = $this->replicationPolicyForMode($mode); + $services = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $target = $serviceSet !== null + ? $this->nullableCoolifyTarget($this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null)) + : null; + $replication = is_array($target['replication'] ?? null) ? $target['replication'] : []; + $lastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : []; + $services[$kind] = [ + 'kind' => $kind, + 'mode' => $mode, + 'service_set_mode' => $serviceSetMode, + 'data_policy' => $mode, + 'state' => $target !== null ? (string)($target['availability_state'] ?? 'configured') : 'production_shared', + 'target' => $target, + 'replication_policy' => $policy, + 'default_shared_production' => $mode === self::PRODUCTION_DATA_POLICY, + 'change_requires_explicit_action' => true, + 'entity_facts' => [ + 'service' => $kind, + 'mode' => $mode, + 'service_set_mode' => $serviceSetMode, + 'data_policy' => $mode, + 'node' => $target['instance_label'] ?? null, + 'online_state' => $target['deployment_status'] ?? $target['availability_state'] ?? 'production_shared', + 'hostname' => $replication['host'] ?? null, + 'port' => $replication['port'] ?? null, + 'uptime' => $lastStatus['uptime'] ?? $lastStatus['uptime_text'] ?? null, + 'version' => $lastStatus['version'] ?? null, + 'replication_role' => $replication['role'] ?? null, + 'replication_lag' => $lastStatus['lag'] ?? $lastStatus['lag_seconds'] ?? null, + 'last_check' => $replication['last_checked_at'] ?? $target['last_reconciled_at'] ?? null, + ], + ]; + } + + return [ + 'channel_id' => (int)($channel['id'] ?? 0), + 'channel_slug' => (string)($channel['slug'] ?? ''), + 'service_channel_id' => (int)($serviceChannel['id'] ?? 0), + 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + 'mode' => $serviceSet === null ? 'production_shared' : $mode, + 'service_set_mode' => $serviceSetMode, + 'data_policy' => $mode, + 'data_service_mode' => $mode, + 'policy' => $policy, + 'services' => $services, + ]; + } + + private function releaseReplicationPolicySummary(): array + { + return [ + 'default_mode' => 'production_shared', + 'normal_sync_changes_data_services' => false, + 'allowed_modes' => [ + 'production_shared', + 'attach_existing', + 'clone_existing', + 'fresh_empty', + 'isolated_stack', + ], + 'service_kinds' => self::STACK_DATA_KINDS, + 'change_control' => 'Data service mode changes are only allowed through explicit replication/failover actions.', + ]; + } + + private function replicationPolicyForMode(string $mode): array + { + return [ + 'mode' => $mode, + 'production_shared' => in_array($mode, ['production_shared', 'attach_existing'], true), + 'replication_configurable' => true, + 'failover_configurable' => true, + 'normal_sync_changes_service' => false, + ]; + } + + private function activeServiceSetForChannel(int $channelId): ?array + { + return $this->selectOne( + "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_channel_versions v + INNER JOIN release_service_sets s ON s.id = v.service_set_id + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE v.channel_id = ? AND v.active = 1 AND s.deleted_at IS NULL + ORDER BY v.activated_at DESC, v.id DESC + LIMIT 1", + 'i', + [$channelId] + ); + } + + private function releaseCoolifySummary(): array + { + $targetCount = (int)($this->selectOne('SELECT COUNT(*) AS total FROM release_deployment_targets WHERE deleted_at IS NULL')['total'] ?? 0); + $instanceCount = $this->tableExists('coolify_instances') + ? (int)($this->selectOne('SELECT COUNT(*) AS total FROM coolify_instances WHERE deleted_at IS NULL')['total'] ?? 0) + : 0; + return [ + 'integrated' => $this->tableExists('coolify_instances'), + 'panel' => 'release_manager', + 'instances' => $instanceCount, + 'deployment_targets' => $targetCount, + 'legacy_route' => '/superuser/configuration/coolify', + 'redirect_panel' => '/superuser/configuration/releases/integrations?panel=coolify', + 'entity_facts' => [ + 'instances' => $instanceCount, + 'deployment_targets' => $targetCount, + 'status' => $this->tableExists('coolify_instances') ? 'integrated' : 'not_configured', + 'last_check' => date('c'), + ], + ]; + } + + private function releaseFailoverSummary(): array + { + $replicationHosts = $this->tableExists('replication_hosts') + ? (int)($this->selectOne('SELECT COUNT(*) AS total FROM replication_hosts')['total'] ?? 0) + : 0; + return [ + 'integrated' => $this->tableExists('replication_hosts'), + 'panel' => 'release_manager', + 'replication_hosts' => $replicationHosts, + 'default_data_mode' => 'production_shared', + 'normal_sync_triggers_failover' => false, + 'legacy_route' => '/superuser/configuration/failover', + 'redirect_panel' => '/superuser/configuration/releases/data-services?panel=failover', + 'entity_facts' => [ + 'replication_hosts' => $replicationHosts, + 'default_data_mode' => 'production_shared', + 'readiness' => $this->tableExists('replication_hosts') ? 'ready' : 'not_configured', + 'normal_sync_triggers_failover' => false, + 'last_check' => date('c'), + ], + ]; + } + + private function githubRepositoryAccess(array $input): array + { + $tokenConfigured = $this->hasGithubApiToken(); + $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? '')); + $branch = trim((string)($input['branch'] ?? '')); + $rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? '')); + $commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha); + + if ($repository === '') { + return [ + 'ok' => false, + 'status' => 'invalid_repository', + 'token_configured' => $tokenConfigured, + 'message' => 'GitHub repository must use owner/repo format.', + 'repository' => trim((string)($input['repository'] ?? '')), + 'branch' => $branch, + 'commit_mode' => $commitMode, + ]; + } + if ($commitMode === 'specific' && $rawCommitSha === '') { + return [ + 'ok' => false, + 'status' => 'commit_required', + 'token_configured' => $tokenConfigured, + 'message' => 'Specific commit deployment requires a commit SHA.', + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => $commitMode, + ]; + } + + if (!$tokenConfigured) { + $response = $this->githubTokenMissingResponse($repository, $branch); + $response['commit_mode'] = $commitMode; + return $response; + } + + try { + $repo = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository)); + $defaultBranch = trim((string)($repo['default_branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + $branch = $branch !== '' ? $branch : $defaultBranch; + $branchRow = $this->githubRequest( + 'GET', + '/repos/' . $this->githubRepositoryPath($repository) . '/branches/' . rawurlencode($branch) + ); + $latestCommitSha = trim((string)($branchRow['commit']['sha'] ?? '')); + $commitSha = $latestCommitSha; + $commitUrl = (string)($branchRow['commit']['url'] ?? ''); + $latestCommit = []; + if ($latestCommitSha !== '') { + $latestCommitRow = $this->githubRequest( + 'GET', + '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($latestCommitSha) + ); + $latestCommit = is_array($latestCommitRow) ? $this->publicGithubCommit($latestCommitRow) : []; + $commitUrl = (string)($latestCommit['html_url'] ?? $commitUrl); + } + $commit = $latestCommit; + if ($commitMode === 'specific') { + $commitRow = $this->githubRequest( + 'GET', + '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($rawCommitSha) + ); + $commit = is_array($commitRow) ? $this->publicGithubCommit($commitRow) : []; + $commitSha = trim((string)($commit['sha'] ?? (is_array($commitRow) ? ($commitRow['sha'] ?? null) : null) ?? $rawCommitSha)); + $commitUrl = (string)($commit['html_url'] ?? (is_array($commitRow) ? ($commitRow['html_url'] ?? null) : null) ?? $commitUrl); + if ($latestCommitSha !== '' && $commitSha !== '' && $commitSha !== $latestCommitSha) { + $comparison = $this->githubRequest( + 'GET', + '/repos/' . $this->githubRepositoryPath($repository) . '/compare/' . rawurlencode($commitSha) . '...' . rawurlencode($latestCommitSha) + ); + $comparisonStatus = (string)($comparison['status'] ?? ''); + if (!in_array($comparisonStatus, ['behind', 'identical'], true)) { + throw new RuntimeException(sprintf('Commit %s is not reachable from branch %s.', $commitSha, $branch)); + } + } + } + + return [ + 'ok' => true, + 'status' => 'accessible', + 'token_configured' => true, + 'message' => $commitMode === 'specific' + ? 'Repository, branch, and commit are accessible with the configured GitHub token.' + : 'Repository and branch are accessible with the configured GitHub token.', + 'repository' => $repository, + 'branch' => $branch, + 'default_branch' => $defaultBranch, + 'private' => (bool)($repo['private'] ?? false), + 'html_url' => $repo['html_url'] ?? null, + 'commit_mode' => $commitMode, + 'commit_sha' => $commitSha !== '' ? $commitSha : null, + 'latest_commit_sha' => $latestCommitSha !== '' ? $latestCommitSha : null, + 'commit' => $commit !== [] ? $commit : null, + 'latest_commit' => $latestCommit !== [] ? $latestCommit : null, + 'commit_authored_at' => $commit['authored_at'] ?? null, + 'commit_url' => $commitUrl !== '' ? $commitUrl : null, + ]; + } catch (Throwable $throwable) { + return [ + 'ok' => false, + 'status' => 'inaccessible', + 'token_configured' => true, + 'message' => $throwable->getMessage(), + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => $commitMode, + ]; + } + } + + private function normalizeCommitMode(string $commitMode, string $commitSha): string + { + $mode = strtolower(trim($commitMode)); + $commit = strtolower(trim($commitSha)); + if ($mode === 'specific') { + return 'specific'; + } + if ($mode === 'latest' || $mode === 'head' || $commit === '' || in_array($commit, ['latest', 'head'], true)) { + return 'latest'; + } + return 'specific'; + } + + private function githubTokenMissingResponse(?string $repository = null, ?string $branch = null): array + { + return [ + 'ok' => false, + 'status' => 'not_configured', + 'token_configured' => false, + 'message' => 'Configure ReleaseManager github_token or RELEASE_MANAGER_GITHUB_TOKEN before using private GitHub repositories.', + 'repository' => $repository, + 'branch' => $branch, + 'repositories' => [], + 'branches' => [], + 'commits' => [], + ]; + } + + private function hasGithubApiToken(): bool + { + return $this->githubApiToken() !== ''; + } + + private function githubEnvToken(): string + { + foreach (['RELEASE_MANAGER_GITHUB_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'] as $key) { + $value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? ''))); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function githubApiToken(): string + { + $envToken = $this->githubEnvToken(); + if ($envToken !== '') { + return $envToken; + } + + $token = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', '')); + if ($token !== '' && str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) { + try { + $token = replication_secret_box::decrypt($token); + } catch (Throwable) { + $token = ''; + } + } + return trim($token); + } + + private function githubApiBaseUrl(): string + { + $value = trim((string)(getenv('RELEASE_MANAGER_GITHUB_API_URL') ?: ($_SERVER['RELEASE_MANAGER_GITHUB_API_URL'] ?? ''))); + if ($value === '') { + $value = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_api_url', 'https://api.github.com')); + } + $value = rtrim($value, '/'); + return preg_match('#^https?://#i', $value) === 1 ? $value : 'https://api.github.com'; + } + + private function githubRepositoryPath(string $repository): string + { + [$owner, $name] = explode('/', $repository, 2); + return rawurlencode($owner) . '/' . rawurlencode($name); + } + + private function githubRequest(string $method, string $path, array $query = []): array + { + $token = $this->githubApiToken(); + if ($token === '') { + throw new RuntimeException('GitHub token is not configured.'); + } + + $url = $this->githubApiBaseUrl() . '/' . ltrim($path, '/'); + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize GitHub API request.'); + } + + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3); + curl_setopt($curl, CURLOPT_TIMEOUT, 10); + curl_setopt($curl, CURLOPT_NOSIGNAL, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, [ + 'Accept: application/vnd.github+json', + 'Authorization: Bearer ' . $token, + 'User-Agent: Truckwash-Release-Manager', + 'X-GitHub-Api-Version: 2022-11-28', + ]); + + $raw = curl_exec($curl); + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if ($raw === false) { + throw new RuntimeException('GitHub API request failed: ' . $error); + } + + $decoded = []; + if (trim((string)$raw) !== '') { + $decodedJson = json_decode((string)$raw, true); + $decoded = is_array($decodedJson) ? $decodedJson : ['raw' => (string)$raw]; + } + + if ($status < 200 || $status >= 300) { + $message = is_array($decoded) + ? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status)) + : ('HTTP ' . $status); + throw new RuntimeException('GitHub API request failed: ' . $message); + } + + return $decoded; + } + + private function publicGithubRepository(array $row): array + { + $fullName = self::normalizeGithubRepositoryName((string)($row['full_name'] ?? '')); + return [ + 'id' => isset($row['id']) ? (int)$row['id'] : null, + 'name' => (string)($row['name'] ?? ''), + 'full_name' => $fullName, + 'private' => (bool)($row['private'] ?? false), + 'default_branch' => (string)($row['default_branch'] ?? self::DEFAULT_BRANCH), + 'description' => $row['description'] ?? null, + 'html_url' => $row['html_url'] ?? null, + 'clone_url' => $row['clone_url'] ?? null, + 'ssh_url' => $row['ssh_url'] ?? null, + 'pushed_at' => $row['pushed_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + ]; + } + + private function publicGithubBranch(array $row): array + { + return [ + 'name' => (string)($row['name'] ?? ''), + 'commit_sha' => $row['commit']['sha'] ?? null, + 'protected' => (bool)($row['protected'] ?? false), + ]; + } + + private function publicGithubCommit(array $row): array + { + $sha = (string)($row['sha'] ?? ''); + $message = (string)($row['commit']['message'] ?? ''); + $title = trim(strtok($message, "\n") ?: $message); + return [ + 'sha' => $sha, + 'short_sha' => substr($sha, 0, 12), + 'message' => $title, + 'author_name' => $row['commit']['author']['name'] ?? $row['author']['login'] ?? null, + 'authored_at' => $row['commit']['author']['date'] ?? null, + 'html_url' => $row['html_url'] ?? null, + ]; + } + + private function releaseSuggestions(): array + { + $channels = $this->listChannels(); + $targets = $this->listDeploymentTargets(); + $deployments = $this->listDeployments(50); + $versions = release_manager_schema_bootstrap::tablesExist() + ? $this->selectRows( + "SELECT app, repository, branch, deployed_url, build_url + FROM release_versions + WHERE repository IS NOT NULL OR branch IS NOT NULL OR deployed_url IS NOT NULL + ORDER BY created_at DESC + LIMIT 100" + ) + : []; + + $repositories = []; + $branches = [self::DEFAULT_BRANCH, 'main', 'develop', 'staging']; + $frontendUrls = []; + $apiUrls = []; + $healthUrls = []; + $loadBalancerDomains = []; + $serviceUuids = []; + + foreach ([ + $this->moduleConfigValue('Coolify', 'public_gateway_host', 'api-v2.truckwash.io'), + getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null), + getenv('PUBLIC_GATEWAY_HOST') ?: ($_SERVER['PUBLIC_GATEWAY_HOST'] ?? null), + getenv('RELEASE_LOAD_BALANCER_DOMAIN') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAIN'] ?? null), + getenv('RELEASE_LOAD_BALANCER_DOMAINS') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAINS'] ?? null), + ] as $domain) { + $this->appendDomainSuggestion($loadBalancerDomains, $domain); + } + + foreach (array_merge($targets, $deployments, $versions) as $row) { + $this->appendSuggestion($repositories, $row['repository'] ?? null); + $this->appendSuggestion($branches, $row['branch'] ?? null); + $this->appendSuggestion($healthUrls, $row['health_url'] ?? null); + $this->appendSuggestion($healthUrls, $row['deployment_url'] ?? null); + $this->appendSuggestion($healthUrls, $row['deployed_url'] ?? null); + $this->appendSuggestion($serviceUuids, $row['coolify_service_uuid'] ?? null); + } + + foreach ($channels as $channel) { + $slug = (string)($channel['slug'] ?? ''); + $this->appendSuggestion($branches, $slug !== '' ? 'release/' . $slug : null); + $this->appendSuggestion($frontendUrls, $channel['frontend_base_url'] ?? null); + $this->appendSuggestion($apiUrls, $channel['api_base_url'] ?? null); + if (!empty($channel['frontend_base_url'])) { + $this->appendSuggestion($healthUrls, rtrim((string)$channel['frontend_base_url'], '/') . '/health'); + } + if (!empty($channel['api_base_url'])) { + $this->appendSuggestion($healthUrls, rtrim((string)$channel['api_base_url'], '/') . '/ping'); + } + } + + foreach ([ + 'GITHUB_REPOSITORY', + 'RELEASE_FRONTEND_REPOSITORY', + 'RELEASE_API_REPOSITORY', + 'FRONTEND_GITHUB_REPOSITORY', + 'API_GITHUB_REPOSITORY', + ] as $key) { + $this->appendSuggestion($repositories, getenv($key) ?: ($_SERVER[$key] ?? null)); + } + + foreach (['GITHUB_REF_NAME', 'RELEASE_BRANCH', 'FRONTEND_BRANCH', 'API_BRANCH'] as $key) { + $this->appendSuggestion($branches, getenv($key) ?: ($_SERVER[$key] ?? null)); + } + + foreach (['FRONTEND_URL', 'APP_URL', 'VITE_APP_URL'] as $key) { + $this->appendSuggestion($frontendUrls, getenv($key) ?: ($_SERVER[$key] ?? null)); + } + foreach (['API_URL', 'BACKEND_URL', 'PUBLIC_API_URL'] as $key) { + $this->appendSuggestion($apiUrls, getenv($key) ?: ($_SERVER[$key] ?? null)); + } + + $origin = trim((string)($_SERVER['HTTP_ORIGIN'] ?? '')); + if ($origin !== '') { + $this->appendSuggestion($frontendUrls, $origin); + } + $host = trim((string)($_SERVER['HTTP_HOST'] ?? '')); + if ($host !== '') { + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $this->appendSuggestion($apiUrls, $scheme . '://' . $host); + } + + $coolifyInstances = []; + $coolifyProjects = []; + $coolifyServices = []; + $coolifyGithubApps = []; + if ($this->tableExists('coolify_instances')) { + $instanceRows = $this->selectRows( + 'SELECT id, label, base_url, api_token_secret, status, default_project_uuid, default_environment_uuid, default_environment_name, default_server_uuid + FROM coolify_instances + WHERE deleted_at IS NULL + ORDER BY status = \'ok\' DESC, label' + ); + $coolifyInstances = array_map(static function (array $row): array { + return [ + 'id' => (int)($row['id'] ?? 0), + 'label' => (string)($row['label'] ?? ''), + 'base_url' => (string)($row['base_url'] ?? ''), + 'status' => (string)($row['status'] ?? 'unknown'), + 'default_project_uuid' => $row['default_project_uuid'] ?? null, + 'default_environment_uuid' => $row['default_environment_uuid'] ?? null, + 'default_environment_name' => $row['default_environment_name'] ?? null, + 'default_server_uuid' => $row['default_server_uuid'] ?? null, + ]; + }, $instanceRows); + + foreach ($instanceRows as $instanceRow) { + foreach ($this->coolifyProjectSuggestions($instanceRow) as $project) { + $coolifyProjects[] = $project; + } + foreach ($this->coolifyGithubAppSuggestions($instanceRow) as $githubApp) { + $coolifyGithubApps[] = $githubApp; + } + foreach ($this->coolifyServiceSuggestions($instanceRow) as $service) { + $coolifyServices[] = $service; + $this->appendSuggestion($serviceUuids, $service['uuid'] ?? null); + foreach (($service['urls'] ?? []) as $url) { + $this->appendSuggestion($healthUrls, $url); + } + } + } + } + + $channelPresets = [ + [ + 'slug' => 'stable', + 'name' => 'Stable', + 'description' => 'Default production release channel.', + 'rollout_percent' => 100, + 'default_channel' => true, + 'replay_enabled' => false, + 'capture_level' => 'metadata', + 'retention_days' => 14, + ], + [ + 'slug' => 'canary', + 'name' => 'Canary', + 'description' => 'Small early-access channel for validating a release before broad rollout.', + 'rollout_percent' => 5, + 'default_channel' => false, + 'replay_enabled' => true, + 'capture_level' => 'full_redacted', + 'retention_days' => 7, + ], + [ + 'slug' => 'beta', + 'name' => 'Beta', + 'description' => 'Customer or staff opt-in channel for release candidate validation.', + 'rollout_percent' => 0, + 'default_channel' => false, + 'replay_enabled' => true, + 'capture_level' => 'metadata', + 'retention_days' => 14, + ], + [ + 'slug' => 'internal', + 'name' => 'Internal', + 'description' => 'Staff-only channel for internal verification and support replay.', + 'rollout_percent' => 0, + 'default_channel' => false, + 'replay_enabled' => true, + 'capture_level' => 'full_redacted', + 'retention_days' => 14, + ], + ]; + + $frontendRepository = $this->firstSuggestion($repositories, ['front-end', 'frontend', 'vue']) ?? ($repositories[0] ?? ''); + $apiRepository = $this->firstSuggestion($repositories, ['backend', 'api', 'php']) ?? ($repositories[1] ?? $repositories[0] ?? ''); + + return [ + 'github_token_configured' => $this->hasGithubApiToken(), + 'github_api_url' => $this->githubApiBaseUrl(), + 'repositories' => array_values($repositories), + 'branches' => array_values($branches), + 'frontend_base_urls' => array_values($frontendUrls), + 'api_base_urls' => array_values($apiUrls), + 'health_urls' => array_values($healthUrls), + 'load_balancer_domains' => array_values($loadBalancerDomains), + 'coolify_instances' => $coolifyInstances, + 'coolify_projects' => $coolifyProjects, + 'coolify_github_apps' => $coolifyGithubApps, + 'coolify_services' => $coolifyServices, + 'coolify_service_uuids' => array_values($serviceUuids), + 'channel_presets' => $channelPresets, + 'target_presets' => [ + [ + 'label' => 'Frontend target', + 'app' => 'frontend', + 'repository' => $frontendRepository, + 'branch' => $branches[0] ?? self::DEFAULT_BRANCH, + 'health_url' => $healthUrls[0] ?? '', + 'auto_deploy' => true, + ], + [ + 'label' => 'API target', + 'app' => 'api', + 'repository' => $apiRepository, + 'branch' => $branches[0] ?? self::DEFAULT_BRANCH, + 'health_url' => $this->firstSuggestion($healthUrls, ['/ping']) + ?? $this->firstSuggestion($healthUrls, ['/health']) + ?? '', + 'auto_deploy' => true, + ], + ], + 'setup_steps' => [ + ['key' => 'channels', 'done' => count($channels) > 0], + ['key' => 'targets', 'done' => count($targets) > 0], + ['key' => 'deployments', 'done' => count($deployments) > 0], + ['key' => 'timeline', 'done' => (int)($this->timelineSummary()['events'] ?? 0) > 0], + ], + ]; + } + + private function appendSuggestion(array &$values, mixed $value): void + { + $value = trim((string)($value ?? '')); + if ($value === '') { + return; + } + foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) { + $part = trim($part); + if ($part !== '' && !in_array($part, $values, true)) { + $values[] = $part; + } + } + } + + private function appendDomainSuggestion(array &$values, mixed $value): void + { + $value = trim((string)($value ?? '')); + if ($value === '') { + return; + } + + foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) { + $domain = self::domainSuggestionHost($part); + if ($domain !== null && !in_array($domain, $values, true)) { + $values[] = $domain; + } + } + } + + private static function domainSuggestionHost(mixed $value): ?string + { + $raw = trim((string)($value ?? '')); + if ($raw === '') { + return null; + } + + $candidate = preg_match('#^https?://#i', $raw) === 1 ? $raw : 'https://' . $raw; + $host = parse_url($candidate, PHP_URL_HOST); + $port = parse_url($candidate, PHP_URL_PORT); + $host = strtolower(trim((string)$host, "[] \t\n\r\0\x0B.")); + + if ( + $host === '' + || $port !== null + || $host === 'localhost' + || str_ends_with($host, '.localhost') + || str_contains($host, '/') + || filter_var($host, FILTER_VALIDATE_IP) !== false + ) { + return null; + } + + return $host; + } + + private function firstSuggestion(array $values, array $needles): ?string + { + foreach ($values as $value) { + foreach ($needles as $needle) { + if (stripos((string)$value, $needle) !== false) { + return (string)$value; + } + } + } + return null; + } + + private function coolifyProjectSuggestions(array $instance): array + { + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return []; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $projects = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listProjects(); + } catch (Throwable) { + return []; + } + + $suggestions = []; + foreach ($this->payloadRows($projects) as $row) { + if (!is_array($row)) { + continue; + } + $uuid = trim((string)($row['uuid'] ?? '')); + if ($uuid === '') { + continue; + } + $suggestions[] = [ + 'instance_id' => (int)($instance['id'] ?? 0), + 'instance_label' => (string)($instance['label'] ?? ''), + 'uuid' => $uuid, + 'name' => (string)($row['name'] ?? $uuid), + 'description' => (string)($row['description'] ?? ''), + 'default' => $uuid === trim((string)($instance['default_project_uuid'] ?? '')), + ]; + } + + return array_slice($suggestions, 0, 50); + } + + private function coolifyGithubAppSuggestions(array $instance): array + { + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return []; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps(); + } catch (Throwable) { + return []; + } + + $suggestions = []; + foreach ($this->payloadRows($apps) as $row) { + if (!is_array($row)) { + continue; + } + $uuid = trim((string)($row['uuid'] ?? '')); + if ($uuid === '') { + continue; + } + $suggestions[] = [ + 'instance_id' => (int)($instance['id'] ?? 0), + 'instance_label' => (string)($instance['label'] ?? ''), + 'uuid' => $uuid, + 'name' => (string)($row['name'] ?? $uuid), + 'organization' => (string)($row['organization'] ?? ''), + 'type' => (string)($row['type'] ?? ''), + 'is_system_wide' => (bool)($row['is_system_wide'] ?? false), + 'html_url' => (string)($row['html_url'] ?? ''), + ]; + } + + return array_slice($suggestions, 0, 50); + } + + private function coolifyServiceSuggestions(array $instance): array + { + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return []; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $services = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServices(); + } catch (Throwable) { + return []; + } + + $rows = $this->payloadRows($services); + $suggestions = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $uuid = trim((string)($row['uuid'] ?? $row['id'] ?? '')); + if ($uuid === '') { + continue; + } + $urls = []; + foreach (['fqdn', 'domain', 'url'] as $key) { + $this->appendSuggestion($urls, $row[$key] ?? null); + } + foreach (['urls', 'domains'] as $key) { + if (!is_array($row[$key] ?? null)) { + continue; + } + foreach ($row[$key] as $url) { + if (is_array($url)) { + $this->appendSuggestion($urls, $url['url'] ?? $url['domain'] ?? $url['fqdn'] ?? null); + } else { + $this->appendSuggestion($urls, $url); + } + } + } + + $suggestions[] = [ + 'instance_id' => (int)($instance['id'] ?? 0), + 'instance_label' => (string)($instance['label'] ?? ''), + 'uuid' => $uuid, + 'name' => (string)($row['name'] ?? $row['service_name'] ?? $uuid), + 'status' => (string)($row['status'] ?? $row['deployment_status'] ?? 'unknown'), + 'urls' => array_values($urls), + ]; + } + + return array_slice($suggestions, 0, 50); + } + + private function payloadRows(array $payload): array + { + if ($payload === []) { + return []; + } + if (array_keys($payload) === range(0, count($payload) - 1)) { + return $payload; + } + foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) { + if (is_array($payload[$key] ?? null)) { + return $this->payloadRows($payload[$key]); + } + } + return []; + } + + private function normalizeChannelInput(array $input, bool $creating): array + { + $slug = self::safeSlug((string)($input['slug'] ?? '')); + if ($slug === '') { + throw new RuntimeException('Release channel slug is required.'); + } + + $name = trim((string)($input['name'] ?? ($creating ? '' : $slug))); + if ($name === '') { + throw new RuntimeException('Release channel name is required.'); + } + + $retention = (int)($input['retention_days'] ?? 14); + return [ + 'slug' => $slug, + 'name' => substr($name, 0, 128), + 'description' => trim((string)($input['description'] ?? '')) ?: null, + 'enabled' => $this->toBool($input['enabled'] ?? true) ? 1 : 0, + 'default_channel' => $this->toBool($input['default_channel'] ?? false) ? 1 : 0, + 'rollout_percent' => max(0, min(100, (float)($input['rollout_percent'] ?? 0))), + 'frontend_base_url' => trim((string)($input['frontend_base_url'] ?? '')) ?: null, + 'api_base_url' => trim((string)($input['api_base_url'] ?? '')) ?: null, + 'replay_enabled' => $this->toBool($input['replay_enabled'] ?? false) ? 1 : 0, + 'capture_level' => $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'metadata')), + 'retention_days' => max(1, min(365, $retention > 0 ? $retention : 14)), + 'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null), + ]; + } + + private function channelFromInput(array $input): array + { + $id = $this->nullablePositiveInt($input['channel_id'] ?? null); + if ($id !== null) { + return $this->getChannel($id); + } + + $slug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? '')); + if ($slug !== '') { + $channel = $this->findChannelBySlug(self::channelSlugForRoute($slug)); + if ($channel !== null) { + return $channel; + } + } + + throw new RuntimeException('Release channel is required.'); + } + + private function deploymentTargetFromInput(array $input, int $channelId, string $app): ?array + { + $targetId = $this->nullablePositiveInt($input['target_id'] ?? null); + if ($targetId !== null) { + return $this->getDeploymentTarget($targetId); + } + + return $this->selectOne( + "SELECT * FROM release_deployment_targets + WHERE deleted_at IS NULL AND channel_id = ? AND app = ? + ORDER BY auto_deploy DESC, id DESC + LIMIT 1", + 'is', + [$channelId, $app] + ); + } + + private function normalizeServiceSetMode(string $value): string + { + $mode = strtolower(trim($value)); + if (!in_array($mode, self::SERVICE_SET_MODES, true)) { + throw new RuntimeException('Release service set mode must be attach_existing, clone_existing, fresh_empty, or isolated_stack.'); + } + return $mode; + } + + private function channelFromInputOrDefault(array $input, ?array $source = null): array + { + foreach (['channel_id', 'channel_slug', 'channel'] as $key) { + if (array_key_exists($key, $input) && trim((string)$input[$key]) !== '') { + return $this->channelFromInput($input); + } + } + + if ($source !== null && !empty($source['channel_id'])) { + return $this->getChannel((int)$source['channel_id']); + } + + return $this->defaultChannel(); + } + + private function serviceSetTargetIdFromInput(array $input, string $app, ?array $source): ?int + { + $aliases = $app === 'api' + ? ['api_target_id', 'php_target_id', 'backend_target_id'] + : ['frontend_target_id']; + $targets = is_array($input['targets'] ?? null) ? $input['targets'] : []; + if (is_array($targets[$app] ?? null)) { + foreach (['target_id', 'id'] as $key) { + $aliases[] = $app . '.' . $key; + } + } + + foreach ($aliases as $key) { + $value = str_contains($key, '.') + ? ($targets[$app][substr($key, strpos($key, '.') + 1)] ?? null) + : ($input[$key] ?? null); + $id = $this->nullablePositiveInt($value); + if ($id === null) { + continue; + } + $target = $this->getDeploymentTarget($id); + if ((string)($target['app'] ?? '') !== $app) { + throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app)); + } + return $id; + } + + $sourceKey = $app . '_target_id'; + return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null; + } + + private function serviceSetDataTargetIdFromInput(array $input, string $kind, ?array $source): ?int + { + $dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : []; + $value = $input[$kind . '_coolify_target_id'] + ?? $input[$kind . '_target_id'] + ?? $dataTargets[$kind . '_coolify_target_id'] + ?? $dataTargets[$kind . '_target_id'] + ?? $dataTargets[$kind] + ?? null; + $id = $this->nullablePositiveInt($value); + if ($id !== null) { + $target = $this->nullableCoolifyTarget($id); + if ($target !== null && (string)($target['kind'] ?? '') !== $kind) { + throw new RuntimeException(sprintf('Selected %s data service target has the wrong replica kind.', $kind)); + } + return $id; + } + + $sourceKey = $kind . '_coolify_target_id'; + return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null; + } + + private function serviceSetInputHasExplicitDataTargets(array $input): bool + { + $dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : []; + foreach (self::STACK_DATA_KINDS as $kind) { + foreach ([ + $input[$kind . '_coolify_target_id'] ?? null, + $input[$kind . '_target_id'] ?? null, + $dataTargets[$kind . '_coolify_target_id'] ?? null, + $dataTargets[$kind . '_target_id'] ?? null, + $dataTargets[$kind] ?? null, + ] as $value) { + if ($this->nullablePositiveInt($value) !== null) { + return true; + } + } + } + return false; + } + + private function isBetaChannel(array $channel): bool + { + return self::safeSlug((string)($channel['slug'] ?? '')) === 'beta'; + } + + private function channelUsesProductionServices(array $channel): bool + { + return in_array( + self::safeSlug((string)($channel['slug'] ?? '')), + self::PRODUCTION_SERVICE_CHANNELS, + true + ); + } + + private function serviceSetDataPolicy(?array $serviceSet): string + { + if ($serviceSet === null) { + return self::PRODUCTION_DATA_POLICY; + } + + $mode = strtolower(trim((string)($serviceSet['mode'] ?? ''))); + if (in_array($mode, ['clone_existing', 'fresh_empty', 'isolated_stack'], true)) { + return $mode; + } + if (in_array($mode, ['', 'attach_existing', self::PRODUCTION_DATA_POLICY], true)) { + return self::PRODUCTION_DATA_POLICY; + } + + $metadata = is_array($serviceSet['metadata'] ?? null) + ? $serviceSet['metadata'] + : self::jsonDecode($serviceSet['metadata_json'] ?? null); + $policy = strtolower(trim((string)($serviceSet['data_policy'] ?? $serviceSet['data_service_mode'] ?? $metadata['data_policy'] ?? $metadata['data_service_mode'] ?? ''))); + if ($policy === self::PRODUCTION_DATA_POLICY) { + return self::PRODUCTION_DATA_POLICY; + } + + return $policy !== '' ? $policy : self::PRODUCTION_DATA_POLICY; + } + + private function assertBetaProductionDataPolicy(array $channel, array $serviceSet): void + { + if (!$this->isBetaChannel($channel)) { + return; + } + + if ($this->serviceSetDataPolicy($serviceSet) !== self::PRODUCTION_DATA_POLICY) { + throw new RuntimeException('Beta release bundles must use production-shared data services.'); + } + } + + private function assertBetaDataSourceChannel(?array $source): void + { + if ($source === null) { + return; + } + + $slug = self::safeSlug((string)($source['channel_slug'] ?? '')); + if (!in_array($slug, self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS, true)) { + throw new RuntimeException('Beta data-only service sets can copy data targets only from Stable/Master production service sets.'); + } + } + + private function assertServiceSetTargetBelongsToChannel(?int $targetId, string $app, array $channel): void + { + if ($targetId === null) { + return; + } + + $target = $this->getDeploymentTarget($targetId); + if ((string)($target['app'] ?? '') !== $app) { + throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app)); + } + if ((int)($target['channel_id'] ?? 0) !== (int)($channel['id'] ?? 0)) { + throw new RuntimeException(sprintf('Beta production-data service sets require %s targets from the beta channel.', $app)); + } + } + + private function assertIsolatedStackTarget(?int $targetId, string $app, bool $allowCreatedService = false): void + { + if ($targetId === null) { + throw new RuntimeException(sprintf('Isolated stack deployments require a new %s Coolify target.', $app)); + } + + $target = $this->getDeploymentTarget($targetId); + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + if ((string)($target['app'] ?? '') !== $app) { + throw new RuntimeException(sprintf('Isolated stack %s target does not match the requested app.', $app)); + } + if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) { + throw new RuntimeException(sprintf('Isolated stack %s target must select a Coolify instance.', $app)); + } + if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '' && !$allowCreatedService) { + throw new RuntimeException(sprintf('Isolated stack %s target must not point at an existing Coolify service.', $app)); + } + + if (!$this->toBool($context['coolify_auto_create'] ?? false)) { + throw new RuntimeException(sprintf('Isolated stack %s target must create a new Coolify service.', $app)); + } + if (!$this->toBool($context['isolated_stack'] ?? false)) { + throw new RuntimeException(sprintf('Isolated stack %s target must be marked as isolated.', $app)); + } + } + + private function assertIsolatedStackDataTarget(?int $targetId, string $kind): void + { + if ($targetId === null) { + throw new RuntimeException(sprintf('Isolated stack deployments require a new %s data target.', $kind)); + } + + $target = $this->nullableCoolifyTarget($targetId); + if ($target === null) { + throw new RuntimeException(sprintf('Selected %s isolated data target was not found.', $kind)); + } + if ((string)($target['kind'] ?? '') !== $kind) { + throw new RuntimeException(sprintf('Selected %s isolated data target has the wrong kind.', $kind)); + } + + $options = is_array($target['options'] ?? null) ? $target['options'] : []; + if (!$this->toBool($options['isolated_stack'] ?? false)) { + throw new RuntimeException(sprintf('Selected %s data target is not marked as an isolated stack target.', $kind)); + } + if ($this->toBool($options['production_data_attached'] ?? true)) { + throw new RuntimeException(sprintf('Selected %s data target must not attach production data.', $kind)); + } + if (!$this->toBool($options['skip_replication_provisioning'] ?? false)) { + throw new RuntimeException(sprintf('Selected %s data target must skip production replication provisioning.', $kind)); + } + } + + private function createIsolatedStackDataTarget( + string $kind, + array $input, + array $channel, + string $serviceSetName, + ?int $frontendTargetId, + ?int $apiTargetId, + ?int $actorUserId + ): int { + if (!class_exists(coolify_manager::class) && function_exists('app_require')) { + app_require('classes/coolify_manager.php'); + } + if (!class_exists(coolify_manager::class)) { + throw new RuntimeException('Coolify integration is required to create isolated stack data services.'); + } + + $placementTarget = $this->isolatedStackPlacementTarget($apiTargetId, $frontendTargetId); + $context = self::jsonDecode($placementTarget['deploy_context_json'] ?? null); + $dataServicesInput = is_array($input['data_services'] ?? null) ? $input['data_services'] : []; + $dataInput = is_array($dataServicesInput[$kind] ?? null) ? $dataServicesInput[$kind] : []; + $placementContext = array_replace($context, $dataInput); + $instanceId = $this->nullablePositiveInt($placementTarget['coolify_instance_id'] ?? null); + if ($instanceId === null) { + throw new RuntimeException('Isolated stack data services require a Coolify instance.'); + } + + $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]); + if ($instance === null) { + throw new RuntimeException('Coolify instance for isolated stack data services was not found.'); + } + + $serverUuid = $this->releaseCoolifyServerUuid($placementContext, $instance); + if ($serverUuid === '') { + throw new RuntimeException('Release Manager could not resolve a Coolify server UUID for isolated data services.'); + } + $placementTarget['channel_slug'] = $placementTarget['channel_slug'] ?? $channel['slug'] ?? ''; + $environment = $this->releaseCoolifyEnvironment($placementTarget, $placementContext, $instance); + + $stackSlug = self::safeSlug($serviceSetName !== '' ? $serviceSetName : ((string)($channel['slug'] ?? 'release') . '-isolated-stack')); + $serviceName = substr('release-' . ($stackSlug !== '' ? $stackSlug : 'isolated-stack') . '-' . $kind, 0, 64); + $payload = array_replace($dataInput, [ + 'kind' => $kind, + 'role' => 'replica', + 'instance_id' => $instanceId, + 'server_uuid' => $serverUuid, + 'project_uuid' => trim((string)($placementContext['coolify_project_uuid'] ?? $placementContext['project_uuid'] ?? $instance['default_project_uuid'] ?? '')), + 'environment_uuid' => $environment['uuid'] ?? '', + 'environment_name' => $environment['name'], + 'destination_uuid' => trim((string)($placementContext['coolify_destination_uuid'] ?? $placementContext['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')), + 'label' => $serviceName, + 'service_name' => $serviceName, + 'resource_name' => $serviceName, + 'isolated_stack' => true, + 'skip_replication_provisioning' => true, + 'deploy' => $this->toBool($input['deploy_data_targets'] ?? $input['deploy_isolated_data_targets'] ?? true), + 'options' => [ + 'isolated_stack' => true, + 'skip_replication_provisioning' => true, + 'production_data_attached' => false, + 'release_service_set_name' => $serviceSetName, + 'release_channel_slug' => (string)($channel['slug'] ?? ''), + ], + ]); + + $created = (new coolify_manager())->createTarget($payload, $actorUserId); + $targetId = $this->nullablePositiveInt($created['target']['id'] ?? null); + if ($targetId === null) { + throw new RuntimeException(sprintf('Coolify did not return a %s isolated data target id.', $kind)); + } + + $this->assertIsolatedStackDataTarget($targetId, $kind); + return $targetId; + } + + private function isolatedStackPlacementTarget(?int $apiTargetId, ?int $frontendTargetId): array + { + foreach ([$apiTargetId, $frontendTargetId] as $targetId) { + if ($targetId === null) { + continue; + } + $target = $this->getDeploymentTarget($targetId); + if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null) { + return $target; + } + } + + throw new RuntimeException('Isolated stack data services require a frontend or API Coolify target.'); + } + + private function uniqueServiceSetSlug(string $slug): string + { + $base = $slug !== '' ? $slug : 'service-set-' . date('Ymd-His'); + $candidate = substr($base, 0, 64); + $suffix = 2; + while ($this->selectOne('SELECT id FROM release_service_sets WHERE slug = ? LIMIT 1', 's', [$candidate]) !== null) { + $tail = '-' . $suffix; + $candidate = substr($base, 0, 64 - strlen($tail)) . $tail; + $suffix++; + } + return $candidate; + } + + private function serviceSetStatus(string $mode, ?int $frontendTargetId, ?int $apiTargetId, array $dataTargets): string + { + $hasCode = $frontendTargetId !== null && $apiTargetId !== null; + $hasData = !in_array(null, $dataTargets, true); + if ($mode === 'isolated_stack') { + return $hasCode && $hasData ? 'isolated_stack' : 'needs_isolated_targets'; + } + if ($mode === 'attach_existing') { + return $hasCode ? 'ready' : 'needs_configuration'; + } + if ($hasCode && $hasData) { + return $mode === 'clone_existing' ? 'provisioning' : 'ready'; + } + if ($mode === 'fresh_empty') { + return 'isolated_empty'; + } + return $mode === 'clone_existing' ? 'needs_clone_targets' : 'needs_configuration'; + } + + private function replicaProvisioningPlan(string $mode, ?array $source, array $dataTargets): array + { + $plan = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $sourceTargetId = $source !== null ? $this->nullablePositiveInt($source[$kind . '_coolify_target_id'] ?? null) : null; + $sourceTarget = $this->nullableCoolifyTarget($sourceTargetId); + $target = $this->nullableCoolifyTarget($dataTargets[$kind] ?? null); + $plan[$kind] = [ + 'action' => match ($mode) { + 'clone_existing' => 'clone_replica_from_source', + 'isolated_stack' => 'create_isolated_empty_stack_service', + 'fresh_empty' => 'register_isolated_empty_service', + default => 'attach_existing_service', + }, + 'source_coolify_target_id' => $sourceTargetId, + 'source_replication_host_id' => $sourceTarget['replication']['id'] ?? null, + 'target_coolify_target_id' => $dataTargets[$kind] ?? null, + 'target_replication_host_id' => $target['replication']['id'] ?? null, + 'production_replication_attached' => $mode === 'attach_existing', + ]; + } + + return $plan; + } + + private function bundleAppInput(array $input, string $app, ?array $target, string $versionLabel): array + { + $appPayload = is_array($input[$app] ?? null) ? $input[$app] : []; + if ($app === 'api') { + $appPayload = array_replace( + is_array($input['php'] ?? null) ? $input['php'] : [], + is_array($input['backend'] ?? null) ? $input['backend'] : [], + $appPayload + ); + } + + $repository = trim((string)($appPayload['repository'] ?? $input[$app . '_repository'] ?? $target['repository'] ?? '')); + $normalizedRepository = self::normalizeGithubRepositoryName($repository); + if ($normalizedRepository !== '') { + $repository = $normalizedRepository; + } + if ($repository === '') { + throw new RuntimeException(sprintf('%s repository is required for bundle releases.', $app === 'api' ? 'PHP backend' : 'Frontend')); + } + + $branch = trim((string)($appPayload['branch'] ?? $input[$app . '_branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + $rawCommitSha = trim((string)($appPayload['commit_sha'] ?? $appPayload['commit'] ?? $input[$app . '_commit_sha'] ?? '')); + $commitMode = $this->normalizeCommitMode((string)($appPayload['commit_mode'] ?? $input[$app . '_commit_mode'] ?? ''), $rawCommitSha); + $commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null; + $githubAccess = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'commit_mode' => $commitMode, + ]); + if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) { + throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.')); + } + if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) { + $commitSha = (string)$githubAccess['commit_sha']; + $branch = (string)($githubAccess['branch'] ?? $branch); + } + + return [ + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => $commitMode, + 'commit_sha' => $commitSha, + 'version_label' => sprintf('%s-%s', $versionLabel, $app === 'api' ? 'php' : 'frontend'), + 'deployed_url' => is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null, + 'github_access' => $githubAccess, + ]; + } + + private function createBundleVersion(array $input, string $app): int + { + return $this->createVersion([ + 'app' => $app, + 'repository' => $input['repository'], + 'branch' => $input['branch'], + 'commit_sha' => $input['commit_sha'], + 'version_label' => $input['version_label'], + 'deployed_url' => $input['deployed_url'] ?? null, + 'status' => 'draft', + 'metadata' => [ + 'commit_mode' => $input['commit_mode'], + 'github_access' => $input['github_access'], + 'bundle_member' => true, + ], + ]); + } + + private function getChannel(int $id): array + { + $row = $this->selectOne('SELECT * FROM release_channels WHERE id = ? AND deleted_at IS NULL', 'i', [$id]); + if ($row === null) { + throw new RuntimeException('Release channel not found.'); + } + return $row; + } + + private function findChannelBySlug(string $slug): ?array + { + return $this->selectOne( + 'SELECT * FROM release_channels WHERE slug = ? AND deleted_at IS NULL LIMIT 1', + 's', + [$slug] + ); + } + + private function getVersion(int $id): array + { + return $this->selectOne('SELECT * FROM release_versions WHERE id = ?', 'i', [$id]) ?? []; + } + + private function getDeployment(int $id): array + { + $row = $this->selectOne( + "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url + FROM release_deployments d + INNER JOIN release_channels c ON c.id = d.channel_id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE d.id = ?", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release deployment not found.'); + } + return $row; + } + + private function getServiceSet(int $id): array + { + $row = $this->selectOne( + "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_service_sets s + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE s.id = ? AND s.deleted_at IS NULL", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release service set not found.'); + } + return $row; + } + + private function getBundle(int $id): array + { + $row = $this->selectOne( + "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug + FROM release_bundles b + INNER JOIN release_channels c ON c.id = b.channel_id + INNER JOIN release_service_sets s ON s.id = b.service_set_id + WHERE b.id = ? AND b.deleted_at IS NULL", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release bundle not found.'); + } + return $row; + } + + private function getDeploymentTarget(int $id): array + { + $row = $this->selectOne( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id + WHERE t.id = ? AND t.deleted_at IS NULL", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release deployment target not found.'); + } + return $row; + } + + private function publicChannel(array $channel): array + { + return [ + 'id' => (int)($channel['id'] ?? 0), + 'slug' => (string)($channel['slug'] ?? ''), + 'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')), + 'name' => (string)($channel['name'] ?? ''), + 'description' => $channel['description'] ?? null, + 'enabled' => (bool)((int)($channel['enabled'] ?? 0)), + 'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)), + 'rollout_percent' => (float)($channel['rollout_percent'] ?? 0), + 'frontend_base_url' => $channel['frontend_base_url'] ?? null, + 'api_base_url' => $channel['api_base_url'] ?? null, + 'replay_enabled' => (bool)((int)($channel['replay_enabled'] ?? 0)), + 'capture_level' => (string)($channel['capture_level'] ?? 'metadata'), + 'retention_days' => (int)($channel['retention_days'] ?? 14), + 'metadata' => self::jsonDecode($channel['metadata_json'] ?? null), + 'created_at' => $channel['created_at'] ?? null, + 'updated_at' => $channel['updated_at'] ?? null, + ]; + } + + private function publicRuntimeChannel(array $channel): array + { + $public = $this->publicChannel($channel); + unset($public['frontend_base_url'], $public['api_base_url']); + return $public; + } + + private function publicRuntimeChannelOptions(array $channels): array + { + return array_map(function (array $channel): array { + $serviceChannel = $this->runtimeServiceChannelFor($channel); + return [ + 'channel' => $this->publicRuntimeChannel($channel), + 'service_channel' => $this->publicRuntimeChannel($serviceChannel), + 'versions' => $this->currentVersionsForChannel((int)($serviceChannel['id'] ?? 0)), + 'availability' => $this->channelAvailability($channel), + ]; + }, $channels); + } + + public static function publicAssignmentSubjectSuggestion(array $candidate): ?array + { + $subjectType = strtolower(trim((string)($candidate['subject_type'] ?? ''))); + if (!in_array($subjectType, self::SUBJECT_TYPES, true)) { + return null; + } + + $subjectId = self::safeIdentifier((string)($candidate['subject_id'] ?? ''), 64); + if ($subjectId === '') { + return null; + } + + $title = self::safeDisplayText($candidate['title'] ?? '', 120); + if ($title === '') { + $title = $subjectType . ':' . $subjectId; + } + $description = self::safeDisplayText($candidate['description'] ?? '', 180); + $icon = self::safeIconClass($candidate['icon'] ?? self::assignmentSubjectIcon($subjectType)); + $source = self::safeIdentifier((string)($candidate['source'] ?? $subjectType), 32) ?: $subjectType; + + return [ + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'label' => $description !== '' ? $title . ' - ' . $description : $title, + 'title' => $title, + 'description' => $description, + 'icon' => $icon, + 'source' => $source, + ]; + } + + private function searchAssignmentUsers(string $query, int $limit): array + { + $like = '%' . $query . '%'; + $rows = $this->selectRows( + "SELECT id, customer_number, display_name, email, phone_country_code, phone + FROM users + WHERE CAST(id AS CHAR) LIKE ? + OR CAST(customer_number AS CHAR) LIKE ? + OR display_name LIKE ? + OR email LIKE ? + ORDER BY id DESC + LIMIT ?", + 'ssssi', + [$like, $like, $like, $like, $limit] + ); + + return array_map(function (array $row): array { + $id = (string)($row['id'] ?? ''); + $customerNumber = trim((string)($row['customer_number'] ?? '')); + $displayName = self::safeDisplayText($row['display_name'] ?? '', 80); + $email = self::safeDisplayText($row['email'] ?? '', 80); + $parts = array_filter([ + $customerNumber !== '' ? 'Customer #' . $customerNumber : '', + $email, + $this->phoneLabel($row), + ]); + + return [ + 'subject_type' => 'user', + 'subject_id' => $id, + 'title' => $displayName !== '' ? $displayName : 'User #' . $id, + 'description' => implode(' / ', $parts), + 'icon' => self::assignmentSubjectIcon('user'), + 'source' => 'users', + ]; + }, $rows); + } + + private function searchAssignmentSubusers(string $query, int $limit): array + { + $like = '%' . $query . '%'; + $rows = $this->selectRows( + "SELECT id, username, name, email, phone_country_code, phone + FROM subusers + WHERE CAST(id AS CHAR) LIKE ? + OR username LIKE ? + OR name LIKE ? + OR email LIKE ? + OR CAST(phone AS CHAR) LIKE ? + ORDER BY id DESC + LIMIT ?", + 'sssssi', + [$like, $like, $like, $like, $like, $limit] + ); + + return array_map(function (array $row): array { + $id = (string)($row['id'] ?? ''); + $name = self::safeDisplayText($row['name'] ?? '', 80); + $username = self::safeDisplayText($row['username'] ?? '', 80); + $email = self::safeDisplayText($row['email'] ?? '', 80); + $parts = array_filter([ + $username !== '' ? '@' . ltrim($username, '@') : '', + $email, + $this->phoneLabel($row), + ]); + + return [ + 'subject_type' => 'subuser', + 'subject_id' => $id, + 'title' => $name !== '' ? $name : 'Subuser #' . $id, + 'description' => implode(' / ', $parts), + 'icon' => self::assignmentSubjectIcon('subuser'), + 'source' => 'subusers', + ]; + }, $rows); + } + + private function searchAssignmentCustomers(string $query, int $limit): array + { + try { + $result = (new economicCustomers())->listCustomers(1, $limit, $query, null); + } catch (Throwable) { + return []; + } + + $customers = is_array($result->collection ?? null) ? $result->collection : []; + return array_map(static function (object $customer): array { + $customerNumber = (string)($customer->customerNumber ?? $customer->customer_number ?? ''); + $name = self::safeDisplayText($customer->name ?? $customer->customer_name ?? '', 100); + $email = self::safeDisplayText($customer->email ?? '', 80); + $city = self::safeDisplayText($customer->city ?? '', 80); + $parts = array_filter([ + $customerNumber !== '' ? 'Customer #' . $customerNumber : '', + $email, + $city, + ]); + + return [ + 'subject_type' => 'customer', + 'subject_id' => $customerNumber, + 'title' => $name !== '' ? $name : 'Customer #' . $customerNumber, + 'description' => implode(' / ', $parts), + 'icon' => self::assignmentSubjectIcon('customer'), + 'source' => 'customers', + ]; + }, $customers); + } + + private static function normalizeAssignmentSubjectSearch(mixed $value): string + { + $query = self::safeDisplayText($value, 80); + return trim($query); + } + + private static function normalizeAssignmentSubjectLimit(mixed $value): int + { + $limit = (int)$value; + if ($limit <= 0) { + return 5; + } + return min(10, max(1, $limit)); + } + + private static function safeDisplayText(mixed $value, int $maxLength): string + { + $text = trim(strip_tags((string)$value)); + $text = preg_replace('/\s+/', ' ', $text) ?? ''; + return substr($text, 0, max(1, $maxLength)); + } + + private static function safeIconClass(mixed $value): string + { + $icon = trim((string)$value); + if (!preg_match('/^[a-z0-9 _-]+$/i', $icon)) { + return 'fas fa-tag'; + } + return $icon; + } + + private static function assignmentSubjectIcon(string $subjectType): string + { + return match ($subjectType) { + 'customer' => 'fas fa-building', + 'subuser' => 'fas fa-id-badge', + default => 'fas fa-user', + }; + } + + private function phoneLabel(array $row): string + { + $countryCode = trim((string)($row['phone_country_code'] ?? '')); + $phone = trim((string)($row['phone'] ?? '')); + if ($phone === '') { + return ''; + } + return $countryCode !== '' ? '+' . $countryCode . ' ' . $phone : $phone; + } + + private function publicVersion(?array $version): ?array + { + if (!$version || empty($version['id'])) { + return null; + } + + $metadata = self::jsonDecode($version['metadata_json'] ?? null); + $commit = $this->versionGithubCommit(['metadata' => $metadata]); + + return [ + 'id' => (int)$version['id'], + 'app' => (string)$version['app'], + 'repository' => $version['repository'] ?? null, + 'branch' => $version['branch'] ?? null, + 'commit_sha' => $version['commit_sha'] ?? null, + 'commit' => $commit, + 'commit_authored_at' => $commit['authored_at'] ?? null, + 'tag' => $version['tag'] ?? null, + 'version_label' => $version['version_label'] ?? null, + 'build_url' => $version['build_url'] ?? null, + 'artifact_url' => $version['artifact_url'] ?? null, + 'deployed_url' => $version['deployed_url'] ?? null, + 'status' => (string)($version['status'] ?? 'unknown'), + 'metadata' => $metadata, + 'created_at' => $version['created_at'] ?? null, + 'deployed_at' => $version['deployed_at'] ?? null, + ]; + } + + private function publicAssignment(array $assignment): array + { + return [ + 'id' => (int)($assignment['id'] ?? 0), + 'subject_type' => (string)($assignment['subject_type'] ?? ''), + 'subject_id' => (string)($assignment['subject_id'] ?? ''), + 'channel_id' => (int)($assignment['channel_id'] ?? 0), + 'channel_slug' => (string)($assignment['channel_slug'] ?? ''), + 'channel_name' => (string)($assignment['channel_name'] ?? ''), + 'reason' => $assignment['reason'] ?? null, + 'expires_at' => $assignment['expires_at'] ?? null, + 'actor_user_id' => isset($assignment['actor_user_id']) ? (int)$assignment['actor_user_id'] : null, + 'created_at' => $assignment['created_at'] ?? null, + ]; + } + + private function publicDeploymentTarget(array $target): array + { + $deployContext = self::jsonDecode($target['deploy_context_json'] ?? null); + $targetWithContext = $target + ['deploy_context' => $deployContext]; + return [ + 'id' => (int)($target['id'] ?? 0), + 'channel_id' => (int)($target['channel_id'] ?? 0), + 'channel_slug' => (string)($target['channel_slug'] ?? ''), + 'channel_name' => (string)($target['channel_name'] ?? ''), + 'app' => (string)($target['app'] ?? ''), + 'coolify_instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null, + 'coolify_instance_label' => $target['coolify_instance_label'] ?? null, + 'coolify_service_uuid' => $target['coolify_service_uuid'] ?? null, + 'repository' => (string)($target['repository'] ?? ''), + 'branch' => (string)($target['branch'] ?? ''), + 'auto_deploy' => (bool)((int)($target['auto_deploy'] ?? 0)), + 'health_url' => $target['health_url'] ?? null, + 'deploy_context' => $deployContext, + 'endpoint' => $this->releaseDeploymentEndpoint($targetWithContext), + 'created_at' => $target['created_at'] ?? null, + 'updated_at' => $target['updated_at'] ?? null, + ]; + } + + private function publicServiceSet(array $serviceSet, bool $includeBundles = true): array + { + $dataServices = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $dataServices[$kind] = $this->nullableCoolifyTarget( + $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null) + ); + } + + $attachedBundles = $includeBundles ? $this->serviceSetBundles((int)($serviceSet['id'] ?? 0)) : []; + $serviceSetId = (int)($serviceSet['id'] ?? 0); + $metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null); + $dataPolicy = $this->serviceSetDataPolicy(array_replace($serviceSet, ['metadata' => $metadata])); + + return [ + 'id' => $serviceSetId, + 'channel_id' => isset($serviceSet['channel_id']) ? (int)$serviceSet['channel_id'] : null, + 'channel_slug' => $serviceSet['channel_slug'] ?? null, + 'channel_name' => $serviceSet['channel_name'] ?? null, + 'name' => (string)($serviceSet['name'] ?? ''), + 'slug' => (string)($serviceSet['slug'] ?? ''), + 'mode' => (string)($serviceSet['mode'] ?? 'attach_existing'), + 'data_policy' => $dataPolicy, + 'data_service_mode' => $dataPolicy, + 'source_service_set_id' => isset($serviceSet['source_service_set_id']) ? (int)$serviceSet['source_service_set_id'] : null, + 'data_source_service_set_id' => $this->nullablePositiveInt($metadata['data_source_service_set_id'] ?? null), + 'data_source_channel_slug' => $metadata['data_source_channel_slug'] ?? null, + 'status' => (string)($serviceSet['status'] ?? 'unknown'), + 'active' => $serviceSetId > 0 && $this->serviceSetIsActive($serviceSetId), + 'targets' => [ + 'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)), + 'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)), + ], + 'data_services' => $dataServices, + 'stack' => [ + 'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)), + 'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)), + 'database' => $dataServices['database'], + 'redis' => $dataServices['redis'], + 'minio' => $dataServices['minio'], + ], + 'health' => self::jsonDecode($serviceSet['health_json'] ?? null), + 'metadata' => $metadata, + 'attached_bundle_count' => count($attachedBundles), + 'attached_bundles' => $attachedBundles, + 'created_at' => $serviceSet['created_at'] ?? null, + 'updated_at' => $serviceSet['updated_at'] ?? null, + ]; + } + + private function publicBundle(array $bundle, bool $includeServiceSet = true): array + { + $bundleId = (int)($bundle['id'] ?? 0); + $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null); + $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null); + $frontendDeploymentId = $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null); + $apiDeploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null); + $frontendVersion = $frontendVersionId !== null ? $this->publicVersion($this->getVersion($frontendVersionId)) : null; + $apiVersion = $apiVersionId !== null ? $this->publicVersion($this->getVersion($apiVersionId)) : null; + $frontendCommit = $this->versionGithubCommit($frontendVersion); + $apiCommit = $this->versionGithubCommit($apiVersion); + $active = $bundleId > 0 && $this->bundleIsActive($bundleId); + $status = (string)($bundle['status'] ?? 'draft'); + if ($status === 'promoted' && !$active) { + $status = 'superseded'; + } + + return [ + 'id' => $bundleId, + 'channel_id' => (int)($bundle['channel_id'] ?? 0), + 'channel_slug' => (string)($bundle['channel_slug'] ?? ''), + 'channel_name' => (string)($bundle['channel_name'] ?? ''), + 'service_set_id' => (int)($bundle['service_set_id'] ?? 0), + 'service_set_name' => $bundle['service_set_name'] ?? null, + 'service_set_slug' => $bundle['service_set_slug'] ?? null, + 'service_set' => $includeServiceSet ? $this->publicServiceSet($this->getServiceSet((int)$bundle['service_set_id']), false) : null, + 'version_label' => $bundle['version_label'] ?? null, + 'status' => $status, + 'active' => $active, + 'apps' => [ + 'frontend' => [ + 'version_id' => $frontendVersionId, + 'deployment_id' => $frontendDeploymentId, + 'repository' => $bundle['frontend_repository'] ?? null, + 'branch' => $bundle['frontend_branch'] ?? null, + 'commit_sha' => $bundle['frontend_commit_sha'] ?? null, + 'commit' => $frontendCommit, + 'commit_authored_at' => $frontendCommit['authored_at'] ?? null, + 'version' => $frontendVersion, + 'deployment' => $this->nullableDeployment($frontendDeploymentId), + ], + 'api' => [ + 'version_id' => $apiVersionId, + 'deployment_id' => $apiDeploymentId, + 'repository' => $bundle['api_repository'] ?? null, + 'branch' => $bundle['api_branch'] ?? null, + 'commit_sha' => $bundle['api_commit_sha'] ?? null, + 'commit' => $apiCommit, + 'commit_authored_at' => $apiCommit['authored_at'] ?? null, + 'version' => $apiVersion, + 'deployment' => $this->nullableDeployment($apiDeploymentId), + ], + ], + 'deployment_result' => self::jsonDecode($bundle['deployment_result_json'] ?? null), + 'metadata' => self::jsonDecode($bundle['metadata_json'] ?? null), + 'actor_user_id' => isset($bundle['actor_user_id']) ? (int)$bundle['actor_user_id'] : null, + 'deployed_at' => $bundle['deployed_at'] ?? null, + 'promoted_at' => $bundle['promoted_at'] ?? null, + 'created_at' => $bundle['created_at'] ?? null, + 'updated_at' => $bundle['updated_at'] ?? null, + ]; + } + + private function versionGithubCommit(?array $version): ?array + { + $metadata = is_array($version['metadata'] ?? null) ? $version['metadata'] : []; + $access = is_array($metadata['github_access'] ?? null) ? $metadata['github_access'] : []; + foreach (['commit', 'latest_commit'] as $key) { + $commit = is_array($access[$key] ?? null) ? $access[$key] : []; + if (trim((string)($commit['sha'] ?? '')) !== '') { + return $commit; + } + } + + return null; + } + + private function serviceSetBundles(int $serviceSetId): array + { + if ($serviceSetId <= 0 || !release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $row): array => $this->publicBundle($row, false), + $this->selectRows( + "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug + FROM release_bundles b + INNER JOIN release_channels c ON c.id = b.channel_id + INNER JOIN release_service_sets s ON s.id = b.service_set_id + WHERE b.deleted_at IS NULL AND b.service_set_id = ? + ORDER BY b.created_at DESC, b.id DESC + LIMIT 10", + 'i', + [$serviceSetId] + ) + ); + } + + private function serviceSetIsActive(int $serviceSetId): bool + { + return $this->selectOne( + 'SELECT id FROM release_channel_versions WHERE service_set_id = ? AND active = 1 LIMIT 1', + 'i', + [$serviceSetId] + ) !== null; + } + + private function bundleIsActive(int $bundleId): bool + { + return $this->selectOne( + 'SELECT id FROM release_channel_versions WHERE bundle_id = ? AND active = 1 LIMIT 1', + 'i', + [$bundleId] + ) !== null; + } + + private function isolatedDeploymentTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool + { + if ($targetId === null || $targetId <= 0) { + return false; + } + + $target = $this->nullableDeploymentTarget($targetId); + if ($target === null) { + return false; + } + + $context = is_array($target['deploy_context'] ?? null) ? $target['deploy_context'] : []; + if (!$this->toBool($context['isolated_stack'] ?? false)) { + return false; + } + if ($this->toBool($context['production_data_attached'] ?? false)) { + return false; + } + + return $this->selectOne( + "SELECT id + FROM release_service_sets + WHERE id <> ? AND deleted_at IS NULL AND (frontend_target_id = ? OR api_target_id = ?) + LIMIT 1", + 'iii', + [$serviceSetId, $targetId, $targetId] + ) === null; + } + + private function isolatedCoolifyTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool + { + if ($targetId === null || $targetId <= 0 || !$this->tableExists('coolify_targets')) { + return false; + } + + $target = $this->nullableCoolifyTarget($targetId); + if ($target === null) { + return false; + } + + $options = is_array($target['options'] ?? null) ? $target['options'] : []; + if (!$this->toBool($options['isolated_stack'] ?? false)) { + return false; + } + if ($this->toBool($options['production_data_attached'] ?? false)) { + return false; + } + + return $this->selectOne( + "SELECT id + FROM release_service_sets + WHERE id <> ? AND deleted_at IS NULL + AND ( + database_coolify_target_id = ? + OR redis_coolify_target_id = ? + OR minio_coolify_target_id = ? + ) + LIMIT 1", + 'iiii', + [$serviceSetId, $targetId, $targetId, $targetId] + ) === null; + } + + private function nullableDeploymentTarget(?int $id): ?array + { + if ($id === null || $id <= 0) { + return null; + } + + try { + return $this->publicDeploymentTarget($this->getDeploymentTarget($id)); + } catch (Throwable) { + return null; + } + } + + private function nullableDeployment(?int $id): ?array + { + if ($id === null || $id <= 0) { + return null; + } + + try { + return $this->publicDeployment($this->getDeployment($id)); + } catch (Throwable) { + return null; + } + } + + private function nullableCoolifyTarget(?int $id): ?array + { + if ($id === null || $id <= 0 || !$this->tableExists('coolify_targets')) { + return null; + } + + $hasReplicationHosts = $this->tableExists('replication_hosts'); + $replicationColumns = $hasReplicationHosts + ? "h.id AS host_id, h.kind AS host_kind, h.label AS host_label, h.host AS host_host, + h.port AS host_port, h.role AS host_role, h.status AS host_status, + h.replication_source_id AS host_replication_source_id, + h.last_status_json AS host_last_status_json, h.last_checked_at AS host_last_checked_at" + : "NULL AS host_id, NULL AS host_kind, NULL AS host_label, NULL AS host_host, + NULL AS host_port, NULL AS host_role, NULL AS host_status, + NULL AS host_replication_source_id, + NULL AS host_last_status_json, NULL AS host_last_checked_at"; + $replicationJoin = $hasReplicationHosts ? 'LEFT JOIN replication_hosts h ON h.id = t.replication_host_id' : ''; + + $row = $this->selectOne( + "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url, + $replicationColumns + FROM coolify_targets t + LEFT JOIN coolify_instances i ON i.id = t.instance_id + $replicationJoin + WHERE t.id = ? AND t.deleted_at IS NULL", + 'i', + [$id] + ); + if ($row === null) { + return null; + } + + $replication = !empty($row['host_id']) ? [ + 'id' => (int)$row['host_id'], + 'kind' => (string)($row['host_kind'] ?? $row['kind'] ?? ''), + 'label' => (string)($row['host_label'] ?? ''), + 'host' => $row['host_host'] ?? null, + 'port' => isset($row['host_port']) ? (int)$row['host_port'] : null, + 'role' => (string)($row['host_role'] ?? 'unknown'), + 'status' => (string)($row['host_status'] ?? 'unknown'), + 'source_host_id' => isset($row['host_replication_source_id']) ? (int)$row['host_replication_source_id'] : null, + 'last_status' => self::jsonDecode($row['host_last_status_json'] ?? null), + 'last_checked_at' => $row['host_last_checked_at'] ?? null, + ] : null; + $endpoint = $replication !== null && !empty($replication['host']) + ? $this->releaseEndpointFromParts( + 'auto', + 'resolved', + (string)$replication['host'], + isset($replication['port']) ? (int)$replication['port'] : null, + 'replication_host', + 'Endpoint resolved from the attached replication host.' + ) + : self::releasePendingEndpoint('auto', 'coolify_target', 'Automatic endpoint resolution is pending Coolify target metadata.'); + + return [ + 'id' => (int)($row['id'] ?? 0), + 'kind' => (string)($row['kind'] ?? ''), + 'label' => (string)($row['label'] ?? ''), + 'role' => (string)($row['role'] ?? ''), + 'instance_id' => isset($row['instance_id']) ? (int)$row['instance_id'] : null, + 'instance_label' => $row['instance_label'] ?? null, + 'resource_uuid' => $row['resource_uuid'] ?? null, + 'resource_name' => $row['resource_name'] ?? null, + 'deployment_status' => (string)($row['deployment_status'] ?? 'unknown'), + 'availability_state' => (string)($row['availability_state'] ?? 'unknown'), + 'last_reconcile_status' => $row['last_reconcile_status'] ?? null, + 'last_reconciled_at' => $row['last_reconciled_at'] ?? null, + 'endpoint' => $endpoint, + 'replication' => $replication, + 'options' => self::jsonDecode($row['options_json'] ?? null), + ]; + } + + private function publicDeployment(array $deployment): array + { + $status = (string)($deployment['status'] ?? 'unknown'); + $result = self::jsonDecode($deployment['result_json'] ?? null); + $failureSummary = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : null; + $promotable = self::deploymentCanBePromoted($status); + + return [ + 'id' => (int)($deployment['id'] ?? 0), + 'channel_id' => (int)($deployment['channel_id'] ?? 0), + 'channel_slug' => (string)($deployment['channel_slug'] ?? ''), + 'channel_name' => (string)($deployment['channel_name'] ?? ''), + 'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null, + 'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null, + 'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null, + 'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null, + 'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'), + 'version_label' => $deployment['version_label'] ?? null, + 'app' => (string)($deployment['app'] ?? ''), + 'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null, + 'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '', + 'provider' => (string)($deployment['provider'] ?? 'coolify'), + 'repository' => $deployment['repository'] ?? null, + 'branch' => $deployment['branch'] ?? null, + 'commit_sha' => $deployment['commit_sha'] ?? null, + 'status' => $status, + 'deployment_url' => $deployment['deployment_url'] ?? $deployment['deployed_url'] ?? null, + 'actor_user_id' => isset($deployment['actor_user_id']) ? (int)$deployment['actor_user_id'] : null, + 'result' => $result, + 'failure_summary' => $failureSummary, + 'error_message' => $deployment['error_message'] ?? null, + 'promotable' => $promotable, + 'promotion_blocked_reason' => $promotable ? null : self::deploymentPromotionBlockedReason($deployment), + 'started_at' => $deployment['started_at'] ?? null, + 'completed_at' => $deployment['completed_at'] ?? null, + 'created_at' => $deployment['created_at'] ?? null, + 'updated_at' => $deployment['updated_at'] ?? null, + ]; + } + + private function publicTimelineEvent(array $event): array + { + return [ + 'id' => (int)($event['id'] ?? 0), + 'timeline_session_id' => isset($event['timeline_session_id']) ? (int)$event['timeline_session_id'] : null, + 'trace_id' => (string)($event['trace_id'] ?? ''), + 'event_type' => (string)($event['event_type'] ?? ''), + 'severity' => (string)($event['severity'] ?? 'info'), + 'module_key' => $event['module_key'] ?? null, + 'route_path' => $event['route_path'] ?? null, + 'component' => $event['component'] ?? null, + 'request_id' => $event['request_id'] ?? null, + 'occurred_at' => $event['occurred_at'] ?? null, + 'payload' => self::jsonDecode($event['payload_json'] ?? null), + 'principal_type' => $event['principal_type'] ?? null, + 'principal_id' => $event['principal_id'] ?? null, + 'customer_number' => isset($event['customer_number']) ? (int)$event['customer_number'] : null, + 'channel_slug' => $event['channel_slug'] ?? null, + ]; + } + + private function publicTimelineSession(array $session): array + { + $moduleKeys = array_filter(array_map('trim', explode(',', (string)($session['module_keys'] ?? '')))); + $principal = $this->timelinePrincipal($session); + + return [ + 'id' => (int)($session['id'] ?? 0), + 'trace_id' => (string)($session['trace_id'] ?? ''), + 'principal_type' => $session['principal_type'] ?? null, + 'principal_id' => $session['principal_id'] ?? null, + 'customer_number' => isset($session['customer_number']) ? (int)$session['customer_number'] : null, + 'user' => $principal, + 'channel_id' => isset($session['channel_id']) ? (int)$session['channel_id'] : null, + 'channel_slug' => $session['channel_slug'] ?? null, + 'release' => [ + 'frontend' => [ + 'version_label' => $session['frontend_version_label'] ?? null, + 'commit_sha' => $session['frontend_commit_sha'] ?? null, + ], + 'api' => [ + 'version_label' => $session['api_version_label'] ?? null, + 'commit_sha' => $session['api_commit_sha'] ?? null, + ], + ], + 'device' => [ + 'type' => $session['device_type'] ?? null, + 'browser_name' => $session['browser_name'] ?? null, + 'browser_version' => $session['browser_version'] ?? null, + 'os_name' => $session['os_name'] ?? null, + 'os_version' => $session['os_version'] ?? null, + 'viewport_width' => isset($session['viewport_width']) ? (int)$session['viewport_width'] : null, + 'viewport_height' => isset($session['viewport_height']) ? (int)$session['viewport_height'] : null, + 'device_pixel_ratio' => isset($session['device_pixel_ratio']) ? (float)$session['device_pixel_ratio'] : null, + 'user_agent' => $session['user_agent'] ?? null, + ], + 'last_route_path' => $session['last_route_path'] ?? null, + 'event_count' => isset($session['event_count']) ? (int)$session['event_count'] : 0, + 'error_count' => isset($session['error_count']) ? (int)$session['error_count'] : 0, + 'error_report_count' => isset($session['error_report_count']) ? (int)$session['error_report_count'] : 0, + 'module_keys' => array_values($moduleKeys), + 'first_event_at' => $session['first_event_at'] ?? null, + 'last_event_at' => $session['last_event_at'] ?? $session['last_seen_at'] ?? null, + 'created_at' => $session['created_at'] ?? null, + 'last_seen_at' => $session['last_seen_at'] ?? null, + ]; + } + + private function timelinePrincipal(array $session): array + { + $type = $session['principal_type'] ?? null; + $id = $session['principal_id'] ?? null; + $customerNumber = isset($session['customer_number']) ? (int)$session['customer_number'] : null; + $label = trim(implode(':', array_filter([(string)$type, (string)$id]))); + $name = null; + $email = null; + + if ($type === 'user' && is_numeric($id) && $this->tableExists('users')) { + $row = $this->selectOne( + 'SELECT id, customer_number, display_name, email FROM users WHERE id = ? LIMIT 1', + 'i', + [(int)$id] + ); + if ($row !== null) { + $name = $row['display_name'] ?? null; + $email = $row['email'] ?? null; + $customerNumber = isset($row['customer_number']) ? (int)$row['customer_number'] : $customerNumber; + } + } + + if ($type === 'subuser' && is_numeric($id) && $this->tableExists('subusers')) { + $row = $this->selectOne( + 'SELECT id, username, name, email FROM subusers WHERE id = ? LIMIT 1', + 'i', + [(int)$id] + ); + if ($row !== null) { + $name = $row['name'] ?? $row['username'] ?? null; + $email = $row['email'] ?? null; + } + } + + $displayLabel = trim((string)($name ?: $email ?: $label)); + if ($displayLabel === '') { + $displayLabel = $customerNumber !== null ? 'customer:' . $customerNumber : 'unknown'; + } + + return [ + 'type' => $type, + 'id' => $id, + 'customer_number' => $customerNumber, + 'name' => $name, + 'email' => $email, + 'label' => $displayLabel, + ]; + } + + private function timelineErrorReports(string $traceId): array + { + if (!$this->tableExists('error_reports')) { + return []; + } + + $rows = $this->selectRows( + "SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id, + reporter_customer_number, reporter_customer_number_context, reporter_name, + reporter_email, route_path, page_url, release_trace_id, frontend_version, + api_version, request_error_count, vue_error_count, created_at, updated_at + FROM error_reports + WHERE release_trace_id = ? + ORDER BY created_at DESC, id DESC + LIMIT 25", + 's', + [$traceId] + ); + + return array_map(static fn(array $row): array => [ + 'id' => (int)($row['id'] ?? 0), + 'status' => (string)($row['status'] ?? ''), + 'reporter' => [ + 'type' => $row['reporter_type'] ?? null, + 'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null, + 'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null, + 'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null, + 'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null, + 'name' => $row['reporter_name'] ?? null, + 'email' => $row['reporter_email'] ?? null, + ], + 'route_path' => $row['route_path'] ?? null, + 'page_url' => $row['page_url'] ?? null, + 'release_trace_id' => $row['release_trace_id'] ?? null, + 'frontend_version' => $row['frontend_version'] ?? null, + 'api_version' => $row['api_version'] ?? null, + 'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0, + 'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0, + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + ], $rows); + } + + private function timelineReleaseContext(array $session, ?array $channel): array + { + $frontend = $this->timelineAppReleaseContext( + 'frontend', + $session['frontend_version_label'] ?? null, + $session['frontend_commit_sha'] ?? null + ); + $api = $this->timelineAppReleaseContext( + 'api', + $session['api_version_label'] ?? null, + $session['api_commit_sha'] ?? null + ); + + $bundle = $this->timelineBundleReference( + $this->nullablePositiveInt($frontend['version']['id'] ?? null), + $this->nullablePositiveInt($api['version']['id'] ?? null), + $this->nullablePositiveInt($frontend['deployment']['bundle_id'] ?? $api['deployment']['bundle_id'] ?? null) + ); + + return [ + 'channel' => $channel !== null ? $this->publicChannel($channel) : null, + 'frontend' => $frontend, + 'api' => $api, + 'bundle' => $bundle, + ]; + } + + private function timelineAppReleaseContext(string $app, mixed $versionLabel, mixed $commitSha): array + { + $versionLabel = $this->nullableString($versionLabel, 128); + $commitSha = $this->nullableString($commitSha, 128); + $context = [ + 'version_label' => $versionLabel, + 'commit_sha' => $commitSha, + 'version' => null, + 'deployment' => null, + ]; + + $where = ['app = ?']; + $types = 's'; + $params = [$app]; + if ($versionLabel !== null && $commitSha !== null) { + $where[] = '(version_label = ? OR commit_sha = ?)'; + $types .= 'ss'; + $params[] = $versionLabel; + $params[] = $commitSha; + } elseif ($versionLabel !== null) { + $where[] = 'version_label = ?'; + $types .= 's'; + $params[] = $versionLabel; + } elseif ($commitSha !== null) { + $where[] = 'commit_sha = ?'; + $types .= 's'; + $params[] = $commitSha; + } else { + return $context; + } + + $version = $this->selectOne( + "SELECT id, app, repository, branch, commit_sha, tag, version_label, build_url, + artifact_url, deployed_url, status, created_at, deployed_at + FROM release_versions + WHERE " . implode(' AND ', $where) . " + ORDER BY deployed_at DESC, id DESC + LIMIT 1", + $types, + $params + ); + if ($version === null) { + return $context; + } + + $context['version_label'] = $version['version_label'] ?? $versionLabel; + $context['commit_sha'] = $version['commit_sha'] ?? $commitSha; + $context['version'] = $this->publicTimelineVersionReference($version); + + $deployment = $this->selectOne( + "SELECT id, channel_id, target_id, version_id, service_set_id, bundle_id, + deployment_kind, app, provider, repository, branch, commit_sha, + status, deployment_url, started_at, completed_at, created_at + FROM release_deployments + WHERE version_id = ? + ORDER BY id DESC + LIMIT 1", + 'i', + [(int)$version['id']] + ); + if ($deployment !== null) { + $context['deployment'] = $this->publicTimelineDeploymentReference($deployment); + } + + return $context; + } + + private function timelineBundleReference(?int $frontendVersionId, ?int $apiVersionId, ?int $bundleId): ?array + { + if ($bundleId !== null) { + $bundle = $this->selectOne( + "SELECT id, channel_id, service_set_id, version_label, frontend_version_id, + api_version_id, frontend_deployment_id, api_deployment_id, + status, deployed_at, promoted_at, created_at + FROM release_bundles + WHERE id = ? AND deleted_at IS NULL + LIMIT 1", + 'i', + [$bundleId] + ); + return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null; + } + + $where = ['deleted_at IS NULL']; + $types = ''; + $params = []; + if ($frontendVersionId !== null && $apiVersionId !== null) { + $where[] = 'frontend_version_id = ?'; + $where[] = 'api_version_id = ?'; + $types .= 'ii'; + $params[] = $frontendVersionId; + $params[] = $apiVersionId; + } elseif ($frontendVersionId !== null) { + $where[] = 'frontend_version_id = ?'; + $types .= 'i'; + $params[] = $frontendVersionId; + } elseif ($apiVersionId !== null) { + $where[] = 'api_version_id = ?'; + $types .= 'i'; + $params[] = $apiVersionId; + } else { + return null; + } + + $bundle = $this->selectOne( + "SELECT id, channel_id, service_set_id, version_label, frontend_version_id, + api_version_id, frontend_deployment_id, api_deployment_id, + status, deployed_at, promoted_at, created_at + FROM release_bundles + WHERE " . implode(' AND ', $where) . " + ORDER BY id DESC + LIMIT 1", + $types, + $params + ); + + return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null; + } + + private function publicTimelineVersionReference(array $version): array + { + return [ + 'id' => (int)($version['id'] ?? 0), + 'app' => (string)($version['app'] ?? ''), + 'repository' => $version['repository'] ?? null, + 'branch' => $version['branch'] ?? null, + 'commit_sha' => $version['commit_sha'] ?? null, + 'tag' => $version['tag'] ?? null, + 'version_label' => $version['version_label'] ?? null, + 'build_url' => $version['build_url'] ?? null, + 'artifact_url' => $version['artifact_url'] ?? null, + 'deployed_url' => $version['deployed_url'] ?? null, + 'status' => (string)($version['status'] ?? ''), + 'created_at' => $version['created_at'] ?? null, + 'deployed_at' => $version['deployed_at'] ?? null, + ]; + } + + private function publicTimelineDeploymentReference(array $deployment): array + { + return [ + 'id' => (int)($deployment['id'] ?? 0), + 'channel_id' => isset($deployment['channel_id']) ? (int)$deployment['channel_id'] : null, + 'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null, + 'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null, + 'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null, + 'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null, + 'deployment_kind' => (string)($deployment['deployment_kind'] ?? ''), + 'app' => (string)($deployment['app'] ?? ''), + 'provider' => (string)($deployment['provider'] ?? ''), + 'repository' => $deployment['repository'] ?? null, + 'branch' => $deployment['branch'] ?? null, + 'commit_sha' => $deployment['commit_sha'] ?? null, + 'status' => (string)($deployment['status'] ?? ''), + 'deployment_url' => $deployment['deployment_url'] ?? null, + 'started_at' => $deployment['started_at'] ?? null, + 'completed_at' => $deployment['completed_at'] ?? null, + 'created_at' => $deployment['created_at'] ?? null, + ]; + } + + private function publicTimelineBundleReference(array $bundle): array + { + return [ + 'id' => (int)($bundle['id'] ?? 0), + 'channel_id' => isset($bundle['channel_id']) ? (int)$bundle['channel_id'] : null, + 'service_set_id' => isset($bundle['service_set_id']) ? (int)$bundle['service_set_id'] : null, + 'version_label' => $bundle['version_label'] ?? null, + 'frontend_version_id' => isset($bundle['frontend_version_id']) ? (int)$bundle['frontend_version_id'] : null, + 'api_version_id' => isset($bundle['api_version_id']) ? (int)$bundle['api_version_id'] : null, + 'frontend_deployment_id' => isset($bundle['frontend_deployment_id']) ? (int)$bundle['frontend_deployment_id'] : null, + 'api_deployment_id' => isset($bundle['api_deployment_id']) ? (int)$bundle['api_deployment_id'] : null, + 'status' => (string)($bundle['status'] ?? ''), + 'deployed_at' => $bundle['deployed_at'] ?? null, + 'promoted_at' => $bundle['promoted_at'] ?? null, + 'created_at' => $bundle['created_at'] ?? null, + ]; + } + + private function clearOtherDefaultChannels(int $channelId): void + { + $this->execute('UPDATE release_channels SET default_channel = 0 WHERE id <> ?', 'i', [$channelId]); + } + + private function normalizeApp(string $value): string + { + $app = strtolower(trim($value)); + if (!in_array($app, self::APPS, true)) { + throw new RuntimeException('Release app must be frontend or api.'); + } + return $app; + } + + private function normalizeCaptureLevel(string $value): string + { + $level = strtolower(trim($value)); + return in_array($level, self::CAPTURE_LEVELS, true) ? $level : 'metadata'; + } + + private function nullableString(mixed $value, int $maxLength): ?string + { + if ($value === null) { + return null; + } + $value = trim((string)$value); + if ($value === '') { + return null; + } + return substr($value, 0, max(1, $maxLength)); + } + + private function nullableIdentifier(mixed $value, int $maxLength): ?string + { + $identifier = self::safeIdentifier((string)($value ?? ''), $maxLength); + return $identifier === '' ? null : $identifier; + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || !is_numeric($value)) { + return null; + } + return (int)$value; + } + + private function nullableFloat(mixed $value): ?float + { + if ($value === null || $value === '' || !is_numeric($value)) { + return null; + } + return (float)$value; + } + + private function normalizeDateTime(mixed $value): ?string + { + if (!is_string($value) || trim($value) === '') { + return null; + } + $timestamp = strtotime($value); + return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp); + } + + private function nullablePositiveInt(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + $int = (int)$value; + return $int > 0 ? $int : null; + } + + private function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + } + + private function requestTraceId(): string + { + $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) + ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] + : self::initializeRequestContext(); + return (string)($context['trace_id'] ?? ''); + } + + private function assignmentCacheKey(array $context): string + { + if (!empty($context['principal_type']) && !empty($context['principal_id'])) { + return 'release_manager:assignment:' . $context['principal_type'] . ':' . $context['principal_id']; + } + if (!empty($context['customer_number'])) { + return 'release_manager:assignment:customer:' . (int)$context['customer_number']; + } + return ''; + } + + private function cacheResolvedChannel(string $cacheKey, array $channel): void + { + if ($cacheKey === '' || !defined('redis')) { + return; + } + try { + redis->setEx($cacheKey, self::jsonEncode($channel), 60); + } catch (Throwable) { + } + } + + private function clearAssignmentCache(string $subjectType, string $subjectId): void + { + if (!defined('redis')) { + return; + } + try { + redis->delete('release_manager:assignment:' . $subjectType . ':' . $subjectId); + } catch (Throwable) { + } + } + + private function moduleConfigValue(string $module, string $variable, mixed $default = null): mixed + { + $row = $this->selectOne( + 'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1', + 'ss', + [$module, $variable] + ); + return $row['value'] ?? $default; + } + + private function upsertModuleConfigValue(string $module, string $variable, string $value, string $type): void + { + $existing = $this->selectOne( + 'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1', + 'ss', + [$module, $variable] + ); + + if ($existing === null) { + $this->execute( + 'INSERT INTO module_config (module, variable, value, type) VALUES (?, ?, ?, ?)', + 'ssss', + [$module, $variable, $value, $type] + ); + return; + } + + $this->execute( + 'UPDATE module_config SET value = ?, type = ? WHERE module = ? AND variable = ?', + 'ssss', + [$value, $type, $module, $variable] + ); + } + + private function audit(?int $channelId, ?int $deploymentId, string $action, ?int $actorUserId, string $severity, array $context): void + { + $this->execute( + "INSERT INTO release_audit_logs (channel_id, deployment_id, action, actor_user_id, severity, context_json) + VALUES (?, ?, ?, ?, ?, ?)", + 'iisiss', + [$channelId, $deploymentId, $action, $actorUserId, $severity, self::jsonEncode(self::redactPayload($context))] + ); + } + + private function selectOne(string $sql, string $types = '', array $params = []): ?array + { + $rows = $this->selectRows($sql, $types, $params); + return $rows[0] ?? null; + } + + private function selectRows(string $sql, string $types = '', array $params = []): array + { + global $db; + if ($types === '') { + $result = $db->query($sql); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } + + $stmt = $db->prepare($sql); + if ($stmt === false) { + throw new RuntimeException('Could not prepare release manager query.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $result = $stmt->get_result(); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } + + private function tableExists(string $table): bool + { + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; + if ($table === '') { + return false; + } + + try { + return $this->selectOne( + 'SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1', + 's', + [$table] + ) !== null; + } catch (Throwable) { + return false; + } + } + + private function cleanupExpiredReplayData(): void + { + try { + $this->execute( + "UPDATE release_replay_targets + SET deleted_at = NOW(), enabled = 0 + WHERE deleted_at IS NULL AND expires_at IS NOT NULL AND expires_at <= NOW()" + ); + + $this->execute( + "DELETE e + FROM release_timeline_events e + INNER JOIN release_timeline_sessions s ON s.id = e.timeline_session_id + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)" + ); + + $this->execute( + "DELETE s + FROM release_timeline_sessions s + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)" + ); + } catch (Throwable) { + // Retention cleanup should never block release debugging reads. + } + } + + private function execute(string $sql, string $types = '', array $params = []): void + { + global $db; + if ($types === '') { + $db->query($sql); + return; + } + + $stmt = $db->prepare($sql); + if ($stmt === false) { + throw new RuntimeException('Could not prepare release manager statement.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + } + + private function insertId(): int + { + global $db; + return (int)$db->insert_id(); + } + + private static function headerValue(array $headers, string $name): string + { + foreach ($headers as $key => $value) { + if (strcasecmp((string)$key, $name) === 0) { + return trim((string)$value); + } + } + $serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + return trim((string)($_SERVER[$serverKey] ?? '')); + } + + private static function requestHeaderValue(string $name): string + { + $headers = function_exists('getallheaders') ? getallheaders() : []; + return self::headerValue(is_array($headers) ? $headers : [], $name); + } + + private static function safeSlug(string $value): string + { + $slug = strtolower(trim($value)); + $slug = preg_replace('/[^a-z0-9_-]/', '-', $slug) ?? ''; + $slug = trim(preg_replace('/-+/', '-', $slug) ?? '', '-'); + return substr($slug, 0, 64); + } + + private static function safeIdentifier(string $value, int $maxLength): string + { + $value = trim($value); + $value = preg_replace('/[^a-zA-Z0-9_.:-]/', '', $value) ?? ''; + return substr($value, 0, max(1, $maxLength)); + } + + private static function isSensitiveKey(string $key): bool + { + return preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $key) === 1; + } + + private static function jsonEncode(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Could not encode release manager JSON payload.'); + } + return $json; + } + + private static function jsonDecode(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/services/nginx/app/classes/release_manager_schema_bootstrap.php b/services/nginx/app/classes/release_manager_schema_bootstrap.php new file mode 100644 index 00000000..a3243adb --- /dev/null +++ b/services/nginx/app/classes/release_manager_schema_bootstrap.php @@ -0,0 +1,557 @@ +query($sql); + } + + self::ensureColumn('release_channel_versions', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER deployment_id'); + self::ensureColumn('release_channel_versions', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id'); + self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id'); + self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id'); + self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id"); + self::ensureColumn('release_deployments', 'active_channel_app_key', 'VARCHAR(96) NULL AFTER app'); + self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id'); + self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type'); + self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name'); + self::ensureColumn('release_timeline_sessions', 'os_name', 'VARCHAR(64) NULL AFTER browser_version'); + self::ensureColumn('release_timeline_sessions', 'os_version', 'VARCHAR(64) NULL AFTER os_name'); + self::ensureColumn('release_timeline_sessions', 'viewport_width', 'INT NULL AFTER os_version'); + self::ensureColumn('release_timeline_sessions', 'viewport_height', 'INT NULL AFTER viewport_width'); + self::ensureColumn('release_timeline_sessions', 'device_pixel_ratio', 'DECIMAL(6,3) NULL AFTER viewport_height'); + self::ensureColumn('release_timeline_sessions', 'frontend_version_label', 'VARCHAR(128) NULL AFTER device_pixel_ratio'); + self::ensureColumn('release_timeline_sessions', 'frontend_commit_sha', 'VARCHAR(128) NULL AFTER frontend_version_label'); + self::ensureColumn('release_timeline_sessions', 'api_version_label', 'VARCHAR(128) NULL AFTER frontend_commit_sha'); + self::ensureColumn('release_timeline_sessions', 'api_commit_sha', 'VARCHAR(128) NULL AFTER api_version_label'); + self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha'); + self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type'); + self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label'); + self::ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app', 'active_channel_app_key'); + + self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool'); + self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string'); + self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_token', '', 'string'); + self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion', 'true', 'bool'); + self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string'); + self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string'); + self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int'); + + self::ensureDefaultChannels(); + + self::$initialized = true; + self::$tablesExist = true; + } + + public static function tablesExist(): bool + { + if (self::$tablesExist !== null) { + return self::$tablesExist; + } + + global $db; + + foreach ([ + 'release_channels', + 'release_versions', + 'release_channel_versions', + 'release_assignments', + 'release_auto_sync_events', + 'release_service_sets', + 'release_deployments', + 'release_bundles', + 'release_operation_runs', + 'release_operation_steps', + 'release_timeline_sessions', + 'release_timeline_events', + ] as $table) { + $tableSql = $db->escape_string($table); + $result = $db->query("SHOW TABLES LIKE '$tableSql'"); + if ($result === false || $result->num_rows === 0) { + self::$tablesExist = false; + return false; + } + } + + self::$tablesExist = true; + return true; + } + + private static function ensureDefaultChannels(): void + { + global $db; + + $channels = [ + ['stable', 'Stable', 'Default production channel.', 1, 1, 'metadata'], + ['canary', 'Canary', 'Earliest production validation channel.', 1, 0, 'metadata'], + ['beta', 'Beta', 'Broader pre-stable rollout channel.', 1, 0, 'metadata'], + ['internal', 'Internal', 'Internal staff and superuser validation channel.', 1, 0, 'metadata'], + ]; + + foreach ($channels as [$slug, $name, $description, $enabled, $default, $captureLevel]) { + $db->query(sprintf( + "INSERT IGNORE INTO release_channels (slug, name, description, enabled, default_channel, capture_level) + VALUES ('%s', '%s', '%s', %d, %d, '%s')", + $db->escape_string($slug), + $db->escape_string($name), + $db->escape_string($description), + (int)$enabled, + (int)$default, + $db->escape_string($captureLevel) + )); + } + } + + private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void + { + global $db; + + $moduleSql = $db->escape_string($module); + $variableSql = $db->escape_string($variable); + $result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1"); + if ($result !== false && $result->num_rows > 0) { + return; + } + + $valueSql = $db->escape_string($value); + $typeSql = $db->escape_string($type); + $db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')"); + } + + private static function ensureColumn(string $table, string $column, string $definition): void + { + global $db; + + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + $column = preg_replace('/[^a-zA-Z0-9_]/', '', $column); + if ($table === '' || $column === '') { + return; + } + + $result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'"); + if ($result !== false && $result->num_rows > 0) { + return; + } + + $db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition"); + } + + private static function ensureIndex(string $table, string $index, string $columns): void + { + global $db; + + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + $index = preg_replace('/[^a-zA-Z0-9_]/', '', $index); + if ($table === '' || $index === '') { + return; + } + + $indexSql = $db->escape_string($index); + $result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'"); + if ($result !== false && $result->num_rows > 0) { + return; + } + + $db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)"); + } + + private static function ensureUniqueIndex(string $table, string $index, string $columns): void + { + global $db; + + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + $index = preg_replace('/[^a-zA-Z0-9_]/', '', $index); + if ($table === '' || $index === '') { + return; + } + + $indexSql = $db->escape_string($index); + $result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'"); + if ($result !== false && $result->num_rows > 0) { + return; + } + + $db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$index` ($columns)"); + } +} diff --git a/services/nginx/app/classes/releasemanager.php b/services/nginx/app/classes/releasemanager.php new file mode 100644 index 00000000..a4450dfe --- /dev/null +++ b/services/nginx/app/classes/releasemanager.php @@ -0,0 +1,19 @@ +query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1"); + $row = $result ? $result->fetch_assoc() : null; + return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true); + } catch (\Throwable) { + return true; + } + } +} diff --git a/services/nginx/app/classes/replica_failover_manager.php b/services/nginx/app/classes/replica_failover_manager.php new file mode 100644 index 00000000..5aaa03d8 --- /dev/null +++ b/services/nginx/app/classes/replica_failover_manager.php @@ -0,0 +1,521 @@ + false, + 'database_enabled' => false, + 'redis_enabled' => false, + 'minio_enabled' => false, + 'max_status_age_seconds' => self::DEFAULT_MAX_STATUS_AGE_SECONDS, + ]; + } + + public static function normalizeConfig(array $config): array + { + $normalized = self::configDefaults(); + foreach (['enabled', 'database_enabled', 'redis_enabled', 'minio_enabled'] as $key) { + if (array_key_exists($key, $config)) { + $normalized[$key] = self::boolValue($config[$key]); + } + } + + if (array_key_exists('max_status_age_seconds', $config)) { + $normalized['max_status_age_seconds'] = max(1, (int)$config['max_status_age_seconds']); + } + + return $normalized; + } + + public static function kindEnabled(array $config, string $kind): bool + { + $config = self::normalizeConfig($config); + return $config['enabled'] && !empty($config[$kind . '_enabled']); + } + + public static function snapshotHostIsStrictlyFresh(array $host, int $maxAgeSeconds, ?int $now = null): bool + { + if (($host['role'] ?? '') !== 'replica') { + return false; + } + + if (!empty($host['deleted_at'])) { + return false; + } + + $status = self::hostStatus($host); + if (($status['status'] ?? '') !== 'ok') { + return false; + } + + if (round((float)($status['replication_percent'] ?? 0), 2) < 100.0) { + return false; + } + + $blockers = $status['blockers'] ?? []; + if (is_array($blockers) && $blockers !== []) { + return false; + } + + $checkedAt = self::hostCheckedAt($host, $status); + if ($checkedAt === null) { + return false; + } + + return (($now ?? time()) - $checkedAt) <= max(1, $maxAgeSeconds); + } + + public static function snapshotFailoverCandidate(array $hosts, string $kind, int $maxAgeSeconds, ?int $now = null): ?array + { + $eligible = array_values(array_filter( + $hosts, + static fn(array $host): bool => ($host['kind'] ?? '') === $kind + && self::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds, $now) + )); + + if ($eligible === []) { + return null; + } + + usort($eligible, static function (array $a, array $b) use ($now): int { + $aChecked = self::hostCheckedAt($a, self::hostStatus($a)) ?? 0; + $bChecked = self::hostCheckedAt($b, self::hostStatus($b)) ?? 0; + if ($aChecked === $bChecked) { + return (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0); + } + return $bChecked <=> $aChecked; + }); + + return $eligible[0]; + } + + public static function activeConfigFromHost(string $kind, array $host): ?array + { + if ($kind === self::KIND_DATABASE) { + $database = trim((string)($host['database_name'] ?? $host['database'] ?? '')); + $user = trim((string)($host['username'] ?? $host['user'] ?? '')); + if ($database === '' || $user === '') { + return null; + } + + return [ + 'id' => isset($host['id']) ? (int)$host['id'] : null, + 'host' => (string)($host['host'] ?? ''), + 'port' => (int)($host['port'] ?? 3306) ?: 3306, + 'database' => $database, + 'user' => $user, + 'password_secret' => (string)($host['password_secret'] ?? ''), + 'ssl_mode' => (string)($host['ssl_mode'] ?? 'DISABLED'), + ]; + } + + if ($kind === self::KIND_REDIS) { + return [ + 'id' => isset($host['id']) ? (int)$host['id'] : null, + 'host' => (string)($host['host'] ?? ''), + 'port' => (int)($host['port'] ?? 6379) ?: 6379, + 'database' => (int)($host['database_index'] ?? $host['database'] ?? 0), + 'user' => (string)($host['username'] ?? $host['user'] ?? ''), + 'password_secret' => (string)($host['password_secret'] ?? ''), + ]; + } + + if ($kind === self::KIND_MINIO) { + $options = self::jsonDecode($host['options_json'] ?? null); + return [ + 'id' => isset($host['id']) ? (int)$host['id'] : null, + 'endpoint' => self::minioEndpoint($host, $options), + 'access_key' => (string)($host['username'] ?? $host['access_key'] ?? ''), + 'secret_key_secret' => (string)($host['password_secret'] ?? ''), + 'buckets' => is_array($options['buckets'] ?? null) ? array_values($options['buckets']) : [], + ]; + } + + return null; + } + + public static function applyStartupFailoverFromSnapshot(?string $path = null, array $probes = []): array + { + $snapshot = replication_bootstrap_config::loadSnapshot($path); + $failover = is_array($snapshot['failover'] ?? null) ? $snapshot['failover'] : []; + $config = self::normalizeConfig(is_array($failover['config'] ?? null) ? $failover['config'] : $failover); + $active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : []; + $hostGroups = is_array($failover['hosts'] ?? null) ? $failover['hosts'] : []; + $maxAgeSeconds = (int)$config['max_status_age_seconds']; + $summary = []; + $changed = false; + + $primaryDown = $probes['primary_down'] ?? [self::class, 'activePrimaryIsDown']; + $candidateReachable = $probes['candidate_reachable'] ?? [self::class, 'candidateReachable']; + $promoteCandidate = $probes['promote_candidate'] ?? [self::class, 'promoteCandidate']; + + foreach ([self::KIND_DATABASE, self::KIND_REDIS, self::KIND_MINIO] as $kind) { + if (!self::kindEnabled($config, $kind)) { + $summary[$kind] = ['status' => 'skipped', 'reason' => 'disabled']; + continue; + } + + if (!is_array($active[$kind] ?? null)) { + $summary[$kind] = ['status' => 'skipped', 'reason' => 'missing_active_primary']; + continue; + } + + try { + if (!call_user_func($primaryDown, $kind, $active[$kind], $snapshot)) { + $summary[$kind] = ['status' => 'skipped', 'reason' => 'primary_healthy']; + continue; + } + + $hosts = is_array($hostGroups[$kind] ?? null) ? $hostGroups[$kind] : []; + $candidate = self::snapshotFailoverCandidate($hosts, $kind, $maxAgeSeconds); + if ($candidate === null) { + $summary[$kind] = ['status' => 'skipped', 'reason' => 'no_fresh_caught_up_replica']; + continue; + } + + if (!call_user_func($candidateReachable, $kind, $candidate)) { + $summary[$kind] = [ + 'status' => 'skipped', + 'reason' => 'candidate_unreachable', + 'candidate_id' => (int)($candidate['id'] ?? 0), + ]; + continue; + } + + call_user_func($promoteCandidate, $kind, $candidate); + $candidateActive = self::activeConfigFromHost($kind, $candidate); + if ($candidateActive === null) { + $summary[$kind] = [ + 'status' => 'skipped', + 'reason' => 'candidate_missing_active_config', + 'candidate_id' => (int)($candidate['id'] ?? 0), + ]; + continue; + } + + $snapshot['active'][$kind] = $candidateActive; + $pending = is_array($snapshot['pending_failovers'] ?? null) ? $snapshot['pending_failovers'] : []; + $pending[] = [ + 'kind' => $kind, + 'host_id' => (int)($candidate['id'] ?? 0), + 'label' => (string)($candidate['label'] ?? ''), + 'source' => 'startup_snapshot', + 'promoted_at' => date('c'), + ]; + $snapshot['pending_failovers'] = $pending; + $summary[$kind] = [ + 'status' => 'promoted', + 'candidate_id' => (int)($candidate['id'] ?? 0), + ]; + $changed = true; + } catch (Throwable $throwable) { + $summary[$kind] = [ + 'status' => 'failed', + 'reason' => $throwable->getMessage(), + ]; + } + } + + if ($changed) { + $snapshot['generated_at'] = date('c'); + replication_bootstrap_config::writeSnapshot($snapshot, $path); + if ($path === null) { + replication_bootstrap_config::applyToGlobals($snapshot); + } + } + + return [ + 'changed' => $changed, + 'results' => $summary, + ]; + } + + public static function activePrimaryIsDown(string $kind, array $activeConfig, array $snapshot = []): bool + { + try { + match ($kind) { + self::KIND_DATABASE => self::probeActiveDatabase($activeConfig), + self::KIND_REDIS => self::probeActiveRedis($activeConfig), + self::KIND_MINIO => self::probeActiveMinio($activeConfig), + default => null, + }; + return false; + } catch (Throwable) { + return true; + } + } + + public static function candidateReachable(string $kind, array $host): bool + { + try { + match ($kind) { + self::KIND_DATABASE => self::probeHostDatabase($host), + self::KIND_REDIS => self::probeHostRedis($host), + self::KIND_MINIO => self::probeHostMinio($host), + default => null, + }; + return true; + } catch (Throwable) { + return false; + } + } + + public static function promoteCandidate(string $kind, array $host): void + { + match ($kind) { + self::KIND_DATABASE => self::promoteDatabaseCandidate($host), + self::KIND_REDIS => self::promoteRedisCandidate($host), + self::KIND_MINIO => self::probeHostMinio($host), + default => null, + }; + } + + private static function probeActiveDatabase(array $config): void + { + $host = (string)($config['host'] ?? ''); + $user = (string)($config['user'] ?? ''); + $database = (string)($config['database'] ?? ''); + $password = self::activePassword($config, 'password_secret', 'password'); + self::connectMysqli($host, $user, $password, $database, (int)($config['port'] ?? 3306))->close(); + } + + private static function probeHostDatabase(array $host): void + { + $credentials = self::hostCredentials($host); + $connection = self::connectMysqli( + (string)($host['host'] ?? ''), + $credentials['username'], + $credentials['password'], + (string)($host['database_name'] ?? ''), + (int)($host['port'] ?? 3306) + ); + $connection->close(); + } + + private static function promoteDatabaseCandidate(array $host): void + { + $credentials = self::hostCredentials($host); + $user = $credentials['admin_username'] !== '' ? $credentials['admin_username'] : $credentials['username']; + $password = $credentials['admin_password'] !== '' ? $credentials['admin_password'] : $credentials['password']; + $connection = self::connectMysqli( + (string)($host['host'] ?? ''), + $user, + $password, + (string)($host['database_name'] ?? ''), + (int)($host['port'] ?? 3306) + ); + + try { + foreach (['STOP REPLICA', 'STOP SLAVE'] as $statement) { + try { + $connection->query($statement); + break; + } catch (Throwable) { + } + } + foreach (['SET GLOBAL super_read_only = OFF', 'SET GLOBAL read_only = OFF'] as $statement) { + try { + $connection->query($statement); + } catch (Throwable) { + } + } + } finally { + $connection->close(); + } + } + + private static function probeActiveRedis(array $config): void + { + self::redisClientFromConfig([ + 'host' => (string)($config['host'] ?? ''), + 'port' => (int)($config['port'] ?? 6379), + 'database' => (int)($config['database'] ?? 0), + 'user' => (string)($config['user'] ?? ''), + 'password' => self::activePassword($config, 'password_secret', 'password'), + ])->ping(); + } + + private static function probeHostRedis(array $host): void + { + self::redisClientFromHost($host)->ping(); + } + + private static function promoteRedisCandidate(array $host): void + { + $client = self::redisClientFromHost($host); + $client->executeRaw(['REPLICAOF', 'NO', 'ONE']); + try { + $client->executeRaw(['CONFIG', 'REWRITE']); + } catch (Throwable) { + } + } + + private static function probeActiveMinio(array $config): void + { + self::minioClientFromConfig([ + 'endpoint' => (string)($config['endpoint'] ?? ''), + 'access_key' => (string)($config['access_key'] ?? $config['user'] ?? ''), + 'secret_key' => self::activePassword($config, 'secret_key_secret', 'secret_key'), + ])->listBuckets(); + } + + private static function probeHostMinio(array $host): void + { + self::minioClientFromHost($host)->listBuckets(); + } + + private static function connectMysqli(string $host, string $user, string $password, string $database, int $port): mysqli + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + $connection = mysqli_init(); + $connection->options(MYSQLI_OPT_CONNECT_TIMEOUT, 2); + $connection->real_connect($host, $user, $password, $database, $port ?: 3306); + $connection->set_charset('utf8mb4'); + return $connection; + } + + private static function redisClientFromHost(array $host): PredisClient + { + $credentials = self::hostCredentials($host); + return self::redisClientFromConfig([ + 'host' => (string)($host['host'] ?? ''), + 'port' => (int)($host['port'] ?? 6379), + 'database' => (int)($host['database_index'] ?? 0), + 'user' => $credentials['username'], + 'password' => $credentials['password'], + ]); + } + + private static function redisClientFromConfig(array $config): PredisClient + { + $params = [ + 'scheme' => 'tcp', + 'host' => (string)$config['host'], + 'port' => (int)$config['port'], + 'database' => (int)$config['database'], + 'password' => (string)$config['password'], + 'timeout' => 2.0, + 'read_write_timeout' => 2.0, + ]; + if (($config['user'] ?? '') !== '' && $config['user'] !== 'default') { + $params['username'] = (string)$config['user']; + } + return new PredisClient($params); + } + + private static function minioClientFromHost(array $host): S3Client + { + $credentials = self::hostCredentials($host); + $options = self::jsonDecode($host['options_json'] ?? null); + return self::minioClientFromConfig([ + 'endpoint' => self::minioEndpoint($host, $options), + 'access_key' => $credentials['username'], + 'secret_key' => $credentials['password'], + ]); + } + + private static function minioClientFromConfig(array $config): S3Client + { + return new S3Client([ + 'version' => 'latest', + 'region' => 'us-east-1', + 'endpoint' => (string)$config['endpoint'], + 'use_path_style_endpoint' => true, + 'credentials' => [ + 'key' => (string)$config['access_key'], + 'secret' => (string)$config['secret_key'], + ], + 'http' => [ + 'connect_timeout' => 2, + 'timeout' => 2, + ], + ]); + } + + private static function minioEndpoint(array $host, array $options): string + { + $endpoint = trim((string)($options['endpoint'] ?? '')); + if ($endpoint !== '') { + return $endpoint; + } + + $scheme = strtolower(trim((string)($options['scheme'] ?? 'http'))); + if ($scheme !== 'https') { + $scheme = 'http'; + } + + return $scheme . '://' . (string)($host['host'] ?? '') . ':' . ((int)($host['port'] ?? 9000) ?: 9000); + } + + private static function hostCredentials(array $host): array + { + return [ + 'username' => (string)($host['username'] ?? ''), + 'password' => replication_secret_box::decrypt($host['password_secret'] ?? ''), + 'admin_username' => (string)($host['admin_username'] ?? ''), + 'admin_password' => replication_secret_box::decrypt($host['admin_password_secret'] ?? ''), + ]; + } + + private static function activePassword(array $config, string $secretKey, string $plainKey): string + { + if (!empty($config[$secretKey])) { + return replication_secret_box::decrypt((string)$config[$secretKey]); + } + + return (string)($config[$plainKey] ?? ''); + } + + private static function hostStatus(array $host): array + { + if (isset($host['last_status']) && is_array($host['last_status'])) { + return $host['last_status']; + } + + return self::jsonDecode($host['last_status_json'] ?? null); + } + + private static function hostCheckedAt(array $host, array $status): ?int + { + $raw = $host['last_checked_at'] ?? $status['checked_at'] ?? null; + if (!is_string($raw) || trim($raw) === '') { + return null; + } + + $timestamp = strtotime($raw); + return $timestamp === false ? null : $timestamp; + } + + private static function boolValue(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + } + + private static function jsonDecode(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/services/nginx/app/classes/replication_bootstrap_config.php b/services/nginx/app/classes/replication_bootstrap_config.php new file mode 100644 index 00000000..bb754721 --- /dev/null +++ b/services/nginx/app/classes/replication_bootstrap_config.php @@ -0,0 +1,156 @@ + 1, + 'generated_at' => date('c'), + ], $snapshot); + + $json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Could not encode replication bootstrap snapshot.'); + } + + $tempPath = tempnam($dir, 'replication-bootstrap-'); + if ($tempPath === false) { + throw new RuntimeException('Could not create replication bootstrap snapshot temp file.'); + } + + try { + if (file_put_contents($tempPath, $json . PHP_EOL, LOCK_EX) === false) { + throw new RuntimeException('Could not write replication bootstrap snapshot.'); + } + + if (!rename($tempPath, $path)) { + throw new RuntimeException('Could not atomically replace replication bootstrap snapshot.'); + } + } finally { + if (is_file($tempPath)) { + @unlink($tempPath); + } + } + } + + public static function applyToGlobals(array $snapshot): void + { + try { + $active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : []; + $database = self::activeDatabaseConfigFromSnapshot($active['database'] ?? null); + $redis = self::activeRedisConfigFromSnapshot($active['redis'] ?? null); + $minio = self::activeMinioConfigFromSnapshot($active['minio'] ?? null); + + if ($database !== null) { + $GLOBALS['CONFIG_DB'] = array_merge($GLOBALS['CONFIG_DB'] ?? [], $database); + } + + if ($redis !== null) { + $GLOBALS['REDIS_CONFIG'] = array_merge($GLOBALS['REDIS_CONFIG'] ?? [], $redis); + } + + if ($minio !== null) { + $GLOBALS['MINIO'] = array_merge($GLOBALS['MINIO'] ?? [], $minio); + } + } catch (Throwable $throwable) { + error_log('[replication-bootstrap] Falling back to environment configuration: ' . $throwable->getMessage()); + } + } + + public static function activeDatabaseConfigFromSnapshot(mixed $config): ?array + { + if (!is_array($config)) { + return null; + } + + $host = trim((string)($config['host'] ?? '')); + $database = trim((string)($config['database'] ?? '')); + $user = trim((string)($config['user'] ?? '')); + if ($host === '' || $database === '' || $user === '') { + return null; + } + + return [ + 'host' => $host, + 'user' => $user, + 'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''), + 'database' => $database, + 'port' => (int)($config['port'] ?? 3306) ?: 3306, + 'ssl_mode' => (string)($config['ssl_mode'] ?? 'DISABLED'), + ]; + } + + public static function activeRedisConfigFromSnapshot(mixed $config): ?array + { + if (!is_array($config)) { + return null; + } + + $host = trim((string)($config['host'] ?? '')); + if ($host === '') { + return null; + } + + return [ + 'host' => $host, + 'user' => (string)($config['user'] ?? ''), + 'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''), + 'database' => (int)($config['database'] ?? 0), + 'port' => (int)($config['port'] ?? 6379) ?: 6379, + ]; + } + + public static function activeMinioConfigFromSnapshot(mixed $config): ?array + { + if (!is_array($config)) { + return null; + } + + $endpoint = trim((string)($config['endpoint'] ?? '')); + $accessKey = trim((string)($config['access_key'] ?? $config['user'] ?? '')); + if ($endpoint === '' || $accessKey === '') { + return null; + } + + return [ + 'endpoint' => $endpoint, + 'access_key' => $accessKey, + 'secret_key' => replication_secret_box::decrypt($config['secret_key_secret'] ?? $config['password_secret'] ?? ''), + 'buckets' => is_array($config['buckets'] ?? null) ? array_values($config['buckets']) : ($config['buckets'] ?? null), + ]; + } + + private static function storageDir(): string + { + $root = defined('WD') ? WD : dirname(__DIR__); + return $root . DIRECTORY_SEPARATOR . 'storage'; + } +} diff --git a/services/nginx/app/classes/replication_manager.php b/services/nginx/app/classes/replication_manager.php new file mode 100644 index 00000000..e2aa189c --- /dev/null +++ b/services/nginx/app/classes/replication_manager.php @@ -0,0 +1,6522 @@ +ensureEnvironmentPrimaryRows(); + if ($refresh) { + $this->refreshStatuses(); + } + + $databaseHosts = $this->listHosts(self::KIND_DATABASE); + $redisHosts = $this->listHosts(self::KIND_REDIS); + $minioHosts = $this->listHosts(self::KIND_MINIO); + + return [ + 'generated_at' => date('c'), + 'database' => [ + 'primary' => $this->publicHost($this->primaryHost(self::KIND_DATABASE)), + 'hosts' => array_map(fn(array $host): array => $this->publicHost($host), $databaseHosts), + 'replication' => $this->buildReplicationSummary(self::KIND_DATABASE, $databaseHosts), + ], + 'redis' => [ + 'primary' => $this->publicHost($this->primaryHost(self::KIND_REDIS)), + 'hosts' => array_map(fn(array $host): array => $this->publicHost($host), $redisHosts), + 'replication' => $this->buildReplicationSummary(self::KIND_REDIS, $redisHosts), + ], + 'minio' => [ + 'primary' => $this->publicHost($this->primaryHost(self::KIND_MINIO)), + 'hosts' => array_map(fn(array $host): array => $this->publicHost($host), $minioHosts), + 'replication' => $this->buildReplicationSummary(self::KIND_MINIO, $minioHosts), + ], + 'write_freeze' => application_write_freeze::state(), + ]; + } + + public function dependencyReplication(string $kind): array + { + $kind = self::normalizeKind($kind); + $this->ensureEnvironmentPrimaryRows(); + return $this->buildReplicationSummary($kind, $this->listHosts($kind)); + } + + public function addHost(string $kind, array $input, ?int $actorUserId = null): array + { + $kind = self::normalizeKind($kind); + $host = $this->normalizeHostInput($kind, $input); + + $this->execute( + "INSERT INTO replication_hosts ( + kind, label, host, port, database_name, database_index, username, password_secret, + admin_username, admin_password_secret, replication_username, replication_password_secret, + role, status, ssl_mode, options_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'replica', 'unknown', ?, ?)", + 'sssissssssssss', + [ + $kind, + $host['label'], + $host['host'], + $host['port'], + $host['database_name'], + $host['database_index'], + $host['username'], + $host['password_secret'], + $host['admin_username'], + $host['admin_password_secret'], + $host['replication_username'], + $host['replication_password_secret'], + $host['ssl_mode'], + self::jsonEncode($host['options']), + ] + ); + + $id = $this->insertId(); + $this->audit($kind, $id, 'host_added', $actorUserId, 'info', [ + 'label' => $host['label'], + 'host' => $host['host'], + 'port' => $host['port'], + ]); + $this->writeBootstrapSnapshot(); + + return $this->publicHost($this->getHost($kind, $id)); + } + + public function testHost(string $kind, int $id, ?int $actorUserId = null): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + + $status = match ($kind) { + self::KIND_DATABASE => $this->testDatabaseHost($host), + self::KIND_REDIS => $this->testRedisHost($host), + self::KIND_MINIO => $this->testMinioHost($host), + }; + + $this->storeStatus($host, $status); + $this->writeBootstrapSnapshot(); + $this->audit($kind, $id, 'host_tested', $actorUserId, $status['blockers'] === [] ? 'info' : 'warning', [ + 'status' => $status['status'], + 'replication_percent' => $status['replication_percent'], + 'blockers' => $status['blockers'], + ]); + + return [ + 'host' => $this->publicHost($this->getHost($kind, $id)), + 'status' => $status, + ]; + } + + public function testCredentials(string $kind, array $input): array + { + $kind = self::normalizeKind($kind); + $host = $this->transientHost($kind, $input); + $options = $this->decodeOptions($host); + if ($kind === self::KIND_DATABASE && !empty($options['allow_preseeded_replica'])) { + $host['connect_without_database'] = true; + } + $status = match ($kind) { + self::KIND_DATABASE => $this->testDatabaseHost($host), + self::KIND_REDIS => $this->testRedisHost($host), + self::KIND_MINIO => $this->testMinioHost(array_merge($host, [ + 'test_connectivity_only' => true, + 'skip_storage_scan' => true, + ])), + }; + + if ($kind === self::KIND_DATABASE + && ($host['role'] ?? '') !== 'primary' + && !empty($options['allow_preseeded_replica']) + && $status['status'] !== 'down') { + try { + $target = $this->databaseConnection($host, true); + try { + $seedBlockers = $this->databaseReplicaSeedBlockers($this->primaryHost(self::KIND_DATABASE), $host, $target); + } finally { + $target->close(); + } + } catch (Throwable $throwable) { + $seedBlockers = [$throwable->getMessage()]; + } + + if ($seedBlockers !== []) { + $status['blockers'] = array_values(array_unique(array_merge($status['blockers'], $seedBlockers))); + $status['status'] = 'degraded'; + if ((float)$status['replication_percent'] >= 100.0) { + $status['replication_percent'] = 99.99; + } + } + } + + return [ + 'ok' => $status['status'] === 'ok', + 'host' => $this->publicHost($host), + 'status' => $status, + ]; + } + + public function provisionHost( + string $kind, + int $id, + ?int $actorUserId = null, + bool $deferCoolifyManagedMinio = false + ): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + $operationId = $this->activeOperationId($kind, $id, 'provision') + ?? $this->startOperation($kind, $id, 'provision', $actorUserId); + + if ($deferCoolifyManagedMinio && $this->shouldDeferCoolifyManagedMinioProvision($kind, $host)) { + return $this->deferCoolifyManagedMinioProvision($host, $operationId); + } + + try { + $result = match ($kind) { + self::KIND_DATABASE => $this->provisionDatabaseHost($host, $operationId), + self::KIND_REDIS => $this->provisionRedisHost($host, $operationId), + self::KIND_MINIO => $this->provisionMinioHost($host, $operationId), + }; + + if (($result['operation']['status'] ?? null) === 'running') { + $this->audit($kind, $id, 'host_provision_progress', $actorUserId, 'info', $result); + return $result; + } + + $status = $result['ok'] ? 'completed' : 'blocked'; + $this->finishOperation($operationId, $status, (float)($result['replication_percent'] ?? 0), $result['message'] ?? null, $result['blockers'] ?? []); + $this->audit($kind, $id, 'host_provisioned', $actorUserId, $result['ok'] ? 'info' : 'warning', $result); + return $result; + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', 0, null, [$throwable->getMessage()]); + $this->audit($kind, $id, 'host_provision_failed', $actorUserId, 'error', ['error' => $throwable->getMessage()]); + throw $throwable; + } + } + + private function shouldDeferCoolifyManagedMinioProvision(string $kind, array $host): bool + { + if ($kind !== self::KIND_MINIO) { + return false; + } + + $options = $this->decodeOptions($host); + return (string)($options['deployment_provider'] ?? '') === 'coolify' + || isset($options['coolify_target_id']) + || isset($options['coolify_instance_id']); + } + + private function deferCoolifyManagedMinioProvision(array $host, int $operationId): array + { + $lastStatus = self::sanitizePublicLastStatus($host, self::jsonDecode($host['last_status_json'] ?? null)); + $progress = 45.0; + if (is_array($lastStatus) && is_numeric($lastStatus['replication_percent'] ?? null)) { + $progress = max($progress, self::minioIncompleteProgress((float)$lastStatus['replication_percent'])); + } + + $message = 'MinIO provisioning was queued for Coolify background maintenance.'; + $this->updateOperationProgress($operationId, $progress, $message, [ + 'phase' => 'coolify_deferred', + 'queued_at' => date('c'), + ]); + $this->execute( + "UPDATE replication_hosts SET status = 'provisioning' WHERE id = ? AND kind = ?", + 'is', + [(int)$host['id'], self::KIND_MINIO] + ); + + $status = [ + 'status' => 'provisioning', + 'replication_percent' => $progress, + 'lag_seconds' => null, + 'blockers' => [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER], + 'raw' => [ + 'progress_source' => 'coolify_deferred', + 'previous_status' => is_array($lastStatus) ? [ + 'status' => $lastStatus['status'] ?? null, + 'replication_percent' => $lastStatus['replication_percent'] ?? null, + 'checked_at' => $lastStatus['checked_at'] ?? null, + ] : null, + ], + 'checked_at' => date('c'), + ]; + $this->storeStatus($this->getHost(self::KIND_MINIO, (int)$host['id']), $status); + + return [ + 'ok' => true, + 'message' => $message, + 'blockers' => $status['blockers'], + 'replication_percent' => $progress, + 'operation' => [ + 'id' => $operationId, + 'status' => 'running', + 'progress_percent' => $progress, + 'message' => $message, + ], + 'host' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + ]; + } + + public function promoteHost(string $kind, int $id, ?int $actorUserId = null): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + if (($host['role'] ?? '') === 'primary') { + return [ + 'ok' => true, + 'message' => 'Host is already primary.', + 'host' => $this->publicHost($host), + 'blockers' => [], + ]; + } + + $operationId = $this->startOperation($kind, $id, 'promote', $actorUserId); + $owner = 'replication-promote-' . $kind . '-' . $id . '-' . bin2hex(random_bytes(4)); + $lockHandle = $this->acquirePromotionLock(); + + try { + application_write_freeze::freeze('Replication promotion in progress.', $owner, 600); + $result = match ($kind) { + self::KIND_DATABASE => $this->promoteDatabaseHost($host), + self::KIND_REDIS => $this->promoteRedisHost($host), + self::KIND_MINIO => $this->promoteMinioHost($host), + }; + + $this->finishOperation($operationId, 'completed', 100, $result['message'] ?? null, []); + $this->audit($kind, $id, 'host_promoted', $actorUserId, 'critical', $result); + return $result; + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', 0, null, [$throwable->getMessage()]); + $this->audit($kind, $id, 'host_promotion_failed', $actorUserId, 'error', ['error' => $throwable->getMessage()]); + throw $throwable; + } finally { + application_write_freeze::unfreeze($owner); + $this->releasePromotionLock($lockHandle); + } + } + + public function runAutomaticFailoverMonitor(?int $actorUserId = null): array + { + $this->ensureEnvironmentPrimaryRows(); + $config = $this->failoverConfigForSnapshot(); + $results = []; + + foreach ([self::KIND_DATABASE, self::KIND_REDIS, self::KIND_MINIO] as $kind) { + $results[$kind] = $this->runAutomaticFailoverForKind($kind, $config, $actorUserId); + } + + try { + $this->refreshStatuses(); + } catch (Throwable $throwable) { + $this->audit(self::KIND_DATABASE, null, 'automatic_failover_status_refresh_failed', $actorUserId, 'warning', [ + 'error' => $throwable->getMessage(), + ]); + } + + $this->writeBootstrapSnapshot(); + + return [ + 'ok' => true, + 'config' => $config, + 'results' => $results, + ]; + } + + public function syncStartupFailoversFromSnapshot(?int $actorUserId = null): array + { + $snapshot = replication_bootstrap_config::loadSnapshot(); + $pending = is_array($snapshot['pending_failovers'] ?? null) ? $snapshot['pending_failovers'] : []; + $synced = []; + + foreach ($pending as $entry) { + if (!is_array($entry)) { + continue; + } + + try { + $kind = self::normalizeKind((string)($entry['kind'] ?? '')); + $hostId = (int)($entry['host_id'] ?? 0); + if ($hostId <= 0) { + continue; + } + + $currentPrimary = $this->primaryHost($kind); + if ($currentPrimary !== null && (int)$currentPrimary['id'] !== $hostId) { + $this->switchPrimary($kind, $hostId, (int)$currentPrimary['id']); + } + + $this->audit($kind, $hostId, 'startup_failover_synced', $actorUserId, 'critical', $entry); + $synced[] = [ + 'kind' => $kind, + 'host_id' => $hostId, + ]; + } catch (Throwable $throwable) { + $this->audit((string)($entry['kind'] ?? self::KIND_DATABASE), null, 'startup_failover_sync_failed', $actorUserId, 'error', [ + 'entry' => $entry, + 'error' => $throwable->getMessage(), + ]); + } + } + + if ($pending !== []) { + $snapshot['pending_failovers'] = []; + replication_bootstrap_config::writeSnapshot($snapshot); + $this->writeBootstrapSnapshot(); + } + + return $synced; + } + + public function removeHost(string $kind, int $id, ?int $actorUserId = null, bool $removeLinkedCoolifyTargets = true): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + $canRemove = self::replicationHostCanBeRemoved($host); + if (!$canRemove && class_exists(coolify_manager::class)) { + $canRemove = coolify_manager::replicationHostCanBeRemoved($host); + } + if (!$canRemove) { + if (($host['role'] ?? '') === 'primary') { + throw new RuntimeException('Primary hosts cannot be removed. Promote a healthy replica first.'); + } + throw new RuntimeException('Only inactive prior hosts or unhealthy replicas can be removed.'); + } + $this->execute( + "UPDATE replication_hosts SET deleted_at = NOW(), status = 'removed' WHERE id = ? AND kind = ?", + 'is', + [$id, $kind] + ); + $this->audit($kind, $id, 'host_removed', $actorUserId, 'warning', [ + 'label' => $host['label'] ?? '', + 'host' => $host['host'] ?? '', + ]); + if ($removeLinkedCoolifyTargets && class_exists(coolify_manager::class)) { + coolify_manager::markTargetsRemovedForReplicationHost($id, $actorUserId); + } + $this->writeBootstrapSnapshot(); + + return [ + 'ok' => true, + 'message' => 'Replication host removed.', + 'id' => $id, + 'kind' => $kind, + ]; + } + + public function renameHost(string $kind, int $id, array $input, ?int $actorUserId = null): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + $label = trim((string)($input['label'] ?? $input['name'] ?? '')); + if ($label === '') { + throw new RuntimeException('Replication host label is required.'); + } + if (mb_strlen($label) > 128) { + throw new RuntimeException('Replication host label must be 128 characters or fewer.'); + } + + $oldLabel = (string)($host['label'] ?? ''); + if ($label !== $oldLabel) { + $this->execute( + "UPDATE replication_hosts SET label = ? WHERE id = ? AND kind = ? AND deleted_at IS NULL", + 'sis', + [$label, $id, $kind] + ); + if (class_exists(coolify_manager::class)) { + coolify_manager::syncLabelForReplicationHost($id, $label); + } + $this->audit($kind, $id, 'host_renamed', $actorUserId, 'info', [ + 'old_label' => $oldLabel, + 'new_label' => $label, + ]); + $this->writeBootstrapSnapshot(); + } + + return $this->publicHost($this->getHost($kind, $id)); + } + + public static function replicationHostCanBeRemoved(array $host): bool + { + $role = (string)($host['role'] ?? ''); + if ($role === 'primary') { + return false; + } + if ($role === 'inactive') { + return true; + } + + $status = (string)($host['status'] ?? 'unknown'); + return $role === 'replica' && in_array($status, ['degraded', 'down', 'unknown', 'not_configured'], true); + } + + public static function composeTemplate(array $input): array + { + $kind = self::normalizeKind((string)($input['kind'] ?? self::KIND_DATABASE)); + $role = self::normalizeComposeRole((string)($input['role'] ?? 'replica')); + + return match ($kind) { + self::KIND_DATABASE => self::databaseComposeTemplate($input, $role), + self::KIND_REDIS => self::redisComposeTemplate($input, $role), + self::KIND_MINIO => self::minioComposeTemplate($input, $role), + }; + } + + public static function normalizeKind(string $kind): string + { + $kind = strtolower(trim($kind)); + if (in_array($kind, ['database', 'databases', 'mysql', 'db'], true)) { + return self::KIND_DATABASE; + } + if ($kind === self::KIND_REDIS) { + return self::KIND_REDIS; + } + if (in_array($kind, ['minio', 's3', 'object-storage', 'object_storage'], true)) { + return self::KIND_MINIO; + } + throw new RuntimeException('Unsupported replication kind.'); + } + + private static function databaseComposeTemplate(array $input, string $role): array + { + $serviceName = self::composeIdentifier($input['service_name'] ?? null, 'mariadb-' . $role); + $volumeName = self::composeIdentifier($input['volume_name'] ?? null, $serviceName . '-data'); + $database = self::composeScalar($input['database'] ?? null, 'nnks_db'); + $username = self::composeScalar($input['username'] ?? null, 'nnks_db_user'); + $image = self::composeImage($input['image'] ?? null, 'mariadb:11'); + $hostPort = self::boundedInt($input['host_port'] ?? null, $role === 'primary' ? 3306 : 3307, 1, 65535); + $serverId = self::boundedInt($input['server_id'] ?? null, $role === 'primary' ? 1 : 2, 1, 4294967295); + $rootPassword = self::composePassword($input['admin_password'] ?? null); + $applicationPassword = self::composePassword($input['password'] ?? null); + $replicationUsername = self::composeScalar($input['replication_username'] ?? null, 'replication'); + $replicationPassword = self::composePassword($input['replication_password'] ?? null); + $primaryHost = self::composeScalar($input['primary_host'] ?? null, ''); + $primaryPort = self::boundedInt($input['primary_port'] ?? null, 3306, 1, 65535); + $primaryAdminUsername = self::composeScalar($input['primary_admin_username'] ?? null, 'root'); + $primaryAdminPassword = trim((string)($input['primary_admin_password'] ?? '')); + + $command = [ + 'mariadbd', + '--server-id=' . $serverId, + '--log-bin=/var/lib/mysql/mariadb-bin', + '--binlog-format=ROW', + '--gtid-strict-mode=ON', + '--expire-logs-days=7', + ]; + if ($role === 'replica') { + $command[] = '--read-only=ON'; + foreach (self::MARIADB_SCHEMA_ONLY_TABLES as $tableName) { + $command[] = '--replicate-ignore-table=' . $database . '.' . $tableName; + } + } + + $lines = [ + 'services:', + ' ' . $serviceName . ':', + ' image: ' . self::yamlQuote($image), + ' restart: unless-stopped', + ' environment:', + ' MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}"', + ' MARIADB_DATABASE: ' . self::yamlQuote($database), + ' MARIADB_USER: ' . self::yamlQuote($username), + ' MARIADB_PASSWORD: "${MARIADB_PASSWORD:?set MARIADB_PASSWORD}"', + ' command:', + ]; + + foreach ($command as $argument) { + $lines[] = ' - ' . self::yamlQuote($argument); + } + + $lines = array_merge($lines, [ + ' volumes:', + ' - ' . $volumeName . ':/var/lib/mysql', + ' ports:', + ' - ' . self::yamlQuote($hostPort . ':3306'), + ' healthcheck:', + ' test:', + ' - "CMD-SHELL"', + ' - "mariadb-admin ping -h 127.0.0.1 -uroot -p$${MARIADB_ROOT_PASSWORD} --silent"', + ' interval: 10s', + ' timeout: 5s', + ' retries: 12', + ]); + + if ($role === 'replica') { + $seedServiceName = self::composeIdentifier($serviceName . '-seed', 'mariadb-replica-seed'); + $seedScript = [ + 'marker="/var/lib/mysql/.truckwash-replica-seeded"', + 'if [ -f "$${marker}" ]; then', + ' echo "Replica already seeded."', + ' exit 0', + 'fi', + 'echo "Waiting for local replica..."', + 'until mariadb-admin ping -h ' . self::shellArg($serviceName) . ' -uroot -p"$${MARIADB_ROOT_PASSWORD}" --silent; do sleep 2; done', + 'echo "Importing seed from primary..."', + 'mariadb-dump --host="$${MARIADB_PRIMARY_HOST}" --port="$${MARIADB_PRIMARY_PORT}" --user="$${MARIADB_PRIMARY_ADMIN_USER}" --password="$${MARIADB_PRIMARY_ADMIN_PASSWORD}" --single-transaction --quick --routines --triggers --events --gtid --master-data=2 ' . self::mariaDbSchemaOnlyDumpIgnoreArgs('$${MARIADB_SEED_DATABASE}') . ' --databases "$${MARIADB_SEED_DATABASE}" | mariadb --host=' . self::shellArg($serviceName) . ' --user=root --password="$${MARIADB_ROOT_PASSWORD}"', + 'for table in ' . implode(' ', self::MARIADB_SCHEMA_ONLY_TABLES) . '; do', + ' mariadb-dump --host="$${MARIADB_PRIMARY_HOST}" --port="$${MARIADB_PRIMARY_PORT}" --user="$${MARIADB_PRIMARY_ADMIN_USER}" --password="$${MARIADB_PRIMARY_ADMIN_PASSWORD}" --single-transaction --quick --no-data "$${MARIADB_SEED_DATABASE}" "$${table}" | mariadb --host=' . self::shellArg($serviceName) . ' --user=root --password="$${MARIADB_ROOT_PASSWORD}" "$${MARIADB_SEED_DATABASE}" || true', + 'done', + 'touch "$${marker}"', + 'echo "Replica seed completed."', + ]; + + $lines = array_merge($lines, [ + ' ' . $seedServiceName . ':', + ' image: ' . self::yamlQuote($image), + ' restart: "no"', + ' depends_on:', + ' ' . $serviceName . ':', + ' condition: service_healthy', + ' environment:', + ' MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}"', + ' MARIADB_PRIMARY_HOST: "${MARIADB_PRIMARY_HOST:?set MARIADB_PRIMARY_HOST}"', + ' MARIADB_PRIMARY_PORT: "${MARIADB_PRIMARY_PORT:-3306}"', + ' MARIADB_PRIMARY_ADMIN_USER: "${MARIADB_PRIMARY_ADMIN_USER:-root}"', + ' MARIADB_PRIMARY_ADMIN_PASSWORD: "${MARIADB_PRIMARY_ADMIN_PASSWORD:?set MARIADB_PRIMARY_ADMIN_PASSWORD}"', + ' MARIADB_SEED_DATABASE: ' . self::yamlQuote($database), + ' volumes:', + ' - ' . $volumeName . ':/var/lib/mysql', + ' entrypoint:', + ' - /bin/sh', + ' - -ec', + ' - |', + ]); + foreach ($seedScript as $scriptLine) { + $lines[] = ' ' . $scriptLine; + } + } + + $lines = array_merge($lines, [ + 'volumes:', + ' ' . $volumeName . ':', + ]); + + $steps = [ + 'Deploy this compose file as a normal Docker Compose or Coolify compose service.', + 'Keep server-id unique across the MariaDB primary and every replica.', + 'Create or store a replication user on the primary with REPLICATION SLAVE privileges.', + ]; + if ($role === 'replica') { + $steps[] = 'Fill MARIADB_PRIMARY_ADMIN_PASSWORD in the generated .env file.'; + $steps[] = 'Deploy the compose file and wait for the seed service to complete successfully.'; + $steps[] = 'Test the connection, then save and provision the replica.'; + } else { + $steps[] = 'Add the primary credentials in the superuser UI after the service is reachable.'; + } + + $seedCommand = implode(' ', [ + 'mariadb-dump', + '--host=' . self::shellArg($primaryHost), + '--port=' . $primaryPort, + '--user=', + '--password', + '--single-transaction', + '--quick', + '--routines', + '--triggers', + '--events', + '--gtid', + '--master-data=2', + ...self::mariaDbSchemaOnlySeedCommandIgnoreArgs($database), + '--databases', + self::shellArg($database), + '|', + 'mariadb', + '--host=', + '--port=' . $hostPort, + '--user=root', + '--password', + ]); + $envLines = [ + 'MARIADB_ROOT_PASSWORD=' . $rootPassword, + 'MARIADB_PASSWORD=' . $applicationPassword, + ]; + if ($role === 'replica') { + $envLines[] = 'MARIADB_PRIMARY_HOST=' . $primaryHost; + $envLines[] = 'MARIADB_PRIMARY_PORT=' . $primaryPort; + $envLines[] = 'MARIADB_PRIMARY_ADMIN_USER=' . $primaryAdminUsername; + $envLines[] = 'MARIADB_PRIMARY_ADMIN_PASSWORD=' . $primaryAdminPassword; + } + + return [ + 'kind' => self::KIND_DATABASE, + 'engine' => 'mariadb', + 'role' => $role, + 'service_name' => $serviceName, + 'host_port' => $hostPort, + 'server_id' => $serverId, + 'compose' => implode("\n", $lines) . "\n", + 'env' => implode("\n", $envLines) . "\n", + 'seed_command' => $role === 'replica' ? $seedCommand : '', + 'credentials' => [ + 'label' => $serviceName, + 'host' => '', + 'port' => $hostPort, + 'database' => $database, + 'username' => $username, + 'password' => $applicationPassword, + 'admin_username' => 'root', + 'admin_password' => $rootPassword, + 'replication_username' => $replicationUsername, + 'replication_password' => $replicationPassword, + 'ssl_mode' => 'DISABLED', + 'allow_preseeded_replica' => $role === 'replica', + ], + 'steps' => $steps, + ]; + } + + private static function redisComposeTemplate(array $input, string $role): array + { + $serviceName = self::composeIdentifier($input['service_name'] ?? null, 'redis-' . $role); + $volumeName = self::composeIdentifier($input['volume_name'] ?? null, $serviceName . '-data'); + $image = self::composeImage($input['image'] ?? null, 'redis:7'); + $hostPort = self::boundedInt($input['host_port'] ?? null, $role === 'primary' ? 6379 : 6380, 1, 65535); + $primaryHost = self::composeScalar($input['primary_host'] ?? null, 'redis-primary'); + $primaryPort = self::boundedInt($input['primary_port'] ?? null, 6379, 1, 65535); + $redisPassword = self::composePassword($input['password'] ?? null); + $primaryPassword = self::composeScalar($input['primary_password'] ?? null, ''); + $primaryUsername = self::composeScalar($input['primary_username'] ?? null, ''); + + $script = [ + 'if [ ! -f /data/redis.conf ]; then', + ' {', + ' echo "appendonly yes"', + ' echo "requirepass $$REDIS_PASSWORD"', + ]; + if ($role === 'replica') { + $script[] = ' echo "replicaof $$REDIS_PRIMARY_HOST $${REDIS_PRIMARY_PORT:-6379}"'; + $script[] = ' echo "masterauth $$REDIS_PRIMARY_PASSWORD"'; + $script[] = ' if [ -n "$${REDIS_PRIMARY_USERNAME:-}" ] && [ "$${REDIS_PRIMARY_USERNAME}" != "default" ]; then'; + $script[] = ' echo "masteruser $$REDIS_PRIMARY_USERNAME"'; + $script[] = ' fi'; + } + $script = array_merge($script, [ + ' } > /data/redis.conf', + 'fi', + 'exec redis-server /data/redis.conf', + ]); + + $lines = [ + 'services:', + ' ' . $serviceName . ':', + ' image: ' . self::yamlQuote($image), + ' restart: unless-stopped', + ' environment:', + ' REDIS_PASSWORD: "${REDIS_PASSWORD:?set REDIS_PASSWORD}"', + ]; + if ($role === 'replica') { + $lines[] = ' REDIS_PRIMARY_HOST: "${REDIS_PRIMARY_HOST:?set REDIS_PRIMARY_HOST}"'; + $lines[] = ' REDIS_PRIMARY_PORT: "${REDIS_PRIMARY_PORT:-6379}"'; + $lines[] = ' REDIS_PRIMARY_PASSWORD: "${REDIS_PRIMARY_PASSWORD:?set REDIS_PRIMARY_PASSWORD}"'; + $lines[] = ' REDIS_PRIMARY_USERNAME: "${REDIS_PRIMARY_USERNAME:-}"'; + } + $lines[] = ' command:'; + $lines[] = ' - /bin/sh'; + $lines[] = ' - -ec'; + $lines[] = ' - |'; + foreach ($script as $scriptLine) { + $lines[] = ' ' . $scriptLine; + } + + $lines = array_merge($lines, [ + ' volumes:', + ' - ' . $volumeName . ':/data', + ' ports:', + ' - ' . self::yamlQuote($hostPort . ':6379'), + ' healthcheck:', + ' test:', + ' - "CMD-SHELL"', + ' - "redis-cli --no-auth-warning -a \"$${REDIS_PASSWORD}\" ping | grep PONG"', + ' interval: 10s', + ' timeout: 5s', + ' retries: 12', + 'volumes:', + ' ' . $volumeName . ':', + ]); + + $steps = [ + 'Deploy this compose file as a normal Docker Compose or Coolify compose service.', + 'Add the Redis credentials in the superuser UI after the service is reachable.', + ]; + if ($role === 'replica') { + $steps[] = 'Use the current Redis primary host and password for REDIS_PRIMARY_HOST and REDIS_PRIMARY_PASSWORD, then run Test in the superuser UI.'; + } + + $envLines = [ + 'REDIS_PASSWORD=' . $redisPassword, + ]; + if ($role === 'replica') { + $envLines[] = 'REDIS_PRIMARY_HOST=' . $primaryHost; + $envLines[] = 'REDIS_PRIMARY_PORT=' . $primaryPort; + $envLines[] = 'REDIS_PRIMARY_PASSWORD=' . $primaryPassword; + $envLines[] = 'REDIS_PRIMARY_USERNAME=' . $primaryUsername; + } + + return [ + 'kind' => self::KIND_REDIS, + 'engine' => 'redis', + 'role' => $role, + 'service_name' => $serviceName, + 'host_port' => $hostPort, + 'compose' => implode("\n", $lines) . "\n", + 'env' => implode("\n", $envLines) . "\n", + 'credentials' => [ + 'label' => $serviceName, + 'host' => '', + 'port' => $hostPort, + 'database' => 0, + 'username' => '', + 'password' => $redisPassword, + ], + 'steps' => $steps, + ]; + } + + private static function minioComposeTemplate(array $input, string $role): array + { + $serviceName = self::composeIdentifier($input['service_name'] ?? null, 'minio-' . $role); + $volumeName = self::composeIdentifier($input['volume_name'] ?? null, $serviceName . '-data'); + $image = self::composeImage($input['image'] ?? null, 'minio/minio:latest'); + $mcImage = self::composeImage($input['mc_image'] ?? null, 'minio/mc:latest'); + $hostPort = self::boundedInt($input['host_port'] ?? null, $role === 'primary' ? 9000 : 9010, 1, 65535); + $consolePort = self::boundedInt($input['console_port'] ?? null, $role === 'primary' ? 9001 : 9011, 1, 65535); + $rootUser = self::composeAccessKey($input['username'] ?? $input['access_key'] ?? null); + $rootPassword = self::composePassword($input['password'] ?? $input['secret_key'] ?? null); + $buckets = self::normalizeMinioBuckets($input['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + $transferLimit = self::normalizeMinioTransferLimit( + $input['replication_transfer_limit'] ?? self::MINIO_DEFAULT_REPLICATION_TRANSFER_LIMIT, + true + ); + $primaryEndpoint = self::minioPrimaryComposeValue($input, 'endpoint'); + $primaryAccessKey = self::minioPrimaryComposeValue($input, 'access_key'); + $primarySecretKey = self::minioPrimaryComposeValue($input, 'secret_key'); + [$serverUrl, $browserRedirectUrl] = self::minioComposePublicUrls($input, $hostPort, $consolePort); + + $setupScript = [ + 'until mc alias set local http://' . self::shellArg($serviceName) . ':9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"; do sleep 2; done', + ]; + foreach ($buckets as $bucket) { + $bucketArg = self::shellArg('local/' . $bucket); + $setupScript[] = 'mc mb --with-lock --ignore-existing ' . $bucketArg; + $setupScript[] = 'mc version enable ' . $bucketArg . ' || true'; + if ($role === 'replica' && self::minioBucketUsesBoundedReplicaRetention($bucket)) { + $setupScript[] = 'mc ilm rule add --expire-days "' . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS . '" --noncurrent-expire-days "' . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS . '" ' . $bucketArg . ' || true'; + } + } + + $lines = [ + 'services:', + ' ' . $serviceName . ':', + ' image: ' . self::yamlQuote($image), + ' restart: unless-stopped', + ' command:', + ' - server', + ' - /data', + ' - --console-address', + ' - ":9001"', + ' environment:', + ' MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"', + ' MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"', + ' MINIO_SERVER_URL: "${MINIO_SERVER_URL:-}"', + ' MINIO_BROWSER_REDIRECT_URL: "${MINIO_BROWSER_REDIRECT_URL:-}"', + ' volumes:', + ' - ' . $volumeName . ':/data', + ' ports:', + ' - ' . self::yamlQuote($hostPort . ':9000'), + ' - ' . self::yamlQuote($consolePort . ':9001'), + ' healthcheck:', + ' test:', + ' - "CMD"', + ' - "curl"', + ' - "-f"', + ' - "http://127.0.0.1:9000/minio/health/live"', + ' interval: 10s', + ' timeout: 5s', + ' retries: 12', + ' ' . $serviceName . '-setup:', + ' image: ' . self::yamlQuote($mcImage), + ' restart: "no"', + ' depends_on:', + ' ' . $serviceName . ':', + ' condition: service_healthy', + ' environment:', + ' MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"', + ' MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"', + ]; + if ($role === 'replica') { + $lines[] = ' MINIO_PRIMARY_ENDPOINT: "${MINIO_PRIMARY_ENDPOINT:-}"'; + $lines[] = ' MINIO_PRIMARY_ACCESS_KEY: "${MINIO_PRIMARY_ACCESS_KEY:-}"'; + $lines[] = ' MINIO_PRIMARY_SECRET_KEY: "${MINIO_PRIMARY_SECRET_KEY:-}"'; + } + $lines = array_merge($lines, [ + ' entrypoint:', + ' - /bin/sh', + ' - -ec', + ' - |', + ]); + foreach ($setupScript as $scriptLine) { + $lines[] = ' ' . $scriptLine; + } + $lines = array_merge($lines, [ + 'volumes:', + ' ' . $volumeName . ':', + ]); + + $envLines = [ + 'MINIO_ROOT_USER=' . $rootUser, + 'MINIO_ROOT_PASSWORD=' . $rootPassword, + 'MINIO_SERVER_URL=' . $serverUrl, + 'MINIO_BROWSER_REDIRECT_URL=' . $browserRedirectUrl, + 'MINIO_BUCKETS=' . implode(',', $buckets), + ]; + if ($role === 'replica') { + $envLines[] = 'MINIO_BACKUP_REPLICA_RETENTION_DAYS=' . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS; + $envLines[] = 'MINIO_REPLICATION_TRANSFER_LIMIT=' . $transferLimit; + $envLines[] = 'MINIO_PRIMARY_ENDPOINT=' . $primaryEndpoint; + $envLines[] = 'MINIO_PRIMARY_ACCESS_KEY=' . $primaryAccessKey; + $envLines[] = 'MINIO_PRIMARY_SECRET_KEY=' . $primarySecretKey; + } + + $steps = [ + 'Deploy this compose file as a normal Docker Compose or Coolify compose service.', + 'The setup service creates required buckets and enables bucket versioning.', + 'Add the MinIO credentials in the superuser UI after the API endpoint is reachable.', + ]; + if ($role === 'replica') { + $steps[] = 'Fill the MINIO_PRIMARY_* .env values for reference; managed bucket replication is configured from the superuser UI.'; + $steps[] = 'The backups bucket is retained on replicas for ' . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS . ' days; other buckets are fully replicated.'; + $steps[] = 'Replica seeding and bucket replication are bandwidth-limited to ' . ($transferLimit !== '' ? $transferLimit : 'unlimited') . '.'; + $steps[] = 'Test the connection, then save and provision the replica.'; + } + + return [ + 'kind' => self::KIND_MINIO, + 'engine' => 'minio', + 'role' => $role, + 'service_name' => $serviceName, + 'host_port' => $hostPort, + 'console_port' => $consolePort, + 'compose' => implode("\n", $lines) . "\n", + 'env' => implode("\n", $envLines) . "\n", + 'credentials' => [ + 'label' => $serviceName, + 'host' => '', + 'port' => $hostPort, + 'scheme' => 'http', + 'endpoint' => '', + 'buckets' => $buckets, + 'console_port' => $consolePort, + 'username' => $rootUser, + 'password' => $rootPassword, + 'replication_transfer_limit' => $transferLimit, + 'space_headroom_percent' => self::MINIO_SPACE_HEADROOM_PERCENT, + ], + 'steps' => $steps, + ]; + } + + private static function normalizeComposeRole(string $role): string + { + $role = strtolower(trim($role)); + if (in_array($role, ['primary', 'replica'], true)) { + return $role; + } + throw new RuntimeException('Unsupported compose role.'); + } + + private static function composeIdentifier(mixed $value, string $fallback): string + { + $identifier = strtolower(trim((string)$value)); + $identifier = (string)preg_replace('/[^a-z0-9_.-]+/', '-', $identifier); + $identifier = trim($identifier, '-_.'); + return $identifier !== '' ? $identifier : $fallback; + } + + private static function composeScalar(mixed $value, string $fallback): string + { + $scalar = trim((string)$value); + return $scalar !== '' ? $scalar : $fallback; + } + + private static function composePassword(mixed $value): string + { + $password = trim((string)$value); + if ($password !== '') { + return $password; + } + + return self::generateSecret(24); + } + + private static function composeAccessKey(mixed $value): string + { + $accessKey = trim((string)$value); + if ($accessKey !== '') { + return $accessKey; + } + + return 'twminio' . bin2hex(random_bytes(12)); + } + + private static function minioPrimaryComposeValue(array $input, string $field): string + { + if ($field === 'endpoint') { + foreach (['primary_endpoint', 'minio_primary_endpoint'] as $key) { + $value = trim((string)($input[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + $primaryHost = trim((string)($input['primary_host'] ?? '')); + if ($primaryHost !== '') { + if (preg_match('/^https?:\/\//i', $primaryHost) === 1) { + return $primaryHost; + } + + $primaryScheme = trim((string)($input['primary_scheme'] ?? 'http')) ?: 'http'; + $primaryPort = self::boundedInt($input['primary_port'] ?? null, 9000, 1, 65535); + return self::minioEndpointFromParts($primaryScheme, $primaryHost, $primaryPort); + } + } + + $inputKeys = match ($field) { + 'endpoint' => [], + 'access_key' => ['primary_access_key', 'minio_primary_access_key', 'primary_username'], + 'secret_key' => ['primary_secret_key', 'minio_primary_secret_key', 'primary_password'], + default => [], + }; + + foreach ($inputKeys as $key) { + $value = trim((string)($input[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + $minioConfig = $GLOBALS['MINIO'] ?? null; + if (!is_array($minioConfig)) { + return ''; + } + + return trim((string)($minioConfig[$field] ?? '')); + } + + /** + * Public MinIO URLs keep browser redirects on the externally mapped ports. + */ + private static function minioComposePublicUrls(array $input, int $hostPort, int $consolePort): array + { + $rawHost = trim((string)($input['public_host'] ?? $input['host'] ?? $input['endpoint'] ?? '')); + if ($rawHost === '') { + return ['', '']; + } + + try { + [$host, , $scheme] = self::normalizeMinioAddress($rawHost, null, $input['scheme'] ?? null); + } catch (Throwable) { + return ['', '']; + } + + return [ + self::minioEndpointFromParts($scheme, $host, $hostPort), + self::minioEndpointFromParts($scheme, $host, $consolePort), + ]; + } + + private static function generateSecret(int $bytes): string + { + return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '='); + } + + private static function composeImage(mixed $value, string $fallback): string + { + $image = trim((string)$value); + if ($image === '' || preg_match('/^[a-zA-Z0-9._:\/-]+$/', $image) !== 1) { + return $fallback; + } + return $image; + } + + private static function boundedInt(mixed $value, int $fallback, int $min, int $max): int + { + if (filter_var($value, FILTER_VALIDATE_INT) === false) { + return $fallback; + } + return max($min, min($max, (int)$value)); + } + + private static function yamlQuote(mixed $value): string + { + $encoded = json_encode((string)$value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return is_string($encoded) ? $encoded : '""'; + } + + private static function shellArg(string $value): string + { + return "'" . str_replace("'", "'\"'\"'", $value) . "'"; + } + + private static function mariaDbSchemaOnlyDumpIgnoreArgs(string $databaseExpression): string + { + return implode(' ', array_map( + static fn(string $tableName): string => '--ignore-table="' . $databaseExpression . '.' . $tableName . '"', + self::MARIADB_SCHEMA_ONLY_TABLES + )); + } + + private static function mariaDbSchemaOnlySeedCommandIgnoreArgs(string $database): array + { + return array_map( + static fn(string $tableName): string => '--ignore-table=' . self::shellArg($database . '.' . $tableName), + self::MARIADB_SCHEMA_ONLY_TABLES + ); + } + + private static function quoteIdentifier(string $identifier): string + { + return '`' . str_replace('`', '``', $identifier) . '`'; + } + + private static function sqlString(mysqli $connection, string $value): string + { + return "'" . $connection->real_escape_string($value) . "'"; + } + + public static function mysqlGtidIntervalCount(string $gtidSet): int + { + $count = 0; + foreach (self::parseMysqlGtidSet($gtidSet) as $intervals) { + foreach ($intervals as [$start, $end]) { + $count += max(0, $end - $start + 1); + } + } + return $count; + } + + public static function mysqlGtidCoveragePercent(string $sourceSet, string $executedSet): float + { + $source = self::parseMysqlGtidSet($sourceSet); + $executed = self::parseMysqlGtidSet($executedSet); + $total = 0; + $covered = 0; + + foreach ($source as $uuid => $sourceIntervals) { + foreach ($sourceIntervals as [$sourceStart, $sourceEnd]) { + $total += max(0, $sourceEnd - $sourceStart + 1); + foreach ($executed[$uuid] ?? [] as [$executedStart, $executedEnd]) { + $start = max($sourceStart, $executedStart); + $end = min($sourceEnd, $executedEnd); + if ($end >= $start) { + $covered += $end - $start + 1; + } + } + } + } + + if ($total === 0) { + return 100.0; + } + + return round(min(100, max(0, ($covered / $total) * 100)), 2); + } + + public static function redisOffsetPercent(int $primaryOffset, int $replicaOffset): float + { + if ($primaryOffset <= 0) { + return 100.0; + } + + return round(min(100, max(0, ($replicaOffset / $primaryOffset) * 100)), 2); + } + + public static function redisReplicationPercentFromInfo(array $primaryInfo, array $replicaInfo): float + { + $syncInProgress = (string)($replicaInfo['master_sync_in_progress'] ?? '0') === '1'; + if ($syncInProgress) { + $totalBytes = (int)($replicaInfo['master_sync_total_bytes'] ?? 0); + $leftBytes = (int)($replicaInfo['master_sync_left_bytes'] ?? 0); + if ($totalBytes <= 0) { + return 5.0; + } + + $copiedBytes = max(0, $totalBytes - max(0, $leftBytes)); + return round(min(99.99, max(5.0, ($copiedBytes / $totalBytes) * 100)), 2); + } + + $primaryOffset = (int)($primaryInfo['master_repl_offset'] ?? 0); + $replicaOffset = (int)($replicaInfo['slave_repl_offset'] ?? $replicaInfo['master_repl_offset'] ?? 0); + return self::redisOffsetPercent($primaryOffset, $replicaOffset); + } + + public static function redisProvisionProgress(float $replicationPercent, array $syncBlockers = []): float + { + if ($replicationPercent >= 100.0 && $syncBlockers === []) { + return 100.0; + } + + return round(min(99.99, max(5.0, $replicationPercent)), 2); + } + + public static function replicationHealthStatus( + bool $reachable, + string $role, + float $replicationPercent, + array $blockers, + bool $replicationChecked = true + ): string { + if (!$reachable) { + return 'down'; + } + + if ($blockers !== []) { + return 'degraded'; + } + + if ($replicationChecked && $role !== 'primary' && $replicationPercent < 100.0) { + return 'degraded'; + } + + return 'ok'; + } + + public static function minioRequiredFreeBytes(int $sourceBytes, float $headroomPercent = self::MINIO_SPACE_HEADROOM_PERCENT): int + { + return (int)ceil(max(0, $sourceBytes) * (1 + max(0.0, $headroomPercent) / 100)); + } + + public static function minioByteReplicationPercent(int $sourceBytes, int $replicaBytes): float + { + if ($sourceBytes <= 0) { + return 100.0; + } + + return round(min(100, max(0, ($replicaBytes / $sourceBytes) * 100)), 2); + } + + public static function minioProvisionProgress(array $status): float + { + $percent = round((float)($status['replication_percent'] ?? 0), 2); + $blockers = array_values(array_filter($status['blockers'] ?? [])); + if ($percent >= 100.0 && $blockers === []) { + return 100.0; + } + + $measured = !empty($status['raw']['storage']['measured']) + || (string)($status['raw']['progress_source'] ?? '') === 'minio_replicate_status'; + if ($measured) { + return min(self::MINIO_INCOMPLETE_PROGRESS_MAX_PERCENT, max(0.0, $percent)); + } + + return self::minioIncompleteProgress($percent); + } + + private static function minioIncompleteProgress(float $percent, float $minimum = 5.0): float + { + return round(min(self::MINIO_INCOMPLETE_PROGRESS_MAX_PERCENT, max($minimum, $percent)), 2); + } + + public static function minioReplicationProgressFromStatusOutput(mixed $value): ?array + { + if (is_string($value)) { + $textProgress = self::minioReplicationProgressFromText($value); + if ($textProgress !== null) { + return $textProgress; + } + + $decoded = self::decodeMinioJsonOutput($value); + if ($decoded !== null && $decoded !== $value) { + return self::minioReplicationProgressFromStatusOutput($decoded); + } + + return null; + } + + $stats = [ + 'completed_bytes' => 0.0, + 'pending_bytes' => 0.0, + 'failed_bytes' => 0.0, + 'total_bytes' => 0.0, + 'completed_count' => 0.0, + 'pending_count' => 0.0, + 'failed_count' => 0.0, + 'total_count' => 0.0, + 'complete_signals' => 0, + 'incomplete_signals' => 0, + ]; + self::collectMinioReplicationProgress($value, $stats); + + $completedBytes = (float)$stats['completed_bytes']; + $remainingBytes = (float)$stats['pending_bytes'] + (float)$stats['failed_bytes']; + $totalBytes = (float)$stats['total_bytes']; + $completedCount = (float)$stats['completed_count']; + $remainingCount = (float)$stats['pending_count'] + (float)$stats['failed_count']; + $totalCount = (float)$stats['total_count']; + $basis = null; + $percent = null; + + if ($totalBytes > 0.0) { + $percent = ($completedBytes / $totalBytes) * 100; + $basis = 'total_bytes'; + } elseif (($completedBytes + $remainingBytes) > 0.0) { + $percent = ($completedBytes / ($completedBytes + $remainingBytes)) * 100; + $basis = 'byte_balance'; + } elseif ($totalCount > 0.0) { + $percent = ($completedCount / $totalCount) * 100; + $basis = 'total_count'; + } elseif (($completedCount + $remainingCount) > 0.0) { + $percent = ($completedCount / ($completedCount + $remainingCount)) * 100; + $basis = 'count_balance'; + } elseif ((int)$stats['complete_signals'] > 0 && (int)$stats['incomplete_signals'] === 0) { + $percent = 100.0; + $basis = 'status_signal'; + } elseif ((int)$stats['incomplete_signals'] > 0) { + $percent = 5.0; + $basis = 'status_signal'; + } + + if ($percent === null) { + return null; + } + + $percent = round(min(100.0, max(0.0, $percent)), 2); + + return [ + 'replication_percent' => $percent, + 'blockers' => $percent < 100.0 ? [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER] : [], + 'basis' => $basis, + 'stats' => $stats, + ]; + } + + public static function minioBackupReplicaRetentionDays(): int + { + return self::MINIO_BACKUP_REPLICA_RETENTION_DAYS; + } + + public static function minioBackupRetentionBlockers(array $stats): array + { + foreach ($stats['buckets'] ?? [] as $bucket) { + if (!is_array($bucket) || (string)($bucket['name'] ?? '') !== self::MINIO_BACKUP_BUCKET) { + continue; + } + + $expiredObjects = (int)($bucket['expired_objects'] ?? 0); + if ($expiredObjects <= 0) { + return []; + } + + return [ + 'MinIO backup replica contains ' . $expiredObjects . ' backup object' + . ($expiredObjects === 1 ? '' : 's') . ' older than ' + . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS . ' days. Run provisioning to prune retained backups.', + ]; + } + + return []; + } + + public static function minioSpaceBlockers(?int $availableBytes, int $requiredBytes): array + { + if ($availableBytes === null) { + return []; + } + if ($availableBytes < $requiredBytes) { + return ['MinIO target does not have enough free space. Required ' . $requiredBytes . ' bytes, available ' . $availableBytes . ' bytes.']; + } + + return []; + } + + public static function normalizeMinioBuckets(mixed $value): array + { + if (is_string($value)) { + $value = preg_split('/[\s,]+/', $value); + } + if (!is_array($value)) { + $value = self::MINIO_DEFAULT_BUCKETS; + } + + $buckets = []; + foreach ($value as $bucket) { + $bucket = strtolower(trim((string)$bucket)); + if ($bucket === '' || preg_match('/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/', $bucket) !== 1) { + continue; + } + $buckets[] = $bucket; + } + + $buckets = array_values(array_unique($buckets)); + return $buckets !== [] ? $buckets : self::MINIO_DEFAULT_BUCKETS; + } + + private static function minioBucketUsesBoundedReplicaRetention(string $bucket): bool + { + return strtolower(trim($bucket)) === self::MINIO_BACKUP_BUCKET; + } + + public static function minioBucketCountsTowardCatchUp(string $bucket): bool + { + return !self::minioBucketUsesBoundedReplicaRetention($bucket); + } + + private static function minioReplicaRetentionDaysByBucket(array $buckets): array + { + $retention = []; + foreach ($buckets as $bucket) { + if (self::minioBucketUsesBoundedReplicaRetention((string)$bucket)) { + $retention[(string)$bucket] = self::MINIO_BACKUP_REPLICA_RETENTION_DAYS; + } + } + + return $retention; + } + + public static function minioDefaultReplicationTransferLimit(): string + { + return self::MINIO_DEFAULT_REPLICATION_TRANSFER_LIMIT; + } + + public static function normalizeMinioTransferLimit(mixed $value, bool $defaultWhenEmpty = true): string + { + $raw = trim((string)$value); + if ($raw === '') { + return $defaultWhenEmpty ? self::MINIO_DEFAULT_REPLICATION_TRANSFER_LIMIT : ''; + } + + $normalized = preg_replace('/\s+/', '', $raw) ?? $raw; + $lower = strtolower($normalized); + if (in_array($lower, ['0', 'none', 'off', 'unlimited', 'disabled'], true)) { + return ''; + } + + $normalized = preg_replace('/\/s$/i', '', $normalized) ?? $normalized; + if (preg_match('/^(\d+(?:\.\d+)?)([a-zA-Z]*)$/', $normalized, $matches) !== 1) { + throw new RuntimeException('MinIO transfer limit must be empty, 0, or a rate like 25Mi, 100M, or 1G.'); + } + + $amount = $matches[1]; + if (str_contains($amount, '.')) { + $amount = rtrim(rtrim($amount, '0'), '.'); + } + if ($amount === '' || (float)$amount <= 0) { + return ''; + } + + $unit = $matches[2]; + $unitMap = [ + '' => '', + 'b' => 'B', + 'k' => 'K', + 'kb' => 'K', + 'm' => 'M', + 'mb' => 'M', + 'g' => 'G', + 'gb' => 'G', + 't' => 'T', + 'tb' => 'T', + 'ki' => 'Ki', + 'kib' => 'Ki', + 'mi' => 'Mi', + 'mib' => 'Mi', + 'gi' => 'Gi', + 'gib' => 'Gi', + 'ti' => 'Ti', + 'tib' => 'Ti', + ]; + $unitKey = strtolower($unit); + if (!array_key_exists($unitKey, $unitMap)) { + throw new RuntimeException('MinIO transfer limit must use B, K, M, G, T, Ki, Mi, Gi, or Ti units.'); + } + + return $amount . $unitMap[$unitKey]; + } + + private static function minioReplicationTransferLimitFromOptions(array $options): string + { + foreach (['replication_transfer_limit', 'transfer_limit', 'bandwidth_limit'] as $key) { + if (array_key_exists($key, $options)) { + return self::normalizeMinioTransferLimit($options[$key], false); + } + } + + return self::MINIO_DEFAULT_REPLICATION_TRANSFER_LIMIT; + } + + private static function minioReplicationTransferLimitArgs(string $transferLimit): array + { + $transferLimit = self::normalizeMinioTransferLimit($transferLimit, false); + if ($transferLimit === '') { + return []; + } + + return ['--limit-upload', $transferLimit, '--limit-download', $transferLimit]; + } + + private static function minioReplicationProgressFromText(string $output): ?array + { + if (preg_match_all('/(? $percent > 0.0)); + $percent = $nonZero !== [] ? min($nonZero) : 0.0; + $percent = round(min(100.0, max(0.0, $percent)), 2); + + return [ + 'replication_percent' => $percent, + 'blockers' => $percent < 100.0 ? [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER] : [], + 'basis' => 'text_percent', + 'stats' => [ + 'percent_values' => $percentages, + ], + ]; + } + + private static function collectMinioReplicationProgress(mixed $value, array &$stats, array $path = []): void + { + if (is_object($value)) { + $value = get_object_vars($value); + } + if (!is_array($value)) { + return; + } + + foreach ($value as $key => $entry) { + $normalizedKey = self::normalizeMinioProgressKey((string)$key); + $nextPath = array_values(array_filter(array_merge($path, [$normalizedKey]), static fn(string $part): bool => $part !== '')); + + if (is_numeric($entry)) { + self::collectMinioReplicationProgressNumber($nextPath, (float)$entry, $stats); + continue; + } + + if (is_string($entry)) { + self::collectMinioReplicationProgressString($entry, $stats); + $textProgress = self::minioReplicationProgressFromText($entry); + if ($textProgress !== null) { + $stats['completed_count'] += (float)$textProgress['replication_percent']; + $stats['total_count'] += 100.0; + } + continue; + } + + self::collectMinioReplicationProgress($entry, $stats, $nextPath); + } + } + + private static function collectMinioReplicationProgressNumber(array $path, float $value, array &$stats): void + { + if ($value < 0.0) { + return; + } + + $pathText = implode('', $path); + foreach ([ + 'percent', + 'percentage', + 'duration', + 'elapsed', + 'timestamp', + 'time', + 'priority', + 'port', + 'versionid', + 'avg', + 'average', + 'peak', + 'rate', + 'latency', + 'uptime', + 'downtime', + 'lastminute', + 'lasthour', + 'last1hr', + 'last1m', + 'last5min', + 'sinceuptime', + ] as $ignored) { + if (str_contains($pathText, $ignored)) { + return; + } + } + + $category = null; + foreach (['failed', 'failure', 'failures', 'error', 'errors'] as $needle) { + if (str_contains($pathText, $needle)) { + $category = 'failed'; + break; + } + } + if ($category === null) { + foreach (['pending', 'queued', 'queue', 'backlog', 'remaining', 'unreplicated', 'inprogress', 'missing'] as $needle) { + if (str_contains($pathText, $needle)) { + $category = 'pending'; + break; + } + } + } + if ($category === null) { + foreach (['completed', 'complete', 'replicated', 'replicate', 'replica', 'success', 'synced'] as $needle) { + if (str_contains($pathText, $needle)) { + $category = 'completed'; + break; + } + } + } + if ($category === null && str_contains($pathText, 'total')) { + $category = 'total'; + } + if ($category === null) { + return; + } + + $isBytes = str_contains($pathText, 'byte') + || str_contains($pathText, 'bytes') + || str_contains($pathText, 'size'); + $suffix = $isBytes ? 'bytes' : 'count'; + $stats[$category . '_' . $suffix] += $value; + } + + private static function collectMinioReplicationProgressString(string $value, array &$stats): void + { + $normalized = self::normalizeMinioProgressKey($value); + if ($normalized === '') { + return; + } + + foreach (['pending', 'queued', 'backlog', 'replicating', 'syncing', 'inprogress', 'failed', 'failure', 'error'] as $needle) { + if (str_contains($normalized, $needle)) { + $stats['incomplete_signals']++; + return; + } + } + + foreach (['completed', 'complete', 'replicated', 'synced', 'success', 'healthy', 'ok'] as $needle) { + if (str_contains($normalized, $needle)) { + $stats['complete_signals']++; + return; + } + } + } + + private static function normalizeMinioProgressKey(string $value): string + { + return strtolower((string)preg_replace('/[^a-zA-Z0-9]+/', '', $value)); + } + + public static function mariadbGtidCoveragePercent(string $sourceSet, string $replicaSet): float + { + $source = self::parseMariaDbGtidSet($sourceSet); + $replica = self::parseMariaDbGtidSet($replicaSet); + $total = array_sum($source); + if ($total <= 0) { + return 100.0; + } + + $covered = 0; + foreach ($source as $domain => $sourceSequence) { + $covered += min($sourceSequence, $replica[$domain] ?? 0); + } + + return round(min(100, max(0, ($covered / $total) * 100)), 2); + } + + private static function parseMariaDbGtidSet(string $gtidSet): array + { + $positions = []; + foreach (explode(',', trim($gtidSet)) as $gtid) { + $gtid = trim($gtid); + if ($gtid === '') { + continue; + } + + $parts = explode('-', $gtid); + if (count($parts) !== 3) { + continue; + } + + [$domain, , $sequence] = array_map('intval', $parts); + if ($sequence <= 0) { + continue; + } + + $positions[$domain] = max($positions[$domain] ?? 0, $sequence); + } + + return $positions; + } + + private static function parseMysqlGtidSet(string $gtidSet): array + { + $parsed = []; + foreach (explode(',', trim($gtidSet)) as $uuidSet) { + $uuidSet = trim($uuidSet); + if ($uuidSet === '') { + continue; + } + + $parts = explode(':', $uuidSet); + if (count($parts) < 2) { + continue; + } + + $uuid = strtolower(array_shift($parts)); + foreach ($parts as $interval) { + if (str_contains($interval, '-')) { + [$start, $end] = array_map('intval', explode('-', $interval, 2)); + } else { + $start = $end = (int)$interval; + } + if ($start <= 0 || $end <= 0) { + continue; + } + if ($end < $start) { + [$start, $end] = [$end, $start]; + } + $parsed[$uuid][] = [$start, $end]; + } + } + + foreach ($parsed as $uuid => $intervals) { + usort($intervals, static fn(array $a, array $b): int => $a[0] <=> $b[0]); + $merged = []; + foreach ($intervals as [$start, $end]) { + $lastIndex = count($merged) - 1; + if ($lastIndex >= 0 && $start <= $merged[$lastIndex][1] + 1) { + $merged[$lastIndex][1] = max($merged[$lastIndex][1], $end); + continue; + } + $merged[] = [$start, $end]; + } + $parsed[$uuid] = $merged; + } + + return $parsed; + } + + private function provisionDatabaseHost(array $host, int $operationId): array + { + $primary = $this->primaryHost(self::KIND_DATABASE); + if ($primary === null) { + throw new RuntimeException('No database primary is registered.'); + } + + $options = $this->decodeOptions($host); + $usePreseededReplica = !empty($options['allow_preseeded_replica']); + $targetStatus = $this->testDatabaseHost(array_merge($host, [ + 'test_connectivity_only' => true, + 'connect_without_database' => $usePreseededReplica, + ])); + $primaryStatus = $this->testDatabaseHost($primary); + $blockers = array_merge($targetStatus['blockers'], $primaryStatus['blockers']); + $targetEngine = self::databaseEngine($targetStatus['raw'] ?? []); + $primaryEngine = self::databaseEngine($primaryStatus['raw'] ?? []); + $targetEngineKnown = self::databaseEngineKnown($targetStatus['raw'] ?? []); + $primaryEngineKnown = self::databaseEngineKnown($primaryStatus['raw'] ?? []); + + if (($targetStatus['raw']['server_id'] ?? null) !== null + && ($primaryStatus['raw']['server_id'] ?? null) !== null + && (int)$targetStatus['raw']['server_id'] === (int)$primaryStatus['raw']['server_id']) { + $blockers[] = 'Database replica must have a unique server_id.'; + } + if ($targetEngineKnown && $primaryEngineKnown && $targetEngine !== $primaryEngine) { + $blockers[] = 'Database primary and replica must use the same engine family.'; + } + + $cloneReady = (bool)($targetStatus['raw']['clone_plugin_active'] ?? false); + $primaryCloneReady = (bool)($primaryStatus['raw']['clone_plugin_active'] ?? false); + if ($targetEngineKnown && $targetEngine === 'mariadb' && !$usePreseededReplica) { + $blockers[] = 'MariaDB replicas must be safely seeded before managed replication can be configured.'; + } + if ($targetEngineKnown && $targetEngine === 'mysql' && !$usePreseededReplica) { + if (!$cloneReady) { + $blockers[] = 'MySQL Clone plugin is not active on the target. Set allow_preseeded_replica only after the target has been safely seeded.'; + } + if (!$primaryCloneReady) { + $blockers[] = 'MySQL Clone plugin is not active on the primary donor.'; + } + } + + if ($blockers !== []) { + $this->storeStatus($host, array_replace($targetStatus, ['blockers' => array_values(array_unique($blockers))])); + return [ + 'ok' => false, + 'message' => 'Database replica provisioning is blocked.', + 'blockers' => array_values(array_unique($blockers)), + 'replication_percent' => $targetStatus['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + $target = $this->databaseConnection($host, true, $usePreseededReplica); + try { + $primaryCredentials = $this->credentials($primary); + $hostCredentials = $this->credentials($host); + $replicationUser = $hostCredentials['replication_username'] + ?: ($primaryCredentials['replication_username'] ?: $primaryCredentials['username']); + $replicationPassword = $hostCredentials['replication_password'] + ?: ($primaryCredentials['replication_password'] ?: $primaryCredentials['password']); + $shouldManageReplicationUser = ($hostCredentials['replication_username'] ?? '') !== '' + && ($hostCredentials['replication_password'] ?? '') !== ''; + + if ($usePreseededReplica && $targetEngine === 'mariadb') { + $seedContext = $this->operationContext($operationId); + $seedInProgress = ($seedContext['phase'] ?? '') !== '' && ($seedContext['phase'] ?? '') !== 'complete'; + $seedBlockers = $this->databaseReplicaSeedBlockers($primary, $host, $target); + if ($seedInProgress || $seedBlockers !== []) { + $seedResult = $this->advanceMariaDbReplicaSeed($operationId, $primary, $host, $target); + $targetStatus = $seedResult['status']; + + if (($seedResult['running'] ?? false) === true) { + $this->storeStatus($host, $targetStatus); + return [ + 'ok' => true, + 'message' => $seedResult['message'], + 'blockers' => [], + 'replication_percent' => $targetStatus['replication_percent'], + 'operation' => [ + 'id' => $operationId, + 'status' => 'running', + 'progress_percent' => $targetStatus['replication_percent'], + 'message' => $seedResult['message'], + ], + 'host' => $this->publicHost($host), + ]; + } + } + } elseif ($usePreseededReplica) { + $seedBlockers = $this->databaseReplicaSeedBlockers($primary, $host, $target); + if ($seedBlockers !== []) { + $combinedBlockers = array_values(array_unique(array_merge($targetStatus['blockers'], $seedBlockers))); + $this->storeStatus($host, array_replace($targetStatus, [ + 'status' => 'degraded', + 'replication_percent' => min(99.99, (float)$targetStatus['replication_percent']), + 'blockers' => $combinedBlockers, + ])); + return [ + 'ok' => false, + 'message' => 'Database replica provisioning is blocked.', + 'blockers' => $combinedBlockers, + 'replication_percent' => min(99.99, (float)$targetStatus['replication_percent']), + 'host' => $this->publicHost($host), + ]; + } + } + + if ($shouldManageReplicationUser) { + $grantHosts = $this->databaseReplicationGrantHosts($host, $targetStatus); + $this->ensureDatabaseReplicationUser($primary, $replicationUser, $replicationPassword, $grantHosts); + } + + if ($targetEngine === 'mysql' && !$usePreseededReplica) { + $this->runMysqlClone($target, $primary, $replicationUser, $replicationPassword); + $target->close(); + $target = $this->waitForDatabaseConnection($host, true, 120); + } + + if ($targetEngine === 'mariadb') { + $this->configureMariaDbReplication($target, $primary, $host, $replicationUser, $replicationPassword); + } else { + $this->configureMySqlReplication($target, $primary, $replicationUser, $replicationPassword); + } + } finally { + $target->close(); + } + + $this->execute( + "UPDATE replication_hosts SET replication_source_id = ?, status = 'provisioning' WHERE id = ?", + 'ii', + [(int)$primary['id'], (int)$host['id']] + ); + + $status = $this->testDatabaseHost($this->getHost(self::KIND_DATABASE, (int)$host['id'])); + $this->storeStatus($host, $status); + + return [ + 'ok' => true, + 'healthy' => $status['blockers'] === [], + 'message' => $targetEngine === 'mariadb' + ? 'MariaDB replication was configured with GTID slave_pos.' + : 'Database replication was configured with GTID auto-positioning.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$host['id'])), + ]; + } + + private function databaseReplicationGrantHosts(array $host, array $targetStatus = []): array + { + $grantHosts = ['%']; + if (isset($host['host'])) { + $grantHosts[] = (string)$host['host']; + } + + $lastStatus = self::jsonDecode($host['last_status_json'] ?? null); + foreach ([$targetStatus, $lastStatus] as $status) { + if (!is_array($status)) { + continue; + } + + foreach ($this->databaseDeniedAccountHostsFromStatus($status) as $deniedHost) { + $grantHosts[] = $deniedHost; + } + } + + $normalized = []; + foreach ($grantHosts as $grantHost) { + foreach (self::databaseAccountHostGrantCandidates((string)$grantHost) as $candidate) { + if (!in_array($candidate, $normalized, true)) { + $normalized[] = $candidate; + } + } + } + + return $normalized === [] ? ['%'] : $normalized; + } + + private function databaseDeniedAccountHostsFromStatus(array $status): array + { + $hosts = []; + + foreach ($status['blockers'] ?? [] as $blocker) { + if (is_scalar($blocker)) { + $hosts = array_merge($hosts, self::databaseDeniedAccountHostsFromText((string)$blocker)); + } + } + + $replicaStatus = $status['raw']['replica_status'] ?? []; + if (is_array($replicaStatus)) { + foreach (['Last_IO_Error', 'Last_SQL_Error', 'Last_Error'] as $errorKey) { + $error = trim((string)($replicaStatus[$errorKey] ?? '')); + if ($error !== '') { + $hosts = array_merge($hosts, self::databaseDeniedAccountHostsFromText($error)); + } + } + } + + return array_values(array_unique($hosts)); + } + + private static function databaseDeniedAccountHostsFromText(string $text): array + { + preg_match_all('/Access denied for user\s+[\'"][^\'"]+[\'"]@[\'"]([^\'"]+)[\'"]/i', $text, $matches); + return array_values(array_unique(array_filter($matches[1] ?? []))); + } + + private static function normalizeDatabaseAccountHost(string $host): ?string + { + $host = trim($host); + if ($host === '') { + return null; + } + + if ($host !== '%') { + $host = trim($host, '[]'); + } + + if ($host === '' || strlen($host) > 255) { + return null; + } + + if (preg_match('/[\s\'"`;\\\\]/', $host)) { + return null; + } + + return preg_match('/^[A-Za-z0-9_.:%-]+$/', $host) === 1 ? $host : null; + } + + private static function databaseAccountHostGrantCandidates(string $host): array + { + $host = self::normalizeDatabaseAccountHost($host); + if ($host === null) { + return []; + } + + $candidates = [$host]; + if ($host !== '%' && !str_contains($host, '%')) { + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + $lastDot = strrpos($host, '.'); + if ($lastDot !== false) { + $candidates[] = substr($host, 0, $lastDot + 1) . '%'; + } + } elseif (str_contains($host, ':')) { + $lastColon = strrpos($host, ':'); + if ($lastColon !== false) { + $candidates[] = substr($host, 0, $lastColon + 1) . '%'; + } + } + } + + return array_values(array_unique(array_filter(array_map( + static fn(string $candidate): ?string => self::normalizeDatabaseAccountHost($candidate), + $candidates + )))); + } + + private function ensureDatabaseReplicationUser(array $primary, string $replicationUser, string $replicationPassword, array $grantHosts = []): void + { + if (trim($replicationUser) === '' || trim($replicationPassword) === '') { + throw new RuntimeException('Replication username and password are required.'); + } + + $connection = $this->databaseConnection($primary, true); + try { + $grantHosts = $grantHosts === [] ? ['%'] : $grantHosts; + $user = $connection->real_escape_string($replicationUser); + $password = $connection->real_escape_string($replicationPassword); + + foreach ($grantHosts as $grantHost) { + $grantHost = self::normalizeDatabaseAccountHost((string)$grantHost); + if ($grantHost === null) { + continue; + } + + $account = sprintf( + "'%s'@'%s'", + $user, + $connection->real_escape_string($grantHost) + ); + + $this->mysqliExec($connection, "CREATE USER IF NOT EXISTS " . $account . " IDENTIFIED BY '" . $password . "'"); + $this->mysqliExec($connection, "ALTER USER " . $account . " IDENTIFIED BY '" . $password . "'"); + $this->mysqliExec($connection, "GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO " . $account); + } + $this->mysqliExec($connection, 'FLUSH PRIVILEGES'); + } catch (Throwable $throwable) { + throw new RuntimeException( + 'Could not create or update the replication user on the primary database. Add primary admin credentials or create the replication user manually: ' . $throwable->getMessage(), + 0, + $throwable + ); + } finally { + $connection->close(); + } + } + + private function configureMySqlReplication(mysqli $target, array $primary, string $replicationUser, string $replicationPassword): void + { + try { + $this->mysqliExec($target, 'STOP REPLICA'); + } catch (Throwable) { + } + $sql = sprintf( + "CHANGE REPLICATION SOURCE TO SOURCE_HOST = '%s', SOURCE_PORT = %d, SOURCE_USER = '%s', SOURCE_PASSWORD = '%s', SOURCE_AUTO_POSITION = 1", + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($replicationUser), + $target->real_escape_string($replicationPassword) + ); + $this->mysqliExec($target, $sql); + $this->mysqliExec($target, 'START REPLICA'); + } + + private function configureMariaDbReplication(mysqli $target, array $primary, array $host, string $replicationUser, string $replicationPassword): void + { + foreach (['STOP SLAVE', 'RESET SLAVE ALL'] as $statement) { + try { + $this->mysqliExec($target, $statement); + } catch (Throwable) { + } + } + $this->configureMariaDbReplicationFilters($target, $primary, $host); + $sql = sprintf( + "CHANGE MASTER TO MASTER_HOST = '%s', MASTER_PORT = %d, MASTER_USER = '%s', MASTER_PASSWORD = '%s', MASTER_USE_GTID = slave_pos", + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($replicationUser), + $target->real_escape_string($replicationPassword) + ); + $this->mysqliExec($target, $sql); + $this->mysqliExec($target, 'START SLAVE'); + } + + private function configureMariaDbReplicationFilters(mysqli $target, array $primary, array $host): void + { + $existing = $this->mysqliSelectOne($target, "SHOW GLOBAL VARIABLES LIKE 'replicate_ignore_table'"); + $filters = array_values(array_filter(array_map( + static fn(string $filter): string => trim($filter), + explode(',', (string)($existing['Value'] ?? '')) + ))); + + foreach ([(string)($primary['database_name'] ?? ''), (string)($host['database_name'] ?? '')] as $database) { + $database = trim($database); + if ($database !== '') { + foreach (self::MARIADB_SCHEMA_ONLY_TABLES as $tableName) { + $filters[] = $database . '.' . $tableName; + } + } + } + + $filters = array_values(array_unique($filters)); + if ($filters === []) { + return; + } + + try { + $this->mysqliExec($target, 'SET GLOBAL replicate_ignore_table = ' . self::sqlString($target, implode(',', $filters))); + } catch (Throwable $throwable) { + throw new RuntimeException( + 'Could not configure MariaDB replica schema-only table filters: ' . $throwable->getMessage(), + 0, + $throwable + ); + } + } + + private function runMysqlClone(mysqli $target, array $primary, string $cloneUser, string $clonePassword): void + { + $donor = $target->real_escape_string((string)$primary['host'] . ':' . (int)$primary['port']); + $this->mysqliExec($target, "SET GLOBAL clone_valid_donor_list = '" . $donor . "'"); + + $sql = sprintf( + "CLONE INSTANCE FROM '%s'@'%s':%d IDENTIFIED BY '%s'", + $target->real_escape_string($cloneUser), + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($clonePassword) + ); + + try { + $this->mysqliExec($target, $sql); + } catch (Throwable $throwable) { + $message = strtolower($throwable->getMessage()); + if (!str_contains($message, 'lost connection') && !str_contains($message, 'server has gone away')) { + throw $throwable; + } + } + } + + private function waitForDatabaseConnection(array $host, bool $admin, int $timeoutSeconds): mysqli + { + $deadline = time() + max(1, $timeoutSeconds); + $lastError = null; + + do { + try { + return $this->databaseConnection($host, $admin); + } catch (Throwable $throwable) { + $lastError = $throwable; + sleep(2); + } + } while (time() < $deadline); + + throw new RuntimeException('Database target did not reconnect after MySQL Clone: ' . ($lastError?->getMessage() ?? 'timeout')); + } + + private function provisionRedisHost(array $host, int $operationId): array + { + $primary = $this->primaryHost(self::KIND_REDIS); + if ($primary === null) { + throw new RuntimeException('No Redis primary is registered.'); + } + + $targetStatus = $this->testRedisHost(array_merge($host, ['test_connectivity_only' => true])); + $primaryStatus = $this->testRedisHost($primary); + $blockers = array_merge($targetStatus['blockers'], $primaryStatus['blockers']); + if ($blockers !== []) { + $this->storeStatus($host, array_replace($targetStatus, ['blockers' => array_values(array_unique($blockers))])); + return [ + 'ok' => false, + 'message' => 'Redis replica provisioning is blocked.', + 'blockers' => array_values(array_unique($blockers)), + 'replication_percent' => $targetStatus['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + $context = $this->operationContext($operationId); + if (($context['phase'] ?? '') !== 'configured') { + try { + $client = $this->redisClient($host); + $primaryCredentials = $this->credentials($primary); + if (($primaryCredentials['username'] ?? '') !== '' && ($primaryCredentials['username'] ?? '') !== 'default') { + $client->executeRaw(['CONFIG', 'SET', 'masteruser', (string)$primaryCredentials['username']]); + } + $client->executeRaw(['CONFIG', 'SET', 'masterauth', (string)($primaryCredentials['password'] ?? '')]); + $client->executeRaw(['REPLICAOF', (string)$primary['host'], (string)$primary['port']]); + $client->executeRaw(['CONFIG', 'REWRITE']); + } catch (Throwable $throwable) { + $blockers = [$throwable->getMessage()]; + $this->storeStatus($host, array_replace($targetStatus, [ + 'status' => 'degraded', + 'replication_percent' => 0, + 'blockers' => $blockers, + ])); + return [ + 'ok' => false, + 'message' => 'Redis replica provisioning is blocked.', + 'blockers' => $blockers, + 'replication_percent' => 0, + 'host' => $this->publicHost($host), + ]; + } + + $context = [ + 'phase' => 'configured', + 'configured_at' => date('c'), + 'primary_host' => (string)$primary['host'], + 'primary_port' => (int)$primary['port'], + ]; + $this->updateOperationProgress( + $operationId, + 5.0, + 'Redis replication was configured; waiting for the replica to catch up.', + $context + ); + } + + $this->execute( + "UPDATE replication_hosts SET replication_source_id = ?, status = 'provisioning' WHERE id = ?", + 'ii', + [(int)$primary['id'], (int)$host['id']] + ); + + $host = $this->getHost(self::KIND_REDIS, (int)$host['id']); + $status = $this->testRedisHost($host); + $syncBlockers = array_values(array_intersect($status['blockers'], [ + 'Redis host is not currently a replica.', + 'Redis replica link to primary is not up.', + ])); + $onlySyncBlockers = $status['blockers'] === [] + || ($syncBlockers !== [] && count($syncBlockers) === count($status['blockers'])); + $progress = self::redisProvisionProgress((float)$status['replication_percent'], $syncBlockers); + + if ($onlySyncBlockers && ((float)$status['replication_percent'] < 100.0 || $syncBlockers !== [])) { + $message = $syncBlockers !== [] + ? 'Redis replication is configured, but the replica is waiting for the primary link.' + : 'Redis replication is configured and syncing in the background.'; + $this->storeStatus($host, array_replace($status, ['replication_percent' => $progress])); + $this->updateOperationProgress($operationId, $progress, $message, $context); + return [ + 'ok' => true, + 'healthy' => false, + 'message' => $message, + 'blockers' => $status['blockers'], + 'replication_percent' => $progress, + 'host' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$host['id'])), + ]; + } + + $this->storeStatus($host, $status); + if ($status['blockers'] !== []) { + return [ + 'ok' => false, + 'message' => 'Redis replica provisioning is blocked.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + return [ + 'ok' => true, + 'healthy' => $status['blockers'] === [], + 'message' => 'Redis replication was configured.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$host['id'])), + ]; + } + + private function provisionMinioHost(array $host, int $operationId): array + { + $primary = $this->primaryHost(self::KIND_MINIO); + if ($primary === null) { + throw new RuntimeException('No MinIO primary is registered.'); + } + + $targetStatus = $this->testMinioHost(array_merge($host, [ + 'test_connectivity_only' => true, + 'skip_storage_scan' => true, + ])); + $primaryStatus = $this->testMinioHost(array_merge($primary, [ + 'test_connectivity_only' => true, + 'skip_storage_scan' => true, + ])); + $blockers = array_values(array_unique(array_merge($targetStatus['blockers'], $primaryStatus['blockers']))); + if ($blockers !== []) { + $this->storeStatus($host, array_replace($targetStatus, ['blockers' => $blockers])); + return [ + 'ok' => false, + 'message' => 'MinIO replica provisioning is blocked.', + 'blockers' => $blockers, + 'replication_percent' => $targetStatus['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + $context = $this->operationContext($operationId); + $replicationConfigured = $this->minioReplicationConfiguredForHosts($primary, $host); + if (!$replicationConfigured) { + try { + $this->configureMinioReplication($primary, $host); + } catch (Throwable $throwable) { + $blockers = [$throwable->getMessage()]; + $this->storeStatus($host, array_replace($targetStatus, [ + 'status' => 'degraded', + 'replication_percent' => 0, + 'blockers' => $blockers, + ])); + return [ + 'ok' => false, + 'message' => 'MinIO replica provisioning is blocked.', + 'blockers' => $blockers, + 'replication_percent' => 0, + 'host' => $this->publicHost($host), + ]; + } + + $context = [ + 'phase' => 'configured', + 'configured_at' => date('c'), + 'primary_endpoint' => self::minioEndpoint($primary), + ]; + $this->updateOperationProgress( + $operationId, + 5.0, + 'MinIO bucket replication was configured; waiting for buckets to catch up.', + $context + ); + } + + $this->execute( + "UPDATE replication_hosts SET replication_source_id = ?, status = 'provisioning' WHERE id = ?", + 'ii', + [(int)$primary['id'], (int)$host['id']] + ); + + $host = $this->getHost(self::KIND_MINIO, (int)$host['id']); + $status = $this->minioProvisionStatus($primary, $host); + $this->storeStatus($host, $status); + + $progress = self::minioProvisionProgress($status); + $syncInProgress = ((float)$status['replication_percent'] < 100.0 || $status['blockers'] !== []) + && self::minioOnlyProgressBlockers($status['blockers']); + if ($syncInProgress) { + $message = self::minioProvisionProgressMessage($status); + $this->updateOperationProgress($operationId, $progress, $message, $context); + return [ + 'ok' => true, + 'message' => $message, + 'blockers' => $status['blockers'], + 'replication_percent' => $progress, + 'operation' => [ + 'id' => $operationId, + 'status' => 'running', + 'progress_percent' => $progress, + 'message' => $message, + ], + 'host' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + ]; + } + + if ($status['blockers'] !== []) { + return [ + 'ok' => false, + 'message' => 'MinIO replica provisioning is blocked.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + return [ + 'ok' => true, + 'healthy' => $status['blockers'] === [], + 'message' => 'MinIO bucket replication was configured.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + ]; + } + + private function promoteDatabaseHost(array $host): array + { + $status = $this->testDatabaseHost($host); + if ($status['blockers'] !== [] || (float)$status['replication_percent'] < 100.0) { + throw new RuntimeException('Database promotion blocked: ' . implode(' ', $status['blockers'] ?: ['Replica is not caught up.'])); + } + + $oldPrimary = $this->primaryHost(self::KIND_DATABASE); + if ($oldPrimary === null) { + throw new RuntimeException('No current database primary is registered.'); + } + + $oldPrimaryConn = null; + $targetConn = null; + $metadataSwitched = false; + + try { + $oldPrimaryConn = $this->databaseConnection($oldPrimary, true); + $oldPrimaryStatus = $this->databaseServerStatus($oldPrimaryConn); + $this->setDatabaseReadOnly($oldPrimaryConn, $oldPrimaryStatus, true); + + $targetConn = $this->databaseConnection($host, true); + $targetStatus = $this->databaseServerStatus($targetConn); + $this->stopDatabaseReplication($targetConn, $targetStatus); + $this->setDatabaseReadOnly($targetConn, $targetStatus, false); + + $this->switchPrimary(self::KIND_DATABASE, (int)$host['id'], (int)$oldPrimary['id']); + $metadataSwitched = true; + $this->writeBootstrapSnapshot(); + } catch (Throwable $throwable) { + if (!$metadataSwitched && $oldPrimaryConn instanceof mysqli) { + try { + $this->setDatabaseReadOnly($oldPrimaryConn, $oldPrimaryStatus ?? [], false); + } catch (Throwable) { + } + } + throw $throwable; + } finally { + if ($targetConn instanceof mysqli) { + $targetConn->close(); + } + if ($oldPrimaryConn instanceof mysqli) { + $oldPrimaryConn->close(); + } + } + + return [ + 'ok' => true, + 'message' => 'Database replica promoted to primary.', + 'primary' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function setDatabaseReadOnly(mysqli $connection, array $status, bool $readOnly): void + { + $value = $readOnly ? 'ON' : 'OFF'; + if (array_key_exists('super_read_only', $status)) { + try { + $this->mysqliExec($connection, 'SET GLOBAL super_read_only = ' . $value); + } catch (Throwable) { + } + } + $this->mysqliExec($connection, 'SET GLOBAL read_only = ' . $value); + } + + private function stopDatabaseReplication(mysqli $connection, array $status): void + { + if (self::databaseEngine($status) === 'mariadb') { + $this->mysqliExec($connection, 'STOP SLAVE'); + return; + } + + $this->mysqliExec($connection, 'STOP REPLICA'); + } + + private function promoteRedisHost(array $host): array + { + $status = $this->testRedisHost($host); + if ($status['blockers'] !== [] || (float)$status['replication_percent'] < 100.0) { + throw new RuntimeException('Redis promotion blocked: ' . implode(' ', $status['blockers'] ?: ['Replica is not caught up.'])); + } + + $oldPrimary = $this->primaryHost(self::KIND_REDIS); + if ($oldPrimary === null) { + throw new RuntimeException('No current Redis primary is registered.'); + } + + $client = $this->redisClient($host); + $client->executeRaw(['REPLICAOF', 'NO', 'ONE']); + try { + $client->executeRaw(['CONFIG', 'REWRITE']); + } catch (Throwable) { + } + + $this->switchPrimary(self::KIND_REDIS, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + + return [ + 'ok' => true, + 'message' => 'Redis replica promoted to primary.', + 'primary' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function promoteMinioHost(array $host): array + { + $status = $this->testMinioHost($host); + if ($status['blockers'] !== [] || (float)$status['replication_percent'] < 100.0) { + throw new RuntimeException('MinIO promotion blocked: ' . implode(' ', $status['blockers'] ?: ['Replica is not caught up.'])); + } + + $oldPrimary = $this->primaryHost(self::KIND_MINIO); + if ($oldPrimary === null) { + throw new RuntimeException('No current MinIO primary is registered.'); + } + + $this->switchPrimary(self::KIND_MINIO, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + + return [ + 'ok' => true, + 'message' => 'MinIO replica promoted to primary.', + 'primary' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function runAutomaticFailoverForKind(string $kind, array $config, ?int $actorUserId): array + { + if (!replica_failover_manager::kindEnabled($config, $kind)) { + return [ + 'ok' => true, + 'status' => 'skipped', + 'reason' => 'disabled', + ]; + } + + $primary = $this->primaryHost($kind); + if ($primary === null) { + return [ + 'ok' => false, + 'status' => 'skipped', + 'reason' => 'missing_primary', + ]; + } + + if (!$this->primaryHostDown($kind, $primary)) { + return [ + 'ok' => true, + 'status' => 'skipped', + 'reason' => 'primary_healthy', + 'primary' => $this->publicHost($primary), + ]; + } + + $maxAgeSeconds = (int)$config['max_status_age_seconds']; + $candidate = replica_failover_manager::snapshotFailoverCandidate($this->listHosts($kind), $kind, $maxAgeSeconds); + if ($candidate === null) { + $result = [ + 'ok' => false, + 'status' => 'blocked', + 'reason' => 'no_fresh_caught_up_replica', + 'primary' => $this->publicHost($primary), + ]; + $this->audit($kind, (int)$primary['id'], 'automatic_failover_blocked', $actorUserId, 'warning', $result); + return $result; + } + + if (!replica_failover_manager::candidateReachable($kind, $candidate)) { + $result = [ + 'ok' => false, + 'status' => 'blocked', + 'reason' => 'candidate_unreachable', + 'primary' => $this->publicHost($primary), + 'candidate' => $this->publicHost($candidate), + ]; + $this->audit($kind, (int)$candidate['id'], 'automatic_failover_blocked', $actorUserId, 'warning', $result); + return $result; + } + + $operationId = $this->startOperation($kind, (int)$candidate['id'], 'automatic_failover', $actorUserId); + $owner = 'replication-auto-failover-' . $kind . '-' . (int)$candidate['id'] . '-' . bin2hex(random_bytes(4)); + $lockHandle = $this->acquirePromotionLock(); + + try { + application_write_freeze::freeze('Automatic replica failover in progress.', $owner, 600); + $result = match ($kind) { + self::KIND_DATABASE => $this->promoteDatabaseHostForFailover($candidate, $primary, $maxAgeSeconds), + self::KIND_REDIS => $this->promoteRedisHostForFailover($candidate, $primary, $maxAgeSeconds), + self::KIND_MINIO => $this->promoteMinioHostForFailover($candidate, $primary, $maxAgeSeconds), + }; + + $this->finishOperation($operationId, 'completed', 100, $result['message'] ?? null, []); + $this->audit($kind, (int)$candidate['id'], 'automatic_failover_promoted', $actorUserId, 'critical', $result); + return array_merge($result, [ + 'status' => 'promoted', + 'candidate' => $this->publicHost($this->getHost($kind, (int)$candidate['id'])), + ]); + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', 0, null, [$throwable->getMessage()]); + $this->audit($kind, (int)$candidate['id'], 'automatic_failover_failed', $actorUserId, 'error', [ + 'error' => $throwable->getMessage(), + ]); + return [ + 'ok' => false, + 'status' => 'failed', + 'reason' => $throwable->getMessage(), + 'candidate' => $this->publicHost($candidate), + ]; + } finally { + application_write_freeze::unfreeze($owner); + $this->releasePromotionLock($lockHandle); + } + } + + private function primaryHostDown(string $kind, array $primary): bool + { + $activeConfig = replica_failover_manager::activeConfigFromHost($kind, $primary); + if ($activeConfig === null) { + return false; + } + + return replica_failover_manager::activePrimaryIsDown($kind, $activeConfig); + } + + private function promoteDatabaseHostForFailover(array $host, array $oldPrimary, int $maxAgeSeconds): array + { + if (!replica_failover_manager::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds)) { + throw new RuntimeException('Database failover blocked: replica status is not fresh and caught up.'); + } + + $targetConn = $this->databaseConnection($host, true); + try { + $targetStatus = $this->databaseServerStatus($targetConn); + $this->stopDatabaseReplication($targetConn, $targetStatus); + $this->setDatabaseReadOnly($targetConn, $targetStatus, false); + $this->switchPrimary(self::KIND_DATABASE, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + replication_bootstrap_config::applyToGlobals(replication_bootstrap_config::loadSnapshot()); + } finally { + $targetConn->close(); + } + + return [ + 'ok' => true, + 'message' => 'Database replica promoted to primary after primary health check failed.', + 'primary' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function promoteRedisHostForFailover(array $host, array $oldPrimary, int $maxAgeSeconds): array + { + if (!replica_failover_manager::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds)) { + throw new RuntimeException('Redis failover blocked: replica status is not fresh and caught up.'); + } + + $client = $this->redisClient($host); + $client->ping(); + $client->executeRaw(['REPLICAOF', 'NO', 'ONE']); + try { + $client->executeRaw(['CONFIG', 'REWRITE']); + } catch (Throwable) { + } + + $this->switchPrimary(self::KIND_REDIS, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + replication_bootstrap_config::applyToGlobals(replication_bootstrap_config::loadSnapshot()); + + return [ + 'ok' => true, + 'message' => 'Redis replica promoted to primary after primary health check failed.', + 'primary' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function promoteMinioHostForFailover(array $host, array $oldPrimary, int $maxAgeSeconds): array + { + if (!replica_failover_manager::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds)) { + throw new RuntimeException('MinIO failover blocked: replica status is not fresh and caught up.'); + } + + $this->minioS3Client($host)->listBuckets(); + $this->switchPrimary(self::KIND_MINIO, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + replication_bootstrap_config::applyToGlobals(replication_bootstrap_config::loadSnapshot()); + + return [ + 'ok' => true, + 'message' => 'MinIO replica endpoint selected after primary health check failed.', + 'primary' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function testDatabaseHost(array $host): array + { + $blockers = []; + $raw = []; + $percent = (float)(($host['role'] ?? '') === 'primary' ? 100 : 0); + $lagSeconds = null; + $reachable = true; + + try { + $connection = $this->databaseConnection($host, true, !empty($host['connect_without_database'])); + try { + $raw = $this->databaseServerStatus($connection); + $blockers = array_merge($blockers, self::databasePrerequisiteBlockers($raw)); + + if (($host['role'] ?? '') !== 'primary' && empty($host['test_connectivity_only'])) { + $primary = $this->primaryHost(self::KIND_DATABASE); + if ($primary === null) { + $blockers[] = 'No database primary is registered.'; + } else { + $sourceConnection = $this->databaseConnection($primary, true); + try { + $source = $this->databaseServerStatus($sourceConnection); + $replica = $this->showReplicaStatus($connection); + $sourceEngine = self::databaseEngine($source); + $replicaEngine = self::databaseEngine($raw); + $raw['source_gtid_executed'] = self::databaseGtidPosition($source); + $raw['replica_status'] = $replica; + + if ($sourceEngine !== $replicaEngine) { + $blockers[] = 'Database primary and replica must use the same engine family.'; + } + $percent = $sourceEngine === 'mariadb' + ? self::mariadbGtidCoveragePercent( + self::databaseGtidPosition($source), + (string)($replica['Gtid_IO_Pos'] ?? $raw['gtid_slave_pos'] ?? $raw['gtid_current_pos'] ?? '') + ) + : self::mysqlGtidCoveragePercent( + (string)($source['gtid_executed'] ?? ''), + (string)($replica['Executed_Gtid_Set'] ?? $raw['gtid_executed'] ?? '') + ); + $lagSeconds = isset($replica['Seconds_Behind_Source']) + ? (int)$replica['Seconds_Behind_Source'] + : (isset($replica['Seconds_Behind_Master']) ? (int)$replica['Seconds_Behind_Master'] : null); + + $ioRunning = false; + $sqlRunning = false; + if ($replica === []) { + $blockers[] = 'Database replica status is not configured.'; + } else { + $ioRunning = strtoupper((string)($replica['Replica_IO_Running'] ?? $replica['Slave_IO_Running'] ?? '')) === 'YES'; + $sqlRunning = strtoupper((string)($replica['Replica_SQL_Running'] ?? $replica['Slave_SQL_Running'] ?? '')) === 'YES'; + if (!$ioRunning || !$sqlRunning) { + $blockers[] = 'Database replication IO and SQL threads must both be running.'; + } + if (!$ioRunning) { + $blockers[] = 'Database replication IO thread is not running.'; + } + if (!$sqlRunning) { + $blockers[] = 'Database replication SQL thread is not running.'; + } + if ($sourceEngine === 'mariadb' && isset($replica['Using_Gtid']) && strtoupper((string)$replica['Using_Gtid']) === 'NO') { + $blockers[] = 'MariaDB replication must use GTID mode.'; + } + foreach (['Last_IO_Error', 'Last_SQL_Error', 'Last_Error'] as $errorKey) { + $error = trim((string)($replica[$errorKey] ?? '')); + if ($error !== '') { + $blockers[] = $error; + } + } + } + if (($blockers !== [] || !$ioRunning || !$sqlRunning) && $percent >= 100.0) { + $percent = 99.99; + } + } finally { + $sourceConnection->close(); + } + } + } + } finally { + $connection->close(); + } + } catch (Throwable $throwable) { + $reachable = false; + $blockers[] = $throwable->getMessage(); + } + + $blockers = array_values(array_unique(array_filter($blockers))); + $status = [ + 'status' => self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$percent, + $blockers, + empty($host['test_connectivity_only']) + ), + 'replication_percent' => round($percent, 2), + 'lag_seconds' => $lagSeconds, + 'blockers' => $blockers, + 'raw' => $raw, + 'checked_at' => date('c'), + ]; + + if ($this->shouldRepairDatabaseReplicationAccess($host, $status)) { + $repair = $this->repairDatabaseReplicationAccess($host, $status); + if (($repair['ok'] ?? false) === true) { + $retested = $this->testDatabaseHost(array_merge($host, [ + 'skip_replication_access_repair' => true, + 'skip_replication_thread_repair' => true, + ])); + $retested['raw']['replication_access_repair'] = $repair; + return $retested; + } + + if (!empty($repair['message'])) { + $status['blockers'][] = 'Database replication access repair failed: ' . $repair['message']; + $status['blockers'] = array_values(array_unique(array_filter($status['blockers']))); + $status['status'] = self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$status['replication_percent'], + $status['blockers'] + ); + } + $status['raw']['replication_access_repair'] = $repair; + } + + if ($this->shouldRepairDatabaseReplicationThreads($host, $status)) { + $repair = $this->repairDatabaseReplicationThreads($host); + if (($repair['ok'] ?? false) === true) { + $retested = $this->testDatabaseHost(array_merge($host, [ + 'skip_replication_access_repair' => true, + 'skip_replication_thread_repair' => true, + ])); + $retested['raw']['replication_thread_repair'] = $repair; + return $retested; + } + + if (!empty($repair['message'])) { + $status['blockers'][] = 'Database replication thread restart failed: ' . $repair['message']; + $status['blockers'] = array_values(array_unique(array_filter($status['blockers']))); + $status['status'] = self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$status['replication_percent'], + $status['blockers'] + ); + } + $status['raw']['replication_thread_repair'] = $repair; + } + + return $status; + } + + private function shouldRepairDatabaseReplicationAccess(array $host, array $status): bool + { + if (!empty($host['skip_replication_access_repair']) + || !empty($host['test_connectivity_only']) + || (string)($host['role'] ?? '') === 'primary') { + return false; + } + + if ($this->databaseDeniedAccountHostsFromStatus($status) === []) { + return false; + } + + $hostCredentials = $this->credentials($host); + $primary = $this->primaryHost(self::KIND_DATABASE); + $primaryCredentials = $primary !== null ? $this->credentials($primary) : []; + + return (($hostCredentials['replication_username'] ?? '') !== '' && ($hostCredentials['replication_password'] ?? '') !== '') + || (($primaryCredentials['replication_username'] ?? '') !== '' && ($primaryCredentials['replication_password'] ?? '') !== ''); + } + + private function repairDatabaseReplicationAccess(array $host, array $status): array + { + $deniedHosts = $this->databaseDeniedAccountHostsFromStatus($status); + if ($deniedHosts === []) { + return [ + 'ok' => false, + 'skipped' => true, + 'message' => 'No denied replication account host was detected.', + ]; + } + + $primary = $this->primaryHost(self::KIND_DATABASE); + if ($primary === null) { + return [ + 'ok' => false, + 'message' => 'No database primary is registered.', + ]; + } + + $primaryCredentials = $this->credentials($primary); + $hostCredentials = $this->credentials($host); + $replicationUser = $hostCredentials['replication_username'] + ?: ($primaryCredentials['replication_username'] ?? ''); + $replicationPassword = $hostCredentials['replication_password'] + ?: ($primaryCredentials['replication_password'] ?? ''); + + if ($replicationUser === '' || $replicationPassword === '') { + return [ + 'ok' => false, + 'skipped' => true, + 'denied_hosts' => $deniedHosts, + 'message' => 'Replication credentials are not available for automatic grant repair.', + ]; + } + + try { + $grantHosts = $this->databaseReplicationGrantHosts($host, $status); + $this->ensureDatabaseReplicationUser($primary, $replicationUser, $replicationPassword, $grantHosts); + + $target = $this->databaseConnection($host, true); + try { + $this->refreshDatabaseReplicationConnection( + $target, + $this->databaseServerStatus($target), + $primary, + $replicationUser, + $replicationPassword + ); + } finally { + $target->close(); + } + + return [ + 'ok' => true, + 'denied_hosts' => $deniedHosts, + 'grant_hosts' => $grantHosts, + ]; + } catch (Throwable $throwable) { + return [ + 'ok' => false, + 'denied_hosts' => $deniedHosts, + 'message' => $throwable->getMessage(), + ]; + } + } + + private function shouldRepairDatabaseReplicationThreads(array $host, array $status): bool + { + if (!empty($host['skip_replication_thread_repair']) + || !empty($host['test_connectivity_only']) + || (string)($host['role'] ?? '') === 'primary') { + return false; + } + + $replicaStatus = $status['raw']['replica_status'] ?? null; + return is_array($replicaStatus) + && $replicaStatus !== [] + && self::databaseOnlyReplicationThreadBlockers($status['blockers'] ?? []); + } + + private static function databaseOnlyReplicationThreadBlockers(array $blockers): bool + { + $blockers = array_values(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + $blockers + ))); + if ($blockers === []) { + return false; + } + + $allowed = [ + 'Database replication IO and SQL threads must both be running.', + 'Database replication IO thread is not running.', + 'Database replication SQL thread is not running.', + ]; + + return array_values(array_diff($blockers, $allowed)) === []; + } + + private function repairDatabaseReplicationThreads(array $host): array + { + try { + $target = $this->databaseConnection($host, true); + try { + $this->restartDatabaseReplicationThreads($target, $this->databaseServerStatus($target)); + } finally { + $target->close(); + } + + return ['ok' => true]; + } catch (Throwable $throwable) { + return [ + 'ok' => false, + 'message' => $throwable->getMessage(), + ]; + } + } + + private function refreshDatabaseReplicationConnection( + mysqli $target, + array $serverStatus, + array $primary, + string $replicationUser, + string $replicationPassword + ): void { + $isMariaDb = self::databaseEngine($serverStatus) === 'mariadb'; + if ($isMariaDb) { + try { + $this->mysqliExec($target, 'STOP SLAVE'); + } catch (Throwable) { + } + $sql = sprintf( + "CHANGE MASTER TO MASTER_HOST = '%s', MASTER_PORT = %d, MASTER_USER = '%s', MASTER_PASSWORD = '%s', MASTER_USE_GTID = slave_pos", + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($replicationUser), + $target->real_escape_string($replicationPassword) + ); + $this->mysqliExec($target, $sql); + } else { + try { + $this->mysqliExec($target, 'STOP REPLICA'); + } catch (Throwable) { + } + $sql = sprintf( + "CHANGE REPLICATION SOURCE TO SOURCE_HOST = '%s', SOURCE_PORT = %d, SOURCE_USER = '%s', SOURCE_PASSWORD = '%s', SOURCE_AUTO_POSITION = 1", + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($replicationUser), + $target->real_escape_string($replicationPassword) + ); + $this->mysqliExec($target, $sql); + } + + $this->restartDatabaseReplicationThreads($target, $serverStatus); + } + + private function restartDatabaseReplicationThreads(mysqli $target, array $serverStatus): void + { + $isMariaDb = self::databaseEngine($serverStatus) === 'mariadb'; + $startStatements = $isMariaDb + ? ['START SLAVE', 'START SLAVE IO_THREAD', 'START SLAVE SQL_THREAD'] + : ['START REPLICA', 'START REPLICA IO_THREAD', 'START REPLICA SQL_THREAD']; + + $lastError = null; + $startedAnyThread = false; + foreach ($startStatements as $index => $statement) { + try { + $this->mysqliExec($target, $statement); + if ($index === 0) { + return; + } + $startedAnyThread = true; + } catch (Throwable $throwable) { + $lastError = $throwable; + } + } + + if ($startedAnyThread) { + return; + } + + if ($lastError !== null) { + throw $lastError; + } + } + + private function testRedisHost(array $host): array + { + $blockers = []; + $raw = []; + $percent = (float)(($host['role'] ?? '') === 'primary' ? 100 : 0); + $reachable = true; + + try { + $client = $this->redisClient($host); + $ping = (string)$client->ping(); + if (stripos($ping, 'PONG') === false && stripos($ping, 'OK') === false) { + $blockers[] = 'Redis PING did not return PONG.'; + } + + $role = $client->executeRaw(['ROLE']); + $info = $this->redisInfo($client); + $raw = [ + 'role' => $role, + 'replication' => $info, + ]; + + try { + $client->executeRaw(['CONFIG', 'GET', 'appendonly']); + } catch (Throwable $throwable) { + $blockers[] = 'Redis ACL must allow CONFIG GET/SET/REWRITE for durable replication changes.'; + } + + if (($host['role'] ?? '') !== 'primary' && empty($host['test_connectivity_only'])) { + $primary = $this->primaryHost(self::KIND_REDIS); + if ($primary === null) { + $blockers[] = 'No Redis primary is registered.'; + } else { + $primaryClient = $this->redisClient($primary); + $primaryInfo = $this->redisInfo($primaryClient); + $percent = self::redisReplicationPercentFromInfo($primaryInfo, $info); + $raw['primary_replication'] = $primaryInfo; + + if (!in_array(strtolower((string)($info['role'] ?? '')), ['slave', 'replica'], true)) { + $blockers[] = 'Redis host is not currently a replica.'; + } + if (strtolower((string)($info['master_link_status'] ?? '')) !== 'up') { + $blockers[] = 'Redis replica link to primary is not up.'; + } + if ($blockers !== [] && $percent >= 100.0) { + $percent = 99.99; + } + } + } + } catch (Throwable $throwable) { + $reachable = false; + $blockers[] = $throwable->getMessage(); + } + + $blockers = array_values(array_unique(array_filter($blockers))); + return [ + 'status' => self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$percent, + $blockers, + empty($host['test_connectivity_only']) + ), + 'replication_percent' => round($percent, 2), + 'lag_seconds' => null, + 'blockers' => $blockers, + 'raw' => $raw, + 'checked_at' => date('c'), + ]; + } + + private function testMinioHost(array $host): array + { + $blockers = []; + $raw = []; + $percent = (float)(($host['role'] ?? '') === 'primary' ? 100 : 0); + $reachable = true; + $connectivityOnly = !empty($host['test_connectivity_only']); + $isPrimary = (string)($host['role'] ?? '') === 'primary'; + $forceStorageScan = !empty($host['force_storage_scan']); + $measureStorage = !$connectivityOnly + && empty($host['skip_storage_scan']) + && $forceStorageScan; + $options = $this->decodeOptions($host); + $buckets = self::normalizeMinioBuckets($options['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + + try { + $client = $this->minioS3Client($host); + $client->listBuckets(); + + if ($isPrimary) { + $sourceStats = $this->minioBucketStats($client, $buckets, true, 'MinIO source bucket', $measureStorage); + $blockers = array_merge($blockers, $sourceStats['blockers']); + $raw['buckets'] = $sourceStats['buckets']; + $raw['storage'] = [ + 'source_bytes' => $sourceStats['bytes'], + 'source_objects' => $sourceStats['objects'], + 'measured' => $measureStorage, + ]; + } else { + $retentionDaysByBucket = self::minioReplicaRetentionDaysByBucket($buckets); + $targetStats = $this->minioBucketStats( + $client, + $buckets, + !$connectivityOnly, + 'MinIO target bucket', + $measureStorage, + $retentionDaysByBucket + ); + $blockers = array_merge($blockers, $targetStats['blockers']); + $raw['target_buckets'] = $targetStats['buckets']; + $raw['storage'] = [ + 'target_bytes' => $targetStats['bytes'], + 'target_objects' => $targetStats['objects'], + 'target_expired_bytes' => $targetStats['expired_bytes'] ?? 0, + 'target_expired_objects' => $targetStats['expired_objects'] ?? 0, + 'measured' => $measureStorage, + ]; + + if (!$connectivityOnly) { + $primary = $this->primaryHost(self::KIND_MINIO); + if ($primary === null) { + $blockers[] = 'No MinIO primary is registered.'; + } else { + $sourceStats = $this->minioBucketStats( + $this->minioS3Client($primary), + $buckets, + true, + 'MinIO source bucket', + $measureStorage, + $retentionDaysByBucket + ); + $blockers = array_merge($blockers, $sourceStats['blockers']); + $blockers = array_merge($blockers, self::minioBackupRetentionBlockers($targetStats)); + $headroom = (float)($options['space_headroom_percent'] ?? self::MINIO_SPACE_HEADROOM_PERCENT); + $availableBytes = $measureStorage ? $this->minioTargetFreeBytes($host) : null; + $requiredBytes = $measureStorage ? self::minioRequiredFreeBytes((int)$sourceStats['bytes'], $headroom) : null; + $spaceBlockers = $requiredBytes === null ? [] : self::minioSpaceBlockers($availableBytes, $requiredBytes); + $blockers = array_merge($blockers, $spaceBlockers); + $percent = $measureStorage + ? self::minioByteReplicationPercent((int)$sourceStats['bytes'], (int)$targetStats['bytes']) + : self::minioIncompleteProgress(self::lastStatusReplicationPercent($host, 5.0)); + $replicationConfigured = $this->minioReplicationConfigured($primary, $buckets); + $progressStatus = null; + if ($replicationConfigured) { + $progressStatus = $this->minioReplicationProgressStatus($primary, $host, $buckets); + if ($progressStatus !== null) { + $percent = round((float)$progressStatus['replication_percent'], 2); + $raw['progress_source'] = 'minio_replicate_status'; + $raw['replication_status'] = $progressStatus; + } else { + $raw['progress_source'] = 'minio_replicate_status_unavailable'; + } + } + if (!$replicationConfigured) { + $blockers[] = 'MinIO bucket replication is not configured.'; + } elseif (in_array(self::MINIO_BACKUP_BUCKET, $buckets, true) + && !$this->minioBackupReplicaRetentionConfigured($host, self::MINIO_BACKUP_BUCKET)) { + $blockers[] = 'MinIO backup replica retention is not configured for the backups bucket.'; + } elseif (($progressStatus !== null || $measureStorage) && $percent < 100.0) { + $blockers[] = self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER; + } elseif ($progressStatus === null && !$measureStorage) { + $blockers[] = self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER; + } + $raw['source_buckets'] = $sourceStats['buckets']; + $raw['storage'] = array_merge($raw['storage'], [ + 'source_bytes' => $sourceStats['bytes'], + 'source_objects' => $sourceStats['objects'], + 'source_expired_bytes' => $sourceStats['expired_bytes'] ?? 0, + 'source_expired_objects' => $sourceStats['expired_objects'] ?? 0, + 'required_free_bytes' => $requiredBytes, + 'available_free_bytes' => $availableBytes, + 'space_headroom_percent' => $headroom, + 'space_ok' => $measureStorage ? ($availableBytes === null ? null : $spaceBlockers === []) : null, + ]); + } + } + } + } catch (Throwable $throwable) { + $reachable = false; + $blockers[] = $throwable->getMessage(); + } + + $blockers = array_values(array_unique(array_filter($blockers))); + return [ + 'status' => self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$percent, + $blockers, + !$connectivityOnly + ), + 'replication_percent' => round($percent, 2), + 'lag_seconds' => null, + 'blockers' => $blockers, + 'raw' => $raw, + 'checked_at' => date('c'), + ]; + } + + private function minioProgressScanHost(array $host): array + { + if (!$this->minioCanReuseRecentMeasuredStatus($host)) { + return $host; + } + + return array_merge($host, ['skip_storage_scan' => true]); + } + + private function minioCanReuseRecentMeasuredStatus(array $host): bool + { + $lastStatus = self::jsonDecode($host['last_status_json'] ?? null); + if (!is_array($lastStatus)) { + return false; + } + + $storage = $lastStatus['raw']['storage'] ?? null; + if (!is_array($storage) || empty($storage['measured'])) { + return false; + } + + $checkedAt = strtotime((string)($lastStatus['checked_at'] ?? $host['last_checked_at'] ?? '')); + if ($checkedAt === false) { + return false; + } + + return (time() - $checkedAt) < self::MINIO_PROGRESS_SCAN_INTERVAL_SECONDS; + } + + private static function minioOnlyProgressBlockers(array $blockers): bool + { + $blockers = array_values(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + $blockers + ))); + if ($blockers === []) { + return true; + } + + return array_values(array_diff($blockers, [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER])) === []; + } + + private static function minioProvisionProgressMessage(array $status): string + { + $storage = is_array($status['raw']['storage'] ?? null) ? $status['raw']['storage'] : []; + if (!empty($storage['measured'])) { + $targetBytes = (int)($storage['target_bytes'] ?? 0); + $sourceBytes = (int)($storage['source_bytes'] ?? 0); + if ($sourceBytes > 0) { + return 'MinIO replica is syncing. Copied ' . $targetBytes . ' of ' . $sourceBytes . ' bytes.'; + } + } + + if ((string)($status['raw']['progress_source'] ?? '') === 'minio_replicate_status') { + $percent = round((float)($status['replication_percent'] ?? 0), 2); + return 'MinIO replica is syncing. Replication status reports ' . $percent . '% complete.'; + } + + return 'MinIO replica is syncing in the background. Waiting for the next progress sample.'; + } + + private function minioProvisionStatus(array $primary, array $host): array + { + $status = $this->testMinioHost(array_merge($host, [ + 'test_connectivity_only' => true, + 'skip_storage_scan' => true, + ])); + if ($status['blockers'] !== []) { + return $status; + } + + $primaryOptions = $this->decodeOptions($primary); + $targetOptions = $this->decodeOptions($host); + $buckets = self::normalizeMinioBuckets($targetOptions['buckets'] ?? $primaryOptions['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + $progress = $this->minioReplicationProgressStatus($primary, $host, $buckets); + + if ($progress === null) { + $percent = self::minioIncompleteProgress(self::lastStatusReplicationPercent($host, 5.0)); + $status['replication_percent'] = $percent; + $status['blockers'] = array_values(array_unique(array_merge( + $status['blockers'], + [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER] + ))); + $status['raw']['progress_source'] = 'minio_replicate_status_unavailable'; + $status['raw']['replication_status'] = [ + 'available' => false, + 'message' => 'MinIO replication status did not report progress yet.', + ]; + $status['status'] = self::replicationHealthStatus( + true, + (string)($host['role'] ?? ''), + $percent, + $status['blockers'] + ); + + return $status; + } + + $status['replication_percent'] = round((float)$progress['replication_percent'], 2); + $status['blockers'] = array_values(array_unique(array_merge( + $status['blockers'], + $progress['blockers'] ?? [] + ))); + $status['raw']['progress_source'] = 'minio_replicate_status'; + $status['raw']['replication_status'] = $progress; + $status = self::normalizeMinioCaughtUpStatus($status, (string)($host['role'] ?? '')); + $status['status'] = self::replicationHealthStatus( + true, + (string)($host['role'] ?? ''), + (float)$status['replication_percent'], + $status['blockers'] + ); + + return $status; + } + + private static function lastStatusReplicationPercent(array $host, float $default): float + { + $lastStatus = self::jsonDecode($host['last_status_json'] ?? null); + if (is_array($lastStatus) && is_numeric($lastStatus['replication_percent'] ?? null)) { + return round((float)$lastStatus['replication_percent'], 2); + } + + return $default; + } + + private static function normalizeMinioCaughtUpStatus(array $status, string $role): array + { + if ($role === 'primary' || round((float)($status['replication_percent'] ?? 0), 2) < 100.0) { + return $status; + } + + $blockers = array_values(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + $status['blockers'] ?? [] + ))); + if ($blockers === []) { + return $status; + } + + $status['blockers'] = array_values(array_diff($blockers, [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER])); + return $status; + } + + public static function databasePrerequisiteBlockers(array $status): array + { + if (self::databaseEngine($status) === 'mariadb') { + return self::mariaDbPrerequisiteBlockers($status); + } + + $blockers = []; + if (strtoupper((string)($status['gtid_mode'] ?? '')) !== 'ON') { + $blockers[] = isset($status['gtid_mode']) + ? 'MySQL GTID mode must be ON.' + : 'MySQL GTID mode is unavailable. Managed replication requires Oracle MySQL 8.x with GTID enabled.'; + } + if (!self::mysqlBooleanEnabled($status['log_bin'] ?? null)) { + $blockers[] = isset($status['log_bin']) + ? 'MySQL binary logging must be enabled.' + : 'MySQL binary logging status is unavailable.'; + } + if ((int)($status['server_id'] ?? 0) <= 0) { + $blockers[] = 'MySQL server_id must be configured.'; + } + if (trim((string)($status['server_uuid'] ?? '')) === '') { + $blockers[] = 'MySQL server_uuid must be available. Managed replication requires Oracle MySQL 8.x.'; + } + $serverVersion = (string)($status['server_version'] ?? ''); + if (!str_starts_with($serverVersion, '8.') || stripos($serverVersion, 'mariadb') !== false) { + $blockers[] = $serverVersion !== '' + ? 'Oracle MySQL 8.x is required for managed replication. Current server reports ' . $serverVersion . '.' + : 'Oracle MySQL 8.x is required for managed replication.'; + } + + return $blockers; + } + + public static function missingDatabaseTables(array $sourceTables, array $replicaTables): array + { + $source = array_values(array_unique(array_filter(array_map( + static fn(mixed $table): string => trim((string)$table), + $sourceTables + )))); + $replicaLookup = array_flip(array_values(array_unique(array_filter(array_map( + static fn(mixed $table): string => trim((string)$table), + $replicaTables + ))))); + + return array_values(array_filter( + $source, + static fn(string $table): bool => !isset($replicaLookup[$table]) + )); + } + + private static function mariaDbPrerequisiteBlockers(array $status): array + { + $blockers = []; + if (!self::mysqlBooleanEnabled($status['log_bin'] ?? null)) { + $blockers[] = isset($status['log_bin']) + ? 'MariaDB binary logging must be enabled.' + : 'MariaDB binary logging status is unavailable.'; + } + if ((int)($status['server_id'] ?? 0) <= 0) { + $blockers[] = 'MariaDB server_id must be configured.'; + } + if (!self::mariaDbGtidPositionAvailable($status)) { + $blockers[] = 'MariaDB GTID position must be available.'; + } + + $serverVersion = (string)($status['server_version'] ?? ''); + if (!self::mariaDbVersionSupported($serverVersion)) { + $blockers[] = $serverVersion !== '' + ? 'MariaDB 10.6 or newer is required for managed replication. Current server reports ' . $serverVersion . '.' + : 'MariaDB 10.6 or newer is required for managed replication.'; + } + + return $blockers; + } + + private static function databaseEngine(array $status): string + { + return stripos((string)($status['server_version'] ?? ''), 'mariadb') !== false ? 'mariadb' : 'mysql'; + } + + private static function databaseEngineKnown(array $status): bool + { + return trim((string)($status['server_version'] ?? '')) !== ''; + } + + private static function databaseGtidPosition(array $status): string + { + if (self::databaseEngine($status) === 'mariadb') { + return trim((string)($status['gtid_binlog_pos'] ?? $status['gtid_current_pos'] ?? $status['gtid_slave_pos'] ?? '')); + } + + return trim((string)($status['gtid_executed'] ?? '')); + } + + private static function mariaDbGtidPositionAvailable(array $status): bool + { + foreach (['gtid_binlog_pos', 'gtid_current_pos', 'gtid_slave_pos'] as $key) { + if (array_key_exists($key, $status) && $status[$key] !== null) { + return true; + } + } + + return false; + } + + private static function mariaDbVersionSupported(string $serverVersion): bool + { + if (!preg_match('/(\d+)\.(\d+)/', $serverVersion, $matches)) { + return false; + } + + $major = (int)$matches[1]; + $minor = (int)$matches[2]; + return $major > 10 || ($major === 10 && $minor >= 6); + } + + private static function mysqlBooleanEnabled(mixed $value): bool + { + $normalized = strtoupper(trim((string)$value)); + return in_array($normalized, ['1', 'ON', 'YES', 'TRUE'], true); + } + + private function databaseServerStatus(mysqli $connection): array + { + $row = $this->mysqliSelectOne($connection, "SELECT VERSION() AS server_version"); + $variables = $connection->query( + "SHOW GLOBAL VARIABLES WHERE Variable_name IN ( + 'gtid_mode', + 'log_bin', + 'server_id', + 'server_uuid', + 'read_only', + 'super_read_only', + 'gtid_executed', + 'gtid_binlog_pos', + 'gtid_current_pos', + 'gtid_slave_pos', + 'gtid_strict_mode' + )" + ); + if ($variables !== false) { + while ($variable = $variables->fetch_assoc()) { + $name = strtolower((string)($variable['Variable_name'] ?? '')); + if ($name !== '') { + $row[$name] = $variable['Value'] ?? null; + } + } + } + + $plugin = $this->mysqliSelectOne( + $connection, + "SELECT PLUGIN_STATUS AS plugin_status FROM information_schema.PLUGINS WHERE PLUGIN_NAME = 'clone' LIMIT 1" + ); + $row['clone_plugin_active'] = strtoupper((string)($plugin['plugin_status'] ?? '')) === 'ACTIVE'; + + return $row; + } + + private function showReplicaStatus(mysqli $connection): array + { + try { + $status = $this->mysqliSelectOne($connection, 'SHOW REPLICA STATUS'); + if ($status !== []) { + return $status; + } + } catch (Throwable) { + } + + return $this->mysqliSelectOne($connection, 'SHOW SLAVE STATUS'); + } + + private function databaseConnection(array $host, bool $admin = false, bool $connectWithoutDatabase = false): mysqli + { + $credentials = $this->credentials($host); + $username = $admin && $credentials['admin_username'] !== '' + ? $credentials['admin_username'] + : $credentials['username']; + $password = $admin && $credentials['admin_password'] !== '' + ? $credentials['admin_password'] + : $credentials['password']; + + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + $connection = new mysqli( + (string)$host['host'], + $username, + $password, + $connectWithoutDatabase ? '' : (string)($host['database_name'] ?? ''), + (int)$host['port'] + ); + $connection->set_charset('utf8mb4'); + return $connection; + } + + private function databaseReplicaSeedBlockers(?array $primary, array $host, mysqli $target): array + { + if ($primary === null) { + return ['No database primary is registered.']; + } + + $primaryDatabase = trim((string)($primary['database_name'] ?? '')); + $targetDatabase = trim((string)($host['database_name'] ?? '')); + if ($primaryDatabase === '' || $targetDatabase === '') { + return []; + } + + $primaryConnection = $this->databaseConnection($primary, true); + try { + $primaryTables = $this->databaseTableNames($primaryConnection, $primaryDatabase); + $targetTables = $this->databaseTableNames($target, $targetDatabase); + } finally { + $primaryConnection->close(); + } + + if ($primaryTables === []) { + return []; + } + + $missingTables = self::missingDatabaseTables($primaryTables, $targetTables); + if ($missingTables === []) { + $schemaOnlyTablesWithRows = $this->databaseSchemaOnlyTablesWithRows($target, $targetDatabase); + if ($schemaOnlyTablesWithRows === []) { + return []; + } + + return [self::schemaOnlyTablesContainRowsBlocker($targetDatabase, $schemaOnlyTablesWithRows)]; + } + + return [self::missingDatabaseTablesBlocker($targetDatabase, $missingTables)]; + } + + private function advanceMariaDbReplicaSeed(int $operationId, array $primary, array $host, mysqli $target): array + { + $owner = 'replication-seed:' . $operationId; + $context = $this->operationContext($operationId); + $freezeState = application_write_freeze::state(); + if (($context['phase'] ?? '') !== '' && ($freezeState['owner'] ?? null) !== $owner) { + $context = []; + } + if (self::mariaDbSeedContextRequiresFilterReset($context)) { + $context = []; + } + application_write_freeze::freeze('MariaDB replica seed is copying data.', $owner, self::MARIADB_SEED_FREEZE_TTL_SECONDS); + + $source = $this->databaseConnection($primary, true); + $deadline = microtime(true) + self::MARIADB_SEED_STEP_SECONDS; + + try { + $this->mysqliExec($target, 'SET SESSION FOREIGN_KEY_CHECKS = 0'); + $this->mysqliExec($target, 'SET SESSION UNIQUE_CHECKS = 0'); + + if (($context['phase'] ?? '') === '') { + $context = $this->initializeMariaDbSeedContext($source, $target, $primary, $host); + } + + while (microtime(true) < $deadline && ($context['phase'] ?? '') !== 'complete') { + if (($context['phase'] ?? '') === 'schema') { + $context = $this->advanceMariaDbSeedSchema($source, $target, $context); + continue; + } + + if (($context['phase'] ?? '') === 'copy') { + $context = $this->advanceMariaDbSeedRows($source, $target, $context); + continue; + } + + break; + } + + $progress = self::mariaDbSeedProgress($context); + $message = self::mariaDbSeedMessage($context); + $this->updateOperationProgress($operationId, $progress, $message, $context); + + if (($context['phase'] ?? '') === 'complete') { + application_write_freeze::unfreeze($owner); + return [ + 'running' => false, + 'message' => 'MariaDB replica seed completed.', + 'status' => [ + 'status' => 'provisioning', + 'replication_percent' => 90.0, + 'lag_seconds' => null, + 'blockers' => [], + 'raw' => ['seed' => $context], + 'checked_at' => date('c'), + ], + ]; + } + + return [ + 'running' => true, + 'message' => $message, + 'status' => [ + 'status' => 'provisioning', + 'replication_percent' => $progress, + 'lag_seconds' => null, + 'blockers' => ['MariaDB replica seed is running.'], + 'raw' => ['seed' => $context], + 'checked_at' => date('c'), + ], + ]; + } catch (Throwable $throwable) { + application_write_freeze::unfreeze($owner); + throw $throwable; + } finally { + $source->close(); + } + } + + private function initializeMariaDbSeedContext(mysqli $source, mysqli $target, array $primary, array $host): array + { + $primaryDatabase = trim((string)($primary['database_name'] ?? '')); + $targetDatabase = trim((string)($host['database_name'] ?? '')); + if ($primaryDatabase === '' || $targetDatabase === '') { + throw new RuntimeException('Database names are required before a MariaDB replica can be seeded.'); + } + + $sourceGtid = $this->mariaDbCurrentGtid($source); + $this->prepareMariaDbReplicaTarget($target, $source, $primaryDatabase, $targetDatabase); + + $tables = []; + foreach ($this->databaseTableNames($source, $primaryDatabase) as $tableName) { + $skipData = self::databaseReplicaSeedSkipsTableData($tableName); + $tables[] = [ + 'name' => $tableName, + 'rows' => $skipData ? 0 : $this->estimatedDatabaseTableRows($source, $primaryDatabase, $tableName), + 'copied' => 0, + 'schema_created' => false, + 'skip_data' => $skipData, + 'skip_reason' => $skipData ? 'excluded from managed replication' : '', + ]; + } + + return [ + 'phase' => 'schema', + 'source_database' => $primaryDatabase, + 'target_database' => $targetDatabase, + 'source_gtid' => $sourceGtid, + 'schema_index' => 0, + 'copy_index' => 0, + 'tables' => $tables, + 'started_at' => date('c'), + 'updated_at' => date('c'), + ]; + } + + private function advanceMariaDbSeedSchema(mysqli $source, mysqli $target, array $context): array + { + $tables = $context['tables'] ?? []; + $index = (int)($context['schema_index'] ?? 0); + if (!isset($tables[$index])) { + $context['phase'] = 'copy'; + $context['copy_index'] = 0; + $context['updated_at'] = date('c'); + return $context; + } + + $table = $tables[$index]; + $this->createMariaDbReplicaTable( + $source, + $target, + (string)$context['source_database'], + (string)$context['target_database'], + (string)$table['name'] + ); + + $context['tables'][$index]['schema_created'] = true; + $context['schema_index'] = $index + 1; + $context['updated_at'] = date('c'); + return $context; + } + + private function advanceMariaDbSeedRows(mysqli $source, mysqli $target, array $context): array + { + $tables = $context['tables'] ?? []; + $index = (int)($context['copy_index'] ?? 0); + if (!isset($tables[$index])) { + if (trim((string)($context['source_gtid'] ?? '')) !== '') { + $this->mysqliExec($target, 'SET GLOBAL gtid_slave_pos = ' . self::sqlString($target, (string)$context['source_gtid'])); + } + $this->mysqliExec($target, 'SET SESSION FOREIGN_KEY_CHECKS = 1'); + $this->mysqliExec($target, 'SET SESSION UNIQUE_CHECKS = 1'); + $context['phase'] = 'complete'; + $context['updated_at'] = date('c'); + $context['completed_at'] = date('c'); + return $context; + } + + $tableName = (string)($tables[$index]['name'] ?? ''); + $copied = (int)($tables[$index]['copied'] ?? 0); + if (!empty($tables[$index]['skip_data'])) { + $context['tables'][$index]['copied'] = (int)($tables[$index]['rows'] ?? 0); + $context['copy_index'] = $index + 1; + $context['updated_at'] = date('c'); + return $context; + } + + $copiedNow = $this->copyMariaDbReplicaTableRowsChunk( + $source, + $target, + (string)$context['source_database'], + (string)$context['target_database'], + $tableName, + $copied, + self::MARIADB_SEED_BATCH_ROWS + ); + + $context['tables'][$index]['copied'] = $copied + $copiedNow; + if ($copiedNow < self::MARIADB_SEED_BATCH_ROWS) { + $context['copy_index'] = $index + 1; + } + $context['updated_at'] = date('c'); + return $context; + } + + private static function mariaDbSeedProgress(array $context): float + { + $tables = is_array($context['tables'] ?? null) ? $context['tables'] : []; + if (($context['phase'] ?? '') === 'complete') { + return 90.0; + } + if ($tables === []) { + return 5.0; + } + + $schemaCount = count($tables); + $schemaDone = min($schemaCount, (int)($context['schema_index'] ?? 0)); + $schemaProgress = $schemaCount > 0 ? ($schemaDone / $schemaCount) * 20.0 : 20.0; + + $totalRows = 0; + $copiedRows = 0; + foreach ($tables as $table) { + if (!empty($table['skip_data'])) { + continue; + } + $rows = max(1, (int)($table['rows'] ?? 0)); + $totalRows += $rows; + $copiedRows += min($rows, (int)($table['copied'] ?? 0)); + } + $copyProgress = $totalRows > 0 ? ($copiedRows / $totalRows) * 65.0 : 0.0; + + return round(min(89.0, 5.0 + $schemaProgress + $copyProgress), 2); + } + + private static function mariaDbSeedMessage(array $context): string + { + $tables = is_array($context['tables'] ?? null) ? $context['tables'] : []; + if (($context['phase'] ?? '') === 'schema') { + return 'Creating replica schema ' . min(count($tables), (int)($context['schema_index'] ?? 0)) . ' of ' . count($tables) . '.'; + } + if (($context['phase'] ?? '') === 'copy') { + $index = (int)($context['copy_index'] ?? 0); + $table = $tables[$index]['name'] ?? 'table data'; + if (!empty($tables[$index]['skip_data'])) { + return 'Skipping replica data for ' . $table . '.'; + } + return 'Copying replica data for ' . $table . '.'; + } + if (($context['phase'] ?? '') === 'complete') { + return 'Replica seed completed.'; + } + return 'Preparing replica seed.'; + } + + private function seedMariaDbReplicaFromPrimary(array $primary, array $host, mysqli $target): void + { + $primaryDatabase = trim((string)($primary['database_name'] ?? '')); + $targetDatabase = trim((string)($host['database_name'] ?? '')); + if ($primaryDatabase === '' || $targetDatabase === '') { + throw new RuntimeException('Database names are required before a MariaDB replica can be seeded.'); + } + + $source = $this->databaseConnection($primary, true); + $readLockAcquired = false; + $transactionStarted = false; + + try { + $source->query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $source->query('FLUSH TABLES WITH READ LOCK'); + $readLockAcquired = true; + $source->query('START TRANSACTION WITH CONSISTENT SNAPSHOT'); + $transactionStarted = true; + $sourceGtid = $this->mariaDbCurrentGtid($source); + $source->query('UNLOCK TABLES'); + $readLockAcquired = false; + + $this->prepareMariaDbReplicaTarget($target, $source, $primaryDatabase, $targetDatabase); + foreach ($this->databaseTableNames($source, $primaryDatabase) as $tableName) { + $this->createMariaDbReplicaTable($source, $target, $primaryDatabase, $targetDatabase, $tableName); + $this->copyMariaDbReplicaTableRows($source, $target, $primaryDatabase, $targetDatabase, $tableName); + } + + if ($sourceGtid !== '') { + $this->mysqliExec($target, 'SET GLOBAL gtid_slave_pos = ' . self::sqlString($target, $sourceGtid)); + } + $this->mysqliExec($target, 'SET SESSION FOREIGN_KEY_CHECKS = 1'); + + $source->query('COMMIT'); + $transactionStarted = false; + } catch (Throwable $throwable) { + if ($readLockAcquired) { + try { + $source->query('UNLOCK TABLES'); + } catch (Throwable) { + } + } + if ($transactionStarted) { + try { + $source->query('ROLLBACK'); + } catch (Throwable) { + } + } + throw new RuntimeException('MariaDB replica seed failed: ' . $throwable->getMessage(), 0, $throwable); + } finally { + $source->close(); + } + } + + private function mariaDbCurrentGtid(mysqli $source): string + { + foreach (['gtid_binlog_pos', 'gtid_current_pos'] as $variable) { + $row = $this->mysqliSelectOne($source, "SELECT @@GLOBAL.$variable AS value"); + $value = trim((string)($row['value'] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function prepareMariaDbReplicaTarget(mysqli $target, mysqli $source, string $primaryDatabase, string $targetDatabase): void + { + foreach (['STOP SLAVE', 'RESET SLAVE ALL'] as $statement) { + try { + $this->mysqliExec($target, $statement); + } catch (Throwable) { + } + } + + $this->mysqliExec($target, 'SET SESSION FOREIGN_KEY_CHECKS = 0'); + $this->mysqliExec($target, 'DROP DATABASE IF EXISTS ' . self::quoteIdentifier($targetDatabase)); + $this->mysqliExec($target, $this->createDatabaseSql($source, $primaryDatabase, $targetDatabase)); + $this->mysqliExec($target, 'USE ' . self::quoteIdentifier($targetDatabase)); + + try { + $this->mysqliExec($target, 'RESET MASTER'); + } catch (Throwable) { + } + try { + $this->mysqliExec($target, "SET GLOBAL gtid_slave_pos = ''"); + } catch (Throwable) { + } + } + + private function createDatabaseSql(mysqli $source, string $primaryDatabase, string $targetDatabase): string + { + $stmt = $source->prepare( + 'SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME + FROM information_schema.SCHEMATA + WHERE SCHEMA_NAME = ? + LIMIT 1' + ); + if ($stmt === false) { + throw new RuntimeException('Could not prepare database schema lookup.'); + } + + $stmt->bind_param('s', $primaryDatabase); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + + $charset = preg_replace('/[^a-zA-Z0-9_]/', '', (string)($row['DEFAULT_CHARACTER_SET_NAME'] ?? 'utf8mb4')) ?: 'utf8mb4'; + $collation = preg_replace('/[^a-zA-Z0-9_]/', '', (string)($row['DEFAULT_COLLATION_NAME'] ?? 'utf8mb4_unicode_ci')) ?: 'utf8mb4_unicode_ci'; + + return 'CREATE DATABASE ' . self::quoteIdentifier($targetDatabase) + . ' CHARACTER SET ' . $charset + . ' COLLATE ' . $collation; + } + + private function createMariaDbReplicaTable(mysqli $source, mysqli $target, string $primaryDatabase, string $targetDatabase, string $tableName): void + { + $create = $this->mysqliSelectOne( + $source, + 'SHOW CREATE TABLE ' . self::quoteIdentifier($primaryDatabase) . '.' . self::quoteIdentifier($tableName) + ); + $createSql = (string)($create['Create Table'] ?? ''); + if ($createSql === '') { + throw new RuntimeException('Could not read CREATE TABLE for ' . $primaryDatabase . '.' . $tableName . '.'); + } + + $this->mysqliExec($target, 'USE ' . self::quoteIdentifier($targetDatabase)); + $this->mysqliExec($target, $createSql); + } + + private static function mariaDbSeedContextRequiresFilterReset(array $context): bool + { + if (($context['phase'] ?? '') === '' || ($context['phase'] ?? '') === 'complete') { + return false; + } + + foreach (($context['tables'] ?? []) as $table) { + if (self::databaseReplicaSeedSkipsTableData((string)($table['name'] ?? '')) + && empty($table['skip_data'])) { + return true; + } + } + + return false; + } + + private function copyMariaDbReplicaTableRows(mysqli $source, mysqli $target, string $primaryDatabase, string $targetDatabase, string $tableName): void + { + if (self::databaseReplicaSeedSkipsTableData($tableName)) { + return; + } + + $offset = 0; + do { + $copied = $this->copyMariaDbReplicaTableRowsChunk( + $source, + $target, + $primaryDatabase, + $targetDatabase, + $tableName, + $offset, + self::MARIADB_SEED_BATCH_ROWS + ); + $offset += $copied; + } while ($copied >= self::MARIADB_SEED_BATCH_ROWS); + } + + private function copyMariaDbReplicaTableRowsChunk( + mysqli $source, + mysqli $target, + string $primaryDatabase, + string $targetDatabase, + string $tableName, + int $offset, + int $limit + ): int { + $columnNames = $this->databaseWritableColumnNames($source, $primaryDatabase, $tableName); + if ($columnNames === []) { + return 0; + } + + $quotedColumns = array_map(static fn(string $column): string => self::quoteIdentifier($column), $columnNames); + $primaryKeyColumns = $this->databasePrimaryKeyColumnNames($source, $primaryDatabase, $tableName); + $orderSql = $primaryKeyColumns !== [] + ? ' ORDER BY ' . implode(', ', array_map(static fn(string $column): string => self::quoteIdentifier($column), $primaryKeyColumns)) + : ''; + $result = $source->query( + 'SELECT ' . implode(', ', $quotedColumns) + . ' FROM ' . self::quoteIdentifier($primaryDatabase) . '.' . self::quoteIdentifier($tableName) + . $orderSql + . ' LIMIT ' . max(0, $offset) . ', ' . max(1, $limit), + MYSQLI_USE_RESULT + ); + if ($result === false) { + throw new RuntimeException('Could not read rows from ' . $primaryDatabase . '.' . $tableName . '.'); + } + + $fields = $result->fetch_fields(); + $insertPrefix = 'INSERT INTO ' . self::quoteIdentifier($targetDatabase) . '.' . self::quoteIdentifier($tableName) + . ' (' . implode(', ', $quotedColumns) . ') VALUES '; + $rows = []; + $batchSize = 200; + $copied = 0; + + try { + $target->begin_transaction(); + while (true) { + $row = $result->fetch_assoc(); + if (!is_array($row)) { + break; + } + + $values = []; + foreach ($fields as $field) { + $value = $row[$field->name] ?? null; + $values[] = $value === null ? 'NULL' : self::sqlString($target, (string)$value); + } + $rows[] = '(' . implode(', ', $values) . ')'; + $copied++; + + if (count($rows) >= $batchSize) { + $this->mysqliExec($target, $insertPrefix . implode(', ', $rows)); + $rows = []; + } + } + + if ($rows !== []) { + $this->mysqliExec($target, $insertPrefix . implode(', ', $rows)); + } + $target->commit(); + } catch (Throwable $throwable) { + try { + $target->rollback(); + } catch (Throwable) { + } + throw $throwable; + } finally { + $result->free(); + } + + return $copied; + } + + private function estimatedDatabaseTableRows(mysqli $connection, string $database, string $tableName): int + { + $stmt = $connection->prepare( + "SELECT TABLE_ROWS + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND TABLE_TYPE = 'BASE TABLE' + LIMIT 1" + ); + if ($stmt === false) { + return 1; + } + + $stmt->bind_param('ss', $database, $tableName); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + + return max(1, (int)($row['TABLE_ROWS'] ?? 1)); + } + + private static function databaseReplicaSeedSkipsTableData(string $tableName): bool + { + return in_array(strtolower($tableName), self::MARIADB_SCHEMA_ONLY_TABLES, true); + } + + private function databaseWritableColumnNames(mysqli $connection, string $database, string $tableName): array + { + $stmt = $connection->prepare( + "SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND EXTRA NOT LIKE '%GENERATED%' + ORDER BY ORDINAL_POSITION" + ); + if ($stmt === false) { + throw new RuntimeException('Could not prepare table column lookup.'); + } + + $stmt->bind_param('ss', $database, $tableName); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $stmt->close(); + + return array_values(array_filter(array_map( + static fn(array $row): string => (string)($row['COLUMN_NAME'] ?? ''), + $rows + ))); + } + + private function databasePrimaryKeyColumnNames(mysqli $connection, string $database, string $tableName): array + { + $stmt = $connection->prepare( + "SELECT COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = 'PRIMARY' + ORDER BY ORDINAL_POSITION" + ); + if ($stmt === false) { + return []; + } + + $stmt->bind_param('ss', $database, $tableName); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $stmt->close(); + + return array_values(array_filter(array_map( + static fn(array $row): string => (string)($row['COLUMN_NAME'] ?? ''), + $rows + ))); + } + + private function databaseTableNames(mysqli $connection, string $database): array + { + $stmt = $connection->prepare( + "SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME" + ); + if ($stmt === false) { + throw new RuntimeException('Could not prepare database table comparison query.'); + } + + $stmt->bind_param('s', $database); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $stmt->close(); + + return array_values(array_map( + static fn(array $row): string => (string)($row['TABLE_NAME'] ?? ''), + $rows + )); + } + + private function databaseSchemaOnlyTablesWithRows(mysqli $connection, string $database): array + { + $existingTables = []; + foreach ($this->databaseTableNames($connection, $database) as $tableName) { + $existingTables[strtolower($tableName)] = $tableName; + } + + $tablesWithRows = []; + foreach (self::MARIADB_SCHEMA_ONLY_TABLES as $schemaOnlyTable) { + $actualTable = $existingTables[strtolower($schemaOnlyTable)] ?? null; + if ($actualTable === null) { + continue; + } + + $row = $this->mysqliSelectOne( + $connection, + 'SELECT 1 AS has_rows FROM ' . self::quoteIdentifier($database) . '.' . self::quoteIdentifier($actualTable) . ' LIMIT 1' + ); + if (($row['has_rows'] ?? null) !== null) { + $tablesWithRows[] = $actualTable; + } + } + + return $tablesWithRows; + } + + private static function missingDatabaseTablesBlocker(string $database, array $missingTables): string + { + $shownTables = array_slice($missingTables, 0, 3); + $qualifiedTables = array_map( + static fn(string $table): string => $database . '.' . $table, + $shownTables + ); + $tableWord = count($missingTables) === 1 ? 'table' : 'tables'; + $sample = $qualifiedTables !== [] ? ': ' . implode(', ', $qualifiedTables) : ''; + + return 'Replica seed is incomplete. Missing ' . count($missingTables) . ' database ' . $tableWord . ' on replica' . $sample . '.'; + } + + private static function schemaOnlyTablesContainRowsBlocker(string $database, array $tables): string + { + $shownTables = array_slice($tables, 0, 3); + $qualifiedTables = array_map( + static fn(string $table): string => $database . '.' . $table, + $shownTables + ); + $sample = $qualifiedTables !== [] ? ': ' . implode(', ', $qualifiedTables) : ''; + + return 'Replica seed includes data for schema-only tables' . $sample . '. Re-run provisioning to rebuild the replica without schema-only table data.'; + } + + private function minioS3Client(array $host): S3Client + { + $credentials = $this->credentials($host); + return new S3Client([ + 'version' => 'latest', + 'region' => 'us-east-1', + 'endpoint' => self::minioEndpoint($host), + 'use_path_style_endpoint' => true, + 'retries' => 0, + 'http' => [ + 'connect_timeout' => self::MINIO_S3_CONNECT_TIMEOUT_SECONDS, + 'timeout' => self::MINIO_S3_REQUEST_TIMEOUT_SECONDS, + ], + 'credentials' => [ + 'key' => $credentials['username'], + 'secret' => $credentials['password'], + ], + ]); + } + + private static function minioObjectLastModifiedTimestamp(mixed $value): ?int + { + if ($value instanceof \DateTimeInterface) { + return $value->getTimestamp(); + } + if (is_numeric($value)) { + return (int)$value; + } + $timestamp = strtotime((string)$value); + return $timestamp === false ? null : $timestamp; + } + + private function minioBucketStats( + S3Client $client, + array $buckets, + bool $requireExists, + string $missingPrefix, + bool $measureObjects = true, + array $retentionDaysByBucket = [] + ): array + { + $stats = [ + 'bytes' => 0, + 'objects' => 0, + 'expired_bytes' => 0, + 'expired_objects' => 0, + 'buckets' => [], + 'blockers' => [], + ]; + + foreach ($buckets as $bucket) { + $retentionDays = isset($retentionDaysByBucket[$bucket]) ? (int)$retentionDaysByBucket[$bucket] : null; + $retentionCutoff = $retentionDays !== null ? time() - ($retentionDays * 86400) : null; + try { + $exists = (bool)$client->doesBucketExist($bucket); + if (!$exists) { + $stats['buckets'][] = [ + 'name' => $bucket, + 'status' => 'missing', + 'bytes' => 0, + 'objects' => 0, + ]; + if ($requireExists) { + $stats['blockers'][] = $missingPrefix . ' ' . $bucket . ' is missing.'; + } + continue; + } + + if (!$measureObjects) { + $stats['buckets'][] = [ + 'name' => $bucket, + 'status' => 'ok', + 'bytes' => null, + 'objects' => null, + 'expired_bytes' => null, + 'expired_objects' => null, + 'measured' => false, + 'retention_days' => $retentionDays, + ]; + continue; + } + + $bucketBytes = 0; + $bucketObjects = 0; + $bucketExpiredBytes = 0; + $bucketExpiredObjects = 0; + $token = null; + do { + $args = ['Bucket' => $bucket]; + if ($token !== null) { + $args['ContinuationToken'] = $token; + } + $result = $client->listObjectsV2($args); + foreach (($result['Contents'] ?? []) as $object) { + $size = (int)($object['Size'] ?? 0); + $lastModified = self::minioObjectLastModifiedTimestamp($object['LastModified'] ?? null); + if ($retentionCutoff !== null && $lastModified !== null && $lastModified < $retentionCutoff) { + $bucketExpiredBytes += $size; + $bucketExpiredObjects++; + continue; + } + + $bucketBytes += $size; + $bucketObjects++; + } + $token = isset($result['NextContinuationToken']) ? (string)$result['NextContinuationToken'] : null; + } while ($token !== null); + + $stats['bytes'] += $bucketBytes; + $stats['objects'] += $bucketObjects; + $stats['expired_bytes'] += $bucketExpiredBytes; + $stats['expired_objects'] += $bucketExpiredObjects; + $stats['buckets'][] = [ + 'name' => $bucket, + 'status' => 'ok', + 'bytes' => $bucketBytes, + 'objects' => $bucketObjects, + 'expired_bytes' => $bucketExpiredBytes, + 'expired_objects' => $bucketExpiredObjects, + 'retention_days' => $retentionDays, + 'retention_cutoff' => $retentionCutoff !== null ? date('c', $retentionCutoff) : null, + ]; + } catch (Throwable $throwable) { + $stats['buckets'][] = [ + 'name' => $bucket, + 'status' => 'down', + 'bytes' => 0, + 'objects' => 0, + 'expired_bytes' => 0, + 'expired_objects' => 0, + 'retention_days' => $retentionDays, + 'error' => $throwable->getMessage(), + ]; + $stats['blockers'][] = $missingPrefix . ' ' . $bucket . ' could not be inspected: ' . $throwable->getMessage(); + } + } + + $stats['blockers'] = array_values(array_unique($stats['blockers'])); + return $stats; + } + + private function configureMinioReplication(array $primary, array $target): void + { + $primaryOptions = $this->decodeOptions($primary); + $targetOptions = $this->decodeOptions($target); + $buckets = self::normalizeMinioBuckets($targetOptions['buckets'] ?? $primaryOptions['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + $transferLimit = self::minioReplicationTransferLimitFromOptions($targetOptions); + $configDir = $this->createMinioConfigDir(); + + try { + $this->prepareMinioAlias($configDir, 'source', $primary); + $this->prepareMinioAlias($configDir, 'target', $target); + + foreach ($buckets as $index => $bucket) { + $this->runMinioClient($configDir, ['mb', '--with-lock', '--ignore-existing', 'target/' . $bucket]); + $this->runMinioClient($configDir, ['version', 'enable', 'source/' . $bucket]); + $this->runMinioClient($configDir, ['version', 'enable', 'target/' . $bucket]); + + if (self::minioBucketUsesBoundedReplicaRetention($bucket)) { + $this->configureMinioReplicaBackupRetention($target, $bucket); + // Backups are bounded on replicas; avoid bulk seeding large historical objects. + $this->pruneMinioReplicaBackupRetention($target, $bucket); + } + + $this->addMinioReplicationRule($configDir, $target, $bucket, $index + 1, $transferLimit); + } + } finally { + $this->removeDirectory($configDir); + } + } + + private function configureMinioReplicaBackupRetention(array $target, string $bucket): void + { + $client = $this->minioS3Client($target); + $rules = []; + + try { + $current = $client->getBucketLifecycleConfiguration(['Bucket' => $bucket]); + foreach (($current['Rules'] ?? []) as $rule) { + if ((string)($rule['ID'] ?? '') !== self::MINIO_BACKUP_REPLICA_RETENTION_RULE_ID) { + $rules[] = $rule; + } + } + } catch (Throwable $throwable) { + if (!self::minioMissingLifecycleConfiguration($throwable)) { + throw $throwable; + } + } + + $rules[] = self::minioBackupReplicaRetentionLifecycleRule(); + + $client->putBucketLifecycleConfiguration([ + 'Bucket' => $bucket, + 'LifecycleConfiguration' => [ + 'Rules' => $rules, + ], + ]); + } + + private static function minioBackupReplicaRetentionLifecycleRule(): array + { + return [ + 'ID' => self::MINIO_BACKUP_REPLICA_RETENTION_RULE_ID, + 'Status' => 'Enabled', + 'Filter' => ['Prefix' => ''], + 'Expiration' => ['Days' => self::MINIO_BACKUP_REPLICA_RETENTION_DAYS], + 'NoncurrentVersionExpiration' => ['NoncurrentDays' => self::MINIO_BACKUP_REPLICA_RETENTION_DAYS], + 'AbortIncompleteMultipartUpload' => ['DaysAfterInitiation' => 7], + ]; + } + + private static function minioMissingLifecycleConfiguration(Throwable $throwable): bool + { + $message = strtolower($throwable->getMessage()); + return str_contains($message, 'nosuchlifecycleconfiguration') + || str_contains($message, 'lifecycle configuration does not exist') + || str_contains($message, 'the lifecycle configuration does not exist'); + } + + private function minioBackupReplicaRetentionConfigured(array $target, string $bucket): bool + { + try { + $current = $this->minioS3Client($target)->getBucketLifecycleConfiguration(['Bucket' => $bucket]); + } catch (Throwable) { + return false; + } + + foreach (($current['Rules'] ?? []) as $rule) { + if ((string)($rule['ID'] ?? '') !== self::MINIO_BACKUP_REPLICA_RETENTION_RULE_ID) { + continue; + } + if (strtolower((string)($rule['Status'] ?? '')) !== 'enabled') { + return false; + } + + $expirationDays = (int)($rule['Expiration']['Days'] ?? 0); + $noncurrentDays = (int)($rule['NoncurrentVersionExpiration']['NoncurrentDays'] ?? 0); + return $expirationDays > 0 + && $expirationDays <= self::MINIO_BACKUP_REPLICA_RETENTION_DAYS + && $noncurrentDays > 0 + && $noncurrentDays <= self::MINIO_BACKUP_REPLICA_RETENTION_DAYS; + } + + return false; + } + + private function pruneMinioReplicaBackupRetention(array $target, string $bucket): array + { + $client = $this->minioS3Client($target); + $cutoff = time() - (self::MINIO_BACKUP_REPLICA_RETENTION_DAYS * 86400); + $deleted = [ + 'versions' => 0, + 'delete_markers' => 0, + 'cutoff' => date('c', $cutoff), + ]; + + try { + $this->pruneMinioReplicaBackupVersions($client, $bucket, $cutoff, $deleted); + } catch (Throwable $throwable) { + if (!self::minioVersionListingUnsupported($throwable)) { + throw $throwable; + } + $this->pruneMinioReplicaBackupCurrentObjects($client, $bucket, $cutoff, $deleted); + } + + return $deleted; + } + + private static function minioVersionListingUnsupported(Throwable $throwable): bool + { + $message = strtolower($throwable->getMessage()); + return str_contains($message, 'not implemented') + || str_contains($message, 'not supported') + || str_contains($message, 'unsupported') + || str_contains($message, 'listobjectversions'); + } + + private function pruneMinioReplicaBackupVersions(S3Client $client, string $bucket, int $cutoff, array &$deleted): void + { + $keyMarker = null; + $versionIdMarker = null; + do { + $args = ['Bucket' => $bucket]; + if ($keyMarker !== null) { + $args['KeyMarker'] = $keyMarker; + } + if ($versionIdMarker !== null) { + $args['VersionIdMarker'] = $versionIdMarker; + } + + $result = $client->listObjectVersions($args); + $objects = []; + + foreach (($result['Versions'] ?? []) as $version) { + if (self::minioObjectVersionIsOlderThan($version, $cutoff)) { + $objects[] = [ + 'Key' => (string)($version['Key'] ?? ''), + 'VersionId' => (string)($version['VersionId'] ?? ''), + ]; + $deleted['versions']++; + } + } + + foreach (($result['DeleteMarkers'] ?? []) as $marker) { + if (self::minioObjectVersionIsOlderThan($marker, $cutoff)) { + $objects[] = [ + 'Key' => (string)($marker['Key'] ?? ''), + 'VersionId' => (string)($marker['VersionId'] ?? ''), + ]; + $deleted['delete_markers']++; + } + } + + $this->deleteMinioObjectsInBatches($client, $bucket, $objects); + + $keyMarker = isset($result['NextKeyMarker']) ? (string)$result['NextKeyMarker'] : null; + $versionIdMarker = isset($result['NextVersionIdMarker']) ? (string)$result['NextVersionIdMarker'] : null; + } while (!empty($result['IsTruncated'])); + } + + private function pruneMinioReplicaBackupCurrentObjects(S3Client $client, string $bucket, int $cutoff, array &$deleted): void + { + $token = null; + do { + $args = ['Bucket' => $bucket]; + if ($token !== null) { + $args['ContinuationToken'] = $token; + } + + $result = $client->listObjectsV2($args); + $objects = []; + foreach (($result['Contents'] ?? []) as $object) { + if (!self::minioObjectVersionIsOlderThan($object, $cutoff)) { + continue; + } + $objects[] = ['Key' => (string)($object['Key'] ?? '')]; + $deleted['versions']++; + } + + $this->deleteMinioObjectsInBatches($client, $bucket, $objects); + $token = isset($result['NextContinuationToken']) ? (string)$result['NextContinuationToken'] : null; + } while ($token !== null); + } + + private static function minioObjectVersionIsOlderThan(array $object, int $cutoff): bool + { + $key = trim((string)($object['Key'] ?? '')); + if ($key === '') { + return false; + } + + $lastModified = self::minioObjectLastModifiedTimestamp($object['LastModified'] ?? null); + return $lastModified !== null && $lastModified < $cutoff; + } + + private function deleteMinioObjectsInBatches(S3Client $client, string $bucket, array $objects): void + { + foreach (array_chunk($objects, 1000) as $chunk) { + $chunk = array_values(array_filter( + $chunk, + static fn(array $object): bool => trim((string)($object['Key'] ?? '')) !== '' + )); + if ($chunk === []) { + continue; + } + + $client->deleteObjects([ + 'Bucket' => $bucket, + 'Delete' => [ + 'Objects' => $chunk, + 'Quiet' => true, + ], + ]); + } + } + + private function addMinioReplicationRule(string $configDir, array $target, string $bucket, int $priority, string $transferLimit): void + { + try { + $this->runMinioClient($configDir, self::minioReplicationRuleCommand($bucket, $priority, $transferLimit)); + return; + } catch (Throwable $throwable) { + $message = strtolower($throwable->getMessage()); + if (self::minioReplicationRuleAlreadyExists($message)) { + $this->updateMinioReplicationRulesForBucket($configDir, $bucket, $transferLimit); + return; + } + if (!$this->repairMinioTargetBucketObjectLockIfEmpty($configDir, $target, $bucket, $message)) { + throw $throwable; + } + } + + try { + $this->runMinioClient($configDir, self::minioReplicationRuleCommand($bucket, $priority, $transferLimit)); + } catch (Throwable $throwable) { + $message = strtolower($throwable->getMessage()); + if (!self::minioReplicationRuleAlreadyExists($message)) { + throw $throwable; + } + $this->updateMinioReplicationRulesForBucket($configDir, $bucket, $transferLimit); + } + } + + private function updateMinioReplicationRulesForBucket(string $configDir, string $bucket, string $transferLimit): void + { + $result = $this->runMinioClient($configDir, ['replicate', 'ls', '--json', 'source/' . $bucket]); + $ruleIds = self::minioReplicationRuleIdsFromList(self::decodeMinioJsonOutput((string)$result['stdout'])); + foreach ($ruleIds as $ruleId) { + $this->runMinioClient($configDir, array_merge([ + 'replicate', + 'update', + '--id', + $ruleId, + '--replicate', + self::minioReplicationFeatures($bucket), + ], self::minioReplicationTransferLimitArgs($transferLimit), [ + 'source/' . $bucket, + ])); + } + } + + private static function minioReplicationRuleCommand(string $bucket, int $priority, string $transferLimit): array + { + return array_merge([ + 'replicate', + 'add', + '--remote-bucket', + 'target/' . $bucket, + '--replicate', + self::minioReplicationFeatures($bucket), + '--priority', + (string)$priority, + ], self::minioReplicationTransferLimitArgs($transferLimit), [ + 'source/' . $bucket, + ]); + } + + private static function minioReplicationFeatures(string $bucket): string + { + return self::minioBucketUsesBoundedReplicaRetention($bucket) + ? 'delete,delete-marker' + : 'delete,delete-marker,existing-objects'; + } + + private static function minioReplicationRuleIdsFromList(mixed $value): array + { + $ids = []; + self::collectMinioReplicationRuleIds($value, $ids); + return array_values(array_unique(array_filter($ids))); + } + + private static function collectMinioReplicationRuleIds(mixed $value, array &$ids): void + { + if (!is_array($value)) { + return; + } + + foreach ($value as $key => $entry) { + $normalizedKey = strtolower(str_replace(['_', '-'], '', (string)$key)); + if (in_array($normalizedKey, ['id', 'ruleid'], true) && is_scalar($entry)) { + $id = trim((string)$entry); + if ($id !== '') { + $ids[] = $id; + } + continue; + } + + self::collectMinioReplicationRuleIds($entry, $ids); + } + } + + private static function minioReplicationRuleAlreadyExists(string $message): bool + { + return str_contains($message, 'already') + || str_contains($message, 'replication rule exists') + || str_contains($message, 'replication configuration exists'); + } + + private function repairMinioTargetBucketObjectLockIfEmpty(string $configDir, array $target, string $bucket, string $message): bool + { + if (!self::minioObjectLockRequiredError($message)) { + return false; + } + + if ($this->minioBucketHasObjects($target, $bucket)) { + throw new RuntimeException( + 'MinIO target bucket ' . $bucket . ' was created without Object Lock and is not empty. ' + . 'Create a new empty replica bucket with Object Lock enabled, or empty and recreate this bucket before provisioning.' + ); + } + + $this->runMinioClient($configDir, ['rb', 'target/' . $bucket]); + $this->runMinioClient($configDir, ['mb', '--with-lock', 'target/' . $bucket]); + $this->runMinioClient($configDir, ['version', 'enable', 'target/' . $bucket]); + + return true; + } + + private static function minioObjectLockRequiredError(string $message): bool + { + return (str_contains($message, 'object lock') || str_contains($message, 'object locking')) + && str_contains($message, 'destination bucket'); + } + + private function minioBucketHasObjects(array $host, string $bucket): bool + { + $client = $this->minioS3Client($host); + $objects = $client->listObjectsV2([ + 'Bucket' => $bucket, + 'MaxKeys' => 1, + ]); + if (!empty($objects['Contents'])) { + return true; + } + + try { + $versions = $client->listObjectVersions([ + 'Bucket' => $bucket, + 'MaxKeys' => 1, + ]); + return !empty($versions['Versions']) || !empty($versions['DeleteMarkers']); + } catch (Throwable) { + return false; + } + } + + private function minioReplicationConfiguredForHosts(array $primary, array $target): bool + { + $primaryOptions = $this->decodeOptions($primary); + $targetOptions = $this->decodeOptions($target); + $buckets = self::normalizeMinioBuckets($targetOptions['buckets'] ?? $primaryOptions['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + + if (!$this->minioReplicationConfigured($primary, $buckets)) { + return false; + } + + if (in_array(self::MINIO_BACKUP_BUCKET, $buckets, true) + && !$this->minioBackupReplicaRetentionConfigured($target, self::MINIO_BACKUP_BUCKET)) { + return false; + } + + return true; + } + + private function minioReplicationConfigured(array $primary, array $buckets): bool + { + $configDir = $this->createMinioConfigDir(); + try { + $this->prepareMinioAlias($configDir, 'source', $primary); + foreach ($buckets as $bucket) { + try { + $result = $this->runMinioClient($configDir, ['replicate', 'list', '--json', 'source/' . $bucket]); + } catch (Throwable) { + return false; + } + if (trim((string)$result['stdout']) === '') { + return false; + } + } + + return true; + } finally { + $this->removeDirectory($configDir); + } + } + + private function minioReplicationProgressStatus(array $primary, array $target, array $buckets): ?array + { + $targetOptions = $this->decodeOptions($target); + $transferLimit = self::minioReplicationTransferLimitFromOptions($targetOptions); + $configDir = $this->createMinioConfigDir(); + $bucketProgress = []; + $bucketOutput = []; + $requiredUnavailableBuckets = []; + + try { + $this->prepareMinioAlias($configDir, 'source', $primary); + foreach ($buckets as $bucket) { + $countsTowardCatchUp = self::minioBucketCountsTowardCatchUp((string)$bucket); + try { + // MinIO keeps removed/re-added ARNs in JSON status output. Prefer standard + // output so stale targets do not keep a healthy current target below 100%. + $result = $this->runMinioClient($configDir, array_merge([ + 'replicate', + 'status', + 'source/' . $bucket, + ], self::minioReplicationTransferLimitArgs($transferLimit))); + $stdout = (string)$result['stdout']; + $raw = $stdout; + $progress = self::minioReplicationProgressFromStatusOutput($stdout); + + if ($progress === null) { + $result = $this->runMinioClient($configDir, array_merge([ + 'replicate', + 'status', + '--json', + 'source/' . $bucket, + ], self::minioReplicationTransferLimitArgs($transferLimit))); + $stdout = (string)$result['stdout']; + $decoded = self::decodeMinioJsonOutput($stdout); + $raw = $decoded ?? $stdout; + $progress = self::minioReplicationProgressFromStatusOutput($raw) + ?? self::minioReplicationProgressFromStatusOutput($stdout); + } + + $bucketOutput[$bucket] = [ + 'ok' => true, + 'counts_toward_catch_up' => $countsTowardCatchUp, + 'raw' => $raw, + 'progress' => $progress, + ]; + if ($progress !== null) { + $bucketProgress[$bucket] = $progress; + } elseif ($countsTowardCatchUp) { + $requiredUnavailableBuckets[] = (string)$bucket; + } + } catch (Throwable $throwable) { + $bucketOutput[$bucket] = [ + 'ok' => false, + 'counts_toward_catch_up' => $countsTowardCatchUp, + 'error' => $throwable->getMessage(), + ]; + if ($countsTowardCatchUp) { + $requiredUnavailableBuckets[] = (string)$bucket; + } + } + } + } finally { + $this->removeDirectory($configDir); + } + + $progress = self::minioCatchUpProgressFromBucketStatuses($bucketProgress); + if ($progress === null) { + return null; + } + + $requiredUnavailableBuckets = array_values(array_unique($requiredUnavailableBuckets)); + if ($requiredUnavailableBuckets !== []) { + $progress['replication_percent'] = self::minioIncompleteProgress((float)$progress['replication_percent']); + $progress['blockers'] = array_values(array_unique(array_merge($progress['blockers'] ?? [], [ + 'MinIO replication status is unavailable for bucket(s): ' . implode(', ', $requiredUnavailableBuckets) . '.', + ]))); + $progress['unavailable_required_buckets'] = $requiredUnavailableBuckets; + } + + $progress['buckets'] = $bucketOutput; + $progress['target_endpoint'] = self::minioEndpoint($target); + + return $progress; + } + + public static function minioCatchUpProgressFromBucketStatuses(array $bucketProgress): ?array + { + $requiredProgress = []; + $ignoredBuckets = []; + + foreach ($bucketProgress as $bucket => $progress) { + $bucket = (string)$bucket; + if (self::minioBucketCountsTowardCatchUp($bucket)) { + $requiredProgress[$bucket] = $progress; + continue; + } + + $ignoredBuckets[] = $bucket; + } + + $progress = self::aggregateMinioReplicationProgress($requiredProgress); + if ($progress === null && $ignoredBuckets !== []) { + $progress = [ + 'replication_percent' => 100.0, + 'blockers' => [], + 'basis' => 'bounded_retention_only', + 'stats' => [ + 'completed_bytes' => 0.0, + 'pending_bytes' => 0.0, + 'failed_bytes' => 0.0, + 'total_bytes' => 0.0, + 'completed_count' => 0.0, + 'pending_count' => 0.0, + 'failed_count' => 0.0, + 'total_count' => 0.0, + ], + 'bucket_count' => 0, + ]; + } + + if ($progress === null) { + return null; + } + + $progress['ignored_buckets'] = $ignoredBuckets; + $progress['catch_up_bucket_count'] = count($requiredProgress); + + return $progress; + } + + private static function aggregateMinioReplicationProgress(array $bucketProgress): ?array + { + if ($bucketProgress === []) { + return null; + } + + $stats = [ + 'completed_bytes' => 0.0, + 'pending_bytes' => 0.0, + 'failed_bytes' => 0.0, + 'total_bytes' => 0.0, + 'completed_count' => 0.0, + 'pending_count' => 0.0, + 'failed_count' => 0.0, + 'total_count' => 0.0, + ]; + $percentages = []; + foreach ($bucketProgress as $progress) { + $percentages[] = (float)($progress['replication_percent'] ?? 0); + $progressStats = is_array($progress['stats'] ?? null) ? $progress['stats'] : []; + foreach (array_keys($stats) as $key) { + $stats[$key] += (float)($progressStats[$key] ?? 0); + } + } + + $completedBytes = $stats['completed_bytes']; + $remainingBytes = $stats['pending_bytes'] + $stats['failed_bytes']; + $totalBytes = $stats['total_bytes']; + $completedCount = $stats['completed_count']; + $remainingCount = $stats['pending_count'] + $stats['failed_count']; + $totalCount = $stats['total_count']; + $basis = 'bucket_average'; + + if ($totalBytes > 0.0) { + $percent = ($completedBytes / $totalBytes) * 100; + $basis = 'total_bytes'; + } elseif (($completedBytes + $remainingBytes) > 0.0) { + $percent = ($completedBytes / ($completedBytes + $remainingBytes)) * 100; + $basis = 'byte_balance'; + } elseif ($totalCount > 0.0) { + $percent = ($completedCount / $totalCount) * 100; + $basis = 'total_count'; + } elseif (($completedCount + $remainingCount) > 0.0) { + $percent = ($completedCount / ($completedCount + $remainingCount)) * 100; + $basis = 'count_balance'; + } else { + $percent = array_sum($percentages) / max(1, count($percentages)); + } + + $percent = round(min(100.0, max(0.0, $percent)), 2); + $withinTolerance = $stats['failed_bytes'] <= 0.0 + && $stats['failed_count'] <= 0.0 + && $stats['pending_bytes'] <= self::MINIO_CATCH_UP_PENDING_BYTES_TOLERANCE + && $stats['pending_count'] <= self::MINIO_CATCH_UP_PENDING_OBJECTS_TOLERANCE; + if ($percent < 100.0 && $withinTolerance) { + $percent = 100.0; + $basis .= '_within_live_tolerance'; + } + + return [ + 'replication_percent' => $percent, + 'blockers' => $percent < 100.0 ? [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER] : [], + 'basis' => $basis, + 'stats' => $stats, + 'bucket_count' => count($bucketProgress), + 'live_tolerance' => [ + 'pending_bytes' => self::MINIO_CATCH_UP_PENDING_BYTES_TOLERANCE, + 'pending_objects' => self::MINIO_CATCH_UP_PENDING_OBJECTS_TOLERANCE, + 'within_tolerance' => $withinTolerance, + ], + ]; + } + + private function minioTargetFreeBytes(array $host): ?int + { + $configDir = $this->createMinioConfigDir(); + try { + $this->prepareMinioAlias($configDir, 'target', $host); + $result = $this->runMinioClient($configDir, ['admin', 'info', '--json', 'target']); + $decoded = self::decodeMinioJsonOutput((string)$result['stdout']); + return self::minioAvailableBytesFromAdminInfo($decoded); + } catch (Throwable) { + return null; + } finally { + $this->removeDirectory($configDir); + } + } + + private function prepareMinioAlias(string $configDir, string $alias, array $host): void + { + $credentials = $this->credentials($host); + $this->runMinioClient($configDir, [ + 'alias', + 'set', + $alias, + self::minioEndpoint($host), + $credentials['username'], + $credentials['password'], + ]); + } + + private function runMinioClient(string $configDir, array $arguments): array + { + $binary = self::minioClientBinary(); + if ($binary === null) { + throw new RuntimeException('MinIO Client (mc) is not available in the PHP runtime. Install mc, set MINIO_MC_BINARY, or enable MINIO_MC_AUTO_INSTALL.'); + } + + $command = array_merge([$binary, '--config-dir', $configDir], array_map('strval', $arguments)); + $timeoutSeconds = self::minioClientCommandTimeoutSeconds(); + $pipes = []; + $process = @proc_open($command, [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + if (!is_resource($process)) { + throw new RuntimeException('MinIO Client (mc) is not available.'); + } + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $exitCode = null; + $timedOut = false; + $deadline = microtime(true) + $timeoutSeconds; + + while (true) { + $stdout .= (string)stream_get_contents($pipes[1]); + $stderr .= (string)stream_get_contents($pipes[2]); + + $status = proc_get_status($process); + if (empty($status['running'])) { + $exitCode = (int)($status['exitcode'] ?? -1); + break; + } + + if (microtime(true) >= $deadline) { + $timedOut = true; + proc_terminate($process); + usleep(100000); + $status = proc_get_status($process); + if (!empty($status['running'])) { + proc_terminate($process, 9); + } + break; + } + + usleep(50000); + } + + $stdout .= (string)stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr .= (string)stream_get_contents($pipes[2]); + fclose($pipes[2]); + + if ($timedOut) { + throw new RuntimeException( + 'MinIO Client command timed out after ' . $timeoutSeconds . ' seconds: ' + . self::minioClientCommandLabel($arguments) + ); + } + + $closeCode = proc_close($process); + if ($exitCode === null || $exitCode < 0) { + $exitCode = $closeCode; + } + + if ($exitCode !== 0) { + $message = trim((string)$stderr) ?: trim((string)$stdout) ?: 'MinIO Client command failed.'; + throw new RuntimeException($message); + } + + return [ + 'stdout' => (string)$stdout, + 'stderr' => (string)$stderr, + 'exit_code' => $exitCode, + ]; + } + + private static function minioClientCommandTimeoutSeconds(): int + { + $configured = getenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS'); + if (is_numeric($configured) && (int)$configured > 0) { + return (int)$configured; + } + + return self::MINIO_MC_COMMAND_TIMEOUT_SECONDS; + } + + private static function minioClientCommandLabel(array $arguments): string + { + $parts = array_values(array_map('strval', $arguments)); + if (($parts[0] ?? '') === 'alias' && ($parts[1] ?? '') === 'set') { + if (isset($parts[4])) { + $parts[4] = '[redacted]'; + } + if (isset($parts[5])) { + $parts[5] = '[redacted]'; + } + } + + return 'mc ' . implode(' ', array_slice($parts, 0, 8)); + } + + private static function minioClientBinary(): ?string + { + $configured = trim((string)(getenv('MINIO_MC_BINARY') ?: '')); + if ($configured !== '') { + return $configured; + } + + foreach (['/usr/local/bin/mc', '/usr/bin/mc'] as $candidate) { + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + + return self::executableFromPath('mc') ?? self::cachedMinioClientBinary(); + } + + private static function cachedMinioClientBinary(): ?string + { + if (!self::minioClientAutoInstallEnabled()) { + return null; + } + + $cacheDir = trim((string)(getenv('MINIO_MC_CACHE_DIR') ?: '')); + if ($cacheDir === '') { + $cacheDir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'truckwash-minio-client'; + } + $binary = rtrim($cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'mc'; + if (is_file($binary) && is_executable($binary)) { + return $binary; + } + + if (!is_dir($cacheDir) && !mkdir($cacheDir, 0700, true) && !is_dir($cacheDir)) { + throw new RuntimeException('Could not create MinIO Client cache directory.'); + } + + $lock = @fopen($cacheDir . DIRECTORY_SEPARATOR . 'mc.lock', 'c'); + if (is_resource($lock)) { + @flock($lock, LOCK_EX); + } + + try { + if (is_file($binary) && is_executable($binary)) { + return $binary; + } + + self::downloadMinioClientBinary($binary); + self::assertMinioClientUsable($binary); + } finally { + if (is_resource($lock)) { + @flock($lock, LOCK_UN); + fclose($lock); + } + } + + return $binary; + } + + private static function minioClientAutoInstallEnabled(): bool + { + $configured = getenv('MINIO_MC_AUTO_INSTALL'); + $value = strtolower(trim((string)($configured === false ? '1' : $configured))); + return !in_array($value, ['0', 'false', 'no', 'off'], true); + } + + private static function downloadMinioClientBinary(string $binary): void + { + $url = self::minioClientDownloadUrl(); + $temp = $binary . '.download-' . getmypid(); + $output = @fopen($temp, 'wb'); + if (!is_resource($output)) { + throw new RuntimeException('Could not write MinIO Client download cache.'); + } + + $ok = false; + $error = ''; + try { + if (function_exists('curl_init')) { + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize MinIO Client download.'); + } + curl_setopt_array($curl, [ + CURLOPT_FILE => $output, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => min(2, self::MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS), + CURLOPT_TIMEOUT => self::minioClientDownloadTimeoutSeconds(), + CURLOPT_FAILONERROR => true, + CURLOPT_USERAGENT => 'truckwash-replication-manager/1.0', + ]); + $ok = curl_exec($curl) === true; + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + curl_close($curl); + if (!$ok && $status > 0) { + $error = 'HTTP ' . $status; + } + } else { + $context = stream_context_create([ + 'http' => ['timeout' => self::minioClientDownloadTimeoutSeconds()], + 'https' => ['timeout' => self::minioClientDownloadTimeoutSeconds()], + ]); + $input = @fopen($url, 'rb', false, $context); + if (is_resource($input)) { + $ok = stream_copy_to_stream($input, $output) !== false; + fclose($input); + } else { + $error = 'download stream could not be opened'; + } + } + } finally { + fclose($output); + } + + if (!$ok || !is_file($temp) || (int)filesize($temp) <= 0) { + @unlink($temp); + throw new RuntimeException('Could not download MinIO Client (mc): ' . ($error !== '' ? $error : 'empty response')); + } + + @chmod($temp, 0755); + if (!@rename($temp, $binary)) { + @unlink($temp); + throw new RuntimeException('Could not install downloaded MinIO Client (mc).'); + } + @chmod($binary, 0755); + } + + private static function minioClientDownloadTimeoutSeconds(): int + { + $configured = getenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS'); + if (is_numeric($configured) && (int)$configured > 0) { + return (int)$configured; + } + + return self::MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS; + } + + private static function minioClientDownloadUrl(): string + { + $configured = trim((string)(getenv('MINIO_MC_DOWNLOAD_URL') ?: '')); + if ($configured !== '') { + return $configured; + } + + $platform = self::minioClientDownloadPlatform(); + if ($platform === null) { + throw new RuntimeException('Automatic MinIO Client download is not supported on this PHP runtime platform.'); + } + + return self::MINIO_MC_DOWNLOAD_BASE_URL . '/' . $platform . '/mc'; + } + + private static function minioClientDownloadPlatform(): ?string + { + if (PHP_OS_FAMILY !== 'Linux') { + return null; + } + + $machine = strtolower((string)php_uname('m')); + return match ($machine) { + 'x86_64', 'amd64' => 'linux-amd64', + 'aarch64', 'arm64' => 'linux-arm64', + default => null, + }; + } + + private static function assertMinioClientUsable(string $binary): void + { + $result = self::runProcessWithTimeout([$binary, '--version'], self::MINIO_MC_COMMAND_TIMEOUT_SECONDS); + if (($result['exit_code'] ?? 1) !== 0) { + @unlink($binary); + $message = trim((string)($result['stderr'] ?? '')) ?: trim((string)($result['stdout'] ?? '')) ?: 'mc --version failed'; + throw new RuntimeException('Downloaded MinIO Client (mc) failed verification: ' . $message); + } + } + + private static function runProcessWithTimeout(array $command, int $timeoutSeconds): array + { + $pipes = []; + $process = @proc_open(array_map('strval', $command), [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + if (!is_resource($process)) { + return [ + 'stdout' => '', + 'stderr' => 'Process could not be started.', + 'exit_code' => 127, + 'timed_out' => false, + ]; + } + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $exitCode = null; + $timedOut = false; + $deadline = microtime(true) + max(1, $timeoutSeconds); + + while (true) { + $stdout .= (string)stream_get_contents($pipes[1]); + $stderr .= (string)stream_get_contents($pipes[2]); + + $status = proc_get_status($process); + if (empty($status['running'])) { + $exitCode = (int)($status['exitcode'] ?? -1); + break; + } + + if (microtime(true) >= $deadline) { + $timedOut = true; + proc_terminate($process); + usleep(100000); + $status = proc_get_status($process); + if (!empty($status['running'])) { + proc_terminate($process, 9); + } + break; + } + + usleep(50000); + } + + $stdout .= (string)stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr .= (string)stream_get_contents($pipes[2]); + fclose($pipes[2]); + + if (!$timedOut) { + $closeCode = proc_close($process); + if ($exitCode === null || $exitCode < 0) { + $exitCode = $closeCode; + } + } + + return [ + 'stdout' => $stdout, + 'stderr' => $stderr, + 'exit_code' => $timedOut ? 124 : (int)$exitCode, + 'timed_out' => $timedOut, + ]; + } + + private static function executableFromPath(string $name): ?string + { + $path = (string)(getenv('PATH') ?: ''); + if ($path === '') { + return null; + } + + foreach (explode(PATH_SEPARATOR, $path) as $dir) { + $dir = rtrim((string)$dir, DIRECTORY_SEPARATOR); + if ($dir === '') { + continue; + } + $candidate = $dir . DIRECTORY_SEPARATOR . $name; + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + + return null; + } + + private function createMinioConfigDir(): string + { + $dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'truckwash-mc-' . bin2hex(random_bytes(6)); + if (!mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new RuntimeException('Could not create MinIO Client config directory.'); + } + return $dir; + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $entries = scandir($dir); + if (!is_array($entries)) { + @rmdir($dir); + return; + } + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $entry; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + @unlink($path); + } + } + @rmdir($dir); + } + + private static function decodeMinioJsonOutput(string $output): mixed + { + $trimmed = trim($output); + if ($trimmed === '') { + return null; + } + + $decoded = json_decode($trimmed, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + $items = []; + foreach (preg_split('/\R/', $trimmed) ?: [] as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $decodedLine = json_decode($line, true); + if (json_last_error() === JSON_ERROR_NONE) { + $items[] = $decodedLine; + } + } + + return $items !== [] ? $items : null; + } + + public static function minioAvailableBytesFromAdminInfo(mixed $value): ?int + { + $values = []; + self::collectMinioAvailableByteValues($value, $values); + if ($values === []) { + return null; + } + + return max($values); + } + + private static function collectMinioAvailableByteValues(mixed $value, array &$values): void + { + if (!is_array($value)) { + return; + } + + foreach ($value as $key => $entry) { + $normalizedKey = strtolower((string)$key); + if (in_array($normalizedKey, ['available', 'availablebytes', 'available_bytes', 'avail', 'availspace', 'avail_space', 'availablespace', 'available_space', 'free', 'freebytes', 'free_bytes', 'freespace', 'free_space'], true)) { + $bytes = self::parseMinioByteValue($entry); + if ($bytes !== null && $bytes >= 0) { + $values[] = $bytes; + } + } + if (is_array($entry)) { + self::collectMinioAvailableByteValues($entry, $values); + } + } + } + + private static function parseMinioByteValue(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_float($value)) { + return (int)$value; + } + + $text = trim((string)$value); + if ($text === '') { + return null; + } + if (ctype_digit($text)) { + return (int)$text; + } + if (preg_match('/^([0-9]+(?:\.[0-9]+)?)\s*([kmgtp]?i?b?|bytes?)$/i', $text, $matches) !== 1) { + return null; + } + + $number = (float)$matches[1]; + $unit = strtolower($matches[2]); + $multipliers = [ + 'b' => 1, + 'byte' => 1, + 'bytes' => 1, + 'kb' => 1000, + 'kib' => 1024, + 'mb' => 1000 ** 2, + 'mib' => 1024 ** 2, + 'gb' => 1000 ** 3, + 'gib' => 1024 ** 3, + 'tb' => 1000 ** 4, + 'tib' => 1024 ** 4, + 'pb' => 1000 ** 5, + 'pib' => 1024 ** 5, + ]; + + return (int)floor($number * ($multipliers[$unit] ?? 1)); + } + + private function redisClient(array $host): PredisClient + { + $credentials = $this->credentials($host); + $params = [ + 'scheme' => 'tcp', + 'host' => (string)$host['host'], + 'port' => (int)$host['port'], + 'database' => (int)($host['database_index'] ?? 0), + 'password' => $credentials['password'], + ]; + + if (($credentials['username'] ?? '') !== '' && $credentials['username'] !== 'default') { + $params['username'] = $credentials['username']; + } + + return new PredisClient($params); + } + + private function redisInfo(PredisClient $client): array + { + $info = $client->info('replication'); + if (is_array($info)) { + return isset($info['Replication']) && is_array($info['Replication']) + ? $info['Replication'] + : $info; + } + + $parsed = []; + foreach (explode("\n", (string)$info) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, ':')) { + continue; + } + [$key, $value] = explode(':', $line, 2); + $parsed[$key] = trim($value); + } + return $parsed; + } + + private function mysqliExec(mysqli $connection, string $sql): void + { + $connection->query($sql); + } + + private function mysqliSelectOne(mysqli $connection, string $sql): array + { + $result = $connection->query($sql); + if ($result === false) { + return []; + } + $row = $result->fetch_assoc(); + return is_array($row) ? $row : []; + } + + private function refreshStatuses(): void + { + foreach ($this->listHosts() as $host) { + $activeOperation = $this->activeOperation((string)$host['kind'], (int)$host['id']); + if ($activeOperation !== null) { + if ($this->shouldAdvanceActiveProvisionDuringRefresh($host, $activeOperation)) { + try { + $this->provisionHost((string)$host['kind'], (int)$host['id']); + } catch (Throwable $throwable) { + $this->storeStatus($host, [ + 'status' => 'degraded', + 'replication_percent' => self::lastStatusReplicationPercent($host, 0.0), + 'lag_seconds' => null, + 'blockers' => [$throwable->getMessage()], + 'raw' => ['active_operation_refresh_failed' => true], + 'checked_at' => date('c'), + ]); + } + } + continue; + } + + try { + $status = match ((string)$host['kind']) { + self::KIND_DATABASE => $this->testDatabaseHost($host), + self::KIND_REDIS => $this->testRedisHost($host), + self::KIND_MINIO => $this->testMinioHost($host), + default => throw new RuntimeException('Unsupported replication kind.'), + }; + $this->storeStatus($host, $status); + } catch (Throwable $throwable) { + $this->storeStatus($host, [ + 'status' => 'degraded', + 'replication_percent' => 0, + 'lag_seconds' => null, + 'blockers' => [$throwable->getMessage()], + 'raw' => [], + 'checked_at' => date('c'), + ]); + } + } + + $this->writeBootstrapSnapshot(); + } + + private function shouldAdvanceActiveProvisionDuringRefresh(array $host, array $activeOperation): bool + { + return (string)($host['kind'] ?? '') === self::KIND_MINIO + && (string)($host['role'] ?? '') !== 'primary' + && (string)($activeOperation['operation'] ?? '') === 'provision'; + } + + private function storeStatus(array $host, array $status): void + { + $publicStatus = [ + 'status' => (string)($status['status'] ?? 'unknown'), + 'replication_percent' => round((float)($status['replication_percent'] ?? 0), 2), + 'lag_seconds' => $status['lag_seconds'] ?? null, + 'blockers' => array_values(array_filter($status['blockers'] ?? [])), + 'raw' => $status['raw'] ?? [], + 'checked_at' => (string)($status['checked_at'] ?? date('c')), + ]; + + $this->execute( + "UPDATE replication_hosts SET status = ?, last_status_json = ?, last_checked_at = NOW() WHERE id = ?", + 'ssi', + [$publicStatus['status'], self::jsonEncode($publicStatus), (int)$host['id']] + ); + $this->execute( + "INSERT INTO replication_status_snapshots + (host_id, kind, status, replication_percent, lag_seconds, blockers_json, raw_status_json) + VALUES (?, ?, ?, ?, ?, ?, ?)", + 'issdiss', + [ + (int)$host['id'], + (string)$host['kind'], + $publicStatus['status'], + (float)$publicStatus['replication_percent'], + $publicStatus['lag_seconds'], + self::jsonEncode($publicStatus['blockers']), + self::jsonEncode($publicStatus['raw']), + ] + ); + + $this->completeReadyMinioProvisionOperation($host, $publicStatus); + + if (class_exists(coolify_manager::class)) { + coolify_manager::syncDeploymentStateForReplicationHost((int)$host['id']); + } + } + + private function completeReadyMinioProvisionOperation(array $host, array $status): void + { + if ((string)($host['kind'] ?? '') !== self::KIND_MINIO || (string)($host['role'] ?? '') === 'primary') { + return; + } + + $blockers = array_values(array_filter($status['blockers'] ?? [])); + if ((string)($status['status'] ?? '') !== 'ok' + || round((float)($status['replication_percent'] ?? 0), 2) < 100.0 + || $blockers !== []) { + return; + } + + $operationId = $this->activeOperationId(self::KIND_MINIO, (int)$host['id'], 'provision'); + if ($operationId !== null) { + $this->finishOperation($operationId, 'completed', 100.0, 'MinIO replication target is caught up.', []); + } + } + + private function buildReplicationSummary(string $kind, array $hosts): array + { + $replicas = []; + $blockers = []; + $percents = []; + $statuses = []; + + foreach ($hosts as $host) { + if (($host['role'] ?? '') === 'primary') { + continue; + } + + $public = $this->publicHost($host); + $replicas[] = $public; + $status = is_array($public['last_status'] ?? null) ? $public['last_status'] : []; + $activeProgress = $public['active_operation']['progress_percent'] ?? null; + $percent = is_numeric($activeProgress) && (float)$activeProgress > 0 + ? (float)$activeProgress + : (float)($status['replication_percent'] ?? $public['replication_percent'] ?? 0); + $percents[] = $percent; + $statuses[] = (string)($status['status'] ?? $public['status'] ?? 'unknown'); + foreach ($status['blockers'] ?? [] as $blocker) { + $blockers[] = (string)$blocker; + } + } + + if ($replicas === []) { + return [ + 'status' => 'not_configured', + 'min_percent' => 0.0, + 'average_percent' => 0.0, + 'replicas' => [], + 'blockers' => ['No ' . $kind . ' replicas configured.'], + ]; + } + + $min = min($percents); + $average = array_sum($percents) / max(1, count($percents)); + $status = ($min >= 100.0 && $blockers === []) ? 'ok' : 'degraded'; + if (in_array('down', $statuses, true)) { + $status = 'down'; + } + + return [ + 'status' => $status, + 'min_percent' => round($min, 2), + 'average_percent' => round($average, 2), + 'replicas' => $replicas, + 'blockers' => array_values(array_unique($blockers)), + ]; + } + + private function publicHost(?array $host): ?array + { + if ($host === null) { + return null; + } + + $lastStatus = self::jsonDecode($host['last_status_json'] ?? null); + $credentials = $this->credentials($host); + $options = $this->decodeOptions($host); + $hasCoolifyDeployment = isset($options['coolify_target_id']) + || isset($options['coolify_instance_id']) + || (string)($options['deployment_provider'] ?? '') === 'coolify'; + $coolifyDeployment = $hasCoolifyDeployment && isset($host['id']) + ? coolify_manager::deploymentMetadataForReplicationHost((int)$host['id']) + : null; + $activeOperation = isset($host['id']) + ? $this->activeOperation((string)$host['kind'], (int)$host['id']) + : null; + $replicationPercent = round((float)($lastStatus['replication_percent'] ?? (($host['role'] ?? '') === 'primary' ? 100 : 0)), 2); + $status = self::publicReplicationStatus($host, $lastStatus, $activeOperation, $replicationPercent); + $database = match ((string)$host['kind']) { + self::KIND_DATABASE => (string)($host['database_name'] ?? ''), + self::KIND_REDIS => (int)($host['database_index'] ?? 0), + self::KIND_MINIO => null, + default => null, + }; + + return [ + 'id' => (int)$host['id'], + 'kind' => (string)$host['kind'], + 'label' => (string)$host['label'], + 'host' => (string)$host['host'], + 'port' => (int)$host['port'], + 'database' => $database, + 'endpoint' => (string)($options['endpoint'] ?? (((string)$host['kind'] === self::KIND_MINIO) ? self::minioEndpoint($host) : '')), + 'scheme' => $options['scheme'] ?? null, + 'buckets' => ((string)$host['kind'] === self::KIND_MINIO) ? self::normalizeMinioBuckets($options['buckets'] ?? []) : [], + 'console_port' => ((string)$host['kind'] === self::KIND_MINIO) ? (int)($options['console_port'] ?? 9001) : null, + 'replication_transfer_limit' => ((string)$host['kind'] === self::KIND_MINIO) ? self::minioReplicationTransferLimitFromOptions($options) : null, + 'space_headroom_percent' => ((string)$host['kind'] === self::KIND_MINIO) ? (float)($options['space_headroom_percent'] ?? self::MINIO_SPACE_HEADROOM_PERCENT) : null, + 'role' => (string)$host['role'], + 'status' => $status, + 'replication_source_id' => isset($host['replication_source_id']) ? (int)$host['replication_source_id'] : null, + 'ssl_mode' => $host['ssl_mode'] ?? null, + 'replication_percent' => $replicationPercent, + 'last_status' => array_replace($lastStatus, ['status' => $status]), + 'active_operation' => $activeOperation, + 'deployment_provider' => (string)($options['deployment_provider'] ?? ($coolifyDeployment !== null ? 'coolify' : 'manual')), + 'coolify' => $coolifyDeployment, + 'availability_state' => $coolifyDeployment['availability_state'] ?? null, + 'last_checked_at' => $host['last_checked_at'] ?? null, + 'credential_summary' => [ + 'username' => $credentials['username'] !== '' ? replication_secret_box::mask($credentials['username']) : '', + 'password_set' => $credentials['password'] !== '', + 'admin_username' => $credentials['admin_username'] !== '' ? replication_secret_box::mask($credentials['admin_username']) : '', + 'admin_password_set' => $credentials['admin_password'] !== '', + 'replication_username' => $credentials['replication_username'] !== '' ? replication_secret_box::mask($credentials['replication_username']) : '', + 'replication_password_set' => $credentials['replication_password'] !== '', + ], + 'created_at' => $host['created_at'] ?? null, + 'updated_at' => $host['updated_at'] ?? null, + 'deleted_at' => $host['deleted_at'] ?? null, + ]; + } + + private static function publicReplicationStatus(array $host, array $lastStatus, ?array $activeOperation, float $replicationPercent): string + { + $hostStatus = (string)($host['status'] ?? 'unknown'); + $status = (string)($lastStatus['status'] ?? $hostStatus); + + if (in_array($hostStatus, ['removed', 'inactive', 'not_configured', 'down'], true)) { + return $hostStatus; + } + if (in_array($status, ['removed', 'inactive', 'not_configured', 'down'], true)) { + return $status; + } + if ($activeOperation !== null || $hostStatus === 'provisioning' || $status === 'provisioning') { + return 'provisioning'; + } + if ($status === 'ok') { + return self::replicationHealthStatus( + true, + (string)($host['role'] ?? ''), + $replicationPercent, + array_values(array_filter($lastStatus['blockers'] ?? [])) + ); + } + + return $status !== '' ? $status : 'unknown'; + } + + private static function sanitizePublicLastStatus(array $host, array $lastStatus): array + { + if ((string)($host['kind'] ?? '') !== self::KIND_MINIO || (string)($host['role'] ?? '') !== 'primary') { + return $lastStatus; + } + + $blockers = array_values(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + $lastStatus['blockers'] ?? [] + ))); + if ($blockers === []) { + return $lastStatus; + } + + $onlyObjectScanTimeouts = true; + foreach ($blockers as $blocker) { + $normalized = strtolower($blocker); + if (!str_contains($normalized, 'could not be inspected') + || !str_contains($normalized, 'listobjectsv2') + || !str_contains($normalized, 'timed out')) { + $onlyObjectScanTimeouts = false; + break; + } + } + + if (!$onlyObjectScanTimeouts || round((float)($lastStatus['replication_percent'] ?? 0), 2) < 100.0) { + return $lastStatus; + } + + $lastStatus['status'] = 'ok'; + $lastStatus['blockers'] = []; + $lastStatus['raw']['suppressed_blockers'] = $blockers; + $lastStatus['raw']['suppressed_reason'] = 'MinIO primary object-scan timeouts do not indicate primary availability failure.'; + return $lastStatus; + } + + private static function normalizeMinioAddress(string $host, mixed $port, mixed $scheme): array + { + $raw = trim($host); + $hasScheme = preg_match('/^https?:\/\//i', $raw) === 1; + $parsed = parse_url($hasScheme ? $raw : 'http://' . $raw); + if (!is_array($parsed) || empty($parsed['host'])) { + throw new RuntimeException('MinIO endpoint host is invalid.'); + } + + $normalizedScheme = strtolower(trim((string)($scheme ?: ($parsed['scheme'] ?? 'http')))); + if (!in_array($normalizedScheme, ['http', 'https'], true)) { + throw new RuntimeException('MinIO scheme must be http or https.'); + } + + $normalizedHost = trim((string)$parsed['host']); + $portValue = ($port !== null && trim((string)$port) !== '') + ? $port + : ($parsed['port'] ?? ($normalizedScheme === 'https' ? 443 : 9000)); + $normalizedPort = (int)$portValue; + + return [$normalizedHost, $normalizedPort, $normalizedScheme]; + } + + private static function minioEndpointFromParts(string $scheme, string $host, int $port): string + { + return strtolower($scheme) . '://' . $host . ':' . $port; + } + + private static function minioEndpoint(array $host): string + { + $options = isset($host['options']) && is_array($host['options']) + ? $host['options'] + : self::jsonDecode($host['options_json'] ?? null); + $endpoint = trim((string)($options['endpoint'] ?? '')); + if ($endpoint !== '') { + return $endpoint; + } + + return self::minioEndpointFromParts((string)($options['scheme'] ?? 'http'), (string)$host['host'], (int)$host['port']); + } + + private function normalizeHostInput(string $kind, array $input): array + { + $host = trim((string)($input['host'] ?? $input['endpoint'] ?? '')); + if ($host === '') { + throw new RuntimeException('Host is required.'); + } + + $scheme = null; + $defaultPort = match ($kind) { + self::KIND_DATABASE => 3306, + self::KIND_REDIS => 6379, + self::KIND_MINIO => 9000, + }; + if ($kind === self::KIND_MINIO) { + [$host, $port, $scheme] = self::normalizeMinioAddress($host, $input['port'] ?? null, $input['scheme'] ?? null); + } else { + $port = (int)($input['port'] ?? $defaultPort); + } + if ($port <= 0 || $port > 65535) { + throw new RuntimeException('Port must be between 1 and 65535.'); + } + + $label = trim((string)($input['label'] ?? '')); + if ($label === '') { + $label = $host . ':' . $port; + } + + $username = trim((string)($input['username'] ?? $input['access_key'] ?? $input['user'] ?? '')); + $password = (string)($input['password'] ?? $input['secret_key'] ?? ''); + $databaseName = null; + $databaseIndex = null; + if ($kind === self::KIND_DATABASE) { + $databaseName = trim((string)($input['database'] ?? $input['database_name'] ?? '')); + if ($databaseName === '' || $username === '') { + throw new RuntimeException('Database name and username are required for database replication hosts.'); + } + } elseif ($kind === self::KIND_REDIS) { + $databaseIndex = (int)($input['database'] ?? $input['database_index'] ?? 0); + if ($databaseIndex < 0) { + throw new RuntimeException('Redis database index must be zero or greater.'); + } + } else { + if ($username === '' || $password === '') { + throw new RuntimeException('Access key and secret key are required for MinIO replication hosts.'); + } + } + + $options = is_array($input['options'] ?? null) ? $input['options'] : []; + if (isset($input['deployment_provider'])) { + $provider = strtolower(trim((string)$input['deployment_provider'])); + if (!in_array($provider, ['manual', 'coolify'], true)) { + throw new RuntimeException('Deployment provider must be manual or coolify.'); + } + $options['deployment_provider'] = $provider; + } + if (isset($input['coolify_target_id'])) { + $options['coolify_target_id'] = (int)$input['coolify_target_id']; + } + if (isset($input['coolify_instance_id'])) { + $options['coolify_instance_id'] = (int)$input['coolify_instance_id']; + } + if ($kind === self::KIND_MINIO) { + $headroom = (float)($input['space_headroom_percent'] ?? $options['space_headroom_percent'] ?? self::MINIO_SPACE_HEADROOM_PERCENT); + $transferLimit = array_key_exists('replication_transfer_limit', $input) + ? self::normalizeMinioTransferLimit($input['replication_transfer_limit'], false) + : self::minioReplicationTransferLimitFromOptions($options); + $options = array_replace($options, [ + 'scheme' => $scheme ?: 'http', + 'endpoint' => self::minioEndpointFromParts($scheme ?: 'http', $host, $port), + 'buckets' => self::normalizeMinioBuckets($input['buckets'] ?? $options['buckets'] ?? self::MINIO_DEFAULT_BUCKETS), + 'console_port' => (int)($input['console_port'] ?? $options['console_port'] ?? 9001), + 'replication_transfer_limit' => $transferLimit, + 'space_headroom_percent' => max(0.0, $headroom), + ]); + } + + return [ + 'label' => $label, + 'host' => $host, + 'port' => $port, + 'database_name' => $databaseName, + 'database_index' => $databaseIndex, + 'username' => $username, + 'password_secret' => replication_secret_box::encrypt($password), + 'admin_username' => trim((string)($input['admin_username'] ?? '')), + 'admin_password_secret' => replication_secret_box::encrypt((string)($input['admin_password'] ?? '')), + 'replication_username' => trim((string)($input['replication_username'] ?? '')), + 'replication_password_secret' => replication_secret_box::encrypt((string)($input['replication_password'] ?? '')), + 'ssl_mode' => strtoupper(trim((string)($input['ssl_mode'] ?? 'DISABLED'))) ?: 'DISABLED', + 'options' => $options, + ]; + } + + private function transientHost(string $kind, array $input): array + { + $normalized = $this->normalizeHostInput($kind, $input); + $role = strtolower(trim((string)($input['role'] ?? 'replica'))); + if (!in_array($role, ['primary', 'replica'], true)) { + $role = 'replica'; + } + + return array_merge($normalized, [ + 'id' => 0, + 'kind' => $kind, + 'role' => $role, + 'status' => 'unknown', + 'replication_source_id' => null, + 'last_status_json' => null, + 'last_checked_at' => null, + 'created_at' => null, + 'updated_at' => null, + 'deleted_at' => null, + 'test_connectivity_only' => true, + ]); + } + + private function ensureEnvironmentPrimaryRows(): void + { + if ($this->primaryHost(self::KIND_DATABASE) === null && isset($GLOBALS['CONFIG_DB']) && is_array($GLOBALS['CONFIG_DB'])) { + $config = $GLOBALS['CONFIG_DB']; + if (!empty($config['host']) && !empty($config['database']) && !empty($config['user'])) { + $this->insertEnvironmentPrimary(self::KIND_DATABASE, [ + 'label' => 'Current database primary', + 'host' => (string)$config['host'], + 'port' => (int)($config['port'] ?? 3306), + 'database_name' => (string)$config['database'], + 'database_index' => null, + 'username' => (string)$config['user'], + 'password_secret' => replication_secret_box::encrypt((string)($config['password'] ?? '')), + 'ssl_mode' => (string)($config['ssl_mode'] ?? 'DISABLED'), + ]); + } + } + + if ($this->primaryHost(self::KIND_REDIS) === null && isset($GLOBALS['REDIS_CONFIG']) && is_array($GLOBALS['REDIS_CONFIG'])) { + $config = $GLOBALS['REDIS_CONFIG']; + if (!empty($config['host'])) { + $this->insertEnvironmentPrimary(self::KIND_REDIS, [ + 'label' => 'Current Redis primary', + 'host' => (string)$config['host'], + 'port' => (int)($config['port'] ?? 6379), + 'database_name' => null, + 'database_index' => (int)($config['database'] ?? 0), + 'username' => (string)($config['user'] ?? ''), + 'password_secret' => replication_secret_box::encrypt((string)($config['password'] ?? '')), + 'ssl_mode' => null, + ]); + } + } + + if ($this->primaryHost(self::KIND_MINIO) === null && isset($GLOBALS['MINIO']) && is_array($GLOBALS['MINIO'])) { + $config = $GLOBALS['MINIO']; + $endpoint = trim((string)($config['endpoint'] ?? '')); + $accessKey = trim((string)($config['access_key'] ?? '')); + if ($endpoint !== '' && $accessKey !== '') { + [$host, $port, $scheme] = self::normalizeMinioAddress($endpoint, null, null); + $buckets = self::normalizeMinioBuckets($config['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + $this->insertEnvironmentPrimary(self::KIND_MINIO, [ + 'label' => 'Current MinIO primary', + 'host' => $host, + 'port' => $port, + 'database_name' => null, + 'database_index' => null, + 'username' => $accessKey, + 'password_secret' => replication_secret_box::encrypt((string)($config['secret_key'] ?? '')), + 'ssl_mode' => null, + 'options' => [ + 'source' => 'environment', + 'scheme' => $scheme, + 'endpoint' => self::minioEndpointFromParts($scheme, $host, $port), + 'buckets' => $buckets, + 'console_port' => (int)($config['console_port'] ?? 9001), + 'space_headroom_percent' => self::MINIO_SPACE_HEADROOM_PERCENT, + ], + ]); + } + } + } + + private function insertEnvironmentPrimary(string $kind, array $host): void + { + $this->execute( + "INSERT INTO replication_hosts ( + kind, label, host, port, database_name, database_index, username, password_secret, + role, status, ssl_mode, options_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'primary', 'unknown', ?, ?)", + 'sssissssss', + [ + $kind, + $host['label'], + $host['host'], + $host['port'], + $host['database_name'], + $host['database_index'], + $host['username'], + $host['password_secret'], + $host['ssl_mode'], + self::jsonEncode(array_replace(['source' => 'environment'], is_array($host['options'] ?? null) ? $host['options'] : [])), + ] + ); + } + + private function writeBootstrapSnapshot(): void + { + $databasePrimary = $this->primaryHost(self::KIND_DATABASE); + $redisPrimary = $this->primaryHost(self::KIND_REDIS); + $minioPrimary = $this->primaryHost(self::KIND_MINIO); + $active = []; + + if ($databasePrimary !== null) { + $credentials = $this->credentials($databasePrimary); + $active['database'] = [ + 'id' => (int)$databasePrimary['id'], + 'host' => (string)$databasePrimary['host'], + 'port' => (int)$databasePrimary['port'], + 'database' => (string)$databasePrimary['database_name'], + 'user' => $credentials['username'], + 'password_secret' => $databasePrimary['password_secret'] ?? '', + 'ssl_mode' => (string)($databasePrimary['ssl_mode'] ?? 'DISABLED'), + ]; + } + + if ($redisPrimary !== null) { + $credentials = $this->credentials($redisPrimary); + $active['redis'] = [ + 'id' => (int)$redisPrimary['id'], + 'host' => (string)$redisPrimary['host'], + 'port' => (int)$redisPrimary['port'], + 'database' => (int)($redisPrimary['database_index'] ?? 0), + 'user' => $credentials['username'], + 'password_secret' => $redisPrimary['password_secret'] ?? '', + ]; + } + + if ($minioPrimary !== null) { + $credentials = $this->credentials($minioPrimary); + $options = $this->decodeOptions($minioPrimary); + $active['minio'] = [ + 'id' => (int)$minioPrimary['id'], + 'endpoint' => self::minioEndpoint($minioPrimary), + 'access_key' => $credentials['username'], + 'secret_key_secret' => $minioPrimary['password_secret'] ?? '', + 'buckets' => self::normalizeMinioBuckets($options['buckets'] ?? self::MINIO_DEFAULT_BUCKETS), + ]; + } + + replication_bootstrap_config::writeSnapshot([ + 'version' => 1, + 'generated_at' => date('c'), + 'active' => $active, + 'failover' => [ + 'config' => $this->failoverConfigForSnapshot(), + 'hosts' => $this->failoverHostsForSnapshot(), + ], + ]); + } + + private function failoverConfigForSnapshot(): array + { + $config = replica_failover_manager::configDefaults(); + + try { + foreach ($this->selectRows("SELECT variable, value FROM module_config WHERE module = 'Failover'") as $row) { + $variable = (string)($row['variable'] ?? ''); + if (!array_key_exists($variable, $config)) { + continue; + } + $config[$variable] = $row['value'] ?? ''; + } + } catch (Throwable) { + } + + return replica_failover_manager::normalizeConfig($config); + } + + private function failoverHostsForSnapshot(): array + { + return [ + self::KIND_DATABASE => array_map( + fn(array $host): array => $this->bootstrapSnapshotHost($host), + $this->listHosts(self::KIND_DATABASE) + ), + self::KIND_REDIS => array_map( + fn(array $host): array => $this->bootstrapSnapshotHost($host), + $this->listHosts(self::KIND_REDIS) + ), + self::KIND_MINIO => array_map( + fn(array $host): array => $this->bootstrapSnapshotHost($host), + $this->listHosts(self::KIND_MINIO) + ), + ]; + } + + private function bootstrapSnapshotHost(array $host): array + { + return [ + 'id' => (int)$host['id'], + 'kind' => (string)$host['kind'], + 'label' => (string)$host['label'], + 'host' => (string)$host['host'], + 'port' => (int)$host['port'], + 'database_name' => $host['database_name'] ?? null, + 'database_index' => isset($host['database_index']) ? (int)$host['database_index'] : null, + 'username' => (string)($host['username'] ?? ''), + 'password_secret' => (string)($host['password_secret'] ?? ''), + 'admin_username' => (string)($host['admin_username'] ?? ''), + 'admin_password_secret' => (string)($host['admin_password_secret'] ?? ''), + 'replication_username' => (string)($host['replication_username'] ?? ''), + 'replication_password_secret' => (string)($host['replication_password_secret'] ?? ''), + 'role' => (string)$host['role'], + 'status' => (string)$host['status'], + 'replication_source_id' => isset($host['replication_source_id']) ? (int)$host['replication_source_id'] : null, + 'ssl_mode' => $host['ssl_mode'] ?? null, + 'options_json' => $host['options_json'] ?? null, + 'last_status_json' => $host['last_status_json'] ?? null, + 'last_checked_at' => $host['last_checked_at'] ?? null, + 'updated_at' => $host['updated_at'] ?? null, + 'deleted_at' => $host['deleted_at'] ?? null, + ]; + } + + private function switchPrimary(string $kind, int $newPrimaryId, int $oldPrimaryId): void + { + $this->execute( + "UPDATE replication_hosts SET role = 'inactive', status = 'inactive' WHERE kind = ? AND role = 'primary' AND id <> ?", + 'si', + [$kind, $newPrimaryId] + ); + $this->execute( + "UPDATE replication_hosts SET role = 'primary', status = 'ok', replication_source_id = NULL WHERE kind = ? AND id = ?", + 'si', + [$kind, $newPrimaryId] + ); + $this->execute( + "UPDATE replication_hosts SET replication_source_id = ? WHERE kind = ? AND role = 'replica'", + 'is', + [$newPrimaryId, $kind] + ); + } + + private function credentials(array $host): array + { + return [ + 'username' => (string)($host['username'] ?? ''), + 'password' => replication_secret_box::decrypt($host['password_secret'] ?? ''), + 'admin_username' => (string)($host['admin_username'] ?? ''), + 'admin_password' => replication_secret_box::decrypt($host['admin_password_secret'] ?? ''), + 'replication_username' => (string)($host['replication_username'] ?? ''), + 'replication_password' => replication_secret_box::decrypt($host['replication_password_secret'] ?? ''), + ]; + } + + private function decodeOptions(array $host): array + { + if (isset($host['options']) && is_array($host['options'])) { + return $host['options']; + } + + return self::jsonDecode($host['options_json'] ?? null); + } + + private function listHosts(?string $kind = null, bool $includeDeleted = false): array + { + $where = []; + $types = ''; + $params = []; + if ($kind !== null) { + $where[] = 'kind = ?'; + $types .= 's'; + $params[] = $kind; + } + if (!$includeDeleted) { + $where[] = 'deleted_at IS NULL'; + } + + $sql = 'SELECT * FROM replication_hosts'; + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= " ORDER BY FIELD(role, 'primary', 'replica', 'inactive'), id"; + + return $this->selectRows($sql, $types, $params); + } + + private function primaryHost(string $kind): ?array + { + return $this->selectOne( + "SELECT * FROM replication_hosts WHERE kind = ? AND role = 'primary' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1", + 's', + [$kind] + ); + } + + private function getHost(string $kind, int $id, bool $includeDeleted = false): array + { + $sql = 'SELECT * FROM replication_hosts WHERE kind = ? AND id = ?'; + if (!$includeDeleted) { + $sql .= ' AND deleted_at IS NULL'; + } + $host = $this->selectOne($sql . ' LIMIT 1', 'si', [$kind, $id]); + if ($host === null) { + throw new RuntimeException('Replication host was not found.'); + } + return $host; + } + + private function startOperation(string $kind, int $hostId, string $operation, ?int $actorUserId): int + { + $this->execute( + "INSERT INTO replication_operations (kind, host_id, operation, status, actor_user_id) + VALUES (?, ?, ?, 'running', ?)", + 'sisi', + [$kind, $hostId, $operation, $actorUserId] + ); + return $this->insertId(); + } + + private function activeOperationId(string $kind, int $hostId, string $operation): ?int + { + $operationRow = $this->selectOne( + "SELECT id FROM replication_operations + WHERE kind = ? AND host_id = ? AND operation = ? AND status = 'running' + ORDER BY id DESC + LIMIT 1", + 'sis', + [$kind, $hostId, $operation] + ); + + return $operationRow !== null ? (int)$operationRow['id'] : null; + } + + private function activeOperation(string $kind, int $hostId): ?array + { + $operation = $this->selectOne( + "SELECT id, operation, status, progress_percent, message, error_message, started_at, updated_at + FROM replication_operations + WHERE kind = ? AND host_id = ? AND status = 'running' + ORDER BY id DESC + LIMIT 1", + 'si', + [$kind, $hostId] + ); + + if ($operation === null) { + return null; + } + + return [ + 'id' => (int)$operation['id'], + 'operation' => (string)$operation['operation'], + 'status' => (string)$operation['status'], + 'progress_percent' => round((float)$operation['progress_percent'], 2), + 'message' => $operation['message'] ?? null, + 'error_message' => $operation['error_message'] ?? null, + 'started_at' => $operation['started_at'] ?? null, + 'updated_at' => $operation['updated_at'] ?? null, + ]; + } + + private function operationContext(int $operationId): array + { + $operation = $this->selectOne( + 'SELECT context_json FROM replication_operations WHERE id = ? LIMIT 1', + 'i', + [$operationId] + ); + + return self::jsonDecode($operation['context_json'] ?? null); + } + + private function updateOperationProgress(int $operationId, float $progress, string $message, array $context): void + { + $this->execute( + "UPDATE replication_operations + SET progress_percent = ?, message = ?, context_json = ? + WHERE id = ?", + 'dssi', + [max(0, min(100, $progress)), $message, self::jsonEncode($context), $operationId] + ); + } + + private function finishOperation(int $operationId, string $status, float $progress, ?string $message, array $errors): void + { + $this->execute( + "UPDATE replication_operations + SET status = ?, progress_percent = ?, message = ?, error_message = ?, completed_at = NOW() + WHERE id = ?", + 'sdssi', + [$status, $progress, $message, implode("\n", $errors), $operationId] + ); + } + + private function audit(string $kind, ?int $hostId, string $action, ?int $actorUserId, string $severity, array $context): void + { + $this->execute( + "INSERT INTO replication_audit_logs (kind, host_id, action, actor_user_id, severity, context_json) + VALUES (?, ?, ?, ?, ?, ?)", + 'sisiss', + [$kind, $hostId, $action, $actorUserId, $severity, self::jsonEncode($context)] + ); + } + + private function acquirePromotionLock() + { + $path = (defined('WD') ? WD : dirname(__DIR__)) . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'replication-promotion.lock'; + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) { + throw new RuntimeException('Could not create promotion lock directory.'); + } + $handle = fopen($path, 'c'); + if ($handle === false || !flock($handle, LOCK_EX | LOCK_NB)) { + throw new RuntimeException('Another replication promotion is already running.'); + } + return $handle; + } + + private function releasePromotionLock($handle): void + { + if (is_resource($handle)) { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + private function selectOne(string $sql, string $types = '', array $params = []): ?array + { + $rows = $this->selectRows($sql, $types, $params); + return $rows[0] ?? null; + } + + private function selectRows(string $sql, string $types = '', array $params = []): array + { + global $db; + if ($types === '') { + $result = $db->query($sql); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } + + $stmt = $db->prepare($sql); + if ($stmt === false) { + throw new RuntimeException('Could not prepare replication query.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $result = $stmt->get_result(); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } + + private function execute(string $sql, string $types = '', array $params = []): void + { + global $db; + if ($types === '') { + $db->query($sql); + return; + } + + $stmt = $db->prepare($sql); + if ($stmt === false) { + throw new RuntimeException('Could not prepare replication statement.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + } + + private function insertId(): int + { + global $db; + return (int)$db->insert_id(); + } + + private static function jsonEncode(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Could not encode replication JSON payload.'); + } + return $json; + } + + private static function jsonDecode(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/services/nginx/app/classes/replication_schema_bootstrap.php b/services/nginx/app/classes/replication_schema_bootstrap.php new file mode 100644 index 00000000..4f596d06 --- /dev/null +++ b/services/nginx/app/classes/replication_schema_bootstrap.php @@ -0,0 +1,122 @@ +query($sql); + } + + self::ensureColumn('replication_operations', 'progress_percent', "DECIMAL(5,2) NOT NULL DEFAULT 0.00"); + self::ensureColumn('replication_operations', 'message', 'VARCHAR(512) NULL'); + self::ensureColumn('replication_operations', 'context_json', 'LONGTEXT NULL'); + + self::$initialized = true; + } + + private static function ensureColumn(string $table, string $column, string $definition): void + { + global $db; + + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + $column = preg_replace('/[^a-zA-Z0-9_]/', '', $column); + if ($table === '' || $column === '') { + return; + } + + $result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'"); + if ($result !== false && $result->num_rows > 0) { + return; + } + + $db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition"); + } +} diff --git a/services/nginx/app/classes/replication_secret_box.php b/services/nginx/app/classes/replication_secret_box.php new file mode 100644 index 00000000..87386a63 --- /dev/null +++ b/services/nginx/app/classes/replication_secret_box.php @@ -0,0 +1,102 @@ + base64_encode($nonce), + 'tag' => base64_encode($tag), + 'ciphertext' => base64_encode($ciphertext), + ], JSON_UNESCAPED_SLASHES)); + } + + public static function decrypt(?string $secret): string + { + $secret = (string)$secret; + if ($secret === '') { + return ''; + } + + if (!str_starts_with($secret, self::PREFIX)) { + return $secret; + } + + $payload = json_decode(base64_decode(substr($secret, strlen(self::PREFIX)), true) ?: '', true); + if (!is_array($payload)) { + throw new RuntimeException('Encrypted secret payload is invalid.'); + } + + $nonce = base64_decode((string)($payload['nonce'] ?? ''), true); + $tag = base64_decode((string)($payload['tag'] ?? ''), true); + $ciphertext = base64_decode((string)($payload['ciphertext'] ?? ''), true); + + if ($nonce === false || $tag === false || $ciphertext === false) { + throw new RuntimeException('Encrypted secret payload is incomplete.'); + } + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + self::key(), + OPENSSL_RAW_DATA, + $nonce, + $tag + ); + + if ($plaintext === false) { + throw new RuntimeException('Secret decryption failed.'); + } + + return $plaintext; + } + + public static function mask(?string $value): string + { + $value = (string)$value; + if ($value === '') { + return ''; + } + + $length = strlen($value); + if ($length <= 4) { + return str_repeat('*', $length); + } + + return substr($value, 0, 2) . str_repeat('*', max(4, $length - 4)) . substr($value, -2); + } + + private static function key(): string + { + $keyMaterial = (string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''); + if (trim($keyMaterial) === '') { + throw new RuntimeException('ENCRYPTION_KEY is required for replication secret encryption.'); + } + + return hash('sha256', $keyMaterial, true); + } +} diff --git a/services/nginx/app/classes/response.php b/services/nginx/app/classes/response.php index f6bb61db..7262ebb7 100644 --- a/services/nginx/app/classes/response.php +++ b/services/nginx/app/classes/response.php @@ -14,6 +14,7 @@ class response implements response_i private array $meta = []; private array $includes = []; private users_o $users_o; + private ?array $jsonRequestBody = null; #[NoReturn] public function success(mixed $data, int $status = null): void { @@ -47,6 +48,11 @@ class response implements response_i 'data' => $this->get_data() ]); } + try { + release_manager::recordBackendFailure($success, $data, $status ?? ($success ? 200 : 400)); + } catch (\Throwable) { + // Release failure telemetry is best-effort and must not block responses. + } echo json_encode([ 'success' => $success, 'data' => $data, @@ -56,6 +62,24 @@ class response implements response_i exit; } + #[NoReturn] public function rawJson(mixed $data, int $status = 200): void + { + header('Content-Type: application/json; charset=utf-8'); + http_response_code($status); + + if (!is_array($data) && !is_object($data)) { + if (is_string($data) && json_decode($data) !== null) { + echo $data; + exit; + } + + $data = ['message' => $data]; + } + + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + public function add_include(string $string, array $dataArray): void { $this->add_included($string, $dataArray); @@ -71,6 +95,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); @@ -101,6 +135,14 @@ class response implements response_i $this->error('Internal server error' . ($error ? ': ' . $error : ''), 500); } + #[NoReturn] public function forbidden(array $permissions): void + { + $this->error([ + 'message' => 'Missing permission(s)', + 'permissions' => $permissions + ], 403); + } + public function paginate(int $page, int $per_page, int $total, string $search = null, array $filters = null, array $order = null): void { // If the total is 0, return 1 page, 0 total @@ -139,35 +181,7 @@ class response implements response_i public function getRequestParameter(string $key): mixed { - $data = []; - // Get the request data if the method is POST, PUT or PATCH - if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') { - $data = json_decode(file_get_contents('php://input'), true); - } - // Get the request data if the method is GET, DELETE or OPTIONS - if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') { - $data = $_GET; - } - - if (!is_array($data)) { - $data = []; - } - - // If the data key is not set, try to get it from the opposite method - if (!array_key_exists($key, $data)) { - if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') { - $data = $_GET; - } else { - $data = json_decode(file_get_contents('php://input'), true); - } - } - - if (!is_array($data)) { - $data = []; - } - - // Return the data - return $data[$key] ?? null; + return $this->requestParametersForMethod()[$key] ?? null; } /** @@ -176,21 +190,7 @@ class response implements response_i */ public function getAllRequestParameters(): array { - $data = []; - // Get the request data if the method is POST, PUT or PATCH - if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') { - $data = json_decode(file_get_contents('php://input'), true); - } - // Get the request data if the method is GET, DELETE or OPTIONS - if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') { - $data = $_GET; - } - - if (!is_array($data)) { - return []; - } - - return $data; + return $this->requestParametersForMethod(); } /** @@ -201,21 +201,44 @@ class response implements response_i */ public function isRequestParameterSet(string $key): bool { - $data = []; - // Get the request data if the method is POST, PUT or PATCH - if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') { - $data = json_decode(file_get_contents('php://input'), true); - } - // Get the request data if the method is GET, DELETE or OPTIONS - if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') { - $data = $_GET; + return array_key_exists($key, $this->requestParametersForMethod()); + } + + private function requestParametersForMethod(): array + { + $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); + + if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) { + return array_replace($_GET, $this->jsonRequestBody()); } - if (!is_array($data)) { - return false; + if ($method === 'DELETE') { + return array_replace($_GET, $this->jsonRequestBody()); } - return array_key_exists($key, $data); + if ($method === 'GET' || $method === 'OPTIONS') { + return $_GET; + } + + return $this->jsonRequestBody(); + } + + private function jsonRequestBody(): array + { + if ($this->jsonRequestBody !== null) { + return $this->jsonRequestBody; + } + + $decoded = json_decode($this->rawRequestBody(), true); + $this->jsonRequestBody = is_array($decoded) ? $decoded : []; + + return $this->jsonRequestBody; + } + + protected function rawRequestBody(): string + { + $body = file_get_contents('php://input'); + return is_string($body) ? $body : ''; } public function parseFilters(?string $filters): array|null @@ -278,4 +301,4 @@ class response implements response_i // Return the user object return $this->users_o; } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/router.php b/services/nginx/app/classes/router.php index 74a12524..b9f6e9d3 100644 --- a/services/nginx/app/classes/router.php +++ b/services/nginx/app/classes/router.php @@ -44,7 +44,7 @@ class router // Try to run the routes, if there is an error, catch it and send an internal server error response try { $this->run(); - } catch (\Exception $e) { + } catch (\Throwable $e) { $response->internal_server_error($e->getMessage()); } } @@ -125,4 +125,4 @@ class router { return $this->routes; } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/selfserve_schema_bootstrap.php b/services/nginx/app/classes/selfserve_schema_bootstrap.php new file mode 100644 index 00000000..d67b4f41 --- /dev/null +++ b/services/nginx/app/classes/selfserve_schema_bootstrap.php @@ -0,0 +1,242 @@ +query($sql); + } + + self::ensureColumn( + 'department_lanes', + 'machine_type_id', + 'ALTER TABLE department_lanes ADD COLUMN machine_type_id INT NULL AFTER dynamic_image_id' + ); + self::ensureColumn( + 'department_lanes', + 'selfserve_enabled', + 'ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id' + ); + self::ensureColumn( + 'department_selfserve_conditions', + 'machine_type_id', + 'ALTER TABLE department_selfserve_conditions ADD COLUMN machine_type_id INT NULL AFTER product' + ); + self::ensureColumn( + 'department_selfserve_tasks', + 'machine_type_id', + 'ALTER TABLE department_selfserve_tasks ADD COLUMN machine_type_id INT NULL AFTER product' + ); + self::ensureColumn( + 'department_selfserve_tasks', + 'gate_type', + "ALTER TABLE department_selfserve_tasks ADD COLUMN gate_type VARCHAR(16) NULL DEFAULT 'ALWAYS' AFTER condition_id" + ); + self::ensureColumn( + 'department_selfserve_tasks', + 'gate_ref_id', + 'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type' + ); + self::ensureColumn( + 'selfserve_wash_session_tasks', + 'dynamic_images_vehicle_type', + 'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons' + ); + self::ensureColumn( + 'selfserve_wash_sessions', + 'wash_started_at', + 'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at' + ); + + self::$initialized = true; + } + + public static function tableHasColumn(string $table, string $column): bool + { + global $db; + $table = $db->escape_string($table); + $column = $db->escape_string($column); + $database = $db->escape_string($db->getDatabase()); + + $sql = "SELECT COUNT(*) AS c + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = '$database' + AND TABLE_NAME = '$table' + AND COLUMN_NAME = '$column'"; + $result = $db->query($sql); + if (!$result) { + return false; + } + $row = $result->fetch_assoc(); + return ((int)($row['c'] ?? 0)) > 0; + } + + public static function ensureColumn(string $table, string $column, string $alterSql): void + { + global $db; + if (self::tableHasColumn($table, $column)) { + return; + } + $db->query($alterSql); + } +} diff --git a/services/nginx/app/classes/shelly.php b/services/nginx/app/classes/shelly.php index 5a39a9ab..9967d9e6 100644 --- a/services/nginx/app/classes/shelly.php +++ b/services/nginx/app/classes/shelly.php @@ -13,6 +13,14 @@ use shelly\shelly_c; class shelly implements shelly_i { + private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20; + private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000; + private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate'; + /** + * @var array> + */ + private static array $blocked_request_log = []; + /** * Configuration of the shelly module * @var shelly_c @@ -34,6 +42,19 @@ class shelly implements shelly_i $this->shelly_search = new shelly_search_a(); } + public static function resetBlockedRequestLog(): void + { + self::$blocked_request_log = []; + } + + /** + * @return array> + */ + public static function blockedRequestLog(): array + { + return self::$blocked_request_log; + } + /** * @inheritDoc * @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid @@ -50,7 +71,7 @@ class shelly implements shelly_i self::requireValidSecretKey(); // Send the request $response = match ($method) { - //'GET' => self::sendGetRequest($endpoint, $data), + 'GET' => self::sendGetRequest($endpoint, $data), 'POST' => self::sendPostRequest($endpoint, $data), //'PUT' => self::sendPutRequest($endpoint, $data), //'DELETE' => self::sendDeleteRequest($endpoint, $data), @@ -142,10 +163,12 @@ class shelly implements shelly_i * -H 'Content-Type: application/json' \ * -d '' */ + $this->guardRealShellyRequest('POST', $endpoint, $data); // Require the module to be enabled self::requireModuleEnabled(); self::requireValidSecretKey(); self::requireValidServerURL(); + $this->waitForShellyRateLimitWindow(); // Send the request $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint)); @@ -181,8 +204,172 @@ class shelly implements shelly_i return json_decode($response); } - function appendAuthKeyToQuery(string $url): string + /** + * @inheritDoc + * @throws Exception + */ + function sendGetRequest(string $endpoint, array $data): array|object|null { - return $url . '?auth_key=' . $this->config->secret_key->getVariableValue(); + $this->guardRealShellyRequest('GET', $endpoint, $data); + self::requireModuleEnabled(); + self::requireValidSecretKey(); + self::requireValidServerURL(); + $this->waitForShellyRateLimitWindow(); + + $ch = curl_init(); + curl_setopt( + $ch, + CURLOPT_URL, + self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint, $data) + ); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPGET, true); + + $response = curl_exec($ch); + $status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) { + self::exception( + [ + 'method' => 'GET', + 'endpoint' => $endpoint, + 'data' => $data, + 'response' => $response, + ], + $status_code + ); + } + + curl_close($ch); + + if ($response === false) { + return null; + } + + return json_decode($response); } -} \ No newline at end of file + + function appendAuthKeyToQuery(string $url, array $query = []): string + { + $query['auth_key'] = $this->config->secret_key->getVariableValue(); + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url . $separator . http_build_query($query); + } + + /** + * @param array $data + * @throws Exception + */ + private function guardRealShellyRequest(string $method, string $endpoint, array $data): void + { + if (!$this->shouldBlockRealShellyRequest()) { + return; + } + + $record = [ + 'method' => strtoupper($method), + 'endpoint' => $endpoint, + 'data' => $data, + 'at' => date('c'), + ]; + self::$blocked_request_log[] = $record; + + $log_path = trim((string)(getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG') ?: '')); + if ($log_path !== '') { + $encoded = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (is_string($encoded)) { + @file_put_contents($log_path, $encoded . PHP_EOL, FILE_APPEND | LOCK_EX); + } + } + + throw new Exception('Real Shelly requests are blocked in test mode: ' . strtoupper($method) . ' ' . $endpoint); + } + + private function shouldBlockRealShellyRequest(): bool + { + return trim((string)(getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY') ?: '')) === '1'; + } + + /** + * @throws Exception + */ + private function waitForShellyRateLimitWindow(): void + { + $deadline = $this->nowTimestamp() + self::SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS; + + do { + if ($this->tryAcquireShellyRateLimitSlot()) { + return; + } + + if ($this->nowTimestamp() >= $deadline) { + throw new Exception('Shelly rate limit gate wait timed out'); + } + + $remaining_ms = $this->getShellyRateLimitSlotRemainingMs(); + if ($remaining_ms <= 0) { + $remaining_ms = 50; + } + $this->sleepMicroseconds(min($remaining_ms, 250) * 1000); + } while (true); + } + + private function tryAcquireShellyRateLimitSlot(): bool + { + $redis = $this->redisFacade(); + if ($redis === null) { + return true; + } + + try { + $result = $redis->get_client()->set( + self::SHELLY_RATE_LIMIT_GATE_KEY, + (string)$this->nowTimestamp(), + 'PX', + self::SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS, + 'NX' + ); + return $result === true || strtoupper((string)$result) === 'OK'; + } catch (\Throwable) { + // If Redis gate can't be evaluated, fail open to avoid blocking API traffic completely. + return true; + } + } + + private function getShellyRateLimitSlotRemainingMs(): int + { + $redis = $this->redisFacade(); + if ($redis === null) { + return 0; + } + + try { + $ttl = $redis->get_client()->pttl(self::SHELLY_RATE_LIMIT_GATE_KEY); + if (!is_numeric($ttl)) { + return 0; + } + $ttl = (int)$ttl; + return $ttl > 0 ? $ttl : 0; + } catch (\Throwable) { + return 0; + } + } + + protected function redisFacade(): mixed + { + return defined('redis') ? redis : null; + } + + protected function nowTimestamp(): float + { + return microtime(true); + } + + protected function sleepMicroseconds(int $microseconds): void + { + if ($microseconds > 0) { + usleep($microseconds); + } + } +} diff --git a/services/nginx/app/classes/shelly_relay_inventory.php b/services/nginx/app/classes/shelly_relay_inventory.php new file mode 100644 index 00000000..7970cc99 --- /dev/null +++ b/services/nginx/app/classes/shelly_relay_inventory.php @@ -0,0 +1,619 @@ +client = $client; + } + + public function setInventoryFetcher(callable $fetcher): self + { + $this->inventory_fetcher = $fetcher; + return $this; + } + + public function setDeviceListFetcher(callable $fetcher): self + { + $this->device_list_fetcher = $fetcher; + return $this; + } + + /** + * @return array> + * @throws Exception + */ + public function listRelayOptions(): array + { + $devices_status = $this->fetchOwnedDevicesStatus(); + $device_catalog = $this->fetchOwnedDeviceCatalog(); + $options_by_id = []; + + foreach ($devices_status as $device) { + $normalized_device = $this->normalizeToArray($device); + $device_id = $this->extractFirstString([ + $normalized_device['_dev_info']['id'] ?? null, + $normalized_device['id'] ?? null, + ]); + $catalog_entry = $device_id !== '' ? ($device_catalog[$device_id] ?? null) : null; + + $option = $this->buildRelayOption( + $normalized_device, + is_array($catalog_entry) ? $catalog_entry : null + ); + if ($option === null) { + continue; + } + + $options_by_id[$option['id']] = $option; + } + + $options = array_values($options_by_id); + usort($options, static function (array $left, array $right): int { + $status_compare = self::statusSortWeight((string)($left['status_color'] ?? '')) + <=> self::statusSortWeight((string)($right['status_color'] ?? '')); + if ($status_compare !== 0) { + return $status_compare; + } + + $name_compare = strcasecmp((string)($left['name'] ?? ''), (string)($right['name'] ?? '')); + if ($name_compare !== 0) { + return $name_compare; + } + + return strcmp((string)($left['id'] ?? ''), (string)($right['id'] ?? '')); + }); + + return $options; + } + + /** + * @return array> + */ + private function fetchOwnedDeviceCatalog(): array + { + try { + $payload = is_callable($this->device_list_fetcher) + ? ($this->device_list_fetcher)() + : $this->getClient()->sendGetRequest('/interface/device/list', [ + 'no_shared' => 'true', + ]); + } catch (\Throwable) { + return []; + } + + $normalized = $this->normalizeToArray($payload); + if (($normalized['isok'] ?? true) === false) { + return []; + } + + $devices = $normalized['data']['devices'] ?? null; + if (!is_array($devices)) { + return []; + } + + $catalog_by_id = []; + foreach ($devices as $device_key => $device) { + $normalized_device = $this->normalizeToArray($device); + $device_id = $this->extractFirstString([ + $normalized_device['id'] ?? null, + is_string($device_key) ? $device_key : null, + ]); + if ($device_id === '') { + continue; + } + + $catalog_by_id[$device_id] = $normalized_device; + } + + return $catalog_by_id; + } + + /** + * @return array + * @throws Exception + */ + private function fetchOwnedDevicesStatus(): array + { + $payload = is_callable($this->inventory_fetcher) + ? ($this->inventory_fetcher)() + : $this->getClient()->sendGetRequest('/device/all_status', [ + 'show_info' => 'true', + 'no_shared' => 'true', + ]); + + $normalized = $this->normalizeToArray($payload); + $is_ok = $normalized['isok'] ?? null; + if ($is_ok === false) { + throw new Exception('Shelly relay inventory request failed'); + } + + $devices_status = $normalized['data']['devices_status'] ?? null; + if (!is_array($devices_status)) { + throw new Exception('Shelly relay inventory response was missing devices_status'); + } + + return $devices_status; + } + + private function getClient(): shelly + { + if ($this->client instanceof shelly) { + return $this->client; + } + + $this->client = new shelly(); + return $this->client; + } + + /** + * @param array $device + * @return array|null + */ + private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array + { + if ($device === [] || !$this->isRelayCapableDevice($device)) { + return null; + } + + $device_id = $this->extractFirstString([ + $device['_dev_info']['id'] ?? null, + $device['id'] ?? null, + ]); + if ($device_id === '') { + return null; + } + + $cloud_name = $this->extractFirstString([ + $catalog_entry['name'] ?? null, + ]); + $local_device_name = $this->extractFirstString([ + $device['name'] ?? null, + $device['_dev_info']['name'] ?? null, + $device['settings']['name'] ?? null, + $device['settings']['device']['name'] ?? null, + $device['status']['name'] ?? null, + $device['status']['sys']['device']['name'] ?? null, + ]); + $device_name = $cloud_name !== '' ? $cloud_name : $local_device_name; + $device_code = $this->extractFirstString([ + $device['_dev_info']['code'] ?? null, + $device['code'] ?? null, + ]); + $device_model = $this->extractDeviceModel($device, $device_code, $catalog_entry); + if ($device_code === '' && $device_model !== null) { + $device_code = $device_model; + } + $device_type = $this->extractDeviceType($device, $device_code, $catalog_entry); + $device_generation = $this->extractDeviceGeneration($device, $device_code, $device_type, $catalog_entry); + $control_type = $this->extractControlType($device); + $control_name = $this->extractControlName($device); + $online = $this->extractOnlineState($device); + if ($online === null) { + $online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null); + } + $status_color = $this->extractStatusColor($online); + $local_ip = $this->extractLocalIp($device, $catalog_entry); + + return [ + 'id' => $device_id, + 'name' => $this->buildRelayLabel( + $device_type, + $cloud_name, + $local_device_name, + $control_name, + $device_id + ), + 'device_id' => $device_id, + 'device_name' => $device_name !== '' ? $device_name : null, + 'cloud_name' => $cloud_name !== '' ? $cloud_name : null, + 'device_type' => $device_type, + 'code' => $device_code !== '' ? $device_code : null, + 'device_model' => $device_model, + 'device_generation' => $device_generation, + 'control_type' => $control_type, + 'control_name' => $control_name !== '' ? $control_name : null, + 'local_ip' => $local_ip, + 'status_color' => $status_color, + 'online' => $online, + ]; + } + + /** + * @param array $device + */ + private function isRelayCapableDevice(array $device): bool + { + if ($this->payloadContainsRelayState($device)) { + return true; + } + + if ($this->payloadContainsRelayState($device['status'] ?? null)) { + return true; + } + + return $this->payloadContainsRelayState($device['settings'] ?? null); + } + + private function payloadContainsRelayState(array|object|null $payload): bool + { + $normalized = $this->normalizeToArray($payload); + if ($normalized === []) { + return false; + } + + foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) { + if (array_key_exists($switch_key, $normalized)) { + return true; + } + } + + foreach (['relays', 'switches'] as $collection_key) { + if (isset($normalized[$collection_key]) && is_array($normalized[$collection_key]) && $normalized[$collection_key] !== []) { + return true; + } + } + + return false; + } + + /** + * @param array $device + */ + private function extractOnlineState(array $device): ?bool + { + $online = $device['_dev_info']['online'] ?? $device['online'] ?? null; + if (is_bool($online)) { + return $online; + } + + if (is_numeric($online)) { + return (int)$online === 1; + } + + return null; + } + + private function extractStatusColor(?bool $online): string + { + if ($online === true) { + return 'Green'; + } + + if ($online === false) { + return 'Red'; + } + + return 'Yellow'; + } + + /** + * @param array $device + */ + private function extractDeviceType(array $device, string $device_code, ?array $catalog_entry = null): ?string + { + $candidates = [ + $device['_dev_info']['type'] ?? null, + $device['type'] ?? null, + $device['settings']['device']['type'] ?? null, + $device['_dev_info']['model'] ?? null, + $device['model'] ?? null, + $device['_dev_info']['app'] ?? null, + $device['app'] ?? null, + $catalog_entry['type'] ?? null, + $device_code, + ]; + + foreach ($candidates as $candidate) { + $normalized = trim((string)($candidate ?? '')); + if ($normalized !== '' && !$this->looksLikeShellyModelCode($normalized)) { + return $normalized; + } + } + + $device_type = $this->extractFirstString($candidates); + + return $device_type !== '' ? $device_type : null; + } + + /** + * @param array $device + */ + private function extractDeviceModel(array $device, string $device_code, ?array $catalog_entry = null): ?string + { + $candidates = [ + $device_code, + $device['_dev_info']['model'] ?? null, + $device['model'] ?? null, + $catalog_entry['type'] ?? null, + $catalog_entry['model'] ?? null, + $device['_dev_info']['type'] ?? null, + $device['type'] ?? null, + ]; + + foreach ($candidates as $candidate) { + $normalized = trim((string)($candidate ?? '')); + if ($normalized !== '' && $this->looksLikeShellyModelCode($normalized)) { + return $normalized; + } + } + + return null; + } + + /** + * @param array $device + */ + private function extractDeviceGeneration( + array $device, + string $device_code, + ?string $device_type, + ?array $catalog_entry = null + ): ?int { + foreach ([ + $device['_dev_info']['gen'] ?? null, + $device['_dev_info']['generation'] ?? null, + $device['gen'] ?? null, + $device['generation'] ?? null, + $device['settings']['device']['gen'] ?? null, + $device['settings']['device']['generation'] ?? null, + $device['status']['sys']['gen'] ?? null, + $device['status']['sys']['generation'] ?? null, + $catalog_entry['gen'] ?? null, + $catalog_entry['generation'] ?? null, + ] as $candidate) { + $generation = $this->normalizeDeviceGeneration($candidate); + if ($generation !== null) { + return $generation; + } + } + + foreach ([ + $device_code, + $device_type, + $device['_dev_info']['model'] ?? null, + $device['_dev_info']['type'] ?? null, + $device['_dev_info']['app'] ?? null, + $device['model'] ?? null, + $device['type'] ?? null, + $device['app'] ?? null, + $catalog_entry['type'] ?? null, + $catalog_entry['model'] ?? null, + ] as $candidate) { + $generation = $this->inferDeviceGenerationFromString((string)($candidate ?? '')); + if ($generation !== null) { + return $generation; + } + } + + return null; + } + + private function normalizeDeviceGeneration(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (is_numeric($value)) { + $generation = (int)$value; + return $generation > 0 ? $generation : null; + } + + return $this->inferDeviceGenerationFromString((string)($value ?? '')); + } + + private function inferDeviceGenerationFromString(string $value): ?int + { + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $matches) === 1) { + return (int)$matches[1]; + } + + $upper = strtoupper($normalized); + if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $matches) === 1) { + return (int)$matches[1]; + } + + if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 + || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) { + return 2; + } + + if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) { + return 1; + } + + return null; + } + + private function looksLikeShellyModelCode(string $value): bool + { + $normalized = strtoupper(trim($value)); + if ($normalized === '') { + return false; + } + + return preg_match('/^(?:S[1-9]|SP|SN|SH)[A-Z0-9]+-[A-Z0-9-]+$/', $normalized) === 1; + } + + /** + * @param array $device + */ + private function extractControlType(array $device): string + { + $status = $this->normalizeToArray($device['status'] ?? null); + $settings = $this->normalizeToArray($device['settings'] ?? null); + + foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) { + if (array_key_exists($switch_key, $device) || array_key_exists($switch_key, $status)) { + return 'Switch'; + } + } + + if ((isset($device['switches']) && is_array($device['switches']) && $device['switches'] !== []) + || (isset($settings['switches']) && is_array($settings['switches']) && $settings['switches'] !== [])) { + return 'Switch'; + } + + if ((isset($device['relays']) && is_array($device['relays']) && $device['relays'] !== []) + || (isset($settings['relays']) && is_array($settings['relays']) && $settings['relays'] !== [])) { + return 'Relay'; + } + + return 'Device'; + } + + /** + * @param array $device + */ + private function extractControlName(array $device): string + { + $status = $this->normalizeToArray($device['status'] ?? null); + $settings = $this->normalizeToArray($device['settings'] ?? null); + + return $this->extractFirstString([ + $status['switch:0']['name'] ?? null, + $status['switch_0']['name'] ?? null, + $status['switch0']['name'] ?? null, + $device['switches'][0]['name'] ?? null, + $settings['switches'][0]['name'] ?? null, + $device['relays'][0]['name'] ?? null, + $settings['relays'][0]['name'] ?? null, + ]); + } + + /** + * @param array $device + * @param array|null $catalog_entry + */ + private function extractLocalIp(array $device, ?array $catalog_entry = null): ?string + { + $candidates = [ + $device['local_ip'] ?? null, + $device['localIp'] ?? null, + $device['ip'] ?? null, + $device['_dev_info']['local_ip'] ?? null, + $device['_dev_info']['localIp'] ?? null, + $device['_dev_info']['ip'] ?? null, + $device['wifi_sta']['ip'] ?? null, + $device['wifi']['ip'] ?? null, + $device['eth']['ip'] ?? null, + $device['status']['wifi_sta']['ip'] ?? null, + $device['status']['wifi']['ip'] ?? null, + $device['status']['eth']['ip'] ?? null, + $device['status']['sta_ip'] ?? null, + $device['settings']['wifi_sta']['ip'] ?? null, + $device['settings']['wifi']['ip'] ?? null, + $device['settings']['eth']['ip'] ?? null, + $catalog_entry['local_ip'] ?? null, + $catalog_entry['localIp'] ?? null, + $catalog_entry['ip'] ?? null, + ]; + + foreach ($candidates as $candidate) { + $normalized = trim((string)($candidate ?? '')); + if ($normalized !== '' && filter_var($normalized, FILTER_VALIDATE_IP) !== false) { + return $normalized; + } + } + + return null; + } + + private function buildRelayLabel( + ?string $device_type, + string $cloud_name, + string $local_device_name, + string $control_name, + string $device_id + ): string { + if ($cloud_name !== '') { + $display_name = $cloud_name; + } elseif ($local_device_name !== '' && $control_name !== '' && strcasecmp($local_device_name, $control_name) !== 0) { + $display_name = $local_device_name . ' / ' . $control_name; + } else { + $display_name = $control_name !== '' ? $control_name : ($local_device_name !== '' ? $local_device_name : $device_id); + } + + if ($device_type !== null && $device_type !== '') { + return $display_name . ' (' . $device_type . ')'; + } + + return $display_name; + } + + /** + * @param array $values + */ + private function extractFirstString(array $values): string + { + foreach ($values as $value) { + $normalized = trim((string)($value ?? '')); + if ($normalized !== '') { + return $normalized; + } + } + + return ''; + } + + private function normalizeBoolean(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int)$value === 1; + } + + return null; + } + + private static function statusSortWeight(string $status_color): int + { + return match (strtolower($status_color)) { + 'green' => 0, + 'yellow' => 1, + 'red' => 2, + default => 3, + }; + } + + /** + * @return array + */ + private function normalizeToArray(array|object|null $payload): array + { + if (is_array($payload)) { + return $payload; + } + + if (is_object($payload)) { + $encoded = json_encode($payload, JSON_UNESCAPED_UNICODE); + if (!is_string($encoded)) { + return []; + } + + $decoded = json_decode($encoded, true); + return is_array($decoded) ? $decoded : []; + } + + return []; + } +} diff --git a/services/nginx/app/classes/shelly_transport_resolver.php b/services/nginx/app/classes/shelly_transport_resolver.php new file mode 100644 index 00000000..171945f6 --- /dev/null +++ b/services/nginx/app/classes/shelly_transport_resolver.php @@ -0,0 +1,48 @@ +gatewayTransport ?? new gateway_shelly_transport($this->manager(), $explicitLocalOverride); + } + + if ($override === edge_gateway_manager::TRANSPORT_MODE_CLOUD) { + return $this->cloudTransport ?? new cloud_shelly_transport(); + } + + $mode = $this->manager()->getDepartmentTransportMode($departmentId); + if ($mode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) { + return $this->gatewayTransport ?? new gateway_shelly_transport($this->manager()); + } + + return $this->cloudTransport ?? new cloud_shelly_transport(); + } + + private function manager(): edge_gateway_manager + { + return $this->edgeGatewayManager ?? new edge_gateway_manager(); + } +} diff --git a/services/nginx/app/classes/stripe.php b/services/nginx/app/classes/stripe.php index 634360d4..1c8b360e 100644 --- a/services/nginx/app/classes/stripe.php +++ b/services/nginx/app/classes/stripe.php @@ -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 @@ -153,4 +167,4 @@ class stripe implements stripe_i throw new Exception('Invalid publishable key'); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/stripe_fake_http_client.php b/services/nginx/app/classes/stripe_fake_http_client.php new file mode 100644 index 00000000..183469da --- /dev/null +++ b/services/nginx/app/classes/stripe_fake_http_client.php @@ -0,0 +1,268 @@ + 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[^/]+)$#', $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[^/]+)$#', $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[^/]+)/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[^/]+)/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[^/]+)$#', $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'; + } +} diff --git a/services/nginx/app/classes/superuser_system_status_service.php b/services/nginx/app/classes/superuser_system_status_service.php new file mode 100644 index 00000000..9a20b459 --- /dev/null +++ b/services/nginx/app/classes/superuser_system_status_service.php @@ -0,0 +1,1617 @@ +collectRuntime($warnings); + $dependencies = $this->collectDependencies($warnings); + $modules = $this->collectModules($force, $warnings); + $sessions = (new system_session_activity_tracker())->getSnapshot(); + + $statuses = [ + $runtime['cpu']['status'] ?? 'ok', + $runtime['memory']['status'] ?? 'ok', + $runtime['disk']['status'] ?? 'ok', + $dependencies['database']['status'] ?? 'down', + $dependencies['redis']['status'] ?? 'down', + $dependencies['minio']['status'] ?? 'down', + $dependencies['database']['replication']['status'] ?? 'not_configured', + $dependencies['redis']['replication']['status'] ?? 'not_configured', + $dependencies['minio']['replication']['status'] ?? 'not_configured', + ]; + foreach ($modules as $module) { + if (($module['enabled'] ?? false) === true) { + $statuses[] = (string)($module['status'] ?? 'configured'); + } + } + + $warningEntries = $this->normalizeWarningEntries($warnings); + + return [ + 'overall_status' => self::reduceOverallStatus($statuses), + 'generated_at' => date('c'), + 'refresh_after_seconds' => self::REFRESH_AFTER_SECONDS, + 'runtime' => $runtime, + 'dependencies' => $dependencies, + 'modules' => $modules, + 'sessions' => $sessions, + 'warnings' => array_map( + static fn(array $warningEntry): string => (string)($warningEntry['message'] ?? ''), + $warningEntries + ), + 'warning_entries' => $warningEntries, + ]; + } + + public function probeDatabase(): array + { + global $db; + + $startedAt = microtime(true); + try { + $result = $db->query("SELECT 1 AS ok, DATABASE() AS database_name, VERSION() AS server_version"); + $row = $result->fetch_assoc(); + return [ + 'status' => 'ok', + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'database' => (string)($row['database_name'] ?? $db->getDatabase()), + 'server_version' => (string)($row['server_version'] ?? ''), + 'checked_at' => date('c'), + ]; + } catch (Throwable $throwable) { + return [ + 'status' => 'down', + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'database' => method_exists($db, 'getDatabase') ? (string)$db->getDatabase() : '', + 'server_version' => null, + 'error' => $throwable->getMessage(), + 'checked_at' => date('c'), + ]; + } + } + + public function probeRedis(): array + { + $startedAt = microtime(true); + try { + if (!defined('redis')) { + throw new \RuntimeException('Redis is not initialized'); + } + $client = redis->get_client(); + $ping = (string)$client->ping(); + return [ + 'status' => (stripos($ping, 'PONG') !== false || stripos($ping, 'OK') !== false) ? 'ok' : 'degraded', + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'database' => isset($GLOBALS['REDIS_CONFIG']['database']) ? (int)$GLOBALS['REDIS_CONFIG']['database'] : null, + 'checked_at' => date('c'), + ]; + } catch (Throwable $throwable) { + return [ + 'status' => 'down', + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'database' => isset($GLOBALS['REDIS_CONFIG']['database']) ? (int)$GLOBALS['REDIS_CONFIG']['database'] : null, + 'error' => $throwable->getMessage(), + 'checked_at' => date('c'), + ]; + } + } + + public function probeMinio(): array + { + $startedAt = microtime(true); + $buckets = $this->minioBuckets(); + + try { + $client = new S3Client([ + 'version' => 'latest', + 'region' => 'us-east-1', + 'endpoint' => (string)($GLOBALS['MINIO']['endpoint'] ?? ''), + 'use_path_style_endpoint' => true, + 'credentials' => [ + 'key' => (string)($GLOBALS['MINIO']['access_key'] ?? ''), + 'secret' => (string)($GLOBALS['MINIO']['secret_key'] ?? ''), + ], + ]); + + $client->listBuckets(); + $bucketStatuses = []; + $overall = 'ok'; + foreach ($buckets as $bucket) { + try { + $exists = (bool)$client->doesBucketExist($bucket); + $bucketStatuses[] = [ + 'name' => $bucket, + 'status' => $exists ? 'ok' : 'down', + ]; + if (!$exists) { + $overall = 'degraded'; + } + } catch (Throwable $throwable) { + $bucketStatuses[] = [ + 'name' => $bucket, + 'status' => 'down', + 'error' => $throwable->getMessage(), + ]; + $overall = 'degraded'; + } + } + + return [ + 'status' => $overall, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'endpoint' => (string)($GLOBALS['MINIO']['endpoint'] ?? ''), + 'buckets' => $bucketStatuses, + 'checked_at' => date('c'), + ]; + } catch (Throwable $throwable) { + return [ + 'status' => 'down', + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'endpoint' => (string)($GLOBALS['MINIO']['endpoint'] ?? ''), + 'buckets' => array_map(static fn(string $bucket): array => ['name' => $bucket, 'status' => 'unknown'], $buckets), + 'error' => $throwable->getMessage(), + 'checked_at' => date('c'), + ]; + } + } + + public static function reduceOverallStatus(array $statuses): string + { + $normalized = array_map(static fn($status): string => strtolower(trim((string)$status)), $statuses); + if (in_array('down', $normalized, true)) { + return 'down'; + } + foreach ($normalized as $status) { + if (in_array($status, ['degraded', 'not_configured'], true)) { + return 'degraded'; + } + } + return 'ok'; + } + + public static function shouldReuseCachedModuleProbe(?array $cachedProbe, bool $force, ?int $referenceTimestamp = null, int $ttlSeconds = self::MODULE_PROBE_TTL_SECONDS): bool + { + if ($force || !is_array($cachedProbe)) { + return false; + } + $checkedAt = $cachedProbe['checked_at'] ?? null; + if (!is_string($checkedAt) || trim($checkedAt) === '') { + return false; + } + $checkedTimestamp = strtotime($checkedAt); + if ($checkedTimestamp === false) { + return false; + } + $referenceTimestamp = $referenceTimestamp ?? time(); + return ($referenceTimestamp - $checkedTimestamp) < max(1, $ttlSeconds); + } + + public static function statusFromUsagePercent(?float $usagePercent, float $degradedThreshold = 85.0, float $downThreshold = 98.0): string + { + if ($usagePercent === null) { + return 'down'; + } + if ($usagePercent >= $downThreshold) { + return 'down'; + } + if ($usagePercent >= $degradedThreshold) { + return 'degraded'; + } + return 'ok'; + } + + protected function moduleReason(string $key, array $params, string $message): array + { + return [ + 'status_reason_key' => $key, + 'status_reason_params' => $params, + 'status_reason' => $message, + ]; + } + + protected function pushWarning(array &$warnings, string $key, array $params, string $message): void + { + $warnings[] = [ + 'key' => $key, + 'params' => $params, + 'message' => $message, + ]; + } + + protected function normalizeWarningEntries(array $warnings): array + { + $entries = []; + $seen = []; + + foreach ($warnings as $warning) { + $entry = $this->normalizeWarningEntry($warning); + if ($entry === null) { + continue; + } + + $signature = md5(json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + if (isset($seen[$signature])) { + continue; + } + + $seen[$signature] = true; + $entries[] = $entry; + } + + return $entries; + } + + protected function normalizeWarningEntry(mixed $warning): ?array + { + if (is_string($warning)) { + $message = trim($warning); + if ($message === '') { + return null; + } + + return [ + 'key' => null, + 'params' => [], + 'message' => $message, + ]; + } + + if (!is_array($warning)) { + return null; + } + + $message = trim((string)($warning['message'] ?? '')); + if ($message === '') { + return null; + } + + return [ + 'key' => isset($warning['key']) ? (string)$warning['key'] : null, + 'params' => isset($warning['params']) && is_array($warning['params']) ? $warning['params'] : [], + 'message' => $message, + ]; + } + + private function collectRuntime(array &$warnings): array + { + return [ + 'cpu' => $this->probeCpu($warnings), + 'memory' => $this->probeMemory($warnings), + 'disk' => $this->probeDisk(), + ]; + } + + private function collectDependencies(array &$warnings): array + { + $database = $this->probeDatabase(); + $redis = $this->probeRedis(); + $minio = $this->probeMinio(); + try { + $replicationManager = new replication_manager(); + $database['replication'] = $replicationManager->dependencyReplication('database'); + $redis['replication'] = $replicationManager->dependencyReplication('redis'); + $minio['replication'] = $replicationManager->dependencyReplication('minio'); + } catch (Throwable $throwable) { + $database['replication'] = $this->replicationStatusFallback('database', $throwable); + $redis['replication'] = $this->replicationStatusFallback('redis', $throwable); + $minio['replication'] = $this->replicationStatusFallback('minio', $throwable); + } + + if (($redis['status'] ?? '') === 'down') { + $this->pushWarning( + $warnings, + 'redis_cache_bypass', + [], + 'Redis is unavailable; module probe caching is bypassed.' + ); + } + if (($minio['status'] ?? '') === 'degraded') { + $this->pushWarning( + $warnings, + 'minio_missing_buckets', + [], + 'MinIO is reachable, but one or more expected buckets are missing or inaccessible.' + ); + } + + return [ + 'database' => $database, + 'redis' => $redis, + 'minio' => $minio, + ]; + } + + private function replicationStatusFallback(string $kind, Throwable $throwable): array + { + return [ + 'status' => 'degraded', + 'min_percent' => 0.0, + 'average_percent' => 0.0, + 'replicas' => [], + 'blockers' => [ + 'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(), + ], + ]; + } + + private function probeCpu(array &$warnings): array + { + $checkedAt = date('c'); + if (is_readable('/proc/stat')) { + $first = $this->readProcStatTotals('/proc/stat'); + usleep(100000); + $second = $this->readProcStatTotals('/proc/stat'); + if ($first !== null && $second !== null) { + $totalDelta = $second['total'] - $first['total']; + $idleDelta = $second['idle'] - $first['idle']; + if ($totalDelta > 0) { + $usagePercent = round((1 - ($idleDelta / $totalDelta)) * 100, 2); + return [ + 'status' => self::statusFromUsagePercent($usagePercent), + 'usage_percent' => $usagePercent, + 'source' => 'proc_stat', + 'checked_at' => $checkedAt, + ]; + } + } + } + + if (is_readable('/proc/loadavg')) { + $loadParts = explode(' ', trim((string)file_get_contents('/proc/loadavg'))); + $load = isset($loadParts[0]) ? (float)$loadParts[0] : null; + $cpuCount = (int)trim((string)@shell_exec('nproc 2>/dev/null')); + if ($cpuCount <= 0) { + $cpuCount = 1; + } + if ($load !== null) { + $usagePercent = round(min(100, max(0, ($load / $cpuCount) * 100)), 2); + $this->pushWarning( + $warnings, + 'cpu_loadavg_fallback', + [], + 'CPU usage fell back to load average because /proc/stat sampling was unavailable.' + ); + return [ + 'status' => self::statusFromUsagePercent($usagePercent), + 'usage_percent' => $usagePercent, + 'source' => 'loadavg', + 'checked_at' => $checkedAt, + ]; + } + } + + $this->pushWarning( + $warnings, + 'cpu_unavailable', + [], + 'CPU usage metrics are unavailable in this runtime.' + ); + return [ + 'status' => 'down', + 'usage_percent' => null, + 'source' => 'unavailable', + 'checked_at' => $checkedAt, + ]; + } + + private function probeMemory(array &$warnings): array + { + $checkedAt = date('c'); + $cgroupCandidates = [ + ['/sys/fs/cgroup/memory.current', '/sys/fs/cgroup/memory.max', 'cgroup_v2'], + ['/sys/fs/cgroup/memory/memory.usage_in_bytes', '/sys/fs/cgroup/memory/memory.limit_in_bytes', 'cgroup_v1'], + ]; + + foreach ($cgroupCandidates as [$usagePath, $limitPath, $source]) { + if (!is_readable($usagePath) || !is_readable($limitPath)) { + continue; + } + $usedBytes = (int)trim((string)file_get_contents($usagePath)); + $limitRaw = trim((string)file_get_contents($limitPath)); + if ($limitRaw === 'max') { + continue; + } + $totalBytes = (int)$limitRaw; + if ($totalBytes <= 0) { + continue; + } + $usagePercent = round(($usedBytes / $totalBytes) * 100, 2); + return [ + 'status' => self::statusFromUsagePercent($usagePercent, 90, 99), + 'usage_percent' => $usagePercent, + 'used_bytes' => $usedBytes, + 'total_bytes' => $totalBytes, + 'source' => $source, + 'checked_at' => $checkedAt, + ]; + } + + if (is_readable('/proc/meminfo')) { + $content = (string)file_get_contents('/proc/meminfo'); + preg_match('/MemTotal:\s+(\d+)\s+kB/i', $content, $totalMatch); + preg_match('/MemAvailable:\s+(\d+)\s+kB/i', $content, $availableMatch); + if (!empty($totalMatch[1]) && !empty($availableMatch[1])) { + $totalBytes = (int)$totalMatch[1] * 1024; + $availableBytes = (int)$availableMatch[1] * 1024; + $usedBytes = max(0, $totalBytes - $availableBytes); + $usagePercent = round(($usedBytes / $totalBytes) * 100, 2); + $this->pushWarning( + $warnings, + 'memory_proc_fallback', + [], + 'Memory usage fell back to /proc/meminfo because cgroup limits were unavailable.' + ); + return [ + 'status' => self::statusFromUsagePercent($usagePercent, 90, 99), + 'usage_percent' => $usagePercent, + 'used_bytes' => $usedBytes, + 'total_bytes' => $totalBytes, + 'source' => 'proc_meminfo', + 'checked_at' => $checkedAt, + ]; + } + } + + $this->pushWarning( + $warnings, + 'memory_unavailable', + [], + 'Memory metrics are unavailable in this runtime.' + ); + return [ + 'status' => 'down', + 'usage_percent' => null, + 'used_bytes' => null, + 'total_bytes' => null, + 'source' => 'unavailable', + 'checked_at' => $checkedAt, + ]; + } + + private function probeDisk(): array + { + $checkedAt = date('c'); + $path = is_dir(WD) ? WD : '/'; + $totalBytes = @disk_total_space($path); + $freeBytes = @disk_free_space($path); + if ($totalBytes === false || $freeBytes === false || $totalBytes <= 0) { + return [ + 'status' => 'down', + 'usage_percent' => null, + 'used_bytes' => null, + 'free_bytes' => null, + 'total_bytes' => null, + 'path' => $path, + 'checked_at' => $checkedAt, + ]; + } + $usedBytes = $totalBytes - $freeBytes; + $usagePercent = round(($usedBytes / $totalBytes) * 100, 2); + return [ + 'status' => self::statusFromUsagePercent($usagePercent, 90, 99), + 'usage_percent' => $usagePercent, + 'used_bytes' => $usedBytes, + 'free_bytes' => $freeBytes, + 'total_bytes' => $totalBytes, + 'path' => $path, + 'checked_at' => $checkedAt, + ]; + } + + private function readProcStatTotals(string $path): ?array + { + $line = strtok((string)file_get_contents($path), PHP_EOL); + if (!is_string($line) || !str_starts_with($line, 'cpu ')) { + return null; + } + $parts = preg_split('/\s+/', trim($line)); + if (!is_array($parts) || count($parts) < 5) { + return null; + } + $values = array_map('intval', array_slice($parts, 1)); + return [ + 'idle' => ($values[3] ?? 0) + ($values[4] ?? 0), + 'total' => array_sum($values), + ]; + } + + protected function minioBuckets(): array + { + return replication_manager::normalizeMinioBuckets($GLOBALS['MINIO']['buckets'] ?? ['attachments', 'backups', 'invoices', 'pdfs', 'uploads', 'truckwashdev']); + } + + protected function collectModules(bool $force, array &$warnings): array + { + $descriptors = $this->moduleDescriptors(); + $moduleNames = array_values(array_unique(array_map(static fn(array $descriptor): string => $descriptor['module'], $descriptors))); + $configRows = $this->loadModuleConfigRows($moduleNames); + $modulesWithoutProbes = []; + $results = []; + + foreach ($descriptors as $descriptor) { + $moduleConfig = $configRows[$descriptor['module']] ?? []; + $enabled = $this->resolveModuleEnabled($descriptor, $moduleConfig); + $configuration = $this->resolveModuleConfiguration($descriptor, $moduleConfig); + $missingRequired = $configuration['missing']; + $configured = $configuration['configured']; + + $result = [ + 'key' => $descriptor['key'], + 'enabled' => $enabled, + 'configured' => $configured, + 'probe_supported' => isset($descriptor['probe']), + 'status' => 'configured', + 'status_reason' => null, + 'status_reason_key' => null, + 'status_reason_params' => [], + 'checked_at' => date('c'), + ]; + + if (!$enabled) { + $result['configured'] = false; + $result['status'] = 'disabled'; + $result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.')); + $results[] = $result; + continue; + } + + if (!$configured) { + $result['status'] = 'not_configured'; + $result['status_reason'] = (string)($configuration['reason'] ?? ('Missing required configuration: ' . implode(', ', $missingRequired))); + $result['status_reason_key'] = $configuration['reason_key'] ?? 'missing_config'; + $result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params']) + ? $configuration['reason_params'] + : ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired]; + $results[] = $result; + continue; + } + + if (!isset($descriptor['probe'])) { + $modulesWithoutProbes[] = $descriptor['key']; + $result['status'] = 'configured'; + $result = array_merge( + $result, + $this->moduleReason( + 'safe_probe_unavailable', + [], + 'Configuration is present, but no safe read-only probe is available.' + ) + ); + $results[] = $result; + continue; + } + + $probeResult = $this->resolveModuleProbeResult($descriptor, $moduleConfig, $force, $warnings); + $result['status'] = (string)($probeResult['status'] ?? 'configured'); + $result['status_reason'] = $probeResult['status_reason'] ?? null; + $result['status_reason_key'] = $probeResult['status_reason_key'] ?? null; + $result['status_reason_params'] = isset($probeResult['status_reason_params']) && is_array($probeResult['status_reason_params']) + ? $probeResult['status_reason_params'] + : []; + $result['checked_at'] = (string)($probeResult['checked_at'] ?? $result['checked_at']); + $results[] = $result; + } + + if (!empty($modulesWithoutProbes)) { + $this->pushWarning( + $warnings, + 'modules_without_probes', + [ + 'modules' => implode(', ', $modulesWithoutProbes), + 'module_keys' => $modulesWithoutProbes, + ], + 'Some modules expose configuration-only status because no safe read-only probe exists: ' . implode(', ', $modulesWithoutProbes) . '.' + ); + } + + return $results; + } + + protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array + { + $cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key']; + $cachedProbe = null; + + if (defined('redis')) { + try { + $rawCached = redis->get($cacheKey); + if (is_string($rawCached) && trim($rawCached) !== '') { + $decoded = json_decode($rawCached, true); + if (is_array($decoded)) { + $cachedProbe = $decoded; + } + } + } catch (Throwable $throwable) { + $this->pushWarning( + $warnings, + 'redis_cache_lookup_failed', + [ + 'module' => $descriptor['key'], + 'error' => $throwable->getMessage(), + ], + 'Redis cache lookup failed for module probe ' . $descriptor['key'] . ': ' . $throwable->getMessage() + ); + } + } + + if (self::shouldReuseCachedModuleProbe($cachedProbe, $force)) { + return $cachedProbe; + } + + $probeCallable = $descriptor['probe']; + $probeResult = $probeCallable($moduleConfig); + if (!is_array($probeResult)) { + $probeResult = [ + 'status' => 'down', + 'status_reason' => 'Probe returned an invalid payload.', + 'status_reason_key' => 'invalid_probe_payload', + 'status_reason_params' => [], + 'checked_at' => date('c'), + ]; + } + + if (!isset($probeResult['checked_at'])) { + $probeResult['checked_at'] = date('c'); + } + + if (defined('redis')) { + try { + redis->setEx( + $cacheKey, + json_encode($probeResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + self::MODULE_PROBE_TTL_SECONDS + ); + } catch (Throwable $throwable) { + $this->pushWarning( + $warnings, + 'redis_cache_write_failed', + [ + 'module' => $descriptor['key'], + 'error' => $throwable->getMessage(), + ], + 'Redis cache write failed for module probe ' . $descriptor['key'] . ': ' . $throwable->getMessage() + ); + } + } + + return $probeResult; + } + + protected function resolveModuleConfiguration(array $descriptor, array $moduleConfig): array + { + if (($descriptor['key'] ?? '') === 'email') { + return $this->resolveEmailModuleConfiguration($moduleConfig); + } + + $missing = $this->collectMissingRequiredVariables((array)($descriptor['required'] ?? []), $moduleConfig); + return [ + 'configured' => count($missing) === 0, + 'missing' => $missing, + 'reason' => count($missing) === 0 ? null : 'Missing required configuration: ' . implode(', ', $missing), + 'reason_key' => count($missing) === 0 ? null : 'missing_config', + 'reason_params' => count($missing) === 0 + ? [] + : ['variables' => implode(', ', $missing), 'variables_list' => $missing], + ]; + } + + protected function loadModuleConfigRows(array $moduleNames): array + { + global $db; + + if (empty($moduleNames)) { + return []; + } + + $escapedModules = array_map(static fn(string $module): string => "'" . $db->escape_string($module) . "'", $moduleNames); + $result = $db->query( + 'SELECT module, variable, value, type FROM module_config WHERE module IN (' . implode(', ', $escapedModules) . ')' + ); + + $rows = []; + while ($row = $result->fetch_assoc()) { + $module = (string)($row['module'] ?? ''); + $variable = (string)($row['variable'] ?? ''); + if ($module === '' || $variable === '') { + continue; + } + $rows[$module][$variable] = [ + 'raw' => $row['value'] ?? null, + 'parsed' => $this->parseModuleConfigValue((string)($row['type'] ?? 'string'), $row['value'] ?? null), + 'type' => (string)($row['type'] ?? 'string'), + ]; + } + + return $rows; + } + + protected function resolveModuleEnabled(array $descriptor, array $moduleConfig): bool + { + if (($descriptor['always_enabled'] ?? false) === true) { + return true; + } + $enabledVariable = $descriptor['enabled_variable'] ?? 'enabled'; + return (bool)($moduleConfig[$enabledVariable]['parsed'] ?? false); + } + + protected function resolveMissingRequiredVariables(array $descriptor, array $moduleConfig): array + { + return $this->collectMissingRequiredVariables((array)($descriptor['required'] ?? []), $moduleConfig); + } + + protected function collectMissingRequiredVariables(array $requiredVariables, array $moduleConfig): array + { + $missing = []; + foreach ($requiredVariables as $variable) { + $value = $moduleConfig[$variable]['parsed'] ?? null; + if (!$this->isConfiguredValuePresent($value)) { + $missing[] = $variable; + } + } + return $missing; + } + + protected function resolveEmailModuleConfiguration(array $moduleConfig): array + { + $mailersendEnabled = (bool)($moduleConfig['mailersend_enabled']['parsed'] ?? false); + if (!$mailersendEnabled) { + return [ + 'configured' => false, + 'missing' => ['mailersend_enabled'], + 'reason' => 'MailerSend must be enabled because default SMTP delivery is not implemented.', + 'reason_key' => 'email_delivery_not_implemented', + 'reason_params' => [], + ]; + } + + $required = ['mailersend_api_key', 'smtp_from', 'smtp_from_name', 'smtp_reply_to', 'smtp_reply_to_name']; + $missing = $this->collectMissingRequiredVariables($required, $moduleConfig); + + return [ + 'configured' => count($missing) === 0, + 'missing' => $missing, + 'reason' => count($missing) === 0 ? null : 'Missing required configuration: ' . implode(', ', $missing), + 'reason_key' => count($missing) === 0 ? null : 'missing_config', + 'reason_params' => count($missing) === 0 + ? [] + : ['variables' => implode(', ', $missing), 'variables_list' => $missing], + ]; + } + + protected function isConfiguredValuePresent(mixed $value): bool + { + if ($value === null) { + return false; + } + if (is_bool($value)) { + return true; + } + if (is_int($value) || is_float($value)) { + return true; + } + if (is_string($value)) { + return trim($value) !== ''; + } + if (is_array($value)) { + return !empty($value); + } + return true; + } + + protected function parseModuleConfigValue(string $type, mixed $value): mixed + { + return match (strtolower($type)) { + 'bool' => in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true), + 'int', 'integer' => is_numeric($value) ? (int)$value : null, + 'float', 'double' => is_numeric($value) ? (float)$value : null, + 'json' => is_string($value) ? json_decode($value, true) : null, + default => $value, + }; + } + + protected function moduleDescriptors(): array + { + return [ + ['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)], + ['key' => 'reCAPTCHA', 'module' => 'reCAPTCHA', 'enabled_variable' => 'enabled', 'required' => ['site_key_v2', 'secret_key_v2'], 'probe' => fn(array $config): array => $this->probeRecaptchaModule($config)], + ['key' => 'email', 'module' => 'Email', 'enabled_variable' => 'enabled', 'required' => ['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_encryption', 'smtp_from', 'smtp_from_name', 'smtp_reply_to', 'smtp_reply_to_name'], 'probe' => fn(array $config): array => $this->probeEmailModule($config)], + ['key' => 'backups', 'module' => 'Backups', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeBackupsModule($config)], + ['key' => 'motorapi', 'module' => 'motorapi', 'enabled_variable' => 'enabled', 'required' => ['secret_key'], 'probe' => fn(array $config): array => $this->probeMotorApiModule($config)], + ['key' => 'stripe', 'module' => 'Stripe', 'enabled_variable' => 'enabled', 'required' => ['publishable_key', 'secret_key', 'economic_customer_number'], 'probe' => fn(array $config): array => $this->probeStripeModule($config)], + ['key' => 'fxratesapi', 'module' => 'fxratesapi', 'enabled_variable' => 'enabled', 'required' => ['secret_key'], 'probe' => fn(array $config): array => $this->probeFxRatesApiModule($config)], + ['key' => 'weatherapi', 'module' => 'weatherapi', 'enabled_variable' => 'enabled', 'required' => ['secret_key'], 'probe' => fn(array $config): array => $this->probeWeatherApiModule($config)], + ['key' => 'workfeed', 'module' => 'workfeed', 'enabled_variable' => 'enabled', 'required' => ['api_url', 'api_key', 'CompanyID'], 'probe' => fn(array $config): array => $this->probeWorkfeedModule($config)], + ['key' => 'gatewayapi', 'module' => 'GatewayAPI', 'enabled_variable' => 'enabled', 'required' => ['api_secret', 'api_token', 'sender'], 'probe' => fn(array $config): array => $this->probeGatewayApiModule($config)], + ['key' => 'xlvask', 'module' => 'xlvask', 'enabled_variable' => 'enabled', 'required' => ['username', 'password'], 'probe' => fn(array $config): array => $this->probeXlVaskModule($config)], + ['key' => 'entra', 'module' => 'Entra', 'enabled_variable' => 'enabled', 'required' => ['entra_client_id', 'entra_client_secret', 'entra_tenant_id'], 'probe' => fn(array $config): array => $this->probeEntraModule($config)], + ['key' => 'limble', 'module' => 'limble', 'enabled_variable' => 'enabled', 'required' => ['client_id', 'client_secret'], 'probe' => fn(array $config): array => $this->probeLimbleModule($config)], + ['key' => 'ocrspace', 'module' => 'ocrSpace', 'enabled_variable' => 'enabled', 'required' => ['api_key']], + ['key' => 'openai', 'module' => 'openAI', 'enabled_variable' => 'enabled', 'required' => ['api_key'], 'probe' => fn(array $config): array => $this->probeOpenAiModule($config)], + ['key' => 'licenseplaterecognizer', 'module' => 'licenseplaterecognizer', 'enabled_variable' => 'enabled', 'required' => ['api_key'], 'probe' => fn(array $config): array => $this->probeLicensePlateRecognizerModule($config)], + ['key' => 'virkdata', 'module' => 'virkdata', 'enabled_variable' => 'enabled', 'required' => ['secret_key']], + ['key' => 'shelly', 'module' => 'shelly', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'secret_key'], 'probe' => fn(array $config): array => $this->probeShellyModule($config)], + ['key' => 'coolify', 'module' => 'Coolify', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeCoolifyModule($config)], + ['key' => 'releasemanager', 'module' => 'ReleaseManager', 'enabled_variable' => 'enabled', 'required' => [], 'always_enabled' => true, 'probe' => fn(array $config): array => $this->probeReleaseManagerModule($config)], + ['key' => 'selfserve', 'module' => 'selfserve', 'enabled_variable' => 'enabled', 'required' => ['machine_wash_minutes_included', 'minute_product'], 'probe' => fn(array $config): array => $this->probeSelfserveModule($config)], + ['key' => 'bird', 'module' => 'bird', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'api_key', 'channelId', 'workplaceId'], 'probe' => fn(array $config): array => $this->probeBirdModule($config)], + ]; + } + + protected function probeReleaseManagerModule(array $config): array + { + return (new release_manager())->healthProbe(); + } + + protected function probeCoolifyModule(array $config): array + { + $startedAt = microtime(true); + + try { + $summary = (new coolify_manager())->summary(); + $instances = is_array($summary['instances'] ?? null) ? $summary['instances'] : []; + $targets = is_array($summary['targets'] ?? null) ? $summary['targets'] : []; + + if ($instances === []) { + return [ + 'status' => 'not_configured', + 'status_reason' => 'Coolify is enabled, but no Coolify API instance is configured.', + 'status_reason_key' => 'coolify_instances_missing', + 'status_reason_params' => [], + 'checked_at' => date('c'), + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } + + $downInstances = array_values(array_filter($instances, static fn(array $instance): bool => ($instance['status'] ?? 'unknown') === 'down')); + $blockedTargets = array_values(array_filter($targets, static function (array $target): bool { + $state = (string)($target['availability_state'] ?? 'degraded'); + return $state === 'destructive_action_required' || str_contains($state, 'blocked'); + })); + $failedTargets = array_values(array_filter($targets, static function (array $target): bool { + return in_array((string)($target['deployment_status'] ?? ''), ['reconcile_failed', 'restart_failed', 'provision_blocked'], true); + })); + + $status = 'ok'; + $reason = 'Coolify deployment state is available.'; + $reasonKey = 'coolify_available'; + if ($downInstances !== []) { + $status = 'down'; + $reason = 'One or more Coolify API instances are unreachable.'; + $reasonKey = 'coolify_instances_down'; + } elseif ($blockedTargets !== [] || $failedTargets !== []) { + $status = 'degraded'; + $reason = 'One or more Coolify targets need operator attention before availability can be protected.'; + $reasonKey = 'coolify_targets_need_attention'; + } elseif ($targets === []) { + $status = 'degraded'; + $reason = 'Coolify is connected, but no replicated infrastructure targets are managed yet.'; + $reasonKey = 'coolify_targets_missing'; + } + + return [ + 'status' => $status, + 'status_reason' => $reason, + 'status_reason_key' => $reasonKey, + 'status_reason_params' => [ + 'instances' => count($instances), + 'targets' => count($targets), + 'blocked_targets' => count($blockedTargets), + 'failed_targets' => count($failedTargets), + ], + 'checked_at' => date('c'), + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } catch (Throwable $throwable) { + return [ + 'status' => 'down', + 'status_reason' => 'Coolify module probe failed: ' . $throwable->getMessage(), + 'status_reason_key' => 'coolify_probe_failed', + 'status_reason_params' => ['error' => $throwable->getMessage()], + 'checked_at' => date('c'), + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } + } + + protected function probeEconomicModule(array $config): array + { + $appSecretToken = trim((string)($GLOBALS['ECONOMIC_API']['app_secret_token'] ?? '')); + $agreementGrantToken = trim((string)($GLOBALS['ECONOMIC_API']['app_access_grant'] ?? '')); + + if ($appSecretToken === '' || $agreementGrantToken === '') { + return [ + 'status' => 'down', + 'status_reason' => 'e-conomic credentials are missing from runtime environment configuration.', + 'status_reason_key' => 'economic_credentials_missing', + 'status_reason_params' => [], + 'checked_at' => date('c'), + ]; + } + + return $this->performHttpProbe( + $this->buildUrlWithQuery('https://restapi.e-conomic.com', '/layouts/'), + [ + 'X-AppSecretToken: ' . $appSecretToken, + 'X-AgreementGrantToken: ' . $agreementGrantToken, + 'Accept: application/json', + ], + 'e-conomic API' + ); + } + + protected function probeRecaptchaModule(array $config): array + { + $secretKey = trim((string)($config['secret_key_v2']['parsed'] ?? '')); + + return $this->performHttpProbe( + 'https://www.google.com/recaptcha/api/siteverify', + ['Content-Type: application/x-www-form-urlencoded'], + 'reCAPTCHA', + null, + 'POST', + http_build_query([ + 'secret' => $secretKey, + 'response' => 'system-status-probe', + ]), + fn(array $httpResponse, string $label): array => $this->evaluateRecaptchaProbeResponse($httpResponse, $label) + ); + } + + protected function probeEmailModule(array $config): array + { + $apiKey = trim((string)($config['mailersend_api_key']['parsed'] ?? '')); + + return $this->performHttpProbe( + 'https://api.mailersend.com/v1/api-quota', + [ + 'Authorization: Bearer ' . $apiKey, + 'Accept: application/json', + ], + 'MailerSend API' + ); + } + + protected function probeBackupsModule(array $config): array + { + $checkedAt = date('c'); + $startedAt = microtime(true); + + try { + $this->validateBackupsStore(); + + return [ + 'status' => 'ok', + 'status_reason' => 'Backup store connectivity confirmed.', + 'status_reason_key' => 'backup_connectivity_confirmed', + 'status_reason_params' => [], + 'checked_at' => $checkedAt, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } catch (Throwable $throwable) { + return [ + 'status' => 'down', + 'status_reason' => 'Backup store probe failed: ' . $throwable->getMessage(), + 'status_reason_key' => 'backup_probe_failed', + 'status_reason_params' => ['error' => $throwable->getMessage()], + 'checked_at' => $checkedAt, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } + } + + protected function probeMotorApiModule(array $config): array + { + $secretKey = trim((string)($config['secret_key']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'), + ['X-AUTH-TOKEN: ' . $secretKey], + 'MotorAPI' + ); + } + + protected function probeOpenAiModule(array $config): array + { + $apiKey = (string)($config['api_key']['parsed'] ?? ''); + return $this->performHttpProbe( + 'https://api.openai.com/v1/models', + [ + 'Authorization: Bearer ' . $apiKey, + 'Accept: application/json', + ], + 'OpenAI API' + ); + } + + protected function probeStripeModule(array $config): array + { + $secretKey = (string)($config['secret_key']['parsed'] ?? ''); + return $this->performHttpProbe( + 'https://api.stripe.com/v1/balance', + ['Accept: application/json'], + 'Stripe API', + $secretKey . ':' + ); + } + + protected function probeFxRatesApiModule(array $config): array + { + $secretKey = trim((string)($config['secret_key']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery('https://api.fxratesapi.com', '/latest', [ + 'base' => 'EUR', + 'currencies' => 'DKK', + ]), + ['X-AUTH-TOKEN: ' . $secretKey], + 'FXRatesAPI' + ); + } + + protected function probeWeatherApiModule(array $config): array + { + $secretKey = trim((string)($config['secret_key']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery('https://api.weatherapi.com/v1', '/current.json', [ + 'key' => $secretKey, + 'q' => 'Copenhagen', + ]), + ['Accept: application/json'], + 'WeatherAPI' + ); + } + + protected function probeWorkfeedModule(array $config): array + { + $apiUrl = trim((string)($config['api_url']['parsed'] ?? '')); + $companyId = trim((string)($config['CompanyID']['parsed'] ?? '')); + $apiKey = trim((string)($config['api_key']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery($apiUrl, '/companies/' . rawurlencode($companyId) . '/departments'), + [ + 'Accept: application/json', + 'Authorization: ' . $apiKey, + ], + 'Workfeed API' + ); + } + + protected function probeGatewayApiModule(array $config): array + { + $apiToken = trim((string)($config['api_token']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery('https://gatewayapi.eu', '/rest/me'), + [ + 'Authorization: Token ' . $apiToken, + 'Accept: application/json', + ], + 'GatewayAPI' + ); + } + + protected function probeXlVaskModule(array $config): array + { + $username = trim((string)($config['username']['parsed'] ?? '')); + $password = trim((string)($config['password']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery('https://api.xlwash.com', '/customers'), + ['Accept: application/json'], + 'XLVask API', + $username . ':' . $password + ); + } + + protected function probeEntraModule(array $config): array + { + $tenantId = trim((string)($config['entra_tenant_id']['parsed'] ?? '')); + return $this->performHttpProbe( + 'https://login.microsoftonline.com/' . rawurlencode($tenantId) . '/v2.0/.well-known/openid-configuration', + ['Accept: application/json'], + 'Microsoft Entra' + ); + } + + protected function probeLimbleModule(array $config): array + { + $clientId = trim((string)($config['client_id']['parsed'] ?? '')); + $clientSecret = trim((string)($config['client_secret']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery('https://api.limblecmms.com:443/v2', '/tasks', [ + 'limit' => 1, + 'page' => 1, + ]), + [ + 'Accept: application/json', + 'Content-Type: application/json', + ], + 'Limble API', + $clientId . ':' . $clientSecret + ); + } + + protected function probeLicensePlateRecognizerModule(array $config): array + { + $apiKey = trim((string)($config['api_key']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery('https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk', '/info/'), + [ + 'Authorization: Token ' . $apiKey, + 'Accept: application/json', + ], + 'License Plate Recognizer' + ); + } + + protected function probeShellyModule(array $config): array + { + $deviceId = $this->findShellyProbeDeviceId(); + if ($deviceId === null) { + return [ + 'status' => 'configured', + 'status_reason' => 'Shelly cloud credentials are configured, but no known device id is available for a safe read-only probe.', + 'status_reason_key' => 'shelly_no_device_id', + 'status_reason_params' => [], + 'checked_at' => date('c'), + ]; + } + + $serverUrl = trim((string)($config['server_url']['parsed'] ?? '')); + $secretKey = trim((string)($config['secret_key']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery($serverUrl, '/device/status', [ + 'id' => $deviceId, + 'auth_key' => $secretKey, + ]), + ['Accept: application/json'], + 'Shelly server' + ); + } + + protected function probeSelfserveModule(array $config): array + { + $checkedAt = date('c'); + $startedAt = microtime(true); + + try { + $minutesIncluded = $this->normalizePositiveInt($config['machine_wash_minutes_included']['parsed'] ?? null); + if ($minutesIncluded === null) { + return [ + 'status' => 'down', + 'status_reason' => 'Self-serve machine minutes configuration is invalid.', + 'status_reason_key' => 'selfserve_minutes_invalid', + 'status_reason_params' => [], + 'checked_at' => $checkedAt, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } + + $minuteProductId = $this->normalizePositiveInt($config['minute_product']['parsed'] ?? null); + if ($minuteProductId === null) { + return [ + 'status' => 'down', + 'status_reason' => 'Self-serve minute product configuration is invalid.', + 'status_reason_key' => 'selfserve_minute_product_invalid', + 'status_reason_params' => [], + 'checked_at' => $checkedAt, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } + + $this->bootstrapSelfserveSchema(); + if (!$this->selfserveMinuteProductExists($minuteProductId)) { + return [ + 'status' => 'down', + 'status_reason' => 'Self-serve minute product #' . $minuteProductId . ' does not exist.', + 'status_reason_key' => 'selfserve_minute_product_missing', + 'status_reason_params' => ['productId' => $minuteProductId], + 'checked_at' => $checkedAt, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } + + return [ + 'status' => 'ok', + 'status_reason' => 'Self-serve schema and minute product configuration confirmed.', + 'status_reason_key' => 'selfserve_configuration_confirmed', + 'status_reason_params' => [], + 'checked_at' => $checkedAt, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } catch (Throwable $throwable) { + return [ + 'status' => 'down', + 'status_reason' => 'Self-serve probe failed: ' . $throwable->getMessage(), + 'status_reason_key' => 'selfserve_probe_failed', + 'status_reason_params' => ['error' => $throwable->getMessage()], + 'checked_at' => $checkedAt, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } + } + + protected function probeBirdModule(array $config): array + { + $serverUrl = trim((string)($config['server_url']['parsed'] ?? '')); + $workspaceId = trim((string)($config['workspaceId']['parsed'] ?? $config['workplaceId']['parsed'] ?? '')); + $channelId = trim((string)($config['channelId']['parsed'] ?? '')); + $apiKey = trim((string)($config['api_key']['parsed'] ?? '')); + + return $this->performHttpProbe( + $this->buildUrlWithQuery( + $serverUrl, + '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls', + ['limit' => 1] + ), + [ + $this->buildBirdAuthorizationHeader($apiKey), + 'Accept: application/json', + ], + 'Bird API' + ); + } + + protected function probeReachableUrlModule(array $config, string $urlVariable, string $label): array + { + $url = trim((string)($config[$urlVariable]['parsed'] ?? '')); + return $this->performHttpProbe($url, ['Accept: application/json'], $label); + } + + protected function buildUrlWithQuery(string $baseUrl, string $path, array $query = []): string + { + $baseUrl = rtrim(trim($baseUrl), '/'); + if ($baseUrl === '') { + return ''; + } + + $url = $baseUrl; + if ($path !== '') { + $url .= '/' . ltrim($path, '/'); + } + + $query = array_filter($query, static fn(mixed $value): bool => $value !== null && $value !== ''); + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + return $url; + } + + protected function performHttpProbe( + string $url, + array $headers, + string $label, + ?string $basicAuth = null, + string $method = 'GET', + ?string $body = null, + ?callable $responseEvaluator = null + ): array + { + $checkedAt = date('c'); + if ($url === '') { + return [ + 'status' => 'down', + 'status_reason' => $label . ' probe could not run because the endpoint is missing.', + 'status_reason_key' => 'http_endpoint_missing', + 'status_reason_params' => ['label' => $label], + 'checked_at' => $checkedAt, + ]; + } + + $curl = curl_init($url); + if ($curl === false) { + return [ + 'status' => 'down', + 'status_reason' => $label . ' probe could not initialize cURL.', + 'status_reason_key' => 'http_curl_init_failed', + 'status_reason_params' => ['label' => $label], + 'checked_at' => $checkedAt, + ]; + } + + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 5, + CURLOPT_CUSTOMREQUEST => strtoupper($method), + CURLOPT_HTTPHEADER => $headers, + CURLOPT_SSL_VERIFYPEER => true, + ]); + if ($basicAuth !== null) { + curl_setopt($curl, CURLOPT_USERPWD, $basicAuth); + } + if ($body !== null) { + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + + $startedAt = microtime(true); + $body = curl_exec($curl); + $httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + $httpResponse = [ + 'checked_at' => $checkedAt, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'http_status' => $httpStatus, + 'body' => is_string($body) ? $body : '', + 'error' => $error, + ]; + + if ($body === false && $error !== '') { + return [ + 'status' => 'down', + 'status_reason' => $label . ' probe failed: ' . $error, + 'status_reason_key' => 'http_probe_failed', + 'status_reason_params' => ['label' => $label, 'error' => $error], + 'checked_at' => $checkedAt, + 'latency_ms' => $httpResponse['latency_ms'], + ]; + } + + if ($responseEvaluator !== null) { + $evaluated = $responseEvaluator($httpResponse, $label); + if (is_array($evaluated)) { + if (!isset($evaluated['checked_at'])) { + $evaluated['checked_at'] = $httpResponse['checked_at']; + } + if (!isset($evaluated['latency_ms'])) { + $evaluated['latency_ms'] = $httpResponse['latency_ms']; + } + if (($httpResponse['http_status'] ?? 0) > 0 && !isset($evaluated['http_status'])) { + $evaluated['http_status'] = $httpResponse['http_status']; + } + return $evaluated; + } + } + + return $this->classifyHttpProbeResult($httpResponse, $label); + } + + protected function classifyHttpProbeResult(array $httpResponse, string $label): array + { + $checkedAt = (string)($httpResponse['checked_at'] ?? date('c')); + $latencyMs = $httpResponse['latency_ms'] ?? null; + $httpStatus = (int)($httpResponse['http_status'] ?? 0); + $error = trim((string)($httpResponse['error'] ?? '')); + + if ($error !== '') { + return [ + 'status' => 'down', + 'status_reason' => $label . ' probe failed: ' . $error, + 'status_reason_key' => 'http_probe_failed', + 'status_reason_params' => ['label' => $label, 'error' => $error], + 'checked_at' => $checkedAt, + 'latency_ms' => $latencyMs, + ]; + } + + if ($httpStatus === 0) { + return [ + 'status' => 'down', + 'status_reason' => $label . ' did not return an HTTP response.', + 'status_reason_key' => 'http_no_response', + 'status_reason_params' => ['label' => $label], + 'checked_at' => $checkedAt, + 'latency_ms' => $latencyMs, + ]; + } + + if ($httpStatus >= 200 && $httpStatus < 300) { + return [ + 'status' => 'ok', + 'status_reason' => $label . ' connectivity confirmed.', + 'status_reason_key' => 'http_ok', + 'status_reason_params' => ['label' => $label], + 'checked_at' => $checkedAt, + 'latency_ms' => $latencyMs, + 'http_status' => $httpStatus, + ]; + } + + if ($httpStatus === 429) { + return [ + 'status' => 'degraded', + 'status_reason' => $label . ' probe was rate limited (HTTP 429).', + 'status_reason_key' => 'http_rate_limited', + 'status_reason_params' => ['label' => $label, 'httpStatus' => $httpStatus], + 'checked_at' => $checkedAt, + 'latency_ms' => $latencyMs, + 'http_status' => $httpStatus, + ]; + } + + if ($httpStatus >= 500) { + return [ + 'status' => 'degraded', + 'status_reason' => $label . ' returned HTTP ' . $httpStatus . '.', + 'status_reason_key' => 'http_status', + 'status_reason_params' => ['label' => $label, 'httpStatus' => $httpStatus], + 'checked_at' => $checkedAt, + 'latency_ms' => $latencyMs, + 'http_status' => $httpStatus, + ]; + } + + if ($httpStatus >= 300) { + return [ + 'status' => 'down', + 'status_reason' => $label . ' returned HTTP ' . $httpStatus . '.', + 'status_reason_key' => 'http_status', + 'status_reason_params' => ['label' => $label, 'httpStatus' => $httpStatus], + 'checked_at' => $checkedAt, + 'latency_ms' => $latencyMs, + 'http_status' => $httpStatus, + ]; + } + + return [ + 'status' => 'degraded', + 'status_reason' => $label . ' returned an unexpected HTTP response.', + 'status_reason_key' => 'http_unexpected_response', + 'status_reason_params' => ['label' => $label], + 'checked_at' => $checkedAt, + 'latency_ms' => $latencyMs, + 'http_status' => $httpStatus, + ]; + } + + protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array + { + $classified = $this->classifyHttpProbeResult($httpResponse, $label); + if (($classified['status'] ?? 'down') !== 'ok') { + return $classified; + } + + $decoded = json_decode((string)($httpResponse['body'] ?? ''), true); + if (!is_array($decoded)) { + return [ + 'status' => 'degraded', + 'status_reason' => $label . ' returned an unreadable response payload.', + 'status_reason_key' => 'recaptcha_unreadable_payload', + 'status_reason_params' => ['label' => $label], + 'checked_at' => $httpResponse['checked_at'] ?? date('c'), + 'latency_ms' => $httpResponse['latency_ms'] ?? null, + 'http_status' => $httpResponse['http_status'] ?? null, + ]; + } + + $errorCodes = array_values(array_filter( + array_map(static fn(mixed $value): string => trim((string)$value), (array)($decoded['error-codes'] ?? [])), + static fn(string $value): bool => $value !== '' + )); + + if (($decoded['success'] ?? false) === true || in_array('invalid-input-response', $errorCodes, true)) { + return [ + 'status' => 'ok', + 'status_reason' => $label . ' connectivity confirmed.', + 'status_reason_key' => 'http_ok', + 'status_reason_params' => ['label' => $label], + 'checked_at' => $httpResponse['checked_at'] ?? date('c'), + 'latency_ms' => $httpResponse['latency_ms'] ?? null, + 'http_status' => $httpResponse['http_status'] ?? null, + ]; + } + + if (in_array('invalid-input-secret', $errorCodes, true) || in_array('missing-input-secret', $errorCodes, true)) { + return [ + 'status' => 'down', + 'status_reason' => $label . ' credentials were rejected by Google.', + 'status_reason_key' => 'recaptcha_credentials_rejected', + 'status_reason_params' => ['label' => $label], + 'checked_at' => $httpResponse['checked_at'] ?? date('c'), + 'latency_ms' => $httpResponse['latency_ms'] ?? null, + 'http_status' => $httpResponse['http_status'] ?? null, + ]; + } + + return [ + 'status' => 'degraded', + 'status_reason' => $label . ' returned unexpected validation errors: ' . implode(', ', $errorCodes), + 'status_reason_key' => 'recaptcha_validation_errors', + 'status_reason_params' => ['label' => $label, 'errors' => implode(', ', $errorCodes)], + 'checked_at' => $httpResponse['checked_at'] ?? date('c'), + 'latency_ms' => $httpResponse['latency_ms'] ?? null, + 'http_status' => $httpResponse['http_status'] ?? null, + ]; + } + + protected function validateBackupsStore(): void + { + new backup_store(); + } + + protected function bootstrapSelfserveSchema(): void + { + selfserve_schema_bootstrap::ensureTables(); + } + + protected function selfserveMinuteProductExists(int $productId): bool + { + global $db; + + $result = $db->query('SELECT id FROM products WHERE id = ' . (int)$productId . ' LIMIT 1'); + if ($result === false) { + throw new \RuntimeException('Failed to query the products table.'); + } + + $row = $result->fetch_assoc(); + return isset($row['id']) && (int)$row['id'] === $productId; + } + + protected function normalizePositiveInt(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) { + $normalized = (int)trim($value); + return $normalized > 0 ? $normalized : null; + } + + if (is_float($value) && $value > 0 && floor($value) === $value) { + return (int)$value; + } + + return null; + } + + protected function buildBirdAuthorizationHeader(string $apiKey): string + { + $apiKey = trim($apiKey); + if ($apiKey === '') { + return 'Authorization: AccessKey'; + } + if (preg_match('/^(Bearer|AccessKey)\s+/i', $apiKey) === 1) { + return 'Authorization: ' . $apiKey; + } + return 'Authorization: AccessKey ' . $apiKey; + } + + protected function findShellyProbeDeviceId(): ?string + { + global $db; + + $queries = [ + "SELECT device_id FROM edge_gateway_relay_bindings WHERE deleted_at IS NULL AND device_id IS NOT NULL AND device_id != '' ORDER BY id DESC LIMIT 1", + "SELECT device_id FROM edge_gateway_device_inventory WHERE deleted_at IS NULL AND device_id IS NOT NULL AND device_id != '' ORDER BY id DESC LIMIT 1", + ]; + + foreach ($queries as $sql) { + try { + $result = $db->query($sql); + if ($result === false) { + continue; + } + $row = $result->fetch_assoc(); + $deviceId = trim((string)($row['device_id'] ?? '')); + if ($deviceId !== '') { + return $deviceId; + } + } catch (Throwable) { + continue; + } + } + + return null; + } +} diff --git a/services/nginx/app/classes/system_search_cache.php b/services/nginx/app/classes/system_search_cache.php new file mode 100644 index 00000000..fe524c41 --- /dev/null +++ b/services/nginx/app/classes/system_search_cache.php @@ -0,0 +1,284 @@ +incr($key); + } + $current = self::redisGet($key); + $next = max(1, (int)$current + 1); + self::redisSet($key, (string)$next); + return $next; + } catch (Throwable) { + return 0; + } + } + + /** + * @param array $tables + */ + public static function tableVersionFingerprint(array $tables): string + { + $versions = []; + foreach ($tables as $table) { + if (!is_string($table)) { + continue; + } + $normalized = trim($table, " `\t\n\r\0\x0B"); + if ($normalized === '') { + continue; + } + $versions[$normalized] = (int)(self::redisGet(self::TABLE_VERSION_PREFIX . $normalized) ?? 0); + } + ksort($versions); + return md5(json_encode($versions, JSON_UNESCAPED_UNICODE)); + } + + public static function enqueueRebuild(string $scope = 'all', array $types = []): array + { + $payload = [ + 'scope' => in_array($scope, ['all', 'types', 'dirty'], true) ? $scope : 'all', + 'types' => array_values(array_unique(array_filter(array_map('strval', $types)))), + 'requested_at' => time(), + ]; + self::redisSet(self::REBUILD_REQUEST_KEY, json_encode($payload, JSON_UNESCAPED_UNICODE)); + self::redisExpire(self::REBUILD_REQUEST_KEY, 86400); + return $payload; + } + + public static function consumeRebuildRequest(): ?array + { + $raw = self::redisGet(self::REBUILD_REQUEST_KEY); + if ($raw === null) { + return null; + } + self::redisDelete(self::REBUILD_REQUEST_KEY); + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; + } + + private static function clearPattern(string $pattern): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->clear_keys($pattern); + } catch (Throwable) { + // Cache clear 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) { + } + } + + private static function redisSet(string $key, string $value): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->set($key, $value); + } catch (Throwable) { + } + } + + 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 redisDelete(string $key): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->delete($key); + } catch (Throwable) { + } + } + + private static function redisSetArray(string $key, array $value): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->set_array($key, $value); + } catch (Throwable) { + } + } + + private static function redisGetArray(string $key): array + { + try { + $client = self::redisClient(); + if ($client === null) { + return []; + } + $value = $client->get_array($key); + return is_array($value) ? $value : []; + } catch (Throwable) { + return []; + } + } + + private static function redisExpire(string $key, int $ttl): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->expire($key, $ttl); + } catch (Throwable) { + } + } + + private static function redisClient(): ?object + { + if (self::$adapter !== null) { + return self::$adapter; + } + if (!defined('redis')) { + return null; + } + $client = constant('redis'); + return is_object($client) ? $client : null; + } +} diff --git a/services/nginx/app/classes/system_search_document_index.php b/services/nginx/app/classes/system_search_document_index.php new file mode 100644 index 00000000..b3844d77 --- /dev/null +++ b/services/nginx/app/classes/system_search_document_index.php @@ -0,0 +1,1403 @@ +> + */ + private array $tableColumnsCache = []; + + public static function ensureTable(): void + { + if (self::$initialized) { + return; + } + + global $db; + if (!is_object($db) || !method_exists($db, 'query')) { + return; + } + + $sql = "CREATE TABLE IF NOT EXISTS `" . self::TABLE . "` ( + `entity_type` VARCHAR(64) NOT NULL, + `entity_id` VARCHAR(191) NOT NULL, + `customer_number` INT NULL, + `department_id` INT NULL, + `title` TEXT NULL, + `description` TEXT NULL, + `search_text` MEDIUMTEXT NULL, + `payload_json` LONGTEXT NULL, + `created_at` DATETIME NULL, + `updated_at` DATETIME NULL, + `indexed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`entity_type`, `entity_id`), + INDEX `idx_ssd_customer` (`customer_number`), + INDEX `idx_ssd_department` (`department_id`), + INDEX `idx_ssd_entity` (`entity_type`), + FULLTEXT KEY `ft_ssd_text` (`title`, `description`, `search_text`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; + + try { + $db->query($sql); + self::$initialized = true; + } catch (Throwable) { + // Search should stay available even if index bootstrap fails. + } + } + + /** + * @param array $types + * @return array + */ + public static function refreshIndex(array $types = []): array + { + $instance = new self(); + return $instance->refresh($types); + } + + /** + * @param array $types + * @return array + */ + private function refresh(array $types = []): array + { + self::ensureTable(); + system_search_economic_customer_index::ensureTable(); + + $stats = [ + 'types' => 0, + 'documents' => 0, + 'errors' => 0, + ]; + + $targetTypes = array_values(array_unique(array_filter(array_map( + static fn($type) => is_string($type) ? trim(mb_strtolower($type)) : '', + $types + )))); + if (empty($targetTypes)) { + $targetTypes = system_search_registry::indexedEntityTypes(); + } else { + $targetTypes = array_values(array_intersect(system_search_registry::indexedEntityTypes(), $targetTypes)); + } + + foreach ($targetTypes as $entityType) { + try { + $documents = $this->buildDocumentsForType($entityType); + $this->replaceDocumentsForType($entityType, $documents); + $stats['types']++; + $stats['documents'] += count($documents); + } catch (Throwable) { + $stats['errors']++; + } + } + + return $stats; + } + + /** + * @return array> + */ + private function buildDocumentsForType(string $entityType): array + { + return match ($entityType) { + 'customers' => $this->buildCustomerDocuments(), + 'employees' => $this->buildEmployeeDocuments(), + 'orders' => $this->buildOrderDocuments(), + 'order_items' => $this->buildOrderItemDocuments(), + 'invoices' => $this->buildInvoiceDocuments(), + 'vehicles' => $this->buildVehicleDocuments(), + 'customer_discounts' => $this->buildCustomerDiscountDocuments(), + 'customer_fixed_prices' => $this->buildCustomerFixedPriceDocuments(), + 'departments' => $this->buildSimpleTableDocuments('departments', 'departments', ['id', 'name', 'address', 'zip', 'city'], ['id', 'name', 'address', 'zip', 'city'], ['name', 'id'], ['address', 'city']), + 'roles' => $this->buildSimpleTableDocuments('roles', 'groups', ['id', 'name', 'description'], ['id', 'name', 'description'], ['name', 'id'], ['description']), + 'module_config' => $this->buildModuleConfigDocuments(), + 'objects' => $this->buildObjectAttachmentDocuments(), + default => $this->buildGenericDocuments($entityType), + }; + } + + /** + * @return array> + */ + private function buildCustomerDocuments(): array + { + $fromClause = 'users u'; + $selectFields = [ + 'u.id AS entity_id', + 'u.customer_number', + 'u.display_name', + 'u.email', + 'u.phone', + ...$this->joinTemporalSelectFields('users', 'u'), + ]; + + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; + $selectFields = [ + ...$selectFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + } + + $rows = $this->fetchRows( + "SELECT " . implode(', ', $selectFields) + . " FROM " . $fromClause + . " WHERE u.customer_number IS NOT NULL AND u.customer_number <> 0" + ); + + $documents = []; + foreach ($rows as $row) { + $title = trim((string)($row['economic_name'] ?? '')); + if ($title === '') { + $title = trim((string)($row['display_name'] ?? '')); + } + if ($title === '') { + $title = 'Customer #' . (string)($row['customer_number'] ?? ''); + } + + $description = trim((string)($row['email'] ?? '')); + if ($description === '') { + $description = trim((string)($row['economic_email'] ?? '')); + } + + $documents[] = $this->makeDocument( + 'customers', + (string)($row['entity_id'] ?? ''), + $title, + $description, + $this->implodeSearchText([ + $row['customer_number'] ?? null, + $row['display_name'] ?? null, + $row['email'] ?? null, + $row['phone'] ?? null, + $row['economic_name'] ?? null, + $row['economic_address'] ?? null, + $row['economic_city'] ?? null, + $row['economic_zip'] ?? null, + $row['economic_email'] ?? null, + $row['economic_cvr'] ?? null, + $row['economic_mobile_phone'] ?? null, + $row['search_text'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'display_name' => $row['display_name'] ?? null, + 'email' => $row['email'] ?? null, + 'phone' => $row['phone'] ?? null, + 'economic_name' => $row['economic_name'] ?? null, + 'economic_address' => $row['economic_address'] ?? null, + 'economic_city' => $row['economic_city'] ?? null, + 'economic_zip' => $row['economic_zip'] ?? null, + 'economic_email' => $row['economic_email'] ?? null, + 'economic_cvr' => $row['economic_cvr'] ?? null, + 'economic_mobile_phone' => $row['economic_mobile_phone'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildEmployeeDocuments(): array + { + $temporalSelect = $this->joinTemporalSelectFields('users', 'u'); + $rows = $this->fetchRows( + "SELECT DISTINCT u.id AS entity_id, u.customer_number, u.display_name, u.email, u.phone" + . (!empty($temporalSelect) ? (', ' . implode(', ', $temporalSelect)) : '') + . " FROM users u" + . " INNER JOIN groups_permissions gp ON gp.group_id = u.group_id" + . " WHERE gp.permission = 'employee_public_data'" + ); + + $documents = []; + foreach ($rows as $row) { + $documents[] = $this->makeDocument( + 'employees', + (string)($row['entity_id'] ?? ''), + (string)(($row['display_name'] ?? '') ?: ('Employee #' . ($row['entity_id'] ?? ''))), + (string)($row['email'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['display_name'] ?? null, + $row['email'] ?? null, + $row['phone'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'display_name' => $row['display_name'] ?? null, + 'email' => $row['email'] ?? null, + 'phone' => $row['phone'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildOrderDocuments(): array + { + $rows = $this->fetchRows( + "SELECT id AS entity_id, customer_id AS customer_number, reference, notes, reg_1, reg_2, reg_3, department_id, po, created_at, updated_at" + . " FROM orders WHERE deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $documents[] = $this->makeDocument( + 'orders', + (string)($row['entity_id'] ?? ''), + 'Order #' . (string)($row['entity_id'] ?? ''), + (string)($row['reference'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['reference'] ?? null, + $row['notes'] ?? null, + $row['reg_1'] ?? null, + $row['reg_2'] ?? null, + $row['reg_3'] ?? null, + $row['po'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + $this->toIntOrNull($row['department_id'] ?? null), + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_id' => $this->toIntOrNull($row['customer_number'] ?? null), + 'reference' => $row['reference'] ?? null, + 'notes' => $row['notes'] ?? null, + 'reg_1' => $row['reg_1'] ?? null, + 'reg_2' => $row['reg_2'] ?? null, + 'reg_3' => $row['reg_3'] ?? null, + 'po' => $row['po'] ?? null, + 'department_id' => $this->toIntOrNull($row['department_id'] ?? null), + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildOrderItemDocuments(): array + { + $temporalSelect = $this->joinTemporalSelectFields('order_items', 'oi'); + $rows = $this->fetchRows( + "SELECT oi.id AS entity_id, oi.order_id, oi.product_id, oi.reference, oi.notes, o.customer_id AS customer_number" + . (!empty($temporalSelect) ? (', ' . implode(', ', $temporalSelect)) : '') + . " FROM order_items oi" + . " INNER JOIN orders o ON o.id = oi.order_id" + . " WHERE o.deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $documents[] = $this->makeDocument( + 'order_items', + (string)($row['entity_id'] ?? ''), + 'Order item #' . (string)($row['entity_id'] ?? ''), + (string)($row['reference'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['order_id'] ?? null, + $row['product_id'] ?? null, + $row['reference'] ?? null, + $row['notes'] ?? null, + $row['customer_number'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'order_id' => $this->toIntOrNull($row['order_id'] ?? null), + 'product_id' => $this->toIntOrNull($row['product_id'] ?? null), + 'reference' => $row['reference'] ?? null, + 'notes' => $row['notes'] ?? null, + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildInvoiceDocuments(): array + { + $rows = $this->fetchRows( + "SELECT id AS entity_id, customer_number, name, notes, external_id, booked_invoice_id, po_number, created_at, updated_at, closed_at" + . " FROM collected_order_invoices WHERE deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $title = $this->invoiceDocumentTitle( + $row['name'] ?? null, + $row['created_at'] ?? null, + $row['closed_at'] ?? null, + $row['entity_id'] ?? null + ); + $documents[] = $this->makeDocument( + 'invoices', + (string)($row['entity_id'] ?? ''), + $title, + (string)($row['external_id'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['name'] ?? null, + $row['notes'] ?? null, + $row['external_id'] ?? null, + $row['booked_invoice_id'] ?? null, + $row['po_number'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'name' => $row['name'] ?? null, + 'external_id' => $row['external_id'] ?? null, + 'booked_invoice_id' => $row['booked_invoice_id'] ?? null, + 'po_number' => $row['po_number'] ?? null, + 'closed_at' => $row['closed_at'] ?? null, + ], + $row + ); + } + + return $documents; + } + + private function invoiceDocumentTitle(mixed $name, mixed $fromDate, mixed $toDate, mixed $invoiceId): string + { + $resolvedName = trim((string)($name ?? '')); + if ($resolvedName !== '') { + return $resolvedName; + } + + $dateRange = $this->invoiceDateRangeLabel($fromDate, $toDate); + if ($dateRange !== '') { + return $dateRange; + } + + return 'Invoice collection #' . (string)$invoiceId; + } + + private function invoiceDateRangeLabel(mixed $fromDate, mixed $toDate): string + { + $fromLabel = $this->invoiceDateLabel($fromDate); + $toLabel = $this->invoiceDateLabel($toDate); + + if ($fromLabel !== '' && $toLabel !== '' && $fromLabel !== $toLabel) { + return $fromLabel . ' - ' . $toLabel; + } + if ($fromLabel !== '') { + return $fromLabel; + } + if ($toLabel !== '') { + return $toLabel; + } + + return ''; + } + + private function invoiceDateLabel(mixed $value): string + { + if ($value === null) { + return ''; + } + + $raw = trim((string)$value); + if ($raw === '' || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') { + return ''; + } + + $timestamp = strtotime($raw); + if ($timestamp === false) { + return ''; + } + + return date('Y-m-d', $timestamp); + } + + /** + * @return array> + */ + private function buildVehicleDocuments(): array + { + $rows = $this->fetchRows( + "SELECT id AS entity_id, customer_id AS customer_number, reg, reference, type, created_at, updated_at" + . " FROM customer_vehicles WHERE deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $documents[] = $this->makeDocument( + 'vehicles', + (string)($row['entity_id'] ?? ''), + (string)(($row['reg'] ?? '') ?: ('Vehicle #' . ($row['entity_id'] ?? ''))), + (string)($row['reference'] ?? ''), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['reg'] ?? null, + $row['reference'] ?? null, + $row['type'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_id' => $this->toIntOrNull($row['customer_number'] ?? null), + 'reg' => $row['reg'] ?? null, + 'reference' => $row['reference'] ?? null, + 'type' => $row['type'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildCustomerDiscountDocuments(): array + { + $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id'; + $selectFields = [ + 'po.id AS entity_id', + 'po.user_id', + 'po.is_category', + 'po.product_or_category_id', + 'po.percentage', + 'u.customer_number', + 'u.display_name', + ...$this->joinTemporalSelectFields('price_overrides', 'po'), + ]; + + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; + $selectFields = [ + ...$selectFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + } + + $rows = $this->fetchRows("SELECT " . implode(', ', $selectFields) . " FROM " . $fromClause); + + $documents = []; + foreach ($rows as $row) { + $customerDisplay = trim((string)($row['economic_name'] ?? '')); + if ($customerDisplay === '') { + $customerDisplay = trim((string)($row['display_name'] ?? '')); + } + + $documents[] = $this->makeDocument( + 'customer_discounts', + (string)($row['entity_id'] ?? ''), + 'Discount #' . (string)($row['entity_id'] ?? ''), + (string)('Customer ' . ($row['customer_number'] ?? '') . ' / ' . $customerDisplay), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['display_name'] ?? null, + $row['economic_name'] ?? null, + $row['economic_address'] ?? null, + $row['economic_city'] ?? null, + $row['economic_zip'] ?? null, + $row['economic_email'] ?? null, + $row['economic_cvr'] ?? null, + $row['economic_mobile_phone'] ?? null, + $row['search_text'] ?? null, + $row['product_or_category_id'] ?? null, + $row['percentage'] ?? null, + $row['user_id'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'user_id' => $this->toIntOrNull($row['user_id'] ?? null), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'product_or_category_id' => $row['product_or_category_id'] ?? null, + 'percentage' => $this->toIntOrNull($row['percentage'] ?? null), + 'economic_name' => $row['economic_name'] ?? null, + 'economic_cvr' => $row['economic_cvr'] ?? null, + 'is_category' => $row['is_category'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildCustomerFixedPriceDocuments(): array + { + $fromClause = 'customer_fixed_pricing cfp'; + $selectFields = [ + 'cfp.id AS entity_id', + 'cfp.customer_number', + 'cfp.price', + 'cfp.description', + ...$this->joinTemporalSelectFields('customer_fixed_pricing', 'cfp'), + ]; + + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = cfp.customer_number'; + $selectFields = [ + ...$selectFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + } + + $rows = $this->fetchRows("SELECT " . implode(', ', $selectFields) . " FROM " . $fromClause); + + $documents = []; + foreach ($rows as $row) { + $description = trim((string)($row['description'] ?? '')); + if ($description === '') { + $description = trim((string)($row['economic_name'] ?? '')); + } + + $documents[] = $this->makeDocument( + 'customer_fixed_prices', + (string)($row['entity_id'] ?? ''), + 'Fixed pricing #' . (string)($row['entity_id'] ?? ''), + $description, + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['customer_number'] ?? null, + $row['price'] ?? null, + $row['description'] ?? null, + $row['economic_name'] ?? null, + $row['economic_address'] ?? null, + $row['economic_city'] ?? null, + $row['economic_zip'] ?? null, + $row['economic_email'] ?? null, + $row['economic_cvr'] ?? null, + $row['economic_mobile_phone'] ?? null, + $row['search_text'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + null, + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'price' => $this->toIntOrNull($row['price'] ?? null), + 'description' => $row['description'] ?? null, + 'economic_name' => $row['economic_name'] ?? null, + 'economic_cvr' => $row['economic_cvr'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildModuleConfigDocuments(): array + { + $rows = $this->fetchRows( + "SELECT module, variable, type, created_at, updated_at" + . " FROM module_config" + ); + + $documents = []; + foreach ($rows as $row) { + $variable = (string)($row['variable'] ?? ''); + if ($variable === '' || $this->looksSecretVariable($variable)) { + continue; + } + + $module = (string)($row['module'] ?? ''); + $entityId = $module . ':' . $variable; + $documents[] = $this->makeDocument( + 'module_config', + $entityId, + $module . '.' . $variable, + (string)($row['type'] ?? ''), + $this->implodeSearchText([$module, $variable, $row['type'] ?? null]), + null, + null, + [ + 'module' => $module, + 'variable' => $variable, + 'type' => $row['type'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildObjectAttachmentDocuments(): array + { + $temporalSelect = $this->joinTemporalSelectFields('object_attachments', 'oa'); + $taskSelect = []; + $taskColumns = $this->getColumns('department_selfserve_tasks'); + foreach (['task', 'description', 'department'] as $column) { + if (in_array($column, $taskColumns, true)) { + $taskSelect[] = 'dst.' . $column . ' AS task_' . $column; + } + } + + $taskDepartmentJoin = ''; + if ($this->tableExists('departments') && in_array('department', $taskColumns, true) && in_array('name', $this->getColumns('departments'), true)) { + $taskDepartmentJoin = ' LEFT JOIN departments d ON d.id = dst.department'; + $taskSelect[] = 'd.name AS task_department_name'; + } + + $customerSelect = []; + $customerJoin = ''; + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $customerJoin = ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = o.customer_id'; + $customerSelect = [ + 'COALESCE(sci.economic_name, sci.local_display_name) AS customer_name', + 'COALESCE(sci.economic_email, sci.local_email) AS customer_email', + 'COALESCE(sci.economic_mobile_phone, sci.local_phone) AS customer_phone', + 'sci.economic_cvr AS customer_cvr', + 'sci.economic_barred AS customer_barred', + ]; + } + + $rows = $this->fetchRows( + "SELECT oa.id AS entity_id, oa.object_type, oa.object_id, oa.content" + . (!empty($temporalSelect) ? (', ' . implode(', ', $temporalSelect)) : '') + . ", o.customer_id AS customer_number, o.department_id, o.reference AS order_reference" + . (!empty($taskSelect) ? (', ' . implode(', ', $taskSelect)) : '') + . (!empty($customerSelect) ? (', ' . implode(', ', $customerSelect)) : '') + . " FROM object_attachments oa" + . " LEFT JOIN orders o ON oa.object_type = 'orders' AND o.id = oa.object_id AND o.deleted_at IS NULL" + . " LEFT JOIN department_selfserve_tasks dst ON oa.object_type = 'department_selfserve_tasks' AND dst.id = oa.object_id AND dst.deleted_at IS NULL" + . $taskDepartmentJoin + . $customerJoin + . " WHERE oa.deleted_at IS NULL" + ); + + $documents = []; + foreach ($rows as $row) { + $content = $row['content'] ?? null; + $contentText = is_string($content) ? $content : json_encode($content, JSON_UNESCAPED_UNICODE); + $attachmentName = ''; + $decodedContent = is_string($content) ? json_decode($content, true) : null; + if (is_array($decodedContent)) { + $attachmentName = trim((string)($decodedContent['other'] ?? '')); + } + + $title = trim($attachmentName); + if ($title === '') { + if (($row['object_type'] ?? '') === 'orders') { + $title = 'Order attachment #' . (string)($row['object_id'] ?? ''); + } elseif (($row['object_type'] ?? '') === 'department_selfserve_tasks') { + $title = 'Task attachment #' . (string)($row['object_id'] ?? ''); + } else { + $title = 'Attachment #' . (string)($row['entity_id'] ?? ''); + } + } + + $descriptionParts = []; + if (!empty($row['order_reference'])) { + $descriptionParts[] = 'Order ref ' . (string)$row['order_reference']; + } + if (!empty($row['customer_name'])) { + $descriptionParts[] = (string)$row['customer_name']; + } + if (!empty($row['task_task'])) { + $descriptionParts[] = (string)$row['task_task']; + } + if (!empty($row['task_department_name'])) { + $descriptionParts[] = (string)$row['task_department_name']; + } + if (!empty($row['task_description'])) { + $descriptionParts[] = (string)$row['task_description']; + } + + $documents[] = $this->makeDocument( + 'objects', + (string)($row['entity_id'] ?? ''), + $title, + implode(' / ', array_slice($descriptionParts, 0, 2)), + $this->implodeSearchText([ + $row['entity_id'] ?? null, + $row['object_type'] ?? null, + $row['object_id'] ?? null, + $attachmentName, + $contentText, + $row['customer_number'] ?? null, + $row['customer_name'] ?? null, + $row['customer_email'] ?? null, + $row['customer_phone'] ?? null, + $row['customer_cvr'] ?? null, + $row['order_reference'] ?? null, + $row['task_task'] ?? null, + $row['task_department_name'] ?? null, + $row['task_description'] ?? null, + ]), + $this->toIntOrNull($row['customer_number'] ?? null), + $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), + [ + 'id' => $this->toIntOrNull($row['entity_id'] ?? null), + 'object_type' => $row['object_type'] ?? null, + 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'linked_entity_type' => $row['object_type'] ?? null, + 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'customer_name' => $row['customer_name'] ?? null, + 'customer_email' => $row['customer_email'] ?? null, + 'customer_phone' => $row['customer_phone'] ?? null, + 'customer_cvr' => $row['customer_cvr'] ?? null, + 'customer_barred' => isset($row['customer_barred']) ? ((int)$row['customer_barred'] === 1) : null, + 'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), + 'order_reference' => $row['order_reference'] ?? null, + 'task_title' => $row['task_task'] ?? null, + 'task_description' => $row['task_description'] ?? null, + 'task_department' => $this->toIntOrNull($row['task_department'] ?? null), + 'task_department_name' => $row['task_department_name'] ?? null, + ], + $row + ); + } + + return $documents; + } + + /** + * @return array> + */ + private function buildGenericDocuments(string $entityType): array + { + $config = system_search_registry::genericEntityConfigs()[$entityType] ?? null; + if (!is_array($config)) { + return []; + } + + $table = trim((string)($config['table'] ?? '')); + if ($table === '' || !$this->tableExists($table)) { + return []; + } + + $columns = $this->getColumns($table); + if (empty($columns)) { + return []; + } + + $idField = (string)($config['id_field'] ?? (in_array('id', $columns, true) ? 'id' : $columns[0])); + if (!in_array($idField, $columns, true)) { + return []; + } + + $customerField = null; + if (isset($config['customer_field']) && is_string($config['customer_field']) && in_array($config['customer_field'], $columns, true)) { + $customerField = $config['customer_field']; + } + $customerFieldMode = isset($config['customer_field_mode']) && is_string($config['customer_field_mode']) + ? trim(mb_strtolower($config['customer_field_mode'])) + : 'default'; + if ($customerFieldMode === '') { + $customerFieldMode = 'default'; + } + + $departmentField = null; + if (isset($config['department_field']) && is_string($config['department_field']) && in_array($config['department_field'], $columns, true)) { + $departmentField = $config['department_field']; + } + + $excludedColumns = []; + if (isset($config['exclude_columns']) && is_array($config['exclude_columns'])) { + $excludedColumns = array_values(array_filter($config['exclude_columns'], static fn($value) => is_string($value) && $value !== '')); + } + + $searchable = []; + if (isset($config['search_fields']) && is_array($config['search_fields']) && !empty($config['search_fields'])) { + $configured = array_values(array_filter($config['search_fields'], static fn($value) => is_string($value) && $value !== '')); + $configured = array_values(array_intersect($configured, $columns)); + $searchable = $this->sanitizeGenericSearchFields($configured, $excludedColumns); + } + if (empty($searchable)) { + $searchable = $this->sanitizeGenericSearchFields($columns, $excludedColumns); + } + if (empty($searchable)) { + return []; + } + + $selectFields = array_values(array_unique(array_filter([ + $idField, + $customerField, + $departmentField, + ...$searchable, + ], static fn($value) => is_string($value) && $value !== ''))); + $selectFields = $this->appendTemporalColumns($table, $selectFields); + if (count($selectFields) > 32) { + $selectFields = array_slice($selectFields, 0, 32); + } + + $fixedConditions = []; + if (isset($config['fixed_conditions']) && is_array($config['fixed_conditions'])) { + foreach ($config['fixed_conditions'] as $column => $value) { + if (!is_string($column) || !in_array($column, $columns, true)) { + continue; + } + $fixedConditions[$column] = $value; + } + } + if (!array_key_exists('deleted_at', $fixedConditions) && in_array('deleted_at', $columns, true)) { + $fixedConditions['deleted_at'] = null; + } + + $whereClauses = []; + foreach ($fixedConditions as $column => $value) { + if ($value === null) { + $whereClauses[] = "`$column` IS NULL"; + } else { + $whereClauses[] = "`$column` = " . $this->sqlString((string)$value); + } + } + + $rows = $this->fetchRows( + "SELECT " . implode(', ', array_map(static fn($field) => "`$field`", $selectFields)) + . " FROM `$table`" + . (!empty($whereClauses) ? (' WHERE ' . implode(' AND ', $whereClauses)) : '') + ); + + $titleFields = []; + if (isset($config['title_fields']) && is_array($config['title_fields'])) { + $titleFields = array_values(array_filter($config['title_fields'], static fn($value) => is_string($value) && in_array($value, $selectFields, true))); + } + if (empty($titleFields)) { + $titleFields = array_values(array_intersect( + ['name', 'title', 'display_name', 'reference', 'reference_number', 'reg', 'reg_1', 'plate', 'module', 'customer_number', 'id'], + $selectFields + )); + } + + $descriptionFields = []; + if (isset($config['description_fields']) && is_array($config['description_fields'])) { + $descriptionFields = array_values(array_filter($config['description_fields'], static fn($value) => is_string($value) && in_array($value, $selectFields, true))); + } + if (empty($descriptionFields)) { + $descriptionFields = array_values(array_intersect( + ['description', 'note', 'notes', 'email', 'status', 'type', 'city', 'address', 'action', 'message', 'customer_id'], + $selectFields + )); + } + + $entityLabel = ucfirst(str_replace('_', ' ', $entityType)); + $documents = []; + foreach ($rows as $row) { + $entityId = isset($row[$idField]) ? (string)$row[$idField] : ''; + if ($entityId === '') { + continue; + } + + $title = ''; + foreach ($titleFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $title = $value; + break; + } + if ($entityType === 'department_goals') { + $title = $this->departmentGoalResultTitle($row['criteria'] ?? null, $entityId, $title); + } + if ($title === '') { + $title = $entityLabel . ' #' . $entityId; + } + + $descriptionParts = []; + foreach ($descriptionFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $descriptionParts[] = $value; + if (count($descriptionParts) >= 2) { + break; + } + } + + $payload = array_intersect_key($row, array_flip($selectFields)); + $documents[] = $this->makeDocument( + $entityType, + $entityId, + $title, + implode(' / ', $descriptionParts), + $this->implodeSearchText(array_map(static fn($field) => $row[$field] ?? null, $searchable)), + $customerField !== null ? $this->resolveConfiguredCustomerNumber($row[$customerField] ?? null, $customerFieldMode) : null, + $departmentField !== null ? $this->toIntOrNull($row[$departmentField] ?? null) : null, + $payload, + $row + ); + } + + return $documents; + } + + /** + * @param array $candidateFields + * @param array $searchFields + * @param array $titleFields + * @param array $descriptionFields + * @return array> + */ + private function buildSimpleTableDocuments( + string $entityType, + string $table, + array $candidateFields, + array $searchFields, + array $titleFields, + array $descriptionFields + ): array { + if (!$this->tableExists($table)) { + return []; + } + + $fields = $this->appendTemporalColumns($table, $this->intersectExistingColumns($table, $candidateFields)); + if (empty($fields) || !in_array('id', $fields, true)) { + return []; + } + + $rows = $this->fetchRows( + "SELECT " . implode(', ', array_map(static fn($field) => "`$field`", $fields)) + . " FROM `$table`" + ); + + $documents = []; + foreach ($rows as $row) { + $entityId = isset($row['id']) ? (string)$row['id'] : ''; + if ($entityId === '') { + continue; + } + + $title = ''; + foreach ($titleFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $title = $value; + break; + } + if ($title === '') { + $title = ucfirst(rtrim(str_replace('_', ' ', $entityType), 's')) . ' #' . $entityId; + } + + $descriptionParts = []; + foreach ($descriptionFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $descriptionParts[] = $value; + } + + $documents[] = $this->makeDocument( + $entityType, + $entityId, + $title, + implode(' / ', array_slice($descriptionParts, 0, 2)), + $this->implodeSearchText(array_map(static fn($field) => $row[$field] ?? null, $searchFields)), + null, + null, + array_intersect_key($row, array_flip($fields)), + $row + ); + } + + return $documents; + } + + /** + * @param array> $documents + */ + private function replaceDocumentsForType(string $entityType, array $documents): void + { + global $db; + if (!is_object($db) || !method_exists($db, 'query')) { + return; + } + + $escapedType = $db->escape_string($entityType); + $db->query("DELETE FROM `" . self::TABLE . "` WHERE `entity_type` = '" . $escapedType . "'"); + + if (empty($documents)) { + return; + } + + foreach (array_chunk($documents, 100) as $chunk) { + $values = []; + foreach ($chunk as $document) { + $values[] = '(' + . $this->sqlString((string)$document['entity_type']) . ', ' + . $this->sqlString((string)$document['entity_id']) . ', ' + . $this->sqlNullableInt($document['customer_number'] ?? null) . ', ' + . $this->sqlNullableInt($document['department_id'] ?? null) . ', ' + . $this->sqlNullableString($document['title'] ?? null) . ', ' + . $this->sqlNullableString($document['description'] ?? null) . ', ' + . $this->sqlNullableString($document['search_text'] ?? null) . ', ' + . $this->sqlNullableString($document['payload_json'] ?? null) . ', ' + . $this->sqlNullableString($document['created_at'] ?? null) . ', ' + . $this->sqlNullableString($document['updated_at'] ?? null) + . ')'; + } + + $db->query( + "INSERT INTO `" . self::TABLE . "` " + . "(`entity_type`, `entity_id`, `customer_number`, `department_id`, `title`, `description`, `search_text`, `payload_json`, `created_at`, `updated_at`) VALUES " + . implode(', ', $values) + ); + } + } + + /** + * @param array $payload + * @param array $row + * @return array + */ + private function makeDocument( + string $entityType, + string $entityId, + string $title, + string $description, + string $searchText, + ?int $customerNumber, + ?int $departmentId, + array $payload, + array $row + ): array { + foreach (['created_at', 'updated_at'] as $column) { + if (array_key_exists($column, $row)) { + $payload[$column] = $row[$column]; + } + } + + return [ + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'customer_number' => $customerNumber, + 'department_id' => $departmentId, + 'title' => trim($title), + 'description' => trim($description), + 'search_text' => trim($searchText), + 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE), + 'created_at' => isset($row['created_at']) ? (string)$row['created_at'] : null, + 'updated_at' => isset($row['updated_at']) ? (string)$row['updated_at'] : null, + ]; + } + + /** + * @param array $fields + * @return array + */ + private function appendTemporalColumns(string $table, array $fields): array + { + $columns = $this->getColumns($table); + foreach (['updated_at', 'created_at'] as $column) { + if (in_array($column, $columns, true) && !in_array($column, $fields, true)) { + $fields[] = $column; + } + } + return array_values(array_unique($fields)); + } + + /** + * @return array + */ + private function joinTemporalSelectFields(string $table, string $alias): array + { + $fields = []; + $columns = $this->getColumns($table); + $aliasPrefix = trim($alias) === '' ? '' : (trim($alias) . '.'); + foreach (['updated_at', 'created_at'] as $column) { + if (!in_array($column, $columns, true)) { + continue; + } + $fields[] = $aliasPrefix . $column . ' AS ' . $column; + } + return $fields; + } + + /** + * @param array $parts + */ + private function implodeSearchText(array $parts): string + { + return trim(implode(' ', array_values(array_filter(array_map( + static function ($value): ?string { + if ($value === null) { + return null; + } + $string = trim((string)$value); + return $string === '' ? null : $string; + }, + $parts + ))))); + } + + /** + * @param array $columns + * @param array $excludeColumns + * @return array + */ + private function sanitizeGenericSearchFields(array $columns, array $excludeColumns = []): array + { + $excluded = array_values(array_unique(array_map(static fn($value) => mb_strtolower((string)$value), $excludeColumns))); + $filtered = []; + foreach ($columns as $column) { + if (!is_string($column) || $column === '') { + continue; + } + $lower = mb_strtolower($column); + if (in_array($lower, $excluded, true)) { + continue; + } + if (in_array($lower, ['created_at', 'updated_at'], true)) { + continue; + } + if (preg_match('/(?:^|_)(password|token|secret|api_key|apikey|private|credential|passkey|session|hash|salt|client_secret|refresh_token|access_token)(?:_|$)/i', $lower)) { + continue; + } + if (in_array($lower, ['data', 'content', 'payload', 'config', 'permissions', 'washitems', 'client_secret'], true)) { + continue; + } + $filtered[] = $column; + } + return array_values(array_unique($filtered)); + } + + /** + * @return array> + */ + private function fetchRows(string $sql): array + { + global $db; + if (!is_object($db) || !method_exists($db, 'query')) { + return []; + } + + try { + $result = $db->query($sql); + if (!($result instanceof \mysqli_result)) { + return []; + } + return $db->fetch_all($result); + } catch (Throwable) { + return []; + } + } + + private function tableExists(string $table): bool + { + return !empty($this->getColumns($table)); + } + + /** + * @param array $candidateFields + * @return array + */ + private function intersectExistingColumns(string $table, array $candidateFields): array + { + $columns = $this->getColumns($table); + if (empty($columns)) { + return []; + } + return array_values(array_intersect($candidateFields, $columns)); + } + + /** + * @return array + */ + private function getColumns(string $table): array + { + if (isset($this->tableColumnsCache[$table])) { + return $this->tableColumnsCache[$table]; + } + + global $db; + try { + if (!is_object($db) || !method_exists($db, 'query')) { + $this->tableColumnsCache[$table] = []; + return []; + } + $result = $db->query("SHOW COLUMNS FROM `$table`"); + if (!($result instanceof \mysqli_result)) { + $this->tableColumnsCache[$table] = []; + return []; + } + $rows = $db->fetch_all($result); + $columns = array_values(array_map(static fn($row) => (string)$row['Field'], $rows)); + $this->tableColumnsCache[$table] = $columns; + return $columns; + } catch (Throwable) { + $this->tableColumnsCache[$table] = []; + return []; + } + } + + private function looksSecretVariable(string $variable): bool + { + $variable = mb_strtolower($variable); + return str_contains($variable, 'api_key') + || str_contains($variable, 'secret') + || str_contains($variable, 'password') + || str_contains($variable, 'token') + || str_contains($variable, 'private_key'); + } + + private function sqlString(string $value): string + { + global $db; + return "'" . $db->escape_string($value) . "'"; + } + + private function sqlNullableString(?string $value): string + { + if ($value === null || trim($value) === '') { + return 'NULL'; + } + return $this->sqlString($value); + } + + private function sqlNullableInt(mixed $value): string + { + $intValue = $this->toIntOrNull($value); + return $intValue === null ? 'NULL' : (string)$intValue; + } + + private function toIntOrNull(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_string($value) && preg_match('/^-?\d+$/', $value)) { + return (int)$value; + } + if (is_float($value)) { + return (int)$value; + } + return null; + } + + private function resolveConfiguredCustomerNumber(mixed $value, string $mode = 'default'): ?int + { + if ($mode !== 'digits_only') { + return $this->toIntOrNull($value); + } + + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (!is_string($value)) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '' || !preg_match('/^\d+$/', $trimmed)) { + return null; + } + + $resolved = (int)$trimmed; + return $resolved > 0 ? $resolved : null; + } + + private function departmentGoalResultTitle(mixed $criteria, mixed $entityId = null, string $fallback = ''): string + { + $label = $this->departmentGoalLabelFromCriteria($criteria); + if ($label !== '') { + return $label; + } + + $trimmedFallback = trim($fallback); + if ($trimmedFallback !== '') { + return $trimmedFallback; + } + + $resolvedId = $this->toIntOrNull($entityId); + return $resolvedId !== null && $resolvedId > 0 + ? 'Department goal #' . $resolvedId + : 'Department goal'; + } + + private function departmentGoalLabelFromCriteria(mixed $criteria): string + { + $decoded = null; + if (is_array($criteria)) { + $decoded = $criteria; + } elseif (is_string($criteria) && trim($criteria) !== '') { + $decodedValue = json_decode($criteria, true); + if (is_array($decodedValue)) { + $decoded = $decodedValue; + } + } + + if (!is_array($decoded)) { + return ''; + } + + return trim((string)($decoded['label'] ?? '')); + } +} diff --git a/services/nginx/app/classes/system_search_economic_customer_index.php b/services/nginx/app/classes/system_search_economic_customer_index.php new file mode 100644 index 00000000..db9a0667 --- /dev/null +++ b/services/nginx/app/classes/system_search_economic_customer_index.php @@ -0,0 +1,382 @@ +query($sql); + self::ensureColumn( + 'economic_barred', + "ALTER TABLE `" . self::TABLE . "` ADD COLUMN `economic_barred` TINYINT(1) NULL AFTER `economic_mobile_phone`" + ); + self::$initialized = true; + } + + /** + * @param array $customerNumbers + * @return array> + */ + public static function fetchContexts(array $customerNumbers): array + { + self::ensureTable(); + + $normalized = array_values(array_unique(array_filter( + array_map('intval', $customerNumbers), + static fn(int $value): bool => $value > 0 + ))); + if (empty($normalized)) { + return []; + } + + global $db; + if (!is_object($db) || !method_exists($db, 'query')) { + return []; + } + + $result = $db->query( + "SELECT `customer_number`, `user_id`, `local_display_name`, `local_email`, `local_phone`," + . " `economic_name`, `economic_address`, `economic_city`, `economic_zip`, `economic_email`," + . " `economic_cvr`, `economic_mobile_phone`, `economic_barred`" + . " FROM `" . self::TABLE . "`" + . " WHERE `customer_number` IN (" . implode(',', $normalized) . ")" + ); + if (!($result instanceof \mysqli_result)) { + return []; + } + + $contexts = []; + while ($row = $result->fetch_assoc()) { + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($customerNumber <= 0) { + continue; + } + + $barred = self::toNullableBool($row['economic_barred'] ?? null); + $contexts[$customerNumber] = [ + 'customer_number' => $customerNumber, + 'user_id' => self::toNullableInt($row['user_id'] ?? null), + 'name' => self::toNullableString($row['economic_name'] ?? null) + ?? self::toNullableString($row['local_display_name'] ?? null), + 'barred' => $barred, + 'status' => self::barredStatus($barred), + 'email' => self::toNullableString($row['economic_email'] ?? null) + ?? self::toNullableString($row['local_email'] ?? null), + 'phone' => self::toNullableString($row['economic_mobile_phone'] ?? null) + ?? self::toNullableString($row['local_phone'] ?? null), + 'cvr' => self::toNullableString($row['economic_cvr'] ?? null), + 'address' => self::toNullableString($row['economic_address'] ?? null), + 'city' => self::toNullableString($row['economic_city'] ?? null), + 'zip' => self::toNullableString($row['economic_zip'] ?? null), + ]; + } + + return $contexts; + } + + /** + * Rebuild local e-conomic customer index from local users + cached/live e-conomic snapshots. + * + * @return array + */ + public static function refreshIndex(bool $refreshEconomicData = false): array + { + self::ensureTable(); + + global $db; + if (!is_object($db) || !method_exists($db, 'query') || !property_exists($db, 'conn')) { + return [ + 'processed' => 0, + 'upserted' => 0, + 'deleted' => 0, + 'errors' => 0, + ]; + } + + $stats = [ + 'processed' => 0, + 'upserted' => 0, + 'deleted' => 0, + 'errors' => 0, + ]; + + $result = $db->query("SELECT `id`, `customer_number`, `display_name`, `email`, `phone` + FROM `users` + WHERE `customer_number` IS NOT NULL + AND `customer_number` <> 0"); + if (!($result instanceof \mysqli_result)) { + return $stats; + } + + $rows = $db->fetch_all($result); + $seenCustomerNumbers = []; + + foreach ($rows as $row) { + $stats['processed']++; + + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($customerNumber <= 0) { + continue; + } + $seenCustomerNumbers[$customerNumber] = true; + + $economic = []; + try { + $tmpUser = new users_o(); + $tmpUser->getUserByCustomerNumber($customerNumber); + if ($refreshEconomicData) { + $tmpUser->getCustomerEcocomicData($customerNumber); + } + $cached = $tmpUser->getCached('economic_customer'); + if (!$cached && !$refreshEconomicData) { + $tmpUser->getCustomerEcocomicData($customerNumber); + $cached = $tmpUser->getCached('economic_customer'); + } + $economic = self::normalizeEconomicSnapshot($cached); + } catch (Throwable) { + $stats['errors']++; + } + + $localDisplayName = self::toNullableString($row['display_name'] ?? null); + $localEmail = self::toNullableString($row['email'] ?? null); + $localPhone = self::toNullableString($row['phone'] ?? null); + + $economicName = self::toNullableString($economic['name'] ?? null); + $economicAddress = self::toNullableString($economic['address'] ?? null); + $economicCity = self::toNullableString($economic['city'] ?? null); + $economicZip = self::toNullableString($economic['zip'] ?? null); + $economicEmail = self::toNullableString($economic['email'] ?? null); + $economicCvr = self::toNullableString($economic['corporateIdentificationNumber'] ?? null); + $economicMobilePhone = self::toNullableString($economic['mobilePhone'] ?? null); + $economicBarred = self::toNullableBool($economic['barred'] ?? null); + + $searchText = trim(implode(' ', array_values(array_filter([ + $customerNumber > 0 ? (string)$customerNumber : null, + $localDisplayName, + $localEmail, + $localPhone, + $economicName, + $economicAddress, + $economicCity, + $economicZip, + $economicEmail, + $economicCvr, + $economicMobilePhone, + ], static fn($v) => is_string($v) && trim($v) !== '')))); + if ($searchText === '') { + $searchText = null; + } + + $sql = "INSERT INTO `" . self::TABLE . "` ( + `customer_number`, + `user_id`, + `local_display_name`, + `local_email`, + `local_phone`, + `economic_name`, + `economic_address`, + `economic_city`, + `economic_zip`, + `economic_email`, + `economic_cvr`, + `economic_mobile_phone`, + `economic_barred`, + `search_text` + ) VALUES ( + " . (int)$customerNumber . ", + " . (int)($row['id'] ?? 0) . ", + " . self::sqlNullableString($localDisplayName) . ", + " . self::sqlNullableString($localEmail) . ", + " . self::sqlNullableString($localPhone) . ", + " . self::sqlNullableString($economicName) . ", + " . self::sqlNullableString($economicAddress) . ", + " . self::sqlNullableString($economicCity) . ", + " . self::sqlNullableString($economicZip) . ", + " . self::sqlNullableString($economicEmail) . ", + " . self::sqlNullableString($economicCvr) . ", + " . self::sqlNullableString($economicMobilePhone) . ", + " . self::sqlNullableBool($economicBarred) . ", + " . self::sqlNullableString($searchText) . " + ) ON DUPLICATE KEY UPDATE + `user_id` = VALUES(`user_id`), + `local_display_name` = VALUES(`local_display_name`), + `local_email` = VALUES(`local_email`), + `local_phone` = VALUES(`local_phone`), + `economic_name` = VALUES(`economic_name`), + `economic_address` = VALUES(`economic_address`), + `economic_city` = VALUES(`economic_city`), + `economic_zip` = VALUES(`economic_zip`), + `economic_email` = VALUES(`economic_email`), + `economic_cvr` = VALUES(`economic_cvr`), + `economic_mobile_phone` = VALUES(`economic_mobile_phone`), + `economic_barred` = VALUES(`economic_barred`), + `search_text` = VALUES(`search_text`), + `updated_at` = CURRENT_TIMESTAMP"; + $db->query($sql); + $stats['upserted']++; + } + + $seen = array_keys($seenCustomerNumbers); + if (empty($seen)) { + $db->query("DELETE FROM `" . self::TABLE . "`"); + $stats['deleted'] = self::safeAffectedRows(); + return $stats; + } + + $in = implode(',', array_map('intval', $seen)); + $db->query("DELETE FROM `" . self::TABLE . "` WHERE `customer_number` NOT IN (" . $in . ")"); + $stats['deleted'] = self::safeAffectedRows(); + + return $stats; + } + + /** + * @return array + */ + private static function normalizeEconomicSnapshot(mixed $snapshot): array + { + if (is_object($snapshot)) { + return get_object_vars($snapshot); + } + if (is_array($snapshot)) { + return $snapshot; + } + return []; + } + + private static function toNullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + $string = trim((string)$value); + return $string === '' ? null : $string; + } + + private static function toNullableInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_numeric($value) && (string)(int)$value === trim((string)$value)) { + return (int)$value; + } + return null; + } + + private static function toNullableBool(mixed $value): ?bool + { + if ($value === null || $value === '') { + return null; + } + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return $value !== 0; + } + if (is_string($value)) { + $normalized = trim(mb_strtolower($value)); + if ($normalized === '') { + return null; + } + if (in_array($normalized, ['1', 'true', 'yes'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no'], true)) { + return false; + } + } + return null; + } + + private static function sqlNullableString(?string $value): string + { + global $db; + if ($value === null) { + return 'NULL'; + } + return "'" . $db->escape_string($value) . "'"; + } + + private static function sqlNullableBool(?bool $value): string + { + if ($value === null) { + return 'NULL'; + } + return $value ? '1' : '0'; + } + + private static function ensureColumn(string $column, string $alterSql): void + { + global $db; + if (!is_object($db) || !method_exists($db, 'query')) { + return; + } + + $result = $db->query( + "SHOW COLUMNS FROM `" . self::TABLE . "` LIKE '" . $db->escape_string($column) . "'" + ); + if ($result instanceof \mysqli_result && $result->num_rows === 0) { + $db->query($alterSql); + } + } + + private static function barredStatus(?bool $barred): string + { + return match ($barred) { + true => 'barred', + false => 'active', + default => 'unknown', + }; + } + + private static function safeAffectedRows(): int + { + global $db; + if (!is_object($db) || !property_exists($db, 'conn') || !is_object($db->conn)) { + return 0; + } + return max(0, (int)($db->conn->affected_rows ?? 0)); + } +} diff --git a/services/nginx/app/classes/system_search_openai_intent_parser.php b/services/nginx/app/classes/system_search_openai_intent_parser.php new file mode 100644 index 00000000..cb5770c4 --- /dev/null +++ b/services/nginx/app/classes/system_search_openai_intent_parser.php @@ -0,0 +1,317 @@ +transport = $transport; + $this->forcedEnabled = $forcedEnabled; + $this->forcedApiKey = $forcedApiKey; + } + + public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array + { + $query = trim($query); + if ($query === '') { + return $this->failed('empty_query', 'none'); + } + + [$enabled, $apiKey] = $this->resolveOpenAISettings(); + if (!$enabled) { + return $this->failed('openai_disabled', 'none'); + } + if (empty($apiKey)) { + return $this->failed('openai_missing_key', 'none'); + } + + $redactedQuery = self::redactSensitiveQuery($query); + $payload = $this->buildPayload($redactedQuery, $allowedEntityTypes, $taxonomy); + $cacheHash = md5(json_encode([ + 'q' => $redactedQuery, + 'types' => $allowedEntityTypes, + 'taxonomy' => $taxonomy, + 'v' => 1, + ], JSON_UNESCAPED_UNICODE)); + + $cached = system_search_cache::getIntent($cacheHash); + if (is_array($cached) && isset($cached['success'])) { + $cached['source'] = 'cache'; + return $this->normalizeResult($cached, $allowedEntityTypes); + } + + try { + $raw = $this->sendRequest($payload, $apiKey); + $parsed = $this->parseResponse($raw); + $parsed['source'] = 'openai'; + system_search_cache::setIntent($cacheHash, $parsed, 3600); + return $this->normalizeResult($parsed, $allowedEntityTypes); + } catch (Throwable $e) { + return $this->failed($e->getMessage(), 'openai'); + } + } + + public static function redactSensitiveQuery(string $query): string + { + $query = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email]', $query) ?? $query; + $query = preg_replace('/\b\d{8}\b/', '[cvr]', $query) ?? $query; + $query = preg_replace('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', '[uuid]', $query) ?? $query; + $query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query; + $query = preg_replace('/\b(order|invoice|booking|customer|kunde|faktura)\s*[#:\-]?\s*\d{4,}\b/iu', '$1 [id]', $query) ?? $query; + $query = preg_replace('/\b(reg(?:istration)?|plate|license plate|nummerplade)\s*[#:\-]?\s*[a-z0-9\-]{4,10}\b/iu', '$1 [plate]', $query) ?? $query; + $query = preg_replace('/\b[a-z]{2}\s?\d{5}\b/iu', '[plate]', $query) ?? $query; + return $query; + } + + private function resolveOpenAISettings(): array + { + if ($this->forcedEnabled !== null) { + return [(bool)$this->forcedEnabled, (string)($this->forcedApiKey ?? '')]; + } + + try { + $openai = new openai(); + $enabled = (bool)$openai->config->enabled->getVariableValue(); + $apiKey = (string)$openai->config->api_key->getVariableValue(); + return [$enabled, $apiKey]; + } catch (Throwable) { + return [false, '']; + } + } + + private function buildPayload(string $query, array $allowedEntityTypes, array $taxonomy): array + { + $taxonomyText = json_encode([ + 'allowed_entity_types' => array_values($allowedEntityTypes), + 'taxonomy' => $taxonomy, + ], JSON_UNESCAPED_UNICODE); + + $prompt = "You parse user search intent into strict JSON.\n" + . "Rules:\n" + . "- Keep output concise and valid JSON only.\n" + . "- Do not invent entity types not listed in allowed_entity_types.\n" + . "- Infer what the user is trying to find, not just literal words.\n" + . "- aliases should contain user-friendly and backend-friendly equivalent terms.\n" + . "- Include cross-language/domain synonyms when likely (example: Danish 'rabat' -> 'discount').\n" + . "- If user references a customer/company by name, include hints that help find related invoices/orders/discounts.\n" + . "- confidence must be between 0 and 1.\n" + . "- association_hint should be true if related records likely needed.\n\n" + . "Context:\n" + . $taxonomyText . "\n\n" + . "User query:\n" + . $query; + + return [ + 'model' => $this->model, + 'temperature' => $this->temperature, + 'input' => [ + [ + 'role' => 'user', + 'content' => [ + ['type' => 'input_text', 'text' => $prompt], + ], + ], + ], + 'text' => [ + 'format' => [ + 'type' => 'json_schema', + 'name' => 'system_search_intent', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'success' => ['type' => 'boolean'], + 'normalized_query' => [ + 'type' => 'string', + 'maxLength' => $this->maxNormalizedQueryLength, + ], + 'aliases' => [ + 'type' => 'array', + 'maxItems' => $this->maxAliases, + 'items' => [ + 'type' => 'string', + 'maxLength' => $this->maxAliasLength, + ], + ], + 'entity_hints' => [ + 'type' => 'array', + 'maxItems' => $this->maxEntityHints, + 'items' => [ + 'type' => 'string', + 'maxLength' => $this->maxHintLength, + ], + ], + 'confidence' => [ + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 1, + ], + 'association_hint' => ['type' => 'boolean'], + 'fallback_reason' => [ + 'anyOf' => [ + ['type' => 'string', 'maxLength' => $this->maxFallbackReasonLength], + ['type' => 'null'], + ], + ], + ], + 'required' => [ + 'success', + 'normalized_query', + 'aliases', + 'entity_hints', + 'confidence', + 'association_hint', + 'fallback_reason', + ], + 'additionalProperties' => false, + ], + 'strict' => true, + ], + ], + ]; + } + + private function sendRequest(array $payload, string $apiKey): array + { + if ($this->transport !== null) { + $result = call_user_func($this->transport, $payload, $apiKey); + if (!is_array($result)) { + throw new Exception('Transport returned invalid payload'); + } + return $result; + } + + $curl = curl_init($this->apiUrl); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_POST, true); + curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds); + curl_setopt($curl, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $apiKey, + ]); + curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); + $raw = curl_exec($curl); + if ($raw === false) { + $error = curl_error($curl); + curl_close($curl); + throw new Exception('cURL error: ' . $error); + } + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + throw new Exception('Invalid JSON from OpenAI'); + } + if ($status >= 400) { + $message = $decoded['error']['message'] ?? ('OpenAI HTTP ' . $status); + throw new Exception($message); + } + return $decoded; + } + + private function parseResponse(array $response): array + { + $text = $response['output'][0]['content'][0]['text'] ?? null; + if (!is_string($text) || $text === '') { + throw new Exception('Invalid response format (missing output text)'); + } + $decoded = json_decode($text, true); + if (!is_array($decoded)) { + throw new Exception('Invalid intent JSON'); + } + return $decoded; + } + + private function normalizeResult(array $result, array $allowedEntityTypes = []): array + { + $normalizedQuery = trim((string)($result['normalized_query'] ?? '')); + if (mb_strlen($normalizedQuery) > $this->maxNormalizedQueryLength) { + $normalizedQuery = mb_substr($normalizedQuery, 0, $this->maxNormalizedQueryLength); + } + + $aliases = $this->sanitizeStringList((array)($result['aliases'] ?? []), $this->maxAliases, $this->maxAliasLength); + $entityHints = $this->sanitizeStringList((array)($result['entity_hints'] ?? []), $this->maxEntityHints, $this->maxHintLength); + if (!empty($allowedEntityTypes)) { + $entityHints = array_values(array_intersect($allowedEntityTypes, $entityHints)); + } + + $fallbackReason = null; + if (isset($result['fallback_reason']) && $result['fallback_reason'] !== null) { + $fallbackReason = trim((string)$result['fallback_reason']); + if (mb_strlen($fallbackReason) > $this->maxFallbackReasonLength) { + $fallbackReason = mb_substr($fallbackReason, 0, $this->maxFallbackReasonLength); + } + } + + return [ + 'success' => (bool)($result['success'] ?? false), + 'normalized_query' => $normalizedQuery, + 'aliases' => $aliases, + 'entity_hints' => $entityHints, + 'confidence' => max(0.0, min(1.0, (float)($result['confidence'] ?? 0.0))), + 'association_hint' => (bool)($result['association_hint'] ?? false), + 'fallback_reason' => $fallbackReason, + 'source' => (string)($result['source'] ?? 'openai'), + ]; + } + + private function sanitizeStringList(array $values, int $maxItems, int $maxLength): array + { + $result = []; + foreach ($values as $value) { + if (!is_string($value)) { + continue; + } + $item = trim(mb_strtolower($value)); + if ($item === '') { + continue; + } + if (mb_strlen($item) > $maxLength) { + $item = mb_substr($item, 0, $maxLength); + } + if (!in_array($item, $result, true)) { + $result[] = $item; + } + if (count($result) >= $maxItems) { + break; + } + } + return $result; + } + + private function failed(string $reason, string $source): array + { + return [ + 'success' => false, + 'normalized_query' => '', + 'aliases' => [], + 'entity_hints' => [], + 'confidence' => 0.0, + 'association_hint' => false, + 'fallback_reason' => $reason, + 'source' => $source, + ]; + } +} diff --git a/services/nginx/app/classes/system_search_registry.php b/services/nginx/app/classes/system_search_registry.php new file mode 100644 index 00000000..74173fe0 --- /dev/null +++ b/services/nginx/app/classes/system_search_registry.php @@ -0,0 +1,277 @@ +> + */ + public static function genericEntityConfigs(): array + { + return [ + 'bookings' => [ + 'table' => 'bookings', + 'customer_field' => 'customer_number', + 'department_field' => 'department', + 'search_fields' => ['id', 'customer_number', 'department', 'status', 'reference', 'notes', 'reg', 'reg_1', 'plate'], + ], + 'bookings_new' => [ + 'table' => 'bookings_new', + 'customer_field' => 'customer_number', + 'department_field' => 'department', + 'search_fields' => ['id', 'customer_number', 'department', 'status', 'reference', 'notes', 'reg', 'reg_1', 'plate'], + ], + 'branding' => ['table' => 'branding'], + 'categories' => ['table' => 'categories'], + 'currency_conversion_rates' => ['table' => 'currency_conversion_rates'], + 'customer_codes' => ['table' => 'customer_codes'], + 'customer_default_department' => [ + 'table' => 'customer_default_department', + 'customer_field' => 'customer_number', + 'department_field' => 'department', + 'search_fields' => ['id', 'customer_number', 'department', 'name', 'reference', 'description'], + ], + 'customer_notes' => [ + 'table' => 'customer_notes', + 'customer_field' => 'customer_id', + 'search_fields' => ['id', 'customer_id', 'title', 'note', 'notes', 'description'], + ], + 'customer_vehicles_addons' => [ + 'table' => 'customer_vehicles_addons', + 'search_fields' => ['id', 'customer_id', 'vehicle_id', 'name', 'reference', 'description', 'type'], + ], + 'department_categories' => ['table' => 'department_categories', 'department_field' => 'department'], + 'department_daily_reports' => [ + 'table' => 'department_daily_reports', + 'department_field' => 'department_id', + 'search_fields' => ['id', 'department_id', 'title', 'description', 'notes', 'status'], + ], + 'department_gates' => ['table' => 'department_gates', 'department_field' => 'department'], + 'department_goals' => ['table' => 'goals'], + 'department_lanes' => ['table' => 'department_lanes', 'department_field' => 'department'], + 'department_notification_sms' => ['table' => 'department_notification_sms', 'department_field' => 'department_id'], + 'department_relays' => ['table' => 'department_relays', 'department_field' => 'department'], + 'department_selfserve_condition_rules' => ['table' => 'department_selfserve_condition_rules'], + 'department_selfserve_conditions' => ['table' => 'department_selfserve_conditions', 'department_field' => 'department'], + 'department_selfserve_questions' => ['table' => 'department_selfserve_questions', 'department_field' => 'department'], + 'department_selfserve_tasks' => [ + 'table' => 'department_selfserve_tasks', + 'department_field' => 'department', + 'search_fields' => ['id', 'department', 'lane', 'product', 'task', 'description'], + 'title_fields' => ['task', 'description', 'id'], + 'description_fields' => ['description', 'department', 'lane', 'product'], + ], + 'department_selfserve_vehicle_conditions' => ['table' => 'department_selfserve_vehicle_conditions', 'customer_field' => 'customer_id', 'department_field' => 'department'], + 'department_time_bookings_entries' => ['table' => 'department_time_bookings_entries', 'department_field' => 'department'], + 'department_time_bookings_opening_hours' => ['table' => 'department_time_bookings_opening_hours', 'department_field' => 'department'], + 'department_time_bookings_types' => ['table' => 'department_time_bookings_types', 'department_field' => 'department'], + 'department_variables' => ['table' => 'department_variables'], + 'fxratesapi_conversion_rates' => ['table' => 'fxratesapi_conversion_rates'], + 'module_action_logs' => [ + 'table' => 'module_usage_logs', + 'search_fields' => ['id', 'module', 'action', 'message', 'customer_number', 'customer_id'], + 'title_fields' => ['action', 'module', 'id'], + 'description_fields' => ['message', 'module'], + ], + 'motorapi_lookups' => [ + 'table' => 'motorapi_lookups', + 'search_fields' => ['id', 'reg', 'plate', 'reference', 'message', 'status'], + ], + 'notifications' => [ + 'table' => 'notifications', + 'customer_field' => 'customer_number', + 'search_fields' => ['id', 'customer_number', 'customer_id', 'title', 'message', 'type', 'status'], + 'title_fields' => ['title', 'type', 'id'], + 'description_fields' => ['message', 'status'], + ], + 'order_bookings' => [ + 'table' => 'order_bookings', + 'customer_field' => 'customer_number', + 'department_field' => 'department', + 'search_fields' => ['id', 'order_id', 'customer_number', 'department', 'status', 'reference', 'notes'], + ], + 'plate_scanners' => ['table' => 'plate_scanners', 'department_field' => 'department_id'], + 'plate_scans' => [ + 'table' => 'plate_scans', + 'search_fields' => ['id', 'plate', 'number_plate', 'reg', 'status', 'message'], + ], + 'product_options' => ['table' => 'products_options', 'search_fields' => ['id', 'product_id', 'name', 'description', 'type', 'reference']], + 'products' => [ + 'table' => 'products', + 'search_fields' => ['id', 'name', 'description', 'product_number', 'reference'], + 'title_fields' => ['name', 'reference', 'id'], + 'description_fields' => ['description', 'product_number'], + ], + 'users' => [ + 'table' => 'users', + 'customer_field' => 'customer_number', + 'search_fields' => ['id', 'customer_number', 'display_name', 'email', 'phone', 'username', 'role'], + 'title_fields' => ['display_name', 'email', 'customer_number', 'id'], + 'description_fields' => ['email', 'phone', 'role'], + ], + 'stripe_module_customers' => [ + 'table' => 'stripe_module_customers', + 'customer_field' => 'customer_id', + 'search_fields' => ['id', 'customer_id', 'name', 'email', 'reference', 'status'], + ], + 'stripe_module_orders' => [ + 'table' => 'stripe_module_orders', + 'customer_field' => 'customer_id', + 'exclude_columns' => ['url'], + 'search_fields' => ['id', 'customer_id', 'reference', 'status', 'payment_intent_id'], + ], + 'stripe_payment_intents' => [ + 'table' => 'stripe_payment_intents', + 'exclude_columns' => ['client_secret', 'data'], + 'search_fields' => ['id', 'customer_id', 'status', 'reference', 'payment_method'], + ], + 'subuser_grants' => [ + 'table' => 'subuser_grants', + 'customer_field' => 'billing_customer_number', + 'search_fields' => ['id', 'subuser', 'billing_customer_number', 'name', 'description', 'reference'], + ], + 'xlvask_customers' => [ + 'table' => 'xlvask_customers', + 'customer_field' => 'externId', + 'customer_field_mode' => 'digits_only', + ], + 'xlvask_potential_order_matches' => ['table' => 'xlvask_potential_order_matches', 'customer_field' => 'customer_number', 'department_field' => 'department'], + 'xlvask_usage_log_wash_items' => ['table' => 'xlvask_usage_log_wash_items'], + 'xlvask_usage_logs' => ['table' => 'xlvask_usage_logs'], + 'xlvask_vehicle_types' => ['table' => 'xlvask_vehicle_types'], + 'xlvask_vehicles' => ['table' => 'xlvask_vehicles'], + ]; + } + + /** + * @return array + */ + public static function allEntityTypes(): array + { + return array_values(array_unique([ + 'objects', + 'module_config', + 'orders', + 'order_items', + 'customers', + 'employees', + 'subusers', + 'customer_discounts', + 'customer_fixed_prices', + 'departments', + 'permissions', + 'roles', + 'invoices', + 'vehicles', + ...array_keys(self::genericEntityConfigs()), + ])); + } + + /** + * @return array + */ + public static function indexedEntityTypes(): array + { + return array_values(array_diff(self::allEntityTypes(), ['permissions', 'subusers'])); + } + + /** + * @return array + */ + public static function sourceTablesForEntityType(string $entityType): array + { + $entityType = trim(mb_strtolower($entityType)); + $manual = [ + 'objects' => ['object_attachments', 'orders', 'department_selfserve_tasks'], + 'module_config' => ['module_config'], + 'orders' => ['orders'], + 'order_items' => ['order_items', 'orders'], + 'customers' => ['users', system_search_economic_customer_index::TABLE], + 'employees' => ['users', 'groups_permissions'], + 'subusers' => ['subusers', 'subuser_grants'], + 'customer_discounts' => ['price_overrides', 'users', system_search_economic_customer_index::TABLE], + 'customer_fixed_prices' => ['customer_fixed_pricing', system_search_economic_customer_index::TABLE], + 'departments' => ['departments'], + 'permissions' => [], + 'roles' => ['groups'], + 'invoices' => ['collected_order_invoices'], + 'vehicles' => ['customer_vehicles'], + ]; + + if (isset($manual[$entityType])) { + return $manual[$entityType]; + } + + $config = self::genericEntityConfigs()[$entityType] ?? null; + if (!is_array($config)) { + return []; + } + + $table = trim((string)($config['table'] ?? '')); + return $table === '' ? [] : [$table]; + } + + /** + * @param array $tables + * @return array + */ + public static function entityTypesForDirtyTables(array $tables): array + { + $normalizedTables = array_values(array_unique(array_filter(array_map( + static fn($table) => is_string($table) ? trim($table, " `\t\n\r\0\x0B") : '', + $tables + )))); + if (empty($normalizedTables)) { + return []; + } + + $types = []; + foreach (self::indexedEntityTypes() as $entityType) { + $sourceTables = self::sourceTablesForEntityType($entityType); + if (!empty(array_intersect($normalizedTables, $sourceTables))) { + $types[] = $entityType; + } + } + return array_values(array_unique($types)); + } + + /** + * @return array> + */ + public static function taxonomyAliases(): array + { + return [ + 'customers' => ['customer', 'account', 'company', 'kunde'], + 'orders' => ['order', 'work order'], + 'order_items' => ['order item', 'line item'], + 'invoices' => ['invoice', 'billing'], + 'vehicles' => ['vehicle', 'truck', 'plate'], + 'employees' => ['employee', 'staff'], + 'subusers' => ['subuser', 'driver'], + 'customer_discounts' => ['discount', 'price override', 'rabat'], + 'customer_fixed_prices' => ['fixed price', 'monthly agreement'], + 'departments' => ['department', 'location'], + 'permissions' => ['permission', 'acl'], + 'roles' => ['role', 'group'], + 'module_config' => ['module config', 'setting', 'configuration'], + 'objects' => ['attachment', 'object'], + 'bookings' => ['booking', 'wash booking'], + 'bookings_new' => ['new booking', 'booking queue'], + 'customer_notes' => ['customer note', 'note'], + 'order_bookings' => ['order booking', 'scheduled order'], + 'products' => ['product', 'service'], + 'product_options' => ['product option', 'addon', 'add on'], + 'plate_scans' => ['plate scan', 'license plate scan'], + 'plate_scanners' => ['plate scanner', 'license plate scanner'], + 'notifications' => ['notification', 'alert'], + 'users' => ['user', 'account user'], + 'module_action_logs' => ['module log', 'action log'], + 'motorapi_lookups' => ['motorapi lookup', 'plate lookup'], + 'xlvask_customers' => ['xlvask customer'], + 'xlvask_vehicles' => ['xlvask vehicle'], + 'xlvask_usage_logs' => ['xlvask usage log'], + 'department_daily_reports' => ['department daily report', 'daily report'], + ]; + } +} diff --git a/services/nginx/app/classes/system_search_service.php b/services/nginx/app/classes/system_search_service.php new file mode 100644 index 00000000..e836f3c9 --- /dev/null +++ b/services/nginx/app/classes/system_search_service.php @@ -0,0 +1,2965 @@ + 35, + 'orders' => 35, + 'order_bookings' => 35, + 'customers' => 35, + ]; + private array $rankingPenaltyByType = [ + 'xlvask_customers' => 90, + 'xlvask_usage_logs' => 90, + 'xlvask_vehicle_types' => 90, + 'motorapi_lookups' => 90, + 'customer_discounts' => 90, + 'department_selfserve_vehicle_conditions' => 90, + 'permissions' => 90, + 'branding' => 90, + 'order_items' => 90, + 'module_config' => 90, + ]; + private array $tableColumnsCache = []; + private array $customerContextCache = []; + + public function __construct(?system_search_intent_parser_i $intentParser = null) + { + $this->intentParser = $intentParser ?? new system_search_openai_intent_parser(); + try { + system_search_economic_customer_index::ensureTable(); + system_search_document_index::ensureTable(); + } catch (Throwable) { + // Search should work even if index bootstrap is temporarily unavailable. + } + } + + public function search(array $options): array + { + $query = trim((string)($options['query'] ?? '')); + $includeTypes = $this->normalizeTypes((array)($options['include_types'] ?? [])); + $excludeTypes = $this->normalizeTypes((array)($options['exclude_types'] ?? [])); + $allowedTypes = $this->normalizeTypes((array)($options['allowed_types'] ?? [])); + $ownOnlyTypes = $this->normalizeTypes((array)($options['own_only_types'] ?? [])); + $ownCustomerNumber = isset($options['own_customer_number']) ? (int)$options['own_customer_number'] : null; + $permissionsCatalogAll = (array)($options['permissions_catalog_all'] ?? []); + $permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []); + $moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []); + $includeAssociations = (bool)($options['include_associations'] ?? true); + $debugIntent = (bool)($options['debug_intent'] ?? false); + + $limit = (int)($options['limit'] ?? 50); + $offset = (int)($options['offset'] ?? 0); + if ($limit < 1) { + $limit = 50; + } + if ($limit > 200) { + $limit = 200; + } + if ($offset < 0) { + $offset = 0; + } + + $allTypes = $this->allEntityTypes(); + $activeTypes = empty($includeTypes) ? $allTypes : array_values(array_intersect($allTypes, $includeTypes)); + if (!empty($excludeTypes)) { + $activeTypes = array_values(array_diff($activeTypes, $excludeTypes)); + } + $activeTypes = array_values(array_intersect($activeTypes, $allowedTypes)); + + $baseMeta = [ + 'query' => $query, + 'limit' => $limit, + 'offset' => $offset, + 'allowed_types' => $activeTypes, + 'cache' => ['hit' => false], + ]; + + if ($query === '' || empty($activeTypes)) { + return [ + 'results' => [], + 'grouped_results' => $this->groupResultsByType([]), + 'meta' => [ + ...$baseMeta, + 'total' => 0, + ], + ]; + } + + $queryCacheHash = md5(json_encode([ + 'q' => $query, + 'include' => $includeTypes, + 'exclude' => $excludeTypes, + 'active' => $activeTypes, + 'limit' => $limit, + 'offset' => $offset, + 'own' => $ownCustomerNumber, + 'own_only' => $ownOnlyTypes, + 'assoc' => $includeAssociations, + 'dbg' => $debugIntent, + 'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility), + 'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)), + 'v' => 12, + ], JSON_UNESCAPED_UNICODE)); + + $cached = system_search_cache::getQuery($queryCacheHash); + if (is_array($cached) && isset($cached['results'], $cached['grouped_results'], $cached['meta'])) { + $cached['meta']['cache'] = ['hit' => true]; + return $cached; + } + + $terms = $this->buildExpandedTerms($this->tokenize($query)); + $entityBoost = []; + $initialResults = $this->executeLexicalSearch( + $activeTypes, + $terms, + $entityBoost, + $ownOnlyTypes, + $ownCustomerNumber, + $permissionsCatalogAll, + $permissionsCatalogOwn, + $moduleConfigVisibility + ); + + $intentAssociationHint = false; + $intentMeta = [ + 'invoked' => false, + 'source' => 'none', + 'status' => 'skipped', + 'confidence' => 0.0, + 'expanded_terms' => $terms, + 'entity_hints' => [], + 'fallback_reason' => null, + ]; + + $shouldInvokeIntent = !empty($terms) && ( + $this->shouldInvokeIntentParser($initialResults) + || $this->queryLooksIntentDriven($query, $terms) + ); + if ($shouldInvokeIntent) { + $intentMeta['invoked'] = true; + $taxonomy = $this->taxonomy($activeTypes); + $intent = $this->intentParser->parse($query, $activeTypes, $taxonomy); + $intentMeta['source'] = (string)($intent['source'] ?? 'none'); + $intentMeta['confidence'] = (float)($intent['confidence'] ?? 0.0); + $intentMeta['fallback_reason'] = $intent['fallback_reason'] ?? null; + $intentMeta['entity_hints'] = (array)($intent['entity_hints'] ?? []); + $intentAssociationHint = (bool)($intent['association_hint'] ?? false); + + if (!empty($intent['success'])) { + $intentMeta['status'] = 'ok'; + $boostedTypes = array_values(array_intersect($activeTypes, (array)($intent['entity_hints'] ?? []))); + foreach ($boostedTypes as $boostedType) { + $entityBoost[$boostedType] = 25; + } + $expandedTerms = $this->buildExpandedTerms([ + ...$terms, + ...$this->tokenize((string)($intent['normalized_query'] ?? '')), + ...$this->tokenize(implode(' ', (array)($intent['aliases'] ?? []))), + ...$this->hintAliasTerms($boostedTypes, $taxonomy), + ]); + $intentMeta['expanded_terms'] = $expandedTerms; + + $initialResults = $this->executeLexicalSearch( + $activeTypes, + $expandedTerms, + $entityBoost, + $ownOnlyTypes, + $ownCustomerNumber, + $permissionsCatalogAll, + $permissionsCatalogOwn, + $moduleConfigVisibility + ); + } else { + $intentMeta['status'] = 'fallback'; + } + } + + if ($includeAssociations) { + $customerNumbers = []; + foreach ($initialResults as $result) { + if (!isset($result['customer_number'])) { + continue; + } + if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) { + continue; + } + $customerNumbers[] = (int)$result['customer_number']; + } + $customerNumbers = array_values(array_unique(array_filter($customerNumbers))); + if (count($customerNumbers) > 15) { + $customerNumbers = array_slice($customerNumbers, 0, 15); + } + if (!empty($customerNumbers)) { + $associationTypes = array_values(array_intersect( + $activeTypes, + $this->associationEntityTypes() + )); + foreach ($customerNumbers as $customerNumber) { + $associated = $this->executeLexicalSearch( + $associationTypes, + [(string)$customerNumber], + [], + $ownOnlyTypes, + $ownCustomerNumber, + $permissionsCatalogAll, + $permissionsCatalogOwn, + $moduleConfigVisibility, + [$customerNumber] + ); + foreach ($associated as &$item) { + if (!isset($item['association_reason'])) { + $item['association_reason'] = 'customer:' . $customerNumber; + } + $item['score'] = max((int)$item['score'], 35); + } + $initialResults = $this->mergeResults($initialResults, $associated); + } + } + } + + $preferRecency = $this->shouldPreferRecencySort($query, $terms); + usort($initialResults, function (array $a, array $b) use ($preferRecency): int { + $scoreA = (int)($a['score'] ?? 0); + $scoreB = (int)($b['score'] ?? 0); + $effectiveScoreA = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a); + $effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($b); + $recencyA = $this->resultRecencyTimestamp($a); + $recencyB = $this->resultRecencyTimestamp($b); + $cancelledA = $this->isCancelledBookingResult($a); + $cancelledB = $this->isCancelledBookingResult($b); + + // Cancelled bookings must never outrank active bookings. + if ($cancelledA !== $cancelledB) { + return $cancelledA ? 1 : -1; + } + + if ($preferRecency && $recencyA !== $recencyB) { + if ($effectiveScoreA === $effectiveScoreB || abs($effectiveScoreA - $effectiveScoreB) <= $this->recencyScoreTolerance) { + return $recencyB <=> $recencyA; + } + } + + if ($effectiveScoreA !== $effectiveScoreB) { + return $effectiveScoreB <=> $effectiveScoreA; + } + if ($scoreA !== $scoreB) { + return $scoreB <=> $scoreA; + } + if ($recencyA !== $recencyB) { + return $recencyB <=> $recencyA; + } + return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']); + }); + + $total = count($initialResults); + $paged = array_slice($initialResults, $offset, $limit); + $grouped = $this->groupResultsByType($paged); + + $meta = [ + ...$baseMeta, + 'total' => $total, + ]; + if ($debugIntent) { + $meta['intent_parser'] = $intentMeta; + } + + $payload = [ + 'results' => $paged, + 'grouped_results' => $grouped, + 'meta' => $meta, + ]; + system_search_cache::setQuery($queryCacheHash, $payload, 120); + + return $payload; + } + + protected function shouldInvokeIntentParser(array $results): bool + { + if (count($results) < $this->lowConfidenceResultThreshold) { + return true; + } + $topScore = (int)($results[0]['score'] ?? 0); + return $topScore < $this->lowConfidenceTopScoreThreshold; + } + + /** + * @param array $activeTypes + * @param array $terms + * @param array $entityBoost + * @param array $ownOnlyTypes + * @param int|null $ownCustomerNumber + * @param array $permissionsCatalogAll + * @param array $permissionsCatalogOwn + * @param array $moduleConfigVisibility + * @param array $forcedCustomerNumbers + * @return array> + */ + protected function executeLexicalSearch( + array $activeTypes, + array $terms, + array $entityBoost, + array $ownOnlyTypes, + ?int $ownCustomerNumber, + array $permissionsCatalogAll, + array $permissionsCatalogOwn, + array $moduleConfigVisibility, + array $forcedCustomerNumbers = [] + ): array { + $results = []; + $dirtyTables = system_search_cache::peekDirtyTables(); + foreach ($activeTypes as $entityType) { + $boost = (int)($entityBoost[$entityType] ?? 0); + $ownOnly = in_array($entityType, $ownOnlyTypes, true); + if ($ownOnly && $ownCustomerNumber === null && empty($forcedCustomerNumbers)) { + continue; + } + if ($this->canUseIndexedSearch($entityType, $dirtyTables)) { + $rows = $this->searchIndexedEntity( + $entityType, + $terms, + $boost, + $ownOnly, + $ownCustomerNumber, + $moduleConfigVisibility, + $forcedCustomerNumbers + ); + if (empty($rows)) { + $rows = $this->searchEntity( + $entityType, + $terms, + $boost, + $ownOnly, + $ownCustomerNumber, + $permissionsCatalogAll, + $permissionsCatalogOwn, + $moduleConfigVisibility, + $forcedCustomerNumbers + ); + } + } else { + $rows = $this->searchEntity( + $entityType, + $terms, + $boost, + $ownOnly, + $ownCustomerNumber, + $permissionsCatalogAll, + $permissionsCatalogOwn, + $moduleConfigVisibility, + $forcedCustomerNumbers + ); + } + $results = $this->mergeResults($results, $rows); + } + return $results; + } + + /** + * @param array $terms + * @param array $permissionsCatalogAll + * @param array $permissionsCatalogOwn + * @param array $moduleConfigVisibility + * @param array $forcedCustomerNumbers + * @return array> + */ + private function searchEntity( + string $entityType, + array $terms, + int $entityBoost, + bool $ownOnly, + ?int $ownCustomerNumber, + array $permissionsCatalogAll, + array $permissionsCatalogOwn, + array $moduleConfigVisibility, + array $forcedCustomerNumbers + ): array { + if ($this->isGenericEntityType($entityType)) { + return $this->searchGenericEntity( + $entityType, + $terms, + $entityBoost, + $ownOnly, + $ownCustomerNumber, + $forcedCustomerNumbers + ); + } + + return match ($entityType) { + 'customers' => $this->searchCustomers($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), + 'employees' => $this->searchEmployees($terms, $entityBoost, $ownOnly, $ownCustomerNumber), + 'orders' => $this->searchOrders($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), + 'order_items' => $this->searchOrderItems($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), + 'invoices' => $this->searchInvoices($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), + 'vehicles' => $this->searchVehicles($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), + 'subusers' => $this->searchSubusers($terms, $entityBoost, $ownOnly, $ownCustomerNumber), + 'customer_discounts' => $this->searchCustomerDiscounts($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), + 'customer_fixed_prices' => $this->searchCustomerFixedPrices($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers), + 'departments' => $this->searchDepartments($terms, $entityBoost), + 'roles' => $this->searchRoles($terms, $entityBoost), + 'permissions' => $this->searchPermissions($terms, $entityBoost, $ownOnly, $permissionsCatalogAll, $permissionsCatalogOwn), + 'module_config' => $this->searchModuleConfig($terms, $entityBoost, $moduleConfigVisibility), + 'objects' => $this->searchObjects($terms, $entityBoost, $ownOnly, $ownCustomerNumber), + default => [], + }; + } + + /** + * @param array $dirtyTables + */ + private function canUseIndexedSearch(string $entityType, array $dirtyTables): bool + { + if (!$this->tableExists(system_search_document_index::TABLE)) { + return false; + } + if (!in_array($entityType, system_search_registry::indexedEntityTypes(), true)) { + return false; + } + + $normalizedDirty = array_values(array_unique(array_filter(array_map( + static fn($table) => is_string($table) ? trim($table, " `\t\n\r\0\x0B") : '', + $dirtyTables + )))); + if (empty($normalizedDirty)) { + return true; + } + + return empty(array_intersect($normalizedDirty, system_search_registry::sourceTablesForEntityType($entityType))); + } + + /** + * @param array $terms + * @param array $moduleConfigVisibility + * @param array $forcedCustomerNumbers + * @return array> + */ + private function searchIndexedEntity( + string $entityType, + array $terms, + int $entityBoost, + bool $ownOnly, + ?int $ownCustomerNumber, + array $moduleConfigVisibility, + array $forcedCustomerNumbers + ): array { + global $db; + + if (!$this->tableExists(system_search_document_index::TABLE) || empty($terms)) { + return []; + } + + $customerNumbers = !empty($forcedCustomerNumbers) + ? $forcedCustomerNumbers + : (($ownOnly && $ownCustomerNumber !== null) ? [$ownCustomerNumber] : []); + if ($ownOnly && empty($customerNumbers)) { + return []; + } + + $wheres = [ + "`entity_type` = '" . $db->escape_string($entityType) . "'", + ]; + if (!empty($customerNumbers)) { + $wheres[] = "`customer_number` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")"; + } + + $booleanQuery = $this->buildBooleanFullTextQuery($terms); + $rows = []; + if ($booleanQuery !== null) { + $escapedBoolean = $db->escape_string($booleanQuery); + $rows = $this->runSelectRows( + "SELECT entity_id, customer_number, department_id, title, description, search_text, payload_json, created_at, updated_at, " + . "MATCH(title, description, search_text) AGAINST ('" . $escapedBoolean . "' IN BOOLEAN MODE) AS indexed_score " + . "FROM `" . system_search_document_index::TABLE . "` " + . "WHERE " . implode(' AND ', $wheres) + . " AND MATCH(title, description, search_text) AGAINST ('" . $escapedBoolean . "' IN BOOLEAN MODE)" + . " ORDER BY indexed_score DESC LIMIT " . $this->defaultEntityFetchLimit + ); + } + + if (empty($rows)) { + $termClauses = []; + foreach ($terms as $term) { + $escaped = $db->escape_string($term); + foreach (['title', 'description', 'search_text'] as $field) { + $termClauses[] = "`$field` LIKE '%$escaped%'"; + } + } + if (empty($termClauses)) { + return []; + } + $rows = $this->runSelectRows( + "SELECT entity_id, customer_number, department_id, title, description, search_text, payload_json, created_at, updated_at, 0 AS indexed_score " + . "FROM `" . system_search_document_index::TABLE . "` " + . "WHERE " . implode(' AND ', $wheres) + . " AND (" . implode(' OR ', $termClauses) . ")" + . " LIMIT " . $this->defaultEntityFetchLimit + ); + } + + $this->primeCustomerContexts(array_values(array_unique(array_filter( + array_map(fn(array $row): ?int => $this->toIntOrNull($row['customer_number'] ?? null), $rows), + static fn(?int $value): bool => $value !== null && $value > 0 + )))); + + $invoiceTitleContexts = $entityType === 'invoices' + ? $this->loadInvoiceTitleContexts(array_map(static fn(array $row): mixed => $row['entity_id'] ?? null, $rows)) + : []; + + $results = []; + foreach ($rows as $row) { + $payload = []; + $payloadJson = $row['payload_json'] ?? null; + if (is_string($payloadJson) && $payloadJson !== '') { + $decoded = json_decode($payloadJson, true); + if (is_array($decoded)) { + $payload = $decoded; + } + } + + $title = (string)($row['title'] ?? ''); + if ($entityType === 'invoices') { + $invoiceContext = $invoiceTitleContexts[(string)($row['entity_id'] ?? '')] ?? []; + foreach (['name', 'created_at', 'closed_at'] as $field) { + if (array_key_exists($field, $invoiceContext)) { + $payload[$field] = $invoiceContext[$field]; + } + } + + $storedTitle = trim($title); + $storedName = null; + if ($storedTitle !== '' && !str_starts_with($storedTitle, 'Invoice collection #')) { + $storedName = $storedTitle; + } + + $title = $this->invoiceResultTitle( + $payload['name'] ?? $storedName, + $payload['created_at'] ?? ($row['created_at'] ?? null), + $payload['closed_at'] ?? null, + $row['entity_id'] ?? null + ); + } + if ($entityType === 'department_goals') { + $title = $this->departmentGoalResultTitle( + $payload['criteria'] ?? null, + $row['entity_id'] ?? null, + $title + ); + } + + if ($entityType === 'module_config') { + $module = (string)($payload['module'] ?? ''); + if ($module !== '' && isset($moduleConfigVisibility[$module]) && !$moduleConfigVisibility[$module]) { + continue; + } + $variable = (string)($payload['variable'] ?? ''); + if ($variable !== '' && $this->looksSecretVariable($variable)) { + continue; + } + } + + $indexedBoost = (int)round(max(0.0, (float)($row['indexed_score'] ?? 0.0)) * 40); + $score = $this->scoreRow([ + 'title' => $title, + 'description' => $row['description'] ?? '', + 'search_text' => $row['search_text'] ?? '', + ], ['title' => 4, 'description' => 2, 'search_text' => 1], $terms) + $indexedBoost + $entityBoost; + if ($score <= 0) { + continue; + } + + $results[] = $this->decorateSearchResultWithCustomerContext([ + 'entity_type' => $entityType, + 'entity_id' => (string)($row['entity_id'] ?? ''), + 'title' => $title, + 'description' => (string)($row['description'] ?? ''), + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'department_id' => $this->toIntOrNull($row['department_id'] ?? null), + 'score' => $score, + 'payload' => $payload, + ]); + } + + return $results; + } + + /** + * @param array $entityIds + * @return array + */ + private function loadInvoiceTitleContexts(array $entityIds): array + { + if (!$this->tableExists('collected_order_invoices')) { + return []; + } + + $invoiceIds = array_values(array_unique(array_filter(array_map( + fn(mixed $entityId): ?int => $this->toIntOrNull($entityId), + $entityIds + ), static fn(?int $invoiceId): bool => $invoiceId !== null && $invoiceId > 0))); + if (empty($invoiceIds)) { + return []; + } + + $rows = $this->runSelectRows( + "SELECT id, name, created_at, closed_at" + . " FROM `collected_order_invoices`" + . " WHERE `deleted_at` IS NULL" + . " AND `id` IN (" . implode(',', $invoiceIds) . ")" + ); + + $contexts = []; + foreach ($rows as $row) { + if (!isset($row['id'])) { + continue; + } + + $contexts[(string)$row['id']] = [ + 'name' => array_key_exists('name', $row) ? $row['name'] : null, + 'created_at' => array_key_exists('created_at', $row) ? $row['created_at'] : null, + 'closed_at' => array_key_exists('closed_at', $row) ? $row['closed_at'] : null, + ]; + } + + return $contexts; + } + + private function searchCustomers(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array + { + $customerNumbers = !empty($forcedCustomerNumbers) + ? $forcedCustomerNumbers + : (($ownOnly && $ownCustomerNumber) ? [$ownCustomerNumber] : []); + $customerFilter = ''; + if (!empty($customerNumbers)) { + $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')'; + } + + $selectFields = [ + 'u.id', + 'u.customer_number', + 'u.display_name', + 'u.email', + 'u.phone', + ...$this->joinTemporalSelectFields('users', 'u'), + ]; + $searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone']; + $fromClause = 'users u'; + + if ($this->isEconomicCustomerIndexAvailable()) { + $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; + $selectFields = [ + ...$selectFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + $searchFields = [ + ...$searchFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + } + + $rows = $this->searchTableWithJoin( + 'users', + $fromClause, + $selectFields, + $searchFields, + $terms, + '1=1' . $customerFilter + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + $title = trim((string)($row['economic_name'] ?? '')); + if ($title === '') { + $title = trim((string)($row['display_name'] ?? '')); + } + if ($title === '') { + $title = 'Customer #' . (string)($row['customer_number'] ?? ''); + } + + $description = trim((string)($row['email'] ?? '')); + if ($description === '') { + $description = trim((string)($row['economic_email'] ?? '')); + } + + return [ + 'entity_type' => 'customers', + 'entity_id' => (string)$row['id'], + 'title' => $title, + 'description' => $description, + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'score' => $this->scoreRow($row, [ + 'customer_number', + 'display_name', + 'email', + 'phone', + 'economic_name', + 'economic_address', + 'economic_city', + 'economic_zip', + 'economic_email', + 'economic_cvr', + 'economic_mobile_phone', + 'search_text', + ], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'display_name' => $row['display_name'] ?? null, + 'email' => $row['email'] ?? null, + 'phone' => $row['phone'] ?? null, + 'economic_name' => $row['economic_name'] ?? null, + 'economic_address' => $row['economic_address'] ?? null, + 'economic_city' => $row['economic_city'] ?? null, + 'economic_zip' => $row['economic_zip'] ?? null, + 'economic_email' => $row['economic_email'] ?? null, + 'economic_cvr' => $row['economic_cvr'] ?? null, + 'economic_mobile_phone' => $row['economic_mobile_phone'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchEmployees(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array + { + $rows = $this->searchTableWithJoin( + 'users', + 'users u INNER JOIN groups_permissions gp ON gp.group_id = u.group_id', + ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone', ...$this->joinTemporalSelectFields('users', 'u')], + ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'], + $terms, + "gp.permission = 'employee_public_data'" . (($ownOnly && $ownCustomerNumber) ? (' AND u.customer_number = ' . (int)$ownCustomerNumber) : '') + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + return [ + 'entity_type' => 'employees', + 'entity_id' => (string)$row['id'], + 'title' => (string)($row['display_name'] ?: ('Employee #' . $row['id'])), + 'description' => (string)($row['email'] ?? ''), + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'score' => $this->scoreRow($row, ['display_name', 'email', 'phone', 'customer_number'], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'display_name' => $row['display_name'] ?? null, + 'email' => $row['email'] ?? null, + 'phone' => $row['phone'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchOrders(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array + { + $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); + $rows = $this->searchTable( + 'orders', + ['id', 'customer_id', 'reference', 'notes', 'reg_1', 'reg_2', 'reg_3', 'department_id', 'po', 'deleted_at'], + ['id', 'customer_id', 'reference', 'notes', 'reg_1', 'reg_2', 'reg_3', 'po'], + $terms, + $customerNumbers, + 'customer_id', + 'default', + ['deleted_at' => null] + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + return [ + 'entity_type' => 'orders', + 'entity_id' => (string)$row['id'], + 'title' => 'Order #' . (string)$row['id'], + 'description' => (string)($row['reference'] ?? ''), + 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'department_id' => isset($row['department_id']) ? (int)$row['department_id'] : null, + 'score' => $this->scoreRow($row, ['id', 'customer_id', 'reference', 'notes', 'reg_1', 'reg_2', 'reg_3', 'po'], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'customer_id' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'reference' => $row['reference'] ?? null, + 'notes' => $row['notes'] ?? null, + 'reg_1' => $row['reg_1'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchOrderItems(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array + { + $customerClause = ''; + if (!empty($forcedCustomerNumbers)) { + $customerClause = ' AND o.customer_id IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')'; + } elseif ($ownOnly && $ownCustomerNumber) { + $customerClause = ' AND o.customer_id = ' . (int)$ownCustomerNumber; + } + $baseWhere = 'o.id = oi.order_id' . $customerClause . ' AND o.deleted_at IS NULL'; + $rows = $this->searchTableWithJoin( + 'order_items', + 'order_items oi INNER JOIN orders o ON o.id = oi.order_id', + [ + 'oi.id', + 'oi.order_id', + 'oi.product_id', + 'oi.reference', + 'oi.notes', + 'o.customer_id AS customer_number', + ...$this->joinTemporalSelectFields('order_items', 'oi'), + ], + ['oi.id', 'oi.order_id', 'oi.product_id', 'oi.reference', 'oi.notes', 'o.customer_id'], + $terms, + $baseWhere + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + return [ + 'entity_type' => 'order_items', + 'entity_id' => (string)$row['id'], + 'title' => 'Order item #' . (string)$row['id'], + 'description' => (string)($row['reference'] ?? ''), + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'score' => $this->scoreRow($row, ['id', 'order_id', 'product_id', 'reference', 'notes', 'customer_number'], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'order_id' => isset($row['order_id']) ? (int)$row['order_id'] : null, + 'product_id' => isset($row['product_id']) ? (int)$row['product_id'] : null, + 'reference' => $row['reference'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchInvoices(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array + { + $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); + $rows = $this->searchTable( + 'collected_order_invoices', + ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number', 'closed_at', 'deleted_at'], + ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number'], + $terms, + $customerNumbers, + 'customer_number', + 'default', + ['deleted_at' => null] + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + return [ + 'entity_type' => 'invoices', + 'entity_id' => (string)$row['id'], + 'title' => $this->invoiceResultTitle( + $row['name'] ?? null, + $row['created_at'] ?? null, + $row['closed_at'] ?? null, + $row['id'] ?? null + ), + 'description' => (string)($row['external_id'] ?? ''), + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'score' => $this->scoreRow($row, ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number'], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'name' => $row['name'] ?? null, + 'external_id' => $row['external_id'] ?? null, + 'closed_at' => $row['closed_at'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function invoiceResultTitle(mixed $name, mixed $fromDate, mixed $toDate, mixed $invoiceId): string + { + $resolvedName = trim((string)($name ?? '')); + if ($resolvedName !== '') { + return $resolvedName; + } + + $dateRange = $this->invoiceDateRangeLabel($fromDate, $toDate); + if ($dateRange !== '') { + return $dateRange; + } + + return 'Invoice collection #' . (string)$invoiceId; + } + + private function invoiceDateRangeLabel(mixed $fromDate, mixed $toDate): string + { + $fromLabel = $this->invoiceDateLabel($fromDate); + $toLabel = $this->invoiceDateLabel($toDate); + + if ($fromLabel !== '' && $toLabel !== '' && $fromLabel !== $toLabel) { + return $fromLabel . ' - ' . $toLabel; + } + if ($fromLabel !== '') { + return $fromLabel; + } + if ($toLabel !== '') { + return $toLabel; + } + + return ''; + } + + private function invoiceDateLabel(mixed $value): string + { + if ($value === null) { + return ''; + } + + $raw = trim((string)$value); + if ($raw === '' || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') { + return ''; + } + + $timestamp = strtotime($raw); + if ($timestamp === false) { + return ''; + } + + return date('Y-m-d', $timestamp); + } + + private function departmentGoalResultTitle(mixed $criteria, mixed $entityId = null, string $fallback = ''): string + { + $label = $this->departmentGoalLabelFromCriteria($criteria); + if ($label !== '') { + return $label; + } + + $trimmedFallback = trim($fallback); + if ($trimmedFallback !== '') { + return $trimmedFallback; + } + + $resolvedId = $this->toIntOrNull($entityId); + return $resolvedId !== null && $resolvedId > 0 + ? 'Department goal #' . $resolvedId + : 'Department goal'; + } + + private function departmentGoalLabelFromCriteria(mixed $criteria): string + { + $decoded = null; + if (is_array($criteria)) { + $decoded = $criteria; + } elseif (is_string($criteria) && trim($criteria) !== '') { + $decodedValue = json_decode($criteria, true); + if (is_array($decodedValue)) { + $decoded = $decodedValue; + } + } + + if (!is_array($decoded)) { + return ''; + } + + return trim((string)($decoded['label'] ?? '')); + } + + private function searchVehicles(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array + { + $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []); + $rows = $this->searchTable( + 'customer_vehicles', + ['id', 'customer_id', 'reg', 'reference', 'type', 'deleted_at'], + ['id', 'customer_id', 'reg', 'reference', 'type'], + $terms, + $customerNumbers, + 'customer_id', + 'default', + ['deleted_at' => null] + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + return [ + 'entity_type' => 'vehicles', + 'entity_id' => (string)$row['id'], + 'title' => (string)($row['reg'] ?: ('Vehicle #' . $row['id'])), + 'description' => (string)($row['reference'] ?? ''), + 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'score' => $this->scoreRow($row, ['id', 'customer_id', 'reg', 'reference', 'type'], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'customer_id' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'reg' => $row['reg'] ?? null, + 'reference' => $row['reference'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchSubusers(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array + { + $where = '1=1'; + if ($ownOnly && $ownCustomerNumber) { + $where .= ' AND sg.billing_customer_number = ' . (int)$ownCustomerNumber . ' AND sg.deleted_at IS NULL'; + } + $rows = $this->searchTableWithJoin( + 'subusers', + 'subusers s LEFT JOIN subuser_grants sg ON sg.subuser = s.id', + [ + 's.id', + 's.username', + 's.name', + 's.email', + 's.phone_country_code', + 's.phone', + ...$this->joinTemporalSelectFields('subusers', 's'), + ], + ['s.id', 's.username', 's.name', 's.email', 's.phone'], + $terms, + $where + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + return [ + 'entity_type' => 'subusers', + 'entity_id' => (string)$row['id'], + 'title' => (string)($row['name'] ?: ($row['username'] ?? ('Subuser #' . $row['id']))), + 'description' => (string)($row['email'] ?? ''), + 'score' => $this->scoreRow($row, ['id', 'username', 'name', 'email', 'phone'], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'username' => $row['username'] ?? null, + 'name' => $row['name'] ?? null, + 'email' => $row['email'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array + { + $customerFilter = ''; + if (!empty($forcedCustomerNumbers)) { + $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')'; + } elseif ($ownOnly && $ownCustomerNumber) { + $customerFilter = ' AND u.customer_number = ' . (int)$ownCustomerNumber; + } + + $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id'; + $selectFields = [ + 'po.id', + 'po.user_id', + 'po.is_category', + 'po.product_or_category_id', + 'po.percentage', + 'u.customer_number', + 'u.display_name', + ...$this->joinTemporalSelectFields('price_overrides', 'po'), + ]; + $searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name']; + + if ($this->isEconomicCustomerIndexAvailable()) { + $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; + $selectFields = [ + ...$selectFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + $searchFields = [ + ...$searchFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + } + + $rows = $this->searchTableWithJoin( + 'price_overrides', + $fromClause, + $selectFields, + $searchFields, + $terms, + '1=1' . $customerFilter + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + $customerDisplay = trim((string)($row['economic_name'] ?? '')); + if ($customerDisplay === '') { + $customerDisplay = trim((string)($row['display_name'] ?? '')); + } + return [ + 'entity_type' => 'customer_discounts', + 'entity_id' => (string)$row['id'], + 'title' => 'Discount #' . (string)$row['id'], + 'description' => (string)('Customer ' . ($row['customer_number'] ?? '') . ' / ' . $customerDisplay), + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'score' => $this->scoreRow($row, [ + 'id', + 'customer_number', + 'display_name', + 'economic_name', + 'economic_address', + 'economic_city', + 'economic_zip', + 'economic_email', + 'economic_cvr', + 'economic_mobile_phone', + 'search_text', + 'product_or_category_id', + 'percentage', + 'user_id', + ], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'product_or_category_id' => $row['product_or_category_id'] ?? null, + 'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null, + 'economic_name' => $row['economic_name'] ?? null, + 'economic_cvr' => $row['economic_cvr'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchCustomerFixedPrices(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array + { + $customerNumbers = !empty($forcedCustomerNumbers) + ? $forcedCustomerNumbers + : (($ownOnly && $ownCustomerNumber) ? [$ownCustomerNumber] : []); + $customerFilter = ''; + if (!empty($customerNumbers)) { + $customerFilter = ' AND cfp.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')'; + } + + $fromClause = 'customer_fixed_pricing cfp'; + $selectFields = [ + 'cfp.id', + 'cfp.customer_number', + 'cfp.price', + 'cfp.description', + ...$this->joinTemporalSelectFields('customer_fixed_pricing', 'cfp'), + ]; + $searchFields = ['cfp.id', 'cfp.customer_number', 'cfp.price', 'cfp.description']; + + if ($this->isEconomicCustomerIndexAvailable()) { + $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = cfp.customer_number'; + $selectFields = [ + ...$selectFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + $searchFields = [ + ...$searchFields, + 'sci.economic_name', + 'sci.economic_address', + 'sci.economic_city', + 'sci.economic_zip', + 'sci.economic_email', + 'sci.economic_cvr', + 'sci.economic_mobile_phone', + 'sci.search_text', + ]; + } + + $rows = $this->searchTableWithJoin( + 'customer_fixed_pricing', + $fromClause, + $selectFields, + $searchFields, + $terms, + '1=1' . $customerFilter + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + $description = trim((string)($row['description'] ?? '')); + if ($description === '') { + $description = trim((string)($row['economic_name'] ?? '')); + } + return [ + 'entity_type' => 'customer_fixed_prices', + 'entity_id' => (string)$row['id'], + 'title' => 'Fixed pricing #' . (string)$row['id'], + 'description' => $description, + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'score' => $this->scoreRow($row, [ + 'id', + 'customer_number', + 'price', + 'description', + 'economic_name', + 'economic_address', + 'economic_city', + 'economic_zip', + 'economic_email', + 'economic_cvr', + 'economic_mobile_phone', + 'search_text', + ], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'price' => isset($row['price']) ? (int)$row['price'] : null, + 'description' => $row['description'] ?? null, + 'economic_name' => $row['economic_name'] ?? null, + 'economic_cvr' => $row['economic_cvr'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchDepartments(array $terms, int $entityBoost): array + { + $rows = $this->searchTable( + 'departments', + ['id', 'name', 'address', 'zip', 'city'], + ['id', 'name', 'address', 'zip', 'city'], + $terms + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + return [ + 'entity_type' => 'departments', + 'entity_id' => (string)$row['id'], + 'title' => (string)($row['name'] ?: ('Department #' . $row['id'])), + 'description' => trim((string)(($row['address'] ?? '') . ' ' . ($row['city'] ?? ''))), + 'score' => $this->scoreRow($row, ['id', 'name', 'address', 'zip', 'city'], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'name' => $row['name'] ?? null, + 'address' => $row['address'] ?? null, + 'zip' => $row['zip'] ?? null, + 'city' => $row['city'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchRoles(array $terms, int $entityBoost): array + { + $rows = $this->searchTable( + 'groups', + ['id', 'name', 'description'], + ['id', 'name', 'description'], + $terms + ); + + return array_map(function (array $row) use ($terms, $entityBoost) { + return [ + 'entity_type' => 'roles', + 'entity_id' => (string)$row['id'], + 'title' => (string)($row['name'] ?: ('Role #' . $row['id'])), + 'description' => (string)($row['description'] ?? ''), + 'score' => $this->scoreRow($row, ['id', 'name', 'description'], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal([ + 'id' => (int)$row['id'], + 'name' => $row['name'] ?? null, + 'description' => $row['description'] ?? null, + ], $row), + ]; + }, $rows); + } + + private function searchPermissions(array $terms, int $entityBoost, bool $ownOnly, array $permissionsCatalogAll, array $permissionsCatalogOwn): array + { + $rows = []; + if ($ownOnly) { + foreach ($permissionsCatalogOwn as $permission) { + $rows[] = ['permission' => (string)$permission, 'description' => (string)$permission]; + } + } else { + foreach ($permissionsCatalogAll as $permission => $description) { + $rows[] = ['permission' => (string)$permission, 'description' => (string)$description]; + } + } + + $filtered = []; + foreach ($rows as $row) { + $score = $this->scoreRow($row, ['permission', 'description'], $terms) + $entityBoost; + if ($score <= 0) { + continue; + } + $filtered[] = [ + 'entity_type' => 'permissions', + 'entity_id' => (string)$row['permission'], + 'title' => (string)$row['permission'], + 'description' => (string)$row['description'], + 'score' => $score, + 'payload' => $row, + ]; + } + return $filtered; + } + + private function searchModuleConfig(array $terms, int $entityBoost, array $moduleConfigVisibility): array + { + $rows = $this->searchTable( + 'module_config', + ['module', 'variable', 'type', 'value'], + ['module', 'variable', 'type'], + $terms + ); + + $filtered = []; + foreach ($rows as $row) { + $module = (string)($row['module'] ?? ''); + if ($module !== '' && isset($moduleConfigVisibility[$module]) && !$moduleConfigVisibility[$module]) { + continue; + } + $variable = (string)($row['variable'] ?? ''); + if ($this->looksSecretVariable($variable)) { + continue; + } + $score = $this->scoreRow($row, ['module', 'variable', 'type'], $terms) + $entityBoost; + if ($score <= 0) { + continue; + } + $filtered[] = [ + 'entity_type' => 'module_config', + 'entity_id' => $module . ':' . $variable, + 'title' => $module . '.' . $variable, + 'description' => (string)($row['type'] ?? ''), + 'score' => $score, + 'payload' => $this->augmentPayloadWithTemporal([ + 'module' => $module, + 'variable' => $variable, + 'type' => $row['type'] ?? null, + ], $row), + ]; + } + return $filtered; + } + + private function searchObjects(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array + { + global $db; + + if ($ownOnly && $ownCustomerNumber === null) { + return []; + } + + $taskColumns = $this->getColumns('department_selfserve_tasks'); + $taskSelectFields = []; + $taskSearchFields = []; + if (in_array('task', $taskColumns, true)) { + $taskSelectFields[] = 'dst.task AS task_title'; + $taskSearchFields[] = 'dst.task'; + } + if (in_array('description', $taskColumns, true)) { + $taskSelectFields[] = 'dst.description AS task_description'; + $taskSearchFields[] = 'dst.description'; + } + if (in_array('department', $taskColumns, true)) { + $taskSelectFields[] = 'dst.department AS task_department'; + } + + $taskDepartmentJoin = ''; + if ($this->tableExists('departments') && in_array('department', $taskColumns, true) && in_array('name', $this->getColumns('departments'), true)) { + $taskDepartmentJoin = ' LEFT JOIN departments d ON d.id = dst.department'; + $taskSelectFields[] = 'd.name AS task_department_name'; + $taskSearchFields[] = 'd.name'; + } + + $customerJoin = ''; + $customerSelectFields = []; + $customerSearchFields = ['o.customer_id', 'o.reference']; + if ($this->tableExists(system_search_economic_customer_index::TABLE)) { + $customerJoin = " LEFT JOIN `" . system_search_economic_customer_index::TABLE . "` sci ON sci.customer_number = o.customer_id"; + $customerSelectFields = [ + "COALESCE(sci.economic_name, sci.local_display_name) AS customer_name", + "COALESCE(sci.economic_email, sci.local_email) AS customer_email", + "COALESCE(sci.economic_mobile_phone, sci.local_phone) AS customer_phone", + "sci.economic_cvr AS customer_cvr", + "sci.economic_barred AS customer_barred", + ]; + $customerSearchFields = [ + ...$customerSearchFields, + 'sci.economic_name', + 'sci.local_display_name', + 'sci.economic_email', + 'sci.local_email', + 'sci.economic_mobile_phone', + 'sci.local_phone', + 'sci.economic_cvr', + ]; + } + + $termClauses = []; + foreach ($terms as $term) { + $escaped = $db->escape_string($term); + foreach (array_values(array_unique([ + 'oa.id', + 'oa.object_type', + 'oa.object_id', + 'oa.content', + ...$customerSearchFields, + ...$taskSearchFields, + ])) as $field) { + $termClauses[] = $field . " LIKE '%$escaped%'"; + } + } + if (empty($termClauses)) { + return []; + } + + $scopeClauses = []; + if ($ownOnly) { + $scopeClauses[] = "(oa.object_type = 'orders' AND o.customer_id = " . (int)$ownCustomerNumber . ")"; + } else { + $scopeClauses[] = "(oa.object_type = 'orders' AND o.id IS NOT NULL)"; + $scopeClauses[] = "(oa.object_type = 'department_selfserve_tasks' AND dst.id IS NOT NULL)"; + } + + $rows = $this->runSelectRows( + "SELECT oa.id, oa.object_type, oa.object_id, oa.content, oa.created_at, oa.updated_at," + . " o.customer_id AS customer_number, o.department_id, o.reference AS order_reference," + . (!empty($taskSelectFields) ? (' ' . ', ' . implode(', ', array_values(array_unique($taskSelectFields)))) : '') + . (!empty($customerSelectFields) ? (' ' . ', ' . implode(', ', $customerSelectFields)) : '') + . " FROM object_attachments oa" + . " LEFT JOIN orders o ON oa.object_type = 'orders' AND o.id = oa.object_id AND o.deleted_at IS NULL" + . " LEFT JOIN department_selfserve_tasks dst ON oa.object_type = 'department_selfserve_tasks' AND dst.id = oa.object_id AND dst.deleted_at IS NULL" + . $taskDepartmentJoin + . $customerJoin + . " WHERE oa.deleted_at IS NULL" + . " AND (" . implode(' OR ', $scopeClauses) . ")" + . " AND (" . implode(' OR ', $termClauses) . ")" + . " LIMIT " . $this->defaultEntityFetchLimit + ); + + $this->primeCustomerContexts(array_values(array_unique(array_filter( + array_map(fn(array $row): ?int => $this->toIntOrNull($row['customer_number'] ?? null), $rows), + static fn(?int $value): bool => $value !== null && $value > 0 + )))); + + return array_map( + fn(array $row): array => $this->buildObjectSearchResult($row, $terms, $entityBoost), + $rows + ); + } + + private function isGenericEntityType(string $entityType): bool + { + $configs = $this->genericEntityConfigs(); + return isset($configs[$entityType]); + } + + /** + * @return array + */ + private function associationEntityTypes(): array + { + $types = ['orders', 'order_items', 'invoices', 'vehicles', 'customer_discounts', 'customer_fixed_prices']; + foreach ($this->genericEntityConfigs() as $entityType => $config) { + if (isset($config['customer_field']) && is_string($config['customer_field']) && $config['customer_field'] !== '') { + $types[] = $entityType; + } + } + return array_values(array_unique($types)); + } + + /** + * @param array $terms + * @param array $forcedCustomerNumbers + * @return array> + */ + private function searchGenericEntity( + string $entityType, + array $terms, + int $entityBoost, + bool $ownOnly, + ?int $ownCustomerNumber, + array $forcedCustomerNumbers = [] + ): array { + if (empty($terms)) { + return []; + } + + $config = $this->genericEntityConfigs()[$entityType] ?? null; + if (!is_array($config)) { + return []; + } + + $table = trim((string)($config['table'] ?? '')); + if ($table === '' || !$this->tableExists($table)) { + return []; + } + + $columns = $this->getColumns($table); + if (empty($columns)) { + return []; + } + + $idField = (string)($config['id_field'] ?? (in_array('id', $columns, true) ? 'id' : $columns[0])); + if (!in_array($idField, $columns, true)) { + return []; + } + + $customerField = null; + if (isset($config['customer_field']) && is_string($config['customer_field']) && in_array($config['customer_field'], $columns, true)) { + $customerField = $config['customer_field']; + } + $customerFieldMode = isset($config['customer_field_mode']) && is_string($config['customer_field_mode']) + ? trim(mb_strtolower($config['customer_field_mode'])) + : 'default'; + if ($customerFieldMode === '') { + $customerFieldMode = 'default'; + } + + if ($ownOnly) { + if ($customerField === null) { + return []; + } + if (empty($forcedCustomerNumbers) && $ownCustomerNumber === null) { + return []; + } + } + + $departmentField = null; + if (isset($config['department_field']) && is_string($config['department_field']) && in_array($config['department_field'], $columns, true)) { + $departmentField = $config['department_field']; + } + + $excludedColumns = []; + if (isset($config['exclude_columns']) && is_array($config['exclude_columns'])) { + $excludedColumns = array_values(array_filter($config['exclude_columns'], static fn($v) => is_string($v) && $v !== '')); + } + + $searchable = []; + if (isset($config['search_fields']) && is_array($config['search_fields']) && !empty($config['search_fields'])) { + $configured = array_values(array_filter($config['search_fields'], static fn($v) => is_string($v) && $v !== '')); + $configured = array_values(array_intersect($configured, $columns)); + $searchable = $this->sanitizeGenericSearchFields($configured, $excludedColumns); + } + if (empty($searchable)) { + $searchable = $this->sanitizeGenericSearchFields($columns, $excludedColumns); + } + if (empty($searchable)) { + return []; + } + + $selectFields = array_values(array_unique(array_filter([ + $idField, + $customerField, + $departmentField, + ...$searchable, + ], static fn($value) => is_string($value) && $value !== ''))); + if (count($selectFields) > 24) { + $selectFields = array_slice($selectFields, 0, 24); + } + $searchable = array_values(array_intersect($searchable, $selectFields)); + + $fixedConditions = []; + if (isset($config['fixed_conditions']) && is_array($config['fixed_conditions'])) { + foreach ($config['fixed_conditions'] as $column => $value) { + if (!is_string($column) || !in_array($column, $columns, true)) { + continue; + } + $fixedConditions[$column] = $value; + } + } + if (!array_key_exists('deleted_at', $fixedConditions) && in_array('deleted_at', $columns, true)) { + $fixedConditions['deleted_at'] = null; + } + + $customerNumbers = !empty($forcedCustomerNumbers) + ? $forcedCustomerNumbers + : (($ownOnly && $ownCustomerNumber !== null && $customerField !== null) ? [$ownCustomerNumber] : []); + + $rows = $this->searchTable( + $table, + $selectFields, + $searchable, + $terms, + $customerNumbers, + $customerField, + $customerFieldMode, + $fixedConditions + ); + + $this->primeCustomerContexts(array_values(array_unique(array_filter( + array_map( + fn(array $row): ?int => ($customerField !== null && array_key_exists($customerField, $row)) + ? $this->resolveConfiguredCustomerNumber($row[$customerField], $customerFieldMode) + : null, + $rows + ), + static fn(?int $value): bool => $value !== null && $value > 0 + )))); + + $titleFields = []; + if (isset($config['title_fields']) && is_array($config['title_fields'])) { + $titleFields = array_values(array_filter($config['title_fields'], static fn($v) => is_string($v) && in_array($v, $selectFields, true))); + } + if (empty($titleFields)) { + $titleFields = array_values(array_intersect( + ['name', 'title', 'display_name', 'reference', 'reference_number', 'reg', 'reg_1', 'plate', 'module', 'customer_number', 'id'], + $selectFields + )); + } + + $descriptionFields = []; + if (isset($config['description_fields']) && is_array($config['description_fields'])) { + $descriptionFields = array_values(array_filter($config['description_fields'], static fn($v) => is_string($v) && in_array($v, $selectFields, true))); + } + if (empty($descriptionFields)) { + $descriptionFields = array_values(array_intersect( + ['description', 'note', 'notes', 'email', 'status', 'type', 'city', 'address', 'action', 'message', 'customer_id'], + $selectFields + )); + } + + $entityLabel = ucfirst(str_replace('_', ' ', $entityType)); + $results = []; + foreach ($rows as $row) { + $entityId = isset($row[$idField]) ? (string)$row[$idField] : md5(json_encode($row, JSON_UNESCAPED_UNICODE)); + + $title = ''; + foreach ($titleFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value !== '') { + $title = $value; + break; + } + } + if ($entityType === 'department_goals') { + $title = $this->departmentGoalResultTitle($row['criteria'] ?? null, $entityId, $title); + } + if ($title === '') { + $title = $entityLabel . ' #' . $entityId; + } + + $descriptionParts = []; + foreach ($descriptionFields as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $descriptionParts[] = $value; + if (count($descriptionParts) >= 2) { + break; + } + } + $description = implode(' / ', $descriptionParts); + + $results[] = $this->decorateSearchResultWithCustomerContext([ + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'title' => $title, + 'description' => $description, + 'customer_number' => ($customerField !== null && array_key_exists($customerField, $row)) + ? $this->resolveConfiguredCustomerNumber($row[$customerField], $customerFieldMode) + : null, + 'department_id' => ($departmentField !== null && isset($row[$departmentField])) ? $this->toIntOrNull($row[$departmentField]) : null, + 'score' => $this->scoreRow($row, $searchable, $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal( + array_intersect_key($row, array_flip([...$selectFields, 'updated_at', 'created_at'])), + $row + ), + ]); + } + + return $results; + } + + /** + * @param array $columns + * @param array $excludeColumns + * @return array + */ + private function sanitizeGenericSearchFields(array $columns, array $excludeColumns = []): array + { + $excluded = array_values(array_unique(array_map(static fn($v) => mb_strtolower((string)$v), $excludeColumns))); + $filtered = []; + foreach ($columns as $column) { + if (!is_string($column) || $column === '') { + continue; + } + $lower = mb_strtolower($column); + if (in_array($lower, $excluded, true)) { + continue; + } + if (in_array($lower, ['created_at', 'updated_at'], true)) { + continue; + } + if (preg_match('/(?:^|_)(password|token|secret|api_key|apikey|private|credential|passkey|session|hash|salt|client_secret|refresh_token|access_token)(?:_|$)/i', $lower)) { + continue; + } + if (in_array($lower, ['data', 'content', 'payload', 'config', 'permissions', 'washitems', 'client_secret'], true)) { + continue; + } + $filtered[] = $column; + } + return array_values(array_unique($filtered)); + } + + /** + * @return array> + */ + private function genericEntityConfigs(): array + { + return system_search_registry::genericEntityConfigs(); + } + + private function toIntOrNull(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_string($value) && preg_match('/^-?\d+$/', $value)) { + return (int)$value; + } + if (is_float($value)) { + return (int)$value; + } + return null; + } + + private function resolveConfiguredCustomerNumber(mixed $value, string $mode = 'default'): ?int + { + if ($mode !== 'digits_only') { + return $this->toIntOrNull($value); + } + + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (!is_string($value)) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '' || !preg_match('/^\d+$/', $trimmed)) { + return null; + } + + $resolved = (int)$trimmed; + return $resolved > 0 ? $resolved : null; + } + + /** + * @param array $customerNumbers + */ + private function customerFieldFilterClause(string $customerField, array $customerNumbers, string $customerFieldMode = 'default'): string + { + $normalizedNumbers = array_values(array_unique(array_filter( + array_map('intval', $customerNumbers), + static fn(int $value): bool => $value > 0 + ))); + if (empty($normalizedNumbers)) { + return ''; + } + + if ($customerFieldMode === 'digits_only') { + return "TRIM(`$customerField`) REGEXP '^[0-9]+$' AND CAST(TRIM(`$customerField`) AS UNSIGNED) IN (" + . implode(',', $normalizedNumbers) + . ")"; + } + + return "`$customerField` IN (" . implode(',', $normalizedNumbers) . ")"; + } + + /** + * Generic table search helper. + * + * @param array $candidateFields + * @param array $searchFields + * @param array $terms + * @param array $customerNumbers + * @param string|null $customerField + * @param string $customerFieldMode + * @param array $fixedConditions + * @return array> + */ + private function searchTable( + string $table, + array $candidateFields, + array $searchFields, + array $terms, + array $customerNumbers = [], + ?string $customerField = null, + string $customerFieldMode = 'default', + array $fixedConditions = [] + ): array { + global $db; + + if (!$this->tableExists($table)) { + return []; + } + $fields = $this->intersectExistingColumns($table, $candidateFields); + if (empty($fields)) { + return []; + } + $fields = $this->appendTemporalColumns($table, $fields); + $searchable = array_values(array_intersect($searchFields, $fields)); + if (empty($searchable)) { + return []; + } + + $wheres = []; + foreach ($fixedConditions as $column => $value) { + if (!in_array($column, $fields, true)) { + continue; + } + if ($value === null) { + $wheres[] = "`$column` IS NULL"; + } else { + $wheres[] = "`$column` = '" . $db->escape_string((string)$value) . "'"; + } + } + + if (!empty($customerNumbers) && $customerField !== null && in_array($customerField, $fields, true)) { + $customerFilterClause = $this->customerFieldFilterClause($customerField, $customerNumbers, $customerFieldMode); + if ($customerFilterClause !== '') { + $wheres[] = $customerFilterClause; + } + } + + $termClauses = []; + foreach ($terms as $term) { + $escaped = $db->escape_string($term); + foreach ($searchable as $field) { + $termClauses[] = "`$field` LIKE '%$escaped%'"; + } + } + if (!empty($termClauses)) { + $wheres[] = '(' . implode(' OR ', $termClauses) . ')'; + } + + if (empty($wheres)) { + return []; + } + + $sql = "SELECT " . implode(', ', array_map(fn($f) => "`$f`", $fields)) + . " FROM `$table`" + . " WHERE " . implode(' AND ', $wheres) + . " LIMIT " . $this->defaultEntityFetchLimit; + return $this->runSelectRows($sql); + } + + /** + * Generic join search helper. + * + * @param array $selectFields + * @param array $searchFields + * @param array $terms + * @return array> + */ + private function searchTableWithJoin( + string $table, + string $fromClause, + array $selectFields, + array $searchFields, + array $terms, + string $baseWhere + ): array { + global $db; + if (!$this->tableExists($table)) { + return []; + } + if (empty($terms)) { + return []; + } + $termClauses = []; + foreach ($terms as $term) { + $escaped = $db->escape_string($term); + foreach ($searchFields as $field) { + $termClauses[] = "$field LIKE '%$escaped%'"; + } + } + if (empty($termClauses)) { + return []; + } + $sql = "SELECT " . implode(', ', $selectFields) + . " FROM " . $fromClause + . " WHERE " . $baseWhere + . " AND (" . implode(' OR ', $termClauses) . ")" + . " LIMIT " . $this->defaultEntityFetchLimit; + return $this->runSelectRows($sql); + } + + /** + * @return array> + */ + protected function runSelectRows(string $sql): array + { + global $db; + try { + $result = $db->query($sql); + if (!($result instanceof \mysqli_result)) { + return []; + } + return $db->fetch_all($result); + } catch (Throwable) { + return []; + } + } + + private function scoreRow(array $row, array $fields, array $terms): int + { + $fieldWeights = []; + foreach ($fields as $key => $value) { + if (is_string($key)) { + $fieldWeights[$key] = max(1, (int)$value); + continue; + } + if (is_string($value)) { + $fieldWeights[$value] = 1; + } + } + if (empty($fieldWeights) || empty($terms)) { + return 0; + } + + $contentTerms = $this->contentTerms($terms); + $matchedTerms = []; + $score = 0; + foreach ($terms as $term) { + $termLower = mb_strtolower($term); + $bestScore = 0; + foreach ($fieldWeights as $field => $weight) { + if (!array_key_exists($field, $row) || $row[$field] === null) { + continue; + } + $value = trim((string)$row[$field]); + if ($value === '') { + continue; + } + $valueLower = mb_strtolower($value); + $baseScore = 0; + if ($valueLower === $termLower) { + $baseScore = 100; + } elseif (str_starts_with($valueLower, $termLower)) { + $baseScore = 60; + } elseif (str_contains($valueLower, $termLower)) { + $baseScore = 30; + } elseif (strlen($termLower) >= 4 && strlen($valueLower) <= 64) { + $distance = levenshtein($termLower, $valueLower); + if ($distance <= 2) { + $baseScore = 20 - ($distance * 5); + } + } + if ($baseScore <= 0) { + continue; + } + $bestScore = max($bestScore, $baseScore * $weight); + } + + if ($bestScore > 0) { + $score += $bestScore; + if (in_array($termLower, $contentTerms, true)) { + $matchedTerms[$termLower] = true; + } + } + } + + if (count($contentTerms) > 1) { + $requiredMatches = $this->minimumTermMatches($contentTerms); + if (count($matchedTerms) < $requiredMatches) { + return 0; + } + } + + return $score; + } + + /** + * @param array $terms + * @return array + */ + private function contentTerms(array $terms): array + { + $stopwords = [ + 'a', 'an', 'and', 'at', 'between', 'find', 'for', 'fra', 'from', 'har', 'have', + 'hvilke', 'hvor', 'i', 'med', 'need', 'of', 'og', 'or', 'search', 'show', 'som', + 'the', 'til', 'uden', 'want', 'where', 'which', 'with', 'without', + 'booking', 'bookings', 'customer', 'customers', 'discount', 'discounts', 'faktura', + 'invoice', 'invoices', 'kunde', 'kunder', 'order', 'orders', 'rabat', 'user', 'users', + 'vehicle', 'vehicles', + ]; + + $filtered = []; + foreach ($terms as $term) { + $normalized = trim(mb_strtolower((string)$term)); + if ($normalized === '' || in_array($normalized, $stopwords, true)) { + continue; + } + $filtered[] = $normalized; + } + + $filtered = array_values(array_unique($filtered)); + if (!empty($filtered)) { + return $filtered; + } + + return array_values(array_unique(array_map(static fn($term) => mb_strtolower((string)$term), $terms))); + } + + /** + * @param array $terms + */ + private function minimumTermMatches(array $terms): int + { + $count = count($terms); + if ($count <= 1) { + return 1; + } + if ($count === 2) { + return 2; + } + return min(3, max(2, (int)ceil($count / 2))); + } + + private function tableExists(string $table): bool + { + return !empty($this->getColumns($table)); + } + + private function intersectExistingColumns(string $table, array $candidateFields): array + { + $columns = $this->getColumns($table); + if (empty($columns)) { + return []; + } + return array_values(array_intersect($candidateFields, $columns)); + } + + private function getColumns(string $table): array + { + if (isset($this->tableColumnsCache[$table])) { + return $this->tableColumnsCache[$table]; + } + global $db; + try { + $result = $db->query("SHOW COLUMNS FROM `$table`"); + if (!($result instanceof \mysqli_result)) { + $this->tableColumnsCache[$table] = []; + return []; + } + $rows = $db->fetch_all($result); + $columns = array_values(array_map(static fn($row) => (string)$row['Field'], $rows)); + $this->tableColumnsCache[$table] = $columns; + return $columns; + } catch (Throwable) { + $this->tableColumnsCache[$table] = []; + return []; + } + } + + private function looksSecretVariable(string $variable): bool + { + $variable = mb_strtolower($variable); + return str_contains($variable, 'api_key') + || str_contains($variable, 'secret') + || str_contains($variable, 'password') + || str_contains($variable, 'token') + || str_contains($variable, 'private_key'); + } + + private function mergeResults(array $base, array $incoming): array + { + $indexed = []; + foreach ($base as $item) { + $key = (string)$item['entity_type'] . ':' . (string)$item['entity_id']; + $indexed[$key] = $item; + } + foreach ($incoming as $item) { + $key = (string)$item['entity_type'] . ':' . (string)$item['entity_id']; + if (!isset($indexed[$key])) { + $indexed[$key] = $item; + continue; + } + if ((int)$item['score'] > (int)$indexed[$key]['score']) { + $indexed[$key]['score'] = (int)$item['score']; + } + if (!isset($indexed[$key]['association_reason']) && isset($item['association_reason'])) { + $indexed[$key]['association_reason'] = $item['association_reason']; + } + } + return array_values($indexed); + } + + /** + * @param array $fields + * @return array + */ + private function appendTemporalColumns(string $table, array $fields): array + { + $columns = $this->getColumns($table); + foreach (['updated_at', 'created_at'] as $column) { + if (in_array($column, $columns, true) && !in_array($column, $fields, true)) { + $fields[] = $column; + } + } + return array_values(array_unique($fields)); + } + + /** + * @return array + */ + private function joinTemporalSelectFields(string $table, string $alias): array + { + $fields = []; + $columns = $this->getColumns($table); + $aliasPrefix = trim($alias) === '' ? '' : (trim($alias) . '.'); + foreach (['updated_at', 'created_at'] as $column) { + if (!in_array($column, $columns, true)) { + continue; + } + $fields[] = $aliasPrefix . $column . ' AS ' . $column; + } + return $fields; + } + + /** + * @param array $payload + * @param array $row + * @return array + */ + private function augmentPayloadWithTemporal(array $payload, array $row): array + { + foreach (['updated_at', 'created_at'] as $column) { + if (array_key_exists($column, $row)) { + $payload[$column] = $row[$column]; + } + } + return $payload; + } + + /** + * @param array $row + * @param array $terms + * @return array + */ + private function buildObjectSearchResult(array $row, array $terms, int $entityBoost): array + { + $context = $this->resolveObjectSearchContext($row); + $result = [ + 'entity_type' => 'objects', + 'entity_id' => (string)($row['id'] ?? ''), + 'title' => (string)$context['title'], + 'description' => (string)$context['description'], + 'customer_number' => $context['customer_number'], + 'department_id' => $context['department_id'], + 'score' => $this->scoreRow($row, [ + 'id' => 3, + 'object_type' => 2, + 'object_id' => 2, + 'content' => 1, + 'customer_number' => 3, + 'customer_name' => 4, + 'customer_email' => 2, + 'customer_cvr' => 2, + 'order_reference' => 3, + 'task_title' => 3, + 'task_description' => 2, + 'task_department_name' => 2, + ], $terms) + $entityBoost, + 'payload' => $this->augmentPayloadWithTemporal((array)$context['payload'], $row), + ]; + + return $this->decorateSearchResultWithCustomerContext($result); + } + + /** + * @param array $row + * @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array} + */ + private function resolveObjectSearchContext(array $row): array + { + return match (trim(mb_strtolower((string)($row['object_type'] ?? '')))) { + 'orders' => $this->resolveOrderObjectSearchContext($row), + 'department_selfserve_tasks' => $this->resolveTaskObjectSearchContext($row), + default => $this->resolveGenericObjectSearchContext($row), + }; + } + + /** + * @param array $row + * @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array} + */ + private function resolveOrderObjectSearchContext(array $row): array + { + $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null); + $title = $attachmentName !== '' ? $attachmentName : ('Order attachment #' . (string)($row['object_id'] ?? '')); + $customerNumber = $this->toIntOrNull($row['customer_number'] ?? null); + $departmentId = $this->toIntOrNull($row['department_id'] ?? null); + $customerName = trim((string)($row['customer_name'] ?? '')); + + $descriptionParts = []; + $orderReference = trim((string)($row['order_reference'] ?? '')); + if ($orderReference !== '') { + $descriptionParts[] = $orderReference; + } + if ($customerName !== '') { + $descriptionParts[] = $customerName; + } + + return [ + 'title' => $title, + 'description' => implode(' / ', array_slice($descriptionParts, 0, 2)), + 'customer_number' => $customerNumber, + 'department_id' => $departmentId, + 'payload' => [ + 'id' => $this->toIntOrNull($row['id'] ?? null), + 'object_type' => $row['object_type'] ?? null, + 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'linked_entity_type' => 'orders', + 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, + 'customer_number' => $customerNumber, + 'department_id' => $departmentId, + 'order_reference' => $row['order_reference'] ?? null, + ], + ]; + } + + /** + * @param array $row + * @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array} + */ + private function resolveTaskObjectSearchContext(array $row): array + { + $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null); + $title = $attachmentName !== '' ? $attachmentName : ('Task attachment #' . (string)($row['object_id'] ?? '')); + $departmentId = $this->toIntOrNull($row['task_department'] ?? null); + + $descriptionParts = []; + foreach (['task_title', 'task_department_name', 'task_description'] as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($value === '') { + continue; + } + $descriptionParts[] = $value; + if (count($descriptionParts) >= 2) { + break; + } + } + + return [ + 'title' => $title, + 'description' => implode(' / ', $descriptionParts), + 'customer_number' => null, + 'department_id' => $departmentId, + 'payload' => [ + 'id' => $this->toIntOrNull($row['id'] ?? null), + 'object_type' => $row['object_type'] ?? null, + 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'linked_entity_type' => 'department_selfserve_tasks', + 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, + 'department_id' => $departmentId, + 'task_title' => $row['task_title'] ?? null, + 'task_description' => $row['task_description'] ?? null, + 'task_department' => $departmentId, + 'task_department_name' => $row['task_department_name'] ?? null, + ], + ]; + } + + /** + * @param array $row + * @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array} + */ + private function resolveGenericObjectSearchContext(array $row): array + { + $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null); + $title = $attachmentName !== '' + ? $attachmentName + : ((string)($row['object_type'] ?? 'object_attachment') . '#' . (string)($row['object_id'] ?? '')); + + return [ + 'title' => $title, + 'description' => '', + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), + 'payload' => [ + 'id' => $this->toIntOrNull($row['id'] ?? null), + 'object_type' => $row['object_type'] ?? null, + 'object_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'linked_entity_type' => $row['object_type'] ?? null, + 'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null), + 'attachment_name' => $attachmentName !== '' ? $attachmentName : null, + 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), + 'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)), + ], + ]; + } + + private function attachmentNameFromContent(mixed $content): string + { + $decodedContent = is_string($content) ? json_decode($content, true) : null; + if (!is_array($decodedContent)) { + return ''; + } + return trim((string)($decodedContent['other'] ?? '')); + } + + /** + * @param array $customerNumbers + */ + private function primeCustomerContexts(array $customerNumbers): void + { + $missing = []; + foreach ($customerNumbers as $customerNumber) { + $normalized = (int)$customerNumber; + if ($normalized <= 0 || array_key_exists($normalized, $this->customerContextCache)) { + continue; + } + $missing[] = $normalized; + } + if (empty($missing)) { + return; + } + + $loaded = $this->loadCustomerContexts($missing); + foreach ($missing as $customerNumber) { + $this->customerContextCache[$customerNumber] = $loaded[$customerNumber] ?? null; + } + } + + /** + * @param array $customerNumbers + * @return array> + */ + protected function loadCustomerContexts(array $customerNumbers): array + { + $contexts = system_search_economic_customer_index::fetchContexts($customerNumbers); + foreach ($customerNumbers as $customerNumber) { + $normalized = (int)$customerNumber; + if ($normalized <= 0 || isset($contexts[$normalized])) { + continue; + } + + $fallback = $this->loadFallbackCustomerContext($normalized); + if ($fallback !== null) { + $contexts[$normalized] = $fallback; + } + } + + return $contexts; + } + + /** + * @return array|null + */ + private function loadFallbackCustomerContext(int $customerNumber): ?array + { + if ($customerNumber <= 0) { + return null; + } + + try { + $users = new \objects\users_o(); + $name = $users->getCustomerName($customerNumber); + $userId = null; + try { + $resolvedUserId = $users->getUserIdFromEconomic($customerNumber); + $userId = $resolvedUserId > 0 ? $resolvedUserId : null; + } catch (Throwable) { + $userId = null; + } + + $resolved = ($name !== null && trim($name) !== '') || $userId !== null; + $barred = $resolved ? $users->isCustomerBarred($customerNumber) : null; + + return [ + 'customer_number' => $customerNumber, + 'user_id' => $userId, + 'name' => is_string($name) && trim($name) !== '' ? trim($name) : null, + 'barred' => $barred, + 'status' => $this->customerBarredStatus($barred), + 'email' => null, + 'phone' => null, + 'cvr' => null, + 'address' => null, + 'city' => null, + 'zip' => null, + ]; + } catch (Throwable) { + return null; + } + } + + /** + * @return array|null + */ + private function customerContext(?int $customerNumber): ?array + { + if ($customerNumber === null || $customerNumber <= 0) { + return null; + } + $this->primeCustomerContexts([$customerNumber]); + return $this->customerContextCache[$customerNumber] ?? null; + } + + /** + * @param array $result + * @return array + */ + private function decorateSearchResultWithCustomerContext(array $result): array + { + $payload = is_array($result['payload'] ?? null) ? $result['payload'] : []; + $customerNumber = $this->toIntOrNull($result['customer_number'] ?? ($payload['customer_number'] ?? null)); + if ($customerNumber !== null) { + $result['customer_number'] = $customerNumber; + } + + $context = $this->customerContext($customerNumber); + $result['payload'] = $this->enrichPayloadWithCustomerContext($payload, $customerNumber, $context); + if ($context !== null) { + $result['customer_name'] = $context['name'] ?? null; + $result['customer_barred'] = $context['barred'] ?? null; + $result['customer_status'] = $context['status'] ?? $this->customerBarredStatus($context['barred'] ?? null); + $result['title'] = $this->overrideUnnamedUserTitleWithCustomerName($result, $context); + } + + return $result; + } + + /** + * @param array $result + * @param array $context + */ + private function overrideUnnamedUserTitleWithCustomerName(array $result, array $context): string + { + $title = trim((string)($result['title'] ?? '')); + if (trim(mb_strtolower((string)($result['entity_type'] ?? ''))) !== 'users') { + return $title; + } + if (trim(mb_strtolower($title)) !== 'unnamed') { + return $title; + } + + $customerName = trim((string)($context['name'] ?? '')); + return $customerName !== '' ? $customerName : $title; + } + + /** + * @param array $payload + * @param array|null $context + * @return array + */ + private function enrichPayloadWithCustomerContext(array $payload, ?int $customerNumber, ?array $context = null): array + { + if ($customerNumber !== null) { + $payload['customer_number'] = $customerNumber; + } + if ($context === null) { + $context = $this->customerContext($customerNumber); + } + if ($context === null) { + return $payload; + } + + $payload['customer_context'] = $context; + $payload['customer_name'] = $context['name'] ?? null; + $payload['customer_barred'] = $context['barred'] ?? null; + $payload['customer_status'] = $context['status'] ?? $this->customerBarredStatus($context['barred'] ?? null); + foreach (['email', 'phone', 'cvr', 'address', 'city', 'zip', 'user_id'] as $key) { + if (array_key_exists($key, $context)) { + $payload['customer_' . $key] = $context[$key]; + } + } + + return $payload; + } + + private function customerBarredStatus(?bool $barred): string + { + return match ($barred) { + true => 'barred', + false => 'active', + default => 'unknown', + }; + } + + private function resultRecencyTimestamp(array $result): int + { + $timestamps = []; + foreach (['updated_at', 'created_at', 'date', 'timestamp'] as $key) { + if (array_key_exists($key, $result)) { + $timestamps[] = $this->normalizeTimestamp($result[$key]); + } + } + + $payload = $result['payload'] ?? null; + if (is_array($payload)) { + foreach (['updated_at', 'created_at', 'date', 'timestamp'] as $key) { + if (!array_key_exists($key, $payload)) { + continue; + } + $timestamps[] = $this->normalizeTimestamp($payload[$key]); + } + } + + $timestamps = array_values(array_filter($timestamps, static fn($v) => is_int($v) && $v > 0)); + if (empty($timestamps)) { + return 0; + } + return max($timestamps); + } + + private function isCancelledBookingResult(array $result): bool + { + $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? ''))); + if (!in_array($entityType, ['bookings', 'bookings_new', 'order_bookings'], true)) { + return false; + } + + $sources = [$result]; + $payload = $result['payload'] ?? null; + if (is_array($payload)) { + $sources[] = $payload; + } + + foreach ($sources as $source) { + foreach (['is_cancelled', 'is_canceled', 'cancelled', 'canceled'] as $flag) { + if (!array_key_exists($flag, $source)) { + continue; + } + if ($this->boolishTrue($source[$flag])) { + return true; + } + } + + foreach (['cancelled_at', 'canceled_at', 'deleted_at'] as $timestampField) { + if (!array_key_exists($timestampField, $source)) { + continue; + } + if ($this->normalizeTimestamp($source[$timestampField]) > 0) { + return true; + } + } + + foreach (['status', 'booking_status', 'state'] as $stateField) { + if (!array_key_exists($stateField, $source)) { + continue; + } + $state = trim(mb_strtolower((string)$source[$stateField])); + if ($state === '') { + continue; + } + if (preg_match('/\b(cancelled?|canceled|aflyst|annulleret|void|voided|cancel)\b/u', $state)) { + return true; + } + } + } + + return false; + } + + private function rankingBoost(array $result): int + { + $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? ''))); + if ($entityType === '') { + return 0; + } + return (int)($this->rankingBoostByType[$entityType] ?? 0); + } + + private function rankingPenalty(array $result): int + { + $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? ''))); + if ($entityType === '') { + return 0; + } + return (int)($this->rankingPenaltyByType[$entityType] ?? 0); + } + + private function boolishTrue(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return (float)$value > 0; + } + if (!is_string($value)) { + return false; + } + $normalized = trim(mb_strtolower($value)); + if ($normalized === '') { + return false; + } + return in_array($normalized, ['1', 'true', 'yes', 'y', 'on'], true); + } + + private function normalizeTimestamp(mixed $value): int + { + if ($value === null) { + return 0; + } + if (is_int($value)) { + if ($value > 2000000000) { + return (int)floor($value / 1000); + } + return max(0, $value); + } + if (is_float($value)) { + return $this->normalizeTimestamp((int)$value); + } + if (is_string($value)) { + $trimmed = trim($value); + if ($trimmed === '') { + return 0; + } + if (preg_match('/^\d+$/', $trimmed)) { + return $this->normalizeTimestamp((int)$trimmed); + } + $parsed = strtotime($trimmed); + return $parsed !== false ? max(0, (int)$parsed) : 0; + } + return 0; + } + + /** + * @param array $terms + */ + private function shouldPreferRecencySort(string $query, array $terms): bool + { + if (empty($terms)) { + return false; + } + $normalized = trim(mb_strtolower($query)); + if ($normalized === '') { + return false; + } + + // Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact intent. + if ($this->queryHasExplicitIdentifier($normalized)) { + return false; + } + return true; + } + + /** + * @param array $terms + * @return array + */ + private function buildExpandedTerms(array $terms): array + { + $base = $this->limitTerms($terms); + if (empty($base)) { + return []; + } + return $this->limitTerms([ + ...$base, + ...$this->expandLexicalSynonyms($base), + ]); + } + + /** + * @param array $boostedTypes + * @param array> $taxonomy + * @return array + */ + private function hintAliasTerms(array $boostedTypes, array $taxonomy): array + { + $terms = []; + foreach ($boostedTypes as $type) { + $aliases = $taxonomy[$type] ?? []; + if (!is_array($aliases)) { + continue; + } + foreach ($aliases as $alias) { + if (!is_string($alias) || trim($alias) === '') { + continue; + } + $terms = [...$terms, ...$this->tokenize($alias)]; + if (count($terms) >= 12) { + return array_slice(array_values(array_unique($terms)), 0, 12); + } + } + } + return array_slice(array_values(array_unique($terms)), 0, 12); + } + + /** + * Detect natural-language style queries where intent parsing is valuable + * even when lexical score looks strong. + * + * @param array $terms + */ + private function queryLooksIntentDriven(string $query, array $terms): bool + { + $normalized = trim(mb_strtolower($query)); + if ($normalized === '' || count($terms) < 2) { + return false; + } + + if ($this->queryHasExplicitIdentifier($normalized)) { + return false; + } + + $hasIntentVerb = preg_match('/\b(find|show|search|looking|need|want|where|which)\b/iu', $normalized) === 1; + $hasRelationalLanguage = preg_match('/\b(with|without|from|between|for|unpaid|overdue|rabat|discount|faktura|invoice|kunde|customer|orders?|vehicles?)\b/iu', $normalized) === 1; + $hasStrongDomainLanguage = preg_match('/\b(unpaid|overdue|rabat|discount|faktura|invoice)\b/iu', $normalized) === 1; + + if ($hasStrongDomainLanguage && count($terms) >= 2) { + return true; + } + + if ($hasIntentVerb && count($terms) >= 3) { + return true; + } + + if ($hasRelationalLanguage && count($terms) >= 3 && mb_strlen($normalized) >= 16) { + return true; + } + + return mb_strlen($normalized) >= 28 && count($terms) >= 4; + } + + private function queryHasExplicitIdentifier(string $normalizedQuery): bool + { + if ($normalizedQuery === '') { + return false; + } + if (str_contains($normalizedQuery, '@')) { + return true; + } + if (preg_match('/(?:^|[\s#])(order|invoice|booking|customer|kunde|vehicle|subuser|user)[\s:#-]*\d{3,}/iu', $normalizedQuery)) { + return true; + } + if (preg_match('/\b\d{5,}\b/', $normalizedQuery)) { + return true; + } + if (preg_match('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', $normalizedQuery)) { + return true; + } + return false; + } + + /** + * @param array $terms + */ + private function buildBooleanFullTextQuery(array $terms): ?string + { + $parts = []; + foreach ($terms as $term) { + $normalized = trim(mb_strtolower((string)$term)); + if ($normalized === '' || mb_strlen($normalized) < 3) { + return null; + } + $sanitized = preg_replace('/[^\p{L}\p{N}_]+/u', '', $normalized); + if (!is_string($sanitized) || $sanitized === '' || mb_strlen($sanitized) < 3) { + return null; + } + $parts[] = '+' . $sanitized . '*'; + } + return empty($parts) ? null : implode(' ', array_values(array_unique($parts))); + } + + /** + * @param array $terms + * @return array + */ + private function expandLexicalSynonyms(array $terms): array + { + $synonyms = [ + 'rabat' => ['discount', 'discounts'], + 'rabatordning' => ['discount'], + 'rabatter' => ['discounts', 'discount'], + 'discount' => ['rabat'], + 'discounts' => ['rabat'], + 'kunde' => ['customer', 'customers'], + 'kunder' => ['customer', 'customers'], + 'faktura' => ['invoice', 'invoices'], + 'fakturaer' => ['invoice', 'invoices'], + ]; + + $expanded = []; + foreach ($terms as $term) { + $term = trim(mb_strtolower((string)$term)); + if ($term === '' || !isset($synonyms[$term])) { + continue; + } + foreach ($synonyms[$term] as $synonym) { + $expanded[] = $synonym; + } + } + return array_values(array_unique($expanded)); + } + + private function isEconomicCustomerIndexAvailable(): bool + { + return $this->tableExists(system_search_economic_customer_index::TABLE); + } + + private function tokenize(string $query): array + { + $query = trim(mb_strtolower($query)); + if ($query === '') { + return []; + } + $parts = preg_split('/[^\p{L}\p{N}_]+/u', $query) ?: []; + $parts = array_values(array_filter(array_map('trim', $parts), static fn($p) => $p !== '' && mb_strlen($p) >= 2)); + return array_values(array_unique($parts)); + } + + private function limitTerms(array $terms): array + { + $normalized = []; + foreach ($terms as $term) { + if (!is_string($term)) { + continue; + } + $value = trim(mb_strtolower($term)); + if ($value === '' || mb_strlen($value) < 2) { + continue; + } + if (mb_strlen($value) > $this->maxTermLength) { + $value = mb_substr($value, 0, $this->maxTermLength); + } + $normalized[] = $value; + if (count($normalized) >= $this->maxExpandedTerms) { + break; + } + } + return array_values(array_unique($normalized)); + } + + private function permissionContextFingerprint( + array $permissionsCatalogAll, + array $permissionsCatalogOwn, + array $moduleConfigVisibility + ): string { + $all = []; + foreach ($permissionsCatalogAll as $permission => $description) { + if (!is_string($permission) || $permission === '') { + continue; + } + $all[$permission] = is_string($description) ? $description : (string)$description; + } + ksort($all); + + $own = array_values(array_unique(array_filter(array_map(static fn($v) => is_string($v) ? trim($v) : '', $permissionsCatalogOwn)))); + sort($own); + + $visibility = []; + foreach ($moduleConfigVisibility as $module => $visible) { + if (!is_string($module) || $module === '') { + continue; + } + $visibility[$module] = (bool)$visible; + } + ksort($visibility); + + return md5(json_encode([ + 'all' => $all, + 'own' => $own, + 'visibility' => $visibility, + 'v' => 1, + ], JSON_UNESCAPED_UNICODE)); + } + + /** + * @param array $activeTypes + * @return array + */ + private function relevantSourceTables(array $activeTypes): array + { + $tables = []; + foreach ($activeTypes as $entityType) { + $tables = [...$tables, ...system_search_registry::sourceTablesForEntityType($entityType)]; + if ($this->entityTypeUsesCustomerContext($entityType)) { + $tables[] = system_search_economic_customer_index::TABLE; + } + } + return array_values(array_unique(array_filter($tables, static fn($table) => is_string($table) && $table !== ''))); + } + + private function entityTypeUsesCustomerContext(string $entityType): bool + { + $entityType = trim(mb_strtolower($entityType)); + if (in_array($entityType, ['objects', 'orders', 'order_items', 'invoices', 'vehicles', 'customers', 'employees', 'customer_discounts', 'customer_fixed_prices'], true)) { + return true; + } + + $config = $this->genericEntityConfigs()[$entityType] ?? null; + return is_array($config) + && isset($config['customer_field']) + && is_string($config['customer_field']) + && trim($config['customer_field']) !== ''; + } + + private function normalizeTypes(array $types): array + { + $normalized = []; + foreach ($types as $type) { + if (!is_string($type)) { + continue; + } + $t = trim(mb_strtolower($type)); + if ($t === '') { + continue; + } + $normalized[] = $t; + } + return array_values(array_unique($normalized)); + } + + private function allEntityTypes(): array + { + return system_search_registry::allEntityTypes(); + } + + private function taxonomy(array $activeTypes): array + { + $aliases = system_search_registry::taxonomyAliases(); + $taxonomy = []; + foreach ($activeTypes as $type) { + $resolved = $aliases[$type] ?? []; + if (empty($resolved)) { + $human = str_replace('_', ' ', $type); + $singular = rtrim($human, 's'); + $resolved = array_values(array_unique(array_filter([$human, $singular], static fn($v) => is_string($v) && $v !== ''))); + } + $taxonomy[$type] = $resolved; + } + return $taxonomy; + } + + private function groupResultsByType(array $results): array + { + $grouped = []; + foreach ($results as $item) { + $type = (string)$item['entity_type']; + if (!isset($grouped[$type])) { + $grouped[$type] = []; + } + $grouped[$type][] = $item; + } + return $grouped; + } +} diff --git a/services/nginx/app/classes/system_session_activity_schema_bootstrap.php b/services/nginx/app/classes/system_session_activity_schema_bootstrap.php new file mode 100644 index 00000000..040a6561 --- /dev/null +++ b/services/nginx/app/classes/system_session_activity_schema_bootstrap.php @@ -0,0 +1,124 @@ +query($sql); + } + + self::ensureColumn( + 'system_session_activity', + 'customer_number_context', + 'ALTER TABLE system_session_activity ADD COLUMN customer_number_context INT NULL AFTER principal_id' + ); + self::ensureColumn( + 'system_session_activity', + 'device_type', + "ALTER TABLE system_session_activity ADD COLUMN device_type VARCHAR(16) NOT NULL DEFAULT 'unknown' AFTER customer_number_context" + ); + self::ensureColumn( + 'system_session_activity', + 'user_agent', + 'ALTER TABLE system_session_activity ADD COLUMN user_agent VARCHAR(1024) NULL AFTER device_type' + ); + self::ensureColumn( + 'system_session_activity', + 'last_route', + 'ALTER TABLE system_session_activity ADD COLUMN last_route VARCHAR(255) NULL AFTER user_agent' + ); + self::ensureColumn( + 'system_session_activity', + 'first_seen_at', + 'ALTER TABLE system_session_activity ADD COLUMN first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER last_route' + ); + self::ensureColumn( + 'system_session_activity', + 'last_seen_at', + 'ALTER TABLE system_session_activity ADD COLUMN last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER first_seen_at' + ); + self::ensureColumn( + 'system_session_activity', + 'created_at', + 'ALTER TABLE system_session_activity ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER last_seen_at' + ); + self::ensureColumn( + 'system_session_activity', + 'updated_at', + 'ALTER TABLE system_session_activity ADD COLUMN updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at' + ); + + self::$initialized = true; + } + + public static function tableHasColumn(string $table, string $column): bool + { + global $db; + + $table = $db->escape_string($table); + $column = $db->escape_string($column); + $database = $db->escape_string($db->getDatabase()); + + $sql = "SELECT COUNT(*) AS c + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = '$database' + AND TABLE_NAME = '$table' + AND COLUMN_NAME = '$column'"; + $result = $db->query($sql); + if (!$result) { + return false; + } + + $row = $result->fetch_assoc(); + return ((int)($row['c'] ?? 0)) > 0; + } + + public static function ensureColumn(string $table, string $column, string $alterSql): void + { + global $db; + + if (self::tableHasColumn($table, $column)) { + return; + } + + $db->query($alterSql); + } +} diff --git a/services/nginx/app/classes/system_session_activity_tracker.php b/services/nginx/app/classes/system_session_activity_tracker.php new file mode 100644 index 00000000..2b1934d8 --- /dev/null +++ b/services/nginx/app/classes/system_session_activity_tracker.php @@ -0,0 +1,306 @@ + + */ + private static array $touchedSessions = []; + + public function __construct() + { + system_session_activity_schema_bootstrap::ensureTables(); + } + + public function touchUser(users_o $user, string $token): void + { + $customerNumber = null; + if (isset($user->customer_number)) { + try { + $customerNumber = (int)$user->customer_number->value(); + } catch (Exception) { + $customerNumber = null; + } + } + + $this->touch('user', (int)$user->id, $token, $customerNumber); + } + + public function touchSubuser(subusers_o $subuser, string $token, ?int $customerNumberContext = null): void + { + $this->touch('subuser', (int)$subuser->id, $token, $customerNumberContext); + } + + public function touch(string $sessionKind, int $principalId, string $token, ?int $customerNumberContext = null): void + { + global $db; + + $token = trim($token); + if ($token === '' || $principalId <= 0) { + return; + } + + $sessionHash = hash('sha256', $token); + if (isset(self::$touchedSessions[$sessionHash])) { + return; + } + self::$touchedSessions[$sessionHash] = true; + + $headers = function_exists('getallheaders') ? (getallheaders() ?: []) : []; + $userAgent = trim((string)($headers['User-Agent'] ?? $headers['user-agent'] ?? '')); + $lastRoute = trim((string)($_SERVER['REQUEST_URI'] ?? '')); + if ($lastRoute !== '') { + $lastRoute = explode('?', $lastRoute)[0] ?? $lastRoute; + } + + $sessionHashEscaped = $db->escape_string($sessionHash); + $sessionKindEscaped = $db->escape_string($sessionKind); + $deviceTypeEscaped = $db->escape_string(self::detectDeviceType($userAgent)); + $userAgentEscaped = $db->escape_string(substr($userAgent, 0, 1024)); + $lastRouteEscaped = $db->escape_string(substr($lastRoute, 0, 255)); + $customerNumberSql = $customerNumberContext === null ? 'NULL' : (string)(int)$customerNumberContext; + $currentUtcDateTime = self::utcSqlDateTime(); + + $sql = "INSERT INTO system_session_activity ( + session_hash, + session_kind, + principal_id, + customer_number_context, + device_type, + user_agent, + last_route, + first_seen_at, + last_seen_at + ) VALUES ( + '$sessionHashEscaped', + '$sessionKindEscaped', + " . (int)$principalId . ", + $customerNumberSql, + '$deviceTypeEscaped', + '$userAgentEscaped', + '$lastRouteEscaped', + '$currentUtcDateTime', + '$currentUtcDateTime' + ) + ON DUPLICATE KEY UPDATE + customer_number_context = VALUES(customer_number_context), + device_type = VALUES(device_type), + user_agent = VALUES(user_agent), + last_route = VALUES(last_route), + last_seen_at = '$currentUtcDateTime'"; + + $db->query($sql); + } + + public function getSnapshot(int $limit = 50): array + { + global $db; + + system_session_activity_schema_bootstrap::ensureTables(); + + $limit = max(1, min(200, $limit)); + $cutoff = self::utcSqlDateTime(time() - (self::ACTIVE_WINDOW_MINUTES * 60)); + $cutoffEscaped = $db->escape_string($cutoff); + + $activeUsersResult = $db->query( + "SELECT COUNT(DISTINCT CONCAT(session_kind, ':', principal_id)) AS c + FROM system_session_activity + WHERE last_seen_at >= '$cutoffEscaped'" + ); + $activeSessionsResult = $db->query( + "SELECT COUNT(*) AS c + FROM system_session_activity + WHERE last_seen_at >= '$cutoffEscaped'" + ); + + $recentRows = $db->query( + "SELECT + s.session_kind, + s.principal_id, + s.customer_number_context, + s.device_type, + s.user_agent, + s.last_route, + s.first_seen_at, + s.last_seen_at, + u.display_name AS user_display_name, + u.customer_number AS user_customer_number, + su.name AS subuser_name, + su.username AS subuser_username + FROM system_session_activity s + LEFT JOIN users u + ON s.session_kind = 'user' + AND u.id = s.principal_id + LEFT JOIN subusers su + ON s.session_kind = 'subuser' + AND su.id = s.principal_id + ORDER BY s.last_seen_at DESC + LIMIT $limit" + ); + + $recentSessions = []; + while ($row = $recentRows->fetch_assoc()) { + $isUser = ($row['session_kind'] ?? '') === 'user'; + $displayName = $isUser + ? trim((string)($row['user_display_name'] ?? '')) + : trim((string)($row['subuser_name'] ?? '')); + $displayNameKey = null; + $displayNameParams = []; + + if ($displayName === '') { + if ($isUser && !empty($row['user_customer_number'])) { + $displayName = 'Customer ' . $row['user_customer_number']; + $displayNameKey = 'customer_number'; + $displayNameParams = ['number' => (int)$row['user_customer_number']]; + } elseif (!$isUser && !empty($row['subuser_username'])) { + $displayName = $row['subuser_username']; + } elseif ($isUser) { + $displayName = 'User #' . (int)($row['principal_id'] ?? 0); + $displayNameKey = 'user_with_id'; + $displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)]; + } elseif (($row['session_kind'] ?? '') === 'subuser') { + $displayName = 'Subuser #' . (int)($row['principal_id'] ?? 0); + $displayNameKey = 'subuser_with_id'; + $displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)]; + } else { + $displayName = 'Session #' . (int)($row['principal_id'] ?? 0); + $displayNameKey = 'session_with_id'; + $displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)]; + } + } + + $contextLabel = null; + $contextLabelKey = null; + $contextLabelParams = []; + if ($isUser && !empty($row['user_customer_number'])) { + $contextLabel = 'Customer ' . $row['user_customer_number']; + $contextLabelKey = 'customer_number'; + $contextLabelParams = ['number' => (int)$row['user_customer_number']]; + } elseif (!empty($row['customer_number_context'])) { + $contextLabel = 'Customer ' . $row['customer_number_context']; + $contextLabelKey = 'customer_number'; + $contextLabelParams = ['number' => (int)$row['customer_number_context']]; + } + + $firstSeenAt = self::databaseDateTimeToIso8601($row['first_seen_at'] ?? null); + $lastSeenAt = self::databaseDateTimeToIso8601($row['last_seen_at'] ?? null); + + $recentSessions[] = [ + 'session_kind' => (string)($row['session_kind'] ?? 'unknown'), + 'principal_id' => (int)($row['principal_id'] ?? 0), + 'display_name' => $displayName, + 'display_name_key' => $displayNameKey, + 'display_name_params' => $displayNameParams, + 'context_label' => $contextLabel, + 'context_label_key' => $contextLabelKey, + 'context_label_params' => $contextLabelParams, + 'customer_number_context' => isset($row['customer_number_context']) ? (int)$row['customer_number_context'] : null, + 'device_type' => (string)($row['device_type'] ?? 'unknown'), + 'user_agent' => (string)($row['user_agent'] ?? ''), + 'last_route' => (string)($row['last_route'] ?? ''), + 'first_seen_at' => $firstSeenAt, + 'last_seen_at' => $lastSeenAt, + 'active' => self::isActive($lastSeenAt), + ]; + } + + $activeUsers = (int)(($activeUsersResult?->fetch_assoc()['c']) ?? 0); + $activeSessions = (int)(($activeSessionsResult?->fetch_assoc()['c']) ?? 0); + + return [ + 'active_window_minutes' => self::ACTIVE_WINDOW_MINUTES, + 'active_users' => $activeUsers, + 'active_sessions' => $activeSessions, + 'recent_sessions' => $recentSessions, + ]; + } + + public function pruneOlderThanDays(int $days = self::PRUNE_AFTER_DAYS): int + { + global $db; + + system_session_activity_schema_bootstrap::ensureTables(); + + $days = max(1, $days); + $cutoff = self::utcSqlDateTime(time() - ($days * 86400)); + $cutoffEscaped = $db->escape_string($cutoff); + + $db->query("DELETE FROM system_session_activity WHERE last_seen_at < '$cutoffEscaped'"); + return (int)$db->conn()->affected_rows; + } + + public static function detectDeviceType(string $userAgent): string + { + $userAgent = strtolower(trim($userAgent)); + if ($userAgent === '') { + return 'unknown'; + } + + if (preg_match('/bot|crawler|spider|slurp|curl|wget|postman|insomnia/', $userAgent) === 1) { + return 'bot'; + } + if (preg_match('/ipad|tablet|kindle|playbook|silk/', $userAgent) === 1) { + return 'tablet'; + } + if (preg_match('/iphone|ipod|android.+mobile|windows phone|mobile/', $userAgent) === 1) { + return 'mobile'; + } + if (preg_match('/macintosh|windows nt|linux|x11|cros/', $userAgent) === 1) { + return 'desktop'; + } + + return 'unknown'; + } + + public static function isActive(?string $lastSeenAt, int $windowMinutes = self::ACTIVE_WINDOW_MINUTES, ?int $referenceTimestamp = null): bool + { + if ($lastSeenAt === null || trim($lastSeenAt) === '') { + return false; + } + + $lastSeenTimestamp = strtotime($lastSeenAt); + if ($lastSeenTimestamp === false) { + return false; + } + + $referenceTimestamp = $referenceTimestamp ?? time(); + return $lastSeenTimestamp >= ($referenceTimestamp - (max(1, $windowMinutes) * 60)); + } + + public static function databaseDateTimeToIso8601(?string $value): ?string + { + if ($value === null || trim($value) === '') { + return null; + } + + $dateTime = DateTimeImmutable::createFromFormat( + 'Y-m-d H:i:s', + trim($value), + new DateTimeZone(self::DATABASE_TIMEZONE) + ); + if (!$dateTime instanceof DateTimeImmutable) { + return null; + } + + return $dateTime->format(DATE_ATOM); + } + + private static function utcSqlDateTime(?int $timestamp = null): string + { + return gmdate('Y-m-d H:i:s', $timestamp ?? time()); + } +} diff --git a/services/nginx/app/classes/weatherapi.php b/services/nginx/app/classes/weatherapi.php new file mode 100644 index 00000000..4994ead9 --- /dev/null +++ b/services/nginx/app/classes/weatherapi.php @@ -0,0 +1,129 @@ +config = new weatherapi_c(); + } + + public function requireModuleEnabled(): void + { + if (!$this->config->enabled->isTrue()) { + throw new Exception('The weatherapi module is not enabled'); + } + } + + public function requireValidSecretKey(): void + { + if ($this->config->secret_key->getVariableValue() === null || $this->config->secret_key->getVariableValue() === '') { + throw new Exception('Invalid secret key defined in the config (weatherapi_secret_key_c)'); + } + } + + public function sendRequest(string $endpoint, array $query = []): object + { + $this->requireModuleEnabled(); + $this->requireValidSecretKey(); + + $endpoint = ltrim($endpoint, '/'); + $query = array_merge(['key' => $this->config->secret_key->getVariableValue()], $query); + $url = $this->api_url . $endpoint . '?' . http_build_query($query); + + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => 'GET', + ]); + + $response = curl_exec($curl); + $status_code = curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_error($curl); + curl_close($curl); + + if ($error) { + throw new Exception('cURL request failed: ' . $error); + } + + if ($response === false || $response === '') { + throw new Exception('Empty response from WeatherAPI'); + } + + $decoded = json_decode($response); + if (json_last_error() !== JSON_ERROR_NONE || !is_object($decoded) && !is_array($decoded)) { + throw new Exception('Invalid response from WeatherAPI'); + } + + if ($status_code >= 400) { + $message = 'WeatherAPI request failed with HTTP ' . $status_code; + if (is_object($decoded) && isset($decoded->error->message)) { + $message .= ': ' . $decoded->error->message; + } + throw new Exception($message); + } + + return (object)$decoded; + } + + public function current(string $query, array $options = []): object + { + return $this->sendRequest('current.json', array_merge(['q' => $query], $options)); + } + + public function forecast(string $query, int $days = 1, array $options = []): object + { + return $this->sendRequest('forecast.json', array_merge(['q' => $query, 'days' => $days], $options)); + } + + public function history(string $query, string $date, array $options = []): object + { + return $this->sendRequest('history.json', array_merge(['q' => $query, 'dt' => $date], $options)); + } + + public function astronomy(string $query, string $date, array $options = []): object + { + return $this->sendRequest('astronomy.json', array_merge(['q' => $query, 'dt' => $date], $options)); + } + + public function timezone(string $query, array $options = []): object + { + return $this->sendRequest('timezone.json', array_merge(['q' => $query], $options)); + } + + public function sports(string $query, array $options = []): object + { + return $this->sendRequest('sports.json', array_merge(['q' => $query], $options)); + } + + public function search(string $query, array $options = []): object + { + return $this->sendRequest('search.json', array_merge(['q' => $query], $options)); + } + + public function marine(string $query, int $days = 1, array $options = []): object + { + return $this->sendRequest('marine.json', array_merge(['q' => $query, 'days' => $days], $options)); + } + + public function future(string $query, string $date, array $options = []): object + { + return $this->sendRequest('future.json', array_merge(['q' => $query, 'dt' => $date], $options)); + } +} diff --git a/services/nginx/app/classes/workfeed.php b/services/nginx/app/classes/workfeed.php new file mode 100644 index 00000000..000102d0 --- /dev/null +++ b/services/nginx/app/classes/workfeed.php @@ -0,0 +1,328 @@ +config = new workfeed_c(); + } + + /** + * @throws Exception + */ + public function requireModuleEnabled(): void + { + if (!$this->config->enabled->isTrue()) { + throw new Exception('The workfeed module is not enabled.'); + } + } + + /** + * @throws Exception + */ + public function listEmployees(array $filters = []): array|object + { + return $this->sendApiRequest('GET', '/employees'); + } + + /** + * @throws Exception + */ + public function getEmployee(string $id): object + { + $this->requireValidIdentifier($id, 'employee id'); + + return $this->sendApiRequest('GET', '/employees/' . rawurlencode($id)); + } + + /** + * @throws Exception + */ + public function listShifts(array $filters = []): array|object + { + $normalizedFilters = $this->normalizeShiftFilters($filters); + + return $this->sendApiRequest('GET', '/shifts', $normalizedFilters); + } + + /** + * @throws Exception + */ + public function getShift(string $id): object + { + $this->requireValidIdentifier($id, 'shift id'); + + return $this->sendApiRequest('GET', '/shifts/' . rawurlencode($id)); + } + + /** + * @throws Exception + */ + public function listDepartments(array $filters = []): array|object + { + return $this->sendApiRequest('GET', '/departments'); + } + + /** + * @throws Exception + */ + private function sendApiRequest(string $method, string $path, array $query = []): array|object + { + $this->requireModuleEnabled(); + $this->requireConfiguredApiUrl(); + $this->requireConfiguredApiKey(); + $companyId = $this->requireConfiguredCompanyId(); + + $url = $this->buildUrl( + $this->config->api_url->getVariableValue(), + '/companies/' . rawurlencode($companyId) . '/' . ltrim($path, '/'), + $query + ); + $headers = [ + 'Accept: application/json', + 'Authorization: ' . trim((string)$this->config->api_key->getVariableValue()), + ]; + + return $this->executeJsonRequest($method, $url, $headers); + } + + /** + * @throws Exception + */ + private function executeJsonRequest(string $method, string $url, array $headers): array|object + { + $response = $this->executeRequest($method, $url, $headers); + $decoded = json_decode($response['body']); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('Invalid JSON response from Workfeed (HTTP ' . $response['status'] . ').'); + } + + if ($response['status'] >= 400) { + throw new Exception($this->extractErrorMessage($decoded, $response['status'], 'Workfeed API request failed')); + } + + if (is_object($decoded) || is_array($decoded)) { + return $decoded; + } + + return (object)[ + 'value' => $decoded, + ]; + } + + /** + * @throws Exception + */ + private function executeRequest(string $method, string $url, array $headers, ?string $body = null): array + { + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + ]); + + if ($body !== null) { + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($curl); + $statusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_error($curl); + curl_close($curl); + + if ($error !== '') { + throw new Exception('cURL request to Workfeed failed: ' . $error); + } + + if ($responseBody === false) { + throw new Exception('Workfeed request returned an empty response.'); + } + + return [ + 'status' => $statusCode, + 'body' => (string)$responseBody, + ]; + } + + private function buildUrl(string $baseUrl, string $path = '', array $query = []): string + { + $url = rtrim(trim($baseUrl), '/'); + if ($path !== '') { + $url .= '/' . ltrim($path, '/'); + } + + $query = array_filter($query, static function (mixed $value): bool { + return $value !== null && $value !== ''; + }); + + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + return $url; + } + + /** + * @throws Exception + */ + private function requireConfiguredApiUrl(): void + { + $url = trim((string)$this->config->api_url->getVariableValue()); + if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) { + throw new Exception('Invalid Workfeed API URL configured.'); + } + } + + /** + * @throws Exception + */ + private function requireConfiguredApiKey(): void + { + if (trim((string)$this->config->api_key->getVariableValue()) === '') { + throw new Exception('Invalid Workfeed API key configured.'); + } + } + + /** + * @throws Exception + */ + private function requireConfiguredCompanyId(): string + { + $companyId = trim((string)$this->config->company_id->getVariableValue()); + if ($companyId === '') { + throw new Exception('Invalid Workfeed CompanyID configured.'); + } + + return $companyId; + } + + private function normalizeUrlForValidation(string $url): string + { + if (preg_match('#^https?://#i', $url)) { + return $url; + } + + return 'https://' . ltrim($url, '/'); + } + + /** + * @throws Exception + */ + private function requireValidIdentifier(string $value, string $label): void + { + if (trim($value) === '') { + throw new Exception('Invalid ' . $label . '.'); + } + } + + private function filterAllowed(array $filters, array $allowedKeys): array + { + $allowed = array_flip($allowedKeys); + $filtered = []; + + foreach ($filters as $key => $value) { + if (isset($allowed[$key])) { + $filtered[$key] = $value; + } + } + + return $filtered; + } + + /** + * @throws Exception + */ + private function normalizeShiftFilters(array $filters): array + { + $filtered = $this->filterAllowed($filters, [ + 'startFrom', + 'startTo', + 'from', + 'to', + 'employeeID', + 'employeeId', + 'released', + ]); + + if (!isset($filtered['startFrom']) && isset($filtered['from'])) { + $filtered['startFrom'] = $filtered['from']; + } + if (!isset($filtered['startTo']) && isset($filtered['to'])) { + $filtered['startTo'] = $filtered['to']; + } + if (!isset($filtered['employeeID']) && isset($filtered['employeeId'])) { + $filtered['employeeID'] = $filtered['employeeId']; + } + + unset($filtered['from'], $filtered['to'], $filtered['employeeId']); + + if (!isset($filtered['startFrom']) || trim((string)$filtered['startFrom']) === '') { + throw new Exception('Workfeed shift query requires startFrom.'); + } + + if (!isset($filtered['startTo']) || trim((string)$filtered['startTo']) === '') { + throw new Exception('Workfeed shift query requires startTo.'); + } + + if (isset($filtered['released'])) { + $filtered['released'] = $this->normalizeBooleanQueryValue($filtered['released']); + } + + return $filtered; + } + + private function normalizeBooleanQueryValue(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return 'true'; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return 'false'; + } + } + + if (is_int($value)) { + return $value === 1 ? 'true' : 'false'; + } + + return (string)$value; + } + + private function extractErrorMessage(mixed $decoded, int $statusCode, string $fallback): string + { + if (is_object($decoded)) { + if (isset($decoded->message) && is_string($decoded->message)) { + return $decoded->message; + } + if (isset($decoded->error) && is_string($decoded->error)) { + return $decoded->error; + } + } + + if (is_string($decoded) && trim($decoded) !== '') { + return $decoded; + } + + return $fallback . ' (HTTP ' . $statusCode . ').'; + } +} diff --git a/services/nginx/app/classes/workfeed_employee_name_formatter.php b/services/nginx/app/classes/workfeed_employee_name_formatter.php new file mode 100644 index 00000000..db549ff8 --- /dev/null +++ b/services/nginx/app/classes/workfeed_employee_name_formatter.php @@ -0,0 +1,103 @@ + $firstNamePaths + * @param array $lastNamePaths + * @param array $fallbackNamePaths + */ + public static function fromRecord( + mixed $record, + array $firstNamePaths, + array $lastNamePaths, + array $fallbackNamePaths = [], + ?string $employeeId = null + ): ?string { + $firstName = self::firstTextValueByPath($record, $firstNamePaths); + $lastName = self::firstTextValueByPath($record, $lastNamePaths); + + $schemaName = self::joinNameParts($firstName, $lastName); + if ($schemaName !== null) { + return $schemaName; + } + + foreach ($fallbackNamePaths as $path) { + $name = self::normalizeTextValue(self::valueByPath($record, $path)); + if ($name !== null && !self::isMissingDisplayName($name, $employeeId)) { + return $name; + } + } + + return null; + } + + public static function isMissingDisplayName(?string $employeeName, ?string $employeeId = null): bool + { + if ($employeeName === null) { + return true; + } + + if ($employeeId !== null && strcasecmp($employeeName, 'Employee ' . $employeeId) === 0) { + return true; + } + + return strcasecmp($employeeName, 'Unknown employee') === 0; + } + + /** + * @param array $paths + */ + private static function firstTextValueByPath(mixed $record, array $paths): ?string + { + foreach ($paths as $path) { + $value = self::normalizeTextValue(self::valueByPath($record, $path)); + if ($value !== null) { + return $value; + } + } + + return null; + } + + private static function joinNameParts(?string $firstName, ?string $lastName): ?string + { + $name = trim((string)($firstName ?? '') . ' ' . (string)($lastName ?? '')); + + return $name !== '' ? $name : null; + } + + private static function valueByPath(mixed $record, string $path): mixed + { + $segments = explode('.', $path); + $value = $record; + foreach ($segments as $segment) { + if (is_array($value) && array_key_exists($segment, $value)) { + $value = $value[$segment]; + continue; + } + + if (is_object($value) && isset($value->{$segment})) { + $value = $value->{$segment}; + continue; + } + + return null; + } + + return $value; + } + + private static function normalizeTextValue(mixed $value): ?string + { + if (!is_scalar($value)) { + return null; + } + + $normalized = trim((string)$value); + + return $normalized !== '' ? $normalized : null; + } +} diff --git a/services/nginx/app/classes/workfeed_shift_time_resolver.php b/services/nginx/app/classes/workfeed_shift_time_resolver.php new file mode 100644 index 00000000..e35e615a --- /dev/null +++ b/services/nginx/app/classes/workfeed_shift_time_resolver.php @@ -0,0 +1,285 @@ +getTimestamp() <= $actual_start->getTimestamp()) { + return null; + } + + return [ + 'actualStart' => $actual_start, + 'scheduledEnd' => $scheduled_end, + 'actualEnd' => $actual_end, + 'hasApproval' => $has_approval, + ]; + } + + 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 + */ + 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 $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; + } +} diff --git a/services/nginx/app/classes/xlvask_automation_service.php b/services/nginx/app/classes/xlvask_automation_service.php new file mode 100644 index 00000000..cee9dcb9 --- /dev/null +++ b/services/nginx/app/classes/xlvask_automation_service.php @@ -0,0 +1,1394 @@ +loadUsageLogRow($usageLogId); + if ($row === null) { + return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); + } + + return $this->evaluateUsageLogRow($row, $actorId, $allowExecute); + } + + public function evaluateUsageLogRow(array $row, ?int $actorId = null, bool $allowExecute = true): array + { + $usageLogId = (int)($row['id'] ?? 0); + if ($usageLogId < 1) { + return $this->emptyAutomation('XL Vask-vasken mangler et gyldigt id.'); + } + + try { + $log = $this->usageLogFromRow($row); + $guard = $this->guardReason($log); + $existing = $this->latestTerminalSuggestion($usageLogId); + if ($guard !== null) { + if ($existing !== null && in_array((string)$existing['status'], [ + self::STATUS_AUTO_ACCEPTED, + self::STATUS_ACCEPTED, + self::STATUS_DENIED, + ], true)) { + return $this->formatSuggestion($existing); + } + + return $this->emptyAutomation($guard); + } + + if ($existing !== null) { + if ((string)$existing['status'] === self::STATUS_SUGGESTED) { + $context = $this->buildContext($usageLogId, $log); + $contextGuard = $this->contextGuardReason($context); + if ($contextGuard !== null) { + return $this->emptyAutomation($contextGuard); + } + + $freshSuggestion = $this->buildSuggestionForContext($context); + if ($freshSuggestion !== null) { + $suggestionId = $this->persistSuggestion($context, $freshSuggestion, $actorId); + $existing = $this->loadSuggestion($suggestionId) ?? $existing; + } + + if ($allowExecute && $this->shouldAutoExecute($existing, $context)) { + return $this->executeSuggestion($existing, $context, $actorId, true); + } + } + + return $this->formatSuggestion($existing); + } + + $context = $this->buildContext($usageLogId, $log); + $contextGuard = $this->contextGuardReason($context); + if ($contextGuard !== null) { + return $this->emptyAutomation($contextGuard); + } + + if ($this->hasDeniedFeedback($context['signature_hash'], self::ACTION_ATTACH) + && $this->hasDeniedFeedback($context['signature_hash'], self::ACTION_CREATE)) { + return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.'); + } + + $suggestion = $this->buildSuggestionForContext($context); + + if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { + return $this->emptyAutomation('Ingen sikker automatiseringshandling fundet.'); + } + + if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) { + return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.'); + } + + $suggestionId = $this->persistSuggestion($context, $suggestion, $actorId); + $suggestionRow = $this->loadSuggestion($suggestionId); + if ($suggestionRow === null) { + return $this->emptyAutomation('Forslaget kunne ikke gemmes.'); + } + + if ($allowExecute && $this->shouldAutoExecute($suggestionRow, $context)) { + return $this->executeSuggestion($suggestionRow, $context, $actorId, true); + } + + return $this->formatSuggestion($suggestionRow); + } catch (Exception $e) { + return [ + ...$this->emptyAutomation('Automatiseringen kunne ikke evaluere vasken.'), + 'status' => self::STATUS_FAILED, + 'error' => $e->getMessage(), + ]; + } + } + + public function acceptUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array + { + $row = $this->loadUsageLogRow($usageLogId); + if ($row === null) { + return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); + } + + $log = $this->usageLogFromRow($row); + $guard = $this->guardReason($log); + if ($guard !== null) { + return $this->emptyAutomation($guard); + } + + $context = $this->buildContext($usageLogId, $log); + $contextGuard = $this->contextGuardReason($context); + if ($contextGuard !== null) { + return $this->emptyAutomation($contextGuard); + } + + $suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId); + if ($suggestion === null) { + $this->evaluateUsageLogRow($row, $actorId, false); + $suggestion = $this->latestActionableSuggestion($usageLogId); + } + + if ($suggestion === null) { + return $this->emptyAutomation('Der er intet forslag at acceptere.'); + } + + $result = $this->executeSuggestion($suggestion, $context, $actorId, false); + $this->persistFeedback($context, (string)$suggestion['action'], 'accepted', (int)($result['matched_order_id'] ?? $result['created_order_id'] ?? 0), $actorId, $reason); + + return $result; + } + + public function denyUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array + { + $row = $this->loadUsageLogRow($usageLogId); + if ($row === null) { + return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); + } + + $log = $this->usageLogFromRow($row); + $context = $this->buildContext($usageLogId, $log); + $suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId); + + if ($suggestion === null) { + return $this->emptyAutomation('Der er intet forslag at afvise.'); + } + + $this->updateSuggestionStatus((int)$suggestion['id'], self::STATUS_DENIED, $actorId); + $this->persistFeedback($context, (string)$suggestion['action'], 'denied', (int)($suggestion['matched_order_id'] ?? 0), $actorId, $reason); + + return $this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion); + } + + public function runPending(?string $dateFrom = null, ?string $dateTo = null, array $ids = [], int $limit = 100, ?int $actorId = null): array + { + $rows = $ids !== [] ? $this->loadUsageLogRowsByIds($ids) : $this->loadPendingRows($dateFrom, $dateTo, $limit); + $results = []; + foreach ($rows as $row) { + $results[] = $this->evaluateUsageLogRow($row, $actorId, true); + } + + return [ + 'processed' => count($results), + 'results' => $results, + ]; + } + + public static function normalizeRegistrationForAutomation(string $registration): string + { + return strtoupper(preg_replace('/[^A-Z0-9]/i', '', $registration) ?? ''); + } + + public static function itemSignaturePartsForAutomation(array $items): array + { + $parts = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $parts[] = implode(':', [ + (int)($item['product_id'] ?? 0), + (int)($item['quantity'] ?? 0), + (int)($item['price'] ?? 0), + ]); + } + + sort($parts, SORT_STRING); + return $parts; + } + + public static function scoreItemMatchForAutomation(array $usageItems, array $orderItems): array + { + $usageSignature = self::itemSignaturePartsForAutomation($usageItems); + $orderSignature = self::itemSignaturePartsForAutomation($orderItems); + $usageTotal = self::itemsTotalForAutomation($usageItems); + $orderTotal = self::itemsTotalForAutomation($orderItems); + + if ($usageSignature === $orderSignature && $usageTotal === $orderTotal) { + return [ + 'confidence' => 0.95, + 'source' => self::SOURCE_DETERMINISTIC, + 'reason' => 'Produkterne og prisen matcher en ordre fra samme dag.', + ]; + } + + $usagePrimary = (int)($usageItems[0]['product_id'] ?? 0); + $orderPrimary = (int)($orderItems[0]['product_id'] ?? 0); + if ($usagePrimary < 1 || $usagePrimary !== $orderPrimary) { + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + $overlap = self::productOverlapForAutomation($usageItems, $orderItems); + $totalDiff = abs($usageTotal - $orderTotal); + if ($overlap >= 0.70 && $totalDiff <= 50) { + return [ + 'confidence' => 0.93, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Samme primære produkt og relaterede tilføjelser matcher en ordre fra samme dag.', + ]; + } + + if ($overlap >= 0.50 && $totalDiff <= 150) { + return [ + 'confidence' => 0.80, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Vasken ligner en ordre fra samme dag, men kræver manuel godkendelse.', + ]; + } + + $matchableUsageItems = self::matchableUsageItemsForAutomation($usageItems); + $matchableOverlap = self::productOverlapForAutomation($matchableUsageItems, $orderItems); + if ( + $matchableUsageItems !== [] + && $matchableOverlap >= 0.95 + && self::orderHasAdditionsBeyondUsage($matchableUsageItems, $orderItems) + ) { + return [ + 'confidence' => 0.88, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Ordren indeholder XL Vask-produkterne samt ekstra ydelser fra samme dag.', + ]; + } + + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + public static function itemsTotalForAutomation(array $items): int + { + return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0); + } + + public static function productOverlapForAutomation(array $usageItems, array $orderItems): float + { + $usageBag = self::productBagForAutomation($usageItems); + $orderBag = self::productBagForAutomation($orderItems); + $usageTotal = array_sum($usageBag); + if ($usageTotal <= 0) { + return 0.0; + } + + $overlap = 0; + foreach ($usageBag as $productId => $quantity) { + $overlap += min($quantity, $orderBag[$productId] ?? 0); + } + + return $overlap / $usageTotal; + } + + public static function productBagForAutomation(array $items): array + { + $bag = []; + foreach ($items as $item) { + $productId = (int)($item['product_id'] ?? 0); + if ($productId < 1) { + continue; + } + $bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1)); + } + + return $bag; + } + + private static function matchableUsageItemsForAutomation(array $items): array + { + $positiveItems = array_values(array_filter($items, static function (array $item): bool { + return (int)($item['product_id'] ?? 0) > 0 + && (int)($item['quantity'] ?? 0) > 0 + && (int)($item['price'] ?? 0) > 0; + })); + + if ($positiveItems !== []) { + return $positiveItems; + } + + return array_values(array_filter($items, static function (array $item): bool { + return (int)($item['product_id'] ?? 0) > 0 + && (int)($item['quantity'] ?? 0) > 0; + })); + } + + private static function orderHasAdditionsBeyondUsage(array $usageItems, array $orderItems): bool + { + $usageBag = self::productBagForAutomation($usageItems); + foreach (self::productBagForAutomation($orderItems) as $productId => $quantity) { + if ($quantity > ($usageBag[$productId] ?? 0)) { + return true; + } + } + + return false; + } + + public static function normalizeUsageLogRowForAutomation(array $row): array + { + unset($row['id']); + + $washItems = $row['WashItems'] ?? []; + if (is_string($washItems)) { + $decoded = json_decode($washItems, true); + $row['WashItems'] = is_array($decoded) ? $decoded : []; + } elseif (!is_array($washItems)) { + $row['WashItems'] = []; + } + + return $row; + } + + public static function openAiCacheKeyForAutomation( + string $schemaName, + string $prompt, + array $payload, + array $schema, + float $temperature + ): string { + $input = [ + 'version' => self::OPENAI_CACHE_VERSION, + 'schema_name' => $schemaName, + 'prompt' => $prompt, + 'payload' => $payload, + 'schema' => $schema, + 'temperature' => round($temperature, 4), + ]; + + return hash('sha256', self::stableJsonForAutomation($input)); + } + + public static function stableJsonForAutomation(mixed $value): string + { + $encoded = json_encode( + self::normalizeForStableJson($value), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + ); + + if ($encoded === false) { + throw new Exception('Kunne ikke opbygge en stabil cache-nøgle for XL Vask-automatisering.'); + } + + return $encoded; + } + + private static function normalizeForStableJson(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $normalized = array_map(fn(mixed $item): mixed => self::normalizeForStableJson($item), $value); + $isList = $normalized === [] || array_keys($normalized) === range(0, count($normalized) - 1); + if (!$isList) { + ksort($normalized, SORT_STRING); + } + + return $normalized; + } + + private function buildDeterministicSuggestion(array $context): ?array + { + $best = null; + foreach ($context['candidate_orders'] as $candidate) { + $score = $this->scoreOrderMatch($context['items'], $candidate['order_items']); + if ($score['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { + continue; + } + + $candidateSuggestion = [ + 'action' => self::ACTION_ATTACH, + 'confidence' => $score['confidence'], + 'source' => $score['source'], + 'matched_order_id' => (int)$candidate['id'], + 'created_order_id' => null, + 'candidate_order' => $candidate, + 'proposed_order' => $context['proposed_order'], + 'reason' => $score['reason'] . ' Ordre #' . (int)$candidate['id'] . '.', + ]; + + if ($best === null || $candidateSuggestion['confidence'] > $best['confidence']) { + $best = $candidateSuggestion; + } + } + + if ($best !== null && $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_ATTACH)) { + $best['confidence'] = max($best['confidence'], 0.96); + $best['source'] = self::SOURCE_HISTORY; + $best['reason'] = 'Tidligere godkendt mønster for køretøjet matcher ordre #' . (int)$best['matched_order_id'] . '.'; + } + + if ($best !== null) { + return $best; + } + + if ($context['age_hours'] >= self::CREATE_MIN_AGE_HOURS) { + $history = $this->findMatchingHistoricalOrder($context); + if ($history !== null || $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_CREATE)) { + return [ + 'action' => self::ACTION_CREATE, + 'confidence' => 0.98, + 'source' => self::SOURCE_HISTORY, + 'matched_order_id' => null, + 'created_order_id' => null, + 'candidate_order' => $history, + 'proposed_order' => $context['proposed_order'], + 'reason' => 'Vasken er over 6 timer gammel og matcher et tidligere godkendt køretøjsmønster.', + ]; + } + } + + return null; + } + + private function buildSuggestionForContext(array $context): ?array + { + $suggestion = $this->buildDeterministicSuggestion($context); + + if ( + ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) + && $this->isOpenAiEnabled() + ) { + $suggestion = $this->buildOpenAiSuggestion($context) ?? $suggestion; + } + + if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { + return null; + } + + if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) { + return null; + } + + return $suggestion; + } + + private function buildOpenAiSuggestion(array $context): ?array + { + try { + $schemaName = 'xlvask_automation'; + $prompt = 'Vurder om en XL Vask-vask skal tilknyttes en eksisterende ordre, oprettes som ordre eller ikke behandles. Returner kun JSON efter skemaet.'; + $temperature = 0.1; + $schema = [ + 'type' => 'object', + 'properties' => [ + 'action' => ['type' => 'string', 'enum' => [self::ACTION_ATTACH, self::ACTION_CREATE, self::ACTION_NONE]], + 'confidence' => ['type' => 'number'], + 'reason_da' => ['type' => 'string'], + 'candidate_order_id' => ['type' => ['integer', 'null']], + 'proposed_order_items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'integer'], + 'quantity' => ['type' => 'integer'], + 'price' => ['type' => 'integer'], + ], + 'required' => ['product_id', 'quantity', 'price'], + 'additionalProperties' => false, + ], + ], + 'risk_flags' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['action', 'confidence', 'reason_da', 'candidate_order_id', 'proposed_order_items', 'risk_flags'], + 'additionalProperties' => false, + ]; + + $creationAllowed = $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS; + $payload = [ + 'usage_log' => [ + 'wash_id' => $context['wash_id'], + 'registration' => $context['signature']['registration'], + 'customer_number' => $context['signature']['customer_number'], + 'department_id' => $context['signature']['department_id'], + 'lane' => $context['signature']['lane'], + 'created_at' => $context['proposed_order']['created_at'] ?? null, + 'total_net_amount' => $context['total'], + 'items' => $this->compactItems($context['items']), + 'creation_allowed' => $creationAllowed, + 'age_bucket' => $creationAllowed ? 'older_than_6_hours' : 'newer_than_6_hours', + ], + 'candidate_orders' => array_map(fn(array $candidate): array => [ + 'id' => (int)$candidate['id'], + 'created_at' => $candidate['created_at'] ?? null, + 'total_net_amount' => (int)($candidate['total_net_amount'] ?? 0), + 'items' => $this->compactItems($candidate['order_items'] ?? []), + ], $context['candidate_orders']), + ]; + + $cacheKey = self::openAiCacheKeyForAutomation($schemaName, $prompt, $payload, $schema, $temperature); + $result = $this->loadOpenAiCacheResult($cacheKey); + if ($result === null) { + $openai = new openai(); + $result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature); + $this->persistOpenAiCacheResult($cacheKey, $schemaName, $payload, $schema, $prompt, $temperature, $result); + } + + $action = (string)($result['action'] ?? self::ACTION_NONE); + $confidence = (float)($result['confidence'] ?? 0); + if (!in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true) || $confidence < self::MIN_SUGGESTION_CONFIDENCE) { + return null; + } + + if ($action === self::ACTION_CREATE && $context['age_hours'] < self::CREATE_MIN_AGE_HOURS) { + return null; + } + + $candidate = null; + $candidateOrderId = (int)($result['candidate_order_id'] ?? 0); + if ($action === self::ACTION_ATTACH) { + foreach ($context['candidate_orders'] as $candidateOrder) { + if ((int)$candidateOrder['id'] === $candidateOrderId) { + $candidate = $candidateOrder; + break; + } + } + if ($candidate === null) { + return null; + } + } + + return [ + 'action' => $action, + 'confidence' => min(1.0, max(0.0, $confidence)), + 'source' => self::SOURCE_OPENAI, + 'matched_order_id' => $candidateOrderId > 0 ? $candidateOrderId : null, + 'created_order_id' => null, + 'candidate_order' => $candidate, + 'proposed_order' => $context['proposed_order'], + 'reason' => (string)($result['reason_da'] ?? 'OpenAI foreslår handlingen ud fra tilgængelige ordredata.'), + ]; + } catch (Exception) { + return null; + } + } + + private function shouldAutoExecute(array $suggestion, array $context): bool + { + $confidence = (float)$suggestion['confidence']; + $action = (string)$suggestion['action']; + $xlvask = new xlvask(); + + if ($action === self::ACTION_ATTACH) { + return $xlvask->config->automatic_order_attachment_enabled->isTrue() + && $confidence >= self::AUTO_ATTACH_CONFIDENCE; + } + + if ($action === self::ACTION_CREATE) { + return $xlvask->config->automatic_order_creation_enabled->isTrue() + && $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS + && $confidence >= self::AUTO_CREATE_CONFIDENCE; + } + + return false; + } + + private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array + { + try { + $action = (string)$suggestion['action']; + if ($action === self::ACTION_ATTACH) { + $orderId = (int)$suggestion['matched_order_id']; + if ($orderId < 1) { + throw new Exception('Forslaget mangler en ordre at tilknytte.'); + } + + if ((new orders_o())->selectByWashId($context['wash_id']) !== null) { + throw new Exception('Vasken er allerede tilknyttet en ordre.'); + } + + $order = (new orders_o())->select($orderId); + $order->wash_id->set($context['wash_id']); + $this->updateSuggestionExecution((int)$suggestion['id'], $automatic ? self::STATUS_AUTO_ACCEPTED : self::STATUS_ACCEPTED, $actorId, $orderId, null); + } elseif ($action === self::ACTION_CREATE) { + if ($context['age_hours'] < self::CREATE_MIN_AGE_HOURS) { + throw new Exception('Vasken er ikke gammel nok til automatisk ordreoprettelse.'); + } + + if ((new orders_o())->selectByWashId($context['wash_id']) !== null) { + throw new Exception('Vasken er allerede tilknyttet en ordre.'); + } + + $order = $this->createOrderFromContext($context); + $this->updateSuggestionExecution((int)$suggestion['id'], $automatic ? self::STATUS_AUTO_ACCEPTED : self::STATUS_ACCEPTED, $actorId, null, (int)$order->id); + } else { + throw new Exception('Ukendt automatiseringshandling.'); + } + + $latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion; + if ($automatic) { + $this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, 'Automatisk accepteret.'); + } + + return $this->formatSuggestion($latest); + } catch (Exception $e) { + $this->updateSuggestionFailure((int)$suggestion['id'], $e->getMessage(), $actorId); + return [ + ...$this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion), + 'status' => self::STATUS_FAILED, + 'error' => $e->getMessage(), + ]; + } + } + + private function createOrderFromContext(array $context): orders_o + { + $orderData = $context['proposed_order']; + $items = $context['items']; + + $order = new orders_o(); + $order->add( + (int)$orderData['customer_id'], + self::AUTOMATION_CASHIER_ID, + (string)($orderData['reference'] ?? ''), + (string)($orderData['notes'] ?? ''), + (int)$orderData['department_id'], + (string)($orderData['reg_1'] ?? ''), + (string)($orderData['reg_2'] ?? ''), + (string)($orderData['reg_3'] ?? '') + ); + $order->wash_id->set($context['wash_id']); + if (isset($orderData['lane'])) { + $order->lane->set((int)$orderData['lane']); + } + if (!empty($orderData['created_at'])) { + $order->created_at->set((string)$orderData['created_at']); + } + + $firstItemId = null; + foreach ($items as $item) { + $orderItem = new order_items_o(); + $orderItem->add( + (int)$order->id, + (int)$item['product_id'], + (string)($item['reference'] ?? ''), + (string)($item['notes'] ?? ''), + self::AUTOMATION_CASHIER_ID, + (int)$item['price'], + (int)$item['quantity'], + $firstItemId + ); + if ($firstItemId === null) { + $firstItemId = (int)$orderItem->id; + } + } + + $order->objectChanged(); + return $order; + } + + private function buildContext(int $usageLogId, xlvask_usage_log $log): array + { + $simulated = (new orders_o())->simulateOrderFromXLVask($log, true); + $proposedOrder = $simulated['order'] ?? []; + $items = $simulated['order_items'] ?? []; + $signature = $this->buildSignature($log, $proposedOrder, $items); + $signatureJson = json_encode($signature, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($signatureJson === false) { + throw new Exception('Kunne ikke opbygge signatur for XL Vask-vasken.'); + } + + return [ + 'usage_log_id' => $usageLogId, + 'wash_id' => (string)$log->WashId, + 'log' => $log, + 'proposed_order' => $proposedOrder, + 'items' => $items, + 'total' => $this->itemsTotal($items), + 'signature' => $signature, + 'signature_json' => $signatureJson, + 'signature_hash' => hash('sha256', $signatureJson), + 'age_hours' => max(0.0, (time() - strtotime((string)$log->StartTime)) / 3600), + 'candidate_orders' => $this->findSameDayCandidateOrders($log, $proposedOrder), + ]; + } + + private function contextGuardReason(array $context): ?string + { + if ((int)($context['proposed_order']['customer_id'] ?? 0) < 1) { + return 'Vasken mangler en gyldig kundemapping.'; + } + + if ((int)($context['proposed_order']['department_id'] ?? 0) < 1) { + return 'Vasken mangler en gyldig afdelingsmapping.'; + } + + if (!is_array($context['items'] ?? null) || count($context['items']) < 1) { + return 'Vasken mangler gyldige produkter.'; + } + + foreach ($context['items'] as $item) { + if (!is_array($item) || (int)($item['product_id'] ?? 0) < 1) { + return 'Vasken mangler gyldige produkter.'; + } + } + + return null; + } + + private function buildSignature(xlvask_usage_log $log, array $proposedOrder, array $items): array + { + return [ + 'registration' => $this->normalizeRegistration((string)$log->RegistrationNumber), + 'customer_number' => (int)$log->CustomerId, + 'department_id' => (int)($proposedOrder['department_id'] ?? 0), + 'lane' => (int)($proposedOrder['lane'] ?? 0), + 'primary_product_id' => (int)($items[0]['product_id'] ?? 0), + 'items' => $this->itemSignatureParts($items), + 'total_net_amount' => $this->itemsTotal($items), + ]; + } + + private function scoreOrderMatch(array $usageItems, array $orderItems): array + { + return self::scoreItemMatchForAutomation($usageItems, $orderItems); + + $usageSignature = $this->itemSignatureParts($usageItems); + $orderSignature = $this->itemSignatureParts($orderItems); + $usageTotal = $this->itemsTotal($usageItems); + $orderTotal = $this->itemsTotal($orderItems); + + if ($usageSignature === $orderSignature && $usageTotal === $orderTotal) { + return [ + 'confidence' => 0.95, + 'source' => self::SOURCE_DETERMINISTIC, + 'reason' => 'Produkterne og prisen matcher en ordre fra samme dag.', + ]; + } + + $usagePrimary = (int)($usageItems[0]['product_id'] ?? 0); + $orderPrimary = (int)($orderItems[0]['product_id'] ?? 0); + if ($usagePrimary < 1 || $usagePrimary !== $orderPrimary) { + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + $overlap = $this->productOverlap($usageItems, $orderItems); + $totalDiff = abs($usageTotal - $orderTotal); + if ($overlap >= 0.70 && $totalDiff <= 50) { + return [ + 'confidence' => 0.93, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Samme primære produkt og relaterede tillæg matcher en ordre fra samme dag.', + ]; + } + + if ($overlap >= 0.50 && $totalDiff <= 150) { + return [ + 'confidence' => 0.80, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Vasken ligner en ordre fra samme dag, men kræver manuel godkendelse.', + ]; + } + + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + private function findSameDayCandidateOrders(xlvask_usage_log $log, array $proposedOrder): array + { + global $db; + + $registration = $db->escape_string($this->normalizeRegistration((string)$log->RegistrationNumber)); + $rawRegistration = $db->escape_string(trim((string)$log->RegistrationNumber)); + $customerNumber = (int)$log->CustomerId; + $departmentId = (int)($proposedOrder['department_id'] ?? 0); + $date = date('Y-m-d', strtotime((string)$log->StartTime)); + $from = $db->escape_string($date . ' 00:00:00'); + $to = $db->escape_string($date . ' 23:59:59'); + + if ($registration === '' || $customerNumber < 1 || $departmentId < 1) { + return []; + } + + $sql = "SELECT * + FROM orders + WHERE deleted_at IS NULL + AND customer_id = {$customerNumber} + AND department_id = {$departmentId} + AND cashier_id <> " . self::AUTOMATION_CASHIER_ID . " + AND created_at BETWEEN '{$from}' AND '{$to}' + AND (wash_id IS NULL OR wash_id = '') + AND ( + REPLACE(UPPER(reg_1), ' ', '') IN ('{$registration}', '{$rawRegistration}') + OR REPLACE(UPPER(reg_2), ' ', '') IN ('{$registration}', '{$rawRegistration}') + OR REPLACE(UPPER(reg_3), ' ', '') IN ('{$registration}', '{$rawRegistration}') + ) + ORDER BY ABS(TIMESTAMPDIFF(SECOND, created_at, '" . $db->escape_string(date('Y-m-d H:i:s', strtotime((string)$log->StartTime))) . "')) ASC + LIMIT 20"; + + $rows = $db->fetch_all($db->query($sql)); + return array_map(function (array $row): array { + $orderItems = (new orders_o())->getOrderItems((int)$row['id']); + return [ + ...$row, + 'id' => (int)$row['id'], + 'total_net_amount' => (int)($row['total_net_amount'] ?? $this->itemsTotal($orderItems)), + 'order_items' => $orderItems, + ]; + }, $rows); + } + + private function findMatchingHistoricalOrder(array $context): ?array + { + global $db; + + $signature = $context['signature']; + $registration = $db->escape_string((string)$signature['registration']); + $customerNumber = (int)$signature['customer_number']; + $departmentId = (int)$signature['department_id']; + $createdBefore = $db->escape_string((string)($context['proposed_order']['created_at'] ?? date('Y-m-d H:i:s'))); + + if ($registration === '' || $customerNumber < 1 || $departmentId < 1) { + return null; + } + + $sql = "SELECT * + FROM orders + WHERE deleted_at IS NULL + AND customer_id = {$customerNumber} + AND department_id = {$departmentId} + AND created_at < '{$createdBefore}' + AND ( + REPLACE(UPPER(reg_1), ' ', '') = '{$registration}' + OR REPLACE(UPPER(reg_2), ' ', '') = '{$registration}' + OR REPLACE(UPPER(reg_3), ' ', '') = '{$registration}' + ) + ORDER BY created_at DESC + LIMIT 10"; + + foreach ($db->fetch_all($db->query($sql)) as $row) { + $orderItems = (new orders_o())->getOrderItems((int)$row['id']); + if ($this->itemSignatureParts($orderItems) === $signature['items']) { + return [ + ...$row, + 'id' => (int)$row['id'], + 'order_items' => $orderItems, + ]; + } + } + + return null; + } + + private function guardReason(xlvask_usage_log $log): ?string + { + if (!empty($log->ignored_at)) { + return 'Vasken er ignoreret.'; + } + + if (!$log->isCompleted()) { + return 'Vasken er ikke afsluttet.'; + } + + if (!$log->hasBillableCustomer()) { + return 'Vasken mangler en fakturerbar kunde.'; + } + + if ((new orders_o())->selectByWashId($log->WashId) !== null) { + return 'Vasken er allerede tilknyttet en ordre.'; + } + + return null; + } + + private function persistSuggestion(array $context, array $suggestion, ?int $actorId): int + { + global $db; + + $existing = $this->latestActionableSuggestion((int)$context['usage_log_id']); + if ($existing !== null) { + $this->updateSuggestionProposal((int)$existing['id'], $context, $suggestion, $actorId); + return (int)$existing['id']; + } + + $fields = [ + 'usage_log_id' => (int)$context['usage_log_id'], + 'wash_id' => (string)$context['wash_id'], + 'signature_hash' => (string)$context['signature_hash'], + 'signature_json' => (string)$context['signature_json'], + 'action' => (string)$suggestion['action'], + 'status' => self::STATUS_SUGGESTED, + 'confidence' => (float)$suggestion['confidence'], + 'source' => (string)$suggestion['source'], + 'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'], + 'created_order_id' => null, + 'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'reason' => (string)$suggestion['reason'], + 'created_by' => $actorId, + ]; + + $columns = []; + $values = []; + foreach ($fields as $column => $value) { + $columns[] = "`{$column}`"; + if ($value === null) { + $values[] = 'NULL'; + } elseif (is_int($value) || is_float($value)) { + $values[] = (string)$value; + } else { + $values[] = "'" . $db->escape_string((string)$value) . "'"; + } + } + + $db->query('INSERT INTO xlvask_automation_suggestions (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ')'); + return (int)$db->insert_id(); + } + + private function updateSuggestionProposal(int $suggestionId, array $context, array $suggestion, ?int $actorId): void + { + global $db; + + $fields = [ + 'signature_hash' => (string)$context['signature_hash'], + 'signature_json' => (string)$context['signature_json'], + 'action' => (string)$suggestion['action'], + 'confidence' => (float)$suggestion['confidence'], + 'source' => (string)$suggestion['source'], + 'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'], + 'created_order_id' => null, + 'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'reason' => (string)$suggestion['reason'], + ]; + + if ($actorId !== null) { + $fields['created_by'] = $actorId; + } + + $assignments = []; + foreach ($fields as $column => $value) { + if ($value === null) { + $sqlValue = 'NULL'; + } elseif (is_int($value) || is_float($value)) { + $sqlValue = (string)$value; + } else { + $sqlValue = "'" . $db->escape_string((string)$value) . "'"; + } + $assignments[] = "`{$column}` = {$sqlValue}"; + } + + $db->query( + 'UPDATE xlvask_automation_suggestions SET ' . implode(', ', $assignments) . + " WHERE id = {$suggestionId} AND status = '" . self::STATUS_SUGGESTED . "'" + ); + } + + private function persistFeedback(array $context, string $action, string $decision, int $orderId = 0, ?int $actorId = null, ?string $reason = null): void + { + global $db; + + $values = [ + 'usage_log_id' => (int)$context['usage_log_id'], + 'wash_id' => (string)$context['wash_id'], + 'signature_hash' => (string)$context['signature_hash'], + 'signature_json' => (string)$context['signature_json'], + 'action' => $action, + 'decision' => $decision, + 'order_id' => $orderId > 0 ? $orderId : null, + 'reason' => $reason, + 'created_by' => $actorId, + ]; + + $columns = []; + $sqlValues = []; + foreach ($values as $column => $value) { + $columns[] = "`{$column}`"; + if ($value === null) { + $sqlValues[] = 'NULL'; + } elseif (is_int($value)) { + $sqlValues[] = (string)$value; + } else { + $sqlValues[] = "'" . $db->escape_string((string)$value) . "'"; + } + } + + $db->query('INSERT INTO xlvask_automation_feedback (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $sqlValues) . ')'); + } + + private function loadOpenAiCacheResult(string $cacheKey): ?array + { + global $db; + + $cacheKey = $db->escape_string($cacheKey); + $result = $db->query( + "SELECT result_json FROM xlvask_automation_openai_cache + WHERE cache_key = '{$cacheKey}' + LIMIT 1" + ); + if ($result === false || $result->num_rows < 1) { + return null; + } + + $row = $db->fetch_assoc($result); + $decoded = json_decode((string)($row['result_json'] ?? ''), true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { + return null; + } + + $db->query( + "UPDATE xlvask_automation_openai_cache + SET hits = hits + 1, last_hit_at = NOW() + WHERE cache_key = '{$cacheKey}'" + ); + + return $decoded; + } + + private function persistOpenAiCacheResult( + string $cacheKey, + string $schemaName, + array $payload, + array $schema, + string $prompt, + float $temperature, + array $result + ): void { + global $db; + + $input = [ + 'version' => self::OPENAI_CACHE_VERSION, + 'schema_name' => $schemaName, + 'prompt' => $prompt, + 'payload' => $payload, + 'schema' => $schema, + 'temperature' => round($temperature, 4), + ]; + + $inputJson = self::stableJsonForAutomation($input); + $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); + if ($resultJson === false) { + return; + } + + $cacheKey = $db->escape_string($cacheKey); + $schemaName = $db->escape_string($schemaName); + $inputJson = $db->escape_string($inputJson); + $resultJson = $db->escape_string($resultJson); + + $db->query( + "INSERT INTO xlvask_automation_openai_cache + (cache_key, schema_name, input_json, result_json) + VALUES + ('{$cacheKey}', '{$schemaName}', '{$inputJson}', '{$resultJson}') + ON DUPLICATE KEY UPDATE + result_json = VALUES(result_json), + input_json = VALUES(input_json), + updated_at = NOW()" + ); + } + + private function hasAcceptedFeedback(string $signatureHash, string $action): bool + { + return $this->hasFeedbackDecision($signatureHash, $action, 'accepted'); + } + + private function hasDeniedFeedback(string $signatureHash, string $action): bool + { + return $this->hasFeedbackDecision($signatureHash, $action, 'denied'); + } + + private function hasFeedbackDecision(string $signatureHash, string $action, string $decision): bool + { + global $db; + $signatureHash = $db->escape_string($signatureHash); + $action = $db->escape_string($action); + $decision = $db->escape_string($decision); + $result = $db->query( + "SELECT id FROM xlvask_automation_feedback + WHERE signature_hash = '{$signatureHash}' AND action = '{$action}' AND decision = '{$decision}' + ORDER BY id DESC LIMIT 1" + ); + return $result !== false && $result->num_rows > 0; + } + + private function latestTerminalSuggestion(int $usageLogId): ?array + { + return $this->latestSuggestionWhere($usageLogId, [ + self::STATUS_SUGGESTED, + self::STATUS_AUTO_ACCEPTED, + self::STATUS_ACCEPTED, + self::STATUS_DENIED, + ]); + } + + private function latestActionableSuggestion(int $usageLogId): ?array + { + return $this->latestSuggestionWhere($usageLogId, [self::STATUS_SUGGESTED]); + } + + private function latestSuggestionWhere(int $usageLogId, array $statuses): ?array + { + global $db; + $statusSql = implode(',', array_map(fn(string $status): string => "'" . $db->escape_string($status) . "'", $statuses)); + $result = $db->query( + "SELECT * FROM xlvask_automation_suggestions + WHERE usage_log_id = {$usageLogId} AND status IN ({$statusSql}) + ORDER BY id DESC LIMIT 1" + ); + if ($result === false || $result->num_rows < 1) { + return null; + } + + return $db->fetch_assoc($result); + } + + private function loadSuggestion(int $suggestionId): ?array + { + global $db; + $result = $db->query("SELECT * FROM xlvask_automation_suggestions WHERE id = {$suggestionId} LIMIT 1"); + if ($result === false || $result->num_rows < 1) { + return null; + } + return $db->fetch_assoc($result); + } + + private function updateSuggestionStatus(int $suggestionId, string $status, ?int $actorId): void + { + global $db; + $status = $db->escape_string($status); + $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; + $db->query( + "UPDATE xlvask_automation_suggestions + SET status = '{$status}', decided_by = {$actorSql}, decided_at = NOW() + WHERE id = {$suggestionId}" + ); + } + + private function updateSuggestionExecution(int $suggestionId, string $status, ?int $actorId, ?int $matchedOrderId, ?int $createdOrderId): void + { + global $db; + $status = $db->escape_string($status); + $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; + $matchedSql = $matchedOrderId === null ? 'matched_order_id' : (string)(int)$matchedOrderId; + $createdSql = $createdOrderId === null ? 'created_order_id' : (string)(int)$createdOrderId; + $db->query( + "UPDATE xlvask_automation_suggestions + SET status = '{$status}', + decided_by = {$actorSql}, + decided_at = NOW(), + executed_at = NOW(), + matched_order_id = {$matchedSql}, + created_order_id = {$createdSql} + WHERE id = {$suggestionId}" + ); + } + + private function updateSuggestionFailure(int $suggestionId, string $message, ?int $actorId): void + { + global $db; + $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; + $message = $db->escape_string($message); + $db->query( + "UPDATE xlvask_automation_suggestions + SET status = '" . self::STATUS_FAILED . "', + reason = CONCAT(COALESCE(reason, ''), ' Fejl: {$message}'), + decided_by = {$actorSql}, + decided_at = NOW() + WHERE id = {$suggestionId}" + ); + } + + private function loadUsageLogRow(int $usageLogId): ?array + { + global $db; + (new xlvask_usage_logs_o())->structure(); + $result = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} LIMIT 1"); + if ($result === false || $result->num_rows < 1) { + return null; + } + + return $db->fetch_assoc($result); + } + + private function loadUsageLogRowsByIds(array $ids): array + { + global $db; + $ids = array_values(array_filter(array_map('intval', $ids), fn(int $id): bool => $id > 0)); + if ($ids === []) { + return []; + } + + (new xlvask_usage_logs_o())->structure(); + $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ')'); + return $db->fetch_all($result); + } + + private function loadPendingRows(?string $dateFrom, ?string $dateTo, int $limit): array + { + global $db; + (new xlvask_usage_logs_o())->structure(); + $where = [ + 'FinishStatus = 1', + '(ignored_at IS NULL OR ignored_at = "")', + ]; + + if ($dateFrom !== null && strtotime($dateFrom) !== false) { + $where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'"; + } else { + $where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'"; + } + + if ($dateTo !== null && strtotime($dateTo) !== false) { + $where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'"; + } + + $limit = max(1, min(500, $limit)); + $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE ' . implode(' AND ', $where) . " ORDER BY StartTime DESC LIMIT {$limit}"); + return $db->fetch_all($result); + } + + private function usageLogFromRow(array $row): xlvask_usage_log + { + $row = self::normalizeUsageLogRowForAutomation($row); + + $xlvask = new xlvask(); + return $xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($row); + } + + private function formatSuggestion(array $row): array + { + $status = (string)($row['status'] ?? self::STATUS_NONE); + return [ + 'id' => isset($row['id']) ? (int)$row['id'] : null, + 'status' => $status, + 'action' => (string)($row['action'] ?? self::ACTION_NONE), + 'confidence' => isset($row['confidence']) ? (float)$row['confidence'] : 0.0, + 'source' => (string)($row['source'] ?? ''), + 'reason' => (string)($row['reason'] ?? ''), + 'matched_order_id' => isset($row['matched_order_id']) && $row['matched_order_id'] !== null ? (int)$row['matched_order_id'] : null, + 'created_order_id' => isset($row['created_order_id']) && $row['created_order_id'] !== null ? (int)$row['created_order_id'] : null, + 'candidate_order' => $this->decodeJsonField($row['candidate_order_json'] ?? null), + 'proposed_order' => $this->decodeJsonField($row['proposed_order_json'] ?? null), + 'can_accept' => $status === self::STATUS_SUGGESTED, + 'can_deny' => $status === self::STATUS_SUGGESTED, + ]; + } + + private function emptyAutomation(string $reason = ''): array + { + return [ + 'id' => null, + 'status' => self::STATUS_NONE, + 'action' => self::ACTION_NONE, + 'confidence' => 0.0, + 'source' => '', + 'reason' => $reason, + 'matched_order_id' => null, + 'created_order_id' => null, + 'candidate_order' => null, + 'proposed_order' => null, + 'can_accept' => false, + 'can_deny' => false, + ]; + } + + private function decodeJsonField(?string $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + $decoded = json_decode($value, true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } + + private function itemSignatureParts(array $items): array + { + return self::itemSignaturePartsForAutomation($items); + } + + private function compactItems(array $items): array + { + return array_map(fn(array $item): array => [ + 'product_id' => (int)($item['product_id'] ?? 0), + 'product_name' => (string)($item['product']['name'] ?? $item['product_name'] ?? ''), + 'quantity' => (int)($item['quantity'] ?? 0), + 'price' => (int)($item['price'] ?? 0), + ], $items); + } + + private function itemsTotal(array $items): int + { + return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0); + } + + private function productOverlap(array $usageItems, array $orderItems): float + { + $usageBag = $this->productBag($usageItems); + $orderBag = $this->productBag($orderItems); + $usageTotal = array_sum($usageBag); + if ($usageTotal <= 0) { + return 0.0; + } + + $overlap = 0; + foreach ($usageBag as $productId => $quantity) { + $overlap += min($quantity, $orderBag[$productId] ?? 0); + } + + return $overlap / $usageTotal; + } + + private function productBag(array $items): array + { + $bag = []; + foreach ($items as $item) { + $productId = (int)($item['product_id'] ?? 0); + if ($productId < 1) { + continue; + } + $bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1)); + } + + return $bag; + } + + private function normalizeRegistration(string $registration): string + { + return self::normalizeRegistrationForAutomation($registration); + } + + private function isOpenAiEnabled(): bool + { + try { + $xlvask = new xlvask(); + if (!$xlvask->config->openai_integration_enabled->isTrue()) { + return false; + } + + $openai = new openai(); + return $openai->config->enabled->isTrue(); + } catch (Exception) { + return false; + } + } +} diff --git a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php new file mode 100644 index 00000000..b300973c --- /dev/null +++ b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php @@ -0,0 +1,145 @@ +query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` ( + `id` INT NOT NULL AUTO_INCREMENT, + `usage_log_id` INT NOT NULL, + `wash_id` VARCHAR(128) NOT NULL, + `signature_hash` CHAR(64) NOT NULL, + `signature_json` LONGTEXT NULL, + `action` VARCHAR(32) NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'suggested', + `confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000, + `source` VARCHAR(32) NOT NULL DEFAULT 'deterministic', + `matched_order_id` INT NULL, + `created_order_id` INT NULL, + `proposed_order_json` LONGTEXT NULL, + `candidate_order_json` LONGTEXT NULL, + `reason` TEXT NULL, + `created_by` INT NULL, + `decided_by` INT NULL, + `decided_at` DATETIME NULL, + `executed_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_xlvask_automation_usage` (`usage_log_id`), + KEY `idx_xlvask_automation_wash` (`wash_id`), + KEY `idx_xlvask_automation_signature` (`signature_hash`), + KEY `idx_xlvask_automation_status` (`status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` ( + `id` INT NOT NULL AUTO_INCREMENT, + `usage_log_id` INT NULL, + `wash_id` VARCHAR(128) NULL, + `signature_hash` CHAR(64) NOT NULL, + `signature_json` LONGTEXT NULL, + `action` VARCHAR(32) NOT NULL, + `decision` VARCHAR(32) NOT NULL, + `order_id` INT NULL, + `reason` TEXT NULL, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`), + KEY `idx_xlvask_feedback_usage` (`usage_log_id`), + KEY `idx_xlvask_feedback_decision` (`decision`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` ( + `id` INT NOT NULL AUTO_INCREMENT, + `cache_key` CHAR(64) NOT NULL, + `schema_name` VARCHAR(96) NOT NULL, + `input_json` LONGTEXT NOT NULL, + `result_json` LONGTEXT NOT NULL, + `hits` INT NOT NULL DEFAULT 0, + `last_hit_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`), + KEY `idx_xlvask_openai_cache_schema` (`schema_name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + } + + private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void + { + if (!self::columnExists($db, $table, $column)) { + $db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); + } + } + + private static function tableExists(object $db, string $table): bool + { + $table = self::escapeIdentifier($table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function columnExists(object $db, string $table, string $column): bool + { + $table = self::escapeIdentifier($table); + $column = self::escapeIdentifier($column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function escapeIdentifier(string $value): string + { + return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); + } +} diff --git a/services/nginx/app/cli.php b/services/nginx/app/cli.php index 4388e638..de06d7e5 100644 --- a/services/nginx/app/cli.php +++ b/services/nginx/app/cli.php @@ -29,6 +29,10 @@ set_time_limit(10 * 60); // 10 minutes // Set the memory limit to 16 GB ini_set('memory_limit', '16G'); +require_once __DIR__ . '/classes/economic_transfer_executor.php'; +require_once __DIR__ . '/classes/economic_transfer_queue_schema_bootstrap.php'; +require_once __DIR__ . '/classes/economic_transfer_queue.php'; + // If the first argument is 'run', switch to the second argument if ($args[1] === 'run') { @@ -66,6 +70,9 @@ if ($args[1] === 'run') { case 'clearAllUsersEconomicCustomerDetails': require_once 'cron/ClearAllUsersEconomicCustomerDetails.php'; break; + case 'economic-v2-backfill': + require_once 'cron/BackfillEconomicV2History.php'; + break; case 'economicOrderParser-test': echo "Running the economicOrderParser test script"; require_once 'tests/economicOrderParser/EconomicOrderParserTest.php'; @@ -85,6 +92,12 @@ if ($args[1] === 'run') { case 'logSync': require_once 'cron/SyncLogs.php'; break; + case 'economic-transfer-queue': + echo "[" . date('Y-m-d H:i:s') . "][CRON] Running economic transfer queue worker\n"; + $queue = new \classes\economic_transfer_queue(); + $result = $queue->processPending(25); + echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + break; case 'cron': echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n"; require_once 'cron/Cron.php'; @@ -96,4 +109,4 @@ if ($args[1] === 'run') { echo "[" . date('Y-m-d H:i:s') . "][CRON] Finished running the script\n"; } else { echo "Invalid action"; -} \ No newline at end of file +} diff --git a/services/nginx/app/composer.json b/services/nginx/app/composer.json index 3683af82..22faa24e 100644 --- a/services/nginx/app/composer.json +++ b/services/nginx/app/composer.json @@ -3,7 +3,42 @@ "test": "composer test:unit", "test:unit": "vendor/bin/pest --testsuite=Unit --colors=always", "test:integration": "vendor/bin/pest --testsuite=Integration --colors=always", - "test:coverage": "php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\" && phpdbg -qrr vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always" + "test:api": [ + "Composer\\Config::disableProcessTimeout", + "@php -r \"putenv('RUN_API_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\"" + ], + "test:api:edge": [ + "Composer\\Config::disableProcessTimeout", + "@php -r \"putenv('RUN_API_TESTS=1'); putenv('API_TEST_BOOTSTRAP_SCHEMA=1'); putenv('CONFIG_DB_TARGET=debug'); putenv('API_TEST_REQUEST_TIMEOUT=180'); putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=0'); putenv('EDGE_BROKER_URL'); passthru('vendor/bin/pest tests/Api/EdgeGateway*ApiTest.php --colors=always', $exitCode); exit($exitCode);\"" + ], + "test:integration:edge": [ + "Composer\\Config::disableProcessTimeout", + "@php -r \"putenv('RUN_INTEGRATION_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); putenv('EDGE_BROKER_URL'); passthru('vendor/bin/pest tests/Integration/EdgeGateway --colors=always', $exitCode); exit($exitCode);\"" + ], + "test:ci:unit": [ + "Composer\\Config::disableProcessTimeout", + "@php tests/Support/run_ci_suite.php unit" + ], + "test:ci:integration": [ + "Composer\\Config::disableProcessTimeout", + "@php tests/Support/run_ci_suite.php integration" + ], + "test:ci:api": [ + "Composer\\Config::disableProcessTimeout", + "@php tests/Support/run_ci_suite.php api" + ], + "test:ci:legacy": [ + "Composer\\Config::disableProcessTimeout", + "@php tests/Support/run_ci_suite.php legacy" + ], + "test:ci:all": [ + "Composer\\Config::disableProcessTimeout", + "@php tests/Support/run_ci_suite.php all" + ], + "test:coverage": [ + "@php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\"", + "@php -r \"if (extension_loaded('pcov')) { passthru('php -d pcov.enabled=1 vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } if (extension_loaded('xdebug')) { passthru('php -d xdebug.mode=coverage vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } fwrite(STDERR, 'No coverage driver available. Enable pcov or xdebug for test:coverage.' . PHP_EOL); exit(1);\"" + ] }, "require-dev": { "rector/rector": "^1.2", @@ -25,10 +60,26 @@ "spipu/html2pdf": "^5.3", "web-auth/webauthn-lib": "^5.2" }, + "autoload": { + "classmap": [ + "classes/", + "interfaces/", + "traits/", + "objects/", + "modules/", + "routes/", + "statistics/" + ], + "exclude-from-classmap": [ + "modules/*/vendor/", + "modules/*/vendor/**" + ] + }, "config": { "allow-plugins": { "php-http/discovery": true, - "tbachert/spi": true + "tbachert/spi": true, + "pestphp/pest-plugin": true } } } diff --git a/services/nginx/app/composer.lock b/services/nginx/app/composer.lock index 8175b22e..e64c5ae9 100644 --- a/services/nginx/app/composer.lock +++ b/services/nginx/app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bf12357a788f855350980902c3c24362", + "content-hash": "826ebc5297144ef1b7dfb057de3f233f", "packages": [ { "name": "aws/aws-crt-php", @@ -3924,16 +3924,16 @@ }, { "name": "symfony/string", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", "shasum": "" }, "require": { @@ -3991,7 +3991,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.4" + "source": "https://github.com/symfony/string/tree/v7.4.6" }, "funding": [ { @@ -4011,7 +4011,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T10:54:30+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "symfony/type-info", @@ -4404,16 +4404,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.5", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188" + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/79155f94852fa27e2f73b459f6503f5e87e2c188", - "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { @@ -4460,12 +4460,1040 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.5" + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-02-18T14:09:36+00:00" + "time": "2026-02-27T10:28:38+00:00" } ], "packages-dev": [ + { + "name": "brianium/paratest", + "version": "v7.8.5", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-timer": "^7.0.1", + "phpunit/phpunit": "^11.5.46", + "sebastian/environment": "^7.2.1", + "symfony/console": "^6.4.22 || ^7.3.4 || ^8.0.3", + "symfony/process": "^6.4.20 || ^7.3.4 || ^8.0.3" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0.0", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.11", + "phpstan/phpstan-strict-rules": "^2.0.7", + "squizlabs/php_codesniffer": "^3.13.5", + "symfony/filesystem": "^6.4.13 || ^7.3.2 || ^8.0.1" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.8.5" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-01-08T08:02:38+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.1", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-02-17T17:33:08+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "pestphp/pest", + "version": "v3.8.6", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest.git", + "reference": "8871a6f5ef1de8e7c8dee2a270991449a7b6af73" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest/zipball/8871a6f5ef1de8e7c8dee2a270991449a7b6af73", + "reference": "8871a6f5ef1de8e7c8dee2a270991449a7b6af73", + "shasum": "" + }, + "require": { + "brianium/paratest": "^7.8.5", + "nunomaduro/collision": "^8.9.1", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest-plugin": "^3.0.0", + "pestphp/pest-plugin-arch": "^3.1.1", + "pestphp/pest-plugin-mutate": "^3.0.5", + "php": "^8.2.0", + "phpunit/phpunit": "^11.5.50" + }, + "conflict": { + "filp/whoops": "<2.16.0", + "phpunit/phpunit": ">11.5.50", + "sebastian/exporter": "<6.0.0", + "webmozart/assert": "<1.11.0" + }, + "require-dev": { + "pestphp/pest-dev-tools": "^3.4.0", + "pestphp/pest-plugin-type-coverage": "^3.6.1", + "symfony/process": "^7.4.5" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Mutate\\Plugins\\Mutate", + "Pest\\Plugins\\Configuration", + "Pest\\Plugins\\Bail", + "Pest\\Plugins\\Cache", + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Environment", + "Pest\\Plugins\\Help", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Only", + "Pest\\Plugins\\Printer", + "Pest\\Plugins\\ProcessIsolation", + "Pest\\Plugins\\Profile", + "Pest\\Plugins\\Retry", + "Pest\\Plugins\\Snapshot", + "Pest\\Plugins\\Verbose", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Parallel" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "files": [ + "src/Functions.php", + "src/Pest.php" + ], + "psr-4": { + "Pest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "The elegant PHP Testing Framework.", + "keywords": [ + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "issues": "https://github.com/pestphp/pest/issues", + "source": "https://github.com/pestphp/pest/tree/v3.8.6" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-03-10T21:04:33+00:00" + }, + { + "name": "pestphp/pest-plugin", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "e79b26c65bc11c41093b10150c1341cc5cdbea83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/e79b26c65bc11c41093b10150c1341cc5cdbea83", + "reference": "e79b26c65bc11c41093b10150c1341cc5cdbea83", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "composer-runtime-api": "^2.2.2", + "php": "^8.2" + }, + "conflict": { + "pestphp/pest": "<3.0.0" + }, + "require-dev": { + "composer/composer": "^2.7.9", + "pestphp/pest": "^3.0.0", + "pestphp/pest-dev-tools": "^3.0.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager" + }, + "autoload": { + "psr-4": { + "Pest\\Plugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest plugin manager", + "keywords": [ + "framework", + "manager", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin/tree/v3.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2024-09-08T23:21:41+00:00" + }, + { + "name": "pestphp/pest-plugin-arch", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-arch.git", + "reference": "db7bd9cb1612b223e16618d85475c6f63b9c8daa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/db7bd9cb1612b223e16618d85475c6f63b9c8daa", + "reference": "db7bd9cb1612b223e16618d85475c6f63b9c8daa", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^3.0.0", + "php": "^8.2", + "ta-tikoma/phpunit-architecture-test": "^0.8.4" + }, + "require-dev": { + "pestphp/pest": "^3.8.1", + "pestphp/pest-dev-tools": "^3.4.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Arch\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Arch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Arch plugin for Pest PHP.", + "keywords": [ + "arch", + "architecture", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-04-16T22:59:48+00:00" + }, + { + "name": "pestphp/pest-plugin-mutate", + "version": "v3.0.5", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-mutate.git", + "reference": "e10dbdc98c9e2f3890095b4fe2144f63a5717e08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/e10dbdc98c9e2f3890095b4fe2144f63a5717e08", + "reference": "e10dbdc98c9e2f3890095b4fe2144f63a5717e08", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.2.0", + "pestphp/pest-plugin": "^3.0.0", + "php": "^8.2", + "psr/simple-cache": "^3.0.0" + }, + "require-dev": { + "pestphp/pest": "^3.0.8", + "pestphp/pest-dev-tools": "^3.0.0", + "pestphp/pest-plugin-type-coverage": "^3.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Pest\\Mutate\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sandro Gehri", + "email": "sandrogehri@gmail.com" + } + ], + "description": "Mutates your code to find untested cases", + "keywords": [ + "framework", + "mutate", + "mutation", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v3.0.5" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2024-09-22T07:54:40+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, { "name": "phpstan/phpstan", "version": "1.12.32", @@ -4519,6 +5547,462 @@ ], "time": "2025-09-30T10:16:31+00:00" }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.50", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.50" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:59:18+00:00" + }, { "name": "rector/rector", "version": "1.2.10", @@ -4577,11 +6061,1476 @@ } ], "time": "2024-11-08T13:59:10+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T14:06:20+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-29T09:40:50+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "608476f4604102976d687c483ac63a79ba18cc97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:07:59+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "ta-tikoma/phpunit-architecture-test", + "version": "0.8.7", + "source": { + "type": "git", + "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18.0 || ^5.0.0", + "php": "^8.1.0", + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "require-dev": { + "laravel/pint": "^1.13.7", + "phpstan/phpstan": "^1.10.52" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPUnit\\Architecture\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ni Shi", + "email": "futik0ma011@gmail.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Methods for testing application architecture", + "keywords": [ + "architecture", + "phpunit", + "stucture", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" + }, + "time": "2026-02-17T17:25:14+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4590,6 +7539,6 @@ "ext-curl": "*", "ext-json": "*" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/services/nginx/app/config.php b/services/nginx/app/config.php index c4f47e52..d0c623bc 100644 --- a/services/nginx/app/config.php +++ b/services/nginx/app/config.php @@ -1,15 +1,29 @@ 'host', 'CONFIG_DB_USER' => 'user', 'CONFIG_DB_PASSWORD' => 'password', 'CONFIG_DB_DATABASE' => 'database', + 'CONFIG_DB_PORT' => 'port', 'DEBUG' => 'DEBUG', 'ENCRYPTION_KEY' => 'ENCRYPTION_KEY', 'CORS' => 'CORS', @@ -25,72 +39,122 @@ if (isset($_ENV['USE_ENV']) && $_ENV['USE_ENV'] === 'true') { 'SLACK_DEFAULT_WEBHOOK' => 'SLACK_DEFAULT_WEBHOOK', 'REDIS_CONFIG_HOST' => 'host', 'REDIS_CONFIG_DATABASE' => 'database', - 'REDIS_CONFIG_PASSWORD' => 'password' + 'REDIS_CONFIG_PASSWORD' => 'password', + 'REDIS_CONFIG_PORT' => 'port', + 'REDIS_CONFIG_USER' => 'user', + 'REDIS_CONFIG_DEBUG_PASSWORD' => 'debug_password' ]; + $dbTarget = strtolower(trim($readEnv('CONFIG_DB_TARGET', 'live'))); + if ($dbTarget !== 'live' && $dbTarget !== 'debug') { + $dbTarget = 'live'; + } + + $resolveDbValue = function (string $key) use ($dbTarget, $readEnv): string { + $liveKey = 'CONFIG_DB_' . $key; + $debugKey = 'CONFIG_DB_DEBUG_' . $key; + $liveValue = $readEnv($liveKey); + $debugValue = $readEnv($debugKey); + + if ($dbTarget === 'debug' && $debugValue !== '') { + return $debugValue; + } + + return $liveValue; + }; + /** - * Set the db configuration + * Set the db configuration from selected target (live/debug) */ $CONFIG_DB = [ - 'host' => $_ENV['CONFIG_DB_HOST'], - 'user' => $_ENV['CONFIG_DB_USER'], - 'password' => $_ENV['CONFIG_DB_PASSWORD'], - 'database' => $_ENV['CONFIG_DB_DATABASE'] + 'host' => $resolveDbValue('HOST'), + 'user' => $resolveDbValue('USER'), + 'password' => $resolveDbValue('PASSWORD'), + 'database' => $resolveDbValue('DATABASE'), + 'port' => (int)($resolveDbValue('PORT') ?: 3306), + 'ssl_mode' => $resolveDbValue('SSL_MODE') ?: 'DISABLED' ]; /** * Set the debug configuration */ - $DEBUG = $_ENV['DEBUG']; + $DEBUG = $readEnv('DEBUG'); /** * Set the encryption key */ - $ENCRYPTION_KEY = $_ENV['ENCRYPTION_KEY']; + $ENCRYPTION_KEY = $readEnv('ENCRYPTION_KEY'); /** * Set the CORS configuration */ - $CORS = $_ENV['CORS']; + $CORS = $readEnv('CORS'); /** * Set the economic API configuration */ + $economicPrimaryGrant = $readEnv('ECONOMIC_API_APP_ACCESS_GRANT'); + $economicSecondaryGrant = $readEnv('ECONOMIC_API_APP_ACCESS_GRANT2'); + $economicSecretToken = $readEnv('ECONOMIC_API_APP_SECRET_TOKEN'); + + if ($economicPrimaryGrant === '' || $economicSecretToken === '') { + error_log('[config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.'); + } + if ($economicSecondaryGrant === '') { + error_log('[config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.'); + } + $ECONOMIC_API = [ - 'app_access_grant' => $_ENV['ECONOMIC_API_APP_ACCESS_GRANT'], - 'app_access_grant2' => $_ENV['ECONOMIC_API_APP_ACCESS_GRANT2'], - 'app_secret_token' => $_ENV['ECONOMIC_API_APP_SECRET_TOKEN'] + 'app_access_grant' => $economicPrimaryGrant, + 'app_access_grant2' => $economicSecondaryGrant, + 'app_secret_token' => $economicSecretToken ]; /** * Set the WordPress static token */ - $WORDPRESS_STATIC_TOKEN = $_ENV['WORDPRESS_STATIC_TOKEN']; + $WORDPRESS_STATIC_TOKEN = $readEnv('WORDPRESS_STATIC_TOKEN'); /** * Set the email wash certificate token */ - $EMAIL_WASH_CERTIFICATE_TOKEN = $_ENV['EMAIL_WASH_CERTIFICATE_TOKEN']; + $EMAIL_WASH_CERTIFICATE_TOKEN = $readEnv('EMAIL_WASH_CERTIFICATE_TOKEN'); /** * Set the WordPress API URL */ - $WORDPRESS_API_URL = $_ENV['WORDPRESS_API_URL']; + $WORDPRESS_API_URL = $readEnv('WORDPRESS_API_URL'); /** * Set the Minio configuration */ $MINIO = [ - 'endpoint' => $_ENV['MINIO_ENDPOINT'], - 'access_key' => $_ENV['MINIO_ACCESS_KEY'], - 'secret_key' => $_ENV['MINIO_SECRET_KEY'] + 'endpoint' => $readEnv('MINIO_ENDPOINT'), + 'access_key' => $readEnv('MINIO_ACCESS_KEY'), + 'secret_key' => $readEnv('MINIO_SECRET_KEY') ]; /** * Set the Slack default webhook */ - $SLACK_DEFAULT_WEBHOOK = $_ENV['SLACK_DEFAULT_WEBHOOK']; + $SLACK_DEFAULT_WEBHOOK = $readEnv('SLACK_DEFAULT_WEBHOOK'); + $resolveRedisValue = function (string $key) use ($dbTarget, $readEnv): string { + $liveKey = 'REDIS_CONFIG_' . $key; + $debugKey = 'REDIS_CONFIG_DEBUG_' . $key; + $liveValue = $readEnv($liveKey); + $debugValue = $readEnv($debugKey); + + if ($dbTarget === 'debug' && $debugValue !== '') { + return $debugValue; + } + + return $liveValue; + }; + /** * Set the Redis configuration */ $REDIS_CONFIG = [ - 'host' => $_ENV['REDIS_CONFIG_HOST'], - 'database' => $_ENV['REDIS_CONFIG_DATABASE'], - 'password' => $_ENV['REDIS_CONFIG_PASSWORD'] + 'host' => $resolveRedisValue('HOST'), + 'user' => $resolveRedisValue('USER'), + 'database' => $resolveRedisValue('DATABASE'), + 'password' => $resolveRedisValue('PASSWORD'), + 'port' => (int)($resolveRedisValue('PORT') ?: 6379) ]; // Set the timezone - date_default_timezone_set($_ENV['CONFIG_TIMEZONE']) ?? 'Europe/Copenhagen'; + $timezone = $readEnv('CONFIG_TIMEZONE', 'Europe/Copenhagen'); + date_default_timezone_set($timezone); // Set the all config $ALL_CONFIG = $_ENV; // This is used by the backup job. @@ -98,4 +162,15 @@ if (isset($_ENV['USE_ENV']) && $_ENV['USE_ENV'] === 'true') { } else { // Throw an error if the environment variables are not set throw new Exception('Environment variables are not set'); -} \ No newline at end of file +} + +require_once __DIR__ . '/classes/replication_secret_box.php'; +require_once __DIR__ . '/classes/replication_bootstrap_config.php'; +require_once __DIR__ . '/classes/replica_failover_manager.php'; +$replicationBootstrapSnapshot = \classes\replication_bootstrap_config::loadSnapshot(); +\classes\replication_bootstrap_config::applyToGlobals($replicationBootstrapSnapshot); +try { + \classes\replica_failover_manager::applyStartupFailoverFromSnapshot(); +} catch (Throwable $throwable) { + error_log('[replication-bootstrap] Startup failover skipped: ' . $throwable->getMessage()); +} diff --git a/services/nginx/app/cron.log b/services/nginx/app/cron.log new file mode 100644 index 00000000..8b118a07 --- /dev/null +++ b/services/nginx/app/cron.log @@ -0,0 +1,2 @@ +2026-04-08 11:13:47 - Cron job started +2026-04-08 13:13:47 - Cron job executed in 1 seconds ( 1.1812269687653ms ) diff --git a/services/nginx/app/cron.php b/services/nginx/app/cron.php index 0ff7e9fa..3e96b9e2 100644 --- a/services/nginx/app/cron.php +++ b/services/nginx/app/cron.php @@ -11,6 +11,17 @@ file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron jo require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/config.php'; +// Run the full cron task scheduler (includes economic transfer queue worker) +try { + require_once __DIR__ . '/cron/Cron.php'; +} catch (\Throwable $e) { + file_put_contents( + __DIR__ . '/cron.log', + date('Y-m-d H:i:s', time()) . ' - Cron scheduler failed: ' . $e->getMessage() . PHP_EOL, + FILE_APPEND + ); +} + // Set the .htaccess file $htaccess = " # php -- BEGIN cPanel-generated handler, do not edit diff --git a/services/nginx/app/cron/BackfillEconomicV2History.php b/services/nginx/app/cron/BackfillEconomicV2History.php new file mode 100644 index 00000000..5ae353cb --- /dev/null +++ b/services/nginx/app/cron/BackfillEconomicV2History.php @@ -0,0 +1,31 @@ +runBestEffortBackfill(); + echo json_encode( + [ + 'success' => true, + 'report' => $report, + ], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT + ) . PHP_EOL; +} catch (\Throwable $e) { + echo json_encode( + [ + 'success' => false, + 'error' => $e->getMessage(), + ], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT + ) . PHP_EOL; + throw $e; +} + +echo '[' . date('Y-m-d H:i:s') . '][ECONOMIC_V2] Finished best-effort history backfill' . PHP_EOL; diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index c3d12991..5dd223e5 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -3,19 +3,39 @@ use classes\backup_store; use classes\economic; +use classes\economic_transfer_queue; +use classes\invoice_period_flag_service; +use classes\coolify_manager; +use classes\replication_manager; +use classes\redis; +use classes\system_search_cache; +use classes\system_search_document_index; +use classes\system_search_economic_customer_index; +use classes\system_search_registry; +use classes\workfeed; +use classes\workfeed_employee_name_formatter; use classes\xlvask; use classes\slack as Slack; use classes\email as Email; use classes\gatewayapi as GatewayAPI; +use dynamicimages\images\machine_1; use goals\classes\goals_criteria; use goals\services\goals_progress_alert_renderer; use goals\helpers\goals_criteria_progress_alert_destination as Dest; use goals\helpers\goals_criteria_progress_alert_frequency as Freq; +use objects\department_lanes_o; use objects\department_goals_o; +use objects\department_selfserve_tasks_o; use objects\departments_o; use objects\bookings_o; use objects\logs_o; use objects\users_o; +use routes\moduleWeatherAPIRoute; + +require_once __DIR__ . '/../classes/economic_transfer_executor.php'; +require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php'; +require_once __DIR__ . '/../classes/economic_transfer_queue.php'; +require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php'; if (!defined('WD')) { exit; @@ -50,6 +70,24 @@ $cron_tasks = [ 'next_run' => 0, 'function' => 'syncLogsToDatabase', ], + 'ReplicaFailoverMonitorCron' => [ + 'interval' => 60, // 1 minute + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'ReplicaFailoverMonitorCron', + ], + 'CoolifyAvailabilityMonitorCron' => [ + 'interval' => 60, // 1 minute + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'CoolifyAvailabilityMonitorCron', + ], + 'CoolifyLoadBalancerReconcileCron' => [ + 'interval' => 60, // 1 minute + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'CoolifyLoadBalancerReconcileCron', + ], 'SyncUserEconomicCustomerDiscounts' => [ 'interval' => 180, // 3 minutes 'last_run' => 0, @@ -62,6 +100,12 @@ $cron_tasks = [ 'next_run' => 0, 'function' => 'SyncUserEconomicCustomerDetails', ], + 'SyncSystemSearchEconomicCustomerIndex' => [ + 'interval' => 900, // 15 minutes + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'SyncSystemSearchEconomicCustomerIndex', + ], 'backup' => [ 'interval' => 43200, // 12 hours 'last_run' => 0, @@ -74,20 +118,391 @@ $cron_tasks = [ 'next_run' => 0, 'function' => 'SyncEconomicInvoiceStatus', ], + 'EconomicTransferQueueCron' => [ + 'interval' => 30, // 30 seconds + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'EconomicTransferQueueCron', + ], 'SyncXLVaskModuleCron' => [ 'interval' => 3600, // 1 hour 'last_run' => 0, 'next_run' => 0, 'function' => 'SyncXLVaskModuleCron', ], + 'SystemSearchCacheMaintenanceCron' => [ + 'interval' => 300, // 5 minutes + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'SystemSearchCacheMaintenanceCron', + ], + 'PreRenderDynamicImagesCron' => [ + 'interval' => 900, // 15 minutes + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'PreRenderDynamicImagesCron', + ], + 'PreloadDepartmentWeatherResponsesCron' => [ + 'interval' => 60, // 1 minute + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'PreloadDepartmentWeatherResponsesCron', + ], + 'WarmWorkfeedEmployeeNamesCron' => [ + 'interval' => 21600, // 6 hours + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'WarmWorkfeedEmployeeNamesCron', + ], 'GoalsProgressAlertsCron' => [ 'interval' => 60, // check every minute 'last_run' => 0, 'next_run' => 0, 'function' => 'GoalsProgressAlertsCron', ], + 'PruneSystemSessionActivityCron' => [ + 'interval' => 86400, // 24 hours + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'PruneSystemSessionActivityCron', + ], + 'WarmInvoicePeriodManualFlagsCron' => [ + 'interval' => 300, // 5 minutes + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'WarmInvoicePeriodManualFlagsCron', + ], + 'WarmInvoicePeriodAutomaticFlagsCron' => [ + 'interval' => 300, // 5 minutes + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'WarmInvoicePeriodAutomaticFlagsCron', + ], ]; +function ReplicaFailoverMonitorCron(): void +{ + global $db; + + if (!($db instanceof \classes\db)) { + warn('ReplicaFailoverMonitorCron skipped: database connection is unavailable.'); + return; + } + + try { + $result = (new replication_manager())->runAutomaticFailoverMonitor(); + $promoted = array_filter( + $result['results'] ?? [], + static fn(array $entry): bool => ($entry['status'] ?? '') === 'promoted' + ); + echo "[" . date('Y-m-d H:i:s') . "][CRON] ReplicaFailoverMonitorCron: " + . count($promoted) . " promotions.\n"; + } catch (Throwable $throwable) { + warn('ReplicaFailoverMonitorCron failed: ' . $throwable->getMessage()); + } +} + +function CoolifyAvailabilityMonitorCron(): void +{ + global $db; + + if (!($db instanceof \classes\db)) { + warn('CoolifyAvailabilityMonitorCron skipped: database connection is unavailable.'); + return; + } + + try { + $result = (new coolify_manager())->runAvailabilityMaintenance(); + echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyAvailabilityMonitorCron: " + . count($result['targets'] ?? []) . " targets checked.\n"; + } catch (Throwable $throwable) { + warn('CoolifyAvailabilityMonitorCron failed: ' . $throwable->getMessage()); + } +} + +function CoolifyLoadBalancerReconcileCron(): void +{ + global $db; + + if (!($db instanceof \classes\db)) { + warn('CoolifyLoadBalancerReconcileCron skipped: database connection is unavailable.'); + return; + } + + try { + $manager = new coolify_manager(); + if (!$manager->loadBalancerAutomationEnabled()) { + echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: skipped.\n"; + return; + } + + $result = $manager->reconcileLoadBalancer(false); + echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: " + . count($result['applied'] ?? []) . " applied, " + . count($result['skipped'] ?? []) . " skipped.\n"; + } catch (Throwable $throwable) { + warn('CoolifyLoadBalancerReconcileCron failed: ' . $throwable->getMessage()); + } +} + +function WarmInvoicePeriodManualFlagsCron(): void +{ + (new invoice_period_flag_service())->warmManualFlagsCache(); +} + +function WarmInvoicePeriodAutomaticFlagsCron(): void +{ + $service = new invoice_period_flag_service(); + $now = new DateTime(); + $previousMonth = new DateTime('first day of previous month'); + + $toWarm = []; + foreach ([$now, $previousMonth] as $date) { + $dateFrom = $date->format('Y-m-01'); + $dateTo = $date->format('Y-m-t'); + $toWarm[$dateFrom . '|' . $dateTo] = ['dateFrom' => $dateFrom, 'dateTo' => $dateTo]; + } + + try { + $queued = (new redis())->consume_invoice_period_warming_queue(); + foreach ($queued as $period) { + $key = $period['dateFrom'] . '|' . $period['dateTo']; + $toWarm[$key] = $period; + } + } catch (Throwable) { + } + + foreach ($toWarm as $period) { + $service->warmOrderItemRowsForPeriod($period['dateFrom'], $period['dateTo']); + $service->warmAutomaticFlagsForPeriod($period['dateFrom'], $period['dateTo']); + } +} + +function WarmWorkfeedEmployeeNamesCron(): void +{ + if (!defined('redis')) { + warn('WarmWorkfeedEmployeeNamesCron skipped: Redis is unavailable.'); + return; + } + + $start = microtime(true); + + $ttlRaw = getenv('WORKFEED_EMPLOYEE_NAME_CACHE_TTL'); + $ttl = max( + 60, + (int)( + $ttlRaw !== false && trim((string)$ttlRaw) !== '' + ? $ttlRaw + : 86400 + ) + ); + + try { + $employeesResponse = (new workfeed())->listEmployees(); + } catch (Throwable $e) { + warn('WarmWorkfeedEmployeeNamesCron failed to list employees: ' . $e->getMessage()); + return; + } + + $employees = normalizeWorkfeedEmployeeWarmupCollection($employeesResponse); + if ($employees === []) { + echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: no employees returned.\n"; + return; + } + + try { + $cache = new redis(); + } catch (Throwable $e) { + warn('WarmWorkfeedEmployeeNamesCron failed to initialize cache: ' . $e->getMessage()); + return; + } + + $cachedCount = 0; + $skippedCount = 0; + foreach ($employees as $employee) { + $identity = extractWorkfeedEmployeeWarmupIdentity($employee); + $employeeIds = extractWorkfeedEmployeeWarmupIds($employee); + $employeeName = $identity['name'] ?? null; + if ($employeeIds === [] || $employeeName === null) { + $skippedCount++; + continue; + } + + foreach ($employeeIds as $employeeId) { + $cache->cache_workfeed_employee_name($employeeId, $employeeName, $ttl); + $cachedCount++; + } + } + + $durationMs = (int)round((microtime(true) - $start) * 1000); + echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: cached " . $cachedCount + . " employees, skipped " . $skippedCount . " in " . $durationMs . "ms.\n"; +} + +/** + * @return array + */ +function normalizeWorkfeedEmployeeWarmupCollection(mixed $raw): array +{ + if (is_array($raw)) { + return array_values($raw); + } + + if ($raw instanceof Traversable) { + return array_values(iterator_to_array($raw, false)); + } + + if (!is_object($raw)) { + return []; + } + + $record = get_object_vars($raw); + foreach (['data', 'items', 'employees', 'results'] as $key) { + $nested = $record[$key] ?? null; + $normalized = normalizeWorkfeedEmployeeWarmupCollection($nested); + if ($normalized !== []) { + return $normalized; + } + } + + return [$raw]; +} + +/** + * @return array{id:?string,name:?string} + */ +function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array +{ + $employeeIds = extractWorkfeedEmployeeWarmupIds($employee); + + $record = is_object($employee) + ? get_object_vars($employee) + : (is_array($employee) ? $employee : []); + + if ($record === []) { + return [ + 'id' => null, + 'name' => null, + ]; + } + + $employeeId = $employeeIds[0] ?? null; + + $employeeName = workfeed_employee_name_formatter::fromRecord($record, [ + 'firstname', + 'firstName', + 'first_name', + 'employee.firstname', + 'employee.firstName', + 'employee.first_name', + 'user.firstname', + 'user.firstName', + 'user.first_name', + ], [ + 'lastname', + 'lastName', + 'last_name', + 'employee.lastname', + 'employee.lastName', + 'employee.last_name', + 'user.lastname', + 'user.lastName', + 'user.last_name', + ], [ + 'employeeName', + 'employee.name', + 'employee.fullName', + 'employee.full_name', + 'employee.displayName', + 'employee.display_name', + 'name', + 'fullName', + 'full_name', + 'displayName', + 'display_name', + 'user.name', + 'user.fullName', + 'user.full_name', + 'user.displayName', + 'user.display_name', + ], $employeeId); + + return [ + 'id' => $employeeId, + 'name' => $employeeName, + ]; +} + +/** + * @return array + */ +function extractWorkfeedEmployeeWarmupIds(mixed $employee): array +{ + $record = is_object($employee) + ? get_object_vars($employee) + : (is_array($employee) ? $employee : []); + + if ($record === []) { + return []; + } + + $employeeIds = []; + foreach ([ + 'employeeID', + 'employeeId', + 'id', + 'uuid', + 'employee.id', + 'employee.employeeID', + 'employee.employeeId', + 'employee.uuid', + 'employeeUUID', + 'employee_uuid', + 'user.id', + 'userId', + ] as $path) { + $employeeId = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, $path)); + if ($employeeId === null) { + continue; + } + + $employeeIds[$employeeId] = true; + } + + return array_keys($employeeIds); +} + +function getWarmupRecordValueByPath(array $record, string $path): mixed +{ + $segments = explode('.', $path); + $value = $record; + foreach ($segments as $segment) { + if (is_array($value) && array_key_exists($segment, $value)) { + $value = $value[$segment]; + continue; + } + + if (is_object($value) && isset($value->{$segment})) { + $value = $value->{$segment}; + continue; + } + + return null; + } + + return $value; +} + +function normalizeWarmupTextValue(mixed $value): ?string +{ + if (is_string($value) || is_numeric($value)) { + $normalized = trim((string)$value); + return $normalized !== '' ? $normalized : null; + } + + return null; +} + function checkUnfulfilledBookings(): void { // This is deactivated for now, as it is not wanted. @@ -127,9 +542,44 @@ function SyncUserEconomicCustomerDiscounts(): void function SyncUserEconomicCustomerDetails(): void { - $users_o = new users_o(); - $users_o->clearAllUsersEconomicCustomerDetailsFromCache(); - //$users_o->syncAllUsersEconomicCustomerDetails(); + try { + $users_o = new users_o(); + $users_o->clearAllUsersEconomicCustomerDetailsFromCache(); + $users_o->syncAllUsersEconomicCustomerDetails(); + $stats = system_search_economic_customer_index::refreshIndex(false); + system_search_document_index::refreshIndex([ + 'customers', + 'customer_discounts', + 'customer_fixed_prices', + 'employees', + 'users', + ]); + system_search_cache::bumpTableVersion(system_search_economic_customer_index::TABLE); + echo "[" . date('Y-m-d H:i:s') . "][CRON] Refreshed e-conomic customer snapshots and search index. Upserted: " + . (int)($stats['upserted'] ?? 0) . "\n"; + } catch (Throwable $e) { + warn('SyncUserEconomicCustomerDetails failed: ' . $e->getMessage()); + } +} + +function SyncSystemSearchEconomicCustomerIndex(): void +{ + try { + $stats = system_search_economic_customer_index::refreshIndex(false); + system_search_document_index::refreshIndex([ + 'customers', + 'customer_discounts', + 'customer_fixed_prices', + 'employees', + 'users', + ]); + system_search_cache::bumpTableVersion(system_search_economic_customer_index::TABLE); + echo "[" . date('Y-m-d H:i:s') . "][CRON] Synced system search e-conomic customer index. Processed: " + . (int)($stats['processed'] ?? 0) . ", upserted: " . (int)($stats['upserted'] ?? 0) + . ", deleted: " . (int)($stats['deleted'] ?? 0) . "\n"; + } catch (Throwable $e) { + warn('SyncSystemSearchEconomicCustomerIndex failed: ' . $e->getMessage()); + } } /** @@ -162,6 +612,601 @@ function SyncXLVaskModuleCron(): void } } +function EconomicTransferQueueCron(): void +{ + try { + $queue = new economic_transfer_queue(); + $result = $queue->processPending(10); + if ((int)($result['processed'] ?? 0) > 0) { + echo "[" . date('Y-m-d H:i:s') . "][CRON] EconomicTransferQueueCron processed=" + . (int)($result['processed'] ?? 0) + . " completed=" . (int)($result['completed'] ?? 0) + . " failed=" . (int)($result['failed'] ?? 0) . "\n"; + } + } catch (Throwable $e) { + warn('EconomicTransferQueueCron failed: ' . $e->getMessage()); + } +} + +function PruneSystemSessionActivityCron(): void +{ + try { + $deleted = (new \classes\system_session_activity_tracker())->pruneOlderThanDays(30); + if ($deleted > 0) { + echo "[" . date('Y-m-d H:i:s') . "][CRON] Pruned $deleted stale system session activity rows\n"; + } + } catch (Throwable $e) { + warn('PruneSystemSessionActivityCron failed: ' . $e->getMessage()); + } +} + +function SystemSearchCacheMaintenanceCron(): void +{ + try { + $rebuildRequest = system_search_cache::consumeRebuildRequest(); + $dirtyTables = system_search_cache::consumeDirtyTables(); + + if ($rebuildRequest !== null) { + system_search_cache::clearQueryCaches(); + system_search_cache::clearIntentCaches(); + $scope = (string)($rebuildRequest['scope'] ?? 'all'); + $types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? [])))); + if ($scope === 'types' && !empty($types)) { + system_search_document_index::refreshIndex($types); + } else { + system_search_economic_customer_index::refreshIndex(false); + system_search_document_index::refreshIndex(); + } + echo "[" . date('Y-m-d H:i:s') . "][CRON] System search cache rebuild handled. Scope: " . ($rebuildRequest['scope'] ?? 'all') . "\n"; + return; + } + + if (!empty($dirtyTables)) { + if (in_array('users', $dirtyTables, true)) { + system_search_economic_customer_index::refreshIndex(false); + } + $typesToRefresh = system_search_registry::entityTypesForDirtyTables($dirtyTables); + if (!empty($typesToRefresh)) { + system_search_document_index::refreshIndex($typesToRefresh); + } + echo "[" . date('Y-m-d H:i:s') . "][CRON] System search maintenance handled dirty tables: " . implode(', ', $dirtyTables) . "\n"; + } + } catch (Throwable $e) { + warn('SystemSearchCacheMaintenanceCron failed: ' . $e->getMessage()); + } +} + +function PreRenderDynamicImagesCron(): void +{ + $start = microtime(true); + $cacheTtlSeconds = 86400; + $maxRendersPerRun = 500; + + if (!defined('redis')) { + warn('PreRenderDynamicImagesCron skipped: Redis is unavailable.'); + return; + } + + if (!extension_loaded('imagick')) { + warn('PreRenderDynamicImagesCron skipped: Imagick extension is not loaded.'); + return; + } + + try { + $laneRows = (new department_lanes_o())->getFieldsWhere([ + 'deleted_at' => null, + 'dynamic_image_id' => '!null', + ], [ + 'id', + 'department', + 'dynamic_image_id', + 'machine_type_id', + ]); + } catch (Throwable $e) { + warn('PreRenderDynamicImagesCron failed to read lanes: ' . $e->getMessage()); + return; + } + + if (!is_array($laneRows) || count($laneRows) === 0) { + echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron: no dynamic-image lanes found.\n"; + return; + } + + $variantsByCacheKey = []; + $unsupportedImageIds = []; + + foreach ($laneRows as $laneRow) { + $dynamicImageId = (int)($laneRow['dynamic_image_id'] ?? 0); + if ($dynamicImageId <= 0) { + continue; + } + if ($dynamicImageId !== 1) { + $unsupportedImageIds[$dynamicImageId] = true; + continue; + } + + $taskGroups = collectDynamicImageTaskGroupsForLane($laneRow); + if ($taskGroups === []) { + $taskGroups = [[ + 'vehicle_type' => null, + 'rows' => [], + ]]; + } + + foreach ($taskGroups as $group) { + $groupVariants = buildDynamicImageVariantsForTaskGroup( + $dynamicImageId, + normalizeDynamicImageVehicleType($group['vehicle_type'] ?? null), + is_array($group['rows'] ?? null) ? $group['rows'] : [] + ); + + foreach ($groupVariants as $variant) { + $cacheKey = buildDynamicImageCacheKey($variant); + if ($cacheKey === '') { + continue; + } + $variantsByCacheKey[$cacheKey] = $variant + ['cache_key' => $cacheKey]; + } + } + } + + $discovered = count($variantsByCacheKey); + $rendered = 0; + $alreadyCached = 0; + $failed = 0; + $skippedByCap = 0; + $attempted = 0; + + foreach ($variantsByCacheKey as $cacheKey => $variant) { + if (redis->exists($cacheKey)) { + $alreadyCached++; + continue; + } + + if ($attempted >= $maxRendersPerRun) { + $skippedByCap++; + continue; + } + $attempted++; + + $imageData = renderDynamicImageVariant( + (int)$variant['dynamic_image_id'], + $variant['buttons'], + (int)$variant['current_step'], + (bool)$variant['only_current_step'] + ); + + if ($imageData === null) { + $failed++; + continue; + } + + redis->setEx($cacheKey, $imageData, $cacheTtlSeconds); + $rendered++; + } + + $duration = round(microtime(true) - $start, 2); + $unsupportedList = empty($unsupportedImageIds) ? 'none' : implode(', ', array_keys($unsupportedImageIds)); + echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron completed. " + . "discovered=$discovered rendered=$rendered cached=$alreadyCached failed=$failed " + . "skipped_by_cap=$skippedByCap unsupported_image_ids=$unsupportedList duration={$duration}s\n"; +} + +/** + * @return array>}> + */ +function collectDynamicImageTaskGroupsForLane(array $laneRow): array +{ + $laneId = (int)($laneRow['id'] ?? 0); + $departmentId = (int)($laneRow['department'] ?? 0); + $machineTypeId = normalizeDynamicImageVehicleType($laneRow['machine_type_id'] ?? null); + $tasksObject = new department_selfserve_tasks_o(); + + $rows = []; + if ($machineTypeId !== null) { + try { + $rows = $tasksObject->getTasksForMachineType($machineTypeId); + } catch (Throwable $e) { + warn('PreRenderDynamicImagesCron: failed reading machine-type tasks for lane #' . $laneId . ': ' . $e->getMessage()); + $rows = []; + } + } + + if (!is_array($rows) || $rows === []) { + try { + $rows = $tasksObject->getFieldsWhere([ + 'department' => $departmentId, + 'lane' => $laneId, + 'deleted_at' => null, + ], [ + 'id', + 'product', + 'order_priority', + 'buttons', + 'dynamic_images_vehicle_type', + ]); + } catch (Throwable $e) { + warn('PreRenderDynamicImagesCron: failed reading legacy tasks for lane #' . $laneId . ': ' . $e->getMessage()); + $rows = []; + } + } + + if (!is_array($rows) || $rows === []) { + return []; + } + + $grouped = []; + foreach ($rows as $row) { + $vehicleType = normalizeDynamicImageVehicleType($row['dynamic_images_vehicle_type'] ?? null); + if ($vehicleType === null) { + $productVehicleType = normalizeDynamicImageVehicleType($row['product'] ?? null); + if ($productVehicleType !== null && $productVehicleType > 0) { + $vehicleType = $productVehicleType; + } + } + $groupKey = $vehicleType === null ? 'null' : 'v' . $vehicleType; + if (!isset($grouped[$groupKey])) { + $grouped[$groupKey] = [ + 'vehicle_type' => $vehicleType, + 'rows' => [], + ]; + } + $grouped[$groupKey]['rows'][] = $row; + } + + return array_values($grouped); +} + +/** + * @param array> $taskRows + * @return array|null,current_step:int,only_current_step:bool,vehicle_type:int|null}> + */ +function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array +{ + $variants = []; + + usort($taskRows, static function (array $a, array $b): int { + $priorityA = (int)($a['order_priority'] ?? 0); + $priorityB = (int)($b['order_priority'] ?? 0); + if ($priorityA !== $priorityB) { + return $priorityA <=> $priorityB; + } + return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); + }); + + $buttonSets = [null]; + $runningButtons = []; + + foreach ($taskRows as $row) { + $buttons = parseDynamicImageButtons($row['buttons'] ?? null); + if ($buttons !== []) { + $buttonSets[] = $buttons; + $runningButtons = mergeUniqueButtonValues($runningButtons, $buttons); + $buttonSets[] = $runningButtons; + } + } + + $dedupedButtonSets = []; + foreach ($buttonSets as $buttonSet) { + $signature = $buttonSet === null ? 'null' : json_encode(array_values($buttonSet)); + if ($signature === false || isset($dedupedButtonSets[$signature])) { + continue; + } + $dedupedButtonSets[$signature] = $buttonSet; + } + + foreach ($dedupedButtonSets as $buttons) { + $buttonCount = is_array($buttons) ? count($buttons) : 0; + $maxStep = min(max(0, $buttonCount + 3), 15); + for ($currentStep = 0; $currentStep <= $maxStep; $currentStep++) { + $variants[] = [ + 'dynamic_image_id' => $dynamicImageId, + 'buttons' => $buttons, + 'current_step' => $currentStep, + 'only_current_step' => false, + 'vehicle_type' => $vehicleType, + ]; + $variants[] = [ + 'dynamic_image_id' => $dynamicImageId, + 'buttons' => $buttons, + 'current_step' => $currentStep, + 'only_current_step' => true, + 'vehicle_type' => $vehicleType, + ]; + } + } + + return $variants; +} + +/** + * @param array|null $buttons + */ +function buildDynamicImageCacheKey(array $variant): string +{ + $cacheParams = [ + 'dynamic_image_id' => (int)($variant['dynamic_image_id'] ?? 0), + 'buttons' => $variant['buttons'] ?? null, + 'current_step' => (int)($variant['current_step'] ?? 0), + 'only_current_step' => (bool)($variant['only_current_step'] ?? false), + 'vehicle_type' => $variant['vehicle_type'] ?? null, + ]; + + $json = json_encode($cacheParams); + if ($json === false) { + return ''; + } + + return 'dynamic_image:' . md5($json); +} + +/** + * @param array|null $buttons + */ +function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string +{ + $image = null; + try { + switch ($dynamicImageId) { + case 1: + $image = new machine_1(); + break; + default: + warn('PreRenderDynamicImagesCron: unsupported dynamic_image_id=' . $dynamicImageId); + return null; + } + + if (is_array($buttons)) { + $image->highlighted_buttons = $buttons; + } + $image->current_step = max(0, $currentStep); + $image->only_generate_current_step = $onlyCurrentStep; + $image->setup(); + + $dataUri = $image->exportAsBase64('png'); + if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) { + return null; + } + $imageData = base64_decode($matches[1], true); + if ($imageData === false) { + return null; + } + return $imageData; + } catch (Throwable $e) { + warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage()); + return null; + } finally { + if (is_object($image) && method_exists($image, 'clearImage')) { + try { + $image->clearImage(); + } catch (Throwable) { + } + } + } +} + +/** + * @param mixed $value + * @return array + */ +function parseDynamicImageButtons(mixed $value): array +{ + if ($value === null || $value === '') { + return []; + } + + try { + return department_selfserve_tasks_o::normalizeButtonsInput($value); + } catch (Throwable) { + return []; + } +} + +/** + * @param mixed $value + */ +function normalizeDynamicImageVehicleType(mixed $value): ?int +{ + if ($value === null) { + return null; + } + if (is_string($value)) { + $value = trim($value); + if ($value === '' || strtolower($value) === 'null') { + return null; + } + } + if (!is_numeric($value)) { + return null; + } + $normalized = (int)$value; + if ($normalized < 0) { + return null; + } + return $normalized; +} + +/** + * @param array $base + * @param array $append + * @return array + */ +function mergeUniqueButtonValues(array $base, array $append): array +{ + $result = $base; + $seen = []; + foreach ($result as $value) { + $seen[(is_int($value) ? 'int:' : 'string:') . (string)$value] = true; + } + foreach ($append as $value) { + $key = (is_int($value) ? 'int:' : 'string:') . (string)$value; + if (!isset($seen[$key])) { + $seen[$key] = true; + $result[] = $value; + } + } + return array_values($result); +} + +function PreloadDepartmentWeatherResponsesCron(): void +{ + $start = microtime(true); + + if (!defined('redis')) { + warn('PreloadDepartmentWeatherResponsesCron skipped: Redis is unavailable.'); + return; + } + + $maxDepartmentsRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_MAX_DEPARTMENTS'); + $maxDepartmentsPerRun = max( + 1, + (int)( + $maxDepartmentsRaw !== false && trim((string)$maxDepartmentsRaw) !== '' + ? $maxDepartmentsRaw + : 25 + ) + ); + $hotLimitRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_LIMIT'); + $hotLimit = max( + 1, + (int)( + $hotLimitRaw !== false && trim((string)$hotLimitRaw) !== '' + ? $hotLimitRaw + : 50 + ) + ); + $hotTtlRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); + $hotTtl = max( + 1, + (int)( + $hotTtlRaw !== false && trim((string)$hotTtlRaw) !== '' + ? $hotTtlRaw + : 900 + ) + ); + + try { + $departmentRows = (new departments_o())->list(true); + } catch (Throwable $e) { + warn('PreloadDepartmentWeatherResponsesCron failed to list departments: ' . $e->getMessage()); + return; + } + + if (!is_array($departmentRows) || $departmentRows === []) { + echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron: no departments found.\n"; + return; + } + + usort($departmentRows, static function (array $left, array $right): int { + $leftPriority = isset($left['order_priority']) ? (int)$left['order_priority'] : PHP_INT_MAX; + $rightPriority = isset($right['order_priority']) ? (int)$right['order_priority'] : PHP_INT_MAX; + if ($leftPriority !== $rightPriority) { + return $leftPriority <=> $rightPriority; + } + + return ((int)($left['id'] ?? 0)) <=> ((int)($right['id'] ?? 0)); + }); + + $departmentIds = []; + foreach ($departmentRows as $row) { + $departmentId = (int)($row['id'] ?? 0); + if ($departmentId < 1) { + continue; + } + if (isset($row['visible']) && (int)$row['visible'] === 0) { + continue; + } + + $departmentIds[$departmentId] = true; + if (count($departmentIds) >= $maxDepartmentsPerRun) { + break; + } + } + + // Fallback: if visibility is unavailable or all are hidden, include all departments up to cap. + if ($departmentIds === []) { + foreach ($departmentRows as $row) { + $departmentId = (int)($row['id'] ?? 0); + if ($departmentId < 1) { + continue; + } + $departmentIds[$departmentId] = true; + if (count($departmentIds) >= $maxDepartmentsPerRun) { + break; + } + } + } + + $departmentIds = array_map('intval', array_keys($departmentIds)); + sort($departmentIds, SORT_NUMERIC); + + if ($departmentIds === []) { + echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron: no preloadable departments.\n"; + return; + } + + $warmedSets = 0; + $failedSets = 0; + $totalEntries = 0; + $hotTargetsRequested = 0; + $hotTargetsWarmed = 0; + + $hotTargets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets($hotLimit, $hotTtl); + $hotTargetsRequested = count($hotTargets); + foreach ($hotTargets as $target) { + try { + $result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache( + $target['department_ids'] ?? [], + null, + null, + $target['timeline_range'] ?? null + ); + if (($result['warmed'] ?? false) === true) { + $warmedSets++; + $hotTargetsWarmed++; + $totalEntries += (int)($result['entries'] ?? 0); + } + } catch (Throwable $e) { + $failedSets++; + warn('PreloadDepartmentWeatherResponsesCron failed for hot target: ' . $e->getMessage()); + } + } + + foreach ($departmentIds as $departmentId) { + try { + $result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache([$departmentId]); + if (($result['warmed'] ?? false) === true) { + $warmedSets++; + $totalEntries += (int)($result['entries'] ?? 0); + } + } catch (Throwable $e) { + $failedSets++; + warn('PreloadDepartmentWeatherResponsesCron failed for department #' . $departmentId . ': ' . $e->getMessage()); + } + } + + if (count($departmentIds) > 1) { + try { + $result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache($departmentIds); + if (($result['warmed'] ?? false) === true) { + $warmedSets++; + $totalEntries += (int)($result['entries'] ?? 0); + } + } catch (Throwable $e) { + $failedSets++; + warn('PreloadDepartmentWeatherResponsesCron failed for aggregate department set: ' . $e->getMessage()); + } + } + + $duration = round(microtime(true) - $start, 2); + echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron completed. " + . "departments=" . count($departmentIds) + . " warmed_sets=$warmedSets failed_sets=$failedSets total_entries=$totalEntries " + . "hot_targets_requested=$hotTargetsRequested hot_targets_warmed=$hotTargetsWarmed " + . "max_departments_per_run=$maxDepartmentsPerRun hot_limit=$hotLimit hot_ttl=$hotTtl " + . "duration={$duration}s\n"; +} + /** * GoalsProgressAlertsCron * @@ -196,14 +1241,17 @@ function GoalsProgressAlertsCron(): void $date_start = date('Y-m-d 00:00:00', strtotime("-$days days")); // $days ago at 00:00:00 // Send the messages $departments = [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - ]; + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 13, + 14, + 84, + ]; $department->sendSlackInternalStatisticNotification($date_start, $date_end, $departments); } } @@ -252,55 +1300,60 @@ function GoalsProgressAlertsCron(): void if (!$dueInfo['due']) { continue; } } - // Deduplicate per goal per slot via Redis + // Deduplicate per goal per slot via Redis using an atomic reservation. + // This prevents two concurrent cron runners from both sending the same alert. $slotKey = $dueInfo['slot']; $redisKey = 'goal_alert_sent:' . $goalId . ':' . $slotKey; - $already = redis->get($redisKey) ?? null; - if ($already) { continue; } + $ttl = max(1, (int)$dueInfo['ttl']); + if (!redis->set_if_absent_with_expiration($redisKey, '1', $ttl)) { + continue; + } // Render message $message = goals_progress_alert_renderer::render($criteria); // Dispatch according to destination $destination = $criteria->progress_alert_destination ?? Dest::SLACK; - switch ($destination) { - case Dest::SLACK: - $departments = (array)$goal->departments->value(); - $sentToDept = false; - if (count($departments) > 0) { - foreach ($departments as $deptId) { - if (!is_numeric($deptId)) { continue; } - $dept = (new departments_o())->select((int)$deptId); - if (!$dept->exists()) { continue; } - $webhook = (string)$dept->slack_webhook->value(); - if (empty($webhook)) { continue; } - (new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria, $dept), $webhook); - $sentToDept = true; + $sent = false; + try { + switch ($destination) { + case Dest::SLACK: + $departments = (array)$goal->departments->value(); + $sentToDept = false; + if (count($departments) > 0) { + foreach ($departments as $deptId) { + if (!is_numeric($deptId)) { continue; } + $dept = (new departments_o())->select((int)$deptId); + if (!$dept->exists()) { continue; } + $webhook = (string)$dept->slack_webhook->value(); + if (empty($webhook)) { continue; } + (new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria, $dept), $webhook); + $sentToDept = true; + } } - } - if (!$sentToDept) { - // Fallback to default webhook - (new Slack())->send_message($message); - } - break; - case Dest::EMAIL: - // No recipient context in goal for automated cron - warn('GoalsProgressAlertsCron: EMAIL destination requires explicit recipients; skipping goal #' . $goalId); - break; - case Dest::SMS: - // No recipient context in goal for automated cron - warn('GoalsProgressAlertsCron: SMS destination requires explicit recipients; skipping goal #' . $goalId); - break; - default: - // Unsupported or NONE - warn('GoalsProgressAlertsCron: Unsupported destination for goal #' . $goalId); - } - - // Mark slot as sent (expire in a reasonable window) - $ttl = $dueInfo['ttl']; - redis->set('' . $redisKey, 1); - if (method_exists(redis, 'expire')) { - redis->expire($redisKey, $ttl); + if (!$sentToDept) { + // Fallback to default webhook + (new Slack())->send_message($message); + } + $sent = true; + break; + case Dest::EMAIL: + // No recipient context in goal for automated cron + warn('GoalsProgressAlertsCron: EMAIL destination requires explicit recipients; skipping goal #' . $goalId); + break; + case Dest::SMS: + // No recipient context in goal for automated cron + warn('GoalsProgressAlertsCron: SMS destination requires explicit recipients; skipping goal #' . $goalId); + break; + default: + // Unsupported or NONE + warn('GoalsProgressAlertsCron: Unsupported destination for goal #' . $goalId); + } + } catch (Throwable $dispatchError) { + if (!$sent) { + redis->delete($redisKey); + } + throw $dispatchError; } // Track last progress for CHANGED @@ -417,4 +1470,4 @@ foreach ( $cron_tasks as $task => $data ) { } else { $response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)'; } -} \ No newline at end of file +} diff --git a/services/nginx/app/index.php b/services/nginx/app/index.php index b7c958f3..4e78baf9 100644 --- a/services/nginx/app/index.php +++ b/services/nginx/app/index.php @@ -8,21 +8,20 @@ ini_set('zlib.output_compression', false); */ const WD = __DIR__; -/** CORS */ -header("Access-Control-Allow-Origin: *"); -header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number"); -header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS"); +require_once __DIR__ . '/vendor/autoload.php'; +require_once 'config.php'; +require_once __DIR__ . '/classes/cors_policy.php'; +/** CORS */ // OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { - header('Access-Control-Allow-Origin: *'); - header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); - header('Access-Control-Allow-Headers: *'); - header('Content-Type: application/json'); - http_response_code(200); + $preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? '')); + \classes\cors_policy::emitHeaders($preflight['headers']); + http_response_code($preflight['status']); + echo $preflight['body']; exit; } -require_once 'config.php'; +\classes\cors_policy::applyResponseHeaders((string)($CORS ?? '')); /** Debug */ if ($DEBUG) { ini_set('display_errors', 1); @@ -61,14 +60,28 @@ try { */ spl_autoload_register(function (string $class): void { $class = ltrim($class, '\\'); + $cache_key = 'autoload:' . $class; + $is_loaded = static function (string $candidate): bool { + return class_exists($candidate, false) + || interface_exists($candidate, false) + || trait_exists($candidate, false) + || (function_exists('enum_exists') && enum_exists($candidate, false)); + }; // Check Redis cache first if (defined('redis')) { try { - $cached = redis->get('autoload:' . $class); - if ($cached && is_file($cached)) { + $cached = redis->get($cache_key); + if (is_string($cached) && $cached !== '' && is_file($cached)) { require_once $cached; - return; + if ($is_loaded($class)) { + return; + } + // Stale class mapping in cache, continue with normal lookup. + redis->delete($cache_key); + } elseif (is_string($cached) && $cached !== '') { + // Remove non-existing cached path to avoid repeated failed lookups. + redis->delete($cache_key); } } catch (\Throwable $e) { // Fall back to manual search on Redis error @@ -85,6 +98,9 @@ spl_autoload_register(function (string $class): void { // 1. Core folders: classes, interfaces, traits, objects, statistics $core_folders = ['classes', 'interfaces', 'traits', 'objects', 'statistics']; if (in_array($top, $core_folders)) { + if ($top === 'classes' && str_starts_with(strtolower($relative), 'edge_gateway_')) { + $candidates[] = $base . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . $relative; + } $candidates[] = $base . $top . DIRECTORY_SEPARATOR . $relative; } // 2. Modules folder: explicitly starting with 'modules' @@ -128,10 +144,10 @@ spl_autoload_register(function (string $class): void { $file = $path . $suffix . '.php'; if (is_file($file)) { require_once $file; - if (class_exists($class, false) || interface_exists($class, false) || trait_exists($class, false) || (function_exists('enum_exists') && enum_exists($class, false))) { + if ($is_loaded($class)) { if (defined('redis')) { try { - redis->setEx('autoload:' . $class, $file, 86400); // Cache for 24 hours + redis->setEx($cache_key, $file, 86400); // Cache for 24 hours } catch (\Throwable $e) {} } return; @@ -141,14 +157,16 @@ spl_autoload_register(function (string $class): void { } }); +use classes\application_write_freeze; use classes\db; +use classes\replication_manager; +use classes\release_manager; use classes\redis; use classes\request; use classes\response; use classes\router; // Start the session -$router = new router(); $response = new response(); $request = new request(); $db = new db($CONFIG_DB); @@ -169,7 +187,49 @@ try { $response->error($e->getMessage(), 500); } +try { + release_manager::initializeRequestContext(); + $releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: ''); + release_manager::normalizeReleaseApiIngressPath( + preg_match('#^/[A-Za-z0-9_-]{1,64}/api(?:/|$)#', $releaseIngressPath) === 1 + ? (new release_manager())->enabledReleaseChannelSlugs() + : [] + ); +} catch (Throwable $e) { + error_log('[release-manager] Could not initialize request context or normalize ingress path: ' . $e->getMessage()); +} +$router = new router(); + +try { + $replicationBootstrapSnapshotForRequest = replication_bootstrap_config::loadSnapshot(); + $pendingStartupFailovers = is_array($replicationBootstrapSnapshotForRequest['pending_failovers'] ?? null) + ? $replicationBootstrapSnapshotForRequest['pending_failovers'] + : []; + if ($pendingStartupFailovers !== []) { + (new replication_manager())->syncStartupFailoversFromSnapshot(); + } +} catch (Throwable $e) { + error_log('[replication-bootstrap] Could not sync startup failover metadata: ' . $e->getMessage()); +} + +if (application_write_freeze::shouldBlock( + $_SERVER['REQUEST_METHOD'] ?? 'GET', + $_SERVER['REQUEST_URI'] ?? '/', + php_sapi_name() === 'cli' || isset($_GET['internalCronCall']) +)) { + $freezeState = application_write_freeze::state(); + if (php_sapi_name() === 'cli') { + fwrite(STDERR, 'Application writes are frozen: ' . (string)($freezeState['reason'] ?? 'replication promotion') . PHP_EOL); + exit(75); + } + + $response->error([ + 'message' => 'Application writes are temporarily frozen.', + 'reason' => $freezeState['reason'] ?? null, + 'expires_at' => $freezeState['expires_at'] ?? null, + ], 503); +} // If the program was called from the command line, run the cli script if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) { @@ -188,5 +248,47 @@ if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains( exit; } +// Load enabled module routes before the global route scan. +$load_enabled_module_routes = static function (): void { + $modules_path = WD . DIRECTORY_SEPARATOR . 'modules'; + if (!is_dir($modules_path)) { + return; + } + + $module_dirs = array_filter(scandir($modules_path), static function (string $item) use ($modules_path): bool { + return $item !== '.' && $item !== '..' && is_dir($modules_path . DIRECTORY_SEPARATOR . $item); + }); + + foreach ($module_dirs as $module_dir) { + $routes_path = $modules_path . DIRECTORY_SEPARATOR . $module_dir . DIRECTORY_SEPARATOR . 'routes'; + if (!is_dir($routes_path)) { + continue; + } + + $module_class = 'classes\\' . $module_dir; + if (!class_exists($module_class)) { + continue; + } + + try { + $module = new $module_class(); + if (method_exists($module, 'isEnabled') && !$module->isEnabled()) { + continue; + } + } catch (\Throwable $exception) { + continue; + } + + foreach (scandir($routes_path) as $file) { + if ($file === '.' || $file === '..') { + continue; + } + require_once $routes_path . DIRECTORY_SEPARATOR . $file; + } + } +}; + +$load_enabled_module_routes(); + // Autoload all the routes $router->auto_load_routes(WD . '/routes'); diff --git a/services/nginx/app/interfaces/attachments_i.php b/services/nginx/app/interfaces/attachments_i.php index f71902d2..8cb7f040 100644 --- a/services/nginx/app/interfaces/attachments_i.php +++ b/services/nginx/app/interfaces/attachments_i.php @@ -16,6 +16,14 @@ interface attachments_i * @return attachment[] An array of attachment objects. */ public function list(string $type, int $object_id, array $options = []): array; + /** + * List attachments for multiple entities of the same type. + * @param string $type The parent entity type (e.g. orders, users, tasks). + * @param int[] $object_ids The parent entity IDs. + * @param array $options Optional projection columns. + * @return array Map of object id => attachment list. + */ + public function listMany(string $type, array $object_ids, array $options = []): array; /** * Get an attachment by its ID. * @param int $attachment_id The ID of the attachment to be retrieved. @@ -45,4 +53,4 @@ interface attachments_i * @see attachment_content */ public function update(int $attachment_id, attachment_content $attachment_content): bool; -} \ No newline at end of file +} diff --git a/services/nginx/app/interfaces/bird_i.php b/services/nginx/app/interfaces/bird_i.php new file mode 100644 index 00000000..77d26b9d --- /dev/null +++ b/services/nginx/app/interfaces/bird_i.php @@ -0,0 +1,83 @@ + + */ + public function consume_invoice_period_warming_queue(): array; } \ No newline at end of file diff --git a/services/nginx/app/interfaces/response_i.php b/services/nginx/app/interfaces/response_i.php index 9bda6a89..a6294d12 100644 --- a/services/nginx/app/interfaces/response_i.php +++ b/services/nginx/app/interfaces/response_i.php @@ -4,13 +4,15 @@ namespace interfaces; interface response_i { - public function response(bool $success, array $data, int $status = null): void; + public function response(bool $success, mixed $data, int $status = null): void; public function success(mixed $data, int $status = null): void; public function error(mixed $data, int $status = null): void; + public function rawJson(mixed $data, int $status = 200): void; public function not_found(): void; public function matching_route_found(): void; public function method_not_allowed(): void; public function internal_server_error($error): void; + public function forbidden(array $permissions): void; public function add_data(string $key, mixed $value): void; public function add_debug(mixed $data): void; -} \ No newline at end of file +} diff --git a/services/nginx/app/interfaces/shelly_i.php b/services/nginx/app/interfaces/shelly_i.php index 592974c7..c20d8a66 100644 --- a/services/nginx/app/interfaces/shelly_i.php +++ b/services/nginx/app/interfaces/shelly_i.php @@ -37,6 +37,14 @@ interface shelly_i */ function requireValidServerUrl(): void; + /** + * Send a GET request to Shelly. + * @param string $endpoint The endpoint to send the request to + * @param array $data Query parameters to append to the request + * @return array|object|null The response from Shelly + */ + function sendGetRequest(string $endpoint, array $data): array|object|null; + /** * Send a POST request to the shelly * @param string $endpoint The endpoint to send the request to (e.g. "latest") @@ -44,4 +52,4 @@ interface shelly_i * @return array|object|null The response from the shelly */ function sendPostRequest(string $endpoint, array $data): array|object|null; -} \ No newline at end of file +} diff --git a/services/nginx/app/interfaces/shelly_transport_i.php b/services/nginx/app/interfaces/shelly_transport_i.php new file mode 100644 index 00000000..2f8c9232 --- /dev/null +++ b/services/nginx/app/interfaces/shelly_transport_i.php @@ -0,0 +1,12 @@ +, + * entity_hints: array, + * confidence: float, + * association_hint: bool, + * fallback_reason: string|null, + * source: string + * } + */ + public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array; +} + diff --git a/services/nginx/app/interfaces/weatherapi_i.php b/services/nginx/app/interfaces/weatherapi_i.php new file mode 100644 index 00000000..dc2dcc69 --- /dev/null +++ b/services/nginx/app/interfaces/weatherapi_i.php @@ -0,0 +1,30 @@ +relation = $relation; return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/bird/bird.md b/services/nginx/app/modules/bird/bird.md index a768146e..e316f87a 100644 --- a/services/nginx/app/modules/bird/bird.md +++ b/services/nginx/app/modules/bird/bird.md @@ -32,6 +32,92 @@ This module provides Bird API integration for voice calls and number management. - `maxPollSeconds` (default `30`) - `hangupCause` +- `POST /bird/voice/calls/webhook/inbound` + - Permission: `modules_bird_voice_call_webhooks_trigger` + - Stateful inbound IVR webhook for phone-controlled department gates. + - On the initial request, the webhook accepts the live inbound call through Bird before returning the first gather command. + - Supports both request formats: + - Native-flow mode: top-level `{ callId, channelId, workspaceId }` for the initial fetch, then `{ callId, channelId, workspaceId, keys }` for selections. + - Compatibility mode: `{ payload, request, waitConditions }`, which returns a raw Bird `callCommand` gather envelope. + - Uses `department_gates` rows with `config.type=PHONE_CALL` as the source of truth for which departments and entrance/exit gates are offered. + - Department menu order follows `departments.order_priority`. + - Supports multi-digit department selections such as `10#`. + - Always prompts for both department selection and gate selection, even when only one valid option exists. + - Uses compact gate numbering: + - both gates available: `1=entrance`, `2=exit` + - entrance only: `1=entrance` + - exit only: `1=exit` + +### Bird Flow Builder setup +- Preferred native-flow setup: + - `Voice` trigger + - `Answer call` + - `Async HTTP request` (initial IVR state fetch) + - `Gather digits from a call` + - `Async HTTP request` (submit selected keys) + - If the second HTTP response returns `completed=false`, run another `Gather digits from a call` with the returned prompt and gather settings. + - If the second HTTP response returns `completed=true`, end the flow or optionally say the returned `message`. +- The caller must be connected in Bird before the HTTP webhook step runs. If the flow omits `Answer call`, the inbound call can keep ringing until Bird times it out. +- Initial HTTP step: + - URL: `https://api.truckwash.io:4433/bird/voice/calls/webhook/inbound` + - Content type: `application/json` + - Body: + +```json +{ + "callId": "{{callId}}", + "channelId": "{{channelId}}", + "workspaceId": "{{workspaceId}}" +} +``` + +- Example native-flow response: + +```json +{ + "requestId": "request-123", + "callId": "4015cf84-8028-46a1-a0d9-9213e5bf4f09", + "status": "gather", + "completed": false, + "stage": "department_select", + "prompt": "Choose department. Press 1 for Roskilde. Press 2 for Demo.", + "gather": { + "input": "dtmf", + "maxNumKeys": 1, + "endKey": "#", + "timeout": 30, + "retries": 3, + "say": { + "locale": "en-US", + "voice": "female", + "text": "Choose department. Press 1 for Roskilde. Press 2 for Demo." + } + } +} +``` + +- Use the initial HTTP step output to configure `Gather digits from a call`: + - Prompt/text from `prompt` or `gather.say.text` + - `maxNumKeys`, `endKey`, `timeout`, and `retries` from `gather` + - TTS `locale` and `voice` from `gather.say` +- Selection HTTP step: + - POST the same `callId`, `channelId`, and `workspaceId` plus the gathered digits as `keys`. + - Example body: + +```json +{ + "callId": "{{callId}}", + "channelId": "{{channelId}}", + "workspaceId": "{{workspaceId}}", + "keys": "{{gatheredKeys}}" +} +``` + +- Bird's current Voice API examples use BCP-47 locales such as `en-US` and TTS voices such as `female`/`male`. Avoid undocumented values like `alice`. +- Compatibility mode: + - The webhook still supports the older `{ payload, request, waitConditions }` contract and returns a raw `202 Accepted` Bird `callCommand` gather envelope. + - That mode is not suitable for Bird Flow Builder `Async HTTP request`, because Bird treats the response as data rather than executing the returned call command. + ### Numbers - `GET /bird/numbers` - Permission: `modules_bird_numbers_list` diff --git a/services/nginx/app/modules/bird/classes/bird_api_client.php b/services/nginx/app/modules/bird/classes/bird_api_client.php new file mode 100644 index 00000000..d315902e --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_api_client.php @@ -0,0 +1,41 @@ +bird = $bird; + } + + protected function get(string $endpoint, array $query = []): array|object|null + { + return $this->bird->sendGetRequest($endpoint, $query); + } + + protected function post(string $endpoint, array $payload = []): array|object|null + { + return $this->bird->sendPostRequest($endpoint, $payload); + } + + protected function patch(string $endpoint, array $payload = []): array|object|null + { + return $this->bird->sendPatchRequest($endpoint, $payload); + } + + protected function delete(string $endpoint, array $query = []): array|object|null + { + return $this->bird->sendDeleteRequest($endpoint, $query); + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_flash_calls_client.php b/services/nginx/app/modules/bird/classes/bird_flash_calls_client.php new file mode 100644 index 00000000..7d43c009 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_flash_calls_client.php @@ -0,0 +1,36 @@ +post($this->birdFlashCallsBasePath($workspaceId, $channelId), $payload); + } + + public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null + { + return $this->get($this->birdFlashCallsBasePath($workspaceId, $channelId), $query); + } + + public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null + { + return $this->get($this->birdFlashCallPath($workspaceId, $channelId, $callId)); + } + + public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + return $this->post($this->birdFlashCallPath($workspaceId, $channelId, $callId), $payload); + } + + public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null + { + return $this->post($this->birdFlashCallsHangupPath($workspaceId, $channelId), $payload); + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_voice_calls_client.php b/services/nginx/app/modules/bird/classes/bird_voice_calls_client.php new file mode 100644 index 00000000..f2d93fa2 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_voice_calls_client.php @@ -0,0 +1,71 @@ +post($this->birdVoiceCallsBasePath($workspaceId, $channelId), $payload); + } + + public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null + { + return $this->get($this->birdVoiceCallsBasePath($workspaceId, $channelId), $query); + } + + public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null + { + return $this->get($this->birdVoiceCallPath($workspaceId, $channelId, $callId)); + } + + public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->patch($this->birdVoiceCallPath($workspaceId, $channelId, $callId), $payload); + } + + public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'answer'), $payload); + } + + public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'ringing'), $payload); + } + + public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'hangup'), $payload); + } + + public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'playback'), $payload); + } + + public function sayVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'say'), $payload); + } + + public function gatherVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'gather'), $payload); + } + + public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'bridge'), $payload); + } + + public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'record'), $payload); + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_voice_insights_client.php b/services/nginx/app/modules/bird/classes/bird_voice_insights_client.php new file mode 100644 index 00000000..dbc5632d --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_voice_insights_client.php @@ -0,0 +1,21 @@ +get($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'insights')); + } + + public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null + { + return $this->get($this->birdVoiceCallsLogPath($workspaceId), $query); + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_voice_recordings_client.php b/services/nginx/app/modules/bird/classes/bird_voice_recordings_client.php new file mode 100644 index 00000000..499efdbb --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_voice_recordings_client.php @@ -0,0 +1,31 @@ +post($this->birdVoiceCallRecordingsPath($workspaceId, $channelId, $callId), $payload); + } + + public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null + { + return $this->get($this->birdVoiceCallRecordingsPath($workspaceId, $channelId, $callId), $query); + } + + public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null + { + return $this->get($this->birdVoiceCallRecordingPath($workspaceId, $channelId, $callId, $recordingId)); + } + + public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null + { + return $this->patch($this->birdVoiceCallRecordingPath($workspaceId, $channelId, $callId, $recordingId), $payload); + } +} diff --git a/services/nginx/app/modules/bird/helpers/bird_payloads.php b/services/nginx/app/modules/bird/helpers/bird_payloads.php new file mode 100644 index 00000000..0e57fc2d --- /dev/null +++ b/services/nginx/app/modules/bird/helpers/bird_payloads.php @@ -0,0 +1,535 @@ +payload = $payload; + } + + final public function toArray(): array + { + return $this->payload; + } + + protected static function pickAllowedKeys(array $payload, array $allowedKeys): array + { + $out = []; + foreach ($allowedKeys as $key) { + if (array_key_exists($key, $payload)) { + $out[$key] = $payload[$key]; + } + } + return $out; + } + + protected static function normalizeIntegerFields(array $payload, array $fields): array + { + foreach ($fields as $field) { + if (!array_key_exists($field, $payload)) { + continue; + } + $intValue = self::toInt($payload[$field]); + if ($intValue !== null) { + $payload[$field] = $intValue; + } + } + return $payload; + } + + protected static function normalizeBooleanFields(array $payload, array $fields): array + { + foreach ($fields as $field) { + if (!array_key_exists($field, $payload)) { + continue; + } + $boolValue = self::toBool($payload[$field]); + if ($boolValue !== null) { + $payload[$field] = $boolValue; + } + } + return $payload; + } + + protected static function normalizeStringListField(array $payload, string $field, bool $splitCsv = false): array + { + if (!array_key_exists($field, $payload)) { + return $payload; + } + + $value = $payload[$field]; + $source = []; + if (is_array($value)) { + $source = $value; + } elseif (is_string($value) && $splitCsv) { + $source = explode(',', $value); + } else { + return $payload; + } + + $out = []; + foreach ($source as $item) { + if (!is_scalar($item)) { + continue; + } + $normalized = trim((string)$item); + if ($normalized !== '') { + $out[] = $normalized; + } + } + + $payload[$field] = array_values($out); + return $payload; + } + + protected static function normalizeCsvStringListField(array $payload, string $field): array + { + return self::normalizeStringListField($payload, $field, true); + } + + protected static function normalizeCallFlow(array $payload): array + { + if (!array_key_exists('callFlow', $payload) || !is_array($payload['callFlow'])) { + return $payload; + } + + $commands = []; + foreach ($payload['callFlow'] as $command) { + if (!is_array($command)) { + continue; + } + $normalizedCommand = self::pickAllowedKeys($command, ['command', 'conditions', 'options']); + + if (array_key_exists('conditions', $normalizedCommand) && is_array($normalizedCommand['conditions'])) { + $conditions = []; + foreach ($normalizedCommand['conditions'] as $condition) { + if (!is_array($condition)) { + continue; + } + $conditions[] = self::pickAllowedKeys($condition, ['variable', 'operator', 'value']); + } + $normalizedCommand['conditions'] = array_values($conditions); + } + + if (array_key_exists('options', $normalizedCommand) && !is_array($normalizedCommand['options'])) { + unset($normalizedCommand['options']); + } + + $commands[] = $normalizedCommand; + } + + $payload['callFlow'] = array_values($commands); + return $payload; + } + + protected static function normalizeNotification(array $payload): array + { + if (!array_key_exists('notification', $payload)) { + return $payload; + } + if (!is_array($payload['notification'])) { + unset($payload['notification']); + return $payload; + } + + $payload['notification'] = self::pickAllowedKeys($payload['notification'], ['url']); + return $payload; + } + + protected static function normalizeAmdSettings(array $payload): array + { + if (!array_key_exists('amdSettings', $payload)) { + return $payload; + } + if (!is_array($payload['amdSettings'])) { + unset($payload['amdSettings']); + return $payload; + } + + $amdSettings = self::pickAllowedKeys($payload['amdSettings'], [ + 'enabled', + 'wordCount', + 'speechTimeout', + 'speechLocale', + 'beepTimeout', + 'ifMachineNotifyAfter', + ]); + $amdSettings = self::normalizeBooleanFields($amdSettings, ['enabled']); + $amdSettings = self::normalizeIntegerFields($amdSettings, ['wordCount', 'speechTimeout', 'beepTimeout']); + + $payload['amdSettings'] = $amdSettings; + return $payload; + } + + protected static function toInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_string($value) && preg_match('/^-?[0-9]+$/', $value) === 1) { + return (int)$value; + } + return null; + } + + protected static function toBool(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) && ($value === 0 || $value === 1)) { + return $value === 1; + } + if (!is_string($value)) { + return null; + } + + $normalized = strtolower(trim($value)); + if ($normalized === 'true' || $normalized === '1') { + return true; + } + if ($normalized === 'false' || $normalized === '0') { + return false; + } + return null; + } +} + +final class bird_no_body_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + return new self([]); + } +} + +final class bird_voice_calls_log_query_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, [ + 'limit', + 'pageToken', + 'startAt', + 'endAt', + 'channelId', + 'status', + 'type', + 'to', + 'from', + 'duration', + 'direction', + 'id', + 'tag', + ]); + $normalized = self::normalizeIntegerFields($normalized, ['limit', 'duration']); + $normalized = self::normalizeCsvStringListField($normalized, 'channelId'); + $normalized = self::normalizeCsvStringListField($normalized, 'tag'); + + return new self($normalized); + } +} + +final class bird_voice_create_call_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, [ + 'from', + 'to', + 'ringTimeout', + 'maxDuration', + 'sendKeys', + 'record', + 'recordStart', + 'flowStart', + 'stereo', + 'callFlow', + 'scheduledFor', + 'notification', + 'amdSettings', + 'tags', + ]); + $normalized = self::normalizeIntegerFields($normalized, ['ringTimeout', 'maxDuration']); + $normalized = self::normalizeBooleanFields($normalized, ['record', 'stereo']); + $normalized = self::normalizeStringListField($normalized, 'tags'); + $normalized = self::normalizeCallFlow($normalized); + $normalized = self::normalizeNotification($normalized); + $normalized = self::normalizeAmdSettings($normalized); + + return new self($normalized); + } +} + +final class bird_voice_list_calls_query_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, [ + 'limit', + 'pageToken', + 'startAt', + 'endAt', + 'status', + 'type', + 'to', + 'from', + 'duration', + 'direction', + 'id', + 'tag', + ]); + $normalized = self::normalizeIntegerFields($normalized, ['limit', 'duration']); + $normalized = self::normalizeCsvStringListField($normalized, 'tag'); + + return new self($normalized); + } +} + +final class bird_voice_update_call_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, ['status', 'callFlow']); + $normalized = self::normalizeCallFlow($normalized); + + return new self($normalized); + } +} + +final class bird_voice_hangup_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + return new self(self::pickAllowedKeys($payload, ['cause'])); + } +} + +final class bird_voice_playback_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, ['media', 'loop', 'timeout', 'pauseMilliseconds']); + $normalized = self::normalizeIntegerFields($normalized, ['loop', 'timeout', 'pauseMilliseconds']); + $normalized = self::normalizeStringListField($normalized, 'media'); + + return new self($normalized); + } +} + +final class bird_voice_say_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, ['text', 'locale', 'voice', 'loop', 'timeout', 'hangup']); + $normalized = self::normalizeIntegerFields($normalized, ['loop', 'timeout']); + $normalized = self::normalizeBooleanFields($normalized, ['hangup']); + if (!array_key_exists('hangup', $normalized)) { + $normalized['hangup'] = true; + } + + return new self($normalized); + } +} + +final class bird_voice_gather_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, [ + 'maxNumKeys', + 'endKey', + 'timeout', + 'retries', + 'input', + 'speechLocale', + 'playback', + 'say', + ]); + $normalized = self::normalizeIntegerFields($normalized, ['maxNumKeys', 'timeout', 'retries']); + + if (array_key_exists('playback', $normalized) && is_array($normalized['playback'])) { + $normalized['playback'] = bird_voice_playback_payload::fromArray($normalized['playback'])->toArray(); + } + + if (array_key_exists('say', $normalized) && is_array($normalized['say'])) { + $say = self::pickAllowedKeys($normalized['say'], ['text', 'locale', 'voice', 'loop', 'timeout', 'hangup']); + $say = self::normalizeIntegerFields($say, ['loop', 'timeout']); + $say = self::normalizeBooleanFields($say, ['hangup']); + $normalized['say'] = $say; + } + + return new self($normalized); + } +} + +final class bird_voice_bridge_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, [ + 'from', + 'to', + 'ringTimeout', + 'maxDuration', + 'ringTone', + 'hangupAfterBridge', + 'record', + 'recordStart', + 'recordStereo', + 'callFlow', + 'notification', + 'amdSettings', + ]); + $normalized = self::normalizeIntegerFields($normalized, ['ringTimeout', 'maxDuration']); + $normalized = self::normalizeBooleanFields($normalized, ['hangupAfterBridge', 'record', 'recordStereo']); + $normalized = self::normalizeCallFlow($normalized); + $normalized = self::normalizeNotification($normalized); + $normalized = self::normalizeAmdSettings($normalized); + + return new self($normalized); + } +} + +final class bird_voice_record_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, ['endKey', 'maxLength', 'timeout', 'beep', 'transcribe', 'transcribeLocale']); + $normalized = self::normalizeIntegerFields($normalized, ['maxLength', 'timeout']); + $normalized = self::normalizeBooleanFields($normalized, ['beep', 'transcribe']); + + return new self($normalized); + } +} + +final class bird_voice_recordings_create_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, ['maxLength', 'stereo']); + $normalized = self::normalizeIntegerFields($normalized, ['maxLength']); + $normalized = self::normalizeBooleanFields($normalized, ['stereo']); + + return new self($normalized); + } +} + +final class bird_voice_recordings_list_query_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, ['limit', 'pageToken']); + $normalized = self::normalizeIntegerFields($normalized, ['limit']); + + return new self($normalized); + } +} + +final class bird_voice_recording_update_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + return new self(self::pickAllowedKeys($payload, ['status'])); + } +} + +final class bird_voice_test_outbound_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, [ + 'from', + 'to', + 'ringTimeout', + 'maxDuration', + 'sendKeys', + 'record', + 'recordStart', + 'flowStart', + 'stereo', + 'callFlow', + 'scheduledFor', + 'notification', + 'amdSettings', + 'tags', + 'timeout', + 'pollIntervalSeconds', + 'maxPollSeconds', + 'hangupCause', + ]); + $normalized = self::normalizeIntegerFields($normalized, [ + 'ringTimeout', + 'maxDuration', + 'timeout', + 'pollIntervalSeconds', + 'maxPollSeconds', + ]); + $normalized = self::normalizeBooleanFields($normalized, ['record', 'stereo']); + $normalized = self::normalizeStringListField($normalized, 'tags'); + $normalized = self::normalizeCallFlow($normalized); + $normalized = self::normalizeNotification($normalized); + $normalized = self::normalizeAmdSettings($normalized); + + return new self($normalized); + } +} + +final class bird_flash_create_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, ['from', 'to', 'ringTimeout']); + $normalized = self::normalizeIntegerFields($normalized, ['ringTimeout']); + + return new self($normalized); + } +} + +final class bird_flash_list_query_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + $normalized = self::pickAllowedKeys($payload, [ + 'limit', + 'pageToken', + 'startAt', + 'endAt', + 'status', + 'to', + 'from', + 'duration', + 'id', + ]); + $normalized = self::normalizeIntegerFields($normalized, ['limit', 'duration']); + + return new self($normalized); + } +} + +final class bird_flash_end_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + return new self(self::pickAllowedKeys($payload, ['receivedCli', 'result'])); + } +} + +final class bird_flash_hangup_payload extends bird_payload +{ + public static function fromArray(array $payload): self + { + return new self(self::pickAllowedKeys($payload, ['from', 'to', 'receivedCli', 'result'])); + } +} diff --git a/services/nginx/app/modules/bird/helpers/bird_request_schemas.php b/services/nginx/app/modules/bird/helpers/bird_request_schemas.php new file mode 100644 index 00000000..84f647df --- /dev/null +++ b/services/nginx/app/modules/bird/helpers/bird_request_schemas.php @@ -0,0 +1,404 @@ + 'object', + 'additionalProperties' => false, + 'properties' => [], + ]; + } + + public static function voiceCreateCallBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['to'], + 'properties' => [ + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'ringTimeout' => ['type' => 'integer', 'min' => 3, 'max' => 120], + 'maxDuration' => ['type' => 'integer', 'min' => 1], + 'sendKeys' => ['type' => 'string', 'maxLength' => 20, 'pattern' => '^[0-9*#]+$'], + 'record' => ['type' => 'boolean'], + 'recordStart' => ['type' => 'string', 'enum' => ['record-from-answer', 'record-from-ringing']], + 'flowStart' => ['type' => 'string', 'enum' => ['from-answer', 'from-ringing']], + 'stereo' => ['type' => 'boolean'], + 'callFlow' => self::callFlowCommands(), + 'scheduledFor' => ['type' => 'string', 'format' => 'date-time'], + 'notification' => self::notificationSchema(), + 'amdSettings' => self::amdSettingsSchema(), + 'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64]], + ], + ]; + } + + public static function voiceListCallsQuery(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000], + 'pageToken' => ['type' => 'string', 'maxLength' => 8000], + 'startAt' => ['type' => 'string', 'format' => 'date-time'], + 'endAt' => ['type' => 'string', 'format' => 'date-time'], + 'status' => ['type' => 'string', 'enum' => self::callStatuses()], + 'type' => ['type' => 'string', 'enum' => ['pstn', 'sip', 'webrtc']], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'duration' => ['type' => 'integer', 'min' => 0], + 'direction' => ['type' => 'string', 'enum' => ['incoming', 'outgoing']], + 'id' => ['type' => 'string', 'format' => 'uuid'], + 'tag' => ['type' => 'array', 'csv' => true, 'items' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64]], + ], + ]; + } + + public static function voiceUpdateCallBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'status' => ['type' => 'string', 'nullable' => true, 'enum' => ['completed']], + 'callFlow' => self::callFlowCommands(), + ], + ]; + } + + public static function voiceHangupBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'cause' => ['type' => 'string', 'enum' => ['rejected', 'busy']], + ], + ]; + } + + public static function voicePlaybackBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['media'], + 'properties' => [ + 'media' => ['type' => 'array', 'minItems' => 1, 'maxItems' => 80, 'items' => ['type' => 'string', 'minLength' => 1]], + 'loop' => ['type' => 'integer', 'min' => 0], + 'timeout' => ['type' => 'integer', 'min' => 0], + 'pauseMilliseconds' => ['type' => 'integer', 'min' => 0, 'max' => 30000], + ], + ]; + } + + public static function voiceSayBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['text'], + 'properties' => [ + 'text' => ['type' => 'string', 'minLength' => 1], + 'locale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20], + 'voice' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64], + 'loop' => ['type' => 'integer', 'min' => 0], + 'timeout' => ['type' => 'integer', 'min' => 0], + 'hangup' => ['type' => 'boolean'], + ], + ]; + } + + public static function voiceGatherBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'maxNumKeys' => ['type' => 'integer', 'min' => 1], + 'endKey' => ['type' => 'string', 'enum' => self::digitEnums()], + 'timeout' => ['type' => 'integer', 'min' => 0], + 'retries' => ['type' => 'integer', 'min' => 0], + 'input' => ['type' => 'string', 'enum' => ['dtmf', 'speech', 'dtmf speech']], + 'speechLocale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20], + 'playback' => self::voicePlaybackBody(), + 'say' => self::voiceSayBody(), + ], + ]; + } + + public static function voiceBridgeBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['to'], + 'properties' => [ + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'ringTimeout' => ['type' => 'integer', 'min' => 3, 'max' => 120], + 'maxDuration' => ['type' => 'integer', 'min' => 1], + 'ringTone' => ['type' => 'string', 'enum' => ['be', 'ca', 'cn', 'cy', 'cz', 'de', 'dk', 'dz', 'eg', 'fi', 'fr', 'hk', 'hu', 'il', 'in', 'jp', 'ko', 'pk', 'pl', 'ro', 'rs', 'ru', 'sa', 'tr', 'uk', 'us']], + 'hangupAfterBridge' => ['type' => 'boolean'], + 'record' => ['type' => 'boolean'], + 'recordStart' => ['type' => 'string', 'enum' => ['record-from-answer', 'record-from-ringing']], + 'recordStereo' => ['type' => 'boolean'], + 'callFlow' => self::callFlowCommands(), + 'notification' => self::notificationSchema(), + 'amdSettings' => self::amdSettingsSchema(), + ], + ]; + } + + public static function voiceRecordBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'endKey' => ['type' => 'string', 'enum' => self::digitEnums()], + 'maxLength' => ['type' => 'integer', 'min' => 1], + 'timeout' => ['type' => 'integer', 'min' => 0], + 'beep' => ['type' => 'boolean'], + 'transcribe' => ['type' => 'boolean'], + 'transcribeLocale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20], + ], + ]; + } + + public static function voiceRecordingsCreateBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'maxLength' => ['type' => 'integer', 'min' => 1], + 'stereo' => ['type' => 'boolean'], + ], + ]; + } + + public static function voiceRecordingsListQuery(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000], + 'pageToken' => ['type' => 'string', 'maxLength' => 8000], + ], + ]; + } + + public static function voiceRecordingUpdateBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['status'], + 'properties' => [ + 'status' => ['type' => 'string', 'enum' => ['paused', 'ongoing', 'completed']], + ], + ]; + } + + public static function voiceCallsLogQuery(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000], + 'pageToken' => ['type' => 'string', 'maxLength' => 8000], + 'startAt' => ['type' => 'string', 'format' => 'date-time'], + 'endAt' => ['type' => 'string', 'format' => 'date-time'], + 'channelId' => ['type' => 'array', 'csv' => true, 'items' => ['type' => 'string', 'format' => 'uuid']], + 'status' => ['type' => 'string', 'enum' => self::callStatuses()], + 'type' => ['type' => 'string', 'enum' => ['pstn', 'sip', 'webrtc']], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'duration' => ['type' => 'integer', 'min' => 0], + 'direction' => ['type' => 'string', 'enum' => ['incoming', 'outgoing']], + 'id' => ['type' => 'string', 'format' => 'uuid'], + 'tag' => ['type' => 'array', 'csv' => true, 'items' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64]], + ], + ]; + } + + public static function voiceTestOutboundBody(): array + { + $create = self::voiceCreateCallBody(); + $properties = isset($create['properties']) && is_array($create['properties']) ? $create['properties'] : []; + + $properties['timeout'] = ['type' => 'integer', 'min' => 1]; + $properties['pollIntervalSeconds'] = ['type' => 'integer', 'min' => 1]; + $properties['maxPollSeconds'] = ['type' => 'integer', 'min' => 5]; + $properties['hangupCause'] = ['type' => 'string', 'enum' => ['rejected', 'busy']]; + + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $properties, + ]; + } + + public static function flashCreateBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['to'], + 'properties' => [ + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'ringTimeout' => ['type' => 'integer', 'min' => 3, 'max' => 120], + ], + ]; + } + + public static function flashListQuery(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000], + 'pageToken' => ['type' => 'string', 'maxLength' => 8000], + 'startAt' => ['type' => 'string', 'format' => 'date-time'], + 'endAt' => ['type' => 'string', 'format' => 'date-time'], + 'status' => ['type' => 'string', 'enum' => self::callStatuses()], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'duration' => ['type' => 'integer', 'min' => 0], + 'id' => ['type' => 'string', 'format' => 'uuid'], + ], + ]; + } + + public static function flashEndBody(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['result'], + 'properties' => [ + 'receivedCli' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'result' => ['type' => 'string', 'enum' => ['unknown', 'verified', 'canceled', 'timeout', 'wrong_cli']], + ], + ]; + } + + public static function flashHangupBody(): array + { + return [ + 'oneOf' => [ + [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['result'], + 'properties' => [ + 'receivedCli' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'result' => ['type' => 'string', 'enum' => ['unknown', 'verified', 'canceled', 'timeout', 'wrong_cli']], + ], + ], + [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['from', 'to'], + 'properties' => [ + 'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'receivedCli' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'result' => ['type' => 'string', 'enum' => ['unknown', 'verified', 'canceled', 'timeout', 'wrong_cli']], + ], + ], + ], + ]; + } + + private static function callFlowCommands(): array + { + return [ + 'type' => 'array', + 'maxItems' => 20, + 'items' => self::callFlowCommandSchema(), + ]; + } + + private static function callFlowCommandSchema(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['command'], + 'properties' => [ + 'command' => [ + 'type' => 'string', + 'enum' => ['answer', 'hangup', 'playback', 'say', 'gather', 'record', 'bridge', 'pause', 'ringing'], + ], + 'conditions' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['variable', 'operator', 'value'], + 'properties' => [ + 'variable' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64], + 'operator' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 16], + 'value' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 255], + ], + ], + ], + 'options' => [ + 'type' => 'object', + 'additionalProperties' => true, + 'properties' => [], + ], + ], + ]; + } + + private static function notificationSchema(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['url'], + 'properties' => [ + 'url' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 2048], + ], + ]; + } + + private static function amdSettingsSchema(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'enabled' => ['type' => 'boolean'], + 'wordCount' => ['type' => 'integer', 'min' => 1], + 'speechTimeout' => ['type' => 'integer', 'min' => 1], + 'speechLocale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20], + 'beepTimeout' => ['type' => 'integer', 'min' => 1], + 'ifMachineNotifyAfter' => ['type' => 'string', 'enum' => ['wordCount', 'beep']], + ], + ]; + } + + private static function callStatuses(): array + { + return ['accepted', 'starting', 'ringing', 'ongoing', 'completed', 'no-answer', 'busy', 'failed', 'cancelled', 'scheduled']; + } + + private static function digitEnums(): array + { + return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#']; + } +} diff --git a/services/nginx/app/modules/bird/helpers/bird_request_validator.php b/services/nginx/app/modules/bird/helpers/bird_request_validator.php new file mode 100644 index 00000000..399231e2 --- /dev/null +++ b/services/nginx/app/modules/bird/helpers/bird_request_validator.php @@ -0,0 +1,272 @@ + self::validateObject($value, $schema, $path, $errors), + 'array' => self::validateArray($value, $schema, $path, $errors), + 'string' => self::validateString($value, $schema, $path, $errors), + 'int', 'integer' => self::validateInteger($value, $schema, $path, $errors), + 'number' => self::validateNumber($value, $schema, $path, $errors), + 'bool', 'boolean' => self::validateBoolean($value, $schema, $path, $errors), + default => $errors[] = $path . ' has unsupported schema type "' . $type . '"', + }; + } + + if (isset($schema['enum']) && is_array($schema['enum'])) { + self::validateEnum($value, $schema['enum'], $path, $errors); + } + } + + private static function validateOneOf(mixed $value, array $schemas, string $path, array &$errors): void + { + $bestErrors = null; + foreach ($schemas as $schema) { + if (!is_array($schema)) { + continue; + } + $tmp = []; + self::validateValue($value, $schema, $path, $tmp); + if ($tmp === []) { + return; + } + if ($bestErrors === null || count($tmp) < count($bestErrors)) { + $bestErrors = $tmp; + } + } + + $errors[] = $path . ' does not match any of the allowed schemas'; + if (is_array($bestErrors)) { + foreach ($bestErrors as $err) { + $errors[] = $err; + } + } + } + + private static function validateObject(mixed $value, array $schema, string $path, array &$errors): void + { + if (!is_array($value) || !self::isAssoc($value)) { + $errors[] = $path . ' must be an object'; + return; + } + + $required = isset($schema['required']) && is_array($schema['required']) ? $schema['required'] : []; + foreach ($required as $requiredKey) { + if (!array_key_exists((string)$requiredKey, $value)) { + $errors[] = $path . '.' . $requiredKey . ' is required'; + } + } + + $properties = isset($schema['properties']) && is_array($schema['properties']) ? $schema['properties'] : []; + $allowAdditional = (bool)($schema['additionalProperties'] ?? true); + + if (!$allowAdditional) { + foreach ($value as $key => $_unused) { + if (!array_key_exists((string)$key, $properties)) { + $errors[] = $path . '.' . $key . ' is not allowed'; + } + } + } + + foreach ($properties as $key => $propertySchema) { + if (!array_key_exists((string)$key, $value)) { + continue; + } + if (!is_array($propertySchema)) { + continue; + } + self::validateValue($value[(string)$key], $propertySchema, $path . '.' . $key, $errors); + } + } + + private static function validateArray(mixed $value, array $schema, string $path, array &$errors): void + { + $arrayValue = $value; + if (is_string($arrayValue) && ($schema['csv'] ?? false)) { + $parts = array_map('trim', explode(',', $arrayValue)); + $arrayValue = array_values(array_filter($parts, static fn($part) => $part !== '')); + } + + if (!is_array($arrayValue) || self::isAssoc($arrayValue)) { + $errors[] = $path . ' must be an array'; + return; + } + + $count = count($arrayValue); + if (isset($schema['minItems']) && is_numeric($schema['minItems']) && $count < (int)$schema['minItems']) { + $errors[] = $path . ' must have at least ' . (int)$schema['minItems'] . ' items'; + } + if (isset($schema['maxItems']) && is_numeric($schema['maxItems']) && $count > (int)$schema['maxItems']) { + $errors[] = $path . ' must have at most ' . (int)$schema['maxItems'] . ' items'; + } + + $itemSchema = isset($schema['items']) && is_array($schema['items']) ? $schema['items'] : null; + if ($itemSchema === null) { + return; + } + + foreach ($arrayValue as $index => $item) { + self::validateValue($item, $itemSchema, $path . '[' . $index . ']', $errors); + } + } + + private static function validateString(mixed $value, array $schema, string $path, array &$errors): void + { + if (!is_string($value)) { + $errors[] = $path . ' must be a string'; + return; + } + + $length = strlen($value); + if (isset($schema['minLength']) && is_numeric($schema['minLength']) && $length < (int)$schema['minLength']) { + $errors[] = $path . ' must be at least ' . (int)$schema['minLength'] . ' characters'; + } + if (isset($schema['maxLength']) && is_numeric($schema['maxLength']) && $length > (int)$schema['maxLength']) { + $errors[] = $path . ' must be at most ' . (int)$schema['maxLength'] . ' characters'; + } + + if (isset($schema['pattern']) && is_string($schema['pattern']) && @preg_match('/' . $schema['pattern'] . '/', $value) !== 1) { + $errors[] = $path . ' has invalid format'; + } + + if (isset($schema['format']) && is_string($schema['format'])) { + if ($schema['format'] === 'uuid' && !self::isUuid($value)) { + $errors[] = $path . ' must be a UUID'; + } + if ($schema['format'] === 'date-time' && strtotime($value) === false) { + $errors[] = $path . ' must be a valid date-time'; + } + } + } + + private static function validateInteger(mixed $value, array $schema, string $path, array &$errors): void + { + $intValue = self::toInt($value); + if ($intValue === null) { + $errors[] = $path . ' must be an integer'; + return; + } + + if (isset($schema['min']) && is_numeric($schema['min']) && $intValue < (int)$schema['min']) { + $errors[] = $path . ' must be at least ' . (int)$schema['min']; + } + if (isset($schema['max']) && is_numeric($schema['max']) && $intValue > (int)$schema['max']) { + $errors[] = $path . ' must be at most ' . (int)$schema['max']; + } + } + + private static function validateNumber(mixed $value, array $schema, string $path, array &$errors): void + { + $numberValue = self::toFloat($value); + if ($numberValue === null) { + $errors[] = $path . ' must be a number'; + return; + } + + if (isset($schema['min']) && is_numeric($schema['min']) && $numberValue < (float)$schema['min']) { + $errors[] = $path . ' must be at least ' . (float)$schema['min']; + } + if (isset($schema['max']) && is_numeric($schema['max']) && $numberValue > (float)$schema['max']) { + $errors[] = $path . ' must be at most ' . (float)$schema['max']; + } + } + + private static function validateBoolean(mixed $value, array $schema, string $path, array &$errors): void + { + if (self::toBool($value) === null) { + $errors[] = $path . ' must be a boolean'; + } + } + + private static function validateEnum(mixed $value, array $allowedValues, string $path, array &$errors): void + { + if (in_array($value, $allowedValues, true)) { + return; + } + + if (is_scalar($value)) { + foreach ($allowedValues as $allowed) { + if (is_scalar($allowed) && (string)$allowed === (string)$value) { + return; + } + } + } + + $errors[] = $path . ' must be one of: ' . implode(', ', array_map(static fn($item) => (string)$item, $allowedValues)); + } + + private static function toInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_string($value) && preg_match('/^-?[0-9]+$/', $value) === 1) { + return (int)$value; + } + return null; + } + + private static function toFloat(mixed $value): ?float + { + if (is_int($value) || is_float($value)) { + return (float)$value; + } + if (is_string($value) && is_numeric($value)) { + return (float)$value; + } + return null; + } + + private static function toBool(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) && ($value === 0 || $value === 1)) { + return $value === 1; + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if ($normalized === 'true' || $normalized === '1') { + return true; + } + if ($normalized === 'false' || $normalized === '0') { + return false; + } + } + return null; + } + + private static function isAssoc(array $array): bool + { + return array_keys($array) !== range(0, count($array) - 1); + } +} + diff --git a/services/nginx/app/modules/bird/interfaces/bird_flash_calls_client_i.php b/services/nginx/app/modules/bird/interfaces/bird_flash_calls_client_i.php new file mode 100644 index 00000000..ccd2ad45 --- /dev/null +++ b/services/nginx/app/modules/bird/interfaces/bird_flash_calls_client_i.php @@ -0,0 +1,17 @@ +birdEncodePathSegment($workspaceId) + . '/channels/' + . $this->birdEncodePathSegment($channelId) + . '/calls'; + } + + protected function birdVoiceCallPath(string $workspaceId, string $channelId, string $callId): string + { + return $this->birdVoiceCallsBasePath($workspaceId, $channelId) + . '/' + . $this->birdEncodePathSegment($callId); + } + + protected function birdVoiceCallCommandPath(string $workspaceId, string $channelId, string $callId, string $command): string + { + return $this->birdVoiceCallPath($workspaceId, $channelId, $callId) + . '/' + . trim($command, '/'); + } + + protected function birdVoiceCallRecordingsPath(string $workspaceId, string $channelId, string $callId): string + { + return $this->birdVoiceCallPath($workspaceId, $channelId, $callId) . '/recordings'; + } + + protected function birdVoiceCallRecordingPath(string $workspaceId, string $channelId, string $callId, string $recordingId): string + { + return $this->birdVoiceCallRecordingsPath($workspaceId, $channelId, $callId) + . '/' + . $this->birdEncodePathSegment($recordingId); + } + + protected function birdVoiceCallsLogPath(string $workspaceId): string + { + return '/workspaces/' . $this->birdEncodePathSegment($workspaceId) . '/channels/calls'; + } + + protected function birdFlashCallsBasePath(string $workspaceId, string $channelId): string + { + return '/workspaces/' + . $this->birdEncodePathSegment($workspaceId) + . '/channels/' + . $this->birdEncodePathSegment($channelId) + . '/flashcalls'; + } + + protected function birdFlashCallPath(string $workspaceId, string $channelId, string $callId): string + { + return $this->birdFlashCallsBasePath($workspaceId, $channelId) + . '/' + . $this->birdEncodePathSegment($callId); + } + + protected function birdFlashCallsHangupPath(string $workspaceId, string $channelId): string + { + return $this->birdFlashCallsBasePath($workspaceId, $channelId) . '/hangup'; + } +} + diff --git a/services/nginx/app/modules/coolify/config/coolify_enabled_c.php b/services/nginx/app/modules/coolify/config/coolify_enabled_c.php new file mode 100644 index 00000000..859c61ee --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_enabled_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'enabled', + 'bool', + false, + null, + 'Enable Coolify-managed replicated infrastructure targets.', + 'true', + false, + 'false' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php b/services/nginx/app/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php new file mode 100644 index 00000000..bb502c55 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php @@ -0,0 +1,38 @@ +setupConfigVariable( + 'Coolify', + 'hetzner_cloud_api_token', + 'string', + false, + null, + 'Hetzner Cloud API token with Load Balancer read/write permissions.', + 'pat_...', + true, + '' + ); + } + + public function setVariableValue(mixed $value): void + { + $value = trim((string)($value ?? '')); + if ($value !== '' && !str_starts_with($value, 'twsec:v1:')) { + $value = replication_secret_box::encrypt($value); + } + + $this->traitSetVariableValue($value); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_hetzner_load_balancer_id_c.php b/services/nginx/app/modules/coolify/config/coolify_hetzner_load_balancer_id_c.php new file mode 100644 index 00000000..7252a38e --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_hetzner_load_balancer_id_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'hetzner_load_balancer_id', + 'string', + false, + null, + 'Hetzner Cloud Load Balancer ID used as the public Coolify gateway.', + '1234567', + false, + '' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_lb_automation_enabled_c.php b/services/nginx/app/modules/coolify/config/coolify_lb_automation_enabled_c.php new file mode 100644 index 00000000..ae273265 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_lb_automation_enabled_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'lb_automation_enabled', + 'bool', + false, + null, + 'Enable automated Hetzner Load Balancer reconciliation for the Coolify public gateway.', + 'false', + false, + 'false' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_lb_automation_mode_c.php b/services/nginx/app/modules/coolify/config/coolify_lb_automation_mode_c.php new file mode 100644 index 00000000..b3183947 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_lb_automation_mode_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'lb_automation_mode', + 'string', + false, + ['report_only', 'enforce'], + 'Controls whether Hetzner Load Balancer reconciliation reports drift only or applies changes.', + 'report_only', + false, + 'report_only' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_public_gateway_host_c.php b/services/nginx/app/modules/coolify/config/coolify_public_gateway_host_c.php new file mode 100644 index 00000000..e62cefd7 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_public_gateway_host_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'public_gateway_host', + 'string', + false, + null, + 'Public DNS hostname served by the replicated Coolify gateway Load Balancer.', + 'api-v2.truckwash.io', + false, + 'api-v2.truckwash.io' + ); + } +} diff --git a/services/nginx/app/modules/coolify/coolify_c.php b/services/nginx/app/modules/coolify/coolify_c.php new file mode 100644 index 00000000..68df3b39 --- /dev/null +++ b/services/nginx/app/modules/coolify/coolify_c.php @@ -0,0 +1,63 @@ +setupConfig('Coolify'); + $this->allowUpdate([ + coolify_enabled_c::class, + coolify_lb_automation_enabled_c::class, + coolify_lb_automation_mode_c::class, + coolify_hetzner_load_balancer_id_c::class, + coolify_hetzner_cloud_api_token_c::class, + coolify_public_gateway_host_c::class, + ]); + $this->enabled = new coolify_enabled_c(); + $this->lb_automation_enabled = new coolify_lb_automation_enabled_c(); + $this->lb_automation_mode = new coolify_lb_automation_mode_c(); + $this->hetzner_load_balancer_id = new coolify_hetzner_load_balancer_id_c(); + $this->hetzner_cloud_api_token = new coolify_hetzner_cloud_api_token_c(); + $this->public_gateway_host = new coolify_public_gateway_host_c(); + } + + public function getConfigRequest(): array + { + return array_map(static function (array $row): array { + if (($row['variable'] ?? '') === 'hetzner_cloud_api_token') { + $secretSet = trim((string)($row['value'] ?? '')) !== ''; + $row['value'] = $secretSet ? '[redacted]' : ''; + $row['secret_set'] = $secretSet; + } + return $row; + }, $this->traitGetConfigRequest()); + } +} diff --git a/services/nginx/app/modules/dynamicimages/images/machine_1.php b/services/nginx/app/modules/dynamicimages/images/machine_1.php index 434c06a0..b02ed759 100644 --- a/services/nginx/app/modules/dynamicimages/images/machine_1.php +++ b/services/nginx/app/modules/dynamicimages/images/machine_1.php @@ -17,6 +17,9 @@ class machine_1 extends dynamicimages_image const IMAGE_BUTTON_HIGHLIGHTED_GREY = 'machine_1_button_highlighted_grey.png'; const IMAGE_BUTTON_HIGHLIGHTED_COMPLETED = 'machine_1_button_highlighted_completed_grey.png'; const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png'; + const BUTTON_RESET = 'reset'; + const BUTTON_START = 'start'; + const BUTTON_PROGRAM_PICKER = 'program_picker'; // Thumb public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees) public int $thumb_size = 1550; // height and width of the thumb @@ -71,11 +74,9 @@ class machine_1 extends dynamicimages_image $this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0); $this->drawProgramWheel(); $this->drawThumb(); - $this->drawStepThumb(); - $this->drawHighlightedResetButton(); - $this->drawHighlightedButtons(); + $deferredStartButtons = $this->drawHighlightedButtonSequence(); $this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0); - $this->drawHighlightedStartButton(); + $this->drawDeferredHighlightedButtons($deferredStartButtons); $this->drawCropOutLine(true); } @@ -174,11 +175,211 @@ class machine_1 extends dynamicimages_image return self::IMAGE_BUTTON_HIGHLIGHTED_GREY; } + private function isHighlightedButton(int|string $button): bool + { + foreach ($this->highlighted_buttons as $highlightedButton) { + if (is_int($button)) { + if (is_numeric($highlightedButton) && (int)$highlightedButton === $button) { + return true; + } + continue; + } + + if (is_string($highlightedButton) && strtolower(trim($highlightedButton)) === $button) { + return true; + } + } + + return false; + } + + private function normalizeHighlightedButtonToken(mixed $button): int|string|null + { + if (is_string($button)) { + $trimmed = trim($button); + $specialButton = strtolower($trimmed); + if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START || $specialButton === self::BUTTON_PROGRAM_PICKER) { + return $specialButton; + } + + if ($trimmed !== '' && ctype_digit($trimmed)) { + $button = (int)$trimmed; + } + } + + if (is_int($button)) { + return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null; + } + + if (is_numeric($button) && (int)$button == $button) { + $button = (int)$button; + return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null; + } + + return null; + } + + private function getOrderedHighlightedButtonTokens(): array + { + $tokens = []; + foreach ($this->highlighted_buttons as $button) { + $token = $this->normalizeHighlightedButtonToken($button); + if ($token !== null) { + $tokens[] = $token; + } + } + + return $tokens; + } + + private function getRegularButtonCoordinates(int $buttonIndex): ?array + { + if ($buttonIndex < 0 || $buttonIndex >= ($this->button_rows * $this->button_columns)) { + return null; + } + + $row = intdiv($buttonIndex, $this->button_columns); + $col = $buttonIndex % $this->button_columns; + $x = 2815 + ($col * ($this->button_highlight_size + $this->button_columns_spacing)); + $y = 1265 + ($row * ($this->button_highlight_size + $this->button_rows_spacing)); + if ($row === $this->button_rows - 1) { + $y += $this->button_last_row_spacing_buffer; + } + + return [ + 'x' => $x, + 'y' => $y, + 'size' => $this->button_highlight_size, + ]; + } + + private function getHighlightedButtonCoordinates(int|string $button): ?array + { + if ($button === self::BUTTON_PROGRAM_PICKER) { + return $this->getProgramPickerStepCoordinates(); + } + + if ($button === self::BUTTON_RESET) { + return [ + 'x' => 1736, + 'y' => 599, + 'size' => $this->reset_button_highlight_size, + ]; + } + + if ($button === self::BUTTON_START) { + return [ + 'x' => 4965, + 'y' => 1980, + 'size' => $this->start_button_highlight_size, + ]; + } + + return is_int($button) ? $this->getRegularButtonCoordinates($button) : null; + } + + private function getProgramPickerStepCoordinates(): array + { + $thumbX = 650; + $thumbY = 1290; + $stepSize = 350; + $baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size); + $centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2); + $centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2); + $angle = $this->getRotationThumbPosition(); + $radius = ($this->thumb_size / 2) - ($stepSize / 2) - 150; + $rad = deg2rad($angle); + + return [ + 'x' => $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2), + 'y' => $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2), + 'size' => $stepSize, + ]; + } + + /** + * @throws \Exception + */ + private function buildHighlightedButtonDraw(int|string $button): ?array + { + $coordinates = $this->getHighlightedButtonCoordinates($button); + if ($coordinates === null) { + return null; + } + + $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); + $tmp->resize($coordinates['size'], $coordinates['size']); + $tmp = $this->drawStepCounterOnButton($tmp); + if ($this->only_generate_current_step && $this->button_counter != $this->current_step + 1) { + if (method_exists($tmp, 'clearMemoryImage')) { + $tmp->clearMemoryImage(); + } + return null; + } + + return [ + 'asset' => $tmp, + 'x' => $coordinates['x'], + 'y' => $coordinates['y'], + ]; + } + + /** + * @throws \Exception + */ + private function drawHighlightedButtonDraw(array $draw): void + { + $tmp = $draw['asset']; + $this->drawAsset($tmp, (int)$draw['x'], (int)$draw['y']); + if (method_exists($tmp, 'clearMemoryImage')) { + $tmp->clearMemoryImage(); + } + } + + /** + * @return array + * @throws \Exception + */ + public function drawHighlightedButtonSequence(): array + { + $deferredStartButtons = []; + foreach ($this->getOrderedHighlightedButtonTokens() as $button) { + $draw = $this->buildHighlightedButtonDraw($button); + if ($draw === null) { + continue; + } + + if ($button === self::BUTTON_START) { + $deferredStartButtons[] = $draw; + continue; + } + + $this->drawHighlightedButtonDraw($draw); + } + + return $deferredStartButtons; + } + + /** + * @param array $deferredStartButtons + * @throws \Exception + */ + public function drawDeferredHighlightedButtons(array $deferredStartButtons): void + { + foreach ($deferredStartButtons as $draw) { + $this->drawHighlightedButtonDraw($draw); + } + } + /** * @throws \Exception */ public function drawHighlightedStartButton(): void { + if (!$this->isHighlightedButton(self::BUTTON_START)) { + return; + } + $x = 4965; // X position for the start button $y = 1980; // Y position for the start button $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); @@ -200,6 +401,10 @@ class machine_1 extends dynamicimages_image */ public function drawHighlightedResetButton(): void { + if (!$this->isHighlightedButton(self::BUTTON_RESET)) { + return; + } + $x = 1736; // X position for the reset button $y = 599; // Y position for the reset button $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); @@ -404,7 +609,7 @@ class machine_1 extends dynamicimages_image for ($col = 0; $col < $this->button_columns; $col++) { // If the current button position is not in the highlighted buttons array, skip it $buttonIndex = $row * $this->button_columns + $col; - if (!in_array($buttonIndex, $this->highlighted_buttons, true)) { + if (!$this->isHighlightedButton($buttonIndex)) { continue; } // Calculate the position for the current button @@ -517,16 +722,12 @@ class machine_1 extends dynamicimages_image $rad = deg2rad($angle); $stepX = $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2); $stepY = $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2); - $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); + $tmp = new dynamicimages_asset($this->getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED_BLUE)); $tmp->resize($stepSize, $stepSize); - $tmp = $this->drawStepCounterOnButton($tmp); - if ($this->only_generate_current_step && $this->button_counter != $this->current_step +1) { - return; - } $this->drawAsset($tmp, $stepX, $stepY); // Free memory used by temporary asset image, if any if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/economic/config/economic_default_department_id_c.php b/services/nginx/app/modules/economic/config/economic_default_department_id_c.php new file mode 100644 index 00000000..994537e4 --- /dev/null +++ b/services/nginx/app/modules/economic/config/economic_default_department_id_c.php @@ -0,0 +1,28 @@ +send_request($url, 'GET', ''); return json_decode($response); } @@ -47,6 +47,10 @@ class economicCustomers extends economic_m public function getCustomerId(int $customerNumber): object|bool { + if ($customerNumber <= 0) { + return false; + } + // Check if the customer exists $url = '/customers/' . $customerNumber; $response = $this->send_request($url, 'GET', ''); @@ -69,17 +73,58 @@ class economicCustomers extends economic_m if ($customer_number === 0) { return 0; } - // Get the customer products - $products = $this->getCustomerProducts($customer_number, 1)->collection; - $discount = $this->getCustomerProductDiscount($customer_number, $products[0]->product->productNumber); - // Since the discount is global, we only need to get the discount for one product - return $discount->discountPercentage ?? 0; + // The discount is global, but e-conomic resolves it through a product-specific + // invoice-line template. For foreign-currency customers some templates can fail + // if that product has no price in the customer currency, so try a few products + // before falling back to zero. + $products = $this->getCustomerProducts($customer_number, 10); + foreach ($this->extractCustomerProductNumbers($products) as $product_number) { + try { + $discount = $this->getCustomerProductDiscount($customer_number, $product_number); + return (int)($discount->discountPercentage ?? 0); + } catch (\RuntimeException $exception) { + if (!$this->isMissingCurrencyPriceLookupError($exception)) { + throw $exception; + } + } + } + + return 0; + } + + /** + * @return int[] + */ + private function extractCustomerProductNumbers(object $products): array + { + if (!isset($products->collection) || !is_array($products->collection)) { + return []; + } + + $product_numbers = []; + foreach ( $products->collection as $product ) { + $product_number = $product->product->productNumber ?? null; + if ($product_number === null || $product_number === '') { + continue; + } + $product_numbers[] = (int)$product_number; + } + + return array_values(array_unique($product_numbers)); + } + + private function isMissingCurrencyPriceLookupError(\RuntimeException $exception): bool + { + $message = $exception->getMessage(); + + return str_contains($message, 'No price in currency') + && str_contains($message, 'can be found for the product'); } public function getCustomerProducts(int $customer_number, int $limit = 10, int $page = 1): object { // Get the customer products - $url = '/customers/' . $customer_number . '/templates/invoiceline/?pagesize=' . $limit . '&skippages=' . $page - 1; + $url = '/customers/' . $customer_number . '/templates/invoiceline/?pagesize=' . $limit . '&skippages=' . ($page - 1); $response = $this->send_request($url, 'GET', ''); return json_decode($response); } @@ -98,10 +143,11 @@ class economicCustomers extends economic_m * @param int $page * @param int $limit * @param string|null $search + * @param mixed $barred_filter Supports true/false values (bool, 1/0, true/false, barred/active) * @return object The list of customers * @throws Exception */ - public function listCustomers(int $page, int $limit, string|null $search = null): object + public function listCustomers(int $page, int $limit, string|null $search = null, mixed $barred_filter = null): object { // Normalize pagination parameters $page = max(1, $page); // Ensure it's at least 1 @@ -116,6 +162,8 @@ class economicCustomers extends economic_m 'city', 'country', 'email', 'telephoneAndFaxNumber', 'website', 'mobilePhone', 'corporateIdentificationNumber' ]; + $filter_parts = []; + // If a search term is present, build the filter expressions if (!empty($search)) { // Escape special characters in the search string @@ -131,11 +179,21 @@ class economicCustomers extends economic_m $filters[] = $property . '$like:' . $escapedSearch; } - // Join the filters with `$or:` - $filterString = implode('$or:', $filters); + // Join the filters with `$or:` and keep grouping explicit for later $and composition + $filter_parts[] = '(' . implode('$or:', $filters) . ')'; + } - // URL encode and append the filter string - $url .= '&filter=' . urlencode($filterString); + // Optional barred filter support (all | true/barred | false/active) + $normalized_barred_filter = $this->normalizeBarredFilter($barred_filter); + if ($normalized_barred_filter !== null) { + $filter_parts[] = 'barred$eq:' . ($normalized_barred_filter ? 'true' : 'false'); + } + + if (!empty($filter_parts)) { + $filter_string = count($filter_parts) === 1 + ? $filter_parts[0] + : '(' . implode('$and:', $filter_parts) . ')'; + $url .= '&filter=' . urlencode($filter_string); } // Send the GET request to the API endpoint @@ -146,8 +204,49 @@ class economicCustomers extends economic_m if ($responseObject === null) { throw new Exception('Failed to decode the response from the API'); } + + if (!is_object($responseObject)) { + throw new \RuntimeException('Malformed e-conomic customers response: expected JSON object.'); + } + + if (!isset($responseObject->collection) || !is_array($responseObject->collection)) { + $upstreamMessage = isset($responseObject->message) && is_string($responseObject->message) + ? trim($responseObject->message) + : 'Missing collection.'; + throw new \RuntimeException('Malformed e-conomic customers response: ' . $upstreamMessage); + } + + if (!isset($responseObject->pagination) || !is_object($responseObject->pagination)) { + throw new \RuntimeException('Malformed e-conomic customers response: missing pagination.'); + } + + if (!isset($responseObject->pagination->results) || !is_numeric($responseObject->pagination->results)) { + throw new \RuntimeException('Malformed e-conomic customers response: missing pagination results.'); + } + + $responseObject->pagination->results = (int)$responseObject->pagination->results; + return $responseObject; } + private function normalizeBarredFilter(mixed $value): ?bool + { + if ($value === null || $value === '') { + return null; + } + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return $value === 1 ? true : ($value === 0 ? false : null); + } + $parsed = strtolower(trim((string)$value)); + return match ($parsed) { + '1', 'true', 'yes', 'barred', 'only_barred' => true, + '0', 'false', 'no', 'active', 'not_barred' => false, + default => null, + }; + } -} \ No newline at end of file + +} diff --git a/services/nginx/app/modules/economic/customers/economic_customer_mo.php b/services/nginx/app/modules/economic/customers/economic_customer_mo.php index 4f3b0735..c77c6cef 100644 --- a/services/nginx/app/modules/economic/customers/economic_customer_mo.php +++ b/services/nginx/app/modules/economic/customers/economic_customer_mo.php @@ -2,7 +2,6 @@ namespace customers; -use classes\response; use objects\users_o; class economic_customer_mo @@ -33,11 +32,14 @@ class economic_customer_mo } - public function parseCustomer($customer): static + public function parseCustomer(mixed $customer): static { - global /** @var response $response */ - $response; - $this->customer_number = $customer->customerNumber; + $customer_number = $this->extractCustomerNumber($customer); + if ($customer_number === null) { + return $this; + } + + $this->customer_number = $customer_number; $this->name = ($customer->name ?? null); $this->address = ($customer->address ?? null); $this->city = ($customer->city ?? null); @@ -48,6 +50,33 @@ class economic_customer_mo $this->currency = ($customer->currency ?? null); $this->country = ($customer->country ?? null); $this->barred = (isset($customer->barred) ? (bool)$customer->barred : false); + $this->cacheCustomerForImportedUser(); + // Add the customer to the debug log (IF DEBUG IS ENABLED) + //$response->add_debug_list('economic_customer', $this->customer_number, $customer); + return $this; + } + + protected function extractCustomerNumber(mixed $customer): ?int + { + if (!is_object($customer)) { + return null; + } + + $customer_number = $customer->customerNumber ?? $customer->customer_number ?? null; + if (!is_int($customer_number) && !is_float($customer_number) && !is_string($customer_number)) { + return null; + } + + $customer_number = (int)$customer_number; + if ($customer_number <= 0) { + return null; + } + + return $customer_number; + } + + protected function cacheCustomerForImportedUser(): void + { $users_o = new users_o(); $user = $users_o->getUserByCustomerNumber($this->customer_number); @@ -55,9 +84,6 @@ class economic_customer_mo if ($user->exists()) { $user->cache('economic_customer', $this); } - // Add the customer to the debug log (IF DEBUG IS ENABLED) - //$response->add_debug_list('economic_customer', $this->customer_number, $customer); - return $this; } public function asArray(): array @@ -80,4 +106,4 @@ class economic_customer_mo 'barred' => $this->barred, ]; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/economic/economic_c.php b/services/nginx/app/modules/economic/economic_c.php index 212359bd..50ef0b88 100644 --- a/services/nginx/app/modules/economic/economic_c.php +++ b/services/nginx/app/modules/economic/economic_c.php @@ -4,12 +4,16 @@ 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'; +require_once WD . '/modules/economic/config/economic_default_department_id_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_default_department_id_c; +use config\economic_transaction_draft_customer_number_c; use traits\module_config_t; class economic_c @@ -21,6 +25,8 @@ 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_default_department_id_c $default_department_id; + public economic_transaction_draft_customer_number_c $transaction_draft_customer_number; public function __construct() { @@ -30,12 +36,16 @@ 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_default_department_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->default_department_id = new economic_default_department_id_c(); + $this->transaction_draft_customer_number = new economic_transaction_draft_customer_number_c(); } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/economic/economic_m.php b/services/nginx/app/modules/economic/economic_m.php index 5c0bbf4c..5a8594c7 100644 --- a/services/nginx/app/modules/economic/economic_m.php +++ b/services/nginx/app/modules/economic/economic_m.php @@ -12,16 +12,56 @@ class economic_m private string $appAccessGrant; private string $app_token; private string $appAccessGrant2; + private static bool $reportedMissingRequiredCredentials = false; + private static bool $reportedMissingGrant2 = false; public function __construct() { global $ECONOMIC_API; - $this->app_token = $ECONOMIC_API['app_secret_token']; - $this->appAccessGrant = $ECONOMIC_API['app_access_grant']; - $this->appAccessGrant2 = $ECONOMIC_API['app_access_grant2']; + $this->app_token = (string)($ECONOMIC_API['app_secret_token'] ?? ''); + $this->appAccessGrant = (string)($ECONOMIC_API['app_access_grant'] ?? ''); + $this->appAccessGrant2 = (string)($ECONOMIC_API['app_access_grant2'] ?? ''); $this->economic = new economic(); } + protected function resolve_app_secret_token(): string + { + $appSecretToken = trim($this->app_token); + if ($appSecretToken === '') { + if (!self::$reportedMissingRequiredCredentials) { + error_log('[economic_m] Missing ECONOMIC_API_APP_SECRET_TOKEN. e-conomic requests will fail until configuration is fixed.'); + self::$reportedMissingRequiredCredentials = true; + } + throw new \RuntimeException('Missing e-conomic app secret token. Set ECONOMIC_API_APP_SECRET_TOKEN and recreate php containers.'); + } + return $appSecretToken; + } + + protected function resolve_agreement_grant_token(bool $authToken2): string + { + $primaryGrant = trim($this->appAccessGrant); + $secondaryGrant = trim($this->appAccessGrant2); + + if ($primaryGrant === '') { + if (!self::$reportedMissingRequiredCredentials) { + error_log('[economic_m] Missing ECONOMIC_API_APP_ACCESS_GRANT. e-conomic requests will fail until configuration is fixed.'); + self::$reportedMissingRequiredCredentials = true; + } + throw new \RuntimeException('Missing e-conomic agreement grant token. Set ECONOMIC_API_APP_ACCESS_GRANT and recreate php containers.'); + } + + if ($authToken2 && $secondaryGrant !== '') { + return $secondaryGrant; + } + + if ($authToken2 && $secondaryGrant === '' && !self::$reportedMissingGrant2) { + error_log('[economic_m] ECONOMIC_API_APP_ACCESS_GRANT2 is missing. Falling back to ECONOMIC_API_APP_ACCESS_GRANT.'); + self::$reportedMissingGrant2 = true; + } + + return $primaryGrant; + } + /** * Allowed search filters * @return array @@ -43,19 +83,23 @@ class economic_m */ protected function send_request($url, $method, $data = '', bool $authToken2 = false): string { + $appSecretToken = $this->resolve_app_secret_token(); + $agreementGrantToken = $this->resolve_agreement_grant_token($authToken2); + $curl = curl_init(); curl_setopt_array($curl, array( CURLOPT_URL => $this->api_url . $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 0, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 30, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => array( - 'X-AppSecretToken: ' . $this->app_token, - 'X-AgreementGrantToken: ' . ($authToken2 ? $this->appAccessGrant2 : $this->appAccessGrant), + 'X-AppSecretToken: ' . $appSecretToken, + 'X-AgreementGrantToken: ' . $agreementGrantToken, 'Content-Type: application/json' ), )); @@ -65,7 +109,79 @@ class economic_m } $response = curl_exec($curl); + if ($response === false) { + $error = curl_error($curl); + curl_close($curl); + throw new \RuntimeException('Curl error: ' . $error); + } + $httpStatusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); + + return $this->assert_successful_response($httpStatusCode, $response); + } + + /** + * Validate e-conomic HTTP responses and convert upstream errors into deterministic runtime exceptions. + */ + protected function assert_successful_response(int $httpStatusCode, string|false $response): string + { + if ($response === false) { + throw new \RuntimeException('e-conomic request failed without a response body.'); + } + + if ($httpStatusCode >= 400) { + throw new \RuntimeException($this->format_upstream_error_message($httpStatusCode, $response)); + } + return $response; } -} \ No newline at end of file + + /** + * Build a sanitized error message from a non-2xx e-conomic response body. + */ + protected function format_upstream_error_message(int $httpStatusCode, string $response): string + { + $prefix = 'e-conomic request failed with HTTP ' . $httpStatusCode; + $decoded = json_decode($response, true); + + if (!is_array($decoded)) { + return $prefix . '.'; + } + + $message = isset($decoded['message']) && is_string($decoded['message']) + ? trim($decoded['message']) + : 'Upstream e-conomic error'; + + $details = []; + + if (isset($decoded['errors']) && is_array($decoded['errors'])) { + $safeErrors = []; + foreach ( $decoded['errors'] as $error ) { + if (is_scalar($error)) { + $safeErrors[] = (string)$error; + } + } + if (!empty($safeErrors)) { + $details['errors'] = $safeErrors; + } + } + + if (isset($decoded['logId']) && is_scalar($decoded['logId'])) { + $details['logId'] = (string)$decoded['logId']; + } + + if (isset($decoded['httpStatusCode']) && is_numeric($decoded['httpStatusCode'])) { + $details['httpStatusCode'] = (int)$decoded['httpStatusCode']; + } + + if (empty($details)) { + return $prefix . ': ' . $message; + } + + return $prefix + . ': ' + . $message + . ' | details=' + . json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } +} diff --git a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_booked_endpoint.php b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_booked_endpoint.php index b5dfecae..27f59528 100644 --- a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_booked_endpoint.php +++ b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_booked_endpoint.php @@ -9,6 +9,10 @@ class economic_invoices_booked_endpoint { use economic_endpoint_t; + private const Q2C_API_URL = 'https://apis.e-conomic.com/q2capi/v5.1.0'; + private const BULK_INVOICE_LINE_BATCH_SIZE = 200; + private const INVOICE_LINE_REQUEST_CONCURRENCY = 50; + /** * List sent invoices * @param string $filter Set the filter using the self::filter method @@ -32,15 +36,148 @@ class economic_invoices_booked_endpoint */ public function get_invoice_lines(array $invoice_ids, array $filters = []): array { - $tmp_invoices = []; - foreach ( $invoice_ids as $invoice ) { - $response = $this->send_request( - '/invoices/booked/' . $invoice . '/?filter=' . self::filters($filters), - 'GET' - ); - $tmp_invoices[$invoice] = json_decode($response)->lines; + $invoice_ids = array_values(array_unique(array_filter(array_map('intval', $invoice_ids), static fn(int $invoice_id): bool => $invoice_id > 0))); + if (empty($invoice_ids)) { + return []; } - return $tmp_invoices; + + if (!empty($filters)) { + return $this->get_invoice_lines_via_detail_requests($invoice_ids, $filters); + } + + return $this->get_invoice_lines_via_bulk_api($invoice_ids); + } + + private function get_invoice_lines_via_bulk_api(array $invoice_ids): array + { + $invoice_lines = array_fill_keys($invoice_ids, []); + + foreach (array_chunk($invoice_ids, self::BULK_INVOICE_LINE_BATCH_SIZE) as $batch) { + $cursor = null; + + do { + $url = '/invoices/booked/lines?filter=documentId$in:[' . implode(',', $batch) . ']'; + if ($cursor !== null) { + $url .= '&cursor=' . $cursor; + } + + $handle = $this->create_curl_handle_for_base_url(self::Q2C_API_URL, $url, 'GET'); + $response = curl_exec($handle); + if (curl_errno($handle)) { + $error = curl_error($handle); + curl_close($handle); + throw new \RuntimeException('Curl error while fetching bulk booked invoice lines: ' . $error); + } + curl_close($handle); + $decoded = json_decode($response); + + if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Invalid bulk booked invoice lines JSON: ' . json_last_error_msg()); + } + + if (isset($decoded->errorCode) || isset($decoded->message)) { + throw new \RuntimeException((string)($decoded->message ?? 'Failed to fetch bulk booked invoice lines.')); + } + + foreach ((array)($decoded->items ?? []) as $line) { + $invoice_id = (int)($line->documentId ?? 0); + if (!isset($invoice_lines[$invoice_id])) { + continue; + } + + $invoice_lines[$invoice_id][] = $line; + } + + $cursor = isset($decoded->cursor) && $decoded->cursor !== '' ? (string)$decoded->cursor : null; + } while ($cursor !== null); + } + + ksort($invoice_lines); + return $invoice_lines; + } + + private function get_invoice_lines_via_detail_requests(array $invoice_ids, array $filters = []): array + { + $filter_query = self::filters($filters); + $multi_handle = curl_multi_init(); + $invoice_lines = []; + $handles = []; + $invoice_by_handle = []; + $next_index = 0; + + try { + if (defined('CURLMOPT_PIPELINING') && defined('CURLPIPE_MULTIPLEX')) { + curl_multi_setopt($multi_handle, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX); + } + if (defined('CURLMOPT_MAX_HOST_CONNECTIONS')) { + curl_multi_setopt($multi_handle, CURLMOPT_MAX_HOST_CONNECTIONS, self::INVOICE_LINE_REQUEST_CONCURRENCY); + } + if (defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')) { + curl_multi_setopt($multi_handle, CURLMOPT_MAX_TOTAL_CONNECTIONS, self::INVOICE_LINE_REQUEST_CONCURRENCY); + } + + do { + while ($next_index < count($invoice_ids) && count($handles) < self::INVOICE_LINE_REQUEST_CONCURRENCY) { + $invoice_id = $invoice_ids[$next_index++]; + $handle = $this->create_curl_handle( + '/invoices/booked/' . $invoice_id . '/?filter=' . $filter_query, + 'GET' + ); + + $handle_id = spl_object_id($handle); + $handles[$handle_id] = $handle; + $invoice_by_handle[$handle_id] = $invoice_id; + curl_multi_add_handle($multi_handle, $handle); + } + + do { + $multi_status = curl_multi_exec($multi_handle, $running_handles); + } while ($multi_status === CURLM_CALL_MULTI_PERFORM); + + if ($multi_status !== CURLM_OK) { + throw new \RuntimeException('Curl multi error: ' . curl_multi_strerror($multi_status)); + } + + while ($completed = curl_multi_info_read($multi_handle)) { + $handle = $completed['handle']; + $handle_id = spl_object_id($handle); + $invoice_id = (int)($invoice_by_handle[$handle_id] ?? 0); + + if ($completed['result'] !== CURLE_OK) { + throw new \RuntimeException( + 'Curl error for booked invoice ' . $invoice_id . ': ' . curl_error($handle) + ); + } + + $response = curl_multi_getcontent($handle); + $decoded = json_decode($response); + if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException( + 'Invalid JSON for booked invoice ' . $invoice_id . ': ' . json_last_error_msg() + ); + } + + $invoice_lines[$invoice_id] = is_array($decoded->lines ?? null) ? $decoded->lines : []; + + curl_multi_remove_handle($multi_handle, $handle); + curl_close($handle); + unset($handles[$handle_id], $invoice_by_handle[$handle_id]); + } + + if (($running_handles > 0 || $next_index < count($invoice_ids)) && curl_multi_select($multi_handle, 1.0) === -1) { + usleep(10000); + } + } while ($running_handles > 0 || $next_index < count($invoice_ids)); + } finally { + foreach ($handles as $handle) { + curl_multi_remove_handle($multi_handle, $handle); + curl_close($handle); + } + curl_multi_close($multi_handle); + } + + ksort($invoice_lines); + return $invoice_lines; } /** @@ -76,4 +213,4 @@ class economic_invoices_booked_endpoint return json_decode($response); } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php index c1156559..76c99160 100644 --- a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php +++ b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php @@ -226,6 +226,9 @@ class economic_invoice_draft $total_discount = 0; // Loop through the order items foreach ( $order_items as $order_item ) { + if ($this->shouldSkipOrderItemLine($order_item)) { + continue; + } // Add the order item to the draft invoice self::addOrderItemLine($order_item, $department); // Add the line discount to the total discount @@ -254,6 +257,9 @@ class economic_invoice_draft if (!isset($order_item['id'])) { throw new Exception('The order item is not valid'); } + if ($this->shouldSkipOrderItemLine($order_item)) { + return; + } // Get the department id $economic_department_id = $department['economic_department_id'] ?? 0; // Get the dimension id @@ -309,6 +315,25 @@ class economic_invoice_draft } + /** + * Skip line when quantity is zero/negative, or final unit price is zero. + */ + private function shouldSkipOrderItemLine(array $order_item): bool + { + $quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity']) + ? (float)$order_item['quantity'] + : 0.0; + if ($quantity <= 0.0) { + return true; + } + + $price = isset($order_item['price']) && is_numeric($order_item['price']) + ? (float)$order_item['price'] + : 0.0; + + return abs($price) < 0.00001; + } + /** * Add a product line to the draft invoice * @note The lines won't be saved until the addLines() method is called. @@ -428,4 +453,4 @@ class economic_invoice_draft throw new Exception('The draft invoice data is not set'); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_agent_artifact_locator.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_agent_artifact_locator.php new file mode 100644 index 00000000..21a8de68 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_agent_artifact_locator.php @@ -0,0 +1,145 @@ + + */ + 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; + } +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php new file mode 100644 index 00000000..fe05f23b --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php @@ -0,0 +1,931 @@ +> + * @throws Exception + */ + public function listDepartmentSummaries(): array + { + $departments = (new departments_o())->list(true); + usort($departments, static function (array $left, array $right): int { + $leftPriority = (int)($left['order_priority'] ?? PHP_INT_MAX); + $rightPriority = (int)($right['order_priority'] ?? PHP_INT_MAX); + if ($leftPriority !== $rightPriority) { + return $leftPriority <=> $rightPriority; + } + + return (int)($left['id'] ?? 0) <=> (int)($right['id'] ?? 0); + }); + + $summaries = []; + foreach ($departments as $departmentRow) { + $departmentId = (int)($departmentRow['id'] ?? 0); + if ($departmentId <= 0) { + continue; + } + + try { + $workspace = $this->buildDepartmentWorkspace($departmentId, false, $departmentRow); + } catch (Exception $exception) { + if ($exception->getMessage() === 'Department not found') { + $this->manager()->deleteOrphanedGatewaysForDepartment($departmentId); + $this->clearDepartmentCache(); + continue; + } + + throw $exception; + } + + $summaries[] = $workspace['summary']; + } + + return $summaries; + } + + /** + * @return array + * @throws Exception + */ + public function getDepartmentWorkspace(int $departmentId): array + { + return $this->buildDepartmentWorkspace($departmentId, true); + } + + /** + * @param array|null $departmentRow + * @return array + * @throws Exception + */ + private function buildDepartmentWorkspace(int $departmentId, bool $includeGateways, ?array $departmentRow = null): array + { + $department = (new departments_o())->select($departmentId); + if (!$department->exists()) { + throw new Exception('Department not found'); + } + + $departmentPayload = [ + 'id' => $departmentId, + 'name' => (string)$department->name->value(), + 'description' => (string)$department->description->value(), + 'order_priority' => (int)$department->order_priority->value(), + ]; + + $transportMode = $this->manager()->getDepartmentTransportMode($departmentId); + $gateways = $this->manager()->listGateways($departmentId, true); + $bindingsByRelayId = $this->indexBindingsByRelayId($gateways); + $relayCatalog = $this->indexRelayCatalog($departmentId); + $consumersByRelayId = []; + + $lanes = $this->buildLanePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId); + $gates = $this->buildGatePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId); + $relays = $this->buildRelayPayloads($relayCatalog, $bindingsByRelayId, $consumersByRelayId); + $gateways = $this->applyBindingConsumerContexts($gateways, $consumersByRelayId); + $selfServe = $this->buildSelfServePayload($department, $lanes); + $scanners = $this->buildScannerPayloads($departmentId, $lanes); + $issues = $this->buildIssues($transportMode, $gateways, $lanes, $gates, $scanners, $selfServe); + $actions = $this->buildActions($departmentId, $gateways, $lanes, $gates, $scanners, $selfServe); + $summary = $this->buildSummary( + $departmentPayload, + $departmentRow, + $transportMode, + $gateways, + $lanes, + $gates, + $scanners, + $selfServe, + $issues + ); + + return [ + 'department' => $departmentPayload, + 'summary' => $summary, + 'gateways' => $includeGateways ? $gateways : [], + 'lanes' => $lanes, + 'self_serve' => $selfServe, + 'gates' => $gates, + 'relays' => $relays, + 'scanners' => $scanners, + 'issues' => $issues, + 'actions' => $actions, + ]; + } + + /** + * @param array> $gateways + * @return array>> + */ + private function indexBindingsByRelayId(array $gateways): array + { + $bindingsByRelayId = []; + + foreach ($gateways as $gateway) { + $bindings = isset($gateway['bindings']) && is_array($gateway['bindings']) + ? (array)$gateway['bindings'] + : []; + + foreach ($bindings as $binding) { + if (!is_array($binding)) { + continue; + } + + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + + $binding['gateway_label'] = (string)($gateway['label'] ?? ('Gateway ' . ($gateway['id'] ?? ''))); + $binding['gateway_status'] = (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE); + $binding['is_primary_gateway'] = (bool)($gateway['is_primary'] ?? false); + $bindingsByRelayId[$relayId][] = $binding; + } + } + + foreach ($bindingsByRelayId as $relayId => $bindings) { + usort($bindings, static function (array $left, array $right): int { + return ((int)($right['is_primary_gateway'] ?? 0) <=> (int)($left['is_primary_gateway'] ?? 0)) + ?: ((int)($left['gateway_id'] ?? 0) <=> (int)($right['gateway_id'] ?? 0)); + }); + $bindingsByRelayId[$relayId] = $bindings; + } + + return $bindingsByRelayId; + } + + /** + * @return array> + * @throws Exception + */ + private function indexRelayCatalog(int $departmentId): array + { + $catalog = []; + foreach ((new department_relays_o())->getDepartmentRelays($departmentId) as $relay) { + if (!$relay->exists()) { + continue; + } + + $relayId = trim((string)$relay->relay_id->value()); + if ($relayId === '') { + continue; + } + + $catalog[$relayId] = $relay->asArray(); + } + + return $catalog; + } + + /** + * @param array>> $bindingsByRelayId + * @param array> $relayCatalog + * @param array>> $consumersByRelayId + * @return array> + * @throws Exception + */ + private function buildLanePayloads( + int $departmentId, + array $bindingsByRelayId, + array $relayCatalog, + array &$consumersByRelayId + ): array { + $lanes = []; + $slotMap = [ + 'relay_in_id' => 'ENTRY', + 'relay_out_id' => 'EXIT', + 'relay_machine_id' => 'MACHINE', + 'relay_machine_program_picker_id' => 'PROGRAM_PICKER', + 'relay_machine_cleaner_id' => 'CLEANER', + ]; + + foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) { + if (!$lane->exists()) { + continue; + } + + $relaySlots = []; + $boundRelayCount = 0; + foreach ($slotMap as $property => $slotName) { + $relayId = trim((string)$lane->{$property}->value()); + if ($relayId === '') { + continue; + } + + $consumersByRelayId[$relayId][] = [ + 'type' => 'lane', + 'id' => (int)$lane->id, + 'slot' => $slotName, + 'label' => (string)$lane->name->value(), + ]; + + $coverage = $this->buildRelayCoverage($relayId, $bindingsByRelayId); + if ((bool)($coverage['covered'] ?? false)) { + $boundRelayCount += 1; + } + + $relaySlots[] = [ + 'slot' => $slotName, + 'relay_id' => $relayId, + 'catalog' => $relayCatalog[$relayId] ?? null, + 'coverage' => $coverage, + ]; + } + + $requiredRelayCount = count($relaySlots); + $laneStatus = 'UNKNOWN'; + try { + $laneStatus = (string)$lane->getLaneStatus()->name; + } catch (\Throwable) { + } + + $lanes[] = [ + 'id' => (int)$lane->id, + 'department' => (int)$lane->department->value(), + 'name' => (string)$lane->name->value(), + 'relay_in_id' => $lane->relay_in_id->value() === null ? null : (string)$lane->relay_in_id->value(), + 'relay_out_id' => $lane->relay_out_id->value() === null ? null : (string)$lane->relay_out_id->value(), + 'relay_machine_id' => $lane->relay_machine_id->value() === null ? null : (string)$lane->relay_machine_id->value(), + 'relay_machine_program_picker_id' => $lane->relay_machine_program_picker_id->value() === null ? null : (string)$lane->relay_machine_program_picker_id->value(), + 'relay_machine_cleaner_id' => $lane->relay_machine_cleaner_id->value() === null ? null : (string)$lane->relay_machine_cleaner_id->value(), + 'dynamic_image_id' => $lane->dynamic_image_id->value() === null ? null : (int)$lane->dynamic_image_id->value(), + 'machine_type_id' => $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(), + 'selfserve_enabled' => $lane->isSelfServeEnabled(), + 'status' => $laneStatus, + 'self_serve_products' => $lane->getSelfServeLaneProducts(), + 'relay_slots' => $relaySlots, + 'binding_coverage' => [ + 'required' => $requiredRelayCount, + 'bound' => $boundRelayCount, + 'missing' => max(0, $requiredRelayCount - $boundRelayCount), + 'state' => $requiredRelayCount === 0 + ? 'NOT_REQUIRED' + : ($boundRelayCount === $requiredRelayCount ? 'READY' : 'MISSING'), + ], + 'links' => [ + 'legacy' => '/superuser/department/lanes/' . (int)$lane->id, + 'self_serve_studio' => '/admin/' . $departmentId . '/modules/self-serve/studio', + ], + ]; + } + + return $lanes; + } + + /** + * @param array>> $bindingsByRelayId + * @param array> $relayCatalog + * @param array>> $consumersByRelayId + * @return array> + * @throws Exception + */ + private function buildGatePayloads( + int $departmentId, + array $bindingsByRelayId, + array $relayCatalog, + array &$consumersByRelayId + ): array { + $gates = []; + + foreach ((new department_gates_o())->getDepartmentGates($departmentId) as $gate) { + if (!$gate->exists()) { + continue; + } + + $config = (array)$gate->config->value(); + $gateType = strtoupper(trim((string)($config['type'] ?? 'UNKNOWN'))); + $relayId = trim((string)($config['relay_id'] ?? '')); + + if ($gateType === 'RELAY' && $relayId !== '') { + $consumersByRelayId[$relayId][] = [ + 'type' => 'gate', + 'id' => (int)$gate->id, + 'slot' => ((bool)$gate->is_entrance->value() ? 'ENTRANCE' : ((bool)$gate->is_exit->value() ? 'EXIT' : 'GENERAL')), + 'label' => (string)$gate->name->value(), + ]; + } + + $coverage = $gateType === 'RELAY' && $relayId !== '' + ? $this->buildRelayCoverage($relayId, $bindingsByRelayId) + : null; + + $gates[] = [ + 'id' => (int)$gate->id, + 'department' => (int)$gate->department->value(), + 'name' => (string)$gate->name->value(), + 'is_entrance' => (bool)$gate->is_entrance->value(), + 'is_exit' => (bool)$gate->is_exit->value(), + 'config' => $config, + 'transport_type' => $gateType, + 'config_complete' => $this->isGateConfigComplete($config), + 'relay' => $relayId !== '' ? ($relayCatalog[$relayId] ?? ['relay_id' => $relayId]) : null, + 'coverage' => $coverage, + ]; + } + + return $gates; + } + + /** + * @param array> $relayCatalog + * @param array>> $bindingsByRelayId + * @param array>> $consumersByRelayId + * @return array> + */ + private function buildRelayPayloads( + array $relayCatalog, + array $bindingsByRelayId, + array $consumersByRelayId + ): array { + $relays = []; + + foreach ($relayCatalog as $relayId => $relay) { + $config = isset($relay['config']) && is_array($relay['config']) + ? (array)$relay['config'] + : []; + + $relays[] = [ + 'id' => isset($relay['id']) ? (int)$relay['id'] : 0, + 'department' => isset($relay['department']) ? (int)$relay['department'] : 0, + 'relay_id' => (string)($relay['relay_id'] ?? $relayId), + 'name' => (string)($relay['name'] ?? $relayId), + 'type' => (string)($relay['type'] ?? ''), + 'config' => $config, + 'coverage' => $this->buildRelayCoverage((string)$relayId, $bindingsByRelayId), + 'consumer_contexts' => $consumersByRelayId[(string)$relayId] ?? [], + ]; + } + + return $relays; + } + + /** + * @param array> $lanes + * @return array + * @throws Exception + */ + private function buildSelfServePayload(departments_o $department, array $lanes): array + { + $enabled = false; + try { + $enabled = $department->getSelfServeEnabled(); + } catch (\Throwable) { + } + + $enabledLanes = array_values(array_filter($lanes, static function (array $lane): bool { + return ($lane['selfserve_enabled'] ?? true) !== false; + })); + $readyLanes = array_values(array_filter($enabledLanes, static function (array $lane): bool { + return (string)($lane['binding_coverage']['state'] ?? 'UNKNOWN') === 'READY'; + })); + + $taskRows = (new \objects\department_selfserve_tasks_o())->getFieldsWhere([ + 'department' => (int)$department->id, + 'deleted_at' => null, + ], ['id', 'lane', 'product']); + + $productIds = []; + foreach ($taskRows as $taskRow) { + if (isset($taskRow['product'])) { + $productIds[(int)$taskRow['product']] = true; + } + } + + return [ + 'enabled' => $enabled, + 'lane_count' => count($lanes), + 'enabled_lanes' => count($enabledLanes), + 'ready_lanes' => count($readyLanes), + 'configured_task_count' => count($taskRows), + 'configured_product_count' => count($productIds), + 'readiness_state' => !$enabled + ? 'DISABLED' + : (count($enabledLanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($enabledLanes) ? 'READY' : 'PARTIAL')), + 'links' => [ + 'studio' => '/admin/' . (int)$department->id . '/modules/self-serve/studio', + 'legacy' => '/superuser/selfserve', + ], + ]; + } + + /** + * @param array> $lanes + * @return array> + * @throws Exception + */ + private function buildScannerPayloads(int $departmentId, array $lanes): array + { + $laneIndex = []; + foreach ($lanes as $lane) { + $laneIndex[(int)$lane['id']] = $lane; + } + + $recentScansByScannerId = $this->groupRecentScansByScannerId($departmentId); + $scanners = []; + foreach ((new plate_scanners_o())->getDepartmentScanners($departmentId) as $scanner) { + if (!$scanner->exists()) { + continue; + } + + $scannerPayload = $scanner->asArray(); + $laneId = isset($scannerPayload['lane_id']) ? (int)($scannerPayload['lane_id'] ?? 0) : 0; + $assignedLane = $laneId > 0 ? ($laneIndex[$laneId] ?? null) : null; + $recentScans = $recentScansByScannerId[(int)$scanner->id] ?? []; + $recentScanAt = $recentScans !== [] ? ($recentScans[0]['created_at'] ?? null) : null; + + $assignmentState = $laneId <= 0 + ? 'UNASSIGNED' + : ($assignedLane === null + ? 'INVALID' + : (((int)($assignedLane['binding_coverage']['missing'] ?? 0) === 0) ? 'READY' : 'PARTIAL')); + + $scanners[] = [ + ...$scannerPayload, + 'assigned_lane' => $assignedLane, + 'assignment_state' => $assignmentState, + 'recent_scan_at' => $recentScanAt, + 'recent_scans' => $recentScans, + 'recent_scan_count' => count($recentScans), + ]; + } + + return $scanners; + } + + /** + * @return array>> + */ + private function groupRecentScansByScannerId(int $departmentId): array + { + $rows = (new plate_scans_o())->getFieldsWhere([ + 'department_id' => $departmentId, + ], ['id', 'plate_scanner_id', 'plate', 'bay_id', 'created_at']); + + usort($rows, static function (array $left, array $right): int { + $rightTimestamp = strtotime((string)($right['created_at'] ?? '')) ?: 0; + $leftTimestamp = strtotime((string)($left['created_at'] ?? '')) ?: 0; + return $rightTimestamp <=> $leftTimestamp ?: ((int)($right['id'] ?? 0) <=> (int)($left['id'] ?? 0)); + }); + + $grouped = []; + foreach ($rows as $row) { + $scannerId = (int)($row['plate_scanner_id'] ?? 0); + if ($scannerId <= 0) { + continue; + } + + if (!isset($grouped[$scannerId])) { + $grouped[$scannerId] = []; + } + + if (count($grouped[$scannerId]) >= 5) { + continue; + } + + $grouped[$scannerId][] = [ + 'id' => (int)($row['id'] ?? 0), + 'plate' => (string)($row['plate'] ?? ''), + 'bay_id' => isset($row['bay_id']) ? (string)$row['bay_id'] : null, + 'created_at' => isset($row['created_at']) ? (string)$row['created_at'] : null, + ]; + } + + return $grouped; + } + + /** + * @param array> $gateways + * @param array> $lanes + * @param array> $gates + * @param array> $scanners + * @return array> + */ + private function buildIssues( + string $transportMode, + array $gateways, + array $lanes, + array $gates, + array $scanners, + array $selfServe + ): array { + $issues = []; + + if ($gateways === []) { + $issues[] = [ + 'severity' => 'danger', + 'code' => 'NO_GATEWAY', + 'message' => 'No edge gateway has been claimed for this department.', + ]; + } + + $onlineGateways = array_values(array_filter($gateways, static function (array $gateway): bool { + return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE; + })); + + if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY && $onlineGateways === []) { + $issues[] = [ + 'severity' => 'danger', + 'code' => 'NO_ONLINE_GATEWAY', + 'message' => 'Gateway transport mode is enabled, but no department gateway is currently online.', + ]; + } + + foreach ($lanes as $lane) { + if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'LANE_BINDING_GAP', + 'message' => 'Lane ' . (string)$lane['name'] . ' is missing relay bindings.', + 'target_type' => 'lane', + 'target_id' => (int)$lane['id'], + ]; + } + } + + foreach ($gates as $gate) { + if (!($gate['config_complete'] ?? false)) { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'GATE_CONFIG_INCOMPLETE', + 'message' => 'Gate ' . (string)$gate['name'] . ' has incomplete transport configuration.', + 'target_type' => 'gate', + 'target_id' => (int)$gate['id'], + ]; + continue; + } + + if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'GATE_BINDING_MISSING', + 'message' => 'Gate ' . (string)$gate['name'] . ' is assigned to an unbound relay.', + 'target_type' => 'gate', + 'target_id' => (int)$gate['id'], + ]; + } + } + + foreach ($scanners as $scanner) { + if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'SCANNER_UNASSIGNED', + 'message' => 'Scanner ' . (string)$scanner['name'] . ' is not assigned to a default lane.', + 'target_type' => 'scanner', + 'target_id' => (int)$scanner['id'], + ]; + } elseif (($scanner['assignment_state'] ?? '') === 'PARTIAL') { + $issues[] = [ + 'severity' => 'info', + 'code' => 'SCANNER_LANE_PARTIAL', + 'message' => 'Scanner ' . (string)$scanner['name'] . ' is assigned to a lane with missing relay coverage.', + 'target_type' => 'scanner', + 'target_id' => (int)$scanner['id'], + ]; + } + } + + if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['enabled_lanes'] ?? 0)) { + $issues[] = [ + 'severity' => 'warning', + 'code' => 'SELFSERVE_PARTIAL_READY', + 'message' => 'Self-serve is enabled, but one or more lanes are missing required relay coverage.', + ]; + } + + return $issues; + } + + /** + * @param array> $gateways + * @param array> $lanes + * @param array> $gates + * @param array> $scanners + * @return array> + */ + private function buildActions( + int $departmentId, + array $gateways, + array $lanes, + array $gates, + array $scanners, + array $selfServe + ): array { + $actions = [ + [ + 'code' => 'OPEN_GATEWAY_TAB', + 'label' => 'Open gateway controls', + 'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gateways', + ], + ]; + + if ($gateways === []) { + $actions[] = [ + 'code' => 'INSTALL_GATEWAY', + 'label' => 'Install first edge gateway', + 'path' => '/superuser/configuration/edgegateway', + ]; + } + + foreach ($lanes as $lane) { + if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) { + $actions[] = [ + 'code' => 'REVIEW_LANE_BINDINGS', + 'label' => 'Resolve lane bindings', + 'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=lanes', + ]; + break; + } + } + + foreach ($gates as $gate) { + if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) { + $actions[] = [ + 'code' => 'REVIEW_GATE_BINDINGS', + 'label' => 'Resolve gate relay bindings', + 'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gates', + ]; + break; + } + } + + foreach ($scanners as $scanner) { + if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') { + $actions[] = [ + 'code' => 'ASSIGN_SCANNERS', + 'label' => 'Assign scanners to lanes', + 'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=scanners', + ]; + break; + } + } + + if (($selfServe['enabled'] ?? false) && (int)($selfServe['lane_count'] ?? 0) > 0) { + $actions[] = [ + 'code' => 'OPEN_SELFSERVE_STUDIO', + 'label' => 'Open self-serve studio', + 'path' => '/admin/' . $departmentId . '/modules/self-serve/studio', + ]; + } + + return $actions; + } + + /** + * @param array $departmentPayload + * @param array|null $departmentRow + * @param array> $gateways + * @param array> $lanes + * @param array> $gates + * @param array> $scanners + * @param array> $issues + * @return array + */ + private function buildSummary( + array $departmentPayload, + ?array $departmentRow, + string $transportMode, + array $gateways, + array $lanes, + array $gates, + array $scanners, + array $selfServe, + array $issues + ): array { + $onlineGatewayCount = count(array_filter($gateways, static function (array $gateway): bool { + return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE; + })); + $primaryGateway = null; + foreach ($gateways as $gateway) { + if (!empty($gateway['is_primary'])) { + $primaryGateway = [ + 'id' => (int)$gateway['id'], + 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])), + 'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE), + ]; + break; + } + } + + if ($primaryGateway === null && $gateways !== []) { + $gateway = $gateways[0]; + $primaryGateway = [ + 'id' => (int)$gateway['id'], + 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])), + 'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE), + ]; + } + + $requiredRelayIds = []; + $coveredRelayIds = []; + foreach ($lanes as $lane) { + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + + $requiredRelayIds[$relayId] = true; + if (!empty($slot['coverage']['covered'])) { + $coveredRelayIds[$relayId] = true; + } + } + } + foreach ($gates as $gate) { + if (($gate['transport_type'] ?? '') !== 'RELAY') { + continue; + } + + $relayId = trim((string)($gate['relay']['relay_id'] ?? $gate['config']['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + + $requiredRelayIds[$relayId] = true; + if (!empty($gate['coverage']['covered'])) { + $coveredRelayIds[$relayId] = true; + } + } + + $assignedScannerCount = count(array_filter($scanners, static function (array $scanner): bool { + return (int)($scanner['lane_id'] ?? 0) > 0; + })); + + $recentScanAt = null; + foreach ($scanners as $scanner) { + $candidate = isset($scanner['recent_scan_at']) ? (string)$scanner['recent_scan_at'] : null; + if ($candidate === null || trim($candidate) === '') { + continue; + } + if ($recentScanAt === null || strtotime($candidate) > strtotime($recentScanAt)) { + $recentScanAt = $candidate; + } + } + + $relayGateCount = count(array_filter($gates, static function (array $gate): bool { + return ($gate['transport_type'] ?? '') === 'RELAY'; + })); + $phoneGateCount = count(array_filter($gates, static function (array $gate): bool { + return ($gate['transport_type'] ?? '') === 'PHONE_CALL'; + })); + + return [ + 'department_id' => (int)$departmentPayload['id'], + 'department_name' => (string)$departmentPayload['name'], + 'order_priority' => (int)($departmentRow['order_priority'] ?? $departmentPayload['order_priority'] ?? PHP_INT_MAX), + 'transport_mode' => $transportMode, + 'gateway_count' => count($gateways), + 'online_gateway_count' => $onlineGatewayCount, + 'primary_gateway' => $primaryGateway, + 'lane_count' => count($lanes), + 'self_serve_enabled' => (bool)($selfServe['enabled'] ?? false), + 'self_serve_ready_lanes' => (int)($selfServe['ready_lanes'] ?? 0), + 'required_relay_count' => count($requiredRelayIds), + 'bound_relay_count' => count($coveredRelayIds), + 'missing_binding_count' => max(0, count($requiredRelayIds) - count($coveredRelayIds)), + 'gate_count' => count($gates), + 'gate_transport_mix' => [ + 'relay' => $relayGateCount, + 'phone_call' => $phoneGateCount, + ], + 'scanner_count' => count($scanners), + 'assigned_scanner_count' => $assignedScannerCount, + 'recent_scan_at' => $recentScanAt, + 'issue_count' => count($issues), + 'health' => $this->deriveHealthState($transportMode, $gateways, $issues), + ]; + } + + /** + * @param array> $gateways + * @param array> $issues + */ + private function deriveHealthState(string $transportMode, array $gateways, array $issues): string + { + foreach ($issues as $issue) { + if (($issue['severity'] ?? '') === 'danger') { + return 'AT_RISK'; + } + } + + if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) { + foreach ($gateways as $gateway) { + if (strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE) { + return $issues === [] ? 'READY' : 'PARTIAL'; + } + } + return 'AT_RISK'; + } + + return $issues === [] ? 'READY' : 'PARTIAL'; + } + + /** + * @param array>> $bindingsByRelayId + * @return array + */ + private function buildRelayCoverage(string $relayId, array $bindingsByRelayId): array + { + $bindings = $bindingsByRelayId[$relayId] ?? []; + $primaryBinding = $bindings[0] ?? null; + + return [ + 'relay_id' => $relayId, + 'covered' => $bindings !== [], + 'status' => $bindings !== [] ? 'BOUND' : 'MISSING', + 'binding_count' => count($bindings), + 'primary_binding' => $primaryBinding, + 'bindings' => $bindings, + ]; + } + + /** + * @param array> $gateways + * @param array>> $consumersByRelayId + * @return array> + */ + private function applyBindingConsumerContexts(array $gateways, array $consumersByRelayId): array + { + foreach ($gateways as $gatewayIndex => $gateway) { + $bindings = isset($gateway['bindings']) && is_array($gateway['bindings']) + ? (array)$gateway['bindings'] + : []; + + foreach ($bindings as $bindingIndex => $binding) { + if (!is_array($binding)) { + continue; + } + + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) + ? (array)$binding['metadata'] + : []; + $consumerContexts = $consumersByRelayId[$relayId] ?? []; + $metadata['consumer_contexts'] = $consumerContexts; + $metadata['consumers'] = $consumerContexts; + $bindings[$bindingIndex]['metadata'] = $metadata; + $bindings[$bindingIndex]['consumer_contexts'] = $consumerContexts; + } + + $gateways[$gatewayIndex]['bindings'] = $bindings; + } + + return $gateways; + } + + /** + * @param array $config + */ + private function isGateConfigComplete(array $config): bool + { + $type = strtoupper(trim((string)($config['type'] ?? ''))); + if ($type === 'PHONE_CALL') { + return trim((string)($config['phone_number'] ?? '')) !== '' + && isset($config['call_duration_threshold']); + } + + if ($type === 'RELAY') { + return trim((string)($config['relay_id'] ?? '')) !== ''; + } + + return false; + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); + } + + private function clearDepartmentCache(): void + { + try { + if (!defined('redis')) { + return; + } + + $redis = constant('redis'); + if (is_object($redis) && method_exists($redis, 'clear_departments')) { + $redis->clear_departments(); + } + } catch (\Throwable) { + } + } +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_install_service.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_install_service.php new file mode 100644 index 00000000..ec5ec68f --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_install_service.php @@ -0,0 +1,80 @@ + 'application/x-httpd-php; charset=utf-8', + 'lan-worker.php' => 'application/x-httpd-php; charset=utf-8', + 'auto-updater.php' => 'application/x-httpd-php; charset=utf-8', + 'docker-compose.gateway.yml' => 'text/yaml; charset=utf-8', + 'Dockerfile.edge-agent' => 'text/plain; charset=utf-8', + 'Dockerfile.lan-worker' => 'text/plain; charset=utf-8', + 'Dockerfile.auto-updater' => 'text/plain; charset=utf-8', + 'gateway-launcher.sh' => 'text/x-shellscript; charset=utf-8', + 'truckwash-edge-gateway-stack.service' => 'text/plain; 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(); + } +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php new file mode 100644 index 00000000..48163a9b --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php @@ -0,0 +1,6877 @@ +>|null */ + private ?array $shellyRelayOptionsCache = null; + /** @var array> */ + private static array $relayActionContextStack = []; + + public function __construct() + { + edge_gateway_schema_bootstrap::ensureTables(); + } + + public static function withRelayActionContext(array $context, callable $callback): mixed + { + self::$relayActionContextStack[] = $context; + try { + return $callback(); + } finally { + array_pop(self::$relayActionContextStack); + } + } + + private static function configuredDefaultReleaseChannel(): string + { + try { + $value = trim((string)(new edgegateway())->defaultReleaseChannel()); + if ($value !== '') { + return $value; + } + } catch (Exception $exception) { + } + + return self::DEFAULT_RELEASE_CHANNEL; + } + + private static function configuredDefaultUpdateWindow(): string + { + try { + $value = trim((string)(new edgegateway())->defaultUpdateWindow()); + if ($value !== '') { + return $value; + } + } catch (Exception $exception) { + } + + return self::DEFAULT_UPDATE_WINDOW; + } + + public function createInstallToken(int $departmentId, ?string $label, ?int $createdBy = null): array + { + $this->requireDepartment($departmentId); + + $token = bin2hex(random_bytes(24)); + $expiresAt = $this->formatDateTime(time() + self::INSTALL_TOKEN_TTL_SECONDS); + $claimToken = new edge_gateway_claim_tokens_o(); + $claimTokenId = $claimToken->add_object([ + 'department_id' => $departmentId, + 'label' => $label, + 'token_hash' => $this->hashToken($token), + 'created_by' => $createdBy, + 'expires_at' => $expiresAt, + 'metadata_json' => [ + 'install_session' => self::mergeInstallSessionUpdate([], [ + 'status' => self::INSTALL_SESSION_STATUS_PENDING, + 'step' => self::INSTALL_SESSION_STATUS_PENDING, + 'message' => 'Installer command generated. Run it on the gateway host.', + ], (self::parseApplicationDateTime($expiresAt) ?? time()) - self::INSTALL_TOKEN_TTL_SECONDS), + ], + ]); + + $claimToken->select($claimTokenId); + $this->writeAudit( + null, + $departmentId, + 'INSTALL_TOKEN_CREATED', + $createdBy, + ['claim_token_id' => $claimTokenId, 'label' => $label] + ); + + return [ + 'claim_token_id' => $claimTokenId, + 'token' => $token, + 'expires_at' => (string)$claimToken->expires_at->value(), + 'install_command' => $this->buildInstallCommand($token), + 'install_url' => $this->buildInstallScriptUrl($token), + ]; + } + + private function buildShellReadinessDiagnostics(edge_gateways_o $gateway): array + { + $gatewayId = (int)$gateway->id; + $brokerUrl = $this->buildBrokerPublicUrl(); + $wsUrl = $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell'); + $presence = $this->readBrokerPresence($gatewayId); + $lastSeenAt = isset($presence['last_seen_at']) ? (string)$presence['last_seen_at'] : null; + $ageSeconds = self::heartbeatAgeSeconds($lastSeenAt); + $connected = self::isBrokerPresenceConnected($presence); + $configuredBrokerUrl = $this->configuredPublicBrokerUrl(); + $derivedWarning = null; + + if ($configuredBrokerUrl === '') { + $apiBaseUrl = rtrim($this->getApiBaseUrl(), '/'); + $apiPath = (string)(parse_url($apiBaseUrl, PHP_URL_PATH) ?: ''); + $apiHost = (string)(parse_url($apiBaseUrl, PHP_URL_HOST) ?: ''); + if ($apiPath === '/api' && !in_array($apiHost, ['localhost', '127.0.0.1'], true)) { + $derivedWarning = 'BROKER_PUBLIC_URL_DERIVED_WITH_API_PREFIX'; + } + } + + $diagnostics = [ + 'ready' => false, + 'reason_code' => 'BROKER_NOT_READY', + 'message' => 'Gateway shell is not ready.', + 'gateway_id' => $gatewayId, + 'broker_url' => $brokerUrl, + 'ws_url' => $wsUrl, + 'public_broker_url_configured' => $configuredBrokerUrl !== '', + 'broker_auth_mode' => $this->configuredBrokerAuthMode(), + 'derived_warning' => $derivedWarning, + 'broker_presence' => [ + 'connected' => !empty($presence['connected']), + 'connection_id' => isset($presence['connection_id']) ? (string)$presence['connection_id'] : null, + 'last_seen_at' => $lastSeenAt, + 'age_seconds' => $ageSeconds, + 'disconnect_reason' => isset($presence['disconnect_reason']) ? (string)$presence['disconnect_reason'] : null, + 'last_error' => isset($presence['last_error']) ? (string)$presence['last_error'] : null, + ], + ]; + + if ($brokerUrl === null || $wsUrl === null) { + return array_merge($diagnostics, [ + 'reason_code' => 'BROKER_NOT_CONFIGURED', + 'message' => 'The public edge broker URL is not configured, so a gateway shell cannot be opened.', + ]); + } + + if ($presence === []) { + return array_merge($diagnostics, [ + 'reason_code' => 'BROKER_DISCONNECTED', + 'message' => 'Gateway agent has not reported an active broker connection yet.', + ]); + } + + if (empty($presence['connected'])) { + return array_merge($diagnostics, [ + 'reason_code' => 'BROKER_DISCONNECTED', + 'message' => 'Gateway agent is not connected to the edge broker.', + ]); + } + + if (!$connected) { + return array_merge($diagnostics, [ + 'reason_code' => 'BROKER_STALE', + 'message' => 'Gateway broker presence is stale. Wait for the agent to reconnect before opening a shell.', + ]); + } + + return array_merge($diagnostics, [ + 'ready' => true, + 'reason_code' => $derivedWarning ?: 'READY', + 'message' => $derivedWarning === null + ? 'Gateway shell broker path is ready.' + : 'Gateway shell broker path is ready, but the broker URL was derived from the API URL.', + ]); + } + + /** + * @throws Exception + */ + public function claimGateway(string $token, string $hostname, ?string $installedVersion = null, array $metadata = []): array + { + $claimToken = $this->requireClaimToken($token); + if ($claimToken->used_at->value() !== null) { + throw new Exception('Install token has already been used'); + } + + $departmentId = (int)$claimToken->department_id->value(); + $label = trim((string)($claimToken->label->value() ?? $hostname)); + $label = $label !== '' ? $label : 'Department gateway'; + $agentToken = bin2hex(random_bytes(32)); + + $gateway = new edge_gateways_o(); + $gatewayId = $gateway->add_object([ + 'department_id' => $departmentId, + 'label' => $label, + 'hostname' => trim($hostname) !== '' ? trim($hostname) : null, + 'agent_token_hash' => $this->hashToken($agentToken), + 'status' => self::STATUS_ONLINE, + 'transport_mode' => self::TRANSPORT_MODE_GATEWAY, + 'release_channel' => self::configuredDefaultReleaseChannel(), + 'installed_version' => $installedVersion, + 'target_version' => $installedVersion, + 'last_heartbeat_at' => $this->now(), + 'last_seen_ip' => $this->remoteIp(), + 'discovery_status' => 'PENDING', + 'is_primary' => 1, + 'metadata_json' => array_merge($metadata, [ + 'credentials_rotated_at' => $this->now(), + 'agent_runtime' => 'compose-php', + 'runtime_mode' => 'compose', + 'update_window' => self::configuredDefaultUpdateWindow(), + 'container_health' => [ + 'overall_status' => self::STATUS_PENDING, + 'services' => self::defaultGatewayServiceHealth(self::STATUS_PENDING), + ], + 'outbox_status' => [ + 'depth' => 0, + 'oldest_age_seconds' => 0, + 'last_flushed_at' => null, + 'pending_types' => [], + ], + 'rollback_status' => [ + 'state' => 'NONE', + 'reason' => null, + 'at' => null, + ], + 'last_sync_at' => null, + ]), + ]); + + $claimToken->used_at->set($this->now()); + $this->persistInstallSession($claimToken, [ + 'status' => self::INSTALL_SESSION_STATUS_CLAIMED, + 'step' => self::INSTALL_SESSION_STATUS_CLAIMED, + 'message' => 'Gateway claimed successfully.', + 'gateway_id' => $gatewayId, + 'last_error' => null, + 'diagnostics' => [], + ]); + $gateway->select($gatewayId); + $this->setGatewayPrimaryState($gateway, true); + + $this->writeAudit( + $gatewayId, + $departmentId, + 'GATEWAY_CLAIMED', + null, + ['hostname' => $hostname, 'installed_version' => $installedVersion] + ); + + $gatewayPayload = $this->getGateway($gatewayId); + edge_gateway_view_cache::syncGateway($gatewayPayload); + + return [ + 'gateway' => $gatewayPayload, + 'agent_token' => $agentToken, + 'heartbeat_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/heartbeat', + 'commands_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/commands/poll', + 'operations_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/next', + 'operation_events_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/events', + 'operation_complete_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/complete', + 'broker_url' => $this->buildBrokerPublicUrl(), + 'release_channel' => (string)$gateway->release_channel->value(), + ]; + } + + /** + * @throws Exception + */ + public function authenticateGateway(int $gatewayId, string $plainToken): edge_gateways_o + { + $gateway = $this->requireGateway($gatewayId); + if (!hash_equals((string)$gateway->agent_token_hash->value(), $this->hashToken($plainToken))) { + throw new Exception('Invalid edge gateway token'); + } + + return $gateway; + } + + /** + * @throws Exception + */ + public function recordHeartbeat(int $gatewayId, string $plainToken, array $payload): array + { + $gateway = $this->authenticateGateway($gatewayId, $plainToken); + $existingMetadata = (array)($gateway->metadata_json->value() ?? []); + $payloadMetadata = (array)($payload['metadata'] ?? []); + $metadata = $this->mergeHeartbeatBrokerPresence($gatewayId, $existingMetadata, $payloadMetadata); + $gateway->status->set((string)($payload['status'] ?? self::STATUS_ONLINE)); + $gateway->hostname->set($payload['hostname'] ?? $gateway->hostname->value()); + $gateway->installed_version->set($payload['installed_version'] ?? $gateway->installed_version->value()); + $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value()); + $gateway->last_heartbeat_at->set($this->now()); + $gateway->last_seen_ip->set($this->remoteIp()); + $gateway->metadata_json->set($metadata); + + if (isset($payload['inventory']) && is_array($payload['inventory'])) { + $this->syncDeviceInventory($gatewayId, $payload['inventory']); + } + + $gatewayPayload = $this->getGateway($gatewayId); + $gatewayPayload['broker_url'] = $this->buildBrokerPublicUrl(); + edge_gateway_view_cache::syncGateway($gatewayPayload); + + return $gatewayPayload; + } + + private function mergeHeartbeatBrokerPresence(int $gatewayId, array $existingMetadata, array $payloadMetadata): array + { + $metadata = array_merge($existingMetadata, $payloadMetadata); + if (!array_key_exists('broker_connected', $payloadMetadata)) { + return $metadata; + } + + $connected = (bool)$payloadMetadata['broker_connected']; + $existingPresence = isset($existingMetadata['broker_presence']) && is_array($existingMetadata['broker_presence']) + ? (array)$existingMetadata['broker_presence'] + : []; + $presenceMetadata = isset($existingPresence['metadata']) && is_array($existingPresence['metadata']) + ? (array)$existingPresence['metadata'] + : []; + $now = $this->now(); + $disconnectReason = isset($payloadMetadata['broker_disconnect_reason']) + ? trim((string)$payloadMetadata['broker_disconnect_reason']) + : ''; + $lastError = isset($payloadMetadata['broker_last_error']) + ? trim((string)$payloadMetadata['broker_last_error']) + : ''; + + $presence = [ + 'gateway_id' => $gatewayId, + 'connected' => $connected, + 'connection_id' => isset($existingPresence['connection_id']) && trim((string)$existingPresence['connection_id']) !== '' + ? (string)$existingPresence['connection_id'] + : null, + 'last_seen_at' => $now, + 'disconnect_reason' => $connected ? null : ($disconnectReason !== '' ? $disconnectReason : null), + 'last_error' => $connected ? null : ($lastError !== '' ? $lastError : ($disconnectReason !== '' ? $disconnectReason : null)), + 'metadata' => array_merge($presenceMetadata, array_filter([ + 'agent_instance_id' => $payloadMetadata['agent_instance_id'] ?? null, + 'broker_url' => $payloadMetadata['broker_url'] ?? null, + ], static fn(mixed $value): bool => $value !== null && $value !== '')), + ]; + + $metadata['broker_presence'] = $presence; + $metadata['broker_connected'] = $connected; + if ($connected) { + $metadata['broker_connected_at'] = $metadata['broker_connected_at'] ?? $now; + $metadata['broker_last_error'] = null; + } else { + $metadata['broker_disconnected_at'] = $now; + $metadata['broker_last_error'] = $lastError !== '' ? $lastError : ($disconnectReason !== '' ? $disconnectReason : null); + } + + $this->writeBrokerPresence($gatewayId, $presence); + + return $metadata; + } + + /** + * @return array> + * @throws Exception + */ + public function listGateways(?int $departmentId = null, bool $includeDetail = true): array + { + $gatewayObject = new edge_gateways_o(); + $rows = $departmentId === null + ? $gatewayObject->getFieldsWhere(['deleted_at' => null], ['id']) + : $gatewayObject->getFieldsWhere(['department_id' => $departmentId, 'deleted_at' => null], ['id']); + + $gateways = []; + $gatewayIds = []; + foreach ($rows as $row) { + $gatewayId = (int)$row['id']; + try { + $gateway = $this->requireGateway($gatewayId); + } catch (Exception $exception) { + if ($exception->getMessage() === 'Edge gateway not found') { + continue; + } + + throw $exception; + } + + $gateways[] = $this->buildGatewayPayload($gateway, $includeDetail); + $gatewayIds[] = $gatewayId; + } + + if ($gatewayIds !== []) { + $gateways = self::attachGatewayCollectionSummaries( + $gateways, + $this->aggregateInventoryUsageByGateway($gatewayIds), + $this->aggregateBindingUsageByGateway($gatewayIds) + ); + } + + usort($gateways, static fn(array $a, array $b): int => ($a['department_id'] <=> $b['department_id']) ?: ($a['id'] <=> $b['id'])); + return $gateways; + } + + /** + * @param array> $gateways + * @return array + * @throws Exception + */ + public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array + { + $fleet = $gateways !== [] ? $gateways : $this->listGateways($departmentId, false); + + return self::summarizeFleetUsageFromGatewayRows($fleet); + } + + /** + * @param array> $gateways + * @return array + */ + public static function summarizeFleetUsage(array $gateways, array $inventoryUsage = [], array $bindingUsage = []): array + { + $inventory = $inventoryUsage === [] + ? self::aggregateInventoryUsageFromGatewayRows($gateways) + : array_merge(self::emptyInventoryUsage(), $inventoryUsage); + $bindings = $bindingUsage === [] + ? self::aggregateBindingUsageFromGatewayRows($gateways) + : array_merge(self::emptyBindingUsage(), $bindingUsage); + $totalGateways = count($gateways); + $departmentIds = []; + $gatewayOnline = 0; + $gatewayOffline = 0; + $gatewayDegraded = 0; + $gatewayDrifted = 0; + $brokerConnected = 0; + $activeOperations = 0; + $pendingOperations = 0; + $inProgressOperations = 0; + $operationBacklog = 0; + $commandBacklog = 0; + $latencyValues = []; + $cpuValues = []; + $memoryValues = []; + $diskValues = []; + + foreach ($gateways as $gateway) { + $departmentId = (int)($gateway['department_id'] ?? 0); + if ($departmentId > 0) { + $departmentIds[$departmentId] = true; + } + + $status = strtoupper((string)($gateway['status'] ?? self::STATUS_OFFLINE)); + if ($status === self::STATUS_ONLINE) { + $gatewayOnline += 1; + } elseif ($status === self::STATUS_DEGRADED) { + $gatewayDegraded += 1; + } else { + $gatewayOffline += 1; + } + + if (!empty($gateway['version_drift']['is_drifted'])) { + $gatewayDrifted += 1; + } + + if (!empty($gateway['channel_status']['broker']['connected'])) { + $brokerConnected += 1; + } + + if (!empty($gateway['active_operation'])) { + $activeOperations += 1; + } + + $recentSummary = isset($gateway['recent_operations_summary']) && is_array($gateway['recent_operations_summary']) + ? (array)$gateway['recent_operations_summary'] + : []; + $pendingOperations += (int)($recentSummary['pending'] ?? 0); + $inProgressOperations += (int)($recentSummary['in_progress'] ?? 0); + + $backlog = isset($gateway['backlog_depth']) && is_array($gateway['backlog_depth']) + ? (array)$gateway['backlog_depth'] + : []; + $operationBacklog += (int)($backlog['operations'] ?? 0); + $commandBacklog += (int)($backlog['commands'] ?? 0); + + $metrics = isset($gateway['metadata']['system_metrics']) && is_array($gateway['metadata']['system_metrics']) + ? (array)$gateway['metadata']['system_metrics'] + : []; + self::appendNumericMetric($latencyValues, $metrics['latency_ms'] ?? null); + self::appendNumericMetric($cpuValues, $metrics['cpu_usage_pct'] ?? null); + self::appendNumericMetric($memoryValues, $metrics['memory_usage_pct'] ?? null); + self::appendNumericMetric($diskValues, $metrics['disk_usage_pct'] ?? null); + } + + return [ + 'gateways' => [ + 'total' => $totalGateways, + 'departments' => count($departmentIds), + 'online' => $gatewayOnline, + 'offline' => $gatewayOffline, + 'degraded' => $gatewayDegraded, + 'drifted' => $gatewayDrifted, + 'broker_connected' => $brokerConnected, + ], + 'inventory' => $inventory, + 'bindings' => $bindings, + 'operations' => [ + 'active' => $activeOperations, + 'pending' => $pendingOperations, + 'in_progress' => $inProgressOperations, + 'backlog' => $operationBacklog, + ], + 'commands' => [ + 'backlog' => $commandBacklog, + ], + 'system' => [ + 'latency_ms_avg' => self::averageMetric($latencyValues), + 'cpu_usage_pct_avg' => self::averageMetric($cpuValues), + 'memory_usage_pct_avg' => self::averageMetric($memoryValues), + 'disk_usage_pct_avg' => self::averageMetric($diskValues), + ], + ]; + } + + /** + * @param array> $gateways + * @return array + */ + public static function summarizeFleetUsageFromGatewayRows(array $gateways): array + { + return self::summarizeFleetUsage( + $gateways, + self::aggregateInventoryUsageFromGatewayRows($gateways), + self::aggregateBindingUsageFromGatewayRows($gateways) + ); + } + + /** + * @param array $gateway + * @return array + */ + public static function prepareGatewayForListCache(array $gateway, bool $includeDetail): array + { + $gateway = self::decorateGatewayUsageSummaries($gateway); + + if ($includeDetail) { + return $gateway; + } + + $gateway['inventory'] = []; + $gateway['bindings'] = []; + $gateway['recent_commands'] = []; + $gateway['audit_logs'] = []; + if (isset($gateway['operations']) && is_array($gateway['operations'])) { + $gateway['operations'] = array_map(static function (mixed $operation): mixed { + if (!is_array($operation)) { + return $operation; + } + + $operation['events'] = []; + return $operation; + }, array_slice($gateway['operations'], 0, 5)); + } + + return $gateway; + } + + /** + * @throws Exception + */ + public function getGateway(int $gatewayId): array + { + $gateway = $this->requireGateway($gatewayId); + return self::decorateGatewayUsageSummaries($this->buildGatewayPayload($gateway, true)); + } + + /** + * @throws Exception + */ + private function buildGatewayPayload(edge_gateways_o $gateway, bool $includeDetail): array + { + $data = $gateway->asArray(); + $operations = new edge_gateway_operation_service($this); + $data['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id); + $gatewayId = (int)$gateway->id; + $this->expireTimedOutRelayStatusCommandJobs($gatewayId); + if ($includeDetail) { + $data['inventory'] = $this->listInventory($gatewayId); + $data['bindings'] = $this->listBindings($gatewayId); + $data['recent_commands'] = $this->listRecentObjects(new edge_gateway_command_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]); + $data['audit_logs'] = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId]); + $data['operations'] = $operations->listOperations($gatewayId, 12, true); + } else { + $data['inventory'] = []; + $data['bindings'] = []; + $data['recent_commands'] = []; + $data['audit_logs'] = []; + $data['operations'] = $operations->listOperations($gatewayId, 5, false); + } + $data['active_operation'] = $operations->getActiveOperation($gatewayId, false); + $data['recent_operations_summary'] = $operations->buildRecentOperationsSummary($gatewayId); + $data['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value()); + $data['operational_snapshot'] = $this->buildGatewayOperationalSnapshot((int)$gateway->id); + return self::deriveGatewayRuntimeState($data); + } + + /** + * @throws Exception + */ + public function updateGatewayMetadata(int $gatewayId, array $payload, ?int $userId = null): array + { + $gateway = $this->requireGateway($gatewayId); + $previousLabel = (string)$gateway->label->value(); + $previousIsPrimary = (bool)$gateway->is_primary->value(); + $label = trim((string)($payload['label'] ?? $previousLabel)); + if ($label === '') { + throw new Exception('Gateway label is required'); + } + + $isPrimary = (bool)($payload['is_primary'] ?? $previousIsPrimary); + $gateway->label->set($label); + + if ($isPrimary) { + $this->setGatewayPrimaryState($gateway, true); + } elseif ($isPrimary !== $previousIsPrimary) { + $this->setGatewayPrimaryState($gateway, false); + } + + $this->writeAudit( + $gatewayId, + (int)$gateway->department_id->value(), + 'GATEWAY_METADATA_UPDATED', + $userId, + [ + 'label' => $label, + 'previous_label' => $previousLabel, + 'is_primary' => $isPrimary, + 'previous_is_primary' => $previousIsPrimary, + ] + ); + + $gatewayPayload = $this->getGateway($gatewayId); + edge_gateway_view_cache::syncGateway($gatewayPayload); + + return $gatewayPayload; + } + + /** + * @throws Exception + */ + public function setDepartmentTransportMode(int $departmentId, string $transportMode, ?int $userId = null): array + { + if (!in_array($transportMode, [self::TRANSPORT_MODE_CLOUD, self::TRANSPORT_MODE_GATEWAY], true)) { + throw new Exception('Invalid transport mode'); + } + + $department = $this->requireDepartment($departmentId); + $department->variables->set(self::DEPARTMENT_VARIABLE_TRANSPORT_MODE, $transportMode); + $this->writeAudit( + null, + $departmentId, + 'DEPARTMENT_TRANSPORT_MODE_UPDATED', + $userId, + ['transport_mode' => $transportMode] + ); + + edge_gateway_view_cache::clearAll(); + + return [ + 'department_id' => $departmentId, + 'transport_mode' => $transportMode, + ]; + } + + public function getDepartmentTransportMode(int $departmentId): string + { + $variables = (new department_variables_o())->selectDepartment($departmentId); + $mode = $variables->getVariable(self::DEPARTMENT_VARIABLE_TRANSPORT_MODE); + if ($mode === self::TRANSPORT_MODE_GATEWAY) { + return self::TRANSPORT_MODE_GATEWAY; + } + return self::TRANSPORT_MODE_CLOUD; + } + + /** + * @return array> + * @throws Exception + */ + public function setRelayBindings(int $gatewayId, array $bindings, ?int $userId = null): array + { + $gateway = $this->requireGateway($gatewayId); + $departmentId = (int)$gateway->department_id->value(); + $incomingRelayIds = []; + + foreach ($bindings as $binding) { + $relayId = trim((string)($binding['relay_id'] ?? '')); + $deviceId = trim((string)($binding['device_id'] ?? '')); + if ($relayId === '' || $deviceId === '') { + throw new Exception('Each relay binding must contain relay_id and device_id'); + } + + $bindingMetadata = $this->normalizeRelayBindingMetadata((array)($binding['metadata'] ?? []), $binding); + + $incomingRelayIds[] = $relayId; + $existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ + 'gateway_id' => $gatewayId, + 'relay_id' => $relayId, + 'deleted_at' => null, + ], ['id']); + + if ($existing !== []) { + $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$existing[0]['id']); + $existingDeviceId = trim((string)$bindingObject->device_id->value()); + $localIp = $this->resolveRelayBindingLocalIp( + $gatewayId, + $binding, + $deviceId, + $existingDeviceId === $deviceId ? $bindingObject->local_ip->value() : null + ); + $bindingObject->device_id->set($deviceId); + $bindingObject->local_ip->set($localIp); + $bindingObject->channel->set((int)($binding['channel'] ?? 0)); + $bindingObject->binding_source->set((string)($binding['binding_source'] ?? 'MANUAL')); + $bindingObject->approved_by->set($userId); + $bindingObject->approved_at->set($this->now()); + $bindingObject->metadata_json->set($bindingMetadata); + continue; + } + + $localIp = $this->resolveRelayBindingLocalIp($gatewayId, $binding, $deviceId); + (new edge_gateway_relay_bindings_o())->add_object([ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'relay_id' => $relayId, + 'device_id' => $deviceId, + 'local_ip' => $localIp, + 'channel' => (int)($binding['channel'] ?? 0), + 'binding_source' => (string)($binding['binding_source'] ?? 'MANUAL'), + 'approved_by' => $userId, + 'approved_at' => $this->now(), + 'metadata_json' => $bindingMetadata, + ]); + } + + foreach ($this->listBindings($gatewayId) as $existingBinding) { + if (in_array((string)$existingBinding['relay_id'], $incomingRelayIds, true)) { + continue; + } + $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$existingBinding['id']); + $bindingObject->deleted_at->set($this->now()); + } + + $this->writeAudit( + $gatewayId, + $departmentId, + 'RELAY_BINDINGS_UPDATED', + $userId, + ['binding_count' => count($bindings)] + ); + + edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); + + return $this->listBindings($gatewayId); + } + + /** + * @throws Exception + */ + public function queueDiscovery(int $gatewayId, ?int $userId = null): array + { + $gateway = $this->requireGateway($gatewayId); + $gateway->discovery_status->set('PENDING'); + $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId, [ + 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), + ]); + + $gatewayPayload = $this->getGateway($gatewayId); + edge_gateway_view_cache::syncGateway($gatewayPayload); + + return $gatewayPayload; + } + + /** + * @throws Exception + */ + public function deleteGateway(int $gatewayId, ?int $userId = null): array + { + $gateway = $this->requireGateway($gatewayId); + $departmentId = (int)$gateway->department_id->value(); + $label = (string)$gateway->label->value(); + if ((bool)$gateway->is_primary->value()) { + $replacement = $this->findAlternateGatewayForDepartment($departmentId, $gatewayId); + if ($replacement !== null) { + $replacement->is_primary->set(true); + } + } + + $this->softDeleteGatewayRelations($gatewayId); + $gateway->deleted_at->set($this->now()); + + $this->writeAudit( + $gatewayId, + $departmentId, + 'GATEWAY_DELETED', + $userId, + ['label' => $label] + ); + + edge_gateway_view_cache::removeGateway($gatewayId, $departmentId); + + return [ + 'deleted' => true, + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + ]; + } + + public function deleteOrphanedGatewaysForDepartment(int $departmentId): void + { + if ($departmentId <= 0 || $this->departmentExists($departmentId)) { + return; + } + + $rows = (new edge_gateways_o())->getFieldsWhere([ + 'department_id' => $departmentId, + 'deleted_at' => null, + ], ['id']); + + foreach ($rows as $row) { + $gatewayId = (int)($row['id'] ?? 0); + if ($gatewayId <= 0) { + continue; + } + + try { + $gateway = (new edge_gateways_o())->select($gatewayId); + if ($gateway->exists()) { + $this->softDeleteOrphanedGateway($gateway); + } + } catch (\Throwable) { + edge_gateway_view_cache::removeGateway($gatewayId, $departmentId); + } + } + } + + /** + * @throws Exception + */ + public function pollCommand(int $gatewayId, string $plainToken, int $waitSeconds = self::COMMAND_POLL_TIMEOUT_SECONDS): ?array + { + $gateway = $this->authenticateGateway($gatewayId, $plainToken); + $deadline = microtime(true) + max(0, $waitSeconds); + + do { + $job = $this->claimNextCommandJob($gateway); + if ($job !== null) { + return $this->formatAgentCommandJob($job, $gateway); + } + + if (microtime(true) >= $deadline) { + break; + } + + usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS); + } while (true); + + return null; + } + + /** + * @throws Exception + */ + public function submitCommandResult( + int $gatewayId, + int $jobId, + string $plainToken, + bool $ok, + array $payload = [], + ?string $error = null + ): array { + $gateway = $this->authenticateGateway($gatewayId, $plainToken); + $job = (new edge_gateway_command_jobs_o())->select($jobId); + if (!$job->exists()) { + throw new Exception('Edge gateway command job not found'); + } + if ((int)$job->gateway_id->value() !== (int)$gateway->id) { + throw new Exception('Edge gateway command job does not belong to this gateway'); + } + + $status = (string)$job->status->value(); + if (in_array($status, ['COMPLETED', 'FAILED', 'TIMED_OUT'], true)) { + return [ + 'acknowledged' => true, + 'job' => $job->asArray(), + ]; + } + + $errorMessage = $ok ? null : trim((string)$error); + if (!$ok && $errorMessage === '') { + $errorMessage = 'Edge gateway command failed'; + } + + $this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway); + + edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); + + return [ + 'acknowledged' => true, + 'job' => $job->asArray(), + ]; + } + + /** + * @throws Exception + */ + public function resolveRelayBinding(int $departmentId, string $logicalRelayId): array + { + $gateway = $this->getPrimaryGatewayForDepartment($departmentId, false); + $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ + 'department_id' => $departmentId, + 'gateway_id' => (int)$gateway->id, + 'relay_id' => $logicalRelayId, + 'deleted_at' => null, + ], ['id']); + + if ($rows === []) { + throw new Exception('No edge gateway relay binding found for relay ' . $logicalRelayId); + } + + return (new edge_gateway_relay_bindings_o())->select((int)$rows[0]['id'])->asArray(); + } + + /** + * @throws Exception + */ + public function dispatchRelayStatus(int $departmentId, string $logicalRelayId, array $actionContext = []): array + { + return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, false, $actionContext); + } + + /** + * @throws Exception + */ + public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId, array $actionContext = []): array + { + return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, true, $actionContext); + } + + /** + * @throws Exception + */ + private function dispatchRelayStatusWithOptions( + int $departmentId, + string $logicalRelayId, + bool $requireFastLocalPath = false, + array $actionContext = [] + ): array + { + $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); + $gateway = $this->requireGateway((int)$binding['gateway_id']); + $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId); + if ($requireFastLocalPath) { + $resolution = $this->forceLocalRelayExecutionPlan($resolution); + } + $actionContext = $this->normalizeRelayActionContext($actionContext); + + if (($resolution['execution_path'] ?? 'local') === 'cloud') { + return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution, null, $actionContext); + } + + $statusRequest = [ + 'relayId' => $logicalRelayId, + 'deviceId' => $binding['device_id'], + 'localIp' => $binding['local_ip'], + 'channel' => (int)$binding['channel'], + ]; + $deviceGeneration = $this->resolveRelayBindingDeviceGeneration($binding); + if ($deviceGeneration !== null) { + $statusRequest['deviceGeneration'] = $deviceGeneration; + $statusRequest['device_generation'] = $deviceGeneration; + } + + $job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', $statusRequest, $this->resolveRelayRequestedBy($actionContext), [ + 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), + 'fallback_reason' => $resolution['reason'] ?? null, + 'require_fast_path' => $requireFastLocalPath, + ]); + $dispatchLog = [ + 'action' => 'STATUS', + 'handler' => 'local', + 'relay_id' => $logicalRelayId, + 'signal' => $this->buildRelayCommandSignal($job, $statusRequest), + 'action_context' => $actionContext, + ]; + + try { + $result = $this->dispatchGatewayCommand($gateway, $job); + return $this->finalizeRelayDispatch($binding, $resolution, $result, $dispatchLog); + } catch (Exception $exception) { + $this->appendRelayDispatchLog($binding, $resolution, false, $dispatchLog, [], $exception); + return $this->handleRelayDispatchFailure( + $departmentId, + $logicalRelayId, + $binding, + $resolution, + null, + $exception, + null, + $actionContext + ); + } + } + + /** + * @throws Exception + */ + public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array + { + return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, false, null, $actionContext); + } + + /** + * @throws Exception + */ + public function dispatchRelaySwitchWithTimer( + int $departmentId, + string $logicalRelayId, + bool $on, + ?int $toggleAfterSeconds, + array $actionContext = [] + ): array { + return $this->dispatchRelaySwitchWithOptions( + $departmentId, + $logicalRelayId, + $on, + false, + $this->normalizeRelayToggleAfter($toggleAfterSeconds), + $actionContext + ); + } + + /** + * @throws Exception + */ + public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array + { + return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, true, null, $actionContext); + } + + /** + * @throws Exception + */ + public function dispatchRelaySwitchLocalOnlyWithTimer( + int $departmentId, + string $logicalRelayId, + bool $on, + ?int $toggleAfterSeconds, + array $actionContext = [] + ): array { + return $this->dispatchRelaySwitchWithOptions( + $departmentId, + $logicalRelayId, + $on, + true, + $this->normalizeRelayToggleAfter($toggleAfterSeconds), + $actionContext + ); + } + + /** + * @throws Exception + */ + private function dispatchRelaySwitchWithOptions( + int $departmentId, + string $logicalRelayId, + bool $on, + bool $requireFastLocalPath = false, + ?int $toggleAfterSeconds = null, + array $actionContext = [] + ): array + { + $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); + $gateway = $this->requireGateway((int)$binding['gateway_id']); + $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId); + if ($requireFastLocalPath) { + $resolution = $this->forceLocalRelayExecutionPlan($resolution); + } + $actionContext = $this->normalizeRelayActionContext($actionContext); + + if (($resolution['execution_path'] ?? 'local') === 'cloud') { + return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, $on, $binding, $resolution, $toggleAfterSeconds, $actionContext); + } + + $request = [ + 'relayId' => $logicalRelayId, + 'deviceId' => $binding['device_id'], + 'localIp' => $binding['local_ip'], + 'channel' => (int)$binding['channel'], + 'on' => $on, + ]; + $deviceGeneration = $this->resolveRelayBindingDeviceGeneration($binding); + if ($deviceGeneration !== null) { + $request['deviceGeneration'] = $deviceGeneration; + $request['device_generation'] = $deviceGeneration; + } + if ($toggleAfterSeconds !== null) { + $request['toggleAfter'] = $toggleAfterSeconds; + $request['toggle_after'] = $toggleAfterSeconds; + } + + $job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', $request, $this->resolveRelayRequestedBy($actionContext), [ + 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), + 'fallback_reason' => $resolution['reason'] ?? null, + 'require_fast_path' => $requireFastLocalPath, + ]); + $dispatchLog = [ + 'action' => 'SWITCH', + 'handler' => 'local', + 'relay_id' => $logicalRelayId, + 'target_on' => $on, + 'toggle_after_seconds' => $toggleAfterSeconds, + 'signal' => $this->buildRelayCommandSignal($job, $request), + 'action_context' => $actionContext, + ]; + + try { + $result = $this->dispatchGatewayCommand($gateway, $job); + return $this->finalizeRelayDispatch($binding, $resolution, $result, $dispatchLog); + } catch (Exception $exception) { + $this->appendRelayDispatchLog($binding, $resolution, false, $dispatchLog, [], $exception); + return $this->handleRelayDispatchFailure( + $departmentId, + $logicalRelayId, + $binding, + $resolution, + $on, + $exception, + $toggleAfterSeconds, + $actionContext + ); + } + } + + /** + * @return array> + */ + public function listBindings(int $gatewayId): array + { + return $this->listRecentObjects(new edge_gateway_relay_bindings_o(), [ + 'gateway_id' => $gatewayId, + 'deleted_at' => null, + ], 100); + } + + /** + * @return array> + */ + public function listInventory(int $gatewayId): array + { + return $this->listRecentObjects(new edge_gateway_device_inventory_o(), [ + 'gateway_id' => $gatewayId, + 'deleted_at' => null, + ], 100); + } + + public function buildInstallCommand(string $plainToken): string + { + return 'curl -fsSL "' . $this->buildInstallScriptUrl($plainToken) . '" | sudo bash'; + } + + public function buildInstallTokenVerifyUrl(string $plainToken): string + { + return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install-token/verify?token=' . urlencode($plainToken); + } + + public function buildInstallScriptUrl(string $plainToken): string + { + return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install.sh?token=' . urlencode($plainToken); + } + + public function buildInstallScript(string $plainToken): string + { + $configJson = json_encode([ + 'apiUrl' => $this->getApiBaseUrl(), + 'brokerUrl' => $this->buildBrokerPublicUrl(), + 'installToken' => $plainToken, + 'gatewayId' => null, + 'agentToken' => null, + 'installDir' => self::DEFAULT_INSTALL_DIR, + 'runtimeDir' => self::DEFAULT_RUNTIME_DIR, + 'runtimeMode' => 'compose', + 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, + 'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME, + 'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE, + 'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME, + 'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME, + 'lanWorkerArtifactName' => self::DEFAULT_LAN_WORKER_ARTIFACT, + 'autoUpdaterArtifactName' => self::DEFAULT_AUTO_UPDATER_ARTIFACT, + 'edgeAgentDockerfileName' => self::DEFAULT_EDGE_AGENT_DOCKERFILE, + 'lanWorkerDockerfileName' => self::DEFAULT_LAN_WORKER_DOCKERFILE, + 'autoUpdaterDockerfileName' => self::DEFAULT_AUTO_UPDATER_DOCKERFILE, + 'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH, + 'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL, + 'updateWindow' => self::configuredDefaultUpdateWindow(), + 'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE, + 'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE, + 'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE, + 'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE, + 'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE, + 'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE, + 'heartbeatIntervalSeconds' => 15, + 'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, + ], JSON_UNESCAPED_SLASHES); + + $script = <<<'BASH' +#!/usr/bin/env bash +set -Eeuo pipefail +INSTALL_DIR=/opt/truckwash-edge-agent +RUNTIME_DIR="$INSTALL_DIR/runtime" +CONFIG_PATH="$INSTALL_DIR/config.json" +CONFIG_TEMPLATE_PATH="$INSTALL_DIR/config.template.json" +HEARTBEAT_MARKER_PATH="$RUNTIME_DIR/last-heartbeat-ok.txt" +STACK_SERVICE_PATH="/etc/systemd/system/truckwash-edge-gateway-stack.service" +INSTALL_TOKEN="__INSTALL_TOKEN__" +INSTALL_STATUS_URL="__STATUS_URL__" +CURRENT_STEP="Preparing installer" +CURRENT_STEP_CODE="PENDING" +CURRENT_METHOD="" +CURRENT_URL="" +INSTALL_STARTED_AT="$(date +%s)" +REUSE_EXISTING_CREDENTIALS=0 +DIAGNOSTIC_NAMES=() +DIAGNOSTIC_OUTPUTS=() +json_escape() { + local value="${1:-}" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "$value" +} +trim_diagnostic_output() { + printf '%s' "${1:-}" | awk 'NR <= 80 { print } NR == 81 { print "..."; exit }' | head -c 4000 +} +append_diagnostic() { + local name="$1" + local output="$2" + if [ -z "$name" ] || [ -z "$output" ]; then + return 0 + fi + DIAGNOSTIC_NAMES+=("$name") + DIAGNOSTIC_OUTPUTS+=("$output") + if [ "${#DIAGNOSTIC_NAMES[@]}" -gt 6 ]; then + DIAGNOSTIC_NAMES=("${DIAGNOSTIC_NAMES[@]: -6}") + DIAGNOSTIC_OUTPUTS=("${DIAGNOSTIC_OUTPUTS[@]: -6}") + fi +} +emit_diagnostic_json() { + local json="[" + local index + for index in "${!DIAGNOSTIC_NAMES[@]}"; do + if [ "$index" -gt 0 ]; then + json="${json}," + fi + json="${json}{\"name\":\"$(json_escape "${DIAGNOSTIC_NAMES[$index]}")\",\"output\":\"$(json_escape "${DIAGNOSTIC_OUTPUTS[$index]}")\"}" + done + json="${json}]" + printf '%s' "$json" +} +capture_command_diagnostic() { + local name="$1" + shift + local output="" + set +e + output="$("$@" 2>&1)" + set -e + output="$(trim_diagnostic_output "$output")" + if [ -n "$output" ]; then + append_diagnostic "$name" "$output" + fi +} +report_install_status() { + local status="$1" + local step="$2" + local message="$3" + local diagnostics_json="${4:-[]}" + local gateway_id="${5:-}" + local payload + + payload="{\"token\":\"$(json_escape "$INSTALL_TOKEN")\",\"status\":\"$(json_escape "$status")\",\"step\":\"$(json_escape "$step")\",\"message\":\"$(json_escape "$message")\",\"diagnostics\":${diagnostics_json:-[]}" + if [ -n "${gateway_id:-}" ] && [ "$gateway_id" -gt 0 ] 2>/dev/null; then + payload="${payload},\"gateway_id\":${gateway_id}" + fi + payload="${payload}}" + + set +e + curl -sS -X POST -H "Content-Type: application/json" --data-binary "$payload" "$INSTALL_STATUS_URL" >/dev/null 2>&1 + set -e +} +begin_install_phase() { + CURRENT_STEP_CODE="$1" + CURRENT_STEP="$2" + CURRENT_METHOD="" + CURRENT_URL="" + report_install_status "RUNNING" "$CURRENT_STEP_CODE" "$CURRENT_STEP" +} +log_info() { + printf '[truckwash-edge-agent] %s\n' "$1" +} +log_error() { + printf '[truckwash-edge-agent] ERROR: %s\n' "$1" >&2 +} +collect_install_diagnostics() { + DIAGNOSTIC_NAMES=() + DIAGNOSTIC_OUTPUTS=() + + if [ -n "${CURRENT_METHOD:-}" ] && [ -n "${CURRENT_URL:-}" ]; then + append_diagnostic "Last request" "${CURRENT_METHOD} ${CURRENT_URL}" + fi + + if command -v systemctl >/dev/null 2>&1; then + capture_command_diagnostic "systemctl status" systemctl status --no-pager truckwash-edge-gateway-stack.service + fi + if command -v journalctl >/dev/null 2>&1; then + capture_command_diagnostic "journalctl" journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager + fi + if command -v docker >/dev/null 2>&1; then + capture_command_diagnostic "docker ps" docker ps --format '{{.Names}} {{.Status}}' + if [ -f "$INSTALL_DIR/docker-compose.gateway.yml" ]; then + if docker compose version >/dev/null 2>&1; then + capture_command_diagnostic "docker compose ps" docker compose -f "$INSTALL_DIR/docker-compose.gateway.yml" ps + capture_command_diagnostic "docker compose logs" docker compose -f "$INSTALL_DIR/docker-compose.gateway.yml" logs --tail=80 + elif command -v docker-compose >/dev/null 2>&1; then + capture_command_diagnostic "docker-compose ps" docker-compose -f "$INSTALL_DIR/docker-compose.gateway.yml" ps + capture_command_diagnostic "docker-compose logs" docker-compose -f "$INSTALL_DIR/docker-compose.gateway.yml" logs --tail=80 + fi + fi + fi + + emit_diagnostic_json +} +on_error() { + local exit_code=$? + local failure_message="Installer failed during step ${CURRENT_STEP_CODE:-FAILED}: ${CURRENT_STEP:-unknown}" + local diagnostics_json + local gateway_id + log_error "$failure_message" + if [ -n "${CURRENT_METHOD:-}" ] && [ -n "${CURRENT_URL:-}" ]; then + log_error "Last request: ${CURRENT_METHOD} ${CURRENT_URL}" + fi + diagnostics_json="$(collect_install_diagnostics)" + gateway_id="$(read_config_value "$CONFIG_PATH" gatewayId 2>/dev/null || true)" + report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id" + exit "$exit_code" +} +trap on_error ERR +run_step() { + local description="$1" + shift + CURRENT_STEP="$description" + CURRENT_METHOD="" + CURRENT_URL="" + log_info "$description" + "$@" +} +fetch_http() { + local description="$1" + local url="$2" + local output_path="${3:-}" + local body_path="$output_path" + local headers_path + local status="" + local curl_exit=0 + local preview="" + local cleanup_body=0 + + if [ -z "$body_path" ]; then + body_path="$(mktemp)" + cleanup_body=1 + fi + headers_path="$(mktemp)" + + CURRENT_STEP="$description" + CURRENT_METHOD="GET" + CURRENT_URL="$url" + log_info "${description}: GET ${url}" + + set +e + status="$(curl -sS -L -D "$headers_path" -o "$body_path" -w '%{http_code}' "$url")" + curl_exit=$? + set -e + + if [ "$curl_exit" -ne 0 ]; then + log_error "${description} request failed before a successful HTTP response was received." + log_error "Request: GET ${url}" + log_error "curl exit code: ${curl_exit}" + if [ -s "$headers_path" ]; then + log_error "Response headers:" + sed 's/^/[truckwash-edge-agent] /' "$headers_path" >&2 + fi + if [ -s "$body_path" ]; then + preview="$(head -c 400 "$body_path" || true)" + if [ -n "$preview" ]; then + log_error "Response body preview (first 400 bytes):" + printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent] /' >&2 + fi + fi + [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" + rm -f "$headers_path" + return "$curl_exit" + fi + + if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then + log_error "${description} returned HTTP ${status}." + log_error "Request: GET ${url}" + if [ -s "$headers_path" ]; then + log_error "Response headers:" + sed 's/^/[truckwash-edge-agent] /' "$headers_path" >&2 + fi + if [ -s "$body_path" ]; then + preview="$(head -c 400 "$body_path" || true)" + if [ -n "$preview" ]; then + log_error "Response body preview (first 400 bytes):" + printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent] /' >&2 + fi + fi + [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" + rm -f "$headers_path" + return 1 + fi + + [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" + rm -f "$headers_path" +} +config_has_claimed_gateway() { + local config_path="$1" + php -r ' + $path = $argv[1]; + if (!is_file($path)) { + exit(1); + } + $decoded = json_decode((string)file_get_contents($path), true); + if (!is_array($decoded)) { + exit(1); + } + $gatewayId = isset($decoded["gatewayId"]) ? (int)$decoded["gatewayId"] : 0; + $agentToken = isset($decoded["agentToken"]) ? trim((string)$decoded["agentToken"]) : ""; + exit($gatewayId > 0 && $agentToken !== "" ? 0 : 1); + ' "$config_path" +} +read_config_value() { + local config_path="$1" + local key="$2" + php -r ' + $path = $argv[1]; + $key = $argv[2]; + if (!is_file($path)) { + exit(0); + } + $decoded = json_decode((string)file_get_contents($path), true); + if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null) { + exit(0); + } + $value = $decoded[$key]; + if (is_array($value) || is_object($value)) { + echo json_encode($value, JSON_UNESCAPED_SLASHES); + exit(0); + } + echo (string)$value; + ' "$config_path" "$key" +} +merge_agent_config() { + local template_path="$1" + local config_path="$2" + php -r ' + $templatePath = $argv[1]; + $configPath = $argv[2]; + $template = json_decode((string)file_get_contents($templatePath), true); + if (!is_array($template)) { + fwrite(STDERR, "Invalid edge agent config template.\n"); + exit(1); + } + $existing = []; + if (is_file($configPath)) { + $decoded = json_decode((string)file_get_contents($configPath), true); + if (is_array($decoded)) { + $existing = $decoded; + } + } + foreach (["gatewayId", "agentToken", "agentInstanceId", "installedVersion", "targetVersion", "lastStagedUpdate"] as $key) { + if (array_key_exists($key, $existing) && $existing[$key] !== null && $existing[$key] !== "") { + $template[$key] = $existing[$key]; + } + } + file_put_contents($configPath, json_encode($template, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + ' "$template_path" "$config_path" +} +heartbeat_marker_is_fresh() { + local heartbeat_path="$1" + local minimum_epoch="$2" + if [ ! -f "$heartbeat_path" ]; then + return 1 + fi + local modified_epoch + modified_epoch="$(stat -c %Y "$heartbeat_path" 2>/dev/null || echo 0)" + [ "${modified_epoch:-0}" -ge "$minimum_epoch" ] +} +print_service_diagnostics() { + log_error "truckwash-edge-gateway-stack.service did not complete installation verification." + log_error "systemctl status --no-pager truckwash-edge-gateway-stack.service" + systemctl status --no-pager truckwash-edge-gateway-stack.service || true + log_error "journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager" + journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true + log_error "docker ps --format '{{.Names}} {{.Status}}'" + docker ps --format '{{.Names}} {{.Status}}' || true +} +resolve_compose_command() { + if docker compose version >/dev/null 2>&1; then + echo "docker compose" + return 0 + fi + if command -v docker-compose >/dev/null 2>&1; then + echo "docker-compose" + return 0 + fi + return 1 +} +apt_package_exists() { + local package_name="$1" + apt-cache show "$package_name" 2>/dev/null | grep -q '^Package: ' +} +install_compose_runtime() { + if resolve_compose_command >/dev/null 2>&1; then + return 0 + fi + + if apt_package_exists docker-compose-plugin; then + log_info "Installing Docker Compose package docker-compose-plugin" + apt-get install -y docker-compose-plugin + elif apt_package_exists docker-compose; then + log_info "Installing Docker Compose package docker-compose" + apt-get install -y docker-compose + else + if apt-get install -y docker-compose-plugin; then + : + elif apt-get install -y docker-compose; then + : + else + echo "Unable to install Docker Compose using docker-compose-plugin or docker-compose." >&2 + return 1 + fi + fi + + if ! resolve_compose_command >/dev/null 2>&1; then + echo "Docker Compose command is unavailable after installation." >&2 + return 1 + fi +} +wait_for_gateway_claim() { + local config_path="$1" + local heartbeat_path="$2" + local minimum_epoch="$3" + local timeout_seconds="${4:-30}" + local elapsed=0 + while [ "$elapsed" -lt "$timeout_seconds" ]; do + if config_has_claimed_gateway "$config_path" && heartbeat_marker_is_fresh "$heartbeat_path" "$minimum_epoch"; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + log_error "Gateway claim did not complete within ${timeout_seconds}s." + print_service_diagnostics + return 1 +} +wait_for_post_restart_heartbeat() { + local heartbeat_path="$1" + local minimum_epoch="$2" + local timeout_seconds="${3:-30}" + local elapsed=0 + while [ "$elapsed" -lt "$timeout_seconds" ]; do + if heartbeat_marker_is_fresh "$heartbeat_path" "$minimum_epoch"; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + log_error "Gateway heartbeat was not observed within ${timeout_seconds}s after reinstall." + print_service_diagnostics + return 1 +} + +begin_install_phase "VERIFY_TOKEN" "Verifying install token" +fetch_http "Verify install token" "__VERIFY_URL__" +begin_install_phase "INSTALL_PACKAGES" "Installing runtime dependencies" +run_step "Creating install directory" mkdir -p "$INSTALL_DIR" "$RUNTIME_DIR" "$RUNTIME_DIR/backups" +export DEBIAN_FRONTEND=noninteractive +run_step "Updating package lists" apt-get update +run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3 +run_step "Installing Docker Compose runtime" install_compose_runtime +begin_install_phase "DOWNLOAD_ARTIFACTS" "Downloading edge gateway artifacts" +fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php" +fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php" +fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php" +fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml" +fetch_http "Download edge-agent Dockerfile" "__EDGE_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.edge-agent" +fetch_http "Download lan-worker Dockerfile" "__WORKER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.lan-worker" +fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater" +fetch_http "Download gateway launcher" "__LAUNCHER_URL__" "$INSTALL_DIR/gateway-launcher.sh" +fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service" +fetch_http "Download compatibility service unit" "__LEGACY_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service" +if config_has_claimed_gateway "$CONFIG_PATH"; then + REUSE_EXISTING_CREDENTIALS=1 + log_info "Existing claimed gateway detected; reinstall will reuse saved gateway credentials." +fi +begin_install_phase "WRITE_CONFIG" "Writing gateway configuration" +cat > "$CONFIG_TEMPLATE_PATH" <<'EOF_JSON' +__CONFIG_JSON__ +EOF_JSON +run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH" +rm -f "$CONFIG_TEMPLATE_PATH" +begin_install_phase "START_STACK" "Starting edge gateway stack" +run_step "Installing systemd stack definition" install -m 0644 "$INSTALL_DIR/truckwash-edge-gateway-stack.service" "$STACK_SERVICE_PATH" +run_step "Setting executable permissions" chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh" +run_step "Ensuring Docker is enabled" systemctl enable docker +run_step "Starting Docker" systemctl restart docker +run_step "Checking Docker Compose availability" resolve_compose_command >/dev/null +run_step "Reloading systemd" systemctl daemon-reload +run_step "Enabling truckwash-edge-gateway-stack.service" systemctl enable truckwash-edge-gateway-stack.service +run_step "Restarting truckwash-edge-gateway-stack.service" systemctl restart truckwash-edge-gateway-stack.service +run_step "Verifying truckwash-edge-gateway-stack.service is active" systemctl is-active --quiet truckwash-edge-gateway-stack.service +begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim" +if [ "$REUSE_EXISTING_CREDENTIALS" -eq 1 ]; then + run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180 + claimed_gateway_id="$(read_config_value "$CONFIG_PATH" gatewayId)" + report_install_status "CLAIMED" "CLAIMED" "Gateway reconnected using preserved credentials." "[]" "$claimed_gateway_id" + log_info "Reinstall reused gateway ${claimed_gateway_id}." +else + run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180 + claimed_gateway_id="$(read_config_value "$CONFIG_PATH" gatewayId)" + report_install_status "CLAIMED" "CLAIMED" "Gateway claim completed successfully." "[]" "$claimed_gateway_id" + log_info "Gateway claim completed for gateway ${claimed_gateway_id}." +fi +echo 'TruckWash edge gateway stack installed.' +BASH; + + return strtr($script, [ + '__INSTALL_TOKEN__' => $plainToken, + '__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken), + '__STATUS_URL__' => rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install-token/status', + '__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.php'), + '__WORKER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT), + '__AUTO_UPDATER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT), + '__COMPOSE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_COMPOSE_STACK_FILE), + '__EDGE_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_EDGE_AGENT_DOCKERFILE), + '__WORKER_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_DOCKERFILE), + '__AUTO_UPDATER_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_DOCKERFILE), + '__LAUNCHER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAUNCHER_SCRIPT_NAME), + '__STACK_SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_STACK_SERVICE_NAME), + '__LEGACY_SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME), + '__CONFIG_JSON__' => (string)$configJson, + ]); + } + + /** + * @throws Exception + */ + public function buildUpdateOperationRequest(string $targetVersion, string $releaseChannel): array + { + return array_merge( + $this->buildUpdateCommandPayload($targetVersion, $releaseChannel), + [ + 'target_version' => $targetVersion, + 'release_channel' => $releaseChannel, + ] + ); + } + + /** + * @throws Exception + */ + public function rotateGatewayCredentials(int $gatewayId, ?int $userId = null): array + { + $gateway = $this->requireGateway($gatewayId); + $newToken = bin2hex(random_bytes(32)); + $gateway->agent_token_hash->set($this->hashToken($newToken)); + + $metadata = (array)($gateway->metadata_json->value() ?? []); + $metadata['credentials_rotated_at'] = $this->now(); + $metadata['credential_rotation_requested_by'] = $userId; + $gateway->metadata_json->set($metadata); + + $payload = [ + 'apiUrl' => $this->getApiBaseUrl(), + 'brokerUrl' => $this->buildBrokerPublicUrl(), + 'gatewayId' => (int)$gateway->id, + 'agentToken' => $newToken, + 'installDir' => self::DEFAULT_INSTALL_DIR, + 'runtimeDir' => self::DEFAULT_RUNTIME_DIR, + 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, + 'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME, + 'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE, + 'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME, + 'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME, + 'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH, + 'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL, + 'updateWindow' => (string)($metadata['update_window'] ?? self::configuredDefaultUpdateWindow()), + 'runtimeMode' => (string)($metadata['runtime_mode'] ?? 'compose'), + 'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE, + 'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE, + 'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE, + 'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE, + 'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE, + 'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE, + 'heartbeatIntervalSeconds' => 15, + 'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, + 'installedVersion' => $gateway->installed_version->value() === null ? null : (string)$gateway->installed_version->value(), + ]; + + $this->writeAudit( + (int)$gateway->id, + (int)$gateway->department_id->value(), + 'GATEWAY_CREDENTIALS_ROTATED', + $userId, + [ + 'service_name' => self::DEFAULT_AGENT_SERVICE_NAME, + 'stack_service_name' => self::DEFAULT_STACK_SERVICE_NAME, + ] + ); + + edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); + + return [ + 'gateway_id' => (int)$gateway->id, + 'rotated_at' => (string)$metadata['credentials_rotated_at'], + 'agent_token' => $newToken, + 'config' => $payload, + 'config_json' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + 'restart_instructions' => [ + 'sudo systemctl restart ' . self::DEFAULT_STACK_SERVICE_NAME, + 'sudo systemctl status ' . self::DEFAULT_STACK_SERVICE_NAME . ' --no-pager', + 'cd ' . self::DEFAULT_INSTALL_DIR . ' && sudo ./gateway-launcher.sh reconcile', + ], + ]; + } + + public function syncGatewayInventory(int $gatewayId, array $inventory): void + { + $this->syncDeviceInventory($gatewayId, $inventory); + } + + public function logGatewayAudit(?int $gatewayId, ?int $departmentId, string $action, ?int $userId, array $context): void + { + $this->writeAudit($gatewayId, $departmentId, $action, $userId, $context); + } + + /** + * @throws Exception + */ + public function buildGatewayTasksPage(int $gatewayId): array + { + $gateway = $this->getGateway($gatewayId); + $operations = new edge_gateway_operation_service($this); + + return [ + 'gateway' => $gateway, + 'active_operation' => $operations->getActiveOperation($gatewayId, true), + 'operations' => $operations->listOperations($gatewayId, 20, true), + 'recent_commands' => $this->listRecentObjects( + new edge_gateway_command_jobs_o(), + ['gateway_id' => $gatewayId, 'deleted_at' => null], + 20 + ), + 'recent_operations_summary' => $operations->buildRecentOperationsSummary($gatewayId), + ]; + } + + /** + * @throws Exception + */ + public function buildGatewayLogsPage(int $gatewayId, int $limit = 120): array + { + $gateway = $this->getGateway($gatewayId); + $operations = (new edge_gateway_operation_service($this))->listOperations($gatewayId, 20, true); + $auditLogs = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId], $limit); + $liveLogs = $this->listRecentObjects( + new edge_gateway_log_entries_o(), + ['gateway_id' => $gatewayId], + $limit + ); + $shellSessions = $this->listRecentObjects( + new edge_gateway_shell_sessions_o(), + ['gateway_id' => $gatewayId, 'deleted_at' => null], + 12 + ); + + $relayLogs = []; + $timeline = []; + foreach ($auditLogs as $auditLog) { + $timeline[] = [ + 'type' => 'audit', + 'level' => (string)($auditLog['severity'] ?? 'INFO'), + 'message' => (string)($auditLog['action'] ?? 'AUDIT_EVENT'), + 'created_at' => (string)($auditLog['created_at'] ?? ''), + 'entry' => $auditLog, + ]; + } + foreach ($liveLogs as $logEntry) { + $type = strtolower(trim((string)($logEntry['stream'] ?? ''))) === 'relay' ? 'relay' : 'log'; + if ($type === 'relay') { + $relayLogs[] = $logEntry; + } + $timeline[] = [ + 'type' => $type, + 'level' => (string)($logEntry['level'] ?? 'INFO'), + 'message' => (string)($logEntry['message'] ?? ''), + 'created_at' => (string)($logEntry['created_at'] ?? ''), + 'entry' => $logEntry, + ]; + } + foreach ($operations as $operation) { + foreach ((array)($operation['events'] ?? []) as $event) { + if (!is_array($event)) { + continue; + } + $timeline[] = [ + 'type' => 'operation_event', + 'level' => (string)($event['level'] ?? 'INFO'), + 'message' => (string)($event['message'] ?? ''), + 'created_at' => (string)($event['created_at'] ?? ''), + 'entry' => array_merge($event, [ + 'operation_id' => $operation['id'] ?? null, + 'operation_type' => $operation['type'] ?? null, + ]), + ]; + } + } + + usort( + $timeline, + static fn(array $left, array $right): int => strcmp( + (string)($right['created_at'] ?? ''), + (string)($left['created_at'] ?? '') + ) + ); + + return [ + 'gateway' => $gateway, + 'timeline' => array_slice($timeline, 0, max(20, $limit)), + 'audit_logs' => $auditLogs, + 'log_entries' => $liveLogs, + 'relay_logs' => $relayLogs, + 'shell_sessions' => $shellSessions, + ]; + } + + /** + * @throws Exception + */ + public function buildGatewayStatisticsPage(int $gatewayId): array + { + $gateway = $this->getGateway($gatewayId); + $fleetUsage = $this->buildFleetUsageStatistics((int)$gateway['department_id'], [$gateway]); + + return [ + 'gateway' => $gateway, + 'fleet_usage' => $fleetUsage, + 'channel_status' => (array)($gateway['channel_status'] ?? []), + 'transport_health' => (array)($gateway['transport_health'] ?? []), + 'backlog_depth' => (array)($gateway['backlog_depth'] ?? []), + 'container_health' => (array)($gateway['container_health'] ?? []), + 'system_metrics' => (array)($gateway['metadata']['system_metrics'] ?? []), + 'version_drift' => (array)($gateway['version_drift'] ?? []), + ]; + } + + /** + * @throws Exception + */ + public function validateGatewayAgentForBroker(int $gatewayId, string $plainToken): array + { + $gateway = $this->authenticateGateway($gatewayId, $plainToken); + + return [ + 'id' => (int)$gateway->id, + 'gateway_id' => (int)$gateway->id, + 'department_id' => (int)$gateway->department_id->value(), + 'label' => (string)$gateway->label->value(), + 'broker_url' => $this->buildBrokerPublicUrl(), + ]; + } + + /** + * @throws Exception + */ + public function createBrowserStreamSession(int $gatewayId, ?int $userId, array $scopes = []): array + { + $gateway = $this->requireGateway($gatewayId); + $scopes = array_values(array_unique(array_filter(array_map( + static fn(mixed $scope): string => strtolower(trim((string)$scope)), + $scopes + )))); + if ($scopes === []) { + $scopes = ['overview', 'tasks', 'logs', 'statistics']; + } + + $expiresAt = time() + self::BROWSER_STREAM_TOKEN_TTL_SECONDS; + $token = $this->buildSignedBrokerToken([ + 'session_type' => 'gateway-stream', + 'gateway_id' => (int)$gateway->id, + 'department_id' => (int)$gateway->department_id->value(), + 'user_id' => $userId, + 'scopes' => $scopes, + 'exp' => $expiresAt, + 'iat' => time(), + 'jti' => bin2hex(random_bytes(12)), + ]); + + return [ + 'token' => $token, + 'gateway_id' => (int)$gateway->id, + 'expires_at' => $this->formatDateTime($expiresAt), + 'scopes' => $scopes, + 'broker_url' => $this->buildBrokerPublicUrl(), + 'ws_url' => $this->buildBrokerPublicWebSocketUrl('/ws/browser-gateway-stream'), + ]; + } + + public function validateBrowserStreamToken(string $token): array + { + $payload = $this->parseSignedBrokerToken($token); + if (($payload['session_type'] ?? null) !== 'gateway-stream') { + throw new Exception('Invalid gateway stream token'); + } + + return $payload; + } + + /** + * @throws Exception + */ + public function createShellSession( + int $gatewayId, + ?int $userId, + string $reason = '', + ?int $cols = null, + ?int $rows = null, + ?string $cwd = null + ): array { + $gateway = $this->requireGateway($gatewayId); + $readiness = $this->buildShellReadinessDiagnostics($gateway); + if (empty($readiness['ready'])) { + throw new edge_gateway_operation_exception( + (string)$readiness['message'], + (string)$readiness['reason_code'], + 409, + $readiness + ); + } + + $sessionToken = bin2hex(random_bytes(32)); + $expiresAt = $this->formatDateTime(time() + self::SHELL_SESSION_TTL_SECONDS); + $brokerUrl = $this->buildBrokerPublicUrl(); + $wsUrl = $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell'); + + $sessionObject = new edge_gateway_shell_sessions_o(); + $sessionId = $sessionObject->add_object([ + 'gateway_id' => (int)$gateway->id, + 'department_id' => (int)$gateway->department_id->value(), + 'actor_user_id' => $userId, + 'session_token_hash' => $this->hashToken($sessionToken), + 'status' => 'PENDING', + 'reason' => trim($reason) !== '' ? trim($reason) : 'Diagnostic shell session', + 'cwd' => $cwd ?: self::DEFAULT_INSTALL_DIR, + 'shell_command' => null, + 'shell_args_json' => [], + 'cols' => $cols, + 'terminal_rows' => $rows, + 'transcript' => null, + 'metadata_json' => [ + 'root_dir' => self::DEFAULT_INSTALL_DIR, + 'shell_diagnostics' => $readiness, + 'requested_broker_url' => $brokerUrl, + 'requested_ws_url' => $wsUrl, + 'shortcut_paths' => [ + self::DEFAULT_INSTALL_DIR, + self::DEFAULT_RUNTIME_DIR, + ], + ], + 'expires_at' => $expiresAt, + 'approved_at' => $this->now(), + 'opened_at' => null, + 'closed_at' => null, + ]); + + $session = $sessionObject->select($sessionId)->asArray(); + $this->writeAudit( + (int)$gateway->id, + (int)$gateway->department_id->value(), + 'GATEWAY_SHELL_SESSION_CREATED', + $userId, + ['shell_session_id' => $sessionId, 'reason' => $session['reason']] + ); + + return [ + 'session' => $session, + 'token' => $sessionToken, + 'gateway_id' => (int)$gateway->id, + 'expires_at' => $expiresAt, + 'broker_url' => $brokerUrl, + 'ws_url' => $wsUrl, + 'diagnostics' => $readiness, + ]; + } + + /** + * @throws Exception + */ + public function validateShellSessionToken(string $plainToken): array + { + $session = $this->findShellSessionByToken($plainToken); + $status = strtoupper((string)$session->status->value()); + if (!in_array($status, ['PENDING', 'OPEN'], true)) { + throw new Exception('Shell session is closed'); + } + + $expiresAt = self::parseApplicationDateTime( + $session->expires_at->value() === null ? null : (string)$session->expires_at->value() + ); + if ($expiresAt !== null && $expiresAt <= time()) { + $session->status->set('EXPIRED'); + $session->closed_at->set($this->now()); + throw new Exception('Shell session expired'); + } + + return $session->asArray(); + } + + /** + * @throws Exception + */ + public function markShellSessionOpened(string $plainToken, ?string $connectionId = null): array + { + $session = $this->findShellSessionByToken($plainToken); + if ((string)$session->status->value() !== 'OPEN') { + $session->status->set('OPEN'); + $session->opened_at->set($this->now()); + } + if ($connectionId !== null && trim($connectionId) !== '') { + $session->connection_id->set(trim($connectionId)); + } + + $this->writeAudit( + (int)$session->gateway_id->value(), + (int)$session->department_id->value(), + 'GATEWAY_SHELL_SESSION_OPENED', + $session->actor_user_id->value() === null ? null : (int)$session->actor_user_id->value(), + ['shell_session_id' => (int)$session->id] + ); + + return $session->asArray(); + } + + /** + * @throws Exception + */ + public function closeShellSessionByToken( + string $plainToken, + string $transcript = '', + ?string $reason = null, + array $details = [] + ): array { + $session = $this->findShellSessionByToken($plainToken); + $metadata = (array)($session->metadata_json->value() ?? []); + $closeDiagnostics = $this->normalizeShellCloseDiagnostics($reason, $details); + $metadata['close_reason'] = $reason; + $metadata['close_message'] = $closeDiagnostics['message'] ?? null; + $metadata['close_code'] = $closeDiagnostics['code'] ?? null; + $metadata['close_stage'] = $closeDiagnostics['stage'] ?? null; + $metadata['close_diagnostics'] = $closeDiagnostics; + $metadata['transcript_bytes'] = strlen($transcript); + + $session->status->set($reason === 'agent_exit' ? 'COMPLETED' : 'CLOSED'); + $session->closed_at->set($this->now()); + $session->transcript->set($transcript); + $session->metadata_json->set($metadata); + + $this->writeAudit( + (int)$session->gateway_id->value(), + (int)$session->department_id->value(), + 'GATEWAY_SHELL_SESSION_CLOSED', + $session->actor_user_id->value() === null ? null : (int)$session->actor_user_id->value(), + ['shell_session_id' => (int)$session->id, 'reason' => $reason] + ); + + edge_gateway_view_cache::syncGateway($this->getGateway((int)$session->gateway_id->value())); + + return $session->asArray(); + } + + private function normalizeShellCloseDiagnostics(?string $reason, array $details = []): array + { + $nestedDetails = isset($details['details']) && is_array($details['details']) ? (array)$details['details'] : []; + $message = self::trimInstallSessionText( + $details['message'] ?? $nestedDetails['message'] ?? null, + self::INSTALL_SESSION_OUTPUT_LIMIT + ); + $stage = self::trimInstallSessionText( + $details['stage'] ?? $details['failure_stage'] ?? $nestedDetails['stage'] ?? null, + 128 + ); + $codeValue = $details['code'] ?? $details['close_code'] ?? $nestedDetails['code'] ?? null; + $code = is_numeric($codeValue) ? (int)$codeValue : null; + $diagnostics = [ + 'reason' => self::trimInstallSessionText($reason, 128), + 'message' => $message, + 'code' => $code, + 'stage' => $stage, + 'was_clean' => array_key_exists('was_clean', $details) ? (bool)$details['was_clean'] : null, + 'connection_id' => self::trimInstallSessionText( + $details['connection_id'] ?? $details['broker_connection_id'] ?? $nestedDetails['connection_id'] ?? null, + 128 + ), + 'broker_url' => self::trimInstallSessionText($details['broker_url'] ?? $nestedDetails['broker_url'] ?? null, 512), + 'ws_url' => self::trimInstallSessionText( + $this->redactShellDiagnosticUrl($details['ws_url'] ?? $nestedDetails['ws_url'] ?? null), + 512 + ), + 'closed_at' => $this->now(), + ]; + + return array_filter($diagnostics, static fn(mixed $value): bool => $value !== null && $value !== ''); + } + + private function redactShellDiagnosticUrl(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $url = trim((string)$value); + if ($url === '') { + return null; + } + + return (string)preg_replace('/([?&](?:token|agentToken|agent_token)=)[^&]*/i', '$1***', $url); + } + + /** + * @throws Exception + */ + public function appendGatewayLogEntry( + int $gatewayId, + string $message, + string $level = 'INFO', + string $stream = 'agent', + string $source = 'BROKER', + array $context = [] + ): array { + $gateway = $this->requireGateway($gatewayId); + $logEntryId = (new edge_gateway_log_entries_o())->add_object([ + 'gateway_id' => $gatewayId, + 'department_id' => (int)$gateway->department_id->value(), + 'level' => strtoupper(trim($level)) ?: 'INFO', + 'stream' => trim($stream) !== '' ? trim($stream) : 'agent', + 'source' => trim($source) !== '' ? trim($source) : 'BROKER', + 'message' => $message, + 'context_json' => $context, + ]); + + edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); + + return (new edge_gateway_log_entries_o())->select($logEntryId)->asArray(); + } + + /** + * @throws Exception + */ + public function appendRelayTransportLog( + int $departmentId, + string $endpoint, + array $payload, + array|object|null $response, + string $handler = 'cloud', + ?string $errorMessage = null, + array $actionContext = [] + ): ?array { + try { + $gateway = $this->getPrimaryGatewayForDepartment($departmentId, false); + } catch (Exception) { + return null; + } + + $relayId = $this->inferRelayIdFromTransportPayload($payload); + $success = $errorMessage === null || trim($errorMessage) === ''; + $targetOn = array_key_exists('on', $payload) ? (bool)$payload['on'] : null; + $handler = strtolower(trim($handler)) === 'local' ? 'local' : 'cloud'; + + return $this->appendRelayDispatchLog( + [ + 'id' => null, + 'gateway_id' => (int)$gateway->id, + 'department_id' => $departmentId, + 'relay_id' => $relayId, + 'device_id' => $payload['deviceId'] ?? $payload['device_id'] ?? null, + 'channel' => $payload['channel'] ?? null, + ], + [ + 'execution_path' => $handler, + 'delivery_channel' => $handler === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : self::DELIVERY_CHANNEL_API, + 'reason' => 'direct_transport', + ], + $success, + [ + 'action' => $this->inferRelayActionFromEndpoint($endpoint), + 'handler' => $handler, + 'relay_id' => $relayId, + 'target_on' => $targetOn, + 'toggle_after_seconds' => $this->normalizeRelayToggleAfter( + isset($payload['toggle_after']) || isset($payload['toggleAfter']) || isset($payload['timer']) + ? (int)($payload['toggle_after'] ?? $payload['toggleAfter'] ?? $payload['timer']) + : null + ), + 'signal' => [ + 'endpoint' => $endpoint, + 'request' => $payload, + ], + 'action_context' => $this->normalizeRelayActionContext($actionContext), + ], + $this->normalizeRelayTransportResponse($response), + $success ? null : new Exception($errorMessage ?? 'Relay transport request failed') + ); + } + + /** + * @throws Exception + */ + public function recordTelemetryFromBroker(int $gatewayId, array $payload): array + { + $gateway = $this->requireGateway($gatewayId); + $now = $this->now(); + $metadata = array_merge( + (array)($gateway->metadata_json->value() ?? []), + isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [] + ); + $existingPresence = isset($metadata['broker_presence']) && is_array($metadata['broker_presence']) + ? (array)$metadata['broker_presence'] + : []; + $presenceMetadata = isset($existingPresence['metadata']) && is_array($existingPresence['metadata']) + ? (array)$existingPresence['metadata'] + : []; + $payloadMetadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []; + $connectionId = trim((string)($payload['broker_connection_id'] ?? $existingPresence['connection_id'] ?? '')); + $presence = [ + 'gateway_id' => $gatewayId, + 'connected' => true, + 'connection_id' => $connectionId !== '' ? $connectionId : null, + 'last_seen_at' => $now, + 'disconnect_reason' => null, + 'last_error' => null, + 'metadata' => array_merge($presenceMetadata, array_filter([ + 'agent_instance_id' => $payload['broker_agent_instance_id'] + ?? $payloadMetadata['agent_instance_id'] + ?? null, + ], static fn(mixed $value): bool => $value !== null && $value !== '')), + ]; + $metadata['broker_presence'] = $presence; + $metadata['broker_connected'] = true; + $metadata['broker_connected_at'] = $now; + $metadata['broker_last_error'] = null; + $this->writeBrokerPresence($gatewayId, $presence); + + $gateway->status->set((string)($payload['status'] ?? self::STATUS_ONLINE)); + $gateway->hostname->set($payload['hostname'] ?? $gateway->hostname->value()); + $gateway->installed_version->set($payload['installed_version'] ?? $gateway->installed_version->value()); + $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value()); + $gateway->last_heartbeat_at->set($now); + $gateway->metadata_json->set($metadata); + + if (isset($payload['inventory']) && is_array($payload['inventory'])) { + $this->syncDeviceInventory($gatewayId, (array)$payload['inventory']); + } + + $gatewayPayload = $this->getGateway($gatewayId); + edge_gateway_view_cache::syncGateway($gatewayPayload); + return $gatewayPayload; + } + + private function buildUpdateCommandPayload(string $targetVersion, string $releaseChannel): array + { + return [ + 'targetVersion' => $targetVersion, + 'releaseChannel' => $releaseChannel, + 'artifactUrl' => $this->buildAgentArtifactUrl('agent.php'), + 'artifactSha256' => $this->buildAgentArtifactSha256('agent.php'), + 'serviceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME), + 'serviceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AGENT_SERVICE_NAME), + 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, + 'runtimeMode' => 'compose', + 'installDir' => self::DEFAULT_INSTALL_DIR, + 'runtimeDir' => self::DEFAULT_RUNTIME_DIR, + 'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME, + 'stackServiceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_STACK_SERVICE_NAME), + 'stackServiceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_STACK_SERVICE_NAME), + 'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE, + 'composeFileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_COMPOSE_STACK_FILE), + 'composeFileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_COMPOSE_STACK_FILE), + 'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME, + 'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME, + 'launcherScriptUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAUNCHER_SCRIPT_NAME), + 'launcherScriptSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAUNCHER_SCRIPT_NAME), + 'lanWorkerArtifactUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT), + 'lanWorkerArtifactSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAN_WORKER_ARTIFACT), + 'autoUpdaterArtifactUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT), + 'autoUpdaterArtifactSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AUTO_UPDATER_ARTIFACT), + 'edgeAgentDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_EDGE_AGENT_DOCKERFILE), + 'edgeAgentDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_EDGE_AGENT_DOCKERFILE), + 'lanWorkerDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_DOCKERFILE), + 'lanWorkerDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAN_WORKER_DOCKERFILE), + 'autoUpdaterDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_DOCKERFILE), + 'autoUpdaterDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AUTO_UPDATER_DOCKERFILE), + 'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH, + 'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL, + 'updateWindow' => self::configuredDefaultUpdateWindow(), + 'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE, + 'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE, + 'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE, + 'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE, + 'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE, + 'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE, + ]; + } + + private function buildAgentArtifactUrl(string $fileName): string + { + return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/artifacts/' . $fileName; + } + + private function buildAgentArtifactPath(string $fileName): string + { + return edge_gateway_agent_artifact_locator::resolve($fileName); + } + + /** + * @throws Exception + */ + private function buildAgentArtifactSha256(string $fileName): string + { + $artifactPath = $this->buildAgentArtifactPath($fileName); + if (!is_file($artifactPath)) { + throw new Exception('Missing edge agent artifact: ' . $fileName); + } + + $sha256 = hash_file('sha256', $artifactPath); + if ($sha256 === false) { + throw new Exception('Unable to checksum edge agent artifact: ' . $fileName); + } + + return $sha256; + } + + /** + * @throws Exception + */ + public function verifyInstallToken(string $plainToken): array + { + $claimToken = $this->requireClaimToken($plainToken); + if ($claimToken->used_at->value() !== null) { + throw new Exception('Install token has already been used'); + } + + return [ + 'valid' => true, + 'claim_token_id' => (int)$claimToken->id, + 'department_id' => (int)$claimToken->department_id->value(), + 'label' => $claimToken->label->value(), + 'expires_at' => (string)$claimToken->expires_at->value(), + ]; + } + + /** + * @throws Exception + */ + public function getInstallTokenStatus(int $claimTokenId): array + { + return $this->buildInstallTokenStatusPayload($this->requireClaimTokenById($claimTokenId)); + } + + /** + * @throws Exception + */ + public function reportInstallTokenStatus(string $plainToken, array $payload): array + { + $claimToken = $this->requireClaimToken($plainToken); + $status = strtoupper(trim((string)($payload['status'] ?? self::INSTALL_SESSION_STATUS_RUNNING))); + $step = trim((string)($payload['step'] ?? ($status === self::INSTALL_SESSION_STATUS_FAILED ? 'FAILED' : $status))); + $message = trim((string)($payload['message'] ?? '')); + $update = [ + 'status' => $status, + 'step' => $step !== '' ? $step : ($status === self::INSTALL_SESSION_STATUS_FAILED ? 'FAILED' : null), + 'message' => $message !== '' ? $message : null, + 'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [], + ]; + + if (array_key_exists('gateway_id', $payload)) { + $update['gateway_id'] = (int)$payload['gateway_id']; + } + if (array_key_exists('last_error', $payload)) { + $update['last_error'] = $payload['last_error']; + } elseif ($status === self::INSTALL_SESSION_STATUS_FAILED) { + $update['last_error'] = $message !== '' ? $message : 'Installer failed.'; + } elseif ($status === self::INSTALL_SESSION_STATUS_CLAIMED) { + $update['last_error'] = null; + } + + return $this->persistInstallSession($claimToken, $update); + } + + public static function installSessionStatusIsTerminal(string $status): bool + { + return in_array( + strtoupper(trim($status)), + [ + self::INSTALL_SESSION_STATUS_CLAIMED, + self::INSTALL_SESSION_STATUS_FAILED, + self::INSTALL_SESSION_STATUS_EXPIRED, + ], + true + ); + } + + /** + * @param array $session + * @param array $update + * @return array + */ + public static function mergeInstallSessionUpdate(array $session, array $update, ?int $now = null): array + { + $timestamp = date('Y-m-d H:i:s', $now ?? time()); + $status = strtoupper(trim((string)($update['status'] ?? $session['status'] ?? self::INSTALL_SESSION_STATUS_PENDING))); + $step = trim((string)($update['step'] ?? $session['step'] ?? '')); + $message = self::trimInstallSessionText($update['message'] ?? ($session['message'] ?? null), self::INSTALL_SESSION_OUTPUT_LIMIT); + $startedAt = isset($session['started_at']) ? self::trimInstallSessionText($session['started_at'], 64) : null; + if ($startedAt === null && $status !== self::INSTALL_SESSION_STATUS_PENDING) { + $startedAt = $timestamp; + } + + $gatewayId = array_key_exists('gateway_id', $update) ? (int)$update['gateway_id'] : (int)($session['gateway_id'] ?? 0); + $lastError = array_key_exists('last_error', $update) + ? self::trimInstallSessionText($update['last_error'], self::INSTALL_SESSION_OUTPUT_LIMIT) + : self::trimInstallSessionText($session['last_error'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT); + if ($status === self::INSTALL_SESSION_STATUS_CLAIMED) { + $lastError = null; + } elseif ($status === self::INSTALL_SESSION_STATUS_FAILED && $lastError === null) { + $lastError = $message ?? 'Installer failed.'; + } + + $diagnostics = array_key_exists('diagnostics', $update) + ? self::sanitizeInstallSessionDiagnostics($update['diagnostics']) + : self::sanitizeInstallSessionDiagnostics($session['diagnostics'] ?? []); + if ($status === self::INSTALL_SESSION_STATUS_CLAIMED) { + $diagnostics = []; + } + + $events = self::sanitizeInstallSessionEvents($session['events'] ?? []); + $shouldRecordEvent = !array_key_exists('record_event', $update) || $update['record_event'] !== false; + if ($shouldRecordEvent) { + $events[] = array_filter([ + 'status' => $status, + 'step' => $step !== '' ? $step : null, + 'message' => $message, + 'at' => $timestamp, + ], static fn(mixed $value): bool => $value !== null && $value !== ''); + } + $events = self::sanitizeInstallSessionEvents($events); + + return [ + 'status' => $status, + 'step' => $step !== '' ? $step : null, + 'message' => $message, + 'started_at' => $startedAt, + 'updated_at' => $timestamp, + 'gateway_id' => $gatewayId > 0 ? $gatewayId : null, + 'last_error' => $lastError, + 'diagnostics' => $diagnostics, + 'events' => $events, + ]; + } + + /** + * @param array $session + * @return array + */ + public static function normalizeInstallSessionRecord(array $session, ?string $expiresAt, ?int $now = null): array + { + $normalized = [ + 'status' => strtoupper(trim((string)($session['status'] ?? self::INSTALL_SESSION_STATUS_PENDING))), + 'step' => self::trimInstallSessionText($session['step'] ?? null, 64), + 'message' => self::trimInstallSessionText($session['message'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT), + 'started_at' => self::trimInstallSessionText($session['started_at'] ?? null, 64), + 'updated_at' => self::trimInstallSessionText($session['updated_at'] ?? null, 64), + 'gateway_id' => (($session['gateway_id'] ?? null) !== null && (int)$session['gateway_id'] > 0) ? (int)$session['gateway_id'] : null, + 'last_error' => self::trimInstallSessionText($session['last_error'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT), + 'diagnostics' => self::sanitizeInstallSessionDiagnostics($session['diagnostics'] ?? []), + 'events' => self::sanitizeInstallSessionEvents($session['events'] ?? []), + ]; + $status = (string)$normalized['status']; + if ( + !self::installSessionStatusIsTerminal($status) + && $expiresAt !== null + && (self::parseApplicationDateTime($expiresAt) ?? PHP_INT_MAX) < ($now ?? time()) + ) { + $status = self::INSTALL_SESSION_STATUS_EXPIRED; + $normalized['status'] = $status; + $normalized['message'] = $normalized['message'] ?: 'Installer token expired before the gateway claimed successfully.'; + $normalized['last_error'] = $normalized['last_error'] ?: 'Install token expired.'; + } + + $normalized['terminal'] = self::installSessionStatusIsTerminal((string)$status); + return $normalized; + } + + /** + * @param mixed $diagnostics + * @return array> + */ + private static function sanitizeInstallSessionDiagnostics(mixed $diagnostics): array + { + if (!is_array($diagnostics)) { + return []; + } + + $normalized = []; + foreach ($diagnostics as $diagnostic) { + if (is_string($diagnostic)) { + $output = self::trimInstallSessionText($diagnostic, self::INSTALL_SESSION_OUTPUT_LIMIT); + if ($output === null) { + continue; + } + + $normalized[] = [ + 'name' => 'Diagnostic', + 'output' => $output, + ]; + continue; + } + + if (!is_array($diagnostic)) { + continue; + } + + $name = self::trimInstallSessionText($diagnostic['name'] ?? $diagnostic['title'] ?? null, 120); + $output = self::trimInstallSessionText($diagnostic['output'] ?? $diagnostic['body'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT); + if ($name === null || $output === null) { + continue; + } + + $normalized[] = [ + 'name' => $name, + 'output' => $output, + ]; + } + + return array_slice($normalized, -self::INSTALL_SESSION_DIAGNOSTIC_LIMIT); + } + + /** + * @param mixed $events + * @return array> + */ + private static function sanitizeInstallSessionEvents(mixed $events): array + { + if (!is_array($events)) { + return []; + } + + $normalized = []; + foreach ($events as $event) { + if (!is_array($event)) { + continue; + } + + $status = self::trimInstallSessionText($event['status'] ?? null, 32); + $step = self::trimInstallSessionText($event['step'] ?? null, 64); + $message = self::trimInstallSessionText($event['message'] ?? null, 512); + $at = self::trimInstallSessionText($event['at'] ?? null, 64); + + $normalized[] = array_filter([ + 'status' => $status, + 'step' => $step, + 'message' => $message, + 'at' => $at, + ], static fn(mixed $value): bool => $value !== null && $value !== ''); + } + + return array_slice($normalized, -self::INSTALL_SESSION_EVENT_LIMIT); + } + + private static function trimInstallSessionText(mixed $value, int $limit): ?string + { + if ($value === null) { + return null; + } + + $text = trim((string)$value); + if ($text === '') { + return null; + } + + if (strlen($text) <= $limit) { + return $text; + } + + return substr($text, 0, max(0, $limit - 3)) . '...'; + } + + /** + * @throws Exception + */ + private function buildInstallTokenStatusPayload(edge_gateway_claim_tokens_o $claimToken): array + { + $metadata = (array)($claimToken->metadata_json->value() ?? []); + $session = self::normalizeInstallSessionRecord( + isset($metadata['install_session']) && is_array($metadata['install_session']) + ? (array)$metadata['install_session'] + : [], + (string)$claimToken->expires_at->value() + ); + + return [ + 'claim_token_id' => (int)$claimToken->id, + 'department_id' => (int)$claimToken->department_id->value(), + 'label' => $claimToken->label->value() === null ? null : (string)$claimToken->label->value(), + 'expires_at' => (string)$claimToken->expires_at->value(), + 'status' => (string)$session['status'], + 'step' => $session['step'] ?? null, + 'message' => $session['message'] ?? null, + 'started_at' => $session['started_at'] ?? null, + 'updated_at' => $session['updated_at'] ?? null, + 'terminal' => (bool)($session['terminal'] ?? false), + 'gateway_id' => $session['gateway_id'] ?? null, + 'last_error' => $session['last_error'] ?? null, + 'diagnostics' => $session['diagnostics'] ?? [], + 'events' => $session['events'] ?? [], + ]; + } + + /** + * @throws Exception + */ + private function persistInstallSession(edge_gateway_claim_tokens_o $claimToken, array $update): array + { + $metadata = (array)($claimToken->metadata_json->value() ?? []); + $metadata['install_session'] = self::mergeInstallSessionUpdate( + isset($metadata['install_session']) && is_array($metadata['install_session']) + ? (array)$metadata['install_session'] + : [], + $update + ); + $claimToken->metadata_json->set($metadata); + + return $this->buildInstallTokenStatusPayload($claimToken); + } + + public function getApiBaseUrl(): string + { + $configured = trim((string)(getenv('EDGE_PUBLIC_API_URL') ?: '')); + if ($configured !== '') { + return $configured; + } + + $forwardedScheme = $this->detectForwardedScheme(); + if ($forwardedScheme !== null) { + $scheme = strtolower($forwardedScheme) === 'https' ? 'https' : 'http'; + } else { + $requestScheme = strtolower(trim((string)($_SERVER['REQUEST_SCHEME'] ?? ''))); + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $requestScheme === 'https' ? 'https' : 'http'; + } + + $host = trim((string)($this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_HOST'] ?? null) ?? ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost'))); + if ($host === '') { + $host = 'localhost'; + } + + $forwardedPort = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PORT'] ?? null); + $port = $forwardedPort !== null ? (int)$forwardedPort : 0; + if ($port <= 0) { + $hostPort = parse_url($scheme . '://' . $host, PHP_URL_PORT); + $port = is_int($hostPort) ? $hostPort : (int)($_SERVER['SERVER_PORT'] ?? 0); + } + if ($scheme === 'http' && in_array($port, [443, 4433], true)) { + $scheme = 'https'; + } + if ($port > 0 && !str_contains($host, ':') && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) { + $host .= ':' . $port; + } + + $forwardedPrefix = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null); + if ($forwardedPrefix !== null) { + $normalizedForwardedPrefix = '/' . trim($forwardedPrefix, '/'); + $basePath = $normalizedForwardedPrefix === '/' ? '' : $normalizedForwardedPrefix; + } else { + $requestPath = parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH); + $basePath = (is_string($requestPath) && preg_match('#^/api(?:/|$)#', $requestPath) === 1) ? '/api' : ''; + } + + return $scheme . '://' . $host . $basePath; + } + + private function detectForwardedScheme(): ?string + { + $forwardedScheme = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null); + if ($forwardedScheme !== null) { + return $forwardedScheme; + } + + $forwardedScheme = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PROTOCOL'] ?? null); + if ($forwardedScheme !== null) { + return $forwardedScheme; + } + + $forwardedHeader = trim((string)($_SERVER['HTTP_FORWARDED'] ?? '')); + if ($forwardedHeader !== '' && preg_match('/proto=([^;,\s]+)/i', $forwardedHeader, $matches) === 1) { + return trim($matches[1], "\"'"); + } + + return null; + } + + private function firstForwardedHeaderValue(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + foreach (explode(',', $value) as $segment) { + $normalized = trim($segment); + if ($normalized !== '') { + return $normalized; + } + } + + return null; + } + + /** + * @throws Exception + */ + private function requireDispatchableGateway(int $gatewayId): edge_gateways_o + { + $gateway = $this->requireGateway($gatewayId); + $effectiveStatus = self::resolveGatewayStatus( + $gateway->status->value() === null ? null : (string)$gateway->status->value(), + $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() + ); + + if (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { + throw new Exception('Gateway agent is offline'); + } + + return $gateway; + } + + /** + * @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'); + } + + if (!$this->departmentExists((int)$gateway->department_id->value())) { + $this->softDeleteOrphanedGateway($gateway); + throw new Exception('Edge gateway not found'); + } + + return $gateway; + } + + private function departmentExists(int $departmentId): bool + { + if ($departmentId <= 0) { + return false; + } + + try { + return (new departments_o())->select($departmentId)->exists(); + } catch (\Throwable) { + return true; + } + } + + private function softDeleteOrphanedGateway(edge_gateways_o $gateway): void + { + $gatewayId = (int)$gateway->id; + $departmentId = (int)$gateway->department_id->value(); + + try { + $this->softDeleteGatewayRelations($gatewayId); + } catch (\Throwable) { + } + + try { + if ($gateway->deleted_at->value() === null) { + $gateway->deleted_at->set($this->now()); + } + } catch (\Throwable) { + } + + edge_gateway_view_cache::removeGateway($gatewayId, $departmentId > 0 ? $departmentId : null); + } + + private function softDeleteGatewayRelations(int $gatewayId): void + { + $tables = [ + 'edge_gateway_device_inventory', + 'edge_gateway_relay_bindings', + 'edge_gateway_command_jobs', + 'edge_gateway_operations', + ]; + $pdo = db::getPDO(); + $deletedAt = $this->now(); + + foreach ($tables as $table) { + $statement = $pdo->prepare( + "UPDATE {$table} + SET deleted_at = :deleted_at + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL" + ); + $statement->execute([ + ':deleted_at' => $deletedAt, + ':gateway_id' => $gatewayId, + ]); + } + } + + /** + * @throws Exception + */ + private function requireDepartment(int $departmentId): departments_o + { + $department = (new departments_o())->select($departmentId); + if (!$department->exists()) { + throw new Exception('Department not found'); + } + + return $department; + } + + /** + * @throws Exception + */ + private function requireClaimToken(string $plainToken): edge_gateway_claim_tokens_o + { + $rows = (new edge_gateway_claim_tokens_o())->getFieldsWhere([ + 'token_hash' => $this->hashToken($plainToken), + 'deleted_at' => null, + ], ['id']); + + if ($rows === []) { + throw new Exception('Invalid install token'); + } + + $claimToken = (new edge_gateway_claim_tokens_o())->select((int)$rows[0]['id']); + if (!$claimToken->exists()) { + throw new Exception('Invalid install token'); + } + if ((self::parseApplicationDateTime((string)$claimToken->expires_at->value()) ?? 0) < time()) { + throw new Exception('Install token has expired'); + } + + return $claimToken; + } + + /** + * @throws Exception + */ + private function requireClaimTokenById(int $claimTokenId): edge_gateway_claim_tokens_o + { + if ($claimTokenId <= 0) { + throw new Exception('Invalid install token'); + } + + $claimToken = (new edge_gateway_claim_tokens_o())->select($claimTokenId); + if (!$claimToken->exists() || $claimToken->deleted_at->value() !== null) { + throw new Exception('Invalid install token'); + } + + return $claimToken; + } + + /** + * @throws Exception + */ + private function getPrimaryGatewayForDepartment(int $departmentId, bool $requireDispatchable = true): edge_gateways_o + { + $rows = (new edge_gateways_o())->getFieldsWhere([ + 'department_id' => $departmentId, + 'deleted_at' => null, + ], ['id']); + + if ($rows === []) { + throw new Exception('No edge gateway found for department'); + } + + $gatewayIds = array_map(static fn(array $row): int => (int)$row['id'], $rows); + $gateways = array_map(static fn(int $id): edge_gateways_o => (new edge_gateways_o())->select($id), $gatewayIds); + usort($gateways, static function (edge_gateways_o $a, edge_gateways_o $b): int { + $aStatus = self::resolveGatewayStatus( + $a->status->value() === null ? null : (string)$a->status->value(), + $a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value() + ); + $bStatus = self::resolveGatewayStatus( + $b->status->value() === null ? null : (string)$b->status->value(), + $b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value() + ); + + return ((int)$b->is_primary->value() <=> (int)$a->is_primary->value()) + ?: (self::statusPriority($bStatus) <=> self::statusPriority($aStatus)) + ?: (self::heartbeatTimestamp($b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value()) + <=> self::heartbeatTimestamp($a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value())); + }); + + $gateway = $gateways[0]; + $effectiveStatus = self::resolveGatewayStatus( + $gateway->status->value() === null ? null : (string)$gateway->status->value(), + $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() + ); + + if ($requireDispatchable && !in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { + throw new Exception('Department edge gateway is offline'); + } + + return $gateway; + } + + /** + * @throws Exception + */ + private function setGatewayPrimaryState(edge_gateways_o $gateway, bool $isPrimary): void + { + if ($isPrimary) { + $statement = db::getPDO()->prepare( + "UPDATE edge_gateways + SET is_primary = CASE WHEN id = :gateway_id THEN 1 ELSE 0 END + WHERE department_id = :department_id + AND deleted_at IS NULL" + ); + $statement->execute([ + ':gateway_id' => (int)$gateway->id, + ':department_id' => (int)$gateway->department_id->value(), + ]); + $gateway->is_primary->set(true); + return; + } + + $replacement = $this->findAlternateGatewayForDepartment( + (int)$gateway->department_id->value(), + (int)$gateway->id + ); + if ($replacement === null) { + throw new Exception('Department must retain a primary gateway'); + } + + $replacement->is_primary->set(true); + $gateway->is_primary->set(false); + } + + private function findAlternateGatewayForDepartment(int $departmentId, int $excludedGatewayId): ?edge_gateways_o + { + $rows = (new edge_gateways_o())->getFieldsWhere([ + 'department_id' => $departmentId, + 'deleted_at' => null, + ], ['id']); + + $gatewayIds = array_values(array_filter( + array_map(static fn(array $row): int => (int)$row['id'], $rows), + static fn(int $gatewayId): bool => $gatewayId !== $excludedGatewayId + )); + + if ($gatewayIds === []) { + return null; + } + + $gateways = array_map(static fn(int $id): edge_gateways_o => (new edge_gateways_o())->select($id), $gatewayIds); + usort($gateways, static function (edge_gateways_o $a, edge_gateways_o $b): int { + $aStatus = self::resolveGatewayStatus( + $a->status->value() === null ? null : (string)$a->status->value(), + $a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value() + ); + $bStatus = self::resolveGatewayStatus( + $b->status->value() === null ? null : (string)$b->status->value(), + $b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value() + ); + + return ((int)$b->is_primary->value() <=> (int)$a->is_primary->value()) + ?: (self::statusPriority($bStatus) <=> self::statusPriority($aStatus)) + ?: (self::heartbeatTimestamp($b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value()) + <=> self::heartbeatTimestamp($a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value())); + }); + + return $gateways[0] ?? null; + } + + private function createCommandJob( + int $gatewayId, + string $commandType, + array $request, + ?int $userId, + array $delivery = [] + ): edge_gateway_command_jobs_o + { + $deliveryMetadata = $this->buildDeliveryMetadata($delivery, self::COMMAND_EXPIRES_AFTER_SECONDS); + $jobObject = new edge_gateway_command_jobs_o(); + $jobId = $jobObject->add_object([ + 'gateway_id' => $gatewayId, + 'command_type' => $commandType, + 'status' => 'PENDING', + 'request_json' => $request, + 'response_json' => [], + 'delivery_json' => $deliveryMetadata, + 'correlation_id' => bin2hex(random_bytes(16)), + 'requested_by' => $userId, + 'requested_at' => $this->now(), + ]); + + return $jobObject->select($jobId); + } + + private function expireTimedOutRelayStatusCommandJobs(int $gatewayId): void + { + if ($gatewayId <= 0) { + return; + } + + $statement = db::getPDO()->prepare( + "SELECT id + FROM edge_gateway_command_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND command_type = 'GET_RELAY_STATUS' + AND status IN ('PENDING', 'DISPATCHING') + AND requested_at <= :cutoff + ORDER BY requested_at ASC, id ASC + LIMIT 200" + ); + $statement->execute([ + ':gateway_id' => $gatewayId, + ':cutoff' => $this->formatDateTime(time() - self::COMMAND_WAIT_TIMEOUT_SECONDS), + ]); + + foreach ($statement->fetchAll() ?: [] as $row) { + $jobId = (int)($row['id'] ?? 0); + if ($jobId <= 0) { + continue; + } + + $job = (new edge_gateway_command_jobs_o())->select($jobId); + if ($job->exists()) { + $this->finalizeCommandJob($job, false, [], 'Edge gateway command timed out', null, 'TIMED_OUT'); + } + } + } + + /** + * @throws Exception + */ + private function waitForCommandResult(int $jobId, int $timeoutSeconds = self::COMMAND_WAIT_TIMEOUT_SECONDS): array + { + $deadline = microtime(true) + max(0, $timeoutSeconds); + + do { + $job = (new edge_gateway_command_jobs_o())->select($jobId); + if (!$job->exists()) { + throw new Exception('Edge gateway command job not found'); + } + + $status = (string)$job->status->value(); + if ($status === 'COMPLETED') { + $response = (array)($job->response_json->value() ?? []); + return (array)($response['payload'] ?? []); + } + + if ($status === 'FAILED') { + $errorMessage = trim((string)($job->error_message->value() ?? '')); + throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command failed'); + } + if ($status === 'TIMED_OUT') { + $errorMessage = trim((string)($job->error_message->value() ?? '')); + throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command timed out'); + } + + if (microtime(true) >= $deadline) { + break; + } + + usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS); + } while (true); + + $job = (new edge_gateway_command_jobs_o())->select($jobId); + if ($job->exists()) { + $status = (string)$job->status->value(); + if ($status === 'COMPLETED') { + $response = (array)($job->response_json->value() ?? []); + return (array)($response['payload'] ?? []); + } + if ($status === 'FAILED') { + $errorMessage = trim((string)($job->error_message->value() ?? '')); + throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command failed'); + } + if ($status === 'TIMED_OUT') { + $errorMessage = trim((string)($job->error_message->value() ?? '')); + throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command timed out'); + } + + $this->finalizeCommandJob($job, false, [], 'Edge gateway command timed out', null, 'TIMED_OUT'); + } + + throw new Exception('Edge gateway command timed out'); + } + + private function claimNextCommandJob(edge_gateways_o $gateway): ?edge_gateway_command_jobs_o + { + $pdo = db::getPDO(); + $pdo->beginTransaction(); + + try { + $statement = $pdo->prepare( + 'SELECT id, delivery_json + FROM edge_gateway_command_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND ( + status = :pending_status_match + OR ( + status = :dispatching_status_match + AND COALESCE(updated_at, created_at, requested_at) <= :stale_before + ) + ) + ORDER BY CASE WHEN status = :pending_status_order THEN 0 ELSE 1 END, requested_at ASC, id ASC + LIMIT 1 + FOR UPDATE' + ); + $statement->execute([ + ':gateway_id' => (int)$gateway->id, + ':pending_status_match' => 'PENDING', + ':dispatching_status_match' => 'DISPATCHING', + ':pending_status_order' => 'PENDING', + ':stale_before' => $this->formatDateTime(time() - self::COMMAND_DISPATCH_STALE_AFTER_SECONDS), + ]); + + $row = $statement->fetch(); + if (!is_array($row) || !isset($row['id'])) { + $pdo->commit(); + return null; + } + + $delivery = isset($row['delivery_json']) && is_string($row['delivery_json']) + ? json_decode($row['delivery_json'], true) + : []; + if (!is_array($delivery)) { + $delivery = []; + } + $delivery['delivery_channel'] = self::DELIVERY_CHANNEL_API; + $delivery['attempt_count'] = ((int)($delivery['attempt_count'] ?? 0)) + 1; + $delivery['last_dispatch_error'] = null; + $encodedDelivery = json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($encodedDelivery)) { + $encodedDelivery = '{}'; + } + + $update = $pdo->prepare( + 'UPDATE edge_gateway_command_jobs + SET status = :status, + response_json = :response_json, + delivery_json = :delivery_json, + error_message = NULL, + completed_at = NULL + WHERE id = :id' + ); + $update->execute([ + ':status' => 'DISPATCHING', + ':response_json' => json_encode([], JSON_UNESCAPED_UNICODE), + ':delivery_json' => $encodedDelivery, + ':id' => (int)$row['id'], + ]); + + $pdo->commit(); + } catch (\Throwable $throwable) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $throwable; + } + + $this->clearObjectPropertyCache('edge_gateway_command_jobs', (int)$row['id']); + return (new edge_gateway_command_jobs_o())->select((int)$row['id']); + } + + private function formatAgentCommandJob(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array + { + return [ + 'id' => (int)$job->id, + 'gateway_id' => (int)$gateway->id, + 'department_id' => (int)$gateway->department_id->value(), + 'command_type' => (string)$job->command_type->value(), + 'commandType' => (string)$job->command_type->value(), + 'payload' => $this->buildCommandExecutionPayload($job, $gateway), + 'requested_at' => (string)$job->requested_at->value(), + ]; + } + + private function buildCommandExecutionPayload(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array + { + return array_merge([ + 'jobId' => (int)$job->id, + 'gatewayId' => (int)$gateway->id, + 'departmentId' => (int)$gateway->department_id->value(), + 'correlationId' => (string)$job->correlation_id->value(), + ], (array)($job->request_json->value() ?? [])); + } + + private function finalizeCommandJob( + edge_gateway_command_jobs_o $job, + bool $ok, + array $payload = [], + ?string $errorMessage = null, + ?edge_gateways_o $gateway = null, + ?string $terminalStatus = null + ): void { + $gatewayObject = $gateway ?? $this->requireGateway((int)$job->gateway_id->value()); + $response = [ + 'ok' => $ok, + 'payload' => $payload, + ]; + if (!$ok && $errorMessage !== null && trim($errorMessage) !== '') { + $response['error'] = $errorMessage; + } + + $job->response_json->set($response); + $job->delivery_json->set($this->buildDeliveryMetadata( + array_merge( + (array)($job->delivery_json->value() ?? []), + [ + 'delivery_channel' => ((array)($job->delivery_json->value() ?? []))['delivery_channel'] ?? self::DELIVERY_CHANNEL_API, + 'last_dispatch_error' => $ok ? null : $errorMessage, + ] + ), + self::COMMAND_EXPIRES_AFTER_SECONDS + )); + $job->completed_at->set($this->now()); + $job->error_message->set($ok ? null : $errorMessage); + $job->status->set($ok ? 'COMPLETED' : $this->normalizeCommandFailureStatus($terminalStatus)); + + $this->applyCommandResult($gatewayObject, $job, $ok, $payload, $errorMessage); + } + + private function normalizeCommandFailureStatus(?string $status): string + { + $normalized = strtoupper(trim((string)$status)); + return in_array($normalized, ['FAILED', 'TIMED_OUT'], true) ? $normalized : 'FAILED'; + } + + private function applyCommandResult( + edge_gateways_o $gateway, + edge_gateway_command_jobs_o $job, + bool $ok, + array $payload, + ?string $errorMessage + ): void { + unset($errorMessage); + if ((string)$job->command_type->value() === 'DISCOVER_SHELLY') { + if ($ok) { + $inventory = isset($payload['inventory']) && is_array($payload['inventory']) ? $payload['inventory'] : []; + $this->syncDeviceInventory((int)$gateway->id, $inventory); + $gateway->discovery_status->set('READY'); + } else { + $gateway->discovery_status->set('FAILED'); + } + } + } + + private function resolveRelayBindingLocalIp( + int $gatewayId, + array $binding, + string $deviceId, + mixed $existingLocalIp = null + ): ?string { + $incomingLocalIp = $this->normalizeLocalIp( + $binding['local_ip'] + ?? $binding['localIp'] + ?? $binding['ip'] + ?? $binding['metadata']['local_ip'] + ?? null + ); + if ($incomingLocalIp !== null) { + return $incomingLocalIp; + } + + $preservedLocalIp = $this->normalizeLocalIp($existingLocalIp); + if ($preservedLocalIp !== null) { + return $preservedLocalIp; + } + + $inventoryLocalIp = $this->findInventoryLocalIp($gatewayId, $deviceId); + if ($inventoryLocalIp !== null) { + return $inventoryLocalIp; + } + + return $this->findShellyCloudRelayLocalIp( + $deviceId, + trim((string)($binding['relay_id'] ?? '')) + ); + } + + private function findInventoryLocalIp(int $gatewayId, string $deviceId): ?string + { + $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([ + 'gateway_id' => $gatewayId, + 'device_id' => $deviceId, + 'deleted_at' => null, + ], ['id']); + + if ($rows === []) { + return null; + } + + $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']); + return $this->normalizeLocalIp($inventoryObject->local_ip->value()); + } + + private function findShellyCloudRelayLocalIp(string $deviceId, string $relayId): ?string + { + foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) { + $optionDeviceId = trim((string)($option['device_id'] ?? '')); + $optionRelayId = trim((string)($option['id'] ?? '')); + if ($optionDeviceId !== $deviceId && $optionRelayId !== $relayId && $optionRelayId !== $deviceId) { + continue; + } + + $localIp = $this->normalizeLocalIp( + $option['local_ip'] + ?? $option['localIp'] + ?? $option['ip'] + ?? null + ); + if ($localIp !== null) { + return $localIp; + } + } + + return null; + } + + /** + * @return array> + */ + private function listShellyRelayOptionsForLocalIpLookup(): array + { + if (is_array($this->shellyRelayOptionsCache)) { + return $this->shellyRelayOptionsCache; + } + + try { + $this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions(); + } catch (\Throwable) { + $this->shellyRelayOptionsCache = []; + } + + return $this->shellyRelayOptionsCache; + } + + private function normalizeLocalIp(mixed $value): ?string + { + $localIp = trim((string)($value ?? '')); + if ($localIp === '') { + return null; + } + + return filter_var($localIp, FILTER_VALIDATE_IP) !== false ? $localIp : null; + } + + /** + * @param array $device + */ + private function extractDeviceLocalIp(array $device): ?string + { + return $this->normalizeLocalIp( + $device['local_ip'] + ?? $device['localIp'] + ?? $device['ip'] + ?? $device['metadata']['local_ip'] + ?? $device['metadata']['localIp'] + ?? $device['metadata']['ip'] + ?? null + ); + } + + private function resolveRelayBindingDeviceGeneration(array $binding): ?int + { + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) + ? (array)$binding['metadata'] + : []; + + $generation = $this->resolveShellyDeviceGenerationFromPayload(array_merge($metadata, $binding)); + if ($generation !== null) { + return $generation; + } + + $inventoryDevice = $this->findInventoryDeviceForRelayBinding($binding); + if ($inventoryDevice !== null) { + $generation = $this->resolveShellyDeviceGenerationFromPayload($inventoryDevice); + if ($generation !== null) { + return $generation; + } + } + + $deviceId = trim((string)($binding['device_id'] ?? '')); + $relayId = trim((string)($binding['relay_id'] ?? '')); + foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) { + $optionDeviceId = trim((string)($option['device_id'] ?? '')); + $optionRelayId = trim((string)($option['id'] ?? '')); + if ($optionDeviceId !== $deviceId && $optionRelayId !== $relayId && $optionRelayId !== $deviceId) { + continue; + } + + $generation = $this->resolveShellyDeviceGenerationFromPayload($option); + if ($generation !== null) { + return $generation; + } + } + + return null; + } + + private function findInventoryDeviceForRelayBinding(array $binding): ?array + { + $gatewayId = (int)($binding['gateway_id'] ?? 0); + $deviceId = trim((string)($binding['device_id'] ?? '')); + if ($gatewayId <= 0 || $deviceId === '') { + return null; + } + + $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([ + 'gateway_id' => $gatewayId, + 'device_id' => $deviceId, + 'deleted_at' => null, + ], ['id']); + if ($rows === []) { + return null; + } + + $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']); + return $inventoryObject->exists() ? $inventoryObject->asArray() : null; + } + + private function normalizeDeviceCapabilities(array $device): array + { + $capabilities = isset($device['capabilities']) && is_array($device['capabilities']) + ? (array)$device['capabilities'] + : []; + + if (!isset($capabilities['generation'])) { + $generation = $this->resolveShellyDeviceGenerationFromPayload($device); + if ($generation !== null) { + $capabilities['generation'] = $generation; + } + } + + return $capabilities; + } + + private function resolveShellyDeviceGenerationFromPayload(array $payload): ?int + { + $metadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []; + $capabilities = isset($payload['capabilities']) && is_array($payload['capabilities']) ? (array)$payload['capabilities'] : []; + + foreach ([ + $payload['deviceGeneration'] ?? null, + $payload['device_generation'] ?? null, + $payload['generation'] ?? null, + $payload['gen'] ?? null, + $capabilities['generation'] ?? null, + $metadata['deviceGeneration'] ?? null, + $metadata['device_generation'] ?? null, + $metadata['generation'] ?? null, + $metadata['gen'] ?? null, + $metadata['capabilities']['generation'] ?? null, + ] as $candidate) { + $generation = $this->normalizeShellyDeviceGeneration($candidate); + if ($generation !== null) { + return $generation; + } + } + + foreach ([ + $payload['device_model'] ?? null, + $payload['deviceModel'] ?? null, + $payload['model'] ?? null, + $payload['device_type'] ?? null, + $payload['deviceType'] ?? null, + $payload['type'] ?? null, + $payload['code'] ?? null, + $metadata['device_model'] ?? null, + $metadata['deviceModel'] ?? null, + $metadata['model'] ?? null, + $metadata['device_type'] ?? null, + $metadata['deviceType'] ?? null, + $metadata['type'] ?? null, + $metadata['code'] ?? null, + ] as $candidate) { + $generation = $this->inferShellyDeviceGenerationFromString((string)($candidate ?? '')); + if ($generation !== null) { + return $generation; + } + } + + return null; + } + + private function normalizeShellyDeviceGeneration(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (is_numeric($value)) { + $generation = (int)$value; + return $generation > 0 ? $generation : null; + } + + return $this->inferShellyDeviceGenerationFromString((string)($value ?? '')); + } + + private function inferShellyDeviceGenerationFromString(string $value): ?int + { + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $matches) === 1) { + return (int)$matches[1]; + } + + $upper = strtoupper($normalized); + if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $matches) === 1) { + return (int)$matches[1]; + } + + if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 + || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) { + return 2; + } + + if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) { + return 1; + } + + return null; + } + + private function backfillRelayBindingLocalIpFromInventory(int $gatewayId, string $deviceId, ?string $localIp): void + { + $localIp = $this->normalizeLocalIp($localIp); + if ($localIp === null) { + return; + } + + $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ + 'gateway_id' => $gatewayId, + 'device_id' => $deviceId, + 'deleted_at' => null, + ], ['id']); + + foreach ($rows as $row) { + $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$row['id']); + if ($this->normalizeLocalIp($bindingObject->local_ip->value()) !== null) { + continue; + } + + $bindingObject->local_ip->set($localIp); + } + } + + /** + * @param array> $inventory + */ + private function syncDeviceInventory(int $gatewayId, array $inventory): void + { + foreach ($inventory as $device) { + $deviceId = trim((string)($device['device_id'] ?? $device['id'] ?? '')); + if ($deviceId === '') { + continue; + } + + $localIp = $this->extractDeviceLocalIp($device); + $capabilities = $this->normalizeDeviceCapabilities($device); + $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([ + 'gateway_id' => $gatewayId, + 'device_id' => $deviceId, + 'deleted_at' => null, + ], ['id']); + + if ($rows === []) { + (new edge_gateway_device_inventory_o())->add_object([ + 'gateway_id' => $gatewayId, + 'device_id' => $deviceId, + 'local_ip' => $localIp, + 'model' => $device['model'] ?? null, + 'channel_count' => (int)($device['channel_count'] ?? $device['channels'] ?? 1), + 'capabilities_json' => $capabilities, + 'online' => (bool)($device['online'] ?? true), + 'last_seen_at' => $this->now(), + 'metadata_json' => (array)($device['metadata'] ?? []), + ]); + $this->backfillRelayBindingLocalIpFromInventory($gatewayId, $deviceId, $localIp); + continue; + } + + $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']); + if ($localIp !== null) { + $inventoryObject->local_ip->set($localIp); + } + $inventoryObject->model->set($device['model'] ?? null); + $inventoryObject->channel_count->set((int)($device['channel_count'] ?? $device['channels'] ?? 1)); + $inventoryObject->capabilities_json->set($capabilities); + $inventoryObject->online->set((bool)($device['online'] ?? true)); + $inventoryObject->last_seen_at->set($this->now()); + $inventoryObject->metadata_json->set((array)($device['metadata'] ?? [])); + $this->backfillRelayBindingLocalIpFromInventory($gatewayId, $deviceId, $localIp); + } + } + + /** + * @return array> + */ + private function listRecentObjects(object $object, array $conditions, int $limit = 20): array + { + if (!method_exists($object, 'getFieldsWhere') || !method_exists($object, 'select')) { + return []; + } + + $rows = $object->getFieldsWhere($conditions, ['id']); + $ids = array_map(static fn(array $row): int => (int)$row['id'], $rows); + rsort($ids); + $ids = array_slice($ids, 0, $limit); + + $result = []; + foreach ($ids as $id) { + $tmp = $object::class; + $selected = (new $tmp())->select($id); + if (method_exists($selected, 'asArray')) { + $result[] = $selected->asArray(); + } + } + return $result; + } + + /** + * @throws Exception + */ + public function validateBrokerAgentConnection(int $gatewayId, string $plainToken): array + { + $gateway = $this->authenticateGateway($gatewayId, $plainToken); + + return [ + 'id' => (int)$gateway->id, + 'gateway_id' => (int)$gateway->id, + 'department_id' => (int)$gateway->department_id->value(), + 'label' => (string)$gateway->label->value(), + 'hostname' => $gateway->hostname->value() === null ? null : (string)$gateway->hostname->value(), + ]; + } + + /** + * @throws Exception + */ + public function recordBrokerPresence( + int $gatewayId, + string $status, + ?string $connectionId = null, + ?string $reason = null, + array $metadata = [] + ): array { + $gateway = $this->requireGateway($gatewayId); + $normalizedStatus = trim(strtolower($status)); + $connected = $normalizedStatus === 'connected'; + $presence = [ + 'gateway_id' => $gatewayId, + 'connected' => $connected, + 'connection_id' => $connectionId, + 'last_seen_at' => $this->now(), + 'disconnect_reason' => $connected ? null : $reason, + 'last_error' => !$connected && $reason !== null && trim($reason) !== '' ? trim($reason) : null, + 'metadata' => $metadata, + ]; + + $this->writeBrokerPresence($gatewayId, $presence); + + $gatewayMetadata = (array)($gateway->metadata_json->value() ?? []); + $gatewayMetadata['broker_presence'] = array_merge( + (array)($gatewayMetadata['broker_presence'] ?? []), + $presence + ); + $gatewayMetadata['broker_connected'] = $connected; + if ($connected) { + $gatewayMetadata['broker_connected_at'] = $this->now(); + $gatewayMetadata['broker_last_error'] = null; + } else { + $gatewayMetadata['broker_disconnected_at'] = $this->now(); + $gatewayMetadata['broker_last_error'] = $reason; + } + $gateway->metadata_json->set($gatewayMetadata); + + edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); + + return $presence; + } + + private function readBrokerPresence(int $gatewayId): array + { + $redisPresence = $this->withRedis( + static fn(redis $redis): ?string => $redis->get(edge_gateway_manager::brokerPresenceKey($gatewayId)), + null + ); + if (is_string($redisPresence) && trim($redisPresence) !== '') { + $decoded = json_decode($redisPresence, true); + if (is_array($decoded)) { + return $decoded; + } + } + + $gateway = (new edge_gateways_o())->select($gatewayId); + if ($gateway->exists()) { + $metadata = (array)($gateway->metadata_json->value() ?? []); + if (isset($metadata['broker_presence']) && is_array($metadata['broker_presence'])) { + return (array)$metadata['broker_presence']; + } + } + + return []; + } + + private function writeBrokerPresence(int $gatewayId, array $presence): void + { + $encoded = json_encode($presence, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + return; + } + + $this->withRedis( + static function (redis $redis) use ($gatewayId, $encoded): void { + $redis->setEx(edge_gateway_manager::brokerPresenceKey($gatewayId), $encoded, edge_gateway_manager::BROKER_PRESENCE_TTL_SECONDS); + } + ); + } + + private static function brokerPresenceKey(int $gatewayId): string + { + return 'edge_gateway_broker_presence_' . $gatewayId; + } + + private function withRedis(callable $callback, mixed $fallback = null): mixed + { + try { + return $callback(new redis()); + } catch (\Throwable) { + return $fallback; + } + } + + private function buildGatewayOperationalSnapshot(int $gatewayId): array + { + $pdo = db::getPDO(); + + $counts = [ + 'command_backlog' => 0, + 'operation_backlog' => 0, + 'last_successful_command_at' => null, + 'last_successful_discovery_at' => null, + 'last_successful_operation_at' => null, + 'last_successful_update_at' => null, + ]; + + $countQueries = [ + 'command_backlog' => "SELECT COUNT(*) AS c + FROM edge_gateway_command_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status IN ('PENDING', 'DISPATCHING')", + 'operation_backlog' => "SELECT COUNT(*) AS c + FROM edge_gateway_operations + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status IN ('PENDING', 'IN_PROGRESS')", + ]; + + foreach ($countQueries as $key => $sql) { + $statement = $pdo->prepare($sql); + $statement->execute([':gateway_id' => $gatewayId]); + $row = $statement->fetch(); + $counts[$key] = isset($row['c']) ? (int)$row['c'] : 0; + } + + $timestampQueries = [ + 'last_successful_command_at' => "SELECT completed_at AS ts + FROM edge_gateway_command_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status = 'COMPLETED' + ORDER BY completed_at DESC, id DESC + LIMIT 1", + 'last_successful_discovery_at' => "SELECT completed_at AS ts + FROM edge_gateway_command_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status = 'COMPLETED' + AND command_type = 'DISCOVER_SHELLY' + ORDER BY completed_at DESC, id DESC + LIMIT 1", + 'last_successful_operation_at' => "SELECT completed_at AS ts + FROM edge_gateway_operations + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status = 'COMPLETED' + ORDER BY completed_at DESC, id DESC + LIMIT 1", + 'last_successful_update_at' => "SELECT completed_at AS ts + FROM edge_gateway_operations + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status = 'COMPLETED' + AND type = 'UPDATE' + ORDER BY completed_at DESC, id DESC + LIMIT 1", + ]; + + foreach ($timestampQueries as $key => $sql) { + $statement = $pdo->prepare($sql); + $statement->execute([':gateway_id' => $gatewayId]); + $row = $statement->fetch(); + $counts[$key] = isset($row['ts']) ? (string)$row['ts'] : null; + } + + return $counts; + } + + private function buildBrokerPublicUrl(): ?string + { + $configured = $this->configuredPublicBrokerUrl(); + if ($configured !== '') { + return rtrim($configured, '/'); + } + + $apiBaseUrl = $this->getApiBaseUrl(); + if (trim($apiBaseUrl) === '') { + return null; + } + + return rtrim($apiBaseUrl, '/') . '/edge-broker'; + } + + private function buildBrokerPublicWebSocketUrl(string $path = ''): ?string + { + $brokerUrl = $this->buildBrokerPublicUrl(); + if ($brokerUrl === null) { + return null; + } + + $parsed = parse_url($brokerUrl); + $scheme = strtolower((string)($parsed['scheme'] ?? 'http')) === 'https' ? 'wss' : 'ws'; + $host = (string)($parsed['host'] ?? ''); + if ($host === '') { + return null; + } + + $port = isset($parsed['port']) ? ':' . (int)$parsed['port'] : ''; + $basePath = rtrim((string)($parsed['path'] ?? ''), '/'); + $normalizedPath = '/' . ltrim($path, '/'); + $fullPath = $basePath . ($normalizedPath === '/' ? '' : $normalizedPath); + + return $scheme . '://' . $host . $port . ($fullPath === '' ? '' : $fullPath); + } + + private function buildBrokerInternalUrl(): ?string + { + $configured = $this->configuredBrokerInternalUrl(); + return $configured !== '' ? rtrim($configured, '/') : null; + } + + private function configuredPublicBrokerUrl(): string + { + try { + $configured = trim((string)(new edgegateway())->publicBrokerUrl()); + if ($configured !== '') { + return rtrim($configured, '/'); + } + } catch (Exception) { + } + + $fallback = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')); + return $fallback !== '' ? rtrim($fallback, '/') : ''; + } + + private function configuredBrokerInternalUrl(): string + { + try { + $configured = trim((string)(new edgegateway())->brokerUrl()); + if ($configured !== '') { + return rtrim($configured, '/'); + } + } catch (Exception) { + } + + $fallback = trim((string)(getenv('EDGE_BROKER_URL') ?: '')); + return $fallback !== '' ? rtrim($fallback, '/') : ''; + } + + private function configuredBrokerAuthMode(): string + { + try { + $configured = trim((string)(new edgegateway())->brokerAuthMode()); + return $configured !== '' ? $configured : 'manager'; + } catch (Exception) { + } + + $fallback = trim((string)(getenv('EDGE_AUTH_MODE') ?: '')); + return $fallback !== '' ? $fallback : 'manager'; + } + + private function configuredBrokerSharedSecret(): string + { + try { + $configured = trim((string)(new edgegateway())->brokerSharedSecret()); + if ($configured !== '') { + return $configured; + } + } catch (Exception) { + } + + return trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); + } + + private function resolveGatewayPreferredCommandChannel(edge_gateways_o|array $gateway): string + { + $gatewayId = is_array($gateway) ? (int)($gateway['id'] ?? 0) : (int)$gateway->id; + if ($gatewayId <= 0 || $this->buildBrokerInternalUrl() === null) { + return self::DELIVERY_CHANNEL_API; + } + + $presence = $this->readBrokerPresence($gatewayId); + return self::isBrokerPresenceConnected($presence) ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API; + } + + public function validateBrokerSharedSecret(?string $secret): bool + { + $configured = $this->configuredBrokerSharedSecret(); + if ($configured === '') { + return false; + } + + return $secret !== null && hash_equals($configured, trim($secret)); + } + + /** + * @param array $options + * @return array + */ + public function diagnoseBrokerConfiguration(array $options = []): array + { + $target = strtolower(trim((string)($options['target'] ?? 'all'))); + $target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all'; + + $internalUrl = $this->normalizeBrokerDiagnosticBaseUrl( + array_key_exists('broker_url', $options) ? $options['broker_url'] : $this->configuredBrokerInternalUrl() + ); + $publicConfigured = array_key_exists('public_broker_url', $options) + ? trim((string)$options['public_broker_url']) + : $this->configuredPublicBrokerUrl(); + $publicUrl = $this->normalizeBrokerDiagnosticBaseUrl( + $publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl() + ); + $sharedSecret = array_key_exists('broker_shared_secret', $options) + ? trim((string)$options['broker_shared_secret']) + : $this->configuredBrokerSharedSecret(); + + $diagnostics = [ + 'target' => $target, + 'checked_at' => $this->now(), + 'broker_auth_mode' => array_key_exists('broker_auth_mode', $options) + ? trim((string)$options['broker_auth_mode']) + : $this->configuredBrokerAuthMode(), + 'broker_shared_secret_configured' => $sharedSecret !== '', + ]; + + if ($target === 'internal' || $target === 'all') { + $diagnostics['internal_broker_connection'] = $this->diagnoseBrokerHttpEndpoint( + $internalUrl, + 'Internal broker' + ); + } + + if ($target === 'public' || $target === 'all') { + $diagnostics['public_broker_url'] = $this->diagnoseBrokerHttpEndpoint( + $publicUrl, + 'Public broker' + ); + } + + if ($target === 'secret' || $target === 'all') { + $diagnostics['broker_shared_secret'] = $this->diagnoseBrokerSharedSecret( + $internalUrl, + $sharedSecret + ); + } + + return $diagnostics; + } + + private function deriveBrokerPublicUrl(): ?string + { + $apiBaseUrl = $this->getApiBaseUrl(); + if (trim($apiBaseUrl) === '') { + return null; + } + + return rtrim($apiBaseUrl, '/') . '/edge-broker'; + } + + /** + * @return array{url:?string,error:?string} + */ + private function normalizeBrokerDiagnosticBaseUrl(mixed $value): array + { + $url = trim((string)$value); + if ($url === '') { + return [ + 'url' => null, + 'error' => 'not_configured', + ]; + } + + $parsed = parse_url($url); + $scheme = is_array($parsed) ? strtolower((string)($parsed['scheme'] ?? '')) : ''; + $host = is_array($parsed) ? trim((string)($parsed['host'] ?? '')) : ''; + if (!is_array($parsed) || $host === '' || !in_array($scheme, ['http', 'https'], true)) { + return [ + 'url' => $url, + 'error' => 'invalid_url', + ]; + } + + return [ + 'url' => rtrim($url, '/'), + 'error' => null, + ]; + } + + /** + * @param array{url:?string,error:?string} $baseUrl + * @return array + */ + private function diagnoseBrokerHttpEndpoint(array $baseUrl, string $label): array + { + if ($baseUrl['url'] === null || $baseUrl['error'] !== null) { + return $this->brokerDiagnosticUrlFailure($baseUrl, $label); + } + + $health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health'); + if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) { + return array_merge($health, [ + 'ok' => true, + 'status' => 'connected', + 'url' => $baseUrl['url'], + 'message' => $label . ' responded to the health check.', + ]); + } + + if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) { + return array_merge($health, [ + 'ok' => true, + 'status' => 'connected_legacy', + 'url' => $baseUrl['url'], + 'message' => $label . ' responded, but the health endpoint is not deployed yet.', + ]); + } + + if (($health['status_code'] ?? null) !== null) { + return array_merge($health, [ + 'ok' => false, + 'status' => 'unexpected_response', + 'url' => $baseUrl['url'], + 'message' => $label . ' returned HTTP ' . (string)$health['status_code'] . ' instead of the broker health response.', + ]); + } + + return array_merge($health, [ + 'ok' => false, + 'status' => 'unreachable', + 'url' => $baseUrl['url'], + 'message' => $label . ' did not respond.', + ]); + } + + /** + * @param array{url:?string,error:?string} $baseUrl + * @return array + */ + private function diagnoseBrokerSharedSecret(array $baseUrl, string $sharedSecret): array + { + if ($baseUrl['url'] === null || $baseUrl['error'] !== null) { + return $this->brokerDiagnosticUrlFailure($baseUrl, 'Internal broker'); + } + + $headers = $sharedSecret !== '' ? ['x-edge-broker-secret: ' . $sharedSecret] : []; + $diagnostic = $this->brokerHttpProbe( + $baseUrl['url'] . '/api/diagnostics/shared-secret', + 'POST', + [], + $headers + ); + + if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) { + $required = (bool)($diagnostic['json']['shared_secret_required'] ?? false); + return array_merge($diagnostic, [ + 'ok' => true, + 'status' => $required ? 'validated' : 'not_required', + 'url' => $baseUrl['url'], + 'message' => $required + ? 'Broker accepted the configured shared secret.' + : 'Broker responded and does not currently require a shared secret.', + ]); + } + + if (($diagnostic['status_code'] ?? null) === 403) { + return array_merge($diagnostic, [ + 'ok' => false, + 'status' => 'secret_rejected', + 'url' => $baseUrl['url'], + 'message' => 'Broker rejected the configured shared secret.', + ]); + } + + if (($diagnostic['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($diagnostic)) { + return $this->diagnoseBrokerSharedSecretWithLegacySync($baseUrl, $headers); + } + + if (($diagnostic['status_code'] ?? null) !== null) { + return array_merge($diagnostic, [ + 'ok' => false, + 'status' => 'unexpected_response', + 'url' => $baseUrl['url'], + 'message' => 'Broker returned HTTP ' . (string)$diagnostic['status_code'] . ' during shared secret validation.', + ]); + } + + return array_merge($diagnostic, [ + 'ok' => false, + 'status' => 'unreachable', + 'url' => $baseUrl['url'], + 'message' => 'Internal broker did not respond during shared secret validation.', + ]); + } + + /** + * @param array{url:?string,error:?string} $baseUrl + * @param array $headers + * @return array + */ + private function diagnoseBrokerSharedSecretWithLegacySync(array $baseUrl, array $headers): array + { + $legacy = $this->brokerHttpProbe( + $baseUrl['url'] . '/api/gateways/0/sync', + 'POST', + ['diagnostic' => true], + $headers + ); + + if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) { + return array_merge($legacy, [ + 'ok' => true, + 'status' => 'validated_legacy', + 'url' => $baseUrl['url'], + 'message' => 'Broker accepted the shared secret through the legacy sync endpoint.', + ]); + } + + if (($legacy['status_code'] ?? null) === 403) { + return array_merge($legacy, [ + 'ok' => false, + 'status' => 'secret_rejected', + 'url' => $baseUrl['url'], + 'message' => 'Broker rejected the configured shared secret.', + ]); + } + + return array_merge($legacy, [ + 'ok' => false, + 'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response', + 'url' => $baseUrl['url'], + 'message' => 'Broker shared secret could not be validated.', + ]); + } + + /** + * @param array{url:?string,error:?string} $baseUrl + * @return array + */ + private function brokerDiagnosticUrlFailure(array $baseUrl, string $label): array + { + $error = (string)($baseUrl['error'] ?? 'not_configured'); + return [ + 'ok' => false, + 'status' => $error, + 'url' => $baseUrl['url'], + 'status_code' => null, + 'elapsed_ms' => 0, + 'message' => $error === 'invalid_url' + ? $label . ' URL is not a valid http(s) URL.' + : $label . ' URL is not configured.', + ]; + } + + /** + * @param array $response + */ + private function isBrokerNotFoundProbe(array $response): bool + { + $json = isset($response['json']) && is_array($response['json']) ? (array)$response['json'] : []; + return strtolower(trim((string)($json['error'] ?? ''))) === 'not found'; + } + + /** + * @param array $headers + * @return array + */ + private function brokerHttpProbe( + string $url, + string $method = 'GET', + ?array $payload = null, + array $headers = [], + int $timeoutSeconds = 3 + ): array { + $method = strtoupper(trim($method)) ?: 'GET'; + $requestHeaders = array_filter(array_merge(['Accept: application/json'], $headers)); + $options = [ + 'method' => $method, + 'header' => implode("\r\n", $requestHeaders), + 'timeout' => max(1, $timeoutSeconds), + 'ignore_errors' => true, + ]; + + if ($payload !== null) { + $requestHeaders[] = 'Content-Type: application/json'; + $options['header'] = implode("\r\n", array_filter($requestHeaders)); + $options['content'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + $context = stream_context_create(['http' => $options]); + $started = microtime(true); + $body = @file_get_contents($url, false, $context); + $elapsedMs = (int)round((microtime(true) - $started) * 1000); + $responseHeaders = is_array($http_response_header ?? null) ? $http_response_header : []; + $statusCode = $this->parseHttpStatusCode($responseHeaders); + + if ($body === false) { + $lastError = error_get_last(); + return [ + 'status_code' => $statusCode, + 'elapsed_ms' => $elapsedMs, + 'error' => isset($lastError['message']) ? self::trimInstallSessionText($lastError['message'], 512) : null, + 'json' => null, + 'body_excerpt' => null, + ]; + } + + $decoded = json_decode($body, true); + return [ + 'status_code' => $statusCode, + 'elapsed_ms' => $elapsedMs, + 'error' => null, + 'json' => is_array($decoded) ? $decoded : null, + 'body_excerpt' => self::trimInstallSessionText($body, 512), + ]; + } + + /** + * @param array $headers + */ + private function parseHttpStatusCode(array $headers): ?int + { + foreach ($headers as $header) { + if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/i', trim((string)$header), $matches) === 1) { + return (int)$matches[1]; + } + } + + return null; + } + + /** + * @throws Exception + */ + public function buildBrokerBacklog(int $gatewayId, ?string $agentInstanceId = null): array + { + $gateway = $this->requireGateway($gatewayId); + $operations = new edge_gateway_operation_service($this); + $dispatch = []; + + $operation = $operations->claimBrokerOperation($gatewayId, $agentInstanceId); + if ($operation !== null) { + $dispatch[] = [ + 'type' => 'TASK_DISPATCH', + 'taskType' => 'OPERATION', + 'operation' => $operation, + ]; + } + + foreach ($operations->listBrokerCancellationRequests($gatewayId, $agentInstanceId) as $cancelledOperation) { + $dispatch[] = [ + 'type' => 'TASK_CANCEL', + 'taskType' => 'OPERATION', + 'operation' => $cancelledOperation, + ]; + } + + return [ + 'gateway' => [ + 'id' => (int)$gateway->id, + 'department_id' => (int)$gateway->department_id->value(), + 'label' => (string)$gateway->label->value(), + ], + 'dispatch' => $dispatch, + ]; + } + + public function notifyBrokerGatewaySync(int $gatewayId): void + { + $brokerUrl = $this->buildBrokerInternalUrl(); + if ($brokerUrl === null) { + return; + } + + try { + $this->httpJsonRequest( + $brokerUrl . '/api/gateways/' . $gatewayId . '/sync', + ['gatewayId' => $gatewayId], + ['x-edge-broker-secret: ' . $this->configuredBrokerSharedSecret()], + self::BROKER_HTTP_TIMEOUT_SECONDS + ); + } catch (Exception) { + // Broker sync is opportunistic. Legacy polling remains available during rollout. + } + } + + private function normalizeRelayBindingMetadata(array $metadata = [], array $binding = []): array + { + $fallbackMode = strtoupper(trim((string)($binding['fallback_mode'] ?? $metadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL))); + if (!in_array($fallbackMode, [ + self::RELAY_FALLBACK_PREFER_LOCAL, + self::RELAY_FALLBACK_LOCAL_ONLY, + self::RELAY_FALLBACK_CLOUD_ONLY, + ], true)) { + $fallbackMode = self::RELAY_FALLBACK_PREFER_LOCAL; + } + + $metadata['fallback_mode'] = $fallbackMode; + $consumerContexts = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? []; + if (!is_array($consumerContexts)) { + $consumerContexts = []; + } + $consumerContexts = array_values(array_filter(array_map(static function (mixed $consumer): ?array { + if (!is_array($consumer)) { + return null; + } + + $consumerType = trim((string)($consumer['type'] ?? '')); + if ($consumerType === '') { + return null; + } + + return [ + 'type' => $consumerType, + 'id' => isset($consumer['id']) ? (int)$consumer['id'] : null, + 'slot' => isset($consumer['slot']) ? (string)$consumer['slot'] : null, + 'label' => isset($consumer['label']) ? (string)$consumer['label'] : null, + ]; + }, $consumerContexts))); + $metadata['consumer_contexts'] = $consumerContexts; + $metadata['consumers'] = $consumerContexts; + + foreach (['device_type', 'device_model', 'code'] as $field) { + $value = trim((string)($binding[$field] ?? $metadata[$field] ?? '')); + if ($value !== '') { + $metadata[$field] = $value; + } + } + + $generation = $this->normalizeShellyDeviceGeneration( + $binding['deviceGeneration'] + ?? $binding['device_generation'] + ?? $binding['generation'] + ?? $metadata['deviceGeneration'] + ?? $metadata['device_generation'] + ?? $metadata['generation'] + ?? null + ); + if ($generation !== null) { + $metadata['device_generation'] = $generation; + $metadata['generation'] = $generation; + } + + if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) { + $metadata['last_resolution'] = (array)$binding['last_resolution']; + } + if (array_key_exists('last_success_at', $binding)) { + $metadata['last_success_at'] = $binding['last_success_at']; + } + if (array_key_exists('last_error', $binding)) { + $metadata['last_error'] = $binding['last_error']; + } + + return $metadata; + } + + private function buildDeliveryMetadata(array $overrides = [], int $ttlSeconds = self::COMMAND_EXPIRES_AFTER_SECONDS): array + { + $preferredChannel = strtoupper(trim((string)($overrides['preferred_channel'] ?? self::DELIVERY_CHANNEL_API))); + if (!in_array($preferredChannel, [ + self::DELIVERY_CHANNEL_BROKER, + self::DELIVERY_CHANNEL_API, + self::DELIVERY_CHANNEL_CLOUD, + ], true)) { + $preferredChannel = self::DELIVERY_CHANNEL_API; + } + + return array_merge([ + 'preferred_channel' => $preferredChannel, + 'delivery_channel' => $overrides['delivery_channel'] ?? null, + 'attempt_count' => isset($overrides['attempt_count']) ? (int)$overrides['attempt_count'] : 0, + 'expires_at' => $overrides['expires_at'] ?? $this->formatDateTime(time() + max(30, $ttlSeconds)), + 'fallback_reason' => $overrides['fallback_reason'] ?? null, + 'last_dispatch_error' => $overrides['last_dispatch_error'] ?? null, + ], $overrides); + } + + private function resolveRelayExecutionPlan(edge_gateways_o $gateway, array $binding, string $logicalRelayId): array + { + $gatewayData = $gateway->asArray(); + $gatewayData['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id); + $gatewayData['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value()); + $gatewayData['inventory'] = $this->listInventory((int)$gateway->id); + $gatewayData['bindings'] = [$binding]; + + $runtime = self::deriveGatewayRuntimeState($gatewayData); + $relayHealth = (array)($runtime['relay_health'][0] ?? []); + $executionPath = (string)($relayHealth['execution_path'] ?? 'local'); + + return array_merge($relayHealth, [ + 'relay_id' => $logicalRelayId, + 'execution_path' => $executionPath, + 'preferred_channel' => $executionPath === 'local' + ? $this->resolveGatewayPreferredCommandChannel($gateway) + : self::DELIVERY_CHANNEL_CLOUD, + ]); + } + + private function forceLocalRelayExecutionPlan(array $resolution): array + { + $wasCloud = (string)($resolution['execution_path'] ?? 'local') === 'cloud'; + $recoveryActions = array_values(array_filter(array_unique(array_merge( + (array)($resolution['recovery_actions'] ?? []), + ['retry_local_command'] + )))); + + return array_merge($resolution, [ + 'execution_path' => 'local', + 'preferred_channel' => self::DELIVERY_CHANNEL_BROKER, + 'fallback_mode' => self::RELAY_FALLBACK_LOCAL_ONLY, + 'reason' => $wasCloud ? 'local_transport_override' : ($resolution['reason'] ?? null), + 'recommended_action' => $resolution['recommended_action'] ?? 'retry_local_command', + 'recovery_actions' => $recoveryActions, + ]); + } + + private function normalizeRelayToggleAfter(?int $toggleAfterSeconds): ?int + { + if ($toggleAfterSeconds === null || $toggleAfterSeconds <= 0) { + return null; + } + + return $toggleAfterSeconds; + } + + private function currentRelayActionContext(): array + { + $context = []; + foreach (self::$relayActionContextStack as $entry) { + if (is_array($entry)) { + $context = array_replace_recursive($context, $entry); + } + } + + return $context; + } + + private function normalizeRelayActionContext(array $context): array + { + $context = array_replace_recursive($this->currentRelayActionContext(), $context); + $module = trim((string)($context['module'] ?? $context['module_responsible'] ?? '')); + $reason = trim((string)($context['reason'] ?? $context['action_reason'] ?? '')); + $context['module'] = $module !== '' ? $module : 'edge_gateway'; + $context['module_responsible'] = $context['module']; + $context['reason'] = $reason !== '' ? $reason : 'Relay dispatch'; + + $actor = array_replace( + $this->resolveRelayAuthenticatedActorContext(), + isset($context['actor']) && is_array($context['actor']) ? (array)$context['actor'] : [] + ); + foreach (['user_id', 'admin_user_id', 'customer_user_id', 'customer_number', 'subuser_id', 'type', 'display_name'] as $key) { + if (array_key_exists($key, $context) && !array_key_exists($key, $actor)) { + $actor[$key] = $context[$key]; + } + } + $context['actor'] = $this->compactRelayLogArray($actor); + + $associated = isset($context['associated']) && is_array($context['associated']) + ? (array)$context['associated'] + : []; + foreach (['admin_user_id', 'customer_user_id', 'customer_number', 'subuser_id'] as $key) { + if (isset($context['actor'][$key]) && !isset($associated[$key])) { + $associated[$key] = $context['actor'][$key]; + } + if (array_key_exists($key, $context) && !isset($associated[$key])) { + $associated[$key] = $context[$key]; + } + } + $context['associated'] = $this->compactRelayLogArray($associated); + + return $this->compactRelayLogArray($context); + } + + private function resolveRelayAuthenticatedActorContext(): array + { + if (!function_exists('getallheaders')) { + return []; + } + + try { + $headers = getallheaders(); + } catch (\Throwable) { + return []; + } + if (!is_array($headers) || $this->relayHeaderValue($headers, 'Authorization') === null) { + return []; + } + + try { + $user = (new authentication())->get_user(); + } catch (\Throwable) { + return []; + } + + if (!$user instanceof \objects\users_o || !$user->exists()) { + return []; + } + + $userId = (int)$user->id; + $customerNumber = isset($user->customer_number) + ? (int)($user->customer_number->value() ?? 0) + : 0; + $displayName = isset($user->display_name) ? trim((string)($user->display_name->value() ?? '')) : ''; + $actor = [ + 'user_id' => $userId, + 'type' => $customerNumber > 0 ? 'customer' : 'admin', + ]; + if ($displayName !== '') { + $actor['display_name'] = $displayName; + } + if ($customerNumber > 0) { + $actor['customer_user_id'] = $userId; + $actor['customer_number'] = $customerNumber; + } else { + $actor['admin_user_id'] = $userId; + } + + return $actor; + } + + private function relayHeaderValue(array $headers, string $name): ?string + { + $normalized = strtolower($name); + foreach ($headers as $key => $value) { + if (strtolower((string)$key) === $normalized) { + $value = trim((string)$value); + return $value !== '' ? $value : null; + } + } + + return null; + } + + private function resolveRelayRequestedBy(array $actionContext): ?int + { + $actor = isset($actionContext['actor']) && is_array($actionContext['actor']) ? (array)$actionContext['actor'] : []; + foreach (['admin_user_id', 'user_id', 'customer_user_id'] as $key) { + $id = isset($actor[$key]) ? (int)$actor[$key] : 0; + if ($id > 0) { + return $id; + } + } + + return null; + } + + private function buildRelayCommandSignal(edge_gateway_command_jobs_o $job, array $request): array + { + $delivery = (array)($job->delivery_json->value() ?? []); + return $this->compactRelayLogArray([ + 'command_type' => (string)$job->command_type->value(), + 'job_id' => (int)$job->id, + 'correlation_id' => (string)$job->correlation_id->value(), + 'request' => $request, + 'preferred_channel' => $delivery['preferred_channel'] ?? null, + 'require_fast_path' => !empty($delivery['require_fast_path']), + 'fallback_reason' => $delivery['fallback_reason'] ?? null, + ]); + } + + private function appendRelayDispatchLog( + array $binding, + array $resolution, + bool $success, + array $dispatchLog, + array $result = [], + ?\Throwable $exception = null + ): ?array { + try { + $gatewayId = (int)($binding['gateway_id'] ?? 0); + if ($gatewayId <= 0) { + return null; + } + + $context = $this->buildRelayLogContext($binding, $resolution, $success, $dispatchLog, $result, $exception); + return $this->appendGatewayLogEntry( + $gatewayId, + $this->buildRelayLogMessage($context, $success), + $success ? 'INFO' : 'ERROR', + 'relay', + 'RELAY_DISPATCH', + $context + ); + } catch (\Throwable) { + return null; + } + } + + private function buildRelayLogContext( + array $binding, + array $resolution, + bool $success, + array $dispatchLog, + array $result, + ?\Throwable $exception + ): array { + $actionContext = $this->normalizeRelayActionContext( + isset($dispatchLog['action_context']) && is_array($dispatchLog['action_context']) + ? (array)$dispatchLog['action_context'] + : [] + ); + $handler = strtolower(trim((string)($dispatchLog['handler'] ?? $resolution['execution_path'] ?? 'local'))); + $handler = $handler === 'cloud' ? 'cloud' : 'local'; + $deliveryChannel = (string)($resolution['delivery_channel'] + ?? ($handler === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : ($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API))); + $relayId = trim((string)($dispatchLog['relay_id'] ?? $binding['relay_id'] ?? '')); + $signal = isset($dispatchLog['signal']) && is_array($dispatchLog['signal']) + ? (array)$dispatchLog['signal'] + : []; + if ($relayId !== '' && !isset($signal['relay_id'])) { + $signal['relay_id'] = $relayId; + } + $relayRole = $this->normalizeRelayLogRole( + $actionContext['relay_role'] + ?? $actionContext['role'] + ?? $binding['metadata']['relay_role'] + ?? $binding['metadata']['role'] + ?? null + ); + $relayName = $this->resolveRelayLogDisplayName($binding, $actionContext, $signal); + + $context = [ + 'success' => $success, + 'module' => (string)$actionContext['module'], + 'module_responsible' => (string)$actionContext['module_responsible'], + 'reason' => (string)$actionContext['reason'], + 'handler' => $handler, + 'execution_path' => $handler, + 'delivery_channel' => $deliveryChannel, + 'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null, + 'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null, + 'relay_id' => $relayId !== '' ? $relayId : null, + 'relay_name' => $relayName, + 'relay_role' => $relayRole, + 'action' => strtoupper(trim((string)($dispatchLog['action'] ?? 'RELAY'))), + 'target_on' => array_key_exists('target_on', $dispatchLog) ? $dispatchLog['target_on'] : null, + 'toggle_after_seconds' => $dispatchLog['toggle_after_seconds'] ?? null, + 'actor' => isset($actionContext['actor']) && is_array($actionContext['actor']) ? (array)$actionContext['actor'] : [], + 'associated' => isset($actionContext['associated']) && is_array($actionContext['associated']) ? (array)$actionContext['associated'] : [], + 'binding' => $this->relayLogBindingPayload($binding), + 'execution' => $this->compactRelayLogArray([ + 'path' => $handler, + 'channel' => $deliveryChannel, + 'reason' => $resolution['reason'] ?? null, + 'fallback_reason' => $resolution['fallback_reason'] ?? null, + 'fallback_mode' => $resolution['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL, + 'recommended_action' => $resolution['recommended_action'] ?? null, + ]), + 'signal' => $this->sanitizeRelayLogValue($signal), + 'response' => $this->relayLogResponsePayload($result), + ]; + + if ($exception !== null) { + $context['error'] = [ + 'message' => $exception->getMessage(), + 'type' => $exception::class, + ]; + } + + $extraContext = $actionContext; + unset( + $extraContext['module'], + $extraContext['module_responsible'], + $extraContext['reason'], + $extraContext['actor'], + $extraContext['associated'], + $extraContext['admin_user_id'], + $extraContext['customer_user_id'], + $extraContext['customer_number'], + $extraContext['subuser_id'], + $extraContext['relay_name'], + $extraContext['relay_label'], + $extraContext['relay_role'], + $extraContext['role'] + ); + if ($extraContext !== []) { + $context['action_context'] = $this->sanitizeRelayLogValue($extraContext); + } + + $context = $this->compactRelayLogArray($context); + $description = $this->buildRelayLogDescription($context, $success); + if ($description !== '') { + $context['description'] = $description; + } + + return $this->compactRelayLogArray($context); + } + + private function buildRelayLogMessage(array $context, bool $success): string + { + $description = trim((string)($context['description'] ?? '')); + if ($description !== '') { + return $description; + } + + return $this->buildRelayLogDescription($context, $success); + } + + private function buildRelayLogDescription(array $context, bool $success): string + { + $subject = $this->buildRelayLogSubject($context); + $handler = strtolower(trim((string)($context['handler'] ?? 'local'))); + $channel = trim((string)($context['delivery_channel'] ?? '')); + + if (!$success) { + return trim(sprintf('%s failed via %s', $subject, $handler)); + } + + return trim(sprintf( + '%s handled by %s%s', + $subject, + $handler, + $channel !== '' ? ' via ' . $channel : '' + )); + } + + private function buildRelayLogSubject(array $context): string + { + $action = strtoupper(trim((string)($context['action'] ?? 'RELAY'))); + $relayId = trim((string)($context['relay_id'] ?? 'unknown')); + $relayName = trim((string)($context['relay_name'] ?? '')); + $relayTarget = $relayName !== '' ? $relayName : $relayId; + $relayRole = $this->normalizeRelayLogRole($context['relay_role'] ?? null); + $state = $this->resolveRelayLogState($context); + + if (in_array($relayRole, ['ENTRY', 'EXIT'], true)) { + $verb = $state === false ? 'Close' : 'Open'; + return trim(sprintf('%s %s %s', $verb, $relayRole, $relayTarget)); + } + + if (in_array($relayRole, ['MACHINE', 'PROGRAM_PICKER', 'CLEANER'], true) && $state !== null) { + return trim(sprintf('%s %s %s', $relayRole, $state ? 'ON' : 'OFF', $relayTarget)); + } + + $targetState = $state !== null ? ($state ? ' ON' : ' OFF') : ''; + return trim(sprintf('Relay %s %s%s', $action, $relayTarget, $targetState)); + } + + private function resolveRelayLogState(array $context): ?bool + { + if (array_key_exists('target_on', $context) && $context['target_on'] !== null) { + return (bool)$context['target_on']; + } + + $signal = isset($context['signal']) && is_array($context['signal']) ? (array)$context['signal'] : []; + $request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : []; + if (array_key_exists('on', $request)) { + return (bool)$request['on']; + } + + $response = isset($context['response']) && is_array($context['response']) ? (array)$context['response'] : []; + if (array_key_exists('on', $response)) { + return (bool)$response['on']; + } + + return null; + } + + private function normalizeRelayLogRole(mixed $role): ?string + { + $normalized = strtoupper(trim((string)($role ?? ''))); + if ($normalized === '') { + return null; + } + + return match ($normalized) { + 'ENTRANCE', 'IN', 'INLET', 'ENTRY_GATE' => 'ENTRY', + 'OUT', 'OUTLET', 'EXIT_GATE' => 'EXIT', + 'MACHINE_PROGRAM_PICKER', 'PROGRAM_SELECTOR', 'PICKER' => 'PROGRAM_PICKER', + 'MACHINE_CLEANER' => 'CLEANER', + default => $normalized, + }; + } + + private function resolveRelayLogDisplayName(array $binding, array $actionContext, array $signal): ?string + { + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + $request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : []; + $relayId = trim((string)($signal['relay_id'] ?? $binding['relay_id'] ?? $request['relayId'] ?? $request['id'] ?? '')); + + $directName = $this->firstRelayLogString([ + $actionContext['relay_name'] ?? null, + $actionContext['relay_label'] ?? null, + $metadata['relay_name'] ?? null, + $metadata['relay_label'] ?? null, + $metadata['name'] ?? null, + $metadata['label'] ?? null, + $request['relay_name'] ?? null, + $request['relay_label'] ?? null, + ]); + if ($directName !== null) { + return $directName; + } + + $departmentName = $this->findDepartmentRelayName( + isset($binding['department_id']) ? (int)$binding['department_id'] : 0, + $relayId + ); + if ($departmentName !== null) { + return $departmentName; + } + + foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) { + $optionRelayId = trim((string)($option['id'] ?? '')); + if ($optionRelayId === '' || $optionRelayId !== $relayId) { + continue; + } + + $optionName = $this->firstRelayLogString([ + $option['name'] ?? null, + $option['label'] ?? null, + $option['device_name'] ?? null, + ]); + if ($optionName !== null) { + return $optionName; + } + } + + return null; + } + + private function firstRelayLogString(array $candidates): ?string + { + foreach ($candidates as $candidate) { + $value = trim((string)($candidate ?? '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function findDepartmentRelayName(int $departmentId, string $relayId): ?string + { + if ($departmentId <= 0 || trim($relayId) === '') { + return null; + } + + try { + $rows = (new department_relays_o())->getFieldsWhere([ + 'department' => $departmentId, + 'relay_id' => $relayId, + 'deleted_at' => null, + ], ['id', 'name']); + } catch (\Throwable) { + return null; + } + + foreach ($rows as $row) { + $name = trim((string)($row['name'] ?? '')); + if ($name !== '') { + return $name; + } + } + + return null; + } + + private function relayLogBindingPayload(array $binding): array + { + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + + return $this->compactRelayLogArray([ + 'id' => isset($binding['id']) ? (int)$binding['id'] : null, + 'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null, + 'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null, + 'relay_id' => $binding['relay_id'] ?? null, + 'relay_name' => $this->firstRelayLogString([ + $metadata['relay_name'] ?? null, + $metadata['relay_label'] ?? null, + $metadata['name'] ?? null, + $metadata['label'] ?? null, + ]), + 'device_id' => $binding['device_id'] ?? null, + 'local_ip' => $binding['local_ip'] ?? null, + 'channel' => isset($binding['channel']) ? (int)$binding['channel'] : null, + ]); + } + + private function relayLogResponsePayload(array $result): array + { + if ($result === []) { + return []; + } + + return $this->compactRelayLogArray([ + 'online' => array_key_exists('online', $result) ? (bool)$result['online'] : null, + 'on' => array_key_exists('on', $result) ? (bool)$result['on'] : null, + 'raw' => $this->sanitizeRelayLogValue($result['raw'] ?? $result), + ]); + } + + private function normalizeRelayTransportResponse(array|object|null $response): array + { + if ($response === null) { + return []; + } + + $normalized = is_array($response) ? (array)($response[0] ?? $response) : (array)$response; + return [ + 'online' => (bool)($normalized['online'] ?? true), + 'on' => (bool)($normalized['on'] + ?? $normalized['output'] + ?? $normalized['status']['switch:0']['output'] + ?? false), + 'raw' => (array)($normalized['raw'] ?? $normalized), + ]; + } + + private function inferRelayIdFromTransportPayload(array $payload): ?string + { + $relayId = trim((string)($payload['id'] ?? $payload['relayId'] ?? $payload['relay_id'] ?? '')); + if ($relayId !== '') { + return $relayId; + } + + $ids = (array)($payload['ids'] ?? []); + foreach ($ids as $id) { + $relayId = trim((string)$id); + if ($relayId !== '') { + return $relayId; + } + } + + return null; + } + + private function inferRelayActionFromEndpoint(string $endpoint): string + { + return str_contains(strtolower($endpoint), '/get') ? 'STATUS' : 'SWITCH'; + } + + private function compactRelayLogArray(array $value): array + { + return array_filter( + $value, + static fn(mixed $entry): bool => $entry !== null && $entry !== '' && $entry !== [] + ); + } + + private function sanitizeRelayLogValue(mixed $value, int $depth = 0): mixed + { + if ($depth > 5) { + return '[truncated]'; + } + + if (is_object($value)) { + $value = (array)$value; + } + + if (is_array($value)) { + $result = []; + $count = 0; + foreach ($value as $key => $entry) { + if ($count >= 80) { + $result['__truncated'] = true; + break; + } + $result[$key] = $this->sanitizeRelayLogValue($entry, $depth + 1); + $count++; + } + return $result; + } + + if (is_string($value) && strlen($value) > 2000) { + return substr($value, 0, 2000) . '...'; + } + + if (is_scalar($value) || $value === null) { + return $value; + } + + return (string)$value; + } + + /** + * @throws Exception + */ + private function dispatchGatewayCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array + { + $delivery = (array)($job->delivery_json->value() ?? []); + $preferredChannel = (string)($delivery['preferred_channel'] ?? self::DELIVERY_CHANNEL_API); + $requireFastPath = !empty($delivery['require_fast_path']); + + $effectiveStatus = self::resolveGatewayStatus( + $gateway->status->value() === null ? null : (string)$gateway->status->value(), + $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() + ); + if (!$requireFastPath && !in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { + throw new Exception('Gateway agent is offline'); + } + + if ($preferredChannel === self::DELIVERY_CHANNEL_BROKER) { + if ($this->buildBrokerInternalUrl() === null) { + $this->markCommandDeliveryFailure($job, self::DELIVERY_CHANNEL_BROKER, 'Edge broker is not configured', 'broker_not_configured'); + if ($requireFastPath) { + $this->finalizeCommandJob($job, false, [], 'Edge broker is not configured', $gateway); + throw new Exception('Edge broker is not configured'); + } + } else { + try { + $this->markCommandJobDispatching($job, self::DELIVERY_CHANNEL_BROKER); + $payload = $this->dispatchBrokerCommand($gateway, $job); + $this->finalizeCommandJob($job, true, $payload, null, $gateway); + return $payload; + } catch (Exception $exception) { + $this->markCommandDeliveryFailure($job, self::DELIVERY_CHANNEL_BROKER, $exception->getMessage(), 'broker_dispatch_failed'); + if ($requireFastPath) { + $this->finalizeCommandJob( + $job, + false, + [], + $exception->getMessage(), + $gateway, + $this->isCommandTimeoutError($exception->getMessage()) ? 'TIMED_OUT' : 'FAILED' + ); + throw $exception; + } + } + } + } + + if ($requireFastPath) { + $this->finalizeCommandJob($job, false, [], 'Edge broker fast path is unavailable', $gateway); + throw new Exception('Edge broker fast path is unavailable'); + } + + return $this->waitForCommandResult((int)$job->id); + } + + private function isCommandTimeoutError(string $errorMessage): bool + { + return str_contains(strtolower(trim($errorMessage)), 'timed out'); + } + + private function markCommandJobDispatching( + edge_gateway_command_jobs_o $job, + string $channel, + ?string $fallbackReason = null + ): void { + $delivery = $this->buildDeliveryMetadata( + array_merge( + (array)($job->delivery_json->value() ?? []), + [ + 'delivery_channel' => $channel, + 'attempt_count' => (int)((array)($job->delivery_json->value() ?? [])['attempt_count'] ?? 0) + 1, + 'fallback_reason' => $fallbackReason, + 'last_dispatch_error' => null, + ] + ), + self::COMMAND_EXPIRES_AFTER_SECONDS + ); + + $job->status->set('DISPATCHING'); + $job->response_json->set([]); + $job->completed_at->set(null); + $job->error_message->set(null); + $job->delivery_json->set($delivery); + } + + private function markCommandDeliveryFailure( + edge_gateway_command_jobs_o $job, + string $channel, + string $errorMessage, + ?string $fallbackReason = null + ): void { + $delivery = $this->buildDeliveryMetadata( + array_merge( + (array)($job->delivery_json->value() ?? []), + [ + 'delivery_channel' => $channel, + 'fallback_reason' => $fallbackReason, + 'last_dispatch_error' => $errorMessage, + ] + ), + self::COMMAND_EXPIRES_AFTER_SECONDS + ); + $job->delivery_json->set($delivery); + } + + /** + * @throws Exception + */ + private function dispatchBrokerCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array + { + $brokerUrl = $this->buildBrokerInternalUrl(); + if ($brokerUrl === null) { + throw new Exception('Edge broker is not configured'); + } + + $result = $this->httpJsonRequest( + $brokerUrl . '/api/gateways/' . (int)$gateway->id . '/commands', + [ + 'commandType' => (string)$job->command_type->value(), + 'payload' => $this->buildCommandExecutionPayload($job, $gateway), + ], + [ + 'x-edge-broker-secret: ' . $this->configuredBrokerSharedSecret(), + ], + self::BROKER_HTTP_TIMEOUT_SECONDS + ); + + if (!is_array($result) || empty($result['ok'])) { + throw new Exception(trim((string)($result['error'] ?? 'Edge broker dispatch failed')) ?: 'Edge broker dispatch failed'); + } + + return isset($result['payload']) && is_array($result['payload']) ? (array)$result['payload'] : []; + } + + /** + * @throws Exception + */ + private function dispatchRelayThroughCloud( + int $departmentId, + string $logicalRelayId, + ?bool $on, + array $binding, + array $resolution, + ?int $toggleAfterSeconds = null, + array $actionContext = [] + ): array { + $transport = new cloud_shelly_transport(null, false); + $setPayload = ['id' => $logicalRelayId, 'on' => $on]; + if ($on !== null && $toggleAfterSeconds !== null) { + $setPayload['toggle_after'] = $toggleAfterSeconds; + } + $endpoint = $on === null ? '/v2/devices/api/get' : '/v2/devices/api/set/switch'; + $request = $on === null ? ['ids' => [$logicalRelayId]] : $setPayload; + $actionContext = $this->normalizeRelayActionContext($actionContext); + $dispatchLog = [ + 'action' => $on === null ? 'STATUS' : 'SWITCH', + 'handler' => 'cloud', + 'relay_id' => $logicalRelayId, + 'target_on' => $on, + 'toggle_after_seconds' => $toggleAfterSeconds, + 'signal' => [ + 'endpoint' => $endpoint, + 'request' => $request, + ], + 'action_context' => $actionContext, + ]; + + try { + $response = $transport->sendPostRequest($endpoint, $request, $departmentId); + } catch (\Throwable $exception) { + $this->appendRelayDispatchLog( + $binding, + array_merge($resolution, [ + 'execution_path' => 'cloud', + 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, + ]), + false, + $dispatchLog, + [], + $exception + ); + throw $exception; + } + + $normalized = is_array($response) ? (array)($response[0] ?? []) : (array)$response; + $result = [ + 'online' => (bool)($normalized['online'] ?? true), + 'on' => (bool)($normalized['on'] + ?? $normalized['output'] + ?? $normalized['status']['switch:0']['output'] + ?? $on + ?? false), + 'raw' => (array)($normalized['raw'] ?? $normalized), + ]; + + return $this->finalizeRelayDispatch( + $binding, + array_merge($resolution, [ + 'execution_path' => 'cloud', + 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, + ]), + $result, + $dispatchLog + ); + } + + /** + * @throws Exception + */ + private function handleRelayDispatchFailure( + int $departmentId, + string $logicalRelayId, + array $binding, + array $resolution, + ?bool $on, + Exception $exception, + ?int $toggleAfterSeconds = null, + array $actionContext = [] + ): array { + $recommendedAction = $this->mapRelayFailureToRecommendedAction($exception->getMessage()); + $this->recordRelayBindingResolution( + $binding, + array_merge($resolution, [ + 'execution_path' => 'local', + 'delivery_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), + 'reason' => 'local_dispatch_failed', + 'recommended_action' => $recommendedAction, + 'recovery_actions' => [$recommendedAction], + ]), + false, + $exception->getMessage() + ); + + if ((string)($resolution['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL) !== self::RELAY_FALLBACK_PREFER_LOCAL) { + throw $exception; + } + + try { + return $this->dispatchRelayThroughCloud( + $departmentId, + $logicalRelayId, + $on, + $binding, + array_merge($resolution, [ + 'execution_path' => 'cloud', + 'reason' => 'local_dispatch_failed', + 'fallback_reason' => $exception->getMessage(), + 'recommended_action' => $recommendedAction, + 'recovery_actions' => [$recommendedAction, 'force_cloud'], + ]), + $toggleAfterSeconds, + $actionContext + ); + } catch (Exception $cloudException) { + $this->recordRelayBindingResolution( + $binding, + array_merge($resolution, [ + 'execution_path' => 'cloud', + 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, + 'reason' => 'cloud_fallback_failed', + 'recommended_action' => $recommendedAction, + ]), + false, + $cloudException->getMessage() + ); + throw $cloudException; + } + } + + private function finalizeRelayDispatch(array $binding, array $resolution, array $result, array $dispatchLog = []): array + { + $executionPath = (string)($resolution['execution_path'] ?? 'local'); + $deliveryChannel = $executionPath === 'cloud' + ? self::DELIVERY_CHANNEL_CLOUD + : (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API); + + $resolutionPayload = array_merge($resolution, [ + 'delivery_channel' => $deliveryChannel, + 'execution_path' => $executionPath, + ]); + $this->recordRelayBindingResolution($binding, $resolutionPayload, true, null); + if ($dispatchLog !== []) { + $this->appendRelayDispatchLog($binding, $resolutionPayload, true, $dispatchLog, $result, null); + } + + return array_merge($result, [ + 'binding' => $this->reloadRelayBinding((int)$binding['id']), + 'execution' => [ + 'path' => $executionPath, + 'channel' => $deliveryChannel, + 'reason' => $resolutionPayload['reason'] ?? null, + 'fallback_mode' => $resolutionPayload['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL, + 'recommended_action' => $resolutionPayload['recommended_action'] ?? null, + ], + 'raw' => (array)($result['raw'] ?? []), + ]); + } + + private function reloadRelayBinding(int $bindingId): array + { + $binding = (new edge_gateway_relay_bindings_o())->select($bindingId); + return $binding->exists() ? $binding->asArray() : []; + } + + private function recordRelayBindingResolution( + array $binding, + array $resolution, + bool $success, + ?string $errorMessage + ): void { + $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$binding['id']); + if (!$bindingObject->exists()) { + return; + } + + $metadata = $this->normalizeRelayBindingMetadata((array)($bindingObject->metadata_json->value() ?? []), $binding); + $metadata['last_resolution'] = [ + 'at' => $this->now(), + 'execution_path' => $resolution['execution_path'] ?? 'local', + 'delivery_channel' => $resolution['delivery_channel'] ?? ($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), + 'reason' => $resolution['reason'] ?? null, + 'fallback_mode' => $metadata['fallback_mode'], + 'recommended_action' => $resolution['recommended_action'] ?? null, + 'recovery_actions' => array_values(array_filter((array)($resolution['recovery_actions'] ?? []))), + 'gateway_status' => $resolution['gateway_status'] ?? null, + 'device_online' => $resolution['device_online'] ?? null, + 'device_freshness_seconds' => $resolution['device_freshness_seconds'] ?? null, + 'device_freshness_state' => $resolution['device_freshness_state'] ?? null, + ]; + + if ($success) { + $metadata['last_success_at'] = $this->now(); + $metadata['last_error'] = null; + } else { + $metadata['last_error'] = $errorMessage; + } + + $bindingObject->metadata_json->set($metadata); + } + + private function mapRelayFailureToRecommendedAction(string $errorMessage): string + { + $normalized = strtolower(trim($errorMessage)); + if ($normalized === '') { + return 'retry_local_command'; + } + if (str_contains($normalized, 'credential') || str_contains($normalized, 'token')) { + return 'rotate_credentials'; + } + if (str_contains($normalized, 'discovery') || str_contains($normalized, 'device')) { + return 'retry_discovery'; + } + if (str_contains($normalized, 'update')) { + return 'retry_update'; + } + if (str_contains($normalized, 'offline') || str_contains($normalized, 'timeout') || str_contains($normalized, 'broker')) { + return 'restart_agent'; + } + + return 'retry_local_command'; + } + + /** + * @throws Exception + */ + private function httpJsonRequest(string $url, array $payload, array $headers = [], int $timeoutSeconds = 5): array + { + $defaultHeaders = [ + 'Content-Type: application/json', + 'Accept: application/json', + ]; + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", array_filter(array_merge($defaultHeaders, $headers))), + 'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'timeout' => max(1, $timeoutSeconds), + 'ignore_errors' => true, + ], + ]); + + $response = @file_get_contents($url, false, $context); + if ($response === false) { + throw new Exception('Unable to reach edge broker'); + } + + $decoded = json_decode($response, true); + if (!is_array($decoded)) { + throw new Exception('Edge broker returned an invalid response'); + } + + $statusLine = is_array($http_response_header ?? null) ? (string)($http_response_header[0] ?? '') : ''; + if ($statusLine !== '' && preg_match('/\s(\d{3})\s/', $statusLine, $matches) === 1) { + $statusCode = (int)$matches[1]; + if ($statusCode >= 400) { + throw new Exception(trim((string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')) ?: 'Edge broker request failed'); + } + } + + return $decoded; + } + + /** + * @param array $gatewayIds + * @return array + */ + private function aggregateInventoryUsage(array $gatewayIds): array + { + if ($gatewayIds === []) { + return self::emptyInventoryUsage(); + } + + $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); + $statement = db::getPDO()->prepare( + "SELECT + COUNT(*) AS total, + SUM(CASE WHEN online = 1 THEN 1 ELSE 0 END) AS online_total, + SUM(CASE WHEN online = 0 THEN 1 ELSE 0 END) AS offline_total + FROM edge_gateway_device_inventory + WHERE deleted_at IS NULL + AND gateway_id IN ($placeholders)" + ); + $statement->execute($gatewayIds); + $row = $statement->fetch(); + + return [ + 'total' => (int)($row['total'] ?? 0), + 'online' => (int)($row['online_total'] ?? 0), + 'offline' => (int)($row['offline_total'] ?? 0), + ]; + } + + /** + * @param array $gatewayIds + * @return array + */ + private function aggregateBindingUsage(array $gatewayIds): array + { + if ($gatewayIds === []) { + return self::emptyBindingUsage(); + } + + $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); + $statement = db::getPDO()->prepare( + "SELECT + COUNT(*) AS total, + SUM(CASE WHEN fallback_mode <> ? THEN 1 ELSE 0 END) AS fallback_overrides, + SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS cloud_only_total, + SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS local_only_total + FROM edge_gateway_relay_bindings + WHERE deleted_at IS NULL + AND gateway_id IN ($placeholders)" + ); + $statement->execute(array_merge([ + self::RELAY_FALLBACK_PREFER_LOCAL, + self::RELAY_FALLBACK_CLOUD_ONLY, + self::RELAY_FALLBACK_LOCAL_ONLY, + ], $gatewayIds)); + $row = $statement->fetch(); + + return [ + 'total' => (int)($row['total'] ?? 0), + 'fallback_overrides' => (int)($row['fallback_overrides'] ?? 0), + 'cloud_only' => (int)($row['cloud_only_total'] ?? 0), + 'local_only' => (int)($row['local_only_total'] ?? 0), + ]; + } + + /** + * @param array $gatewayIds + * @return array> + */ + private function aggregateInventoryUsageByGateway(array $gatewayIds): array + { + if ($gatewayIds === []) { + return []; + } + + $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); + $statement = db::getPDO()->prepare( + "SELECT + gateway_id, + COUNT(*) AS total, + SUM(CASE WHEN online = 1 THEN 1 ELSE 0 END) AS online_total, + SUM(CASE WHEN online = 0 THEN 1 ELSE 0 END) AS offline_total + FROM edge_gateway_device_inventory + WHERE deleted_at IS NULL + AND gateway_id IN ($placeholders) + GROUP BY gateway_id" + ); + $statement->execute($gatewayIds); + + $rows = []; + while (($row = $statement->fetch()) !== false) { + if (!is_array($row) || !isset($row['gateway_id'])) { + continue; + } + + $rows[(int)$row['gateway_id']] = [ + 'total' => (int)($row['total'] ?? 0), + 'online' => (int)($row['online_total'] ?? 0), + 'offline' => (int)($row['offline_total'] ?? 0), + ]; + } + + return $rows; + } + + /** + * @param array $gatewayIds + * @return array> + */ + private function aggregateBindingUsageByGateway(array $gatewayIds): array + { + if ($gatewayIds === []) { + return []; + } + + $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); + $statement = db::getPDO()->prepare( + "SELECT + gateway_id, + COUNT(*) AS total, + SUM(CASE WHEN fallback_mode <> ? THEN 1 ELSE 0 END) AS fallback_overrides, + SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS cloud_only_total, + SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS local_only_total + FROM edge_gateway_relay_bindings + WHERE deleted_at IS NULL + AND gateway_id IN ($placeholders) + GROUP BY gateway_id" + ); + $statement->execute(array_merge([ + self::RELAY_FALLBACK_PREFER_LOCAL, + self::RELAY_FALLBACK_CLOUD_ONLY, + self::RELAY_FALLBACK_LOCAL_ONLY, + ], $gatewayIds)); + + $rows = []; + while (($row = $statement->fetch()) !== false) { + if (!is_array($row) || !isset($row['gateway_id'])) { + continue; + } + + $rows[(int)$row['gateway_id']] = [ + 'total' => (int)($row['total'] ?? 0), + 'fallback_overrides' => (int)($row['fallback_overrides'] ?? 0), + 'cloud_only' => (int)($row['cloud_only_total'] ?? 0), + 'local_only' => (int)($row['local_only_total'] ?? 0), + ]; + } + + return $rows; + } + + /** + * @param array> $gateways + * @param array> $inventoryUsageByGateway + * @param array> $bindingUsageByGateway + * @return array> + */ + private static function attachGatewayCollectionSummaries( + array $gateways, + array $inventoryUsageByGateway = [], + array $bindingUsageByGateway = [] + ): array { + foreach ($gateways as $index => $gateway) { + if (!is_array($gateway)) { + continue; + } + + $gatewayId = (int)($gateway['id'] ?? 0); + $gateways[$index] = self::decorateGatewayUsageSummaries( + $gateway, + $inventoryUsageByGateway[$gatewayId] ?? null, + $bindingUsageByGateway[$gatewayId] ?? null + ); + } + + return $gateways; + } + + /** + * @param array> $gateways + * @return array + */ + private static function aggregateInventoryUsageFromGatewayRows(array $gateways): array + { + $totals = self::emptyInventoryUsage(); + + foreach ($gateways as $gateway) { + if (!is_array($gateway)) { + continue; + } + + $summary = self::resolveInventoryUsageForGateway($gateway); + $totals['total'] += (int)($summary['total'] ?? 0); + $totals['online'] += (int)($summary['online'] ?? 0); + $totals['offline'] += (int)($summary['offline'] ?? 0); + } + + return $totals; + } + + /** + * @param array> $gateways + * @return array + */ + private static function aggregateBindingUsageFromGatewayRows(array $gateways): array + { + $totals = self::emptyBindingUsage(); + + foreach ($gateways as $gateway) { + if (!is_array($gateway)) { + continue; + } + + $summary = self::resolveBindingUsageForGateway($gateway); + $totals['total'] += (int)($summary['total'] ?? 0); + $totals['fallback_overrides'] += (int)($summary['fallback_overrides'] ?? 0); + $totals['cloud_only'] += (int)($summary['cloud_only'] ?? 0); + $totals['local_only'] += (int)($summary['local_only'] ?? 0); + } + + return $totals; + } + + /** + * @param array $gateway + * @param array|null $inventoryUsage + * @param array|null $bindingUsage + * @return array + */ + private static function decorateGatewayUsageSummaries( + array $gateway, + ?array $inventoryUsage = null, + ?array $bindingUsage = null + ): array { + $inventory = self::resolveInventoryUsageForGateway($gateway, $inventoryUsage); + $bindings = self::resolveBindingUsageForGateway($gateway, $bindingUsage); + + $gateway['inventory_summary'] = $inventory; + $gateway['binding_summary'] = [ + 'total' => (int)($bindings['total'] ?? 0), + 'fallback_overrides' => (int)($bindings['fallback_overrides'] ?? 0), + ]; + + $fallbackSummary = isset($gateway['fallback_summary']) && is_array($gateway['fallback_summary']) + ? (array)$gateway['fallback_summary'] + : []; + $gateway['fallback_summary'] = array_merge($fallbackSummary, [ + 'cloud_only_relays' => (int)($fallbackSummary['cloud_only_relays'] ?? $bindings['cloud_only'] ?? 0), + 'local_only_relays' => (int)($fallbackSummary['local_only_relays'] ?? $bindings['local_only'] ?? 0), + ]); + + return $gateway; + } + + /** + * @param array $gateway + * @param array|null $summary + * @return array + */ + private static function resolveInventoryUsageForGateway(array $gateway, ?array $summary = null): array + { + if ($summary !== null) { + return array_merge(self::emptyInventoryUsage(), $summary); + } + + if (isset($gateway['inventory_summary']) && is_array($gateway['inventory_summary'])) { + return array_merge(self::emptyInventoryUsage(), array_map('intval', $gateway['inventory_summary'])); + } + + $inventory = isset($gateway['inventory']) && is_array($gateway['inventory']) ? $gateway['inventory'] : []; + $online = 0; + $offline = 0; + foreach ($inventory as $device) { + if (!is_array($device)) { + continue; + } + + if (($device['online'] ?? true) === false) { + $offline += 1; + continue; + } + + $online += 1; + } + + return [ + 'total' => count($inventory), + 'online' => $online, + 'offline' => $offline, + ]; + } + + /** + * @param array $gateway + * @param array|null $summary + * @return array + */ + private static function resolveBindingUsageForGateway(array $gateway, ?array $summary = null): array + { + if ($summary !== null) { + return array_merge(self::emptyBindingUsage(), $summary); + } + + if (isset($gateway['binding_summary']) && is_array($gateway['binding_summary'])) { + $fallbackSummary = isset($gateway['fallback_summary']) && is_array($gateway['fallback_summary']) + ? (array)$gateway['fallback_summary'] + : []; + + return array_merge(self::emptyBindingUsage(), [ + 'total' => (int)($gateway['binding_summary']['total'] ?? 0), + 'fallback_overrides' => (int)($gateway['binding_summary']['fallback_overrides'] ?? 0), + 'cloud_only' => (int)($fallbackSummary['cloud_only_relays'] ?? 0), + 'local_only' => (int)($fallbackSummary['local_only_relays'] ?? 0), + ]); + } + + $bindings = isset($gateway['bindings']) && is_array($gateway['bindings']) ? $gateway['bindings'] : []; + $fallbackOverrides = 0; + $cloudOnly = 0; + $localOnly = 0; + foreach ($bindings as $binding) { + if (!is_array($binding)) { + continue; + } + + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + $fallbackMode = isset($metadata['fallback_mode']) + ? (string)$metadata['fallback_mode'] + : (string)($binding['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL); + $fallbackMode = self::normalizeFallbackMode($fallbackMode); + + if ($fallbackMode !== self::RELAY_FALLBACK_PREFER_LOCAL) { + $fallbackOverrides += 1; + } + if ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { + $cloudOnly += 1; + } + if ($fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY) { + $localOnly += 1; + } + } + + return [ + 'total' => count($bindings), + 'fallback_overrides' => $fallbackOverrides, + 'cloud_only' => $cloudOnly, + 'local_only' => $localOnly, + ]; + } + + /** + * @return array + */ + private static function emptyInventoryUsage(): array + { + return [ + 'total' => 0, + 'online' => 0, + 'offline' => 0, + ]; + } + + /** + * @return array + */ + private static function emptyBindingUsage(): array + { + return [ + 'total' => 0, + 'fallback_overrides' => 0, + 'cloud_only' => 0, + 'local_only' => 0, + ]; + } + + /** + * @param array $values + */ + private static function appendNumericMetric(array &$values, mixed $value): void + { + if (!is_int($value) && !is_float($value) && !(is_string($value) && is_numeric($value))) { + return; + } + + $values[] = (float)$value; + } + + /** + * @param array $values + */ + private static function averageMetric(array $values): ?int + { + if ($values === []) { + return null; + } + + return (int)round(array_sum($values) / count($values)); + } + + public static function deriveGatewayRuntimeState(array $gateway, ?int $now = null): array + { + $effectiveStatus = self::resolveGatewayStatus( + isset($gateway['status']) ? (string)$gateway['status'] : null, + isset($gateway['last_heartbeat_at']) && $gateway['last_heartbeat_at'] !== null + ? (string)$gateway['last_heartbeat_at'] + : null, + $now + ); + + $gateway['status'] = $effectiveStatus; + $gateway['discovery_status'] = self::resolveDiscoveryStatus( + isset($gateway['discovery_status']) ? (string)$gateway['discovery_status'] : null, + $effectiveStatus + ); + $gateway['metadata'] = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $gateway['operational_snapshot'] = isset($gateway['operational_snapshot']) && is_array($gateway['operational_snapshot']) + ? (array)$gateway['operational_snapshot'] + : []; + + $channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now); + $relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now); + $fallbackSummary = self::buildFallbackSummary($relayHealth); + $gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now); + $lastSyncAt = self::resolveLastSyncAt($gateway); + + $gateway['channel_status'] = $channelStatus; + $gateway['relay_health'] = $relayHealth; + $gateway['fallback_summary'] = $fallbackSummary; + $gateway['transport_health'] = self::deriveTransportHealth( + $gateway, + $effectiveStatus, + $channelStatus, + $fallbackSummary, + $lastSyncAt + ); + $gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at'] + ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null); + $gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at'] + ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), 'DISCOVER_SHELLY'); + $gateway['last_successful_operation_at'] = $gateway['operational_snapshot']['last_successful_operation_at'] ?? null; + $gateway['backlog_depth'] = [ + 'commands' => (int)($gateway['operational_snapshot']['command_backlog'] ?? 0), + 'operations' => (int)($gateway['operational_snapshot']['operation_backlog'] ?? 0), + ]; + $gateway['version_drift'] = self::buildVersionDriftSummary($gateway); + $gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now); + $gateway['container_health'] = self::buildContainerHealthSummary($gateway, $effectiveStatus); + $gateway['last_sync_at'] = $lastSyncAt; + $gateway['update_window'] = self::buildUpdateWindowSummary($gateway); + $gateway['staged_version'] = self::buildStagedVersionSummary($gateway); + $gateway['rollback_status'] = self::buildRollbackStatusSummary($gateway); + $gateway['diagnostics'] = self::buildGatewayDiagnostics($gateway, $effectiveStatus, $now); + $gateway['error_state'] = self::primaryGatewayErrorState($gateway['diagnostics'], $gateway); + + return $gateway; + } + + private static function deriveChannelStatus(array $gateway, string $effectiveStatus, ?int $now = null): array + { + $metadata = (array)($gateway['metadata'] ?? []); + $operational = (array)($gateway['operational_snapshot'] ?? []); + $brokerPresence = isset($metadata['broker_presence']) && is_array($metadata['broker_presence']) + ? (array)$metadata['broker_presence'] + : []; + $brokerLastSeenAt = isset($brokerPresence['last_seen_at']) ? (string)$brokerPresence['last_seen_at'] : null; + $brokerAgeSeconds = self::heartbeatAgeSeconds($brokerLastSeenAt, $now); + $brokerConnected = self::isBrokerPresenceConnected($brokerPresence, $now); + $brokerHealthy = $brokerConnected + && $brokerAgeSeconds !== null + && $brokerAgeSeconds < self::BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS; + $commandPreferred = $brokerConnected ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API; + + return [ + 'command' => [ + 'preferred' => $commandPreferred, + 'active' => $brokerHealthy ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API, + 'state' => $effectiveStatus === self::STATUS_OFFLINE + ? self::STATUS_OFFLINE + : ($brokerConnected ? ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED) : self::STATUS_DEGRADED), + 'backlog_depth' => (int)($operational['command_backlog'] ?? 0), + 'last_success_at' => $operational['last_successful_command_at'] + ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null), + ], + 'broker' => [ + 'connected' => $brokerConnected, + 'state' => !$brokerConnected + ? self::STATUS_OFFLINE + : ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED), + 'last_seen_at' => $brokerLastSeenAt, + 'disconnect_reason' => isset($brokerPresence['disconnect_reason']) ? (string)$brokerPresence['disconnect_reason'] : null, + 'last_error' => isset($brokerPresence['last_error']) ? (string)$brokerPresence['last_error'] : null, + ], + ]; + } + + private static function isBrokerPresenceConnected(array $presence, ?int $now = null): bool + { + if (empty($presence['connected'])) { + return false; + } + + $lastSeenAt = isset($presence['last_seen_at']) ? (string)$presence['last_seen_at'] : null; + $ageSeconds = self::heartbeatAgeSeconds($lastSeenAt, $now); + + return $ageSeconds !== null && $ageSeconds < self::BROKER_PRESENCE_TTL_SECONDS; + } + + private static function buildRelayHealth(array $gateway, string $effectiveStatus, ?int $now = null): array + { + $bindings = is_array($gateway['bindings'] ?? null) ? (array)$gateway['bindings'] : []; + $inventory = is_array($gateway['inventory'] ?? null) ? (array)$gateway['inventory'] : []; + $inventoryByDeviceId = []; + foreach ($inventory as $device) { + if (!is_array($device)) { + continue; + } + $deviceId = trim((string)($device['device_id'] ?? '')); + if ($deviceId !== '') { + $inventoryByDeviceId[$deviceId] = $device; + } + } + + $departmentTransportMode = (string)($gateway['department_transport_mode'] ?? self::TRANSPORT_MODE_CLOUD); + $relayHealth = []; + foreach ($bindings as $binding) { + if (!is_array($binding)) { + continue; + } + + $bindingMetadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + $fallbackMode = self::normalizeFallbackMode((string)($binding['fallback_mode'] ?? $bindingMetadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL)); + $device = isset($inventoryByDeviceId[(string)($binding['device_id'] ?? '')]) + ? (array)$inventoryByDeviceId[(string)$binding['device_id']] + : null; + $deviceLastSeenAt = is_array($device) && isset($device['last_seen_at']) ? (string)$device['last_seen_at'] : null; + $deviceFreshnessSeconds = self::heartbeatAgeSeconds($deviceLastSeenAt, $now); + $deviceOnline = is_array($device) && array_key_exists('online', $device) ? (bool)$device['online'] : null; + $deviceFresh = $device !== null + && $deviceOnline !== false + && $deviceFreshnessSeconds !== null + && $deviceFreshnessSeconds < self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS; + + $executionPath = 'local'; + $reason = null; + if ($departmentTransportMode === self::TRANSPORT_MODE_CLOUD) { + $executionPath = 'cloud'; + $reason = 'department_cutover'; + } elseif ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { + $executionPath = 'cloud'; + $reason = 'binding_cloud_only'; + } elseif ($effectiveStatus === self::STATUS_OFFLINE) { + $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; + $reason = 'gateway_offline'; + } elseif ($device === null) { + $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; + $reason = 'device_missing'; + } elseif (!$deviceFresh) { + $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; + $reason = $deviceOnline === false ? 'device_offline' : 'device_stale'; + } + + $recommendedAction = match ($reason) { + 'department_cutover' => 'review_department_cutover', + 'binding_cloud_only' => 'review_binding_override', + 'gateway_offline' => 'restart_agent', + 'device_missing', 'device_stale', 'device_offline' => 'retry_discovery', + default => null, + }; + + $relayHealth[] = [ + 'binding_id' => isset($binding['id']) ? (int)$binding['id'] : null, + 'relay_id' => isset($binding['relay_id']) ? (string)$binding['relay_id'] : null, + 'device_id' => isset($binding['device_id']) ? (string)$binding['device_id'] : null, + 'fallback_mode' => $fallbackMode, + 'execution_path' => $executionPath, + 'reason' => $reason, + 'recommended_action' => $recommendedAction, + 'recovery_actions' => array_values(array_filter([$recommendedAction])), + 'device_online' => $deviceOnline, + 'device_freshness_seconds' => $deviceFreshnessSeconds, + 'device_freshness_state' => self::resolveDeviceFreshnessState($device, $deviceFreshnessSeconds), + 'gateway_status' => $effectiveStatus, + 'last_resolution' => isset($binding['last_resolution']) && is_array($binding['last_resolution']) + ? (array)$binding['last_resolution'] + : (isset($bindingMetadata['last_resolution']) && is_array($bindingMetadata['last_resolution']) + ? (array)$bindingMetadata['last_resolution'] + : null), + 'last_success_at' => $binding['last_success_at'] ?? $bindingMetadata['last_success_at'] ?? null, + 'last_error' => $binding['last_error'] ?? $bindingMetadata['last_error'] ?? null, + ]; + } + + return $relayHealth; + } + + private static function buildFallbackSummary(array $relayHealth): array + { + $summary = [ + 'local_relays' => 0, + 'cloud_relays' => 0, + 'local_only_relays' => 0, + 'cloud_only_relays' => 0, + 'affected_relays' => [], + 'recommended_action' => null, + ]; + + foreach ($relayHealth as $relay) { + $executionPath = (string)($relay['execution_path'] ?? 'local'); + if ($executionPath === 'cloud') { + $summary['cloud_relays'] += 1; + if (!empty($relay['relay_id'])) { + $summary['affected_relays'][] = (string)$relay['relay_id']; + } + if ($summary['recommended_action'] === null && !empty($relay['recommended_action'])) { + $summary['recommended_action'] = (string)$relay['recommended_action']; + } + } else { + $summary['local_relays'] += 1; + } + + $fallbackMode = (string)($relay['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL); + if ($fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY) { + $summary['local_only_relays'] += 1; + } + if ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { + $summary['cloud_only_relays'] += 1; + } + } + + return $summary; + } + + private static function deriveTransportHealth( + array $gateway, + string $effectiveStatus, + array $channelStatus, + array $fallbackSummary, + ?string $lastSyncAt + ): array + { + $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status']) + ? (array)$metadata['control_plane_status'] + : []; + $brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE); + $affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? [])); + $transportState = $effectiveStatus; + if ($effectiveStatus !== self::STATUS_OFFLINE && ($brokerState === self::STATUS_DEGRADED || $affectedRelayCount > 0)) { + $transportState = self::STATUS_DEGRADED; + } + + return [ + 'status' => $transportState, + 'broker_connected' => !empty($channelStatus['broker']['connected']), + 'affected_relay_count' => $affectedRelayCount, + 'summary' => $affectedRelayCount > 0 + ? $affectedRelayCount . ' relæ(er) kører via cloud fallback' + : (!empty($channelStatus['broker']['connected']) + ? 'Broker fast path er aktiv med API polling som fallback' + : 'API polling er aktiv som primær kontrolkanal'), + 'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null), + 'last_successful_sync_at' => $lastSyncAt, + 'last_transport_failure_at' => isset($controlPlaneStatus['last_transport_failure_at']) + ? (string)$controlPlaneStatus['last_transport_failure_at'] + : null, + 'last_transport_error' => isset($controlPlaneStatus['last_transport_error']) + ? (string)$controlPlaneStatus['last_transport_error'] + : null, + ]; + } + + private static function buildVersionDriftSummary(array $gateway): array + { + $installed = isset($gateway['installed_version']) ? trim((string)$gateway['installed_version']) : ''; + $target = isset($gateway['target_version']) ? trim((string)$gateway['target_version']) : ''; + $isDrifted = $installed !== '' && $target !== '' && $installed !== $target; + + return [ + 'installed_version' => $installed !== '' ? $installed : null, + 'target_version' => $target !== '' ? $target : null, + 'release_channel' => isset($gateway['release_channel']) ? (string)$gateway['release_channel'] : self::configuredDefaultReleaseChannel(), + 'is_drifted' => $isDrifted, + 'status' => $isDrifted ? 'UPDATE_AVAILABLE' : (($installed === '' || $target === '') ? 'UNKNOWN' : 'IN_SYNC'), + 'last_successful_update_at' => $gateway['operational_snapshot']['last_successful_update_at'] ?? null, + ]; + } + + private static function buildCredentialFreshnessSummary(array $gateway, ?int $now = null): array + { + $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $rotatedAt = isset($metadata['credentials_rotated_at']) ? (string)$metadata['credentials_rotated_at'] : null; + $ageSeconds = self::heartbeatAgeSeconds($rotatedAt, $now); + + return [ + 'rotated_at' => $rotatedAt, + 'age_days' => $ageSeconds === null ? null : (int)floor($ageSeconds / 86400), + 'state' => $rotatedAt === null + ? 'UNKNOWN' + : ($ageSeconds !== null && $ageSeconds <= self::CREDENTIAL_FRESH_AFTER_SECONDS ? 'FRESH' : 'STALE'), + ]; + } + + /** + * @return array> + */ + private static function defaultGatewayServiceHealth(string $defaultStatus): array + { + return [ + ['name' => 'edge-agent', 'status' => $defaultStatus], + ['name' => 'lan-worker', 'status' => $defaultStatus], + ['name' => 'redis', 'status' => $defaultStatus], + ['name' => 'mariadb', 'status' => $defaultStatus], + ['name' => 'minio', 'status' => $defaultStatus], + ['name' => 'auto-updater', 'status' => $defaultStatus], + ]; + } + + private static function buildContainerHealthSummary(array $gateway, string $effectiveStatus): array + { + $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $raw = isset($metadata['container_health']) && is_array($metadata['container_health']) + ? (array)$metadata['container_health'] + : []; + $rawServices = isset($raw['services']) && is_array($raw['services']) ? (array)$raw['services'] : []; + $defaultServices = self::defaultGatewayServiceHealth( + $effectiveStatus === self::STATUS_OFFLINE ? 'offline' : 'healthy' + ); + $services = $defaultServices; + foreach ($rawServices as $rawService) { + if (!is_array($rawService)) { + continue; + } + + $serviceName = trim((string)($rawService['name'] ?? '')); + if ($serviceName === '') { + continue; + } + + $matched = false; + foreach ($services as $index => $defaultService) { + if ((string)($defaultService['name'] ?? '') !== $serviceName) { + continue; + } + + $services[$index] = array_merge($defaultService, $rawService); + $matched = true; + break; + } + + if (!$matched) { + $services[] = $rawService; + } + } + $healthyCount = 0; + $degradedCount = 0; + foreach ($services as $index => $service) { + if (!is_array($service)) { + $services[$index] = ['name' => 'service-' . $index, 'status' => 'unknown']; + continue; + } + + $status = strtolower(trim((string)($service['status'] ?? 'unknown'))); + $name = trim((string)($service['name'] ?? 'service-' . $index)); + if (in_array($status, ['healthy', 'running', 'online'], true)) { + $status = 'healthy'; + $healthyCount += 1; + } elseif (in_array($status, ['degraded', 'starting', 'unknown'], true)) { + $status = 'degraded'; + $degradedCount += 1; + } else { + $status = $status === 'offline' ? 'offline' : 'degraded'; + $degradedCount += 1; + } + + $services[$index] = array_merge($service, [ + 'name' => $name, + 'status' => $status, + ]); + } + + $state = $effectiveStatus === self::STATUS_OFFLINE + ? self::STATUS_OFFLINE + : ($degradedCount > 0 ? self::STATUS_DEGRADED : self::STATUS_ONLINE); + + return [ + 'state' => strtoupper((string)($raw['state'] ?? $state)), + 'summary' => (string)($raw['summary'] ?? sprintf('%d/%d containers healthy', $healthyCount, count($services))), + 'services' => $services, + 'healthy_count' => $healthyCount, + 'total' => count($services), + ]; + } + + private static function buildOutboxStatusSummary(array $gateway, ?int $now = null): array + { + $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $raw = isset($metadata['outbox_status']) && is_array($metadata['outbox_status']) + ? (array)$metadata['outbox_status'] + : []; + $queued = max(0, (int)($raw['queued'] ?? $raw['queue_depth'] ?? 0)); + $oldestQueuedAt = isset($raw['oldest_queued_at']) ? (string)$raw['oldest_queued_at'] : null; + $oldestAgeSeconds = self::heartbeatAgeSeconds($oldestQueuedAt, $now); + $state = $queued === 0 + ? 'IN_SYNC' + : (($oldestAgeSeconds !== null && $oldestAgeSeconds >= self::HEARTBEAT_OFFLINE_AFTER_SECONDS) ? 'DEGRADED' : 'QUEUED'); + + return [ + 'state' => (string)($raw['state'] ?? $state), + 'queued' => $queued, + 'oldest_queued_at' => $oldestQueuedAt, + 'oldest_age_seconds' => $oldestAgeSeconds, + 'last_replayed_at' => isset($raw['last_replayed_at']) ? (string)$raw['last_replayed_at'] : null, + 'summary' => (string)($raw['summary'] ?? ($queued === 0 ? 'Outbox is empty' : sprintf('%d outbound items queued', $queued))), + ]; + } + + private static function resolveLastSyncAt(array $gateway): ?string + { + $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status']) + ? (array)$metadata['control_plane_status'] + : []; + $outbox = isset($gateway['outbox_status']) && is_array($gateway['outbox_status']) + ? (array)$gateway['outbox_status'] + : []; + $outboxMetadata = isset($metadata['outbox_status']) && is_array($metadata['outbox_status']) + ? (array)$metadata['outbox_status'] + : []; + + return self::latestTimestamp([ + $controlPlaneStatus['last_successful_sync_at'] ?? null, + $metadata['last_sync_at'] ?? null, + $outbox['last_replayed_at'] ?? null, + $outboxMetadata['last_replayed_at'] ?? null, + $gateway['last_heartbeat_at'] ?? null, + ]); + } + + private static function buildUpdateWindowSummary(array $gateway): array + { + $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $window = trim((string)($metadata['update_window'] ?? self::configuredDefaultUpdateWindow())); + if ($window === '') { + $window = self::configuredDefaultUpdateWindow(); + } + + return [ + 'window' => $window, + 'timezone' => isset($metadata['timezone']) ? (string)$metadata['timezone'] : null, + 'strategy' => 'nightly', + ]; + } + + private static function buildStagedVersionSummary(array $gateway): ?array + { + $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $raw = isset($metadata['staged_version']) && is_array($metadata['staged_version']) + ? (array)$metadata['staged_version'] + : []; + $targetVersion = trim((string)($raw['target_version'] ?? $raw['version'] ?? '')); + if ($targetVersion === '') { + return null; + } + + return [ + 'target_version' => $targetVersion, + 'staged_at' => isset($raw['staged_at']) ? (string)$raw['staged_at'] : null, + 'apply_after' => isset($raw['apply_after']) ? (string)$raw['apply_after'] : null, + 'status' => isset($raw['status']) ? (string)$raw['status'] : 'STAGED', + ]; + } + + private static function buildRollbackStatusSummary(array $gateway): array + { + $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $raw = isset($metadata['rollback_status']) && is_array($metadata['rollback_status']) + ? (array)$metadata['rollback_status'] + : []; + $state = trim((string)($raw['state'] ?? 'IDLE')); + + return [ + 'state' => $state !== '' ? $state : 'IDLE', + 'reason' => isset($raw['reason']) ? (string)$raw['reason'] : null, + 'rolled_back_to' => isset($raw['rolled_back_to']) ? (string)$raw['rolled_back_to'] : null, + 'at' => isset($raw['at']) ? (string)$raw['at'] : null, + ]; + } + + private static function buildGatewayDiagnostics(array $gateway, string $effectiveStatus, ?int $now = null): array + { + $diagnostics = []; + $heartbeatAge = self::heartbeatAgeSeconds( + isset($gateway['last_heartbeat_at']) ? (string)$gateway['last_heartbeat_at'] : null, + $now + ); + + if ($effectiveStatus === self::STATUS_OFFLINE) { + $diagnostics[] = [ + 'code' => edge_gateway_operation_service::ERROR_OFFLINE, + 'severity' => 'danger', + 'message' => 'Gateway heartbeat has expired and the gateway is offline.', + 'recommended_action' => 'restart_agent', + ]; + } elseif ($effectiveStatus === self::STATUS_DEGRADED || ($heartbeatAge !== null && $heartbeatAge >= self::HEARTBEAT_DEGRADED_AFTER_SECONDS)) { + $diagnostics[] = [ + 'code' => edge_gateway_operation_service::ERROR_STALE_HEARTBEAT, + 'severity' => 'warning', + 'message' => 'Gateway heartbeat is stale and control traffic may degrade.', + 'recommended_action' => 'inspect_connectivity', + ]; + } + + $activeOperation = isset($gateway['active_operation']) && is_array($gateway['active_operation']) + ? (array)$gateway['active_operation'] + : null; + if ($activeOperation !== null && !empty($activeOperation['started_at'])) { + $startedAt = self::parseApplicationDateTime((string)$activeOperation['started_at']); + if ($startedAt !== null && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) { + $diagnostics[] = [ + 'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT, + 'severity' => 'warning', + 'message' => 'The active gateway operation has exceeded the expected timeout.', + 'recommended_action' => 'retry_operation', + ]; + } + } + + if (!empty($gateway['version_drift']['is_drifted'])) { + $diagnostics[] = [ + 'code' => 'EDGE_GATEWAY_VERSION_DRIFT', + 'severity' => 'info', + 'message' => 'Installed gateway version differs from the target version.', + 'recommended_action' => 'queue_update', + ]; + } + + if (($gateway['credential_freshness']['state'] ?? 'UNKNOWN') === 'STALE') { + $diagnostics[] = [ + 'code' => 'EDGE_GATEWAY_CREDENTIALS_STALE', + 'severity' => 'warning', + 'message' => 'Gateway credentials have not been rotated recently.', + 'recommended_action' => 'rotate_credentials', + ]; + } + + $containerHealth = isset($gateway['container_health']) && is_array($gateway['container_health']) + ? (array)$gateway['container_health'] + : []; + $containerState = strtoupper((string)($containerHealth['state'] ?? self::STATUS_ONLINE)); + if (in_array($containerState, [self::STATUS_DEGRADED, self::STATUS_OFFLINE], true)) { + $diagnostics[] = [ + 'code' => 'EDGE_GATEWAY_CONTAINER_DEGRADED', + 'severity' => $containerState === self::STATUS_OFFLINE ? 'danger' : 'warning', + 'message' => 'One or more compose services are not healthy on the gateway.', + 'recommended_action' => 'restart_agent', + ]; + } + + $outboxStatus = isset($gateway['outbox_status']) && is_array($gateway['outbox_status']) + ? (array)$gateway['outbox_status'] + : []; + if ((int)($outboxStatus['queued'] ?? 0) > 0) { + $diagnostics[] = [ + 'code' => 'EDGE_GATEWAY_OUTBOX_BACKLOG', + 'severity' => 'warning', + 'message' => 'The gateway has queued outbound control-plane items waiting for replay.', + 'recommended_action' => 'inspect_connectivity', + ]; + } + + $rollbackStatus = isset($gateway['rollback_status']) && is_array($gateway['rollback_status']) + ? (array)$gateway['rollback_status'] + : []; + $rollbackState = strtoupper((string)($rollbackStatus['state'] ?? 'IDLE')); + if ($rollbackState === 'ROLLED_BACK') { + $diagnostics[] = [ + 'code' => 'EDGE_GATEWAY_UPDATE_ROLLED_BACK', + 'severity' => 'warning', + 'message' => 'The last container rollout was rolled back automatically.', + 'recommended_action' => 'review_diagnostics', + ]; + } elseif ($rollbackState === 'FAILED') { + $diagnostics[] = [ + 'code' => 'EDGE_GATEWAY_ROLLBACK_FAILED', + 'severity' => 'danger', + 'message' => 'Gateway rollback failed and manual intervention is required.', + 'recommended_action' => 'review_diagnostics', + ]; + } + + return $diagnostics; + } + + private static function primaryGatewayErrorState(array $diagnostics, array $gateway): ?array + { + if ($diagnostics !== []) { + return [ + 'code' => (string)$diagnostics[0]['code'], + 'message' => (string)$diagnostics[0]['message'], + 'recommended_action' => $diagnostics[0]['recommended_action'] ?? null, + ]; + } + + $activeStatus = strtoupper((string)($gateway['active_operation']['status'] ?? '')); + if (in_array($activeStatus, [ + edge_gateway_operation_service::STATUS_CANCEL_REQUESTED, + edge_gateway_operation_service::STATUS_CANCELLED, + ], true)) { + return null; + } + + if (!empty($gateway['active_operation']['error_code']) || !empty($gateway['active_operation']['error_message'])) { + return [ + 'code' => $gateway['active_operation']['error_code'] ?? 'EDGE_GATEWAY_OPERATION_FAILED', + 'message' => $gateway['active_operation']['error_message'] ?? 'Gateway operation failed', + 'recommended_action' => 'retry_operation', + ]; + } + + return null; + } + + private static function findLatestCompletionTimestamp(array $jobs, ?string $commandType = null): ?string + { + foreach ($jobs as $job) { + if (!is_array($job)) { + continue; + } + if (($job['status'] ?? null) !== 'COMPLETED') { + continue; + } + if ($commandType !== null && ($job['command_type'] ?? null) !== $commandType) { + continue; + } + return isset($job['completed_at']) ? (string)$job['completed_at'] : null; + } + + return null; + } + + private static function findLatestShellTimestamp(array $sessions): ?string + { + foreach ($sessions as $session) { + if (!is_array($session)) { + continue; + } + foreach (['opened_at', 'approved_at', 'created_at'] as $field) { + if (!empty($session[$field])) { + return (string)$session[$field]; + } + } + } + + return null; + } + + /** + * @param array $timestamps + */ + private static function latestTimestamp(array $timestamps): ?string + { + $latestValue = null; + $latestEpoch = 0; + + foreach ($timestamps as $timestamp) { + if (!is_string($timestamp) || trim($timestamp) === '') { + continue; + } + + $epoch = self::parseApplicationDateTime($timestamp); + if ($epoch === null) { + continue; + } + + if ($latestValue === null || $epoch >= $latestEpoch) { + $latestValue = $timestamp; + $latestEpoch = $epoch; + } + } + + return $latestValue; + } + + private static function normalizeFallbackMode(?string $fallbackMode): string + { + $normalized = strtoupper(trim((string)$fallbackMode)); + if (in_array($normalized, [ + self::RELAY_FALLBACK_PREFER_LOCAL, + self::RELAY_FALLBACK_LOCAL_ONLY, + self::RELAY_FALLBACK_CLOUD_ONLY, + ], true)) { + return $normalized; + } + + return self::RELAY_FALLBACK_PREFER_LOCAL; + } + + private function clearObjectPropertyCache(string $table, int $id): void + { + if ($id <= 0 || !defined('redis')) { + return; + } + + $normalizedTable = trim($table, " `\t\n\r\0\x0B"); + redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*'); + } + + private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string + { + if ($device === null) { + return 'MISSING'; + } + if (isset($device['online']) && $device['online'] === false) { + return 'OFFLINE'; + } + if ($ageSeconds === null) { + return 'UNKNOWN'; + } + if ($ageSeconds >= self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS) { + return 'STALE'; + } + + return 'READY'; + } + + public static function resolveGatewayStatus(?string $reportedStatus, ?string $lastHeartbeatAt, ?int $now = null): string + { + $normalizedStatus = self::normalizeGatewayStatus($reportedStatus); + $heartbeatAgeSeconds = self::heartbeatAgeSeconds($lastHeartbeatAt, $now); + + if ($normalizedStatus === self::STATUS_OFFLINE) { + return self::STATUS_OFFLINE; + } + + if ($heartbeatAgeSeconds === null || $heartbeatAgeSeconds >= self::HEARTBEAT_OFFLINE_AFTER_SECONDS) { + return self::STATUS_OFFLINE; + } + + if ($normalizedStatus === self::STATUS_DEGRADED) { + return self::STATUS_DEGRADED; + } + + if ($normalizedStatus === self::STATUS_ONLINE && $heartbeatAgeSeconds >= self::HEARTBEAT_DEGRADED_AFTER_SECONDS) { + return self::STATUS_DEGRADED; + } + + return $normalizedStatus; + } + + public static function resolveDiscoveryStatus(?string $discoveryStatus, string $effectiveStatus): string + { + $normalizedStatus = trim(strtoupper((string)$discoveryStatus)); + + if ($effectiveStatus === self::STATUS_OFFLINE && $normalizedStatus === 'READY') { + return 'STALE'; + } + + return $normalizedStatus !== '' ? $normalizedStatus : 'UNKNOWN'; + } + + private static function normalizeGatewayStatus(?string $reportedStatus): string + { + $normalizedStatus = trim(strtoupper((string)$reportedStatus)); + + if (in_array($normalizedStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED, self::STATUS_OFFLINE], true)) { + return $normalizedStatus; + } + + return $normalizedStatus !== '' ? $normalizedStatus : 'UNKNOWN'; + } + + private static function heartbeatAgeSeconds(?string $lastHeartbeatAt, ?int $now = null): ?int + { + if ($lastHeartbeatAt === null || trim($lastHeartbeatAt) === '') { + return null; + } + + $heartbeatTimestamp = self::parseApplicationDateTime($lastHeartbeatAt); + if ($heartbeatTimestamp === null) { + return null; + } + + return max(0, ($now ?? time()) - $heartbeatTimestamp); + } + + private static function heartbeatTimestamp(?string $lastHeartbeatAt): int + { + if ($lastHeartbeatAt === null || trim($lastHeartbeatAt) === '') { + return 0; + } + + return self::parseApplicationDateTime($lastHeartbeatAt) ?? 0; + } + + private static function statusPriority(string $status): int + { + return match ($status) { + self::STATUS_ONLINE => 3, + self::STATUS_DEGRADED => 2, + self::STATUS_OFFLINE => 1, + default => 0, + }; + } + + private function writeAudit(?int $gatewayId, ?int $departmentId, string $action, ?int $userId, array $context): void + { + (new edge_gateway_audit_logs_o())->add_object([ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'action' => $action, + 'actor_user_id' => $userId, + 'actor_type' => $userId === null ? 'SYSTEM' : 'USER', + 'severity' => 'INFO', + 'context_json' => $context, + ]); + } + + private function buildSignedBrokerToken(array $payload): string + { + $body = self::base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES)); + $signature = self::base64UrlEncode(hash_hmac('sha256', $body, $this->brokerSessionSecret(), true)); + return $body . '.' . $signature; + } + + /** + * @return array + * @throws Exception + */ + private function parseSignedBrokerToken(string $token): array + { + $token = trim($token); + if ($token === '' || !str_contains($token, '.')) { + throw new Exception('Missing broker session token'); + } + + [$body, $signature] = explode('.', $token, 2); + $expectedSignature = self::base64UrlEncode(hash_hmac('sha256', $body, $this->brokerSessionSecret(), true)); + if (!hash_equals($expectedSignature, $signature)) { + throw new Exception('Invalid broker session token'); + } + + $decoded = json_decode((string)self::base64UrlDecode($body), true); + if (!is_array($decoded)) { + throw new Exception('Broker session token payload is invalid'); + } + + $expiresAt = isset($decoded['exp']) ? (int)$decoded['exp'] : 0; + if ($expiresAt > 0 && $expiresAt <= time()) { + throw new Exception('Broker session token expired'); + } + + return $decoded; + } + + private function findShellSessionByToken(string $plainToken): edge_gateway_shell_sessions_o + { + $tokenHash = $this->hashToken($plainToken); + $rows = (new edge_gateway_shell_sessions_o())->getFieldsWhere([ + 'session_token_hash' => $tokenHash, + 'deleted_at' => null, + ], ['id']); + + $sessionId = isset($rows[0]['id']) ? (int)$rows[0]['id'] : 0; + $session = (new edge_gateway_shell_sessions_o())->select($sessionId); + if (!$session->exists()) { + throw new Exception('Shell session not found'); + } + + return $session; + } + + private function brokerSessionSecret(): string + { + $secret = trim((string)(getenv('EDGE_GATEWAY_SESSION_SECRET') ?: '')); + if ($secret !== '') { + return $secret; + } + + $fallback = $this->configuredBrokerSharedSecret(); + if ($fallback !== '') { + return $fallback; + } + + return hash('sha256', $this->getApiBaseUrl() . '::edgegateway'); + } + + private static function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + private static function base64UrlDecode(string $value): string + { + $padding = strlen($value) % 4; + if ($padding > 0) { + $value .= str_repeat('=', 4 - $padding); + } + + return (string)base64_decode(strtr($value, '-_', '+/')); + } + + private function hashToken(string $plainToken): string + { + return hash('sha256', $plainToken); + } + + public static function parseApplicationDateTime(?string $value): ?int + { + $normalized = trim((string)$value); + if ($normalized === '') { + return null; + } + + $timezone = self::applicationTimeZone(); + $dateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone); + if ($dateTime instanceof \DateTimeImmutable) { + return $dateTime->getTimestamp(); + } + + try { + return (new \DateTimeImmutable($normalized, $timezone))->getTimestamp(); + } catch (\Throwable) { + return null; + } + } + + public static function formatApplicationDateTime(int $timestamp): string + { + return (new \DateTimeImmutable('@' . $timestamp)) + ->setTimezone(self::applicationTimeZone()) + ->format('Y-m-d H:i:s'); + } + + private static function applicationTimeZone(): \DateTimeZone + { + $timezone = trim((string)($_ENV['CONFIG_TIMEZONE'] ?? getenv('CONFIG_TIMEZONE') ?: 'Europe/Copenhagen')); + if ($timezone === '') { + $timezone = 'Europe/Copenhagen'; + } + + try { + return new \DateTimeZone($timezone); + } catch (\Throwable) { + return new \DateTimeZone('Europe/Copenhagen'); + } + } + + private function now(): string + { + return self::formatApplicationDateTime(time()); + } + + private function formatDateTime(int $timestamp): string + { + return self::formatApplicationDateTime($timestamp); + } + + private function remoteIp(): ?string + { + $ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? '')); + return $ip !== '' ? $ip : null; + } +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_operation_exception.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_operation_exception.php new file mode 100644 index 00000000..f232a26f --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_operation_exception.php @@ -0,0 +1,17 @@ +> + * @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> + * @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', 'CANCEL_REQUESTED') + ORDER BY FIELD(status, 'IN_PROGRESS', 'CANCEL_REQUESTED', '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, + 'cancel_requested' => 0, + 'cancelled' => 0, + 'completed' => 0, + 'failed' => 0, + 'latest_cancelled_at' => null, + '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_CANCEL_REQUESTED) { + $summary['cancel_requested'] += 1; + } elseif ($status === self::STATUS_CANCELLED) { + $summary['cancelled'] += 1; + $summary['latest_cancelled_at'] ??= $operation['completed_at'] ?? null; + } 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] + ); + + $this->refreshGatewayViewCache($gatewayId); + $this->manager()->notifyBrokerGatewaySync($gatewayId); + + 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 cancelOperation(int $gatewayId, int $operationId, ?int $requestedBy = null): array + { + $gateway = $this->requireGateway($gatewayId); + $operation = $this->requireOperation($gatewayId, $operationId); + $status = (string)$operation->status->value(); + + if (in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED], true)) { + return $this->serializeOperation($operation, true); + } + + $message = 'Operation cancelled by operator'; + if ($status === self::STATUS_PENDING) { + $this->markOperationCancelled( + $gatewayId, + $operation, + $message, + 'OPERATION_CANCELLED', + ['requested_by' => $requestedBy] + ); + $this->manager()->logGatewayAudit( + $gatewayId, + (int)$gateway->department_id->value(), + 'GATEWAY_OPERATION_CANCELLED', + $requestedBy, + ['operation_id' => $operationId, 'type' => (string)$operation->type->value()] + ); + } elseif ($status === self::STATUS_IN_PROGRESS) { + $operation->status->set(self::STATUS_CANCEL_REQUESTED); + $operation->error_code->set(self::ERROR_CANCELLED); + $operation->error_message->set('Operation cancellation requested by operator'); + $operation->last_progress_at->set($this->now()); + $summary = (array)($operation->summary_json->value() ?? []); + $summary['label'] = 'Cancellation requested'; + $summary['retryable'] = true; + $operation->summary_json->set($summary); + $this->appendEventRecord( + $gatewayId, + $operationId, + self::LEVEL_WARNING, + 'OPERATION_CANCEL_REQUESTED', + 'Operation cancellation requested by operator', + ['requested_by' => $requestedBy] + ); + $this->manager()->logGatewayAudit( + $gatewayId, + (int)$gateway->department_id->value(), + 'GATEWAY_OPERATION_CANCEL_REQUESTED', + $requestedBy, + ['operation_id' => $operationId, 'type' => (string)$operation->type->value()] + ); + } + + $this->refreshGatewayViewCache($gatewayId); + $this->manager()->notifyBrokerGatewaySync($gatewayId); + + return $this->serializeOperation($operation, true); + } + + /** + * @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 claimBrokerOperation(int $gatewayId, ?string $agentInstanceId = null): ?array + { + $this->requireGateway($gatewayId); + $this->failTimedOutOperations($gatewayId); + return $this->claimPendingOperation($gatewayId, $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId)); + } + + /** + * @return array> + * @throws Exception + */ + public function listBrokerCancellationRequests(int $gatewayId, ?string $agentInstanceId = null): array + { + $this->requireGateway($gatewayId); + $rows = (new edge_gateway_operations_o())->getFieldsWhere([ + 'gateway_id' => $gatewayId, + 'status' => self::STATUS_CANCEL_REQUESTED, + 'deleted_at' => null, + ], ['id']); + + $requestedInstance = $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId); + $operations = []; + foreach ($rows as $row) { + $operation = (new edge_gateway_operations_o())->select((int)$row['id']); + if (!$operation->exists()) { + continue; + } + + $claimedBy = trim((string)($operation->agent_instance_id->value() ?? '')); + if ($claimedBy !== '' && $requestedInstance !== '' && $claimedBy !== $requestedInstance) { + continue; + } + + $operations[] = $this->serializeOperation($operation, true); + } + + return $operations; + } + + /** + * @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, + self::STATUS_CANCEL_REQUESTED, + self::STATUS_CANCELLED, + ], true)) { + return $this->serializePersistedOperation($operationId, 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); + + if ($level === self::LEVEL_ERROR) { + $this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context); + } + + $this->clearObjectPropertyCache('edge_gateway_operations', $operationId); + $this->clearGatewayViewCache($gatewayId); + + return $this->serializePersistedOperation($operationId, true); + } + + /** + * @throws Exception + */ + public function appendBrokerOperationEvent(int $gatewayId, int $operationId, array $payload): array + { + $this->requireGateway($gatewayId); + return $this->appendOperationEventWithoutAuthentication($gatewayId, $operationId, $payload); + } + + /** + * @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, + self::STATUS_CANCELLED, + ], true)) { + return $this->serializePersistedOperation($operationId, 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'] ?? '')); + $status = (string)$operation->status->value(); + if ($status === self::STATUS_CANCEL_REQUESTED && !$ok && $errorCode === '') { + $errorCode = self::ERROR_CANCELLED; + } + if (!$ok && $errorCode === '') { + $errorCode = $this->classifyCompletionError($gatewayId, $errorMessage); + } + if (!$ok && $errorMessage === '') { + $errorMessage = $errorCode === self::ERROR_CANCELLED + ? 'Gateway operation cancelled' + : 'Gateway operation failed'; + } + + $finalStatus = $ok + ? self::STATUS_COMPLETED + : (($status === self::STATUS_CANCEL_REQUESTED && $errorCode === self::ERROR_CANCELLED) + ? self::STATUS_CANCELLED + : self::STATUS_FAILED); + $operation->status->set($finalStatus); + $operation->result_json->set($result); + $operation->error_code->set($ok ? null : ($finalStatus === self::STATUS_CANCELLED ? self::ERROR_CANCELLED : $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'] = match ($finalStatus) { + self::STATUS_COMPLETED => 'Completed', + self::STATUS_CANCELLED => 'Cancelled', + default => 'Failed', + }; + $summary['progress'] = $finalStatus === self::STATUS_CANCELLED + ? max(0, min(100, (int)($summary['progress'] ?? 0))) + : 100; + $summary['retryable'] = $finalStatus === self::STATUS_CANCELLED + ? true + : (!$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION); + $operation->summary_json->set($summary); + + $this->appendEventRecord( + $gatewayId, + $operationId, + $finalStatus === self::STATUS_COMPLETED + ? self::LEVEL_INFO + : ($finalStatus === self::STATUS_CANCELLED ? self::LEVEL_WARNING : self::LEVEL_ERROR), + $finalStatus === self::STATUS_COMPLETED + ? 'OPERATION_COMPLETED' + : ($finalStatus === self::STATUS_CANCELLED ? 'OPERATION_CANCELLED' : $errorCode), + $finalStatus === self::STATUS_COMPLETED + ? 'Operation completed successfully' + : $errorMessage, + $result + ); + + if ($finalStatus !== self::STATUS_CANCELLED) { + $this->applyCompletionSideEffects( + $gatewayId, + $operation, + $ok, + $result, + $errorCode, + $errorMessage + ); + } + $this->clearObjectPropertyCache('edge_gateway_operations', $operationId); + $this->clearGatewayViewCache($gatewayId); + $this->manager()->notifyBrokerGatewaySync($gatewayId); + + return $this->serializePersistedOperation($operationId, true); + } + + /** + * @throws Exception + */ + public function completeBrokerOperation(int $gatewayId, int $operationId, array $payload): array + { + $this->requireGateway($gatewayId); + return $this->completeOperationWithoutAuthentication($gatewayId, $operationId, $payload); + } + + 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_STACK_SERVICE_NAME, + 'install_dir' => edge_gateway_manager::DEFAULT_INSTALL_DIR, + ], $request); + } + + return $request; + } + + /** + * @throws Exception + */ + private function claimPendingOperation(int $gatewayId, string $agentInstanceId): ?array + { + $pdo = db::getPDO(); + $pdo->beginTransaction(); + + try { + $statement = $pdo->prepare( + "SELECT id, type, attempt_count, summary_json + 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; + } + + $operationId = (int)$row['id']; + $startedAt = $this->now(); + $leaseExpiresAt = $this->leaseExpiry(); + $attemptCount = ((int)($row['attempt_count'] ?? 0)) + 1; + $summary = isset($row['summary_json']) && is_string($row['summary_json']) + ? json_decode($row['summary_json'], true) + : []; + if (!is_array($summary)) { + $summary = []; + } + $summary['label'] = 'Gateway is processing the operation'; + $summary['progress'] = max(5, (int)($summary['progress'] ?? 0)); + $summary['claimed_by'] = $agentInstanceId; + $encodedSummary = json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($encodedSummary)) { + $encodedSummary = '[]'; + } + + $update = $pdo->prepare( + "UPDATE edge_gateway_operations + SET status = :status, + started_at = :started_at, + agent_instance_id = :agent_instance_id, + last_progress_at = :last_progress_at, + lease_expires_at = :lease_expires_at, + attempt_count = :attempt_count, + summary_json = :summary_json + WHERE id = :id" + ); + $update->execute([ + ':status' => self::STATUS_IN_PROGRESS, + ':started_at' => $startedAt, + ':agent_instance_id' => $agentInstanceId, + ':last_progress_at' => $startedAt, + ':lease_expires_at' => $leaseExpiresAt, + ':attempt_count' => $attemptCount, + ':summary_json' => $encodedSummary, + ':id' => $operationId, + ]); + $pdo->commit(); + + $this->clearObjectPropertyCache('edge_gateway_operations', $operationId); + $operation = $this->fetchOperationRecord($operationId, $pdo); + if ($operation === null) { + throw new RuntimeException('Claimed edge gateway operation could not be reloaded'); + } + $this->appendEventRecord( + $gatewayId, + $operationId, + self::LEVEL_INFO, + 'OPERATION_STARTED', + 'Gateway started processing the operation', + [ + 'type' => (string)($row['type'] ?? $operation['type'] ?? ''), + 'agent_instance_id' => $agentInstanceId, + 'attempt_count' => $attemptCount, + 'stage' => self::STATUS_IN_PROGRESS, + ] + ); + + $this->clearGatewayViewCache($gatewayId, $pdo); + + return $this->serializeOperationRecord($operation, true); + } catch (\Throwable $throwable) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $throwable; + } + } + + /** + * @throws Exception + */ + private function failTimedOutOperations(int $gatewayId): void + { + $statement = db::getPDO()->prepare( + "SELECT id + FROM edge_gateway_operations + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status IN ('IN_PROGRESS', 'CANCEL_REQUESTED')" + ); + $statement->execute([':gateway_id' => $gatewayId]); + $rows = $statement->fetchAll(); + + $now = time(); + foreach ($rows as $row) { + $operation = (new edge_gateway_operations_o())->select((int)$row['id']); + $status = (string)$operation->status->value(); + $startedAt = edge_gateway_manager::parseApplicationDateTime( + $operation->started_at->value() === null ? null : (string)$operation->started_at->value() + ); + $leaseExpiresAt = edge_gateway_manager::parseApplicationDateTime( + $operation->lease_expires_at->value() === null ? null : (string)$operation->lease_expires_at->value() + ); + $timedOut = $startedAt !== null + && ($now - $startedAt) >= self::OPERATION_TIMEOUT_SECONDS; + $leaseExpired = $leaseExpiresAt !== null + && $leaseExpiresAt <= $now; + + if (!$timedOut && !$leaseExpired) { + continue; + } + + if ($status === self::STATUS_CANCEL_REQUESTED) { + $this->markOperationCancelled( + $gatewayId, + $operation, + 'Gateway did not acknowledge cancellation before the operation lease expired', + 'OPERATION_CANCELLED', + [ + 'agent_instance_id' => $operation->agent_instance_id->value(), + 'last_progress_at' => $operation->last_progress_at->value(), + ] + ); + $this->refreshGatewayViewCache($gatewayId); + $this->manager()->notifyBrokerGatewaySync($gatewayId); + 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(), + ] + ); + + $this->refreshGatewayViewCache($gatewayId); + $this->manager()->notifyBrokerGatewaySync($gatewayId); + } + } + + /** + * @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) { + $requestedUpdate = is_array($operation->request_json->value()) ? (array)$operation->request_json->value() : []; + $stagedVersion = trim((string)($result['staged_version'] ?? $result['target_version'] ?? $requestedUpdate['target_version'] ?? '')); + $applied = array_key_exists('applied', $result) + ? (bool)$result['applied'] + : trim((string)($result['installed_version'] ?? '')) !== ''; + if ($ok) { + $installedVersion = trim((string)($result['installed_version'] ?? ($applied ? $stagedVersion : ''))); + if ($applied && $installedVersion !== '') { + $gateway->installed_version->set($installedVersion); + $gateway->target_version->set($installedVersion); + } + $metadata['last_update_completed_at'] = $now; + $metadata['last_update_error'] = null; + if ($stagedVersion !== '') { + $metadata['staged_version'] = [ + 'target_version' => $stagedVersion, + 'staged_at' => $result['staged_at'] ?? $now, + 'apply_after' => $result['apply_after'] ?? null, + 'status' => $applied ? 'APPLIED' : 'STAGED', + ]; + } + if (isset($result['update_window'])) { + $metadata['update_window'] = (string)$result['update_window']; + } + if (isset($result['rollback_status']) && is_array($result['rollback_status'])) { + $metadata['rollback_status'] = (array)$result['rollback_status']; + } + } 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 { + $stage = trim((string)($context['stage'] ?? '')); + if ($stage === '') { + $operationRecord = $this->fetchOperationRecord($operationId); + $stage = trim((string)($operationRecord['status'] ?? '')); + } + if ($stage === '') { + $stage = 'RECORDED'; + } + + $eventId = (new edge_gateway_operation_events_o())->add_object([ + 'operation_id' => $operationId, + 'gateway_id' => $gatewayId, + 'stage' => $stage, + '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()); + } + + private function refreshGatewayViewCache(int $gatewayId): void + { + edge_gateway_view_cache::syncGateway($this->manager()->getGateway($gatewayId)); + } + + private function clearGatewayViewCache(int $gatewayId, ?\PDO $pdo = null): void + { + $statement = ($pdo ?? db::getPDO())->prepare( + "SELECT department_id + FROM edge_gateways + WHERE id = :id + AND deleted_at IS NULL + LIMIT 1" + ); + $statement->execute([':id' => $gatewayId]); + $row = $statement->fetch(); + $departmentId = is_array($row) && isset($row['department_id']) ? (int)$row['department_id'] : null; + edge_gateway_view_cache::clearGateway($gatewayId, $departmentId); + } + + private function clearObjectPropertyCache(string $table, int $id): void + { + if ($id <= 0 || !defined('redis')) { + return; + } + + $normalizedTable = trim($table, " `\t\n\r\0\x0B"); + redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*'); + } + + private function fetchOperationRecord(int $operationId, ?\PDO $pdo = null): ?array + { + $statement = ($pdo ?? db::getPDO())->prepare( + "SELECT id, + gateway_id, + type, + operation_type, + status, + request_json, + summary_json, + result_json, + error_code, + error_message, + correlation_id, + agent_instance_id, + lease_expires_at, + last_progress_at, + attempt_count, + requested_by, + requested_at, + started_at, + completed_at, + created_at, + updated_at + FROM edge_gateway_operations + WHERE id = :id + AND deleted_at IS NULL + LIMIT 1" + ); + $statement->execute([':id' => $operationId]); + $row = $statement->fetch(); + + return is_array($row) ? $row : null; + } + + /** + * @return array + */ + private function serializePersistedOperation(int $operationId, bool $includeEvents = true): array + { + $operation = $this->fetchOperationRecord($operationId); + if ($operation === null) { + throw new RuntimeException('Edge gateway operation could not be reloaded'); + } + + return $this->serializeOperationRecord($operation, $includeEvents); + } + + /** + * @param array $operation + * @return array + */ + private function serializeOperationRecord(array $operation, bool $includeEvents = true): array + { + $operationId = (int)($operation['id'] ?? 0); + $gatewayId = (int)($operation['gateway_id'] ?? 0); + + $payload = [ + 'id' => $operationId, + 'gateway_id' => $gatewayId, + 'type' => (string)($operation['type'] ?? $operation['operation_type'] ?? ''), + 'status' => (string)($operation['status'] ?? self::STATUS_PENDING), + 'request' => $this->decodeJsonRecord($operation['request_json'] ?? []), + 'summary' => $this->decodeJsonRecord($operation['summary_json'] ?? []), + 'result' => $this->decodeJsonRecord($operation['result_json'] ?? []), + 'error_code' => isset($operation['error_code']) ? (string)$operation['error_code'] : null, + 'error_message' => isset($operation['error_message']) ? (string)$operation['error_message'] : null, + 'correlation_id' => (string)($operation['correlation_id'] ?? ''), + 'agent_instance_id' => isset($operation['agent_instance_id']) ? (string)$operation['agent_instance_id'] : null, + 'lease_expires_at' => isset($operation['lease_expires_at']) ? (string)$operation['lease_expires_at'] : null, + 'last_progress_at' => isset($operation['last_progress_at']) ? (string)$operation['last_progress_at'] : null, + 'attempt_count' => (int)($operation['attempt_count'] ?? 0), + 'requested_by' => isset($operation['requested_by']) ? (int)$operation['requested_by'] : null, + 'requested_at' => isset($operation['requested_at']) ? (string)$operation['requested_at'] : '', + 'started_at' => isset($operation['started_at']) ? (string)$operation['started_at'] : null, + 'completed_at' => isset($operation['completed_at']) ? (string)$operation['completed_at'] : null, + 'created_at' => isset($operation['created_at']) ? (string)$operation['created_at'] : '', + 'updated_at' => isset($operation['updated_at']) ? (string)$operation['updated_at'] : null, + ]; + + if ($includeEvents && $gatewayId > 0 && $operationId > 0) { + $payload['events'] = $this->listOperationEvents($gatewayId, $operationId, 20); + } + + return $payload; + } + + /** + * @return array + */ + private function decodeJsonRecord(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (!is_string($value) || trim($value) === '') { + return []; + } + + $decoded = json_decode($value, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @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, 'cancel')) { + return self::ERROR_CANCELLED; + } + 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 edge_gateway_manager::formatApplicationDateTime(time()); + } + + private function leaseExpiry(): string + { + return edge_gateway_manager::formatApplicationDateTime(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); + } + + private function markOperationFailedFromEvent( + int $gatewayId, + edge_gateway_operations_o $operation, + ?string $code, + string $message, + array $context + ): void { + $operation->status->set(self::STATUS_FAILED); + $operation->error_code->set($code !== null && trim($code) !== '' ? trim($code) : self::ERROR_VALIDATION); + $operation->error_message->set($message); + $operation->completed_at->set($this->now()); + $operation->lease_expires_at->set(null); + $summary = (array)($operation->summary_json->value() ?? []); + $summary['label'] = 'Failed'; + $summary['retryable'] = ((string)$operation->error_code->value()) !== self::ERROR_UNSUPPORTED_VERSION; + if (isset($context['progress'])) { + $summary['progress'] = max(0, min(100, (int)$context['progress'])); + } + $operation->summary_json->set($summary); + } + + private function markOperationCancelled( + int $gatewayId, + edge_gateway_operations_o $operation, + string $message, + string $eventCode, + array $context = [] + ): void { + $operation->status->set(self::STATUS_CANCELLED); + $operation->error_code->set(self::ERROR_CANCELLED); + $operation->error_message->set($message); + $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'] = 'Cancelled'; + $summary['retryable'] = true; + $summary['progress'] = max(0, min(100, (int)($summary['progress'] ?? 0))); + $operation->summary_json->set($summary); + $this->appendEventRecord( + $gatewayId, + (int)$operation->id, + self::LEVEL_WARNING, + $eventCode, + $message, + $context + ); + } + + /** + * @throws Exception + */ + private function appendOperationEventWithoutAuthentication(int $gatewayId, int $operationId, array $payload): array + { + $operation = $this->requireOperation($gatewayId, $operationId); + if (in_array((string)$operation->status->value(), [ + self::STATUS_COMPLETED, + self::STATUS_FAILED, + self::STATUS_CANCEL_REQUESTED, + self::STATUS_CANCELLED, + ], true)) { + return $this->serializePersistedOperation($operationId, 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); + + if ($level === self::LEVEL_ERROR) { + $this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context); + } + + $this->clearObjectPropertyCache('edge_gateway_operations', $operationId); + $this->clearGatewayViewCache($gatewayId); + + return $this->serializePersistedOperation($operationId, true); + } + + /** + * @throws Exception + */ + private function completeOperationWithoutAuthentication(int $gatewayId, int $operationId, array $payload): array + { + $operation = $this->requireOperation($gatewayId, $operationId); + if (in_array((string)$operation->status->value(), [ + self::STATUS_COMPLETED, + self::STATUS_FAILED, + self::STATUS_CANCELLED, + ], true)) { + return $this->serializePersistedOperation($operationId, 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'] ?? '')); + $status = (string)$operation->status->value(); + if ($status === self::STATUS_CANCEL_REQUESTED && !$ok && $errorCode === '') { + $errorCode = self::ERROR_CANCELLED; + } + if (!$ok && $errorCode === '') { + $errorCode = $this->classifyCompletionError($gatewayId, $errorMessage); + } + if (!$ok && $errorMessage === '') { + $errorMessage = $errorCode === self::ERROR_CANCELLED + ? 'Gateway operation cancelled' + : 'Gateway operation failed'; + } + + $finalStatus = $ok + ? self::STATUS_COMPLETED + : (($status === self::STATUS_CANCEL_REQUESTED && $errorCode === self::ERROR_CANCELLED) + ? self::STATUS_CANCELLED + : self::STATUS_FAILED); + $operation->status->set($finalStatus); + $operation->result_json->set($result); + $operation->error_code->set($ok ? null : ($finalStatus === self::STATUS_CANCELLED ? self::ERROR_CANCELLED : $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'] = match ($finalStatus) { + self::STATUS_COMPLETED => 'Completed', + self::STATUS_CANCELLED => 'Cancelled', + default => 'Failed', + }; + $summary['progress'] = $finalStatus === self::STATUS_CANCELLED + ? max(0, min(100, (int)($summary['progress'] ?? 0))) + : 100; + $summary['retryable'] = $finalStatus === self::STATUS_CANCELLED + ? true + : (!$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION); + $operation->summary_json->set($summary); + + $this->appendEventRecord( + $gatewayId, + $operationId, + $finalStatus === self::STATUS_COMPLETED + ? self::LEVEL_INFO + : ($finalStatus === self::STATUS_CANCELLED ? self::LEVEL_WARNING : self::LEVEL_ERROR), + $finalStatus === self::STATUS_COMPLETED + ? 'OPERATION_COMPLETED' + : ($finalStatus === self::STATUS_CANCELLED ? 'OPERATION_CANCELLED' : $errorCode), + $finalStatus === self::STATUS_COMPLETED + ? 'Operation completed successfully' + : $errorMessage, + $result + ); + + if ($finalStatus !== self::STATUS_CANCELLED) { + $this->applyCompletionSideEffects( + $gatewayId, + $operation, + $ok, + $result, + $errorCode, + $errorMessage + ); + } + $this->clearObjectPropertyCache('edge_gateway_operations', $operationId); + $this->clearGatewayViewCache($gatewayId); + $this->manager()->notifyBrokerGatewaySync($gatewayId); + + return $this->serializePersistedOperation($operationId, true); + } +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_registry_service.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_registry_service.php new file mode 100644 index 00000000..a0d650b3 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_registry_service.php @@ -0,0 +1,108 @@ +manager()->createInstallToken($departmentId, $label, $createdBy); + } + + /** + * @throws Exception + */ + public function verifyInstallToken(string $plainToken): array + { + return $this->manager()->verifyInstallToken($plainToken); + } + + /** + * @throws Exception + */ + public function getInstallTokenStatus(int $claimTokenId): array + { + return $this->manager()->getInstallTokenStatus($claimTokenId); + } + + /** + * @throws Exception + */ + public function reportInstallTokenStatus(string $plainToken, array $payload): array + { + return $this->manager()->reportInstallTokenStatus($plainToken, $payload); + } + + /** + * @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> + * @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(); + } +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_schema_bootstrap.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_schema_bootstrap.php new file mode 100644 index 00000000..1649cb28 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_schema_bootstrap.php @@ -0,0 +1,400 @@ +query($sql); + } + + self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json'); + + self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel"); + + self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id"); + 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'); + + self::ensureColumn('edge_gateway_operation_events', 'stage', "VARCHAR(64) NOT NULL DEFAULT 'RECORDED' AFTER gateway_id"); + 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_operation_events', 'counts_json', 'JSON NULL AFTER context_json'); + self::ensureColumn('edge_gateway_operation_events', 'payload_json', 'JSON NULL AFTER counts_json'); + self::ensureColumn('edge_gateway_operation_events', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER created_at'); + + 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::ensureColumn('edge_gateway_log_entries', 'department_id', 'INT NULL AFTER gateway_id'); + self::ensureColumn('edge_gateway_log_entries', 'level', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER department_id"); + self::ensureColumn('edge_gateway_log_entries', 'stream', "VARCHAR(32) NOT NULL DEFAULT 'agent' AFTER level"); + self::ensureColumn('edge_gateway_log_entries', 'source', "VARCHAR(64) NOT NULL DEFAULT 'BROKER' AFTER stream"); + self::ensureColumn('edge_gateway_log_entries', 'message', 'TEXT NOT NULL AFTER source'); + self::ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message'); + + self::ensureColumn('edge_gateway_shell_sessions', 'department_id', 'INT NOT NULL AFTER gateway_id'); + self::ensureColumn('edge_gateway_shell_sessions', 'actor_user_id', 'INT NULL AFTER department_id'); + self::ensureColumn('edge_gateway_shell_sessions', 'session_token_hash', 'CHAR(64) NOT NULL AFTER actor_user_id'); + self::ensureColumn('edge_gateway_shell_sessions', 'status', "VARCHAR(32) NOT NULL DEFAULT 'PENDING' AFTER session_token_hash"); + self::ensureColumn('edge_gateway_shell_sessions', 'reason', 'VARCHAR(255) NULL AFTER status'); + self::ensureColumn('edge_gateway_shell_sessions', 'connection_id', 'VARCHAR(128) NULL AFTER reason'); + self::ensureColumn('edge_gateway_shell_sessions', 'cwd', 'VARCHAR(255) NULL AFTER connection_id'); + self::ensureColumn('edge_gateway_shell_sessions', 'shell_command', 'VARCHAR(255) NULL AFTER cwd'); + self::ensureColumn('edge_gateway_shell_sessions', 'shell_args_json', 'JSON NULL AFTER shell_command'); + self::ensureColumn('edge_gateway_shell_sessions', 'cols', 'INT NULL AFTER shell_args_json'); + self::renameColumnIfPresent('edge_gateway_shell_sessions', 'rows', 'terminal_rows', 'INT NULL', 'cols'); + self::ensureColumn('edge_gateway_shell_sessions', 'terminal_rows', 'INT NULL AFTER cols'); + self::ensureColumn('edge_gateway_shell_sessions', 'transcript', 'LONGTEXT NULL AFTER terminal_rows'); + self::ensureColumn('edge_gateway_shell_sessions', 'metadata_json', 'JSON NULL AFTER transcript'); + self::ensureColumn('edge_gateway_shell_sessions', 'expires_at', 'DATETIME NULL AFTER metadata_json'); + self::ensureColumn('edge_gateway_shell_sessions', 'approved_at', 'DATETIME NULL AFTER expires_at'); + self::ensureColumn('edge_gateway_shell_sessions', 'opened_at', 'DATETIME NULL AFTER approved_at'); + self::ensureColumn('edge_gateway_shell_sessions', 'closed_at', 'DATETIME NULL AFTER opened_at'); + self::ensureColumn('edge_gateway_shell_sessions', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); + self::ensureColumn('edge_gateway_shell_sessions', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER updated_at'); + + 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 renameColumnIfPresent( + string $table, + string $from, + string $to, + string $definition, + ?string $afterColumn = null + ): void { + global $db; + + if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) { + return; + } + + if (!preg_match('/^[A-Za-z0-9_]+$/', $table) + || !preg_match('/^[A-Za-z0-9_]+$/', $from) + || !preg_match('/^[A-Za-z0-9_]+$/', $to) + || ($afterColumn !== null && !preg_match('/^[A-Za-z0-9_]+$/', $afterColumn))) { + throw new \RuntimeException('Invalid schema bootstrap identifier'); + } + + $positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`"; + + $db->query( + "ALTER TABLE `$table` + CHANGE COLUMN `$from` `$to` $definition$positionClause" + ); + } + + private static function tableHasColumn(string $table, string $column): bool + { + global $db; + + $table = $db->escape_string($table); + $column = $db->escape_string($column); + $database = $db->escape_string($db->getDatabase()); + + $result = $db->query( + "SELECT COUNT(*) AS c + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = '$database' + AND TABLE_NAME = '$table' + AND COLUMN_NAME = '$column'" + ); + + if (!$result) { + return false; + } + + $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 = '')" + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_view_cache.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_view_cache.php new file mode 100644 index 00000000..6b6ad939 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_view_cache.php @@ -0,0 +1,339 @@ +>, fleet_usage: array}|null + */ + public static function getListPayload(?int $departmentId = null, bool $includeDetail = true): ?array + { + return self::decodeListPayload(self::redisGet(self::listKey($departmentId, $includeDetail))); + } + + /** + * @param array{gateways: array>, fleet_usage: array} $payload + */ + public static function storeListPayload(?int $departmentId, bool $includeDetail, array $payload, ?int $ttl = null): void + { + self::storePayload(self::listKey($departmentId, $includeDetail), $payload, $ttl); + } + + /** + * @return array|null + */ + public static function getDetailPayload(int $gatewayId): ?array + { + $decoded = self::decodePayload(self::redisGet(self::detailKey($gatewayId))); + if (!is_array($decoded) || !isset($decoded['gateway']) || !is_array($decoded['gateway'])) { + return null; + } + + return $decoded['gateway']; + } + + /** + * @param array $gateway + */ + public static function storeDetailPayload(int $gatewayId, array $gateway, ?int $ttl = null): void + { + self::storePayload(self::detailKey($gatewayId), ['gateway' => $gateway], $ttl); + } + + public static function clearAll(): void + { + self::clearPattern(self::PREFIX . '*'); + } + + public static function clearGateway(int $gatewayId, ?int $departmentId = null): void + { + self::clearPattern(self::detailKey($gatewayId)); + self::clearPattern(self::listKey(null, true)); + self::clearPattern(self::listKey(null, false)); + + if ($departmentId !== null) { + self::clearPattern(self::listKey($departmentId, true)); + self::clearPattern(self::listKey($departmentId, false)); + } + } + + /** + * @param array $gateway + */ + public static function syncGateway(array $gateway): void + { + $gatewayId = (int)($gateway['id'] ?? 0); + $departmentId = isset($gateway['department_id']) ? (int)$gateway['department_id'] : null; + + if ($gatewayId <= 0) { + return; + } + + $detailGateway = edge_gateway_manager::prepareGatewayForListCache($gateway, true); + + self::storeDetailPayload($gatewayId, $detailGateway); + self::syncListPayload(null, true, $detailGateway); + self::syncListPayload(null, false, $detailGateway); + + if ($departmentId !== null && $departmentId > 0) { + self::syncListPayload($departmentId, true, $detailGateway); + self::syncListPayload($departmentId, false, $detailGateway); + } + } + + public static function removeGateway(int $gatewayId, ?int $departmentId = null): void + { + self::clearPattern(self::detailKey($gatewayId)); + self::removeGatewayFromListPayload(null, true, $gatewayId); + self::removeGatewayFromListPayload(null, false, $gatewayId); + + if ($departmentId !== null) { + self::removeGatewayFromListPayload($departmentId, true, $gatewayId); + self::removeGatewayFromListPayload($departmentId, false, $gatewayId); + } + } + + /** + * @param array $gateway + */ + private static function syncListPayload(?int $departmentId, bool $includeDetail, array $gateway): void + { + $payload = self::getListPayload($departmentId, $includeDetail); + if ($payload === null) { + return; + } + + $rows = isset($payload['gateways']) && is_array($payload['gateways']) ? array_values($payload['gateways']) : []; + $preparedGateway = edge_gateway_manager::prepareGatewayForListCache($gateway, $includeDetail); + $gatewayId = (int)($preparedGateway['id'] ?? 0); + $matchesDepartment = $departmentId === null + || (int)($preparedGateway['department_id'] ?? 0) === (int)$departmentId; + + if ($gatewayId <= 0 || !$matchesDepartment) { + return; + } + + $updated = false; + foreach ($rows as $index => $row) { + if ((int)($row['id'] ?? 0) !== $gatewayId) { + continue; + } + + $rows[$index] = self::mergeGatewayPayload($row, $preparedGateway, $includeDetail); + $updated = true; + break; + } + + if (!$updated) { + $rows[] = $preparedGateway; + } + + usort($rows, static fn(array $left, array $right): int => ((int)($left['department_id'] ?? 0) <=> (int)($right['department_id'] ?? 0)) + ?: ((int)($left['id'] ?? 0) <=> (int)($right['id'] ?? 0))); + + self::storeListPayload($departmentId, $includeDetail, [ + 'gateways' => $rows, + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows($rows), + ]); + } + + private static function removeGatewayFromListPayload(?int $departmentId, bool $includeDetail, int $gatewayId): void + { + $payload = self::getListPayload($departmentId, $includeDetail); + if ($payload === null) { + return; + } + + $rows = array_values(array_filter( + isset($payload['gateways']) && is_array($payload['gateways']) ? $payload['gateways'] : [], + static fn(mixed $row): bool => (int)(is_array($row) ? ($row['id'] ?? 0) : 0) !== $gatewayId + )); + + self::storeListPayload($departmentId, $includeDetail, [ + 'gateways' => $rows, + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows($rows), + ]); + } + + /** + * @param array $currentGateway + * @param array $nextGateway + * @return array + */ + private static function mergeGatewayPayload(array $currentGateway, array $nextGateway, bool $includeDetail): array + { + $merged = array_merge($currentGateway, $nextGateway); + $merged['metadata'] = array_merge( + isset($currentGateway['metadata']) && is_array($currentGateway['metadata']) ? $currentGateway['metadata'] : [], + isset($nextGateway['metadata']) && is_array($nextGateway['metadata']) ? $nextGateway['metadata'] : [] + ); + + foreach (['inventory', 'bindings', 'recent_commands', 'audit_logs', 'operations', 'relay_health', 'diagnostics'] as $listKey) { + if (array_key_exists($listKey, $nextGateway)) { + $merged[$listKey] = $nextGateway[$listKey]; + continue; + } + + if (array_key_exists($listKey, $currentGateway)) { + $merged[$listKey] = $currentGateway[$listKey]; + } + } + + return edge_gateway_manager::prepareGatewayForListCache($merged, $includeDetail); + } + + /** + * @return array{gateways: array>, fleet_usage: array}|null + */ + private static function decodeListPayload(?string $raw): ?array + { + $decoded = self::decodePayload($raw); + if (!is_array($decoded)) { + return null; + } + + if (!isset($decoded['gateways']) || !is_array($decoded['gateways'])) { + return null; + } + + if (!isset($decoded['fleet_usage']) || !is_array($decoded['fleet_usage'])) { + return null; + } + + return [ + 'gateways' => array_values($decoded['gateways']), + 'fleet_usage' => $decoded['fleet_usage'], + ]; + } + + /** + * @return array|null + */ + private static function decodePayload(?string $raw): ?array + { + if ($raw === null || trim($raw) === '') { + return null; + } + + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array $payload + */ + private 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); + } + + 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; + } + } +} diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_view_service.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_view_service.php new file mode 100644 index 00000000..8696ac7b --- /dev/null +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_view_service.php @@ -0,0 +1,78 @@ +> + * @throws Exception + */ + public function listGateways(?int $departmentId = null, bool $includeDetail = true): array + { + return $this->listGatewaysWithFleetUsage($departmentId, $includeDetail)['gateways']; + } + + /** + * @return array{gateways: array>, fleet_usage: array} + * @throws Exception + */ + public function listGatewaysWithFleetUsage(?int $departmentId = null, bool $includeDetail = true): array + { + $cached = edge_gateway_view_cache::getListPayload($departmentId, $includeDetail); + if ($cached !== null) { + return $cached; + } + + $gateways = $this->manager()->listGateways($departmentId, $includeDetail); + $payload = [ + 'gateways' => $gateways, + 'fleet_usage' => $this->manager()->buildFleetUsageStatistics($departmentId, $gateways), + ]; + edge_gateway_view_cache::storeListPayload($departmentId, $includeDetail, $payload); + + return $payload; + } + + /** + * @param array> $gateways + * @return array + * @throws Exception + */ + public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array + { + if ($gateways === []) { + return $this->listGatewaysWithFleetUsage($departmentId, false)['fleet_usage']; + } + + return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways); + } + + /** + * @throws Exception + */ + public function getGateway(int $gatewayId): array + { + $cached = edge_gateway_view_cache::getDetailPayload($gatewayId); + if ($cached !== null) { + return $cached; + } + + $gateway = $this->manager()->getGateway($gatewayId); + edge_gateway_view_cache::storeDetailPayload($gatewayId, $gateway); + + return $gateway; + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_broker_auth_mode_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_auth_mode_c.php new file mode 100644 index 00000000..278a6e2b --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_auth_mode_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'broker_auth_mode', + 'string', + true, + ['manager', 'stub'], + 'The broker authentication mode. Production should use manager.', + 'manager', + false, + trim((string)(getenv('EDGE_AUTH_MODE') ?: '')) ?: 'manager' + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_broker_shared_secret_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_shared_secret_c.php new file mode 100644 index 00000000..bb226e06 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_shared_secret_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'broker_shared_secret', + 'string', + false, + null, + 'The shared secret used between the edge broker and PHP manager callbacks.', + 'truckwash-edge-dev', + true, + trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')) + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_broker_url_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_url_c.php new file mode 100644 index 00000000..b64e1190 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_url_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'broker_url', + 'string', + false, + null, + 'The internal HTTP URL used by PHP workers to reach the edge broker service.', + 'http://edge-broker:4300', + false, + trim((string)(getenv('EDGE_BROKER_URL') ?: '')) ?: 'http://edge-broker:4300' + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_default_release_channel_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_default_release_channel_c.php new file mode 100644 index 00000000..e00adda1 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_default_release_channel_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'default_release_channel', + 'string', + true, + ['stable', 'canary'], + 'The default release channel assigned to newly claimed edge gateways.', + 'stable', + false, + 'stable' + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_default_update_window_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_default_update_window_c.php new file mode 100644 index 00000000..2ad5ff0f --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_default_update_window_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'default_update_window', + 'string', + true, + null, + 'The default maintenance window applied to newly claimed edge gateways.', + '02:00-04:00', + false, + '02:00-04:00' + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_enabled_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_enabled_c.php new file mode 100644 index 00000000..9cff0e59 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_enabled_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'enabled', + 'bool', + true, + null, + 'Whether the edge gateway module is enabled.', + 'true', + false, + 'true' + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_public_broker_url_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_public_broker_url_c.php new file mode 100644 index 00000000..51bc1195 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_public_broker_url_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'public_broker_url', + 'string', + false, + null, + 'The browser-routable edge broker URL, including the proxy path prefix.', + 'https://api-v2.truckwash.io/edge-broker', + false, + trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')) ?: 'https://api-v2.truckwash.io/edge-broker' + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/edgegateway_c.php b/services/nginx/app/modules/edgegateway/edgegateway_c.php new file mode 100644 index 00000000..81d82628 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/edgegateway_c.php @@ -0,0 +1,53 @@ +setupConfig('edgegateway'); + $this->allowUpdate([ + edgegateway_enabled_c::class, + edgegateway_default_release_channel_c::class, + edgegateway_default_update_window_c::class, + edgegateway_broker_url_c::class, + edgegateway_public_broker_url_c::class, + edgegateway_broker_auth_mode_c::class, + ]); + $this->enabled = new edgegateway_enabled_c(); + $this->default_release_channel = new edgegateway_default_release_channel_c(); + $this->default_update_window = new edgegateway_default_update_window_c(); + $this->broker_url = new edgegateway_broker_url_c(); + $this->public_broker_url = new edgegateway_public_broker_url_c(); + $this->broker_auth_mode = new edgegateway_broker_auth_mode_c(); + $this->broker_shared_secret = new edgegateway_broker_shared_secret_c(); + } +} diff --git a/services/nginx/app/modules/edgegateway/routes/edgeGatewayConfigRoute.php b/services/nginx/app/modules/edgegateway/routes/edgeGatewayConfigRoute.php new file mode 100644 index 00000000..103acb32 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/routes/edgeGatewayConfigRoute.php @@ -0,0 +1,85 @@ +get('/edgegateway/config', fn() => $this->handleGetConfig(), [ + 'modules_shelly_config' => 'Get edge gateway config', + ]); + $this->post('/edgegateway/config', fn() => $this->handlePostConfig(), [ + 'modules_shelly_config' => 'Update edge gateway config', + ]); + $this->post('/edgegateway/config/broker-diagnostics', fn() => $this->handleBrokerDiagnostics(), [ + 'modules_shelly_config' => 'Test edge gateway broker configuration', + ]); + } + + private function handleGetConfig(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $user = (new authentication())->get_user(); + + if (!$user) { + (new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + (new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config'); + $config = (new edgegateway())->config->getConfigRequest(); + foreach ($config as &$entry) { + if (($entry['variable'] ?? null) === 'broker_shared_secret') { + $entry['value'] = ''; + } + } + unset($entry); + + $response->success($config); + } + + private function handlePostConfig(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $user = (new authentication())->get_user(); + + if (!$user) { + (new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + (new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully updated edge gateway config'); + $response->success((new edgegateway())->config->postConfigRequest()); + } + + private function handleBrokerDiagnostics(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $user = (new authentication())->get_user(); + + if (!$user) { + (new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + $payload = self::getParametersAsArray(); + (new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'Tested edge gateway broker config'); + $response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($payload)); + } +} diff --git a/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php b/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php new file mode 100644 index 00000000..3fbcd00d --- /dev/null +++ b/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php @@ -0,0 +1,821 @@ +get('/edge-gateways', fn() => $this->handleListGateways(), [ + 'modules_shelly_config' => 'Manage department edge gateways for local Shelly control', + ]); + $this->get('/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [ + 'modules_shelly_config' => 'View department edge gateway detail', + ]); + $this->get('/edge-gateways/{id}/tasks', fn() => $this->handleGatewayTasksPage(), [ + 'modules_shelly_config' => 'View edge gateway task timeline', + ]); + $this->get('/edge-gateways/{id}/logs', fn() => $this->handleGatewayLogsPage(), [ + 'modules_shelly_config' => 'View edge gateway logs', + ]); + $this->get('/edge-gateways/{id}/statistics', fn() => $this->handleGatewayStatisticsPage(), [ + 'modules_shelly_config' => 'View edge gateway statistics', + ]); + $this->post('/edge-gateways/{id}/stream-session', fn() => $this->handleGatewayStreamSessionCreate(), [ + 'modules_shelly_config' => 'Create an edge gateway live stream session', + ]); + $this->post('/edge-gateways/{id}/shell-sessions', fn() => $this->handleGatewayShellSessionCreate(), [ + 'modules_shelly_config' => 'Create an edge gateway shell session', + ]); + $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->post('/edge-gateways/{id}/operations/{operationId}/cancel', fn() => $this->handleGatewayOperationCancel(), [ + 'modules_shelly_config' => 'Cancel an active 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->get('/edge-gateways/install-token/{id}/status', fn() => $this->handleInstallTokenStatus(), [ + 'modules_shelly_config' => 'View edge gateway installer session status', + ]); + $this->post('/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [ + 'modules_shelly_config' => 'Queue Shelly discovery through the local edge gateway', + ]); + $this->put('/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [ + 'modules_shelly_config' => 'Approve or override relay bindings for an edge gateway', + ]); + $this->delete('/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [ + 'modules_shelly_config' => 'Delete an edge gateway registration', + ]); + $this->post('/departments/{id}/gateway-cutover', fn() => $this->handleDepartmentCutover(), [ + 'modules_shelly_config' => 'Cut a department over from Shelly cloud to local edge gateways', + ]); + + $this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify()); + $this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus()); + $this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript()); + $this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php')); + $this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php')); + $this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php')); + $this->get('/edge-agent/artifacts/docker-compose.gateway.yml', fn() => $this->renderArtifact('docker-compose.gateway.yml')); + $this->get('/edge-agent/artifacts/Dockerfile.edge-agent', fn() => $this->renderArtifact('Dockerfile.edge-agent')); + $this->get('/edge-agent/artifacts/Dockerfile.lan-worker', fn() => $this->renderArtifact('Dockerfile.lan-worker')); + $this->get('/edge-agent/artifacts/Dockerfile.auto-updater', fn() => $this->renderArtifact('Dockerfile.auto-updater')); + $this->get('/edge-agent/artifacts/gateway-launcher.sh', fn() => $this->renderArtifact('gateway-launcher.sh')); + $this->get('/edge-agent/artifacts/truckwash-edge-gateway-stack.service', fn() => $this->renderArtifact('truckwash-edge-gateway-stack.service')); + $this->get('/edge-agent/artifacts/truckwash-edge-agent.service', fn() => $this->renderArtifact('truckwash-edge-agent.service')); + $this->post('/edge-agent/claim', fn() => $this->handleAgentClaim()); + $this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat()); + $this->post('/edge-agent/gateways/{id}/operations/next', fn() => $this->handleAgentOperationNext()); + $this->post('/edge-agent/gateways/{id}/operations/{operationId}/events', fn() => $this->handleAgentOperationEvent()); + $this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete()); + $this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll()); + $this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult()); + $this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence()); + $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings()); + $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal()); + + $this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate()); + $this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence()); + $this->post('/edge-agent/internal/gateways/{id}/backlog', fn() => $this->handleBrokerGatewayBacklog()); + $this->post('/edge-agent/internal/gateways/{id}/telemetry', fn() => $this->handleBrokerGatewayTelemetry()); + $this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent()); + $this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete()); + $this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry()); + $this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate()); + $this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate()); + $this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened()); + $this->post('/edge-agent/internal/shell-sessions/close', fn() => $this->handleBrokerShellSessionClose()); + } + + 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); + } + + $payload = $this->views()->listGatewaysWithFleetUsage($departmentId, $view !== 'summary'); + $response->add_meta('fleet_usage', $payload['fleet_usage']); + $response->success($payload['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 handleGatewayTasksPage(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $response->success($this->manager()->buildGatewayTasksPage($gatewayId)); + } + + private function handleGatewayLogsPage(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $response->success($this->manager()->buildGatewayLogsPage($gatewayId)); + } + + private function handleGatewayStatisticsPage(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $response->success($this->manager()->buildGatewayStatisticsPage($gatewayId)); + } + + private function handleGatewayStreamSessionCreate(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $payload = self::getParametersAsArray(); + $scopes = isset($payload['scopes']) && is_array($payload['scopes']) ? (array)$payload['scopes'] : []; + $response->success($this->manager()->createBrowserStreamSession($gatewayId, $this->actorUserId(), $scopes), 201); + } + + private function handleGatewayShellSessionCreate(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $payload = self::getParametersAsArray(); + $reason = isset($payload['reason']) ? (string)$payload['reason'] : ''; + $cwd = isset($payload['cwd']) ? (string)$payload['cwd'] : null; + $cols = isset($payload['cols']) ? (int)$payload['cols'] : null; + $rows = isset($payload['rows']) ? (int)$payload['rows'] : null; + try { + $response->success($this->manager()->createShellSession($gatewayId, $this->actorUserId(), $reason, $cols, $rows, $cwd), 201); + } catch (edge_gateway_operation_exception $exception) { + $response->error([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + 'diagnostics' => $exception->details, + ], $exception->status); + } + } + + 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 handleGatewayOperationCancel(): 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); + + try { + $operation = $this->operations()->cancelOperation($gatewayId, $operationId, $this->actorUserId()); + $response->success([ + 'operation' => $operation, + 'gateway' => $this->views()->getGateway($gatewayId), + ]); + } catch (edge_gateway_operation_exception $exception) { + $response->error([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], $exception->status); + } + } + + 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 handleInstallTokenStatus(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + + $claimTokenId = (int)$this->fromRoute('id'); + self::requireParameterIntPositive($claimTokenId, 'id'); + + $status = $this->registry()->getInstallTokenStatus($claimTokenId); + $this->requireDepartmentAccess((int)$status['department_id']); + unset($status['department_id']); + $response->success($status); + } + + 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 + { + $token = trim((string)$this->fromQuery('token')); + if ($token === '') { + http_response_code(400); + echo 'Missing token'; + exit; + } + + header('Content-Type: text/x-shellscript; charset=utf-8'); + echo $this->install()->buildInstallScript($token); + exit; + } + + private function handleInstallTokenVerify(): void + { + global /** @var response $response */ $response; + $token = trim((string)$this->fromQuery('token')); + if ($token === '') { + $response->error('Missing token', 400); + } + + $response->success($this->install()->verifyInstallToken($token)); + } + + private function handleAgentInstallTokenStatus(): void + { + global /** @var response $response */ $response; + self::requireParameters(['token', 'status']); + + $payload = self::getParametersAsArray(); + $response->success($this->registry()->reportInstallTokenStatus( + (string)$payload['token'], + [ + 'status' => (string)$payload['status'], + 'step' => isset($payload['step']) ? (string)$payload['step'] : null, + 'message' => isset($payload['message']) ? (string)$payload['message'] : null, + 'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [], + 'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null, + 'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null, + ] + )); + } + + private function renderArtifact(string $fileName): void + { + try { + header('Content-Type: ' . $this->install()->contentType($fileName)); + echo $this->install()->readArtifact($fileName); + exit; + } catch (Exception $exception) { + http_response_code(404); + echo $exception->getMessage(); + exit; + } + } + + private function handleAgentClaim(): void + { + global /** @var response $response */ $response; + self::requireParameters(['token']); + $payload = self::getParametersAsArray(); + + $response->success( + $this->registry()->claimGateway( + (string)$payload['token'], + trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')), + isset($payload['installed_version']) ? (string)$payload['installed_version'] : null, + isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [] + ), + 201 + ); + } + + private function handleAgentHeartbeat(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + $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); + } + } + + private function handleAgentCommandPoll(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + $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 + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $jobId = (int)$this->fromRoute('jobId'); + self::requireParameterIntPositive($jobId, 'jobId'); + + $payload = self::getParametersAsArray(); + $response->success($this->manager()->submitCommandResult( + $gatewayId, + $jobId, + $this->requireAgentToken($payload), + (bool)($payload['ok'] ?? false), + isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [], + isset($payload['error']) ? (string)$payload['error'] : null + )); + } + + private function handleAgentPresence(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + + $this->requireAgentToken($payload); + + $response->success($this->manager()->recordBrokerPresence( + $gatewayId, + 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 handleAgentSelfserveMachineSignalBindings(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + + try { + $response->success((new selfserve_machine_signal())->listEdgeGatewayMachineSignalMonitors( + $gatewayId, + $this->requireAgentToken($payload) + )); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 400); + } + } + + private function handleAgentSelfserveMachineSignal(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + + try { + $result = (new selfserve_machine_signal())->recordEdgeGatewaySignal( + $gatewayId, + $this->requireAgentToken($payload), + $payload + ); + $response->success($result, !empty($result['recorded']) ? 201 : 202); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 400); + } + } + + private function handleBrokerGatewayValidate(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + self::requireParameters(['token']); + $gatewayId = (int)$this->fromRoute('id'); + $response->success($this->manager()->validateGatewayAgentForBroker($gatewayId, (string)self::getParameter('token'))); + } + + private function handleBrokerGatewayPresence(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + $response->success($this->manager()->recordBrokerPresence( + $gatewayId, + 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 handleBrokerGatewayBacklog(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + $response->success($this->manager()->buildBrokerBacklog( + $gatewayId, + isset($payload['agent_instance_id']) ? (string)$payload['agent_instance_id'] : null + )); + } + + private function handleBrokerGatewayTelemetry(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + $response->success($this->manager()->recordTelemetryFromBroker($gatewayId, $payload)); + } + + private function handleBrokerOperationEvent(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + $gatewayId = (int)$this->fromRoute('id'); + $operationId = (int)$this->fromRoute('operationId'); + self::requireParameterIntPositive($operationId, 'operationId'); + $payload = self::getParametersAsArray(); + + try { + $response->success($this->operations()->appendBrokerOperationEvent($gatewayId, $operationId, $payload)); + } catch (edge_gateway_operation_exception $exception) { + $response->error([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], $exception->status); + } + } + + private function handleBrokerOperationComplete(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + $gatewayId = (int)$this->fromRoute('id'); + $operationId = (int)$this->fromRoute('operationId'); + self::requireParameterIntPositive($operationId, 'operationId'); + $payload = self::getParametersAsArray(); + + try { + $response->success($this->operations()->completeBrokerOperation($gatewayId, $operationId, $payload)); + } catch (edge_gateway_operation_exception $exception) { + $response->error([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], $exception->status); + } + } + + private function handleBrokerGatewayLogEntry(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + self::requireParameters(['message']); + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + $response->success($this->manager()->appendGatewayLogEntry( + $gatewayId, + (string)self::getParameter('message'), + isset($payload['level']) ? (string)$payload['level'] : 'INFO', + isset($payload['stream']) ? (string)$payload['stream'] : 'agent', + isset($payload['source']) ? (string)$payload['source'] : 'BROKER', + isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : [] + )); + } + + private function handleBrokerBrowserStreamValidate(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + self::requireParameters(['token']); + $response->success($this->manager()->validateBrowserStreamToken((string)self::getParameter('token'))); + } + + private function handleBrokerShellSessionValidate(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + self::requireParameters(['token']); + $response->success($this->manager()->validateShellSessionToken((string)self::getParameter('token'))); + } + + private function handleBrokerShellSessionOpened(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + self::requireParameters(['token']); + $payload = self::getParametersAsArray(); + $response->success($this->manager()->markShellSessionOpened( + (string)self::getParameter('token'), + isset($payload['connection_id']) ? (string)$payload['connection_id'] : null + )); + } + + private function handleBrokerShellSessionClose(): void + { + global /** @var response $response */ $response; + $this->requireBrokerSecret(); + self::requireParameters(['token']); + $payload = self::getParametersAsArray(); + $response->success($this->manager()->closeShellSessionByToken( + (string)self::getParameter('token'), + isset($payload['transcript']) ? (string)$payload['transcript'] : '', + isset($payload['reason']) ? (string)$payload['reason'] : null, + [ + 'message' => isset($payload['message']) ? (string)$payload['message'] : null, + 'code' => isset($payload['code']) ? (int)$payload['code'] : null, + 'close_code' => isset($payload['close_code']) ? (int)$payload['close_code'] : null, + 'stage' => isset($payload['stage']) ? (string)$payload['stage'] : null, + 'failure_stage' => isset($payload['failure_stage']) ? (string)$payload['failure_stage'] : null, + 'was_clean' => isset($payload['was_clean']) ? (bool)$payload['was_clean'] : null, + 'connection_id' => isset($payload['connection_id']) ? (string)$payload['connection_id'] : null, + 'broker_connection_id' => isset($payload['broker_connection_id']) ? (string)$payload['broker_connection_id'] : null, + 'broker_url' => isset($payload['broker_url']) ? (string)$payload['broker_url'] : null, + 'ws_url' => isset($payload['ws_url']) ? (string)$payload['ws_url'] : null, + 'details' => isset($payload['details']) && is_array($payload['details']) ? (array)$payload['details'] : [], + ] + )); + } + + private function requireGatewayAccess(int $gatewayId): array + { + self::requireParameterIntPositive($gatewayId, 'id'); + $gateway = $this->views()->getGateway($gatewayId); + $this->requireDepartmentAccess((int)$gateway['department_id']); + return $gateway; + } + + private function requireAgentToken(array $payload): string + { + global /** @var response $response */ $response; + $token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token'))); + if ($token === '') { + $response->error('Missing edge gateway agent token', 401); + } + + return $token; + } + + private function requireBrokerSecret(): void + { + global /** @var response $response */ $response; + $provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? '')); + if (!$this->manager()->validateBrokerSharedSecret($provided)) { + $response->error('Invalid edge broker secret', 403); + } + } + + 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(); + } +} diff --git a/services/nginx/app/modules/edgegateway/routes/moduleEdgeGatewayRoute.php b/services/nginx/app/modules/edgegateway/routes/moduleEdgeGatewayRoute.php new file mode 100644 index 00000000..edc9b27d --- /dev/null +++ b/services/nginx/app/modules/edgegateway/routes/moduleEdgeGatewayRoute.php @@ -0,0 +1,430 @@ +get('/modules/edge-gateways', fn() => $this->handleListGateways(), [ + 'modules_shelly_config' => 'List edge gateway module fleet', + ]); + $this->get('/modules/edge-gateways/workspace/departments', fn() => $this->handleDepartmentWorkspaceList(), [ + 'modules_shelly_config' => 'List department hardware workspaces', + ]); + $this->get('/modules/edge-gateways/workspace/departments/{id}', fn() => $this->handleDepartmentWorkspaceDetail(), [ + 'modules_shelly_config' => 'View department hardware workspace detail', + ]); + $this->get('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [ + 'modules_shelly_config' => 'View edge gateway module detail', + ]); + $this->get('/modules/edge-gateways/{id}/tasks', fn() => $this->handleGatewayTasksPage(), [ + 'modules_shelly_config' => 'View edge gateway module tasks', + ]); + $this->get('/modules/edge-gateways/{id}/logs', fn() => $this->handleGatewayLogsPage(), [ + 'modules_shelly_config' => 'View edge gateway module logs', + ]); + $this->get('/modules/edge-gateways/{id}/statistics', fn() => $this->handleGatewayStatisticsPage(), [ + 'modules_shelly_config' => 'View edge gateway module statistics', + ]); + $this->post('/modules/edge-gateways/{id}/stream-session', fn() => $this->handleGatewayStreamSessionCreate(), [ + 'modules_shelly_config' => 'Create an edge gateway module live stream session', + ]); + $this->put('/modules/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [ + 'modules_shelly_config' => 'Update edge gateway module metadata', + ]); + $this->get('/modules/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationsList(), [ + 'modules_shelly_config' => 'List edge gateway module operations', + ]); + $this->post('/modules/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [ + 'modules_shelly_config' => 'Queue an edge gateway module operation', + ]); + $this->post('/modules/edge-gateways/{id}/operations/{operationId}/cancel', fn() => $this->handleGatewayOperationCancel(), [ + 'modules_shelly_config' => 'Cancel an edge gateway module operation', + ]); + $this->get('/modules/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [ + 'modules_shelly_config' => 'List edge gateway module operation events', + ]); + $this->post('/modules/edge-gateways/{id}/rotate-credentials', fn() => $this->handleGatewayCredentialRotate(), [ + 'modules_shelly_config' => 'Rotate edge gateway module credentials', + ]); + $this->post('/modules/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [ + 'modules_shelly_config' => 'Create an edge gateway module install token', + ]); + $this->get('/modules/edge-gateways/install-token/{id}/status', fn() => $this->handleInstallTokenStatus(), [ + 'modules_shelly_config' => 'View edge gateway module installer status', + ]); + $this->post('/modules/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [ + 'modules_shelly_config' => 'Queue discovery through the edge gateway module', + ]); + $this->put('/modules/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [ + 'modules_shelly_config' => 'Update edge gateway module relay bindings', + ]); + $this->delete('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [ + 'modules_shelly_config' => 'Delete an edge gateway module registration', + ]); + $this->post('/modules/edge-gateways/departments/{id}/cutover', fn() => $this->handleDepartmentCutover(), [ + 'modules_shelly_config' => 'Update department cutover through the edge gateway module', + ]); + } + + private function handleListGateways(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + + $departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null; + $view = trim((string)$this->fromQuery('view')); + if ($departmentId !== null && $departmentId > 0) { + $this->requireDepartmentAccess((string)$departmentId); + } + + $payload = $this->views()->listGatewaysWithFleetUsage($departmentId, $view !== 'summary'); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_LIST', 'Listed edge gateways'); + $response->add_meta('fleet_usage', $payload['fleet_usage']); + $response->success($payload['gateways']); + } + + private function handleGatewayDetail(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gateway = $this->requireGatewayAccess((int)$this->fromRoute('id')); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_GET', 'Fetched edge gateway detail'); + $response->success($gateway); + } + + private function handleDepartmentWorkspaceList(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $summaries = $this->workspaces()->listDepartmentSummaries(); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_LIST', 'Listed department hardware workspace summaries'); + $response->success($summaries); + } + + private function handleDepartmentWorkspaceDetail(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $departmentId = (int)$this->fromRoute('id'); + self::requireParameterIntPositive($departmentId, 'id'); + $this->requireDepartmentAccess((string)$departmentId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_GET', 'Fetched department hardware workspace detail'); + $response->success($this->workspaces()->getDepartmentWorkspace($departmentId)); + } + + private function handleGatewayTasksPage(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_TASKS_GET', 'Fetched edge gateway task timeline'); + $response->success($this->manager()->buildGatewayTasksPage($gatewayId)); + } + + private function handleGatewayLogsPage(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_LOGS_GET', 'Fetched edge gateway logs'); + $response->success($this->manager()->buildGatewayLogsPage($gatewayId)); + } + + private function handleGatewayStatisticsPage(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_STATISTICS_GET', 'Fetched edge gateway statistics'); + $response->success($this->manager()->buildGatewayStatisticsPage($gatewayId)); + } + + private function handleGatewayStreamSessionCreate(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $payload = self::getParametersAsArray(); + $scopes = isset($payload['scopes']) && is_array($payload['scopes']) ? (array)$payload['scopes'] : []; + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_STREAM_SESSION_CREATE', 'Created edge gateway stream session'); + $response->success($this->manager()->createBrowserStreamSession($gatewayId, (int)$user->id, $scopes), 201); + } + + private function handleGatewayUpdate(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + 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); + $result = $this->registry()->updateGatewayMetadata($gatewayId, [ + 'label' => (string)self::getParameter('label'), + 'is_primary' => (bool)self::getParameter('is_primary'), + ], (int)$user->id); + + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_UPDATE', 'Updated edge gateway metadata'); + $response->success($result); + } + + private function handleGatewayOperationsList(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATIONS_LIST', 'Listed edge gateway operations'); + $response->success($this->operations()->listOperations($gatewayId)); + } + + private function handleGatewayOperationCreate(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + 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'), + (int)$user->id + ); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATION_QUEUE', 'Queued edge gateway operation'); + $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 handleGatewayOperationCancel(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $operationId = (int)$this->fromRoute('operationId'); + self::requireParameterIntPositive($operationId, 'operationId'); + $this->requireGatewayAccess($gatewayId); + + try { + $operation = $this->operations()->cancelOperation($gatewayId, $operationId, (int)$user->id); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATION_CANCEL', 'Cancelled edge gateway operation'); + $response->success([ + 'operation' => $operation, + 'gateway' => $this->views()->getGateway($gatewayId), + ]); + } 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; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $operationId = (int)$this->fromRoute('operationId'); + self::requireParameterIntPositive($operationId, 'operationId'); + $this->requireGatewayAccess($gatewayId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_EVENTS_LIST', 'Listed edge gateway operation events'); + $response->success($this->operations()->listOperationEvents($gatewayId, $operationId)); + } + + private function handleGatewayCredentialRotate(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_CREDENTIALS_ROTATE', 'Rotated edge gateway credentials'); + $response->success($this->operations()->rotateCredentials($gatewayId, (int)$user->id)); + } + + private function handleInstallTokenCreate(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + self::requireParameters(['department_id']); + + $departmentId = (int)self::getParameter('department_id'); + self::requireParameterIntPositive($departmentId, 'department_id'); + $this->requireDepartmentAccess((string)$departmentId); + + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_INSTALL_TOKEN_CREATE', 'Created edge gateway install token'); + $response->success( + $this->registry()->createInstallToken( + $departmentId, + self::isParametersSet(['label']) ? (string)self::getParameter('label') : null, + (int)$user->id + ), + 201 + ); + } + + private function handleInstallTokenStatus(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $claimTokenId = (int)$this->fromRoute('id'); + self::requireParameterIntPositive($claimTokenId, 'id'); + + $status = $this->registry()->getInstallTokenStatus($claimTokenId); + $departmentId = (int)($status['department_id'] ?? 0); + self::requireParameterIntPositive($departmentId, 'department_id'); + $this->requireDepartmentAccess((string)$departmentId); + + (new logs_o())->add( + 'modules_edgegateway', + 'global', + 1, + $user->id, + 'MODULES_EDGEGATEWAY_INSTALL_TOKEN_STATUS', + 'Viewed edge gateway installer status' + ); + + unset($status['department_id']); + $response->success($status); + } + + private function handleGatewayDiscovery(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $this->operations()->queueDiscoveryOperation($gatewayId, (int)$user->id); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DISCOVERY_QUEUE', 'Queued edge gateway discovery'); + $response->success($this->views()->getGateway($gatewayId)); + } + + private function handleBindingsUpdate(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + 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'), (int)$user->id); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_BINDINGS_UPDATE', 'Updated edge gateway bindings'); + $response->success($this->views()->getGateway($gatewayId)); + } + + private function handleGatewayDelete(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DELETE', 'Deleted edge gateway'); + $response->success($this->registry()->deleteGateway($gatewayId, (int)$user->id)); + } + + private function handleDepartmentCutover(): void + { + global /** @var response $response */ $response; + $user = $this->requireModuleOperator(); + self::requireParameters(['transport_mode']); + $departmentId = (int)$this->fromRoute('id'); + self::requireParameterIntPositive($departmentId, 'id'); + $this->requireDepartmentAccess((string)$departmentId); + (new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DEPARTMENT_CUTOVER', 'Updated department gateway cutover'); + $response->success($this->registry()->setDepartmentTransportMode($departmentId, (string)self::getParameter('transport_mode'), (int)$user->id)); + } + + private function requireModuleOperator(): object + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + + try { + (new edgegateway())->requireModuleEnabled(); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 409); + } + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + return $user; + } + + private function requireGatewayAccess(int $gatewayId): array + { + global /** @var response $response */ $response; + $gateway = $this->views()->getGateway($gatewayId); + if (!isset($gateway['id'])) { + $response->error('Edge gateway not found', 404); + } + + $departmentId = (int)($gateway['department_id'] ?? 0); + self::requireParameterIntPositive($departmentId, 'department_id'); + $this->requireDepartmentAccess((string)$departmentId); + return $gateway; + } + + 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 operations(): edge_gateway_operation_service + { + return new edge_gateway_operation_service(); + } + + private function manager(): edge_gateway_manager + { + return new edge_gateway_manager(); + } + + private function workspaces(): edge_gateway_department_workspace_service + { + return new edge_gateway_department_workspace_service(); + } +} diff --git a/services/nginx/app/modules/failover/config/failover_database_enabled_c.php b/services/nginx/app/modules/failover/config/failover_database_enabled_c.php new file mode 100644 index 00000000..d122d663 --- /dev/null +++ b/services/nginx/app/modules/failover/config/failover_database_enabled_c.php @@ -0,0 +1,29 @@ +setupConfig('Failover'); + $this->allowUpdate([ + failover_enabled_c::class, + failover_database_enabled_c::class, + failover_redis_enabled_c::class, + failover_minio_enabled_c::class, + failover_max_status_age_seconds_c::class, + ]); + + $this->enabled = new failover_enabled_c(); + $this->database_enabled = new failover_database_enabled_c(); + $this->redis_enabled = new failover_redis_enabled_c(); + $this->minio_enabled = new failover_minio_enabled_c(); + $this->max_status_age_seconds = new failover_max_status_age_seconds_c(); + } +} diff --git a/services/nginx/app/modules/forms/objects/complete_booking_f.php b/services/nginx/app/modules/forms/objects/complete_booking_f.php deleted file mode 100644 index daad4d94..00000000 --- a/services/nginx/app/modules/forms/objects/complete_booking_f.php +++ /dev/null @@ -1,103 +0,0 @@ -form_unsanitized_data as $key => $value ) { - // Sanitize the input TODO: Implement the sanitization logic - $this->form_unsanitized_data[$key] = $value; - } - // Set the sanitized data - $this->form_sanitized_data = $this->form_unsanitized_data; - } - - /** - * @inheritDoc - */ - public function validateInput(): void - { - - } - - /** - * @inheritDoc - */ - public function beforeSave(): void - { - // Get the booking from the booking id - $bookings = new bookings_o(); - $bookings->select(self::getSanitizedData('booking_id')); - // Check if the user has access to the booking - self::restrictAccessDepartment($bookings->department->value()); - // Check if the user has access to issue wash certificates - self::requirePermission( - 'issue_wash_certificates', - 'User does not have access to issue wash certificates', - ); - // Set the department id to the department id of the user - self::setDepartmentId($bookings->department->value()); - // Set the customer number to the customer number of the user - self::setCustomerNumber($bookings->customer_number->value()); - // Check if the booking is cancelled - switch ($bookings->status->value()) { - case 'cancelled': - throw new \Exception('Booking is cancelled'); - break; - case 'completed': - throw new \Exception('Booking is completed'); - break; - case 'pending': - break; - default: - throw new \Exception('Booking status is unknown, expected cancelled, completed or pending'); - break; - } - // Generate the wash certificate - $bookings->completeWashWithoutWashCertificate( - $bookings->id, - ); - } - - /** - * @inheritDoc - */ - public function afterSubmit(): void - { - // TODO: Implement afterSubmit() method. - } - - /** - * @inheritDoc - */ - public function setup(): void - { - self::setFormIdentifier('COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE'); - self::setFormName('Bekræft vask'); - self::setFormDescription('Bekræft vask af booking'); - self::setSubmitButtonText('Bekræft vask'); - // Set the form fields - self::defineInputFieldsAdvanced([ - 'booking_id' => [ - 'description' => 'Booking ID er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'required' => true, - 'placeholder' => 'Indtast booking ID', - 'label' => 'Booking ID', - 'help' => 'Dette er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'error' => 'Du skal indtaste et gyldigt booking ID.', - 'validation_method' => 'validateBookingId', - ], - ]); - } -} \ No newline at end of file diff --git a/services/nginx/app/modules/forms/objects/generate_booking_wash_certificate_f.php b/services/nginx/app/modules/forms/objects/generate_booking_wash_certificate_f.php deleted file mode 100644 index 1cb69f5b..00000000 --- a/services/nginx/app/modules/forms/objects/generate_booking_wash_certificate_f.php +++ /dev/null @@ -1,142 +0,0 @@ -form_unsanitized_data as $key => $value ) { - // Sanitize the input TODO: Implement the sanitization logic - $this->form_unsanitized_data[$key] = $value; - } - // Set the sanitized data - $this->form_sanitized_data = $this->form_unsanitized_data; - } - - /** - * @inheritDoc - */ - public function validateInput(): void - { - - } - - /** - * @inheritDoc - */ - public function beforeSave(): void - { - // Get the booking from the booking id - $bookings = new bookings_o(); - $bookings->select(self::getSanitizedData('booking_id')); - // Check if the user has access to the booking - self::restrictAccessDepartment($bookings->department->value()); - // Check if the user has access to issue wash certificates - self::requirePermission( - 'issue_wash_certificates', - 'User does not have access to issue wash certificates', - ); - // Set the department id to the department id of the user - self::setDepartmentId($bookings->department->value()); - // Set the customer number to the customer number of the user - self::setCustomerNumber($bookings->customer_number->value()); - // Check if the booking is cancelled - switch ($bookings->status->value()) { - case 'cancelled': - throw new \Exception('Booking is cancelled'); - break; - case 'completed': - throw new \Exception('Booking is completed'); - break; - case 'pending': - break; - default: - throw new \Exception('Booking status is unknown, expected cancelled, completed or pending'); - break; - } - try { - $safety_seal = (int)self::getSanitizedData('safety_seal'); - } catch (\Exception $e) { - $safety_seal = null; - } - try { - $operator = (string)self::getSanitizedData('operator'); - } catch (\Exception $e) { - $operator = null; - } - // Generate the wash certificate - $bookings->generateWashCertificate( - $safety_seal, - $operator, - ); - // Send the wash certificate to the customer - $bookings->sendWashCertificateToCustomer(); - // Create the transaction based on the booking - //$transaction = $bookings->createTransaction(); - // Mark the booking as completed - $bookings->status->set('completed'); - $bookings->washCertificateStatus->set('completed'); - } - - /** - * @inheritDoc - */ - public function afterSubmit(): void - { - // TODO: Implement afterSubmit() method. - } - - /** - * @inheritDoc - */ - public function setup(): void - { - self::setFormIdentifier('GENERATE_BOOKING_WASH_CERTIFICATE'); - self::setFormName('Opret vaskecertifikat'); - self::setFormDescription('Du kan oprette et vaskecertifikat til en vask, der allerede er booket. Du skal blot indtaste de nødvendige oplysninger nedenfor.'); - self::setSubmitButtonText('Opret vaskecertifikat'); - // Set the form fields - self::defineInputFieldsAdvanced([ - 'booking_id' => [ - 'description' => 'Booking ID er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'required' => true, - 'placeholder' => 'Indtast booking ID', - 'label' => 'Booking ID', - 'help' => 'Dette er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'error' => 'Du skal indtaste et gyldigt booking ID.', - 'validation_method' => 'validateBookingId', - ], - 'safety_seal' => [ - 'description' => 'En sikkerhedssikring er en form for beskyttelse, der sikrer, at køretøjet ikke er blevet åbnet eller ændret efter vasken.', - 'required' => false, - 'placeholder' => 'Indtast sikkerhedssikring', - 'label' => 'Safety Seal / PLOM', - 'help' => 'Dette er et Safety Seal, der bruges til at dokumentere, at køretøjet er blevet vasket.', - 'error' => 'Du skal indtaste en gyldig sikkerhedssikring.', - 'validation_method' => 'validateInt', - 'default' => '', - ], - 'operator' => [ - 'description' => 'Operatøren er den person, der har vasket køretøjet. Dette felt er valgfrit.', - 'required' => false, - 'placeholder' => 'Indtast operatør', - 'label' => 'Vognvasker', - 'help' => 'Dette er navnet på den person, der har vasket køretøjet.', - 'error' => 'Du skal indtaste en gyldig operatør.', - 'validation_method' => 'validateString', - 'default' => '', - ], - ]); - - } -} \ No newline at end of file diff --git a/services/nginx/app/modules/goals/classes/goals_criteria.php b/services/nginx/app/modules/goals/classes/goals_criteria.php index c600998a..94c82be3 100644 --- a/services/nginx/app/modules/goals/classes/goals_criteria.php +++ b/services/nginx/app/modules/goals/classes/goals_criteria.php @@ -1,8 +1,10 @@ */ public array $department_weekly_targets; + /** + * Optional advanced target duration mode. + * When null, legacy target behavior is preserved. + * @var goals_criteria_target_duration|null + */ + public ?goals_criteria_target_duration $target_duration = null; + /** + * Optional cadence amount for advanced target duration modes. + * Used for WEEKS, MONTHS, YEARS. Ignored for ENTIRE_DURATION. + * @var int|null + */ + public ?int $target_duration_every = null; /** * Constructor */ @@ -103,6 +117,8 @@ class goals_criteria implements goals_criteria_i $this->progress_alert_style = goals_criteria_progress_alert_style::NONE; $this->department_daily_targets = []; $this->department_weekly_targets = []; + $this->target_duration = null; + $this->target_duration_every = null; } /** @@ -134,6 +150,31 @@ class goals_criteria implements goals_criteria_i } } + // Parse advanced target duration mode (ignore unknowns) + $rawTargetDuration = null; + if (isset($data['target_duration']) && is_string($data['target_duration'])) { + $rawTargetDuration = $data['target_duration']; + } elseif (isset($data['targetDuration']) && is_string($data['targetDuration'])) { + $rawTargetDuration = $data['targetDuration']; + } + if (is_string($rawTargetDuration)) { + $duration = goals_criteria_target_duration::tryFrom($rawTargetDuration); + if ($duration !== null) { + $criteria->target_duration = $duration; + } + } + + // Parse advanced target duration cadence value (>=1) + $rawTargetDurationEvery = null; + if (isset($data['target_duration_every'])) { + $rawTargetDurationEvery = $data['target_duration_every']; + } elseif (isset($data['targetDurationEvery'])) { + $rawTargetDurationEvery = $data['targetDurationEvery']; + } + if (is_numeric($rawTargetDurationEvery)) { + $criteria->target_duration_every = max(1, (int)$rawTargetDurationEvery); + } + // Sanitize label: trim, strip tags, collapse whitespace, max length 255 if (isset($data['label']) && is_string($data['label'])) { $label = trim($data['label']); @@ -277,6 +318,16 @@ class goals_criteria implements goals_criteria_i } } $criteria->department_daily_targets = $targets; + } elseif (isset($data['departmentDailyTargets']) && is_array($data['departmentDailyTargets'])) { + $targets = []; + foreach ($data['departmentDailyTargets'] as $deptId => $target) { + if (is_numeric($deptId) && is_numeric($target)) { + $id = (int)$deptId; + $t = max(0.0, (float)$target); + $targets[$id] = $t; + } + } + $criteria->department_daily_targets = $targets; } // Parse department weekly targets (expects object with department_id => target) if (isset($data['department_weekly_targets']) && is_array($data['department_weekly_targets'])) { @@ -447,6 +498,8 @@ class goals_criteria implements goals_criteria_i return [ 'type' => $this->type?->name ?? goals_criteria_type::NONE->name, 'target' => $this->target ?? 0, + 'target_duration' => $this->target_duration?->name, + 'target_duration_every' => $this->target_duration_every, 'label' => (string)$this->label, 'start' => ($this->start instanceof \DateTimeInterface) ? $this->start->format(DATE_ATOM) : null, 'end' => ($this->end instanceof \DateTimeInterface) ? $this->end->format(DATE_ATOM) : null, @@ -608,6 +661,11 @@ class goals_criteria implements goals_criteria_i return $this->getProgress(); } + public function usesAdvancedTargetDuration(): bool + { + return $this->target_duration instanceof goals_criteria_target_duration; + } + public function validateAndSanitize(): void { // Normalize custom daily targets map: ints and non-negative; filter to selected departments @@ -636,6 +694,20 @@ class goals_criteria implements goals_criteria_i $this->target = 0; } + // Normalize advanced target duration values + if ($this->target_duration !== null && !in_array($this->target_duration, goals_criteria_target_duration::cases(), true)) { + $this->target_duration = null; + } + if ($this->target_duration === goals_criteria_target_duration::ENTIRE_DURATION) { + $this->target_duration_every = null; + } elseif ($this->target_duration !== null) { + $every = (int)($this->target_duration_every ?? 1); + $this->target_duration_every = max(1, $every); + } else { + // Strict legacy compatibility branch marker: null duration means old behavior. + $this->target_duration_every = null; + } + // Ensure timeframe is valid if (($this->start instanceof \DateTimeInterface) && ($this->end instanceof \DateTimeInterface)) { if ($this->end < $this->start) { @@ -751,6 +823,21 @@ class goals_criteria implements goals_criteria_i { $results = []; $department_ids = $this->departments->listIDs(); + if ($this->usesAdvancedTargetDuration()) { + foreach ($department_ids as $dept_id) { + $deptId = (int)$dept_id; + $results[$deptId] = [ + 'all' => $this->getAdvancedProgressDetailsForDepartment($deptId, null, $department_ids), + 'today' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'today', $department_ids), + 'week' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'week', $department_ids), + 'month' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'month', $department_ids), + 'year' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'year', $department_ids), + 'to_date' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'to_date', $department_ids), + ]; + } + return $results; + } + foreach ($department_ids as $dept_id) { $dept_criteria = clone $this; $dept_criteria->departments->set([(new \objects\departments_o())->select($dept_id)]); @@ -787,6 +874,25 @@ class goals_criteria implements goals_criteria_i $criteria->setTimeframeByName($timeframe); } + if ($this->usesAdvancedTargetDuration()) { + $target = 0.0; + if (($criteria->start instanceof \DateTimeInterface) && ($criteria->end instanceof \DateTimeInterface)) { + $target = $this->calculateTargetForRange( + $criteria->start, + $criteria->end, + null, + $this->departments->listIDs() + ); + } + + return [ + 'count' => $criteria->getProgress(), + 'target' => $target, + 'date_from' => ($criteria->start instanceof \DateTimeInterface) ? $criteria->start->format(DATE_ATOM) : null, + 'date_end' => ($criteria->end instanceof \DateTimeInterface) ? $criteria->end->format(DATE_ATOM) : null, + ]; + } + $target = 0.0; $active_dept_ids = $criteria->departments->listIDs(); @@ -827,6 +933,282 @@ class goals_criteria implements goals_criteria_i ]; } + private function getAdvancedProgressDetailsForDepartment( + int $departmentId, + ?string $timeframe, + array $allDepartmentIds + ): array { + $deptCriteria = $this->withDepartmentFilter($departmentId); + if ($timeframe !== null) { + $deptCriteria->setTimeframeByName($timeframe); + } + + $target = 0.0; + if (($deptCriteria->start instanceof \DateTimeInterface) && ($deptCriteria->end instanceof \DateTimeInterface)) { + $target = $this->calculateTargetForRange( + $deptCriteria->start, + $deptCriteria->end, + $departmentId, + $allDepartmentIds + ); + } + + return [ + 'count' => $deptCriteria->getProgress(), + 'target' => $target, + 'date_from' => ($deptCriteria->start instanceof \DateTimeInterface) ? $deptCriteria->start->format(DATE_ATOM) : null, + 'date_end' => ($deptCriteria->end instanceof \DateTimeInterface) ? $deptCriteria->end->format(DATE_ATOM) : null, + ]; + } + + public function calculateTargetForRange( + \DateTimeInterface $windowStart, + \DateTimeInterface $windowEnd, + ?int $departmentId = null, + ?array $departmentIdsForSplit = null + ): float { + if (!$this->usesAdvancedTargetDuration()) { + return 0.0; + } + + $goalRange = $this->getGoalRange(); + if ($goalRange === null) { + return 0.0; + } + [$goalStart, $goalEnd] = $goalRange; + + $clampedRange = $this->clampRangeToGoal($windowStart, $windowEnd, $goalStart, $goalEnd); + if ($clampedRange === null) { + return 0.0; + } + [$rangeStart, $rangeEnd] = $clampedRange; + + $totalTarget = $this->calculateAdvancedTotalTargetForRange($rangeStart, $rangeEnd, $goalStart, $goalEnd); + if ($departmentId === null) { + return $totalTarget; + } + + return $this->calculateDepartmentTargetShare($totalTarget, $departmentId, $departmentIdsForSplit); + } + + /** + * @return array{0:\DateTimeImmutable,1:\DateTimeImmutable}|null + */ + private function getGoalRange(): ?array + { + if (!($this->start instanceof \DateTimeInterface) || !($this->end instanceof \DateTimeInterface)) { + return null; + } + if ($this->end < $this->start) { + return null; + } + + $timezone = $this->start->getTimezone(); + $goalStart = $this->toImmutable($this->start, $timezone); + $goalEnd = $this->toImmutable($this->end, $timezone); + if ($goalEnd < $goalStart) { + return null; + } + + return [$goalStart, $goalEnd]; + } + + /** + * @return array{0:\DateTimeImmutable,1:\DateTimeImmutable}|null + */ + private function clampRangeToGoal( + \DateTimeInterface $windowStart, + \DateTimeInterface $windowEnd, + \DateTimeImmutable $goalStart, + \DateTimeImmutable $goalEnd + ): ?array { + $timezone = $goalStart->getTimezone(); + $start = $this->toImmutable($windowStart, $timezone); + $end = $this->toImmutable($windowEnd, $timezone); + if ($end < $start) { + [$start, $end] = [$end, $start]; + } + + if ($start < $goalStart) { + $start = $goalStart; + } + if ($end > $goalEnd) { + $end = $goalEnd; + } + + if ($end < $start) { + return null; + } + + return [$start, $end]; + } + + private function toImmutable(\DateTimeInterface $date, ?\DateTimeZone $timezone = null): \DateTimeImmutable + { + $immutable = $date instanceof \DateTimeImmutable + ? $date + : \DateTimeImmutable::createFromMutable($date); + if ($timezone !== null) { + $immutable = $immutable->setTimezone($timezone); + } + return $immutable; + } + + private function calculateAdvancedTotalTargetForRange( + \DateTimeImmutable $rangeStart, + \DateTimeImmutable $rangeEnd, + \DateTimeImmutable $goalStart, + \DateTimeImmutable $goalEnd + ): float { + $baseTarget = max(0.0, (float)$this->target); + if ($baseTarget <= 0.0 || !($this->target_duration instanceof goals_criteria_target_duration)) { + return 0.0; + } + + if ($this->target_duration === goals_criteria_target_duration::ENTIRE_DURATION) { + $goalDays = $this->inclusiveDayCount($goalStart, $goalEnd); + if ($goalDays <= 0) { + return 0.0; + } + $overlapDays = $this->inclusiveDayCount($rangeStart, $rangeEnd); + return $baseTarget * ($overlapDays / $goalDays); + } + + $every = max(1, (int)($this->target_duration_every ?? 1)); + $touchedBuckets = match ($this->target_duration) { + goals_criteria_target_duration::WEEKS => $this->countTouchedIsoWeeks($rangeStart, $rangeEnd), + goals_criteria_target_duration::MONTHS => $this->countTouchedCalendarMonths($rangeStart, $rangeEnd), + goals_criteria_target_duration::YEARS => $this->countTouchedCalendarYears($rangeStart, $rangeEnd), + default => 0, + }; + + if ($touchedBuckets <= 0) { + return 0.0; + } + + $intervals = (int)ceil($touchedBuckets / $every); + return $baseTarget * $intervals; + } + + private function inclusiveDayCount(\DateTimeImmutable $start, \DateTimeImmutable $end): int + { + if ($end < $start) { + return 0; + } + return (int)$start->diff($end)->format('%a') + 1; + } + + private function countTouchedIsoWeeks(\DateTimeImmutable $start, \DateTimeImmutable $end): int + { + $current = $start->setTime(0, 0, 0); + $last = $end->setTime(0, 0, 0); + $seen = []; + while ($current <= $last) { + $seen[$current->format('o-W')] = true; + $current = $current->modify('+1 day'); + } + return count($seen); + } + + private function countTouchedCalendarMonths(\DateTimeImmutable $start, \DateTimeImmutable $end): int + { + $current = $start->modify('first day of this month')->setTime(0, 0, 0); + $last = $end->modify('first day of this month')->setTime(0, 0, 0); + $count = 0; + while ($current <= $last) { + $count++; + $current = $current->modify('+1 month'); + } + return $count; + } + + private function countTouchedCalendarYears(\DateTimeImmutable $start, \DateTimeImmutable $end): int + { + $current = $start->setDate((int)$start->format('Y'), 1, 1)->setTime(0, 0, 0); + $last = $end->setDate((int)$end->format('Y'), 1, 1)->setTime(0, 0, 0); + $count = 0; + while ($current <= $last) { + $count++; + $current = $current->modify('+1 year'); + } + return $count; + } + + private function calculateDepartmentTargetShare( + float $totalTarget, + int $departmentId, + ?array $departmentIdsForSplit = null + ): float { + if ($totalTarget <= 0.0) { + return 0.0; + } + + $departmentIds = $this->normalizeDepartmentIds($departmentIdsForSplit ?? $this->departments->listIDs()); + if (empty($departmentIds) || !in_array($departmentId, $departmentIds, true)) { + return 0.0; + } + + $weights = $this->getDepartmentWeights($departmentIds); + $weightSum = array_sum($weights); + if ($weightSum <= 0.0) { + return 0.0; + } + + return $totalTarget * (($weights[$departmentId] ?? 0.0) / $weightSum); + } + + /** + * @param int[] $departmentIds + * @return array + */ + private function getDepartmentWeights(array $departmentIds): array + { + $weights = []; + $hasOverrides = false; + foreach ($departmentIds as $departmentId) { + if (isset($this->department_daily_targets[$departmentId]) || isset($this->department_weekly_targets[$departmentId])) { + $hasOverrides = true; + break; + } + } + + if ($hasOverrides) { + foreach ($departmentIds as $departmentId) { + if (isset($this->department_daily_targets[$departmentId])) { + $weights[$departmentId] = max(0.0, (float)$this->department_daily_targets[$departmentId]); + continue; + } + if (isset($this->department_weekly_targets[$departmentId])) { + $weights[$departmentId] = max(0.0, (float)$this->department_weekly_targets[$departmentId]); + continue; + } + $weights[$departmentId] = 0.0; + } + if (array_sum($weights) > 0.0) { + return $weights; + } + } + + foreach ($departmentIds as $departmentId) { + $weights[$departmentId] = 1.0; + } + return $weights; + } + + /** + * @param array $departmentIds + * @return int[] + */ + private function normalizeDepartmentIds(array $departmentIds): array + { + $normalized = array_values(array_unique(array_filter( + array_map('intval', $departmentIds), + static fn(int $id): bool => $id > 0 + ))); + sort($normalized); + return $normalized; + } + private function getTimeframeDayCount(): int { if (!($this->start instanceof \DateTimeInterface) || !($this->end instanceof \DateTimeInterface)) { diff --git a/services/nginx/app/modules/goals/helpers/goals_criteria_target_duration.php b/services/nginx/app/modules/goals/helpers/goals_criteria_target_duration.php new file mode 100644 index 00000000..50c9c046 --- /dev/null +++ b/services/nginx/app/modules/goals/helpers/goals_criteria_target_duration.php @@ -0,0 +1,27 @@ + goals_criteria_target_duration::ENTIRE_DURATION, + 'WEEKS' => goals_criteria_target_duration::WEEKS, + 'MONTHS' => goals_criteria_target_duration::MONTHS, + 'YEARS' => goals_criteria_target_duration::YEARS, + default => null, + }; + } + + public function equals(goals_criteria_target_duration $param): bool + { + return $this === $param; + } +} diff --git a/services/nginx/app/modules/goals/services/goals_progress_alert_renderer.php b/services/nginx/app/modules/goals/services/goals_progress_alert_renderer.php index 9b7c224b..69ca9201 100644 --- a/services/nginx/app/modules/goals/services/goals_progress_alert_renderer.php +++ b/services/nginx/app/modules/goals/services/goals_progress_alert_renderer.php @@ -160,6 +160,8 @@ class goals_progress_alert_renderer 'highlight' => ($department && isset($department->id) && (int)$department->id === $deptId) ]; } + $allDepartmentIds = array_map(static fn(array $deptData): int => (int)$deptData['id'], $departments); + $isAdvancedDuration = method_exists($criteria, 'usesAdvancedTargetDuration') && $criteria->usesAdvancedTargetDuration(); // Build the output lines $outLines = []; // Prepend header lines if provided @@ -247,16 +249,31 @@ class goals_progress_alert_renderer $total_period_count += $count; if ($includeTargets && isset($criteria->target)) { - if ($isMonthPeriod) { - $target = self::getMonthlyTargetForDepartment($criteria, (int)$deptId, $departments); + if ($isAdvancedDuration) { + if ($range !== null) { + $target = (int)round($criteria->calculateTargetForRange( + \DateTime::createFromImmutable($rs), + \DateTime::createFromImmutable($re), + (int)$deptId, + $allDepartmentIds + )); + } else { + $target = 0; + } } else { - // Set the target to (operating days in period) * (daily target) - $target = self::getTargetForDepartmentInTimeframe( - $criteria, - $rs, - $re, - (int)$deptId - ); + if ($isMonthPeriod) { + $target = self::getMonthlyTargetForDepartment($criteria, (int)$deptId, $departments); + } elseif ($range === null) { + $target = 0; + } else { + // Set the target to (operating days in period) * (daily target) + $target = self::getTargetForDepartmentInTimeframe( + $criteria, + $rs, + $re, + (int)$deptId + ); + } } $percent = $target > 0 ? round(($count / max(1, $target)) * 100, 2) : 0.0; // If the $department is set and matches the current department, make the related line stand out (*text here*) @@ -273,18 +290,33 @@ class goals_progress_alert_renderer } // Total line for the period if ($includeTargets && isset($criteria->target)) { - // Sum per-department targets to account for overrides - if ($isMonthPeriod) { - $total_target = max(0, (int)round((float)($criteria->target ?? 0))); + if ($isAdvancedDuration) { + if ($range !== null) { + $total_target = (int)round($criteria->calculateTargetForRange( + \DateTime::createFromImmutable($rs), + \DateTime::createFromImmutable($re), + null, + $allDepartmentIds + )); + } else { + $total_target = 0; + } } else { - $total_target = 0; - foreach ($departments as $deptData) { - $total_target += self::getTargetForDepartmentInTimeframe( - $criteria, - $rs, - $re, - (int)$deptData['id'] - ); + // Sum per-department targets to account for overrides + if ($isMonthPeriod) { + $total_target = max(0, (int)round((float)($criteria->target ?? 0))); + } elseif ($range === null) { + $total_target = 0; + } else { + $total_target = 0; + foreach ($departments as $deptData) { + $total_target += self::getTargetForDepartmentInTimeframe( + $criteria, + $rs, + $re, + (int)$deptData['id'] + ); + } } } $total_percent = $total_target > 0 ? round(($total_period_count / max(1, $total_target)) * 100, 2) : 0.0; diff --git a/services/nginx/app/modules/n8n/config/n8n_api_key_c.php b/services/nginx/app/modules/n8n/config/n8n_api_key_c.php new file mode 100644 index 00000000..274952f1 --- /dev/null +++ b/services/nginx/app/modules/n8n/config/n8n_api_key_c.php @@ -0,0 +1,29 @@ +setupConfig('n8n'); + $this->allowUpdate([ + n8n_enabled_c::class, + n8n_api_url_c::class, + n8n_api_key_c::class, + n8n_webhook_base_url_c::class, + ]); + + $this->enabled = new n8n_enabled_c(); + $this->api_url = new n8n_api_url_c(); + $this->api_key = new n8n_api_key_c(); + $this->webhook_base_url = new n8n_webhook_base_url_c(); + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php new file mode 100644 index 00000000..dc18db0c --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php @@ -0,0 +1,592 @@ +isRuleSatisfied($rule, $answers, $resolver)) { + $andSatisfied = false; + break; + } + } + + $orSatisfied = true; + if ($orRules !== []) { + $orSatisfied = false; + foreach ($orRules as $rule) { + if ($this->isRuleSatisfied($rule, $answers, $resolver)) { + $orSatisfied = true; + break; + } + } + } + + unset($resolving[$conditionId]); + $results[$conditionId] = $andSatisfied && $orSatisfied; + return $results[$conditionId]; + }; + + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId <= 0) { + continue; + } + $resolver($conditionId); + } + + return $results; + } + + public function evaluateExpressions(array $conditions, array $answers): array + { + return $this->evaluateExpressionsWithTrace($conditions, $answers)['results']; + } + + public function evaluateExpressionsWithTrace(array $conditions, array $answers): array + { + $conditionsById = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId > 0) { + $conditionsById[$conditionId] = $condition; + } + } + + $results = []; + $trace = []; + $resolving = []; + + $resolver = function (int $conditionId) use (&$resolver, &$results, &$trace, &$resolving, $conditionsById, $answers): bool { + if (array_key_exists($conditionId, $results)) { + return $results[$conditionId]; + } + if (isset($resolving[$conditionId])) { + $results[$conditionId] = false; + $trace[$conditionId] = [ + 'type' => 'cycle', + 'condition_id' => $conditionId, + 'result' => false, + 'reason' => 'Condition dependency cycle detected.', + ]; + return false; + } + + $condition = $conditionsById[$conditionId] ?? null; + if (!is_array($condition)) { + $results[$conditionId] = false; + $trace[$conditionId] = [ + 'type' => 'missing_condition', + 'condition_id' => $conditionId, + 'result' => false, + 'reason' => 'Condition was not found.', + ]; + return false; + } + + $expression = is_array($condition['expression'] ?? null) + ? (array)$condition['expression'] + : $this->emptyExpression(); + + $resolving[$conditionId] = true; + $evaluated = $this->evaluateExpressionNode($expression, $answers, $resolver); + unset($resolving[$conditionId]); + + $results[$conditionId] = (bool)($evaluated['result'] ?? false); + $previousTrace = $trace[$conditionId] ?? null; + $trace[$conditionId] = [ + 'type' => 'condition', + 'condition_id' => $conditionId, + 'result' => $results[$conditionId], + 'expression' => $evaluated, + ]; + if (is_array($previousTrace) && ($previousTrace['type'] ?? null) === 'cycle') { + $trace[$conditionId]['cycle'] = $previousTrace; + } + return $results[$conditionId]; + }; + + foreach (array_keys($conditionsById) as $conditionId) { + $resolver((int)$conditionId); + } + + return [ + 'results' => $results, + 'trace' => $trace, + ]; + } + + public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool + { + if ($gateId === null || $gateId <= 0) { + return true; + } + if (array_key_exists($gateId, $conditionResults)) { + return $conditionResults[$gateId] === true; + } + return ($answers[$gateId] ?? null) === true; + } + + public function taskGateSatisfiedTyped(string $gateType, ?int $gateRefId, array $conditionResults, array $answers): bool + { + $typed = selfserve_task_gate_type::tryFrom(strtoupper(trim($gateType))); + if ($typed === null) { + return $this->taskGateSatisfied($gateRefId, $conditionResults, $answers); + } + + return match ($typed) { + selfserve_task_gate_type::ALWAYS => true, + selfserve_task_gate_type::CONDITION => ($gateRefId !== null && $gateRefId > 0) ? (($conditionResults[$gateRefId] ?? false) === true) : false, + selfserve_task_gate_type::QUESTION => ($gateRefId !== null && $gateRefId > 0) ? (($answers[$gateRefId] ?? null) === true) : false, + }; + } + + /** + * @param array $rule + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return bool + */ + private function isRuleSatisfied(array $rule, array $answers, callable $conditionResolver): bool + { + $ruleType = selfserve_condition_rule_type::tryFrom((string)($rule['type'] ?? '')); + if ($ruleType === null) { + return false; + } + + $objectType = selfserve_condition_rule_object_type::tryFrom((string)($rule['object_type'] ?? '')); + if ($objectType === null) { + return false; + } + + $objectId = (int)($rule['object_id'] ?? 0); + $value = match ($objectType) { + selfserve_condition_rule_object_type::QUESTION => ($answers[$objectId] ?? null), + selfserve_condition_rule_object_type::CONDITION => $conditionResolver($objectId), + }; + + return match ($ruleType) { + selfserve_condition_rule_type::IS_TRUE, + selfserve_condition_rule_type::IS_TRUE_OR_ANY_TRUE => $value === true, + selfserve_condition_rule_type::IS_FALSE => $value === false, + selfserve_condition_rule_type::IS_SET => $value !== null, + selfserve_condition_rule_type::IS_TRUE_OR_NOT_SET => $value === true || $value === null, + selfserve_condition_rule_type::IS_FALSE_OR_NOT_SET => $value === false || $value === null, + }; + } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateExpressionNode(array $node, array $answers, callable $conditionResolver): array + { + $type = strtolower((string)($node['type'] ?? $node['kind'] ?? 'group')); + if ($type === 'predicate') { + return $this->evaluateExpressionPredicate($node, $answers, $conditionResolver); + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + return $this->evaluateBranchExpression($node, $answers, $conditionResolver); + } + if ($type === 'case') { + return $this->evaluateCaseExpression($node, $answers, $conditionResolver); + } + + $operator = strtoupper((string)($node['operator'] ?? $node['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $operator = 'ALL'; + } + + $children = is_array($node['children'] ?? null) ? array_values((array)$node['children']) : []; + if ($children === []) { + return [ + 'type' => 'group', + 'operator' => $operator, + 'result' => false, + 'children' => [], + 'reason' => 'Group has no predicates.', + ]; + } + + $childTraces = []; + foreach ($children as $child) { + if (!is_array($child)) { + continue; + } + $childTraces[] = $this->evaluateExpressionNode((array)$child, $answers, $conditionResolver); + } + + if ($childTraces === []) { + $result = false; + } elseif ($operator === 'ANY') { + $result = count(array_filter($childTraces, static fn(array $child): bool => ($child['result'] ?? false) === true)) > 0; + } else { + $result = count(array_filter($childTraces, static fn(array $child): bool => ($child['result'] ?? false) !== true)) === 0; + } + + return [ + 'type' => 'group', + 'operator' => $operator, + 'result' => $result, + 'children' => $childTraces, + 'reason' => $result ? 'Group passed.' : 'Group did not pass.', + ]; + } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateExpressionPredicate(array $node, array $answers, callable $conditionResolver): array + { + $subjectType = strtolower((string)($node['subject_type'] ?? $node['object_type'] ?? '')); + $subjectId = (int)($node['subject_id'] ?? $node['object_id'] ?? 0); + $operator = strtoupper((string)($node['operator'] ?? $node['rule_type'] ?? '')); + + if (!in_array($operator, self::V2_OPERATORS, true)) { + return [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'operator' => $operator, + 'actual_value' => null, + 'result' => false, + 'reason' => 'Unsupported predicate operator.', + ]; + } + + $actual = $this->expressionSubjectValue($subjectType, $subjectId, $answers, $conditionResolver); + $result = match ($operator) { + 'IS_TRUE' => $actual === true, + 'IS_FALSE' => $actual === false, + 'IS_SET' => $actual !== null, + 'IS_TRUE_OR_NOT_SET' => $actual === true || $actual === null, + 'IS_FALSE_OR_NOT_SET' => $actual === false || $actual === null, + default => false, + }; + + return [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'operator' => $operator, + 'actual_value' => $actual, + 'result' => $result, + 'reason' => $result ? 'Predicate passed.' : 'Predicate did not pass.', + ]; + } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateBranchExpression(array $node, array $answers, callable $conditionResolver): array + { + $branches = is_array($node['branches'] ?? null) ? array_values((array)$node['branches']) : []; + if ($branches === []) { + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => false, + 'branches' => [], + 'reason' => 'Branch has no clauses.', + ]; + } + + $branchTraces = []; + foreach ($branches as $index => $branch) { + if (!is_array($branch)) { + $branchTraces[] = [ + 'index' => $index, + 'kind' => 'invalid', + 'matched' => false, + 'result' => false, + 'reason' => 'Branch clause is invalid.', + ]; + continue; + } + + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + $whenTrace = null; + $matched = $isElse; + if (!$isElse) { + $when = is_array($branch['when'] ?? null) ? (array)$branch['when'] : $this->emptyExpression(); + $whenTrace = $this->evaluateExpressionNode($when, $answers, $conditionResolver); + $matched = (bool)($whenTrace['result'] ?? false); + } + + if (!$matched) { + $branchTraces[] = [ + 'index' => $index, + 'kind' => $isElse ? 'else' : $kind, + 'matched' => false, + 'result' => false, + 'when' => $whenTrace, + 'reason' => $isElse ? 'Else branch was not reached.' : 'Branch condition did not pass.', + ]; + continue; + } + + $then = is_array($branch['then'] ?? null) + ? (array)$branch['then'] + : (is_array($branch['result_expression'] ?? null) ? (array)$branch['result_expression'] : $this->emptyExpression()); + $thenTrace = $this->evaluateExpressionNode($then, $answers, $conditionResolver); + $result = (bool)($thenTrace['result'] ?? false); + $branchTraces[] = [ + 'index' => $index, + 'kind' => $isElse ? 'else' : $kind, + 'matched' => true, + 'result' => $result, + 'when' => $whenTrace, + 'then' => $thenTrace, + 'reason' => $result ? 'Branch matched and passed.' : 'Branch matched and did not pass.', + ]; + + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => $result, + 'selected_index' => $index, + 'branches' => $branchTraces, + 'reason' => $result ? 'Selected branch passed.' : 'Selected branch did not pass.', + ]; + } + + if (is_array($node['default'] ?? null)) { + $defaultTrace = $this->evaluateExpressionNode((array)$node['default'], $answers, $conditionResolver); + $result = (bool)($defaultTrace['result'] ?? false); + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => $result, + 'branches' => $branchTraces, + 'default' => $defaultTrace, + 'reason' => $result ? 'Default branch passed.' : 'Default branch did not pass.', + ]; + } + + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => false, + 'branches' => $branchTraces, + 'reason' => 'No branch matched.', + ]; + } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateCaseExpression(array $node, array $answers, callable $conditionResolver): array + { + $subjectType = strtolower((string)($node['subject_type'] ?? $node['object_type'] ?? '')); + $subjectId = (int)($node['subject_id'] ?? $node['object_id'] ?? 0); + $actual = $this->expressionSubjectValue($subjectType, $subjectId, $answers, $conditionResolver); + $cases = is_array($node['cases'] ?? null) ? array_values((array)$node['cases']) : []; + + if (!in_array($subjectType, ['question', 'condition'], true) || $subjectId <= 0) { + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => null, + 'result' => false, + 'cases' => [], + 'reason' => 'Case subject is invalid.', + ]; + } + + $caseTraces = []; + foreach ($cases as $index => $case) { + if (!is_array($case)) { + $caseTraces[] = [ + 'index' => $index, + 'matched' => false, + 'result' => false, + 'reason' => 'Case clause is invalid.', + ]; + continue; + } + + $expected = $case['value'] ?? null; + $matched = $this->caseValueMatches($expected, $actual); + if (!$matched) { + $caseTraces[] = [ + 'index' => $index, + 'value' => $expected, + 'matched' => false, + 'result' => false, + 'reason' => 'Case value did not match.', + ]; + continue; + } + + $then = is_array($case['then'] ?? null) + ? (array)$case['then'] + : (is_array($case['result_expression'] ?? null) ? (array)$case['result_expression'] : $this->emptyExpression()); + $thenTrace = $this->evaluateExpressionNode($then, $answers, $conditionResolver); + $result = (bool)($thenTrace['result'] ?? false); + $caseTraces[] = [ + 'index' => $index, + 'value' => $expected, + 'matched' => true, + 'result' => $result, + 'then' => $thenTrace, + 'reason' => $result ? 'Case matched and passed.' : 'Case matched and did not pass.', + ]; + + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => $actual, + 'result' => $result, + 'selected_index' => $index, + 'cases' => $caseTraces, + 'reason' => $result ? 'Selected case passed.' : 'Selected case did not pass.', + ]; + } + + if (is_array($node['default'] ?? null)) { + $defaultTrace = $this->evaluateExpressionNode((array)$node['default'], $answers, $conditionResolver); + $result = (bool)($defaultTrace['result'] ?? false); + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => $actual, + 'result' => $result, + 'cases' => $caseTraces, + 'default' => $defaultTrace, + 'reason' => $result ? 'Default case passed.' : 'Default case did not pass.', + ]; + } + + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => $actual, + 'result' => false, + 'cases' => $caseTraces, + 'reason' => 'No case matched.', + ]; + } + + /** + * @param array $answers + * @param callable(int):bool $conditionResolver + */ + private function expressionSubjectValue(string $subjectType, int $subjectId, array $answers, callable $conditionResolver): ?bool + { + if ($subjectType === 'condition') { + return $subjectId > 0 ? $conditionResolver($subjectId) : null; + } + if ($subjectType === 'question') { + return $answers[$subjectId] ?? null; + } + return null; + } + + private function caseValueMatches(mixed $expected, ?bool $actual): bool + { + if (is_string($expected)) { + $normalized = strtolower(trim($expected)); + return match ($normalized) { + 'true', '1', 'yes' => $actual === true, + 'false', '0', 'no' => $actual === false, + 'null', 'unset', 'not_set', 'unanswered' => $actual === null, + 'set' => $actual !== null, + 'any', '*' => true, + default => false, + }; + } + + return $expected === $actual; + } + + /** + * @return array + */ + private function emptyExpression(): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [], + ]; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php new file mode 100644 index 00000000..ae3977a8 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php @@ -0,0 +1,1230 @@ +}|null + */ + public function getPublishedConfig(int $departmentId): ?array + { + $version = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_PUBLISHED); + if (!$version->exists()) { + return null; + } + + return [ + 'version_id' => (int)$version->id, + 'config' => (array)($version->config_json->value() ?? []), + ]; + } + + /** + * @return array{version_id:int,config:array}|null + */ + public function getPublishedV2Config(int $departmentId): ?array + { + $published = $this->getPublishedConfig($departmentId); + if (!is_array($published) || !$this->isV2Config((array)($published['config'] ?? []))) { + return null; + } + + $published['config'] = $this->normalizeV2Config((array)$published['config']); + return $published; + } + + public function isV2Config(array $config): bool + { + return (int)($config['schema_version'] ?? 0) === self::SCHEMA_VERSION_V2; + } + + /** + * @return array + */ + public function ensureDraftFromLegacy(int $departmentId, ?int $createdBy = null, bool $forceRefresh = false): array + { + $versionObject = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT); + $config = $this->snapshotLegacyConfig($departmentId); + $validation = $this->validateConfig($config); + + if ($versionObject->exists()) { + $existingConfig = (array)($versionObject->config_json->value() ?? []); + if ($forceRefresh || !$this->isV2Config($existingConfig)) { + $nextConfig = $forceRefresh ? $config : $this->migrateLegacyConfigToV2($existingConfig + ['department_id' => $departmentId]); + $versionObject->config_json->set($nextConfig); + $versionObject->validation_result_json->set($this->validateConfig($nextConfig)); + } else { + $normalizedConfig = $this->normalizeV2Config($existingConfig + ['department_id' => $departmentId]); + if ($normalizedConfig !== $existingConfig) { + $versionObject->config_json->set($normalizedConfig); + $versionObject->validation_result_json->set($this->validateConfig($normalizedConfig)); + } + } + return $versionObject->asArray(); + } + + $latestVersionNumber = $this->getLatestVersionNumber($departmentId); + $newVersion = (new selfserve_config_versions_o())->add( + $departmentId, + self::STATUS_DRAFT, + $latestVersionNumber + 1, + $config, + $validation, + null, + $createdBy, + null, + ); + return $newVersion->asArray(); + } + + /** + * @return array> + */ + public function listVersions(int $departmentId): array + { + return (new selfserve_config_versions_o())->listByDepartment($departmentId); + } + + /** + * @return array + */ + public function validateDraft(int $departmentId): array + { + $draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT); + if (!$draft->exists()) { + $created = $this->ensureDraftFromLegacy($departmentId); + $draft = (new selfserve_config_versions_o())->select((int)$created['id']); + } + + $validation = $this->validateConfig((array)($draft->config_json->value() ?? [])); + $draft->validation_result_json->set($validation); + + return [ + 'version' => $draft->asArray(), + 'validation' => $validation, + ]; + } + + /** + * @return array + */ + public function publishDraft(int $departmentId, ?int $publishedBy = null): array + { + $draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT); + if (!$draft->exists()) { + $created = $this->ensureDraftFromLegacy($departmentId, $publishedBy); + $draft = (new selfserve_config_versions_o())->select((int)$created['id']); + } + + $config = (array)($draft->config_json->value() ?? []); + if (!$this->isV2Config($config)) { + $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]); + $draft->config_json->set($config); + } + + $validation = $this->validateConfig($config); + $draft->validation_result_json->set($validation); + if (($validation['valid'] ?? false) !== true) { + throw new \RuntimeException('Draft validation failed. Resolve errors before publishing.'); + } + + $this->archivePublishedVersions($departmentId); + $draft->status->set(self::STATUS_PUBLISHED); + $draft->published_at->set(date('Y-m-d H:i:s')); + if ($publishedBy !== null) { + $draft->created_by->set($publishedBy); + } + + // Keep editing path open by creating a new draft cloned from newly published version. + $publishedArray = $draft->asArray(); + $this->createDraftFromConfig($departmentId, $config, (int)$draft->id, $publishedBy); + + return $publishedArray; + } + + /** + * @return array + */ + public function rollbackToVersion(int $departmentId, int $targetVersionId, ?int $createdBy = null): array + { + $target = (new selfserve_config_versions_o())->select($targetVersionId); + if (!$target->exists() || (int)$target->department_id->value() !== $departmentId) { + throw new \RuntimeException('Target version not found for department.'); + } + + $config = (array)($target->config_json->value() ?? []); + if (!$this->isV2Config($config)) { + $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]); + } + $validation = $this->validateConfig($config); + if (($validation['valid'] ?? false) !== true) { + throw new \RuntimeException('Target version cannot be rolled back because validation fails.'); + } + + $this->archivePublishedVersions($departmentId); + $latestVersionNumber = $this->getLatestVersionNumber($departmentId); + $rollbackVersion = (new selfserve_config_versions_o())->add( + $departmentId, + self::STATUS_PUBLISHED, + $latestVersionNumber + 1, + $config, + $validation, + $targetVersionId, + $createdBy, + date('Y-m-d H:i:s'), + ); + + $this->createDraftFromConfig($departmentId, $config, (int)$rollbackVersion->id, $createdBy); + + return $rollbackVersion->asArray(); + } + + public function syncDraftFromLegacyForDepartment(int $departmentId): void + { + if ($departmentId > 0) { + $this->ensureDraftFromLegacy($departmentId, null, true); + return; + } + + foreach ($this->getKnownDepartmentIdsForSync() as $id) { + $this->ensureDraftFromLegacy($id, null, true); + } + } + + /** + * @return array + */ + public function snapshotLegacyConfig(int $departmentId): array + { + return $this->migrateLegacyConfigToV2($this->snapshotLegacyTableConfig($departmentId)); + } + + /** + * @return array + */ + protected function snapshotLegacyTableConfig(int $departmentId): array + { + $questionsObject = new department_selfserve_questions_o(); + $conditionsObject = new department_selfserve_conditions_o(); + $tasksObject = new department_selfserve_tasks_o(); + $rulesObject = new department_selfserve_condition_rules_o(); + + $departmentFilter = [0, $departmentId]; + + $questions = $questionsObject->getFieldsWhereIn([ + 'department' => $departmentFilter, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']); + + $conditions = $conditionsObject->getFieldsWhereIn([ + 'department' => $departmentFilter, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']); + + $tasks = $tasksObject->getFieldsWhereIn([ + 'department' => $departmentFilter, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']); + + $conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions); + $rules = $conditionIds === [] + ? [] + : $rulesObject->getFieldsWhereIn([ + 'condition_id' => $conditionIds, + 'deleted_at' => null, + ], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']); + + $conditionIdMap = array_fill_keys($conditionIds, true); + $tasks = array_map(fn(array $task): array => $this->normalizeTaskGate($task, $conditionIdMap), $tasks); + + usort($questions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']); + usort($conditions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']); + usort($rules, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']); + usort($tasks, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']); + + return [ + 'department_id' => $departmentId, + 'questions' => $questions, + 'conditions' => $conditions, + 'rules' => $rules, + 'tasks' => $tasks, + 'actions' => [], + 'snapshot_meta' => [ + 'captured_at' => date('c'), + 'source' => 'legacy_tables', + ], + ]; + } + + /** + * @param array $legacyConfig + * @return array + */ + public function migrateLegacyConfigToV2(array $legacyConfig): array + { + if ($this->isV2Config($legacyConfig)) { + return $this->normalizeV2Config($legacyConfig); + } + + $rulesByCondition = []; + foreach ((array)($legacyConfig['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + $rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule; + } + + $migrationIssues = []; + $conditions = []; + foreach ((array)($legacyConfig['conditions'] ?? []) as $condition) { + if (!is_array($condition)) { + continue; + } + $conditionId = (int)($condition['id'] ?? 0); + $conditionRules = array_values((array)($rulesByCondition[$conditionId] ?? [])); + $condition['expression'] = $this->migrateLegacyRulesToExpression($conditionId, $conditionRules, $migrationIssues); + $conditions[] = $condition; + } + + $config = [ + 'schema_version' => self::SCHEMA_VERSION_V2, + 'department_id' => (int)($legacyConfig['department_id'] ?? 0), + 'questions' => array_values((array)($legacyConfig['questions'] ?? [])), + 'conditions' => array_values($conditions), + 'rules' => [], + 'tasks' => array_values((array)($legacyConfig['tasks'] ?? [])), + 'actions' => array_values((array)($legacyConfig['actions'] ?? [])), + 'v2_meta' => [ + 'migrated_from' => (int)($legacyConfig['schema_version'] ?? 1), + 'migrated_at' => date('c'), + 'source' => (string)($legacyConfig['snapshot_meta']['source'] ?? 'legacy_config'), + 'next_ids' => $this->nextIdsForConfig($legacyConfig), + ], + ]; + + if ($migrationIssues !== []) { + $config['migration_issues'] = $migrationIssues; + } + + return $this->normalizeV2Config($config); + } + + /** + * @param array $config + * @return array + */ + public function validateConfig(array $config): array + { + if ($this->isV2Config($config)) { + return $this->validateV2Config($this->normalizeV2Config($config)); + } + + $errors = []; + $warnings = []; + + $questions = is_array($config['questions'] ?? null) ? $config['questions'] : []; + $conditions = is_array($config['conditions'] ?? null) ? $config['conditions'] : []; + $rules = is_array($config['rules'] ?? null) ? $config['rules'] : []; + $tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : []; + $actions = is_array($config['actions'] ?? null) ? $config['actions'] : []; + + $questionIds = []; + foreach ($questions as $question) { + $id = (int)($question['id'] ?? 0); + if ($id <= 0) { + $errors[] = 'Question without valid id.'; + continue; + } + $questionIds[$id] = true; + } + + $conditionIds = []; + $conditionParents = []; + foreach ($conditions as $condition) { + $id = (int)($condition['id'] ?? 0); + if ($id <= 0) { + $errors[] = 'Condition without valid id.'; + continue; + } + $conditionIds[$id] = true; + $parentId = $this->nullableInt($condition['condition_id'] ?? null); + $conditionParents[$id] = $parentId; + } + + foreach ($conditionParents as $id => $parentId) { + if ($parentId !== null && !isset($conditionIds[$parentId])) { + $errors[] = 'Condition ' . $id . ' references unknown parent condition_id ' . $parentId; + } + if ($parentId === $id) { + $errors[] = 'Condition ' . $id . ' cannot reference itself as parent condition.'; + } + } + + foreach ($this->detectConditionCycles($conditionParents) as $cycle) { + $errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle); + } + + foreach ($rules as $rule) { + $conditionId = (int)($rule['condition_id'] ?? 0); + if ($conditionId <= 0 || !isset($conditionIds[$conditionId])) { + $errors[] = 'Rule references unknown condition_id: ' . $conditionId; + } + $objectType = (string)($rule['object_type'] ?? ''); + $objectId = (int)($rule['object_id'] ?? 0); + if ($objectType === 'question' && !isset($questionIds[$objectId])) { + $errors[] = 'Rule references unknown question object_id: ' . $objectId; + } + if ($objectType === 'condition' && !isset($conditionIds[$objectId])) { + $errors[] = 'Rule references unknown condition object_id: ' . $objectId; + } + } + + foreach ($tasks as $task) { + $resolvedGate = $this->resolveTaskGate($task, $conditionIds); + $gateTypeRaw = (string)($task['gate_type'] ?? ''); + $gateType = $resolvedGate['gate_type']; + if (selfserve_task_gate_type::tryFrom($gateTypeRaw) === null && $gateTypeRaw !== '') { + $warnings[] = 'Task ' . (int)($task['id'] ?? 0) . ' has invalid gate_type `' . $gateTypeRaw . '`, falling back to legacy handling.'; + } + + $gateRefId = $resolvedGate['gate_ref_id']; + if ($gateType === selfserve_task_gate_type::ALWAYS) { + continue; + } + if ($gateRefId === null) { + $errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' requires gate_ref_id for gate_type ' . $gateType->value; + continue; + } + if ($gateType === selfserve_task_gate_type::CONDITION && !isset($conditionIds[$gateRefId])) { + $errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown condition gate_ref_id ' . $gateRefId; + } + if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) { + $errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown question gate_ref_id ' . $gateRefId; + } + } + + return [ + 'valid' => $errors === [], + 'errors' => $errors, + 'warnings' => $warnings, + 'stats' => [ + 'questions' => count($questions), + 'conditions' => count($conditions), + 'rules' => count($rules), + 'tasks' => count($tasks), + 'actions' => count($actions), + ], + 'validated_at' => date('c'), + ]; + } + + /** + * @param array $config + * @return array + */ + protected function validateV2Config(array $config): array + { + $errors = []; + $warnings = []; + + foreach ((array)($config['migration_issues'] ?? []) as $issue) { + if (is_array($issue)) { + $errors[] = (string)($issue['message'] ?? 'Migration issue detected.'); + } else { + $errors[] = (string)$issue; + } + } + + $questions = is_array($config['questions'] ?? null) ? array_values((array)$config['questions']) : []; + $conditions = is_array($config['conditions'] ?? null) ? array_values((array)$config['conditions']) : []; + $tasks = is_array($config['tasks'] ?? null) ? array_values((array)$config['tasks']) : []; + $actions = is_array($config['actions'] ?? null) ? array_values((array)$config['actions']) : []; + + $questionIds = []; + foreach ($questions as $question) { + $id = (int)($question['id'] ?? 0); + if ($id <= 0) { + $errors[] = 'Question without valid id.'; + continue; + } + $questionIds[$id] = true; + } + + $conditionIds = []; + $conditionParents = []; + foreach ($conditions as $condition) { + $id = (int)($condition['id'] ?? 0); + if ($id <= 0) { + $errors[] = 'Condition without valid id.'; + continue; + } + $conditionIds[$id] = true; + $conditionParents[$id] = $this->nullableInt($condition['condition_id'] ?? null); + } + + $usedConditionIds = []; + $conditionEdges = []; + $conditionHasPredicate = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId <= 0) { + continue; + } + $parentId = $conditionParents[$conditionId] ?? null; + if ($parentId !== null) { + if (!isset($conditionIds[$parentId])) { + $errors[] = 'Condition ' . $conditionId . ' references unknown parent condition_id ' . $parentId; + } + if ($parentId === $conditionId) { + $errors[] = 'Condition ' . $conditionId . ' cannot reference itself as parent condition.'; + } + $conditionEdges[$conditionId][] = $parentId; + } + + $expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : $this->emptyV2Expression(); + $expressionValidation = $this->validateExpressionNode( + $expression, + $conditionId, + $questionIds, + $conditionIds, + $usedConditionIds, + $conditionEdges, + ); + $conditionHasPredicate[$conditionId] = $expressionValidation['has_predicate']; + foreach ($expressionValidation['errors'] as $message) { + $errors[] = $message; + } + } + + foreach ($questions as $question) { + $conditionId = $this->nullableInt($question['condition_id'] ?? null); + if ($conditionId === null) { + continue; + } + $usedConditionIds[$conditionId] = true; + if (!isset($conditionIds[$conditionId])) { + $errors[] = 'Question ' . (int)($question['id'] ?? 0) . ' references unknown visibility condition_id ' . $conditionId; + } + } + + foreach ($tasks as $task) { + $taskId = (int)($task['id'] ?? 0); + $gateTypeRaw = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw); + if ($gateType === null) { + $errors[] = 'Task ' . $taskId . ' has invalid gate_type `' . $gateTypeRaw . '`.'; + continue; + } + + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + if ($gateType === selfserve_task_gate_type::ALWAYS) { + continue; + } + if ($gateRefId === null) { + $errors[] = 'Task ' . $taskId . ' requires gate_ref_id for gate_type ' . $gateType->value; + continue; + } + if ($gateType === selfserve_task_gate_type::CONDITION) { + $usedConditionIds[$gateRefId] = true; + if (!isset($conditionIds[$gateRefId])) { + $errors[] = 'Task ' . $taskId . ' references unknown condition gate_ref_id ' . $gateRefId; + } + } + if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) { + $errors[] = 'Task ' . $taskId . ' references unknown question gate_ref_id ' . $gateRefId; + } + } + + $actionIds = []; + foreach ($actions as $action) { + if (!is_array($action)) { + $errors[] = 'Action has invalid payload.'; + continue; + } + $actionId = (int)($action['id'] ?? 0); + if ($actionId <= 0) { + $errors[] = 'Action without valid id.'; + continue; + } + if (isset($actionIds[$actionId])) { + $errors[] = 'Duplicate action id ' . $actionId . '.'; + } + $actionIds[$actionId] = true; + + $event = strtolower(trim((string)($action['event'] ?? ''))); + if (!in_array($event, selfserve_studio_actions::events(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid event `' . $event . '`.'; + } + + $washMode = strtolower(trim((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH))); + if (!in_array($washMode, selfserve_studio_actions::washModes(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid wash_mode `' . $washMode . '`.'; + } + if ($event === selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED && $washMode === selfserve_studio_actions::MODE_MANUAL) { + $warnings[] = 'Action ' . $actionId . ' uses manual mode for the machine-start event and will never run.'; + } + + $operation = strtolower(trim((string)($action['operation'] ?? ''))); + if (!in_array($operation, selfserve_studio_actions::operations(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid operation `' . $operation . '`.'; + } + if (selfserve_studio_actions::isRelayOperation($operation) && !array_key_exists('relay_state', $action)) { + $errors[] = 'Action ' . $actionId . ' requires relay_state for operation ' . $operation . '.'; + } + + $options = is_array($action['options'] ?? null) ? (array)$action['options'] : []; + $failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE))); + if (!in_array($failurePolicy, selfserve_studio_actions::failurePolicies(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid failure_policy `' . $failurePolicy . '`.'; + } + + $conditionId = $this->nullableInt($action['condition_id'] ?? null); + if ($conditionId !== null) { + $usedConditionIds[$conditionId] = true; + if (!isset($conditionIds[$conditionId])) { + $errors[] = 'Action ' . $actionId . ' references unknown condition_id ' . $conditionId . '.'; + } + } + } + + foreach ($usedConditionIds as $conditionId => $_used) { + if (isset($conditionIds[(int)$conditionId]) && (($conditionHasPredicate[(int)$conditionId] ?? false) !== true)) { + $errors[] = 'Condition ' . (int)$conditionId . ' is used but has an empty expression.'; + } + } + + foreach ($this->detectDirectedConditionCycles($conditionEdges) as $cycle) { + $errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle); + } + + return [ + 'valid' => $errors === [], + 'errors' => array_values(array_unique($errors)), + 'warnings' => $warnings, + 'stats' => [ + 'schema_version' => self::SCHEMA_VERSION_V2, + 'questions' => count($questions), + 'conditions' => count($conditions), + 'rules' => 0, + 'tasks' => count($tasks), + 'actions' => count($actions), + ], + 'validated_at' => date('c'), + ]; + } + + /** + * @param array $expression + * @param array $questionIds + * @param array $conditionIds + * @param array $usedConditionIds + * @param array> $conditionEdges + * @return array{errors:array,has_predicate:bool} + */ + protected function validateExpressionNode(array $expression, int $ownerConditionId, array $questionIds, array $conditionIds, array &$usedConditionIds, array &$conditionEdges): array + { + $errors = []; + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? '')); + if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has invalid predicate operator `' . $operator . '`.'; + } + if ($subjectType === 'question') { + if ($subjectId <= 0 || !isset($questionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown question predicate subject_id ' . $subjectId; + } + } elseif ($subjectType === 'condition') { + $usedConditionIds[$subjectId] = true; + $conditionEdges[$ownerConditionId][] = $subjectId; + if ($subjectId === $ownerConditionId) { + $errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in an expression.'; + } + if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition predicate subject_id ' . $subjectId; + } + } else { + $errors[] = 'Condition ' . $ownerConditionId . ' has unsupported predicate subject_type `' . $subjectType . '`.'; + } + + return [ + 'errors' => $errors, + 'has_predicate' => true, + ]; + } + + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + $branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : []; + if ($branches === []) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an empty if/else expression.'; + } + + $hasPredicate = false; + foreach ($branches as $index => $branch) { + if (!is_array($branch)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else clause.'; + continue; + } + + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + if (!$isElse) { + if (!is_array($branch['when'] ?? null)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a when expression.'; + } else { + $whenValidation = $this->validateExpressionNode((array)$branch['when'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $whenValidation['has_predicate']; + foreach ($whenValidation['errors'] as $message) { + $errors[] = $message; + } + } + } + + if (!is_array($branch['then'] ?? null)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a then expression.'; + continue; + } + + $thenValidation = $this->validateExpressionNode((array)$branch['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $thenValidation['has_predicate']; + foreach ($thenValidation['errors'] as $message) { + $errors[] = $message; + } + } + + if (array_key_exists('default', $expression)) { + if (!is_array($expression['default'])) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else default expression.'; + } else { + $defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $defaultValidation['has_predicate']; + foreach ($defaultValidation['errors'] as $message) { + $errors[] = $message; + } + } + } + + return [ + 'errors' => $errors, + 'has_predicate' => $hasPredicate, + ]; + } + + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + if ($subjectType === 'question') { + if ($subjectId <= 0 || !isset($questionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown question case subject_id ' . $subjectId; + } + } elseif ($subjectType === 'condition') { + $usedConditionIds[$subjectId] = true; + $conditionEdges[$ownerConditionId][] = $subjectId; + if ($subjectId === $ownerConditionId) { + $errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in a case expression.'; + } + if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition case subject_id ' . $subjectId; + } + } else { + $errors[] = 'Condition ' . $ownerConditionId . ' has unsupported case subject_type `' . $subjectType . '`.'; + } + + $cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : []; + if ($cases === []) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an empty case expression.'; + } + + $hasPredicate = false; + foreach ($cases as $case) { + if (!is_array($case)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case clause.'; + continue; + } + if (!array_key_exists('value', $case)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a value.'; + } + if (!is_array($case['then'] ?? null)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a then expression.'; + continue; + } + + $thenValidation = $this->validateExpressionNode((array)$case['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $thenValidation['has_predicate']; + foreach ($thenValidation['errors'] as $message) { + $errors[] = $message; + } + } + + if (array_key_exists('default', $expression)) { + if (!is_array($expression['default'])) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case default expression.'; + } else { + $defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $defaultValidation['has_predicate']; + foreach ($defaultValidation['errors'] as $message) { + $errors[] = $message; + } + } + } + + return [ + 'errors' => $errors, + 'has_predicate' => $hasPredicate, + ]; + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has invalid group operator `' . $operator . '`.'; + } + + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + $hasPredicate = false; + foreach ($children as $child) { + if (!is_array($child)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid expression child.'; + continue; + } + $childValidation = $this->validateExpressionNode((array)$child, $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $childValidation['has_predicate']; + foreach ($childValidation['errors'] as $message) { + $errors[] = $message; + } + } + + return [ + 'errors' => $errors, + 'has_predicate' => $hasPredicate, + ]; + } + + protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void + { + // Remove stale drafts first. + $this->deleteAllDrafts($departmentId); + + if (!$this->isV2Config($config)) { + $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]); + } + + $validation = $this->validateConfig($config); + $latestVersionNumber = $this->getLatestVersionNumber($departmentId); + (new selfserve_config_versions_o())->add( + $departmentId, + self::STATUS_DRAFT, + $latestVersionNumber + 1, + $config, + $validation, + $sourceVersionId, + $createdBy, + null, + ); + } + + protected function archivePublishedVersions(int $departmentId): void + { + global $db; + $departmentId = (int)$departmentId; + $sql = "UPDATE selfserve_config_versions + SET status = '" . self::STATUS_ARCHIVED . "' + WHERE department_id = $departmentId + AND status = '" . self::STATUS_PUBLISHED . "' + AND deleted_at IS NULL"; + $db->query($sql); + } + + protected function deleteAllDrafts(int $departmentId): void + { + global $db; + $departmentId = (int)$departmentId; + $sql = "UPDATE selfserve_config_versions + SET deleted_at = NOW() + WHERE department_id = $departmentId + AND status = '" . self::STATUS_DRAFT . "' + AND deleted_at IS NULL"; + $db->query($sql); + } + + protected function getLatestVersionNumber(int $departmentId): int + { + global $db; + $departmentId = (int)$departmentId; + $sql = "SELECT MAX(version_number) AS latest_version + FROM selfserve_config_versions + WHERE department_id = $departmentId + AND deleted_at IS NULL"; + $result = $db->query($sql); + $row = $db->fetch_assoc($result); + return (int)($row['latest_version'] ?? 0); + } + + /** + * @return array + */ + protected function getKnownDepartmentIdsForSync(): array + { + $departmentRows = (new departments_o())->getFieldsWhere([ + 'deleted_at' => null, + ], ['id']); + $ids = array_map(static fn(array $row): int => (int)$row['id'], $departmentRows); + + if ($ids === []) { + return []; + } + + return array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0))); + } + + protected function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } + + /** + * @param array $parents + * @return array> + */ + protected function detectConditionCycles(array $parents): array + { + $cycles = []; + $seenCycleKeys = []; + + foreach (array_keys($parents) as $startId) { + $path = []; + $indexById = []; + $currentId = (int)$startId; + + while ($currentId > 0 && array_key_exists($currentId, $parents)) { + if (isset($indexById[$currentId])) { + $cycle = array_slice($path, $indexById[$currentId]); + $cycle[] = $currentId; + $keyNodes = $cycle; + sort($keyNodes); + $key = implode(':', $keyNodes); + if (!isset($seenCycleKeys[$key])) { + $seenCycleKeys[$key] = true; + $cycles[] = $cycle; + } + break; + } + + $indexById[$currentId] = count($path); + $path[] = $currentId; + $currentId = (int)($parents[$currentId] ?? 0); + } + } + + return $cycles; + } + + /** + * @param array> $rules + * @param array> $migrationIssues + * @return array + */ + protected function migrateLegacyRulesToExpression(int $conditionId, array $rules, array &$migrationIssues): array + { + $allChildren = []; + $anyChildren = []; + + foreach ($rules as $rule) { + $predicate = $this->legacyRuleToPredicate($conditionId, $rule, $migrationIssues); + if ($predicate === null) { + continue; + } + + if (strtoupper((string)($rule['type'] ?? '')) === 'IS_TRUE_OR_ANY_TRUE') { + $anyChildren[] = $predicate; + } else { + $allChildren[] = $predicate; + } + } + + if ($anyChildren !== []) { + $allChildren[] = [ + 'type' => 'group', + 'operator' => 'ANY', + 'children' => $anyChildren, + ]; + } + + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => $allChildren, + ]; + } + + /** + * @param array $rule + * @param array> $migrationIssues + * @return array|null + */ + protected function legacyRuleToPredicate(int $conditionId, array $rule, array &$migrationIssues): ?array + { + $ruleId = (int)($rule['id'] ?? 0); + $objectType = strtolower((string)($rule['object_type'] ?? '')); + if (!in_array($objectType, ['question', 'condition'], true)) { + $migrationIssues[] = [ + 'severity' => 'error', + 'condition_id' => $conditionId, + 'rule_id' => $ruleId, + 'message' => 'Rule ' . $ruleId . ' uses unsupported object_type `' . $objectType . '` and cannot be migrated to v2.', + ]; + return null; + } + + $legacyType = strtoupper((string)($rule['type'] ?? '')); + $operator = $legacyType === 'IS_TRUE_OR_ANY_TRUE' ? 'IS_TRUE' : $legacyType; + if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) { + $migrationIssues[] = [ + 'severity' => 'error', + 'condition_id' => $conditionId, + 'rule_id' => $ruleId, + 'message' => 'Rule ' . $ruleId . ' uses unsupported type `' . $legacyType . '` and cannot be migrated to v2.', + ]; + return null; + } + + return [ + 'type' => 'predicate', + 'subject_type' => $objectType, + 'subject_id' => (int)($rule['object_id'] ?? 0), + 'operator' => $operator, + 'legacy_rule_id' => $ruleId > 0 ? $ruleId : null, + ]; + } + + /** + * @param array $config + * @return array + */ + protected function normalizeV2Config(array $config): array + { + $config['schema_version'] = self::SCHEMA_VERSION_V2; + $config['questions'] = array_values((array)($config['questions'] ?? [])); + $config['conditions'] = array_values(array_map(function ($condition): array { + $condition = is_array($condition) ? $condition : []; + if (!is_array($condition['expression'] ?? null)) { + $condition['expression'] = $this->emptyV2Expression(); + } + return $condition; + }, (array)($config['conditions'] ?? []))); + $config['rules'] = []; + $conditionIds = []; + foreach ($config['conditions'] as $condition) { + $id = (int)($condition['id'] ?? 0); + if ($id > 0) { + $conditionIds[$id] = true; + } + } + $config['tasks'] = array_values(array_map( + fn($task): array => $this->normalizeTaskGate(is_array($task) ? (array)$task : [], $conditionIds), + (array)($config['tasks'] ?? []) + )); + $config['actions'] = array_values(array_map( + static fn($action): array => selfserve_studio_actions::normalize(is_array($action) ? (array)$action : []), + (array)($config['actions'] ?? []) + )); + $config['v2_meta'] = is_array($config['v2_meta'] ?? null) ? (array)$config['v2_meta'] : []; + $config['v2_meta']['next_ids'] = $this->nextIdsForConfig($config); + return $config; + } + + /** + * @param array $task + * @param array $conditionIds + * @return array + */ + protected function normalizeTaskGate(array $task, array $conditionIds): array + { + $resolvedGate = $this->resolveTaskGate($task, $conditionIds); + $task['gate_type'] = $resolvedGate['gate_type']->value; + $task['gate_ref_id'] = $resolvedGate['gate_ref_id']; + + $task['condition_id'] = $resolvedGate['gate_type'] === selfserve_task_gate_type::ALWAYS + ? null + : $resolvedGate['gate_ref_id']; + + return $task; + } + + /** + * @param array $task + * @param array $conditionIds + * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null} + */ + protected function resolveTaskGate(array $task, array $conditionIds): array + { + $gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? '')))); + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + $legacyGateId = $this->nullableInt($task['condition_id'] ?? null); + + if ( + $gateType === selfserve_task_gate_type::CONDITION + || $gateType === selfserve_task_gate_type::QUESTION + ) { + return [ + 'gate_type' => $gateType, + 'gate_ref_id' => $gateRefId ?? $legacyGateId, + ]; + } + + $shouldInferLegacyGate = $gateType === null + || ( + $gateType === selfserve_task_gate_type::ALWAYS + && $gateRefId === null + && $legacyGateId !== null + ); + + if ($shouldInferLegacyGate) { + $fallbackGateId = $gateRefId ?? $legacyGateId; + if ($fallbackGateId === null) { + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + return [ + 'gate_type' => $this->containsIntegerId($conditionIds, $fallbackGateId) + ? selfserve_task_gate_type::CONDITION + : selfserve_task_gate_type::QUESTION, + 'gate_ref_id' => $fallbackGateId, + ]; + } + + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + /** + * @param array $ids + */ + protected function containsIntegerId(array $ids, int $id): bool + { + return isset($ids[$id]) || in_array($id, $ids, true); + } + + /** + * @param array $config + * @return array + */ + protected function nextIdsForConfig(array $config): array + { + $next = []; + foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task', 'actions' => 'action'] as $key => $name) { + $max = 0; + foreach ((array)($config[$key] ?? []) as $row) { + if (is_array($row)) { + $max = max($max, (int)($row['id'] ?? 0)); + } + } + $next[$name] = $max + 1; + } + return $next; + } + + /** + * @return array + */ + protected function emptyV2Expression(): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [], + ]; + } + + /** + * @param array> $edges + * @return array> + */ + protected function detectDirectedConditionCycles(array $edges): array + { + $cycles = []; + $visiting = []; + $visited = []; + $stack = []; + + $walk = function (int $conditionId) use (&$walk, &$cycles, &$visiting, &$visited, &$stack, $edges): void { + if (isset($visited[$conditionId])) { + return; + } + if (isset($visiting[$conditionId])) { + $start = array_search($conditionId, $stack, true); + $cycle = array_slice($stack, $start === false ? 0 : (int)$start); + $cycle[] = $conditionId; + $cycles[] = $cycle; + return; + } + + $visiting[$conditionId] = true; + $stack[] = $conditionId; + foreach (array_unique(array_map('intval', (array)($edges[$conditionId] ?? []))) as $nextId) { + if ($nextId > 0) { + $walk($nextId); + } + } + array_pop($stack); + unset($visiting[$conditionId]); + $visited[$conditionId] = true; + }; + + foreach (array_keys($edges) as $conditionId) { + $walk((int)$conditionId); + } + + return $cycles; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_lane.php b/services/nginx/app/modules/selfserve/classes/selfserve_lane.php index 809aab28..9b796d02 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_lane.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_lane.php @@ -45,7 +45,10 @@ class selfserve_lane implements selfserve_lane_i selfserve_lane_invoice_t, selfserve_lane_reservation_timer_t, selfserve_lane_relay_controller_t, - selfserve_lane_log_t; + selfserve_lane_log_t { + selfserve_lane_relay_controller_t::createShellyTransport insteadof selfserve_lane_port_controller_t; + selfserve_lane_relay_controller_t::resolveShellyTransportDepartmentId insteadof selfserve_lane_port_controller_t; + } /** * @throws \Exception @@ -86,4 +89,4 @@ class selfserve_lane implements selfserve_lane_i { $this->bypass_customer_number_validation = $bypass; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php b/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php index 0e41eb93..dd8510f6 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php @@ -6,6 +6,8 @@ class selfserve_lane_command_arguments { public ?string $license_plate = null; public ?int $customer_number = null; + public ?int $subuser_id = null; + public bool $defer_relay_side_effects = false; /** * Set the license plate of the vehicle currently in the lane @@ -24,6 +26,18 @@ class selfserve_lane_command_arguments return $this; } + public function setSubuserId(?int $subuser_id): self + { + $this->subuser_id = $subuser_id !== null && $subuser_id > 0 ? $subuser_id : null; + return $this; + } + + public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self + { + $this->defer_relay_side_effects = $defer_relay_side_effects; + return $this; + } + public function setParameters($params): self { if (is_array($params)) { @@ -33,7 +47,16 @@ class selfserve_lane_command_arguments if (array_key_exists('customer_number', $params)) { $this->setCustomerNumber($params['customer_number']); } + if (array_key_exists('subuser_id', $params)) { + $this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']); + } + if (array_key_exists('defer_relay_side_effects', $params)) { + $this->setDeferRelaySideEffects(filter_var( + $params['defer_relay_side_effects'], + FILTER_VALIDATE_BOOLEAN + )); + } } return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php b/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php new file mode 100644 index 00000000..2e74ae05 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php @@ -0,0 +1,427 @@ + $payload + * @return array + */ + public function normalizeShellyPayload(array $payload): array + { + if (isset($payload['events']) && is_array($payload['events'])) { + foreach ((array)$payload['events'] as $eventPayload) { + if (is_array($eventPayload)) { + $payload = array_replace($payload, (array)$eventPayload); + break; + } + } + } + + $event = $this->firstString($payload, ['event', 'event_type', 'eventType', 'name', 'type']); + $component = $this->normalizeComponent($this->firstString($payload, ['component', 'component_id', 'componentId'])); + $relayId = $this->firstString($payload, ['relay_id', 'logical_relay_id', 'logicalRelayId', 'relayId']); + $deviceId = $this->firstString($payload, ['device_id', 'deviceId', 'device']); + $channel = $this->firstInt($payload, ['channel', 'id', 'input_id', 'switch_id']); + $on = $this->extractOnState($payload); + $eventName = strtolower(trim((string)$event)); + + if ($component === null && str_starts_with($eventName, 'input.')) { + $component = 'input'; + } + if ($component === null && str_starts_with($eventName, 'switch.')) { + $component = 'switch'; + } + + $positiveEvents = [ + 'on', + 'toggle_on', + 'btn_down', + 'single_push', + 'machine.on', + 'switch.on', + 'switch.toggle_on', + 'input.on', + 'input.toggle_on', + 'input.btn_down', + 'input.single_push', + ]; + $negativeEvents = [ + 'off', + 'toggle_off', + 'btn_up', + 'machine.off', + 'switch.off', + 'switch.toggle_off', + 'input.off', + 'input.toggle_off', + 'input.btn_up', + ]; + + $eventIsOn = in_array($eventName, $positiveEvents, true); + $eventIsOff = in_array($eventName, $negativeEvents, true); + $recognized = $eventIsOn || $eventIsOff || $on !== null; + $onState = $eventIsOn || ($on === true && !$eventIsOff); + + return [ + 'recognized' => $recognized, + 'on' => $onState, + 'event' => $event !== null ? (string)$event : null, + 'component' => $component, + 'relay_id' => $relayId, + 'device_id' => $deviceId, + 'channel' => $channel, + 'source' => (string)($payload['source'] ?? 'shelly'), + 'raw_status' => $this->extractStatusPayload($payload), + ]; + } + + /** + * @param array $payload + * @param array $context + * @return array + */ + public function recordCloudShellySignal(int $departmentId, ?int $laneId, array $payload, array $context = []): array + { + $signal = $this->normalizeShellyPayload($payload + ['source' => 'shelly_cloud']); + if (!$signal['recognized']) { + return [ + 'recorded' => false, + 'ignored' => true, + 'reason' => 'Payload does not contain a recognized Shelly ON/OFF signal.', + 'signal' => $signal, + ]; + } + if (!$signal['on']) { + return [ + 'recorded' => false, + 'ignored' => true, + 'reason' => 'Shelly signal was recognized but it was not ON.', + 'signal' => $signal, + ]; + } + + $resolvedLaneId = $this->resolveLaneId($departmentId, $laneId, $signal['relay_id'] ?? null); + $summary = (new selfserve_wash_flow())->recordMachineStartWebhook( + $resolvedLaneId, + $this->extractRegistration($payload), + $payload + [ + 'source' => 'shelly_cloud', + 'shelly_signal' => $signal, + 'context' => $context, + ] + ); + + return [ + 'recorded' => true, + 'ignored' => false, + 'lane_id' => $resolvedLaneId, + 'signal' => $signal, + 'selfserve' => $summary, + ]; + } + + /** + * @param array $payload + * @return array + */ + public function recordEdgeGatewaySignal(int $gatewayId, string $agentToken, array $payload): array + { + $gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken); + $departmentId = (int)$gateway->department_id->value(); + + return $this->recordCloudShellySignal( + $departmentId, + isset($payload['lane_id']) ? (int)$payload['lane_id'] : null, + $payload + [ + 'source' => 'edge_gateway', + 'gateway_id' => $gatewayId, + ], + [ + 'source' => 'edge_gateway', + 'gateway_id' => $gatewayId, + 'agent_instance_id' => $payload['agent_instance_id'] ?? null, + ] + ); + } + + /** + * @return array + */ + public function listEdgeGatewayMachineSignalMonitors(int $gatewayId, string $agentToken): array + { + $manager = new edge_gateway_manager(); + $gateway = $manager->authenticateGateway($gatewayId, $agentToken); + $departmentId = (int)$gateway->department_id->value(); + + $bindings = []; + foreach ($this->bindingRows($gatewayId, $departmentId) as $binding) { + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId !== '') { + $bindings[$relayId] = $binding; + } + } + + $monitors = []; + foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) { + $relayId = trim((string)$lane->relay_machine_id->value()); + if ($relayId === '' || !isset($bindings[$relayId])) { + continue; + } + + $binding = $bindings[$relayId]; + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + $component = $this->normalizeMonitorComponent((string)($metadata['machine_signal_component'] ?? $metadata['signal_component'] ?? 'input')); + $channel = (int)($metadata['machine_signal_channel'] ?? $metadata['input_channel'] ?? $binding['channel'] ?? 0); + + $monitors[] = [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'lane_id' => (int)$lane->id, + 'lane_label' => (string)$lane->name->value(), + 'relay_id' => $relayId, + 'device_id' => (string)($metadata['machine_signal_device_id'] ?? $binding['device_id'] ?? ''), + 'local_ip' => $metadata['machine_signal_local_ip'] ?? $binding['local_ip'] ?? null, + 'channel' => $channel, + 'component' => $component, + 'expected_event' => $component === 'switch' ? 'switch.on' : 'input.toggle_on', + ]; + } + + return [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'monitors' => $monitors, + ]; + } + + public function resolveLaneId(int $departmentId, ?int $laneId, ?string $relayId = null): int + { + if ($laneId !== null && $laneId > 0) { + $lane = (new department_lanes_o())->select($laneId); + if (!$lane->exists()) { + throw new \RuntimeException('Department lane not found.'); + } + if ((int)$lane->department->value() !== $departmentId) { + throw new \RuntimeException('The lane does not belong to the Shelly signal department.'); + } + + return (int)$lane->id; + } + + $relayId = trim((string)$relayId); + if ($relayId !== '') { + $matches = (new department_lanes_o())->getFieldsWhere( + [ + 'department' => $departmentId, + 'relay_machine_id' => $relayId, + 'deleted_at' => null, + ], + ['id'] + ); + if ($matches !== []) { + return (int)$matches[0]['id']; + } + } + + $lanes = (new department_lanes_o())->getDepartmentLanes($departmentId); + if (count($lanes) === 1) { + return (int)$lanes[0]->id; + } + + throw new \RuntimeException('lane_id or relay_id is required when the department has multiple self-serve lanes.'); + } + + /** + * @param array $payload + */ + private function extractRegistration(array $payload): ?string + { + $reg = $this->firstString($payload, ['reg', 'registration', 'license_plate', 'licensePlate', 'plate']); + return $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg); + } + + /** + * @param array $payload + * @param array $keys + */ + private function firstString(array $payload, array $keys): ?string + { + foreach ($keys as $key) { + if (!array_key_exists($key, $payload)) { + continue; + } + $value = $payload[$key]; + if (is_scalar($value) && trim((string)$value) !== '') { + return trim((string)$value); + } + } + + foreach (['params', 'data', 'status'] as $container) { + if (!isset($payload[$container]) || !is_array($payload[$container])) { + continue; + } + $match = $this->firstString((array)$payload[$container], $keys); + if ($match !== null) { + return $match; + } + } + + return null; + } + + /** + * @param array $payload + * @param array $keys + */ + private function firstInt(array $payload, array $keys): ?int + { + foreach ($keys as $key) { + if (array_key_exists($key, $payload) && is_numeric($payload[$key])) { + return (int)$payload[$key]; + } + } + + foreach (['params', 'data', 'status'] as $container) { + if (!isset($payload[$container]) || !is_array($payload[$container])) { + continue; + } + $match = $this->firstInt((array)$payload[$container], $keys); + if ($match !== null) { + return $match; + } + } + + return null; + } + + /** + * @param array $payload + */ + private function extractOnState(array $payload): ?bool + { + foreach (['on', 'output', 'state', 'ison'] as $key) { + if (array_key_exists($key, $payload)) { + return $this->boolValue($payload[$key]); + } + } + + foreach (['input', 'switch', 'params', 'data', 'status'] as $key) { + if (!isset($payload[$key]) || !is_array($payload[$key])) { + continue; + } + $value = $this->extractOnState((array)$payload[$key]); + if ($value !== null) { + return $value; + } + } + + foreach (['input:0', 'switch:0'] as $componentKey) { + if (!isset($payload[$componentKey]) || !is_array($payload[$componentKey])) { + continue; + } + $value = $this->extractOnState((array)$payload[$componentKey]); + if ($value !== null) { + return $value; + } + } + + return null; + } + + private function boolValue(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return (int)$value === 1; + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['1', 'true', 'on', 'yes'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'off', 'no'], true)) { + return false; + } + } + + return null; + } + + private function normalizeComponent(?string $component): ?string + { + $component = strtolower(trim((string)$component)); + if ($component === '') { + return null; + } + if (str_starts_with($component, 'input')) { + return 'input'; + } + if (str_starts_with($component, 'switch') || str_starts_with($component, 'relay')) { + return 'switch'; + } + + return null; + } + + private function normalizeMonitorComponent(string $component): string + { + return $this->normalizeComponent($component) === 'switch' ? 'switch' : 'input'; + } + + /** + * @param array $payload + * @return array|null + */ + private function extractStatusPayload(array $payload): ?array + { + if (isset($payload['status']) && is_array($payload['status'])) { + return (array)$payload['status']; + } + if (isset($payload['raw']) && is_array($payload['raw'])) { + return (array)$payload['raw']; + } + + return null; + } + + /** + * @return array> + */ + private function bindingRows(int $gatewayId, int $departmentId): array + { + $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere( + [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'deleted_at' => null, + ], + ['id'] + ); + + return array_map( + static fn(array $row): array => (new edge_gateway_relay_bindings_o())->select((int)$row['id'])->asArray(), + $rows + ); + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php new file mode 100644 index 00000000..287ed7d4 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php @@ -0,0 +1,208 @@ + $context + * @return array> + */ + public function executeForLaneEvent(int|object $lane, string $event, string $washMode = selfserve_studio_actions::MODE_BOTH, array $context = []): array + { + $laneObject = is_int($lane) ? (new selfserve())->lane($lane) : $lane; + $departmentId = $this->departmentIdForLane($laneObject); + if ($departmentId <= 0) { + return []; + } + + $published = (new selfserve_config_versioning())->getPublishedV2Config($departmentId); + $config = is_array($published['config'] ?? null) ? (array)$published['config'] : []; + $actions = $this->matchingActions($config, $laneObject, $event, $washMode, $context); + $results = []; + + foreach ($actions as $action) { + $results[] = $this->executeAction($laneObject, $action); + } + + return $results; + } + + /** + * @param array $config + * @param object $lane + * @param array $context + * @return array> + */ + public function matchingActions(array $config, object $lane, string $event, string $washMode, array $context = []): array + { + $event = strtolower(trim($event)); + $washMode = strtolower(trim($washMode)); + $laneId = (int)($lane->id ?? 0); + $departmentId = $this->departmentIdForLane($lane); + $machineTypeId = $this->nullableInt($context['machine_type_id'] ?? null) ?? $this->machineTypeIdForLane($lane); + $productId = $this->nullableInt($context['product'] ?? $context['product_id'] ?? $context['vehicle_type_id'] ?? null); + $conditionResults = is_array($context['condition_results'] ?? null) ? (array)$context['condition_results'] : null; + + $actions = []; + foreach ((array)($config['actions'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $action = selfserve_studio_actions::normalize((array)$row); + if (!$action['enabled'] || $action['event'] !== $event) { + continue; + } + if (!in_array($action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $washMode], true)) { + continue; + } + if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) { + continue; + } + if ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) { + continue; + } + if ((int)$action['product'] !== 0 && ($productId === null || (int)$action['product'] !== $productId)) { + continue; + } + if ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== $machineTypeId) { + continue; + } + $conditionId = $action['condition_id']; + if ($conditionId !== null && (($conditionResults[$conditionId] ?? false) !== true)) { + continue; + } + $actions[] = $action; + } + + usort($actions, static fn(array $left, array $right): int => ((int)$left['order_priority'] <=> (int)$right['order_priority']) ?: ((int)$left['id'] <=> (int)$right['id'])); + return $actions; + } + + /** + * @param object $lane + * @param array $action + * @return array + */ + private function executeAction(object $lane, array $action): array + { + $options = is_array($action['options'] ?? null) ? (array)$action['options'] : []; + $attempts = max(1, min(4, ((int)($options['retry_count'] ?? 0)) + 1)); + $delayMs = max(0, min(10000, (int)($options['delay_ms'] ?? 0))); + $lastError = null; + + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + try { + if ($delayMs > 0) { + usleep($delayMs * 1000); + } + $this->dispatchAction($lane, $action); + return [ + 'action_id' => (int)$action['id'], + 'name' => (string)$action['name'], + 'event' => (string)$action['event'], + 'operation' => (string)$action['operation'], + 'status' => 'sent', + 'attempts' => $attempt, + ]; + } catch (\Throwable $e) { + $lastError = $e; + } + } + + $result = [ + 'action_id' => (int)$action['id'], + 'name' => (string)$action['name'], + 'event' => (string)$action['event'], + 'operation' => (string)$action['operation'], + 'status' => 'failed', + 'attempts' => $attempts, + 'error' => $lastError?->getMessage(), + ]; + + if (($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE) === selfserve_studio_actions::FAILURE_BLOCK) { + throw new \RuntimeException('Self-serve studio action failed: ' . (string)$action['name'], 0, $lastError); + } + + return $result; + } + + /** + * @param object $lane + * @param array $action + */ + private function dispatchAction(object $lane, array $action): void + { + $toggleAfter = $this->nullableInt($action['options']['toggle_after_seconds'] ?? null); + switch ((string)$action['operation']) { + case selfserve_studio_actions::OP_OPEN_PROPERTY_ENTRANCE_GATE: + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments()); + return; + case selfserve_studio_actions::OP_OPEN_PROPERTY_EXIT_GATE: + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, new selfserve_lane_command_arguments()); + return; + case selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT: + $lane->open(selfserve_lane_port::ENTRANCE, $toggleAfter); + return; + case selfserve_studio_actions::OP_OPEN_LANE_EXIT_PORT: + $lane->open(selfserve_lane_port::EXIT, $toggleAfter); + return; + case selfserve_studio_actions::OP_SET_CLEANER_RELAY: + $lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, (bool)$action['relay_state']); + return; + case selfserve_studio_actions::OP_SET_MACHINE_RELAY: + $lane->setRelayStatusHard(selfserve_lane_relay::MACHINE, (bool)$action['relay_state']); + return; + case selfserve_studio_actions::OP_SET_PROGRAM_PICKER_RELAY: + $lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, (bool)$action['relay_state']); + return; + } + + throw new \RuntimeException('Unsupported self-serve studio action operation: ' . (string)$action['operation']); + } + + private function departmentIdForLane(object $lane): int + { + try { + return empty($lane->department_lane) || empty($lane->department_lane->department) + ? 0 + : (int)$lane->department_lane->department->value(); + } catch (\Throwable) { + return 0; + } + } + + private function machineTypeIdForLane(object $lane): int + { + try { + return empty($lane->department_lane) || empty($lane->department_lane->machine_type_id) + ? 0 + : (int)$lane->department_lane->machine_type_id->value(); + } catch (\Throwable) { + return 0; + } + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php new file mode 100644 index 00000000..7101a325 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php @@ -0,0 +1,217 @@ + + */ + public static function events(): array + { + return [ + self::EVENT_WASH_START_COMMAND, + self::EVENT_WASH_STOP_COMMAND, + self::EVENT_MACHINE_START_TRIGGERED, + ]; + } + + /** + * @return array + */ + public static function washModes(): array + { + return [ + self::MODE_MANUAL, + self::MODE_MACHINE, + self::MODE_BOTH, + ]; + } + + /** + * @return array + */ + public static function operations(): array + { + return [ + self::OP_OPEN_PROPERTY_ENTRANCE_GATE, + self::OP_OPEN_PROPERTY_EXIT_GATE, + self::OP_OPEN_LANE_ENTRANCE_PORT, + self::OP_OPEN_LANE_EXIT_PORT, + self::OP_SET_CLEANER_RELAY, + self::OP_SET_MACHINE_RELAY, + self::OP_SET_PROGRAM_PICKER_RELAY, + ]; + } + + /** + * @return array + */ + public static function failurePolicies(): array + { + return [ + self::FAILURE_CONTINUE, + self::FAILURE_BLOCK, + ]; + } + + public static function isRelayOperation(string $operation): bool + { + return in_array($operation, [ + self::OP_SET_CLEANER_RELAY, + self::OP_SET_MACHINE_RELAY, + self::OP_SET_PROGRAM_PICKER_RELAY, + ], true); + } + + public static function isOpenOperation(string $operation): bool + { + return in_array($operation, [ + self::OP_OPEN_PROPERTY_ENTRANCE_GATE, + self::OP_OPEN_PROPERTY_EXIT_GATE, + self::OP_OPEN_LANE_ENTRANCE_PORT, + self::OP_OPEN_LANE_EXIT_PORT, + ], true); + } + + public static function relayRoleForOperation(string $operation): string + { + return match ($operation) { + self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'PROPERTY_ENTRANCE', + self::OP_OPEN_PROPERTY_EXIT_GATE => 'PROPERTY_EXIT', + self::OP_OPEN_LANE_ENTRANCE_PORT => 'ENTRY', + self::OP_OPEN_LANE_EXIT_PORT => 'EXIT', + self::OP_SET_CLEANER_RELAY => 'CLEANER', + self::OP_SET_MACHINE_RELAY => 'MACHINE', + self::OP_SET_PROGRAM_PICKER_RELAY => 'PROGRAM_PICKER', + default => 'ACTION', + }; + } + + public static function eventLabel(string $event): string + { + return match ($event) { + self::EVENT_WASH_START_COMMAND => 'When wash starts', + self::EVENT_WASH_STOP_COMMAND => 'When wash stops', + self::EVENT_MACHINE_START_TRIGGERED => 'When the machine start button is triggered', + default => 'On action event', + }; + } + + public static function operationLabel(string $operation, ?bool $relayState = null): string + { + $state = $relayState === null ? '' : ($relayState ? 'ON ' : 'OFF '); + return match ($operation) { + self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'Open property entrance gate', + self::OP_OPEN_PROPERTY_EXIT_GATE => 'Open property exit gate', + self::OP_OPEN_LANE_ENTRANCE_PORT => 'Open lane entrance port', + self::OP_OPEN_LANE_EXIT_PORT => 'Open lane exit port', + self::OP_SET_CLEANER_RELAY => 'Turn ' . $state . 'CLEANER', + self::OP_SET_MACHINE_RELAY => 'Turn ' . $state . 'MACHINE', + self::OP_SET_PROGRAM_PICKER_RELAY => 'Turn ' . $state . 'PROGRAM PICKER', + default => 'Action', + }; + } + + public static function runtimeStageForEvent(string $event): string + { + return match ($event) { + self::EVENT_WASH_START_COMMAND => 'start', + self::EVENT_WASH_STOP_COMMAND => 'stop', + self::EVENT_MACHINE_START_TRIGGERED => 'machine_start', + default => 'action', + }; + } + + /** + * @param array $action + * @return array + */ + public static function normalize(array $action): array + { + $event = strtolower(trim((string)($action['event'] ?? self::EVENT_WASH_START_COMMAND))); + if (!in_array($event, self::events(), true)) { + $event = self::EVENT_WASH_START_COMMAND; + } + + $operation = strtolower(trim((string)($action['operation'] ?? self::OP_OPEN_LANE_ENTRANCE_PORT))); + if (!in_array($operation, self::operations(), true)) { + $operation = self::OP_OPEN_LANE_ENTRANCE_PORT; + } + + $washMode = strtolower(trim((string)($action['wash_mode'] ?? self::MODE_BOTH))); + if (!in_array($washMode, self::washModes(), true)) { + $washMode = self::MODE_BOTH; + } + + $options = is_array($action['options'] ?? null) ? (array)$action['options'] : []; + $failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? self::FAILURE_CONTINUE))); + if (!in_array($failurePolicy, self::failurePolicies(), true)) { + $failurePolicy = self::FAILURE_CONTINUE; + } + + $relayState = array_key_exists('relay_state', $action) + ? filter_var($action['relay_state'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) + : null; + if (self::isRelayOperation($operation) && $relayState === null) { + $relayState = true; + } + + $label = trim((string)($action['name'] ?? $action['label'] ?? '')); + if ($label === '') { + $label = self::operationLabel($operation, $relayState); + } + + return [ + 'id' => (int)($action['id'] ?? 0), + 'department' => (int)($action['department'] ?? 0), + 'lane' => (int)($action['lane'] ?? 0), + 'product' => (int)($action['product'] ?? 0), + 'machine_type_id' => self::nullableInt($action['machine_type_id'] ?? null), + 'condition_id' => self::nullableInt($action['condition_id'] ?? null), + 'name' => $label, + 'description' => (string)($action['description'] ?? ''), + 'event' => $event, + 'wash_mode' => $washMode, + 'operation' => $operation, + 'relay_state' => $relayState, + 'enabled' => filter_var($action['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'order_priority' => (int)($action['order_priority'] ?? 0), + 'options' => [ + 'delay_ms' => max(0, (int)($options['delay_ms'] ?? 0)), + 'toggle_after_seconds' => self::nullableInt($options['toggle_after_seconds'] ?? null), + 'retry_count' => max(0, min(3, (int)($options['retry_count'] ?? 0))), + 'failure_policy' => $failurePolicy, + 'record_event' => filter_var($options['record_event'] ?? true, FILTER_VALIDATE_BOOLEAN), + ], + ]; + } + + private static function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php new file mode 100644 index 00000000..3621d958 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php @@ -0,0 +1,5210 @@ +> */ + private array $columnCache = []; + + public function __construct() + { + selfserve_schema_bootstrap::ensureTables(); + } + + /** + * @param array $permissions + * @return array + */ + public function buildGraph(int $departmentId, ?int $userId = null, array $permissions = []): array + { + $versioning = new selfserve_config_versioning(); + $draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId); + $gatewayWorkspace = ($permissions['modules_shelly_config'] ?? false) + ? $this->buildGatewayWorkspace($departmentId) + : [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'restricted' => true, + ]; + $lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace); + $layout = $this->loadLayout($departmentId, $userId); + $configWithAttachments = $this->withTaskAttachments($config); + $graph = $this->buildGraphFromConfig($configWithAttachments, [ + 'department_id' => $departmentId, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + ], $layout); + + $validation = $versioning->validateConfig($config); + $virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace); + if ($virtualWarnings !== []) { + $validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings))); + } + $validation['items'] = $this->buildValidationItems($validation); + + return [ + 'nodes' => $graph['nodes'], + 'edges' => $graph['edges'], + 'lookups' => $lookups, + 'validation' => $validation, + 'layout' => $layout, + 'versions' => $versioning->listVersions($departmentId), + 'active_config' => $versioning->getPublishedConfig($departmentId), + 'draft' => [ + 'id' => $draft['id'] ?? null, + 'status' => $draft['status'] ?? selfserve_config_versioning::STATUS_DRAFT, + 'version_number' => $draft['version_number'] ?? null, + 'created_at' => $draft['created_at'] ?? null, + 'updated_at' => $draft['updated_at'] ?? null, + ], + 'simulator_defaults' => $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace), + 'gateway_workspace' => $gatewayWorkspace, + 'permissions' => $permissions, + 'meta' => [ + 'department_id' => $departmentId, + 'layout_affects_runtime' => false, + 'generated_at' => date('c'), + 'path_editor' => is_array($config['v2_meta']['path_editor'] ?? null) ? (array)$config['v2_meta']['path_editor'] : ['paths' => []], + ], + ]; + } + + /** + * Pure graph builder used by unit tests and the API serializer. + * + * @param array $config + * @param array $context + * @param array $layout + * @return array{nodes:array>,edges:array>} + */ + public function buildGraphFromConfig(array $config, array $context = [], array $layout = []): array + { + $lookups = is_array($context['lookups'] ?? null) ? (array)$context['lookups'] : []; + $gatewayWorkspace = is_array($context['gateway_workspace'] ?? null) ? (array)$context['gateway_workspace'] : []; + $isV2Config = (int)($config['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2; + $nodes = []; + $edges = []; + + $nodes[] = $this->node('checkpoint:start', 'input', 'Runtime start', 'runtime_checkpoint', [ + 'stage' => 'start', + 'subtitle' => 'Vehicle scanned', + ], 0, 0); + $nodes[] = $this->node('checkpoint:eligible', 'default', 'Eligibility resolved', 'runtime_checkpoint', [ + 'stage' => 'eligible', + 'subtitle' => 'Questions, rules, and gates evaluated', + ], 320, 0); + $nodes[] = $this->node('checkpoint:finish', 'output', 'Wash complete', 'runtime_checkpoint', [ + 'stage' => 'finish', + 'subtitle' => 'Session closed', + ], 640, 0); + $edges[] = $this->edge('runtime:start-eligible', 'checkpoint:start', 'checkpoint:eligible', 'runtime', 'runtime'); + $edges[] = $this->edge('runtime:eligible-finish', 'checkpoint:eligible', 'checkpoint:finish', 'runtime', 'runtime'); + + foreach ($this->lookupRows($lookups, 'lanes') as $index => $lane) { + $id = 'lane:' . (int)($lane['id'] ?? 0); + $nodes[] = $this->node($id, 'default', (string)($lane['label'] ?? ('Lane ' . ($lane['id'] ?? ''))), 'lane', [ + 'object_id' => (int)($lane['id'] ?? 0), + 'raw' => $lane, + 'subtitle' => 'Lane scope', + ], 0, 180 + ($index * 120)); + } + + foreach ($this->lookupRows($lookups, 'machine_types') as $index => $machineType) { + $id = 'machine_type:' . (int)($machineType['id'] ?? 0); + $nodes[] = $this->node($id, 'default', (string)($machineType['label'] ?? ('Machine type ' . ($machineType['id'] ?? ''))), 'machine_type', [ + 'object_id' => (int)($machineType['id'] ?? 0), + 'raw' => $machineType, + 'subtitle' => 'Reusable machine setup', + ], 0, 560 + ($index * 120)); + } + + foreach ($this->lookupRows($lookups, 'vehicle_types') as $index => $vehicleType) { + $id = 'vehicle_type:' . (int)($vehicleType['id'] ?? 0); + $nodes[] = $this->node($id, 'default', (string)($vehicleType['label'] ?? ('Vehicle type ' . ($vehicleType['id'] ?? ''))), 'vehicle_type', [ + 'object_id' => (int)($vehicleType['id'] ?? 0), + 'raw' => $vehicleType, + 'subtitle' => 'Vehicle scope', + ], 0, 880 + ($index * 120)); + } + + foreach ($this->sortedRows((array)($config['conditions'] ?? []), ['name', 'id']) as $index => $condition) { + $id = (int)($condition['id'] ?? 0); + $nodes[] = $this->node('condition:' . $id, 'default', $this->entityLabel('condition', $id, $condition, $lookups), 'condition', [ + 'object_id' => $id, + 'raw' => $condition, + 'scope' => $this->scopeForRow($condition, $lookups), + 'expression_summary' => $this->expressionSummary(is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : []), + 'subtitle' => $this->scopeLabel($condition, $lookups), + ], 360, 160 + ($index * 130)); + + $parentId = $this->nullableInt($condition['condition_id'] ?? null); + if ($parentId !== null) { + $edges[] = $this->edge('condition-parent:' . $parentId . ':' . $id, 'condition:' . $parentId, 'condition:' . $id, 'condition_group', 'parent'); + } + if ($isV2Config && is_array($condition['expression'] ?? null)) { + $this->appendExpressionEdges($edges, 'condition:' . $id, $id, (array)$condition['expression']); + } + $this->appendScopeEdges($edges, 'condition:' . $id, $condition); + } + + foreach ($this->sortedRows((array)($config['questions'] ?? []), ['order_priority', 'id']) as $index => $question) { + $id = (int)($question['id'] ?? 0); + $nodes[] = $this->node('question:' . $id, 'default', $this->entityLabel('question', $id, $question, $lookups), 'question', [ + 'object_id' => $id, + 'raw' => $question, + 'scope' => $this->scopeForRow($question, $lookups), + 'subtitle' => $this->scopeLabel($question, $lookups), + ], 720, 160 + ($index * 130)); + + $conditionId = $this->nullableInt($question['condition_id'] ?? null); + if ($conditionId !== null) { + $edges[] = $this->edge('question-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'question:' . $id, 'visibility_gate', 'show if'); + } + $this->appendScopeEdges($edges, 'question:' . $id, $question); + } + + if (!$isV2Config) { + foreach ($this->sortedRows((array)($config['rules'] ?? []), ['condition_id', 'id']) as $index => $rule) { + $id = (int)($rule['id'] ?? 0); + $nodes[] = $this->node('rule:' . $id, 'default', $this->entityLabel('rule', $id, $rule, $lookups), 'rule', [ + 'object_id' => $id, + 'raw' => $rule, + 'subtitle' => $this->ruleSubtitle($rule, $lookups), + ], 520, 520 + ($index * 120)); + + $conditionId = (int)($rule['condition_id'] ?? 0); + if ($conditionId > 0) { + $edges[] = $this->edge('rule-owner:' . $conditionId . ':' . $id, 'rule:' . $id, 'condition:' . $conditionId, 'condition_rule', 'rule of'); + } + $objectType = strtolower((string)($rule['object_type'] ?? '')); + $objectId = (int)($rule['object_id'] ?? 0); + if (in_array($objectType, ['question', 'condition', 'task'], true) && $objectId > 0) { + $edges[] = $this->edge('rule-input:' . $objectType . ':' . $objectId . ':' . $id, $objectType . ':' . $objectId, 'rule:' . $id, 'rule_input', (string)($rule['type'] ?? 'rule')); + } + } + } + + $tasksByScope = []; + $taskRows = $this->sortedRows((array)($config['tasks'] ?? []), ['order_priority', 'id']); + foreach ($taskRows as $index => $task) { + $id = (int)($task['id'] ?? 0); + $nodes[] = $this->node('task:' . $id, 'default', $this->entityLabel('task', $id, $task, $lookups), 'task', [ + 'object_id' => $id, + 'raw' => $this->normalizeTaskPayload($task), + 'scope' => $this->scopeForRow($task, $lookups), + 'subtitle' => $this->scopeLabel($task, $lookups), + ], 1080, 160 + ($index * 130)); + + $gateType = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + if ($gateType === selfserve_task_gate_type::CONDITION->value && $gateRefId !== null) { + $edges[] = $this->edge('task-gate:condition:' . $gateRefId . ':' . $id, 'condition:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks'); + } elseif ($gateType === selfserve_task_gate_type::QUESTION->value && $gateRefId !== null) { + $edges[] = $this->edge('task-gate:question:' . $gateRefId . ':' . $id, 'question:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks'); + } + + $scopeKey = implode(':', [ + (int)($task['department'] ?? 0), + (int)($task['lane'] ?? 0), + (int)($task['product'] ?? 0), + (int)($task['machine_type_id'] ?? 0), + ]); + $tasksByScope[$scopeKey][] = $task; + $this->appendScopeEdges($edges, 'task:' . $id, $task); + } + + foreach ($tasksByScope as $tasks) { + $orderedTasks = $this->sortedRows($tasks, ['order_priority', 'id']); + for ($i = 1; $i < count($orderedTasks); $i++) { + $sourceId = (int)($orderedTasks[$i - 1]['id'] ?? 0); + $targetId = (int)($orderedTasks[$i]['id'] ?? 0); + if ($sourceId > 0 && $targetId > 0) { + $edges[] = $this->edge('task-order:' . $sourceId . ':' . $targetId, 'task:' . $sourceId, 'task:' . $targetId, 'task_order', 'then'); + } + } + } + + $actionsByEvent = []; + $actionRows = $this->sortedRows((array)($config['actions'] ?? []), ['event', 'order_priority', 'id']); + foreach ($actionRows as $index => $action) { + $normalizedAction = selfserve_studio_actions::normalize($action); + $id = (int)($normalizedAction['id'] ?? 0); + if ($id <= 0) { + continue; + } + $actionsByEvent[$normalizedAction['event']][] = $normalizedAction; + $nodes[] = $this->node('action:' . $id, 'default', $this->entityLabel('action', $id, $normalizedAction, $lookups), 'action', [ + 'object_id' => $id, + 'raw' => $normalizedAction, + 'scope' => $this->scopeForRow($normalizedAction, $lookups), + 'subtitle' => $this->actionSubtitle($normalizedAction, $lookups), + 'action_label' => selfserve_studio_actions::operationLabel((string)$normalizedAction['operation'], $normalizedAction['relay_state']), + 'event_label' => selfserve_studio_actions::eventLabel((string)$normalizedAction['event']), + 'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']), + ], 1360, 160 + ($index * 130)); + + $eventSource = match ((string)$normalizedAction['event']) { + selfserve_studio_actions::EVENT_WASH_START_COMMAND => 'checkpoint:start', + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => 'checkpoint:finish', + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => 'checkpoint:eligible', + default => 'checkpoint:start', + }; + $edges[] = $this->edge('action-event:' . $normalizedAction['event'] . ':' . $id, $eventSource, 'action:' . $id, 'action_event', $normalizedAction['wash_mode']); + + $conditionId = $this->nullableInt($normalizedAction['condition_id'] ?? null); + if ($conditionId !== null) { + $edges[] = $this->edge('action-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'action:' . $id, 'action_gate', 'allows'); + } + $this->appendScopeEdges($edges, 'action:' . $id, $normalizedAction); + } + + foreach ($actionsByEvent as $event => $actions) { + $orderedActions = $this->sortedRows($actions, ['order_priority', 'id']); + for ($i = 1; $i < count($orderedActions); $i++) { + $sourceId = (int)($orderedActions[$i - 1]['id'] ?? 0); + $targetId = (int)($orderedActions[$i]['id'] ?? 0); + if ($sourceId > 0 && $targetId > 0) { + $edges[] = $this->edge('action-order:' . $event . ':' . $sourceId . ':' . $targetId, 'action:' . $sourceId, 'action:' . $targetId, 'action_order', 'then'); + } + } + } + + $this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace); + $this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace); + $this->appendActionRelayEdges($edges, $actionRows, $gatewayWorkspace); + + return [ + 'nodes' => $this->applyLayoutToNodes($nodes, $layout), + 'edges' => array_values($edges), + ]; + } + + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function applyGraphSave(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array + { + $operations = isset($payload['operations']) && is_array($payload['operations']) ? (array)$payload['operations'] : []; + + $versioning = new selfserve_config_versioning(); + $draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId); + + if ($versioning->isV2Config($config)) { + foreach ($operations as $operation) { + if (is_array($operation)) { + $this->applyConfigOperation($departmentId, $config, $operation, $permissions); + } + } + + $validation = $versioning->validateConfig($config); + $draftObject = (new selfserve_config_versions_o())->select((int)($draft['id'] ?? 0)); + if (!$draftObject->exists()) { + throw new \RuntimeException('Self-serve draft version was not found.'); + } + $draftObject->config_json->set($config); + $draftObject->validation_result_json->set($validation); + } else { + foreach ($operations as $operation) { + if (is_array($operation)) { + $this->applyOperation($departmentId, $operation, $permissions); + } + } + } + + if (isset($payload['layout']) && is_array($payload['layout'])) { + $this->saveLayout($departmentId, $userId, (array)$payload['layout']); + } elseif (isset($payload['nodes']) && is_array($payload['nodes'])) { + $this->saveLayout($departmentId, $userId, [ + 'nodes' => $this->extractNodePositions((array)$payload['nodes']), + 'viewport' => is_array($payload['viewport'] ?? null) ? (array)$payload['viewport'] : [], + ]); + } + + if (!$versioning->isV2Config($config)) { + $versioning->syncDraftFromLegacyForDepartment($departmentId); + } + return $this->buildGraph($departmentId, $userId, $permissions); + } + + /** + * @param array $layout + * @return array + */ + public function saveLayout(int $departmentId, ?int $userId, array $layout): array + { + $normalized = [ + 'nodes' => $this->extractNodePositions((array)($layout['nodes'] ?? [])), + 'viewport' => is_array($layout['viewport'] ?? null) ? (array)$layout['viewport'] : [], + 'saved_at' => date('c'), + 'runtime_affecting' => false, + ]; + $layoutJson = json_encode($normalized, JSON_UNESCAPED_UNICODE); + if ($layoutJson === false) { + throw new \RuntimeException('Failed to encode studio layout JSON: ' . json_last_error_msg()); + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + "SELECT id + FROM department_selfserve_studio_layouts + WHERE department_id = :department_id + AND " . ($userId === null ? "user_id IS NULL" : "user_id = :user_id") . " + AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1" + ); + $params = [':department_id' => $departmentId]; + if ($userId !== null) { + $params[':user_id'] = $userId; + } + $statement->execute($params); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + + if (is_array($row) && (int)($row['id'] ?? 0) > 0) { + $update = $pdo->prepare( + "UPDATE department_selfserve_studio_layouts + SET layout_json = :layout_json, updated_at = NOW() + WHERE id = :id" + ); + $update->execute([ + ':layout_json' => $layoutJson, + ':id' => (int)$row['id'], + ]); + } else { + $insert = $pdo->prepare( + "INSERT INTO department_selfserve_studio_layouts (department_id, user_id, layout_json) + VALUES (:department_id, :user_id, :layout_json)" + ); + $insert->execute([ + ':department_id' => $departmentId, + ':user_id' => $userId, + ':layout_json' => $layoutJson, + ]); + } + + return $normalized; + } + + /** + * @return array + */ + public function loadLayout(int $departmentId, ?int $userId): array + { + $pdo = db::getPDO(); + $statement = $pdo->prepare( + "SELECT layout_json + FROM department_selfserve_studio_layouts + WHERE department_id = :department_id + AND deleted_at IS NULL + AND (user_id = :user_id_filter OR user_id IS NULL) + ORDER BY CASE WHEN user_id = :user_id_sort THEN 0 ELSE 1 END, updated_at DESC, id DESC + LIMIT 1" + ); + $statement->execute([ + ':department_id' => $departmentId, + ':user_id_filter' => $userId, + ':user_id_sort' => $userId, + ]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + if (!is_array($row)) { + return [ + 'nodes' => [], + 'viewport' => [], + 'runtime_affecting' => false, + ]; + } + + $layout = json_decode((string)($row['layout_json'] ?? '{}'), true); + if (!is_array($layout)) { + $layout = []; + } + $layout['runtime_affecting'] = false; + return $layout; + } + + /** + * @param array $payload + * @return array + */ + public function validatePayload(int $departmentId, array $payload = []): array + { + $versioning = new selfserve_config_versioning(); + $draft = $versioning->ensureDraftFromLegacy($departmentId, null, false); + $config = is_array($payload['config'] ?? null) + ? (array)$payload['config'] + : (is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId)); + if (isset($payload['operations']) && is_array($payload['operations']) && $versioning->isV2Config($config)) { + foreach ((array)$payload['operations'] as $operation) { + if (is_array($operation)) { + $this->applyConfigOperation($departmentId, $config, $operation); + } + } + } + $validation = $versioning->validateConfig($config); + $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId); + $virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace); + if ($virtualWarnings !== []) { + $validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings))); + } + $validation['items'] = $this->buildValidationItems($validation); + return $validation; + } + + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function simulateGraph(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array + { + $laneId = (int)($payload['lane_id'] ?? 0); + if ($laneId <= 0) { + throw new \RuntimeException('lane_id is required for studio simulation.'); + } + + $configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft'))); + if (!in_array($configSource, ['draft', 'published'], true)) { + $configSource = 'draft'; + } + + $versioning = new selfserve_config_versioning(); + if ($configSource === 'published') { + $version = $versioning->getPublishedV2Config($departmentId); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : null; + $versionId = isset($version['version_id']) ? (int)$version['version_id'] : null; + } else { + $version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId); + $versionId = isset($version['id']) ? (int)$version['id'] : null; + } + + $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $includeHardware = $includeHardware !== false; + $hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? ''))); + if ($hardwareMode === '') { + $hardwareMode = $includeHardware ? 'studio' : 'none'; + } + if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) { + $hardwareMode = 'studio'; + } + if ($hardwareMode === 'none') { + $includeHardware = false; + } + if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) { + $gatewayWorkspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'restricted' => !$includeHardware ? false : true, + 'virtual' => [ + 'enabled' => $hardwareMode === 'studio', + 'has_virtual_hardware' => false, + 'gateway_count' => 0, + 'binding_count' => 0, + ], + ]; + } else { + $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real'); + } + $graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId); + $lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace); + $graph = $this->buildGraphFromConfig($graphConfig, [ + 'department_id' => $departmentId, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + ]); + + return (new selfserve_wash_flow())->previewStudioSimulation( + $departmentId, + $laneId, + (string)($payload['reg'] ?? ''), + array_key_exists('customer_number', $payload) ? $this->nullableInt($payload['customer_number']) : null, + array_key_exists('vehicle_type_id', $payload) ? $this->nullableInt($payload['vehicle_type_id']) : null, + [ + 'mode' => 'full_dry_run', + 'config_source' => $configSource, + 'config_payload' => $config, + 'config_version_id' => $versionId, + 'answer_overrides' => is_array($payload['answer_overrides'] ?? null) ? (array)$payload['answer_overrides'] : [], + 'include_hardware' => $includeHardware, + 'hardware_mode' => $hardwareMode, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + 'graph' => $graph, + ], + ); + } + + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function projectPathOutcomes( + int $departmentId, + array $payload, + ?int $userId = null, + array $permissions = [], + ?callable $progressCallback = null + ): array + { + $configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft'))); + if (!in_array($configSource, ['draft', 'published'], true)) { + $configSource = 'draft'; + } + + $versioning = new selfserve_config_versioning(); + if ($configSource === 'published') { + $version = $versioning->getPublishedV2Config($departmentId); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : null; + $versionId = isset($version['version_id']) ? (int)$version['version_id'] : null; + } else { + $version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId); + $versionId = isset($version['id']) ? (int)$version['id'] : null; + } + + $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $includeHardware = $includeHardware !== false; + $hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? ''))); + if ($hardwareMode === '') { + $hardwareMode = $includeHardware ? 'studio' : 'none'; + } + if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) { + $hardwareMode = 'studio'; + } + if ($hardwareMode === 'none') { + $includeHardware = false; + } + + if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) { + $gatewayWorkspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'restricted' => !$includeHardware ? false : true, + 'virtual' => [ + 'enabled' => $hardwareMode === 'studio', + 'has_virtual_hardware' => false, + 'gateway_count' => 0, + 'binding_count' => 0, + ], + ]; + } else { + $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real'); + } + + $graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId); + $lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace); + $graph = $this->buildGraphFromConfig($graphConfig, [ + 'department_id' => $departmentId, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + ]); + $defaults = $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace); + $laneId = $this->nullableInt($payload['lane_id'] ?? null) ?? $this->nullableInt($defaults['lane_id'] ?? null); + if ($laneId === null) { + throw new \RuntimeException('No lane is available for path outcome projection.'); + } + + $vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? null); + $vehicleTypeIds = []; + if ($vehicleTypeId !== null) { + $vehicleTypeIds[] = $vehicleTypeId; + } else { + foreach ($this->lookupRows($lookups, 'vehicle_types') as $row) { + $id = $this->nullableInt($row['id'] ?? null); + if ($id !== null) { + $vehicleTypeIds[$id] = $id; + } + } + $vehicleTypeIds = array_values($vehicleTypeIds); + } + if ($vehicleTypeIds === []) { + $vehicleTypeIds[] = null; + } + + $maxStates = $this->pathLimit($payload['max_states'] ?? null); + $reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123')); + if ($reg === '') { + $reg = 'TEST123'; + } + $customerNumber = array_key_exists('customer_number', $payload) + ? $this->nullableInt($payload['customer_number']) + : $this->nullableInt($defaults['customer_number'] ?? null); + + $flow = new selfserve_wash_flow(); + $outcomes = []; + $warnings = []; + $truncated = false; + $stateCount = 0; + $terminalPathCount = 0; + $questionIds = []; + $pathSampleLimit = $this->pathLimit($payload['path_sample_limit'] ?? null); + $paths = []; + $scenarioCount = max(1, count($vehicleTypeIds)); + $confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource); + + foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) { + $remainingStates = $maxStates === null ? null : $maxStates - $stateCount; + if ($remainingStates !== null && $remainingStates <= 0) { + $truncated = true; + break; + } + + $scenarioScope = [ + 'department_id' => $departmentId, + 'department' => $this->labelFor('departments', $departmentId, $lookups), + 'lane_id' => $laneId, + 'lane' => $this->labelFor('lanes', $laneId, $lookups), + 'vehicle_type_id' => $scenarioVehicleTypeId, + 'vehicle_type' => $scenarioVehicleTypeId === null ? 'Auto' : $this->labelFor('vehicle_types', $scenarioVehicleTypeId, $lookups), + 'registration' => $reg, + 'customer_number' => $customerNumber, + 'config_source' => $configSource, + 'config_version_id' => $versionId, + 'hardware_mode' => $hardwareMode, + ]; + $simulate = function (array $answerOverrides) use ( + $flow, + $departmentId, + $laneId, + $reg, + $customerNumber, + $scenarioVehicleTypeId, + $configSource, + $graphConfig, + $versionId, + $includeHardware, + $hardwareMode, + $lookups, + $gatewayWorkspace, + $graph + ): array { + return $flow->previewStudioSimulation( + $departmentId, + $laneId, + $reg, + $customerNumber, + $scenarioVehicleTypeId, + [ + 'mode' => 'full_dry_run', + 'config_source' => $configSource, + 'config_payload' => $graphConfig, + 'config_version_id' => $versionId, + 'answer_overrides' => $answerOverrides, + 'include_hardware' => $includeHardware, + 'hardware_mode' => $hardwareMode, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + 'graph' => $graph, + ], + ); + }; + + $projectionOptions = [ + 'scope' => $scenarioScope, + 'max_states' => $remainingStates, + 'path_sample_limit' => $pathSampleLimit === null ? null : max(0, $pathSampleLimit - count($paths)), + 'progress_callback' => function (array $projection) use ( + $progressCallback, + &$outcomes, + &$paths, + &$stateCount, + &$terminalPathCount, + &$questionIds, + $maxStates, + $pathSampleLimit, + $scenarioIndex, + $scenarioCount, + $departmentId, + $lookups, + $laneId, + $vehicleTypeId, + $reg, + $customerNumber, + $configSource, + $versionId, + $hardwareMode + ): void { + if ($progressCallback === null) { + return; + } + + $partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? []))); + $partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? []))); + if ($pathSampleLimit !== null && count($partialPaths) > $pathSampleLimit) { + $partialPaths = array_slice($partialPaths, 0, $pathSampleLimit); + } + + $partialQuestionIds = $questionIds; + foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) { + $partialQuestionIds[(int)$questionId] = true; + } + + $projectionProgress = is_array($projection['progress'] ?? null) ? (array)$projection['progress'] : []; + $scenarioPercent = (float)($projectionProgress['percent'] ?? 0); + $overallPercent = min(99.0, (($scenarioIndex + ($scenarioPercent / 100)) / $scenarioCount) * 100); + + $partialPayload = $this->pathOutcomesPayload( + [ + 'department_id' => $departmentId, + 'department' => $this->labelFor('departments', $departmentId, $lookups), + 'lane_id' => $laneId, + 'lane' => $this->labelFor('lanes', $laneId, $lookups), + 'vehicle_type_id' => $vehicleTypeId, + 'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups), + 'vehicle_type_count' => $scenarioCount, + 'registration' => $reg, + 'customer_number' => $customerNumber, + 'config_source' => $configSource, + 'config_version_id' => $versionId, + 'hardware_mode' => $hardwareMode, + 'max_states' => $maxStates, + ], + $partialOutcomes, + $partialPaths, + [], + false, + $maxStates, + $stateCount + (int)($projection['summary']['state_count'] ?? 0), + $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0), + $partialQuestionIds, + [ + 'complete' => false, + 'percent' => (int)floor($overallPercent), + 'state_count' => $stateCount + (int)($projection['summary']['state_count'] ?? 0), + 'pending_state_count' => (int)($projectionProgress['pending_state_count'] ?? 0), + 'terminal_path_count' => $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0), + 'scenario_index' => $scenarioIndex + 1, + 'scenario_count' => $scenarioCount, + ], + $confirmationRows + ); + $progressCallback($partialPayload); + }, + 'confirmation_rows' => $confirmationRows, + ]; + if ($remainingStates === null) { + unset($projectionOptions['max_states']); + } + if ($pathSampleLimit === null) { + unset($projectionOptions['path_sample_limit']); + } + $projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions); + foreach ((array)($projection['outcomes'] ?? []) as $outcome) { + if (is_array($outcome)) { + $outcomes[] = $outcome; + } + } + foreach ((array)($projection['paths'] ?? []) as $path) { + if (!is_array($path)) { + continue; + } + if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) { + $paths[] = $path; + } + } + foreach ((array)($projection['warnings'] ?? []) as $warning) { + $warnings[] = (string)$warning; + } + $truncated = $truncated || (bool)($projection['truncated'] ?? false); + $stateCount += (int)($projection['summary']['state_count'] ?? 0); + $terminalPathCount += (int)($projection['summary']['terminal_path_count'] ?? 0); + foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) { + $questionIds[(int)$questionId] = true; + } + } + + if ($truncated) { + $warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s). Narrow the lane or vehicle type filters to inspect more paths.'; + } + + return $this->pathOutcomesPayload( + [ + 'department_id' => $departmentId, + 'department' => $this->labelFor('departments', $departmentId, $lookups), + 'lane_id' => $laneId, + 'lane' => $this->labelFor('lanes', $laneId, $lookups), + 'vehicle_type_id' => $vehicleTypeId, + 'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups), + 'vehicle_type_count' => count($vehicleTypeIds), + 'registration' => $reg, + 'customer_number' => $customerNumber, + 'config_source' => $configSource, + 'config_version_id' => $versionId, + 'hardware_mode' => $hardwareMode, + 'max_states' => $maxStates, + ], + $outcomes, + $paths, + $warnings, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + [ + 'complete' => true, + 'percent' => 100, + 'state_count' => $stateCount, + 'pending_state_count' => 0, + 'terminal_path_count' => $terminalPathCount, + 'scenario_index' => $scenarioCount, + 'scenario_count' => $scenarioCount, + ], + $confirmationRows + ); + } + + /** + * @param callable(array):array $simulate + * @param array $options + * @return array + */ + public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array + { + $maxStates = $this->pathLimit($options['max_states'] ?? null); + $sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5))); + $pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null); + $progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null; + $progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128)); + $scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : []; + $confirmationRows = is_array($options['confirmation_rows'] ?? null) ? (array)$options['confirmation_rows'] : []; + $stack = [[ + 'answers' => [], + 'chain' => [], + ]]; + $seenStates = []; + $groups = []; + $paths = []; + $stateCount = 0; + $terminalPathCount = 0; + $questionIds = []; + $truncated = false; + + while ($stack !== []) { + if ($maxStates !== null && $stateCount >= $maxStates) { + $truncated = true; + break; + } + + $state = array_pop($stack); + $answers = is_array($state['answers'] ?? null) ? (array)$state['answers'] : []; + ksort($answers, SORT_NUMERIC); + $stateKey = $this->stableJson($answers); + if (isset($seenStates[$stateKey])) { + continue; + } + $seenStates[$stateKey] = true; + $stateCount++; + + $simulation = $simulate($this->pathAnswerOverrides($answers)); + $nextQuestion = $this->nextPathQuestion($simulation, $answers); + if ($nextQuestion !== null) { + $questionId = (int)($nextQuestion['id'] ?? 0); + if ($questionId > 0) { + $questionIds[$questionId] = true; + foreach ([false, true] as $answerValue) { + $nextAnswers = $answers; + $nextAnswers[$questionId] = $answerValue; + ksort($nextAnswers, SORT_NUMERIC); + $nextChain = is_array($state['chain'] ?? null) ? array_values((array)$state['chain']) : []; + $nextChain[] = [ + 'question_id' => $questionId, + 'question' => (string)($nextQuestion['label'] ?? $nextQuestion['question'] ?? ('Question ' . $questionId)), + 'node_id' => (string)($nextQuestion['node_id'] ?? ('question:' . $questionId)), + 'answer' => $answerValue, + 'answer_label' => $answerValue ? 'Yes' : 'No', + ]; + $stack[] = [ + 'answers' => $nextAnswers, + 'chain' => $nextChain, + ]; + } + } + if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) { + $progressCallback($this->pathOutcomesProjectionPayload( + $scope, + $groups, + $paths, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + count($stack), + $confirmationRows + )); + } + continue; + } + + $terminalPathCount++; + $chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : []; + $this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit); + if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) { + $paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope); + } + + if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) { + $progressCallback($this->pathOutcomesProjectionPayload( + $scope, + $groups, + $paths, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + count($stack), + $confirmationRows + )); + } + } + + return $this->pathOutcomesProjectionPayload( + $scope, + $groups, + $paths, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + count($stack), + $confirmationRows + ); + } + + /** + * @param array $payload + * @return array + */ + public function confirmPathOutcome(int $departmentId, array $payload, ?int $userId): array + { + $pathSignature = trim((string)($payload['path_signature'] ?? '')); + $resultSignature = trim((string)($payload['result_signature'] ?? '')); + if ($pathSignature === '' || $resultSignature === '') { + throw new \RuntimeException('path_signature and result_signature are required.'); + } + + $scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : []; + $laneId = $this->nullableInt($payload['lane_id'] ?? $scope['lane_id'] ?? null); + $vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? $scope['vehicle_type_id'] ?? null); + $configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null); + $configSource = strtolower(trim((string)($payload['config_source'] ?? $scope['config_source'] ?? 'draft'))); + if ($configSource === '') { + $configSource = 'draft'; + } + $answers = is_array($payload['answers'] ?? null) ? array_values((array)$payload['answers']) : []; + $result = is_array($payload['result'] ?? null) ? (array)$payload['result'] : []; + + $answersJson = json_encode($this->sortStableValue($answers), JSON_UNESCAPED_UNICODE); + $resultJson = json_encode($this->sortStableValue($result), JSON_UNESCAPED_UNICODE); + $scopeJson = json_encode($this->sortStableValue($scope), JSON_UNESCAPED_UNICODE); + if ($answersJson === false || $resultJson === false || $scopeJson === false) { + throw new \RuntimeException('Could not encode path confirmation payload.'); + } + + $pdo = db::getPDO(); + $select = $pdo->prepare( + "SELECT id + FROM department_selfserve_path_confirmations + WHERE department_id = :department_id + AND path_signature = :path_signature + AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . " + AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1" + ); + $selectParams = [ + ':department_id' => $departmentId, + ':path_signature' => $pathSignature, + ]; + if ($configVersionId !== null) { + $selectParams[':config_version_id'] = $configVersionId; + } + $select->execute($selectParams); + $row = $select->fetch(\PDO::FETCH_ASSOC); + + if (is_array($row) && (int)($row['id'] ?? 0) > 0) { + $update = $pdo->prepare( + "UPDATE department_selfserve_path_confirmations + SET lane_id = :lane_id, + vehicle_type_id = :vehicle_type_id, + config_source = :config_source, + result_signature = :result_signature, + answers_json = :answers_json, + result_json = :result_json, + scope_json = :scope_json, + confirmed_by = :confirmed_by, + confirmed_at = NOW(), + stale_reason = NULL, + deleted_at = NULL + WHERE id = :id" + ); + $update->execute([ + ':lane_id' => $laneId, + ':vehicle_type_id' => $vehicleTypeId, + ':config_source' => $configSource, + ':result_signature' => $resultSignature, + ':answers_json' => $answersJson, + ':result_json' => $resultJson, + ':scope_json' => $scopeJson, + ':confirmed_by' => $userId, + ':id' => (int)$row['id'], + ]); + $id = (int)$row['id']; + } else { + $insert = $pdo->prepare( + "INSERT INTO department_selfserve_path_confirmations + (department_id, lane_id, vehicle_type_id, config_version_id, config_source, path_signature, result_signature, answers_json, result_json, scope_json, confirmed_by, confirmed_at) + VALUES + (:department_id, :lane_id, :vehicle_type_id, :config_version_id, :config_source, :path_signature, :result_signature, :answers_json, :result_json, :scope_json, :confirmed_by, NOW())" + ); + $insert->execute([ + ':department_id' => $departmentId, + ':lane_id' => $laneId, + ':vehicle_type_id' => $vehicleTypeId, + ':config_version_id' => $configVersionId, + ':config_source' => $configSource, + ':path_signature' => $pathSignature, + ':result_signature' => $resultSignature, + ':answers_json' => $answersJson, + ':result_json' => $resultJson, + ':scope_json' => $scopeJson, + ':confirmed_by' => $userId, + ]); + $id = (int)$pdo->lastInsertId(); + } + + return [ + 'id' => $id, + 'department_id' => $departmentId, + 'lane_id' => $laneId, + 'vehicle_type_id' => $vehicleTypeId, + 'config_version_id' => $configVersionId, + 'config_source' => $configSource, + 'path_signature' => $pathSignature, + 'result_signature' => $resultSignature, + 'answers' => $answers, + 'result' => $result, + 'scope' => $scope, + 'confirmation_status' => 'confirmed', + ]; + } + + /** + * @param array $payload + * @return array + */ + public function resetPathConfirmation(int $departmentId, array $payload): array + { + $pathSignature = trim((string)($payload['path_signature'] ?? '')); + if ($pathSignature === '') { + throw new \RuntimeException('path_signature is required.'); + } + $scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : []; + $configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null); + $pdo = db::getPDO(); + $statement = $pdo->prepare( + "UPDATE department_selfserve_path_confirmations + SET deleted_at = NOW() + WHERE department_id = :department_id + AND path_signature = :path_signature + AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . " + AND deleted_at IS NULL" + ); + $params = [ + ':department_id' => $departmentId, + ':path_signature' => $pathSignature, + ]; + if ($configVersionId !== null) { + $params[':config_version_id'] = $configVersionId; + } + $statement->execute($params); + + return [ + 'path_signature' => $pathSignature, + 'reset' => true, + 'affected' => $statement->rowCount(), + ]; + } + + /** + * @param array $payload + * @return array + */ + public function runGatewayAction(int $departmentId, int $gatewayId, string $action, array $payload, ?int $userId): array + { + if (!class_exists(edge_gateway_view_service::class)) { + throw new \RuntimeException('Edge gateway module is not available.'); + } + + $gateway = (new edge_gateway_view_service())->getGateway($gatewayId); + if (!isset($gateway['id'])) { + throw new \RuntimeException('Edge gateway not found.'); + } + if ((int)($gateway['department_id'] ?? 0) !== $departmentId) { + throw new \RuntimeException('Edge gateway does not belong to the selected department.'); + } + + $action = strtolower(trim($action)); + $operations = new edge_gateway_operation_service(); + return match ($action) { + 'discovery', 'discover' => [ + 'action' => 'discovery', + 'operation' => $operations->queueDiscoveryOperation($gatewayId, $userId), + ], + 'update' => [ + 'action' => 'update', + 'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UPDATE, (array)($payload['request'] ?? []), $userId), + ], + 'uninstall' => [ + 'action' => 'uninstall', + 'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UNINSTALL, (array)($payload['request'] ?? []), $userId), + ], + 'cancel' => [ + 'action' => 'cancel', + 'operation' => $operations->cancelOperation($gatewayId, (int)($payload['operation_id'] ?? 0), $userId), + ], + 'rotate_credentials' => [ + 'action' => 'rotate_credentials', + 'gateway' => $operations->rotateCredentials($gatewayId, $userId), + ], + 'bindings' => [ + 'action' => 'bindings', + 'gateway' => (new edge_gateway_registry_service())->setRelayBindings($gatewayId, (array)($payload['bindings'] ?? []), $userId), + ], + default => throw new \RuntimeException('Unsupported gateway action: ' . $action), + }; + } + + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function applyVirtualHardwareOperation(int $departmentId, array $payload, ?int $userId, array $permissions = []): array + { + if (($permissions['can_edit'] ?? false) !== true) { + throw new \RuntimeException('You do not have permission to edit self-serve studio hardware.'); + } + + $operation = strtolower(trim((string)($payload['operation'] ?? $payload['action'] ?? ''))); + $data = is_array($payload['data'] ?? null) ? (array)$payload['data'] : $payload; + $realWorkspace = $this->buildGatewayWorkspace($departmentId, false); + (new selfserve_virtual_hardware())->applyOperation($departmentId, $operation, $data, $userId, $realWorkspace); + + return $this->buildGraph($departmentId, $userId, $permissions); + } + + /** + * @return array + */ + private function buildGatewayWorkspace(int $departmentId, bool $includeVirtual = true): array + { + if (!class_exists(edge_gateway_department_workspace_service::class)) { + $workspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'available' => false, + ]; + return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace; + } + + try { + $workspace = (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId); + } catch (\Throwable $exception) { + $workspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [ + [ + 'severity' => 'warning', + 'message' => $exception->getMessage(), + ], + ], + 'actions' => [], + 'available' => false, + ]; + } + + return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace; + } + + /** + * @param array $config + * @param array $gatewayWorkspace + * @return array + */ + private function buildLookups(int $departmentId, array $config, array $gatewayWorkspace): array + { + $departmentRows = $this->fetchRows('departments', ['id', 'name', 'description'], ['id' => $departmentId]); + $laneRows = $this->fetchRows('department_lanes', [ + 'id', + 'department', + 'name', + 'relay_in_id', + 'relay_out_id', + 'relay_machine_id', + 'relay_machine_program_picker_id', + 'relay_machine_cleaner_id', + 'machine_type_id', + 'dynamic_image_id', + 'selfserve_enabled', + ], ['department' => $departmentId]); + $machineTypeRows = $this->fetchRows('selfserve_machine_types', ['id', 'name', 'description'], []); + $productRows = $this->fetchRows('products', ['id', 'name', 'description', 'price', 'subscription_allowed', 'category', 'piktogram', 'is_wash', 'order_priority'], []); + $vehicleTypeRows = $this->vehicleTypeRowsFromProducts($productRows); + $users = $this->fetchRows('users', ['id', 'customer_number', 'display_name'], []); + $laneLookupRows = $this->labelRows($laneRows, 'name'); + $machineTypeLookupRows = $this->addReferencedMachineTypeRows($this->labelRows($machineTypeRows, 'name'), $laneRows, $config); + + $lookups = [ + 'departments' => $this->labelRows($departmentRows, 'name'), + 'lanes' => $laneLookupRows, + 'products' => $this->labelRows($productRows, 'name'), + 'machine_types' => $machineTypeLookupRows, + 'dynamic_images' => $this->dynamicImageRowsFromLanes($laneRows), + 'vehicle_types' => $vehicleTypeRows, + 'questions' => $this->configLabelRows((array)($config['questions'] ?? []), 'question'), + 'conditions' => $this->configLabelRows((array)($config['conditions'] ?? []), 'name'), + 'rules' => $this->configLabelRows((array)($config['rules'] ?? []), 'name'), + 'tasks' => $this->configLabelRows((array)($config['tasks'] ?? []), 'task'), + 'actions' => $this->configLabelRows((array)($config['actions'] ?? []), 'name'), + 'gateways' => $this->gatewayLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), + 'relays' => $this->relayLabelRows((array)($gatewayWorkspace['relays'] ?? [])), + 'bindings' => $this->bindingLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), + 'users' => array_map(static function (array $row): array { + $label = trim((string)($row['display_name'] ?? '')); + if ($label === '') { + $label = 'User ' . (string)($row['customer_number'] ?? $row['id'] ?? ''); + } + $row['label'] = $label; + return $row; + }, $users), + ]; + + $labels = []; + foreach ($lookups as $type => $rows) { + if (!is_array($rows)) { + continue; + } + $labels[$type] = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $id = (string)($row['id'] ?? ''); + if ($id !== '') { + $labels[$type][$id] = (string)($row['label'] ?? $id); + } + } + } + $lookups['labels'] = $labels; + + return $lookups; + } + + /** + * @param array> $rows + * @param array> $laneRows + * @param array $config + * @return array> + */ + private function addReferencedMachineTypeRows(array $rows, array $laneRows, array $config): array + { + $rowsById = []; + foreach ($rows as $row) { + $id = $this->nullableInt($row['id'] ?? null); + if ($id === null) { + continue; + } + $row['id'] = $id; + $row['label'] = trim((string)($row['label'] ?? $row['name'] ?? '')) ?: 'Machine type ' . $id; + $rowsById[$id] = $row; + } + + foreach ($this->referencedMachineTypeIds($laneRows, $config) as $id) { + if (!isset($rowsById[$id])) { + $rowsById[$id] = [ + 'id' => $id, + 'name' => 'Machine type ' . $id, + 'label' => 'Machine type ' . $id, + 'referenced' => true, + ]; + } + } + + ksort($rowsById, SORT_NUMERIC); + return array_values($rowsById); + } + + /** + * @param array> $laneRows + * @param array $config + * @return array + */ + private function referencedMachineTypeIds(array $laneRows, array $config): array + { + $ids = []; + foreach ($laneRows as $row) { + $id = $this->nullableInt($row['machine_type_id'] ?? null); + if ($id !== null) { + $ids[$id] = $id; + } + } + + foreach (['conditions', 'questions', 'tasks', 'actions'] as $section) { + foreach ((array)($config[$section] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $id = $this->nullableInt($row['machine_type_id'] ?? null); + if ($id !== null) { + $ids[$id] = $id; + } + } + } + + ksort($ids, SORT_NUMERIC); + return array_values($ids); + } + + /** + * @param array> $laneRows + * @return array> + */ + private function dynamicImageRowsFromLanes(array $laneRows): array + { + $rowsById = []; + foreach ($this->supportedDynamicImageRows() as $row) { + $id = $this->nullableInt($row['id'] ?? null); + if ($id !== null) { + $rowsById[$id] = $row; + } + } + + foreach ($laneRows as $lane) { + $id = $this->nullableInt($lane['dynamic_image_id'] ?? null); + if ($id === null || isset($rowsById[$id])) { + continue; + } + $rowsById[$id] = [ + 'id' => $id, + 'name' => 'Dynamic image ' . $id, + 'label' => 'Dynamic image ' . $id, + 'referenced' => true, + ]; + } + + ksort($rowsById, SORT_NUMERIC); + return array_values($rowsById); + } + + /** + * @return array> + */ + private function supportedDynamicImageRows(): array + { + return [ + [ + 'id' => 1, + 'name' => 'Machine 1', + 'label' => 'Machine 1', + 'class' => 'dynamicimages\\images\\machine_1', + ], + ]; + } + + /** + * @param array> $nodes + * @param array> $edges + * @param array $workspace + */ + private function appendGatewayNodesAndEdges(array &$nodes, array &$edges, array $workspace): void + { + $relayServices = $this->relayServicesFromWorkspace($workspace); + + foreach ((array)($workspace['gateways'] ?? []) as $index => $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = $this->gatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } + + $nodeId = $this->gatewayNodeId($gateway); + $nodes[] = $this->node($nodeId, 'default', (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'edge_gateway', [ + 'object_id' => $gatewayId, + 'raw' => $gateway, + 'subtitle' => (string)($gateway['status'] ?? 'UNKNOWN'), + ], 1440, 160 + ($index * 150)); + + foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $bindingServices = $this->bindingServices($binding, $relayId, $relayServices); + if ($bindingServices !== []) { + $binding['services'] = $bindingServices; + if (trim((string)($binding['role'] ?? '')) === '' && count($bindingServices) === 1) { + $binding['role'] = $bindingServices[0]; + } + } + $bindingId = $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding); + $nodes[] = $this->node($bindingId, 'default', (string)($binding['label'] ?? ('Relay ' . $relayId)), 'relay_binding', [ + 'object_id' => $relayId, + 'raw' => $binding, + 'subtitle' => (string)($binding['role'] ?? 'Relay binding'), + ], 1720, 180 + (($index * 4 + $bindingIndex) * 100)); + $edges[] = $this->edge('gateway-binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $nodeId, $bindingId, 'gateway_binding', 'binds'); + $edges[] = $this->edge('binding-relay:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $bindingId, 'relay:' . $relayId, 'relay_binding', 'controls'); + } + } + + $relayIndex = 0; + foreach ((array)($workspace['relays'] ?? []) as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $nodes[] = $this->node('relay:' . $relayId, 'default', (string)($relay['name'] ?? ('Relay ' . $relayId)), 'relay', [ + 'object_id' => $relayId, + 'raw' => $relay, + 'subtitle' => 'Hardware relay', + ], 2020, 180 + ($relayIndex * 100)); + $relayIndex++; + } + + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + $laneId = (int)($lane['id'] ?? 0); + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($laneId > 0 && $relayId !== '') { + $edges[] = $this->edge('relay-lane:' . $relayId . ':' . $laneId . ':' . (string)($slot['slot'] ?? ''), 'relay:' . $relayId, 'lane:' . $laneId, 'lane_relay', (string)($slot['slot'] ?? 'relay')); + } + } + } + } + + /** + * @param array> $edges + * @param array> $tasks + * @param array $workspace + */ + private function appendTaskServiceEdges(array &$edges, array $tasks, array $workspace): void + { + $bindingsByService = []; + foreach ($this->gatewayBindingReferences($workspace) as $binding) { + foreach ((array)$binding['services'] as $service) { + $bindingsByService[$service][] = $binding; + } + } + + foreach ($tasks as $task) { + $taskId = (int)($task['id'] ?? 0); + if ($taskId <= 0) { + continue; + } + foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { + foreach ($bindingsByService[$service] ?? [] as $binding) { + $edges[] = $this->edge( + 'task-service:' . $taskId . ':' . $service . ':' . $binding['gateway_id'] . ':' . $binding['relay_id'] . ':' . $binding['binding_index'], + 'task:' . $taskId, + (string)$binding['node_id'], + 'task_service', + $service + ); + } + } + } + } + + /** + * @param array> $edges + * @param array> $actions + * @param array $workspace + */ + private function appendActionRelayEdges(array &$edges, array $actions, array $workspace): void + { + foreach ($actions as $action) { + if (!is_array($action)) { + continue; + } + $normalizedAction = selfserve_studio_actions::normalize($action); + $actionId = (int)($normalizedAction['id'] ?? 0); + if ($actionId <= 0) { + continue; + } + + $relayRole = $this->normalizeServiceName(selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation'])); + if ($relayRole === '' || $relayRole === 'ACTION') { + continue; + } + + $laneId = (int)($normalizedAction['lane'] ?? 0); + foreach ($this->actionRelayTargets($workspace, $relayRole, $laneId) as $target) { + $edges[] = $this->edge( + 'action-relay:' . $actionId . ':' . $target['relay_id'] . ':' . $relayRole . ':' . $target['lane_id'], + 'action:' . $actionId, + 'relay:' . $target['relay_id'], + 'action_relay', + $relayRole + ); + } + } + } + + /** + * @param array $workspace + * @return array + */ + private function actionRelayTargets(array $workspace, string $relayRole, int $actionLaneId): array + { + $targets = []; + $seen = []; + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + $laneId = (int)($lane['id'] ?? 0); + if ($laneId <= 0 || ($actionLaneId > 0 && $laneId !== $actionLaneId)) { + continue; + } + + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $slotRole = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''); + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($relayId === '' || $slotRole !== $relayRole) { + continue; + } + + $key = $laneId . ':' . $relayRole . ':' . $relayId; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $targets[] = [ + 'relay_id' => $relayId, + 'lane_id' => $laneId, + ]; + } + } + + return $targets; + } + + /** + * @param array $config + * @param array $operation + */ + private function applyConfigOperation(int $departmentId, array &$config, array $operation, array $permissions = []): void + { + $action = strtolower((string)($operation['action'] ?? '')); + $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? '')); + $data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : []; + $id = (int)($operation['id'] ?? $data['id'] ?? 0); + + if ($action === 'connect') { + $this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false); + return; + } + if ($action === 'disconnect') { + $this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true); + return; + } + if ($action === 'reorder') { + $this->applyConfigReorder($config, $entity, (array)($operation['items'] ?? [])); + return; + } + if ($action === 'upsert_path' || ($entity === 'path' && $action === 'upsert')) { + $this->upsertConfigPath($departmentId, $config, $data); + return; + } + if ($entity === '') { + throw new \RuntimeException('Studio graph operation is missing entity.'); + } + if ($entity === 'lane') { + $this->applyLaneOperation($departmentId, $action, $id, $data, $permissions); + return; + } + if ($entity === 'rule') { + throw new \RuntimeException('Standalone rule operations are not supported in self-serve rules v2.'); + } + + if ($action === 'create') { + $this->createConfigEntity($departmentId, $config, $entity, $data); + return; + } + if ($id <= 0) { + throw new \RuntimeException('Studio graph operation is missing id.'); + } + if ($action === 'update') { + $this->updateConfigEntity($departmentId, $config, $entity, $id, $data); + return; + } + if ($action === 'delete') { + $this->deleteConfigEntity($config, $entity, $id); + return; + } + + throw new \RuntimeException('Unsupported studio graph operation: ' . $action); + } + + /** + * @param array $config + * @param array $data + */ + private function upsertConfigPath(int $departmentId, array &$config, array $data): void + { + $scope = $this->normalizePathEditorScope($departmentId, is_array($data['scope'] ?? null) ? (array)$data['scope'] : $data); + $answers = $this->normalizePathEditorAnswers($data['answers'] ?? []); + if ($answers === []) { + throw new \RuntimeException('Path editor operation requires at least one answer.'); + } + $this->assertPathEditorQuestionsExist($config, $answers); + + $result = $this->normalizePathEditorResult(is_array($data['result'] ?? null) ? (array)$data['result'] : $data); + $previousPathKey = trim((string)($data['previous_path_key'] ?? '')); + $pathKey = trim((string)($data['path_key'] ?? '')); + if ($pathKey === '') { + $pathKey = $previousPathKey !== '' ? $previousPathKey : $this->pathEditorPathKey($scope, $answers); + } + + if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) { + $config['v2_meta'] = []; + } + if (!isset($config['v2_meta']['path_editor']) || !is_array($config['v2_meta']['path_editor'])) { + $config['v2_meta']['path_editor'] = []; + } + if (!isset($config['v2_meta']['path_editor']['paths']) || !is_array($config['v2_meta']['path_editor']['paths'])) { + $config['v2_meta']['path_editor']['paths'] = []; + } + if ($previousPathKey !== '' && $previousPathKey !== $pathKey && isset($config['v2_meta']['path_editor']['paths'][$previousPathKey])) { + $config['v2_meta']['path_editor']['paths'][$pathKey] = $config['v2_meta']['path_editor']['paths'][$previousPathKey]; + unset($config['v2_meta']['path_editor']['paths'][$previousPathKey]); + } + + $paths = &$config['v2_meta']['path_editor']['paths']; + $existing = is_array($paths[$pathKey] ?? null) ? (array)$paths[$pathKey] : []; + $conditionId = $this->nullableInt($existing['condition_id'] ?? $data['condition_id'] ?? $data['existing_condition_id'] ?? null); + $existingTaskIds = $this->pathEditorExistingTaskIds($existing, $data); + $conditionId = $this->upsertPathEditorCondition($departmentId, $config, $pathKey, $scope, $answers, $result, $conditionId); + + $taskIds = []; + if ((bool)$result['machine_allowed']) { + $baseOrderPriority = $this->pathEditorTaskBaseOrderPriority($config, $existingTaskIds); + foreach (array_values((array)($result['tasks'] ?? [])) as $index => $taskResult) { + if (!is_array($taskResult)) { + continue; + } + $taskIds[] = $this->upsertPathEditorTask( + $departmentId, + $config, + $pathKey, + $scope, + $taskResult, + $conditionId, + $existingTaskIds[$index] ?? null, + $baseOrderPriority + ($index * 10) + ); + } + foreach (array_slice($existingTaskIds, count($taskIds)) as $staleTaskId) { + $this->deleteConfigEntity($config, 'task', $staleTaskId); + } + } else { + foreach ($existingTaskIds as $staleTaskId) { + $this->deleteConfigEntity($config, 'task', $staleTaskId); + } + } + + $taskId = $taskIds[0] ?? null; + $pathSignature = $this->pathSignature($scope, $answers); + $resultSignature = $this->pathEditorResultSignature($result, $taskIds); + $paths[$pathKey] = [ + 'path_key' => $pathKey, + 'condition_id' => $conditionId, + 'task_id' => $taskId, + 'task_ids' => $taskIds, + 'scope' => $scope, + 'answers' => $answers, + 'result' => $result, + 'path_signature' => $pathSignature, + 'result_signature' => $resultSignature, + 'updated_at' => date('c'), + ]; + unset($paths); + } + + /** + * @param array $scope + * @return array + */ + private function normalizePathEditorScope(int $departmentId, array $scope): array + { + return [ + 'department_id' => $departmentId, + 'lane_id' => $this->nullableInt($scope['lane_id'] ?? $scope['lane'] ?? null), + 'vehicle_type_id' => $this->nullableInt($scope['vehicle_type_id'] ?? $scope['product'] ?? $scope['product_id'] ?? null), + 'machine_type_id' => $this->nullableInt($scope['machine_type_id'] ?? null), + 'config_source' => strtolower(trim((string)($scope['config_source'] ?? 'draft'))) ?: 'draft', + 'hardware_mode' => strtolower(trim((string)($scope['hardware_mode'] ?? 'studio'))) ?: 'studio', + ]; + } + + /** + * @return array + */ + private function normalizePathEditorAnswers(mixed $answers): array + { + $rows = []; + if (!is_array($answers)) { + return $rows; + } + + foreach ($answers as $key => $entry) { + if (is_array($entry)) { + $questionId = (int)($entry['question_id'] ?? $entry['id'] ?? $key); + $rawValue = $entry['value'] ?? $entry['answer'] ?? null; + } else { + $questionId = (int)$key; + $rawValue = $entry; + } + if ($questionId <= 0) { + continue; + } + $value = filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($value === null) { + continue; + } + $rows[] = [ + 'question_id' => $questionId, + 'value' => (bool)$value, + 'answer' => (bool)$value, + 'answer_label' => (bool)$value ? 'Yes' : 'No', + ]; + } + + return $rows; + } + + /** + * @param array $config + * @param array $answers + */ + private function assertPathEditorQuestionsExist(array $config, array $answers): void + { + $questionIds = []; + foreach ((array)($config['questions'] ?? []) as $question) { + if (is_array($question)) { + $questionIds[(int)($question['id'] ?? 0)] = true; + } + } + foreach ($answers as $answer) { + $questionId = (int)($answer['question_id'] ?? 0); + if ($questionId > 0 && !isset($questionIds[$questionId])) { + throw new \RuntimeException('Path editor answer references unknown question ' . $questionId . '.'); + } + } + } + + /** + * @param array $result + * @return array + */ + private function normalizePathEditorResult(array $result): array + { + $machineAllowed = filter_var($result['machine_allowed'] ?? $result['allowed'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $machineAllowed = $machineAllowed !== false; + $services = $machineAllowed ? $this->normalizeServiceList($result['services'] ?? ['MACHINE']) : $this->normalizeServiceList($result['services'] ?? []); + if ($machineAllowed && !in_array('MACHINE', $services, true)) { + $services[] = 'MACHINE'; + sort($services); + } + + try { + $buttons = department_selfserve_tasks_o::normalizeButtonsInput($result['buttons'] ?? []); + } catch (\Throwable $exception) { + throw new \RuntimeException('Invalid path editor buttons: ' . $exception->getMessage()); + } + + $dynamicImagesVehicleType = $this->nullableInt($result['dynamic_images_vehicle_type'] ?? null); + $hasTaskList = array_key_exists('tasks', $result) && is_array($result['tasks']); + $tasks = []; + if ($machineAllowed) { + if ($hasTaskList) { + foreach (array_values((array)$result['tasks']) as $index => $task) { + if (!is_array($task)) { + continue; + } + $tasks[] = $this->normalizePathEditorTaskResult((array)$task, $services, $index); + } + } else { + $tasks[] = $this->normalizePathEditorTaskResult([ + 'task' => $result['task'] ?? $result['task_text'] ?? 'Start machine', + 'description' => $result['description'] ?? '', + 'services' => $services, + 'buttons' => $buttons, + 'dynamic_images_vehicle_type' => $dynamicImagesVehicleType, + ], $services, 0); + } + } + + if ($hasTaskList) { + $buttons = $this->flattenPathEditorTaskButtons($tasks); + $services = $this->mergePathEditorTaskServices($services, $tasks, $machineAllowed); + if ($dynamicImagesVehicleType === null) { + foreach ($tasks as $task) { + $dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null); + if ($dynamicImagesVehicleType !== null) { + break; + } + } + } + } + + return [ + 'machine_allowed' => $machineAllowed, + 'task' => trim((string)($result['task'] ?? $result['task_text'] ?? 'Start machine')) ?: 'Start machine', + 'description' => trim((string)($result['description'] ?? '')), + 'services' => $services, + 'buttons' => $buttons, + 'dynamic_images_vehicle_type' => $dynamicImagesVehicleType, + 'tasks' => $tasks, + 'condition_name' => trim((string)($result['condition_name'] ?? '')), + ]; + } + + /** + * @param array $task + * @param array $fallbackServices + * @return array + */ + private function normalizePathEditorTaskResult(array $task, array $fallbackServices, int $index): array + { + try { + $buttons = department_selfserve_tasks_o::normalizeButtonsInput($task['buttons'] ?? []); + } catch (\Throwable $exception) { + throw new \RuntimeException('Invalid path editor task buttons: ' . $exception->getMessage()); + } + + $services = $this->normalizeServiceList($task['services'] ?? $fallbackServices); + if (!in_array('MACHINE', $services, true)) { + $services[] = 'MACHINE'; + } + + $dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null); + if (($dynamicImagesVehicleType !== null || in_array('program_picker', $buttons, true)) && !in_array('PROGRAM_PICKER', $services, true)) { + $services[] = 'PROGRAM_PICKER'; + } + sort($services); + + return [ + 'task' => trim((string)($task['task'] ?? $task['label'] ?? ('Task ' . ($index + 1)))) ?: ('Task ' . ($index + 1)), + 'description' => trim((string)($task['description'] ?? '')), + 'services' => $services, + 'buttons' => $buttons, + 'dynamic_images_vehicle_type' => $dynamicImagesVehicleType, + ]; + } + + /** + * @param array> $tasks + * @return array + */ + private function flattenPathEditorTaskButtons(array $tasks): array + { + $buttons = []; + foreach ($tasks as $task) { + if (!is_array($task)) { + continue; + } + foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) { + $buttons[] = $button; + } + } + + return department_selfserve_tasks_o::normalizeButtonsInput($buttons); + } + + /** + * @param array $services + * @param array> $tasks + * @return array + */ + private function mergePathEditorTaskServices(array $services, array $tasks, bool $machineAllowed): array + { + $merged = []; + foreach ($this->normalizeServiceList($services) as $service) { + $merged[$service] = true; + } + foreach ($tasks as $task) { + if (!is_array($task)) { + continue; + } + foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { + $merged[$service] = true; + } + } + if ($machineAllowed) { + $merged['MACHINE'] = true; + } + + $values = array_keys($merged); + sort($values); + return $values; + } + + /** + * @param array $scope + * @param array> $answers + */ + private function pathEditorPathKey(array $scope, array $answers): string + { + return 'path_' . substr(hash('sha256', $this->stableJson([ + 'scope' => $scope, + 'answers' => $answers, + ])), 0, 16); + } + + /** + * @param array $config + * @param array $scope + * @param array> $answers + * @param array $result + */ + private function upsertPathEditorCondition(int $departmentId, array &$config, string $pathKey, array $scope, array $answers, array $result, ?int $conditionId): int + { + if ($conditionId === null || $this->configRowIndex((array)($config['conditions'] ?? []), $conditionId) === null) { + $conditionId = $this->allocateConfigId($config, 'condition'); + $rows = &$this->configRows($config, 'condition'); + $rows[] = [ + 'id' => $conditionId, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'name' => 'Generated path condition', + 'description' => '', + 'expression' => $this->emptyV2Expression(), + ]; + unset($rows); + } + + $name = trim((string)($result['condition_name'] ?? '')); + if ($name === '') { + $name = 'Path: ' . $this->pathEditorAnswerSummary($answers); + } + $rows = &$this->configRows($config, 'condition'); + $index = $this->configRowIndex($rows, $conditionId); + if ($index === null) { + throw new \RuntimeException('Generated path condition could not be created.'); + } + $rows[$index] = $this->mergeConfigEntityData('condition', $rows[$index], [ + 'department' => $departmentId, + 'lane' => $scope['lane_id'] ?? 0, + 'product' => $scope['vehicle_type_id'] ?? 0, + 'machine_type_id' => $scope['machine_type_id'] ?? null, + 'name' => $name, + 'description' => 'Generated by Path Editor for ' . $this->pathEditorAnswerSummary($answers), + 'expression' => $this->pathEditorConditionExpression($answers), + ]); + $rows[$index]['generated_by'] = 'path_editor'; + $rows[$index]['path_key'] = $pathKey; + unset($rows); + + return $conditionId; + } + + /** + * @param array $config + * @param array $scope + * @param array $result + */ + private function upsertPathEditorTask(int $departmentId, array &$config, string $pathKey, array $scope, array $result, int $conditionId, ?int $taskId, int $orderPriority): int + { + if ($taskId === null || $this->configRowIndex((array)($config['tasks'] ?? []), $taskId) === null) { + $taskId = $this->allocateConfigId($config, 'task'); + $rows = &$this->configRows($config, 'task'); + $rows[] = [ + 'id' => $taskId, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'gate_type' => selfserve_task_gate_type::CONDITION->value, + 'gate_ref_id' => $conditionId, + 'task' => 'Start machine', + 'description' => '', + 'order_priority' => $orderPriority, + 'services' => [], + 'buttons' => [], + 'dynamic_images_vehicle_type' => null, + ]; + unset($rows); + } + + $rows = &$this->configRows($config, 'task'); + $index = $this->configRowIndex($rows, $taskId); + if ($index === null) { + throw new \RuntimeException('Generated path task could not be created.'); + } + $rows[$index] = $this->mergeConfigEntityData('task', $rows[$index], [ + 'department' => $departmentId, + 'lane' => $scope['lane_id'] ?? 0, + 'product' => $scope['vehicle_type_id'] ?? 0, + 'machine_type_id' => $scope['machine_type_id'] ?? null, + 'condition_id' => $conditionId, + 'gate_type' => selfserve_task_gate_type::CONDITION->value, + 'gate_ref_id' => $conditionId, + 'task' => $result['task'], + 'description' => $result['description'], + 'order_priority' => $orderPriority, + 'services' => $result['services'], + 'buttons' => $result['buttons'], + 'dynamic_images_vehicle_type' => $result['dynamic_images_vehicle_type'], + ]); + $rows[$index]['condition_id'] = $conditionId; + $rows[$index]['generated_by'] = 'path_editor'; + $rows[$index]['path_key'] = $pathKey; + unset($rows); + + return $taskId; + } + + /** + * @param array $existing + * @param array $data + * @return array + */ + private function pathEditorExistingTaskIds(array $existing, array $data): array + { + $ids = []; + foreach ([$existing['task_ids'] ?? null, $data['task_ids'] ?? null] as $taskIds) { + if (!is_array($taskIds)) { + continue; + } + foreach ($taskIds as $taskId) { + $normalizedTaskId = $this->nullableInt($taskId); + if ($normalizedTaskId !== null) { + $ids[] = $normalizedTaskId; + } + } + } + + foreach ([ + $existing['task_id'] ?? null, + $data['task_id'] ?? null, + $data['existing_task_id'] ?? null, + ] as $taskId) { + $normalizedTaskId = $this->nullableInt($taskId); + if ($normalizedTaskId !== null) { + $ids[] = $normalizedTaskId; + } + } + + return array_values(array_unique(array_filter($ids, static fn(int $taskId): bool => $taskId > 0))); + } + + /** + * @param array $config + * @param array $taskIds + */ + private function pathEditorTaskBaseOrderPriority(array $config, array $taskIds): int + { + $taskRows = (array)($config['tasks'] ?? []); + foreach ($taskIds as $taskId) { + $index = $this->configRowIndex($taskRows, $taskId); + if ($index !== null && is_array($taskRows[$index] ?? null)) { + return (int)($taskRows[$index]['order_priority'] ?? 0); + } + } + + return $this->nextOrderPriority($taskRows); + } + + /** + * @param array> $answers + * @return array + */ + private function pathEditorConditionExpression(array $answers): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => array_values(array_map(static fn(array $answer): array => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => (int)($answer['question_id'] ?? 0), + 'operator' => ((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'IS_TRUE' : 'IS_FALSE', + ], $answers)), + ]; + } + + /** + * @param array> $answers + */ + private function pathEditorAnswerSummary(array $answers): string + { + $parts = []; + foreach ($answers as $answer) { + $parts[] = 'Q' . (int)($answer['question_id'] ?? 0) . '=' . (((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'Yes' : 'No'); + } + return implode(', ', $parts) ?: 'answers'; + } + + /** + * @param array $scope + * @param array> $answers + */ + private function pathSignature(array $scope, array $answers): string + { + return hash('sha256', $this->stableJson([ + 'scope' => $this->pathScopeKey($scope), + 'answers' => array_values(array_map(static fn(array $answer): array => [ + 'question_id' => (int)($answer['question_id'] ?? 0), + 'answer' => (bool)($answer['answer'] ?? $answer['value'] ?? false), + ], $answers)), + ])); + } + + /** + * @param array $result + */ + private function pathEditorResultSignature(array $result, array $taskIds): string + { + $tasks = []; + if ((bool)($result['machine_allowed'] ?? false)) { + foreach (array_values((array)($result['tasks'] ?? [])) as $index => $task) { + if (!is_array($task)) { + continue; + } + $tasks[] = [ + 'id' => (int)($taskIds[$index] ?? 0), + 'label' => (string)($task['task'] ?? $task['label'] ?? ''), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'dynamic_images_vehicle_type' => $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null), + ]; + } + } + + return hash('sha256', $this->stableJson([ + 'allowed' => (bool)($result['machine_allowed'] ?? false), + 'services' => $this->normalizeServiceList($result['services'] ?? []), + 'tasks' => $tasks, + 'buttons' => $this->normalizeArrayPayload($result['buttons'] ?? []), + 'signals' => [], + ])); + } + + /** + * @param array $config + * @param array $data + */ + private function createConfigEntity(int $departmentId, array &$config, string $entity, array $data): void + { + $id = $this->allocateConfigId($config, $entity); + $row = match ($entity) { + 'question' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'condition_id' => null, + 'question' => 'New question', + 'description' => '', + 'order_priority' => $this->nextOrderPriority((array)($config['questions'] ?? [])), + ], + 'condition' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'name' => 'New condition', + 'description' => '', + 'expression' => $this->emptyV2Expression(), + ], + 'task' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'gate_type' => selfserve_task_gate_type::ALWAYS->value, + 'gate_ref_id' => null, + 'task' => 'New task', + 'description' => '', + 'order_priority' => $this->nextOrderPriority((array)($config['tasks'] ?? [])), + 'services' => [], + 'buttons' => [], + 'dynamic_images_vehicle_type' => null, + ], + 'action' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'name' => 'Open lane entrance port', + 'description' => '', + 'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND, + 'wash_mode' => selfserve_studio_actions::MODE_BOTH, + 'operation' => selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT, + 'relay_state' => null, + 'enabled' => true, + 'order_priority' => $this->nextOrderPriority((array)($config['actions'] ?? [])), + 'options' => [ + 'delay_ms' => 0, + 'toggle_after_seconds' => 1, + 'retry_count' => 0, + 'failure_policy' => selfserve_studio_actions::FAILURE_CONTINUE, + 'record_event' => true, + ], + ], + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + + $row = $this->mergeConfigEntityData($entity, $row, $data); + $rows = &$this->configRows($config, $entity); + $rows[] = $row; + } + + /** + * @param array $config + * @param array $data + */ + private function updateConfigEntity(int $departmentId, array &$config, string $entity, int $id, array $data): void + { + unset($departmentId); + $rows = &$this->configRows($config, $entity); + $index = $this->configRowIndex($rows, $id); + if ($index === null) { + throw new \RuntimeException(ucfirst($entity) . ' is not available in the current draft.'); + } + + $rows[$index] = $this->mergeConfigEntityData($entity, $rows[$index], $data); + } + + /** + * @param array $config + */ + private function deleteConfigEntity(array &$config, string $entity, int $id): void + { + $rows = &$this->configRows($config, $entity); + $rows = array_values(array_filter($rows, static fn(array $row): bool => (int)($row['id'] ?? 0) !== $id)); + + if ($entity === 'question') { + $conditionRows = &$this->configRows($config, 'condition'); + foreach ($conditionRows as &$condition) { + if (is_array($condition) && is_array($condition['expression'] ?? null)) { + $condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'question', $id); + } + } + unset($condition); + $taskRows = &$this->configRows($config, 'task'); + foreach ($taskRows as &$task) { + if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::QUESTION->value && (int)($task['gate_ref_id'] ?? 0) === $id) { + $task['gate_type'] = selfserve_task_gate_type::ALWAYS->value; + $task['gate_ref_id'] = null; + $task['condition_id'] = null; + } + } + unset($task); + } + + if ($entity === 'condition') { + $conditionRows = &$this->configRows($config, 'condition'); + foreach ($conditionRows as &$condition) { + if (!is_array($condition)) { + continue; + } + if ((int)($condition['condition_id'] ?? 0) === $id) { + $condition['condition_id'] = null; + } + if (is_array($condition['expression'] ?? null)) { + $condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'condition', $id); + } + } + unset($condition); + $questionRows = &$this->configRows($config, 'question'); + foreach ($questionRows as &$question) { + if (is_array($question) && (int)($question['condition_id'] ?? 0) === $id) { + $question['condition_id'] = null; + } + } + unset($question); + $taskRows = &$this->configRows($config, 'task'); + foreach ($taskRows as &$task) { + if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::CONDITION->value && (int)($task['gate_ref_id'] ?? 0) === $id) { + $task['gate_type'] = selfserve_task_gate_type::ALWAYS->value; + $task['gate_ref_id'] = null; + $task['condition_id'] = null; + } + } + unset($task); + $actionRows = &$this->configRows($config, 'action'); + foreach ($actionRows as &$action) { + if (is_array($action) && (int)($action['condition_id'] ?? 0) === $id) { + $action['condition_id'] = null; + } + } + unset($action); + } + } + + /** + * @param array $config + * @param array> $items + */ + private function applyConfigReorder(array &$config, string $entity, array $items): void + { + if (!in_array($entity, ['question', 'task', 'action'], true)) { + throw new \RuntimeException('Only questions, tasks, and actions can be reordered.'); + } + + $priorities = []; + foreach ($items as $index => $item) { + if (is_array($item)) { + $priorities[(int)($item['id'] ?? 0)] = (int)($item['order_priority'] ?? $index); + } + } + + $rows = &$this->configRows($config, $entity); + foreach ($rows as &$row) { + $id = (int)($row['id'] ?? 0); + if (isset($priorities[$id])) { + $row['order_priority'] = $priorities[$id]; + } + } + unset($row); + } + + /** + * @param array $config + */ + private function applyConfigConnection(int $departmentId, array &$config, string $source, string $target, bool $disconnect): void + { + [$sourceType, $sourceIdRaw] = $this->parseNodeId($source); + [$targetType, $targetIdRaw] = $this->parseNodeId($target); + if ($sourceType === '' || $targetType === '' || $sourceIdRaw === '') { + throw new \RuntimeException('Invalid connection endpoints.'); + } + + if ($sourceType === 'task' && $targetType === 'binding') { + $service = $this->serviceForBindingNode($departmentId, $target); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateConfigTaskServiceConnection($config, (int)$sourceIdRaw, $service, $disconnect); + return; + } + if ($sourceType === 'binding' && $targetType === 'task') { + $service = $this->serviceForBindingNode($departmentId, $source); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateConfigTaskServiceConnection($config, (int)$targetIdRaw, $service, $disconnect); + return; + } + + $sourceId = (int)$sourceIdRaw; + $targetId = (int)$targetIdRaw; + if ($sourceId <= 0 || $targetId <= 0) { + throw new \RuntimeException('Invalid connection endpoints.'); + } + + if (in_array($sourceType, ['question', 'condition'], true) && $targetType === 'condition') { + if ($sourceType === 'condition' && $sourceId === $targetId && !$disconnect) { + throw new \RuntimeException('Condition expressions cannot reference themselves.'); + } + $this->updateConditionExpressionConnection($config, $targetId, $sourceType, $sourceId, $disconnect); + return; + } + + if ($sourceType === 'condition' && $targetType === 'question') { + $rows = &$this->configRows($config, 'question'); + $index = $this->configRowIndex($rows, $targetId); + if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) { + $rows[$index]['condition_id'] = $disconnect ? null : $sourceId; + } + return; + } + + if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') { + $rows = &$this->configRows($config, 'task'); + $index = $this->configRowIndex($rows, $targetId); + if ($index === null) { + return; + } + $currentType = strtoupper((string)($rows[$index]['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $currentRef = (int)($rows[$index]['gate_ref_id'] ?? 0); + if ($disconnect && ($currentType !== strtoupper($sourceType) || $currentRef !== $sourceId)) { + return; + } + $rows[$index]['gate_type'] = $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType); + $rows[$index]['gate_ref_id'] = $disconnect ? null : $sourceId; + $rows[$index]['condition_id'] = (!$disconnect && $sourceType === 'question') ? $sourceId : null; + return; + } + + if ($sourceType === 'condition' && $targetType === 'action') { + $rows = &$this->configRows($config, 'action'); + $index = $this->configRowIndex($rows, $targetId); + if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) { + $rows[$index]['condition_id'] = $disconnect ? null : $sourceId; + } + return; + } + } + + /** + * @param array $config + */ + private function updateConditionExpressionConnection(array &$config, int $conditionId, string $subjectType, int $subjectId, bool $disconnect): void + { + $rows = &$this->configRows($config, 'condition'); + $index = $this->configRowIndex($rows, $conditionId); + if ($index === null) { + throw new \RuntimeException('Condition is not available in the current draft.'); + } + + $expression = $this->normalizeExpressionNode($rows[$index]['expression'] ?? $this->emptyV2Expression()); + if (($expression['type'] ?? 'group') === 'predicate') { + $expression = [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [$expression], + ]; + } elseif (($expression['type'] ?? 'group') !== 'group') { + $expression = [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [$expression], + ]; + } + if ($disconnect) { + $rows[$index]['expression'] = $this->removeExpressionPredicate($expression, $subjectType, $subjectId); + return; + } + + if (!$this->expressionHasPredicate($expression, $subjectType, $subjectId)) { + $expression['children'][] = [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'operator' => 'IS_TRUE', + ]; + } + $rows[$index]['expression'] = $expression; + } + + /** + * @param array $config + */ + private function updateConfigTaskServiceConnection(array &$config, int $taskId, string $service, bool $disconnect): void + { + if ($taskId <= 0 || $service === '') { + return; + } + + $rows = &$this->configRows($config, 'task'); + $index = $this->configRowIndex($rows, $taskId); + if ($index === null) { + throw new \RuntimeException('Task is not available in the current draft.'); + } + + $services = array_fill_keys($this->normalizeServiceList($rows[$index]['services'] ?? []), true); + if ($disconnect) { + unset($services[$service]); + } else { + $services[$service] = true; + } + $rows[$index]['services'] = array_keys($services); + } + + /** + * @param array $config + * @return array> + */ + private function &configRows(array &$config, string $entity): array + { + $key = match ($entity) { + 'question' => 'questions', + 'condition' => 'conditions', + 'task' => 'tasks', + 'action' => 'actions', + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + if (!isset($config[$key]) || !is_array($config[$key])) { + $config[$key] = []; + } + return $config[$key]; + } + + /** + * @param array> $rows + */ + private function configRowIndex(array $rows, int $id): ?int + { + foreach ($rows as $index => $row) { + if ((int)($row['id'] ?? 0) === $id) { + return (int)$index; + } + } + return null; + } + + /** + * @param array $config + */ + private function allocateConfigId(array &$config, string $entity): int + { + $name = match ($entity) { + 'question', 'condition', 'task', 'action' => $entity, + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) { + $config['v2_meta'] = []; + } + if (!isset($config['v2_meta']['next_ids']) || !is_array($config['v2_meta']['next_ids'])) { + $config['v2_meta']['next_ids'] = []; + } + + $rows = &$this->configRows($config, $entity); + $maxId = 0; + foreach ($rows as $row) { + $maxId = max($maxId, (int)($row['id'] ?? 0)); + } + + $nextId = max((int)($config['v2_meta']['next_ids'][$name] ?? 0), $maxId + 1); + $config['v2_meta']['next_ids'][$name] = $nextId + 1; + return $nextId; + } + + /** + * @param array> $rows + */ + private function nextOrderPriority(array $rows): int + { + $max = 0; + foreach ($rows as $row) { + $max = max($max, (int)($row['order_priority'] ?? 0)); + } + return $max + 10; + } + + /** + * @param array $row + * @param array $data + * @return array + */ + private function mergeConfigEntityData(string $entity, array $row, array $data): array + { + if (array_key_exists('label', $data)) { + if ($entity === 'question' && !array_key_exists('question', $data)) { + $data['question'] = $data['label']; + } elseif ($entity === 'condition' && !array_key_exists('name', $data)) { + $data['name'] = $data['label']; + } elseif ($entity === 'task' && !array_key_exists('task', $data)) { + $data['task'] = $data['label']; + } elseif ($entity === 'action' && !array_key_exists('name', $data)) { + $data['name'] = $data['label']; + } + } + + $fields = match ($entity) { + 'question' => ['department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority'], + 'condition' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'expression'], + 'task' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'], + 'action' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'event', 'wash_mode', 'operation', 'relay_state', 'enabled', 'order_priority', 'options'], + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + + foreach ($fields as $field) { + if (!array_key_exists($field, $data)) { + continue; + } + $row[$field] = $this->normalizeConfigField($field, $data[$field]); + } + + if ($entity === 'condition' && !is_array($row['expression'] ?? null)) { + $row['expression'] = $this->emptyV2Expression(); + } + if ($entity === 'task') { + $row['gate_type'] = $this->normalizeGateType((string)($row['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + if ($row['gate_type'] === selfserve_task_gate_type::ALWAYS->value) { + $row['gate_ref_id'] = null; + $row['condition_id'] = null; + } + } + if ($entity === 'action') { + $row = selfserve_studio_actions::normalize($row); + } + + return $row; + } + + private function normalizeConfigField(string $field, mixed $value): mixed + { + if ($field === 'expression') { + return $this->normalizeExpressionNode($value); + } + if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type', 'toggle_after_seconds'], true)) { + return $this->nullableInt($value); + } + if (in_array($field, ['department', 'lane', 'product', 'order_priority', 'delay_ms', 'retry_count'], true)) { + return (int)$value; + } + if (in_array($field, ['enabled', 'relay_state', 'record_event'], true)) { + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + if ($field === 'services') { + return $this->normalizeServiceList($value); + } + if ($field === 'buttons') { + return $this->normalizeArrayPayload($value); + } + if ($field === 'options') { + return is_array($value) ? (array)$value : []; + } + if ($field === 'gate_type') { + return $this->normalizeGateType((string)$value); + } + return is_array($value) ? $value : (string)$value; + } + + /** + * @param array $operation + */ + private function applyOperation(int $departmentId, array $operation, array $permissions = []): void + { + $action = strtolower((string)($operation['action'] ?? '')); + $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? '')); + $data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : []; + $id = (int)($operation['id'] ?? $data['id'] ?? 0); + + if ($action === 'connect') { + $this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false); + return; + } + if ($action === 'disconnect') { + $this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true); + return; + } + if ($action === 'reorder') { + $this->applyReorder($entity, (array)($operation['items'] ?? [])); + return; + } + if ($entity === '') { + throw new \RuntimeException('Studio graph operation is missing entity.'); + } + if ($entity === 'lane') { + $this->applyLaneOperation($departmentId, $action, $id, $data, $permissions); + return; + } + + if ($action === 'create') { + $this->createEntity($departmentId, $entity, $data); + return; + } + if ($id <= 0) { + throw new \RuntimeException('Studio graph operation is missing id.'); + } + if ($action === 'update') { + $this->updateEntity($departmentId, $entity, $id, $data); + return; + } + if ($action === 'delete') { + $this->softDeleteEntity($departmentId, $entity, $id); + return; + } + + throw new \RuntimeException('Unsupported studio graph operation: ' . $action); + } + + /** + * @param array $data + */ + private function applyLaneOperation(int $departmentId, string $action, int $id, array $data, array $permissions = []): void + { + if (!$this->tableExists('department_lanes')) { + throw new \RuntimeException('Department lanes are not available.'); + } + + $this->assertLaneOperationAuthorized($action, $data, $permissions); + + if ($action === 'create') { + $this->createLane($departmentId, $data); + return; + } + + if ($id <= 0) { + throw new \RuntimeException('Studio lane operation is missing id.'); + } + + if (!$this->laneBelongsToDepartment($id, $departmentId)) { + throw new \RuntimeException('Lane is not available in the selected department.'); + } + + if ($action === 'update') { + $this->updateLane($departmentId, $id, $data); + return; + } + + if ($action === 'delete') { + $this->softDeleteLane($departmentId, $id); + return; + } + + throw new \RuntimeException('Unsupported studio lane operation: ' . $action); + } + + /** + * @param array $data + * @param array $permissions + */ + private function assertLaneOperationAuthorized(string $action, array $data, array $permissions): void + { + if ($action === 'create' && !($permissions['can_add_department_lane'] ?? false)) { + throw new \RuntimeException('Missing permission: add_department_lane.'); + } + if (in_array($action, ['update', 'delete'], true) && !($permissions['can_edit_department_lane'] ?? false)) { + throw new \RuntimeException('Missing permission: edit_department_lane.'); + } + + if (!in_array($action, ['create', 'update'], true)) { + return; + } + + $relayFields = [ + 'relay_in_id', + 'relay_out_id', + 'relay_machine_id', + 'relay_machine_program_picker_id', + 'relay_machine_cleaner_id', + ]; + foreach ($relayFields as $field) { + if (array_key_exists($field, $data) && !($permissions['modules_shelly_config'] ?? false)) { + throw new \RuntimeException('Missing permission: modules_shelly_config.'); + } + } + } + + /** + * @param array $data + */ + private function createLane(int $departmentId, array $data): void + { + $fields = [ + 'department' => $departmentId, + 'name' => $this->normalizeLaneName((string)($data['name'] ?? $data['label'] ?? 'New lane')), + ]; + + foreach ($this->laneOptionalFields() as $field) { + if (array_key_exists($field, $data)) { + $fields[$field] = $this->normalizeLaneField($field, $data[$field]); + } + } + + $availableColumns = $this->tableColumns('department_lanes'); + $fields = array_filter( + $fields, + static fn(mixed $value, string $field): bool => in_array($field, $availableColumns, true), + ARRAY_FILTER_USE_BOTH + ); + + $columns = array_keys($fields); + $placeholders = array_map(static fn(string $field): string => ':' . $field, $columns); + $params = []; + foreach ($fields as $field => $value) { + $params[':' . $field] = $value; + } + + db::getPDO()->prepare( + 'INSERT INTO department_lanes (`' . implode('`, `', $columns) . '`) VALUES (' . implode(', ', $placeholders) . ')' + )->execute($params); + } + + /** + * @param array $data + */ + private function updateLane(int $departmentId, int $id, array $data): void + { + unset($departmentId); + $availableColumns = $this->tableColumns('department_lanes'); + $updates = []; + $params = [':id' => $id]; + $wasSelfServeEnabled = null; + + if (array_key_exists('selfserve_enabled', $data) && in_array('selfserve_enabled', $availableColumns, true)) { + $statement = db::getPDO()->prepare( + 'SELECT selfserve_enabled FROM department_lanes WHERE id = :id AND deleted_at IS NULL' + ); + $statement->execute([':id' => $id]); + $wasSelfServeEnabled = ((int)($statement->fetch(\PDO::FETCH_ASSOC)['selfserve_enabled'] ?? 1)) === 1; + } + + $fields = ['name', ...$this->laneOptionalFields()]; + foreach ($fields as $field) { + if (!array_key_exists($field, $data) || !in_array($field, $availableColumns, true)) { + continue; + } + $updates[] = '`' . $field . '` = :' . $field; + $params[':' . $field] = $field === 'name' + ? $this->normalizeLaneName((string)$data[$field]) + : $this->normalizeLaneField($field, $data[$field]); + } + + if ($updates === []) { + return; + } + + db::getPDO()->prepare( + 'UPDATE department_lanes SET ' . implode(', ', $updates) . ' WHERE id = :id AND deleted_at IS NULL' + )->execute($params); + + if ( + $wasSelfServeEnabled === true + && array_key_exists(':selfserve_enabled', $params) + && (int)$params[':selfserve_enabled'] === 0 + ) { + \objects\department_lanes_o::disableSelfServeRelaysBestEffort($id); + } + } + + private function softDeleteLane(int $departmentId, int $id): void + { + unset($departmentId); + db::getPDO()->prepare( + 'UPDATE department_lanes SET deleted_at = NOW() WHERE id = :id AND deleted_at IS NULL' + )->execute([':id' => $id]); + } + + /** + * @return array + */ + private function laneOptionalFields(): array + { + return [ + 'relay_in_id', + 'relay_out_id', + 'relay_machine_id', + 'relay_machine_program_picker_id', + 'relay_machine_cleaner_id', + 'dynamic_image_id', + 'machine_type_id', + 'selfserve_enabled', + ]; + } + + private function normalizeLaneField(string $field, mixed $value): mixed + { + if ($field === 'selfserve_enabled') { + return \objects\department_lanes_o::normalizeSelfServeEnabledValue($value) ? 1 : 0; + } + + if (in_array($field, ['dynamic_image_id', 'machine_type_id'], true)) { + return $this->nullableInt($value); + } + + $normalized = trim((string)($value ?? '')); + if ($normalized === '' || $normalized === '0' || strtolower($normalized) === 'null') { + return null; + } + + return $normalized; + } + + private function normalizeLaneName(string $name): string + { + $normalized = trim($name); + if ($normalized === '') { + throw new \RuntimeException('Lane name is required.'); + } + + return $normalized; + } + + /** + * @param array $data + */ + private function createEntity(int $departmentId, string $entity, array $data): void + { + $pdo = db::getPDO(); + if ($entity === 'question') { + $pdo->prepare( + "INSERT INTO department_selfserve_questions (department, lane, product, condition_id, question, description, order_priority) + VALUES (:department, :lane, :product, :condition_id, :question, :description, :order_priority)" + )->execute([ + ':department' => $departmentId, + ':lane' => (int)($data['lane'] ?? 0), + ':product' => (int)($data['product'] ?? 0), + ':condition_id' => $this->nullableInt($data['condition_id'] ?? null), + ':question' => (string)($data['question'] ?? $data['label'] ?? 'New question'), + ':description' => (string)($data['description'] ?? ''), + ':order_priority' => (int)($data['order_priority'] ?? 0), + ]); + return; + } + if ($entity === 'condition') { + $pdo->prepare( + "INSERT INTO department_selfserve_conditions (department, lane, product, machine_type_id, condition_id, name, description) + VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :name, :description)" + )->execute([ + ':department' => $departmentId, + ':lane' => (int)($data['lane'] ?? 0), + ':product' => (int)($data['product'] ?? 0), + ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null), + ':condition_id' => $this->nullableInt($data['condition_id'] ?? null), + ':name' => (string)($data['name'] ?? $data['label'] ?? 'New condition'), + ':description' => (string)($data['description'] ?? ''), + ]); + return; + } + if ($entity === 'rule') { + $conditionId = (int)($data['condition_id'] ?? 0); + if (!$this->conditionBelongsToDepartment($conditionId, $departmentId)) { + throw new \RuntimeException('Rule condition_id is not available in the selected department.'); + } + $pdo->prepare( + "INSERT INTO department_selfserve_condition_rules (condition_id, type, object_type, object_id, name, description) + VALUES (:condition_id, :type, :object_type, :object_id, :name, :description)" + )->execute([ + ':condition_id' => $conditionId, + ':type' => (string)($data['type'] ?? 'IS_TRUE'), + ':object_type' => (string)($data['object_type'] ?? 'question'), + ':object_id' => (int)($data['object_id'] ?? 0), + ':name' => (string)($data['name'] ?? $data['label'] ?? 'New rule'), + ':description' => (string)($data['description'] ?? ''), + ]); + return; + } + if ($entity === 'task') { + $gateType = $this->normalizeGateType((string)($data['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $gateRefId = $gateType === selfserve_task_gate_type::ALWAYS->value ? null : $this->nullableInt($data['gate_ref_id'] ?? null); + $pdo->prepare( + "INSERT INTO department_selfserve_tasks (department, lane, product, machine_type_id, condition_id, gate_type, gate_ref_id, task, description, order_priority, services, buttons, dynamic_images_vehicle_type) + VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :gate_type, :gate_ref_id, :task, :description, :order_priority, :services, :buttons, :dynamic_images_vehicle_type)" + )->execute([ + ':department' => $departmentId, + ':lane' => (int)($data['lane'] ?? 0), + ':product' => (int)($data['product'] ?? 0), + ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null), + ':condition_id' => $gateType === selfserve_task_gate_type::QUESTION->value ? $gateRefId : null, + ':gate_type' => $gateType, + ':gate_ref_id' => $gateRefId, + ':task' => (string)($data['task'] ?? $data['label'] ?? 'New task'), + ':description' => (string)($data['description'] ?? ''), + ':order_priority' => (int)($data['order_priority'] ?? 0), + ':services' => $this->jsonArray($data['services'] ?? []), + ':buttons' => $this->jsonArray($data['buttons'] ?? []), + ':dynamic_images_vehicle_type' => $this->nullableInt($data['dynamic_images_vehicle_type'] ?? null), + ]); + return; + } + + throw new \RuntimeException('Unsupported studio entity: ' . $entity); + } + + /** + * @param array $data + */ + private function updateEntity(int $departmentId, string $entity, int $id, array $data): void + { + $map = [ + 'question' => [ + 'table' => 'department_selfserve_questions', + 'fields' => ['lane', 'product', 'condition_id', 'question', 'description', 'order_priority'], + 'department' => 'department', + ], + 'condition' => [ + 'table' => 'department_selfserve_conditions', + 'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description'], + 'department' => 'department', + ], + 'rule' => [ + 'table' => 'department_selfserve_condition_rules', + 'fields' => ['condition_id', 'type', 'object_type', 'object_id', 'name', 'description'], + 'department' => null, + ], + 'task' => [ + 'table' => 'department_selfserve_tasks', + 'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'], + 'department' => 'department', + ], + ]; + if (!isset($map[$entity])) { + throw new \RuntimeException('Unsupported studio entity: ' . $entity); + } + if ($entity === 'rule' && array_key_exists('condition_id', $data) && !$this->conditionBelongsToDepartment((int)$data['condition_id'], $departmentId)) { + throw new \RuntimeException('Rule condition_id is not available in the selected department.'); + } + + $updates = []; + $params = [ + ':id' => $id, + ]; + foreach ($map[$entity]['fields'] as $field) { + if (!array_key_exists($field, $data)) { + continue; + } + $updates[] = "`$field` = :$field"; + $value = $data[$field]; + if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) { + $value = $this->nullableInt($value); + } elseif (in_array($field, ['services', 'buttons'], true)) { + $value = $this->jsonArray($value); + } elseif ($field === 'gate_type') { + $value = $this->normalizeGateType((string)$value); + } + $params[':' . $field] = $value; + } + + if (isset($data['label'])) { + if ($entity === 'question' && !isset($data['question'])) { + $updates[] = '`question` = :label'; + $params[':label'] = (string)$data['label']; + } elseif ($entity === 'condition' && !isset($data['name'])) { + $updates[] = '`name` = :label'; + $params[':label'] = (string)$data['label']; + } elseif ($entity === 'task' && !isset($data['task'])) { + $updates[] = '`task` = :label'; + $params[':label'] = (string)$data['label']; + } + } + + if ($updates === []) { + return; + } + + $where = 'id = :id'; + if ($entity === 'rule') { + $where .= " AND condition_id IN ( + SELECT id + FROM department_selfserve_conditions + WHERE department IN (0, :department) + AND deleted_at IS NULL + )"; + $params[':department'] = $departmentId; + } elseif ($map[$entity]['department'] !== null) { + $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)'; + $params[':department'] = $departmentId; + } + db::getPDO()->prepare( + 'UPDATE `' . $map[$entity]['table'] . '` SET ' . implode(', ', $updates) . ' WHERE ' . $where + )->execute($params); + } + + private function softDeleteEntity(int $departmentId, string $entity, int $id): void + { + $map = [ + 'question' => ['table' => 'department_selfserve_questions', 'department' => 'department'], + 'condition' => ['table' => 'department_selfserve_conditions', 'department' => 'department'], + 'rule' => ['table' => 'department_selfserve_condition_rules', 'department' => null], + 'task' => ['table' => 'department_selfserve_tasks', 'department' => 'department'], + ]; + if (!isset($map[$entity])) { + throw new \RuntimeException('Unsupported studio entity: ' . $entity); + } + + $params = [ + ':id' => $id, + ]; + $where = 'id = :id'; + if ($entity === 'rule') { + $where .= " AND condition_id IN ( + SELECT id + FROM department_selfserve_conditions + WHERE department IN (0, :department) + AND deleted_at IS NULL + )"; + $params[':department'] = $departmentId; + } elseif ($map[$entity]['department'] !== null) { + $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)'; + $params[':department'] = $departmentId; + } + db::getPDO()->prepare( + 'UPDATE `' . $map[$entity]['table'] . '` SET deleted_at = NOW() WHERE ' . $where + )->execute($params); + } + + /** + * @param array> $items + */ + private function applyReorder(string $entity, array $items): void + { + $table = match ($entity) { + 'question' => 'department_selfserve_questions', + 'task' => 'department_selfserve_tasks', + default => null, + }; + if ($table === null) { + throw new \RuntimeException('Only questions and tasks can be reordered.'); + } + + $statement = db::getPDO()->prepare('UPDATE `' . $table . '` SET order_priority = :order_priority WHERE id = :id'); + foreach ($items as $index => $item) { + if (!is_array($item)) { + continue; + } + $statement->execute([ + ':id' => (int)($item['id'] ?? 0), + ':order_priority' => (int)($item['order_priority'] ?? $index), + ]); + } + } + + private function applyConnection(int $departmentId, string $source, string $target, bool $disconnect): void + { + [$sourceType, $sourceId] = $this->parseNodeId($source); + [$targetType, $targetId] = $this->parseNodeId($target); + if ($sourceType === '' || $targetType === '' || $sourceId === '') { + throw new \RuntimeException('Invalid connection endpoints.'); + } + + if ($sourceType === 'task' && $targetType === 'binding') { + $service = $this->serviceForBindingNode($departmentId, $target); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateTaskServiceConnection($departmentId, (int)$sourceId, $service, $disconnect); + return; + } + if ($sourceType === 'binding' && $targetType === 'task') { + $service = $this->serviceForBindingNode($departmentId, $source); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateTaskServiceConnection($departmentId, (int)$targetId, $service, $disconnect); + return; + } + + if ($sourceType === 'condition' && $targetType === 'question') { + db::getPDO()->prepare('UPDATE department_selfserve_questions SET condition_id = :condition_id WHERE id = :id')->execute([ + ':condition_id' => $disconnect ? null : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + if ($sourceType === 'condition' && $targetType === 'condition') { + db::getPDO()->prepare('UPDATE department_selfserve_conditions SET condition_id = :condition_id WHERE id = :id')->execute([ + ':condition_id' => $disconnect ? null : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') { + db::getPDO()->prepare('UPDATE department_selfserve_tasks SET gate_type = :gate_type, gate_ref_id = :gate_ref_id, condition_id = :legacy_question_id WHERE id = :id')->execute([ + ':gate_type' => $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType), + ':gate_ref_id' => $disconnect ? null : (int)$sourceId, + ':legacy_question_id' => (!$disconnect && $sourceType === 'question') ? (int)$sourceId : null, + ':id' => (int)$targetId, + ]); + return; + } + if ($sourceType === 'condition' && $targetType === 'rule') { + db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET condition_id = :condition_id WHERE id = :id')->execute([ + ':condition_id' => $disconnect ? 0 : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + if (in_array($sourceType, ['question', 'condition', 'task'], true) && $targetType === 'rule') { + db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET object_type = :object_type, object_id = :object_id WHERE id = :id')->execute([ + ':object_type' => $disconnect ? '' : $sourceType, + ':object_id' => $disconnect ? 0 : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + } + + /** + * @param array $row + * @param array $lookups + * @return array + */ + private function scopeForRow(array $row, array $lookups): array + { + return [ + 'department' => $this->labelFor('departments', $row['department'] ?? null, $lookups), + 'lane' => $this->labelFor('lanes', $row['lane'] ?? null, $lookups), + 'product' => $this->labelFor('products', $row['product'] ?? null, $lookups), + 'machine_type' => $this->labelFor('machine_types', $row['machine_type_id'] ?? null, $lookups), + ]; + } + + /** + * @param array $row + */ + private function scopeLabel(array $row, array $lookups): string + { + $parts = []; + foreach (['lane' => 'lanes', 'product' => 'products', 'machine_type_id' => 'machine_types'] as $field => $lookupType) { + $value = $this->nullableInt($row[$field] ?? null); + if ($value !== null) { + $parts[] = $this->labelFor($lookupType, $value, $lookups); + } + } + + return $parts === [] ? 'Shared scope' : implode(' / ', $parts); + } + + /** + * @param array $row + */ + private function ruleSubtitle(array $row, array $lookups): string + { + $objectType = strtolower((string)($row['object_type'] ?? 'object')); + $objectId = (int)($row['object_id'] ?? 0); + $lookupType = $objectType . 's'; + $label = $objectId > 0 ? $this->labelFor($lookupType, $objectId, $lookups) : 'Unbound object'; + return strtoupper((string)($row['type'] ?? 'RULE')) . ' ' . $label; + } + + /** + * @param array $expression + */ + private function expressionSummary(array $expression): string + { + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); + $subjectLabel = match ($subjectType) { + 'question' => 'Question ' . $subjectId, + 'condition' => 'Condition ' . $subjectId, + default => 'Unknown subject', + }; + return $subjectLabel . ' ' . strtolower(str_replace('_', ' ', $operator)); + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + $branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : []; + if ($branches === []) { + return 'No if/else clauses'; + } + + $parts = []; + foreach (array_slice($branches, 0, 3) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + $label = $isElse ? 'Else' : ($index === 0 ? 'If' : 'Else if'); + $when = !$isElse && is_array($branch['when'] ?? null) ? $this->expressionSummary((array)$branch['when']) : ''; + $then = is_array($branch['then'] ?? null) ? $this->expressionSummary((array)$branch['then']) : 'No result'; + $parts[] = trim($label . ($when === '' ? '' : ' ' . $when) . ' then ' . $then); + } + if (count($branches) > 3) { + $parts[] = '+' . (count($branches) - 3) . ' more'; + } + + return implode('; ', $parts); + } + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $subjectLabel = match ($subjectType) { + 'question' => 'Question ' . $subjectId, + 'condition' => 'Condition ' . $subjectId, + default => 'Unknown subject', + }; + $cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : []; + if ($cases === []) { + return 'Case ' . $subjectLabel . ': no clauses'; + } + + $parts = []; + foreach (array_slice($cases, 0, 3) as $case) { + if (!is_array($case)) { + continue; + } + $value = $this->caseValueLabel($case['value'] ?? null); + $then = is_array($case['then'] ?? null) ? $this->expressionSummary((array)$case['then']) : 'No result'; + $parts[] = $value . ' then ' . $then; + } + if (count($cases) > 3) { + $parts[] = '+' . (count($cases) - 3) . ' more'; + } + + return 'Case ' . $subjectLabel . ': ' . implode('; ', $parts); + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $operator = 'ALL'; + } + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + if ($children === []) { + return 'No predicates'; + } + + $parts = []; + foreach (array_slice($children, 0, 3) as $child) { + if (is_array($child)) { + $parts[] = $this->expressionSummary((array)$child); + } + } + if (count($children) > 3) { + $parts[] = '+' . (count($children) - 3) . ' more'; + } + + $prefix = $operator === 'ANY' ? 'Any of' : 'All of'; + return $prefix . ': ' . implode('; ', $parts); + } + + /** + * @param array> $edges + * @param array $expression + */ + private function appendExpressionEdges(array &$edges, string $targetNodeId, int $ownerConditionId, array $expression, string $path = '0'): void + { + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + if (!in_array($subjectType, ['question', 'condition'], true) || $subjectId <= 0) { + return; + } + $edge = $this->edge( + 'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path), 0, 8), + $subjectType . ':' . $subjectId, + $targetNodeId, + 'condition_expression', + strtoupper((string)($expression['operator'] ?? 'IS_TRUE')) + ); + $edge['data']['subject_type'] = $subjectType; + $edge['data']['subject_id'] = $subjectId; + $edge['data']['condition_id'] = $ownerConditionId; + $edges[] = $edge; + return; + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + foreach ((array)($expression['branches'] ?? []) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + if (is_array($branch['when'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['when'], $path . '.b' . $index . '.when'); + } + if (is_array($branch['then'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['then'], $path . '.b' . $index . '.then'); + } + } + if (is_array($expression['default'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default'); + } + return; + } + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + if (in_array($subjectType, ['question', 'condition'], true) && $subjectId > 0) { + $edge = $this->edge( + 'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path . '.case'), 0, 8), + $subjectType . ':' . $subjectId, + $targetNodeId, + 'condition_expression', + 'CASE' + ); + $edge['data']['subject_type'] = $subjectType; + $edge['data']['subject_id'] = $subjectId; + $edge['data']['condition_id'] = $ownerConditionId; + $edges[] = $edge; + } + foreach ((array)($expression['cases'] ?? []) as $index => $case) { + if (is_array($case) && is_array($case['then'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$case['then'], $path . '.c' . $index . '.then'); + } + } + if (is_array($expression['default'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default'); + } + return; + } + + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + foreach ($children as $index => $child) { + if (is_array($child)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$child, $path . '.' . $index); + } + } + } + + private function normalizeExpressionNode(mixed $expression): array + { + if (!is_array($expression)) { + return $this->emptyV2Expression(); + } + + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + if (!in_array($subjectType, ['question', 'condition'], true)) { + $subjectType = 'question'; + } + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); + if (!in_array($operator, ['IS_TRUE', 'IS_FALSE', 'IS_SET', 'IS_TRUE_OR_NOT_SET', 'IS_FALSE_OR_NOT_SET'], true)) { + $operator = 'IS_TRUE'; + } + return [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0), + 'operator' => $operator, + ]; + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + $branches = []; + foreach ((array)($expression['branches'] ?? []) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + $normalized = [ + 'kind' => $isElse ? 'else' : ($index === 0 ? 'if' : 'else_if'), + 'then' => $this->normalizeExpressionNode($branch['then'] ?? $this->emptyV2Expression()), + ]; + if ($isElse) { + $normalized['else'] = true; + } else { + $normalized['when'] = $this->normalizeExpressionNode($branch['when'] ?? $this->emptyV2Expression()); + } + $branches[] = $normalized; + } + + $normalizedExpression = [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'branches' => $branches, + ]; + if (is_array($expression['default'] ?? null)) { + $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']); + } + return $normalizedExpression; + } + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + if (!in_array($subjectType, ['question', 'condition'], true)) { + $subjectType = 'question'; + } + $cases = []; + foreach ((array)($expression['cases'] ?? []) as $case) { + if (!is_array($case)) { + continue; + } + $cases[] = [ + 'value' => $case['value'] ?? null, + 'then' => $this->normalizeExpressionNode($case['then'] ?? $this->emptyV2Expression()), + ]; + } + + $normalizedExpression = [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0), + 'cases' => $cases, + ]; + if (is_array($expression['default'] ?? null)) { + $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']); + } + return $normalizedExpression; + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $operator = 'ALL'; + } + $children = []; + foreach ((array)($expression['children'] ?? []) as $child) { + if (is_array($child)) { + $children[] = $this->normalizeExpressionNode((array)$child); + } + } + + return [ + 'type' => 'group', + 'operator' => $operator, + 'children' => $children, + ]; + } + + /** + * @param array $expression + */ + private function expressionHasPredicate(array $expression, string $subjectType, int $subjectId): bool + { + $type = strtolower((string)($expression['type'] ?? 'group')); + if ($type === 'predicate') { + return strtolower((string)($expression['subject_type'] ?? '')) === $subjectType + && (int)($expression['subject_id'] ?? 0) === $subjectId; + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + foreach ((array)($expression['branches'] ?? []) as $branch) { + if (!is_array($branch)) { + continue; + } + if (is_array($branch['when'] ?? null) && $this->expressionHasPredicate((array)$branch['when'], $subjectType, $subjectId)) { + return true; + } + if (is_array($branch['then'] ?? null) && $this->expressionHasPredicate((array)$branch['then'], $subjectType, $subjectId)) { + return true; + } + } + return is_array($expression['default'] ?? null) + && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId); + } + if ($type === 'case') { + if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType + && (int)($expression['subject_id'] ?? 0) === $subjectId) { + return true; + } + foreach ((array)($expression['cases'] ?? []) as $case) { + if (is_array($case) && is_array($case['then'] ?? null) && $this->expressionHasPredicate((array)$case['then'], $subjectType, $subjectId)) { + return true; + } + } + return is_array($expression['default'] ?? null) + && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId); + } + + foreach ((array)($expression['children'] ?? []) as $child) { + if (is_array($child) && $this->expressionHasPredicate((array)$child, $subjectType, $subjectId)) { + return true; + } + } + return false; + } + + /** + * @param array $expression + * @return array + */ + private function removeExpressionPredicate(array $expression, string $subjectType, int $subjectId): array + { + $expression = $this->normalizeExpressionNode($expression); + if (($expression['type'] ?? 'group') === 'predicate') { + return $this->expressionHasPredicate($expression, $subjectType, $subjectId) + ? $this->emptyV2Expression() + : $expression; + } + if (in_array(strtolower((string)($expression['type'] ?? 'group')), ['branch', 'if', 'if_else'], true)) { + foreach ((array)($expression['branches'] ?? []) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + if (is_array($branch['when'] ?? null)) { + $expression['branches'][$index]['when'] = $this->removeExpressionPredicate((array)$branch['when'], $subjectType, $subjectId); + } + if (is_array($branch['then'] ?? null)) { + $expression['branches'][$index]['then'] = $this->removeExpressionPredicate((array)$branch['then'], $subjectType, $subjectId); + } + } + if (is_array($expression['default'] ?? null)) { + $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId); + } + return $expression; + } + if (($expression['type'] ?? 'group') === 'case') { + if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType + && (int)($expression['subject_id'] ?? 0) === $subjectId) { + return $this->emptyV2Expression(); + } + foreach ((array)($expression['cases'] ?? []) as $index => $case) { + if (is_array($case) && is_array($case['then'] ?? null)) { + $expression['cases'][$index]['then'] = $this->removeExpressionPredicate((array)$case['then'], $subjectType, $subjectId); + } + } + if (is_array($expression['default'] ?? null)) { + $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId); + } + return $expression; + } + + $children = []; + foreach ((array)($expression['children'] ?? []) as $child) { + if (!is_array($child)) { + continue; + } + $normalizedChild = $this->normalizeExpressionNode((array)$child); + if (($normalizedChild['type'] ?? '') === 'predicate' && $this->expressionHasPredicate($normalizedChild, $subjectType, $subjectId)) { + continue; + } + if (($normalizedChild['type'] ?? '') === 'group') { + $normalizedChild = $this->removeExpressionPredicate($normalizedChild, $subjectType, $subjectId); + } + $children[] = $normalizedChild; + } + + $expression['children'] = $children; + return $expression; + } + + /** + * @return array + */ + private function emptyV2Expression(): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [], + ]; + } + + private function caseValueLabel(mixed $value): string + { + if ($value === true) { + return 'true'; + } + if ($value === false) { + return 'false'; + } + if ($value === null) { + return 'unanswered'; + } + return (string)$value; + } + + /** + * @param array> $edges + * @param array $row + */ + private function appendScopeEdges(array &$edges, string $targetId, array $row): void + { + $scopes = [ + 'lane' => 'lane', + 'product' => 'vehicle_type', + 'machine_type_id' => 'machine_type', + ]; + foreach ($scopes as $field => $type) { + $scopeId = $this->nullableInt($row[$field] ?? null); + if ($scopeId !== null) { + $edges[] = $this->edge('scope:' . $type . ':' . $scopeId . ':' . $targetId, $type . ':' . $scopeId, $targetId, 'scope', 'scope'); + } + } + } + + /** + * @param array $layout + * @param array> $nodes + * @return array> + */ + private function applyLayoutToNodes(array $nodes, array $layout): array + { + $positions = $this->extractNodePositions((array)($layout['nodes'] ?? [])); + foreach ($nodes as &$node) { + $id = (string)($node['id'] ?? ''); + if (isset($positions[$id])) { + $node['position'] = $positions[$id]; + } + } + unset($node); + return array_values($nodes); + } + + /** + * @param array $nodes + * @return array + */ + private function extractNodePositions(array $nodes): array + { + $positions = []; + foreach ($nodes as $key => $node) { + if (is_array($node) && isset($node['id'], $node['position']) && is_array($node['position'])) { + $positions[(string)$node['id']] = [ + 'x' => (float)($node['position']['x'] ?? 0), + 'y' => (float)($node['position']['y'] ?? 0), + ]; + continue; + } + if (is_string($key) && is_array($node)) { + $positions[$key] = [ + 'x' => (float)($node['x'] ?? $node['position']['x'] ?? 0), + 'y' => (float)($node['y'] ?? $node['position']['y'] ?? 0), + ]; + } + } + + return $positions; + } + + /** + * @param array $validation + * @return array> + */ + private function buildValidationItems(array $validation): array + { + $items = []; + foreach ((array)($validation['errors'] ?? []) as $message) { + $items[] = [ + 'severity' => 'error', + 'message' => (string)$message, + ]; + } + foreach ((array)($validation['warnings'] ?? []) as $message) { + $items[] = [ + 'severity' => 'warning', + 'message' => (string)$message, + ]; + } + return $items; + } + + /** + * @param array $lookups + * @return array + */ + private function buildSimulatorDefaults(int $departmentId, array $lookups, array $gatewayWorkspace = []): array + { + $lane = $this->lookupRows($lookups, 'lanes')[0] ?? null; + $vehicleType = $this->lookupRows($lookups, 'vehicle_types')[0] ?? null; + $hasVirtualHardware = (bool)($gatewayWorkspace['virtual']['has_virtual_hardware'] ?? false); + return [ + 'department' => $departmentId, + 'lane_id' => is_array($lane) ? (int)($lane['id'] ?? 0) : null, + 'vehicle_type_id' => is_array($vehicleType) ? (int)($vehicleType['id'] ?? 0) : null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'hardware_mode' => $hasVirtualHardware ? 'studio' : 'real', + ]; + } + + /** + * @param array $data + * @return array + */ + private function node(string $id, string $type, string $label, string $kind, array $data, int $x, int $y): array + { + $data['kind'] = $kind; + $data['label'] = $label; + return [ + 'id' => $id, + 'type' => $type, + 'position' => [ + 'x' => $x, + 'y' => $y, + ], + 'data' => $data, + ]; + } + + /** + * @return array + */ + private function edge(string $id, string $source, string $target, string $kind, string $label): array + { + return [ + 'id' => $id, + 'source' => $source, + 'target' => $target, + 'type' => 'smoothstep', + 'label' => $label, + 'data' => [ + 'kind' => $kind, + ], + ]; + } + + /** + * @param array $row + * @param array $lookups + */ + private function entityLabel(string $entity, int $id, array $row, array $lookups): string + { + $field = match ($entity) { + 'question' => 'question', + 'condition', 'rule' => 'name', + 'task' => 'task', + 'action' => 'name', + default => 'label', + }; + $label = trim((string)($row[$field] ?? '')); + if ($label !== '') { + return $label; + } + return $this->labelFor($entity . 's', $id, $lookups); + } + + /** + * @param array $action + * @param array $lookups + */ + private function actionSubtitle(array $action, array $lookups): string + { + $parts = [ + selfserve_studio_actions::eventLabel((string)($action['event'] ?? '')), + ucfirst((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)), + selfserve_studio_actions::operationLabel((string)($action['operation'] ?? ''), $action['relay_state'] ?? null), + ]; + $scope = $this->scopeLabel($action, $lookups); + if ($scope !== 'Shared scope') { + $parts[] = $scope; + } + return implode(' / ', array_filter($parts, static fn(string $part): bool => $part !== '')); + } + + /** + * @param array $lookups + */ + private function labelFor(string $lookupType, mixed $id, array $lookups): string + { + $id = $this->nullableInt($id); + if ($id === null) { + return 'All'; + } + $labels = is_array($lookups['labels'][$lookupType] ?? null) ? (array)$lookups['labels'][$lookupType] : []; + return (string)($labels[(string)$id] ?? ucfirst(str_replace('_', ' ', rtrim($lookupType, 's'))) . ' ' . $id); + } + + /** + * @param array $lookups + * @return array> + */ + private function lookupRows(array $lookups, string $type): array + { + return isset($lookups[$type]) && is_array($lookups[$type]) ? array_values((array)$lookups[$type]) : []; + } + + /** + * @param array> $rows + * @return array> + */ + private function sortedRows(array $rows, array $fields): array + { + usort($rows, static function (array $left, array $right) use ($fields): int { + foreach ($fields as $field) { + $leftValue = $left[$field] ?? null; + $rightValue = $right[$field] ?? null; + if (is_numeric($leftValue) && is_numeric($rightValue)) { + $comparison = (int)$leftValue <=> (int)$rightValue; + } else { + $comparison = strcmp((string)$leftValue, (string)$rightValue); + } + if ($comparison !== 0) { + return $comparison; + } + } + return 0; + }); + return array_values($rows); + } + + /** + * @param array $task + * @return array + */ + private function normalizeTaskPayload(array $task): array + { + $task['services'] = $this->normalizeServiceList($task['services'] ?? []); + $task['buttons'] = $this->normalizeArrayPayload($task['buttons'] ?? []); + $task['attachments'] = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : []; + return $task; + } + + /** + * @param array $config + * @return array + */ + private function withTaskAttachments(array $config): array + { + if (!is_array($config['tasks'] ?? null)) { + return $config; + } + + $config['tasks'] = (new selfserve_task_attachment_payloads())->attachToTasks(array_values((array)$config['tasks'])); + return $config; + } + + /** + * @param array $workspace + * @return array> + */ + private function relayServicesFromWorkspace(array $workspace): array + { + $servicesByRelay = []; + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + $service = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''); + if ($relayId === '' || $service === '') { + continue; + } + $servicesByRelay[$relayId][$service] = true; + } + } + + return array_map(static fn(array $services): array => array_keys($services), $servicesByRelay); + } + + /** + * @param array $binding + * @param array> $relayServices + * @return array + */ + private function bindingServices(array $binding, string $relayId, array $relayServices): array + { + $services = []; + foreach (['role', 'service', 'slot'] as $field) { + $service = $this->normalizeServiceName($binding[$field] ?? ''); + if ($service !== '') { + $services[$service] = true; + } + } + foreach ($this->normalizeServiceList($binding['services'] ?? []) as $service) { + $services[$service] = true; + } + foreach ((array)($relayServices[$relayId] ?? []) as $service) { + $normalized = $this->normalizeServiceName($service); + if ($normalized !== '') { + $services[$normalized] = true; + } + } + + return array_keys($services); + } + + /** + * @param array $gateway + */ + private function gatewayIdentifier(array $gateway): string + { + return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '')); + } + + /** + * @param array $gateway + */ + private function gatewayNodeId(array $gateway): string + { + $nodeId = trim((string)($gateway['node_id'] ?? '')); + return $nodeId !== '' ? $nodeId : 'gateway:' . $this->gatewayIdentifier($gateway); + } + + /** + * @param array $binding + */ + private function bindingNodeId(string $gatewayId, string $relayId, int $bindingIndex, array $binding): string + { + $nodeId = trim((string)($binding['node_id'] ?? '')); + return $nodeId !== '' ? $nodeId : 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex; + } + + /** + * @param array $workspace + * @return array> + */ + private function gatewayBindingReferences(array $workspace): array + { + $relayServices = $this->relayServicesFromWorkspace($workspace); + $references = []; + foreach ((array)($workspace['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = $this->gatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } + foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $services = $this->bindingServices($binding, $relayId, $relayServices); + if ($services === []) { + continue; + } + $references[] = [ + 'gateway_id' => $gatewayId, + 'relay_id' => $relayId, + 'binding_index' => (int)$bindingIndex, + 'node_id' => $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding), + 'services' => $services, + 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), + ]; + } + } + + return $references; + } + + private function serviceForBindingNode(int $departmentId, string $nodeId): string + { + $workspace = $this->buildGatewayWorkspace($departmentId); + foreach ($this->gatewayBindingReferences($workspace) as $binding) { + if ((string)$binding['node_id'] === $nodeId) { + return (string)($binding['services'][0] ?? ''); + } + } + return ''; + } + + private function updateTaskServiceConnection(int $departmentId, int $taskId, string $service, bool $disconnect): void + { + if ($taskId <= 0 || $service === '') { + return; + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'SELECT services FROM department_selfserve_tasks WHERE id = :id AND department IN (0, :department) LIMIT 1' + ); + $statement->execute([ + ':id' => $taskId, + ':department' => $departmentId, + ]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + if (!is_array($row)) { + throw new \RuntimeException('Task is not available in the selected department.'); + } + + $services = $this->normalizeServiceList($row['services'] ?? []); + $serviceSet = array_fill_keys($services, true); + if ($disconnect) { + unset($serviceSet[$service]); + } else { + $serviceSet[$service] = true; + } + + $pdo->prepare( + 'UPDATE department_selfserve_tasks SET services = :services WHERE id = :id AND department IN (0, :department)' + )->execute([ + ':services' => $this->jsonArray(array_keys($serviceSet)), + ':id' => $taskId, + ':department' => $departmentId, + ]); + } + + /** + * @return array + */ + private function normalizeArrayPayload(mixed $value): array + { + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value)), static fn(string $item): bool => $item !== ''); + } + return is_array($value) ? array_values($value) : []; + } + + /** + * @return array + */ + private function normalizeServiceList(mixed $value): array + { + $services = []; + foreach ($this->normalizeArrayPayload($value) as $entry) { + $service = $this->normalizeServiceName($entry); + if ($service !== '') { + $services[$service] = true; + } + } + return array_keys($services); + } + + private function normalizeServiceName(mixed $value): string + { + return strtoupper(trim((string)$value)); + } + + /** + * @param array $answers + * @return array + */ + private function pathAnswerOverrides(array $answers): array + { + ksort($answers, SORT_NUMERIC); + $overrides = []; + foreach ($answers as $questionId => $answer) { + if ($answer !== true && $answer !== false) { + continue; + } + $overrides[] = [ + 'question_id' => (int)$questionId, + 'value' => $answer, + ]; + } + return $overrides; + } + + /** + * @param array $simulation + * @param array $answers + * @return array|null + */ + private function nextPathQuestion(array $simulation, array $answers): ?array + { + $debugQuestions = is_array($simulation['debug']['questions'] ?? null) ? (array)$simulation['debug']['questions'] : []; + foreach ($debugQuestions as $question) { + if (!is_array($question)) { + continue; + } + $questionId = (int)($question['id'] ?? 0); + if ($questionId <= 0 || array_key_exists($questionId, $answers)) { + continue; + } + $visible = !array_key_exists('visible', $question) || (bool)$question['visible'] === true; + $answer = $question['answer'] ?? null; + if ($visible && $answer !== true && $answer !== false) { + return $question; + } + } + + $visibleQuestions = is_array($simulation['questions'] ?? null) ? (array)$simulation['questions'] : []; + foreach ($visibleQuestions as $question) { + if (!is_array($question)) { + continue; + } + $questionId = (int)($question['id'] ?? 0); + if ($questionId <= 0 || array_key_exists($questionId, $answers)) { + continue; + } + $answer = $question['answer'] ?? null; + if ($answer !== true && $answer !== false) { + return [ + 'id' => $questionId, + 'label' => (string)($question['question'] ?? ('Question ' . $questionId)), + 'node_id' => 'question:' . $questionId, + 'answer' => $answer, + 'visible' => true, + ]; + } + } + + return null; + } + + private function pathLimit(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + $parsed = (int)$value; + return $parsed > 0 ? $parsed : null; + } + + /** + * @param array $scope + * @param array> $groups + * @param array> $paths + * @param array $questionIds + * @return array + */ + private function pathOutcomesProjectionPayload( + array $scope, + array $groups, + array $paths, + bool $truncated, + ?int $maxStates, + int $stateCount, + int $terminalPathCount, + array $questionIds, + int $pendingStateCount, + array $confirmationRows = [] + ): array { + $warnings = []; + if ($truncated && $maxStates !== null) { + $warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s).'; + } + + $knownStateCount = max(1, $stateCount + $pendingStateCount); + $complete = !$truncated && $pendingStateCount === 0; + $percent = $complete ? 100 : min(99, max(1, (int)floor(($stateCount / $knownStateCount) * 100))); + + return $this->pathOutcomesPayload( + $scope, + $this->finalizePathOutcomeGroups($groups), + $paths, + $warnings, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + [ + 'complete' => $complete, + 'percent' => $percent, + 'state_count' => $stateCount, + 'pending_state_count' => $pendingStateCount, + 'terminal_path_count' => $terminalPathCount, + ], + $confirmationRows + ); + } + + /** + * @param array $scope + * @param array> $outcomes + * @param array> $paths + * @param array $warnings + * @param array|array $questionIds + * @param array $progress + * @return array + */ + private function pathOutcomesPayload( + array $scope, + array $outcomes, + array $paths, + array $warnings, + bool $truncated, + ?int $maxStates, + int $stateCount, + int $terminalPathCount, + array $questionIds, + array $progress = [], + ?array $confirmationRows = null + ): array { + $confirmationRows = $confirmationRows ?? []; + + usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0)) + ?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? ''))); + foreach ($outcomes as $index => &$outcome) { + $outcome['id'] = 'outcome-' . ($index + 1); + } + unset($outcome); + + foreach ($paths as $index => &$path) { + $path['id'] = 'path-' . ($index + 1); + } + unset($path); + + $confirmations = $this->applyPathConfirmations($paths, $confirmationRows); + + $questionIdValues = []; + foreach ($questionIds as $key => $value) { + $questionIdValues[] = $value === true ? (int)$key : (int)$value; + } + $questionIdValues = array_values(array_unique(array_filter($questionIdValues, static fn(int $id): bool => $id > 0))); + sort($questionIdValues); + + $progress = array_merge([ + 'complete' => !$truncated, + 'percent' => $truncated ? 99 : 100, + 'state_count' => $stateCount, + 'pending_state_count' => 0, + 'terminal_path_count' => $terminalPathCount, + ], $progress); + + return [ + 'scope' => $scope, + 'summary' => [ + 'state_count' => $stateCount, + 'terminal_path_count' => $terminalPathCount, + 'outcome_count' => count($outcomes), + 'question_count' => count($questionIdValues), + 'question_ids' => $questionIdValues, + 'max_states' => $maxStates, + 'path_sample_count' => count($paths), + 'confirmations' => $confirmations['summary'], + ], + 'outcomes' => array_values($outcomes), + 'paths' => array_values($paths), + 'confirmations' => $confirmations, + 'warnings' => array_values(array_unique($warnings)), + 'truncated' => $truncated, + 'progress' => $progress, + ]; + } + + /** + * @param array> $paths + * @param array> $confirmationRows + * @return array{summary:array,removed:array>} + */ + private function applyPathConfirmations(array &$paths, array $confirmationRows): array + { + $rowsBySignature = []; + foreach ($confirmationRows as $row) { + if (!is_array($row)) { + continue; + } + $signature = trim((string)($row['path_signature'] ?? '')); + if ($signature !== '') { + $rowsBySignature[$signature] = $row; + } + } + + $matched = []; + $summary = [ + 'confirmed' => 0, + 'unconfirmed' => 0, + 'stale' => 0, + 'removed' => 0, + 'total' => count($paths), + ]; + + foreach ($paths as &$path) { + if (!is_array($path)) { + continue; + } + $pathSignature = $this->pathSignature( + is_array($path['scope'] ?? null) ? (array)$path['scope'] : [], + is_array($path['answers'] ?? null) ? (array)$path['answers'] : [] + ); + $resultSignature = $this->pathResultSignature($path); + $path['path_signature'] = $pathSignature; + $path['result_signature'] = $resultSignature; + $path['confirmation_status'] = 'unconfirmed'; + $path['confirmed_at'] = null; + $path['confirmed_by'] = null; + $path['stale_reason'] = null; + + $row = $rowsBySignature[$pathSignature] ?? null; + if (is_array($row)) { + $matched[$pathSignature] = true; + $path['confirmed_at'] = $row['confirmed_at'] ?? null; + $path['confirmed_by'] = $row['confirmed_by'] ?? null; + if ((string)($row['result_signature'] ?? '') === $resultSignature) { + $path['confirmation_status'] = 'confirmed'; + } else { + $path['confirmation_status'] = 'stale'; + $path['stale_reason'] = 'Result changed since confirmation.'; + } + } + + $summary[(string)$path['confirmation_status']]++; + } + unset($path); + + $removed = []; + foreach ($rowsBySignature as $signature => $row) { + if (isset($matched[$signature])) { + continue; + } + $removed[] = [ + 'id' => $row['id'] ?? null, + 'path_signature' => $signature, + 'result_signature' => (string)($row['result_signature'] ?? ''), + 'confirmation_status' => 'stale', + 'stale_reason' => 'Path no longer appears in the projected cases.', + 'answers' => is_array($row['answers'] ?? null) ? $row['answers'] : [], + 'result' => is_array($row['result'] ?? null) ? $row['result'] : [], + 'scope' => is_array($row['scope'] ?? null) ? $row['scope'] : [], + 'confirmed_at' => $row['confirmed_at'] ?? null, + 'confirmed_by' => $row['confirmed_by'] ?? null, + ]; + } + $summary['removed'] = count($removed); + + return [ + 'summary' => $summary, + 'removed' => $removed, + ]; + } + + /** + * @param array> $groups + * @param array $simulation + * @param array> $chain + * @param array $scope + */ + private function addPathOutcomeGroup(array &$groups, array $simulation, array $chain, array $scope, int $sampleLimit): void + { + $tasks = $this->pathActiveTasks($simulation); + $services = $this->pathServices($simulation, $tasks); + $signals = $this->pathSignals($simulation); + $allowed = (bool)($simulation['allowed'] ?? false); + $key = $this->stableJson([ + 'scope' => $this->pathScopeKey($scope), + 'allowed' => $allowed, + 'services' => $services, + 'tasks' => array_map(static fn(array $task): array => [ + 'id' => (int)($task['id'] ?? 0), + 'services' => (array)($task['services'] ?? []), + ], $tasks), + 'signals' => $signals, + ]); + + if (!isset($groups[$key])) { + $groups[$key] = [ + 'path_count' => 0, + 'allowed' => $allowed, + 'services' => $services, + 'tasks' => $tasks, + 'signals' => $signals, + 'sample_chains' => [], + 'scopes' => [], + 'node_ids' => [], + 'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals), + ]; + } + + $groups[$key]['path_count'] = (int)$groups[$key]['path_count'] + 1; + $scopeKey = $this->stableJson($this->pathScopeKey($scope)); + $groups[$key]['scopes'][$scopeKey] = $scope; + if (count((array)$groups[$key]['sample_chains']) < $sampleLimit) { + $groups[$key]['sample_chains'][] = [ + 'scope' => $scope, + 'answers' => array_values($chain), + ]; + } + + foreach ($this->pathNodeIds($chain, $tasks, $signals) as $nodeId) { + $groups[$key]['node_ids'][$nodeId] = true; + } + } + + /** + * @param array $simulation + * @param array> $chain + * @param array $scope + * @return array + */ + private function pathResultFromSimulation(array $simulation, array $chain, array $scope): array + { + $tasks = $this->pathActiveTasks($simulation); + $services = $this->pathServices($simulation, $tasks); + $signals = $this->pathSignals($simulation); + $allowed = (bool)($simulation['allowed'] ?? false); + + return [ + 'id' => '', + 'result' => $allowed ? 'Allowed' : 'Blocked', + 'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals), + 'allowed' => $allowed, + 'services' => $services, + 'tasks' => $tasks, + 'signals' => $signals, + 'task_count' => count($tasks), + 'signal_count' => count($signals), + 'answers' => array_values($chain), + 'scope' => $scope, + 'node_ids' => $this->pathNodeIds($chain, $tasks, $signals), + ]; + } + + /** + * @param array> $chain + * @param array> $tasks + * @param array> $signals + * @return array + */ + private function pathNodeIds(array $chain, array $tasks, array $signals): array + { + $nodeIds = []; + foreach ($chain as $answer) { + if (is_array($answer) && trim((string)($answer['node_id'] ?? '')) !== '') { + $nodeIds[(string)$answer['node_id']] = true; + } + } + foreach ($tasks as $task) { + if (trim((string)($task['node_id'] ?? '')) !== '') { + $nodeIds[(string)$task['node_id']] = true; + } + } + foreach ($signals as $signal) { + foreach (['target_binding', 'target_relay_node_id', 'target_gateway_node_id'] as $field) { + if (trim((string)($signal[$field] ?? '')) !== '') { + $nodeIds[(string)$signal[$field]] = true; + } + } + } + + $nodeIds = array_keys($nodeIds); + sort($nodeIds); + return $nodeIds; + } + + /** + * @param array> $groups + * @return array> + */ + private function finalizePathOutcomeGroups(array $groups): array + { + $outcomes = []; + foreach ($groups as $group) { + $scopes = array_values((array)($group['scopes'] ?? [])); + $nodeIds = array_values(array_keys((array)($group['node_ids'] ?? []))); + sort($nodeIds); + $outcomes[] = [ + 'id' => '', + 'summary' => (string)($group['summary'] ?? ''), + 'path_count' => (int)($group['path_count'] ?? 0), + 'allowed' => (bool)($group['allowed'] ?? false), + 'services' => array_values((array)($group['services'] ?? [])), + 'tasks' => array_values((array)($group['tasks'] ?? [])), + 'signals' => array_values((array)($group['signals'] ?? [])), + 'sample_chains' => array_values((array)($group['sample_chains'] ?? [])), + 'scopes' => $scopes, + 'node_ids' => $nodeIds, + ]; + } + + usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0)) + ?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? ''))); + foreach ($outcomes as $index => &$outcome) { + $outcome['id'] = 'outcome-' . ($index + 1); + } + unset($outcome); + return $outcomes; + } + + /** + * @param array $simulation + * @return array> + */ + private function pathActiveTasks(array $simulation): array + { + $tasks = []; + $debugTasks = is_array($simulation['debug']['tasks'] ?? null) ? (array)$simulation['debug']['tasks'] : []; + foreach ($debugTasks as $task) { + if (!is_array($task) || (bool)($task['active'] ?? false) !== true) { + continue; + } + $tasks[] = [ + 'id' => (int)($task['id'] ?? 0), + 'node_id' => (string)($task['node_id'] ?? ('task:' . (int)($task['id'] ?? 0))), + 'label' => (string)($task['label'] ?? $task['task'] ?? ('Task ' . (int)($task['id'] ?? 0))), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'order_priority' => (int)($task['order_priority'] ?? 0), + ]; + } + if ($tasks !== []) { + return $tasks; + } + + foreach ((array)($simulation['tasks'] ?? []) as $task) { + if (!is_array($task)) { + continue; + } + $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); + $tasks[] = [ + 'id' => $taskId, + 'node_id' => 'task:' . $taskId, + 'label' => (string)($task['task'] ?? $task['label'] ?? ('Task ' . $taskId)), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'order_priority' => (int)($task['order_priority'] ?? 0), + ]; + } + usort($tasks, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)) + ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0))); + return $tasks; + } + + /** + * @param array $simulation + * @param array> $tasks + * @return array + */ + private function pathServices(array $simulation, array $tasks): array + { + $services = []; + foreach ((array)($simulation['allowed_services'] ?? []) as $service) { + $normalized = $this->normalizeServiceName($service); + if ($normalized !== '') { + $services[$normalized] = true; + } + } + foreach ($tasks as $task) { + foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { + $services[$service] = true; + } + } + $values = array_keys($services); + sort($values); + return $values; + } + + /** + * @param array $simulation + * @return array> + */ + private function pathSignals(array $simulation): array + { + $timeline = is_array($simulation['debug']['signal_timeline'] ?? null) + ? (array)$simulation['debug']['signal_timeline'] + : (is_array($simulation['debug']['hardware']['signal_timeline'] ?? null) ? (array)$simulation['debug']['hardware']['signal_timeline'] : []); + $signals = []; + foreach ($timeline as $index => $signal) { + if (!is_array($signal)) { + continue; + } + $signals[] = [ + 'sequence' => (int)($signal['sequence'] ?? ($index + 1)), + 'runtime_stage' => (string)($signal['runtime_stage'] ?? ''), + 'signal_type' => (string)($signal['signal_type'] ?? ''), + 'relay_role' => (string)($signal['relay_role'] ?? ''), + 'relay_id' => $signal['relay_id'] ?? null, + 'target_gateway_label' => $signal['target_gateway_label'] ?? null, + 'target_binding' => $signal['target_binding'] ?? null, + 'target_gateway_node_id' => $signal['target_gateway_node_id'] ?? null, + 'target_relay_node_id' => $signal['target_relay_node_id'] ?? null, + 'source' => (string)($signal['source'] ?? ''), + 'virtual' => (bool)($signal['virtual'] ?? false), + 'predicted_status' => (string)($signal['predicted_status'] ?? ''), + 'payload' => $this->sortStableValue(is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : []), + 'skip_block_reason' => $signal['skip_block_reason'] ?? null, + ]; + } + return $signals; + } + + /** + * @param array $scope + * @return array + */ + private function pathScopeKey(array $scope): array + { + return [ + 'department_id' => $scope['department_id'] ?? null, + 'lane_id' => $scope['lane_id'] ?? null, + 'vehicle_type_id' => $scope['vehicle_type_id'] ?? null, + 'machine_type_id' => $scope['machine_type_id'] ?? null, + 'config_source' => $scope['config_source'] ?? null, + 'config_version_id' => $scope['config_version_id'] ?? null, + 'hardware_mode' => $scope['hardware_mode'] ?? null, + ]; + } + + /** + * @param array $path + */ + private function pathResultSignature(array $path): string + { + return hash('sha256', $this->stableJson([ + 'allowed' => (bool)($path['allowed'] ?? false), + 'services' => $this->normalizeServiceList($path['services'] ?? []), + 'tasks' => array_values(array_map(function (array $task): array { + return [ + 'id' => (int)($task['id'] ?? 0), + 'label' => (string)($task['label'] ?? $task['task'] ?? ''), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'order_priority' => (int)($task['order_priority'] ?? 0), + ]; + }, array_values((array)($path['tasks'] ?? [])))), + 'buttons' => array_values(array_reduce( + array_values((array)($path['tasks'] ?? [])), + function (array $carry, mixed $task): array { + if (!is_array($task)) { + return $carry; + } + foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) { + $key = (is_int($button) ? 'int:' : 'string:') . (string)$button; + $carry[$key] = $button; + } + return $carry; + }, + [] + )), + 'signals' => array_values(array_map(static fn(array $signal): array => [ + 'runtime_stage' => (string)($signal['runtime_stage'] ?? ''), + 'signal_type' => (string)($signal['signal_type'] ?? ''), + 'relay_role' => (string)($signal['relay_role'] ?? ''), + 'relay_id' => $signal['relay_id'] ?? null, + 'target_binding' => $signal['target_binding'] ?? null, + 'source' => (string)($signal['source'] ?? ''), + 'predicted_status' => (string)($signal['predicted_status'] ?? ''), + 'payload' => is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : [], + ], array_values((array)($path['signals'] ?? [])))), + ])); + } + + /** + * @return array> + */ + private function loadPathConfirmationRows(int $departmentId, ?int $configVersionId, int $laneId, ?int $vehicleTypeId, string $configSource): array + { + if (!$this->tableExists('department_selfserve_path_confirmations')) { + return []; + } + + $where = [ + 'department_id = :department_id', + 'lane_id = :lane_id', + 'config_source = :config_source', + 'deleted_at IS NULL', + ]; + $params = [ + ':department_id' => $departmentId, + ':lane_id' => $laneId, + ':config_source' => $configSource, + ]; + if ($configVersionId === null) { + $where[] = 'config_version_id IS NULL'; + } else { + $where[] = 'config_version_id = :config_version_id'; + $params[':config_version_id'] = $configVersionId; + } + if ($vehicleTypeId !== null) { + $where[] = 'vehicle_type_id = :vehicle_type_id'; + $params[':vehicle_type_id'] = $vehicleTypeId; + } + + $statement = db::getPDO()->prepare( + 'SELECT * + FROM department_selfserve_path_confirmations + WHERE ' . implode(' AND ', $where) . ' + ORDER BY confirmed_at DESC, id DESC' + ); + $statement->execute($params); + $rows = $statement->fetchAll(\PDO::FETCH_ASSOC) ?: []; + + return array_values(array_map(function (array $row): array { + $row['answers'] = $this->decodeJsonArray($row['answers_json'] ?? null); + $row['result'] = $this->decodeJsonArray($row['result_json'] ?? null); + $row['scope'] = $this->decodeJsonArray($row['scope_json'] ?? null); + return $row; + }, $rows)); + } + + /** + * @return array|array + */ + private function decodeJsonArray(mixed $value): array + { + if (is_array($value)) { + return $value; + } + $decoded = json_decode((string)($value ?? '[]'), true); + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $services + * @param array> $tasks + * @param array> $signals + */ + private function pathOutcomeSummary(bool $allowed, array $services, array $tasks, array $signals): string + { + $serviceLabel = $services === [] ? 'No services' : implode(', ', $services); + $taskText = count($tasks) === 1 ? '1 task' : count($tasks) . ' tasks'; + $signalText = count($signals) === 1 ? '1 signal' : count($signals) . ' signals'; + return ($allowed ? 'Allowed' : 'Blocked') . ' / ' . $serviceLabel . ' / ' . $taskText . ' / ' . $signalText; + } + + private function stableJson(mixed $value): string + { + $json = json_encode($this->sortStableValue($value), JSON_UNESCAPED_UNICODE); + if ($json === false) { + return ''; + } + return $json; + } + + private function sortStableValue(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $isList = array_keys($value) === range(0, count($value) - 1); + if (!$isList) { + ksort($value); + } + foreach ($value as $key => $item) { + $value[$key] = $this->sortStableValue($item); + } + return $value; + } + + /** + * @param array> $rows + * @return array> + */ + private function vehicleTypeRowsFromProducts(array $rows): array + { + $vehicleTypes = []; + foreach ($this->labelRows($rows, 'name') as $row) { + $productId = (int)($row['id'] ?? 0); + if ($productId <= 0 || (int)($row['is_wash'] ?? 0) !== 1 || (int)($row['subscription_allowed'] ?? 0) !== 1) { + continue; + } + + $row['id'] = $productId; + $row['product'] = $productId; + $row['product_id'] = $productId; + $row['source'] = 'products'; + $vehicleTypes[] = $row; + } + + return $vehicleTypes; + } + + /** + * @param array> $rows + * @return array> + */ + private function labelRows(array $rows, string $labelField): array + { + return array_map(static function (array $row) use ($labelField): array { + $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? ''); + return $row; + }, $rows); + } + + /** + * @param array> $rows + * @return array> + */ + private function configLabelRows(array $rows, string $labelField): array + { + return array_map(static function (array $row) use ($labelField): array { + $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? ''); + return [ + 'id' => (int)($row['id'] ?? 0), + 'label' => $row['label'], + 'raw' => $row, + ]; + }, $rows); + } + + /** + * @param array> $gateways + * @return array> + */ + private function gatewayLabelRows(array $gateways): array + { + $rows = []; + foreach ($gateways as $gateway) { + if (is_array($gateway)) { + $gatewayId = $this->gatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } + $rows[] = [ + 'id' => $gatewayId, + 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), + 'status' => (string)($gateway['status'] ?? 'UNKNOWN'), + 'virtual' => (bool)($gateway['virtual'] ?? false), + 'raw' => $gateway, + ]; + } + } + return $rows; + } + + /** + * @param array> $relays + * @return array> + */ + private function relayLabelRows(array $relays): array + { + $rows = []; + foreach ($relays as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $rows[] = [ + 'id' => $relayId, + 'label' => (string)($relay['name'] ?? ('Relay ' . $relayId)), + 'raw' => $relay, + ]; + } + return $rows; + } + + /** + * @param array> $gateways + * @return array> + */ + private function bindingLabelRows(array $gateways): array + { + $rows = []; + foreach ($gateways as $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = $this->gatewayIdentifier($gateway); + foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($gatewayId === '' || $relayId === '') { + continue; + } + $services = $this->bindingServices($binding, $relayId, []); + $rows[] = [ + 'id' => $gatewayId . ':' . $relayId . ':' . $index, + 'label' => (string)($binding['label'] ?? ('Gateway ' . $gatewayId . ' relay ' . $relayId)), + 'gateway_id' => $gatewayId, + 'relay_id' => $relayId, + 'role' => (string)($binding['role'] ?? ''), + 'services' => $services, + 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), + ]; + } + } + return $rows; + } + + /** + * @return array> + */ + private function fetchRows(string $table, array $columns, array $where): array + { + if (!$this->tableExists($table)) { + return []; + } + $availableColumns = $this->tableColumns($table); + $columns = array_values(array_filter($columns, static fn(string $column): bool => in_array($column, $availableColumns, true))); + if ($columns === []) { + return []; + } + + $conditions = []; + $params = []; + foreach ($where as $field => $value) { + if (!in_array($field, $availableColumns, true)) { + continue; + } + $conditions[] = '`' . $field . '` = :' . $field; + $params[':' . $field] = $value; + } + if (in_array('deleted_at', $availableColumns, true)) { + $conditions[] = '`deleted_at` IS NULL'; + } + $sql = 'SELECT `' . implode('`, `', $columns) . '` FROM `' . $table . '`'; + if ($conditions !== []) { + $sql .= ' WHERE ' . implode(' AND ', $conditions); + } + if (in_array('order_priority', $availableColumns, true)) { + $sql .= ' ORDER BY `order_priority` ASC, `id` ASC'; + } elseif (in_array('id', $availableColumns, true)) { + $sql .= ' ORDER BY `id` ASC'; + } + + $statement = db::getPDO()->prepare($sql); + $statement->execute($params); + return $statement->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } + + private function tableExists(string $table): bool + { + $statement = db::getPDO()->prepare( + 'SELECT COUNT(*) AS c FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table' + ); + $statement->execute([':table' => $table]); + return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; + } + + /** + * @return array + */ + private function tableColumns(string $table): array + { + if (isset($this->columnCache[$table])) { + return $this->columnCache[$table]; + } + $statement = db::getPDO()->prepare( + 'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table' + ); + $statement->execute([':table' => $table]); + $this->columnCache[$table] = array_map( + static fn(array $row): string => (string)$row['COLUMN_NAME'], + $statement->fetchAll(\PDO::FETCH_ASSOC) ?: [] + ); + return $this->columnCache[$table]; + } + + private function normalizeEntity(string $entity): string + { + $entity = strtolower(trim($entity)); + return match ($entity) { + 'questions' => 'question', + 'conditions' => 'condition', + 'rules' => 'rule', + 'tasks' => 'task', + 'actions' => 'action', + 'lanes' => 'lane', + 'paths' => 'path', + default => $entity, + }; + } + + private function normalizeGateType(string $gateType): string + { + $gateType = strtoupper(trim($gateType)); + return in_array($gateType, [ + selfserve_task_gate_type::ALWAYS->value, + selfserve_task_gate_type::CONDITION->value, + selfserve_task_gate_type::QUESTION->value, + ], true) ? $gateType : selfserve_task_gate_type::ALWAYS->value; + } + + private function conditionBelongsToDepartment(int $conditionId, int $departmentId): bool + { + if ($conditionId <= 0) { + return false; + } + + $statement = db::getPDO()->prepare( + "SELECT COUNT(*) AS c + FROM department_selfserve_conditions + WHERE id = :id + AND department IN (0, :department) + AND deleted_at IS NULL" + ); + $statement->execute([ + ':id' => $conditionId, + ':department' => $departmentId, + ]); + + return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; + } + + private function laneBelongsToDepartment(int $laneId, int $departmentId): bool + { + if ($laneId <= 0) { + return false; + } + + $statement = db::getPDO()->prepare( + "SELECT COUNT(*) AS c + FROM department_lanes + WHERE id = :id + AND department = :department + AND deleted_at IS NULL" + ); + $statement->execute([ + ':id' => $laneId, + ':department' => $departmentId, + ]); + + return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; + } + + /** + * @return array{0:string,1:string} + */ + private function parseNodeId(string $nodeId): array + { + $parts = explode(':', $nodeId, 2); + return [ + strtolower((string)($parts[0] ?? '')), + (string)($parts[1] ?? ''), + ]; + } + + private function jsonArray(mixed $value): string + { + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value))); + } + if (!is_array($value)) { + $value = []; + } + $json = json_encode(array_values($value), JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new \RuntimeException('Failed to encode JSON array: ' . json_last_error_msg()); + } + return $json; + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php b/services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php new file mode 100644 index 00000000..4a6b2ea7 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php @@ -0,0 +1,118 @@ +> $tasks + * @return array> + */ + public function attachToTasks(array $tasks): array + { + if ($tasks === []) { + return []; + } + + $taskIds = []; + foreach ($tasks as $task) { + if (!is_array($task)) { + continue; + } + $taskId = $this->taskObjectId($task); + if ($taskId !== null) { + $taskIds[] = $taskId; + } + } + + $attachmentsByTask = []; + if ($taskIds !== []) { + try { + $attachmentsByTask = (new attachments())->listMany(self::OBJECT_TYPE, $taskIds); + } catch (\Throwable) { + $attachmentsByTask = []; + } + } + + $store = new attachment_store(); + return array_values(array_map(function (array $task) use ($attachmentsByTask, $store): array { + $taskId = $this->taskObjectId($task); + $attachments = []; + + if ($taskId !== null && isset($attachmentsByTask[$taskId])) { + foreach ((array)$attachmentsByTask[$taskId] as $attachment) { + if (is_object($attachment)) { + $attachments[] = $this->formatAttachment($attachment, $store); + } + } + } elseif (isset($task['attachments']) && is_array($task['attachments'])) { + $attachments = array_values($task['attachments']); + } + + $task['attachments'] = $attachments; + return $task; + }, $tasks)); + } + + /** + * @param array $task + */ + private function taskObjectId(array $task): ?int + { + $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); + return $taskId > 0 ? $taskId : null; + } + + /** + * @return array + */ + private function formatAttachment(object $attachment, attachment_store $store): array + { + $content = $this->contentPayload($attachment->content ?? null); + $fileName = $content['document'] ?: $content['image'] ?: null; + + return [ + 'id' => isset($attachment->id) ? (int)$attachment->id : null, + 'object_type' => isset($attachment->object_type) ? (string)$attachment->object_type : self::OBJECT_TYPE, + 'object_id' => isset($attachment->object_id) ? (int)$attachment->object_id : null, + 'content' => $content, + 'download_link' => is_string($fileName) && trim($fileName) !== '' + ? $store->generateDirectDownloadUrl($fileName) + : null, + 'created_at' => isset($attachment->created_at) ? (string)$attachment->created_at : null, + 'updated_at' => isset($attachment->updated_at) ? (string)$attachment->updated_at : null, + ]; + } + + /** + * @return array{image:?string,document:?string,relation:mixed,other:mixed} + */ + private function contentPayload(mixed $content): array + { + $payload = is_object($content) && method_exists($content, 'toArray') + ? $content->toArray() + : (is_array($content) ? $content : (array)$content); + + $relation = $payload['relation'] ?? null; + if (is_object($relation) && method_exists($relation, 'toArray')) { + $relation = $relation->toArray(); + } elseif (is_object($relation)) { + $relation = (array)$relation; + } + + return [ + 'image' => isset($payload['image']) && is_string($payload['image']) ? $payload['image'] : null, + 'document' => isset($payload['document']) && is_string($payload['document']) ? $payload['document'] : null, + 'relation' => $relation, + 'other' => $payload['other'] ?? null, + ]; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php b/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php new file mode 100644 index 00000000..038f8371 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php @@ -0,0 +1,784 @@ + + */ + public function getConfig(int $departmentId): array + { + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'SELECT config_json + FROM department_selfserve_studio_virtual_hardware + WHERE department_id = :department_id AND deleted_at IS NULL + LIMIT 1' + ); + $statement->execute([':department_id' => $departmentId]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + if (!is_array($row)) { + return $this->emptyConfig(); + } + + $decoded = json_decode((string)($row['config_json'] ?? '{}'), true); + return $this->normalizeConfig(is_array($decoded) ? $decoded : []); + } + + /** + * @param array $config + * @return array + */ + public function saveConfig(int $departmentId, array $config, ?int $userId = null): array + { + $normalized = $this->normalizeConfig($config); + $json = json_encode($normalized, JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \RuntimeException('Unable to encode virtual hardware config.'); + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'INSERT INTO department_selfserve_studio_virtual_hardware + (department_id, config_json, created_by, updated_by, deleted_at) + VALUES + (:department_id, :config_json, :created_by, :updated_by, NULL) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_by = VALUES(updated_by), + deleted_at = NULL' + ); + $statement->execute([ + ':department_id' => $departmentId, + ':config_json' => $json, + ':created_by' => $userId, + ':updated_by' => $userId, + ]); + + return $normalized; + } + + /** + * @param array $payload + * @param array $realWorkspace + * @return array + */ + public function applyOperation(int $departmentId, string $operation, array $payload, ?int $userId, array $realWorkspace): array + { + $config = $this->getConfig($departmentId); + $operation = strtolower(trim($operation)); + + if ($operation === 'generate_from_lanes') { + $config = $this->generateFromLanes($realWorkspace, $config); + } elseif ($operation === 'upsert_gateway') { + $config = $this->upsertGateway($config, $payload); + } elseif ($operation === 'upsert_binding') { + $config = $this->upsertBinding($config, $payload, $realWorkspace); + } elseif ($operation === 'delete_binding') { + $config = $this->deleteBinding($config, $payload); + } elseif ($operation === 'reset') { + $config = $this->emptyConfig(); + } else { + throw new \RuntimeException('Unsupported virtual hardware operation: ' . $operation); + } + + return $this->saveConfig($departmentId, $config, $userId); + } + + /** + * @param array $workspace + * @return array + */ + public function mergeWorkspace(array $workspace, int $departmentId): array + { + return $this->mergeWorkspaceWithConfig($workspace, $this->getConfig($departmentId)); + } + + /** + * Pure merge helper used by graph serialization and tests. + * + * @param array $workspace + * @param array $config + * @return array + */ + public function mergeWorkspaceWithConfig(array $workspace, array $config): array + { + $config = $this->normalizeConfig($config); + $workspace += [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + ]; + + $virtualBindings = (array)($config['bindings'] ?? []); + $enabled = (bool)($config['enabled'] ?? true); + if (!$enabled || $virtualBindings === []) { + $workspace['virtual'] = $this->workspaceVirtualSummary($config, 0, 0); + return $workspace; + } + + $realBindingRelayIds = $this->realBindingRelayIds((array)($workspace['gateways'] ?? [])); + $virtualGateways = $this->virtualGatewaysForWorkspace($config); + $virtualRelays = $this->virtualRelaysForWorkspace($config); + $bindingsByRelayId = $this->indexVirtualBindingsByRelayId($virtualGateways); + + $workspace['gateways'] = array_values(array_merge((array)($workspace['gateways'] ?? []), $virtualGateways)); + $workspace['relays'] = $this->mergeRelays((array)($workspace['relays'] ?? []), $virtualRelays); + $workspace['lanes'] = $this->mergeLaneCoverage((array)($workspace['lanes'] ?? []), $bindingsByRelayId); + + $coveredRelayIds = array_fill_keys(array_keys($bindingsByRelayId), true); + $workspace['issues'] = $this->mergeIssues((array)($workspace['issues'] ?? []), $coveredRelayIds, $virtualGateways); + $workspace['virtual'] = $this->workspaceVirtualSummary($config, count($virtualGateways), count($virtualBindings), $realBindingRelayIds); + $workspace['summary'] = $this->mergeSummary((array)($workspace['summary'] ?? []), $workspace); + + return $workspace; + } + + /** + * @param array $workspace + * @return array + */ + public function validationWarnings(array $workspace): array + { + $virtual = is_array($workspace['virtual'] ?? null) ? (array)$workspace['virtual'] : []; + if (($virtual['has_virtual_hardware'] ?? false) !== true) { + return []; + } + + $warnings = [ + 'Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.', + ]; + $virtualOnlyRelays = (array)($virtual['virtual_only_relay_ids'] ?? []); + if ($virtualOnlyRelays !== []) { + $warnings[] = 'Virtual coverage only for relay IDs: ' . implode(', ', $virtualOnlyRelays) . '.'; + } + + return $warnings; + } + + /** + * @param array $workspace + * @param array|null $baseConfig + * @return array + */ + public function generateFromLanes(array $workspace, ?array $baseConfig = null): array + { + $config = $this->normalizeConfig($baseConfig ?? $this->emptyConfig()); + $gatewayKey = self::DEFAULT_GATEWAY_KEY; + $config = $this->upsertGateway($config, [ + 'key' => $gatewayKey, + 'label' => 'Virtual Studio Gateway', + 'status' => 'VIRTUAL', + ]); + + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + $role = $this->normalizeRole($slot['slot'] ?? $slot['role'] ?? ''); + if ($relayId === '' || $role === '') { + continue; + } + $config = $this->upsertBinding($config, [ + 'gateway_key' => $gatewayKey, + 'relay_id' => $relayId, + 'role' => $role, + 'services' => [$role], + 'label' => trim((string)($lane['name'] ?? ('Lane ' . ($lane['id'] ?? '')))) . ' ' . $role, + 'lane_id' => (int)($lane['id'] ?? 0), + 'slot' => $role, + 'generated' => true, + ], $workspace); + } + } + + return $config; + } + + /** + * @return array + */ + public function emptyConfig(): array + { + return [ + 'schema_version' => self::SCHEMA_VERSION, + 'enabled' => true, + 'gateways' => [], + 'relays' => [], + 'bindings' => [], + ]; + } + + /** + * @param array $config + * @return array + */ + public function normalizeConfig(array $config): array + { + $normalized = $this->emptyConfig(); + $normalized['schema_version'] = (int)($config['schema_version'] ?? self::SCHEMA_VERSION); + $normalized['enabled'] = filter_var($config['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false; + + foreach ((array)($config['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $key = $this->normalizeGatewayKey($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? ''); + if ($key === '') { + continue; + } + $normalized['gateways'][$key] = [ + 'key' => $key, + 'label' => trim((string)($gateway['label'] ?? ('Virtual Gateway ' . $key))), + 'status' => strtoupper(trim((string)($gateway['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL', + 'metadata' => is_array($gateway['metadata'] ?? null) ? (array)$gateway['metadata'] : [], + ]; + } + + foreach ((array)($config['relays'] ?? []) as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $normalized['relays'][$relayId] = [ + 'relay_id' => $relayId, + 'name' => trim((string)($relay['name'] ?? $relay['label'] ?? ('Virtual relay ' . $relayId))), + 'status' => strtoupper(trim((string)($relay['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL', + 'virtual' => true, + ]; + } + + $bindings = []; + foreach ((array)($config['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? $binding['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY); + $relayId = trim((string)($binding['relay_id'] ?? '')); + $role = $this->normalizeRole($binding['role'] ?? $binding['slot'] ?? $binding['service'] ?? ''); + if ($gatewayKey === '' || $relayId === '') { + continue; + } + if (!isset($normalized['gateways'][$gatewayKey])) { + $normalized['gateways'][$gatewayKey] = [ + 'key' => $gatewayKey, + 'label' => 'Virtual Gateway ' . $gatewayKey, + 'status' => 'VIRTUAL', + 'metadata' => [], + ]; + } + if (!isset($normalized['relays'][$relayId])) { + $normalized['relays'][$relayId] = [ + 'relay_id' => $relayId, + 'name' => trim((string)($binding['label'] ?? ('Virtual relay ' . $relayId))), + 'status' => 'VIRTUAL', + 'virtual' => true, + ]; + } + $services = $this->normalizeServiceList($binding['services'] ?? ($role !== '' ? [$role] : [])); + if ($services === [] && $role !== '') { + $services = [$role]; + } + $id = $this->bindingId($gatewayKey, $relayId, $role); + $bindings[$id] = [ + 'id' => $id, + 'gateway_key' => $gatewayKey, + 'relay_id' => $relayId, + 'role' => $role, + 'slot' => $role, + 'services' => $services, + 'label' => trim((string)($binding['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))), + 'channel' => array_key_exists('channel', $binding) ? (int)$binding['channel'] : 0, + 'lane_id' => isset($binding['lane_id']) ? (int)$binding['lane_id'] : null, + 'generated' => (bool)($binding['generated'] ?? false), + 'virtual' => true, + ]; + } + + $normalized['gateways'] = array_values($normalized['gateways']); + $normalized['relays'] = array_values($normalized['relays']); + $normalized['bindings'] = array_values($bindings); + + return $normalized; + } + + /** + * @param array $config + * @param array $payload + * @return array + */ + private function upsertGateway(array $config, array $payload): array + { + $config = $this->normalizeConfig($config); + $key = $this->normalizeGatewayKey($payload['key'] ?? $payload['gateway_key'] ?? $payload['id'] ?? self::DEFAULT_GATEWAY_KEY); + if ($key === '') { + throw new \RuntimeException('Virtual gateway key is required.'); + } + + $gateways = []; + foreach ((array)$config['gateways'] as $gateway) { + $gateways[$this->normalizeGatewayKey($gateway['key'] ?? $gateway['id'] ?? '')] = $gateway; + } + $gateways[$key] = [ + 'key' => $key, + 'label' => trim((string)($payload['label'] ?? $gateways[$key]['label'] ?? ('Virtual Gateway ' . $key))), + 'status' => strtoupper(trim((string)($payload['status'] ?? $gateways[$key]['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL', + 'metadata' => is_array($payload['metadata'] ?? null) ? (array)$payload['metadata'] : (array)($gateways[$key]['metadata'] ?? []), + ]; + $config['gateways'] = array_values($gateways); + + return $this->normalizeConfig($config); + } + + /** + * @param array $config + * @param array $payload + * @param array $workspace + * @return array + */ + private function upsertBinding(array $config, array $payload, array $workspace): array + { + $config = $this->normalizeConfig($config); + $gatewayKey = $this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY); + $relayId = trim((string)($payload['relay_id'] ?? '')); + $role = $this->normalizeRole($payload['role'] ?? $payload['slot'] ?? $payload['service'] ?? ''); + if ($gatewayKey === '' || $relayId === '') { + throw new \RuntimeException('Virtual gateway key and relay id are required.'); + } + + $config = $this->upsertGateway($config, ['key' => $gatewayKey]); + $services = $this->normalizeServiceList($payload['services'] ?? ($role !== '' ? [$role] : [])); + if ($services === [] && $role !== '') { + $services = [$role]; + } + $binding = [ + 'id' => $this->bindingId($gatewayKey, $relayId, $role), + 'gateway_key' => $gatewayKey, + 'relay_id' => $relayId, + 'role' => $role, + 'slot' => $role, + 'services' => $services, + 'label' => trim((string)($payload['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))), + 'channel' => array_key_exists('channel', $payload) ? (int)$payload['channel'] : 0, + 'lane_id' => isset($payload['lane_id']) ? (int)$payload['lane_id'] : $this->laneIdForRelay($workspace, $relayId), + 'generated' => (bool)($payload['generated'] ?? false), + 'virtual' => true, + ]; + + $bindings = []; + foreach ((array)$config['bindings'] as $existing) { + if (!is_array($existing)) { + continue; + } + $bindings[(string)($existing['id'] ?? $this->bindingId((string)($existing['gateway_key'] ?? ''), (string)($existing['relay_id'] ?? ''), (string)($existing['role'] ?? '')))] = $existing; + } + $bindings[$binding['id']] = $binding; + $config['bindings'] = array_values($bindings); + $config['relays'][] = [ + 'relay_id' => $relayId, + 'name' => trim((string)($payload['relay_label'] ?? $payload['label'] ?? ('Virtual relay ' . $relayId))), + 'status' => 'VIRTUAL', + 'virtual' => true, + ]; + + return $this->normalizeConfig($config); + } + + /** + * @param array $config + * @param array $payload + * @return array + */ + private function deleteBinding(array $config, array $payload): array + { + $config = $this->normalizeConfig($config); + $id = trim((string)($payload['id'] ?? '')); + if ($id === '') { + $id = $this->bindingId( + $this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY), + trim((string)($payload['relay_id'] ?? '')), + $this->normalizeRole($payload['role'] ?? $payload['slot'] ?? '') + ); + } + + $config['bindings'] = array_values(array_filter((array)$config['bindings'], static function (array $binding) use ($id): bool { + return (string)($binding['id'] ?? '') !== $id; + })); + + return $this->normalizeConfig($config); + } + + /** + * @param array $config + * @return array> + */ + private function virtualGatewaysForWorkspace(array $config): array + { + $bindingsByGateway = []; + foreach ((array)($config['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? self::DEFAULT_GATEWAY_KEY); + $bindingsByGateway[$gatewayKey][] = [ + ...$binding, + 'gateway_id' => $gatewayKey, + 'gateway_key' => $gatewayKey, + 'node_id' => 'binding:' . $gatewayKey . ':' . (string)($binding['relay_id'] ?? '') . ':' . count($bindingsByGateway[$gatewayKey] ?? []), + 'virtual' => true, + ]; + } + + $gateways = []; + foreach ((array)($config['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $key = $this->normalizeGatewayKey($gateway['key'] ?? self::DEFAULT_GATEWAY_KEY); + $bindings = array_values($bindingsByGateway[$key] ?? []); + if ($bindings === []) { + continue; + } + $gateways[] = [ + 'id' => $key, + 'key' => $key, + 'label' => (string)($gateway['label'] ?? ('Virtual Gateway ' . $key)), + 'status' => (string)($gateway['status'] ?? 'VIRTUAL'), + 'virtual' => true, + 'studio_only' => true, + 'bindings' => $bindings, + 'metadata' => (array)($gateway['metadata'] ?? []), + ]; + } + + return $gateways; + } + + /** + * @param array $config + * @return array> + */ + private function virtualRelaysForWorkspace(array $config): array + { + return array_values(array_map(static function (array $relay): array { + return [ + ...$relay, + 'virtual' => true, + 'studio_only' => true, + ]; + }, (array)($config['relays'] ?? []))); + } + + /** + * @param array> $gateways + * @return array>> + */ + private function indexVirtualBindingsByRelayId(array $gateways): array + { + $bindings = []; + foreach ($gateways as $gateway) { + foreach ((array)($gateway['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $bindings[$relayId][] = [ + ...$binding, + 'gateway_id' => (string)($gateway['id'] ?? ''), + 'gateway_label' => (string)($gateway['label'] ?? 'Virtual Gateway'), + 'gateway_status' => (string)($gateway['status'] ?? 'VIRTUAL'), + 'virtual' => true, + 'studio_only' => true, + ]; + } + } + return $bindings; + } + + /** + * @param array> $realRelays + * @param array> $virtualRelays + * @return array> + */ + private function mergeRelays(array $realRelays, array $virtualRelays): array + { + $rows = []; + foreach (array_merge($realRelays, $virtualRelays) as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $rows[$relayId . ':' . (!empty($relay['virtual']) ? 'virtual' : 'real')] = $relay; + } + return array_values($rows); + } + + /** + * @param array> $lanes + * @param array>> $bindingsByRelayId + * @return array> + */ + private function mergeLaneCoverage(array $lanes, array $bindingsByRelayId): array + { + foreach ($lanes as $laneIndex => $lane) { + if (!is_array($lane)) { + continue; + } + $required = 0; + $bound = 0; + foreach ((array)($lane['relay_slots'] ?? []) as $slotIndex => $slot) { + if (!is_array($slot)) { + continue; + } + $required += 1; + $relayId = trim((string)($slot['relay_id'] ?? '')); + $covered = (bool)($slot['coverage']['covered'] ?? false); + if (!$covered && $relayId !== '' && isset($bindingsByRelayId[$relayId])) { + $slot['coverage'] = [ + 'relay_id' => $relayId, + 'covered' => true, + 'status' => 'VIRTUAL', + 'binding_count' => count($bindingsByRelayId[$relayId]), + 'primary_binding' => $bindingsByRelayId[$relayId][0] ?? null, + 'bindings' => $bindingsByRelayId[$relayId], + 'virtual' => true, + 'studio_only' => true, + ]; + $slot['virtual'] = true; + $covered = true; + } + if ($covered) { + $bound += 1; + } + $lane['relay_slots'][$slotIndex] = $slot; + } + $lane['binding_coverage'] = [ + 'required' => $required, + 'bound' => $bound, + 'missing' => max(0, $required - $bound), + 'state' => $required === 0 ? 'NOT_REQUIRED' : ($bound === $required ? 'READY' : 'MISSING'), + 'virtual' => $this->laneHasVirtualCoverage($lane), + ]; + $lanes[$laneIndex] = $lane; + } + + return $lanes; + } + + /** + * @param array $lane + */ + private function laneHasVirtualCoverage(array $lane): bool + { + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (is_array($slot) && !empty($slot['coverage']['virtual'])) { + return true; + } + } + return false; + } + + /** + * @param array> $issues + * @param array $coveredRelayIds + * @param array> $virtualGateways + * @return array> + */ + private function mergeIssues(array $issues, array $coveredRelayIds, array $virtualGateways): array + { + $filtered = []; + foreach ($issues as $issue) { + if (!is_array($issue)) { + continue; + } + $code = strtoupper((string)($issue['code'] ?? '')); + if (in_array($code, ['LANE_BINDING_GAP', 'SCANNER_LANE_PARTIAL', 'SELFSERVE_PARTIAL_READY'], true)) { + $filtered[] = [ + ...$issue, + 'severity' => 'info', + 'virtual' => true, + 'message' => (string)($issue['message'] ?? 'Relay coverage is incomplete.') . ' Studio virtual hardware covers this for dry runs only.', + ]; + continue; + } + if (in_array($code, ['NO_GATEWAY', 'NO_ONLINE_GATEWAY'], true) && $virtualGateways !== []) { + $filtered[] = [ + ...$issue, + 'severity' => 'warning', + 'virtual' => true, + 'message' => (string)($issue['message'] ?? 'Real gateway is not ready.') . ' A virtual studio gateway is available for dry runs only.', + ]; + continue; + } + $filtered[] = $issue; + } + + $filtered[] = [ + 'severity' => 'warning', + 'code' => 'VIRTUAL_HARDWARE_ACTIVE', + 'message' => 'Virtual studio hardware is active. It counts for studio validation and simulation only; live dispatch still requires real gateway bindings.', + 'virtual' => true, + 'relay_ids' => array_keys($coveredRelayIds), + ]; + + return $filtered; + } + + /** + * @param array $summary + * @param array $workspace + * @return array + */ + private function mergeSummary(array $summary, array $workspace): array + { + $virtual = (array)($workspace['virtual'] ?? []); + $summary['virtual_gateway_count'] = (int)($virtual['gateway_count'] ?? 0); + $summary['virtual_binding_count'] = (int)($virtual['binding_count'] ?? 0); + $summary['has_virtual_hardware'] = (bool)($virtual['has_virtual_hardware'] ?? false); + return $summary; + } + + /** + * @param array $config + * @return array + */ + /** + * @param array $realBindingRelayIds + */ + private function workspaceVirtualSummary(array $config, int $gatewayCount, int $bindingCount, array $realBindingRelayIds = []): array + { + $relayIds = []; + foreach ((array)($config['bindings'] ?? []) as $binding) { + if (is_array($binding) && trim((string)($binding['relay_id'] ?? '')) !== '') { + $relayId = trim((string)$binding['relay_id']); + if (!isset($realBindingRelayIds[$relayId])) { + $relayIds[] = $relayId; + } + } + } + + return [ + 'schema_version' => (int)($config['schema_version'] ?? self::SCHEMA_VERSION), + 'enabled' => (bool)($config['enabled'] ?? true), + 'has_virtual_hardware' => $bindingCount > 0, + 'gateway_count' => $gatewayCount, + 'binding_count' => $bindingCount, + 'virtual_only_relay_ids' => array_values(array_unique($relayIds)), + 'config' => $config, + ]; + } + + /** + * @param array> $gateways + * @return array + */ + private function realBindingRelayIds(array $gateways): array + { + $relayIds = []; + foreach ($gateways as $gateway) { + if (!is_array($gateway) || !empty($gateway['virtual'])) { + continue; + } + foreach ((array)($gateway['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId !== '') { + $relayIds[$relayId] = true; + } + } + } + return $relayIds; + } + + /** + * @param array $workspace + */ + private function laneIdForRelay(array $workspace, string $relayId): ?int + { + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (is_array($slot) && trim((string)($slot['relay_id'] ?? '')) === $relayId) { + return (int)($lane['id'] ?? 0) ?: null; + } + } + } + return null; + } + + private function normalizeGatewayKey(mixed $value): string + { + return trim((string)$value); + } + + private function normalizeRole(mixed $value): string + { + return strtoupper(trim((string)$value)); + } + + /** + * @return array + */ + private function normalizeServiceList(mixed $value): array + { + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : explode(',', $value); + } + if (!is_array($value)) { + $value = [$value]; + } + + $services = []; + foreach ($value as $entry) { + if (is_array($entry)) { + continue; + } + $service = $this->normalizeRole($entry); + if ($service !== '') { + $services[$service] = true; + } + } + return array_keys($services); + } + + private function bindingId(string $gatewayKey, string $relayId, string $role): string + { + return $gatewayKey . ':' . $relayId . ':' . ($role !== '' ? $role : 'relay'); + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php new file mode 100644 index 00000000..cf90eca1 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -0,0 +1,3540 @@ +conditionEvaluator ??= new selfserve_condition_evaluator(); + } + + public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array + { + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); + $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); + + return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null); + } + + /** + * @param array $options + * @return array + */ + public function previewStudioSimulation(int $departmentId, int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array + { + $options['debug'] = true; + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); + $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); + $response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null); + $response['simulator_version'] = 2; + $response['dry_run'] = true; + $response['mode'] = 'full_dry_run'; + $response['config_source'] = (string)($options['config_source'] ?? 'draft'); + $response['debug'] = $this->buildStudioDebugPayload($departmentId, $snapshot, $options); + + return $response; + } + + public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array + { + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); + $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); + + if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) { + return $session->exists() + ? $this->getSessionSummary((int)$session->id) + : $this->formatBlockedSessionSummary($snapshot); + } + + if (!$session->exists()) { + $session = (new selfserve_wash_sessions_o())->add( + $laneId, + (int)$snapshot['lane']['department'], + $snapshot['machine_type']['id'] ?? null, + $snapshot['customer_number'], + $snapshot['reg'], + $snapshot['vehicle']['id'] ?? null, + $snapshot['vehicle']['type'] ?? null, + $this->deriveBaseStatus($snapshot), + (bool)$snapshot['allowed'], + $this->buildSessionMetadata($snapshot), + ); + } else { + $session->machine_type_id->set($snapshot['machine_type']['id'] ?? null); + $session->customer_number->set($snapshot['customer_number']); + $session->vehicle_id->set($snapshot['vehicle']['id'] ?? null); + $session->vehicle_type_id->set($snapshot['vehicle_type_id']); + $session->reg->set($snapshot['reg']); + $session->allowed->set((bool)$snapshot['allowed']); + $session->metadata_json->set($this->buildSessionMetadata($snapshot)); + $session->updateStatus($this->deriveCurrentStatus($snapshot, $session)); + } + + $this->syncSessionAnswers((int)$session->id, $snapshot['questions']); + $this->syncSessionTasks((int)$session->id, $snapshot['tasks']); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [ + 'allowed' => (bool)$snapshot['allowed'], + 'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'], + 'allowed_services' => $snapshot['allowed_services'], + 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), + ]); + + if ($syncRelayState) { + $this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine); + } + + return $this->getSessionSummary((int)$session->id); + } + + public function recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = []): array + { + $normalizedReg = $reg === null ? null : selfserve::standardize_registration($reg); + $session = $normalizedReg !== null + ? $this->findLatestOpenSession($laneId, $normalizedReg) + : $this->findLatestOpenSessionByLane($laneId); + + if (!$session->exists()) { + if ($normalizedReg === null) { + throw new \RuntimeException('No active self-serve wash session found for the lane.'); + } + $summary = $this->synchronizeSession($laneId, $normalizedReg, null, false, null, false); + if (empty($summary['session']['id'])) { + throw new \RuntimeException((string)($summary['blocked_reason'] ?? 'Self-serve is disabled for this lane.')); + } + $session = (new selfserve_wash_sessions_o())->select((int)$summary['session']['id']); + } + + $lane = (new selfserve())->lane($laneId); + $effectiveReg = $normalizedReg ?? (string)$session->reg->value(); + $customerNumber = $session->customer_number->value() === null ? null : (int)$session->customer_number->value(); + + if ($lane->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) { + $lane->setLaneStatus(selfserve_lane_status::OCCUPIED); + } + if (!$lane->getLaneState()->equals(selfserve_lane_state::IN_WASH)) { + $lane->setLaneState(selfserve_lane_state::IN_WASH); + } + if ($effectiveReg !== '') { + $lane->setLicensePlate($effectiveReg); + } + if ($customerNumber !== null && $customerNumber > 0) { + $lane->setCustomerNumber($customerNumber); + } + if ((int)$lane->getWashStartTime() <= 0) { + $lane->setWashStartTime(time()); + } + $washStartedAt = (int)$lane->getWashStartTime(); + + $session->markMachineStartTriggered( + $washStartedAt > 0 ? date('Y-m-d H:i:s', $washStartedAt) : null + ); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_START_TRIGGERED, $payload + [ + 'lane_id' => $laneId, + 'reg' => $effectiveReg, + 'customer_number' => $customerNumber, + ]); + $actionContext = [ + 'lane_id' => $laneId, + 'reg' => $effectiveReg, + 'customer_number' => $customerNumber, + 'session_id' => (int)$session->id, + 'source_payload' => $payload, + ]; + try { + if ($effectiveReg !== '') { + $actionSnapshot = $this->buildEligibilitySnapshot( + $laneId, + $effectiveReg, + $customerNumber, + $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), + ['config_source' => 'published'] + ); + if (is_array($actionSnapshot['evaluation_trace']['condition_results'] ?? null)) { + $actionContext['condition_results'] = (array)$actionSnapshot['evaluation_trace']['condition_results']; + } + if (is_array($actionSnapshot['evaluation_trace']['visibility_condition_results'] ?? null)) { + $actionContext['visibility_condition_results'] = (array)$actionSnapshot['evaluation_trace']['visibility_condition_results']; + } + $actionContext['allowed_services'] = (array)($actionSnapshot['allowed_services'] ?? []); + $actionContext['vehicle_type_id'] = $actionSnapshot['vehicle_type_id'] ?? null; + $actionContext['product'] = $actionSnapshot['vehicle_type_id'] ?? null; + $actionContext['machine_type_id'] = $actionSnapshot['machine_type']['id'] ?? null; + } + } catch (\Throwable) { + // Action execution should stay best-effort even when preview context cannot be rebuilt. + } + (new selfserve_studio_action_runner())->executeForLaneEvent( + $lane, + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED, + selfserve_studio_actions::MODE_MACHINE, + $actionContext + ); + $this->enableCleanerRelayForStartedWash($lane); + + return $this->getSessionSummary((int)$session->id); + } + + public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool + { + $normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg); + $session = $normalizedReg !== null + ? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber) + : $this->findLatestOpenSessionByLane($laneId, $customerNumber); + + return $session->exists() && (bool)$session->machine_start_triggered->value(); + } + + protected function enableCleanerRelayForStartedWash(selfserve_lane $lane): void + { + try { + if ( + empty($lane->department_lane) + || empty($lane->department_lane->relay_machine_cleaner_id) + || trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) === '' + ) { + return; + } + $lane->setMachineCleanerRelayStatusHard(true); + } catch (\Throwable) { + // Best effort only; webhook start flow must continue. + } + } + + protected function disableMachineRelayForCompletedWash(int $laneId): void + { + try { + $lane = (new selfserve())->lane($laneId); + if (empty($lane->department_lane)) { + return; + } + + $this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE); + $this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER); + } catch (\Throwable) { + // Best effort only; session completion flow must continue. + } + } + + protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void + { + if (!$this->isRelayConfiguredForLane($lane, $relay)) { + return; + } + + try { + $lane->setRelayStatusHard($relay, false); + } catch (\Throwable) { + // Best effort only; session completion flow must continue. + } + } + + protected function isRelayConfiguredForLane(selfserve_lane $lane, selfserve_lane_relay $relay): bool + { + if (empty($lane->department_lane)) { + return false; + } + + $relayId = match ($relay) { + selfserve_lane_relay::MACHINE => (string)$lane->department_lane->relay_machine_id->value(), + selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$lane->department_lane->relay_machine_program_picker_id->value(), + selfserve_lane_relay::MACHINE_CLEANER => (string)$lane->department_lane->relay_machine_cleaner_id->value(), + }; + + return trim($relayId) !== ''; + } + + public function getSessionSummary(int $sessionId): array + { + $session = (new selfserve_wash_sessions_o())->select($sessionId); + if (!$session->exists()) { + throw new \RuntimeException('Self-serve wash session not found.'); + } + + $lane = (new department_lanes_o())->select((int)$session->lane_id->value()); + $machineType = null; + if ($session->machine_type_id->value() !== null) { + $machineTypeObject = (new selfserve_machine_types_o())->select((int)$session->machine_type_id->value()); + if ($machineTypeObject->exists()) { + $machineType = $machineTypeObject->asArray(); + } + } + + $answerRows = (new selfserve_wash_session_answers_o())->listBySession($sessionId); + $answers = $this->buildSessionQuestions($session, $answerRows); + $metadata = is_array($session->metadata_json->value()) ? $session->metadata_json->value() : []; + $allowedServices = $this->normalizeServiceNames( + is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : [] + ); + $machineAvailable = array_key_exists('machine_available', $metadata) + ? (bool)$metadata['machine_available'] + : ($lane->exists() && !empty($lane->relay_machine_id->value())); + $allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata) + ? (bool)$metadata['all_visible_questions_answered'] + : true; + if (!array_key_exists('all_visible_questions_answered', $metadata)) { + foreach ($answers as $answer) { + if (($answer['answer'] ?? null) === null) { + $allVisibleQuestionsAnswered = false; + break; + } + } + } + + $tasks = array_map(function (array $row): array { + return [ + 'task_id' => $row['task_id'] === null ? null : (int)$row['task_id'], + 'task' => (string)$row['task_text'], + 'description' => $row['description'] === null ? null : (string)$row['description'], + 'services' => $this->normalizeJsonArray($row['services'] ?? null), + 'buttons' => $this->normalizeJsonArray($row['buttons'] ?? null), + 'dynamic_images_vehicle_type' => $row['dynamic_images_vehicle_type'] === null ? null : (int)$row['dynamic_images_vehicle_type'] + ]; + }, (new selfserve_wash_session_tasks_o())->listBySession($sessionId)); + if (array_key_exists('allowed_services', $metadata) || (bool)$session->allowed->value() === false) { + $tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices); + } + $tasks = (new selfserve_task_attachment_payloads())->attachToTasks($tasks); + + $events = array_map(function (array $row): array { + return [ + 'id' => (int)$row['id'], + 'type' => (string)$row['event_type'], + 'payload' => $this->normalizeJsonValue($row['payload_json'] ?? null), + 'created_at' => (string)$row['created_at'], + ]; + }, (new selfserve_wash_session_events_o())->listBySession($sessionId)); + + return [ + 'session' => $session->asArray(), + 'lane' => $lane->exists() ? $lane->asArray() : null, + 'machine_type' => $machineType, + 'questions' => $answers, + 'tasks' => $tasks, + 'events' => $events, + 'allowed_services' => $allowedServices, + 'machine_available' => $machineAvailable, + 'all_visible_questions_answered' => $allVisibleQuestionsAnswered, + 'allowed' => (bool)$session->allowed->value(), + 'config_version_id' => $metadata['config_version_id'] ?? null, + 'evaluation_trace' => $metadata['evaluation_trace'] ?? null, + ]; + } + + public function getLatestSessionSummary(int $laneId, string $reg): array + { + $session = (new selfserve_wash_sessions_o())->selectLatestByLaneAndReg($laneId, selfserve::standardize_registration($reg)); + if (!$session->exists()) { + throw new \RuntimeException('No self-serve wash session found for the lane and vehicle.'); + } + + return $this->getSessionSummary((int)$session->id); + } + + public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array + { + $session = $reg !== null + ? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber) + : $this->findLatestOpenSessionByLane($laneId, $customerNumber); + + if (!$session->exists()) { + return null; + } + + $session->markCompleted($orderId); + $this->disableMachineRelayForCompletedWash($laneId); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [ + 'lane_id' => $laneId, + 'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg), + 'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()), + 'order_id' => $orderId, + ]); + + return $this->getSessionSummary((int)$session->id); + } + + public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array + { + $lane = (new selfserve())->lane($laneId); + $session = $this->resolveForceStopSession($laneId, $sessionId); + $runtimeSnapshot = $this->buildForceStopRuntimeSnapshot($lane); + $hasRuntime = $this->laneRuntimeLooksActive($runtimeSnapshot); + if (!$session->exists() && !$hasRuntime) { + throw new \RuntimeException('No active self-serve wash session or lane runtime found.'); + } + + $orderId = null; + if ($bill) { + try { + if ($lane->invoice() !== true) { + throw new \RuntimeException('Elapsed-minute invoice was not created.'); + } + $orderId = method_exists($lane, 'getLastInvoiceOrderId') ? $lane->getLastInvoiceOrderId() : null; + } catch (\Throwable $e) { + throw new \RuntimeException('Failed to bill elapsed minutes before force stop: ' . $e->getMessage(), 409, $e); + } + } + + $summary = null; + if ($session->exists()) { + $eventPayload = [ + 'lane_id' => $laneId, + 'reason' => $reason, + 'user_id' => $userId, + 'bill' => $bill, + 'order_id' => $orderId, + 'runtime_before_reset' => $runtimeSnapshot, + 'forced_at' => date('Y-m-d H:i:s'), + ]; + $session->markForceStopped($orderId, $eventPayload); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload); + $summary = $this->getSessionSummary((int)$session->id); + } + + $lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); + + return [ + 'lane_id' => $laneId, + 'forced' => true, + 'bill' => $bill, + 'order_id' => $orderId, + 'session' => $summary, + 'runtime_before_reset' => $runtimeSnapshot, + ]; + } + + /** + * @param array $options + */ + protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array + { + $normalizedReg = selfserve::standardize_registration($reg); + $lane = (new department_lanes_o())->select($laneId); + if (!$lane->exists()) { + throw new \RuntimeException('Department lane not found.'); + } + + $departmentId = (int)$lane->department->value(); + $machineTypeId = $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(); + if (!$lane->isSelfServeEnabled()) { + $vehicle = $this->findVehicleByRegistration($normalizedReg); + $vehicleData = $vehicle?->asArray(); + $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride); + $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null); + + return [ + 'lane' => $lane->asArray(), + 'machine_type' => null, + 'vehicle' => $vehicleData, + 'reg' => $normalizedReg, + 'customer_number' => $resolvedCustomerNumber, + 'vehicle_type_id' => $vehicleTypeId, + 'answers' => [], + 'persisted_answers' => [], + 'persisted_answer_customer_number' => null, + 'answer_overrides' => [], + 'answer_sources' => [], + 'questions' => [], + 'conditions' => [], + 'tasks' => [], + 'allowed_services' => [], + 'machine_available' => false, + 'all_visible_questions_answered' => false, + 'allowed' => false, + 'blocked_reason' => 'Self-serve is disabled for this lane.', + 'config_version_id' => null, + 'config_source' => (string)($options['config_source'] ?? 'published'), + 'evaluation_trace' => [ + 'blocked' => true, + 'blocking_reasons' => ['LANE_SELFSERVE_DISABLED'], + 'disabled_lane' => true, + 'message' => 'Self-serve is disabled for this lane.', + 'visibility_condition_results' => [], + 'condition_results' => [], + 'visibility_expression_traces' => [], + 'condition_expression_traces' => [], + 'task_gates' => [], + 'visible_question_ids' => [], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + 'actions' => [], + 'visible_answers' => [], + ], + ]; + } + $configSource = (string)($options['config_source'] ?? 'published'); + $publishedConfigVersionId = $options['config_version_id'] ?? null; + $publishedConfigPayload = is_array($options['config_payload'] ?? null) ? (array)$options['config_payload'] : null; + $versioning = new selfserve_config_versioning(); + if ($publishedConfigPayload === null) { + $publishedConfig = $versioning->getPublishedV2Config($departmentId); + $publishedConfigVersionId = $publishedConfig['version_id'] ?? null; + $publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null; + $configSource = $publishedConfigPayload === null ? 'legacy' : 'published'; + } + $isV2Config = is_array($publishedConfigPayload) && $versioning->isV2Config($publishedConfigPayload); + $vehicle = $this->findVehicleByRegistration($normalizedReg); + $vehicleData = $vehicle?->asArray(); + $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride); + $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null); + $persistedAnswerCustomerNumber = $this->resolvePersistedAnswerCustomerNumber($resolvedCustomerNumber); + + $questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId, $publishedConfigPayload); + $conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload); + $rules = $this->loadConditionRules($conditions, $publishedConfigPayload); + $persistedAnswers = $this->loadPersistedAnswers($departmentId, $laneId, $normalizedReg, $persistedAnswerCustomerNumber); + $answerOverrides = $this->normalizeAnswerOverrides($options['answer_overrides'] ?? []); + $answers = $this->applyAnswerOverrides($persistedAnswers, $answerOverrides); + $answerSources = $this->buildAnswerSources($persistedAnswers, $answerOverrides); + if ($isV2Config) { + $visibilityEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $answers); + $visibilityConditionResults = (array)($visibilityEvaluation['results'] ?? []); + $visibilityExpressionTrace = (array)($visibilityEvaluation['trace'] ?? []); + } else { + $visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers); + $visibilityExpressionTrace = []; + } + + $visibleQuestions = []; + $visibleQuestionIds = []; + foreach ($questions as $question) { + $gateId = $this->nullableInt($question['condition_id'] ?? null); + if ($gateId !== null && (($visibilityConditionResults[$gateId] ?? false) !== true)) { + continue; + } + $questionId = (int)$question['id']; + $visibleQuestionIds[] = $questionId; + $visibleQuestions[] = [ + 'id' => $questionId, + 'question' => (string)$question['question'], + 'description' => (string)($question['description'] ?? ''), + 'condition_id' => $gateId, + 'order_priority' => (int)($question['order_priority'] ?? 0), + 'answer' => array_key_exists($questionId, $answers) ? $answers[$questionId] : null, + 'answer_source' => $answerSources[$questionId] ?? 'missing', + ]; + } + + usort($visibleQuestions, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); + $visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds); + if ($isV2Config) { + $serviceEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $visibleAnswers); + $serviceConditionResults = (array)($serviceEvaluation['results'] ?? []); + $serviceExpressionTrace = (array)($serviceEvaluation['trace'] ?? []); + } else { + $serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers); + $serviceExpressionTrace = []; + } + + $tasks = (new selfserve_task_attachment_payloads())->attachToTasks( + $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload) + ); + $activeTasks = []; + $taskGateTrace = []; + $conditionIds = array_map(static fn(array $condition): int => (int)($condition['id'] ?? 0), $conditions); + foreach ($tasks as $task) { + $gateId = $this->nullableInt($task['condition_id'] ?? null); + $resolvedGate = $this->resolveTaskGate($task, $conditionIds); + $typedGateType = $resolvedGate['gate_type']; + $typedGateRefId = $resolvedGate['gate_ref_id']; + + $gateSatisfied = $this->conditionEvaluator->taskGateSatisfiedTyped( + $typedGateType->value, + $typedGateRefId, + $serviceConditionResults, + $visibleAnswers + ); + + $taskGateTrace[] = [ + 'task_id' => (int)$task['id'], + 'legacy_gate_id' => $gateId, + 'gate_type' => $typedGateType->value, + 'gate_ref_id' => $typedGateRefId, + 'satisfied' => $gateSatisfied, + ]; + + if (!$gateSatisfied) { + continue; + } + + $activeTasks[] = [ + 'id' => (int)$task['id'], + 'task' => (string)$task['task'], + 'description' => (string)($task['description'] ?? ''), + 'condition_id' => $gateId, + 'gate_type' => $typedGateType->value, + 'gate_ref_id' => $typedGateRefId, + 'order_priority' => (int)($task['order_priority'] ?? 0), + 'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), + 'buttons' => $this->normalizeButtonList($task['buttons'] ?? null), + 'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'], + 'attachments' => $task['attachments'] ?? [], + ]; + } + usort($activeTasks, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); + + $allowedServices = []; + foreach ($activeTasks as $task) { + foreach ($task['services'] as $service) { + if (!in_array($service, $allowedServices, true)) { + $allowedServices[] = $service; + } + } + } + + $machineAvailable = !empty($lane->relay_machine_id->value()); + $allVisibleQuestionsAnswered = true; + foreach ($visibleQuestions as $question) { + if ($question['answer'] === null) { + $allVisibleQuestionsAnswered = false; + break; + } + } + + $machineAllowed = $allVisibleQuestionsAnswered + && $machineAvailable + && in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true); + $visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices); + + $machineType = null; + if ($machineTypeId !== null) { + $machineTypeObject = (new selfserve_machine_types_o())->select($machineTypeId); + if ($machineTypeObject->exists()) { + $machineType = $machineTypeObject->asArray(); + } + } + + return [ + 'lane' => $lane->asArray(), + 'machine_type' => $machineType, + 'vehicle' => $vehicleData, + 'reg' => $normalizedReg, + 'customer_number' => $resolvedCustomerNumber, + 'vehicle_type_id' => $vehicleTypeId, + 'answers' => $answers, + 'persisted_answers' => $persistedAnswers, + 'persisted_answer_customer_number' => $persistedAnswerCustomerNumber, + 'answer_overrides' => $answerOverrides, + 'answer_sources' => $answerSources, + 'questions' => $visibleQuestions, + 'conditions' => $serviceConditionResults, + 'tasks' => $visibleTasks, + 'allowed_services' => $allowedServices, + 'machine_available' => $machineAvailable, + 'all_visible_questions_answered' => $allVisibleQuestionsAnswered, + 'allowed' => $machineAllowed, + 'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId, + 'config_source' => $configSource, + 'evaluation_trace' => [ + 'visibility_condition_results' => $visibilityConditionResults, + 'condition_results' => $serviceConditionResults, + 'visibility_expression_traces' => $visibilityExpressionTrace, + 'condition_expression_traces' => $serviceExpressionTrace, + 'task_gates' => $taskGateTrace, + 'visible_question_ids' => $visibleQuestionIds, + ], + 'debug_candidates' => [ + 'questions' => $questions, + 'conditions' => $conditions, + 'rules' => $rules, + 'tasks' => $tasks, + 'actions' => $isV2Config ? array_values((array)($publishedConfigPayload['actions'] ?? [])) : [], + 'visible_answers' => $visibleAnswers, + ], + ]; + } + + protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void + { + $laneId = (int)$snapshot['lane']['id']; + $lane = (new selfserve())->lane($laneId); + $this->enableCleanerRelayForStartedWash($lane); + + if ((bool)$session->machine_relay_enabled->value() === true) { + return; + } + + $session->markRelayEnabled(); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [ + 'lane_id' => $laneId, + 'reg' => $snapshot['reg'], + 'allowed_services' => $snapshot['allowed_services'], + 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), + ]); + } + + protected function syncMachineRelayFromVisibleServices(array $snapshot, selfserve_wash_sessions_o $session, bool $allowEnable): void + { + $laneId = (int)$snapshot['lane']['id']; + $lane = (new selfserve())->lane($laneId); + $sync = $lane->syncMachineRelayFromVisibleServices( + is_array($snapshot['allowed_services'] ?? null) ? $snapshot['allowed_services'] : [], + $allowEnable + ); + + if (($sync['relay_action'] ?? '') === 'enabled') { + $this->enableMachineRelayIfAllowed($snapshot, $session); + } + + $relayTargetOn = (bool)($sync['relay_target_on'] ?? false); + if (!$relayTargetOn && (bool)$session->machine_relay_enabled->value() === true) { + $session->markRelayDisabled(); + $session->updateStatus($this->deriveCurrentStatus($snapshot, $session)); + } + } + + protected function deriveBaseStatus(array $snapshot): selfserve_wash_session_status + { + if (!$snapshot['all_visible_questions_answered']) { + return selfserve_wash_session_status::PENDING_QUESTIONS; + } + if ($snapshot['allowed']) { + return selfserve_wash_session_status::READY_FOR_MACHINE_START; + } + return selfserve_wash_session_status::MACHINE_NOT_ALLOWED; + } + + protected function deriveCurrentStatus(array $snapshot, selfserve_wash_sessions_o $session): selfserve_wash_session_status + { + if ($session->completed_at->value() !== null) { + return selfserve_wash_session_status::COMPLETED; + } + if ((bool)$session->machine_start_triggered->value() === true) { + return selfserve_wash_session_status::MACHINE_STARTED; + } + if ((bool)$session->machine_relay_enabled->value() === true) { + return selfserve_wash_session_status::MACHINE_RELAY_ENABLED; + } + return $this->deriveBaseStatus($snapshot); + } + + protected function formatSnapshotResponse(array $snapshot, ?array $session = null): array + { + return [ + 'lane' => $snapshot['lane'], + 'machine_type' => $snapshot['machine_type'], + 'vehicle' => $snapshot['vehicle'], + 'reg' => $snapshot['reg'], + 'customer_number' => $snapshot['customer_number'], + 'vehicle_type_id' => $snapshot['vehicle_type_id'], + 'questions' => $snapshot['questions'], + 'tasks' => $snapshot['tasks'], + 'allowed_services' => $snapshot['allowed_services'], + 'machine_available' => $snapshot['machine_available'], + 'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'], + 'allowed' => $snapshot['allowed'], + 'blocked_reason' => $snapshot['blocked_reason'] ?? null, + 'session' => $session, + 'config_version_id' => $snapshot['config_version_id'] ?? null, + 'config_source' => $snapshot['config_source'] ?? null, + 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, + ]; + } + + protected function formatBlockedSessionSummary(array $snapshot): array + { + return [ + 'session' => null, + 'lane' => $snapshot['lane'], + 'machine_type' => $snapshot['machine_type'], + 'questions' => [], + 'tasks' => [], + 'events' => [], + 'allowed' => false, + 'allowed_services' => [], + 'machine_available' => false, + 'blocked_reason' => $snapshot['blocked_reason'] ?? 'Self-serve is disabled for this lane.', + 'config_version_id' => $snapshot['config_version_id'] ?? null, + 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, + ]; + } + + /** + * @param array $snapshot + * @param array $options + * @return array + */ + public function buildStudioDebugPayload(int $departmentId, array $snapshot, array $options = []): array + { + $lookups = is_array($options['lookups'] ?? null) ? (array)$options['lookups'] : []; + $candidates = is_array($snapshot['debug_candidates'] ?? null) ? (array)$snapshot['debug_candidates'] : []; + $questions = $this->buildDebugQuestions($snapshot, (array)($candidates['questions'] ?? []), $lookups); + $rules = $this->buildDebugRules($snapshot, (array)($candidates['rules'] ?? []), $lookups); + $conditions = $this->buildDebugConditions($snapshot, (array)($candidates['conditions'] ?? []), $rules, $lookups); + $tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? [])); + $actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups); + $hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions); + $dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks); + $decisions = $this->buildDebugDecisions( + $questions, + $conditions, + $rules, + $tasks, + $actions, + (array)($hardware['signal_timeline'] ?? []), + $dynamicImageButtons + ); + $recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules); + $summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware); + $stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups); + $annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $actions, $hardware, (array)($options['graph'] ?? [])); + + return [ + 'summary' => $summary, + 'parameters' => [ + 'department_id' => $departmentId, + 'department' => $this->debugLabel($lookups, 'departments', $departmentId, 'Department ' . $departmentId), + 'lane_id' => (int)($snapshot['lane']['id'] ?? 0), + 'lane' => $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, (string)($snapshot['lane']['name'] ?? 'Lane')), + 'machine_type_id' => $snapshot['machine_type']['id'] ?? null, + 'machine_type' => $this->debugLabel($lookups, 'machine_types', $snapshot['machine_type']['id'] ?? null, 'No machine type'), + 'vehicle_type_id' => $snapshot['vehicle_type_id'], + 'vehicle_type' => $this->debugLabel($lookups, 'vehicle_types', $snapshot['vehicle_type_id'] ?? null, 'Auto'), + 'registration' => $snapshot['reg'], + 'customer_number' => $snapshot['customer_number'], + 'config_source' => $snapshot['config_source'] ?? ($options['config_source'] ?? 'draft'), + 'config_version_id' => $snapshot['config_version_id'] ?? null, + 'hardware_mode' => $options['hardware_mode'] ?? 'studio', + 'mode' => 'full_dry_run', + 'dry_run' => true, + ], + 'stages' => $stages, + 'questions' => $questions, + 'conditions' => $conditions, + 'rules' => $rules, + 'tasks' => $tasks, + 'actions' => $actions, + 'dynamic_image_buttons' => $dynamicImageButtons, + 'decisions' => $decisions, + 'hardware' => $hardware, + 'signal_timeline' => (array)($hardware['signal_timeline'] ?? []), + 'graph_annotations' => $annotations, + 'recommendations' => $recommendations, + ]; + } + + /** + * @param mixed $raw + * @return array + */ + protected function normalizeAnswerOverrides(mixed $raw): array + { + $overrides = []; + if (!is_array($raw)) { + return $overrides; + } + + foreach ($raw as $key => $entry) { + if (is_array($entry)) { + $questionId = (int)($entry['question_id'] ?? $entry['id'] ?? $key); + $value = $entry['value'] ?? $entry['answer'] ?? null; + } else { + $questionId = (int)$key; + $value = $entry; + } + if ($questionId <= 0) { + continue; + } + if ($value === null || $value === '' || strtolower((string)$value) === 'unset' || strtolower((string)$value) === 'null') { + $overrides[$questionId] = null; + continue; + } + $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $overrides[$questionId] = $parsed; + } + + return $overrides; + } + + /** + * @param array $answers + * @param array $overrides + * @return array + */ + protected function applyAnswerOverrides(array $answers, array $overrides): array + { + foreach ($overrides as $questionId => $value) { + $answers[(int)$questionId] = $value; + } + return $answers; + } + + /** + * @param array $answers + * @param array $overrides + * @return array + */ + protected function buildAnswerSources(array $answers, array $overrides): array + { + $sources = []; + foreach ($answers as $questionId => $_value) { + $sources[(int)$questionId] = 'saved'; + } + foreach ($overrides as $questionId => $_value) { + $sources[(int)$questionId] = 'override'; + } + return $sources; + } + + protected function resolvePersistedAnswerCustomerNumber(?int $resolvedCustomerNumber): ?int + { + if ($resolvedCustomerNumber === null || $resolvedCustomerNumber <= 0) { + return null; + } + + return $resolvedCustomerNumber; + } + + /** + * @return array + */ + protected function loadPersistedAnswers(int $departmentId, int $laneId, string $reg, ?int $customerNumber): array + { + if ($customerNumber === null || $customerNumber <= 0) { + return []; + } + + return (new department_selfserve_vehicle_conditions_o())->getAnswerMapForVehicle( + $departmentId, + $laneId, + $reg, + $customerNumber + ); + } + + /** + * @param array $snapshot + * @param array> $questions + * @param array $lookups + * @return array> + */ + protected function buildDebugQuestions(array $snapshot, array $questions, array $lookups): array + { + $visibleIds = array_flip(array_map('intval', (array)($snapshot['evaluation_trace']['visible_question_ids'] ?? []))); + $answers = is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []; + $sources = is_array($snapshot['answer_sources'] ?? null) ? (array)$snapshot['answer_sources'] : []; + $conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null) + ? (array)$snapshot['evaluation_trace']['visibility_condition_results'] + : []; + $expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null) + ? (array)$snapshot['evaluation_trace']['visibility_expression_traces'] + : []; + $items = []; + + foreach ($questions as $question) { + $questionId = (int)($question['id'] ?? 0); + if ($questionId <= 0) { + continue; + } + $gateId = $this->nullableInt($question['condition_id'] ?? null); + $visible = isset($visibleIds[$questionId]); + $answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null; + $state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered'); + $label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)); + $conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId); + $gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true); + $causes = []; + if ($gateId !== null) { + $causes[] = $this->debugCause( + 'condition', + $gateId, + $conditionLabel, + true, + ($conditionResults[$gateId] ?? null), + $this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null) + ); + } + if ($visible && $answer === null) { + $causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.'); + } + $reason = $visible + ? ($answer === null + ? 'Question ' . $label . ' is visible but has no answer.' + : 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.') + : 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.'; + $items[] = [ + 'kind' => 'question', + 'id' => $questionId, + 'node_id' => 'question:' . $questionId, + 'node_ids' => ['question:' . $questionId], + 'label' => $label, + 'visible' => $visible, + 'state' => $state, + 'answer' => $answer, + 'answer_source' => $sources[$questionId] ?? 'missing', + 'condition_id' => $gateId, + 'condition' => $conditionLabel, + 'gate_satisfied' => $gateSatisfied, + 'reason' => $reason, + 'causes' => $causes, + 'order_priority' => (int)($question['order_priority'] ?? 0), + ]; + } + + usort($items, static fn(array $a, array $b): int => ((int)$a['order_priority'] <=> (int)$b['order_priority']) ?: ((int)$a['id'] <=> (int)$b['id'])); + return $items; + } + + /** + * @param array $snapshot + * @param array> $rules + * @param array $lookups + * @return array> + */ + protected function buildDebugRules(array $snapshot, array $rules, array $lookups): array + { + $answers = is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []; + $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : []; + $items = []; + + foreach ($rules as $rule) { + $ruleId = (int)($rule['id'] ?? 0); + if ($ruleId <= 0) { + continue; + } + $objectType = strtolower((string)($rule['object_type'] ?? '')); + $objectId = (int)($rule['object_id'] ?? 0); + $actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null); + $satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual); + $lookupType = $objectType === 'condition' ? 'conditions' : 'questions'; + $label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)); + $objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId); + $expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? '')); + $invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'); + $reason = $invalidReference + ? 'Rule ' . $label . ' skipped because its referenced object is invalid.' + : ($satisfied + ? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.' + : 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.'); + $items[] = [ + 'kind' => 'rule', + 'id' => $ruleId, + 'node_id' => 'rule:' . $ruleId, + 'node_ids' => ['rule:' . $ruleId], + 'condition_id' => (int)($rule['condition_id'] ?? 0), + 'label' => $label, + 'type' => (string)($rule['type'] ?? ''), + 'object_type' => $objectType, + 'object_id' => $objectId, + 'object_label' => $objectLabel, + 'actual_value' => $actual, + 'satisfied' => $satisfied, + 'invalid_reference' => $invalidReference, + 'reason' => $reason, + 'causes' => [ + $this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null), + ], + ]; + } + + return $items; + } + + /** + * @param array $snapshot + * @param array> $conditions + * @param array> $rules + * @param array $lookups + * @return array> + */ + protected function buildDebugConditions(array $snapshot, array $conditions, array $rules, array $lookups): array + { + $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : []; + $expressionTraces = is_array($snapshot['evaluation_trace']['condition_expression_traces'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_expression_traces'] : []; + $cycleIds = $this->detectConditionCycles($conditions, $rules); + $rulesByCondition = []; + foreach ($rules as $rule) { + $rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule; + } + + $items = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId <= 0) { + continue; + } + $result = ($conditionResults[$conditionId] ?? false) === true; + $expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : []; + $expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null; + $nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups); + $label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)); + $causes = []; + $failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups); + if ($failedExpressionCause !== null) { + $causes[] = $failedExpressionCause; + } elseif (!$result) { + foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) { + if (($rule['satisfied'] ?? false) !== true) { + foreach ((array)($rule['causes'] ?? []) as $cause) { + if (is_array($cause)) { + $causes[] = $cause; + } + } + break; + } + } + } + $items[] = [ + 'kind' => 'condition', + 'id' => $conditionId, + 'node_id' => 'condition:' . $conditionId, + 'node_ids' => ['condition:' . $conditionId], + 'label' => $label, + 'result' => $result, + 'state' => $result ? 'passed' : 'failed', + 'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null), + 'rules' => array_values($rulesByCondition[$conditionId] ?? []), + 'expression' => $expression, + 'expression_summary' => $expression === [] ? 'Legacy rules' : $this->debugExpressionSummary($expression, $lookups), + 'expression_trace' => $expressionTrace, + 'next_fix' => $nextFix, + 'has_cycle' => in_array($conditionId, $cycleIds, true), + 'reason' => in_array($conditionId, $cycleIds, true) + ? 'Condition dependency cycle detected.' + : ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')), + 'causes' => $causes, + ]; + } + + return $items; + } + + /** + * @param array $snapshot + * @param array> $tasks + * @param array $lookups + * @param array $gatewayWorkspace + * @return array> + */ + protected function buildDebugTasks(array $snapshot, array $tasks, array $lookups, array $gatewayWorkspace): array + { + $activeIds = array_flip(array_map(static fn(array $task): int => (int)($task['id'] ?? 0), (array)($snapshot['tasks'] ?? []))); + $activeAttachments = []; + foreach ((array)($snapshot['tasks'] ?? []) as $task) { + if (!is_array($task)) { + continue; + } + $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); + if ($taskId > 0 && is_array($task['attachments'] ?? null)) { + $activeAttachments[$taskId] = array_values($task['attachments']); + } + } + $gateTrace = []; + foreach ((array)($snapshot['evaluation_trace']['task_gates'] ?? []) as $trace) { + if (is_array($trace)) { + $gateTrace[(int)($trace['task_id'] ?? 0)] = $trace; + } + } + $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) + ? (array)$snapshot['evaluation_trace']['condition_results'] + : []; + $visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null) + ? (array)$snapshot['debug_candidates']['visible_answers'] + : (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []); + $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace); + $items = []; + + foreach ($tasks as $task) { + $taskId = (int)($task['id'] ?? 0); + if ($taskId <= 0) { + continue; + } + $trace = $gateTrace[$taskId] ?? []; + $services = $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)); + $bindings = []; + foreach ($services as $service) { + foreach ($bindingsByService[$service] ?? [] as $binding) { + $bindings[] = $binding; + } + } + $active = isset($activeIds[$taskId]); + $gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value); + $gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null); + $gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId); + $label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)); + $gateSatisfied = ($trace['satisfied'] ?? false) === true; + $gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers); + $taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []); + $items[] = [ + 'kind' => 'task', + 'id' => $taskId, + 'node_id' => 'task:' . $taskId, + 'node_ids' => ['task:' . $taskId], + 'label' => $label, + 'description' => (string)($task['description'] ?? ''), + 'active' => $active, + 'state' => $active ? 'active' : 'blocked', + 'gate_type' => $gateType, + 'gate_ref_id' => $gateRefId, + 'gate_ref_label' => $gateRefLabel, + 'gate_satisfied' => $gateSatisfied, + 'services' => $services, + 'buttons' => $this->normalizeButtonList($task['buttons'] ?? null), + 'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'], + 'attachments' => $taskAttachments, + 'relay_bindings' => $bindings, + 'order_priority' => (int)($task['order_priority'] ?? 0), + 'reason' => $gateDecision['reason'], + 'causes' => $gateDecision['causes'], + ]; + } + + usort($items, static fn(array $a, array $b): int => ((int)$a['order_priority'] <=> (int)$b['order_priority']) ?: ((int)$a['id'] <=> (int)$b['id'])); + return $items; + } + + /** + * @param array $snapshot + * @param array> $actions + * @param array $lookups + * @return array> + */ + protected function buildDebugActions(array $snapshot, array $actions, array $lookups): array + { + $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) + ? (array)$snapshot['evaluation_trace']['condition_results'] + : []; + $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : []; + $laneId = (int)($lane['id'] ?? 0); + $departmentId = (int)($lane['department'] ?? 0); + $vehicleTypeId = $this->nullableInt($snapshot['vehicle_type_id'] ?? null); + $machineTypeId = $this->nullableInt($snapshot['machine_type']['id'] ?? $lane['machine_type_id'] ?? null); + $eventModes = [ + selfserve_studio_actions::EVENT_WASH_START_COMMAND => $this->debugWashModeForStart($snapshot), + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => $this->debugWashModeForStop($snapshot), + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => selfserve_studio_actions::MODE_MACHINE, + ]; + $items = []; + + foreach ($actions as $row) { + if (!is_array($row)) { + continue; + } + $action = selfserve_studio_actions::normalize((array)$row); + $actionId = (int)($action['id'] ?? 0); + if ($actionId <= 0) { + continue; + } + + $expectedMode = $eventModes[(string)$action['event']] ?? selfserve_studio_actions::MODE_BOTH; + $modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true); + $scopeMatches = true; + $scopeReason = null; + $scopeCause = null; + if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) { + $scopeMatches = false; + $scopeReason = 'Action department scope does not match the simulated lane.'; + $scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason); + } elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) { + $scopeMatches = false; + $scopeReason = 'Action lane scope does not match the simulated lane.'; + $scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason); + } elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) { + $scopeMatches = false; + $scopeReason = 'Action vehicle type scope does not match the simulated vehicle.'; + $scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason); + } elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) { + $scopeMatches = false; + $scopeReason = 'Action machine type scope does not match the simulated lane.'; + $scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason); + } + + $conditionId = $this->nullableInt($action['condition_id'] ?? null); + $conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true); + $enabled = (bool)($action['enabled'] ?? true); + $active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied; + $label = (string)$action['name']; + $conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId); + $causes = []; + $reason = 'Action would run for this simulator event.'; + if (!$enabled) { + $reason = 'Action is disabled.'; + $causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason); + } elseif (!$modeMatches) { + $reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.'; + $causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason); + } elseif (!$scopeMatches) { + $reason = $scopeReason ?? 'Action scope does not match the simulated lane.'; + if ($scopeCause !== null) { + $causes[] = $scopeCause; + } + } elseif (!$conditionSatisfied) { + $reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.'; + $causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason); + } + + $items[] = [ + 'kind' => 'action', + 'id' => $actionId, + 'node_id' => 'action:' . $actionId, + 'node_ids' => ['action:' . $actionId], + 'label' => $label, + 'active' => $active, + 'state' => $active ? 'active' : 'skipped', + 'reason' => $reason, + 'causes' => $causes, + 'event' => (string)$action['event'], + 'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']), + 'wash_mode' => (string)$action['wash_mode'], + 'simulated_wash_mode' => $expectedMode, + 'operation' => (string)$action['operation'], + 'operation_label' => selfserve_studio_actions::operationLabel((string)$action['operation'], $action['relay_state']), + 'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']), + 'relay_state' => $action['relay_state'], + 'condition_id' => $conditionId, + 'condition' => $conditionLabel, + 'condition_satisfied' => $conditionSatisfied, + 'scope' => [ + 'department' => (int)$action['department'], + 'lane' => (int)$action['lane'], + 'product' => (int)$action['product'], + 'machine_type_id' => $action['machine_type_id'], + ], + 'options' => (array)$action['options'], + 'order_priority' => (int)$action['order_priority'], + 'raw' => $action, + ]; + } + + usort($items, static fn(array $a, array $b): int => strcmp((string)$a['event'], (string)$b['event']) + ?: ((int)$a['order_priority'] <=> (int)$b['order_priority']) + ?: ((int)$a['id'] <=> (int)$b['id'])); + return $items; + } + + /** + * @param array $snapshot + * @param array> $tasks + * @param array $gatewayWorkspace + * @param array $lookups + * @param array> $actions + * @return array + */ + protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups, array $actions = []): array + { + $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace); + $allowedServices = (array)($snapshot['allowed_services'] ?? []); + $missingBindings = []; + foreach ($allowedServices as $service) { + $service = strtoupper((string)$service); + if ($service !== '' && empty($bindingsByService[$service])) { + $missingBindings[] = $service; + } + } + + $laneId = (int)($snapshot['lane']['id'] ?? 0); + $laneRelaySlots = []; + foreach ((array)($gatewayWorkspace['lanes'] ?? []) as $lane) { + if (!is_array($lane) || (int)($lane['id'] ?? 0) !== $laneId) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (is_array($slot)) { + $laneRelaySlots[] = $slot; + } + } + } + $signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $actions); + + $dryRunOperations = []; + if (($snapshot['allowed'] ?? false) === true) { + $dryRunOperations[] = [ + 'operation' => 'machine_relay_enable', + 'status' => 'predicted', + 'message' => 'Dry run predicts the machine relay would be enabled.', + ]; + } else { + $dryRunOperations[] = [ + 'operation' => 'machine_relay_enable', + 'status' => 'blocked', + 'message' => 'Dry run predicts no machine relay action because eligibility is blocked.', + ]; + } + + return [ + 'machine_relay_configured' => (bool)($snapshot['machine_available'] ?? false), + 'lane_relay_slots' => $laneRelaySlots, + 'allowed_services' => $allowedServices, + 'service_bindings' => $bindingsByService, + 'missing_service_bindings' => array_values($missingBindings), + 'gateways' => array_values((array)($gatewayWorkspace['gateways'] ?? [])), + 'issues' => array_values((array)($gatewayWorkspace['issues'] ?? [])), + 'restricted' => (bool)($gatewayWorkspace['restricted'] ?? false), + 'virtual' => is_array($gatewayWorkspace['virtual'] ?? null) ? (array)$gatewayWorkspace['virtual'] : [], + 'signal_timeline' => $signalTimeline, + 'dry_run_operations' => $dryRunOperations, + 'summary' => $this->debugHardwareSummary($snapshot, $missingBindings, $lookups), + ]; + } + + /** + * @param array $snapshot + * @param array $gatewayWorkspace + * @param array>> $bindingsByService + * @param array> $laneRelaySlots + * @param array> $actions + * @return array> + */ + protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots, array $actions = []): array + { + $timeline = []; + $sequence = 1; + $allowed = (bool)($snapshot['allowed'] ?? false); + $machineAvailable = (bool)($snapshot['machine_available'] ?? false); + + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + 'eligibility_sync', + 'session_event', + 'SESSION', + null, + null, + [ + 'event' => 'SESSION_SYNCED', + 'allowed' => $allowed, + 'allowed_services' => array_values((array)($snapshot['allowed_services'] ?? [])), + ], + 'none', + 'sent', + null + ); + + $this->appendDebugActionSignalRows( + $timeline, + $sequence, + $actions, + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + $allowed, + $gatewayWorkspace, + $bindingsByService, + $laneRelaySlots, + $snapshot + ); + + $machineRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'MACHINE', $snapshot); + $machineBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $machineRelayId, 'MACHINE'); + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'eligibility_sync', + 'relay_switch', + 'MACHINE', + $machineRelayId, + $machineBinding, + ['id' => $machineRelayId, 'channel' => 0, 'on' => true], + $allowed && $machineAvailable, + $allowed ? (!$machineAvailable ? 'Lane machine relay is not configured.' : null) : 'Eligibility is blocked, so the machine relay would not be enabled.' + ); + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'machine_start_signal', + 'shelly_event', + 'MACHINE', + $machineRelayId, + $machineBinding, + [ + 'event' => 'input.toggle_on', + 'alternate_event' => 'switch.on', + 'input' => ['component' => 'input:0', 'state' => true], + 'switch' => ['component' => 'switch:0', 'output' => true], + 'bill_machine_wash' => true, + ], + $allowed && $machineAvailable, + $allowed ? (!$machineAvailable ? 'Lane machine signal relay is not configured.' : null) : 'Machine ON signal would not be accepted before eligibility passes.' + ); + + $this->appendDebugActionSignalRows( + $timeline, + $sequence, + $actions, + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED, + $allowed && $machineAvailable, + $gatewayWorkspace, + $bindingsByService, + $laneRelaySlots, + $snapshot + ); + + $cleanerRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'CLEANER', $snapshot); + $cleanerBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $cleanerRelayId, 'CLEANER'); + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'machine_start', + 'relay_switch', + 'CLEANER', + $cleanerRelayId, + $cleanerBinding, + ['id' => $cleanerRelayId, 'channel' => 0, 'on' => true], + $allowed && $cleanerRelayId !== null, + $allowed ? 'Lane has no machine start cleaner relay configured.' : 'Machine start would not run because eligibility is blocked.' + ); + + $this->appendDebugActionSignalRows( + $timeline, + $sequence, + $actions, + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND, + $allowed, + $gatewayWorkspace, + $bindingsByService, + $laneRelaySlots, + $snapshot + ); + + $exitRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'EXIT', $snapshot); + $exitBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $exitRelayId, 'EXIT'); + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'stop', + 'relay_pulse', + 'EXIT', + $exitRelayId, + $exitBinding, + ['id' => $exitRelayId, 'on' => true, 'toggle_after' => 1], + $allowed && $exitRelayId !== null, + $allowed ? 'Lane has no STOP exit relay configured.' : 'STOP exit open would not run before a valid wash can start.' + ); + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'stop', + 'relay_switch', + 'CLEANER', + $cleanerRelayId, + $cleanerBinding, + ['id' => $cleanerRelayId, 'channel' => 0, 'on' => false], + $allowed && $cleanerRelayId !== null, + $allowed ? 'Lane has no cleaner relay to turn off.' : 'Cleaner off would not run before a valid wash can start.' + ); + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'stop', + 'relay_switch', + 'MACHINE', + $machineRelayId, + $machineBinding, + ['id' => $machineRelayId, 'channel' => 0, 'on' => false], + $machineRelayId !== null, + $machineRelayId === null ? 'Lane machine relay is not configured.' : null + ); + + $timeline[] = $this->debugSignalTimelineRow( + $sequence, + 'session_completion', + 'session_event', + 'SESSION', + null, + null, + [ + 'event' => 'SESSION_COMPLETED', + 'reset_lane_state' => true, + ], + 'none', + $allowed ? 'sent' : 'skipped', + $allowed ? null : 'Session completion/reset only applies after a dry-run wash can start.' + ); + + return $timeline; + } + + protected function debugWashModeForStart(array $snapshot): string + { + return in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true) + ? selfserve_studio_actions::MODE_MACHINE + : selfserve_studio_actions::MODE_MANUAL; + } + + protected function debugWashModeForStop(array $snapshot): string + { + return ((bool)($snapshot['allowed'] ?? false) === true && (bool)($snapshot['machine_available'] ?? false) === true) + ? selfserve_studio_actions::MODE_MACHINE + : selfserve_studio_actions::MODE_MANUAL; + } + + /** + * @param array> $timeline + * @param array> $actions + * @param array $gatewayWorkspace + * @param array>> $bindingsByService + * @param array> $laneRelaySlots + * @param array $snapshot + */ + protected function appendDebugActionSignalRows( + array &$timeline, + int &$sequence, + array $actions, + string $event, + bool $eventAllowed, + array $gatewayWorkspace, + array $bindingsByService, + array $laneRelaySlots, + array $snapshot + ): void { + $eventActions = array_values(array_filter( + $actions, + static fn(array $action): bool => (string)($action['event'] ?? '') === $event + )); + usort($eventActions, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)) + ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0))); + + foreach ($eventActions as $action) { + $raw = is_array($action['raw'] ?? null) ? (array)$action['raw'] : selfserve_studio_actions::normalize($action); + $operation = (string)($raw['operation'] ?? ''); + $relayRole = selfserve_studio_actions::relayRoleForOperation($operation); + $runtimeStage = selfserve_studio_actions::runtimeStageForEvent($event); + $isRelayOperation = selfserve_studio_actions::isRelayOperation($operation); + $isPropertyGate = in_array($relayRole, ['PROPERTY_ENTRANCE', 'PROPERTY_EXIT'], true); + $relayId = $isPropertyGate ? null : $this->debugRelayIdForRole($laneRelaySlots, $relayRole, $snapshot); + $binding = $isPropertyGate ? null : $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $relayId, $relayRole); + $active = (bool)($action['active'] ?? false); + $reason = (string)($action['reason'] ?? 'Action does not match the simulated scenario.'); + $payload = [ + 'action_id' => (int)($raw['id'] ?? $action['id'] ?? 0), + 'action' => (string)($raw['name'] ?? $action['label'] ?? ''), + 'event' => $event, + 'wash_mode' => (string)($raw['wash_mode'] ?? $action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH), + 'operation' => $operation, + 'operation_label' => selfserve_studio_actions::operationLabel($operation, $raw['relay_state'] ?? null), + 'enabled' => (bool)($raw['enabled'] ?? true), + 'order_priority' => (int)($raw['order_priority'] ?? $action['order_priority'] ?? 0), + 'options' => (array)($raw['options'] ?? []), + ]; + + if ($isRelayOperation) { + $payload['id'] = $relayId; + $payload['channel'] = 0; + $payload['on'] = (bool)($raw['relay_state'] ?? true); + $signalType = 'studio_action_relay_switch'; + } elseif ($isPropertyGate) { + $payload['command'] = $relayRole === 'PROPERTY_ENTRANCE' ? 'OPEN_PROPERTY_ACCESS_GATE' : 'OPEN_PROPERTY_EXIT_GATE'; + $signalType = 'studio_action_gate_open'; + } else { + $payload['id'] = $relayId; + $payload['on'] = true; + $payload['toggle_after'] = $this->nullableInt($raw['options']['toggle_after_seconds'] ?? null) ?? 1; + $signalType = 'studio_action_relay_pulse'; + } + + if (!$active) { + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + $relayId, + $binding, + $payload, + $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), + 'skipped', + $reason + ); + continue; + } + + if (!$eventAllowed) { + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + $relayId, + $binding, + $payload, + $binding === null ? ($isPropertyGate ? 'real' : 'none') : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), + 'blocked', + 'Action event would not fire because the dry-run scenario is blocked before this stage.' + ); + continue; + } + + if ($isPropertyGate) { + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + null, + null, + $payload, + 'real', + 'sent', + null + ); + continue; + } + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + $relayId, + $binding, + $payload, + true, + 'Action relay is not configured for the simulated lane.' + ); + } + } + + /** + * @param array $snapshot + * @param array> $laneRelaySlots + */ + protected function debugRelayIdForRole(array $laneRelaySlots, string $role, array $snapshot): ?string + { + $role = strtoupper(trim($role)); + foreach ($laneRelaySlots as $slot) { + if (!is_array($slot)) { + continue; + } + $slotRole = strtoupper(trim((string)($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''))); + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($slotRole === $role && $relayId !== '') { + return $relayId; + } + } + + $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : []; + $field = match ($role) { + 'ENTRY' => 'relay_in_id', + 'EXIT' => 'relay_out_id', + 'MACHINE' => 'relay_machine_id', + 'PROGRAM_PICKER' => 'relay_machine_program_picker_id', + 'CLEANER' => 'relay_machine_cleaner_id', + default => '', + }; + $relayId = $field !== '' ? trim((string)($lane[$field] ?? '')) : ''; + return $relayId !== '' ? $relayId : null; + } + + /** + * @param array $gatewayWorkspace + * @param array>> $bindingsByService + * @return array|null + */ + protected function debugBindingForRelayRole(array $gatewayWorkspace, array $bindingsByService, ?string $relayId, string $role): ?array + { + if ($relayId === null || trim($relayId) === '') { + return null; + } + $role = strtoupper(trim($role)); + + foreach ((array)($bindingsByService[$role] ?? []) as $binding) { + if (is_array($binding) && (string)($binding['relay_id'] ?? '') === $relayId) { + return $binding; + } + } + + foreach ($this->debugGatewayBindingReferences($gatewayWorkspace) as $binding) { + if ((string)($binding['relay_id'] ?? '') === $relayId) { + return $binding; + } + } + + return null; + } + + /** + * @param array $binding|null + * @param array $payload + * @return array + */ + protected function debugRelaySignalTimelineRow( + int $sequence, + string $runtimeStage, + string $signalType, + string $relayRole, + ?string $relayId, + ?array $binding, + array $payload, + bool $eligible, + ?string $reason + ): array { + if ($relayId === null || trim($relayId) === '') { + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, null, null, $payload, 'none', 'skipped', $reason); + } + if (!$eligible) { + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), 'blocked', $reason); + } + if ($binding === null) { + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, null, $payload, 'none', 'skipped', 'No gateway binding is available for this relay in the selected hardware mode.'); + } + + $source = (bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'; + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $source, $source === 'virtual' ? 'virtual_only' : 'sent', $source === 'virtual' ? 'Virtual studio hardware only; live dispatch would require a real gateway binding.' : null); + } + + /** + * @param array|null $binding + * @param array $payload + * @return array + */ + protected function debugSignalTimelineRow( + int $sequence, + string $runtimeStage, + string $signalType, + string $relayRole, + ?string $relayId, + ?array $binding, + array $payload, + string $source, + string $predictedStatus, + ?string $reason + ): array { + $id = 'signal:' . $sequence; + $label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType); + $targetBinding = $binding['node_id'] ?? null; + $nodeIds = [$id]; + if (isset($payload['action_id'])) { + $nodeIds[] = 'action:' . (int)$payload['action_id']; + } + if (is_string($targetBinding) && $targetBinding !== '') { + $nodeIds[] = $targetBinding; + } + if ($relayId !== null && trim($relayId) !== '') { + $nodeIds[] = 'relay:' . $relayId; + } + $causes = $reason === null ? [] : [ + $this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason), + ]; + + return [ + 'kind' => 'signal', + 'id' => $id, + 'node_id' => $id, + 'node_ids' => array_values(array_unique($nodeIds)), + 'label' => $label, + 'state' => $predictedStatus, + 'sequence' => $sequence, + 'runtime_stage' => $runtimeStage, + 'signal_type' => $signalType, + 'relay_role' => $relayRole, + 'relay_id' => $relayId, + 'target_gateway' => $binding['gateway_id'] ?? null, + 'target_gateway_label' => $binding['gateway_label'] ?? null, + 'target_binding' => $binding['node_id'] ?? null, + 'target_binding_label' => $binding['relay_label'] ?? null, + 'transport' => match ($signalType) { + 'session_event' => 'selfserve_wash_session_events', + 'shelly_event', 'machine_signal' => 'shelly_webhook_or_edge_gateway_event', + default => '/v2/devices/api/set/switch', + }, + 'payload' => $payload, + 'source' => $source, + 'virtual' => $source === 'virtual', + 'predicted_status' => $predictedStatus, + 'skip_block_reason' => $reason, + 'reason' => $reason, + 'causes' => $causes, + ]; + } + + /** + * @param array $snapshot + * @param array> $questions + * @param array> $tasks + * @param array $hardware + * @param array> $conditions + * @param array> $rules + * @return array> + */ + protected function buildDebugRecommendations(array $snapshot, array $questions, array $tasks, array $hardware, array $conditions, array $rules): array + { + $items = []; + $missingQuestions = array_values(array_filter($questions, static fn(array $question): bool => ($question['state'] ?? '') === 'missing')); + if ($missingQuestions !== []) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Answer required questions', + 'message' => count($missingQuestions) . ' visible question(s) are missing answers.', + 'node_ids' => array_map(static fn(array $question): string => (string)$question['node_id'], $missingQuestions), + ]; + } + if (($snapshot['machine_available'] ?? false) !== true) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Configure lane machine relay', + 'message' => 'The selected lane has no machine relay configured, so the machine cannot start.', + 'node_ids' => ['lane:' . (int)($snapshot['lane']['id'] ?? 0)], + ]; + } + if (!in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true)) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Expose MACHINE service', + 'message' => 'No active task exposes the MACHINE service for this scenario.', + 'node_ids' => array_map(static fn(array $task): string => (string)$task['node_id'], $tasks), + ]; + } + foreach ((array)($hardware['missing_service_bindings'] ?? []) as $service) { + $items[] = [ + 'severity' => 'warning', + 'title' => 'Bind gateway relay for ' . $service, + 'message' => 'The active service has no edge gateway relay binding in the studio hardware workspace.', + 'node_ids' => [], + ]; + } + foreach ($conditions as $condition) { + if (($condition['has_cycle'] ?? false) === true) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Fix condition cycle', + 'message' => 'Condition "' . (string)$condition['label'] . '" depends on itself through another condition.', + 'node_ids' => [(string)$condition['node_id']], + ]; + } + } + foreach ($rules as $rule) { + if (($rule['invalid_reference'] ?? false) === true) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Fix invalid rule reference', + 'message' => 'Rule "' . (string)$rule['label'] . '" references an invalid object.', + 'node_ids' => [(string)$rule['node_id']], + ]; + } + } + + if ($items === []) { + $items[] = [ + 'severity' => 'success', + 'title' => 'Flow is ready', + 'message' => 'The simulated parameters pass every dry-run check.', + 'node_ids' => ['checkpoint:eligible'], + ]; + } + + return $items; + } + + /** + * @param array $snapshot + * @param array> $recommendations + * @param array $hardware + * @return array + */ + protected function buildDebugSummary(array $snapshot, array $recommendations, array $hardware): array + { + $primary = null; + foreach ($recommendations as $recommendation) { + if (($recommendation['severity'] ?? '') === 'error') { + $primary = $recommendation; + break; + } + } + $warnings = array_values(array_filter($recommendations, static fn(array $recommendation): bool => ($recommendation['severity'] ?? '') === 'warning')); + $allowed = ($snapshot['allowed'] ?? false) === true; + + return [ + 'status' => $allowed ? ($warnings === [] ? 'allowed' : 'warning') : 'blocked', + 'allowed' => $allowed, + 'title' => $allowed ? ($warnings === [] ? 'Allowed' : 'Allowed with warnings') : 'Blocked', + 'primary_blocker' => $primary, + 'next_action' => $primary['message'] ?? ($warnings[0]['message'] ?? 'No action required.'), + 'warning_count' => count($warnings), + 'dry_run' => true, + 'hardware_ready' => (bool)($hardware['machine_relay_configured'] ?? false), + ]; + } + + /** + * @param array $snapshot + * @param array> $questions + * @param array> $conditions + * @param array> $rules + * @param array> $tasks + * @param array $hardware + * @param array $summary + * @param array $lookups + * @return array> + */ + protected function buildDebugStages(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $hardware, array $summary, array $lookups): array + { + $missingQuestions = array_values(array_filter($questions, static fn(array $question): bool => ($question['state'] ?? '') === 'missing')); + $activeTasks = array_values(array_filter($tasks, static fn(array $task): bool => ($task['active'] ?? false) === true)); + $failedConditions = array_values(array_filter($conditions, static fn(array $condition): bool => ($condition['result'] ?? false) !== true)); + + return [ + [ + 'id' => 'input', + 'title' => 'Input normalization', + 'status' => 'ok', + 'summary' => 'Registration normalized to ' . (string)$snapshot['reg'] . '.', + 'node_ids' => ['checkpoint:start'], + 'edge_ids' => ['runtime:start-eligible'], + ], + [ + 'id' => 'scope', + 'title' => 'Lane and scope resolution', + 'status' => $snapshot['vehicle_type_id'] === null ? 'warning' : 'ok', + 'summary' => 'Lane ' . $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, 'selected') . ' uses ' . $this->debugLabel($lookups, 'vehicle_types', $snapshot['vehicle_type_id'] ?? null, 'automatic vehicle type') . '.', + 'node_ids' => array_values(array_filter([ + 'lane:' . (int)($snapshot['lane']['id'] ?? 0), + $snapshot['vehicle_type_id'] === null ? null : 'vehicle_type:' . (int)$snapshot['vehicle_type_id'], + isset($snapshot['machine_type']['id']) ? 'machine_type:' . (int)$snapshot['machine_type']['id'] : null, + ])), + 'edge_ids' => [], + ], + [ + 'id' => 'questions', + 'title' => 'Question visibility and answers', + 'status' => $missingQuestions === [] ? 'ok' : 'error', + 'summary' => count($questions) . ' question(s) evaluated; ' . count($missingQuestions) . ' visible question(s) missing answers.', + 'node_ids' => array_map(static fn(array $question): string => (string)$question['node_id'], $questions), + 'edge_ids' => [], + ], + [ + 'id' => 'conditions', + 'title' => 'Condition and rule evaluation', + 'status' => count(array_filter($conditions, static fn(array $condition): bool => ($condition['has_cycle'] ?? false) === true)) > 0 ? 'error' : 'ok', + 'summary' => count($conditions) . ' condition(s) and ' . count($rules) . ' rule(s) evaluated; ' . count($failedConditions) . ' condition(s) false.', + 'node_ids' => array_merge( + array_map(static fn(array $condition): string => (string)$condition['node_id'], $conditions), + array_map(static fn(array $rule): string => (string)$rule['node_id'], $rules), + ), + 'edge_ids' => [], + ], + [ + 'id' => 'tasks', + 'title' => 'Task gates and services', + 'status' => in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true) ? 'ok' : 'error', + 'summary' => count($activeTasks) . ' task(s) active; services: ' . implode(', ', (array)($snapshot['allowed_services'] ?? [])), + 'node_ids' => array_map(static fn(array $task): string => (string)$task['node_id'], $tasks), + 'edge_ids' => [], + ], + [ + 'id' => 'hardware', + 'title' => 'Gateway and relay readiness', + 'status' => ($snapshot['machine_available'] ?? false) === true ? (((array)($hardware['missing_service_bindings'] ?? [])) === [] ? 'ok' : 'warning') : 'error', + 'summary' => (string)($hardware['summary'] ?? ''), + 'node_ids' => [], + 'edge_ids' => [], + ], + [ + 'id' => 'final', + 'title' => 'Final eligibility decision', + 'status' => ($summary['status'] ?? '') === 'blocked' ? 'error' : (($summary['status'] ?? '') === 'warning' ? 'warning' : 'ok'), + 'summary' => (string)($summary['next_action'] ?? ''), + 'node_ids' => ['checkpoint:eligible', 'checkpoint:finish'], + 'edge_ids' => ['runtime:eligible-finish'], + ], + ]; + } + + /** + * @param array $snapshot + * @param array> $questions + * @param array> $conditions + * @param array> $rules + * @param array> $tasks + * @param array> $actions + * @param array $hardware + * @param array $graph + * @return array + */ + protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $actions, array $hardware, array $graph): array + { + $nodes = [ + 'checkpoint:start' => ['state' => 'visited', 'label' => 'Simulation started'], + 'checkpoint:eligible' => ['state' => ($snapshot['allowed'] ?? false) ? 'active' : 'blocked', 'label' => 'Eligibility resolved'], + 'checkpoint:finish' => ['state' => ($snapshot['allowed'] ?? false) ? 'visited' : 'not_applicable', 'label' => 'Predicted completion checkpoint'], + 'lane:' . (int)($snapshot['lane']['id'] ?? 0) => ['state' => 'active', 'label' => 'Selected lane'], + ]; + if ($snapshot['vehicle_type_id'] !== null) { + $nodes['vehicle_type:' . (int)$snapshot['vehicle_type_id']] = ['state' => 'active', 'label' => 'Selected vehicle type']; + } + if (isset($snapshot['machine_type']['id'])) { + $nodes['machine_type:' . (int)$snapshot['machine_type']['id']] = ['state' => 'active', 'label' => 'Resolved machine type']; + } + + foreach ($questions as $question) { + $state = match ($question['state'] ?? '') { + 'answered' => 'active', + 'missing' => 'warning', + default => 'not_applicable', + }; + $nodes[(string)$question['node_id']] = ['state' => $state, 'label' => (string)$question['reason']]; + } + foreach ($conditions as $condition) { + $nodes[(string)$condition['node_id']] = [ + 'state' => ($condition['has_cycle'] ?? false) ? 'error' : (($condition['result'] ?? false) ? 'active' : 'blocked'), + 'label' => (string)$condition['reason'], + ]; + } + foreach ($rules as $rule) { + $nodes[(string)$rule['node_id']] = [ + 'state' => ($rule['invalid_reference'] ?? false) ? 'error' : (($rule['satisfied'] ?? false) ? 'active' : 'blocked'), + 'label' => (string)$rule['reason'], + ]; + } + foreach ($tasks as $task) { + $nodes[(string)$task['node_id']] = [ + 'state' => ($task['active'] ?? false) ? 'active' : 'blocked', + 'label' => (string)$task['reason'], + ]; + foreach ((array)($task['relay_bindings'] ?? []) as $binding) { + if (isset($binding['node_id'])) { + $nodes[(string)$binding['node_id']] = ['state' => ($task['active'] ?? false) ? 'active' : 'visited', 'label' => 'Relay binding for active service']; + } + if (isset($binding['gateway_node_id'])) { + $nodes[(string)$binding['gateway_node_id']] = ['state' => 'visited', 'label' => 'Gateway available for service']; + } + if (isset($binding['relay_node_id'])) { + $nodes[(string)$binding['relay_node_id']] = ['state' => 'visited', 'label' => 'Hardware relay available for service']; + } + } + } + foreach ($actions as $action) { + $nodes[(string)$action['node_id']] = [ + 'state' => ($action['active'] ?? false) ? 'active' : 'not_applicable', + 'label' => (string)($action['reason'] ?? 'Action evaluated.'), + ]; + } + + $edges = []; + foreach ((array)($graph['edges'] ?? []) as $edge) { + if (!is_array($edge)) { + continue; + } + $edgeId = (string)($edge['id'] ?? ''); + $source = (string)($edge['source'] ?? ''); + $target = (string)($edge['target'] ?? ''); + if ($edgeId === '' || (!isset($nodes[$source]) && !isset($nodes[$target]))) { + continue; + } + $sourceState = (string)($nodes[$source]['state'] ?? 'visited'); + $targetState = (string)($nodes[$target]['state'] ?? 'visited'); + $edges[$edgeId] = [ + 'state' => $this->mergeAnnotationStates($sourceState, $targetState), + 'label' => (string)($edge['label'] ?? 'Simulated relationship'), + ]; + } + + if (($snapshot['machine_available'] ?? false) !== true) { + $nodes['lane:' . (int)($snapshot['lane']['id'] ?? 0)] = ['state' => 'error', 'label' => 'Lane machine relay is not configured.']; + } + + return [ + 'nodes' => $nodes, + 'edges' => $edges, + ]; + } + + protected function debugRuleSatisfied(string $type, mixed $actual): bool + { + return match (strtoupper(trim($type))) { + 'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => $actual === true, + 'IS_FALSE' => $actual === false, + 'IS_SET' => $actual !== null, + 'IS_TRUE_OR_NOT_SET' => $actual === true || $actual === null, + 'IS_FALSE_OR_NOT_SET' => $actual === false || $actual === null, + default => false, + }; + } + + protected function debugRuleExpectedValue(string $type): mixed + { + return match (strtoupper(trim($type))) { + 'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true, + 'IS_FALSE' => false, + 'IS_SET' => 'set', + 'IS_TRUE_OR_NOT_SET' => 'true or missing', + 'IS_FALSE_OR_NOT_SET' => 'false or missing', + default => strtolower(str_replace('_', ' ', trim($type))), + }; + } + + protected function debugExpectedLabel(mixed $expected): string + { + return $this->debugValueLabel($expected); + } + + protected function debugValueLabel(mixed $value): string + { + if ($value === true) { + return 'true'; + } + if ($value === false) { + return 'false'; + } + if ($value === null) { + return 'missing'; + } + if (is_array($value)) { + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return $encoded === false ? 'array' : $encoded; + } + if ($value instanceof \Stringable) { + return (string)$value; + } + return trim((string)$value) === '' ? 'empty' : (string)$value; + } + + /** + * @return array + */ + protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array + { + return [ + 'kind' => $kind, + 'id' => $id, + 'label' => $label, + 'expected' => $expected, + 'actual' => $actual, + 'expected_label' => $this->debugExpectedLabel($expected), + 'actual_label' => $this->debugValueLabel($actual), + 'reason' => $reason, + ]; + } + + protected function debugExpressionTraceReason(mixed $trace): ?string + { + if (!is_array($trace)) { + return null; + } + $expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace; + $failed = $this->firstFailedPredicateTrace($expression); + if ($failed !== null) { + return (string)($failed['reason'] ?? 'Predicate did not pass.'); + } + return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null); + } + + /** + * @param array $trace + * @param array $lookups + * @return array|null + */ + protected function debugFailedExpressionCause(array $trace, array $lookups): ?array + { + $expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace; + $failed = $this->firstFailedPredicateTrace($expression); + if ($failed === null) { + if (($expression['result'] ?? true) === false) { + return $this->debugCause( + 'expression', + $trace['condition_id'] ?? null, + 'Condition expression', + true, + false, + (string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.') + ); + } + return null; + } + + $subjectType = strtolower((string)($failed['subject_type'] ?? 'question')); + $subjectId = (int)($failed['subject_id'] ?? 0); + $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; + $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); + $expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE')); + $actual = $failed['actual_value'] ?? null; + $reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.'; + + return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason); + } + + /** + * @param array $conditionResults + * @param array $visibleAnswers + * @return array{reason:string,causes:array>} + */ + protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array + { + $gateType = strtoupper(trim($gateType)); + if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) { + if (!$active && $gateSatisfied) { + return [ + 'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.', + 'causes' => [ + $this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'), + ], + ]; + } + return [ + 'reason' => $active + ? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.' + : 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.', + 'causes' => $active ? [] : [ + $this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'), + ], + ]; + } + + $actual = $gateType === selfserve_task_gate_type::CONDITION->value + ? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null)) + : ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null)); + $sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question'; + if (!$active && $gateSatisfied) { + return [ + 'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.', + 'causes' => [ + $this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'), + $this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'), + ], + ]; + } + $reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.'; + + return [ + 'reason' => $reason, + 'causes' => $active ? [] : [ + $this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason), + ], + ]; + } + + /** + * @param array> $tasks + * @return array> + */ + protected function buildDebugDynamicImageButtons(array $tasks): array + { + $items = []; + foreach ($tasks as $task) { + $taskId = (int)($task['id'] ?? 0); + if ($taskId <= 0) { + continue; + } + $taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId)); + $active = (bool)($task['active'] ?? false); + foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) { + $buttonLabel = $this->debugDynamicImageButtonLabel($button); + $id = $taskId . ':' . (int)$index; + $nodeId = 'dynamic_image_button:' . $id; + $reason = $active + ? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.' + : 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.'; + $items[] = [ + 'kind' => 'dynamic_image_button', + 'id' => $id, + 'node_id' => $nodeId, + 'node_ids' => ['task:' . $taskId, $nodeId], + 'label' => $buttonLabel, + 'task_id' => $taskId, + 'task_label' => $taskLabel, + 'button_index' => (int)$index, + 'button' => $button, + 'state' => $active ? 'active' : 'hidden', + 'reason' => $reason, + 'causes' => $active ? [] : [ + $this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)), + ], + ]; + } + } + + return $items; + } + + protected function taskUsesProgramPicker(array $task): bool + { + if (in_array( + 'PROGRAM_PICKER', + $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), + true + )) { + return true; + } + + return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true); + } + + protected function isProgramNumberButton(mixed $button): bool + { + return is_int($button) && $button >= 0 && $button <= 11; + } + + protected function dynamicImageButtonSequenceForTask(array $task): array + { + $buttons = $this->normalizeButtonList($task['buttons'] ?? null); + if (!$this->taskUsesProgramPicker($task)) { + return $buttons; + } + + $sequence = ['program_picker']; + foreach ($buttons as $button) { + if ($button === 'program_picker' || $this->isProgramNumberButton($button)) { + continue; + } + $sequence[] = $button; + } + + return $this->normalizeButtonList($sequence); + } + + /** + * @param array> $questions + * @param array> $conditions + * @param array> $rules + * @param array> $tasks + * @param array> $actions + * @param array> $signals + * @param array> $dynamicImageButtons + * @return array> + */ + protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array + { + $decisions = []; + foreach ([ + 'question' => $questions, + 'condition' => $conditions, + 'rule' => $rules, + 'task' => $tasks, + 'action' => $actions, + 'signal' => $signals, + 'dynamic_image_button' => $dynamicImageButtons, + ] as $kind => $items) { + foreach ($items as $item) { + if (is_array($item)) { + $decisions[] = $this->debugDecisionFromItem($kind, $item); + } + } + } + + return $decisions; + } + + /** + * @param array $item + * @return array + */ + protected function debugDecisionFromItem(string $kind, array $item): array + { + $nodeIds = []; + foreach ((array)($item['node_ids'] ?? []) as $nodeId) { + if (is_string($nodeId) && $nodeId !== '') { + $nodeIds[] = $nodeId; + } + } + if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') { + $nodeIds[] = $item['node_id']; + } + + $state = (string)($item['state'] ?? ''); + if ($state === '') { + $state = match ($kind) { + 'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'), + 'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'), + 'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'), + 'signal' => (string)($item['predicted_status'] ?? 'unknown'), + default => 'unknown', + }; + } + + $causes = []; + foreach ((array)($item['causes'] ?? []) as $cause) { + if (is_array($cause)) { + $causes[] = $cause; + } + } + + return [ + 'kind' => (string)($item['kind'] ?? $kind), + 'id' => $item['id'] ?? ($item['node_id'] ?? null), + 'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind), + 'state' => $state, + 'reason' => (string)($item['reason'] ?? ''), + 'node_ids' => array_values(array_unique($nodeIds)), + 'causes' => $causes, + ]; + } + + protected function debugDynamicImageButtonLabel(mixed $button): string + { + if (is_array($button)) { + foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) { + if (isset($button[$key]) && trim((string)$button[$key]) !== '') { + return (string)$button[$key]; + } + } + $encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return $encoded === false ? 'Button' : $encoded; + } + $label = trim((string)$button); + if (strtolower($label) === 'program_picker') { + return 'Program picker'; + } + + return $label === '' ? 'Button' : $label; + } + + /** + * @param array> $conditions + * @param array> $rules + * @return array + */ + protected function detectConditionCycles(array $conditions, array $rules): array + { + $edges = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + $parentId = $this->nullableInt($condition['condition_id'] ?? null); + if ($conditionId > 0 && $parentId !== null) { + $edges[$conditionId][] = $parentId; + } + } + foreach ($rules as $rule) { + if (strtolower((string)($rule['object_type'] ?? '')) !== 'condition') { + continue; + } + $conditionId = (int)($rule['condition_id'] ?? 0); + $objectId = (int)($rule['object_id'] ?? 0); + if ($conditionId > 0 && $objectId > 0) { + $edges[$conditionId][] = $objectId; + } + } + + $visiting = []; + $visited = []; + $cycles = []; + $walk = function (int $conditionId) use (&$walk, &$visiting, &$visited, &$cycles, $edges): void { + if (isset($visited[$conditionId])) { + return; + } + if (isset($visiting[$conditionId])) { + $cycles[$conditionId] = $conditionId; + return; + } + $visiting[$conditionId] = true; + foreach ($edges[$conditionId] ?? [] as $nextId) { + $walk((int)$nextId); + if (isset($cycles[(int)$nextId])) { + $cycles[$conditionId] = $conditionId; + } + } + unset($visiting[$conditionId]); + $visited[$conditionId] = true; + }; + + foreach (array_keys($edges) as $conditionId) { + $walk((int)$conditionId); + } + + return array_values($cycles); + } + + /** + * @param array $expression + * @param array $lookups + */ + protected function debugExpressionSummary(array $expression, array $lookups): string + { + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); + $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; + $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); + return $label . ' ' . strtolower(str_replace('_', ' ', $operator)); + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + if ($children === []) { + return 'No predicates'; + } + + $parts = []; + foreach (array_slice($children, 0, 3) as $child) { + if (is_array($child)) { + $parts[] = $this->debugExpressionSummary((array)$child, $lookups); + } + } + if (count($children) > 3) { + $parts[] = '+' . (count($children) - 3) . ' more'; + } + + return ($operator === 'ANY' ? 'Any of: ' : 'All of: ') . implode('; ', $parts); + } + + /** + * @param array $trace + * @param array $lookups + */ + protected function nextFixForExpressionTrace(array $trace, array $lookups): ?string + { + $failed = $this->firstFailedPredicateTrace((array)($trace['expression'] ?? $trace)); + if ($failed === null) { + return null; + } + + $subjectType = strtolower((string)($failed['subject_type'] ?? 'question')); + $subjectId = (int)($failed['subject_id'] ?? 0); + $operator = strtoupper((string)($failed['operator'] ?? 'IS_TRUE')); + $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; + $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); + + return 'Set ' . $label . ' so it satisfies ' . strtolower(str_replace('_', ' ', $operator)) . '.'; + } + + /** + * @param array $trace + * @return array|null + */ + protected function firstFailedPredicateTrace(array $trace): ?array + { + if (($trace['type'] ?? '') === 'predicate') { + return (($trace['result'] ?? false) === true) ? null : $trace; + } + foreach ((array)($trace['children'] ?? []) as $child) { + if (!is_array($child)) { + continue; + } + $failed = $this->firstFailedPredicateTrace((array)$child); + if ($failed !== null) { + return $failed; + } + } + foreach (['when', 'then', 'default'] as $field) { + if (!is_array($trace[$field] ?? null)) { + continue; + } + $failed = $this->firstFailedPredicateTrace((array)$trace[$field]); + if ($failed !== null) { + return $failed; + } + } + foreach (['branches', 'cases'] as $field) { + foreach ((array)($trace[$field] ?? []) as $child) { + if (!is_array($child)) { + continue; + } + $failed = $this->firstFailedPredicateTrace((array)$child); + if ($failed !== null) { + return $failed; + } + } + } + return null; + } + + /** + * @param array $workspace + * @return array> + */ + protected function debugRelayServicesFromWorkspace(array $workspace): array + { + $servicesByRelay = []; + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + foreach ($this->debugNormalizeServiceValues($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '') as $service) { + $servicesByRelay[$relayId][$service] = true; + } + } + } + + return array_map(static fn(array $services): array => array_keys($services), $servicesByRelay); + } + + /** + * @param array $binding + * @param array> $relayServices + * @return array + */ + protected function debugBindingServices(array $binding, string $relayId, array $relayServices): array + { + $services = []; + $addServices = function (mixed $value) use (&$services): void { + foreach ($this->debugNormalizeServiceValues($value) as $service) { + $services[$service] = true; + } + }; + + foreach (['role', 'service', 'slot'] as $field) { + $addServices($binding[$field] ?? null); + } + $addServices($binding['services'] ?? []); + + foreach ($this->debugBindingConsumerContexts($binding) as $context) { + if (!is_array($context)) { + continue; + } + foreach (['slot', 'role', 'service'] as $field) { + $addServices($context[$field] ?? null); + } + } + + foreach ((array)($relayServices[$relayId] ?? []) as $service) { + $addServices($service); + } + + return array_keys($services); + } + + /** + * @param array $binding + * @return array + */ + protected function debugBindingConsumerContexts(array $binding): array + { + $contexts = []; + foreach (['consumer_contexts', 'consumers'] as $field) { + foreach ((array)($binding[$field] ?? []) as $context) { + $contexts[] = $context; + } + } + $metadata = is_array($binding['metadata'] ?? null) ? (array)$binding['metadata'] : []; + foreach (['consumer_contexts', 'consumers'] as $field) { + foreach ((array)($metadata[$field] ?? []) as $context) { + $contexts[] = $context; + } + } + + return $contexts; + } + + /** + * @return array + */ + protected function debugNormalizeServiceValues(mixed $value): array + { + if (is_string($value)) { + $trimmed = trim($value); + if ($trimmed === '') { + return []; + } + $decoded = json_decode($trimmed, true); + $value = json_last_error() === JSON_ERROR_NONE && is_array($decoded) + ? $decoded + : explode(',', $trimmed); + } + + if (!is_array($value)) { + $value = [$value]; + } + + $services = []; + foreach ($value as $entry) { + if (is_array($entry)) { + continue; + } + $service = strtoupper(trim((string)$entry)); + if ($service !== '') { + $services[$service] = true; + } + } + + return array_keys($services); + } + + /** + * @param array $workspace + * @return array>> + */ + protected function debugGatewayBindingsByService(array $workspace): array + { + $bindings = []; + foreach ($this->debugGatewayBindingReferences($workspace) as $binding) { + foreach ((array)($binding['services'] ?? []) as $service) { + $row = $binding; + $row['service'] = $service; + $bindings[$service][] = $row; + } + } + + return $bindings; + } + + /** + * @param array $workspace + * @return array> + */ + protected function debugGatewayBindingReferences(array $workspace): array + { + $references = []; + $relayServices = $this->debugRelayServicesFromWorkspace($workspace); + foreach ((array)($workspace['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = $this->debugGatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } + foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $services = $this->debugBindingServices($binding, $relayId, $relayServices); + if ($services === []) { + continue; + } + $references[] = [ + 'gateway_id' => $gatewayId, + 'gateway_label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), + 'gateway_status' => (string)($gateway['status'] ?? 'UNKNOWN'), + 'gateway_node_id' => (string)($gateway['node_id'] ?? ('gateway:' . $gatewayId)), + 'relay_id' => $relayId, + 'relay_label' => (string)($binding['label'] ?? ('Relay ' . $relayId)), + 'relay_node_id' => 'relay:' . $relayId, + 'binding_index' => (int)$index, + 'node_id' => (string)($binding['node_id'] ?? ('binding:' . $gatewayId . ':' . $relayId . ':' . (int)$index)), + 'services' => $services, + 'channel' => $binding['channel'] ?? null, + 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), + ]; + } + } + + return $references; + } + + /** + * @param array $gateway + */ + protected function debugGatewayIdentifier(array $gateway): string + { + return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '')); + } + + /** + * @param array $snapshot + * @param array $missingBindings + * @param array $lookups + */ + protected function debugHardwareSummary(array $snapshot, array $missingBindings, array $lookups): string + { + if (($snapshot['machine_available'] ?? false) !== true) { + return 'Lane ' . $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, 'selected') . ' has no machine relay configured.'; + } + if ($missingBindings !== []) { + return 'Lane relay is configured, but gateway bindings are missing for: ' . implode(', ', $missingBindings) . '.'; + } + return 'Lane relay and gateway service bindings are ready for the simulated services.'; + } + + /** + * @param array $lookups + */ + protected function debugLabel(array $lookups, string $type, mixed $id, string $fallback): string + { + $key = (string)($id ?? ''); + if ($key === '' || $key === '0') { + return $fallback; + } + if (isset($lookups['labels'][$type][$key])) { + return (string)$lookups['labels'][$type][$key]; + } + foreach ((array)($lookups[$type] ?? []) as $row) { + if (is_array($row) && (string)($row['id'] ?? '') === $key) { + return (string)($row['label'] ?? $fallback); + } + } + return $fallback; + } + + /** + * @param array $lookups + */ + protected function debugGateReferenceLabel(array $lookups, string $gateType, int $gateRefId): string + { + $gateType = strtoupper(trim($gateType)); + if ($gateType === selfserve_task_gate_type::CONDITION->value) { + return $this->debugLabel($lookups, 'conditions', $gateRefId, 'Condition ' . $gateRefId); + } + if ($gateType === selfserve_task_gate_type::QUESTION->value) { + return $this->debugLabel($lookups, 'questions', $gateRefId, 'Question ' . $gateRefId); + } + return 'Always'; + } + + protected function mergeAnnotationStates(string $left, string $right): string + { + $rank = [ + 'error' => 6, + 'blocked' => 5, + 'warning' => 4, + 'active' => 3, + 'visited' => 2, + 'not_applicable' => 1, + ]; + return (($rank[$left] ?? 0) >= ($rank[$right] ?? 0)) ? $left : $right; + } + + /** + * @param array|null $publishedConfig + */ + protected function loadQuestions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?array $publishedConfig = null): array + { + if (is_array($publishedConfig) && isset($publishedConfig['questions']) && is_array($publishedConfig['questions'])) { + $questions = array_values(array_filter($publishedConfig['questions'], static function (array $question) use ($departmentId): bool { + return ((int)($question['department'] ?? 0) === 0) || ((int)($question['department'] ?? 0) === $departmentId); + })); + + $sharedQuestions = array_values(array_filter($questions, static function (array $question): bool { + return (int)($question['department'] ?? 0) === 0 + && (int)($question['lane'] ?? 0) === 0 + && (int)($question['product'] ?? 0) === 0; + })); + if ($sharedQuestions !== []) { + return $sharedQuestions; + } + + if ($vehicleTypeId === null) { + return []; + } + + return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool { + return (int)($question['department'] ?? 0) === $departmentId + && (int)($question['lane'] ?? 0) === $laneId + && (int)($question['product'] ?? 0) === $vehicleTypeId; + })); + } + + $questionsObject = new department_selfserve_questions_o(); + $sharedQuestions = $questionsObject->getSharedQuestions(); + if ($sharedQuestions !== []) { + return $sharedQuestions; + } + if ($vehicleTypeId === null) { + return []; + } + + return $questionsObject->getLegacyQuestionsForLaneProduct($departmentId, $laneId, $vehicleTypeId); + } + + /** + * @param array|null $publishedConfig + */ + protected function loadConditions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array + { + if (is_array($publishedConfig) && isset($publishedConfig['conditions']) && is_array($publishedConfig['conditions'])) { + $conditions = array_values(array_filter($publishedConfig['conditions'], static function (array $condition) use ($departmentId): bool { + return ((int)($condition['department'] ?? 0) === 0) || ((int)($condition['department'] ?? 0) === $departmentId); + })); + + if ($machineTypeId !== null) { + $machineTypeConditions = array_values(array_filter($conditions, static function (array $condition) use ($machineTypeId): bool { + return (int)($condition['machine_type_id'] ?? 0) === $machineTypeId; + })); + if ($machineTypeConditions !== []) { + return $machineTypeConditions; + } + } + + if ($vehicleTypeId === null) { + return []; + } + + return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool { + return (int)($condition['machine_type_id'] ?? 0) === 0 + && (int)($condition['department'] ?? 0) === $departmentId + && (int)($condition['lane'] ?? 0) === $laneId + && (int)($condition['product'] ?? 0) === $vehicleTypeId; + })); + } + + $conditionsObject = new department_selfserve_conditions_o(); + if ($machineTypeId !== null) { + $machineTypeConditions = $conditionsObject->getConditionsForMachineType($machineTypeId); + if ($machineTypeConditions !== []) { + return $machineTypeConditions; + } + } + if ($vehicleTypeId === null) { + return []; + } + + return $conditionsObject->getLegacyConditionsForLaneProduct($departmentId, $laneId, $vehicleTypeId); + } + + /** + * @param array|null $publishedConfig + */ + protected function loadTasks(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array + { + if (is_array($publishedConfig) && isset($publishedConfig['tasks']) && is_array($publishedConfig['tasks'])) { + $tasks = array_values(array_filter($publishedConfig['tasks'], static function (array $task) use ($departmentId): bool { + return ((int)($task['department'] ?? 0) === 0) || ((int)($task['department'] ?? 0) === $departmentId); + })); + + if ($machineTypeId !== null) { + $machineTypeTasks = array_values(array_filter($tasks, static function (array $task) use ($machineTypeId): bool { + return (int)($task['machine_type_id'] ?? 0) === $machineTypeId; + })); + if ($machineTypeTasks !== []) { + return $machineTypeTasks; + } + } + if ($vehicleTypeId === null) { + return []; + } + + return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool { + return (int)($task['machine_type_id'] ?? 0) === 0 + && (int)($task['department'] ?? 0) === $departmentId + && (int)($task['lane'] ?? 0) === $laneId + && (int)($task['product'] ?? 0) === $vehicleTypeId; + })); + } + + $tasksObject = new department_selfserve_tasks_o(); + if ($machineTypeId !== null) { + $machineTypeTasks = $tasksObject->getTasksForMachineType($machineTypeId); + if ($machineTypeTasks !== []) { + return $machineTypeTasks; + } + } + if ($vehicleTypeId === null) { + return []; + } + + return $tasksObject->getLegacyTasksForLaneProduct($departmentId, $laneId, $vehicleTypeId); + } + + /** + * @param array $task + * @param array $conditionIds + * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null} + */ + protected function resolveTaskGate(array $task, array $conditionIds): array + { + $gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? '')))); + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + $legacyGateId = $this->nullableInt($task['condition_id'] ?? null); + + if ( + $gateType === selfserve_task_gate_type::CONDITION + || $gateType === selfserve_task_gate_type::QUESTION + ) { + return [ + 'gate_type' => $gateType, + 'gate_ref_id' => $gateRefId ?? $legacyGateId, + ]; + } + + $shouldInferLegacyGate = $gateType === null + || ( + $gateType === selfserve_task_gate_type::ALWAYS + && $gateRefId === null + && $legacyGateId !== null + ); + + if ($shouldInferLegacyGate) { + $fallbackGateId = $gateRefId ?? $legacyGateId; + if ($fallbackGateId === null) { + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + return [ + 'gate_type' => in_array($fallbackGateId, $conditionIds, true) + ? selfserve_task_gate_type::CONDITION + : selfserve_task_gate_type::QUESTION, + 'gate_ref_id' => $fallbackGateId, + ]; + } + + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + /** + * @param array|null $publishedConfig + */ + protected function loadConditionRules(array $conditions, ?array $publishedConfig = null): array + { + if ($conditions === []) { + return []; + } + + if (is_array($publishedConfig) && (int)($publishedConfig['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2) { + return []; + } + + $conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions); + + if (is_array($publishedConfig) && isset($publishedConfig['rules']) && is_array($publishedConfig['rules'])) { + return array_values(array_filter($publishedConfig['rules'], static function (array $rule) use ($conditionIds): bool { + return in_array((int)($rule['condition_id'] ?? 0), $conditionIds, true); + })); + } + + return (new department_selfserve_condition_rules_o())->getFieldsWhereIn([ + 'condition_id' => $conditionIds, + 'deleted_at' => null, + ], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']); + } + + protected function syncSessionAnswers(int $sessionId, array $questions): void + { + $answersObject = new selfserve_wash_session_answers_o(); + $answeredQuestionIds = []; + foreach ($questions as $question) { + if ($question['answer'] === null) { + continue; + } + $answeredQuestionIds[] = (int)$question['id']; + $answersObject->upsert( + $sessionId, + (int)$question['id'], + (string)$question['question'], + (bool)$question['answer'], + ); + } + $answersObject->deleteMissingForSession($sessionId, $answeredQuestionIds); + } + + protected function syncSessionTasks(int $sessionId, array $tasks): void + { + $tasksObject = new selfserve_wash_session_tasks_o(); + $tasksObject->deleteBySession($sessionId); + + foreach ($tasks as $task) { + $tasksObject->addSnapshot( + $sessionId, + (int)$task['id'], + (string)$task['task'], + (string)$task['description'], + $task['services'], + $task['buttons'], + $task['dynamic_images_vehicle_type'] ?? null, + ); + } + } + + protected function logSessionEvent(int $sessionId, selfserve_wash_event_type $eventType, ?array $payload = null): void + { + (new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload); + } + + protected function buildSessionMetadata(array $snapshot): array + { + return [ + 'allowed_services' => $snapshot['allowed_services'], + 'machine_available' => (bool)$snapshot['machine_available'], + 'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'], + 'config_version_id' => $snapshot['config_version_id'] ?? null, + 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, + 'visible_question_ids' => array_map(static fn(array $question): int => (int)$question['id'], $snapshot['questions']), + 'visible_questions' => array_map(static fn(array $question): array => [ + 'id' => (int)$question['id'], + 'question' => (string)$question['question'], + 'order_priority' => (int)($question['order_priority'] ?? 0), + ], $snapshot['questions']), + 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), + ]; + } + + /** + * @param array> $answerRows + * @return array> + */ + protected function buildSessionQuestions(selfserve_wash_sessions_o $session, array $answerRows): array + { + $answersByQuestionId = []; + foreach ($answerRows as $row) { + $answersByQuestionId[(int)$row['question_id']] = $row; + } + + $metadata = $session->metadata_json->value(); + $metadata = is_array($metadata) ? $metadata : []; + $visibleQuestions = $this->resolveVisibleQuestionsFromMetadata( + $metadata, + (int)$session->department_id->value(), + (int)$session->lane_id->value(), + $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), + ); + + if ($visibleQuestions === []) { + return array_map(static function (array $row): array { + return [ + 'question_id' => (int)$row['question_id'], + 'question' => (string)$row['question_text'], + 'answer' => (bool)$row['answer_value'], + 'answered_at' => (string)$row['answered_at'], + ]; + }, $answerRows); + } + + $questions = []; + foreach ($visibleQuestions as $visibleQuestion) { + $questionId = (int)$visibleQuestion['id']; + if ($questionId <= 0) { + continue; + } + + $answerRow = $answersByQuestionId[$questionId] ?? null; + $questions[] = [ + 'question_id' => $questionId, + 'question' => $answerRow === null ? (string)$visibleQuestion['question'] : (string)$answerRow['question_text'], + 'answer' => $answerRow === null ? null : (bool)$answerRow['answer_value'], + 'answered_at' => $answerRow === null ? null : (string)$answerRow['answered_at'], + ]; + unset($answersByQuestionId[$questionId]); + } + + foreach ($answerRows as $row) { + $questionId = (int)$row['question_id']; + if (!array_key_exists($questionId, $answersByQuestionId)) { + continue; + } + + $questions[] = [ + 'question_id' => $questionId, + 'question' => (string)$row['question_text'], + 'answer' => (bool)$row['answer_value'], + 'answered_at' => (string)$row['answered_at'], + ]; + unset($answersByQuestionId[$questionId]); + } + + return $questions; + } + + /** + * @param array $metadata + * @return array + */ + protected function resolveVisibleQuestionsFromMetadata(array $metadata, int $departmentId, int $laneId, ?int $vehicleTypeId): array + { + $visibleQuestions = []; + if (isset($metadata['visible_questions']) && is_array($metadata['visible_questions'])) { + foreach ($metadata['visible_questions'] as $visibleQuestion) { + if (!is_array($visibleQuestion)) { + continue; + } + $questionId = (int)($visibleQuestion['id'] ?? 0); + if ($questionId <= 0) { + continue; + } + $visibleQuestions[] = [ + 'id' => $questionId, + 'question' => (string)($visibleQuestion['question'] ?? ''), + 'order_priority' => (int)($visibleQuestion['order_priority'] ?? 0), + ]; + } + return $visibleQuestions; + } + + if (!isset($metadata['visible_question_ids']) || !is_array($metadata['visible_question_ids'])) { + return []; + } + + $questionById = []; + foreach ($this->loadQuestions($departmentId, $laneId, $vehicleTypeId) as $question) { + $questionById[(int)$question['id']] = [ + 'question' => (string)($question['question'] ?? ''), + 'order_priority' => (int)($question['order_priority'] ?? 0), + ]; + } + + foreach ($metadata['visible_question_ids'] as $visibleQuestionId) { + $questionId = (int)$visibleQuestionId; + if ($questionId <= 0) { + continue; + } + $visibleQuestions[] = [ + 'id' => $questionId, + 'question' => (string)($questionById[$questionId]['question'] ?? ''), + 'order_priority' => (int)($questionById[$questionId]['order_priority'] ?? 0), + ]; + } + + return $visibleQuestions; + } + + protected function findVehicleByRegistration(string $reg): ?customer_vehicles_o + { + $vehicle = (new customer_vehicles_o())->selectByPlate($reg); + return $vehicle->exists() ? $vehicle : null; + } + + protected function resolveVehicleTypeId(?customer_vehicles_o $vehicle, ?int $vehicleTypeIdOverride = null): ?int + { + if ($vehicleTypeIdOverride !== null && $vehicleTypeIdOverride > 0) { + return $vehicleTypeIdOverride; + } + if ($vehicle === null) { + return null; + } + + $vehicleTypeId = (int)$vehicle->type->value(); + return $vehicleTypeId > 0 ? $vehicleTypeId : null; + } + + protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o + { + $session = new selfserve_wash_sessions_o(); + $session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber); + return $session; + } + + protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null): selfserve_wash_sessions_o + { + $rows = (new selfserve_wash_sessions_o())->getFieldsWhere( + [ + 'lane_id' => $laneId, + 'completed_at' => null, + 'deleted_at' => null, + ...($customerNumber !== null ? ['customer_number' => $customerNumber] : []), + ], + ['id', 'status'] + ); + $rows = array_values(array_filter( + $rows, + static fn(array $row): bool => !selfserve_wash_sessions_o::isTerminalStatus($row['status'] ?? null) + )); + if ($rows === []) { + return new selfserve_wash_sessions_o(); + } + + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + return (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']); + } + + protected function resolveForceStopSession(int $laneId, ?int $sessionId = null): selfserve_wash_sessions_o + { + if ($sessionId === null) { + return (new selfserve_wash_sessions_o())->selectLatestOpenByLane($laneId); + } + + $session = (new selfserve_wash_sessions_o())->select($sessionId); + if (!$session->exists()) { + throw new \RuntimeException('Self-serve wash session not found.'); + } + if ((int)$session->lane_id->value() !== $laneId) { + throw new \RuntimeException('Self-serve wash session does not belong to the requested lane.'); + } + if ($session->completed_at->value() !== null) { + throw new \RuntimeException('Self-serve wash session is already closed.'); + } + + return $session; + } + + protected function buildForceStopRuntimeSnapshot(selfserve_lane $lane): array + { + return [ + 'status' => $lane->getLaneStatus()->name, + 'mode' => $lane->getLaneMode()->name, + 'state' => $lane->getLaneState()->name, + 'wash_start_time' => $lane->getWashStartTime(), + 'elapsed_wash_time' => $lane->getElapsedWashTime(), + 'license_plate' => $lane->getLicensePlate(), + 'customer_number' => $lane->getCustomerNumber(), + ]; + } + + protected function laneRuntimeLooksActive(array $snapshot): bool + { + return $snapshot['status'] === selfserve_lane_status::OCCUPIED->name + || $snapshot['state'] === selfserve_lane_state::IN_WASH->name + || (int)($snapshot['wash_start_time'] ?? 0) > 0 + || trim((string)($snapshot['license_plate'] ?? '')) !== '' + || (int)($snapshot['customer_number'] ?? 0) > 0; + } + + protected function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 0 || $value === '0') { + return null; + } + + return (int)$value; + } + + /** + * @param array $answers + * @param array $visibleQuestionIds + * @return array + */ + protected function filterAnswersToVisibleQuestions(array $answers, array $visibleQuestionIds): array + { + $filtered = []; + foreach ($visibleQuestionIds as $questionId) { + $questionId = (int)$questionId; + if ($questionId <= 0 || !array_key_exists($questionId, $answers)) { + continue; + } + $filtered[$questionId] = $answers[$questionId]; + } + return $filtered; + } + + protected function normalizeJsonValue(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + if (is_array($value)) { + return $value; + } + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + } + return $value; + } + + protected function normalizeJsonArray(mixed $value): array + { + $decoded = $this->normalizeJsonValue($value); + return is_array($decoded) ? $decoded : []; + } + + protected function normalizeButtonList(mixed $value): array + { + try { + return department_selfserve_tasks_o::normalizeButtonsInput($value); + } catch (\Throwable) { + return []; + } + } + + /** + * @param array> $tasks + * @param array $allowedServices + * @return array> + */ + protected function filterTasksForAllowedServices(array $tasks, array $allowedServices): array + { + if (in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true)) { + return array_values($tasks); + } + + return array_values(array_filter( + $tasks, + fn(array $task): bool => !$this->taskUsesMachineControls($task) + )); + } + + protected function taskUsesMachineControls(array $task): bool + { + if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) { + return true; + } + + if ($this->normalizeButtonList($task['buttons'] ?? null) !== []) { + return true; + } + + $dynamicImagesVehicleType = $task['dynamic_images_vehicle_type'] ?? null; + return $dynamicImagesVehicleType !== null && $dynamicImagesVehicleType !== ''; + } + + protected function normalizeServiceNames(array $services): array + { + $normalized = []; + foreach ($services as $service) { + $name = strtoupper(trim((string)$service)); + if ($name === '') { + continue; + } + if (!in_array($name, $normalized, true)) { + $normalized[] = $name; + } + } + return $normalized; + } + + protected function normalizeIntArray(array $values): array + { + $normalized = []; + foreach ($values as $value) { + $intValue = (int)$value; + if (!in_array($intValue, $normalized, true)) { + $normalized[] = $intValue; + } + } + return $normalized; + } + + public function isMachineAllowedToStartWash(int $id): bool + { + $sessionSummary = $this->getSessionSummary($id); + return ($sessionSummary['session']['allowed'] ?? false) === true; + } +} diff --git a/services/nginx/app/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php b/services/nginx/app/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php new file mode 100644 index 00000000..d34d8550 --- /dev/null +++ b/services/nginx/app/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php @@ -0,0 +1,29 @@ + selfserve_lane_command::RESET, 'RESERVE' => selfserve_lane_command::RESERVE, 'RELEASE' => selfserve_lane_command::RELEASE, + 'OPEN_PROPERTY_ACCESS_GATE' => selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, + 'OPEN_PROPERTY_EXIT_GATE' => selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, default => null, }; } diff --git a/services/nginx/app/modules/selfserve/helpers/selfserve_lane_relay.php b/services/nginx/app/modules/selfserve/helpers/selfserve_lane_relay.php index abeaca0f..e78833a9 100644 --- a/services/nginx/app/modules/selfserve/helpers/selfserve_lane_relay.php +++ b/services/nginx/app/modules/selfserve/helpers/selfserve_lane_relay.php @@ -5,4 +5,6 @@ namespace modules\selfserve\helpers; enum selfserve_lane_relay { case MACHINE; // Relay that controls the machine power + case MACHINE_PROGRAM_PICKER; // Relay that controls the machine program picker + case MACHINE_CLEANER; // Relay that controls the machine cleaner } diff --git a/services/nginx/app/modules/selfserve/helpers/selfserve_lane_services.php b/services/nginx/app/modules/selfserve/helpers/selfserve_lane_services.php index 2dc4c6ac..c59c2ea6 100644 --- a/services/nginx/app/modules/selfserve/helpers/selfserve_lane_services.php +++ b/services/nginx/app/modules/selfserve/helpers/selfserve_lane_services.php @@ -5,5 +5,6 @@ namespace modules\selfserve\helpers; enum selfserve_lane_services { case MACHINE; // Relay that controls the machine power + case PROGRAM_PICKER; // Relay that controls the machine program picker } diff --git a/services/nginx/app/modules/selfserve/helpers/selfserve_task_gate_type.php b/services/nginx/app/modules/selfserve/helpers/selfserve_task_gate_type.php new file mode 100644 index 00000000..9e4ff74d --- /dev/null +++ b/services/nginx/app/modules/selfserve/helpers/selfserve_task_gate_type.php @@ -0,0 +1,10 @@ +> $conditions + * @param array> $rules + * @param array $answers + * @return array + */ + public function evaluate(array $conditions, array $rules, array $answers): array; + + /** + * @param array> $conditions + * @param array $answers + * @return array + */ + public function evaluateExpressions(array $conditions, array $answers): array; + + /** + * @param array> $conditions + * @param array $answers + * @return array{results:array,trace:array>} + */ + public function evaluateExpressionsWithTrace(array $conditions, array $answers): array; + + /** + * @param int|null $gateId + * @param array $conditionResults + * @param array $answers + * @return bool + */ + public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool; + + /** + * @param string $gateType ALWAYS|CONDITION|QUESTION + * @param int|null $gateRefId + * @param array $conditionResults + * @param array $answers + * @return bool + */ + public function taskGateSatisfiedTyped(string $gateType, ?int $gateRefId, array $conditionResults, array $answers): bool; +} diff --git a/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php new file mode 100644 index 00000000..587384dc --- /dev/null +++ b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php @@ -0,0 +1,20 @@ +>API: GET /department/selfserve/vehicle/allowed?lane_id=12®=AB12345 + API->>Flow: previewVehicleEligibility() + Flow-->>API: Questions, tasks, allowed=false/true + API-->>Customer: Eligibility preview + + Customer->>API: POST /department/selfserve/vehicle/conditions + API->>Flow: synchronizeSession() + Flow->>Lane: setLaneCache(allowed_services) + Flow->>Lane: turnOnRelay(MACHINE) + Lane->>Shelly: switch(true) + Flow-->>API: Session summary with MACHINE_RELAY_ENABLED event + API-->>Customer: Updated summary + + Scanner->>API: POST /relay/button/press/post + API->>Flow: recordMachineStartWebhook() + Flow->>Lane: set status/state, reg, customer, wash timer + Flow-->>API: Session summary with MACHINE_START_TRIGGERED event + + Customer->>API: POST /modules/self-serve/lane/command (STOP) + API->>Lane: execute(STOP) + Lane->>Lane: invoice() and open exit port + Lane->>Flow: completeLatestSessionForLane(order_id) + Flow-->>API: Session summary with SESSION_COMPLETED event + API-->>Customer: Lane reset response +``` + +## Architecture + +```mermaid +flowchart LR + Q["department_selfserve_questions
shared questions preferred"] --> F["selfserve_wash_flow"] + VC["department_selfserve_vehicle_conditions
vehicle answers"] --> F + C["department_selfserve_conditions
machine-type or legacy"] --> E["selfserve_condition_evaluator"] + R["department_selfserve_condition_rules"] --> E + T["department_selfserve_tasks
machine-type or legacy"] --> F + MT["selfserve_machine_types"] --> L["department_lanes.machine_type_id"] + E --> F + L --> F + F --> SL["selfserve_lane"] + SL --> SH["Shelly machine relay"] + F --> S["selfserve_wash_sessions"] + F --> SA["selfserve_wash_session_answers"] + F --> ST["selfserve_wash_session_tasks"] + F --> SE["selfserve_wash_session_events"] +``` + +## Domain Model + +### Entity Roles + +| Concept | Main object/class | Scope | Purpose | +| --- | --- | --- | --- | +| Shared questions | `department_selfserve_questions_o` | Shared rows are preferred over legacy lane/product rows | Defines the yes/no questions the customer must answer. | +| Conditions | `department_selfserve_conditions_o` | Prefer `machine_type_id`, else fall back to legacy department/lane/product rows | Groups rules into named boolean gates. | +| Condition rules | `department_selfserve_condition_rules_o` | Attached to a condition | Evaluates question answers or nested conditions. | +| Tasks | `department_selfserve_tasks_o` | Prefer `machine_type_id`, else fall back to legacy department/lane/product rows | Drives the visible self-serve tasks and exposed services such as `MACHINE`. | +| Machine types | `selfserve_machine_types_o` | Reusable across lanes | Lets multiple lanes share the same conditions and tasks. | +| Vehicle answers | `department_selfserve_vehicle_conditions_o` | Per department, lane, registration, and question | Stores the customer's answers. | +| Wash session | `selfserve_wash_sessions_o` | Per lane and vehicle | Tracks current wash lifecycle, timestamps, machine flags, and billing order. | +| Session answers | `selfserve_wash_session_answers_o` | Per session | Snapshot of the answered visible questions. | +| Session tasks | `selfserve_wash_session_tasks_o` | Per session | Snapshot of the active tasks and attached services/buttons. | +| Session events | `selfserve_wash_session_events_o` | Per session | Audit trail for sync, relay enable, machine start, and completion. | +| Lane runtime | `selfserve_lane` | Per lane | Applies lane status/state changes, relay control, billing, and STOP/RESET behavior. | + +### Scoping Rules + +- Questions should now be defined as shared questions. `selfserve_wash_flow::loadQuestions()` first calls `department_selfserve_questions_o::getSharedQuestions()`. If shared questions exist, they are used for every department and lane. +- Conditions and tasks are machine-type-first. If a lane has `department_lanes.machine_type_id` and matching rows exist, `selfserve_wash_flow` uses those rows and ignores the legacy lane/product rows. +- Legacy fallback still exists. If a lane has no machine type or there are no machine-type-specific rows, the flow falls back to the old department/lane/product plus vehicle-type lookup. +- Vehicle answers are still stored per department, lane, registration number, and question. Those answers feed both direct task gates and nested condition evaluation. + +### Runtime Status Tables + +#### Wash Session Status + +| Status | Meaning | +| --- | --- | +| `PENDING_QUESTIONS` | At least one visible question has no answer yet. | +| `READY_FOR_MACHINE_START` | The snapshot is eligible and ready to enable or start the machine. | +| `MACHINE_NOT_ALLOWED` | All visible questions are answered, but the lane cannot start the machine. | +| `MACHINE_RELAY_ENABLED` | The relay has already been enabled for the session. | +| `MACHINE_STARTED` | The physical machine start webhook has been recorded. | +| `COMPLETED` | The STOP flow completed the session, optionally with an `order_id`. | + +#### Wash Event Types + +| Event | Meaning | +| --- | --- | +| `SESSION_SYNCED` | A snapshot was written to the current session. | +| `MACHINE_RELAY_ENABLED` | The relay was enabled because the snapshot allowed machine start. | +| `MACHINE_START_TRIGGERED` | The physical machine start webhook was recorded. | +| `SESSION_COMPLETED` | The latest open session for the lane was closed, usually from STOP. | + +#### Lane Status + +| Status | Meaning | +| --- | --- | +| `AVAILABLE` | Lane is free and ready. | +| `OCCUPIED` | Lane is currently in use. | +| `RESERVED` | Lane is reserved but not yet started. | +| `FAULT` | Lane cannot be used until the fault is cleared. | +| `MAINTENANCE` | Lane is intentionally unavailable. | +| `CLOSED` | Lane is closed. | + +#### Lane State + +| State | Meaning | +| --- | --- | +| `IDLE` | Resting lane state. | +| `ENTRANCE_PORT_OPEN_QUEUED` | Entrance gate open is queued. | +| `ENTRANCE_PORT_OPEN` | Entrance gate is open. | +| `MACHINE_RELAY_ON_QUEUED` | Machine relay enable is queued. | +| `MACHINE_RELAY_ON` | Machine relay is on. | +| `MACHINE_RELAY_OFF_QUEUED` | Machine relay disable is queued. | +| `MACHINE_RELAY_OFF` | Machine relay is off. | +| `IN_WASH` | The machine has started or the wash is in progress. | +| `EXIT_PORT_OPEN_QUEUED` | Exit gate open is queued. | +| `EXIT_PORT_OPEN` | Exit gate is open. | +| `FAULT` | Lane fault state. | +| `MAINTENANCE` | Maintenance state. | +| `CLOSED` | Closed state. | + +## Eligibility Rules + +`selfserve_wash_flow` allows machine start only when all of these are true: + +1. Every visible question has an answer. +2. The lane has `relay_machine_id`. +3. At least one active task exposes the `MACHINE` service. + +Visible questions are determined by question `condition_id` gates. Active tasks are determined by `selfserve_condition_evaluator::taskGateSatisfied()`. + +`selfserve_condition_evaluator` uses these rules: + +- Non-`IS_TRUE_OR_ANY_TRUE` rules are AND-ed together. +- `IS_TRUE_OR_ANY_TRUE` rules are OR-ed together inside the same condition. +- A task gate id is resolved as a condition result first. If there is no condition with that id, it falls back to the raw answer for that question id. + +Supported rule types: + +- `IS_TRUE` +- `IS_FALSE` +- `IS_SET` +- `IS_TRUE_OR_NOT_SET` +- `IS_FALSE_OR_NOT_SET` +- `IS_TRUE_OR_ANY_TRUE` + +Supported rule object types: + +- `question` +- `condition` + +## Public APIs + +All `/department/selfserve/*` and `/modules/self-serve/*` routes require an authenticated user session and the listed permissions. + +`/relay/button/press/post` is different: it uses plate-scanner authentication through `authentication::get_plate_scanner()`, which accepts the token from: + +- `Authorization: Bearer ` +- query parameter `token` +- POST body `token` +- JSON body field `token` + +### `/department/selfserve/questions` + +Purpose: manage shared or legacy self-serve questions. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/questions` | none, optional `id`, `department`, `lane`, `product` | `list_department_selfserve_questions`, optional `view_all_department_selfserve_questions` | Returns one question by `id` or a filtered paginated list. | +| `POST /department/selfserve/questions` | `question`, `description` | `add_department_selfserve_questions` | Optional `department`, `lane`, `product`, `condition_id`, `order_priority`. Use `0/0/0` for shared questions. | +| `PUT /department/selfserve/questions` | `id` | `edit_department_selfserve_questions` | Updates any subset of fields. | +| `DELETE /department/selfserve/questions` | `id` | `delete_department_selfserve_questions` | Soft-deletes the question. | + +Typical failures: + +- `400` missing required fields +- `403` user cannot access the target department +- `404` question not found + +### `/department/selfserve/conditions` + +Purpose: manage named condition gates used by tasks and question visibility. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/conditions` | none, optional `id`, `department`, `lane`, `product`, `machine_type_id` | `list_department_selfserve_conditions`, optional `view_all_department_selfserve_conditions` | Lists one or many conditions. | +| `POST /department/selfserve/conditions` | `name`, `description`, and either `machine_type_id` or `department`+`lane`+`product` | `add_department_selfserve_conditions` | Optional parent `condition_id` supports nested trees. | +| `PUT /department/selfserve/conditions` | `id` | `update_department_selfserve_conditions` | Can move a condition between machine types or legacy scopes. | +| `DELETE /department/selfserve/conditions` | `id` | `delete_department_selfserve_conditions` | Soft-deletes the condition. | + +Typical failures: + +- `400` missing scope information +- `403` user cannot access the source or target department +- `404` condition not found + +### `/department/selfserve/condition/rules` + +Purpose: define how a condition becomes true or false. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/condition/rules` | none, optional `id`, `condition_id`, `type`, `object_type`, `object_id` | `list_department_selfserve_condition_rules`, optional `view_all_department_selfserve_condition_rules` | Access is checked through the parent condition's department. | +| `POST /department/selfserve/condition/rules` | `condition_id`, `type`, `object_type`, `object_id`, `name`, `description` | `add_department_selfserve_condition_rules` | `object_type` must be `question` or `condition`. | +| `PUT /department/selfserve/condition/rules` | `id` | `update_department_selfserve_condition_rules` | Supports moving the rule to another condition. | +| `DELETE /department/selfserve/condition/rules` | `id` | `delete_department_selfserve_condition_rules` | Soft-deletes the rule. | + +Typical failures: + +- `400` missing fields +- `403` department access denied through the parent condition +- `404` rule or target condition not found + +### `/department/selfserve/tasks` + +Purpose: manage the task list shown after eligibility evaluation. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/tasks` | none, optional `id`, `department`, `lane`, `product`, `condition_id`, `machine_type_id` | `list_department_selfserve_tasks`, optional `view_all_department_selfserve_tasks` | Lists one or many tasks. | +| `POST /department/selfserve/tasks` | `task`, `description`, and either `machine_type_id` or `department`+`lane`+`product` | `add_department_selfserve_tasks` | Optional `condition_id`, `order_priority`, `services`, `buttons`, `dynamic_images_vehicle_type`. | +| `PUT /department/selfserve/tasks` | `id` | `edit_department_selfserve_tasks` | Updates any subset of fields. | +| `DELETE /department/selfserve/tasks` | `id` | `delete_department_selfserve_tasks` | Soft-deletes the task. | + +Notes: + +- Route parameter name is `condition_id`. That value is used as the task gate id. +- `services` accepts an array, JSON array string, or comma-separated string. Current enum cases are `MACHINE` and `PROGRAM_PICKER`. +- If an active task does not expose `MACHINE`, the relay will not be enabled. + +Typical failures: + +- `400` missing fields or invalid `services` format +- `403` department access denied +- `404` task not found + +### `/department/selfserve/tasks/attachments*` + +Purpose: attach downloadable assets to self-serve tasks. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/tasks/attachments` | `id` | `list_department_selfserve_task_attachments` | Lists attachments for a task. | +| `GET /department/selfserve/tasks/attachments/download` | `task_id`, `attachment_id` | `download_department_selfserve_task_attachments` | Returns a direct download URL. | +| `POST /department/selfserve/tasks/attachments/upload` | `task_id`, `base64_file`, `file_name` | `add_department_selfserve_task_attachments` | Stores an attachment and links it to the task. | +| `DELETE /department/selfserve/tasks/attachments` | `task_id`, `attachment_id` | `delete_department_selfserve_task_attachments` | Removes the linked attachment from the task. | + +Typical failures: + +- `400` missing fields +- `403` department access denied +- `404` task or attachment not found + +### `/department/selfserve/vehicle/conditions` + +Purpose: create and manage the vehicle-specific answers that drive eligibility. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/vehicle/conditions` | none, optional `id`, `department`, `customer_id`, `lane`, `reg`, `question` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | `own_*` access is restricted to the user's customer number. | +| `POST /department/selfserve/vehicle/conditions` | `department`, `lane`, `reg`, `question`, `value` | `add_department_selfserve_vehicle_conditions` or `add_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::synchronizeSession()` and returns both the condition row and `selfserve` summary. | +| `PUT /department/selfserve/vehicle/conditions` | `id` | `update_department_selfserve_vehicle_conditions` or `update_own_department_selfserve_vehicle_conditions` | Re-synchronizes the session after the update. | +| `DELETE /department/selfserve/vehicle/conditions` | `id` | `delete_department_selfserve_vehicle_conditions` or `delete_own_department_selfserve_vehicle_conditions` | Deletes the answer and attempts to re-synchronize the session. | + +Typical failures: + +- `400` missing fields or invalid session +- `403` permission denied, wrong department, or wrong customer ownership +- `404` answer row not found + +### `/department/selfserve/vehicle/allowed` + +Purpose: preview whether self-serve is currently allowed for a vehicle on a lane. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/vehicle/allowed` | `lane_id`, `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::previewVehicleEligibility()` and returns questions, tasks, allowed services, and the current session if one exists. Own-permission customers may evaluate borrowed plates; saved answers only apply when scoped to the authenticated customer. | + +Typical failures: + +- `400` missing `lane_id` or `reg` +- `403` permission denied, missing customer context, or wrong department +- `404` lane not found + +### `/department/selfserve/washes/summary` + +Purpose: inspect the summary of a self-serve wash. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/washes/summary` | either `session_id`, or `lane_id` plus `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Returns `session`, `lane`, `machine_type`, `questions`, `tasks`, and `events`. | + +Typical failures: + +- `400` missing identifying parameters +- `403` permission denied or wrong vehicle ownership +- `404` session not found, or no session exists for lane and vehicle + +### `/department/selfserve/machine-types` + +Purpose: manage reusable machine type profiles. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/machine-types` | none, optional `id` | `list_department_selfserve_machine_types` | Lists one or many machine types. | +| `POST /department/selfserve/machine-types` | `name` | `add_department_selfserve_machine_types` | Optional `description`. | +| `PUT /department/selfserve/machine-types` | `id` | `update_department_selfserve_machine_types` | Updates `name` and/or `description`. | +| `DELETE /department/selfserve/machine-types` | `id` | `delete_department_selfserve_machine_types` | Soft-deletes the machine type. | + +Typical failures: + +- `400` missing `name` or empty update +- `404` machine type not found + +### `/modules/self-serve/lane/*` + +Purpose: operational lane control and relay management. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. | +| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. | +| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. | +| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. | +| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. | +| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. | +| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. | +| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. | +| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. | +| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine`, or customer `list_own_department_selfserve_vehicle_conditions` with an active wash in the lane department | Manual enable, still gated by allowed services. Customer flow calls this only after `START` and only when `MACHINE` is allowed. | +| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. | +| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. | + +Shelly transport behavior: + +- The department variable `shelly_transport_mode` controls the default relay path. Missing or `cloud` keeps Shelly cloud as the default. `gateway` routes self-serve relay status, set, manual enable, force, START/STOP side effects, and gate relay operations through the edge gateway/local edge agent. +- Local edge operation requires an active edge gateway plus an `edge_gateway_relay_bindings` row for each logical Shelly relay id. Each binding must resolve a bound `device_id`, `local_ip`, and `channel`. +- Binding fallback stays unchanged: `PREFER_LOCAL` uses the local edge agent first and may fall back to cloud, `LOCAL_ONLY` fails instead of falling back, and `CLOUD_ONLY` bypasses local dispatch. +- Operator diagnostics can add `transport=local` or `transport=gateway` to the `/modules/self-serve/lane/*` relay, gate, command, and allowed-services endpoints to force local-only dispatch. `transport=cloud` forces Shelly cloud. Regular customer flows do not send these overrides. +- Relay status and set responses keep `relay_id`, `online`, `on`, and `status.switch:0.output` stable. Gateway responses may also include `binding`, `execution`, and `raw` metadata for diagnostics. + +STOP flow details: + +- `selfserve_lane_command::STOP` requires lane status `OCCUPIED`. +- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number. +- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane. + +Customer start-wash release checklist: + +- Canary hardware validation must use the department and lane configured for the live Playwright/release credentials. Record the exact `department_id` and `lane_id` in the release notes before the live run. +- Verify the self-serve module is enabled, department self-serve is enabled, the target lane has `selfserve_enabled=1`, relays and property gates are bound, minute billing product is configured, and the machine task exposes the `MACHINE` service before promoting canary. +- Validate one supervised real-lane manual wash and, when configured, one machine wash before stable promotion. Confirm no relay changes before customer confirmation, active wash restore works across reloads, property gates open only during the active wash, `STOP` completes the session, and billing/order linkage is present. + +Typical failures: + +- `400` invalid parameters +- `403` missing permission +- `404` lane not found +- `403` from `/relay/machine/enable` if the `MACHINE` service is not currently allowed + +### `/relay/button/press/post` + +Purpose: record the physical machine start trigger. + +| Method | Required params | Authentication | Notes | +| --- | --- | --- | --- | +| `GET /relay/button/press/post` | optional `reg`, optional `lane_id` depending on department lane count | Plate-scanner auth | Supported for devices that can only call GET. | +| `POST /relay/button/press/post` | optional `reg`, optional `lane_id` depending on department lane count | Plate-scanner auth | Same behavior as GET. | + +Lane resolution behavior: + +- If `lane_id` is provided, the route verifies that the lane belongs to the scanner's department. +- If `lane_id` is not provided and the department has exactly one lane, that lane is used automatically. +- If `lane_id` is not provided and the department has multiple lanes, the request fails with `400`. + +Runtime behavior: + +- Calls `selfserve_wash_flow::recordMachineStartWebhook()`. +- Marks the session as machine-started. +- Updates lane status/state to occupied and in-wash if needed. +- Sets registration number, customer number, and wash start timestamp on the lane. + +Typical failures: + +- `403` invalid plate scanner token +- `400` missing `lane_id` in a multi-lane department +- `403` lane does not belong to the scanner department +- `404` no active self-serve wash session for the lane + +## Public Code Interfaces + +### `selfserve_wash_flow` and `selfserve_wash_flow_i` + +Main orchestration class for the self-serve lifecycle. + +Public methods: + +| Method | Use it when | Returns | +| --- | --- | --- | +| `previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null)` | You need a read-only eligibility preview without mutating state. | Snapshot with `questions`, `tasks`, `allowed_services`, `allowed`, and optional current `session`. | +| `synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true)` | Answers changed and you want session state, tasks, events, and relay enable to stay in sync. | Full session summary. | +| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. | +| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. | +| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. | +| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. | + +Key implementation details: + +- Calls `selfserve_schema_bootstrap::ensureTables()` in the constructor, so the session and machine-type tables are created lazily and idempotently at runtime. +- Loads shared questions first, then machine-type conditions and tasks first, then legacy fallback only when needed. +- Persists answer, task, and event snapshots on every sync. +- Enables the Shelly machine relay automatically when the snapshot is eligible and `activateMachine` is `true`. + +### `selfserve_condition_evaluator` and `selfserve_condition_evaluator_i` + +Pure evaluation layer for conditions and task gates. + +Public methods: + +| Method | Use it when | +| --- | --- | +| `evaluate(array $conditions, array $rules, array $answers)` | You need a boolean result map keyed by condition id. | +| `taskGateSatisfied(?int $gateId, array $conditionResults, array $answers)` | You need to decide whether a task should be active. | + +### `selfserve_lane` + +Runtime lane aggregate built from traits. + +Important operational methods used by the module: + +- `execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments)` +- `turnOnRelay(selfserve_lane_relay::MACHINE, ?int $duration = null)` +- `turnOffRelay(selfserve_lane_relay::MACHINE)` +- `forceTurnOnMachineRelay(?int $duration = null)` +- `forceTurnOffMachineRelay()` +- `getLaneStatus()` +- `getLaneState()` +- `setLaneStatus(...)` +- `setLaneState(...)` +- `setWashStartTime(...)` +- `getElapsedWashTime()` +- `setLicensePlate(...)` +- `setCustomerNumber(...)` +- `setLaneCache(...)` +- `invoice()` + +Important enums: + +- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE` +- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED` +- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states +- `selfserve_lane_services`: currently `MACHINE` and `PROGRAM_PICKER` + +### Machine Type And Wash Session Objects + +`selfserve_machine_types_o` is a reusable configuration object. It stores the profile name and description and is attached to a lane through `department_lanes.machine_type_id`. + +`selfserve_wash_sessions_o` is the lifecycle record. At behavior level it supports: + +- creating a new session with lane, machine type, customer, vehicle, and metadata +- updating status +- marking relay enabled +- marking machine start triggered +- marking completion with optional `order_id` +- selecting the latest open or latest overall session for a lane and registration number + +## End-To-End Example: Happy Path + +The example below shows the intended production flow with one shared question and one machine-type-specific task that exposes `MACHINE`. + +### 1. Create a machine type + +```bash +curl -X POST "$BASE_URL/department/selfserve/machine-types" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "HighPressureFoam", + "description": "Shared profile for foam cannon lanes" + }' +``` + +### 2. Associate the machine type with the lane + +This is done through the existing department lanes route, not a self-serve-specific route. + +```bash +curl -X PUT "$BASE_URL/department/lanes" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "id": 12, + "machine_type_id": 3 + }' +``` + +### 3. Create a shared question + +```bash +curl -X POST "$BASE_URL/department/selfserve/questions" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "department": 0, + "lane": 0, + "product": 0, + "question": "Is the hydraulic lock engaged?", + "description": "Required before machine start", + "order_priority": 10 + }' +``` + +### 4. Create a machine-type-specific condition and rule + +```bash +curl -X POST "$BASE_URL/department/selfserve/conditions" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "machine_type_id": 3, + "name": "Machine may start", + "description": "All mandatory safety checks passed" + }' +``` + +```bash +curl -X POST "$BASE_URL/department/selfserve/condition/rules" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "condition_id": 14, + "type": "IS_TRUE", + "object_type": "question", + "object_id": 21, + "name": "Hydraulic lock is engaged", + "description": "Question 21 must be answered true" + }' +``` + +### 5. Create a machine-type-specific task that exposes `MACHINE` + +```bash +curl -X POST "$BASE_URL/department/selfserve/tasks" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "machine_type_id": 3, + "condition_id": 14, + "task": "Press the machine start button", + "description": "The relay is enabled automatically when all answers allow it.", + "order_priority": 10, + "services": ["MACHINE"] + }' +``` + +### 6. Preview eligibility before answering + +```bash +curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \ + -H "Authorization: Bearer $TOKEN" +``` + +Typical result before all answers are present: + +```json +{ + "success": true, + "data": { + "allowed": false, + "all_visible_questions_answered": false, + "machine_available": true, + "questions": [ + { + "id": 21, + "question": "Is the hydraulic lock engaged?", + "answer": null + } + ], + "tasks": [] + } +} +``` + +### 7. Submit the answer + +```bash +curl -X POST "$BASE_URL/department/selfserve/vehicle/conditions" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "department": 7, + "lane": 12, + "reg": "AB12345", + "question": 21, + "value": true, + "customer_id": 100234 + }' +``` + +The POST response includes a `selfserve` summary. When the answer makes the vehicle eligible, `synchronizeSession()` will: + +- create or update the session +- snapshot the answer and tasks +- populate lane cache with `allowed_services` +- enable the Shelly relay +- log a `MACHINE_RELAY_ENABLED` event + +### 8. Inspect the summary + +```bash +curl "$BASE_URL/department/selfserve/washes/summary?lane_id=12®=AB12345" \ + -H "Authorization: Bearer $TOKEN" +``` + +Expected highlights: + +- `session.status` becomes `MACHINE_RELAY_ENABLED` +- `questions` contains the answered question +- `tasks` contains the machine start task +- `events` contains both `SESSION_SYNCED` and `MACHINE_RELAY_ENABLED` + +### 9. Record the physical machine start + +```bash +curl -X POST "$BASE_URL/relay/button/press/post?token=$SCANNER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lane_id": 12, + "reg": "AB12345", + "source": "shelly-button" + }' +``` + +Expected highlights: + +- `session.status` becomes `MACHINE_STARTED` +- the lane is `OCCUPIED` +- the lane state is `IN_WASH` +- `machine_start_triggered_at` is set +- the summary includes a `MACHINE_START_TRIGGERED` event + +### 10. Stop the lane and complete billing + +```bash +curl -X POST "$BASE_URL/modules/self-serve/lane/command" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lane_id": 12, + "command": "STOP" + }' +``` + +STOP invoices the elapsed wash time using the configured minute product, writes `order_id` to the session when available, logs `SESSION_COMPLETED`, and resets the lane. + +## End-To-End Example: Machine Not Allowed + +This example shows the failure mode the UI usually needs to handle. + +### Preview shows the blocking reason + +```bash +curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \ + -H "Authorization: Bearer $TOKEN" +``` + +Example response: + +```json +{ + "success": true, + "data": { + "allowed": false, + "all_visible_questions_answered": true, + "machine_available": false, + "allowed_services": [], + "questions": [ + { + "id": 21, + "question": "Is the hydraulic lock engaged?", + "answer": true + } + ], + "tasks": [] + } +} +``` + +Interpretation: + +- All questions are answered. +- The machine is still not allowed because either `relay_machine_id` is missing on the lane or no active task exposed `MACHINE`. + +If an operator tries to enable the relay manually anyway: + +```bash +curl -X POST "$BASE_URL/modules/self-serve/lane/relay/machine/enable" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lane_id": 12 + }' +``` + +The route returns `403` when the lane cache does not currently allow `MACHINE`. + +## PHP Examples + +These examples assume you are running inside the app runtime after the normal bootstrap has loaded the classes and database connection. + +### Preview eligibility + +```php +previewVehicleEligibility(12, 'AB12345', 100234); + +var_dump([ + 'allowed' => $preview['allowed'], + 'all_visible_questions_answered' => $preview['all_visible_questions_answered'], + 'questions' => $preview['questions'], + 'tasks' => $preview['tasks'], +]); +``` + +### Synchronize a session after answers change + +```php +add( + 7, + 12, + 'AB12345', + 21, + true, + 100234 +); + +$flow = new selfserve_wash_flow(); +$summary = $flow->synchronizeSession(12, 'AB12345', 100234); + +var_dump([ + 'session_id' => $summary['session']['id'], + 'status' => $summary['session']['status'], + 'events' => $summary['events'], +]); +``` + +### Record the machine start webhook + +```php +recordMachineStartWebhook(12, 'AB12345', [ + 'source' => 'manual-test', + 'button' => 'start', +]); + +var_dump([ + 'status' => $summary['session']['status'], + 'machine_start_triggered' => $summary['session']['machine_start_triggered'], + 'events' => $summary['events'], +]); +``` + +### Retrieve the latest wash summary + +```php +getLatestSessionSummary(12, 'AB12345'); + +var_dump([ + 'session' => $summary['session'], + 'questions' => $summary['questions'], + 'tasks' => $summary['tasks'], + 'events' => $summary['events'], +]); +``` + +### Define a machine type and associate it with a lane + +```php +add( + 'HighPressureFoam', + 'Reusable profile for foam cannon lanes' +); + +$lane = (new department_lanes_o())->select(12); +$lane->machine_type_id->set((int)$machineType->id); + +var_dump([ + 'machine_type_id' => $machineType->id, + 'lane_id' => $lane->id, +]); +``` + +### Add a shared question and machine-type-specific conditions, rules, and tasks + +```php +add( + 0, + 0, + 0, + 'Is the hydraulic lock engaged?', + 'Required before machine start', + null, + 10 +); + +$condition = (new department_selfserve_conditions_o())->add( + 0, + 0, + 0, + 'Machine may start', + 'All mandatory safety checks passed', + null, + 3 +); + +(new department_selfserve_condition_rules_o())->add( + (int)$condition->id, + selfserve_condition_rule_type::IS_TRUE->value, + selfserve_condition_rule_object_type::QUESTION->value, + (int)$question->id, + 'Hydraulic lock must be engaged', + 'The machine may only start when the shared question is true' +); + +$task = (new department_selfserve_tasks_o())->add( + 0, + 0, + 0, + (int)$condition->id, + 'Press the machine start button', + 'The relay is already enabled when this task becomes active', + 10, + [selfserve_lane_services::MACHINE], + null, + null, + 3 +); + +var_dump([ + 'question_id' => $question->id, + 'condition_id' => $condition->id, + 'task_id' => $task->id, +]); +``` + +## Implementation Recipes + +### Add a new self-serve machine type + +1. Create a `selfserve_machine_types` row through `/department/selfserve/machine-types` or `selfserve_machine_types_o`. +2. Set `department_lanes.machine_type_id` through `/department/lanes` or `department_lanes_o`. +3. Add the machine-type-specific conditions. +4. Add the condition rules that evaluate your questions or nested conditions. +5. Add the machine-type-specific tasks. +6. Ensure at least one active task exposes `MACHINE` if the lane should auto-enable the relay. + +### Configure shared questions + +Use `/department/selfserve/questions` with: + +- `department = 0` +- `lane = 0` +- `product = 0` + +Once shared questions exist, `selfserve_wash_flow` prefers them over legacy lane/product question rows. + +### Add machine-type-specific conditions, rules, and tasks + +Recommended pattern: + +1. Keep questions shared. +2. Define one or more conditions per machine type. +3. Attach rules that reference shared questions or nested conditions. +4. Gate tasks with `condition_id`. +5. Put `MACHINE` on the task that should allow relay enable. + +### Understand webhook and STOP interaction + +- `synchronizeSession()` can enable the machine relay before the physical machine has started. +- `/relay/button/press/post` is the authoritative machine-start signal. That is the point where the session becomes `MACHINE_STARTED` and the lane wash timer is initialized if needed. +- `STOP` is the point where billing is finalized. +- `selfserve_lane_invoice_t::invoice()` bills `ceil(elapsedWashTime / 60)` units of the configured minute product. +- The current implementation creates the order with system user id `2285`. +- `completeLatestSessionForLane()` stores the final `order_id` on the session when STOP can provide it. + +### Billing prerequisites + +Billing on STOP depends on: + +- self-serve minute product config being set +- lane status being `OCCUPIED` +- lane customer number being set +- lane license plate being set +- a positive elapsed wash time + +Relevant config: + +- `selfserve.enabled` +- `selfserve.minute_product` +- department variable `selfserve_enabled` + +## Troubleshooting + +### `allowed` is always `false` + +Check all three eligibility requirements: + +1. Every visible question must have an answer. +2. The lane must have `relay_machine_id`. +3. At least one active task must expose `MACHINE`. + +Also check whether the machine-type-specific tasks and conditions exist for the lane's `machine_type_id`. If they do not, the flow may fall back to legacy lane/product data instead. + +### The relay is not enabled after an answer update + +Common causes: + +- `synchronizeSession()` was called with `activateMachine = false` +- the active task list does not expose `MACHINE` +- the lane is `CLOSED`, `MAINTENANCE`, or `FAULT` +- `relay_machine_id` is missing on the lane + +Inspect the latest summary and look for: + +- `allowed_services` +- `session.machine_relay_enabled` +- a `MACHINE_RELAY_ENABLED` event + +### The machine button webhook returns `404` + +This means the route could not find an active self-serve session for the resolved lane. + +Check: + +- the lane id resolved from the scanner department is correct +- the vehicle registration matches the session registration +- the session was synchronized before the button press + +If you pass `reg` and no open session exists yet, `recordMachineStartWebhook()` will attempt a non-activating synchronize first. If that still cannot resolve a session, inspect the lane and answer data. + +### The summary does not show questions, tasks, or events you expected + +Check: + +- whether you are reading by `session_id` or by `lane_id` plus `reg` +- whether a newer session exists for the same lane and vehicle +- whether the lane changed machine type after the session was created +- whether the question or task was visible at the moment the session was synchronized + +Remember that session answers and tasks are snapshots, not live joins. + +### The webhook fails in a multi-lane department + +If the plate scanner belongs to a department with more than one lane, you must include `lane_id` in the webhook request. + +### STOP did not create an `order_id` on the session + +Check: + +- the minute product configuration +- lane customer number and registration number +- that wash time was greater than zero +- whether `invoice()` threw before completion + +The STOP flow tries not to let relay or session-completion errors block the lane reset. If billing failed earlier, the lane may still reset without a final `order_id`. + +## Operational Notes + +- Runtime schema changes are additive and lazy through `classes/selfserve_schema_bootstrap.php`. +- The session tables are safe to create idempotently from runtime flows because the project does not use a centralized migration runner. +- Machine relay control is delegated to `selfserve_lane_relay_controller_t`, which resolves Shelly cloud vs. edge gateway transport per department. +- Manual relay enable is still gated by the lane cache, while force enable and force disable bypass that gate. + +## Suggested Usage Pattern + +For new implementations, the intended setup is: + +1. Define one or more reusable machine types. +2. Attach each self-serve lane to a machine type. +3. Keep questions shared. +4. Put machine-specific logic in machine-type conditions, rules, and tasks. +5. Let vehicle answer changes call `synchronizeSession()`. +6. Let the physical button or PLC call `/relay/button/press/post`. +7. Let the normal STOP lane command complete billing and close the session. diff --git a/services/nginx/app/modules/selfserve/selfserve_c.php b/services/nginx/app/modules/selfserve/selfserve_c.php index cb12fbd5..bc171dcc 100644 --- a/services/nginx/app/modules/selfserve/selfserve_c.php +++ b/services/nginx/app/modules/selfserve/selfserve_c.php @@ -3,8 +3,10 @@ namespace modules\selfserve; require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php'; require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php'; +require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php'; use modules\selfserve\config\selfserve_enabled_c; +use modules\selfserve\config\selfserve_machine_wash_minutes_included_c; use modules\selfserve\config\selfserve_minute_product_c; use traits\module_config_t; @@ -22,15 +24,22 @@ class selfserve_c * @var selfserve_minute_product_c $minute_product */ public selfserve_minute_product_c $minute_product; + /** + * Included machine wash minutes before minute-based self-serve billing starts + * @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included + */ + public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included; public function __construct() { $this->setupConfig('selfserve'); $this->allowUpdate([ selfserve_enabled_c::class, - selfserve_minute_product_c::class + selfserve_minute_product_c::class, + selfserve_machine_wash_minutes_included_c::class ]); $this->enabled = new selfserve_enabled_c(); $this->minute_product = new selfserve_minute_product_c(); + $this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c(); } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php index 1adf8a29..817488ae 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php @@ -9,6 +9,7 @@ trait selfserve_lane_cache_t { const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_'; const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status'; + const CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT = self::CACHE_SELFSERVE_PREFIX . 'status_audit'; const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state'; const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode'; const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time'; @@ -77,4 +78,4 @@ trait selfserve_lane_cache_t redis->delete($this->getLaneCacheKey($laneId, $property)); return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php index d1f2707d..8a5a06a7 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php @@ -9,10 +9,22 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php'; +if (!class_exists(\modules\selfserve\classes\selfserve_wash_flow::class, false)) { + require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php'; +} +if (!class_exists(\modules\selfserve\classes\selfserve_studio_action_runner::class, false)) { + require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php'; +} +if (!class_exists(\modules\selfserve\classes\selfserve_studio_actions::class, false)) { + require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php'; +} use Exception; use modules\selfserve\classes\selfserve_lane; use modules\selfserve\classes\selfserve_lane_command_arguments; +use modules\selfserve\classes\selfserve_studio_action_runner; +use modules\selfserve\classes\selfserve_studio_actions; +use modules\selfserve\classes\selfserve_wash_flow; use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\helpers\selfserve_lane_log_action; use modules\selfserve\helpers\selfserve_lane_mode; @@ -20,13 +32,14 @@ use modules\selfserve\helpers\selfserve_lane_port; use modules\selfserve\helpers\selfserve_lane_state; use modules\selfserve\helpers\selfserve_lane_status; use modules\selfserve\helpers\selfserve_lane_relay; +use objects\department_gates_o; use objects\users_o; use objects\department_variables_o; trait selfserve_lane_command_t { /** - * Determine if the lane's department has self-serve enabled. + * Determine if the lane and its department have self-serve enabled. * This method is intentionally protected to allow tests to override * and avoid I/O when needed. */ @@ -39,18 +52,419 @@ trait selfserve_lane_command_t $departmentId = (int)$this->department_lane->department->value(); if ($departmentId <= 0) return false; $vars = (new department_variables_o())->selectDepartment($departmentId); - return $vars->getVariable('selfserve_enabled') === true; + return $vars->getVariable('selfserve_enabled') === true + && $this->department_lane->isSelfServeEnabled(); } catch (\Throwable $e) { // If anything goes wrong, default to not enabled return false; } } + + /** + * Resolve whether the program selector relay is currently online. + * Fail-closed to false when relay status cannot be read. + */ + protected function isProgramSelectorRelayOnlineForStop(): bool + { + try { + $status = $this->getMachineProgramPickerRelayStatus(); + return (bool)($status['online'] ?? false); + } catch (\Throwable) { + return false; + } + } + + /** + * Machine-wash billing is based on the physical machine ON signal, not selector relay status. + */ + protected function hasMachineStartSignalForStop(): bool + { + try { + $customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null; + return (new selfserve_wash_flow())->hasMachineStartTriggeredForLane( + (int)$this->id, + method_exists($this, 'getLicensePlate') ? ($this->getLicensePlate() ?: null) : null, + $customerNumber !== null && $customerNumber > 0 ? $customerNumber : null + ); + } catch (\Throwable) { + return false; + } + } + + /** + * Append the lane vehicle-type product to the current invoice order when requested. + */ + protected function addVehicleTypeProductToInvoiceIfNeeded(bool $should_add): void + { + if (!$should_add) { + return; + } + + try { + $active_wash = new selfserve_wash_flow(); + $active_wash->addVehicleTypeProductToInvoiceForLane($this->id); + } catch (\Throwable) { + // Best effort only; invoice correction can be handled manually if needed. + } + } + + /** + * Enable cleaner relay when a wash is started. + * Best-effort and skipped when cleaner relay is not configured. + */ + protected function turnOnCleanerRelayForWashStart(): void + { + if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_CLEANER)) { + return; + } + + try { + $this->setMachineCleanerRelayStatusHard(true); + } catch (\Throwable) { + // Best effort only; wash start must continue. + } + } + /** + * Ensure machine relay is ON when a wash starts, when it is allowed by configuration. + * If machine relay is not configured, this is a no-op. + */ + protected function setMachineRelayStatusForWashStart(): void + { + if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) { + return; + } + $active_wash = new selfserve_wash_flow(); + if ($active_wash->isMachineAllowedToStartWash($this->id)) { + try { + $this->setMachineRelayStatusHard(true); + } catch (\Throwable) { + // Best effort only; wash start must continue. + } + } else { + // Turning on the machine relay is not allowed by configuration, so ensure it is OFF. + try { + $this->setMachineRelayStatusHard(false); + } catch (\Throwable) { + // Best effort only; wash start must continue. + } + } + } + + protected function openEntrancePortForWashStart(): void + { + try { + $this->open(selfserve_lane_port::ENTRANCE); + } catch (\Throwable $e) { + if ($this->isAmbiguousGatewayTimeout($e)) { + $this->reportWashStartEntranceTimeout($e); + return; + } + + throw $e; + } + } + + protected function isAmbiguousGatewayTimeout(\Throwable $e): bool + { + $current = $e; + while ($current !== null) { + $message = strtolower(trim($current->getMessage())); + if ( + str_contains($message, 'edge gateway command timed out') || + str_contains($message, 'command timed out') || + str_contains($message, 'timed out') || + str_contains($message, 'timeout') + ) { + return true; + } + + $current = $current->getPrevious(); + } + + return false; + } + + protected function reportWashStartEntranceTimeout(\Throwable $e): void + { + try { + $laneId = isset($this->id) ? (string)$this->id : 'unknown'; + error_log( + 'Self-serve START entrance gate dispatch timed out for lane ' . + $laneId . + '; continuing wash start because the gateway command may already have reached the relay: ' . + $e->getMessage() + ); + } catch (\Throwable) { + // Diagnostics must not block the user wash start flow. + } + } + + protected function openExitPortForWashStop(): void + { + try { + $this->open(selfserve_lane_port::EXIT); + } catch (\Throwable $e) { + if ($this->isAmbiguousGatewayTimeout($e)) { + $this->reportWashStopExitTimeout($e); + return; + } + + throw $e; + } + } + + protected function reportWashStopExitTimeout(\Throwable $e): void + { + try { + $laneId = isset($this->id) ? (string)$this->id : 'unknown'; + error_log( + 'Self-serve STOP exit gate dispatch timed out for lane ' . + $laneId . + '; continuing wash stop because the gateway command may already have reached the relay: ' . + $e->getMessage() + ); + } catch (\Throwable) { + // Diagnostics must not block the user wash stop flow. + } + } + + protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void + { + if ($arguments->defer_relay_side_effects) { + return; + } + + // Ensure cleaner relay is enabled whenever wash starts. + $this->turnOnCleanerRelayForWashStart(); + // Ensure the machine relay is ON when a wash starts, when it is allowed. + $this->setMachineRelayStatusForWashStart(); + } + + protected function resolveSelfServeActionWashModeForStart(): string + { + try { + if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) { + $services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES); + if (is_array($services)) { + foreach ($services as $service) { + if (strtoupper((string)$service) === 'MACHINE') { + return selfserve_studio_actions::MODE_MACHINE; + } + } + } + } + } catch (\Throwable) { + // Fall through to manual mode when the cached service set is unavailable. + } + + return selfserve_studio_actions::MODE_MANUAL; + } + + /** + * Execute configured Studio actions from the published flow for this lane. + * + * @param array $context + * @return array> + */ + protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array + { + return (new selfserve_studio_action_runner())->executeForLaneEvent( + $this, + $event, + $washMode, + $this->buildPublishedStudioActionContext($context) + ); + } + + /** + * @param array $context + * @return array + */ + protected function buildPublishedStudioActionContext(array $context): array + { + if (!array_key_exists('lane_id', $context)) { + $context['lane_id'] = (int)$this->id; + } + + $reg = trim((string)($context['reg'] ?? '')); + if ($reg === '' && method_exists($this, 'getLicensePlate')) { + $reg = trim((string)$this->getLicensePlate()); + if ($reg !== '') { + $context['reg'] = $reg; + } + } + + $customerNumber = $context['customer_number'] ?? null; + if (($customerNumber === null || (int)$customerNumber <= 0) && method_exists($this, 'getCustomerNumber')) { + $resolvedCustomerNumber = (int)$this->getCustomerNumber(); + if ($resolvedCustomerNumber > 0) { + $customerNumber = $resolvedCustomerNumber; + $context['customer_number'] = $resolvedCustomerNumber; + } + } + + if ($reg === '') { + return $context; + } + + try { + $preview = (new selfserve_wash_flow())->previewVehicleEligibility( + (int)$this->id, + $reg, + $customerNumber === null || (int)$customerNumber <= 0 ? null : (int)$customerNumber + ); + if (!isset($context['condition_results']) && is_array($preview['evaluation_trace']['condition_results'] ?? null)) { + $context['condition_results'] = (array)$preview['evaluation_trace']['condition_results']; + } + if (!isset($context['visibility_condition_results']) && is_array($preview['evaluation_trace']['visibility_condition_results'] ?? null)) { + $context['visibility_condition_results'] = (array)$preview['evaluation_trace']['visibility_condition_results']; + } + if (!isset($context['allowed_services']) && is_array($preview['allowed_services'] ?? null)) { + $context['allowed_services'] = (array)$preview['allowed_services']; + } + if (!isset($context['vehicle_type_id']) && array_key_exists('vehicle_type_id', $preview)) { + $context['vehicle_type_id'] = $preview['vehicle_type_id']; + } + if (!isset($context['product']) && array_key_exists('vehicle_type_id', $preview)) { + $context['product'] = $preview['vehicle_type_id']; + } + if (!isset($context['machine_type_id']) && is_array($preview['machine_type'] ?? null)) { + $context['machine_type_id'] = $preview['machine_type']['id'] ?? null; + } + } catch (\Throwable) { + // Studio actions remain best-effort for legacy command flows. + } + + return $context; + } + + /** + * Disable relays after STOP in deterministic order: + * 1. Cleaner relay + * 2. Machine relay + */ + protected function turnOffRelaysAfterStop(): void + { + $relays = [ + selfserve_lane_relay::MACHINE_CLEANER, + selfserve_lane_relay::MACHINE, + ]; + + foreach ($relays as $relay) { + if (!$this->isRelayConfigured($relay)) { + continue; + } + + try { + $this->setRelayStatusHard($relay, false); + } catch (\Throwable) { + // Continue attempting to turn off remaining relays. + } + } + } + + protected function isRelayConfigured(selfserve_lane_relay $relay): bool + { + if (empty($this->department_lane)) { + return false; + } + + $relay_id = match ($relay) { + selfserve_lane_relay::MACHINE => (string)$this->department_lane->relay_machine_id->value(), + selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$this->department_lane->relay_machine_program_picker_id->value(), + selfserve_lane_relay::MACHINE_CLEANER => (string)$this->department_lane->relay_machine_cleaner_id->value(), + }; + + return trim($relay_id) !== ''; + } + + /** + * Finalize any active self-serve wash session before resetting lane state. + */ + protected function completeLatestSessionForStop(): void + { + try { + (new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane( + $this->id, + $this->getLicensePlate() ?: null, + $this->getCustomerNumber() ?: null, + method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null + ); + } catch (\Throwable) { + // Session completion must not block STOP flow. + } + } + + protected function executeOpenPropertyGateCommand(bool $isAccessGate): void + { + $commandLabel = $isAccessGate ? 'access' : 'exit'; + $gateLabel = $isAccessGate ? 'entrance' : 'exit'; + + if (!$this->isDepartmentSelfServeEnabled()) { + throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Self-serve is not enabled for this lane.'); + } + if (empty($this->department_lane) || empty($this->department_lane->department)) { + throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Lane department is not configured.'); + } + + $department_id = (int)$this->department_lane->department->value(); + if ($department_id <= 0) { + throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Lane department is not configured.'); + } + + $gate = $this->resolveDepartmentGateForCommand($department_id, $isAccessGate); + if ($gate === null || !$gate->exists()) { + throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: No ' . $gateLabel . ' gate configured for this lane\'s department.'); + } + + try { + $this->openDepartmentGateForCommand($gate); + } catch (\Throwable $e) { + $this->reportPropertyGateCommandFailure($commandLabel, $e); + throw new \RuntimeException($this->propertyGateCommandFailureMessage($isAccessGate), 0, $e); + } + } + + protected function resolveDepartmentGateForCommand(int $department_id, bool $isAccessGate): ?department_gates_o + { + $department_gates = new department_gates_o(); + return $isAccessGate + ? $department_gates->getEntranceGate($department_id) + : $department_gates->getExitGate($department_id); + } + + /** + * @throws Exception + */ + protected function openDepartmentGateForCommand(department_gates_o $gate): void + { + $gate->openGate(); + } + + protected function propertyGateCommandFailureMessage(bool $isAccessGate): string + { + return $isAccessGate + ? 'Failed to open property access gate.' + : 'Failed to open property exit gate.'; + } + + protected function reportPropertyGateCommandFailure(string $commandLabel, \Throwable $e): void + { + try { + $laneId = isset($this->id) ? (string)$this->id : 'unknown'; + error_log('Self-serve property ' . $commandLabel . ' gate open failed for lane ' . $laneId . ': ' . $e->getMessage()); + } catch (\Throwable) { + // Never block API flow on diagnostics logging. + } + } + /** * Execute a command on a self-serve lane * @param selfserve_lane_command $command The command to execute * @param selfserve_lane_command_arguments $arguments The arguments for the command * @return selfserve_lane|selfserve_lane_command_t * @throws Exception If the command cannot be executed + * @throws \Throwable */ public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self { @@ -95,17 +509,37 @@ trait selfserve_lane_command_t // Validate customer number if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number); if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number); + $previous_customer_number = $this->getCustomerNumber(); + $previous_license_plate = $this->getLicensePlate(); // Set the customer number and license plate $this->setCustomerNumber($customer_number); $this->setLicensePlate($license_plate); + try { + // Open the entrance port before marking the lane occupied. Gateway timeouts are + // ambiguous because the relay may already have received the pulse. + $this->openEntrancePortForWashStart(); + $this->turnOnCleanerRelayForWashStart(); + } catch (\Throwable $e) { + $this->setCustomerNumber($previous_customer_number); + $this->setLicensePlate($previous_license_plate); + $this->setLaneState(selfserve_lane_state::IDLE); + throw $e; + } // Set the lane status to OCCUPIED when started $this->setLaneStatus(selfserve_lane_status::OCCUPIED); // Set the lane state to IN_WASH $this->setLaneState(selfserve_lane_state::IN_WASH); - // Open the entrance port - $this->open(selfserve_lane_port::ENTRANCE); // Start the wash timer $this->setWashStartTime(time()); + $this->runPublishedStudioActions( + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + $this->resolveSelfServeActionWashModeForStart(), + [ + 'customer_number' => (int)$customer_number, + 'reg' => $license_plate, + ] + ); + $this->runRelaySideEffectsForWashStart($arguments); // Log the lane start event $this->logLaneAction(selfserve_lane_log_action::START_WASH); break; @@ -116,23 +550,30 @@ trait selfserve_lane_command_t if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) { throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number); } - // Invoice the customer - $this->invoice(); - // Open the exit port - $this->open(selfserve_lane_port::EXIT); - // If department has enabled self-serve, turn off the machine relay when stopping - try { - if ($this->isDepartmentSelfServeEnabled()) { - // Only attempt if a machine relay is configured for this lane - if (!empty($this->department_lane) && !empty($this->department_lane->relay_machine_id) && !empty($this->department_lane->relay_machine_id->value())) { - $this->turnOffRelay(selfserve_lane_relay::MACHINE); - } - } - } catch (\Throwable $e) { - // Swallow relay control errors to not block STOP flow - } + // Snapshot the physical machine ON signal before session completion/reset. + $machine_start_triggered = $this->hasMachineStartSignalForStop(); + $this->runPublishedStudioActions( + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND, + $machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL, + [ + 'customer_number' => $arguments->customer_number, + 'reg' => $this->getLicensePlate(), + 'machine_start_triggered' => $machine_start_triggered, + ] + ); + // Open the exit port. Gateway timeouts are ambiguous because + // the relay may already have received the pulse. + $this->openExitPortForWashStop(); + // Turn off relays in deterministic order after STOP + $this->turnOffRelaysAfterStop(); // Log the lane stop event $this->logLaneAction(selfserve_lane_log_action::STOP_WASH); + // Invoice the customer + $this->invoice($arguments); + // Only bill the machine wash product when the physical machine start signal was recorded. + $this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered); + // Finalize any active self-serve wash session for this lane + $this->completeLatestSessionForStop(); // Reset the lane self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); break; @@ -146,9 +587,15 @@ trait selfserve_lane_command_t $this->setLicensePlate(self::DEFAULT_LICENSE_PLATE); $this->setReservationStartTime(null); break; + case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE: + $this->executeOpenPropertyGateCommand(true); + break; + case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE: + $this->executeOpenPropertyGateCommand(false); + break; default: throw new \InvalidArgumentException("Unknown command: " . $command->name); } return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_customer_number_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_customer_number_t.php index de21dd12..81ff7190 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_customer_number_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_customer_number_t.php @@ -37,4 +37,16 @@ trait selfserve_lane_customer_number_t $this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_CUSTOMER_NUMBER, $this->customer_number); return $this; } + + /** + * Clear the customer number associated with the current wash + * @notation Should be used when the wash is complete and the customer number is no longer needed + * @return selfserve_lane_customer_number_t|selfserve_lane + */ + public function clearCustomerNumber(): self + { + $this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_CUSTOMER_NUMBER); + $this->customer_number = self::DEFAULT_CUSTOMER_NUMBER; + return $this; + } } \ No newline at end of file diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php index ebe03c95..28f0b7b1 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php @@ -2,22 +2,47 @@ namespace modules\selfserve\traits; require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php'; +require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane.php'; +require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php'; +require_once WD . '/modules/attachments/helpers/attachment_content.php'; +require_once WD . '/objects/selfserve_wash_sessions_o.php'; +require_once WD . '/objects/subusers_o.php'; use classes\selfserve; +use classes\economic; use Exception; +use attachments\helpers\attachment_content; +use modules\selfserve\classes\selfserve_lane_command_arguments; use modules\selfserve\classes\selfserve_lane; +use modules\selfserve\helpers\selfserve_lane_mode; use modules\selfserve\helpers\selfserve_lane_status; +use objects\customer_vehicles_o; use objects\order_items_o; use objects\orders_o; +use objects\selfserve_wash_sessions_o; +use objects\subusers_o; trait selfserve_lane_invoice_t { + private const INVOICE_SYSTEM_USER_ID = 2285; + public ?int $last_invoice_order_id = null; + /** * The product ID for minute-based billing * @var int|null $minute_billing_product_id */ public ?int $minute_billing_product_id = null; + /** + * Included machine wash minutes before minute billing starts. + * @var int|null $machine_wash_minutes_included + */ + public ?int $machine_wash_minutes_included = null; + /** + * The billable minutes order item, stored to be corrected / deleted when a machine wash is performed + * @var order_items_o|null + */ + public ?order_items_o $billable_minutes_order_item = null; /** * Get the minute billing product ID * @return int|null The product ID for minute-based billing, or null if not set @@ -34,41 +59,282 @@ trait selfserve_lane_invoice_t return $this->minute_billing_product_id; } + /** + * Get included machine wash minutes before minute billing starts. + */ + public function getMachineWashMinutesIncluded(): int + { + $included_minutes = selfserve::getInstance() + ->config + ->machine_wash_minutes_included + ->getVariableValue(); + $included_minutes = is_numeric($included_minutes) ? (int)$included_minutes : null; + + if ($included_minutes === null || $included_minutes < 0) { + return 0; + } + + return $this->machine_wash_minutes_included; + } + + public function getLastInvoiceOrderId(): ?int + { + return $this->last_invoice_order_id; + } + + /** + * Ensure there is an invoice order context for optional STOP follow-up lines + * (for example vehicle-type product) even when minute billing quantity is zero. + */ + public function ensureInvoiceOrderContextForVehicleProduct(): bool + { + if (!empty($this->last_invoice_order_id)) { + return true; + } + + if (empty($this->id)) { + return false; + } + if ($this->getLaneStatus() !== selfserve_lane_status::OCCUPIED) { + return false; + } + if (empty($this->getCustomerNumber()) || empty($this->getLicensePlate())) { + return false; + } + + $this->createInvoiceOrderContext(); + return !empty($this->last_invoice_order_id); + } + /** * Invoice for minute-based billing * @return bool True on success, false on failure * @throws Exception if lane ID is not set, lane is not occupied, customer number or license plate is not set, or product ID is not set */ - public function invoice(): bool + public function invoice(?selfserve_lane_command_arguments $arguments = null): bool { + $this->last_invoice_order_id = null; + if (empty($this->id)) throw new \Exception("Lane ID is not set."); if ($this->getLaneStatus() !== selfserve_lane_status::OCCUPIED) throw new \Exception("Lane ID {$this->id} is not occupied; cannot invoice."); if (empty($this->getCustomerNumber())) throw new \Exception("Customer number is not set for lane ID {$this->id}."); if (empty($this->getLicensePlate())) throw new \Exception("License plate is not set for lane ID {$this->id}."); if (empty($product_id = $this->getMinuteBillingProductId())) throw new \Exception("Minute billing product ID is not set."); - // Calculate minutes used - $minutes = $this->getElapsedWashTime() / 60; // Convert seconds to minutes - $minutes = (int)ceil($minutes); // Round up to nearest whole minute - if ($minutes <= 0) throw new \Exception("No minutes to bill for lane ID {$this->id}."); - $amount = $minutes; // Assuming 1 unit per minute, adjust as needed - // Create invoice order + $elapsed_minutes = $this->calculateElapsedMinutesForBilling($this->getElapsedWashTime()); + $included_minutes = $this->resolveIncludedMinutesForBilling(); + $billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes); + + if ($billable_minutes > 0) { + $order = $this->createInvoiceOrderContext($arguments); + $this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes); + } + return true; + } + + /** + * Add the current vehicle type product as a single order line on the latest invoice order. + * Returns true when the line was added, false when vehicle/product context was unavailable. + * @throws Exception + */ + public function addVehicleTypeProductToLastInvoiceOrder(): bool + { + if (empty($this->last_invoice_order_id)) { + return false; + } + + $vehicle_type_product_id = $this->resolveVehicleTypeProductIdForInvoice(); + if ($vehicle_type_product_id === null || $vehicle_type_product_id <= 0) { + return false; + } + + $primary_vehicle_order_item = (new order_items_o())->addItemToOrder( + (int)$this->last_invoice_order_id, + $vehicle_type_product_id, + self::INVOICE_SYSTEM_USER_ID, + 1 + ); + + // If the billable minutes order item isn't defined, throw an exception as this method should only be called in the context of an existing invoice with a billable minutes line + if ($this->billable_minutes_order_item === null) { + throw new \Exception("Billable minutes order item is not defined; cannot adjust quantity for included minutes."); + } + + // If the time exceeds the included minutes, reduce the quantity by the included minutes, if not delete the order item + if ($this->billable_minutes_order_item->quantity->value() > $this->machine_wash_minutes_included) { + $this->billable_minutes_order_item->quantity->set($this->billable_minutes_order_item->quantity->value() - $this->machine_wash_minutes_included); + } else { + $this->billable_minutes_order_item->delete(); + } + + return true; + } + + protected function resolveVehicleTypeProductIdForInvoice(): ?int + { + $license_plate = trim((string)$this->getLicensePlate()); + if ($license_plate === '') { + return null; + } + + $vehicle = (new customer_vehicles_o())->selectByPlate(selfserve::standardize_registration($license_plate)); + if (!$vehicle->exists()) { + return null; + } + + $product_id = (int)$vehicle->type->value(); + return $product_id > 0 ? $product_id : null; + } + + protected function calculateElapsedMinutesForBilling(?int $elapsed_wash_time_seconds): int + { + if ($elapsed_wash_time_seconds === null || $elapsed_wash_time_seconds <= 0) { + return 0; + } + + return (int)ceil($elapsed_wash_time_seconds / 60); + } + + protected function resolveIncludedMinutesForBilling(): int + { + if (!$this->shouldApplyIncludedMinutesReduction()) { + return 0; + } + + return $this->getMachineWashMinutesIncluded(); + } + + protected function shouldApplyIncludedMinutesReduction(): bool + { + // Only apply included minutes reduction for machine wash mode + return $this->getLaneMode() === selfserve_lane_mode::AUTOMATIC; + } + + protected function calculateBillableMinutes(int $elapsed_minutes, int $included_minutes): int + { + if ($elapsed_minutes <= 0) { + return 0; + } + + $included_minutes = max(0, $included_minutes); + return max(0, $elapsed_minutes - $included_minutes); + } + + protected function createInvoiceOrderContext(?selfserve_lane_command_arguments $arguments = null): orders_o + { + $billing_customer_number = $this->getCustomerNumber(); + $draft_customer_number = (new economic())->getTransactionDraftCustomerNumber(); $order = (new orders_o())->add( - $this->getCustomerNumber(), - 2285, // System User ID + $billing_customer_number, + self::INVOICE_SYSTEM_USER_ID, '', '', (int)$this->department_lane->department->value(), (string)$this->getLicensePlate() ); $order->lane->set($this->id); - // Add product to order - $order_items = new order_items_o(); - $order_items->addItemToOrder( - $order->id, - $product_id, - 2285, // System User ID - $amount, - ); - return true; + $this->last_invoice_order_id = (int)$order->id; + $this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments); + + return $order; } -} \ No newline at end of file + + protected function attachSelfServeMetadataToOrder( + orders_o $order, + int $billing_customer_number, + ?int $draft_customer_number, + ?selfserve_lane_command_arguments $arguments = null + ): void { + try { + $order->addAttachment( + (new attachment_content())->setOther( + $this->buildSelfServeOrderAttachmentPayload($billing_customer_number, $draft_customer_number, $arguments) + ) + ); + } catch (\Throwable) { + // Metadata attachments must not block billing; the order itself is the source of record. + } + } + + protected function buildSelfServeOrderAttachmentPayload( + int $billing_customer_number, + ?int $draft_customer_number, + ?selfserve_lane_command_arguments $arguments = null + ): array { + $session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number); + $subuser_id = $arguments?->subuser_id; + + return [ + 'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH, + 'source' => 'selfserve', + 'customer_number' => $billing_customer_number, + 'draft_customer_number' => $draft_customer_number, + 'subuser_id' => $subuser_id, + 'subuser' => $this->formatSelfServeAttachmentSubuser($subuser_id), + 'session_id' => $session?->id, + 'lane_id' => (int)$this->id, + 'department_id' => (int)$this->department_lane->department->value(), + 'license_plate' => (string)$this->getLicensePlate(), + 'lane_status' => $this->getLaneStatus()->name, + 'lane_mode' => $this->getLaneMode()->name, + 'wash_start_time' => (int)$this->getWashStartTime(), + 'elapsed_wash_time_seconds' => (int)$this->getElapsedWashTime(), + 'created_at' => date('Y-m-d H:i:s'), + ]; + } + + protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o + { + $license_plate = trim((string)$this->getLicensePlate()); + if ($license_plate === '') { + return null; + } + + try { + $session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg( + (int)$this->id, + selfserve::standardize_registration($license_plate), + $billing_customer_number > 0 ? $billing_customer_number : null + ); + return $session->exists() ? $session : null; + } catch (\Throwable) { + return null; + } + } + + protected function formatSelfServeAttachmentSubuser(?int $subuser_id): ?array + { + if ($subuser_id === null || $subuser_id <= 0) { + return null; + } + + try { + $subuser = (new subusers_o())->select($subuser_id); + if (!$subuser->exists()) { + return [ + 'id' => $subuser_id, + ]; + } + + return [ + 'id' => (int)$subuser->id, + 'name' => $subuser->name->value(), + 'username' => $subuser->username->value(), + 'email' => $subuser->email->value(), + ]; + } catch (\Throwable) { + return [ + 'id' => $subuser_id, + ]; + } + } + + protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o + { + return (new order_items_o())->addItemToOrder( + $order_id, + $product_id, + self::INVOICE_SYSTEM_USER_ID, + $quantity, + ); + } +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php index d956591d..96b2ebe1 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php @@ -4,23 +4,28 @@ namespace modules\selfserve\traits; require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane.php'; -use classes\shelly; +use classes\edge_gateway_manager; +use classes\shelly_transport_resolver; +use interfaces\shelly_transport_i; use modules\selfserve\helpers\selfserve_lane_port; use modules\selfserve\helpers\selfserve_lane_log_action; use modules\selfserve\helpers\selfserve_lane_state; use modules\selfserve\helpers\selfserve_lane_status; use modules\shelly\helpers\shelly_device_switch; -use modules\shelly\helpers\shelly_request_body_get_states; trait selfserve_lane_port_controller_t { + private const DEMO_RELAY_ID_PREFIX = 'demo-'; + private const DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS = 1; + /** * Open the lane port * @param selfserve_lane_port $port The port to open (ENTRANCE or EXIT) + * @param int|null $toggle_after_seconds Seconds before the relay flips back off * @return bool True if the port was successfully opened, false otherwise * @throws \Exception If an invalid port is specified, or if the lane is not in a state to open the port */ - public function open(selfserve_lane_port $port): bool + public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool { // Require department lane object if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}"); @@ -30,8 +35,8 @@ trait selfserve_lane_port_controller_t if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot open port on FAULT lane"); // Get the relay ID based on the port $relay_id = match ($port) { - selfserve_lane_port::ENTRANCE => $this->department_lane->relay_out_id->value(), - selfserve_lane_port::EXIT => $this->department_lane->relay_in_id->value(), + selfserve_lane_port::ENTRANCE => $this->department_lane->relay_in_id->value(), + selfserve_lane_port::EXIT => $this->department_lane->relay_out_id->value(), default => throw new \Exception("Invalid port specified, must be ENTRANCE or EXIT"), }; // Make sure relay ID is valid @@ -48,44 +53,105 @@ trait selfserve_lane_port_controller_t // Log the port open event $this->logLaneAction(selfserve_lane_log_action::OPEN_PORT, 200, ['port' => $port->name]); // Open the relay - return $this->shellyOpenPort($port); + return $this->shellyOpenPort($port, $toggle_after_seconds); } /** * Open the Shelly relay for the specified port * @param selfserve_lane_port $port The port to open (ENTRANCE or EXIT) + * @param int|null $toggle_after_seconds Seconds before the relay flips back off * @return bool True if the relay was successfully opened * @throws \Exception If an invalid port is specified or if the relay ID is invalid */ - public function shellyOpenPort(selfserve_lane_port $port): bool + public function shellyOpenPort(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool { - // Get the relay ID based on the port + // Queue the relay switch command to be executed asynchronously $relay_id = match ($port) { - selfserve_lane_port::ENTRANCE => $this->department_lane->relay_out_id->value(), - selfserve_lane_port::EXIT => $this->department_lane->relay_in_id->value(), + selfserve_lane_port::ENTRANCE => $this->department_lane->relay_in_id->value(), + selfserve_lane_port::EXIT => $this->department_lane->relay_out_id->value(), default => throw new \Exception("Invalid port specified, must be ENTRANCE or EXIT"), }; - // Make sure relay ID is valid - if (empty($relay_id)) { - throw new \Exception("Invalid relay ID for port {$port->name}"); + // If the relay ID indicates a demo port, skip the Shelly switch call but keep the logging and state changes + if ($this->isDemoPortRelayId($relay_id)) { + return true; } - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - // Wait 1 second - sleep(1); - // Format the result - $result = array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); - // Open the switch - foreach ($result as $device) { + + $device = $this->createShellySwitchDevice(); + $device->id = (string)$relay_id; + $device->toggle_after = $this->normalizePortOpenToggleAfter($toggle_after_seconds); + $contextPayload = [ + 'id' => (string)$relay_id, + 'on' => true, + 'toggle_after' => $device->toggle_after, + ]; + $context = method_exists($this, 'buildSelfServeRelayActionContext') + ? $this->buildSelfServeRelayActionContext('/v2/devices/api/set/switch', $contextPayload, [ + 'reason' => 'Open self-serve ' . $port->name . ' gate relay', + 'relay_role' => $port->name, + ]) + : [ + 'module' => 'selfserve', + 'reason' => 'Open self-serve ' . $port->name . ' gate relay', + 'relay_id' => (string)$relay_id, + 'relay_role' => $port->name, + ]; + + return edge_gateway_manager::withRelayActionContext($context, function () use ($device): bool { $device->switch(true); - } - return true; + return true; + }); } -} \ No newline at end of file + + private function normalizePortOpenToggleAfter(?int $toggle_after_seconds): int + { + if ($toggle_after_seconds !== null && $toggle_after_seconds > 0) { + return $toggle_after_seconds; + } + + return self::DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS; + } + + private function isDemoPortRelayId(string $relay_id): bool + { + $normalized = strtolower(trim($relay_id)); + return $normalized !== '' && str_starts_with($normalized, self::DEMO_RELAY_ID_PREFIX); + } + + protected function createShellySwitchDevice(): shelly_device_switch + { + return (new shelly_device_switch())->setRequestSender( + fn(array $parameters): array|object|null => $this + ->createShellyTransport() + ->sendPostRequest( + '/v2/devices/api/set/switch', + $parameters, + $this->resolveShellyTransportDepartmentId() + ) + ); + } + + /** + * @throws \Exception + */ + protected function createShellyTransport(): shelly_transport_i + { + return (new shelly_transport_resolver())->resolveForDepartment($this->resolveShellyTransportDepartmentId()); + } + + /** + * @throws \Exception + */ + protected function resolveShellyTransportDepartmentId(): int + { + if (empty($this->department_lane)) { + throw new \Exception("Department lane object not found for lane ID {$this->id}"); + } + + $department_id = (int)$this->department_lane->department->value(); + if ($department_id <= 0) { + throw new \Exception('Unable to resolve department for Shelly transport'); + } + + return $department_id; + } +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php index 439c3450..362135a9 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php @@ -4,69 +4,918 @@ namespace modules\selfserve\traits; require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane.php'; +use classes\edge_gateway_manager; use classes\shelly; -use modules\selfserve\helpers\selfserve_lane_log_action; +use classes\shelly_transport_resolver; +use interfaces\shelly_transport_i; use modules\selfserve\helpers\selfserve_lane_relay; -use modules\selfserve\helpers\selfserve_lane_state; -use modules\selfserve\helpers\selfserve_lane_status; use modules\selfserve\helpers\selfserve_lane_services; -use modules\shelly\helpers\shelly_device_switch; +use modules\selfserve\helpers\selfserve_lane_status; use modules\shelly\helpers\shelly_request_body_get_states; trait selfserve_lane_relay_controller_t { + private const SHELLY_STATUS_WAIT_TIMEOUT_SECONDS = 20; + private const SHELLY_RETRY_SLEEP_MICROSECONDS = 250000; + private const SHELLY_STATUS_SNAPSHOT_TTL_SECONDS = 1; + private const SHELLY_STATUS_SNAPSHOT_KEY_PREFIX = 'selfserve_lane_shelly_status_snapshot_'; + private const SHELLY_DEFAULT_CHANNEL = 0; + private const DEMO_RELAY_ID_PREFIX = 'demo-'; + + protected ?string $shelly_transport_override = null; + + /** + * @throws \Exception + */ + public function setShellyTransportOverride(?string $transport): self + { + $normalized = strtolower(trim((string)$transport)); + if ($normalized === '') { + $this->shelly_transport_override = null; + return $this; + } + + if (!in_array($normalized, ['cloud', 'gateway', 'local'], true)) { + throw new \Exception('Invalid Shelly transport override. Expected cloud, gateway, or local.'); + } + + $this->shelly_transport_override = $normalized; + return $this; + } + + /** + * Get current MACHINE relay status from Shelly. + * @return array{relay_id: string, online: bool, on: bool, status: array>, binding?: array, execution?: array, raw?: array} + * @throws \Exception + */ + public function getMachineRelayStatus(): array + { + return $this->getRelayStatus(selfserve_lane_relay::MACHINE); + } + + /** + * Get current MACHINE_PROGRAM_PICKER relay status from Shelly. + * @return array{relay_id: string, online: bool, on: bool, status: array>, binding?: array, execution?: array, raw?: array} + * @throws \Exception + */ + public function getMachineProgramPickerRelayStatus(): array + { + return $this->getRelayStatus(selfserve_lane_relay::MACHINE_PROGRAM_PICKER); + } + + /** + * Get current MACHINE_CLEANER relay status from Shelly. + * @return array{relay_id: string, online: bool, on: bool, status: array>, binding?: array, execution?: array, raw?: array} + * @throws \Exception + */ + public function getMachineCleanerRelayStatus(): array + { + return $this->getRelayStatus(selfserve_lane_relay::MACHINE_CLEANER); + } + + /** + * Get current relay status from Shelly for a specific relay type. + * @param selfserve_lane_relay $relay + * @return array{relay_id: string, online: bool, on: bool, status: array>, binding?: array, execution?: array, raw?: array} + * @throws \Exception + */ + public function getRelayStatus(selfserve_lane_relay $relay): array + { + $relay_id = $this->getRelayId($relay); + $snapshot = $this->getLaneShellyStatusSnapshot([$relay_id]); + if (!array_key_exists($relay_id, $snapshot)) { + throw new \Exception("Shelly did not return a status entry for {$relay->name} relay"); + } + + $device = $snapshot[$relay_id]; + $on = $this->extractRelayOnState($device, $relay); + $result = [ + 'relay_id' => $relay_id, + 'online' => $this->extractRelayOnlineState($device), + 'on' => $on, + 'status' => ['switch:0' => ['output' => $on]], + ]; + + foreach (['binding', 'execution', 'raw'] as $diagnostic_key) { + $diagnostic_value = $this->getPayloadValue($device, $diagnostic_key); + if (is_array($diagnostic_value) && $diagnostic_value !== []) { + $result[$diagnostic_key] = $diagnostic_value; + } + } + + return $result; + } + + /** + * Set MACHINE relay status directly. + * @param bool $on true to turn on, false to turn off + * @return bool + * @throws \Exception + */ + public function setMachineRelayStatus(bool $on): bool + { + return $this->setRelayStatus(selfserve_lane_relay::MACHINE, $on); + } + + /** + * Set MACHINE relay status directly, bypassing lane status guards. + * Intended for department-level operational toggles. + * @throws \Exception + */ + public function setMachineRelayStatusHard(bool $on): bool + { + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); + } + + /** + * Set PROGRAM SELECTOR relay status directly, bypassing lane status guards. + * Intended for department-level operational toggles. + * @throws \Exception + */ + public function setProgramSelectorRelayStatusHard(bool $on): bool + { + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); + } + + /** + * Set MACHINE_PROGRAM_PICKER relay status directly. + * @param bool $on true to turn on, false to turn off + * @return bool + * @throws \Exception + */ + public function setMachineProgramPickerRelayStatus(bool $on): bool + { + return $this->setRelayStatus(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); + } + + /** + * Set MACHINE_PROGRAM_PICKER relay status directly, bypassing lane status guards. + * Intended for department-level operational toggles. + * @throws \Exception + */ + public function setMachineProgramPickerRelayStatusHard(bool $on): bool + { + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); + } + + /** + * Set MACHINE_CLEANER relay status directly. + * @param bool $on true to turn on, false to turn off + * @return bool + * @throws \Exception + */ + public function setMachineCleanerRelayStatus(bool $on): bool + { + return $this->setRelayStatus(selfserve_lane_relay::MACHINE_CLEANER, $on); + } + + /** + * Set MACHINE_CLEANER relay status directly, bypassing lane status guards. + * Intended for department-level operational toggles. + * @throws \Exception + */ + public function setMachineCleanerRelayStatusHard(bool $on): bool + { + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, $on); + } + + /** + * Set relay status directly for a specific relay type. + * @param selfserve_lane_relay $relay + * @param bool $on true to turn on, false to turn off + * @return bool + * @throws \Exception + */ + public function setRelayStatus(selfserve_lane_relay $relay, bool $on): bool + { + return $on + ? $this->forceTurnOnRelay($relay) + : $this->forceTurnOffRelay($relay); + } + + /** + * Set relay status directly for a specific relay type, bypassing lane status guards. + * @throws \Exception + */ + public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool + { + return $this->sendRelaySwitchCommand($relay, $on); + } + + /** + * Synchronize MACHINE relay state from currently visible task services. + * + * - Always persists normalized services in lane cache. + * - Enables MACHINE only when visible and allowEnable=true (guarded path). + * - Disables MACHINE immediately when not visible (hard OFF path). + * + * @param array $allowedServices + * @return array{ + * machine_visible: bool, + * relay_action: string, + * relay_target_on: bool + * } + * @throws \Exception + */ + public function syncMachineRelayFromVisibleServices(array $allowedServices, bool $allowEnable = true): array + { + $normalizedServices = $this->normalizeVisibleServiceNames($allowedServices); + $this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $normalizedServices); + + $machineVisible = in_array(selfserve_lane_services::MACHINE->name, $normalizedServices, true); + $relayAction = 'noop'; + + if (!$this->isDepartmentSelfServeRelayMutationsEnabled()) { + return [ + 'machine_visible' => $machineVisible, + 'relay_action' => 'noop_selfserve_disabled', + 'relay_target_on' => $machineVisible, + ]; + } + + if (!$this->hasConfiguredRelay(selfserve_lane_relay::MACHINE)) { + return [ + 'machine_visible' => $machineVisible, + 'relay_action' => 'noop_missing_machine_relay', + 'relay_target_on' => $machineVisible, + ]; + } + + if ($machineVisible) { + if ($allowEnable) { + $this->turnOnRelay(selfserve_lane_relay::MACHINE); + $relayAction = 'enabled'; + } else { + $relayAction = 'noop_enable_blocked'; + } + } else { + $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, false); + $relayAction = 'disabled'; + } + + return [ + 'machine_visible' => $machineVisible, + 'relay_action' => $relayAction, + 'relay_target_on' => $machineVisible, + ]; + } + + /** + * Persist the services currently visible to the user without mutating hardware. + * + * The user wash start flow calls this before the user confirms lane and wash type. + * Hardware activation remains owned by START / explicit relay endpoints. + * + * @param array $allowedServices + * @return array{ + * machine_visible: bool, + * relay_action: string, + * relay_target_on: bool + * } + */ + public function setAllowedServicesFromVisibleTasks(array $allowedServices): array + { + $normalizedServices = $this->normalizeVisibleServiceNames($allowedServices); + $this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $normalizedServices); + + $machineVisible = in_array(selfserve_lane_services::MACHINE->name, $normalizedServices, true); + + return [ + 'machine_visible' => $machineVisible, + 'relay_action' => 'cache_only', + 'relay_target_on' => $machineVisible, + ]; + } + + /** + * @param array $services + * @return string[] + */ + private function normalizeVisibleServiceNames(array $services): array + { + $normalized = []; + foreach ($services as $service) { + $name = strtoupper(trim((string)$service)); + if ($name === '') { + continue; + } + if (!in_array($name, $normalized, true)) { + $normalized[] = $name; + } + } + return $normalized; + } + + private function hasConfiguredRelay(selfserve_lane_relay $relay): bool + { + try { + $relayId = trim($this->getRelayId($relay)); + return $relayId !== ''; + } catch (\Throwable) { + return false; + } + } + + private function isDepartmentSelfServeRelayMutationsEnabled(): bool + { + if (!method_exists($this, 'isDepartmentSelfServeEnabled')) { + return true; + } + + try { + return $this->isDepartmentSelfServeEnabled() === true; + } catch (\Throwable) { + return false; + } + } + + /** + * Resolve relay ID for the current lane. + * @param selfserve_lane_relay $relay + * @throws \Exception + */ + private function getRelayId(selfserve_lane_relay $relay): string + { + if (empty($this->department_lane)) { + throw new \Exception("Department lane object not found for lane ID {$this->id}"); + } + + $relay_id = match ($relay) { + selfserve_lane_relay::MACHINE => (string)$this->department_lane->relay_machine_id->value(), + selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$this->department_lane->relay_machine_program_picker_id->value(), + selfserve_lane_relay::MACHINE_CLEANER => (string)$this->department_lane->relay_machine_cleaner_id->value(), + }; + + if ($relay_id === '') { + throw new \Exception("Invalid relay ID for {$relay->name} relay"); + } + + return $relay_id; + } + + /** + * @return string[] + * @throws \Exception + */ + private function getConfiguredLaneRelayIds(): array + { + if (empty($this->department_lane)) { + throw new \Exception("Department lane object not found for lane ID {$this->id}"); + } + + $ids = [ + (string)$this->department_lane->relay_machine_id->value(), + (string)$this->department_lane->relay_machine_program_picker_id->value(), + (string)$this->department_lane->relay_machine_cleaner_id->value(), + ]; + $ids = array_values(array_unique(array_filter(array_map('trim', $ids), static fn(string $id): bool => $id !== ''))); + + if (count($ids) < 1) { + throw new \Exception('No configured Shelly relay IDs for this lane'); + } + + return $ids; + } + + /** + * @param string[] $required_relay_ids + * @return array + * @throws \Exception + */ + private function getLaneShellyStatusSnapshot(array $required_relay_ids): array + { + $cached = $this->getCachedLaneShellyStatusSnapshot(); + if ($cached !== null && $this->snapshotHasUsablePayload($cached, $required_relay_ids)) { + return $cached; + } + + $fresh = $this->fetchLaneShellyStatusSnapshotWithReadiness($required_relay_ids); + $this->setCachedLaneShellyStatusSnapshot($fresh); + return $fresh; + } + + /** + * @param string[] $required_relay_ids + * @return array + * @throws \Exception + */ + private function fetchLaneShellyStatusSnapshotWithReadiness(array $required_relay_ids): array + { + $relay_ids = $this->getConfiguredLaneRelayIds(); + $cloud_relay_ids = array_values(array_filter( + $relay_ids, + fn(string $relay_id): bool => !$this->isDemoRelayId($relay_id) + )); + $demo_relay_ids = array_values(array_filter( + $relay_ids, + fn(string $relay_id): bool => $this->isDemoRelayId($relay_id) + )); + + if (count($cloud_relay_ids) < 1) { + return $this->appendDemoRelaySnapshots([], $relay_ids); + } + + $deadline = $this->nowTimestamp() + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS; + $last_reason = 'Shelly relay status is not ready yet'; + + do { + $result = $this->sendShellyPost('/v2/devices/api/get', $this->buildStatusGetPayload($cloud_relay_ids)); + $snapshot = $this->appendDemoRelaySnapshots( + $this->mapResponseToRelaySnapshot($result), + $demo_relay_ids + ); + + if ($this->snapshotHasUsablePayload($snapshot, $required_relay_ids)) { + return $snapshot; + } + + $last_reason = $this->describeShellyNotReadyReason($result, $snapshot, $required_relay_ids); + if ($this->nowTimestamp() >= $deadline) { + break; + } + $this->sleepMicroseconds(self::SHELLY_RETRY_SLEEP_MICROSECONDS); + } while (true); + + throw new \Exception($last_reason); + } + + /** + * @param string[] $relay_ids + */ + private function buildStatusGetPayload(array $relay_ids): array + { + $parameters = new shelly_request_body_get_states(); + $parameters->ids = $relay_ids; + $parameters->select = ['status']; + return (array)$parameters; + } + + private function isDemoRelayId(string $relay_id): bool + { + $normalized = strtolower(trim($relay_id)); + return $normalized !== '' && str_starts_with($normalized, self::DEMO_RELAY_ID_PREFIX); + } + + /** + * @param array $snapshot + * @param string[] $relay_ids + * @return array + */ + private function appendDemoRelaySnapshots(array $snapshot, array $relay_ids): array + { + foreach ($relay_ids as $relay_id) { + if (!$this->isDemoRelayId($relay_id)) { + continue; + } + $snapshot[$relay_id] = $this->getDemoRelaySnapshotEntry($relay_id); + } + + return $snapshot; + } + + /** + * @return array{id: string, online: bool, on: bool, status: array>} + */ + private function buildDemoRelaySnapshotEntry(string $relay_id, bool $on): array + { + return [ + 'id' => $relay_id, + 'online' => true, + 'on' => $on, + 'status' => ['switch:0' => ['output' => $on]], + ]; + } + + private function getDemoRelaySnapshotEntry(string $relay_id): array + { + $snapshot = $this->getCachedLaneShellyStatusSnapshot() ?? []; + $entry = $snapshot[$relay_id] ?? null; + if (is_array($entry) && $this->relayStatusPayloadExists($entry)) { + return $entry; + } + + return $this->buildDemoRelaySnapshotEntry($relay_id, false); + } + + /** + * @param array|object|null $response + * @return array + */ + private function mapResponseToRelaySnapshot(array|object|null $response): array + { + $snapshot = []; + foreach ($this->normalizeRelayDevicesResponse($response) as $device) { + $id = isset($device['id']) ? trim((string)$device['id']) : ''; + if ($id === '') { + continue; + } + $snapshot[$id] = $this->normalizeRelaySnapshotEntry($device); + } + return $snapshot; + } + + /** + * @param array|object|null $response + * @return array + */ + private function normalizeRelayDevicesResponse(array|object|null $response): array + { + if ($response === null) { + return []; + } + if (is_object($response)) { + $response = [$response]; + } + if (!is_array($response)) { + return []; + } + + $devices = []; + foreach ($response as $item) { + if (is_object($item)) { + $item = $this->normalizeRelaySnapshotEntry($item); + } + if (!is_array($item)) { + continue; + } + $devices[] = $this->normalizeRelaySnapshotEntry($item); + } + return $devices; + } + + /** + * @param array|object $device + * @return array + */ + private function normalizeRelaySnapshotEntry(array|object $device): array + { + $encoded = json_encode($device, JSON_UNESCAPED_UNICODE); + if (!is_string($encoded)) { + return []; + } + $decoded = json_decode($encoded, true); + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $snapshot + * @param string[] $required_relay_ids + */ + private function snapshotHasUsablePayload(array $snapshot, array $required_relay_ids): bool + { + foreach ($required_relay_ids as $relay_id) { + if (!array_key_exists($relay_id, $snapshot)) { + return false; + } + if (!$this->relayStatusPayloadExists($snapshot[$relay_id])) { + return false; + } + } + return true; + } + + private function relayStatusPayloadExists(array $device): bool + { + $status = $this->getPayloadValue($device, 'status'); + if ($status !== null) { + foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) { + $switch_state = $this->getPayloadValue($status, $switch_key); + if ($switch_state === null) { + continue; + } + if ($this->getPayloadValue($switch_state, 'output') !== null) { + return true; + } + } + } + + return $this->getPayloadValue($device, 'on') !== null; + } + + private function extractRelayOnlineState(array $device): bool + { + $online = $this->getPayloadValue($device, 'online'); + if ($online === null) { + return false; + } + if (is_bool($online)) { + return $online; + } + return (int)$online === 1; + } + + /** + * Extract boolean on/off status from Shelly switch payload. + * @throws \Exception + */ + private function extractRelayOnState(array $device, selfserve_lane_relay $relay): bool + { + $direct = $this->getPayloadValue($device, 'on'); + if ($direct !== null) { + return (bool)$direct; + } + + $status = $this->getPayloadValue($device, 'status'); + foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) { + $switch_state = $this->getPayloadValue($status, $switch_key); + if ($switch_state === null) { + continue; + } + + $output = $this->getPayloadValue($switch_state, 'output'); + if ($output !== null) { + return (bool)$output; + } + } + + throw new \Exception("Unable to determine {$relay->name} relay state from Shelly status payload"); + } + + private function getPayloadValue(array|object|null $payload, string $key): mixed + { + if (is_array($payload)) { + return $payload[$key] ?? null; + } + if (is_object($payload)) { + return $payload->$key ?? null; + } + return null; + } + + /** + * @param array|object|null $response + * @param array $snapshot + * @param string[] $required_relay_ids + */ + private function describeShellyNotReadyReason(array|object|null $response, array $snapshot, array $required_relay_ids): string + { + $payload_text = strtolower($this->serializeShellyResponse($response)); + foreach (['rate limit', 'ratelimit', 'too many', '429', 'throttle', 'retry'] as $token) { + if (str_contains($payload_text, $token)) { + return 'Shelly rate limit reached, waiting for next available window'; + } + } + + foreach ($required_relay_ids as $relay_id) { + if (!array_key_exists($relay_id, $snapshot)) { + return 'Shelly returned no device status yet'; + } + if (!$this->relayStatusPayloadExists($snapshot[$relay_id])) { + return 'Shelly relay status payload is not ready yet'; + } + } + + return 'Shelly relay status is not ready yet'; + } + + private function serializeShellyResponse(array|object|null $response): string + { + if ($response === null) { + return ''; + } + if (is_scalar($response)) { + return (string)$response; + } + $encoded = json_encode($response, JSON_UNESCAPED_UNICODE); + return is_string($encoded) ? $encoded : ''; + } + + private function getLaneShellyStatusSnapshotCacheKey(): string + { + $transport = strtolower(trim((string)$this->shelly_transport_override)); + $safeTransport = $transport !== '' ? (preg_replace('/[^a-z0-9_-]+/', '_', $transport) ?: $transport) : ''; + $transportSuffix = $safeTransport !== '' ? '_' . $safeTransport : ''; + return self::SHELLY_STATUS_SNAPSHOT_KEY_PREFIX . (int)$this->id . $transportSuffix; + } + + /** + * @return array|null + */ + private function getCachedLaneShellyStatusSnapshot(): ?array + { + $redis = $this->redisFacade(); + if ($redis === null) { + return null; + } + + $raw = $redis->get($this->getLaneShellyStatusSnapshotCacheKey()); + if (!is_string($raw) || $raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return null; + } + + $snapshot = []; + foreach ($decoded as $relay_id => $entry) { + $id = trim((string)$relay_id); + if ($id === '' || !is_array($entry)) { + continue; + } + $snapshot[$id] = $entry; + } + return $snapshot; + } + + /** + * @param array $snapshot + */ + private function setCachedLaneShellyStatusSnapshot(array $snapshot): void + { + $redis = $this->redisFacade(); + if ($redis === null) { + return; + } + + $encoded = json_encode($snapshot, JSON_UNESCAPED_UNICODE); + if (!is_string($encoded)) { + return; + } + $redis->setEx( + $this->getLaneShellyStatusSnapshotCacheKey(), + $encoded, + self::SHELLY_STATUS_SNAPSHOT_TTL_SECONDS + ); + } + + /** + * @throws \Exception + */ + protected function sendShellyPost(string $endpoint, array $payload): array|object|null + { + $transport = $this->createShellyTransport(); + $transport->requireModuleEnabled(); + $transport->requireValidSecretKey(); + return edge_gateway_manager::withRelayActionContext( + $this->buildSelfServeRelayActionContext($endpoint, $payload), + fn(): array|object|null => $transport->sendPostRequest($endpoint, $payload, $this->resolveShellyTransportDepartmentId()) + ); + } + + protected function createShellyClient(): shelly + { + return new shelly(); + } + + protected function createShellyTransport(): shelly_transport_i + { + return (new shelly_transport_resolver())->resolveForDepartment( + $this->resolveShellyTransportDepartmentId(), + $this->shelly_transport_override + ); + } + + /** + * @throws \Exception + */ + protected function resolveShellyTransportDepartmentId(): int + { + if (empty($this->department_lane)) { + throw new \Exception("Department lane object not found for lane ID {$this->id}"); + } + + $department_id = (int)$this->department_lane->department->value(); + if ($department_id <= 0) { + throw new \Exception('Unable to resolve department for Shelly transport'); + } + + return $department_id; + } + + protected function buildSelfServeRelayActionContext(string $endpoint, array $payload, array $overrides = []): array + { + $relayIds = $this->extractSelfServeRelayIdsFromShellyPayload($payload); + $context = [ + 'module' => 'selfserve', + 'reason' => $this->describeSelfServeRelayReason($endpoint, $payload), + 'lane_id' => (int)$this->id, + 'department_id' => $this->safeSelfServeDepartmentId(), + 'route' => $_SERVER['REQUEST_URI'] ?? null, + 'relay_ids' => $relayIds, + ]; + + if (count($relayIds) === 1) { + $context['relay_id'] = $relayIds[0]; + $context['relay_role'] = $this->describeSelfServeRelayRole($relayIds[0]); + } + + try { + $customerNumber = method_exists($this, 'getCustomerNumber') ? $this->getCustomerNumber() : null; + if ($customerNumber !== null && (int)$customerNumber > 0) { + $context['customer_number'] = (int)$customerNumber; + } + } catch (\Throwable) { + } + + try { + $licensePlate = method_exists($this, 'getLicensePlate') ? trim((string)$this->getLicensePlate()) : ''; + if ($licensePlate !== '') { + $context['license_plate'] = $licensePlate; + } + } catch (\Throwable) { + } + + return array_replace_recursive(array_filter( + $context, + static fn(mixed $value): bool => $value !== null && $value !== '' && $value !== [] + ), $overrides); + } + + /** + * @return string[] + */ + private function extractSelfServeRelayIdsFromShellyPayload(array $payload): array + { + $ids = []; + foreach ((array)($payload['ids'] ?? []) as $id) { + $id = trim((string)$id); + if ($id !== '') { + $ids[] = $id; + } + } + + $singleId = trim((string)($payload['id'] ?? $payload['relayId'] ?? $payload['relay_id'] ?? '')); + if ($singleId !== '') { + $ids[] = $singleId; + } + + return array_values(array_unique($ids)); + } + + private function describeSelfServeRelayReason(string $endpoint, array $payload): string + { + if (str_contains(strtolower($endpoint), '/get')) { + return 'Read self-serve relay status'; + } + + $target = array_key_exists('on', $payload) && (bool)$payload['on'] ? 'ON' : 'OFF'; + return 'Set self-serve relay ' . $target; + } + + private function describeSelfServeRelayRole(string $relayId): ?string + { + foreach (selfserve_lane_relay::cases() as $relay) { + try { + if ($this->getRelayId($relay) === $relayId) { + return $relay->name; + } + } catch (\Throwable) { + } + } + + return null; + } + + private function safeSelfServeDepartmentId(): ?int + { + try { + return $this->resolveShellyTransportDepartmentId(); + } catch (\Throwable) { + return null; + } + } + + protected function redisFacade(): mixed + { + return defined('redis') ? redis : null; + } + + protected function nowTimestamp(): float + { + return microtime(true); + } + + protected function sleepMicroseconds(int $microseconds): void + { + if ($microseconds > 0) { + usleep($microseconds); + } + } + /** * Turn on the lane relay - * @param selfserve_lane_relay $relay The relay to turn on (MACHINE) + * @param selfserve_lane_relay $relay The relay to turn on (MACHINE or MACHINE_PROGRAM_PICKER) * @parm int|null $duration The duration in seconds to keep the relay on (optional) * @return bool True if the relay was successfully turned on * @throws \Exception If an invalid relay is specified or if the lane is not in a state to turn on the relay */ public function turnOnRelay(selfserve_lane_relay $relay, ?int $duration = null): bool { - // Require department lane object if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}"); - // Check lane status (ensure initialization via getter) if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn on relay on CLOSED lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane"); - // Enforce that MACHINE relay can only be enabled when allowed by current self-serve tasks (self-serve, manual trigger required) + if ($relay === selfserve_lane_relay::MACHINE) { - // Allowed services are stored as an array of names in lane cache $allowed = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES); if (!is_array($allowed) || !in_array(selfserve_lane_services::MACHINE->name, $allowed, true)) { throw new \Exception("MACHINE relay is not allowed to be enabled at this time"); } } - // Get the relay ID based on the relay type - $relay_id = match ($relay) { - selfserve_lane_relay::MACHINE => $this->department_lane->relay_machine_id->value(), - default => throw new \Exception("Invalid relay specified, must be MACHINE"), - }; - // Make sure relay ID is valid - if (empty($relay_id)) { - throw new \Exception("Invalid relay ID for relay {$relay->name}"); - } - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - // Wait 1 second - sleep(1); - // Format the result - $result = array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); - // Turn on the switch - foreach ($result as $device) { - // Ensure machine switches never auto-toggle off; enforce toggle_after = 0 - $device->toggle_after = 0; - $device->switch(true); - } - return true; + + return $this->sendRelaySwitchCommand($relay, true, $duration); } /** @@ -78,37 +927,24 @@ trait selfserve_lane_relay_controller_t */ public function forceTurnOnMachineRelay(?int $duration = null): bool { - // Require department lane object + return $this->forceTurnOnRelay(selfserve_lane_relay::MACHINE, $duration); + } + + /** + * Force turn on a specific relay, bypassing allowed services gating. + * @param selfserve_lane_relay $relay + * @param int|null $duration Optional auto-off duration in seconds + * @return bool + * @throws \Exception + */ + public function forceTurnOnRelay(selfserve_lane_relay $relay, ?int $duration = null): bool + { if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}"); - // Basic sanity checks on lane status (still disallow clearly invalid states) if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn on relay on CLOSED lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane"); - // Directly control Shelly without checking allowed services - $relay_id = $this->department_lane->relay_machine_id->value(); - if (empty($relay_id)) { - throw new \Exception("Invalid relay ID for MACHINE relay"); - } - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - // Wait 1 second - sleep(1); - // Format the result - $result = array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); - // Turn on the switch - foreach ($result as $device) { - // Ensure machine switches never auto-toggle off; enforce toggle_after = 0 - $device->toggle_after = 0; - $device->switch(true); - } - return true; + + return $this->sendRelaySwitchCommand($relay, true, $duration); } /** @@ -119,77 +955,110 @@ trait selfserve_lane_relay_controller_t */ public function forceTurnOffMachineRelay(): bool { - // Require department lane object + return $this->forceTurnOffRelay(selfserve_lane_relay::MACHINE); + } + + /** + * Force turn off a specific relay, bypassing allowed services gating. + * @param selfserve_lane_relay $relay + * @return bool + * @throws \Exception + */ + public function forceTurnOffRelay(selfserve_lane_relay $relay): bool + { if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}"); - // Basic sanity checks on lane status (still disallow clearly invalid states) if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn off relay on CLOSED lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane"); - // Directly control Shelly without checking allowed services - $relay_id = $this->department_lane->relay_machine_id->value(); - if (empty($relay_id)) { - throw new \Exception("Invalid relay ID for MACHINE relay"); - } - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - // Wait 1 second - sleep(1); - // Format the result - $result = array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); - // Turn off the switch - foreach ($result as $device) { - $device->switch(false); - } - return true; + + return $this->sendRelaySwitchCommand($relay, false); } /** * Turn off the lane relay - * @param selfserve_lane_relay $relay The relay to turn off (MACHINE) + * @param selfserve_lane_relay $relay The relay to turn off (MACHINE or MACHINE_PROGRAM_PICKER) * @return bool True if the relay was successfully turned off * @throws \Exception If an invalid relay is specified or if the lane is not in a state to turn off the relay */ public function turnOffRelay(selfserve_lane_relay $relay): bool { - // Require department lane object if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}"); - // Check lane status (ensure initialization via getter) if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn off relay on CLOSED lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane"); - // Get the relay ID based on the relay type - $relay_id = match ($relay) { - selfserve_lane_relay::MACHINE => $this->department_lane->relay_machine_id->value(), - default => throw new \Exception("Invalid relay specified, must be MACHINE"), - }; - // Make sure relay ID is valid - if (empty($relay_id)) { - throw new \Exception("Invalid relay ID for relay {$relay->name}"); + + return $this->sendRelaySwitchCommand($relay, false); + } + + /** + * @throws \Exception + */ + private function sendRelaySwitchCommand(selfserve_lane_relay $relay, bool $on, ?int $duration = null): bool + { + if (!$this->isDepartmentSelfServeRelayMutationsEnabled()) { + throw new \Exception("Cannot change relay state: Self-serve is not enabled for this lane's department."); } - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - // Wait 1 second - sleep(1); - // Format the result - $result = array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); - // Turn off the switch - foreach ($result as $device) { - $device->switch(false); + + $relay_id = $this->getRelayId($relay); + $payload = [ + 'id' => $relay_id, + 'channel' => self::SHELLY_DEFAULT_CHANNEL, + 'on' => $on, + ]; + if ($duration !== null && $duration > 0) { + $payload['toggle_after'] = $duration; } + + $response = $this->isDemoRelayId($relay_id) + ? [$this->buildDemoRelaySnapshotEntry($relay_id, $on)] + : $this->sendShellyPost('/v2/devices/api/set/switch', $payload); + $this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response); return true; } -} \ No newline at end of file + + /** + * @throws \Exception + */ + private function seedLaneShellySnapshotFromSwitch(string $relay_id, bool $on, array|object|null $response): void + { + $snapshot = $this->getCachedLaneShellyStatusSnapshot() ?? []; + $existing = $snapshot[$relay_id] ?? []; + if (!is_array($existing)) { + $existing = []; + } + + $seed = [ + 'id' => $relay_id, + 'on' => $on, + 'status' => ['switch:0' => ['output' => $on]], + ]; + + $response_entry = $this->extractRelaySnapshotEntryFromSwitchResponse($response, $relay_id); + if ($response_entry !== null) { + $seed = array_replace_recursive($seed, $response_entry); + } + + $snapshot[$relay_id] = array_replace_recursive($existing, $seed); + $this->setCachedLaneShellyStatusSnapshot($snapshot); + } + + /** + * @return array|null + */ + private function extractRelaySnapshotEntryFromSwitchResponse(array|object|null $response, string $relay_id): ?array + { + $devices = $this->normalizeRelayDevicesResponse($response); + if (count($devices) < 1) { + return null; + } + + foreach ($devices as $device) { + $id = isset($device['id']) ? (string)$device['id'] : ''; + if ($id === $relay_id) { + return $device; + } + } + + return $devices[0]; + } +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php index 06186b09..3a5a0913 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php @@ -87,4 +87,44 @@ trait selfserve_lane_status_t return $this; } -} \ No newline at end of file + + public function setLaneStatusAudit(?array $audit): self + { + if ($audit === null) { + $this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT); + return $this; + } + + $this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT, [ + 'modified_at' => isset($audit['modified_at']) ? (string)$audit['modified_at'] : date(DATE_ATOM), + 'modified_by_user_id' => isset($audit['modified_by_user_id']) ? (int)$audit['modified_by_user_id'] : null, + 'modified_by_name' => isset($audit['modified_by_name']) ? (string)$audit['modified_by_name'] : null, + ]); + + return $this; + } + + public function getLaneStatusAudit(): ?array + { + $audit = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT); + if (!is_array($audit)) { + return null; + } + + $modified_at = isset($audit['modified_at']) ? trim((string)$audit['modified_at']) : ''; + $modified_by_name = isset($audit['modified_by_name']) ? trim((string)$audit['modified_by_name']) : ''; + $modified_by_user_id = isset($audit['modified_by_user_id']) && is_numeric($audit['modified_by_user_id']) + ? (int)$audit['modified_by_user_id'] + : null; + + if ($modified_at === '' && $modified_by_name === '' && $modified_by_user_id === null) { + return null; + } + + return [ + 'modified_at' => $modified_at !== '' ? $modified_at : null, + 'modified_by_user_id' => $modified_by_user_id, + 'modified_by_name' => $modified_by_name !== '' ? $modified_by_name : null, + ]; + } +} diff --git a/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php b/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php index d16287c3..d81e6b5a 100644 --- a/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php +++ b/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php @@ -12,6 +12,9 @@ use Exception; */ class shelly_device_switch extends shelly_device_state { + /** @var callable|null */ + private $request_sender = null; + /** * @var boolean $on * @description The output state @@ -45,8 +48,38 @@ class shelly_device_switch extends shelly_device_state 'id' => (string)$this->id, 'channel' => (int)$this->channel, 'on' => $on, - 'toggle_after' => $skip_toggle_after ? 0 : (int)$this->toggle_after, + ...($skip_toggle_after ? [] : ['toggle_after' => (int)$this->toggle_after]), ]; + return $this->sendShellySwitchRequest($parameters); + } + + /** + * @throws Exception + */ + protected function sendShellySwitchRequest(array $parameters): array|object|null + { + if (is_callable($this->request_sender)) { + $sender = $this->request_sender; + return $sender($parameters); + } return (new shelly())->sendPostRequest('/v2/devices/api/set/switch', $parameters); } -} \ No newline at end of file + + /** + * @throws Exception + */ + public function getStatus(): array|object|null + { + $parameters = [ + 'id' => (string)$this->id, + 'channel' => (int)$this->channel, + ]; + return $this->sendShellySwitchRequest($parameters); + } + + public function setRequestSender(callable $sender): self + { + $this->request_sender = $sender; + return $this; + } +} diff --git a/services/nginx/app/modules/stripe/endpoints/stripe_endpoint_invoice.php b/services/nginx/app/modules/stripe/endpoints/stripe_endpoint_invoice.php index 6b914a21..7040693c 100644 --- a/services/nginx/app/modules/stripe/endpoints/stripe_endpoint_invoice.php +++ b/services/nginx/app/modules/stripe/endpoints/stripe_endpoint_invoice.php @@ -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 @@ -96,4 +138,4 @@ class stripe_endpoint_invoice return $invoice->finalizeInvoice(); } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/washcertificates/composer.json b/services/nginx/app/modules/washcertificates/composer.json index 628892b6..63add6e9 100644 --- a/services/nginx/app/modules/washcertificates/composer.json +++ b/services/nginx/app/modules/washcertificates/composer.json @@ -19,5 +19,8 @@ "setasign/fpdi": "^2.6", "setasign/fpdf": "^1.8", "ext-mysqli": "*" + }, + "config": { + "secure-http": false } } diff --git a/services/nginx/app/modules/washcertificates/composer.lock b/services/nginx/app/modules/washcertificates/composer.lock index 944fc255..8dcbfd89 100644 --- a/services/nginx/app/modules/washcertificates/composer.lock +++ b/services/nginx/app/modules/washcertificates/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5c2ab021deb58020ce7d1f7f06e14f19", + "content-hash": "d6a6015f0fa919d0d8fd2b111e901572", "packages": [ { "name": "dompdf/dompdf", @@ -1366,10 +1366,12 @@ "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform": { + "ext-mysqli": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/services/nginx/app/modules/washcertificates/index.php b/services/nginx/app/modules/washcertificates/index.php index 9563d96c..49c026a4 100644 --- a/services/nginx/app/modules/washcertificates/index.php +++ b/services/nginx/app/modules/washcertificates/index.php @@ -27,7 +27,7 @@ require_once 'twc_spreadsheet_class.php'; // Set CORS headers header("Access-Control-Allow-Origin: *"); header("Access-Control-Allow-Methods: GET, POST"); -header("Access-Control-Allow-Headers: Content-Type, X-Customer-Number"); +header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma"); // Set the timezone date_default_timezone_set('Europe/Copenhagen'); @@ -79,6 +79,10 @@ if (isset($_GET['justDownload'])) { exit; } +http_response_code(410); +echo 'Booking completion must be completed through POS desktop or mobile steps.'; +exit; + // Require the $_GET variables sealOrPlumber, safetySeal, performedBy, and bookingId, regNumber, and regNumberTrailer to be set if (!isset($_GET['sealOrPlumber']) || !isset($_GET['performedBy']) || !isset($_GET['bookingId']) || !isset($_GET['regNumber']) || !isset($_GET['regNumberTrailer']) || !isset($_GET['department'])) { // We are missing some required fields in the query string @@ -140,4 +144,4 @@ $booking->washCertificateStatus->set('completed'); // Return the generated certificate object download URL echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']); // Exit the script -exit; \ No newline at end of file +exit; diff --git a/services/nginx/app/modules/weatherapi/config/weatherapi_enabled_c.php b/services/nginx/app/modules/weatherapi/config/weatherapi_enabled_c.php new file mode 100644 index 00000000..2faa5681 --- /dev/null +++ b/services/nginx/app/modules/weatherapi/config/weatherapi_enabled_c.php @@ -0,0 +1,29 @@ +setupConfig('weatherapi'); + $this->allowUpdate([ + weatherapi_enabled_c::class, + weatherapi_secret_key_c::class, + ]); + + $this->enabled = new weatherapi_enabled_c(); + $this->secret_key = new weatherapi_secret_key_c(); + } +} diff --git a/services/nginx/app/modules/workfeed/config/workfeed_api_key_c.php b/services/nginx/app/modules/workfeed/config/workfeed_api_key_c.php new file mode 100644 index 00000000..8cff965b --- /dev/null +++ b/services/nginx/app/modules/workfeed/config/workfeed_api_key_c.php @@ -0,0 +1,29 @@ +setupConfig('workfeed'); + $this->allowUpdate([ + workfeed_enabled_c::class, + workfeed_api_url_c::class, + workfeed_api_key_c::class, + workfeed_company_id_c::class, + ]); + + $this->enabled = new workfeed_enabled_c(); + $this->api_url = new workfeed_api_url_c(); + $this->api_key = new workfeed_api_key_c(); + $this->company_id = new workfeed_company_id_c(); + } +} diff --git a/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php b/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php new file mode 100644 index 00000000..bcd47458 --- /dev/null +++ b/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php @@ -0,0 +1,29 @@ +importCustomers(); (new xlvask_vehicles_o())->importVehicles(); (new xlvask_usage_logs_o())->importUsageLogs(); + (new xlvask_automation_service())->runPending(null, null, [], 100, null); }; } @@ -608,4 +612,4 @@ class xlvask_tasks $xlvask->requireModuleEnabled(); // Run } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php b/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php index 8f06f093..f827a17c 100644 --- a/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php +++ b/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php @@ -141,6 +141,21 @@ class xlvask_usage_log extends xlvask_helper * @see xlvask_wash_item */ public array $WashItems; + /** + * Timestamp for invoice-period ignore state, when the wash has been ignored by a superuser. + * @var string|int|null $ignored_at + */ + public string|int|null $ignored_at; + /** + * Superuser id for invoice-period ignore state. + * @var int|string|null $ignored_by + */ + public int|string|null $ignored_by; + /** + * Optional reason for invoice-period ignore state. + * @var string|int|null $ignored_reason + */ + public string|int|null $ignored_reason; private string $default_string = 'DEFAULT_STRING_1'; private string $default_int = 'DEFAULT_INT_1'; @@ -194,6 +209,9 @@ class xlvask_usage_log extends xlvask_helper $this->CustomerGuid = $this->default_string; $this->VehicleId = $this->default_string; $this->WashItems = []; // Initialize as an empty array + $this->ignored_at = $this->default_string_nullable; + $this->ignored_by = $this->default_int_nullable; + $this->ignored_reason = $this->default_string_nullable; } /** @@ -226,6 +244,9 @@ class xlvask_usage_log extends xlvask_helper 'FinishStatus' => $this->default_int, 'CustomerGuid' => $this->default_string, 'VehicleId' => $this->default_string, + 'ignored_at' => $this->default_string_nullable, + 'ignored_by' => $this->default_int_nullable, + 'ignored_reason' => $this->default_string_nullable, ]; foreach ( $data as $key => $value ) { if (property_exists(self::class, $key)) { @@ -363,7 +384,8 @@ class xlvask_usage_log extends xlvask_helper 'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location', 'Hall', 'HallId', 'StartTime', 'FinishTime', 'RegistrationNumber', 'VehicleType', 'IdentificationType', 'IdentificationId', 'Info', - 'Updated', 'Prepaid', 'FinishStatus', 'CustomerGuid', 'VehicleId' + 'Updated', 'Prepaid', 'FinishStatus', 'CustomerGuid', 'VehicleId', + 'ignored_at', 'ignored_by', 'ignored_reason', ]; foreach ( $properties as $property ) { if ($this->isEmptyOrDefault($this->{$property})) { @@ -639,4 +661,4 @@ class xlvask_usage_log extends xlvask_helper // Check if the wash is prepaid return !empty($this->Prepaid) && $this->Prepaid === 1; // Assuming 1 indicates a prepaid wash } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/xlvask/xlvask_c.php b/services/nginx/app/modules/xlvask/xlvask_c.php index 69560070..016334b8 100644 --- a/services/nginx/app/modules/xlvask/xlvask_c.php +++ b/services/nginx/app/modules/xlvask/xlvask_c.php @@ -3,11 +3,17 @@ namespace xlvask; require_once WD . '/modules/xlvask/config/xlvask_enabled_c.php'; require_once WD . '/modules/xlvask/config/xlvask_synchronization_enabled_c.php'; +require_once WD . '/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php'; +require_once WD . '/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php'; +require_once WD . '/modules/xlvask/config/xlvask_openai_integration_enabled_c.php'; require_once WD . '/modules/xlvask/config/xlvask_username_c.php'; require_once WD . '/modules/xlvask/config/xlvask_password_c.php'; use traits\module_config_t; +use xlvask\config\xlvask_automatic_order_attachment_enabled_c; +use xlvask\config\xlvask_automatic_order_creation_enabled_c; use xlvask\config\xlvask_enabled_c; +use xlvask\config\xlvask_openai_integration_enabled_c; use xlvask\config\xlvask_password_c; use xlvask\config\xlvask_synchronization_enabled_c; use xlvask\config\xlvask_username_c; @@ -31,6 +37,18 @@ class xlvask_c * @var xlvask_synchronization_enabled_c $synchronization_enabled */ public xlvask_synchronization_enabled_c $synchronization_enabled; + /** + * @var xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled + */ + public xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled; + /** + * @var xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled + */ + public xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled; + /** + * @var xlvask_openai_integration_enabled_c $openai_integration_enabled + */ + public xlvask_openai_integration_enabled_c $openai_integration_enabled; /** * The username * @var xlvask_username_c @@ -53,12 +71,18 @@ class xlvask_c $this->allowUpdate([ xlvask_enabled_c::class, xlvask_synchronization_enabled_c::class, + xlvask_automatic_order_attachment_enabled_c::class, + xlvask_automatic_order_creation_enabled_c::class, + xlvask_openai_integration_enabled_c::class, xlvask_username_c::class, xlvask_password_c::class ]); $this->enabled = new xlvask_enabled_c(); $this->synchronization_enabled = new xlvask_synchronization_enabled_c(); + $this->automatic_order_attachment_enabled = new xlvask_automatic_order_attachment_enabled_c(); + $this->automatic_order_creation_enabled = new xlvask_automatic_order_creation_enabled_c(); + $this->openai_integration_enabled = new xlvask_openai_integration_enabled_c(); $this->username = new xlvask_username_c(); $this->password = new xlvask_password_c(); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 5d4c2e1f..cac02b97 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -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'); @@ -506,6 +508,7 @@ class collected_order_invoices_o extends db } // Set the processor to E-conomic, if it's not already set to Stripe. $this->processor->set(ECONOMIC_PROCESSOR); + $this->error_message->nullify(); // Object changed self::objectChanged(); return $this; @@ -544,9 +547,6 @@ class collected_order_invoices_o extends db $db; // Sanitize the input $customer_number = $db->escape_string($customer_number); - if (!empty($name)) { - $name = $db->escape_string($name); - } if (!empty($notes)) { $notes = $db->escape_string($notes); } @@ -562,10 +562,20 @@ class collected_order_invoices_o extends db } // Require the customer number to be of a valid customer self::requireValidCustomer($customer_number); + $resolved_name = is_string($name) ? trim($name) : ''; + if ($resolved_name === '') { + $customer = (new users_o())->getUserByCustomerNumber((int)$customer_number); + $customer->requireSelected(); + $resolved_name = trim((string)$customer->display_name->value()); + } + if ($resolved_name === '') { + $resolved_name = 'Invoice collection ' . (string)$customer_number; + } + $resolved_name = $db->escape_string($resolved_name); // Add the object $tmp_id = self::add_object([ 'customer_number' => (int)$customer_number, - 'name' => $name, + 'name' => $resolved_name, 'notes' => $notes, ...(!empty($closed_at) ? ['closed_at' => (string)$closed_at] : []), ]); @@ -1023,6 +1033,207 @@ class collected_order_invoices_o extends db $this->objectChanged(); } + /** + * Split this invoice collection into one collection per order month. + * + * @return array + * @throws Exception + */ + public function splitByOrderMonth(): array + { + global $db; + + self::requireSelected(); + $this->requireCanSplitByOrderMonth(); + + $orders_by_month = $this->getIncludedOrdersGroupedByCreatedMonth(); + $preview = $this->buildSplitByOrderMonthPreview($orders_by_month); + if (($preview['status'] ?? '') === 'skipped') { + $preview['preview'] = false; + return $preview; + } + + $original_invoice_collection_id = (int)$this->id; + $created_invoice_collection_ids = []; + $month_collection_ids = []; + $months = array_keys($orders_by_month); + $month_results = $preview['months']; + + $db->conn()->begin_transaction(); + try { + foreach ( $months as $index => $month ) { + $month_timestamp = self::getFirstDayOfMonth($month . '-01 00:00:01'); + $month_closed_at = self::getLastSecondOfMonthIfEnded($month . '-01 00:00:01'); + if ($index === 0) { + $month_collection = $this; + $month_collection->created_at->set($month_timestamp); + $month_collection->closed_at->set($month_closed_at); + } else { + $month_collection = (new collected_order_invoices_o())->add( + (int)$this->customer_number->value(), + $this->name->value(), + $this->notes->value(), + null, + $month_closed_at + ); + $month_collection->created_at->set($month_timestamp); + $created_invoice_collection_ids[] = (int)$month_collection->id; + } + + $month_collection_ids[$month] = (int)$month_collection->id; + $month_results[$index]['invoice_collection_id'] = (int)$month_collection->id; + $month_results[$index]['target_invoice_collection_id'] = (int)$month_collection->id; + } + + foreach ( $orders_by_month as $month => $orders ) { + $target_invoice_collection_id = (int)$month_collection_ids[$month]; + foreach ( $orders as $order ) { + if ((int)$order->invoice_collection_id->value() === $target_invoice_collection_id) { + continue; + } + $order->assignToInvoiceCollection($target_invoice_collection_id); + } + } + + $this->objectChanged(); + foreach ( $created_invoice_collection_ids as $created_invoice_collection_id ) { + (new collected_order_invoices_o())->select($created_invoice_collection_id)->objectChanged(); + } + + $db->conn()->commit(); + } catch (\Throwable $e) { + $db->conn()->rollback(); + throw $e; + } + + return [ + 'status' => 'changed', + 'invoice_collection_id' => $original_invoice_collection_id, + 'preview' => false, + 'created_invoice_collection_ids' => $created_invoice_collection_ids, + 'months' => $month_results, + ]; + } + + /** + * Preview how this invoice collection would be split into one collection per order month. + * + * @return array + * @throws Exception + */ + public function previewSplitByOrderMonth(): array + { + self::requireSelected(); + $this->requireCanSplitByOrderMonth(); + + return $this->buildSplitByOrderMonthPreview($this->getIncludedOrdersGroupedByCreatedMonth()); + } + + /** + * @param array $orders_by_month + * @return array + * @throws Exception + */ + private function buildSplitByOrderMonthPreview(array $orders_by_month): array + { + if (empty($orders_by_month)) { + throw new Exception('No orders in invoice collection'); + } + + ksort($orders_by_month); + if (count($orders_by_month) < 2) { + return [ + 'status' => 'skipped', + 'reason' => 'already_single_month', + 'message' => 'Invoice collection already belongs to one month', + 'invoice_collection_id' => (int)$this->id, + 'preview' => true, + 'months' => array_keys($orders_by_month), + ]; + } + + $months = []; + foreach ( array_keys($orders_by_month) as $index => $month ) { + $order_ids = array_map(static function (orders_o $order): int { + return (int)$order->id; + }, $orders_by_month[$month]); + $month_timestamp = self::getFirstDayOfMonth($month . '-01 00:00:01'); + $month_closed_at = self::getLastSecondOfMonthIfEnded($month . '-01 00:00:01'); + $will_create_collection = $index !== 0; + $months[] = [ + 'month' => $month, + 'invoice_collection_id' => $will_create_collection ? null : (int)$this->id, + 'target_invoice_collection_id' => $will_create_collection ? null : (int)$this->id, + 'source_invoice_collection_id' => (int)$this->id, + 'will_create_collection' => $will_create_collection, + 'order_count' => count($orders_by_month[$month]), + 'order_ids' => $order_ids, + 'created_at' => $month_timestamp, + 'closed_at' => $month_closed_at, + ]; + } + + return [ + 'status' => 'changed', + 'invoice_collection_id' => (int)$this->id, + 'preview' => true, + 'created_invoice_collection_ids' => [], + 'months' => $months, + ]; + } + + /** + * @throws Exception + */ + private function requireCanSplitByOrderMonth(): void + { + self::requireSelected(); + self::requireInvoiceIsNotBooked(); + + $processor = $this->processor->value(); + $processor = $processor === null ? 0 : (int)$processor; + if ($processor === STRIPE_PROCESSOR) { + throw new Exception('Stripe invoice collections cannot be split'); + } + if ($processor === OTHER_PROCESSOR) { + throw new Exception('Due to the stateless nature of the processor, invoice collections cannot be split. Please contact technical support for assistance.'); + } + if (!in_array($processor, [0, ECONOMIC_PROCESSOR], true)) { + throw new Exception('Invalid processor type'); + } + if (!empty($this->external_id->value())) { + throw new Exception('Invoice collection already has an external invoice reference'); + } + } + + /** + * @return array + * @throws Exception + */ + private function getIncludedOrdersGroupedByCreatedMonth(): array + { + $order_ids = self::getOrderIds(); + $orders_by_month = []; + foreach ( $order_ids as $order_id ) { + $order = (new orders_o())->select((int)$order_id['id']); + $order->requireSelected(); + if ($order->isBooked(true)) { + throw new Exception('Invoice collection contains booked orders'); + } + + $created_at = (string)$order->created_at->value(); + if (strtotime($created_at) === false) { + throw new Exception('Order has invalid created_at date'); + } + + $month = date('Y-m', strtotime($created_at)); + $orders_by_month[$month] = $orders_by_month[$month] ?? []; + $orders_by_month[$month][] = $order; + } + + return $orders_by_month; + } + /** * Add the vehicle subscriptions transaction to the invoice collection * @throws Exception If the invoice collection is not selected @@ -1245,6 +1456,18 @@ class collected_order_invoices_o extends db return $date->format('Y-m-d H:i:s'); } + private static function getLastSecondOfMonthIfEnded(string $timestamp): ?string + { + $date = new \DateTime($timestamp); + $date->modify('last day of this month'); + $date->setTime(23, 59, 59); + if ($date > new \DateTime()) { + return null; + } + + return $date->format('Y-m-d H:i:s'); + } + /** * Get the wash subscription price * @param float $price The price of the wash subscription @@ -1482,4 +1705,9 @@ class collected_order_invoices_o extends db $row = $result->fetch_assoc(); return (int)$row['count'] === 0; } -} \ No newline at end of file + + public function clearCachedData(): void + { + $this->objectChanged(); + } +} diff --git a/services/nginx/app/objects/customer_vehicles_o.php b/services/nginx/app/objects/customer_vehicles_o.php index 058d84b8..e63a5746 100644 --- a/services/nginx/app/objects/customer_vehicles_o.php +++ b/services/nginx/app/objects/customer_vehicles_o.php @@ -131,18 +131,8 @@ class customer_vehicles_o extends db private function getLastOrderId(): ?int { self::requireSelected(); - $orders_o = (new orders_o()); - $orders = $orders_o->getFieldsWhere([ - 'reg_1' => (string)$this->reg->value(), - 'deleted_at' => null, - ], [ - 'id', - ]); - if (count($orders) > 0) { - $last_order = array_pop($orders); - return (int)$last_order['id']; - } - return null; + $last_order = $this->getLastOrderByPlate((string)$this->reg->value()); + return $last_order?->id ? (int)$last_order->id : null; } /** @@ -549,4 +539,4 @@ class customer_vehicles_o extends db ], $numberOfTransactions); return array_map(fn($order) => (int)$order['id'], $orders); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/department_daily_report_complaints_o.php b/services/nginx/app/objects/department_daily_report_complaints_o.php new file mode 100644 index 00000000..108a8a29 --- /dev/null +++ b/services/nginx/app/objects/department_daily_report_complaints_o.php @@ -0,0 +1,239 @@ +setTable('department_daily_report_complaints'); + } + + public function getObjectProperties(): void + { + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->customer_number = new object_property($this->table, $this->id, 'customer_number', 'int', false); + $this->wash_date = new object_property($this->table, $this->id, 'wash_date', 'string', false); + $this->category = new object_property($this->table, $this->id, 'category', 'string', false); + $this->description = new object_property($this->table, $this->id, 'description', 'string', false); + $this->created_by = new object_property($this->table, $this->id, 'created_by', 'int', false); + $this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false); + } + + public function objectChanged(): void + { + // No additional cache invalidation is needed for complaint rows in v1. + } + + public function asArray(): array + { + $this->requireSelected(); + + return [ + 'id' => (int)$this->id, + 'department_id' => (int)$this->department_id->value(), + 'customer_number' => $this->customer_number->value() === null ? null : (int)$this->customer_number->value(), + 'wash_date' => $this->wash_date->value() === null ? null : (string)$this->wash_date->value(), + 'category' => $this->category->value() === null ? null : (string)$this->category->value(), + 'description' => (string)$this->description->value(), + 'created_by' => (int)$this->created_by->value(), + 'created_at' => (string)$this->created_at->value(), + ]; + } + + public function parseComplaint(array $complaint): array + { + static $department_name_cache = []; + static $customer_name_cache = []; + static $created_by_name_cache = []; + + $department_id = (int)($complaint['department_id'] ?? 0); + $customer_number = isset($complaint['customer_number']) && $complaint['customer_number'] !== null + ? (int)$complaint['customer_number'] + : null; + $wash_date = isset($complaint['wash_date']) && $complaint['wash_date'] !== null + ? trim((string)$complaint['wash_date']) + : null; + $category = isset($complaint['category']) && $complaint['category'] !== null + ? trim((string)$complaint['category']) + : null; + $created_by = (int)($complaint['created_by'] ?? 0); + + if (!array_key_exists($department_id, $department_name_cache)) { + $department_name_cache[$department_id] = $department_id > 0 + ? (new departments_o())->getDepartmentName($department_id) + : null; + } + + if ($customer_number !== null && !array_key_exists($customer_number, $customer_name_cache)) { + $customer_name_cache[$customer_number] = (new users_o())->getCustomerName($customer_number); + } + + if (!array_key_exists($created_by, $created_by_name_cache)) { + $created_by_name_cache[$created_by] = $this->resolveCreatedByName($created_by); + } + + return [ + 'id' => (int)($complaint['id'] ?? 0), + 'department_id' => $department_id, + 'department_name' => $department_name_cache[$department_id] ?? null, + 'customer_number' => $customer_number, + 'customer_name' => $customer_number !== null + ? ($customer_name_cache[$customer_number] ?? null) + : null, + 'wash_date' => $wash_date === '' ? null : $wash_date, + 'category' => $category === '' ? null : $category, + 'description' => (string)($complaint['description'] ?? ''), + 'created_by' => $created_by, + 'created_by_name' => $created_by_name_cache[$created_by] ?? null, + 'created_at' => (string)($complaint['created_at'] ?? ''), + ]; + } + + public function parseComplaints(array $complaints): array + { + return array_map(fn (array $complaint): array => $this->parseComplaint($complaint), $complaints); + } + + /** + * @throws Exception + */ + public function addComplaint( + int $department_id, + ?int $customer_number, + string $wash_date, + string $category, + string $description, + int $created_by + ): self + { + $wash_date = trim($wash_date); + if ($wash_date === '') { + throw new Exception('Wash date is required'); + } + + $category = trim($category); + if (!self::isValidCategory($category)) { + throw new Exception('Invalid complaint category'); + } + + $description = trim($description); + if ($description === '') { + throw new Exception('Description is required'); + } + + $this->id = $this->add_object([ + 'department_id' => (int)$department_id, + 'customer_number' => $customer_number === null ? null : (int)$customer_number, + 'wash_date' => $wash_date, + 'category' => $category, + 'description' => $description, + 'created_by' => (int)$created_by, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + + return $this; + } + + /** + * @param array $department_ids + */ + public function countForDepartmentsInRange(array $department_ids, string $date, ?string $date_to = null): int + { + global $db; + + $normalized_department_ids = array_values(array_unique(array_filter( + array_map('intval', $department_ids), + static fn (int $department_id): bool => $department_id > 0 + ))); + + if ($normalized_department_ids === []) { + return 0; + } + + if ($date_to === null) { + $date_to = $date; + } + + $range_start = date('Y-m-d 00:00:00', strtotime($date)); + $range_end = date('Y-m-d 23:59:59', strtotime($date_to)); + $department_ids_sql = implode(',', $normalized_department_ids); + $date_sql = $db->escape_string($date); + $date_to_sql = $db->escape_string($date_to); + $range_start_sql = $db->escape_string($range_start); + $range_end_sql = $db->escape_string($range_end); + + $result = $db->query( + "SELECT COUNT(*) AS total + FROM department_daily_report_complaints + WHERE department_id IN ($department_ids_sql) + AND ( + (wash_date IS NOT NULL AND wash_date BETWEEN '$date_sql' AND '$date_to_sql') + OR + (wash_date IS NULL AND created_at BETWEEN '$range_start_sql' AND '$range_end_sql') + )" + ); + $row = $db->fetch_assoc($result); + + return (int)($row['total'] ?? 0); + } + + public static function validCategories(): array + { + return self::CATEGORY_VALUES; + } + + public static function isValidCategory(?string $category): bool + { + if ($category === null) { + return false; + } + + return in_array(trim($category), self::CATEGORY_VALUES, true); + } + + private function resolveCreatedByName(int $user_id): ?string + { + if ($user_id <= 0) { + return null; + } + + $user = (new users_o())->select($user_id); + if (!$user->exists()) { + return null; + } + + $display_name = trim((string)$user->display_name->value()); + return $display_name === '' ? null : $display_name; + } +} diff --git a/services/nginx/app/objects/department_daily_reports_o.php b/services/nginx/app/objects/department_daily_reports_o.php index af19a9bf..0bc54af1 100644 --- a/services/nginx/app/objects/department_daily_reports_o.php +++ b/services/nginx/app/objects/department_daily_reports_o.php @@ -609,4 +609,231 @@ class department_daily_reports_o extends db }); } -} \ No newline at end of file + /** + * @param array $department_ids + * @return array{quantity:int,products:int,earnings:int,washes:int,water_usage:int} + * @throws Exception + */ + public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array + { + global /** @var db $db */ + $db; + + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + if ($normalized_department_ids === []) { + return [ + 'quantity' => 0, + 'products' => 0, + 'earnings' => 0, + 'washes' => 0, + 'water_usage' => 0, + ]; + } + + [$date_start, $date_end] = $this->resolveDateRange($date, $date_to); + $department_ids_sql = implode(',', $normalized_department_ids); + $escaped_start = $db->escape_string($date_start); + $escaped_end = $db->escape_string($date_end); + + $sql = "SELECT COUNT(DISTINCT o.id) AS quantity, + COALESCE(SUM(oi.quantity), 0) AS products, + COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings, + COUNT(DISTINCT CASE WHEN p.is_wash = 1 THEN o.id END) AS washes + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + LEFT JOIN products p ON p.id = oi.product_id + WHERE o.department_id IN ($department_ids_sql) + AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end' + AND o.deleted_at IS NULL + AND oi.deleted_at IS NULL"; + + $result = $db->query($sql); + $row = is_object($result) ? $result->fetch_assoc() : null; + + return [ + 'quantity' => (int)($row['quantity'] ?? 0), + 'products' => (int)($row['products'] ?? 0), + 'earnings' => (int)round((float)($row['earnings'] ?? 0)), + 'washes' => (int)($row['washes'] ?? 0), + 'water_usage' => $this->getWaterUsageForDepartments($date, $normalized_department_ids, $date_to), + ]; + } + + /** + * @param array $department_ids + * @return array{completed:int,total:int} + * @throws Exception + */ + public function getBookingSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array + { + global /** @var db $db */ + $db; + + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + if ($normalized_department_ids === []) { + return [ + 'completed' => 0, + 'total' => 0, + ]; + } + + [$date_start, $date_end] = $this->resolveDateRange($date, $date_to); + $department_ids_sql = implode(',', $normalized_department_ids); + $escaped_start = $db->escape_string($date_start); + $escaped_end = $db->escape_string($date_end); + + $sql = "SELECT COUNT(*) AS total, + COALESCE(SUM(CASE WHEN order_id IS NOT NULL THEN 1 ELSE 0 END), 0) AS completed + FROM order_bookings + WHERE department IN ($department_ids_sql) + AND datetime BETWEEN '$escaped_start' AND '$escaped_end' + AND deleted_at IS NULL"; + + $result = $db->query($sql); + $row = is_object($result) ? $result->fetch_assoc() : null; + + return [ + 'completed' => (int)($row['completed'] ?? 0), + 'total' => (int)($row['total'] ?? 0), + ]; + } + + /** + * @param array $department_ids + * @param array $product_ids + * @return array + * @throws Exception + */ + public function getProductOverviewForDepartments(string $date, array $department_ids, array $product_ids, string $date_to = null): array + { + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + $normalized_product_ids = $this->normalizeDepartmentIds($product_ids); + + $overview = []; + foreach ($normalized_product_ids as $product_id) { + $quantity = 0; + $out_of = 0; + + foreach ($normalized_department_ids as $department_id) { + $quantity += $this->getProductsSoldOnDate($date, $department_id, $product_id, $date_to); + $out_of += (int)(new departments_o())->getTotalMaxAddonsInDepartment( + [$product_id], + $date, + $date_to ?? $date, + $department_id + ); + } + + $overview[$product_id] = [ + 'product_id' => (int)$product_id, + 'quantity' => (int)$quantity, + 'out_of' => (int)$out_of, + ]; + } + + return $overview; + } + + /** + * @param array $department_ids + * @return int + * @throws Exception + */ + public function getWaterUsageForDepartments(string $date, array $department_ids, string $date_to = null): int + { + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + $water_usage = 0; + + foreach ($normalized_department_ids as $department_id) { + $water_usage += $this->getTransactionsOnDateWaterUsage($date, $department_id, $date_to); + } + + return $water_usage; + } + + /** + * @param array $department_ids + * @return array + * @throws Exception + */ + public function getWashTransactionsForDepartments(string $date, array $department_ids, string $date_to = null): array + { + global /** @var db $db */ + $db; + + $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); + if ($normalized_department_ids === []) { + return []; + } + + [$date_start, $date_end] = $this->resolveDateRange($date, $date_to); + $department_ids_sql = implode(',', $normalized_department_ids); + $escaped_start = $db->escape_string($date_start); + $escaped_end = $db->escape_string($date_end); + + $sql = "SELECT DISTINCT o.id, o.department_id, o.created_at + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + JOIN products p ON p.id = oi.product_id + WHERE o.department_id IN ($department_ids_sql) + AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end' + AND o.deleted_at IS NULL + AND oi.deleted_at IS NULL + AND p.is_wash = 1 + ORDER BY o.created_at ASC"; + + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = [ + 'id' => (int)($row['id'] ?? 0), + 'department_id' => (int)($row['department_id'] ?? 0), + 'created_at' => (string)($row['created_at'] ?? ''), + ]; + } + + return $rows; + } + + /** + * @param array $values + * @return array + */ + private function normalizeDepartmentIds(array $values): array + { + $normalized = []; + foreach ($values as $value) { + $id = (int)$value; + if ($id > 0) { + $normalized[$id] = $id; + } + } + + return array_values($normalized); + } + + /** + * @return array{0:string,1:string} + * @throws Exception + */ + private function resolveDateRange(string $date, string $date_to = null): array + { + if ($date_to === null) { + $date_to = $date; + } + + $date_start = date('Y-m-d 00:00:00', strtotime($date)); + $date_end = date('Y-m-d 23:59:59', strtotime($date_to)); + + if ($date_start === false || $date_end === false) { + throw new Exception('Invalid date range provided'); + } + + return [$date_start, $date_end]; + } + +} diff --git a/services/nginx/app/objects/department_gates_o.php b/services/nginx/app/objects/department_gates_o.php index 91985872..ee107ee0 100644 --- a/services/nginx/app/objects/department_gates_o.php +++ b/services/nginx/app/objects/department_gates_o.php @@ -3,9 +3,11 @@ namespace objects; use classes\department_gate_config; +use classes\edge_gateway_manager; +use classes\bird; use classes\db; use classes\object_property; -use classes\selfserve; +use classes\slack; use Exception; use traits\db_object_t; @@ -167,4 +169,288 @@ class department_gates_o extends db return (new department_gates_o())->select((int)$exit_gate_data[0]['id']); } + + public static function normalizePhoneCandidate(mixed $candidate): ?array + { + if (is_array($candidate)) { + $phone = $candidate['phone_number'] ?? null; + if ($phone !== null) { + $country = self::extractCountryCodeFromPhoneNumber((string)$phone); + return self::normalizePhone((string)$phone, $country); + } + return null; + } + + if (is_string($candidate) && trim($candidate) !== '') { + return self::normalizePhone($candidate); + } + + return null; + } + + public static function normalizePhone(string $raw, ?int $defaultCountryCode = 45): ?array + { + $trimmed = trim($raw); + if ($trimmed === '') { + return null; + } + $digits = preg_replace('/\D+/', '', $trimmed); + if (!is_string($digits) || $digits === '') { + return null; + } + + $country = $defaultCountryCode; + $phone = $digits; + + if (str_starts_with($trimmed, '+')) { + $extractedCountry = self::extractCountryCodeFromPhoneNumber($trimmed); + if ($extractedCountry !== null) { + $country = $extractedCountry; + $phone = substr($digits, strlen((string)$extractedCountry)); + } elseif (strlen($digits) > 8) { + $country = (int)substr($digits, 0, 2); + $phone = substr($digits, 2); + } + } elseif ($country !== null && str_starts_with($digits, (string)$country) && strlen($digits) > 8) { + $phone = substr($digits, strlen((string)$country)); + } + + if ($country === null || $country <= 0 || $phone === '') { + return null; + } + + return [$country, (int)$phone]; + } + + public static function extractCountryCodeFromPhoneNumber(string $phone): ?int + { + if (str_starts_with($phone, '+45')) { + return 45; + } + if (str_starts_with($phone, '+46')) { + return 46; + } + if (str_starts_with($phone, '+47')) { + return 47; + } + if (str_starts_with($phone, '+358')) { + return 358; + } + if (str_starts_with($phone, '+49')) { + return 49; + } + if (str_starts_with($phone, '+44')) { + return 44; + } + if (str_starts_with($phone, '+1')) { + return 1; + } + return null; + } + + protected function matchesPhoneCallGateConfig(array $config): bool + { + return strtoupper(trim((string)($config['type'] ?? ''))) === 'PHONE_CALL'; + } + + protected function resolveBirdClient(): bird + { + return new bird(); + } + + protected function resolveSlackClient(): slack + { + return new slack(); + } + + protected function resolveEdgeGatewayManager(): edge_gateway_manager + { + return new edge_gateway_manager(); + } + + /** + * @return array> + */ + public function getPhoneCallDepartmentSummaries(): array + { + $summaries = []; + $gateRows = self::getFieldsWhere( + [ + 'deleted_at' => null, + ], + [ + 'id', + ], + ); + + foreach ($gateRows as $gateRow) { + $gate = (new department_gates_o())->select((int)$gateRow['id']); + if (!$gate->exists()) { + continue; + } + + $config = (array)$gate->config->value(); + if (!$this->matchesPhoneCallGateConfig($config)) { + continue; + } + + $departmentId = (int)$gate->department->value(); + if ($departmentId <= 0) { + continue; + } + + if (!isset($summaries[$departmentId])) { + $departmentRow = (new departments_o())->getDepartmentById($departmentId); + $summaries[$departmentId] = [ + 'department_id' => $departmentId, + 'department_name' => trim((string)($departmentRow['name'] ?? ('Afdeling ' . $departmentId))), + 'order_priority' => (int)($departmentRow['order_priority'] ?? PHP_INT_MAX), + 'has_entrance_gate' => false, + 'has_exit_gate' => false, + ]; + } + + if ((bool)$gate->is_entrance->value()) { + $summaries[$departmentId]['has_entrance_gate'] = true; + } + if ((bool)$gate->is_exit->value()) { + $summaries[$departmentId]['has_exit_gate'] = true; + } + } + + $summaries = array_values(array_filter($summaries, static function (array $summary): bool { + return ($summary['has_entrance_gate'] ?? false) === true + || ($summary['has_exit_gate'] ?? false) === true; + })); + + usort($summaries, static function (array $left, array $right): int { + $leftPriority = (int)($left['order_priority'] ?? PHP_INT_MAX); + $rightPriority = (int)($right['order_priority'] ?? PHP_INT_MAX); + if ($leftPriority !== $rightPriority) { + return $leftPriority <=> $rightPriority; + } + + return (int)($left['department_id'] ?? 0) <=> (int)($right['department_id'] ?? 0); + }); + + return $summaries; + } + + public function getEntrancePhoneCallGate(int $department_id): ?department_gates_o + { + $gates = $this->getDepartmentGates($department_id); + foreach ($gates as $gate) { + if (!$gate->exists() || !(bool)$gate->is_entrance->value()) { + continue; + } + + if ($this->matchesPhoneCallGateConfig((array)$gate->config->value())) { + return $gate; + } + } + + return null; + } + + public function getExitPhoneCallGate(int $department_id): ?department_gates_o + { + $gates = $this->getDepartmentGates($department_id); + foreach ($gates as $gate) { + if (!$gate->exists() || !(bool)$gate->is_exit->value()) { + continue; + } + + if ($this->matchesPhoneCallGateConfig((array)$gate->config->value())) { + return $gate; + } + } + + return null; + } + + public function openGate(): void + { + $this->requireSelected(); + $config = (array)$this->config->value(); + + if ($this->matchesPhoneCallGateConfig($config)) { + $this->openPhoneCallGate($config); + return; + } + + if (strtoupper(trim((string)($config['type'] ?? ''))) === 'RELAY') { + $this->openRelayBackedGate($config); + return; + } + + throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? '')); + } + + /** + * @param array $config + * @throws Exception + */ + protected function openPhoneCallGate(array $config): void + { + if (!isset($config['phone_number'])) { + throw new Exception('Phone number is required for PHONE_CALL gate type'); + } + + $normalized = self::normalizePhoneCandidate($config['phone_number']); + if ($normalized === null) { + throw new Exception('Invalid phone number for PHONE_CALL gate type'); + } + [$countryCode, $phone] = $normalized; + $ringTimeout = isset($config['call_duration_threshold']) ? (int)$config['call_duration_threshold'] : 30; + $client = $this->resolveBirdClient(); + try { + $client->callGatePreferringFlashCall($countryCode, $phone, $ringTimeout); + } catch (\Throwable $e) { + $this->resolveSlackClient()->send_message( + 'Failed to call gate for phone ' . $countryCode . ' ' . $phone . ': ' . $e->getMessage(), + 'Bird Voice Call Webhooks' + ); + throw new Exception('Failed to open gate relay via phone call', 0, $e); + } + } + + /** + * @param array $config + * @throws Exception + */ + protected function openRelayBackedGate(array $config): void + { + $relayId = trim((string)($config['relay_id'] ?? '')); + if ($relayId === '') { + throw new Exception('relay_id is required for RELAY gate type'); + } + + $departmentId = (int)$this->department->value(); + if ($departmentId <= 0) { + throw new Exception('Gate department is invalid'); + } + + $pulseSeconds = isset($config['pulse_seconds']) ? max(0, (int)$config['pulse_seconds']) : 1; + $manager = $this->resolveEdgeGatewayManager(); + $manager->dispatchRelaySwitch($departmentId, $relayId, true, [ + 'module' => 'department_gates', + 'reason' => 'Open relay-backed department gate', + 'gate_id' => (int)$this->id, + 'gate_name' => (string)$this->name->value(), + 'relay_id' => $relayId, + 'pulse_seconds' => $pulseSeconds, + ]); + + if ($pulseSeconds > 0) { + usleep($pulseSeconds * 1000000); + $manager->dispatchRelaySwitch($departmentId, $relayId, false, [ + 'module' => 'department_gates', + 'reason' => 'Close relay-backed department gate after pulse', + 'gate_id' => (int)$this->id, + 'gate_name' => (string)$this->name->value(), + 'relay_id' => $relayId, + 'pulse_seconds' => $pulseSeconds, + ]); + } + } } diff --git a/services/nginx/app/objects/department_lanes_o.php b/services/nginx/app/objects/department_lanes_o.php index 730a554d..eb371258 100644 --- a/services/nginx/app/objects/department_lanes_o.php +++ b/services/nginx/app/objects/department_lanes_o.php @@ -5,6 +5,7 @@ namespace objects; use classes\db; use classes\object_property; use classes\selfserve; +use classes\selfserve_schema_bootstrap; use Exception; use traits\db_object_t; @@ -17,7 +18,11 @@ class department_lanes_o extends db public object_property $relay_in_id; // The Shelly relay for the entrance port (if applicable) public object_property $relay_out_id; // The Shelly relay for the exit port (if applicable) public object_property $relay_machine_id; // The Shelly relay for the machine (if applicable) + public object_property $relay_machine_program_picker_id; // The Shelly relay for the machine program picker (if applicable) + public object_property $relay_machine_cleaner_id; // The Shelly relay for the machine cleaner (if applicable) public object_property $dynamic_image_id; // The dynamic image id for the lane (if applicable) + public object_property $machine_type_id; // The reusable self-serve machine type for the lane (if applicable) + public object_property $selfserve_enabled; // Whether this lane can be used for self-serve when department self-serve is enabled public object_property $created_at; public object_property $updated_at; public object_property $deleted_at; @@ -25,6 +30,7 @@ class department_lanes_o extends db public function structure(): void { + selfserve_schema_bootstrap::ensureTables(); $this->setTable('department_lanes'); } @@ -47,7 +53,7 @@ class department_lanes_o extends db * @return department_lanes_o * @throws Exception If the object was not created successfully */ - public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, int $dynamic_image_id = null): department_lanes_o + public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, string $relay_machine_program_picker_id = null, string $relay_machine_cleaner_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null, bool $selfserve_enabled = true): department_lanes_o { global /** @var db $db */ $db; @@ -63,12 +69,24 @@ class department_lanes_o extends db if (!is_null($relay_machine_id)) { $relay_machine_id = $db->escape_string($relay_machine_id); } + if (!is_null($relay_machine_program_picker_id)) { + $relay_machine_program_picker_id = $db->escape_string($relay_machine_program_picker_id); + } + if (!is_null($relay_machine_cleaner_id)) { + $relay_machine_cleaner_id = $db->escape_string($relay_machine_cleaner_id); + } if (!is_null($dynamic_image_id)) { $dynamic_image_id = (int)$dynamic_image_id; if ($dynamic_image_id <= 0) { throw new Exception('dynamic_image_id must be a positive integer'); } } + if (!is_null($machine_type_id)) { + $machine_type_id = (int)$machine_type_id; + if ($machine_type_id <= 0) { + throw new Exception('machine_type_id must be a positive integer'); + } + } // Add the object $tmp_id = self::add_object([ 'department' => $department, @@ -76,7 +94,11 @@ class department_lanes_o extends db ...(!is_null($relay_in_id) ? ['relay_in_id' => $relay_in_id] : []), // If the relay_in_id is null, it will be set to null in the database ...(!is_null($relay_out_id) ? ['relay_out_id' => $relay_out_id] : []), // If the relay_out_id is null, it will be set to null in the database ...(!is_null($relay_machine_id) ? ['relay_machine_id' => $relay_machine_id] : []), // If the relay_machine_id is null, it will be set to null in the database + ...(!is_null($relay_machine_program_picker_id) ? ['relay_machine_program_picker_id' => $relay_machine_program_picker_id] : []), + ...(!is_null($relay_machine_cleaner_id) ? ['relay_machine_cleaner_id' => $relay_machine_cleaner_id] : []), ...(!is_null($dynamic_image_id) ? ['dynamic_image_id' => $dynamic_image_id] : []), // If the dynamic_image_id is null, it will be set to null in the database + ...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []), + 'selfserve_enabled' => $selfserve_enabled ? 1 : 0, ]); $this->id = $tmp_id; self::getObjectProperties(); @@ -91,7 +113,11 @@ class department_lanes_o extends db $this->relay_in_id = new object_property($this->table, $this->id, 'relay_in_id', 'string', false); $this->relay_out_id = new object_property($this->table, $this->id, 'relay_out_id', 'string', false); $this->relay_machine_id = new object_property($this->table, $this->id, 'relay_machine_id', 'string', false); + $this->relay_machine_program_picker_id = new object_property($this->table, $this->id, 'relay_machine_program_picker_id', 'string', false); + $this->relay_machine_cleaner_id = new object_property($this->table, $this->id, 'relay_machine_cleaner_id', 'string', false); $this->dynamic_image_id = new object_property($this->table, $this->id, 'dynamic_image_id', 'int', false); + $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); + $this->selfserve_enabled = new object_property($this->table, $this->id, 'selfserve_enabled', 'bool', false, true); $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); @@ -104,6 +130,10 @@ class department_lanes_o extends db public function asArray(): array { + $status = (string)$this->getLaneStatus()->name; + $machine_status_audit = $this->getMachineStatusAudit(); + $selfserve_configuration_warnings = $this->getSelfServeConfigurationWarnings(); + return [ 'id' => (int)$this->id, 'department' => (int)$this->department->value(), @@ -111,15 +141,181 @@ class department_lanes_o extends db 'relay_in_id' => (string)$this->relay_in_id->value(), 'relay_out_id' => (string)$this->relay_out_id->value(), 'relay_machine_id' => (string)$this->relay_machine_id->value(), + 'relay_machine_program_picker_id' => (string)$this->relay_machine_program_picker_id->value(), + 'relay_machine_cleaner_id' => (string)$this->relay_machine_cleaner_id->value(), 'dynamic_image_id' => (function($v){ return $v === null ? null : (int)$v; })($this->dynamic_image_id->value()), + 'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()), + 'selfserve_enabled' => $this->isSelfServeEnabled(), // Status of the lane - 'status' => (string)$this->getLaneStatus()->name, + 'status' => $status, + 'machine_status_enabled' => self::isOperationalStatusName($status), + 'machine_status_audit' => $machine_status_audit, + 'machine_status_modified_at' => $machine_status_audit['modified_at'] ?? null, + 'machine_status_modified_by' => $machine_status_audit['modified_by_name'] ?? null, + 'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'] ?? null, + 'selfserve_configured' => $selfserve_configuration_warnings === [], + 'dognvask_configured' => $selfserve_configuration_warnings === [], + 'dognvask_configuration_warnings' => $selfserve_configuration_warnings, // Timestamps 'created_at' => (string)$this->created_at->value(), 'updated_at' => (string)$this->updated_at->value(), ]; } + private function getMachineStatusAudit(): ?array + { + try { + $lane = (new selfserve())->lane((int)$this->id); + if (!method_exists($lane, 'getLaneStatusAudit')) { + return null; + } + + $audit = $lane->getLaneStatusAudit(); + return is_array($audit) ? $audit : null; + } catch (\Throwable) { + return null; + } + } + + public function getSelfServeConfigurationWarnings(): array + { + self::requireSelected(); + + $required_fields = [ + 'relay_in_id' => 'Indgangsrelæ', + 'relay_out_id' => 'Udgangsrelæ', + 'relay_machine_id' => 'Maskinrelæ', + 'relay_machine_program_picker_id' => 'Programvælgerrelæ', + 'relay_machine_cleaner_id' => 'Vaskerelæ', + 'dynamic_image_id' => 'Maskinstatusbillede', + 'machine_type_id' => 'Maskintype', + ]; + + $warnings = []; + foreach ($required_fields as $field => $label) { + if ($this->hasConfiguredFieldValue($field)) { + continue; + } + + $warnings[] = [ + 'field' => $field, + 'label' => $label, + 'message' => $label . ' mangler', + ]; + } + + return $warnings; + } + + public function isSelfServeConfigured(): bool + { + return $this->getSelfServeConfigurationWarnings() === []; + } + + public static function isOperationalStatusName(string $status): bool + { + return in_array(strtoupper(trim($status)), ['AVAILABLE', 'OCCUPIED', 'RESERVED'], true); + } + + public function isSelfServeEnabled(): bool + { + self::requireSelected(); + try { + $value = $this->selfserve_enabled->value(); + } catch (\Throwable) { + return true; + } + + if ($value === null || $value === '') { + return true; + } + if (is_bool($value)) { + return $value; + } + if (is_numeric($value)) { + return (int)$value === 1; + } + + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + } + + public static function normalizeSelfServeEnabledValue(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_numeric($value)) { + return (int)$value === 1; + } + + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + } + + private function hasConfiguredFieldValue(string $field): bool + { + if (!isset($this->{$field}) || !is_object($this->{$field}) || !method_exists($this->{$field}, 'value')) { + return false; + } + + $value = $this->{$field}->value(); + if ($value === null) { + return false; + } + + if (is_string($value)) { + $value = trim($value); + return $value !== '' && $value !== '0' && strtolower($value) !== 'null'; + } + + if (is_numeric($value)) { + return (int)$value > 0; + } + + return (bool)$value; + } + + public static function disableSelfServeRelaysBestEffort(int $lane_id): void + { + if ($lane_id <= 0) { + return; + } + + try { + $lane = (new selfserve())->lane($lane_id); + } catch (\Throwable) { + return; + } + + self::setLaneRelayOffIfConfigured($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { + $lane->setMachineProgramPickerRelayStatusHard(false); + }); + self::setLaneRelayOffIfConfigured($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { + $lane->setMachineCleanerRelayStatusHard(false); + }); + self::setLaneRelayOffIfConfigured($lane, 'relay_machine_id', static function () use ($lane): void { + $lane->setMachineRelayStatusHard(false); + }); + } + + private static function setLaneRelayOffIfConfigured(object $lane, string $relay_property, callable $callback): void + { + if ( + empty($lane->department_lane) + || !isset($lane->department_lane->{$relay_property}) + || !is_object($lane->department_lane->{$relay_property}) + || !method_exists($lane->department_lane->{$relay_property}, 'value') + || trim((string)$lane->department_lane->{$relay_property}->value()) === '' + ) { + return; + } + + try { + $callback(); + } catch (\Throwable) { + // Best effort only; toggling lane self-serve should not fail on relay I/O. + } + } + /** * Get the self-serve lane products available for this lane * @return array An array of product ids available for this lane @@ -152,4 +348,4 @@ class department_lanes_o extends db } return $lanes; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/department_selfserve_conditions_o.php b/services/nginx/app/objects/department_selfserve_conditions_o.php index 70e1d311..dc560126 100644 --- a/services/nginx/app/objects/department_selfserve_conditions_o.php +++ b/services/nginx/app/objects/department_selfserve_conditions_o.php @@ -4,6 +4,7 @@ namespace objects; use classes\db; use classes\object_property; +use classes\selfserve_schema_bootstrap; use Exception; use traits\db_object_t; @@ -11,10 +12,19 @@ class department_selfserve_conditions_o extends db { use db_object_t; + /** + * Canonical relationship model: + * - A condition is a reusable logical node. + * - `condition_id` on this entity is a parent condition id, enabling nested condition trees. + * - Questions may reference a condition as their gate. + * - Tasks may reference a question as their gate (legacy stored in tasks.condition_id). + */ + public object_property $department; // The department id public object_property $lane; // The lane id public object_property $product; // The product id - public object_property $condition_id; // The condition id (optional) + public object_property $machine_type_id; // The reusable machine type id (nullable, preferred over department/lane/product) + public object_property $condition_id; // Parent condition id for nesting/grouping (nullable) public object_property $name; // The condition name public object_property $description; // The task description public object_property $created_at; @@ -24,6 +34,7 @@ class department_selfserve_conditions_o extends db public function structure(): void { + selfserve_schema_bootstrap::ensureTables(); $this->setTable('department_selfserve_conditions'); } @@ -34,11 +45,11 @@ class department_selfserve_conditions_o extends db * @param int $product The product id * @param string $name The condition name * @param string $description The condition description - * @param int|null $condition_id The condition id (optional) + * @param int|null $condition_id Optional parent condition id for nesting/grouping. * @return department_selfserve_conditions_o * @throws Exception If the object was not created successfully */ - public function add(int $department, int $lane, int $product, string $name, string $description, int $condition_id = null): department_selfserve_conditions_o + public function add(int $department, int $lane, int $product, string $name, string $description, int $condition_id = null, ?int $machine_type_id = null): department_selfserve_conditions_o { global /** @var db $db */ $db; @@ -51,12 +62,16 @@ class department_selfserve_conditions_o extends db if (!is_null($condition_id)) { $condition_id = (int)$condition_id; } + if (!is_null($machine_type_id)) { + $machine_type_id = (int)$machine_type_id; + } // Add the object $tmp_id = self::add_object([ 'department' => $department, 'lane' => $lane, 'product' => $product, + ...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []), 'name' => $name, 'description' => $description, ...(!is_null($condition_id) ? ['condition_id' => $condition_id] : []), @@ -74,6 +89,7 @@ class department_selfserve_conditions_o extends db $this->department = new object_property($this->table, $this->id, 'department', 'int', false); $this->lane = new object_property($this->table, $this->id, 'lane', 'int', false); $this->product = new object_property($this->table, $this->id, 'product', 'int', false); + $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); $this->condition_id = new object_property($this->table, $this->id, 'condition_id', 'int', false); $this->name = new object_property($this->table, $this->id, 'name', 'string', false); $this->description = new object_property($this->table, $this->id, 'description', 'string', false); @@ -94,6 +110,7 @@ class department_selfserve_conditions_o extends db 'department' => (int)$this->department->value(), 'lane' => (int)$this->lane->value(), 'product' => (int)$this->product->value(), + 'machine_type_id' => is_null($this->machine_type_id->value()) ? null : (int)$this->machine_type_id->value(), 'condition_id' => is_null($this->condition_id->value()) ? null : (int)$this->condition_id->value(), 'name' => (string)$this->name->value(), 'description' => (string)$this->description->value(), @@ -102,4 +119,22 @@ class department_selfserve_conditions_o extends db 'updated_at' => (string)$this->updated_at->value(), ]; } -} \ No newline at end of file + + public function getConditionsForMachineType(int $machineTypeId): array + { + return self::getFieldsWhere([ + 'machine_type_id' => $machineTypeId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']); + } + + public function getLegacyConditionsForLaneProduct(int $departmentId, int $laneId, int $productId): array + { + return self::getFieldsWhere([ + 'department' => $departmentId, + 'lane' => $laneId, + 'product' => $productId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']); + } +} diff --git a/services/nginx/app/objects/department_selfserve_questions_o.php b/services/nginx/app/objects/department_selfserve_questions_o.php index 06bdf1a5..653fcb57 100644 --- a/services/nginx/app/objects/department_selfserve_questions_o.php +++ b/services/nginx/app/objects/department_selfserve_questions_o.php @@ -9,14 +9,19 @@ use traits\db_object_t; class department_selfserve_questions_o extends db { - use db_object_t { - delete as trait_delete; - } + use db_object_t; public object_property $department; // The department id public object_property $lane; // The lane id public object_property $product; // The product id - public object_property $condition_id; // The question condition object id (if applicable) + /** + * Canonical relationship model: + * - Question `condition_id` references `department_selfserve_conditions.id` and means + * "show/ask this question only when this parent condition is met". + * - Tasks are linked to questions (not conditions); tasks use adapter methods in tasks object + * while persisting to legacy `department_selfserve_tasks.condition_id`. + */ + public object_property $condition_id; // Parent condition id that gates this question (nullable) public object_property $question; // The question text public object_property $description; // The question description public object_property $order_priority; // The order priority of the question (lower numbers are shown first) @@ -37,7 +42,7 @@ class department_selfserve_questions_o extends db * @param int $product The product id * @param string $question The question text * @param string $description The question description - * @param int|null $condition_id The question condition_id (if applicable) + * @param int|null $condition_id Optional parent condition id that gates this question. * @param int $order_priority The order priority of the question (lower numbers are shown first) * @return department_selfserve_questions_o * @throws Exception If the object was not created successfully @@ -96,9 +101,17 @@ class department_selfserve_questions_o extends db self::requireSelected(); global $db; $id = (int)$this->id; + // Deleting a question detaches dependent tasks by clearing the legacy + // `department_selfserve_tasks.condition_id` link (which semantically stores question id). $sql = "UPDATE department_selfserve_tasks SET condition_id = NULL WHERE condition_id = $id"; $db->query($sql); - $this->trait_delete(); + + if (self::columnsExist(['deleted_at'])) { + self::update(['deleted_at' => date('Y-m-d H:i:s')]); + return; + } + + self::deletePermanently(); } public function asArray(): array @@ -117,4 +130,24 @@ class department_selfserve_questions_o extends db 'updated_at' => (string)$this->updated_at->value(), ]; } -} \ No newline at end of file + + public function getSharedQuestions(): array + { + return self::getFieldsWhere([ + 'department' => 0, + 'lane' => 0, + 'product' => 0, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']); + } + + public function getLegacyQuestionsForLaneProduct(int $departmentId, int $laneId, int $productId): array + { + return self::getFieldsWhere([ + 'department' => $departmentId, + 'lane' => $laneId, + 'product' => $productId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']); + } +} diff --git a/services/nginx/app/objects/department_selfserve_tasks_o.php b/services/nginx/app/objects/department_selfserve_tasks_o.php index 307f9513..3e077698 100644 --- a/services/nginx/app/objects/department_selfserve_tasks_o.php +++ b/services/nginx/app/objects/department_selfserve_tasks_o.php @@ -4,24 +4,36 @@ namespace objects; use classes\db; use classes\object_property; +use classes\selfserve_schema_bootstrap; use Exception; -use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\helpers\selfserve_lane_services; +use modules\selfserve\helpers\selfserve_task_gate_type; use traits\db_object_t; class department_selfserve_tasks_o extends db { use db_object_t; + /** + * Canonical relationship model for self-serve entities: + * - Conditions can be nested via `department_selfserve_conditions.condition_id` (parent condition id). + * - Questions can optionally be gated by a condition via `department_selfserve_questions.condition_id` (parent condition id). + * - Tasks can optionally be gated by a question. For backward compatibility this is stored in + * `department_selfserve_tasks.condition_id`, but semantically this is a question reference. + */ + public object_property $department; // The department id public object_property $lane; // The lane id public object_property $product; // The product id - public object_property $condition_id; // The question id (if conditional task) + public object_property $machine_type_id; // The reusable machine type id (nullable, preferred over department/lane/product) + public object_property $condition_id; // Legacy column name: stores question id that gates this task (nullable) + public object_property $gate_type; // Canonical gate type: ALWAYS|CONDITION|QUESTION + public object_property $gate_ref_id; // Canonical gate reference id (nullable) public object_property $task; // The task public object_property $description; // The task description public object_property $order_priority; // The order priority of the task (lower numbers are shown first) public object_property $services; // The services that the task enables (json), this is used to enable machine wash. - public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of button ids) + public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of mapped button ids) public object_property $dynamic_images_vehicle_type; // The vehicle type selection override on the machine, used by dynamicimages - int or null if not applicable. public object_property $created_at; public object_property $updated_at; @@ -46,6 +58,7 @@ class department_selfserve_tasks_o extends db public function structure(): void { + selfserve_schema_bootstrap::ensureTables(); $this->setTable('department_selfserve_tasks'); } @@ -54,17 +67,31 @@ class department_selfserve_tasks_o extends db * @param int $department The department id * @param int $lane The lane id * @param int $product The product id - * @param int|null $condition_id The question id (if conditional task) + * @param int|null $question_id Optional question id that gates this task. Persisted in legacy `condition_id` column. * @param string $task The task text * @param string $description The task description * @param int $order_priority The order priority of the task (lower numbers are shown first) * @param selfserve_lane_services[]|string[]|null $services The services that the task enables (stored as JSON array of service names). May be an array of enum cases or names. - * @param array|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts array of ints or a parsable string/JSON. + * @param array|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts program integers plus "reset" and "start". * @param int|null $dynamic_images_vehicle_type Optional vehicle type selection override for the machine UI. Integer >= 0 or null. * @return department_selfserve_tasks_o * @throws Exception If the object was not created successfully */ - public function add(int $department, int $lane, int $product, int|null $condition_id, string $task, string $description, int $order_priority = 0, ?array $services = null, array|string|null $buttons = null, int|null $dynamic_images_vehicle_type = null): self + public function add( + int $department, + int $lane, + int $product, + int|null $question_id, + string $task, + string $description, + int $order_priority = 0, + ?array $services = null, + array|string|null $buttons = null, + int|null $dynamic_images_vehicle_type = null, + ?int $machine_type_id = null, + ?selfserve_task_gate_type $gate_type = null, + ?int $gate_ref_id = null, + ): self { global /** @var db $db */ $db; @@ -72,8 +99,14 @@ class department_selfserve_tasks_o extends db $department = (int)$department; $lane = (int)$lane; $product = (int)$product; - if (!is_null($condition_id)) { - $condition_id = (int)$condition_id; + if (!is_null($question_id)) { + $question_id = (int)$question_id; + } + if (!is_null($machine_type_id)) { + $machine_type_id = (int)$machine_type_id; + } + if (!is_null($gate_ref_id)) { + $gate_ref_id = (int)$gate_ref_id; } $task = $db->escape_string($task); $description = $db->escape_string($description); @@ -119,12 +152,25 @@ class department_selfserve_tasks_o extends db } } + if ($gate_type === null) { + $resolvedGate = self::resolveLegacyGateDefinition($question_id); + $gate_type = $resolvedGate['gate_type']; + $gate_ref_id = $resolvedGate['gate_ref_id']; + } elseif ($gate_type === selfserve_task_gate_type::ALWAYS) { + $gate_ref_id = null; + } elseif ($gate_ref_id === null || $gate_ref_id <= 0) { + throw new Exception('gate_ref_id must be provided for CONDITION and QUESTION task gate types'); + } + // Add the object $tmp_id = self::add_object([ 'department' => $department, 'lane' => $lane, 'product' => $product, - ...(!is_null($condition_id) ? ['condition_id' => $condition_id] : []), // If the question is null, it will be set to null in the database + ...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []), + ...(!is_null($question_id) ? ['condition_id' => $question_id] : []), // Legacy column name; contains the gating question id + 'gate_type' => $gate_type->value, + 'gate_ref_id' => $gate_ref_id, 'task' => $task, 'description' => $description, 'order_priority' => $order_priority, @@ -144,7 +190,10 @@ class department_selfserve_tasks_o extends db $this->department = new object_property($this->table, $this->id, 'department', 'int', false); $this->lane = new object_property($this->table, $this->id, 'lane', 'int', false); $this->product = new object_property($this->table, $this->id, 'product', 'int', false); + $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); $this->condition_id = new object_property($this->table, $this->id, 'condition_id', 'int', true); + $this->gate_type = new object_property($this->table, $this->id, 'gate_type', 'string', false); + $this->gate_ref_id = new object_property($this->table, $this->id, 'gate_ref_id', 'int', true); $this->task = new object_property($this->table, $this->id, 'task', 'string', false); $this->description = new object_property($this->table, $this->id, 'description', 'string', false); $this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false); @@ -168,7 +217,10 @@ class department_selfserve_tasks_o extends db 'department' => (int)$this->department->value(), 'lane' => (int)$this->lane->value(), 'product' => (int)$this->product->value(), + 'machine_type_id' => is_null($this->machine_type_id->value()) ? null : (int)$this->machine_type_id->value(), 'condition_id' => is_null($this->condition_id->value()) ? null : (int)$this->condition_id->value(), + 'gate_type' => (string)($this->gate_type->value() ?? selfserve_task_gate_type::ALWAYS->value), + 'gate_ref_id' => is_null($this->gate_ref_id->value()) ? null : (int)$this->gate_ref_id->value(), 'task' => (string)$this->task->value(), 'description' => (string)$this->description->value(), 'order_priority' => (int)$this->order_priority->value(), @@ -181,6 +233,96 @@ class department_selfserve_tasks_o extends db ]; } + /** + * Adapter for canonical naming. + * @return int|null Question id that gates this task. + */ + public function getQuestionId(): ?int + { + $value = $this->condition_id->value(); + return is_null($value) ? null : (int)$value; + } + + /** + * Adapter for canonical naming while persisting to legacy `condition_id` column. + * @param int|null $question_id + * @return void + */ + public function setQuestionId(?int $question_id): void + { + $this->condition_id->set(is_null($question_id) ? null : (int)$question_id); + $resolvedGate = self::resolveLegacyGateDefinition($question_id); + $this->gate_type->set($resolvedGate['gate_type']->value); + $this->gate_ref_id->set($resolvedGate['gate_ref_id']); + } + + public function getTasksForMachineType(int $machineTypeId): array + { + return self::getFieldsWhere([ + 'machine_type_id' => $machineTypeId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']); + } + + public function getLegacyTasksForLaneProduct(int $departmentId, int $laneId, int $productId): array + { + return self::getFieldsWhere([ + 'department' => $departmentId, + 'lane' => $laneId, + 'product' => $productId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']); + } + + /** + * Normalize external gate_type inputs. + * @param mixed $input + * @return selfserve_task_gate_type + * @throws Exception + */ + public static function normalizeGateTypeInput(mixed $input): selfserve_task_gate_type + { + if ($input instanceof selfserve_task_gate_type) { + return $input; + } + if (is_string($input)) { + $normalized = strtoupper(trim($input)); + $gateType = selfserve_task_gate_type::tryFrom($normalized); + if ($gateType !== null) { + return $gateType; + } + } + throw new Exception('Invalid gate_type. Expected ALWAYS, CONDITION, or QUESTION.'); + } + + /** + * Resolve a typed gate from legacy `condition_id` input. + * @param int|null $legacyGateId + * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null} + */ + public static function resolveLegacyGateDefinition(?int $legacyGateId): array + { + if ($legacyGateId === null || $legacyGateId <= 0) { + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + $condition = (new department_selfserve_conditions_o())->select((int)$legacyGateId); + if ($condition->exists()) { + return [ + 'gate_type' => selfserve_task_gate_type::CONDITION, + 'gate_ref_id' => (int)$legacyGateId, + ]; + } + + return [ + 'gate_type' => selfserve_task_gate_type::QUESTION, + 'gate_ref_id' => (int)$legacyGateId, + ]; + } + /** * Normalize input for dynamic_images_vehicle_type into a nullable non-negative integer. * Accepts int, string (numeric), null, or empty string (treated as null). @@ -216,13 +358,13 @@ class department_selfserve_tasks_o extends db return $val; } /** - * Normalize mixed input for buttons into an array of integer IDs (>= 0). + * Normalize mixed input for buttons into an array of mapped button IDs. * Accepts: * - array of ints/strings * - JSON array string * - comma-separated string * @param mixed $input - * @return array + * @return array * @throws Exception */ public static function normalizeButtonsInput(mixed $input): array @@ -237,14 +379,22 @@ class department_selfserve_tasks_o extends db } } if (!is_array($raw)) { - throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of integers.'); + throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of mapped button ids.'); } $ids = []; foreach ($raw as $btn) { + if (is_string($btn)) { + $trimmed = trim($btn); + $specialButton = strtolower($trimmed); + if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') { + $ids[] = $specialButton; + continue; + } + } if (is_int($btn)) { $val = $btn; - } elseif (is_string($btn) && ctype_digit($btn)) { - $val = (int)$btn; + } elseif (is_string($btn) && ctype_digit(trim($btn))) { + $val = (int)trim($btn); } elseif (is_numeric($btn) && (int)$btn == $btn) { $val = (int)$btn; } else { @@ -256,7 +406,16 @@ class department_selfserve_tasks_o extends db $ids[] = $val; } // de-duplicate while preserving order - $ids = array_values(array_unique($ids)); - return $ids; + $deduped = []; + $seen = []; + foreach ($ids as $id) { + $key = (is_int($id) ? 'int:' : 'string:') . (string)$id; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $deduped[] = $id; + } + return $deduped; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php b/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php index 7b13c157..574fab23 100644 --- a/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php +++ b/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php @@ -53,8 +53,10 @@ class department_selfserve_vehicle_conditions_o extends db $question = (int)$question; $value = (bool)$value; $customer_id = $customer_id !== null ? (int)$customer_id : null; - // Remove any existing entry for the same department, lane, reg and question - $sql = "DELETE FROM $this->table WHERE department = $department AND lane = $lane AND reg = '$reg' AND question = $question"; + // Remove any existing entry for the same department, lane, customer, reg and question. + // Saved answers must not bleed across customers that temporarily wash the same plate. + $customer_filter = $customer_id === null ? 'customer_id IS NULL' : 'customer_id = ' . $customer_id; + $sql = "DELETE FROM $this->table WHERE department = $department AND lane = $lane AND $customer_filter AND reg = '$reg' AND question = $question"; $db->query($sql); // Add the object $tmp_id = self::add_object([ @@ -104,4 +106,26 @@ class department_selfserve_vehicle_conditions_o extends db 'deleted_at' => is_null($this->deleted_at->value()) ? null : (string)$this->deleted_at->value(), ]; } -} \ No newline at end of file + + public function getAnswerMapForVehicle(int $departmentId, int $laneId, string $reg, ?int $customerId = null): array + { + if ($customerId === null || $customerId <= 0) { + return []; + } + + $rows = self::getFieldsWhere([ + 'department' => $departmentId, + 'lane' => $laneId, + 'customer_id' => $customerId, + 'reg' => selfserve::standardize_registration($reg), + 'deleted_at' => null, + ], ['question', 'value']); + + $answers = []; + foreach ($rows as $row) { + $answers[(int)$row['question']] = (bool)$row['value']; + } + + return $answers; + } +} diff --git a/services/nginx/app/objects/departments_o.php b/services/nginx/app/objects/departments_o.php index b2b81ce6..9baaea50 100644 --- a/services/nginx/app/objects/departments_o.php +++ b/services/nginx/app/objects/departments_o.php @@ -3,6 +3,7 @@ namespace objects; use classes\db; +use classes\departments_schema_bootstrap; use classes\object_property; use classes\slack; use classes\stripe; @@ -20,6 +21,7 @@ class departments_o extends db public department_variables_o $variables; // The department variables object public object_property $dimension; // The dimension of the department public object_property $visible; // The visibility of the department + public object_property $archived; // Whether the department is archived public object_property $branding; // The branding of the department public object_property $longitude; // The longitude of the department (Can be null) public object_property $latitude; // The latitude of the department (Can be null) @@ -29,6 +31,7 @@ class departments_o extends db public function structure(): void { + departments_schema_bootstrap::ensureTables(); $this->setTable('departments'); } @@ -103,6 +106,7 @@ class departments_o extends db $this->dimension = new object_property($this->table, $this->id, 'dimension', 'int', false); $this->branding = new object_property($this->table, $this->id, 'branding', 'int', false); $this->visible = new object_property($this->table, $this->id, 'visible', 'int', false); + $this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false); $this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false); $this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false); $this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false); @@ -156,6 +160,7 @@ class departments_o extends db 'description' => $department['description'], 'id' => $department['id'], 'visible' => $department['visible'], + 'archived' => $department['archived'] ?? 0, ]; }, $departments); } @@ -446,22 +451,39 @@ class departments_o extends db * Send department period statistics to Slack * @param string $start_date (YYYY-MM-DD) * @param string $end_date (YYYY-MM-DD) - * @return void + * @param int[] $product_ids The product ids to include in the statistics (e.g. [25, [23, 24], 22, 27, 21, 26]) + * @param bool $return_as_array Whether to return the results as an array or not + * @return string[] * @throws Exception If the department is not selected */ - public function sendPeriodStatisticsToSlack(string $start_date, string $end_date): void + public function sendPeriodStatisticsToSlack(string $start_date, string $end_date, array $product_ids = [ + 25, + [23, 24], // Used to merge two products into one percentage (Spot Free) + 22, + 27, + 21, + 26 + ], bool $return_as_array = false): array { self::requireSelected(); + // Ensure only one message is sent a week using redis cache + $cacheKey = "department_{$this->id}_weekly_statistics_sent_v2"; + // Get time until next monday at 00:00:00 + $nextMonday = strtotime('next monday'); + $cacheDuration = $nextMonday - time(); + $lastSent = redis->get($cacheKey); + // If null or more than a week has passed since the last message, send a new message + $sendNow = match (true) { + $lastSent === null => true, + default => (time() - $lastSent) >= $cacheDuration + }; + if (!$sendNow) { + return $return_as_array ? ["A weekly statistics message has already been sent for this department."] : ["A weekly statistics message has already been sent for this department."]; + } + $this->cache($cacheKey, $cacheDuration); + $this->setCachedExpiration($cacheKey, $cacheDuration); // Configuration $department_id = $this->id; - $product_ids = [ - 25, - [23, 24], // Used to merge two products into one percentage (Spot Free) - 22, - 27, - 21, - 26 - ]; $date_end = date('Y-m-d 23:59:59', strtotime($end_date)); // End date at 23:59:59 $date_start = date('Y-m-d 00:00:00', strtotime($start_date)); // Start date at 00:00:00 /** @@ -487,6 +509,10 @@ class departments_o extends db $wash_count = (new orders_o())->countWashesInDateRange($date_start, $date_end, $department_id); $analytics = $this->analyzeAddonSalesData($product_ids, $date_start, $date_end, $department_id, $max_addons, $sold_addons, $percentages); $tmp .= "Washes: $wash_count\n" . implode('', $analytics); + // If it should be returned as an array, return it as an array instead of sending it to Slack + if ($return_as_array) { + return explode("\n", trim($tmp)); + } // Check if there's a custom webhook for the department $slack = new slack(); @@ -497,6 +523,8 @@ class departments_o extends db // Set the custom webhook for the department $slack->send_webhook_message($tmp, $this->slack_webhook->value()); } + + return explode("\n", trim($tmp)); } /** @@ -686,7 +714,8 @@ class departments_o extends db * @param string $date_end (YYYY-MM-DD) * @param int[] $department_ids * @param int[] $product_ids - * @return void + * @param bool $return_as_array Whether to return the results as an array or not + * @return null|array * @throws Exception */ public function sendSlackInternalStatisticNotification(string $date_start, string $date_end, array $department_ids, array $product_ids = [ @@ -696,7 +725,7 @@ class departments_o extends db 27, 21, 26 - ]): void + ], bool $return_as_array = false): null|array { if (empty($department_ids)) { throw new Exception('No department ids provided for the Slack internal statistic notification.'); @@ -810,8 +839,25 @@ class departments_o extends db } $tmp .= "> - Total: " . number_format($total_percentage, 2) . "%\n"; } + $array_of_results = [ + "daily_management" => [], + "departments" => [] + ]; + // Add $tmp to the daily management message + $array_of_results['daily_management'][] = $tmp; + // Send the message to the internal Slack webhook $slack = new slack(); - $slack->send_webhook_message($tmp, (new departments_o())->select(10)->slack_webhook->value()); + if (!$return_as_array) { + $slack->send_webhook_message($tmp, (new departments_o())->select(10)->slack_webhook->value()); + } + // Send a department-specific message for each internal department with the percentage of addons sold + foreach ( $department_ids as $department_id ) { + $department = (new departments_o())->select($department_id); + $tmp_dept = $department->sendPeriodStatisticsToSlack($date_start, $date_end, $product_ids, $return_as_array); + $array_of_results['departments'][$department_id] = $tmp_dept; + } + + return $array_of_results; } } diff --git a/services/nginx/app/objects/economic_module_orders.php b/services/nginx/app/objects/economic_module_orders.php index be2c31b7..7745d134 100644 --- a/services/nginx/app/objects/economic_module_orders.php +++ b/services/nginx/app/objects/economic_module_orders.php @@ -38,6 +38,61 @@ class economic_module_orders extends db return $this; } + /** + * Ensure rows exist for the provided order IDs using one batched INSERT IGNORE. + * + * @param int[] $orderIds + */ + public function ensureRowsForOrderIds(array $orderIds): void + { + $orderIds = $this->normalizeOrderIds($orderIds); + if (empty($orderIds)) { + return; + } + + $this->performEnsureRowsInsert($orderIds); + } + + /** + * Get economic module payload for many orders as an id-keyed map. + * + * @param int[] $orderIds + * @return array + */ + public function getByOrderIdsAsArray(array $orderIds): array + { + $orderIds = $this->normalizeOrderIds($orderIds); + if (empty($orderIds)) { + return []; + } + + $rows = $this->fetchRowsByOrderIds($orderIds); + $byId = []; + foreach ($rows as $row) { + $id = (int)($row['id'] ?? 0); + if ($id <= 0) { + continue; + } + $byId[$id] = [ + 'id' => $id, + 'invoice_draft_id' => isset($row['invoice_draft_id']) && $row['invoice_draft_id'] !== null ? (int)$row['invoice_draft_id'] : null, + 'invoice_id' => isset($row['invoice_id']) && $row['invoice_id'] !== null ? (int)$row['invoice_id'] : null, + ]; + } + + foreach ($orderIds as $orderId) { + if (!isset($byId[$orderId])) { + $byId[$orderId] = [ + 'id' => $orderId, + 'invoice_draft_id' => null, + 'invoice_id' => null, + ]; + } + } + + return $byId; + } + public function getObjectProperties(): void { $this->economic_invoice_draft_id = new object_property($this->table, $this->id, 'invoice_draft_id', 'int', true); @@ -91,4 +146,38 @@ class economic_module_orders extends db } return null; } -} \ No newline at end of file + + /** + * @param int[] $orderIds + * @return int[] + */ + protected function normalizeOrderIds(array $orderIds): array + { + $orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn(int $id): bool => $id > 0))); + sort($orderIds); + return $orderIds; + } + + /** + * @param int[] $orderIds + */ + protected function performEnsureRowsInsert(array $orderIds): void + { + global $db; + $values = implode(',', array_map(static fn(int $id): string => "($id)", $orderIds)); + $sql = "INSERT IGNORE INTO $this->table (id) VALUES $values"; + $db->query($sql); + } + + /** + * @param int[] $orderIds + * @return array> + */ + protected function fetchRowsByOrderIds(array $orderIds): array + { + return $this->getFieldsWhereIn( + ['id' => $orderIds], + ['id', 'invoice_draft_id', 'invoice_id'] + ); + } +} diff --git a/services/nginx/app/objects/edge_gateway_audit_logs_o.php b/services/nginx/app/objects/edge_gateway_audit_logs_o.php new file mode 100644 index 00000000..fd88d313 --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_audit_logs_o.php @@ -0,0 +1,61 @@ +setTable('edge_gateway_audit_logs'); + } + + public function getObjectProperties(): void + { + $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->action = new object_property($this->table, $this->id, 'action', 'string', false); + $this->actor_user_id = new object_property($this->table, $this->id, 'actor_user_id', 'int', false); + $this->actor_type = new object_property($this->table, $this->id, 'actor_type', 'string', false); + $this->severity = new object_property($this->table, $this->id, 'severity', 'string', 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, + 'gateway_id' => $this->gateway_id->value() === null ? null : (int)$this->gateway_id->value(), + 'department_id' => $this->department_id->value() === null ? null : (int)$this->department_id->value(), + 'action' => (string)$this->action->value(), + 'actor_user_id' => $this->actor_user_id->value() === null ? null : (int)$this->actor_user_id->value(), + 'actor_type' => (string)$this->actor_type->value(), + 'severity' => (string)$this->severity->value(), + 'context' => (array)($this->context_json->value() ?? []), + 'created_at' => (string)$this->created_at->value(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateway_claim_tokens_o.php b/services/nginx/app/objects/edge_gateway_claim_tokens_o.php new file mode 100644 index 00000000..c3cc69fa --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_claim_tokens_o.php @@ -0,0 +1,65 @@ +setTable('edge_gateway_claim_tokens'); + } + + public function getObjectProperties(): void + { + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->label = new object_property($this->table, $this->id, 'label', 'string', false); + $this->token_hash = new object_property($this->table, $this->id, 'token_hash', 'string', false); + $this->created_by = new object_property($this->table, $this->id, 'created_by', 'int', false); + $this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false); + $this->used_at = new object_property($this->table, $this->id, 'used_at', 'string', 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, + 'department_id' => (int)$this->department_id->value(), + 'label' => $this->label->value() === null ? null : (string)$this->label->value(), + 'created_by' => $this->created_by->value() === null ? null : (int)$this->created_by->value(), + 'expires_at' => (string)$this->expires_at->value(), + 'used_at' => $this->used_at->value() === null ? null : (string)$this->used_at->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(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateway_command_jobs_o.php b/services/nginx/app/objects/edge_gateway_command_jobs_o.php new file mode 100644 index 00000000..5f7db50e --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_command_jobs_o.php @@ -0,0 +1,78 @@ +setTable('edge_gateway_command_jobs'); + } + + public function getObjectProperties(): void + { + $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); + $this->command_type = new object_property($this->table, $this->id, 'command_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->response_json = new object_property($this->table, $this->id, 'response_json', 'json', false); + $this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false); + $this->correlation_id = new object_property($this->table, $this->id, 'correlation_id', 'string', 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(), + 'command_type' => (string)$this->command_type->value(), + 'status' => (string)$this->status->value(), + 'request' => (array)($this->request_json->value() ?? []), + 'response' => (array)($this->response_json->value() ?? []), + 'delivery' => (array)($this->delivery_json->value() ?? []), + 'correlation_id' => (string)$this->correlation_id->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(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateway_device_inventory_o.php b/services/nginx/app/objects/edge_gateway_device_inventory_o.php new file mode 100644 index 00000000..a941cb00 --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_device_inventory_o.php @@ -0,0 +1,72 @@ +setTable('edge_gateway_device_inventory'); + } + + public function getObjectProperties(): void + { + $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); + $this->device_id = new object_property($this->table, $this->id, 'device_id', 'string', false); + $this->local_ip = new object_property($this->table, $this->id, 'local_ip', 'string', false); + $this->model = new object_property($this->table, $this->id, 'model', 'string', false); + $this->channel_count = new object_property($this->table, $this->id, 'channel_count', 'int', false); + $this->capabilities_json = new object_property($this->table, $this->id, 'capabilities_json', 'json', false); + $this->online = new object_property($this->table, $this->id, 'online', 'bool', false); + $this->last_seen_at = new object_property($this->table, $this->id, 'last_seen_at', 'string', 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(), + 'device_id' => (string)$this->device_id->value(), + 'local_ip' => $this->local_ip->value() === null ? null : (string)$this->local_ip->value(), + 'model' => $this->model->value() === null ? null : (string)$this->model->value(), + 'channel_count' => (int)$this->channel_count->value(), + 'capabilities' => (array)($this->capabilities_json->value() ?? []), + 'online' => (bool)$this->online->value(), + 'last_seen_at' => $this->last_seen_at->value() === null ? null : (string)$this->last_seen_at->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(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateway_log_entries_o.php b/services/nginx/app/objects/edge_gateway_log_entries_o.php new file mode 100644 index 00000000..26a08434 --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_log_entries_o.php @@ -0,0 +1,61 @@ +setTable('edge_gateway_log_entries'); + } + + public function getObjectProperties(): void + { + $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->level = new object_property($this->table, $this->id, 'level', 'string', false); + $this->stream = new object_property($this->table, $this->id, 'stream', 'string', false); + $this->source = new object_property($this->table, $this->id, 'source', '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, + 'gateway_id' => (int)$this->gateway_id->value(), + 'department_id' => $this->department_id->value() === null ? null : (int)$this->department_id->value(), + 'level' => (string)$this->level->value(), + 'stream' => (string)$this->stream->value(), + 'source' => (string)$this->source->value(), + 'message' => (string)$this->message->value(), + 'context' => (array)($this->context_json->value() ?? []), + 'created_at' => (string)$this->created_at->value(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateway_operation_events_o.php b/services/nginx/app/objects/edge_gateway_operation_events_o.php new file mode 100644 index 00000000..9d36d8f1 --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_operation_events_o.php @@ -0,0 +1,61 @@ +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->stage = new object_property($this->table, $this->id, 'stage', 'string', 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(), + 'stage' => (string)$this->stage->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(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateway_operations_o.php b/services/nginx/app/objects/edge_gateway_operations_o.php new file mode 100644 index 00000000..15295868 --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_operations_o.php @@ -0,0 +1,98 @@ +setTable('edge_gateway_operations'); + } + + public function getObjectProperties(): void + { + $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', 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->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(), + '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(), + 'created_at' => (string)$this->created_at->value(), + 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateway_relay_bindings_o.php b/services/nginx/app/objects/edge_gateway_relay_bindings_o.php new file mode 100644 index 00000000..5dee84e7 --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_relay_bindings_o.php @@ -0,0 +1,82 @@ +setTable('edge_gateway_relay_bindings'); + } + + public function getObjectProperties(): void + { + $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->relay_id = new object_property($this->table, $this->id, 'relay_id', 'string', false); + $this->device_id = new object_property($this->table, $this->id, 'device_id', 'string', false); + $this->local_ip = new object_property($this->table, $this->id, 'local_ip', 'string', false); + $this->channel = new object_property($this->table, $this->id, 'channel', 'int', false); + $this->binding_source = new object_property($this->table, $this->id, 'binding_source', 'string', 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->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(); + $metadata = (array)($this->metadata_json->value() ?? []); + + return [ + 'id' => (int)$this->id, + 'gateway_id' => (int)$this->gateway_id->value(), + 'department_id' => (int)$this->department_id->value(), + 'relay_id' => (string)$this->relay_id->value(), + 'device_id' => (string)$this->device_id->value(), + 'local_ip' => $this->local_ip->value() === null ? null : (string)$this->local_ip->value(), + 'channel' => (int)$this->channel->value(), + 'binding_source' => (string)$this->binding_source->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(), + 'metadata' => $metadata, + 'fallback_mode' => isset($metadata['fallback_mode']) ? (string)$metadata['fallback_mode'] : 'PREFER_LOCAL', + 'last_resolution' => isset($metadata['last_resolution']) && is_array($metadata['last_resolution']) + ? (array)$metadata['last_resolution'] + : null, + 'last_success_at' => isset($metadata['last_success_at']) ? (string)$metadata['last_success_at'] : null, + 'last_error' => isset($metadata['last_error']) ? (string)$metadata['last_error'] : null, + 'created_at' => (string)$this->created_at->value(), + 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateway_shell_sessions_o.php b/services/nginx/app/objects/edge_gateway_shell_sessions_o.php new file mode 100644 index 00000000..a5dc5b3c --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_shell_sessions_o.php @@ -0,0 +1,98 @@ +setTable('edge_gateway_shell_sessions'); + } + + public function getObjectProperties(): void + { + $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->actor_user_id = new object_property($this->table, $this->id, 'actor_user_id', 'int', false); + $this->session_token_hash = new object_property($this->table, $this->id, 'session_token_hash', 'string', false); + $this->status = new object_property($this->table, $this->id, 'status', 'string', false); + $this->reason = new object_property($this->table, $this->id, 'reason', 'string', false); + $this->connection_id = new object_property($this->table, $this->id, 'connection_id', 'string', false); + $this->cwd = new object_property($this->table, $this->id, 'cwd', 'string', false); + $this->shell_command = new object_property($this->table, $this->id, 'shell_command', 'string', false); + $this->shell_args_json = new object_property($this->table, $this->id, 'shell_args_json', 'json', false); + $this->cols = new object_property($this->table, $this->id, 'cols', 'int', false); + $this->rows = new object_property($this->table, $this->id, 'terminal_rows', 'int', false); + $this->transcript = new object_property($this->table, $this->id, 'transcript', 'text', false); + $this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false); + $this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false); + $this->approved_at = new object_property($this->table, $this->id, 'approved_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->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(), + 'department_id' => (int)$this->department_id->value(), + 'actor_user_id' => $this->actor_user_id->value() === null ? null : (int)$this->actor_user_id->value(), + 'status' => (string)$this->status->value(), + 'reason' => $this->reason->value() === null ? null : (string)$this->reason->value(), + 'connection_id' => $this->connection_id->value() === null ? null : (string)$this->connection_id->value(), + 'cwd' => $this->cwd->value() === null ? null : (string)$this->cwd->value(), + 'shell_command' => $this->shell_command->value() === null ? null : (string)$this->shell_command->value(), + 'shell_args' => (array)($this->shell_args_json->value() ?? []), + 'cols' => $this->cols->value() === null ? null : (int)$this->cols->value(), + 'rows' => $this->rows->value() === null ? null : (int)$this->rows->value(), + 'transcript' => $this->transcript->value() === null ? null : (string)$this->transcript->value(), + 'metadata' => (array)($this->metadata_json->value() ?? []), + 'expires_at' => $this->expires_at->value() === null ? null : (string)$this->expires_at->value(), + 'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_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(), + 'created_at' => (string)$this->created_at->value(), + 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateways_o.php b/services/nginx/app/objects/edge_gateways_o.php new file mode 100644 index 00000000..fb6068cb --- /dev/null +++ b/services/nginx/app/objects/edge_gateways_o.php @@ -0,0 +1,86 @@ +setTable('edge_gateways'); + } + + public function getObjectProperties(): void + { + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->label = new object_property($this->table, $this->id, 'label', 'string', false); + $this->hostname = new object_property($this->table, $this->id, 'hostname', 'string', false); + $this->agent_token_hash = new object_property($this->table, $this->id, 'agent_token_hash', 'string', false); + $this->status = new object_property($this->table, $this->id, 'status', 'string', false); + $this->transport_mode = new object_property($this->table, $this->id, 'transport_mode', 'string', false); + $this->release_channel = new object_property($this->table, $this->id, 'release_channel', 'string', false); + $this->installed_version = new object_property($this->table, $this->id, 'installed_version', 'string', false); + $this->target_version = new object_property($this->table, $this->id, 'target_version', 'string', false); + $this->last_heartbeat_at = new object_property($this->table, $this->id, 'last_heartbeat_at', 'string', false); + $this->last_seen_ip = new object_property($this->table, $this->id, 'last_seen_ip', 'string', false); + $this->discovery_status = new object_property($this->table, $this->id, 'discovery_status', 'string', false); + $this->is_primary = new object_property($this->table, $this->id, 'is_primary', 'bool', 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, + 'department_id' => (int)$this->department_id->value(), + 'label' => (string)$this->label->value(), + 'hostname' => $this->hostname->value() === null ? null : (string)$this->hostname->value(), + 'status' => (string)$this->status->value(), + 'transport_mode' => (string)$this->transport_mode->value(), + 'release_channel' => (string)$this->release_channel->value(), + 'installed_version' => $this->installed_version->value() === null ? null : (string)$this->installed_version->value(), + 'target_version' => $this->target_version->value() === null ? null : (string)$this->target_version->value(), + 'last_heartbeat_at' => $this->last_heartbeat_at->value() === null ? null : (string)$this->last_heartbeat_at->value(), + 'last_seen_ip' => $this->last_seen_ip->value() === null ? null : (string)$this->last_seen_ip->value(), + 'discovery_status' => (string)$this->discovery_status->value(), + 'is_primary' => (bool)$this->is_primary->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(), + ]; + } +} diff --git a/services/nginx/app/objects/groups_permissions_o.php b/services/nginx/app/objects/groups_permissions_o.php index 2d0ffec3..efb5b5b0 100644 --- a/services/nginx/app/objects/groups_permissions_o.php +++ b/services/nginx/app/objects/groups_permissions_o.php @@ -6,6 +6,7 @@ use classes\db; use classes\object_property; use Exception; use traits\db_object_t; +use Throwable; class groups_permissions_o extends db { @@ -48,10 +49,11 @@ class groups_permissions_o extends db ]); $this->id = $tmp_id; self::getObjectProperties(); - self::objectChanged(); if (!$this->id) { throw new Exception('The permission was not created successfully.'); } + $this->invalidateGroupSessionCaches($group_id); + self::objectChanged(); } public function getObjectProperties(): void @@ -62,7 +64,7 @@ class groups_permissions_o extends db public function objectChanged(): void { - //TODO: Add cache invalidation + // Cache invalidation is handled in add/remove where group context is guaranteed. } /** @@ -96,6 +98,58 @@ class groups_permissions_o extends db $tmp_id->select((int)$id); $tmp_id->requireSelected(); $tmp_id->delete(); + $this->invalidateGroupSessionCaches($group_id); + } + + /** + * Invalidate cached permissions and session payloads for users associated with a group. + */ + private function invalidateGroupSessionCaches(int $group_id): void + { + if ($group_id <= 0 || !defined('redis')) { + return; + } + + try { + $userRows = (new users_o())->getFieldsWhere([ + 'group_id' => $group_id, + ], ['id']); + + if (count($userRows) === 0) { + return; + } + + $userIds = []; + foreach ($userRows as $userRow) { + $id = (int)($userRow['id'] ?? 0); + if ($id > 0) { + $userIds[] = $id; + } + } + $userIds = array_values(array_unique($userIds)); + + if (count($userIds) === 0) { + return; + } + + foreach ($userIds as $userId) { + redis->clear_keys('perm:user:' . $userId . ':*'); + } + + $tokenRows = (new tokens_o())->getFieldsWhere([ + 'user_id' => $userIds, + ], ['token']); + + foreach ($tokenRows as $tokenRow) { + $token = (string)($tokenRow['token'] ?? ''); + if ($token === '') { + continue; + } + redis->clear_auth_session($token); + } + } catch (Throwable) { + // Cache invalidation must not block permission updates. + } } public function asArray(): array @@ -145,4 +199,4 @@ class groups_permissions_o extends db return preg_match($regex, $permission['permission']); }); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/order_bookings_o.php b/services/nginx/app/objects/order_bookings_o.php index 76017060..8888020f 100644 --- a/services/nginx/app/objects/order_bookings_o.php +++ b/services/nginx/app/objects/order_bookings_o.php @@ -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. + } } @@ -347,19 +354,36 @@ class order_bookings_o extends db /** * @throws Exception */ - public function completeBooking(int $user_id, string $safety_seal = null): void + public function completeBooking(int $user_id, ?string $safety_seal = null): void { self::requireSelected(); if (!$this->order_id->value()) { // Create order, if not already created - self::createOrderBy($user_id); + $this->createOrderBy($user_id); // Add order items, re-calculate the prices to be customer-specific - self::createOrderItemsBy($user_id); + $this->createOrderItemsBy($user_id); + } + + $order = $this->getOrder(); + if (!$this->containsWashCertificateItem() && !$order->containsWashCertificateItem()) { + return; + } + + $this->requireLinkedOrderMatchesBooking($order); + $normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal); + if ($normalizedSafetySeal !== null) { + $order->setSafetySealValue($normalizedSafetySeal); + $order->objectChanged(); + } + + if ($order->hasWashCertificateAttached()) { + return; + } + + $this->attachWashCertificate($user_id, $order->getSafetySealValue()); + if ($order->hasWashCertificateAttached()) { + $this->sendWashCertificateToCustomer(); } - // Create a wash certificate (If applicable) - if (self::containsWashCertificateItem()) self::attachWashCertificate($user_id, $safety_seal); - // Send wash certificate - self::sendWashCertificateToCustomer(); } /** @@ -391,11 +415,16 @@ class order_bookings_o extends db continue; } $orderItems = new order_items_o(); + $itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== '' + ? (string)$item['notes'] + : ((string)($this->note->value() ?? '') ?: null); $orderItems->addItemToOrder( (int)$order->id, (int)$item['id'], (int)$user_id, (int)$item['quantity'], + null, + $itemNotes, ); } @@ -407,21 +436,18 @@ class order_bookings_o extends db public function containsWashCertificateItem(): bool { self::requireSelected(); - return self::containsProductId(41); - } + foreach ($this->items->value() as $item) { + $product_id = (int)($item['id'] ?? 0); + if ($product_id <= 0) { + continue; + } - /** - * @throws Exception - */ - private function containsProductId(int $productId): bool - { - self::requireSelected(); - $items = $this->items->value(); - foreach ($items as $item) { - if (isset($item['id']) && (int)$item['id'] == $productId) { + $product = (new products_o())->select($product_id); + if ($product->exists() && $product->isWashCertificate()) { return true; } } + return false; } @@ -445,11 +471,31 @@ class order_bookings_o extends db /** * @throws Exception */ - private function attachWashCertificate(int $user_id, string $safety_seal = null): void + private function requireLinkedOrderMatchesBooking(orders_o $order): void { self::requireSelected(); + + $bookingCustomerNumber = (int)$this->customer_number->value(); + $bookingDepartmentId = (int)$this->department->value(); + $orderCustomerId = (int)$order->customer_id->value(); + $orderDepartmentId = (int)$order->department_id->value(); + + if ($orderCustomerId !== $bookingCustomerNumber || $orderDepartmentId !== $bookingDepartmentId) { + throw new Exception('Linked order does not match booking customer or department'); + } + } + + /** + * @throws Exception + */ + protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void + { + self::requireSelected(); + $order = $this->getOrder(); + $this->requireLinkedOrderMatchesBooking($order); + // Check if the order already has a wash certificate attached - if (self::getOrder()->hasWashCertificateAttached()) { + if ($order->hasWashCertificateAttached()) { return; } // Get the operator name @@ -458,7 +504,7 @@ class order_bookings_o extends db throw new Exception('Operator not found'); } // Generate wash certificate - self::generateWashCertificate($safety_seal, $operator->display_name->value()); + $this->generateWashCertificate($safety_seal, $operator->display_name->value()); } /** @@ -468,7 +514,7 @@ class order_bookings_o extends db * @throws Exception If the object is not selected * @throws Exception If the booking already has a wash certificate */ - public function generateWashCertificate(int|null $safety_seal = null, string|null $operator = null): void + public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null): void { self::requireSelected(); // Generate the wash certificate @@ -510,7 +556,7 @@ class order_bookings_o extends db ]) ->addData([ 'booking_number' => $this->id, - 'seal_number' => ($safety_seal ?? null), + 'seal_number' => orders_o::normalizeSafetySealValue($safety_seal), 'reg_1' => $booking_array['reg_1'], 'reg_2' => $booking_array['reg_2'], 'date' => date('d-m-Y'), @@ -556,4 +602,68 @@ class order_bookings_o extends db return count($order_ids); } -} \ No newline at end of file + /** + * @param array|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)), + ]; + } + +} diff --git a/services/nginx/app/objects/order_items_o.php b/services/nginx/app/objects/order_items_o.php index d878b70d..38a43603 100644 --- a/services/nginx/app/objects/order_items_o.php +++ b/services/nginx/app/objects/order_items_o.php @@ -161,7 +161,7 @@ class order_items_o extends db } } - public function addItemToOrder(int $order_id, int $product_id, int $cashier_id, int $quantity, $related_item_id = null, $notes = null, $forcePrice = null): void + public function addItemToOrder(int $order_id, int $product_id, int $cashier_id, int $quantity, $related_item_id = null, $notes = null, $forcePrice = null): order_items_o { global $db, $response; try { @@ -199,6 +199,7 @@ class order_items_o extends db } // Invalidate the order cache $order->objectChanged(); + return (new order_items_o())->select($this->id); } catch (Exception $e) { $response->error($e->getMessage()); diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 1122ce4c..03d98546 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -5,6 +5,8 @@ namespace objects; use attachments\helpers\attachment_content; use classes\db; use classes\email; +use classes\invoicing_period_utils; +use classes\orders_schema_bootstrap; use classes\pdf_generator; use classes\motorapi; use classes\object_property; @@ -30,6 +32,7 @@ class orders_o extends db public object_property $reg_3; public economic_module_orders $economic_module_orders; public object_property $created_at; + public object_property $include_in_invoice; public object_property $deleted_at; public object_property $completed_at; @@ -39,6 +42,7 @@ class orders_o extends db public object_property $wash_id; // The XL Vask Wash ID, if any public object_property $lane; // The lane used for the order, if any public object_property $po; // The (optional) PO number, filled by the customer. + public object_property $safety_seal; // The optional safety seal value for wash certificates. public object_property $using_hand_held; // Whether the order is being processed using a handheld device /** @@ -52,6 +56,7 @@ class orders_o extends db public function structure(): void { + orders_schema_bootstrap::ensureTables(); $this->setTable('orders'); } @@ -85,6 +90,7 @@ class orders_o extends db $this->reg_2 = new object_property($this->table, $this->id, 'reg_2', 'string', false); $this->reg_3 = new object_property($this->table, $this->id, 'reg_3', 'string', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); + $this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false); $this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'timestamp', false); $this->economic_module_orders = (new economic_module_orders())->getByOrderId($this->id); $this->stripe_module_orders = (new stripe_module_orders_o())->select($this->id); @@ -94,6 +100,7 @@ class orders_o extends db $this->wash_id = new object_property($this->table, $this->id, 'wash_id', 'string', false); $this->lane = new object_property($this->table, $this->id, 'lane', 'string', false); $this->po = new object_property($this->table, $this->id, 'po', 'string', false); + $this->safety_seal = new object_property($this->table, $this->id, 'safety_seal', 'string', false); $this->using_hand_held = new object_property($this->table, $this->id, 'using_hand_held', 'bool', false); } @@ -147,6 +154,80 @@ class orders_o extends db // Save the object } + /** + * Return the data needed to decide whether deletion needs explicit confirmation. + * + * @return array{ + * requires_confirmation: bool, + * protected_reasons: array, + * order_item_count: int, + * attachment_count: int, + * completed_at: mixed + * } + * @throws Exception + */ + public function getDeleteProtectionSummary(): array + { + self::requireSelected(); + + $completedAt = $this->completed_at->value(); + $orderItemCount = $this->countActiveOrderItems(); + $attachmentCount = $this->countActiveOrderAttachments(); + $protectedReasons = []; + + if ($completedAt !== null) { + $protectedReasons[] = 'completed'; + } + if ($orderItemCount > 0) { + $protectedReasons[] = 'order_items'; + } + if ($attachmentCount > 0) { + $protectedReasons[] = 'attachments'; + } + + return [ + 'requires_confirmation' => count($protectedReasons) > 0, + 'protected_reasons' => $protectedReasons, + 'order_item_count' => $orderItemCount, + 'attachment_count' => $attachmentCount, + 'completed_at' => $completedAt, + ]; + } + + /** + * @throws Exception + */ + private function countActiveOrderItems(): int + { + self::requireSelected(); + global $db; + + $orderId = (int)$this->id; + $result = $db->query("SELECT COUNT(*) AS total FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL"); + if ($result && $row = $result->fetch_assoc()) { + return (int)($row['total'] ?? 0); + } + + return 0; + } + + /** + * @throws Exception + */ + private function countActiveOrderAttachments(): int + { + self::requireSelected(); + global $db; + + $orderId = (int)$this->id; + $result = $db->query("SELECT COUNT(*) AS total FROM object_attachments WHERE object_type = 'orders' AND object_id = {$orderId} AND deleted_at IS NULL"); + if ($result && $row = $result->fetch_assoc()) { + return (int)($row['total'] ?? 0); + } + + return 0; + } + /** * @throws Exception If the order is not selected * This function is called when the order object is changed. @@ -262,10 +343,8 @@ class orders_o extends db * @throws Exception If the order is not selected * @throws Exception If the order is already completed */ - public function markAsCompleted(): void + public function markAsCompleted(string|null $operator = null): void { - global /** @var db $db */ - $db; self::requireSelected(); // Check if the order is already completed if ($this->completed_at->value() !== null) { @@ -273,9 +352,16 @@ class orders_o extends db } // Set the completed_at property to the current timestamp $this->completed_at->set(date('Y-m-d H:i:s')); - $sql = "UPDATE $this->table SET completed_at = '" . $this->completed_at->value() . "' WHERE id = " . $this->id; - $db->query($sql); + $this->setPendingHandheldIndicator(false); + $washCertificateCreated = $this->completeWashCertificateIfNeeded( + $operator, + (string)$this->completed_at->value() + ); $this->objectChanged(); + + if ($washCertificateCreated && (int)$this->booking_id->value() > 0) { + $this->getOrderBooking()?->sendWashCertificateToCustomer(); + } } /** @@ -307,6 +393,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; @@ -364,10 +460,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(); @@ -380,7 +477,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(); + } } /** @@ -567,6 +673,8 @@ class orders_o extends db 'reg_3' => $this->reg_3->value(), 'completed_at' => $this->completed_at->value(), 'created_at' => $this->created_at->value(), + 'include_in_invoice' => $this->getIncludeInInvoiceOverride(), + 'include_in_invoice_effective' => $this->isIncludedInInvoicing(), 'deleted_at' => $this->deleted_at->value(), 'total_net_amount' => $this->temporary_net_amount ?: $this->getNetAmount(), 'invoice_collection_id' => (int)$this->invoice_collection_id->value(), @@ -574,6 +682,7 @@ class orders_o extends db 'wash_id' => $this->wash_id->value(), 'lane' => $this->lane->value(), 'po' => $this->po->value(), + 'safety_seal' => $this->getSafetySealValue(), 'closed_at' => (int)$this->invoice_collection_id->value() ? (new collected_order_invoices_o())->select((int)$this->invoice_collection_id->value())->closed_at->value() : null, 'pending_handheld' => $this->isPendingHandheld(), ]; @@ -619,6 +728,10 @@ class orders_o extends db */ public function getNetAmountForOrders(array $order_ids): array { + if (empty($order_ids)) { + return []; + } + $order_items = new order_items_o(); $tmp = $order_items->getFieldsWhere( [ @@ -1053,6 +1166,9 @@ class orders_o extends db public function getTransactionsForCustomersInDateRange(array $customers, string $dateFrom, string $dateTo): array { global $db; + if (empty($customers)) { + return []; + } // Validate the date range if (strtotime($dateFrom) === false || strtotime($dateTo) === false) { throw new Exception('Invalid date range provided'); @@ -1078,6 +1194,141 @@ class orders_o extends db return $transactions; } + /** + * Get period transactions as plain rows grouped by customer number. + * + * This avoids hydrating one orders_o object per order for the invoicing period response. + * + * @param int[]|null $customers Null means all local customers with orders in the period. + * @return array>> + * @throws Exception + */ + public function getPeriodTransactionsForCustomersInDateRange(?array $customers, string $dateFrom, string $dateTo): array + { + global $db; + if (strtotime($dateFrom) === false || strtotime($dateTo) === false) { + throw new Exception('Invalid date range provided'); + } + if (strtotime($dateFrom) > strtotime($dateTo)) { + throw new Exception('The start date cannot be after the end date'); + } + + $customerFilter = ''; + if ($customers !== null) { + $customers = array_values(array_unique(array_filter( + array_map('intval', $customers), + static fn(int $customerNumber): bool => $customerNumber > 0 + ))); + if (empty($customers)) { + return []; + } + $customerFilter = ' AND o.customer_id IN (' . implode(',', $customers) . ')'; + } + + $dateFrom = $db->escape_string($dateFrom); + $dateTo = $db->escape_string($dateTo); + $sql = " + SELECT + o.id, + o.customer_id AS customer_number, + customer_user.user_id, + customer_user.customer_name, + o.created_at, + COALESCE(SUM(CASE WHEN oi.include_in_invoice = 1 THEN oi.price * oi.quantity ELSE 0 END), 0) AS net_amount, + CASE + WHEN COALESCE(o.invoice_collection_id, 0) > 0 + THEN CASE WHEN COALESCE(coi.booked_invoice_id, 0) <> 0 THEN 1 ELSE 0 END + ELSE CASE WHEN COALESCE(emo.invoice_id, 0) <> 0 THEN 1 ELSE 0 END + END AS booked, + o.department_id, + o.reference, + o.po, + o.notes, + o.reg_1, + o.reg_2, + o.reg_3, + o.invoice_collection_id, + CASE + WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice + WHEN COALESCE(department_flags.exclude_from_invoicing, 0) = 1 THEN 0 + ELSE 1 + END AS include_in_invoice_effective + FROM {$this->table} o + INNER JOIN ( + SELECT customer_number, MIN(id) AS user_id, MAX(display_name) AS customer_name + FROM users + WHERE customer_number IS NOT NULL AND customer_number <> 0 + GROUP BY customer_number + ) customer_user ON customer_user.customer_number = o.customer_id + LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + LEFT JOIN collected_order_invoices coi ON coi.id = o.invoice_collection_id + LEFT JOIN economic_module_orders emo ON emo.id = o.id + LEFT JOIN ( + SELECT department_id, MAX(value = 'true') AS exclude_from_invoicing + FROM department_variables + WHERE variable = 'exclude_from_invoicing' + GROUP BY department_id + ) department_flags ON department_flags.department_id = o.department_id + WHERE o.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}' + AND o.deleted_at IS NULL + {$customerFilter} + GROUP BY + o.id, + o.customer_id, + customer_user.user_id, + customer_user.customer_name, + o.created_at, + coi.booked_invoice_id, + emo.invoice_id, + o.department_id, + o.reference, + o.po, + o.notes, + o.reg_1, + o.reg_2, + o.reg_3, + o.invoice_collection_id, + o.include_in_invoice, + department_flags.exclude_from_invoicing + ORDER BY o.customer_id, o.created_at, o.id"; + $result = $db->query($sql); + if (!$result || $result->num_rows === 0) { + return []; + } + + $transactions = []; + while ($row = $result->fetch_assoc()) { + $customerNumber = (int)$row['customer_number']; + if ($customerNumber < 1) { + continue; + } + $invoiceCollectionId = (int)($row['invoice_collection_id'] ?? 0); + $transactions[$customerNumber][] = [ + 'id' => (int)$row['id'], + 'date' => (string)($row['created_at'] ?? ''), + 'created_at' => (string)($row['created_at'] ?? ''), + 'amount' => (float)($row['net_amount'] ?? 0), + 'booked' => (int)($row['booked'] ?? 0) === 1, + 'department_id' => (int)($row['department_id'] ?? 0), + 'customer_number' => $customerNumber, + 'reference' => (string)($row['reference'] ?? ''), + 'po' => (string)($row['po'] ?? ''), + 'notes' => (string)($row['notes'] ?? ''), + 'reg_1' => (string)($row['reg_1'] ?? ''), + 'reg_2' => (string)($row['reg_2'] ?? ''), + 'reg_3' => (string)($row['reg_3'] ?? ''), + 'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1, + 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, + 'queue_status' => null, + 'queue_job_id' => null, + 'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null, + 'customer_name' => (string)($row['customer_name'] ?? ''), + ]; + } + + return $transactions; + } + /** * Get orders with possible duplicates in a date range * @param string $dateFrom The start date of the date range (inclusive) "Y-m-d H:i:s" format @@ -1126,36 +1377,8 @@ class orders_o extends db 'object' => (new orders_o())->select((int)$tmp['id']) ]; } - // Filter out orders with more than one entry for the same registration numbers (in a 24 hour period) - $possibleDuplicates = []; - // Loop through the registration numbers - foreach ( $orders as $reg_1 => $orderList ) { - // If there are more than one order for the same registration number, add it to the possible duplicates - if (count($orderList) > 1) { - // Loop through the orders and check if they are within 24 hours of each other - $filteredOrders = []; - foreach ( $orderList as $order ) { - // Check if the order is within 24 hours of the previous order (if any) - if (empty($filteredOrders)) { - $filteredOrders[] = $order; // Add the first order - } else { - // Check if the order is within 24 hours of the previous order - $firstOrderTime = strtotime($filteredOrders[0]['created_at']); - $currentOrderTime = strtotime($order['created_at']); - if ($currentOrderTime - $firstOrderTime <= 86400) { // 86400 seconds = 24 hours - $filteredOrders[] = $order; // Add the order to the filtered list - } - } - } - // If there are more than one order in the filtered list, add it to the possible duplicates - if (count($filteredOrders) > 1) { - $possibleDuplicates[$reg_1] = $filteredOrders; - } - } - } - // Return the possible duplicates - return $possibleDuplicates; + return invoicing_period_utils::filterPossibleDuplicates($orders, 86400); } public function setTemporaryNetAmount(float $amount): void @@ -1332,31 +1555,42 @@ class orders_o extends db { // Get the original net amount for the order items, ignoring any temporary net amount set self::requireSelected(); - $order_items = $this->getOrderItems((int)$this->id); - //echo 'Calculating net amount for order ID ' . $this->id . ' with ' . count($order_items) . " items\n"; - return array_sum(array_map(/** - * @throws Exception - */ function ($item) { - if ( (int)$item['price'] > 0 && (int)$item['quantity'] > 0) { - return (int)$item['price'] * (int)$item['quantity']; + $order_items = (new order_items_o())->getFieldsWhere( + ['order_id' => (int)$this->id], + ['price', 'quantity', 'product_id'] + ); + if (empty($order_items)) { + return 0; + } + + $total = 0; + $department_id = (int)$this->department_id->value(); + $tmp_user = null; + $department_price_cache = []; + + foreach ( $order_items as $item ) { + $price = (int)$item['price']; + $quantity = (int)$item['quantity']; + + if ($price > 0 && $quantity > 0) { + $total += $price * $quantity; + continue; } - // Otherwise we need to get the product price - $product = (new products_o())->select((int)$item['product_id']); - // Apply the customer discount if applicable - $department_price_original = (int)$product->getDepartmentPrice((int)$this->department_id->value()); - // Get the customers user object - $tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value()); - // Get the discount percentage for the customer - $discount = $tmp_user->getCustomPrice((int)$product->id, false); - // Calculate the final price after discount - $post_discount = (int)round($department_price_original * (1 - ($discount / 100))) * (int)$item['quantity']; - //echo 'Adding ' . $post_discount . ' for product ' . $product->name . ' (Original price: ' . $department_price_original . ', Discount: ' . $discount . '%, Quantity: ' . (int)$item['quantity'] . ")\n"; - $actual_price = (int)$item['price']; - if ($actual_price !== $post_discount) { - //echo "Warning: The actual price ($actual_price) does not match the calculated price ($post_discount) for product " . $product->name . "\n"; + + $product_id = (int)$item['product_id']; + if (!isset($department_price_cache[$product_id])) { + $product = (new products_o())->select($product_id); + $department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id); } - return $post_discount; - }, $order_items)); + if ($tmp_user === null) { + $tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value()); + } + $discount = $tmp_user->getCustomPrice($product_id, false); + $post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity; + $total += $post_discount; + } + + return $total; } /** @@ -1374,24 +1608,212 @@ 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); + } + + /** + * @throws Exception + */ + public function containsWashCertificateItem(): bool + { + self::requireSelected(); + $products = new products_o(); + + foreach ($this->getOrderItems((int)$this->id) as $item) { + $product_id = (int)($item['product_id'] ?? 0); + if ($product_id <= 0) { + continue; + } + + $product = $products->select($product_id); + if ($product->exists() && $product->isWashCertificate()) { + return true; + } + } + + return false; + } + + public static function normalizeSafetySealValue(mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (is_string($value)) { + $normalized = trim($value); + return $normalized === '' ? null : $normalized; + } + + if (is_scalar($value)) { + $normalized = trim((string)$value); + return $normalized === '' ? null : $normalized; + } + + return null; + } + + /** + * @throws Exception + */ + public function getSafetySealValue(): ?string + { + self::requireSelected(); + 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 + */ + public function setSafetySealValue(mixed $value): void + { + self::requireSelected(); + $normalized = self::normalizeSafetySealValue($value); + $this->safety_seal->set($normalized); + } + + /** + * @throws Exception + */ + public function completeWashCertificateIfNeeded(string|null $operator = null, $date = null): bool + { + self::requireSelected(); + if (!$this->containsWashCertificateItem() || $this->hasWashCertificateAttached()) { + return false; + } + + $this->generateWashCertificate($this->getSafetySealValue(), $operator, $date); + return $this->hasWashCertificateAttached(); } /** * Generate and attach a wash certificate directly on an order (without a booking) - * @param int|null $safety_seal Optional safety seal number + * @param string|null $safety_seal Optional safety seal number * @param string|null $operator Optional operator/employee name who carried out the wash * @param string|DateTime|null $date Optional date of the wash (defaults to current date) * @throws Exception If the order is not selected or required related objects are missing */ - public function generateWashCertificate(int|null $safety_seal = null, string|null $operator = null, $date = null): void + public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null, $date = null): void { self::requireSelected(); // Avoid generating duplicate certificates @@ -1403,17 +1825,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')); @@ -1440,17 +1872,17 @@ class orders_o extends db ]) ->addData([ 'booking_number' => $this->id, // Used as document number on the template - 'seal_number' => ($safety_seal ?? null), + 'seal_number' => self::normalizeSafetySealValue($safety_seal), 'reg_1' => $order_array['reg_1'], 'reg_2' => $order_array['reg_2'], 'date' => $date_formatted, '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() @@ -1500,6 +1932,44 @@ class orders_o extends db public function isIncludedInInvoicing(): bool { self::requireSelected(); + $override = $this->getIncludeInInvoiceOverride(); + if ($override !== null) { + return $override; + } + + return $this->resolveDepartmentIncludedInInvoicing(); + } + + public static function normalizeNullableBooleanValue(mixed $value): ?bool + { + if ($value === null || $value === '') { + return null; + } + + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + return $value === 1 ? true : ($value === 0 ? false : null); + } + + $normalized = strtolower(trim((string)$value)); + return match ($normalized) { + '1', 'true' => true, + '0', 'false' => false, + default => null, + }; + } + + public function getIncludeInInvoiceOverride(): ?bool + { + self::requireSelected(); + return self::normalizeNullableBooleanValue($this->include_in_invoice->value()); + } + + protected function resolveDepartmentIncludedInInvoicing(): bool + { return !(new departments_o())->select((int)$this->department_id->value())->isExcludedFromInvoicing(); } @@ -1550,6 +2020,67 @@ class orders_o extends db return (int)$row['wash_count']; } + /** + * @param array $department_ids + * @return array + * @throws Exception + */ + public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array + { + global /** @var db $db */ + $db; + + if (strtotime($date_start) === false || strtotime($date_end) === false) { + throw new Exception('Invalid date range provided'); + } + if (strtotime($date_start) > strtotime($date_end)) { + throw new Exception('The start date cannot be after the end date'); + } + + $normalized_department_ids = []; + foreach ($department_ids as $department_id) { + $normalized_id = (int)$department_id; + if ($normalized_id > 0) { + $normalized_department_ids[$normalized_id] = true; + } + } + if ($normalized_department_ids === []) { + return []; + } + + $department_ids_sql = implode(',', array_map('intval', array_keys($normalized_department_ids))); + $escaped_start = $db->escape_string($date_start); + $escaped_end = $db->escape_string($date_end); + + $sql = "SELECT o.department_id, + DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00') AS hour_bucket, + COUNT(DISTINCT o.id) AS wash_count + FROM $this->table o + JOIN order_items oi ON oi.order_id = o.id + JOIN products p ON p.id = oi.product_id + WHERE o.department_id IN ($department_ids_sql) + AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end' + AND o.deleted_at IS NULL + AND p.is_wash = 1 + GROUP BY o.department_id, DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00')"; + + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = [ + 'department_id' => (int)($row['department_id'] ?? 0), + 'hour_bucket' => (string)($row['hour_bucket'] ?? ''), + 'wash_count' => (int)($row['wash_count'] ?? 0), + ]; + } + + return $rows; + } + /** * @throws Exception * @retuns order_items_o[] @@ -1624,4 +2155,45 @@ class orders_o extends db } return $orders; } -} \ No newline at end of file + + /** + * @throws Exception + */ + public function getOrdersWithRegistrationNumberInDateRange(string $registration_number, string $from_date, string $to_date): array + { + global /** @var db $db */ + $db; + // Validate the date range + $from_timestamp = strtotime($from_date); + $to_timestamp = strtotime($to_date); + if ($from_timestamp === false || $to_timestamp === false) { + throw new Exception('Invalid date range provided'); + } + if ($from_timestamp > $to_timestamp) { + throw new Exception('The start date cannot be after the end date'); + } + // Prepare the SQL query to find orders with the registration number in the date range + $reg = strtoupper(trim($registration_number)); + if ($reg === '') { + return []; + } + $reg = $db->escape_string($reg); + $from_date = $db->escape_string(date('Y-m-d H:i:s', $from_timestamp)); + $to_date = $db->escape_string(date('Y-m-d H:i:s', $to_timestamp)); + $sql = "SELECT id FROM $this->table + WHERE (UPPER(TRIM(reg_1)) = '$reg' OR UPPER(TRIM(reg_2)) = '$reg' OR UPPER(TRIM(reg_3)) = '$reg') + AND created_at BETWEEN '$from_date' AND '$to_date' + AND deleted_at IS NULL"; + $result = $db->query($sql); + if ($result->num_rows === 0) { + return []; // No orders found with the registration number in the date range + } + $orders = []; + while ($row = $result->fetch_assoc()) { + $order = new orders_o(); + $order->select((int)$row['id']); + $orders[] = $order; + } + return $orders; + } +} diff --git a/services/nginx/app/objects/plate_scanners_o.php b/services/nginx/app/objects/plate_scanners_o.php index 0d23570b..196b5b76 100644 --- a/services/nginx/app/objects/plate_scanners_o.php +++ b/services/nginx/app/objects/plate_scanners_o.php @@ -4,6 +4,7 @@ namespace objects; use classes\db; use classes\object_property; +use Exception; use traits\db_object_t; class plate_scanners_o extends db @@ -11,12 +12,16 @@ class plate_scanners_o extends db use db_object_t; public object_property $department_id; + public object_property $lane_id; public object_property $name; public object_property $notes; public object_property $api_key; + private static bool $schemaInitialized = false; + public function structure(): void { + self::ensureSchema(); $this->setTable('plate_scanners'); } @@ -41,22 +46,25 @@ class plate_scanners_o extends db public function getObjectProperties(): void { $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int'); + $this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int'); $this->name = new object_property($this->table, $this->id, 'name', 'string'); $this->notes = new object_property($this->table, $this->id, 'notes', 'string'); $this->api_key = new object_property($this->table, $this->id, 'api_key', 'string'); } - public function add(int $department_id, string $name, string $notes): void + public function add(int $department_id, string $name, string $notes, ?int $lane_id = null): void { global $db, $response; try { // Generate an API key $api_key = bin2hex(random_bytes(32)); + $lane_id = $this->normalizeLaneId($department_id, $lane_id); // Avoid SQL injection $name = $db->escape_string($name); $notes = $db->escape_string($notes); + $laneValue = $lane_id === null ? 'NULL' : (string)$lane_id; // Create a new record in the database - $sql = "INSERT INTO $this->table (department_id, name, notes, api_key) VALUES ($department_id, '$name', '$notes', '$api_key')"; + $sql = "INSERT INTO $this->table (department_id, lane_id, name, notes, api_key) VALUES ($department_id, $laneValue, '$name', '$notes', '$api_key')"; $db->query($sql); // Get the id of the new record @@ -69,20 +77,26 @@ class plate_scanners_o extends db } } - public function edit(int $id, int $department_id, string $name, string $notes): void + public function edit( + int $id, + int $department_id, + string $name, + string $notes, + ?int $lane_id = null, + bool $laneIdProvided = false + ): void { - global $db, $response; - $this->id = $id; + global $response; try { - // Avoid SQL injection - $name = $db->escape_string($name); - $notes = $db->escape_string($notes); - // Update the record in the database - $sql = "UPDATE $this->table SET department_id = $department_id, name = '$name', notes = '$notes' WHERE id = $id"; - $db->query($sql); - - // Set the values of the object properties - $this->getObjectProperties(); + $this->select($id); + // Use object_property setters so cached field values are invalidated before we serialize the scanner. + $this->department_id->set($department_id); + $this->name->set($name); + $this->notes->set($notes); + if ($laneIdProvided) { + $lane_id = $this->normalizeLaneId($department_id, $lane_id); + $this->lane_id->set($lane_id); + } } catch (\Exception $e) { $response->error($e->getMessage()); } @@ -102,4 +116,116 @@ class plate_scanners_o extends db } return $this; } -} \ No newline at end of file + + /** + * @return array{id:int,department_id:int,lane_id:int|null,name:string,notes:string,api_key:string} + * @throws Exception + */ + public function asArray(): array + { + $this->requireSelected(); + + return [ + 'id' => (int)$this->id, + 'department_id' => (int)$this->department_id->value(), + 'lane_id' => $this->lane_id->value() === null ? null : (int)$this->lane_id->value(), + 'name' => (string)$this->name->value(), + 'notes' => (string)$this->notes->value(), + 'api_key' => (string)$this->api_key->value(), + ]; + } + + /** + * @return array + * @throws Exception + */ + public function getDepartmentScanners(int $departmentId): array + { + $scanners = []; + $rows = self::getFieldsWhere([ + 'department_id' => $departmentId, + ], ['id']); + + foreach ($rows as $row) { + $scanner = (new plate_scanners_o())->select((int)$row['id']); + if ($scanner->exists()) { + $scanners[] = $scanner; + } + } + + return $scanners; + } + + /** + * @throws Exception + */ + public function rotateApiKey(int $id): array + { + $scanner = $this->select($id); + if (!$scanner->exists()) { + throw new Exception('Number plate scanner not found'); + } + + $newApiKey = bin2hex(random_bytes(32)); + $scanner->api_key->set($newApiKey); + + return $scanner->asArray(); + } + + private function normalizeLaneId(int $departmentId, ?int $laneId): ?int + { + if ($laneId === null || $laneId <= 0) { + return null; + } + + $lane = (new department_lanes_o())->select($laneId); + if (!$lane->exists()) { + throw new Exception('Department lane not found'); + } + + if ((int)$lane->department->value() !== $departmentId) { + throw new Exception('The lane does not belong to the number plate scanner department'); + } + + return (int)$lane->id; + } + + private static function ensureSchema(): void + { + if (self::$schemaInitialized) { + return; + } + + global $db; + + if (!self::tableHasColumn('plate_scanners', 'lane_id')) { + $db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`"); + } + + self::$schemaInitialized = true; + } + + private static function tableHasColumn(string $table, string $column): bool + { + global $db; + + $table = $db->escape_string($table); + $column = $db->escape_string($column); + $database = $db->escape_string($db->getDatabase()); + + $result = $db->query( + "SELECT COUNT(*) AS c + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = '$database' + AND TABLE_NAME = '$table' + AND COLUMN_NAME = '$column'" + ); + + if (!$result) { + return false; + } + + $row = $result->fetch_assoc(); + return ((int)($row['c'] ?? 0)) > 0; + } +} diff --git a/services/nginx/app/objects/products_o.php b/services/nginx/app/objects/products_o.php index 6dc691d0..eea9c541 100644 --- a/services/nginx/app/objects/products_o.php +++ b/services/nginx/app/objects/products_o.php @@ -4,12 +4,16 @@ namespace objects; use classes\db; use classes\object_property; +use classes\products_schema_bootstrap; use traits\db_object_t; class products_o extends db { use db_object_t; + public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27; + public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi'; + /** * The name of the product * @var object_property @@ -70,6 +74,11 @@ class products_o extends db * @var object_property $order_priority */ public object_property $order_priority; + /** + * Optional upper quantity limit for a product on one order. + * @var object_property $max_quantity_per_order + */ + public object_property $max_quantity_per_order; /** * The timestamp of when the object was created * @var object_property @@ -83,6 +92,7 @@ class products_o extends db public function structure(): void { + products_schema_bootstrap::ensureTables(); $this->setTable('products'); } @@ -118,6 +128,7 @@ class products_o extends db $this->is_wash = new object_property($this->table, $this->id, 'is_wash', 'bool', false); $this->display_in_booking_form = new object_property($this->table, $this->id, 'display_in_booking_form', 'bool', false); $this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false); + $this->max_quantity_per_order = new object_property($this->table, $this->id, 'max_quantity_per_order', 'int', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false); } @@ -206,15 +217,38 @@ class products_o extends db 'piktogram' => $this->piktogram->value(), 'economic_product_id' => $this->economic_product_id->value(), 'apply_category_discount' => (bool)$this->apply_category_discount->value(), - 'requires_note' => (bool)$this->requires_note->value(), + 'requires_note' => $this->requiresOrderItemNote(), 'is_wash' => (bool)$this->is_wash->value(), 'display_in_booking_form' => (bool)$this->display_in_booking_form->value(), 'order_priority' => (int)$this->order_priority->value(), + 'max_quantity_per_order' => $this->max_quantity_per_order->value() === null ? null : (int)$this->max_quantity_per_order->value(), 'created_at' => (string)$this->created_at->value(), 'updated_at' => (string)$this->updated_at->value(), ]; } + public static function productDataRequiresOrderItemNote(array $product): bool + { + if ((bool)($product['requires_note'] ?? false)) { + return true; + } + + if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) { + return true; + } + + return trim((string)($product['name'] ?? '')) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME; + } + + public function requiresOrderItemNote(): bool + { + return self::productDataRequiresOrderItemNote([ + 'id' => $this->id, + 'name' => (string)$this->name->value(), + 'requires_note' => (bool)$this->requires_note->value(), + ]); + } + /** * Apply department pricing to a list of products * @param array $products @@ -286,4 +320,4 @@ class products_o extends db self::requireSelected(); return $this->id === 41; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/selfserve_config_versions_o.php b/services/nginx/app/objects/selfserve_config_versions_o.php new file mode 100644 index 00000000..c05381a1 --- /dev/null +++ b/services/nginx/app/objects/selfserve_config_versions_o.php @@ -0,0 +1,139 @@ +setTable('selfserve_config_versions'); + } + + public function getObjectProperties(): void + { + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->status = new object_property($this->table, $this->id, 'status', 'string', false); + $this->version_number = new object_property($this->table, $this->id, 'version_number', 'int', false); + $this->config_json = new object_property($this->table, $this->id, 'config_json', 'json', false); + $this->validation_result_json = new object_property($this->table, $this->id, 'validation_result_json', 'json', false); + $this->source_version_id = new object_property($this->table, $this->id, 'source_version_id', 'int', false); + $this->created_by = new object_property($this->table, $this->id, 'created_by', 'int', false); + $this->published_at = new object_property($this->table, $this->id, 'published_at', 'datetime', 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 + { + // No-op for now. + } + + public function add( + int $departmentId, + string $status, + int $versionNumber, + array $config, + ?array $validationResult = null, + ?int $sourceVersionId = null, + ?int $createdBy = null, + ?string $publishedAt = null, + ): self { + $configJson = json_encode($config, JSON_UNESCAPED_UNICODE); + if ($configJson === false) { + throw new \RuntimeException('Failed to encode self-serve config JSON: ' . json_last_error_msg()); + } + + $validationJson = null; + if ($validationResult !== null) { + $validationJson = json_encode($validationResult, JSON_UNESCAPED_UNICODE); + if ($validationJson === false) { + throw new \RuntimeException('Failed to encode self-serve validation JSON: ' . json_last_error_msg()); + } + } + + $this->id = $this->add_object([ + 'department_id' => $departmentId, + 'status' => $status, + 'version_number' => $versionNumber, + // Pass JSON as escaped strings to avoid SQL quoting issues inside generic add_object(). + 'config_json' => $configJson, + 'validation_result_json' => $validationJson, + 'source_version_id' => $sourceVersionId, + 'created_by' => $createdBy, + 'published_at' => $publishedAt, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + return $this; + } + + public function selectLatestByDepartmentAndStatus(int $departmentId, string $status): self + { + $rows = $this->getFieldsWhere([ + 'department_id' => $departmentId, + 'status' => $status, + 'deleted_at' => null, + ], ['id']); + if ($rows === []) { + return $this; + } + + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $this->select((int)$rows[0]['id']); + return $this; + } + + public function listByDepartment(int $departmentId): array + { + $rows = $this->getFieldsWhere([ + 'department_id' => $departmentId, + 'deleted_at' => null, + ], ['id']); + if ($rows === []) { + return []; + } + + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + return array_map(function (array $row): array { + return (new selfserve_config_versions_o())->select((int)$row['id'])->asArray(); + }, $rows); + } + + public function asArray(): array + { + return [ + 'id' => (int)$this->id, + 'department_id' => (int)$this->department_id->value(), + 'status' => (string)$this->status->value(), + 'version_number' => (int)$this->version_number->value(), + 'config' => (array)($this->config_json->value() ?? []), + 'validation_result' => (array)($this->validation_result_json->value() ?? []), + 'source_version_id' => $this->source_version_id->value() === null ? null : (int)$this->source_version_id->value(), + 'created_by' => $this->created_by->value() === null ? null : (int)$this->created_by->value(), + 'published_at' => $this->published_at->value() === null ? null : (string)$this->published_at->value(), + 'created_at' => (string)$this->created_at->value(), + 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), + ]; + } +} diff --git a/services/nginx/app/objects/selfserve_machine_types_o.php b/services/nginx/app/objects/selfserve_machine_types_o.php new file mode 100644 index 00000000..80d16510 --- /dev/null +++ b/services/nginx/app/objects/selfserve_machine_types_o.php @@ -0,0 +1,67 @@ +setTable('selfserve_machine_types'); + } + + public function add(string $name, ?string $description = null): self + { + global $db; + $name = $db->escape_string($name); + $description = $description === null ? null : $db->escape_string($description); + + $this->id = self::add_object([ + 'name' => $name, + 'description' => $description, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + + return $this; + } + + public function getObjectProperties(): void + { + $this->name = new object_property($this->table, $this->id, 'name', 'string', false); + $this->description = new object_property($this->table, $this->id, 'description', 'string', 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 + { + // No dedicated cache invalidation yet. + } + + public function asArray(): array + { + return [ + 'id' => (int)$this->id, + 'name' => (string)$this->name->value(), + 'description' => $this->description->value() === null ? null : (string)$this->description->value(), + 'created_at' => (string)$this->created_at->value(), + 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), + ]; + } +} diff --git a/services/nginx/app/objects/selfserve_wash_session_answers_o.php b/services/nginx/app/objects/selfserve_wash_session_answers_o.php new file mode 100644 index 00000000..9ed6f7b2 --- /dev/null +++ b/services/nginx/app/objects/selfserve_wash_session_answers_o.php @@ -0,0 +1,97 @@ +setTable('selfserve_wash_session_answers'); + } + + public function getObjectProperties(): void + { + $this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false); + $this->question_id = new object_property($this->table, $this->id, 'question_id', 'int', false); + $this->question_text = new object_property($this->table, $this->id, 'question_text', 'string', false); + $this->answer_value = new object_property($this->table, $this->id, 'answer_value', 'bool', false); + $this->answered_at = new object_property($this->table, $this->id, 'answered_at', 'datetime', 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 + { + // No dedicated cache invalidation yet. + } + + public function upsert(int $sessionId, int $questionId, string $questionText, bool $answerValue): self + { + $rows = $this->getFieldsWhere([ + 'session_id' => $sessionId, + 'question_id' => $questionId, + 'deleted_at' => null, + ], ['id']); + + if ($rows !== []) { + $this->select((int)$rows[0]['id']); + $this->question_text->set($questionText); + $this->answer_value->set($answerValue); + $this->answered_at->set(date('Y-m-d H:i:s')); + return $this; + } + + $this->id = self::add_object([ + 'session_id' => $sessionId, + 'question_id' => $questionId, + 'question_text' => $questionText, + 'answer_value' => $answerValue, + 'answered_at' => date('Y-m-d H:i:s'), + ]); + $this->getObjectProperties(); + $this->objectChanged(); + return $this; + } + + public function listBySession(int $sessionId): array + { + return $this->getFieldsWhere([ + 'session_id' => $sessionId, + 'deleted_at' => null, + ], ['id', 'question_id', 'question_text', 'answer_value', 'answered_at']); + } + + public function deleteMissingForSession(int $sessionId, array $questionIds): void + { + global $db; + + $sessionId = (int)$sessionId; + $questionIds = array_values(array_unique(array_map(static fn(mixed $value): int => (int)$value, $questionIds))); + + if ($questionIds === []) { + $db->query("DELETE FROM $this->table WHERE session_id = $sessionId"); + return; + } + + $questionIdsSql = implode(',', $questionIds); + $db->query("DELETE FROM $this->table WHERE session_id = $sessionId AND question_id NOT IN ($questionIdsSql)"); + } +} diff --git a/services/nginx/app/objects/selfserve_wash_session_events_o.php b/services/nginx/app/objects/selfserve_wash_session_events_o.php new file mode 100644 index 00000000..4442d48d --- /dev/null +++ b/services/nginx/app/objects/selfserve_wash_session_events_o.php @@ -0,0 +1,58 @@ +setTable('selfserve_wash_session_events'); + } + + public function getObjectProperties(): void + { + $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); + } + + public function objectChanged(): void + { + // No dedicated cache invalidation yet. + } + + public function add(int $sessionId, selfserve_wash_event_type $eventType, ?array $payload = null): self + { + $this->id = self::add_object([ + 'session_id' => $sessionId, + 'event_type' => $eventType->value, + 'payload_json' => $payload, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + + return $this; + } + + public function listBySession(int $sessionId): array + { + return $this->getFieldsWhere([ + 'session_id' => $sessionId, + ], ['id', 'event_type', 'payload_json', 'created_at']); + } +} diff --git a/services/nginx/app/objects/selfserve_wash_session_tasks_o.php b/services/nginx/app/objects/selfserve_wash_session_tasks_o.php new file mode 100644 index 00000000..fe6d5f53 --- /dev/null +++ b/services/nginx/app/objects/selfserve_wash_session_tasks_o.php @@ -0,0 +1,87 @@ +setTable('selfserve_wash_session_tasks'); + } + + public function getObjectProperties(): void + { + $this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false); + $this->task_id = new object_property($this->table, $this->id, 'task_id', 'int', false); + $this->task_text = new object_property($this->table, $this->id, 'task_text', 'string', false); + $this->description = new object_property($this->table, $this->id, 'description', 'string', false); + $this->services = new object_property($this->table, $this->id, 'services', 'json', false); + $this->buttons = new object_property($this->table, $this->id, 'buttons', 'json', false); + $this->dynamic_images_vehicle_type = new object_property($this->table, $this->id, 'dynamic_images_vehicle_type', 'string', 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 + { + // No dedicated cache invalidation yet. + } + + public function addSnapshot( + int $sessionId, + ?int $taskId, + string $taskText, + ?string $description = null, + ?array $services = null, + ?array $buttons = null, + ?int $thumb_position = null, + ): self { + $this->id = self::add_object([ + 'session_id' => $sessionId, + 'task_id' => $taskId, + 'task_text' => $taskText, + 'description' => $description, + 'services' => $services, + 'buttons' => $buttons, + 'dynamic_images_vehicle_type' => $thumb_position === null ? null : (int)$thumb_position, // The rotations to do on the image. + ]); + $this->getObjectProperties(); + $this->objectChanged(); + + return $this; + } + + public function deleteBySession(int $sessionId): void + { + global $db; + $db->query("DELETE FROM $this->table WHERE session_id = " . (int)$sessionId); + } + + public function listBySession(int $sessionId): array + { + return $this->getFieldsWhere([ + 'session_id' => $sessionId, + 'deleted_at' => null, + ], ['id', 'task_id', 'task_text', 'description', 'services', 'buttons', 'dynamic_images_vehicle_type']); + } +} diff --git a/services/nginx/app/objects/selfserve_wash_sessions_o.php b/services/nginx/app/objects/selfserve_wash_sessions_o.php new file mode 100644 index 00000000..88a7ee6a --- /dev/null +++ b/services/nginx/app/objects/selfserve_wash_sessions_o.php @@ -0,0 +1,295 @@ +setTable('selfserve_wash_sessions'); + } + + public function add( + int $laneId, + int $departmentId, + ?int $machineTypeId, + ?int $customerNumber, + string $reg, + ?int $vehicleId, + ?int $vehicleTypeId, + selfserve_wash_session_status $status, + bool $allowed = false, + ?array $metadata = null + ): self { + $this->id = self::add_object([ + 'lane_id' => $laneId, + 'department_id' => $departmentId, + 'machine_type_id' => $machineTypeId, + 'customer_number' => $customerNumber, + 'vehicle_id' => $vehicleId, + 'vehicle_type_id' => $vehicleTypeId, + 'reg' => selfserve::standardize_registration($reg), + 'status' => $status->value, + 'allowed' => $allowed, + 'metadata_json' => $metadata, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + + return $this; + } + + public function getObjectProperties(): void + { + $this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int', false); + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); + $this->customer_number = new object_property($this->table, $this->id, 'customer_number', 'int', false); + $this->vehicle_id = new object_property($this->table, $this->id, 'vehicle_id', 'int', false); + $this->vehicle_type_id = new object_property($this->table, $this->id, 'vehicle_type_id', 'int', false); + $this->reg = new object_property($this->table, $this->id, 'reg', 'string', false); + $this->status = new object_property($this->table, $this->id, 'status', 'string', false); + $this->allowed = new object_property($this->table, $this->id, 'allowed', 'bool', false); + $this->machine_relay_enabled = new object_property($this->table, $this->id, 'machine_relay_enabled', 'bool', false); + $this->machine_relay_enabled_at = new object_property($this->table, $this->id, 'machine_relay_enabled_at', 'datetime', false); + $this->machine_start_triggered = new object_property($this->table, $this->id, 'machine_start_triggered', 'bool', false); + $this->machine_start_triggered_at = new object_property($this->table, $this->id, 'machine_start_triggered_at', 'datetime', false); + $this->wash_started_at = new object_property($this->table, $this->id, 'wash_started_at', 'datetime', false); + $this->order_id = new object_property($this->table, $this->id, 'order_id', 'int', false); + $this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'datetime', 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 + { + // No dedicated cache invalidation yet. + } + + public function updateStatus(selfserve_wash_session_status $status): void + { + $this->status->set($status->value); + } + + public static function isTerminalStatus(?string $status): bool + { + return in_array(strtoupper(trim((string)$status)), self::TERMINAL_STATUSES, true); + } + + public static function terminalStatusSqlList(): string + { + return "'" . implode("','", array_map( + static fn(string $status): string => str_replace("'", "''", $status), + self::TERMINAL_STATUSES + )) . "'"; + } + + public function isOpen(): bool + { + return $this->completed_at->value() === null + && !self::isTerminalStatus((string)$this->status->value()); + } + + public function markRelayEnabled(): void + { + $now = date('Y-m-d H:i:s'); + $this->machine_relay_enabled->set(true); + $this->machine_relay_enabled_at->set($now); + $this->status->set(selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value); + } + + public function markRelayDisabled(): void + { + $this->machine_relay_enabled->set(false); + $this->machine_relay_enabled_at->set(null); + } + + public function markMachineStartTriggered(?string $washStartedAt = null): void + { + $now = date('Y-m-d H:i:s'); + $resolvedWashStartedAt = $washStartedAt ?? $now; + $this->machine_start_triggered->set(true); + $this->machine_start_triggered_at->set($now); + $this->wash_started_at->set($resolvedWashStartedAt); + if ((bool)$this->machine_relay_enabled->value() !== true) { + $this->machine_relay_enabled->set(true); + $this->machine_relay_enabled_at->set($now); + } + $this->status->set(selfserve_wash_session_status::MACHINE_STARTED->value); + } + + public function markCompleted(?int $orderId = null): void + { + $this->completed_at->set(date('Y-m-d H:i:s')); + if ($orderId !== null) { + $this->order_id->set($orderId); + } + $this->status->set(selfserve_wash_session_status::COMPLETED->value); + } + + public function markForceStopped(?int $orderId = null, ?array $metadata = null): void + { + $this->completed_at->set(date('Y-m-d H:i:s')); + if ($orderId !== null) { + $this->order_id->set($orderId); + } + if ($metadata !== null) { + $existing = $this->metadata_json->value(); + $existing = is_array($existing) ? $existing : []; + $existing['force_stop'] = $metadata; + $this->metadata_json->set($existing); + } + $this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value); + } + + public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self + { + $filters = [ + 'lane_id' => $laneId, + 'completed_at' => null, + 'deleted_at' => null, + ]; + if ($customerNumber !== null) { + $filters['customer_number'] = $customerNumber; + } + $rows = $this->getFieldsWhere($filters, ['id', 'status']); + $rows = array_values(array_filter( + $rows, + static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null) + )); + if ($rows === []) { + return $this; + } + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $this->select((int)$rows[0]['id']); + return $this; + } + + public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null): self + { + $filters = [ + 'lane_id' => $laneId, + 'reg' => selfserve::standardize_registration($reg), + 'completed_at' => null, + 'deleted_at' => null, + ]; + if ($customerNumber !== null) { + $filters['customer_number'] = $customerNumber; + } + $rows = $this->getFieldsWhere($filters, ['id', 'status']); + $rows = array_values(array_filter( + $rows, + static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null) + )); + if ($rows === []) { + return $this; + } + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $this->select((int)$rows[0]['id']); + return $this; + } + + public function selectLatestByLaneAndReg(int $laneId, string $reg): self + { + $rows = $this->getFieldsWhere([ + 'lane_id' => $laneId, + 'reg' => selfserve::standardize_registration($reg), + 'deleted_at' => null, + ], ['id']); + if ($rows === []) { + return $this; + } + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $this->select((int)$rows[0]['id']); + return $this; + } + + public function asArray(): array + { + return [ + 'id' => (int)$this->id, + 'lane_id' => (int)$this->lane_id->value(), + 'department_id' => (int)$this->department_id->value(), + 'machine_type_id' => $this->machine_type_id->value() === null ? null : (int)$this->machine_type_id->value(), + 'customer_number' => $this->customer_number->value() === null ? null : (int)$this->customer_number->value(), + 'vehicle_id' => $this->vehicle_id->value() === null ? null : (int)$this->vehicle_id->value(), + 'vehicle_type_id' => $this->vehicle_type_id->value() === null ? null : (int)$this->vehicle_type_id->value(), + 'reg' => (string)$this->reg->value(), + 'status' => (string)$this->status->value(), + 'allowed' => (bool)$this->allowed->value(), + 'machine_relay_enabled' => (bool)$this->machine_relay_enabled->value(), + 'machine_relay_enabled_at' => $this->machine_relay_enabled_at->value() === null ? null : (string)$this->machine_relay_enabled_at->value(), + 'machine_start_triggered' => (bool)$this->machine_start_triggered->value(), + 'machine_start_triggered_at' => $this->machine_start_triggered_at->value() === null ? null : (string)$this->machine_start_triggered_at->value(), + 'wash_started_at' => $this->wash_started_at->value() === null ? null : (string)$this->wash_started_at->value(), + 'order_id' => $this->order_id->value() === null ? null : (int)$this->order_id->value(), + 'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(), + 'metadata' => (array)($this->metadata_json->value() ?? []), + 'open' => $this->isOpen(), + 'created_at' => (string)$this->created_at->value(), + 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), + ]; + } + + public function getElapsedMinutes(): int + { + $startAt = $this->wash_started_at->value() ?? $this->machine_start_triggered_at->value(); + if ($startAt === null || trim((string)$startAt) === '') { + return 0; + } + + try { + $start = new DateTime((string)$startAt); + $endAt = $this->completed_at->value() ?? date('Y-m-d H:i:s'); + $end = new DateTime((string)$endAt); + } catch (\Throwable) { + return 0; + } + + if ($start > $end) { + return 0; + } + + $diff = $start->diff($end); + return (int)(($diff->days * 24 * 60) + ($diff->h * 60) + $diff->i); + } +} diff --git a/services/nginx/app/objects/stripe_module_orders_o.php b/services/nginx/app/objects/stripe_module_orders_o.php index 28c03626..813fe25e 100644 --- a/services/nginx/app/objects/stripe_module_orders_o.php +++ b/services/nginx/app/objects/stripe_module_orders_o.php @@ -93,4 +93,19 @@ class stripe_module_orders_o extends db return (new stripe())->invoice->retrieve($this->invoice_id->value()); } -} \ No newline at end of file + 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(); + } + +} diff --git a/services/nginx/app/objects/stripe_payment_intents_o.php b/services/nginx/app/objects/stripe_payment_intents_o.php index 0780078c..5d137d9b 100644 --- a/services/nginx/app/objects/stripe_payment_intents_o.php +++ b/services/nginx/app/objects/stripe_payment_intents_o.php @@ -26,25 +26,27 @@ class stripe_payment_intents_o extends db /** * Add a new payment intent - * @param int $order_id The id of the order - * @param string $payment_intent_id The id of the payment intent - * @param string $client_secret The client secret of the payment intent - * @param mixed $data The data to be added - * @param int|null $tax_percentage The tax percentage to be added - * @return void - * @throws Exception If the object was not created successfully + * @throws Exception */ - public function add(int $order_id, string $payment_intent_id, string $client_secret, mixed $data = null, int $tax_percentage = null): void - { - // Convert the data to a JSON string (If it's an object or array) + public function add( + int $order_id, + string $payment_intent_id, + string $client_secret, + mixed $data = null, + ?string $reader_id = null, + int $tax_percentage = null + ): void { if (is_object($data) || is_array($data)) { $data = json_encode($data); } + + $this->clearOrderPaymentIntents($order_id); $tmp_id = self::add_object([ 'order_id' => $order_id, 'payment_intent_id' => $payment_intent_id, 'client_secret' => $client_secret, 'data' => $data, + 'reader_id' => $reader_id, 'tax_percentage' => ($tax_percentage !== null) ? (int)$tax_percentage : 0, ]); $this->id = $tmp_id; @@ -58,53 +60,128 @@ class stripe_payment_intents_o extends db $this->payment_intent_id = new object_property($this->table, $this->id, 'payment_intent_id', 'string', false); $this->client_secret = new object_property($this->table, $this->id, 'client_secret', 'string', false); $this->data = new object_property($this->table, $this->id, 'data', 'string', false); - $this->reader_id = new object_property($this->table, $this->id, 'reader_id', 'int', false); + $this->reader_id = new object_property($this->table, $this->id, 'reader_id', 'string', false); $this->tax_percentage = new object_property($this->table, $this->id, 'tax_percentage', 'int', false); } public function objectChanged(): void { - //TODO: Add cache invalidation + // TODO: Add cache invalidation } - /** - * Check if an order has a payment intent - * @param int $order_id The id of the order - * @return bool True if the order has a payment intent, false otherwise - */ public function doesOrderHavePaymentIntent(int $order_id): bool { - return self::countRowsWhere([ - 'order_id' => $order_id, - ]) > 0; + return count($this->getOrderPaymentIntentRows($order_id)) > 0; } /** - * Get the payment intent id of an order - * @param int $order_id The id of the order - * @return self - * @throws Exception If the object was not selected + * @throws Exception */ public function selectOrderPaymentIntent(int $order_id): self { - $tmp = self::getFieldsWhere([ - 'order_id' => $order_id, - ], - ['id']); - if (count($tmp) > 0) { - $this->id = $tmp[0]['id']; - self::getObjectProperties(); - return $this; - } else { + $rows = $this->getOrderPaymentIntentRows($order_id); + if (count($rows) === 0) { throw new Exception('No payment intent found for order id: ' . $order_id); } + + $this->id = (int)$rows[0]['id']; + self::getObjectProperties(); + $this->deleteDuplicateOrderPaymentIntents($order_id, $this->id); + return $this; } /** - * There's no need to keep track of when the payment intent was created - * so we just forcefully delete it. - * @throws Exception If the object was not selected - * @throws Exception If the object was not deleted successfully + * @return array> + */ + public function getOrderPaymentIntentRows(int $order_id): array + { + $rows = self::getFieldsWhere( + [ + 'order_id' => $order_id, + ], + ['id', 'payment_intent_id', 'reader_id', 'tax_percentage'] + ); + + usort($rows, static fn(array $a, array $b): int => ((int)($b['id'] ?? 0)) <=> ((int)($a['id'] ?? 0))); + return $rows; + } + + public function clearOrderPaymentIntents(int $order_id, ?int $keepId = null): void + { + foreach ($this->getOrderPaymentIntentRows($order_id) as $row) { + $rowId = (int)($row['id'] ?? 0); + if ($rowId <= 0 || ($keepId !== null && $rowId === $keepId)) { + continue; + } + self::delete_object($this->getTable(), $rowId); + } + } + + public function updateStoredPaymentIntent(mixed $paymentIntent): void + { + self::requireSelected(); + + if (is_object($paymentIntent) && method_exists($paymentIntent, 'toJSON')) { + $encodedData = $paymentIntent->toJSON(); + } elseif (is_array($paymentIntent) || is_object($paymentIntent)) { + $encodedData = json_encode($paymentIntent); + } else { + $encodedData = (string)$paymentIntent; + } + + $this->data->set($encodedData); + + $normalizedClientSecret = null; + $normalizedReaderId = null; + $normalizedTaxPercentage = null; + + if (is_object($paymentIntent) || is_array($paymentIntent)) { + $metadata = is_array($paymentIntent) + ? ($paymentIntent['metadata'] ?? []) + : ($paymentIntent->metadata ?? null); + + $normalizedClientSecret = is_array($paymentIntent) + ? ($paymentIntent['client_secret'] ?? null) + : ($paymentIntent->client_secret ?? null); + + if (is_array($metadata)) { + $normalizedReaderId = $metadata['reader_id'] ?? $metadata['reader'] ?? null; + $normalizedTaxPercentage = $metadata['tax_percentage'] ?? null; + } elseif (is_object($metadata)) { + $normalizedReaderId = $metadata->reader_id ?? $metadata->reader ?? null; + $normalizedTaxPercentage = $metadata->tax_percentage ?? null; + } + } + + if (is_string($normalizedClientSecret) && $normalizedClientSecret !== '') { + $this->client_secret->set($normalizedClientSecret); + } + if ($normalizedReaderId !== null) { + $this->reader_id->set((string)$normalizedReaderId); + } + if ($normalizedTaxPercentage !== null && is_numeric($normalizedTaxPercentage)) { + $this->tax_percentage->set((int)$normalizedTaxPercentage); + } + + self::objectChanged(); + } + + public function setReaderId(?string $readerId): void + { + self::requireSelected(); + + if ($readerId === null || trim($readerId) === '') { + $this->reader_id->nullify(); + self::objectChanged(); + return; + } + + $this->reader_id->set(trim($readerId)); + self::objectChanged(); + } + + /** + * @throws Exception */ public function delete(): void { @@ -114,19 +191,52 @@ class stripe_payment_intents_o extends db } /** - * Cancel the payment intent on the reader - * @throws Exception If the object was not selected - * @throws Exception If the object was not deleted successfully + * @throws Exception */ public function cancelPaymentIntent(): void { self::requireSelected(); - // If a reader id is set, cancel the payment intent on the reader - if (!empty($this->reader_id->value())) { - $stripe = new stripe(); - $stripe->readers->sendCancelPaymentIntent($this->reader_id->value(), $this->payment_intent_id->value()); + $stripe = new stripe(); + + $readerId = trim((string)($this->reader_id->value() ?? '')); + if ($readerId !== '') { + try { + $stripe->readers->sendCancelPaymentIntent($readerId); + } catch (\Stripe\Exception\InvalidRequestException) { + // The reader may already be idle or missing. Clearing local state is sufficient. + } $this->reader_id->nullify(); + } + + $paymentIntentId = trim((string)($this->payment_intent_id->value() ?? '')); + if ($paymentIntentId === '') { + self::objectChanged(); + return; + } + + try { + $paymentIntent = $stripe->payment_intents->get($paymentIntentId); + } catch (\Stripe\Exception\InvalidRequestException) { + self::objectChanged(); + return; + } + + $status = strtolower((string)($paymentIntent->status ?? '')); + if (in_array($status, ['succeeded', 'canceled'], true)) { + $this->updateStoredPaymentIntent($paymentIntent); + return; + } + + try { + $cancelledPaymentIntent = $stripe->payment_intents->cancel($paymentIntentId); + $this->updateStoredPaymentIntent($cancelledPaymentIntent); + } catch (\Stripe\Exception\InvalidRequestException) { self::objectChanged(); } } -} \ No newline at end of file + + private function deleteDuplicateOrderPaymentIntents(int $order_id, int $keepId): void + { + $this->clearOrderPaymentIntents($order_id, $keepId); + } +} diff --git a/services/nginx/app/objects/subuser_grants_o.php b/services/nginx/app/objects/subuser_grants_o.php index 6278eb95..3c7b2641 100644 --- a/services/nginx/app/objects/subuser_grants_o.php +++ b/services/nginx/app/objects/subuser_grants_o.php @@ -22,14 +22,63 @@ class subuser_grants_o extends db public object_property $updated_at; public object_property $deleted_at; const defaultPermissions = [ - subusers_permission_node_key::VEHICLES_LIST, - subusers_permission_node_key::SELFSERVE_ADD, - subusers_permission_node_key::BOOKINGS_LIST, - subusers_permission_node_key::BOOKINGS_ADD, - subusers_permission_node_key::BOOKINGS_EDIT, - subusers_permission_node_key::BOOKINGS_DELETE, + 'VEHICLES_LIST', + 'SELFSERVE_ADD', + 'BOOKINGS_LIST', + 'BOOKINGS_ADD', + 'BOOKINGS_EDIT', + 'BOOKINGS_DELETE', ]; + public static function normalizePermissionsValue(mixed $raw): array + { + if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') { + return []; + } + + if ($raw instanceof subusers_permission_node_key) { + return [$raw->name]; + } + + if (is_array($raw)) { + $permissions = []; + $permissionCandidates = array_is_list($raw) + ? $raw + : array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled)); + + foreach ($permissionCandidates as $permission) { + if ($permission instanceof subusers_permission_node_key) { + $permission = $permission->name; + } + + if (!is_string($permission)) { + continue; + } + + $permission = strtoupper(trim($permission)); + if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) { + $permissions[] = $permission; + } + } + + return array_values(array_unique($permissions)); + } + + if (is_string($raw)) { + $decoded = json_decode($raw, true); + if (json_last_error() === JSON_ERROR_NONE) { + return self::normalizePermissionsValue($decoded); + } + + $permission = strtoupper(trim($raw)); + if (subusers_permission_node_key::tryFrom($permission) !== null) { + return [$permission]; + } + } + + return []; + } + public function structure(): void { @@ -62,23 +111,7 @@ class subuser_grants_o extends db 'subuser' => (int)$this->subuser->value(), 'enabled' => (bool)$this->enabled->value(), 'note' => $this->note->value(), - 'permissions' => (function ($raw) { - // Handle different representations from object_property: - // - When type is 'json', object_property::value() may already return an array - // - In older behavior, it could return a JSON string - // Normalize to an array for API output - if ($raw === null || $raw === '') { - return []; - } - if (is_array($raw)) { - return $raw; - } - if (is_string($raw)) { - $decoded = json_decode($raw, true); - return is_array($decoded) ? $decoded : []; - } - return []; - })($this->permissions->value()), + 'permissions' => self::normalizePermissionsValue($this->permissions->value()), 'created_at' => $this->created_at->value(), 'updated_at' => $this->updated_at->value(), 'deleted_at' => $this->deleted_at->value(), @@ -99,12 +132,13 @@ class subuser_grants_o extends db public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o { global $db; + $permissions = self::normalizePermissionsValue($permissions); $tmp = $this->add_object([ 'billing_customer_number' => (int)$billing_customer_number, 'subuser' => (int)$subuser, 'enabled' => (bool)$enabled, 'note' => !empty($note) ? $db->escape_string($note) : null, - 'permissions' => !empty($permissions) ? json_encode($permissions) : json_encode([]), + 'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); $this->id = (int)$tmp; $this->getObjectProperties(); @@ -122,11 +156,33 @@ class subuser_grants_o extends db // Extract permissions from the grants $permissions = []; foreach ($grants as $grant) { - $grant_permissions = json_decode($grant['permissions'], true); + $grant_permissions = self::normalizePermissionsValue($grant['permissions'] ?? null); if (is_array($grant_permissions)) { $permissions = array_merge($permissions, $grant_permissions); } } - return $permissions; + return array_values(array_unique($permissions)); } -} \ No newline at end of file + + public function getGrantForSubuserAndCustomer(int $subuser_id, int $customer_number, bool $includeDisabled = true): ?subuser_grants_o + { + $grants = self::getFieldsWhere([ + 'billing_customer_number' => $customer_number, + 'subuser' => $subuser_id, + 'deleted_at' => null, + ], ['id', 'enabled']); + + if (!$includeDisabled) { + $grants = array_values(array_filter($grants, static fn (array $grant): bool => (int)($grant['enabled'] ?? 0) === 1)); + } + + if (count($grants) === 0) { + return null; + } + + usort($grants, static fn (array $left, array $right): int => (int)$right['id'] <=> (int)$left['id']); + $grant = (new subuser_grants_o())->select((int)$grants[0]['id']); + $grant->getObjectProperties(); + return $grant; + } +} diff --git a/services/nginx/app/objects/subusers_o.php b/services/nginx/app/objects/subusers_o.php index 2a5f95a4..f7fd4b43 100644 --- a/services/nginx/app/objects/subusers_o.php +++ b/services/nginx/app/objects/subusers_o.php @@ -15,6 +15,11 @@ class subusers_o extends db { use db_object_t; + public const PASSWORD_MIN_LENGTH = 8; + public const PASSWORD_MAX_LENGTH = 255; + public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/'; + public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number'; + public object_property $username; public object_property $password; public object_property $name; @@ -62,6 +67,23 @@ class subusers_o extends db return (bool)$this->two_factor_enabled->value(); } + /** + * @throws Exception + */ + public static function assertValidPassword(string $password): void + { + if ( + strlen($password) < self::PASSWORD_MIN_LENGTH + || strlen($password) > self::PASSWORD_MAX_LENGTH + || !preg_match(self::PASSWORD_PATTERN, $password) + ) { + throw new Exception( + 'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH + . ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.' + ); + } + } + /** * @throws Exception */ @@ -103,11 +125,9 @@ class subusers_o extends db { global $db, $response; try { - if (!empty($password)) { - // Validate the password (at least 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number) - if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $password)) { - throw new Exception('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number.'); - } + $passwordWasProvided = !empty($password); + if ($passwordWasProvided) { + self::assertValidPassword($password); // Hash the password $password = password_hash($password, PASSWORD_DEFAULT); } @@ -163,6 +183,7 @@ class subusers_o extends db public function setPassword(string $password): self { self::requireSelected(); + self::assertValidPassword($password); $this->password->set((string)password_hash($password, PASSWORD_DEFAULT)); return $this; } @@ -261,6 +282,34 @@ class subusers_o extends db return $subuser; } + /** + * @throws Exception + */ + public function getSubuserByEmail(string $email): ?subusers_o + { + global $db; + $email = $db->escape_string($email); + $tmp = self::getFieldsWhere([ + 'email' => $email, + ], ['id']); + if (count($tmp) === 0) { + return null; + } + $subuser = (new subusers_o())->select((int)$tmp[0]['id']); + $subuser->getObjectProperties(); + return $subuser; + } + + /** + * @throws Exception + */ + public function requiresSetup(): bool + { + self::requireSelected(); + $password = $this->password->value(); + return !is_string($password) || trim($password) === ''; + } + /** * @throws RandomException * @throws Exception @@ -277,6 +326,11 @@ class subusers_o extends db return $session_token; } + public function invalidateSessionToken(string $token): void + { + $this->deleteCached('session_token:' . $token, 'subuser_sessions'); + } + /** * @param string $token The session token * @return subusers_o|null The subuser object or null if the token is invalid or expired @@ -332,4 +386,4 @@ class subusers_o extends db $grant = new subuser_user_grant((int)$this->id, (int)$customer_number); return $grant->hasNode($permission_node_key); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/user_key_value_pairs_o.php b/services/nginx/app/objects/user_key_value_pairs_o.php index 07e91fe0..d84d17e7 100644 --- a/services/nginx/app/objects/user_key_value_pairs_o.php +++ b/services/nginx/app/objects/user_key_value_pairs_o.php @@ -33,7 +33,7 @@ class user_key_value_pairs_o extends db public function setUser($user_id): user_key_value_pairs_o { - $this->user_id = $user_id; + $this->user_id = (int)$user_id; return $this; } @@ -41,6 +41,7 @@ class user_key_value_pairs_o extends db { global $db; self::requireSelected(); + $userId = (int)$this->user_id; // Avoid SQL injection $var = $db->escape_string($var); $val = $db->escape_string($val); @@ -48,9 +49,9 @@ class user_key_value_pairs_o extends db $exists = $this->getValue($var); // Create a new record in the database if it doesn't exist if ($exists !== null) { - $sql = "UPDATE $this->table SET val = '$val' WHERE user_id = $this->user_id AND var = '$var'"; + $sql = "UPDATE $this->table SET val = '$val' WHERE user_id = $userId AND var = '$var'"; } else { - $sql = "INSERT INTO $this->table (user_id, var, val) VALUES ($this->user_id, '$var', '$val')"; + $sql = "INSERT INTO $this->table (user_id, var, val) VALUES ($userId, '$var', '$val')"; } $db->query($sql); return $this; @@ -70,10 +71,11 @@ class user_key_value_pairs_o extends db { global $db; self::requireSelected(); + $userId = (int)$this->user_id; // Avoid SQL injection $var = $db->escape_string($var); // Get the record from the database - $sql = "SELECT val FROM $this->table WHERE user_id = $this->user_id AND var = '$var'"; + $sql = "SELECT val FROM $this->table WHERE user_id = $userId AND var = '$var'"; $result = $db->query($sql); if ($result->num_rows > 0) { return $db->fetch_assoc($result)['val']; @@ -85,10 +87,11 @@ class user_key_value_pairs_o extends db { global $db; self::requireSelected(); + $userId = (int)$this->user_id; // Avoid SQL injection $var = $db->escape_string($var); // Create a new record in the database - $sql = "DELETE FROM $this->table WHERE user_id = $this->user_id AND var = '$var'"; + $sql = "DELETE FROM $this->table WHERE user_id = $userId AND var = '$var'"; $db->query($sql); return $this; } @@ -97,8 +100,9 @@ class user_key_value_pairs_o extends db { global $db; self::requireSelected(); + $userId = (int)$this->user_id; // Get all the keys from the database - $sql = "SELECT var, val FROM $this->table WHERE user_id = $this->user_id"; + $sql = "SELECT var, val FROM $this->table WHERE user_id = $userId"; $result = $db->query($sql); $result = $db->fetch_all($result); $keys = []; @@ -111,8 +115,23 @@ class user_key_value_pairs_o extends db public function getCustomerNumbersWithKey(array $keys): array { global $db; + $safeKeys = []; + foreach ($keys as $key) { + if (!is_scalar($key)) { + continue; + } + $key = trim((string)$key); + if ($key === '') { + continue; + } + $safeKeys[] = "'" . $db->escape_string($key) . "'"; + } + $safeKeys = array_values(array_unique($safeKeys)); + if (empty($safeKeys)) { + return []; + } // Get all the users, and their customer numbers, with the given keys - $sql = "SELECT user_id, val FROM $this->table WHERE var IN ('" . implode("','", $keys) . "')"; + $sql = "SELECT user_id, val FROM $this->table WHERE var IN (" . implode(',', $safeKeys) . ")"; $result = $db->query($sql); $result = $db->fetch_all($result); $user_ids = []; @@ -124,6 +143,10 @@ class user_key_value_pairs_o extends db } $user_ids[] = (int)$row['user_id']; } + $user_ids = array_values(array_unique($user_ids)); + if (empty($user_ids)) { + return []; + } // Get the customer numbers from the users table $sql = "SELECT id, customer_number FROM users WHERE id IN (" . implode(',', $user_ids) . ")"; $result = $db->query($sql); @@ -136,4 +159,4 @@ class user_key_value_pairs_o extends db return $customer_numbers; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index 731079c2..242982dd 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -3,9 +3,11 @@ namespace objects; use classes\db; +use classes\customer_name_cache_payload_builder; use classes\object_property; use classes\redis; use classes\response; +use classes\system_search_economic_customer_index; use classes\xlvask; use customers\economic_customer_mo; use customers\economicCustomers; @@ -47,6 +49,10 @@ class users_o extends db $this->setTable('users'); } + private static function redisCache(): ?redis + { + return defined('redis') ? constant('redis') : null; + } public function edit(int $id, string $customer_number, string|null $role, string|null $password, string|null $display_name): void { @@ -58,12 +64,12 @@ class users_o extends db if ($old_res && $old_res->num_rows > 0) { $old_cn = (int)$old_res->fetch_assoc()['customer_number']; if ($old_cn !== 0 && $old_cn !== (int)$customer_number) { - redis->clear_user_id_from_customer_number($old_cn); + self::redisCache()?->clear_user_id_from_customer_number($old_cn); } } // Cache the mapping from customer_number to user_id (new value) - redis->cache_user_id_from_customer_number((int)$customer_number, $this->id); - redis->cache_customer_number_from_user_id($this->id, (int)$customer_number); + self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id); + self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number); // Avoid SQL injection $customer_number = $db->escape_string($customer_number); @@ -234,8 +240,8 @@ class users_o extends db $this->id = (int)$db->insert_id(); // Cache the mapping from customer_number to user_id - redis->cache_user_id_from_customer_number((int)$customer_number, $this->id); - redis->cache_customer_number_from_user_id($this->id, (int)$customer_number); + self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id); + self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number); // Set the values of the object properties $this->getObjectProperties(); @@ -319,21 +325,47 @@ class users_o extends db } else { $data = json_decode(file_get_contents('php://input'), true); } - // Check if the user id is set in the request - if (isset($data['user_id'])) { - return $this->getUserById((int)$data['user_id']); - } elseif (isset($data['customer_number'])) { - return $this->getUserByCustomerNumber($data['customer_number']); - } else { + if (!is_array($data)) { return $this; } + // Check if the user id is set in the request + $user_id = $this->parsePositiveIntFromRequest($data['user_id'] ?? null); + if ($user_id !== null) { + return $this->getUserById($user_id); + } + + $customer_number = $this->parsePositiveIntFromRequest($data['customer_number'] ?? null); + if ($customer_number !== null) { + return $this->getUserByCustomerNumber($customer_number); + } + + return $this; + } + + private function parsePositiveIntFromRequest(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (!is_string($value)) { + return null; + } + + $value = trim($value); + if ($value === '' || !ctype_digit($value)) { + return null; + } + + $parsed = (int)$value; + return $parsed > 0 ? $parsed : null; } public function getUserById(int $id): users_o { global $db; // Check Redis for existence (by checking if we have the customer number) - $customer_number = redis->get_customer_number_from_user_id($id); + $customer_number = self::redisCache()?->get_customer_number_from_user_id($id); if ($customer_number !== null) { $this->id = $id; $this->getObjectProperties(); @@ -347,7 +379,7 @@ class users_o extends db $this->id = $id; $customer_number = (int)$result->fetch_assoc()['customer_number']; // Cache the result - redis->cache_customer_number_from_user_id($id, $customer_number); + self::redisCache()?->cache_customer_number_from_user_id($id, $customer_number); $this->getObjectProperties(); } return $this; @@ -357,7 +389,7 @@ class users_o extends db { global $db; // Check Redis first - $user_id = redis->get_user_id_from_customer_number($customer_number); + $user_id = self::redisCache()?->get_user_id_from_customer_number($customer_number); if ($user_id !== null) { $this->id = (int)$user_id; $this->getObjectProperties(); @@ -370,7 +402,7 @@ class users_o extends db if ($result->num_rows > 0) { $this->id = (int)$result->fetch_assoc()['id']; // Cache the result - redis->cache_user_id_from_customer_number($customer_number, $this->id); + self::redisCache()?->cache_user_id_from_customer_number($customer_number, $this->id); $this->getObjectProperties(); } else { // Import the customer @@ -451,28 +483,65 @@ class users_o extends db // Create a temporary user object $tmp_user = new users_o(); $tmp_user->getUserByCustomerNumber($customer_number); + $fallbackName = null; + if ($tmp_user->exists()) { + $displayName = $tmp_user->display_name->value(); + if (is_string($displayName) && trim($displayName) !== '') { + $fallbackName = $displayName; + } + } // Get the customer name (Check cache first) $cached = $tmp_user->getCached('economic_customer'); if (!$cached) { - $tmp_user->getCustomerEcocomicData($customer_number); + try { + $tmp_user->getCustomerEcocomicData($customer_number); + } catch (Exception) { + return $fallbackName; + } $cached = $tmp_user->getCached('economic_customer'); } - if ($cached) { - return $cached->name; + $cachePayload = self::buildCustomerNameCachePayload($cached, $fallbackName); + if ($cachePayload !== null) { + return $cachePayload['name']; } - return null; + return $fallbackName; + } + + /** + * @return array{name:string}|null + */ + private static function buildCustomerNameCachePayload(mixed $cached_name, ?string $fallback_name): ?array + { + return customer_name_cache_payload_builder::build($cached_name, $fallback_name); } public function getCustomerEcocomicData(int $customer_number = null): users_o { - // Get the customer data from the external source - $economic = new economicCustomers(); // Check if the customer number is set if (!isset($this->customer_number) && $customer_number === null) { return $this; } - $customer_number = $customer_number ?? $this->customer_number->value(); - $this->economic_customer = (new economic_customer_mo())->getCustomerByCustomerNumber($customer_number); + + $customer_number = (int)($customer_number ?? $this->customer_number->value()); + if ($customer_number <= 0) { + $this->economic_customer = new economic_customer_mo(); + return $this; + } + + $cachedCustomer = $this->getCached('economic_customer'); + if (is_object($cachedCustomer)) { + $cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0); + if ($cachedCustomerNumber === $customer_number) { + $this->economic_customer = (new economic_customer_mo())->parseCustomer($cachedCustomer); + return $this; + } + } + + try { + $this->economic_customer = (new economic_customer_mo())->getCustomerByCustomerNumber($customer_number); + } catch (Exception) { + $this->economic_customer = new economic_customer_mo(); + } return $this; } @@ -1010,22 +1079,22 @@ class users_o extends db public function clearAllUsersEconomicCustomerDiscountsFromCache(): void { // Get all the cached results matching the pattern 'users_*_economic_customer_discount_percentage' - $cached_results = redis->get_keys('users_*_economic_customer_discount_percentage'); + $cached_results = self::redisCache()?->get_keys('users_*_economic_customer_discount_percentage') ?? []; // Loop through the cached results foreach ( $cached_results as $key ) { // Clear the cached discount percentage - redis->delete($key); + self::redisCache()?->delete($key); } } public function clearAllUsersEconomicCustomerDetailsFromCache(): void { // Get all the cached results matching the pattern 'users_*_economic_customer' - $cached_results = redis->get_keys('users_*_economic_customer'); + $cached_results = self::redisCache()?->get_keys('users_*_economic_customer') ?? []; // Loop through the cached results foreach ( $cached_results as $key ) { // Clear the cached economic customer details - redis->delete($key); + self::redisCache()?->delete($key); } } @@ -1033,7 +1102,7 @@ class users_o extends db { self::requireSelected(); // Check if the discount percentage is cached - $cached_discount_percentage = redis->get_economic_customer_discount_percentage($this->id); + $cached_discount_percentage = self::redisCache()?->get_economic_customer_discount_percentage($this->id); if ($cached_discount_percentage !== null) { return $cached_discount_percentage; } @@ -1041,7 +1110,7 @@ class users_o extends db $economic = new economicCustomers(); $discount_percentage = $economic->getCustomerDiscountPercentage($this->customer_number->value()); // Cache the discount percentage - redis->cache_economic_customer_discount_percentage($this->id, $discount_percentage); + self::redisCache()?->cache_economic_customer_discount_percentage($this->id, $discount_percentage); return $discount_percentage; } @@ -1080,7 +1149,7 @@ class users_o extends db public function isImportedFromEconomic($customerNumber): bool { // Check Redis first - $user_id = redis->get_user_id_from_customer_number((int)$customerNumber); + $user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber); if ($user_id !== null) { return true; } @@ -1095,7 +1164,7 @@ class users_o extends db public function getUserIdFromEconomic($customerNumber): int { // Check Redis first - $user_id = redis->get_user_id_from_customer_number((int)$customerNumber); + $user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber); if ($user_id !== null) { return (int)$user_id; } @@ -1105,7 +1174,7 @@ class users_o extends db $id = (int)$user[0]['id']; // Cache the result - redis->cache_user_id_from_customer_number((int)$customerNumber, $id); + self::redisCache()?->cache_user_id_from_customer_number((int)$customerNumber, $id); return $id; } @@ -1180,36 +1249,41 @@ class users_o extends db public function getCustomerNumbersWithAttributes(array $attributes): array { global $db; - // Create an array to store the customer numbers - $user_ids = []; - $customer_numbers = []; - // Loop through the attributes - foreach ( $attributes as $attribute ) { - // Get the customer numbers with the attribute - $sql = "SELECT user_id FROM customer_attributes WHERE attribute = '$attribute'"; - $result = $db->query($sql); - // Loop through the results - while ($row = $result->fetch_assoc()) { - // Add the customer number to the array - $user_ids[] = (int)$row['user_id']; + $safeAttributes = []; + foreach ($attributes as $attribute) { + if (!is_scalar($attribute)) { + continue; } - } - // Remove duplicates from the array - $user_ids = array_unique($user_ids); - // Get the customer numbers from the user IDs - foreach ( $user_ids as $user_id ) { - // Get the customer number from the user ID - $sql = "SELECT customer_number FROM $this->table WHERE id = $user_id"; - $result = $db->query($sql); - // Loop through the results - while ($row = $result->fetch_assoc()) { - // Add the customer number to the array - $customer_numbers[] = (int)$row['customer_number']; + $attribute = trim((string)$attribute); + if ($attribute === '') { + continue; } + $safeAttributes[] = "'" . $db->escape_string($attribute) . "'"; } - // Remove duplicates from the array - // Return the customer numbers - return array_unique($customer_numbers); + $safeAttributes = array_values(array_unique($safeAttributes)); + if (empty($safeAttributes)) { + return []; + } + + $userIds = []; + $sql = "SELECT DISTINCT user_id FROM customer_attributes WHERE attribute IN (" . implode(',', $safeAttributes) . ")"; + $result = $db->query($sql); + while ($row = $result->fetch_assoc()) { + $userIds[] = (int)$row['user_id']; + } + $userIds = array_values(array_unique($userIds)); + if (empty($userIds)) { + return []; + } + + $customerNumbers = []; + $sql = "SELECT customer_number FROM $this->table WHERE id IN (" . implode(',', array_map('intval', $userIds)) . ")"; + $result = $db->query($sql); + while ($row = $result->fetch_assoc()) { + $customerNumbers[] = (int)$row['customer_number']; + } + + return array_values(array_unique($customerNumbers)); } /** @@ -1299,8 +1373,8 @@ class users_o extends db public function getCustomersWithVehicleSubscriptions(): array { global $db; - // Get all customers with vehicle subscriptions - $sql = "SELECT DISTINCT customer_id FROM customer_vehicles WHERE wash_subscription = 1"; + // Get all customers with vehicle subscriptions and not fixed pricing + $sql = "SELECT DISTINCT customer_id FROM customer_vehicles WHERE wash_subscription = 1 AND customer_id NOT IN (SELECT customer_number FROM customer_fixed_pricing)"; $result = $db->query($sql); $customer_numbers = []; while ($row = $result->fetch_assoc()) { @@ -1462,16 +1536,21 @@ class users_o extends db * @param int[] $customer_numbers * @return array Map of customer number to customer name */ - public function getCustomerNames(array $customer_numbers): array + public function getCustomerNames(array $customer_numbers, bool $allowExternalFetch = true): array { global $db; $customer_numbers = array_map('intval', $customer_numbers); + if (empty($customer_numbers)) { + return []; + } + // Look in the cache first $customer_numbers_to_fetch = []; $customer_names_cached = self::getCachedForMultipleObjects('economic_customer_name', $customer_numbers); // Loop through the customer numbers and check if they are cached $customer_names = array_map(function ($cached_name) { - return $cached_name ? json_decode($cached_name)->name : null; + $cache_payload = self::buildCustomerNameCachePayload($cached_name, null); + return $cache_payload['name'] ?? null; }, array_values($customer_names_cached)); // Set the names for the cached customer numbers [ "customer_number" => "customer_name" ] $customer_names = array_combine( @@ -1484,29 +1563,49 @@ class users_o extends db $customer_numbers_to_fetch[] = (int)$customer_number; } } + $fallback_names = $this->getLocalDisplayNamesByCustomerNumber($customer_numbers_to_fetch); + $local_cached_names = $this->getCachedEconomicCustomerNamesByCustomerNumber($customer_numbers_to_fetch); + if (!$allowExternalFetch) { + foreach ($customer_numbers_to_fetch as $customer_number) { + $customer_names[(string)$customer_number] = $local_cached_names[$customer_number] ?? $fallback_names[$customer_number] ?? 'Unknown Customer'; + } + return $customer_names; + } + // Fetch the remaining customer names from E-conomic if (count($customer_numbers_to_fetch) > 0) { foreach ( $customer_numbers_to_fetch as $customer_number ) { + if (isset($local_cached_names[$customer_number])) { + $customer_names[(string)$customer_number] = $local_cached_names[$customer_number]; + continue; + } // Get the customer name from the external source + $fallback_name = $fallback_names[$customer_number] ?? null; try { // Try to get the economic customer data cached in the user $tmp_user = new users_o(); $tmp_user->getUserByCustomerNumber($customer_number); + if ($tmp_user->exists()) { + $display_name = $tmp_user->display_name->value(); + if (is_string($display_name) && trim($display_name) !== '') { + $fallback_name = $display_name; + } + } $cached_name = $tmp_user->getCached('economic_customer'); // If not cached, fetch from E-conomic if (!$cached_name) { $tmp_user->getCustomerEcocomicData($customer_number); $cached_name = $tmp_user->getCached('economic_customer'); } - if ($cached_name) { - $customer_names[(string)$customer_number] = $cached_name->name; + $cache_payload = self::buildCustomerNameCachePayload($cached_name, $fallback_name); + if ($cache_payload !== null) { + $customer_names[(string)$customer_number] = $cache_payload['name']; + $this->cache('economic_customer_name', $cache_payload, $customer_number); + $this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number); } - // Cache the name - $this->cache('economic_customer_name', $cached_name, $customer_number); - $this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number); } catch ( Exception $e ) { // Ignore exceptions - $customer_names[(string)$customer_number] = 'Unable to fetch name'; + $customer_names[(string)$customer_number] = $fallback_name ?? 'Unable to fetch name'; } } } @@ -1514,6 +1613,195 @@ class users_o extends db return $customer_names; } + /** + * @param int[] $customer_numbers + * @return array + */ + private function getLocalDisplayNamesByCustomerNumber(array $customer_numbers): array + { + global $db; + + $customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0))); + if (empty($customer_numbers)) { + return []; + } + + $sql = "SELECT customer_number, display_name FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")"; + $result = $db->query($sql); + if (!$result) { + return []; + } + + $names = []; + while ($row = $result->fetch_assoc()) { + $customer_number = (int)($row['customer_number'] ?? 0); + $display_name = trim((string)($row['display_name'] ?? '')); + if ($customer_number > 0 && $display_name !== '') { + $names[$customer_number] = $display_name; + } + } + + return $names; + } + + /** + * Resolve names from local e-conomic snapshots only. This keeps period/listing + * requests fast while still avoiding "Unnamed" fallbacks when a richer cached + * e-conomic customer payload already exists. + * + * @param int[] $customer_numbers + * @return array + */ + private function getCachedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array + { + global $db; + + $customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0))); + if (empty($customer_numbers)) { + return []; + } + + $sql = "SELECT id, customer_number FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")"; + $result = $db->query($sql); + if (!$result) { + return $this->getIndexedEconomicCustomerNamesByCustomerNumber($customer_numbers); + } + + $user_ids_by_customer_number = []; + while ($row = $result->fetch_assoc()) { + $customer_number = (int)($row['customer_number'] ?? 0); + $user_id = (int)($row['id'] ?? 0); + if ($customer_number <= 0 || $user_id <= 0) { + continue; + } + + $user_ids_by_customer_number[$customer_number] = $user_id; + } + + $names = []; + $customer_numbers_by_index = array_keys($user_ids_by_customer_number); + $cached_names = $this->getCachedForMultipleObjects('economic_customer', array_values($user_ids_by_customer_number)); + foreach ($customer_numbers_by_index as $index => $customer_number) { + $cached_name = $cached_names[$index] ?? null; + $cache_payload = self::buildCustomerNameCachePayload($cached_name, null); + if ($cache_payload === null) { + continue; + } + + $names[$customer_number] = $cache_payload['name']; + $this->cache('economic_customer_name', $cache_payload, $customer_number); + $this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number); + } + + $missing_customer_numbers = array_values(array_diff($customer_numbers, array_keys($names))); + if (!empty($missing_customer_numbers)) { + foreach ($this->getIndexedEconomicCustomerNamesByCustomerNumber($missing_customer_numbers) as $customer_number => $name) { + $cache_payload = self::buildCustomerNameCachePayload((object)['name' => $name], null); + if ($cache_payload === null) { + continue; + } + + $names[$customer_number] = $cache_payload['name']; + $this->cache('economic_customer_name', $cache_payload, $customer_number); + $this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number); + } + } + + return $names; + } + + /** + * @param int[] $customer_numbers + * @return array + */ + private function getIndexedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array + { + global $db; + + $customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0))); + if (empty($customer_numbers)) { + return []; + } + + try { + system_search_economic_customer_index::ensureTable(); + } catch (\Throwable) { + return []; + } + + $result = $db->query( + "SELECT customer_number, economic_name FROM `" . system_search_economic_customer_index::TABLE . "`" + . " WHERE customer_number IN (" . implode(',', $customer_numbers) . ")" + ); + if (!$result) { + return []; + } + + $names = []; + while ($row = $result->fetch_assoc()) { + $customer_number = (int)($row['customer_number'] ?? 0); + $cache_payload = self::buildCustomerNameCachePayload((object)['name' => $row['economic_name'] ?? null], null); + if ($customer_number > 0 && $cache_payload !== null) { + $names[$customer_number] = $cache_payload['name']; + } + } + + return $names; + } + + /** + * @param int[] $cashier_ids + * @return array Map of cashier id => display name + */ + public function getCashierNames(array $cashier_ids): array + { + $cashier_ids = array_values(array_unique(array_filter(array_map('intval', $cashier_ids), static fn(int $id): bool => $id > 0))); + if (empty($cashier_ids)) { + return []; + } + + $cache_key = 'cashier_name'; + $virtual_cache_ids = array_map(static fn(int $id): string => "cashier_$id", $cashier_ids); + $cached_values = $this->getCachedForMultipleObjects($cache_key, $virtual_cache_ids); + + $names = []; + $missing_ids = []; + foreach ($cashier_ids as $index => $cashier_id) { + $cached_name = $cached_values[$index] ?? null; + if ($cached_name !== null && $cached_name !== '') { + $names[$cashier_id] = (string)$cached_name; + continue; + } + $missing_ids[] = $cashier_id; + } + + if (!empty($missing_ids)) { + $rows = $this->getFieldsWhereIn( + ['id' => $missing_ids], + ['id', 'display_name'] + ); + $fetched_names = []; + foreach ($rows as $row) { + $cashier_id = (int)($row['id'] ?? 0); + if ($cashier_id <= 0) { + continue; + } + $display_name = trim((string)($row['display_name'] ?? '')); + $fetched_names[$cashier_id] = ($display_name !== '') ? $display_name : 'Unknown Cashier'; + } + + foreach ($missing_ids as $cashier_id) { + $resolved_name = $fetched_names[$cashier_id] ?? 'Unknown Cashier'; + $names[$cashier_id] = $resolved_name; + $virtual_cache_object_id = "cashier_$cashier_id"; + $this->cache($cache_key, $resolved_name, $virtual_cache_object_id); + $this->setCachedExpiration($cache_key, self::$cashierNameCacheExpiration, $virtual_cache_object_id); + } + } + + return $names; + } + public function getCashierName(int $cashier_id): string { $virtualCacheObjectID = "cashier_$cashier_id"; @@ -1562,4 +1850,4 @@ class users_o extends db return "https://truckwash.io/auth/password-reset/" . $token; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/xlvask_usage_logs_o.php b/services/nginx/app/objects/xlvask_usage_logs_o.php index 6d1c6d49..ac30d7d9 100644 --- a/services/nginx/app/objects/xlvask_usage_logs_o.php +++ b/services/nginx/app/objects/xlvask_usage_logs_o.php @@ -5,6 +5,7 @@ namespace objects; use classes\db; use classes\object_property; use classes\xlvask; +use classes\xlvask_usage_logs_schema_bootstrap; use Exception; use helpers\xlvask_customer; use helpers\xlvask_usage_log; @@ -35,9 +36,13 @@ class xlvask_usage_logs_o extends db public object_property $CustomerGuid; public object_property $VehicleId; public object_property $WashItems; + public object_property $ignored_at; + public object_property $ignored_by; + public object_property $ignored_reason; public function structure(): void { + xlvask_usage_logs_schema_bootstrap::ensureTables(); $this->setTable('xlvask_usage_logs'); } @@ -77,6 +82,9 @@ class xlvask_usage_logs_o extends db $this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false); $this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false); $this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false); + $this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false); + $this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false); + $this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false); } public function objectChanged(): void @@ -84,6 +92,107 @@ class xlvask_usage_logs_o extends db //TODO: Add cache invalidation } + public function getCachedAmountSummaryFromRow(array $row): array + { + $cached_amount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null); + $cached_at = trim((string)($row['cached_amount_at'] ?? '')); + + if ($cached_amount !== null && $cached_at !== '') { + return [ + 'total_net_amount' => $cached_amount, + 'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''), + 'cached' => true, + ]; + } + + $summary = self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []); + $id = (int)($row['id'] ?? 0); + if ($id > 0) { + self::cacheAmountSummary($id, $summary); + } + + return [ + ...$summary, + 'cached' => false, + ]; + } + + public static function calculateAmountSummaryFromWashItems(array|string|null $washItems): array + { + if (is_string($washItems)) { + $decoded = json_decode($washItems, true); + $washItems = is_array($decoded) ? $decoded : []; + } + + $total = 0.0; + $primaryProductName = ''; + foreach (is_array($washItems) ? $washItems : [] as $item) { + if (!is_array($item)) { + continue; + } + + if ($primaryProductName === '' && isset($item['OriginalProductName'])) { + $primaryProductName = trim((string)$item['OriginalProductName']); + } + + $priceIncVat = self::normalizeMoneyValue($item['PriceIncVat'] ?? null); + $vat = self::normalizeMoneyValue($item['Vat'] ?? 0.0) ?? 0.0; + if ($priceIncVat === null) { + continue; + } + + $total += $priceIncVat - $vat; + } + + return [ + 'total_net_amount' => round($total, 2), + 'primary_product_name' => $primaryProductName, + ]; + } + + private static function cacheAmountSummary(int $id, array $summary): void + { + global $db; + + if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) { + return; + } + + $amount = number_format((float)($summary['total_net_amount'] ?? 0.0), 2, '.', ''); + $primaryProductName = $db->escape_string((string)($summary['primary_product_name'] ?? '')); + $db->query( + "UPDATE xlvask_usage_logs + SET cached_total_net_amount = {$amount}, + cached_primary_product_name = " . ($primaryProductName === '' ? 'NULL' : "'{$primaryProductName}'") . ", + cached_amount_at = NOW() + WHERE id = {$id}" + ); + } + + private static function normalizeMoneyValue(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + + if (is_int($value) || is_float($value)) { + return (float)$value; + } + + $normalized = preg_replace('/[^\d,.\-]/', '', (string)$value); + if ($normalized === null || $normalized === '') { + return null; + } + + if (str_contains($normalized, ',') && !str_contains($normalized, '.')) { + $normalized = str_replace(',', '.', $normalized); + } else { + $normalized = str_replace(',', '', $normalized); + } + + return is_numeric($normalized) ? (float)$normalized : null; + } + /** * Import the usage logs from XL Vask * @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days' @@ -150,4 +259,4 @@ class xlvask_usage_logs_o extends db )); return $vehicles; } -} \ No newline at end of file +} diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml new file mode 100644 index 00000000..1fa7d0b2 --- /dev/null +++ b/services/nginx/app/openapi.yaml @@ -0,0 +1,21324 @@ +openapi: 3.0.3 +info: + title: Copenhagen Truck Wash API + description: | + This API provides access to the Copenhagen Truck Wash system, managing orders, bookings, + departments, products, customers, and various integrations including e-conomic, Stripe, + XLVask, and more. + + ## Authentication + Most endpoints require authentication using a Bearer token obtained from the `/auth/login` + or `/auth/employee/login` endpoints. + + ## Permissions + Many endpoints require specific permissions that are assigned to user groups/roles. + + ## Subusers and customer targeting + When authenticated as a subuser, most customer-scoped endpoints require an explicit target + customer context. Provide the header `X-Customer-Number: ` to target a + specific customer. If omitted, the API attempts to infer the customer from the authenticated + user context when possible. Classic user sessions ignore this header. + version: 1.0.0 + contact: + name: Copenhagen Truck Wash + email: support@truckwash.dk +servers: + - url: https://api.truckwash.dk + description: Production server (.dk) + - url: https://api.truckwash.io + description: Production server (.io) + - url: http://localhost/api + description: Local development server + +security: + - BearerAuth: [] + +tags: + - name: Authentication + description: User and employee authentication endpoints + - name: Security + description: Account security and passkey management endpoints + - name: Users + description: User management and customer operations + - name: Search + description: System-wide search endpoints + - name: Orders + description: Order creation, management, and retrieval + - name: Order Items + description: Managing items within orders + - name: Bookings + description: Booking management for wash services + - name: Departments + description: Department and location management + - name: Products + description: Product catalog and pricing + - name: Categories + description: Product category management + - name: Invoices + description: Invoice generation and management + - name: Payments + description: Payment processing and collection + - name: Vehicles + description: Vehicle registration and management + - name: Notifications + description: System notifications and alerts + - name: Statistics + description: Business analytics and reporting + - name: Modules + description: Third-party integrations and modules + - name: Attachments + description: File upload and attachment management + - name: Forms + description: Form submissions and management + - name: Worker + description: System worker status and maintenance + - name: Error Reports + description: Authenticated application error reporting + - name: Plate Scans + description: License plate scanning operations + - name: Config + description: Module configuration management + - name: Release Manager + description: Release channel, deployment, and operation management + - name: Branding + description: Branding options management + - name: Roles + description: Role and permission management + - name: Self-Serve + description: Self-serve lane operations and questions + - name: Goals + description: Department goals management + - name: Subusers + description: Subuser registration and setup + - name: Bird + description: Voice Calls via Bird + +paths: + /error-reports: + post: + tags: + - Error Reports + summary: Submit an authenticated user error report + operationId: submitErrorReport + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportSubmissionRequest' + responses: + '201': + description: Error report submitted + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /superuser/error-reports: + get: + tags: + - Error Reports + summary: List error reports for superusers + operationId: listSuperuserErrorReports + security: + - BearerAuth: [] + parameters: + - name: status + in: query + required: false + schema: + type: string + enum: [open, resolved, all] + default: open + - name: q + in: query + required: false + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Error reports retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportListResponse' + '403': + description: Missing superuser error report permission + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /superuser/error-reports/{id}: + get: + tags: + - Error Reports + summary: Get an error report detail + operationId: getSuperuserErrorReport + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Error report retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportResponse' + '404': + description: Error report not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /superuser/error-reports/{id}/status: + patch: + tags: + - Error Reports + summary: Mark an error report open or resolved + operationId: updateSuperuserErrorReportStatus + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportStatusUpdateRequest' + responses: + '200': + description: Error report status updated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReportResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Missing superuser error report resolve permission + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + # Bird Voice Calls + /bird/voice/calls: + post: + tags: + - Bird + summary: Create/place a voice call via Bird + operationId: birdCreateVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallCreateRequest' + responses: + '200': + description: Call created + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + get: + tags: + - Bird + summary: List voice calls + operationId: birdListVoiceCalls + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: status + schema: + type: string + - in: query + name: type + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: direction + schema: + type: string + - in: query + name: id + schema: + type: string + format: uuid + - in: query + name: tag + schema: + oneOf: + - type: string + - type: array + items: + type: string + responses: + '200': + description: A list of calls + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallListResponse' } + + /bird/voice/calls/log: + get: + tags: + - Bird + summary: List workspace call log entries + operationId: birdListVoiceCallsLog + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: channelId + schema: + oneOf: + - type: string + - type: array + items: + type: string + format: uuid + - in: query + name: status + schema: + type: string + - in: query + name: type + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: direction + schema: + type: string + - in: query + name: id + schema: + type: string + format: uuid + - in: query + name: tag + schema: + oneOf: + - type: string + - type: array + items: + type: string + responses: + '200': + description: Workspace call log entries + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallsLogResponse' } + + /bird/voice/calls/{id}: + get: + tags: + - Bird + summary: Get a voice call by ID + operationId: birdGetVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Call details + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + patch: + tags: + - Bird + summary: Update a voice call by ID + operationId: birdUpdateVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallUpdateRequest' + responses: + '200': + description: Call update accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + + /bird/voice/calls/{id}/answer: + post: + tags: + - Bird + summary: Answer an incoming voice call by ID + operationId: birdAnswerVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallAnswerRequest' + responses: + '200': + description: Answer command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/ringing: + post: + tags: + - Bird + summary: Mark voice call as ringing by ID + operationId: birdRingingVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRingingRequest' + responses: + '200': + description: Ringing command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/hangup: + post: + tags: + - Bird + summary: Hang up a voice call by ID + operationId: birdHangupVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallHangupRequest' + responses: + '200': + description: Hangup requested + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/playback: + post: + tags: + - Bird + summary: Playback media on a voice call by ID + operationId: birdPlaybackVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallPlaybackRequest' + responses: + '200': + description: Playback command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/say: + post: + tags: + - Bird + summary: Say a message on an active voice call and optionally hang up + operationId: birdSayOnVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallSayRequest' + responses: + '200': + description: Say command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/gather: + post: + tags: + - Bird + summary: Gather input from a voice call by ID + operationId: birdGatherVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallGatherRequest' + responses: + '200': + description: Gather command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/bridge: + post: + tags: + - Bird + summary: Bridge a voice call by ID + operationId: birdBridgeVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallBridgeRequest' + responses: + '200': + description: Bridge command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallBridgeResponse' } + + /bird/voice/calls/{id}/record: + post: + tags: + - Bird + summary: Record call audio by ID + operationId: birdRecordVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordRequest' + responses: + '200': + description: Record command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/recordings: + post: + tags: + - Bird + summary: Create a call recording session + operationId: birdCreateVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordingCreateRequest' + responses: + '200': + description: Recording session created + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + get: + tags: + - Bird + summary: List call recordings for a voice call + operationId: birdListVoiceCallRecordings + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + responses: + '200': + description: List of call recordings + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingListResponse' } + + /bird/voice/calls/{id}/recordings/{recordingId}: + get: + tags: + - Bird + summary: Get a single call recording + operationId: birdGetVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: recordingId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Call recording details + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + patch: + tags: + - Bird + summary: Update call recording state + operationId: birdUpdateVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: recordingId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordingUpdateRequest' + responses: + '200': + description: Recording update accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + + /bird/voice/calls/{id}/insights: + get: + tags: + - Bird + summary: Get voice call insights + operationId: birdGetVoiceCallInsights + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Voice call insights + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallInsightsResponse' } + + /bird/voice/calls/test-outbound: + post: + tags: + - Bird + summary: Place a test outbound call and hang up when accepted + operationId: birdTestOutboundVoiceCall + description: Calls +45 42 33 11 28 and hangs up when the call reaches accepted/ongoing state. + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdTestOutboundCallRequest' + responses: + '200': + description: Test call created and either hung up or timed out + content: + application/json: + schema: { $ref: '#/components/schemas/BirdTestOutboundCallResponse' } + + /bird/voice/calls/webhook/inbound: + post: + tags: + - Bird + summary: Process inbound Bird voice call lifecycle + operationId: birdInboundVoiceCallWebhook + description: > + Stateful inbound-call webhook that owns department and gate selection for phone-controlled + gates. The preferred Bird Flow Builder integration is the native-flow mode: + send top-level `callId`, `channelId`, and `workspaceId` to fetch IVR prompt data, + then submit the selected DTMF digits as `keys` in a follow-up request. In this mode + the webhook returns a plain `200 OK` JSON body with `prompt`, `stage`, and `gather` + settings that Bird native voice steps can consume directly. For backward compatibility, + the webhook also supports the older `{ payload, request, waitConditions }` contract and + returns a raw `202 Accepted` Bird `callCommand` gather envelope that resumes on + `call_command_gather_finished`. The Bird Flow itself must answer the inbound call before + invoking this HTTP step; the backend answer attempt is only a best-effort fallback. + Department options are generated from `department_gates` records with `config.type=PHONE_CALL`, + ordered by `departments.order_priority`, and support multi-digit DTMF selections such as `10#`. + After a department is chosen, the webhook returns compact gate options, for example `Press 1 for exit` + when exit is the only available phone-controlled gate for that department. When a valid gate is confirmed, + the webhook opens the gate through the corresponding `department_gates` phone-call record and returns + a `200 OK` completion result. + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (fallbacks to payload/state/module configuration) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (fallbacks to payload/state/module configuration) + - in: query + name: callId + schema: + type: string + required: false + description: Bird Call identifier (fallbacks to payload fields) + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdInboundCallWebhookRequest' + responses: + '200': + description: Native-flow gather data or gate action result for this webhook invocation + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/BirdInboundCallWebhookFlowGatherResponse' + - $ref: '#/components/schemas/BirdInboundCallWebhookActionResultResponse' + '202': + description: Gather command accepted and returned to Bird + content: + application/json: + schema: + $ref: '#/components/schemas/BirdInboundCallWebhookGatherAcceptedResponse' + '400': + description: Malformed Bird webhook payload + content: + application/json: + schema: + $ref: '#/components/schemas/BirdInboundCallWebhookTransportErrorResponse' + + # Bird Numbers + /bird/numbers: + get: + tags: + - Bird + summary: List your numbers + operationId: birdListNumbers + parameters: + - in: query + name: workspaceId + required: false + schema: + type: string + format: uuid + description: Bird Workspace identifier (optional if configured) + - in: query + name: page + required: false + schema: + type: integer + - in: query + name: limit + required: false + schema: + type: integer + responses: + '200': + description: A list of numbers + content: + application/json: + schema: + $ref: '#/components/schemas/BirdNumberListResponse' + + /bird/numbers/{id}: + get: + tags: + - Bird + summary: Get a number by ID + operationId: birdGetNumber + parameters: + - in: query + name: workspaceId + required: false + schema: + type: string + format: uuid + description: Bird Workspace identifier (optional if configured) + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: Number details + content: + application/json: + schema: + $ref: '#/components/schemas/BirdNumberSingleResponse' + delete: + tags: + - Bird + summary: Delete/release a number by ID + operationId: birdDeleteNumber + parameters: + - in: query + name: workspaceId + required: false + schema: + type: string + format: uuid + description: Bird Workspace identifier (optional if configured) + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: Number deletion/release accepted + content: + application/json: + schema: + type: object + additionalProperties: true + + # Bird Voice Flash Calling + /bird/voice/flash-calls: + post: + tags: + - Bird + summary: Create/place a flash call via Bird + operationId: birdCreateFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallCreateRequest' + responses: + '200': + description: Flash call created + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallSingleResponse' + get: + tags: + - Bird + summary: List flash calls + operationId: birdListFlashCalls + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: status + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: id + schema: + type: string + format: uuid + responses: + '200': + description: A list of flash calls + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallListResponse' + + /bird/voice/flash-calls/{id}: + get: + tags: + - Bird + summary: Get a flash call by ID + operationId: birdGetFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Flash call details + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallSingleResponse' + post: + tags: + - Bird + summary: Complete/end a flash call by ID + operationId: birdEndFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallEndRequest' + responses: + '200': + description: Flash call completed + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallSingleResponse' + + /bird/voice/flash-calls/hangup: + post: + tags: + - Bird + summary: Hang up flash calls using payload criteria + operationId: birdHangupFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupRequest' + responses: + '200': + description: Flash call hangup accepted + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupResponse' + + /bird/voice/flash-calls/end: + post: + tags: + - Bird + summary: Compatibility alias for flash hangup endpoint + description: Deprecated alias for `/bird/voice/flash-calls/hangup`. + deprecated: true + operationId: birdEndFlashCallByNumbers + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupRequest' + responses: + '200': + description: Flash call hangup accepted (alias) + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupResponse' + + # Subusers (public registration + setup) + /subusers: + get: + tags: + - Subusers + summary: List subusers visible to the authenticated user + description: | + Returns a paginated list of subusers (drivers) that have enabled grants tied to the + authenticated user's customer number. Only subusers with at least one enabled, non-deleted + grant for the caller's customer are returned. + operationId: listSubusers + parameters: + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 1000 + - name: search + in: query + required: false + schema: + type: string + - name: include_non_enabled + in: query + required: false + description: Include subusers that only have non-enabled grants (default false) + schema: + type: boolean + responses: + '200': + description: List of visible subusers + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + username: + type: string + nullable: true + name: + type: string + nullable: true + email: + type: string + format: email + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: integer + nullable: true + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + suspended_at: + type: string + format: date-time + nullable: true + two_factor_enabled: + type: boolean + description: Indicates if 2FA is enabled for this account + permissions: + type: array + description: Aggregated permission keys granted for the caller's customer + items: + type: string + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/me: + get: + tags: + - Subusers + summary: Get current subuser profile + description: | + Returns the authenticated subuser (driver) profile and their enabled grants grouped by + `billing_customer_number`. + + Notes: + - This endpoint is available only to authenticated subuser sessions. + - It does not require the `X-Customer-Number` header; all enabled, non-deleted grants for the + subuser are included in the response. + operationId: getCurrentSubuser + responses: + '200': + description: Current subuser details + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserSelf' + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + tags: + - Subusers + summary: Create a subuser registration + description: | + Creates a subuser (driver) account using a company's CVR and a phone number. Validates the + CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled + sends a setup link by SMS for the user to complete registration. + operationId: createSubuser + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - cvr + - phone_country_code + - phone + properties: + cvr: + type: integer + description: Danish CVR (8 digits) + example: 12345678 + phone_country_code: + type: integer + description: Phone country code (1–3 digits) + example: 45 + phone: + type: integer + description: Phone number (4–15 digits, no leading +) + example: 12345678 + responses: + '200': + description: Subuser created (or pending setup) and company identified + content: + application/json: + schema: + type: object + properties: + cvr: + type: integer + example: 12345678 + customer_number: + type: integer + description: Matched e-conomic customer number + example: 1000 + '400': { $ref: '#/components/responses/BadRequest' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/{id}: + get: + tags: + - Subusers + summary: Get a subuser by ID (visible by grant) + description: | + Returns the subuser if the authenticated user has at least one enabled, non-deleted grant + for their customer number to this subuser. Otherwise returns 404. + operationId: getSubuser + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Subuser details + content: + application/json: + schema: + type: object + properties: + id: + type: integer + username: + type: string + nullable: true + name: + type: string + nullable: true + email: + type: string + format: email + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: integer + nullable: true + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + suspended_at: + type: string + format: date-time + nullable: true + permissions: + type: array + description: Aggregated permission keys granted for the caller's customer + items: + type: string + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/setup: + get: + tags: + - Subusers + summary: Validate setup token + description: Validates a subuser setup token generated during registration. + operationId: validateSubuserSetupToken + security: [] + parameters: + - name: token + in: query + required: true + schema: + type: string + description: One-time setup token received via SMS + responses: + '200': + description: Token is valid + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Token is valid + subuser_id: + type: integer + example: 42 + '400': { $ref: '#/components/responses/BadRequest' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + tags: + - Subusers + summary: Complete subuser setup + description: | + Completes subuser setup by setting a password and basic profile fields. Accepts optional + `username` and `email`. + operationId: completeSubuserSetup + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - token + - password + - name + properties: + token: + type: string + description: One-time setup token + password: + type: string + format: password + minLength: 8 + description: Must include at least one uppercase letter, one lowercase letter, and one number + pattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$' + name: + type: string + minLength: 3 + maxLength: 255 + username: + type: string + minLength: 3 + maxLength: 255 + email: + type: string + format: email + minLength: 3 + maxLength: 255 + responses: + '200': + description: Setup completed + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Password set successfully + '400': { $ref: '#/components/responses/BadRequest' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/auth/password: + post: + tags: + - Subusers + summary: Authenticate subuser with password + description: | + Authenticates a subuser (driver) using a password together with one of the supported + identifiers: `phone_country_code` + `phone`, `subuser_id`, or `username`. + + On success, returns a newly generated session token for the subuser. + operationId: subuserPasswordAuth + security: [] + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: object + required: [phone_country_code, phone, password] + properties: + phone_country_code: + type: integer + description: Phone country code (1–3 digits) + minimum: 1 + maximum: 999 + example: 45 + phone: + type: integer + description: Phone number (4–15 digits, no leading +) + minimum: 1000 + maximum: 999999999999999 + example: 12345678 + password: + type: string + format: password + minLength: 8 + maxLength: 255 + - type: object + required: [subuser_id, password] + properties: + subuser_id: + type: integer + description: Subuser ID + example: 42 + password: + type: string + format: password + minLength: 8 + maxLength: 255 + - type: object + required: [username, password] + properties: + username: + type: string + minLength: 3 + maxLength: 255 + example: jdoe + password: + type: string + format: password + minLength: 8 + maxLength: 255 + examples: + withPhone: + summary: Authenticate with phone + value: + phone_country_code: 45 + phone: 12345678 + password: MySecureP@ssw0rd + withSubuserId: + summary: Authenticate with subuser_id + value: + subuser_id: 42 + password: MySecureP@ssw0rd + withUsername: + summary: Authenticate with username + value: + username: jdoe + password: MySecureP@ssw0rd + responses: + '200': + description: Authentication successful + content: + application/json: + schema: + oneOf: + - type: object + required: [session] + properties: + session: + type: string + description: Newly generated subuser session token + example: "2f7a8c0e-9b1d-4c6a-91a9-1a2b3c4d5e6f" + - type: object + required: [2fa_required, 2fa_token] + properties: + 2fa_required: + type: boolean + example: true + 2fa_token: + type: string + description: Temporary 2FA verification token + example: "557a3e7b1a2b..." + '400': { $ref: '#/components/responses/BadRequest' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/grants: + get: + tags: + - Subusers + summary: List subuser grants + description: Returns subuser grant records filtered by `customer_number` and/or `subuser_id`. + operationId: listSubuserGrants + security: + - BearerAuth: [] + parameters: + - name: customer_number + in: query + required: false + schema: + type: integer + description: e-conomic customer number to filter by + - name: subuser_id + in: query + required: false + schema: + type: integer + description: Subuser ID to filter by + responses: + '200': + description: Grants fetched + content: + application/json: + schema: + type: object + properties: + grants: + type: array + items: + $ref: '#/components/schemas/SubuserGrant' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + tags: + - Subusers + summary: Create subuser grant + operationId: createSubuserGrant + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserGrantCreateRequest' + examples: + default: + value: + customer_number: 1000 + subuser_id: 42 + enabled: true + note: "Grant for bookings access" + permissions: ["BOOKINGS_LIST", "BOOKINGS_ADD", "BOOKINGS_EDIT"] + responses: + '200': + description: Grant created + content: + application/json: + schema: + type: object + properties: + grant: + $ref: '#/components/schemas/SubuserGrant' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/grants/{id}: + patch: + tags: + - Subusers + summary: Update subuser grant + operationId: updateSubuserGrant + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserGrantUpdateRequest' + examples: + enableOnly: + value: + enabled: true + updatePermissions: + value: + permissions: ["VEHICLES_LIST", "SELFSERVE_ADD"] + responses: + '200': + description: Grant updated + content: + application/json: + schema: + type: object + properties: + grant: + $ref: '#/components/schemas/SubuserGrant' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + delete: + tags: + - Subusers + summary: Delete subuser grant + operationId: deleteSubuserGrant + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Grant deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Grant deleted + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/permission-nodes: + get: + tags: + - Subusers + summary: List available subuser permission nodes + description: Returns grouped permission nodes available for subuser grants. + operationId: listSubuserPermissionNodes + security: + - BearerAuth: [] + responses: + '200': + description: Permission nodes fetched + content: + application/json: + schema: + type: object + properties: + permission_nodes: + type: array + items: + $ref: '#/components/schemas/PermissionNodeGroup' + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + + # Authentication Endpoints + /auth/login: + post: + tags: + - Authentication + summary: Customer login + description: Authenticate a customer using customer number and password + operationId: customerLogin + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - customer_number + - password + - g_recaptcha_response + properties: + customer_number: + type: integer + description: Customer's e-conomic customer number + example: 12345 + password: + type: string + format: password + description: Customer password + minLength: 1 + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Login successful + content: + application/json: + schema: + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer authentication token + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + - type: object + required: [2fa_required, 2fa_token] + properties: + 2fa_required: + type: boolean + example: true + 2fa_token: + type: string + description: Temporary 2FA verification token + example: "557a3e7b1a2b..." + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/employee/login: + post: + tags: + - Authentication + summary: Employee login + description: Authenticate an employee using user ID and password + operationId: employeeLogin + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - user_id + - password + - g_recaptcha_response + properties: + user_id: + type: integer + description: Employee user ID + example: 1 + password: + type: string + format: password + description: Employee password + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Login successful + content: + application/json: + schema: + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer authentication token + - type: object + required: [2fa_required, 2fa_token] + properties: + 2fa_required: + type: boolean + example: true + 2fa_token: + type: string + description: Temporary 2FA verification token + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/passkey/challenge: + post: + tags: + - Authentication + summary: Initiate passkey authentication challenge + description: Generates a WebAuthn PublicKeyCredentialRequestOptions payload. If customer_number is provided, allowCredentials will be populated with existing passkeys for that account. Otherwise, a challenge is issued for discoverable credentials. + operationId: passkeyChallenge + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - g_recaptcha_response + properties: + customer_number: + type: integer + description: Optional customer's e-conomic customer number + example: 12345 + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Challenge generated + content: + application/json: + schema: + type: object + properties: + challenge_token: + type: string + description: Temporary token binding the challenge to the login attempt + publicKey: + type: object + properties: + challenge: + type: string + description: Base64URL-encoded challenge + rpId: + type: string + description: Relying party ID (truckwash.io or localhost) + example: truckwash.io + timeout: + type: integer + description: Timeout in milliseconds + userVerification: + type: string + enum: [required, preferred, discouraged] + allowCredentials: + type: array + items: + type: object + properties: + type: + type: string + example: public-key + id: + type: string + description: Base64URL-encoded credential ID + transports: + type: array + items: + type: string + '400': + $ref: '#/components/responses/BadRequest' + + /auth/passkey/verify: + post: + tags: + - Authentication + summary: Verify passkey authentication and start session + description: Verifies the WebAuthn assertion and challenge token. Returns a session token on success. + operationId: passkeyVerify + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - challenge_token + - credential + - g_recaptcha_response + properties: + challenge_token: + type: string + description: The token returned by the challenge endpoint + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + credential: + type: object + description: The WebAuthn PublicKeyCredential object (assertion) + required: + - id + - rawId + - type + - response + properties: + id: + type: string + description: The credential ID (base64url) + rawId: + type: string + description: The raw credential ID (base64url) + type: + type: string + example: public-key + clientExtensionResults: + type: object + response: + type: object + required: + - clientDataJSON + - authenticatorData + - signature + properties: + clientDataJSON: + type: string + description: Base64URL-encoded client data + authenticatorData: + type: string + description: Base64URL-encoded authenticator data + signature: + type: string + description: Base64URL-encoded signature + userHandle: + type: string + nullable: true + description: Base64URL-encoded user handle + responses: + '200': + description: Verification successful, session started + content: + application/json: + schema: + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer token for customer + - type: object + required: [session] + properties: + session: + type: string + description: Session token for subuser + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/logout: + get: + tags: + - Authentication + summary: Logout + description: Invalidate the current authentication token + operationId: logout + responses: + '200': + description: Logout successful + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: "Logged out" + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/session: + get: + tags: + - Authentication + summary: Get current session + description: Retrieve information about the current authenticated user session + operationId: getSession + responses: + '200': + description: Session information retrieved successfully + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + 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 + default_distribution_department_id: + type: integer + additionalProperties: false + additionalProperties: true + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/setup: + post: + tags: + - Authentication + summary: Generate 2FA secret + description: Generate a new TOTP secret for the authenticated user/subuser + operationId: setup2fa + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: 2FA secret generated successfully + content: + application/json: + schema: + type: object + properties: + secret: + type: string + description: The base32 encoded TOTP secret + qr_code_url: + type: string + description: An otpauth URL for generating a QR code + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/enable: + post: + tags: + - Authentication + summary: Enable 2FA + description: Verify a code and enable 2FA for the authenticated user/subuser + operationId: enable2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: + type: string + description: The 6-digit TOTP code + responses: + '200': + description: 2FA enabled successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/disable: + post: + tags: + - Authentication + summary: Disable 2FA + description: Verify a code and disable 2FA for the authenticated user/subuser + operationId: disable2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: + type: string + description: The 6-digit TOTP code + responses: + '200': + description: 2FA disabled successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/verify: + post: + tags: + - Authentication + summary: Verify 2FA code during login + description: Complete the login process by verifying the 2FA code + operationId: verify2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [2fa_token, code] + properties: + 2fa_token: + type: string + description: The temporary 2FA verification token + code: + type: string + description: The 6-digit TOTP code + responses: + '200': + description: Login successful + content: + application/json: + schema: + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer authentication token (for users/employees) + - type: object + required: [session] + properties: + session: + type: string + description: Session token (for subusers) + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/reCAPTCHA/public: + get: + tags: + - Authentication + summary: Get reCAPTCHA configuration + description: Retrieve public reCAPTCHA configuration for login forms + operationId: getRecaptchaConfig + security: [] + responses: + '200': + description: reCAPTCHA configuration retrieved successfully + content: + application/json: + schema: + type: object + properties: + rate_limit: + type: object + properties: + enabled: + type: boolean + limit: + type: integer + remaining: + type: integer + reset: + type: integer + warning: + type: string + nullable: true + recaptcha: + type: object + + /auth/register/cvr: + post: + tags: + - Authentication + summary: Register new customer by CVR + description: Register a new customer account using Danish CVR number + operationId: registerCustomerByCvr + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - cvr + - companyPhone + - invoiceEmail + - contactEmail + - contactPhone + - contactName + - g_recaptcha_response + properties: + cvr: + type: string + description: Danish CVR number + minLength: 8 + maxLength: 20 + example: "44794780" + companyPhone: + type: integer + description: Company phone number + minimum: 10000000 + maximum: 9999999999 + example: 21754690 + invoiceEmail: + type: string + format: email + description: Email for invoices + minLength: 5 + maxLength: 255 + example: "invoice@company.dk" + contactEmail: + type: string + format: email + description: Contact email + minLength: 5 + maxLength: 255 + example: "contact@company.dk" + contactPhone: + type: integer + description: Contact phone number + minimum: 10000000 + maximum: 9999999999 + example: 21754690 + contactName: + type: string + description: Contact person name + example: "Mikkel" + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Matching e-conomic customer already existed and local registration was completed + content: + application/json: + schema: {} + '201': + description: Customer registered successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '409': + description: Customer number conflict between the submitted phone number and the existing or created e-conomic customer + content: + application/json: + schema: {} + + /auth/password-reset/request: + post: + tags: + - Authentication + summary: Request a customer password reset email + description: Send an email with a password reset token to the customer's email address + operationId: requestPasswordReset + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - customer_number + - g_recaptcha_response + properties: + customer_number: + type: integer + description: The customer number + example: 123456 + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Request processed + content: + application/json: + schema: + type: object + properties: + message: + type: string + '400': + $ref: '#/components/responses/BadRequest' + + /auth/password-reset/validate: + get: + tags: + - Authentication + summary: Validate a customer password reset key + description: Check if a password reset token is valid and hasn't expired + operationId: validatePasswordResetToken + security: [] + parameters: + - name: token + in: query + required: true + schema: + type: string + description: The password reset token + responses: + '200': + description: Token is valid + content: + application/json: + schema: + type: object + properties: + valid: + type: boolean + customer_id: + type: integer + '404': + description: Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /auth/password-reset/set: + post: + tags: + - Authentication + summary: Set a customer password using a reset key + description: Update the customer password using a valid reset token + operationId: setPasswordUsingResetToken + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - token + - password + - g_recaptcha_response + properties: + token: + type: string + description: The password reset token + password: + type: string + description: The new password + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Password updated successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '404': + description: Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /su/intimidate: + post: + tags: + - Authentication + summary: Intimidate a user + description: Create an authentication token for another user (Superuser only) + operationId: suIntimidate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [user_id] + properties: + user_id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + token: {type: string} + + # User Endpoints + /users: + get: + tags: + - Users + summary: List users + description: Retrieve a paginated list of users + operationId: listUsers + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Users retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Users + summary: Create new user + description: Create a new user account + operationId: createUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreate' + responses: + '201': + description: User created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + put: + tags: + - Users + summary: Update user + description: Update an existing user + operationId: updateUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserUpdate' + responses: + '200': + description: User updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /users/customer: + get: + tags: + - Users + summary: Get customer details + description: Get details about a specific customer + operationId: getCustomer + parameters: + - name: customer_number + in: query + schema: + type: integer + responses: + '200': + description: Customer retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + $ref: '#/components/responses/NotFound' + + /superuser/user: + get: + tags: + - Users + summary: Get user by ID (superuser) + description: Get detailed user information by user ID + operationId: getSuperuserUser + parameters: + - name: user_id + in: query + schema: + type: integer + responses: + '200': + description: User retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + $ref: '#/components/responses/NotFound' + + /admin/customer/code: + get: + tags: + - Users + summary: Get customer code + operationId: getCustomerCode + parameters: + - name: customer_number + in: query + schema: {type: integer} + - name: user_id + in: query + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer code + operationId: addCustomerCode + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + customer_number: {type: integer} + user_id: {type: integer} + code: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /customer/department/default: + get: + tags: + - Users + summary: Get customer default department + operationId: getCustomerDefaultDepartment + parameters: + - name: customer_number + in: query + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer default department + operationId: addCustomerDefaultDepartment + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + customer_number: {type: integer} + department: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + delete: + tags: + - Users + summary: Delete customer default department + operationId: deleteCustomerDefaultDepartment + parameters: + - name: customer_number + in: query + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /customer/pricing/fixed: + get: + tags: + - Users + summary: Get customer fixed pricing + operationId: getCustomerFixedPricing + parameters: + - name: customer_number + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer fixed pricing + operationId: addCustomerFixedPricing + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customer_number, price, description] + properties: + customer_number: {type: integer} + price: {type: integer} + description: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + delete: + tags: + - Users + summary: Delete customer fixed pricing + operationId: deleteCustomerFixedPricing + parameters: + - name: customer_number + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /account/notifications: + put: + tags: + - Users + summary: Update user notification settings + operationId: updateUserNotifications + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + wash_certificate_email: {type: string} + sms_notifications_enabled: {type: boolean} + email_notifications_enabled: {type: boolean} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /user/permissions: + get: + tags: + - Users + summary: Get user permissions + operationId: getUserPermissions + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /customers: + get: + tags: + - Users + summary: List customers + operationId: listCustomers + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - name: barred + in: query + required: false + description: Optional e-conomic barred customer filter. + schema: + type: string + enum: ['true', 'false', 'barred', 'active', '1', '0'] + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/user/discounts: + get: + tags: + - Users + summary: Get user discounts + operationId: getUserDiscounts + parameters: + - name: user_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Set user discount + operationId: setUserDiscount + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [discount, object_id, is_category] + properties: + user_id: {type: integer} + discount: {type: integer} + object_id: {type: string} + is_category: {type: boolean} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/user/keys: + get: + tags: + - Users + summary: Get user keys + operationId: getUserKeys + parameters: + - name: user_id + in: query + required: true + schema: {type: integer} + - name: key + in: query + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Set user key + operationId: setUserKey + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [key, value] + properties: + user_id: {type: integer} + key: {type: string} + value: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/user/password: + post: + tags: + - Users + summary: Set user password + operationId: setUserPassword + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + user_id: {type: integer} + password: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/customer/getUserId: + get: + tags: + - Users + summary: Get user ID from customer number + description: Convert e-conomic customer number to internal user ID + operationId: getUserIdFromCustomerNumber + parameters: + - name: customer_number + in: query + required: true + schema: + type: integer + responses: + '200': + description: User ID retrieved successfully + content: + application/json: + schema: + type: object + properties: + user_id: + type: integer + + /admin/customer/name: + get: + tags: + - Users + summary: Get customer name + description: Get the full name of a customer + operationId: getCustomerName + parameters: + - name: user_id + in: query + schema: + type: integer + responses: + '200': + description: Customer name retrieved successfully + content: + application/json: + schema: + type: object + properties: + name: + type: string + + # Orders Endpoints + /orders: + get: + tags: + - Orders + summary: List orders + description: Retrieve a paginated list of orders + operationId: listOrders + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - name: show_wash_subscription + in: query + schema: + type: string + enum: [true, false] + responses: + '200': + description: Orders retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: + - Orders + summary: Create new order + description: Create a new wash order + operationId: createOrder + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderCreate' + responses: + '201': + description: Order created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + delete: + tags: + - Orders + summary: Delete order + description: Delete an existing order + operationId: deleteOrder + parameters: + - name: id + in: query + required: true + schema: + type: integer + - name: confirmed + in: query + required: false + description: Must be true to delete an order that is completed or has active items or attachments. + schema: + type: boolean + responses: + '200': + description: Order deleted successfully + content: + application/json: + schema: {} + '409': + description: Order deletion requires explicit confirmation + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: false + data: + type: object + properties: + message: + type: string + example: Order deletion requires confirmation + requires_confirmation: + type: boolean + example: true + protected_reasons: + type: array + items: + type: string + enum: [completed, order_items, attachments] + order_item_count: + type: integer + example: 2 + attachment_count: + type: integer + example: 1 + completed_at: + type: string + nullable: true + meta: + type: object + includes: + type: object + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: + - Orders + summary: Update order (alias) + description: Update an existing order + operationId: updateOrders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderUpdate' + responses: + '200': + description: Order updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /order: + get: + tags: + - Orders + summary: Get order details + description: Get detailed information about a specific order + operationId: getOrder + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: + - Orders + summary: Update order + description: Update an existing order + operationId: updateOrder + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderUpdate' + responses: + '200': + description: Order updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /user/orders: + get: + tags: + - Orders + summary: Get current user's orders + description: Retrieve orders for the authenticated user + operationId: getUserOrders + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Orders retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + + /user/order: + get: + tags: + - Orders + summary: Get user's specific order + description: Get details of a specific order for the authenticated user + operationId: getUserOrder + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + + /orders/mark_as_completed: + post: + tags: + - Orders + summary: Mark order as completed + description: Mark an order as completed + operationId: markOrderCompleted + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + responses: + '200': + description: Order marked as completed successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /order/wash-certificate: + post: + tags: + - Orders + summary: Generate wash certificate + description: Generate a wash certificate for an order + operationId: generateWashCertificate + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + order_id: + type: integer + responses: + '200': + description: Wash certificate generated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + # Order Items Endpoints + /order/items: + get: + tags: + - Order Items + summary: List order items + description: Get all items for a specific order + operationId: listOrderItems + parameters: + - name: order_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order items retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OrderItem' + post: + tags: + - Order Items + summary: Add item to order + description: Add a new item to an existing order + operationId: addOrderItem + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderItemCreate' + responses: + '201': + description: Order item added successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + put: + tags: + - Order Items + summary: Update order item + description: Update an existing order item + operationId: updateOrderItem + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderItemUpdate' + responses: + '200': + description: Order item updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + delete: + tags: + - Order Items + summary: Delete order item + description: Remove an item from an order + operationId: deleteOrderItem + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order item deleted successfully + content: + application/json: + schema: {} + '404': + $ref: '#/components/responses/NotFound' + + # Departments Endpoints + /departments: + get: + tags: + - Departments + summary: List departments + description: Retrieve a list of all visible departments + operationId: listDepartments + parameters: + - name: id + in: query + schema: + type: integer + description: Filter by specific department ID + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Departments retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Department' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Departments + summary: Create department + description: Create a new department + operationId: createDepartment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCreate' + responses: + '201': + description: Department created successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + put: + tags: + - Departments + summary: Update department + description: Update an existing department + operationId: updateDepartment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentUpdate' + responses: + '200': + description: Department updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /departments/categories: + get: + tags: + - Departments + summary: Get department categories + description: Get product categories available in a department + operationId: getDepartmentCategories + parameters: + - name: department_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Department categories retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Departments + summary: Add category to department + description: Associate a product category with a department + operationId: addDepartmentCategory + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + department_id: + type: integer + category_id: + type: integer + responses: + '201': + description: Category added to department successfully + content: + application/json: + schema: {} + delete: + tags: + - Departments + summary: Remove category from department + operationId: removeDepartmentCategory + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/self-serve/enabled: + get: + tags: + - Departments + summary: Get department self-serve status + description: Check if self-serve is enabled for a specific department + operationId: getDepartmentSelfServeEnabled + parameters: + - name: id + in: query + required: true + description: Department ID + schema: + type: integer + responses: + '200': + description: Successfully retrieved status + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + '404': + $ref: '#/components/responses/NotFound' + put: + tags: + - Departments + summary: Update department self-serve status + description: Enable or disable self-serve for a specific department + operationId: updateDepartmentSelfServeEnabled + parameters: + - name: id + in: query + required: true + description: Department ID + schema: + type: integer + - name: enabled + in: query + required: true + description: Enabled status (true/false) + schema: + type: string + enum: ['true', 'false'] + responses: + '200': + description: Status updated successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + $ref: '#/components/responses/NotFound' + + /departments/order/recommended: + get: + tags: + - Departments + summary: Get recommended order for department + operationId: getDepartmentRecommendedOrder + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /department/lanes: + get: + tags: + - Departments + summary: List department lanes + description: Retrieve a list of all department lanes + operationId: listDepartmentLanes + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Department lanes retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentLane' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Departments + summary: Create department lane + description: Create a new department lane + operationId: createDepartmentLane + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentLaneCreate' + responses: + '201': + description: Department lane created successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + put: + tags: + - Departments + summary: Update department lane + description: Update an existing department lane + operationId: updateDepartmentLane + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentLaneUpdate' + responses: + '200': + description: Department lane updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/gates: + get: + tags: + - Departments + summary: List department gates + description: Retrieve department gates, optionally filtered by id + operationId: listDepartmentGates + parameters: + - name: id + in: query + required: false + schema: + type: integer + minimum: 1 + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Department gates retrieved successfully + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/DepartmentGate' + - type: array + items: + $ref: '#/components/schemas/DepartmentGate' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Departments + summary: Create department gate + operationId: createDepartmentGate + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGateCreate' + responses: + '201': + description: Department gate created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGate' + put: + tags: + - Departments + summary: Update department gate + operationId: updateDepartmentGate + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGateUpdate' + responses: + '200': + description: Department gate updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGate' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Departments + summary: Delete department gate + operationId: deleteDepartmentGate + parameters: + - name: id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Department gate deleted + content: + application/json: + schema: {} + + /department/relays: + get: + tags: + - Departments + summary: List department relays + description: Retrieve department relays, optionally filtered by id + operationId: listDepartmentRelays + parameters: + - name: id + in: query + required: false + schema: + type: integer + minimum: 1 + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Department relays retrieved successfully + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/DepartmentRelay' + - type: array + items: + $ref: '#/components/schemas/DepartmentRelay' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Departments + summary: Create department relay + operationId: createDepartmentRelay + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentRelayCreate' + responses: + '201': + description: Department relay created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentRelay' + put: + tags: + - Departments + summary: Update department relay + operationId: updateDepartmentRelay + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentRelayUpdate' + responses: + '200': + description: Department relay updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentRelay' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Departments + summary: Delete department relay + operationId: deleteDepartmentRelay + parameters: + - name: id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Department relay deleted + content: + application/json: + schema: {} + + /department/lanes/dynamic-image: + get: + tags: + - Departments + summary: Generate dynamic image for a department lane + description: | + Returns a composed machine UI image for the specified department lane. + You can optionally highlight button indices, set the current step indicator, and toggle only-current-step mode. + operationId: getDepartmentLaneDynamicImage + parameters: + - name: department + in: query + required: true + description: Department ID + schema: + type: integer + minimum: 1 + - name: lane + in: query + required: true + description: Lane ID + schema: + type: integer + minimum: 1 + - name: buttons + in: query + required: false + description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params. + schema: + oneOf: + - type: string + - type: array + items: + oneOf: + - type: integer + - type: string + - name: current_step + in: query + required: false + description: Current step indicator (non-negative integer) + schema: + type: integer + minimum: 0 + - name: only_current_step + in: query + required: false + description: If true, only draw the current step highlight + schema: + type: boolean + - name: vehicle_type + in: query + required: false + description: Vehicle type selection override (nullable non-negative integer) + schema: + type: integer + minimum: 0 + responses: + '200': + description: Dynamic image rendered successfully + content: + image/png: + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /guest/validation/customer-number: + post: + tags: + - Users + summary: Validate customer number + description: Check if a customer number is valid and exists + operationId: validateCustomerNumber + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - customer_number + properties: + customer_number: + type: integer + responses: + '200': + description: Customer number validation successful + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /guest/departments: + get: + tags: + - Departments + summary: List public departments + description: Get list of departments without authentication + operationId: listGuestDepartments + security: [] + parameters: + - name: include_lanes + in: query + description: Whether to include lane status and self-serve information + schema: + type: boolean + responses: + '200': + description: Departments retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentGuest' + + /department/selfserve/machine-types: + get: + tags: + - Self-Serve + summary: List reusable self-serve machine types + operationId: listSelfserveMachineTypes + parameters: + - name: id + in: query + required: true + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Successfully retrieved machine types + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/SelfserveMachineType' + - type: array + items: + $ref: '#/components/schemas/SelfserveMachineType' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Self-Serve + summary: Add reusable self-serve machine type + operationId: addSelfserveMachineType + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + nullable: true + responses: + '200': + description: Successfully added machine type + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveMachineType' + put: + tags: + - Self-Serve + summary: Update reusable self-serve machine type + operationId: updateSelfserveMachineType + parameters: + - name: id + in: query + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + nullable: true + responses: + '200': + description: Successfully updated machine type + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveMachineType' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Self-Serve + summary: Delete reusable self-serve machine type + operationId: deleteSelfserveMachineType + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Successfully deleted machine type + content: + application/json: + schema: + type: string + example: Machine type deleted + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/questions: + get: + tags: + - Self-Serve + summary: List self-serve questions + description: Retrieve a list of self-serve questions for a department, lane, or product. + operationId: listSelfserveQuestions + parameters: + - name: id + in: query + description: Filter by question ID + schema: + type: integer + - name: department + in: query + description: Filter by department ID + schema: + type: integer + - name: lane + in: query + description: Filter by lane ID + schema: + type: integer + - name: product + in: query + description: Filter by product ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved questions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveQuestion' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add self-serve question + description: Add a new self-serve question. Questions are typically shared across departments and lanes by omitting department, lane, and product, which default to 0. + operationId: addSelfserveQuestion + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - question + - description + properties: + department: + type: integer + default: 0 + lane: + type: integer + default: 0 + product: + type: integer + default: 0 + question: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + default: 0 + responses: + '200': + description: Successfully added question + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveQuestion' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update self-serve question + description: Update an existing self-serve question. + operationId: updateSelfserveQuestion + parameters: + - name: id + in: query + required: true + description: Question ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + department: + type: integer + lane: + type: integer + product: + type: integer + question: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + responses: + '200': + description: Successfully updated question + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveQuestion' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + delete: + tags: + - Self-Serve + summary: Delete self-serve question + description: Delete a self-serve question by ID. + operationId: deleteSelfserveQuestion + parameters: + - name: id + in: query + required: true + description: Question ID + schema: + type: integer + responses: + '200': + description: Successfully deleted question + content: + application/json: + schema: + type: string + example: Question deleted + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/conditions: + get: + tags: + - Self-Serve + summary: List self-serve conditions + description: Retrieve a list of self-serve conditions for a department, lane, or product. + operationId: listSelfserveConditions + parameters: + - name: id + in: query + description: Filter by condition ID + schema: + type: integer + - name: department + in: query + description: Filter by department ID + schema: + type: integer + - name: lane + in: query + description: Filter by lane ID + schema: + type: integer + - name: product + in: query + description: Filter by product ID + schema: + type: integer + - name: condition_id + in: query + description: Filter by condition ID + schema: + type: integer + - name: machine_type_id + in: query + description: Filter by reusable machine type ID + schema: + type: integer + - name: machine_type_id + in: query + description: Filter by reusable machine type ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved conditions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveCondition' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add self-serve condition + description: Add a new self-serve condition. Either provide a reusable machine_type_id or a legacy department/lane/product scope. + operationId: addSelfserveCondition + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - description + properties: + department: + type: integer + default: 0 + lane: + type: integer + default: 0 + product: + type: integer + default: 0 + machine_type_id: + type: integer + nullable: true + condition_id: + type: integer + nullable: true + name: + type: string + description: + type: string + responses: + '200': + description: Successfully added condition + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveCondition' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update self-serve condition + description: Update an existing self-serve condition. + operationId: updateSelfserveCondition + parameters: + - name: id + in: query + required: true + description: Condition ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + department: + type: integer + lane: + type: integer + product: + type: integer + machine_type_id: + type: integer + nullable: true + condition_id: + type: integer + nullable: true + name: + type: string + description: + type: string + responses: + '200': + description: Successfully updated condition + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveCondition' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + tags: + - Self-Serve + summary: Delete self-serve condition + description: Delete a self-serve condition. + operationId: deleteSelfserveCondition + parameters: + - name: id + in: query + required: true + description: Condition ID + schema: + type: integer + responses: + '200': + description: Successfully deleted condition + content: + application/json: + schema: + type: string + example: Condition deleted + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/condition/rules: + get: + tags: + - Self-Serve + summary: List self-serve condition rules + description: Retrieve a list of self-serve condition rules. + operationId: listSelfserveConditionRules + parameters: + - name: id + in: query + description: Filter by rule ID + schema: + type: integer + - name: condition_id + in: query + description: Filter by condition ID + schema: + type: integer + - name: type + in: query + description: Filter by rule type + schema: + type: string + - name: object_type + in: query + description: Filter by object type + schema: + type: string + - name: object_id + in: query + description: Filter by object ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved condition rules + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveConditionRule' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add self-serve condition rule + description: Add a new self-serve condition rule. + operationId: addSelfserveConditionRule + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - condition_id + - type + - object_type + - object_id + - name + - description + properties: + condition_id: + type: integer + type: + type: string + object_type: + type: string + object_id: + type: integer + name: + type: string + description: + type: string + responses: + '200': + description: Successfully added condition rule + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveConditionRule' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update self-serve condition rule + description: Update an existing self-serve condition rule. + operationId: updateSelfserveConditionRule + parameters: + - name: id + in: query + required: true + description: Rule ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + condition_id: + type: integer + type: + type: string + object_type: + type: string + object_id: + type: integer + name: + type: string + description: + type: string + responses: + '200': + description: Successfully updated condition rule + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveConditionRule' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + tags: + - Self-Serve + summary: Delete self-serve condition rule + description: Delete a self-serve condition rule. + operationId: deleteSelfserveConditionRule + parameters: + - name: id + in: query + required: true + description: Rule ID + schema: + type: integer + responses: + '200': + description: Successfully deleted condition rule + content: + application/json: + schema: + type: string + example: Rule deleted + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/vehicle/conditions: + get: + tags: + - Self-Serve + summary: List vehicle conditions + description: Retrieve a list of vehicle conditions for a department, lane, reg, or question. Customers will only see their own vehicle conditions. + operationId: listSelfserveVehicleConditions + parameters: + - name: id + in: query + description: Filter by condition ID + schema: + type: integer + - name: department + in: query + description: Filter by department ID + schema: + type: integer + - name: lane + in: query + description: Filter by lane ID + schema: + type: integer + - name: reg + in: query + description: Filter by vehicle registration number + schema: + type: string + - name: question + in: query + description: Filter by question ID + schema: + type: integer + - name: customer_id + in: query + description: Filter by customer ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved vehicle conditions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add vehicle condition + description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. + operationId: addSelfserveVehicleCondition + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + - lane + - reg + - question + properties: + department: + type: integer + lane: + type: integer + reg: + type: string + question: + type: integer + value: + type: boolean + customer_id: + type: integer + nullable: true + vehicle_type: + type: integer + nullable: true + description: Optional product/vehicle type override used when refreshing the self-serve summary. + vehicle_type_id: + type: integer + nullable: true + description: Alias for vehicle_type. + activate_machine: + type: boolean + default: true + description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false. + sync_relay_state: + type: boolean + default: true + description: Whether the answer mutation should synchronize live relay state. + responses: + '200': + description: Successfully added vehicle condition + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update vehicle condition + description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. + operationId: updateSelfserveVehicleCondition + parameters: + - name: id + in: query + required: true + description: Condition ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + department: + type: integer + lane: + type: integer + reg: + type: string + question: + type: integer + value: + type: boolean + customer_id: + type: integer + nullable: true + vehicle_type: + type: integer + nullable: true + description: Optional product/vehicle type override used when refreshing the self-serve summary. + vehicle_type_id: + type: integer + nullable: true + description: Alias for vehicle_type. + activate_machine: + type: boolean + default: true + description: Whether the session synchronization may enable the machine relay. + sync_relay_state: + type: boolean + default: true + description: Whether the mutation should synchronize live relay state. + responses: + '200': + description: Successfully updated vehicle condition + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + tags: + - Self-Serve + summary: Delete vehicle condition + description: Delete a vehicle condition. Customers can only delete conditions for their own vehicles. + operationId: deleteSelfserveVehicleCondition + parameters: + - name: id + in: query + required: true + description: Condition ID + schema: + type: integer + responses: + '200': + description: Successfully deleted vehicle condition + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Condition deleted + selfserve: + allOf: + - $ref: '#/components/schemas/SelfserveWashSummary' + nullable: true + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/vehicle/allowed: + get: + tags: + - Self-Serve + summary: Check whether self-serve is allowed for a vehicle on a lane + description: Customers with own self-serve permissions may evaluate any registration plate for their wash. Persisted self-serve answers are only applied when they are scoped to the authenticated customer. + operationId: getSelfserveVehicleAllowed + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + - name: reg + in: query + required: true + schema: + type: string + - name: vehicle_type_id + in: query + required: false + description: Optional vehicle type override used when no vehicle is found by registration plate. + schema: + type: integer + minimum: 0 + - name: vehicle_type + in: query + required: false + description: Backward-compatible alias of `vehicle_type_id`. + schema: + type: integer + minimum: 0 + responses: + '200': + description: Successfully evaluated self-serve eligibility + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveVehicleAllowedResponse' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/washes/summary: + get: + tags: + - Self-Serve + summary: Get self-serve wash summary + operationId: getSelfserveWashSummary + parameters: + - name: session_id + in: query + required: false + schema: + type: integer + - name: lane_id + in: query + required: false + schema: + type: integer + - name: reg + in: query + required: false + schema: + type: string + - name: vehicle_type_id + in: query + required: false + description: Optional vehicle type override used to refresh summary data for unknown or reassigned plates. + schema: + type: integer + minimum: 0 + - name: vehicle_type + in: query + required: false + description: Backward-compatible alias of `vehicle_type_id`. + schema: + type: integer + minimum: 0 + responses: + '200': + description: Successfully retrieved self-serve wash summary + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveWashSummary' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/tasks: + get: + tags: + - Self-Serve + summary: List self-serve tasks + description: Retrieve a list of self-serve tasks for a department, lane, product, or condition_id. + operationId: listSelfserveTasks + parameters: + - name: id + in: query + description: Filter by task ID + schema: + type: integer + - name: department + in: query + description: Filter by department ID + schema: + type: integer + - name: lane + in: query + description: Filter by lane ID + schema: + type: integer + - name: product + in: query + description: Filter by product ID + schema: + type: integer + - name: condition_id + in: query + description: Filter by condition ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved tasks + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveTask' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add self-serve task + description: Add a new self-serve task. Either provide a reusable machine_type_id or a legacy department/lane/product scope. + operationId: addSelfserveTask + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - task + - description + properties: + department: + type: integer + default: 0 + lane: + type: integer + default: 0 + product: + type: integer + default: 0 + machine_type_id: + type: integer + nullable: true + condition_id: + type: integer + nullable: true + task: + type: string + description: + type: string + order_priority: + type: integer + default: 0 + services: + type: array + description: Optional services enabled by this task. Items must be valid service enum names. + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + description: Optional dynamic image button IDs enabled by this task. + items: + type: integer + default: [] + dynamic_images_vehicle_type: + type: integer + nullable: true + description: Optional vehicle type selection override for the machine UI. Integer >= 0 or null. + responses: + '200': + description: Successfully added task + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveTask' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update self-serve task + description: Update an existing self-serve task. + operationId: updateSelfserveTask + parameters: + - name: id + in: query + required: true + description: Task ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + department: + type: integer + lane: + type: integer + product: + type: integer + machine_type_id: + type: integer + nullable: true + condition_id: + type: integer + nullable: true + task: + type: string + description: + type: string + order_priority: + type: integer + services: + type: array + nullable: true + description: Services enabled by this task. Set to null to clear all services. + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + nullable: true + description: Button IDs enabled by this task. Set to null to clear all buttons. + items: + type: integer + dynamic_images_vehicle_type: + type: integer + nullable: true + description: Vehicle type selection override. Set to null to clear. + responses: + '200': + description: Successfully updated task + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveTask' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + delete: + tags: + - Self-Serve + summary: Delete self-serve task + description: Delete a self-serve task by ID. + operationId: deleteSelfserveTask + parameters: + - name: id + in: query + required: true + description: Task ID + schema: + type: integer + responses: + '200': + description: Successfully deleted task + content: + application/json: + schema: + type: string + example: Task deleted + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/tasks/attachments: + get: + tags: + - Self-Serve + summary: List task attachments + description: Retrieve a list of attachments for a specific self-serve task. + operationId: listSelfserveTaskAttachments + parameters: + - name: id + in: query + required: true + description: Task ID + schema: + type: integer + responses: + '200': + description: Successfully retrieved task attachments + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + delete: + tags: + - Self-Serve + summary: Delete task attachment + description: Remove an attachment from a specific self-serve task. + operationId: deleteSelfserveTaskAttachment + parameters: + - name: task_id + in: query + required: true + description: Task ID + schema: + type: integer + - name: attachment_id + in: query + required: true + description: Attachment ID + schema: + type: integer + responses: + '200': + description: Attachment deleted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Attachment deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/tasks/attachments/upload: + post: + tags: + - Self-Serve + summary: Upload task attachment + description: Upload a new attachment to a specific self-serve task using base64 encoding. + operationId: uploadSelfserveTaskAttachment + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - task_id + - base64_file + - file_name + properties: + task_id: + type: integer + base64_file: + type: string + description: Base64 encoded file content + file_name: + type: string + description: Name of the file including extension + responses: + '200': + description: Attachment uploaded successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /department/selfserve/tasks/attachments/download: + get: + tags: + - Self-Serve + summary: Download task attachment + description: Generate a download link for a specific self-serve task attachment. + operationId: downloadSelfserveTaskAttachment + parameters: + - name: task_id + in: query + required: true + description: Task ID + schema: + type: integer + - name: attachment_id + in: query + required: true + description: Attachment ID + schema: + type: integer + responses: + '200': + description: Successfully generated download link + content: + application/json: + schema: + type: object + properties: + download_link: + type: string + format: uri + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/studio/graph: + get: + tags: + - Self-Serve + summary: Get all-in-one self-serve studio graph + description: Returns the replacement studio workspace graph backed by the schema_version 2 draft config. Conditions own grouped expression trees directly; standalone rule nodes are omitted from v2 graphs. + operationId: getSelfserveStudioGraph + parameters: + - name: department + in: query + required: true + schema: + type: integer + responses: + '200': + description: Studio graph returned + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraph' + put: + tags: + - Self-Serve + summary: Bulk save all-in-one self-serve studio graph changes + description: Creates, updates, deletes, connects, disconnects, reorders, and upserts self-serve answer paths by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; Path Editor upserts create normal generated condition and task nodes; layout remains separate from runtime behavior. + operationId: saveSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraphSaveRequest' + responses: + '200': + description: Studio graph saved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraph' + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/layout: + put: + tags: + - Self-Serve + summary: Save self-serve studio canvas layout + description: Persists canvas-only node positions and viewport state. Layout does not affect runtime wash behavior. + operationId: saveSelfserveStudioLayout + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioLayoutSaveRequest' + responses: + '200': + description: Layout saved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioLayout' + + /department/selfserve/studio/validate: + post: + tags: + - Self-Serve + summary: Validate self-serve studio graph + operationId: validateSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + department: + type: integer + responses: + '200': + description: Validation result + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioValidation' + + /department/selfserve/studio/simulate: + post: + tags: + - Self-Serve + summary: Simulate self-serve studio runtime + operationId: simulateSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, lane_id, reg] + properties: + department: { type: integer } + lane_id: { type: integer } + reg: { type: string } + customer_number: { type: integer, nullable: true } + vehicle_type_id: { type: integer, nullable: true } + config_source: + type: string + enum: [draft, published] + default: draft + answer_overrides: + type: array + items: + type: object + required: [question_id] + properties: + question_id: { type: integer } + value: + type: boolean + nullable: true + include_hardware: + type: boolean + default: true + mode: + type: string + enum: [full_dry_run] + default: full_dry_run + responses: + '200': + description: Simulator result + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioSimulationResponse' + + /department/selfserve/studio/path-outcomes: + post: + tags: + - Self-Serve + summary: Project grouped self-serve studio question path outcomes + description: Enumerates feasible yes/no answer paths for the selected studio scope and groups terminal paths by resulting tasks, services, and dry-run signal timeline. No live hardware commands are sent. + operationId: projectSelfserveStudioPathOutcomes + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathOutcomesRequest' + responses: + '200': + description: Grouped path outcomes returned + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathOutcomesResponse' + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/path-outcomes/stream: + post: + tags: + - Self-Serve + summary: Stream self-serve studio question path outcome progress + description: Streams newline-delimited JSON progress events while enumerating the complete feasible yes/no answer path space. Progress events contain the same response shape as the final result with partial outcomes and paths. + operationId: streamSelfserveStudioPathOutcomes + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathOutcomesRequest' + responses: + '200': + description: Newline-delimited path outcome progress events + content: + application/x-ndjson: + schema: + type: string + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/path-confirmations: + post: + tags: + - Self-Serve + summary: Confirm or reset a projected self-serve studio path + description: Stores confirmation for a projected terminal path using its stable path and result signatures. Projections report confirmed, unconfirmed, or stale when the resulting tasks, buttons, services, or signals change. + operationId: confirmSelfserveStudioPath + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathConfirmationRequest' + responses: + '200': + description: Path confirmation updated + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathConfirmation' + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/publish: + post: + tags: + - Self-Serve + summary: Publish self-serve studio draft + operationId: publishSelfserveStudioDraft + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + department: { type: integer } + responses: + '200': + description: Published version + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveConfigVersion' + + /department/selfserve/studio/rollback: + post: + tags: + - Self-Serve + summary: Roll back self-serve studio to an earlier version + operationId: rollbackSelfserveStudioDraft + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, target_version_id] + properties: + department: { type: integer } + target_version_id: { type: integer } + responses: + '200': + description: Rollback version + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveConfigVersion' + + /department/selfserve/studio/gateway-action: + post: + tags: + - Self-Serve + summary: Run permission-gated edge gateway action from studio + operationId: runSelfserveStudioGatewayAction + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, gateway_id, action] + properties: + department: { type: integer } + gateway_id: { type: integer } + action: + type: string + enum: [discovery, discover, update, uninstall, cancel, rotate_credentials, bindings] + confirm: + type: boolean + description: Required for dangerous gateway actions such as uninstall and credential rotation. + operation_id: { type: integer, nullable: true } + request: + type: object + additionalProperties: true + bindings: + type: array + items: + type: object + additionalProperties: true + responses: + '200': + description: Gateway action result + content: + application/json: + schema: + type: object + additionalProperties: true + + # Products Endpoints + /products: + get: + tags: + - Products + summary: List products + description: Retrieve a list of products with optional filters for customer pricing and department + operationId: listProducts + parameters: + - name: customer_id + in: query + schema: + type: integer + description: Customer ID for custom pricing + - name: department_id + in: query + schema: + type: integer + description: Department ID for department-specific pricing + - name: category + in: query + schema: + type: integer + description: Filter by category ID + - name: id + in: query + schema: + type: integer + description: Get specific product by ID + - name: final_price + in: query + schema: + type: boolean + description: Whether to return final prices including discounts + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Products retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Product' + post: + tags: + - Products + summary: Create product + description: Create a new product + operationId: createProduct + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductCreate' + responses: + '201': + description: Product created successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + put: + tags: + - Products + summary: Update product + description: Update an existing product + operationId: updateProduct + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductUpdate' + responses: + '200': + description: Product updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + # Categories Endpoints + /categories: + get: + tags: + - Categories + summary: List categories + description: Retrieve a list of product categories + operationId: listCategories + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Categories retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Category' + post: + tags: + - Categories + summary: Create category + description: Create a new product category + operationId: createCategory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CategoryCreate' + responses: + '201': + description: Category created successfully + content: + application/json: + schema: {} + put: + tags: + - Categories + summary: Update category + description: Update an existing category + operationId: updateCategory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CategoryUpdate' + responses: + '200': + description: Category updated successfully + content: + application/json: + schema: {} + + # Bookings Endpoints + /bookings: + get: + tags: + - Bookings + summary: List bookings + description: Retrieve a list of bookings + operationId: listBookings + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Bookings retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Booking' + put: + tags: + - Bookings + summary: Update booking + description: Update an existing booking + operationId: updateBooking + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BookingUpdate' + responses: + '200': + description: Booking updated successfully + content: + application/json: + schema: {} + + /user/bookings: + get: + tags: + - Bookings + summary: Get user bookings + description: Retrieve bookings for the authenticated user + operationId: getUserBookings + responses: + '200': + description: User bookings retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Booking' + + + /order-bookings: + get: + tags: + - Bookings + summary: List order bookings + operationId: listOrderBookings + parameters: + - name: id + in: query + required: false + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Goals Endpoints + /goals/department: + get: + tags: + - Goals + summary: List or get department goals + description: | + Retrieve a list of department goals or a single goal when `id` is provided. + + Access control: + - A user may only access goals where the goal's `departments` set is a subset of the user's departments. + - Users with the `superuser` permission may access all goals. + operationId: listDepartmentGoals + parameters: + - name: id + in: query + required: false + schema: { type: integer } + description: When provided, returns the single goal with this id (if accessible) + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Goals retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentGoal' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + post: + tags: + - Goals + summary: Create department goal + description: | + Create a new department goal. + + Access control: + - The provided `departments` must be a subset of the user's departments unless the user has `superuser`. + operationId: createDepartmentGoal + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGoalCreate' + responses: + '201': + description: Department goal created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGoal' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + put: + tags: + - Goals + summary: Update department goal + description: | + Update an existing department goal by `id`. + + Access control: + - The creator (`created_by`) may update regardless of department membership. + - Otherwise the user must satisfy the same subset rule as for read access, and any new `departments` provided must also be a subset unless the user has `superuser`. + operationId: updateDepartmentGoal + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGoalUpdate' + responses: + '200': + description: Department goal updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGoal' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /goals/department/progress-alert/test: + post: + tags: + - Goals + summary: Send a test progress alert for a department goal + description: | + Sends a progress alert for a department goal to the destination defined in the goal's criteria. + + Permission required: `goals_department_progress_alert_test`. + + Access control: + - The caller must be a superuser or belong to all departments targeted by the goal. + + Behavior: + - Looks up the goal by `id`. + - Rebuilds the criteria from stored JSON and attaches the goal's departments. + - Renders the alert using the server-side renderer (respecting progress type/style/format and destination limits). + - Sends the alert to Slack, Email, or SMS depending on `progress_alert_destination`, unless overridden. + operationId: sendDepartmentGoalProgressAlertTest + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + description: The department goal id + example: 42 + overrideDestination: + type: string + description: Override the destination for this test + enum: [SLACK, EMAIL, SMS, NONE] + example: SLACK + email_to: + type: string + format: email + description: Email recipient when destination is EMAIL + example: tester@example.com + subject: + type: string + description: Optional email subject when destination is EMAIL + example: Dept Goal Progress Test + sms_to: + description: One or more MSISDN recipients when destination is SMS + oneOf: + - type: string + description: Comma or semicolon separated list + example: "+4512345678, +4598765432" + - type: array + items: + type: string + example: ["+4512345678", "+4598765432"] + slack_webhook: + type: string + description: Slack webhook URL when destination is SLACK + example: https://hooks.slack.com/services/T000/B000/XXX + department_id: + type: integer + description: Department id to use that department's Slack webhook when destination is SLACK + example: 3 + responses: + '200': + description: Alert sent successfully + content: + application/json: + schema: + type: object + properties: + id: + type: integer + description: Goal id + destination: + type: string + description: Final destination used + enum: [SLACK, EMAIL, SMS, NONE] + target: + description: The target used for delivery (email address, phone numbers, department id, or webhook) + message_preview: + type: string + description: Rendered message preview + provider_response: + description: Provider-specific response or status message + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + delete: + tags: + - Goals + summary: Delete department goal + description: | + Delete a department goal by `id`. + + Access control: + - The creator (`created_by`) may delete regardless of department membership. + - Otherwise the user must satisfy the subset rule or have `superuser`. + operationId: deleteDepartmentGoal + parameters: + - name: id + in: query + required: true + schema: { type: integer } + description: ID of the goal to delete + responses: + '200': + description: Department goal deleted successfully + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '/order-bookings': + post: + tags: + - Bookings + summary: Create order booking + operationId: createOrderBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, reg_1, datetime, items] + properties: + customer_number: {type: integer} + department: {type: integer} + reg_1: {type: string} + reg_2: {type: string} + reg_3: {type: string} + datetime: {type: string, format: date-time} + note: {type: string} + reference: {type: string} + po: {type: string} + pickup: {type: boolean} + items: + type: array + items: + type: object + required: [id, quantity] + properties: + id: {type: integer} + quantity: {type: integer} + responses: + '200': + description: Success + put: + tags: + - Bookings + summary: Update order booking + operationId: updateOrderBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + customer_number: {type: integer} + department: {type: integer} + reg_1: {type: string} + reg_2: {type: string} + reg_3: {type: string} + datetime: {type: string, format: date-time} + note: {type: string} + reference: {type: string} + po: {type: string} + pickup: {type: boolean} + order_id: {type: integer, nullable: true} + items: + type: array + items: + type: object + required: [id, quantity] + properties: + id: {type: integer} + quantity: {type: integer} + responses: + '200': + description: Success + delete: + tags: + - Bookings + summary: Delete order booking + operationId: deleteOrderBooking + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + + /order-bookings/complete: + post: + tags: + - Bookings + summary: Complete order booking + operationId: completeOrderBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + safety_seal: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/bookings/sync: + post: + tags: + - Bookings + summary: Sync booking from external system + operationId: syncBooking + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/bookings/department/count: + get: + tags: + - Bookings + summary: Get department unfulfilled bookings count + operationId: getDepartmentBookingCount + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /user/bookings/washcertificate/download: + post: + tags: + - Bookings + summary: Get download link for own wash certificate + operationId: downloadOwnWashCertificate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /bookings/download_pdf: + get: + tags: + - Bookings + summary: Download booking PDF + operationId: downloadBookingPdf + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/bookings/delete: + post: + tags: + - Bookings + summary: Delete booking (admin) + operationId: adminDeleteBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/bookings/sync/all: + post: + tags: + - Bookings + summary: Sync all bookings from external system + operationId: syncAllBookings + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/bookings/completeWashWithoutWashCertificate: + post: + tags: + - Bookings + summary: Complete wash without wash certificate + operationId: completeWashWithoutWashCertificate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /user/bookings/delete: + post: + tags: + - Bookings + summary: Delete own booking + operationId: deleteOwnBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Invoices Endpoints + /invoices/draft: + get: + tags: + - Invoices + summary: List draft invoices + description: Retrieve a list of draft invoices + operationId: listDraftInvoices + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Draft invoices retrieved successfully + content: + application/json: + schema: {} + + /invoices/draft/close: + post: + tags: + - Invoices + summary: Close draft invoice + description: Close a draft invoice + operationId: closeDraftInvoice + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: integer + responses: + '200': + description: Draft invoice closed successfully + content: + application/json: + schema: {} + + /invoices/pdf: + get: + tags: + - Invoices + summary: Get invoice PDF + description: Download an invoice as PDF + operationId: getInvoicePdf + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: PDF retrieved successfully + content: + application/pdf: + schema: + type: string + format: binary + + /user/invoices: + get: + tags: + - Invoices + summary: Get user invoices + description: Retrieve invoices for the authenticated user + operationId: getUserInvoices + responses: + '200': + description: User invoices retrieved successfully + content: + application/json: + schema: {} + + /collected-invoices: + get: + tags: + - Invoices + summary: List collected invoices + description: Get list of collected invoices + operationId: listCollectedInvoices + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Collected invoices retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Invoices + summary: Create collected invoice + description: Create a new collected invoice + operationId: createCollectedInvoice + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '201': + description: Collected invoice created successfully + content: + application/json: + schema: {} + put: + tags: + - Invoices + summary: Update collected invoice + description: Update a collected invoice + operationId: updateCollectedInvoice + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Collected invoice updated successfully + content: + application/json: + schema: {} + + /collected-invoices/ready-to-invoice: + get: + tags: + - Invoices + summary: Get invoices ready to process + description: Get collected invoices that are ready to be processed + operationId: getReadyToInvoice + responses: + '200': + description: Ready invoices retrieved successfully + content: + application/json: + schema: {} + + /collected-invoices/economic: + post: + tags: + - Invoices + summary: Export collected invoice to e-conomic + description: | + Exports collected invoice to e-conomic. + Uses async queue when available, otherwise falls back to synchronous processing. + operationId: queueCollectedInvoiceEconomicTransfer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: integer + minimum: 1 + send_as_is: + type: boolean + default: false + responses: + '200': + description: Collected invoice export processed synchronously (fallback) + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse' + '202': + description: Collected invoice transfer queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/stripe/book: + post: + tags: + - Invoices + summary: Export Stripe collected invoice to e-conomic + description: | + Exports a Stripe-backed collected invoice to e-conomic. + Uses async queue when available, otherwise falls back to synchronous processing. + operationId: queueStripeCollectedInvoiceEconomicTransfer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: integer + minimum: 1 + responses: + '200': + description: Stripe collected invoice export processed synchronously (fallback) + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse' + '202': + description: Stripe collected invoice transfer queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue: + get: + tags: + - Invoices + summary: List collected-invoice e-conomic transfer queue jobs + operationId: listCollectedInvoiceEconomicQueueJobs + parameters: + - name: status + in: query + required: false + description: Comma-separated queue statuses to filter by. + style: form + explode: false + schema: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueStatus' + uniqueItems: true + example: [QUEUED, FAILED] + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Queue jobs retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueListResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/monitor: + get: + tags: + - Invoices + summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs + operationId: monitorCollectedInvoiceEconomicQueueJobs + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: Queue monitor state retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueMonitorResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/status: + get: + tags: + - Invoices + summary: Get collected-invoice e-conomic transfer queue job status + operationId: getCollectedInvoiceEconomicQueueJobStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/retry: + post: + tags: + - Invoices + summary: Retry failed collected-invoice queue job + operationId: retryCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/dismiss: + post: + tags: + - Invoices + summary: Clear one completed or failed collected-invoice queue job for the current user + operationId: dismissCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/dismiss-terminal: + post: + tags: + - Invoices + summary: Clear all visible completed or failed collected-invoice queue jobs for the current user + operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs + responses: + '200': + description: Terminal queue jobs cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/run: + post: + tags: + - Invoices + summary: Run one collected-invoice queue batch immediately + operationId: runCollectedInvoiceEconomicQueueBatch + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + limit: + type: integer + minimum: 1 + maximum: 10 + default: 10 + responses: + '200': + description: Queue batch processed + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRunResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/compare: + get: + tags: + - Invoices + summary: Compare collected invoice totals with E-conomic + description: | + Compares a collected invoice in the system with its corresponding invoice in E-conomic. + Returns totals from both sources, their difference, and any warnings detected during comparison. + operationId: compareCollectedInvoiceEconomic + parameters: + - name: collected_invoice_id + in: query + required: true + description: The internal collected invoice ID to compare + schema: + type: integer + minimum: 1 + responses: + '200': + description: Comparison completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicCompareResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /collected-invoices/economic/v2/details: + get: + tags: + - Invoices + summary: Get deep V2 e-conomic invoice details + description: | + Returns normalized internal lines and best-effort fetched draft/booked e-conomic lines + for a collected invoice, including department distributions and warnings. + operationId: getCollectedInvoiceEconomicV2Details + parameters: + - name: collected_invoice_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Details resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/compare: + get: + tags: + - Invoices + summary: Compare internal invoice with draft/booked (V2) + operationId: compareCollectedInvoiceEconomicV2 + parameters: + - name: collected_invoice_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Comparison completed + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareResponse' + examples: + exactMatch: + summary: Exact match between internal and draft/booked + value: + collected_invoice_id: 123 + warnings: [] + comparison: + totals: + internal_net_total: 694 + targets: + draft: + target: draft + status: exact_match + overall_match: true + booked: + target: booked + status: exact_match + overall_match: true + partialMismatch: + summary: Partial mismatch with line and department differences + value: + collected_invoice_id: 123 + warnings: + - Non-billable line count differs + comparison: + totals: + internal_net_total: 694 + targets: + draft: + target: draft + status: partial_mismatch + overall_match: false + mismatch_reasons: + - quantity_mismatch + - department_total_mismatch + missingBooked: + summary: Missing booked target + value: + collected_invoice_id: 123 + comparison: + totals: + internal_net_total: 694 + targets: + booked: + target: booked + status: missing_target + overall_match: false + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/compare/bulk: + post: + tags: + - Invoices + summary: Bulk compare collected invoices against draft/booked (V2) + operationId: compareCollectedInvoiceEconomicV2Bulk + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [collected_invoice_ids] + properties: + collected_invoice_ids: + type: array + minItems: 1 + maxItems: 200 + items: + type: integer + minimum: 1 + responses: + '200': + description: Bulk comparison completed + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareBulkResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/revenue-statistics: + get: + tags: + - Invoices + summary: Get overall booked revenue statistics from e-conomic (V2) + description: | + Aggregates booked e-conomic revenue across invoices and lines, with optional filters + for date range, customer(s), department(s), currency, and barred-customer status. + operationId: getCollectedInvoiceEconomicV2RevenueStatistics + parameters: + - name: dateFrom + in: query + required: false + description: Start date (inclusive), defaults to first day of current month. + schema: + type: string + format: date + - name: dateTo + in: query + required: false + description: End date (inclusive), defaults to today. + schema: + type: string + format: date + - name: customer_numbers + in: query + required: false + description: Comma-separated customer numbers to include. + schema: + type: string + example: "42493959,42493960" + - name: department_numbers + in: query + required: false + description: Comma-separated department numbers to include. + schema: + type: string + example: "75,10" + - name: currency + in: query + required: false + description: Restrict to a specific invoice currency. + schema: + type: string + example: "DKK" + - name: barred + in: query + required: false + description: Filter by e-conomic customer barred status. + schema: + type: string + enum: [all, barred, active] + default: all + - name: max_pages + in: query + required: false + description: Safety cap for paginated e-conomic reads. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 10 + responses: + '200': + description: Revenue statistics resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2RevenueStatisticsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period: + get: + tags: + - Invoices + summary: Get invoicing periods + description: Retrieve invoicing periods for superusers + operationId: getInvoicingPeriods + parameters: + - name: dateFrom + in: query + required: true + schema: {type: string, format: date} + - name: dateTo + in: query + required: true + schema: {type: string, format: date} + responses: + '200': + description: Invoicing periods retrieved successfully + content: + application/json: + schema: {} + + /superuser/invoicing/period/distribution/fixed-pricing: + get: + tags: + - Invoices + summary: Get fixed pricing distribution + description: Get invoicing distribution for fixed pricing items + operationId: getInvoicingFixedPricingDistribution + parameters: + - name: dateFrom + in: query + required: true + schema: {type: string, format: date} + - name: dateTo + in: query + required: true + schema: {type: string, format: date} + responses: + '200': + description: Fixed pricing distribution retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionResponse' + + /superuser/invoicing/period/distribution/wash-subscriptions: + get: + tags: + - Invoices + summary: Get wash subscriptions distribution + description: Get invoicing distribution for wash subscriptions + operationId: getInvoicingWashSubscriptionsDistribution + parameters: + - name: dateFrom + in: query + required: true + schema: {type: string, format: date} + - name: dateTo + in: query + required: true + schema: {type: string, format: date} + responses: + '200': + description: Wash subscriptions distribution retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingWashSubscriptionsDistributionResponse' + + /superuser/invoicing/period/distribution/v2/all: + get: + tags: + - Invoices + summary: Get version-aware historical distribution (all) + operationId: getInvoicingPeriodDistributionV2All + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware historical distribution (all categories) + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2AllResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/fixed-pricing: + get: + tags: + - Invoices + summary: Get version-aware historical fixed pricing distribution + operationId: getInvoicingPeriodDistributionV2FixedPricing + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware fixed pricing distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2FixedPricingResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/wash-subscriptions: + get: + tags: + - Invoices + summary: Get version-aware historical wash subscription distribution + operationId: getInvoicingPeriodDistributionV2WashSubscriptions + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware wash subscription distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/customer-prices: + get: + tags: + - Invoices + summary: Get version-aware historical customer-price discount distribution + operationId: getInvoicingPeriodDistributionV2CustomerPrices + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware customer-price discount distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/booked-department-75: + get: + tags: + - Invoices + summary: Get booked e-conomic department 75 redistribution + operationId: getInvoicingPeriodDistributionV2BookedDepartment75 + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Actual booked e-conomic department 75 net amounts redistributed to internal departments + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/customers/pricing-history: + get: + tags: + - Invoices + summary: Get customer versioned pricing/subscription/discount timeline + operationId: getCustomerPricingHistoryV2 + parameters: + - name: customer_number + in: query + required: true + schema: + type: integer + minimum: 1 + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Customer timeline resolved + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerPricingHistoryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + # Vehicles Endpoints + /vehicles: + get: + tags: + - Vehicles + summary: List vehicles + description: | + List vehicles or fetch a specific vehicle when `id` is provided. + + - When `id` is present, returns a single vehicle object (404 if not found). + - Otherwise returns a paginated list of vehicles. + + Permissions: + - Own scope: `list_own_vehicles` (linked to subuser node `VEHICLES_LIST`). + - Broader scope: `list_vehicles_other`. + + Subusers may specify header `X-Customer-Number` to target a specific customer. If the broader + permission is missing, the list will automatically be restricted to the effective customer context. + operationId: listVehicles + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/XCustomerNumber' + - name: id + in: query + schema: {type: integer} + - name: reg + in: query + schema: {type: string} + - name: customer_id + in: query + schema: {type: integer} + responses: + '200': + description: Vehicle(s) retrieved successfully + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Vehicle' + - type: array + items: + $ref: '#/components/schemas/Vehicle' + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + post: + tags: + - Vehicles + summary: Add vehicle + operationId: addVehicle + description: | + Create a new vehicle for a customer. + + Permissions: + - Own scope: `add_vehicle` (linked to subuser node `VEHICLES_ADD`). + - Broader scope: `add_vehicle_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [reg, type, wash_subscription] + properties: + reg: + type: string + minLength: 2 + maxLength: 12 + description: Vehicle registration number + type: + type: integer + description: Product ID representing the vehicle wash type + wash_subscription: + type: boolean + reference: + type: string + maxLength: 255 + nullable: true + customer_id: + type: integer + description: Optional explicit target customer. Defaults to the effective customer context. + responses: + '200': + description: Vehicle created + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + put: + tags: + - Vehicles + summary: Edit vehicle + operationId: editVehicle + description: | + Update fields on an existing vehicle. + + Permissions: + - Own scope: `edit_vehicle` (linked to subuser node `VEHICLES_EDIT`). + - Broader scope: `edit_vehicle_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + reg: + type: string + minLength: 2 + maxLength: 12 + type: {type: integer} + wash_subscription: {type: boolean} + reference: + type: string + maxLength: 255 + nullable: true + responses: + '200': + description: Vehicle updated + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: + - Vehicles + summary: Delete vehicle + operationId: deleteVehicle + description: | + Delete an existing vehicle. + + Permissions: + - Own scope: `delete_vehicle` (linked to subuser node `VEHICLES_DELETE`). + - Broader scope: `delete_vehicle_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Vehicle deleted + content: + application/json: + schema: {} + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /vehicles/addons/available: + get: + tags: + - Vehicles + summary: Get available vehicle addons + description: | + Get list of available addons for a vehicle. + + Permissions: + - Own scope: `list_vehicle_addon_own` (linked to subuser node `VEHICLES_LIST`). + - Broader scope: `list_vehicles_addon_other`. + operationId: getAvailableVehicleAddons + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Available addons retrieved successfully + content: + application/json: + schema: {} + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /vehicles/addons/toggle: + post: + tags: + - Vehicles + summary: Toggle vehicle addon + description: | + Enable or disable a vehicle addon for a vehicle. + + Permissions: + - Own scope: `toggle_vehicle_addon_own` (linked to subuser node `VEHICLES_EDIT`). + - Broader scope: `toggle_vehicle_addon_other`. + operationId: toggleVehicleAddon + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [vehicle_id, addon_id] + properties: + vehicle_id: + type: integer + addon_id: + type: integer + responses: + '200': + description: Vehicle addon toggled successfully + content: + application/json: + schema: {} + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /department/vehicles/unknown-customer: + get: + tags: + - Vehicles + summary: Get unknown customer vehicles in department + operationId: getUnknownCustomerVehicles + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /department/vehicle/customer-suggestions: + get: + tags: + - Vehicles + summary: Get vehicle customer suggestions + operationId: getVehicleCustomerSuggestions + parameters: + - name: reg + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /vehicles/set-auto-start-on-lpr: + post: + tags: + - Vehicles + summary: Set auto start on LPR + operationId: setVehicleAutoStartOnLpr + description: | + Enable or disable automatic start on LPR for a vehicle in XL Vask. + + Permissions: + - Own scope: `set_auto_start_on_lpr` (linked to subuser node `VEHICLES_EDIT`). + - Broader scope: `set_auto_start_on_lpr_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, active] + properties: + id: {type: integer} + active: {type: boolean} + responses: + '200': + description: Success + content: + application/json: + schema: {} + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /vehicles/set-vehicle-type-id: + post: + tags: + - Vehicles + summary: Set vehicle type ID + operationId: setVehicleTypeId + description: | + Set or change the XL Vask `vehicleTypeId` for a vehicle. + + Permissions: + - Own scope: `set_vehicle_type_id` (linked to subuser node `VEHICLES_EDIT`). + - Broader scope: `set_vehicle_type_id_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, vehicleTypeId] + properties: + id: {type: integer} + vehicleTypeId: + type: string + minLength: 1 + maxLength: 50 + responses: + '200': + description: Success + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /superuser/users-with-vehicle-subscriptions: + get: + tags: + - Vehicles + summary: Get users with vehicle subscriptions + operationId: getUsersWithVehicleSubscriptions + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /vehicles/status: + get: + tags: + - Vehicles + summary: Get vehicle status + operationId: getVehicleStatus + parameters: + - name: reg + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /vehicles/search: + get: + tags: + - Vehicles + summary: Search vehicles + operationId: searchVehicles + parameters: + - name: search + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Notifications Endpoints + /notifications: + get: + tags: + - Notifications + summary: List notifications + description: Get list of notifications + operationId: listNotifications + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Notifications retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Notification' + post: + tags: + - Notifications + summary: Create notification + description: Create a new notification + operationId: createNotification + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationCreate' + responses: + '201': + description: Notification created successfully + content: + application/json: + schema: {} + delete: + tags: + - Notifications + summary: Delete notification + description: Delete a notification + operationId: deleteNotification + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Notification deleted successfully + content: + application/json: + schema: {} + + # Statistics Endpoints + /statistics/bookings/new: + get: + tags: + - Statistics + summary: Get new bookings statistics + description: Get statistics for new bookings + operationId: getNewBookingsStats + responses: + '200': + description: New bookings statistics retrieved successfully + content: + application/json: + schema: {} + + /orders/module/stripe/payment_intent: + get: + tags: + - Orders + summary: Get Stripe payment intent + operationId: getStripePaymentIntent + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Orders + summary: Create Stripe payment intent + operationId: createStripePaymentIntent + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, reader] + properties: + id: {type: integer} + reader: {type: string} + tax_percentage: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + delete: + tags: + - Orders + summary: Delete Stripe payment intent + operationId: deleteStripePaymentIntent + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /orders/module/stripe/payment_intent/capture: + post: + tags: + - Orders + summary: Capture Stripe payment intent + operationId: captureStripePaymentIntent + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /orders/module/stripe/debug/simulate_payment: + post: + tags: + - Orders + summary: Simulate Stripe payment + operationId: simulateStripePayment + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /statistics/orders/new: + get: + tags: + - Statistics + summary: Get new orders statistics + description: Get statistics for new orders + operationId: getNewOrdersStats + responses: + '200': + description: New orders statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/today: + get: + tags: + - Statistics + summary: Get today's income + description: Get income statistics for today + operationId: getTodayIncome + responses: + '200': + description: Today's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/yesterday: + get: + tags: + - Statistics + summary: Get yesterday's income + description: Get income statistics for yesterday + operationId: getYesterdayIncome + responses: + '200': + description: Yesterday's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/this-month: + get: + tags: + - Statistics + summary: Get this month's income + description: Get income statistics for the current month + operationId: getThisMonthIncome + responses: + '200': + description: This month's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/last-month: + get: + tags: + - Statistics + summary: Get last month's income + description: Get income statistics for the previous month + operationId: getLastMonthIncome + responses: + '200': + description: Last month's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/this-year: + get: + tags: + - Statistics + summary: Get this year's income + description: Get income statistics for the current year + operationId: getThisYearIncome + responses: + '200': + description: This year's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/departments: + get: + tags: + - Statistics + summary: Get total income today by departments + operationId: getTotalIncomeTodayByDepartments + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /statistics/economic/totals: + get: + tags: + - Statistics + summary: Get total economic statistics + operationId: getEconomicTotals + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /statistics/economic/totals/department_sent_invoice_totals: + get: + tags: + - Statistics + summary: Get department sent invoice totals + operationId: getDepartmentSentInvoiceTotals + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /statistics/economic/totals/department_draft_invoice_totals: + get: + tags: + - Statistics + summary: Get department draft invoice totals + operationId: getDepartmentDraftInvoiceTotals + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Worker Endpoints + /worker/version: + get: + tags: + - Worker + summary: Get worker version + description: Get the current version of the system worker + operationId: getWorkerVersion + responses: + '200': + description: Worker version retrieved successfully + content: + application/json: + schema: {} + + /worker/update-version: + get: + tags: + - Worker + summary: Update worker version + description: Set the target version for the worker update + operationId: updateWorkerVersion + parameters: + - name: version + in: query + required: true + schema: + type: string + responses: + '200': + description: Version update target set successfully + content: + application/json: + schema: {} + + /worker/status: + get: + tags: + - Worker + summary: Get worker status + description: Get detailed status of the system worker + operationId: getWorkerStatus + responses: + '200': + description: Worker status retrieved successfully + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + api_commit_sha: + type: string + description: Running API commit SHA, or unknown when unavailable. + + /worker/debug: + get: + tags: + - Worker + summary: Debug worker + description: Execute debug commands on the worker (often restricted) + operationId: debugWorker + responses: + '200': + description: Debug information retrieved successfully + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + + /worker/debug/on: + get: + tags: + - Worker + summary: Enable worker debug + operationId: enableWorkerDebug + responses: + '200': + description: Worker debug enabled + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + + /worker/debug/off: + get: + tags: + - Worker + summary: Disable worker debug + operationId: disableWorkerDebug + responses: + '200': + description: Worker debug disabled + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + + /worker/licenseplates: + get: + tags: + - Worker + summary: Get unique license plates + description: Fetch all unique license plates from various database tables + operationId: getWorkerLicensePlates + responses: + '200': + description: License plates retrieved successfully + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + + /economic/doesCustomerExist: + get: + tags: + - Modules + summary: Check if customer exists in e-conomic + operationId: checkEconomicCustomerExists + parameters: + - name: cvr + in: query + required: true + schema: + type: string + responses: + '200': + description: Customer check completed + content: + application/json: + schema: {} + '404': + $ref: '#/components/responses/NotFound' + + /cvr/lookup: + get: + tags: + - Modules + summary: Lookup CVR information + description: Get detailed information for a CVR number + operationId: lookupCvr + parameters: + - name: cvr + in: query + required: true + schema: + type: string + responses: + '200': + description: CVR information retrieved successfully + content: + application/json: + schema: {} + + /cvr/search: + get: + tags: + - Modules + summary: Search CVR + description: Search for companies by name or CVR + operationId: searchCvr + parameters: + - name: query + in: query + required: true + schema: + type: string + minLength: 2 + responses: + '200': + description: Search results retrieved successfully + content: + application/json: + schema: {} + + # Plate Scans Endpoints + /numberplatescans: + get: + tags: + - Plate Scans + summary: List plate scans + description: Get a list of license plate scans + operationId: listPlateScans + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Plate scans retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Plate Scans + summary: Record plate scan + description: Record a new license plate scan + operationId: recordPlateScan + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - plate + - lane_id + properties: + plate: + type: string + lane_id: + type: integer + responses: + '201': + description: Plate scan recorded successfully + content: + application/json: + schema: {} + + /numberplatescans/department: + post: + tags: + - Plate Scans + summary: Record plate scan for department + operationId: recordDepartmentPlateScan + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - plate + - department_id + properties: + plate: + type: string + department_id: + type: integer + responses: + '201': + description: Plate scan recorded successfully + content: + application/json: + schema: {} + + /numberplatescans/post: + get: + tags: + - Plate Scans + summary: Get post-scan results + operationId: getPlateScanPostResults + responses: + '200': + description: Post-scan results retrieved successfully + content: + application/json: + schema: {} + + /numberplatescanners: + get: + tags: + - Plate Scans + summary: List plate scanners + description: Get a list of all number plate scanners + operationId: listPlateScanners + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Plate scanners retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Plate Scans + summary: Add plate scanner + operationId: addPlateScanner + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, name, notes] + properties: + department_id: {type: integer} + name: {type: string} + notes: {type: string} + responses: + '201': + description: Plate scanner added successfully + content: + application/json: + schema: {} + put: + tags: + - Plate Scans + summary: Update plate scanner + operationId: updatePlateScanner + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, department_id, name, notes] + properties: + id: {type: integer} + department_id: {type: integer} + name: {type: string} + notes: {type: string} + responses: + '200': + description: Plate scanner updated successfully + content: + application/json: + schema: {} + + /department/numberplatescanners: + get: + tags: + - Plate Scans + summary: List department plate scanners + operationId: listDepartmentPlateScanners + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Department plate scanners retrieved successfully + content: + application/json: + schema: {} + + /relay/button/press/post: + get: + tags: + - Plate Scans + summary: Record machine start button press webhook + operationId: addButtonPress + parameters: + - name: token + in: query + required: false + schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: reg + in: query + required: false + schema: + type: string + responses: + '201': + description: Button press recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Plate Scans + summary: Record machine start button press webhook + operationId: addButtonPressPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + reg: + type: string + responses: + '201': + description: Button press recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '404': + $ref: '#/components/responses/NotFound' + + /relay/machine/on/post: + get: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud webhook/query parameters for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignal + parameters: + - name: token + in: query + required: false + schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: relay_id + in: query + required: false + schema: + type: string + - name: event + in: query + required: false + schema: + type: string + enum: [input.toggle_on, switch.on] + - name: reg + in: query + required: false + schema: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + post: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud JSON webhook payloads for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignalPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + relay_id: + type: string + event: + type: string + enum: [input.toggle_on, switch.on] + component: + type: string + example: input:0 + state: + type: boolean + output: + type: boolean + reg: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + + # Module - e-conomic Endpoints + /economic/customers/import: + post: + tags: + - Modules + summary: Import e-conomic customers + description: Import customers from e-conomic + operationId: importEconomicCustomers + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Customers imported successfully + content: + application/json: + schema: {} + + /economic/departments: + get: + tags: + - Modules + summary: Get e-conomic departments + operationId: getEconomicDepartments + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /economic/products: + get: + tags: + - Modules + summary: Get e-conomic products + operationId: getEconomicProducts + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/economic/customer: + get: + tags: + - Modules + summary: Get e-conomic customer details + operationId: getEconomicCustomer + parameters: + - name: customer_number + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Modules + summary: Create e-conomic customer + operationId: createEconomicCustomer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customer_number, cvr, email, phone, name] + properties: + customer_number: {type: integer} + cvr: {type: integer} + email: {type: string} + phone: {type: integer} + name: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /economic/layouts: + get: + tags: + - Modules + summary: Get e-conomic layouts + description: Get available invoice layouts from e-conomic + operationId: getEconomicLayouts + responses: + '200': + description: Layouts retrieved successfully + content: + application/json: + schema: {} + + /economic/payment-terms: + get: + tags: + - Modules + summary: Get e-conomic payment terms + description: Get available payment terms from e-conomic + operationId: getEconomicPaymentTerms + responses: + '200': + description: Payment terms retrieved successfully + content: + application/json: + schema: {} + + /economic/invoice/draft/export: + post: + tags: + - Modules + summary: Export draft invoice to e-conomic + description: Exports draft invoice using queue processing when available, with synchronous fallback when queue dependencies are unavailable. + operationId: queueDraftInvoiceExportToEconomic + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [order_id] + properties: + order_id: + type: integer + minimum: 1 + responses: + '200': + description: Draft invoice export processed synchronously (fallback) + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse' + '202': + description: Draft invoice export queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /economic/invoice/draft/export/status: + get: + tags: + - Modules + summary: Get queued draft export job status + operationId: getDraftInvoiceExportQueueStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /economic/invoice/draft/export/retry: + post: + tags: + - Modules + summary: Retry failed draft export queue job + operationId: retryDraftInvoiceExportQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /economic/invoice/export: + post: + tags: + - Modules + summary: Export invoice to e-conomic + description: Exports booked invoice using queue processing when available, with synchronous fallback when queue dependencies are unavailable. + operationId: queueInvoiceExportToEconomic + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [order_id] + properties: + order_id: + type: integer + minimum: 1 + responses: + '200': + description: Invoice export processed synchronously (fallback) + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse' + '202': + description: Invoice export queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /economic/invoice/export/status: + get: + tags: + - Modules + summary: Get queued invoice export job status + operationId: getInvoiceExportQueueStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /economic/invoice/export/retry: + post: + tags: + - Modules + summary: Retry failed invoice export queue job + operationId: retryInvoiceExportQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + # Module - Stripe Endpoints + /modules/stripe/customers: + get: + tags: + - Modules + summary: List Stripe customers + description: Get list of Stripe customers + operationId: listStripeCustomers + responses: + '200': + description: Stripe customers retrieved successfully + content: + application/json: + schema: {} + + /modules/stripe/products: + get: + tags: + - Modules + summary: List Stripe products + description: Get list of Stripe products + operationId: listStripeProducts + responses: + '200': + description: Stripe products retrieved successfully + content: + application/json: + schema: {} + + /modules/stripe/prices: + get: + tags: + - Modules + summary: List Stripe prices + description: Get list of Stripe prices + operationId: listStripePrices + responses: + '200': + description: Stripe prices retrieved successfully + content: + application/json: + schema: {} + + /modules/stripe/invoice: + post: + tags: + - Modules + summary: Create Stripe invoice + description: Create an invoice in Stripe + operationId: createStripeInvoice + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '201': + description: Stripe invoice created successfully + content: + application/json: + schema: {} + + /modules/stripe/terminal/readers: + get: + tags: + - Modules + summary: List Stripe terminal readers + operationId: listStripeTerminalReaders + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/stripe/terminal/locations: + get: + tags: + - Modules + summary: List Stripe terminal locations + operationId: listStripeTerminalLocations + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/stripe/department/terminal/location: + get: + tags: + - Modules + summary: Get department terminal location + operationId: getDepartmentTerminalLocation + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Modules + summary: Set department terminal location + operationId: setDepartmentTerminalLocation + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, location] + properties: + id: {type: integer} + location: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/stripe/department/terminal/readers: + get: + tags: + - Modules + summary: Get department terminal readers + operationId: getDepartmentTerminalReaders + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Module - Backup Endpoints + /modules/backup/backups: + get: + tags: + - Modules + summary: List backup modules + operationId: listBackupModules + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Modules + summary: Create backup module + operationId: createBackupModule + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, description] + properties: + name: {type: string} + description: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Module - XLVask Endpoints + /modules/xlvask/usageLog: + get: + tags: + - Modules + summary: Get XLVask usage logs + description: Retrieve usage logs from XLVask system + operationId: getXlvaskUsageLogs + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Usage logs retrieved successfully + content: + application/json: + schema: {} + + /modules/xlvask/vehicles: + get: + tags: + - Modules + summary: List XLVask vehicles + description: Get list of vehicles from XLVask + operationId: listXlvaskVehicles + responses: + '200': + description: XLVask vehicles retrieved successfully + content: + application/json: + schema: {} + + /modules/xlvask/customers: + get: + tags: + - Modules + summary: List XLVask customers + description: Get list of customers from XLVask + operationId: listXlvaskCustomers + responses: + '200': + description: XLVask customers retrieved successfully + content: + application/json: + schema: {} + + /modules/action-logs: + get: + tags: + - Modules + summary: List module action logs + description: Retrieve a paginated list of module action logs with searching and filtering + operationId: listModuleActionLogs + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Module action logs retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ModuleActionLog' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # Module - Self-Serve Endpoints + /modules/self-serve/lane/status: + get: + tags: + - Modules + summary: Get self-serve lane status + description: Retrieve the current status of a self-serve lane + operationId: getSelfServeLaneStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Lane status retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneStatus' + + /modules/self-serve/lane/wash/in-progress: + get: + tags: + - Modules + summary: Get in-progress self-serve wash customer and vehicle details + description: | + Returns the current open self-serve wash session details for a lane (if any), + including resolved customer and vehicle details. + operationId: getSelfServeLaneWashInProgress + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: In-progress wash details resolved + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + in_progress: + type: boolean + session: + type: object + nullable: true + properties: + id: + type: integer + status: + type: string + reg: + type: string + customer_number: + type: integer + nullable: true + vehicle_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + included_minutes: + type: integer + nullable: true + machine_type_id: + type: integer + nullable: true + machine_relay_enabled: + type: boolean + machine_relay_enabled_at: + type: string + nullable: true + machine_start_triggered: + type: boolean + machine_start_triggered_at: + type: string + nullable: true + wash_started_at: + type: string + nullable: true + created_at: + type: string + updated_at: + type: string + nullable: true + customer: + type: object + nullable: true + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + nullable: true + display_name: + type: string + nullable: true + email: + type: string + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: string + nullable: true + vehicle: + type: object + nullable: true + properties: + id: + type: integer + customer_id: + type: integer + type: + type: integer + reg: + type: string + reference: + type: string + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/sessions: + get: + tags: + - Modules + summary: List self-serve wash sessions + description: Retrieve paginated self-serve wash sessions with search, filters, ordering, and active/open-only support. + operationId: listSelfServeSessions + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + - name: order + in: query + required: false + schema: + type: string + example: id:DESC + - name: open_only + in: query + required: false + schema: + type: boolean + responses: + '200': + description: Self-serve wash sessions retrieved successfully + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/SelfserveWashSession' + - type: object + properties: + elapsed_minutes: + type: integer + open: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/sessions/{id}: + get: + tags: + - Modules + summary: Get self-serve wash session detail + operationId: getSelfServeSessionDetail + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Self-serve wash session detail retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveWashSummary' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /modules/self-serve/lane/force/stop: + post: + tags: + - Modules + summary: Force stop a self-serve wash session + description: | + Clears the current self-serve wash session and lane runtime with RESET behavior only. + This administrative action does not signal relays or gates. When billing is requested, + only elapsed-minute billing is attempted before runtime is cleared. + operationId: forceStopSelfServeLane + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - bill + properties: + lane_id: + type: integer + session_id: + type: integer + nullable: true + bill: + type: boolean + reason: + type: string + nullable: true + responses: + '200': + description: Self-serve wash force stopped successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveForceStopResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + + /modules/self-serve/lane/command: + post: + tags: + - Modules + summary: Send self-serve lane command + description: | + Send a command (e.g., start, stop, reset) to a self-serve lane. + Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here. + Property gate commands require the matching explicit command permissions. + Operator callers require the base command permission plus the command-specific permission. Authenticated + customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve + lanes. Customer `STOP` requires the customer's active self-serve wash in the target department. + operationId: sendSelfServeLaneCommand + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - command + properties: + lane_id: + type: integer + command: + type: string + enum: [START, STOP, RESET, RESERVE, RELEASE, OPEN_PROPERTY_ACCESS_GATE, OPEN_PROPERTY_EXIT_GATE] + license_plate: + type: string + description: Required for START command + customer_number: + type: integer + description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients. + defer_relay_side_effects: + type: boolean + default: false + description: For START, open the entrance gate as part of the command but defer cleaner and machine relay activation to explicit relay endpoints. + responses: + '200': + description: Command sent successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneStatus' + '400': + description: Command execution failed + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Failed to execute command: Failed to open property access gate.' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/services/allowed: + post: + tags: + - Modules + summary: Set allowed services for a lane based on shown tasks + description: | + Updates the set of services that are allowed to be manually activated for a given self-serve lane, + derived from the tasks currently shown to the user after answering the self-serve questions. + This endpoint does not activate anything by itself; it only sets what is allowed to be activated. + Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with + `list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before + confirming a wash start. + operationId: setSelfServeLaneAllowedServices + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + properties: + lane_id: + type: integer + task_ids: + type: array + description: List of task IDs that are currently shown to the user + items: + type: integer + responses: + '200': + description: Allowed services updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + allowed_services: + type: array + items: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/gate/open: + post: + tags: + - Modules + summary: Open a self-serve lane gate + description: | + Opens either the ENTRANCE or EXIT gate relay for a self-serve lane. + Failures return a sanitized gate-specific message. + operationId: openSelfServeLaneGate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - gate + properties: + lane_id: + type: integer + gate: + type: string + enum: [ENTRANCE, EXIT] + responses: + '200': + description: Lane gate opened + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + gate: + type: string + enum: [ENTRANCE, EXIT] + opened: + type: boolean + state: + type: string + '400': + description: Gate open failed + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Failed to open entrance gate. + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine/status: + get: + tags: + - Modules + summary: Get MACHINE relay status for a lane + description: | + Reads the current Shelly MACHINE relay status (`on`/`off`) for the given lane. + operationId: getSelfServeLaneMachineRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: MACHINE relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_program_picker/status: + get: + tags: + - Modules + summary: Get MACHINE_PROGRAM_PICKER relay status for a lane + description: | + Reads the current Shelly MACHINE_PROGRAM_PICKER relay status (`on`/`off`) for the given lane. + operationId: getSelfServeLaneMachineProgramPickerRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: MACHINE_PROGRAM_PICKER relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_program_picker/set: + post: + tags: + - Modules + summary: Set MACHINE_PROGRAM_PICKER relay status for a lane + description: | + Sets the Shelly MACHINE_PROGRAM_PICKER relay state for the lane to on or off and returns the latest status. + operationId: setSelfServeLaneMachineProgramPickerRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + responses: + '200': + description: MACHINE_PROGRAM_PICKER relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE_PROGRAM_PICKER] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_cleaner/status: + get: + tags: + - Modules + summary: Get MACHINE_CLEANER relay status for a lane + description: | + Reads the current Shelly MACHINE_CLEANER relay status (`on`/`off`) for the given lane. + operationId: getSelfServeLaneMachineCleanerRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: MACHINE_CLEANER relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_cleaner/set: + post: + tags: + - Modules + summary: Set MACHINE_CLEANER relay status for a lane + description: | + Sets the Shelly MACHINE_CLEANER relay state for the lane to on or off and returns the latest status. + operationId: setSelfServeLaneMachineCleanerRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + responses: + '200': + description: MACHINE_CLEANER relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE_CLEANER] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine/set: + post: + tags: + - Modules + summary: Set MACHINE relay status for a lane + description: | + Sets the Shelly MACHINE relay state for the lane to on or off and returns the latest status. + operationId: setSelfServeLaneMachineRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + responses: + '200': + description: MACHINE relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine/enable: + post: + tags: + - Modules + summary: Manually enable MACHINE relay for a lane + description: | + Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services + include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically + enabled; an explicit call to this endpoint is required. Operator callers require + `modules_selfserve_lane_relay_enable_machine`; authenticated customers with + `list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash. + operationId: enableSelfServeLaneMachineRelay + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + properties: + lane_id: + type: integer + duration: + type: integer + description: Optional number of seconds after which the relay should automatically turn off + responses: + '200': + description: MACHINE relay enabled + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enabled: + type: boolean + duration: + type: integer + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Not allowed to enable MACHINE relay (no matching task currently shown) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /modules/self-serve/lane/force/machine/enable: + post: + tags: + - Modules + summary: Force enable MACHINE relay and mark lane as in-wash (superusers only) + description: | + Superuser/emergency endpoint. Bypasses the allowed services gating and directly turns on the MACHINE relay. + Also ensures the lane is marked as OCCUPIED and IN_WASH with a wash start timestamp if not already set. + operationId: forceEnableSelfServeLaneMachine + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + properties: + lane_id: + type: integer + duration: + type: integer + nullable: true + description: Optional number of seconds after which the relay should automatically turn off + license_plate: + type: string + nullable: true + description: Optional license plate to associate with the lane + responses: + '200': + description: MACHINE relay force-enabled and lane marked in-wash + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + forced: + type: boolean + machine: + type: string + enum: [ENABLED] + duration: + type: integer + nullable: true + status: + type: string + state: + type: string + wash_start_time: + type: integer + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/force/machine/disable: + post: + tags: + - Modules + summary: Force disable MACHINE relay but keep lane as in-wash (superusers only) + description: | + Superuser/emergency endpoint. Turns off the MACHINE relay while ensuring the lane remains in an IN_WASH state + (simulating a started wash without machine assistance). + operationId: forceDisableSelfServeLaneMachine + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + properties: + lane_id: + type: integer + license_plate: + type: string + nullable: true + responses: + '200': + description: MACHINE relay force-disabled and lane ensured in-wash + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + forced: + type: boolean + machine: + type: string + enum: [DISABLED] + status: + type: string + state: + type: string + wash_start_time: + type: integer + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # Module - Other Integration Endpoints + /modules/motorapi/lookup: + get: + tags: + - Modules + summary: Lookup vehicle via MotorAPI + description: Look up vehicle information using license plate + operationId: motorApiLookup + parameters: + - name: plate + in: query + required: true + schema: + type: string + responses: + '200': + description: Vehicle information retrieved successfully + content: + application/json: + schema: {} + + /modules/virkdata/search: + get: + tags: + - Modules + summary: Search VirkData + description: Search for company information in VirkData + operationId: virkdataSearch + parameters: + - name: search + in: query + required: true + schema: + type: string + responses: + '200': + description: Company information retrieved successfully + content: + application/json: + schema: {} + + /modules/fxratesapi/rate: + get: + tags: + - Modules + summary: Get exchange rate + description: Get current exchange rate + operationId: getExchangeRate + parameters: + - name: from + in: query + required: true + schema: + type: string + - name: to + in: query + required: true + schema: + type: string + responses: + '200': + description: Exchange rate retrieved successfully + content: + application/json: + schema: {} + + /modules/fxratesapi/rates: + get: + tags: + - Modules + summary: Get all exchange rates + description: Get all available exchange rates + operationId: getAllExchangeRates + responses: + '200': + description: Exchange rates retrieved successfully + content: + application/json: + schema: {} + + + /modules/weatherapi/current: + get: + tags: + - Modules + summary: Get current weather + description: Get current weather data from WeatherAPI for a location query + operationId: weatherApiCurrent + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Location query (e.g. city, postal code, or latitude,longitude) + responses: + '200': + description: Current weather retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiObjectResponse' + + /modules/weatherapi/forecast: + get: + tags: + - Modules + summary: Get weather forecast + description: Get forecast weather data from WeatherAPI + operationId: weatherApiForecast + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Location query (e.g. city, postal code, or latitude,longitude) + - name: days + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 14 + description: Number of forecast days + responses: + '200': + description: Forecast weather retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiObjectResponse' + + /modules/weatherapi/search: + get: + tags: + - Modules + summary: Search weather locations + description: Search location suggestions from WeatherAPI + operationId: weatherApiSearch + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Search text + responses: + '200': + description: Location search results retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiObjectResponse' + + /modules/workfeed/employees: + get: + tags: + - Modules + summary: List Workfeed employees + description: List employees from Workfeed (`GET /companies/{CompanyID}/employees`) + operationId: workfeedListEmployees + responses: + '200': + description: Workfeed employees retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedEmployeeListResponse' + + /modules/workfeed/employees/{id}: + get: + tags: + - Modules + summary: Get Workfeed employee + description: Retrieve a single Workfeed employee by identifier + operationId: workfeedGetEmployee + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workfeed employee identifier + responses: + '200': + description: Workfeed employee retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedEmployeeSingleResponse' + + /modules/workfeed/shifts: + get: + tags: + - Modules + summary: List Workfeed shifts + description: List Workfeed shifts (`GET /companies/{CompanyID}/shifts`) + operationId: workfeedListShifts + parameters: + - name: startFrom + in: query + required: true + schema: + type: string + format: date-time + description: Only return shifts starting on or after this timestamp (ISO 8601) + - name: startTo + in: query + required: true + schema: + type: string + format: date-time + description: Only return shifts starting before this timestamp (ISO 8601) + - name: employeeID + in: query + required: false + schema: + type: string + description: Filter shifts by employee ID + - name: released + in: query + required: false + schema: + type: boolean + description: Filter by released/published status + responses: + '200': + description: Workfeed shifts retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedShiftListResponse' + + /modules/workfeed/shifts/{id}: + get: + tags: + - Modules + summary: Get Workfeed shift + description: Retrieve a single Workfeed shift by identifier + operationId: workfeedGetShift + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workfeed shift identifier + responses: + '200': + description: Workfeed shift retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedShiftSingleResponse' + + /modules/workfeed/departments: + get: + tags: + - Modules + summary: List Workfeed departments + description: List departments from Workfeed (`GET /companies/{CompanyID}/departments`) + operationId: workfeedListDepartments + responses: + '200': + description: Workfeed departments retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedDepartmentListResponse' + + /departments/weather: + get: + tags: + - Departments + summary: Get department weather timeline + description: Returns hourly weather, washes, Workfeed employee-hours, and productivity status aggregated across selected departments (server local time). Default range is start of yesterday (`00:00`) to end of today (`23:00`). Use `date_from` and `date_to` (`YYYY-MM-DD`) together to override the range. If department coordinates are missing/invalid or WeatherAPI cannot resolve the location, weather data falls back silently and timeline slots default to `mostly_clear`. Slots return `unknown` status when they have no evaluable employee-hours or when one or more selected departments are missing department weather targets. + operationId: getDepartmentWeatherTimeline + parameters: + - name: id + in: query + required: false + schema: + type: array + items: + type: integer + minimum: 1 + minItems: 1 + uniqueItems: true + style: form + explode: true + description: Department ID list. Repeat `id` to select multiple departments (`?id=1&id=2`). + - name: ids + in: query + required: false + schema: + type: string + example: '1,2,3' + description: Optional CSV alternative for department IDs. Merged with `id` if both are provided. At least one of `id` or `ids` must be provided. + - name: date_from + in: query + required: false + schema: + type: string + format: date + example: '2026-03-23' + description: Optional range start date (`YYYY-MM-DD`). Must be used together with `date_to`. + - name: date_to + in: query + required: false + schema: + type: string + format: date + example: '2026-03-24' + description: Optional range end date (`YYYY-MM-DD`, inclusive). Must be used together with `date_from`. + responses: + '200': + description: Department weather timeline retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWeatherTimelineResponse' + example: + success: true + meta: [] + includes: [] + data: + - date: '2026-03-23' + time: '00:00' + current: false + weather: mostly_cloudy + washes: 1 + hours: 2.0 + status: degraded + - date: '2026-03-24' + time: '13:00' + current: true + weather: rain + washes: 0 + hours: 2.5 + status: unhealthy + - date: '2026-03-24' + time: '14:00' + current: false + weather: mostly_clear + washes: 0 + hours: 1.0 + status: unknown + + /departments/weather/targets: + get: + tags: + - Departments + summary: Get department weather status targets + description: Returns department-specific weather productivity thresholds used by `/departments/weather` to classify `healthy`, `degraded`, and `unhealthy` statuses. + operationId: getDepartmentWeatherTargets + parameters: + - name: id + in: query + required: false + schema: + type: array + items: + type: integer + minimum: 1 + minItems: 1 + uniqueItems: true + style: form + explode: true + description: Department ID list. Repeat `id` to select multiple departments (`?id=1&id=2`). + - name: ids + in: query + required: false + schema: + type: string + example: '1,2,3' + description: Optional CSV alternative for department IDs. Merged with `id` if both are provided. At least one of `id` or `ids` must be provided. + responses: + '200': + description: Department weather targets retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWeatherTargetsResponse' + example: + success: true + meta: [] + includes: [] + data: + - department_id: 1 + degraded_threshold: 1.0 + healthy_threshold: 1.3 + configured: true + - department_id: 2 + degraded_threshold: null + healthy_threshold: null + configured: false + put: + tags: + - Departments + summary: Upsert department weather status targets + description: Creates or updates the weather productivity thresholds for one department. `healthy_threshold` must be greater than or equal to `degraded_threshold`. + operationId: upsertDepartmentWeatherTarget + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWeatherTargetUpsertRequest' + example: + department_id: 1 + degraded_threshold: 1.0 + healthy_threshold: 1.3 + responses: + '200': + description: Department weather targets updated successfully + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/DepartmentWeatherTarget' + required: [data] + example: + success: true + meta: [] + includes: [] + data: + department_id: 1 + degraded_threshold: 1.0 + healthy_threshold: 1.3 + configured: true + + /modules/entra/users: + get: + tags: + - Modules + summary: List Microsoft Entra users + description: Get list of users from Microsoft Entra (Azure AD) + operationId: listEntraUsers + responses: + '200': + description: Entra users retrieved successfully + content: + application/json: + schema: {} + + # Attachments Endpoints + /attachments/upload: + post: + tags: + - Attachments + summary: Upload attachment + description: Upload a file attachment + operationId: uploadAttachment + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '201': + description: Attachment uploaded successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /orders/attachments: + get: + tags: + - Attachments + summary: List order attachments + description: Get attachments for an order + operationId: listOrderAttachments + parameters: + - name: order_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order attachments retrieved successfully + content: + application/json: + schema: {} + + /orders/attachments/upload: + post: + tags: + - Attachments + summary: Upload order attachment + description: Upload an attachment to an order + operationId: uploadOrderAttachment + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + order_id: + type: integer + file: + type: string + format: binary + responses: + '201': + description: Order attachment uploaded successfully + content: + application/json: + schema: {} + + /orders/attachments/download: + get: + tags: + - Attachments + summary: Download order attachment + description: Download a specific order attachment + operationId: downloadOrderAttachment + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Attachment downloaded successfully + content: + application/octet-stream: + schema: + type: string + format: binary + + # Forms Endpoints + /form: + get: + tags: + - Forms + summary: Get form + description: Retrieve a form definition + operationId: getForm + parameters: + - name: id + in: query + schema: + type: integer + responses: + '200': + description: Form retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Forms + summary: Submit form + description: Submit a form + operationId: submitForm + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, data] + properties: + id: + type: string + description: Form identifier + data: + type: object + description: Form submission data + g_recaptcha_response: + type: string + description: reCAPTCHA verification token (required if not authenticated) + responses: + '201': + description: Form submitted successfully + content: + application/json: + schema: {} + + # Permissions Endpoints + /permissions: + get: + tags: + - Users + summary: List permissions + description: Get list of all available permissions + operationId: listPermissions + responses: + '200': + description: Permissions retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Permission' + + # Customer Management Endpoints + /customer/attributes: + get: + tags: + - Users + summary: Get customer attributes + description: Get custom attributes for a customer + operationId: getCustomerAttributes + parameters: + - name: customer_id + in: query + schema: + type: integer + responses: + '200': + description: Customer attributes retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer attribute + description: Add a custom attribute to a customer + operationId: addCustomerAttribute + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '201': + description: Customer attribute added successfully + content: + application/json: + schema: {} + delete: + tags: + - Users + summary: Delete customer attribute + description: Remove a custom attribute from a customer + operationId: deleteCustomerAttribute + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Customer attribute deleted successfully + content: + application/json: + schema: {} + + /customer/notes: + get: + tags: + - Users + summary: Get customer notes + description: Get notes for a customer + operationId: getCustomerNotes + parameters: + - name: customer_id + in: query + schema: + type: integer + responses: + '200': + description: Customer notes retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer note + description: Add a note to a customer + operationId: addCustomerNote + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '201': + description: Customer note added successfully + content: + application/json: + schema: {} + delete: + tags: + - Users + summary: Delete customer note + description: Remove a note from a customer + operationId: deleteCustomerNote + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Customer note deleted successfully + content: + application/json: + schema: {} + + /customers/search: + post: + tags: + - Users + summary: Search customers + description: Search for customers using various criteria + operationId: searchCustomers + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + query: + type: string + responses: + '200': + description: Customers found successfully + content: + application/json: + schema: {} + + /search/system: + get: + tags: + - Search + summary: System-wide search + description: Search across all supported entities with permission-aware filtering and optional intent parsing debug metadata. Customer records and customer-related entities are matched against a local e-conomic customer index (name/address/email/CVR) that is refreshed by cron. Intent parsing is invoked adaptively when lexical confidence is low or when the query looks intent-driven. Results are ordered by relevance, with recent records preferred when relevance is comparable. + operationId: systemWideSearchGet + parameters: + - in: query + name: query + required: true + schema: + type: string + description: Free-text query to search for. Supports natural-language intent fallback and domain synonyms such as `rabat` -> `discount`. + - in: query + name: include_types + required: false + schema: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + style: form + explode: false + description: Comma-separated list of entity types to include. Defaults to all allowed types. + - in: query + name: exclude_types + required: false + schema: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + style: form + explode: false + description: Comma-separated list of entity types to exclude. + - in: query + name: include_associations + required: false + schema: + type: boolean + default: true + description: Include associated objects when matching a primary entity such as a customer. + - in: query + name: debug_intent + required: false + schema: + type: boolean + default: false + description: Include intent parser diagnostics in `meta.intent_parser`. + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Search results returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: + - Search + summary: System-wide search + description: Search across all supported entities using JSON request payload. Customer-related matches include local e-conomic customer index fields. Intent parsing may run adaptively for intent-driven natural-language queries. Results are ordered by relevance, with recent records preferred when relevance is comparable. + operationId: systemWideSearchPost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchRequest' + responses: + '200': + description: Search results returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/search/system/cache: + delete: + tags: + - Search + summary: Clear system search caches + description: Clears both query-result cache and intent-parser cache namespaces for system-wide search. + operationId: clearSystemSearchCache + responses: + '200': + description: Cache cleared successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchCacheClearResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/search/system/cache/rebuild: + post: + tags: + - Search + summary: Queue system search cache rebuild + description: Queues a cache rebuild request and clears active query/intent cache namespaces immediately. + operationId: rebuildSystemSearchCache + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchCacheRebuildRequest' + responses: + '200': + description: Cache rebuild queued successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchCacheRebuildResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/system/status: + get: + tags: + - Superuser + summary: Aggregated system status snapshot + description: Returns a read-only snapshot of runtime health, dependency connectivity, module configuration/probe status, and active user session activity for the superuser dashboard. + operationId: getSuperuserSystemStatus + parameters: + - in: query + name: force + required: false + schema: + type: boolean + default: false + description: Bypass cached external module probes for this request. + responses: + '200': + description: System status snapshot returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserSystemStatusResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/replication: + get: + tags: + - Superuser + summary: Database, Redis, and MinIO replication topology + operationId: getSuperuserReplication + parameters: + - in: query + name: refresh + required: false + schema: + type: boolean + default: false + description: Refresh host connectivity and replication status before returning the topology. + responses: + '200': + description: Replication topology returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/databases: + post: + tags: + - Superuser + summary: Add database replication host credentials + operationId: addSuperuserDatabaseReplicationHost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + responses: + '201': + description: Database replication host added + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/redis: + post: + tags: + - Superuser + summary: Add Redis replication host credentials + operationId: addSuperuserRedisReplicationHost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + responses: + '201': + description: Redis replication host added + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/minio: + post: + tags: + - Superuser + summary: Add MinIO replication host credentials + operationId: addSuperuserMinioReplicationHost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + responses: + '201': + description: MinIO replication host added + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/compose-template: + post: + tags: + - Superuser + summary: Generate a replication-ready Docker Compose template + operationId: generateSuperuserReplicationComposeTemplate + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationComposeTemplateRequest' + responses: + '200': + description: Docker Compose template generated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationComposeTemplateResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/test-credentials: + post: + tags: + - Superuser + summary: Test replication host credentials before saving + operationId: testSuperuserReplicationCredentials + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationUnsavedCredentialTestRequest' + responses: + '200': + description: Credential test returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/{kind}/{id}/test: + post: + tags: + - Superuser + summary: Test replication host connectivity and privileges + operationId: testSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + responses: + '200': + description: Host test result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /superuser/replication/{kind}/{id}/provision: + post: + tags: + - Superuser + summary: Provision a host as a replica of the current primary + operationId: provisionSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + responses: + '200': + description: Replica provisioning started or completed + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/{kind}/{id}/promote: + post: + tags: + - Superuser + summary: Promote a caught-up replica to primary + operationId: promoteSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + responses: + '200': + description: Replica promoted to primary + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/{kind}/{id}: + patch: + tags: + - Superuser + summary: Rename a replication host + operationId: renameSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostRenameRequest' + responses: + '200': + description: Replication host renamed + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: + - Superuser + summary: Remove an inactive or unhealthy replication host + operationId: removeSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + responses: + '200': + description: Replication host removed + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify: + get: + tags: + - Superuser + summary: Coolify-managed replicated infrastructure state + operationId: getSuperuserCoolify + responses: + '200': + description: Coolify summary returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/load-balancer: + get: + tags: + - Superuser + summary: Coolify public gateway Load Balancer state + operationId: getSuperuserCoolifyLoadBalancer + responses: + '200': + description: Load Balancer summary returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/load-balancer/reconcile: + post: + tags: + - Superuser + summary: Reconcile Coolify public gateway Load Balancer state + operationId: reconcileSuperuserCoolifyLoadBalancer + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + dry_run: + type: boolean + default: true + enforce: + type: boolean + default: false + responses: + '200': + description: Load Balancer reconcile result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerReconcileResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/load-balancer/routes/deploy: + post: + tags: + - Superuser + summary: Deploy the Coolify API route for the public gateway host + operationId: deploySuperuserCoolifyGatewayRoutes + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + dry_run: + type: boolean + default: true + enforce: + type: boolean + default: false + responses: + '200': + description: Gateway application route deploy result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewayRouteDeployResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/load-balancer/api/deploy: + post: + tags: + - Superuser + summary: Deploy the latest Coolify API code for the public gateway host + operationId: deploySuperuserCoolifyGatewayApiCode + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + dry_run: + type: boolean + default: true + enforce: + type: boolean + default: false + deploy_routes: + type: boolean + default: true + responses: + '200': + description: Gateway API code deployment result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewayRouteDeployResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/gateways: + get: + tags: + - Superuser + summary: List Coolify public gateway Load Balancer targets + operationId: listSuperuserCoolifyGateways + responses: + '200': + description: Gateway targets returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewaysResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + post: + tags: + - Superuser + summary: Create or update a Coolify public gateway Load Balancer target + operationId: saveSuperuserCoolifyGateway + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewaySaveRequest' + responses: + '201': + description: Gateway target saved + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewayResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/gateways/{id}/test: + post: + tags: + - Superuser + summary: Probe a Coolify public gateway target + operationId: testSuperuserCoolifyGateway + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Gateway target probe result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/instances: + post: + tags: + - Superuser + summary: Create Coolify API connection + operationId: createSuperuserCoolifyInstance + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyInstanceCreateRequest' + responses: + '201': + description: Coolify instance created + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyInstanceResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/instances/{id}/test: + post: + tags: + - Superuser + summary: Test Coolify API connection + operationId: testSuperuserCoolifyInstance + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Coolify connection test returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyOperationResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /superuser/coolify/instances/{id}/placement: + get: + tags: + - Superuser + summary: Discover Coolify placement options + operationId: discoverSuperuserCoolifyInstancePlacement + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Coolify project, environment, and server options returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyPlacementResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /superuser/coolify/targets: + get: + tags: + - Superuser + summary: List Coolify-managed replication targets + operationId: listSuperuserCoolifyTargets + parameters: + - in: query + name: kind + required: false + schema: + type: string + enum: [database, redis, minio] + responses: + '200': + description: Coolify targets returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyTargetsResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + post: + tags: + - Superuser + summary: Create Coolify-managed passive replication target + operationId: createSuperuserCoolifyTarget + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyTargetCreateRequest' + responses: + '201': + description: Coolify target created + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}/reconcile: + post: + tags: + - Superuser + summary: Reconcile a passive Coolify target + operationId: reconcileSuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': { description: Reconcile completed or queued } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}/deploy: + post: + tags: + - Superuser + summary: Deploy and provision a passive Coolify target + operationId: deploySuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': { description: Deploy and provision flow completed or queued } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}/restart: + post: + tags: + - Superuser + summary: Restart a passive Coolify target + operationId: restartSuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': { description: Restart requested } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}/failover: + post: + tags: + - Superuser + summary: Promote a Coolify-managed replica through replication failover + operationId: failoverSuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': { description: Failover action returned } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}: + delete: + tags: + - Superuser + summary: Delete a Coolify target mapping with destructive confirmation + operationId: deleteSuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [confirm] + properties: + confirm: + type: string + description: Must equal delete-coolify-target-{id}. + delete_resource: + type: boolean + default: false + responses: + '200': { description: Target deleted } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + # Configuration Endpoints + /economic/config: + get: + tags: [Config] + summary: Get e-conomic config + operationId: getEconomicConfig + responses: + '200': + description: e-conomic configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicConfigListResponse' + post: + tags: [Config] + summary: Update e-conomic config + operationId: updateEconomicConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: e-conomic configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /reCAPTCHA/config: + get: + tags: [Config] + summary: Get reCAPTCHA config + operationId: getRecaptchaModuleConfig + responses: + '200': + description: reCAPTCHA configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/RecaptchaConfigListResponse' + post: + tags: [Config] + summary: Update reCAPTCHA config + operationId: updateRecaptchaConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: reCAPTCHA configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /email/config: + get: + tags: [Config] + summary: Get email config + operationId: getEmailConfig + responses: + '200': + description: Email configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmailConfigListResponse' + post: + tags: [Config] + summary: Update email config + operationId: updateEmailConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Email configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /email/config/test: + post: + tags: [Config] + summary: Test email config + operationId: testEmailConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Email configuration test completed + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigTestResponse' + + /backups/config: + get: + tags: [Config] + summary: Get backups config + operationId: getBackupsConfig + responses: + '200': + description: Backups configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BackupsConfigListResponse' + post: + tags: [Config] + summary: Update backups config + operationId: updateBackupsConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Backups configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /bird/config: + get: + tags: [Config] + summary: Get Bird config + operationId: getBirdConfig + responses: + '200': + description: Bird configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BirdConfigListResponse' + post: + tags: [Config] + summary: Update Bird config + operationId: updateBirdConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Bird configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /motorapi/config: + get: + tags: [Config] + summary: Get MotorAPI config + operationId: getMotorApiConfig + responses: + '200': + description: MotorAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MotorApiConfigListResponse' + post: + tags: [Config] + summary: Update MotorAPI config + operationId: updateMotorApiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: MotorAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /stripe/config: + get: + tags: [Config] + summary: Get Stripe config + operationId: getStripeConfig + responses: + '200': + description: Stripe configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/StripeConfigListResponse' + post: + tags: [Config] + summary: Update Stripe config + operationId: updateStripeConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Stripe configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /fxratesapi/config: + get: + tags: [Config] + summary: Get FXRatesAPI config + operationId: getFxRatesApiConfig + responses: + '200': + description: FXRatesAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/FxRatesApiConfigListResponse' + post: + tags: [Config] + summary: Update FXRatesAPI config + operationId: updateFxRatesApiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: FXRatesAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + + /weatherapi/config: + get: + tags: [Config] + summary: Get WeatherAPI config + operationId: getWeatherApiConfig + responses: + '200': + description: WeatherAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiConfigListResponse' + post: + tags: [Config] + summary: Update WeatherAPI config + operationId: updateWeatherApiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: WeatherAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /workfeed/config: + get: + tags: [Config] + summary: Get Workfeed config + operationId: getWorkfeedConfig + responses: + '200': + description: Workfeed configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedConfigListResponse' + examples: + default: + summary: Workfeed module configuration + value: + success: true + data: + - module: workfeed + variable: enabled + type: bool + value: true + - module: workfeed + variable: api_url + type: string + value: https://europe-west1-production-eu-327a3.cloudfunctions.net/api + - module: workfeed + variable: api_key + type: string + value: wf_live_xxxxxxxxxxxxxxxxx + - module: workfeed + variable: CompanyID + type: string + value: "123456" + meta: [] + includes: [] + post: + tags: [Config] + summary: Update Workfeed config + operationId: updateWorkfeedConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Workfeed configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /gatewayapi/config: + get: + tags: [Config] + summary: Get GatewayAPI config + operationId: getGatewayApiConfig + responses: + '200': + description: GatewayAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayApiConfigListResponse' + post: + tags: [Config] + summary: Update GatewayAPI config + operationId: updateGatewayApiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: GatewayAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /xlvask/config: + get: + tags: [Config] + summary: Get XLVask config + operationId: getXlvaskConfig + responses: + '200': + description: XLVask configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/XlvaskConfigListResponse' + post: + tags: [Config] + summary: Update XLVask config + operationId: updateXlvaskConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: XLVask configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /entra/config: + get: + tags: [Config] + summary: Get Entra config + operationId: getEntraConfig + responses: + '200': + description: Entra configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EntraConfigListResponse' + post: + tags: [Config] + summary: Update Entra config + operationId: updateEntraConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Entra configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /limble/config: + get: + tags: [Config] + summary: Get Limble config + operationId: getLimbleConfig + responses: + '200': + description: Limble configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/LimbleConfigListResponse' + post: + tags: [Config] + summary: Update Limble config + operationId: updateLimbleConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Limble configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /ocrspace/config: + get: + tags: [Config] + summary: Get OcrSpace config + operationId: getOcrSpaceConfig + responses: + '200': + description: OcrSpace configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/OcrSpaceConfigListResponse' + post: + tags: [Config] + summary: Update OcrSpace config + operationId: updateOcrSpaceConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: OcrSpace configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /openai/config: + get: + tags: [Config] + summary: Get OpenAI config + operationId: getOpenAiConfig + responses: + '200': + description: OpenAI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAiConfigListResponse' + post: + tags: [Config] + summary: Update OpenAI config + operationId: updateOpenAiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: OpenAI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /licenseplaterecognizer/config: + get: + tags: [Config] + summary: Get LicensePlateRecognizer config + operationId: getLicensePlateRecognizerConfig + responses: + '200': + description: LicensePlateRecognizer configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/LicensePlateRecognizerConfigListResponse' + post: + tags: [Config] + summary: Update LicensePlateRecognizer config + operationId: updateLicensePlateRecognizerConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: LicensePlateRecognizer configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /virkdata/config: + get: + tags: [Config] + summary: Get Virkdata config + operationId: getVirkdataConfig + responses: + '200': + description: Virkdata configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/VirkdataConfigListResponse' + post: + tags: [Config] + summary: Update Virkdata config + operationId: updateVirkdataConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Virkdata configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /shelly/config: + get: + tags: [Config] + summary: Get Shelly config + operationId: getShellyConfig + responses: + '200': + description: Shelly configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ShellyConfigListResponse' + post: + tags: [Config] + summary: Update Shelly config + operationId: updateShellyConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Shelly configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /selfserve/config: + get: + tags: [Config] + summary: Get Self-Serve config + operationId: getSelfServeConfig + responses: + '200': + description: Self-serve configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeConfigListResponse' + post: + tags: [Config] + summary: Update Self-Serve config + operationId: updateSelfServeConfig + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeConfig' + responses: + '200': + description: Self-serve configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /branding: + get: + tags: + - Branding + summary: List branding options + description: Retrieve a list of branding options or a specific branding option if ID is provided + operationId: listBrandingOptions + parameters: + - name: id + in: query + required: false + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Branding options retrieved successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: + - Branding + summary: Add branding option + description: Create a new branding option + operationId: addBrandingOption + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, description, cvr] + properties: + name: {type: string} + description: {type: string} + cvr: {type: integer} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} + responses: + '200': + description: Branding option added successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + put: + tags: + - Branding + summary: Edit branding option + description: Update an existing branding option + operationId: editBrandingOption + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + name: {type: string, nullable: true} + description: {type: string, nullable: true} + cvr: {type: integer, nullable: true} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} + responses: + '200': + description: Branding option updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /roles: + get: + tags: + - Roles + summary: List roles + operationId: listRoles + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Roles + summary: Add role + operationId: addRole + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + put: + tags: + - Roles + summary: Edit role + operationId: editRole + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, name] + properties: + id: {type: integer} + name: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /roles/permissions: + post: + tags: + - Roles + summary: Add permission to role + operationId: addRolePermission + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role_id, permission] + properties: + role_id: {type: integer} + permission: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + delete: + tags: + - Roles + summary: Remove permission from role + operationId: removeRolePermission + parameters: + - name: role_id + in: query + required: true + schema: {type: integer} + - name: permission + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /roles/clone: + post: + tags: + - Roles + summary: Clone role + operationId: cloneRole + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role_id, name] + properties: + role_id: {type: integer} + name: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/washcertificates: + get: + tags: + - Modules + summary: List wash certificates + operationId: listWashCertificates + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/xlvask/services/usage/orders: + get: + tags: + - Modules + summary: Get XLVask usage orders + operationId: getXlvaskUsageOrders + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/xlvask/services/usage/orders/fast-link: + get: + tags: + - Modules + summary: Get XLVask usage orders fast link + operationId: getXlvaskUsageOrdersFastLink + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/department: + get: + tags: + - Departments + summary: List departments (superuser) + operationId: listSuperuserDepartments + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/department/branding: + put: + tags: + - Departments + summary: Set department branding + description: Assign an existing branding option to a department, or clear the department branding by sending a null branding_id. + operationId: setDepartmentBranding + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, branding_id] + properties: + department_id: {type: integer} + branding_id: {type: integer, nullable: true} + responses: + '200': + description: Department branding updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /superuser/department/prices: + get: + tags: + - Departments + summary: Get department prices + operationId: getDepartmentPrices + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Departments + summary: Set department price + operationId: setDepartmentPrice + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, product_id, price] + properties: + department_id: {type: integer} + product_id: {type: integer} + price: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/department/variables: + get: + tags: + - Departments + summary: Get department variables + operationId: getDepartmentVariables + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Departments + summary: Set department variable + operationId: setDepartmentVariable + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, variable, value] + properties: + department_id: {type: integer} + variable: {type: string} + value: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/daily-reports: + get: + tags: + - Departments + summary: List daily reports + operationId: listDailyReports + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Departments + summary: Add daily report + operationId: addDailyReport + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, date, report] + properties: + department_id: {type: integer} + date: {type: string} + report: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + put: + tags: + - Departments + summary: Edit daily report + operationId: editDailyReport + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, report] + properties: + id: {type: integer} + report: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/daily-reports/overview: + get: + tags: + - Departments + summary: Get daily report overview + operationId: getDailyReportOverview + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: date_to + in: query + required: false + schema: {type: string} + - name: department_ids + in: query + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportOverviewResponse' + + /departments/daily-reports/get: + get: + tags: + - Departments + summary: Get daily report + operationId: getDailyReport + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + - name: date + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/daily-reports/complaints: + get: + tags: + - Departments + summary: List or fetch daily report customer complaints + operationId: listDailyReportComplaints + parameters: + - name: id + in: query + required: false + schema: {type: integer} + - name: page + in: query + required: false + schema: {type: integer} + - name: limit + in: query + required: false + schema: {type: integer} + - name: search + in: query + required: false + schema: {type: string} + - name: filters + in: query + required: false + schema: {type: string} + - name: order + in: query + required: false + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCollectionResponse' + post: + tags: + - Departments + summary: Create daily report customer complaint + operationId: createDailyReportComplaint + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCreateRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintResponse' + put: + tags: + - Departments + summary: Update daily report customer complaint + operationId: updateDailyReportComplaint + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintUpdateRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintResponse' + delete: + tags: + - Departments + summary: Delete daily report customer complaint + operationId: deleteDailyReportComplaint + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintDeleteResponse' + + /departments/daily-reports/complaints/customers: + get: + tags: + - Departments + summary: Search selectable customers for daily report complaints + operationId: searchDailyReportComplaintCustomers + parameters: + - name: search + in: query + required: true + schema: + type: string + minLength: 2 + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 20 + default: 10 + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResponse' + + /departments/daily-reports/product-count: + get: + tags: + - Departments + summary: Get product count for daily reports + operationId: getDailyReportProductCount + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: date_to + in: query + required: false + schema: {type: string} + - name: department_id + in: query + required: true + schema: {type: integer} + - name: product_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/daily-reports/transaction-count: + get: + tags: + - Departments + summary: Get transaction count for daily reports + operationId: getDailyReportTransactionCount + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: date_to + in: query + required: false + schema: {type: string} + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportTransactionCountResponse' + + /departments/daily-reports/outside-hours-trend: + get: + tags: + - Departments + summary: Get outside-hours trend for daily reports + operationId: getDailyReportOutsideHoursTrend + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: date_to + in: query + required: true + schema: {type: string} + - name: department_ids + in: query + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendResponse' + + /departments/daily-reports/bookings-count: + get: + tags: + - Departments + summary: Get bookings count for daily reports + operationId: getDailyReportBookingsCount + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Account Security - Passkeys + /account/security/passkeys: + get: + tags: + - Security + summary: List passkeys for the authenticated user + operationId: listPasskeys + responses: + '200': + description: A list of passkeys + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Passkey' + '400': + description: Invalid session or request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + tags: + - Security + summary: Create/add a passkey for the authenticated user + operationId: createPasskey + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasskeyCreateRequest' + responses: + '200': + description: Passkey created + content: + application/json: + schema: + type: object + properties: + id: + type: integer + '400': + description: Invalid session or request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /account/security/passkeys/{id}: + patch: + tags: + - Security + summary: Rename a passkey + operationId: renamePasskey + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasskeyRenameRequest' + responses: + '200': + description: Passkey renamed + content: + application/json: + schema: {} + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: + - Security + summary: Delete a passkey + operationId: deletePasskey + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Passkey deleted + content: + application/json: + schema: {} + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /superuser/releases/operations: + get: + tags: + - Release Manager + summary: List release operation runs + operationId: listReleaseOperations + parameters: + - in: query + name: channel_id + schema: + type: integer + - in: query + name: operation_type + schema: + type: string + - in: query + name: status + schema: + type: string + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + responses: + '200': + description: Release operation runs + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: array + items: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/releases/operations/{id}: + get: + tags: + - Release Manager + summary: Get release operation details + operationId: getReleaseOperation + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Release operation details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /superuser/releases/test-runs: + post: + tags: + - Release Manager + summary: Run Release Manager diagnostics + operationId: runReleaseTest + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '202': + description: Release test operation started + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + + /superuser/releases/channels/{id}/sync: + post: + tags: + - Release Manager + summary: Sync latest branch commits into a release channel + operationId: syncReleaseChannel + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '202': + description: Channel sync operation started + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + + /superuser/releases/issues/actions: + post: + tags: + - Release Manager + summary: Run a Release Manager issue action + operationId: runReleaseIssueAction + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + issue_key: + type: string + action_id: + type: string + inputs: + type: object + additionalProperties: true + confirm: + type: boolean + additionalProperties: true + responses: + '200': + description: Issue action result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT token obtained from /auth/login or /auth/employee/login + + parameters: + PageParam: + name: page + in: query + description: Page number for pagination + schema: + type: integer + minimum: 1 + default: 1 + PerPageParam: + name: per_page + in: query + description: Number of items per page + schema: + type: integer + minimum: 1 + maximum: 100 + default: 30 + SearchParam: + name: search + in: query + description: Search query string + schema: + type: string + LimitParam: + name: limit + in: query + description: Number of items per page + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + FiltersParam: + name: filters + in: query + description: Filters for the list (e.g., module:selfserve,status_code:200) + schema: + type: string + XCustomerNumber: + name: X-Customer-Number + in: header + required: false + description: | + Target customer number for subuser requests. Ignored for classic user sessions. + Required on customer-scoped endpoints when authenticated as a subuser unless + the target customer can be inferred from context. + schema: + type: integer + SuperuserReplicationKindParam: + name: kind + in: path + required: true + schema: + type: string + enum: [databases, redis, minio] + SuperuserReplicationHostIdParam: + name: id + in: path + required: true + schema: + type: integer + minimum: 1 + + responses: + BadRequest: + description: Bad request - Invalid input parameters + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Conflict: + description: Conflict - Request could not be completed due to current resource state + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Unauthorized: + description: Unauthorized - Invalid or missing authentication token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: Not found - Resource does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + ServiceUnavailable: + description: Service unavailable - Required async queue dependencies are unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Error: + type: object + properties: + error: + type: string + description: Error message + code: + type: integer + description: HTTP status code + + ErrorReportSubmissionRequest: + type: object + required: + - before_error + - expected + - actual + - data_collection_accepted + - screenshot + properties: + before_error: + type: string + maxLength: 4000 + description: What the user was doing before the error occurred + expected: + type: string + maxLength: 4000 + description: What the user expected would happen + actual: + type: string + maxLength: 4000 + description: What actually happened + data_collection_accepted: + type: boolean + description: Required acceptance of collecting screenshot and diagnostic error data + screenshot: + type: string + description: PNG, JPEG, or WebP data URI of the current app viewport + route_path: + type: string + nullable: true + page_url: + type: string + nullable: true + release_trace_id: + type: string + nullable: true + request_errors: + type: array + items: + type: object + additionalProperties: true + vue_errors: + type: array + items: + type: object + additionalProperties: true + context: + type: object + additionalProperties: true + + ErrorReportStatusUpdateRequest: + type: object + required: + - status + properties: + status: + type: string + enum: [open, resolved] + resolution_note: + type: string + nullable: true + maxLength: 2000 + + ErrorReportResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/ErrorReport' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + ErrorReportListResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/ErrorReport' + counts: + type: object + properties: + open: + type: integer + resolved: + type: integer + all: + type: integer + limit: + type: integer + offset: + type: integer + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + ErrorReport: + type: object + properties: + id: + type: integer + status: + type: string + enum: [open, resolved] + reporter: + type: object + additionalProperties: true + route_path: + type: string + nullable: true + page_url: + type: string + nullable: true + release_trace_id: + type: string + nullable: true + frontend_version: + type: string + nullable: true + api_version: + type: string + nullable: true + screenshot: + type: object + additionalProperties: true + answers: + type: object + properties: + before_error: + type: string + expected: + type: string + actual: + type: string + request_error_count: + type: integer + vue_error_count: + type: integer + request_errors: + type: array + items: + type: object + additionalProperties: true + vue_errors: + type: array + items: + type: object + additionalProperties: true + runtime_context: + type: object + additionalProperties: true + resolved_at: + type: string + nullable: true + resolved_by_user_id: + type: integer + nullable: true + created_at: + type: string + updated_at: + type: string + nullable: true + + SuperuserSystemStatusResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserSystemStatusPayload' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + SuperuserReplicationResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserReplicationSummary' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserReplicationHostResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserReplicationHost' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserReplicationOperationResponse: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + generated_at: + type: string + format: date-time + instances: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyInstance' + targets: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyTarget' + availability: + type: object + additionalProperties: true + load_balancer: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancer' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyInstanceResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserCoolifyInstance' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyTargetsResponse: + type: object + properties: + success: + type: boolean + data: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyTarget' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyOperationResponse: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyLoadBalancerResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancer' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyLoadBalancerReconcileResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + ok: + type: boolean + dry_run: + type: boolean + mutated: + type: boolean + config: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerConfig' + load_balancer: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerPublicState' + drift: + type: object + additionalProperties: true + planned: + type: array + items: + type: object + additionalProperties: true + applied: + type: array + items: + type: object + additionalProperties: true + skipped: + type: array + items: + type: object + additionalProperties: true + errors: + type: array + items: + type: object + additionalProperties: true + gateways: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyGatewayRouteDeployResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + ok: + type: boolean + dry_run: + type: boolean + mutated: + type: boolean + public_host: + type: string + public_url: + type: string + planned: + type: array + items: + type: object + additionalProperties: true + applied: + type: array + items: + type: object + additionalProperties: true + skipped: + type: array + items: + type: object + additionalProperties: true + errors: + type: array + items: + type: object + additionalProperties: true + warnings: + type: array + items: + type: string + coverage: + type: object + additionalProperties: true + gateways: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyGatewaysResponse: + type: object + properties: + success: + type: boolean + data: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyGatewayResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyLoadBalancer: + type: object + properties: + configured: + type: boolean + status: + type: string + enum: [not_configured, ok, degraded, down] + config: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerConfig' + gateways: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + load_balancer: + nullable: true + allOf: + - $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerPublicState' + drift: + type: object + additionalProperties: true + last_error: + type: string + nullable: true + + SuperuserCoolifyLoadBalancerConfig: + type: object + properties: + automation_enabled: + type: boolean + automation_mode: + type: string + enum: [report_only, enforce] + load_balancer_id: + type: string + public_gateway_host: + type: string + token_set: + type: boolean + token_source: + type: string + nullable: true + required_services: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerService' + + SuperuserCoolifyLoadBalancerPublicState: + type: object + properties: + id: + type: integer + nullable: true + name: + type: string + ipv4: + type: string + nullable: true + ipv6: + type: string + nullable: true + location: + type: string + nullable: true + algorithm: + type: string + nullable: true + targets: + type: array + items: + type: string + services: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerService' + + SuperuserCoolifyLoadBalancerService: + type: object + properties: + protocol: + type: string + enum: [http, tcp] + listen_port: + type: integer + destination_port: + type: integer + proxyprotocol: + type: boolean + + SuperuserCoolifyGateway: + type: object + properties: + id: + type: integer + instance_id: + type: integer + nullable: true + hostname: + type: string + target_ip: + type: string + enabled: + type: boolean + priority: + type: integer + health_state: + type: string + lb_state: + type: string + last_probe: + type: object + nullable: true + additionalProperties: true + last_probed_at: + type: string + nullable: true + last_reconciled_at: + type: string + nullable: true + created_at: + type: string + nullable: true + updated_at: + type: string + nullable: true + + SuperuserCoolifyGatewaySaveRequest: + type: object + required: + - hostname + - target_ip + properties: + id: + type: integer + instance_id: + type: integer + nullable: true + hostname: + type: string + target_ip: + type: string + enabled: + type: boolean + default: true + priority: + type: integer + default: 100 + + SuperuserCoolifyInstance: + type: object + properties: + id: + type: integer + label: + type: string + base_url: + type: string + api_token_set: + type: boolean + default_project_uuid: + type: string + nullable: true + default_environment_uuid: + type: string + nullable: true + default_environment_name: + type: string + nullable: true + default_server_uuid: + type: string + nullable: true + default_destination_uuid: + type: string + nullable: true + status: + type: string + last_checked_at: + type: string + nullable: true + last_error: + type: string + nullable: true + + SuperuserCoolifyPlacementResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + generated_at: + type: string + instance: + $ref: '#/components/schemas/SuperuserCoolifyInstance' + servers: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyPlacementServer' + projects: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyPlacementProject' + environments: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyPlacementEnvironment' + destination_discovery_supported: + type: boolean + errors: + type: object + additionalProperties: true + + SuperuserCoolifyPlacementServer: + type: object + properties: + id: + type: integer + nullable: true + uuid: + type: string + name: + type: string + description: + type: string + ip: + type: string + user: + type: string + port: + type: integer + nullable: true + proxy_type: + type: string + swarm_cluster: + type: string + is_reachable: + type: boolean + nullable: true + is_usable: + type: boolean + nullable: true + + SuperuserCoolifyPlacementProject: + type: object + properties: + id: + type: integer + nullable: true + uuid: + type: string + name: + type: string + description: + type: string + + SuperuserCoolifyPlacementEnvironment: + type: object + properties: + id: + type: integer + nullable: true + uuid: + type: string + name: + type: string + description: + type: string + project_id: + type: integer + nullable: true + project_uuid: + type: string + project_name: + type: string + + SuperuserCoolifyTarget: + type: object + properties: + id: + type: integer + instance_id: + type: integer + instance_label: + type: string + kind: + type: string + enum: [database, redis, minio] + label: + type: string + role: + type: string + enum: [replica] + server_uuid: + type: string + nullable: true + project_uuid: + type: string + nullable: true + environment_uuid: + type: string + nullable: true + environment_name: + type: string + nullable: true + destination_uuid: + type: string + nullable: true + resource_uuid: + type: string + nullable: true + resource_type: + type: string + resource_name: + type: string + nullable: true + deployment_status: + type: string + availability_state: + type: string + enum: [protected, degraded, failover_ready, failover_blocked, failing_over, destructive_action_required] + last_reconcile_status: + type: string + nullable: true + replication: + type: object + additionalProperties: true + + SuperuserCoolifyInstanceCreateRequest: + type: object + required: + - label + - base_url + - api_token + properties: + label: + type: string + base_url: + type: string + api_token: + type: string + format: password + + SuperuserCoolifyTargetCreateRequest: + allOf: + - $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + - type: object + required: + - kind + - host + properties: + instance_id: + type: integer + kind: + type: string + enum: [database, redis, minio] + role: + type: string + enum: [replica] + default: replica + server_uuid: + type: string + project_uuid: + type: string + environment_uuid: + type: string + environment_name: + type: string + destination_uuid: + type: string + deploy: + type: boolean + default: false + + SuperuserReplicationComposeTemplateResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserReplicationComposeTemplate' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserReplicationComposeTemplate: + type: object + properties: + kind: + type: string + enum: [database, redis, minio] + engine: + type: string + enum: [mariadb, redis, minio] + role: + type: string + enum: [primary, replica] + service_name: + type: string + host_port: + type: integer + console_port: + type: integer + nullable: true + server_id: + type: integer + nullable: true + compose: + type: string + description: Complete docker-compose.yml content with secret environment placeholders. + env: + type: string + description: Example .env content for the placeholders used by compose. + seed_command: + type: string + description: One-time MariaDB seed command to initialize a replica from the primary before provisioning. + credentials: + $ref: '#/components/schemas/SuperuserReplicationGeneratedCredentials' + steps: + type: array + items: + type: string + + SuperuserReplicationGeneratedCredentials: + type: object + properties: + label: + type: string + host: + type: string + port: + type: integer + endpoint: + type: string + scheme: + type: string + enum: [http, https] + buckets: + type: array + items: + type: string + console_port: + type: integer + replication_transfer_limit: + type: string + nullable: true + space_headroom_percent: + type: number + format: float + database: + oneOf: + - type: string + - type: integer + username: + type: string + password: + type: string + format: password + admin_username: + type: string + admin_password: + type: string + format: password + replication_username: + type: string + replication_password: + type: string + format: password + ssl_mode: + type: string + allow_preseeded_replica: + type: boolean + + SuperuserReplicationSummary: + type: object + properties: + generated_at: + type: string + format: date-time + database: + $ref: '#/components/schemas/SuperuserReplicationKindSummary' + redis: + $ref: '#/components/schemas/SuperuserReplicationKindSummary' + minio: + $ref: '#/components/schemas/SuperuserReplicationKindSummary' + write_freeze: + type: object + additionalProperties: true + + SuperuserReplicationKindSummary: + type: object + properties: + primary: + $ref: '#/components/schemas/SuperuserReplicationHost' + nullable: true + hosts: + type: array + items: + $ref: '#/components/schemas/SuperuserReplicationHost' + replication: + $ref: '#/components/schemas/SuperuserReplicationStatus' + + SuperuserReplicationStatus: + type: object + properties: + status: + type: string + enum: [ok, degraded, down, not_configured] + min_percent: + type: number + format: float + average_percent: + type: number + format: float + replicas: + type: array + items: + $ref: '#/components/schemas/SuperuserReplicationHost' + blockers: + type: array + items: + type: string + + SuperuserReplicationHost: + type: object + properties: + id: + type: integer + kind: + type: string + enum: [database, redis, minio] + label: + type: string + host: + type: string + port: + type: integer + database: + oneOf: + - type: string + - type: integer + nullable: true + endpoint: + type: string + nullable: true + scheme: + type: string + enum: [http, https] + nullable: true + buckets: + type: array + items: + type: string + console_port: + type: integer + nullable: true + replication_transfer_limit: + type: string + nullable: true + description: MinIO replication and seed bandwidth cap passed to mc --limit-upload/--limit-download, for example 25Mi. Use 0 to disable. + space_headroom_percent: + type: number + format: float + nullable: true + role: + type: string + enum: [primary, replica, inactive] + status: + type: string + replication_source_id: + type: integer + nullable: true + replication_percent: + type: number + format: float + last_status: + type: object + additionalProperties: true + credential_summary: + type: object + additionalProperties: true + deployment_provider: + type: string + enum: [manual, coolify] + coolify: + type: object + nullable: true + additionalProperties: true + availability_state: + type: string + nullable: true + enum: [protected, degraded, failover_ready, failover_blocked, failing_over, destructive_action_required] + + SuperuserReplicationHostCreateRequest: + type: object + required: + - host + - port + properties: + label: + type: string + host: + type: string + description: Hostname or MinIO endpoint. MinIO hosts may include http(s) scheme; the backend stores the host without scheme. + endpoint: + type: string + description: Optional MinIO endpoint alias for host. + port: + type: integer + database: + oneOf: + - type: string + - type: integer + username: + type: string + description: Database/Redis username or MinIO access key. + password: + type: string + format: password + description: Database/Redis password or MinIO secret key. + scheme: + type: string + enum: [http, https] + description: MinIO endpoint scheme. + buckets: + type: array + items: + type: string + description: MinIO buckets to replicate. + console_port: + type: integer + description: Optional MinIO console port for UI display. + replication_transfer_limit: + type: string + description: Optional MinIO replication and seed bandwidth cap. Defaults to 25Mi. Use 0 to disable. + space_headroom_percent: + type: number + format: float + description: MinIO free-space headroom required before provisioning. Defaults to 20. + admin_username: + type: string + admin_password: + type: string + format: password + replication_username: + type: string + replication_password: + type: string + format: password + ssl_mode: + type: string + deployment_provider: + type: string + enum: [manual, coolify] + options: + type: object + properties: + allow_preseeded_replica: + type: boolean + description: Allow configuring replication when the replica has already been safely seeded outside the orchestrator. Required for MariaDB, which does not support MySQL Clone. + scheme: + type: string + enum: [http, https] + buckets: + type: array + items: + type: string + console_port: + type: integer + replication_transfer_limit: + type: string + space_headroom_percent: + type: number + format: float + additionalProperties: true + + SuperuserReplicationHostRenameRequest: + type: object + required: + - label + properties: + label: + type: string + minLength: 1 + maxLength: 128 + + SuperuserReplicationUnsavedCredentialTestRequest: + allOf: + - $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + - type: object + required: + - kind + properties: + kind: + type: string + enum: [database, databases, mysql, redis, minio, s3, object-storage, object_storage] + role: + type: string + enum: [primary, replica] + + SuperuserReplicationComposeTemplateRequest: + type: object + properties: + kind: + type: string + enum: [database, databases, mysql, redis, minio, s3, object-storage, object_storage] + default: database + role: + type: string + enum: [primary, replica] + default: replica + service_name: + type: string + volume_name: + type: string + image: + type: string + database: + type: string + description: MariaDB database name to create on first startup. + username: + type: string + description: MariaDB application username to create on first startup. + password: + type: string + format: password + description: Optional application password to reuse instead of generating one. + admin_password: + type: string + format: password + description: Optional MariaDB root password to reuse instead of generating one. + replication_username: + type: string + description: Replication username to place in generated credentials. + replication_password: + type: string + format: password + description: Optional replication password to reuse instead of generating one. + host_port: + type: integer + minimum: 1 + maximum: 65535 + server_id: + type: integer + minimum: 1 + description: MariaDB server-id. Must be unique across the primary and replicas. + primary_host: + type: string + description: Redis primary host used when generating a Redis replica template. + primary_port: + type: integer + minimum: 1 + maximum: 65535 + description: Redis primary port used when generating a Redis replica template. + primary_password: + type: string + format: password + description: Redis primary password used in the generated Redis replica .env file. If omitted, the .env keeps the value blank for manual entry. + primary_username: + type: string + description: Optional Redis primary ACL username used in the generated Redis replica .env file. Leave blank or default for the default Redis user. + buckets: + type: array + items: + type: string + description: MinIO buckets to create, version, and replicate. + console_port: + type: integer + minimum: 1 + maximum: 65535 + description: MinIO console port exposed by the generated compose service. + replication_transfer_limit: + type: string + description: MinIO replication and seed bandwidth cap included in generated credentials. Defaults to 25Mi. Use 0 to disable. + + SuperuserSystemStatusPayload: + type: object + properties: + overall_status: + $ref: '#/components/schemas/SuperuserSystemStatusEnum' + generated_at: + type: string + format: date-time + refresh_after_seconds: + type: integer + runtime: + type: object + properties: + cpu: + $ref: '#/components/schemas/SuperuserRuntimeMetric' + memory: + $ref: '#/components/schemas/SuperuserRuntimeMetric' + disk: + $ref: '#/components/schemas/SuperuserRuntimeMetric' + dependencies: + type: object + properties: + database: + $ref: '#/components/schemas/SuperuserDependencyStatus' + redis: + $ref: '#/components/schemas/SuperuserDependencyStatus' + minio: + $ref: '#/components/schemas/SuperuserMinioDependencyStatus' + modules: + type: array + items: + $ref: '#/components/schemas/SuperuserModuleStatus' + sessions: + $ref: '#/components/schemas/SuperuserSessionStatus' + warnings: + type: array + items: + type: string + required: + - overall_status + - generated_at + - refresh_after_seconds + - runtime + - dependencies + - modules + - sessions + - warnings + + SuperuserSystemStatusEnum: + type: string + enum: [ok, degraded, down] + + SuperuserModuleStatusEnum: + type: string + enum: [disabled, not_configured, configured, ok, degraded, down] + + SuperuserRuntimeMetric: + type: object + properties: + status: + $ref: '#/components/schemas/SuperuserSystemStatusEnum' + usage_percent: + type: number + format: float + nullable: true + used_bytes: + type: integer + nullable: true + free_bytes: + type: integer + nullable: true + total_bytes: + type: integer + nullable: true + path: + type: string + nullable: true + source: + type: string + nullable: true + checked_at: + type: string + format: date-time + + SuperuserDependencyStatus: + type: object + properties: + status: + $ref: '#/components/schemas/SuperuserSystemStatusEnum' + latency_ms: + type: number + format: float + nullable: true + database: + oneOf: + - type: integer + - type: string + nullable: true + server_version: + type: string + nullable: true + http_status: + type: integer + nullable: true + checked_at: + type: string + format: date-time + error: + type: string + nullable: true + replication: + $ref: '#/components/schemas/SuperuserReplicationStatus' + + SuperuserMinioDependencyStatus: + type: object + properties: + status: + $ref: '#/components/schemas/SuperuserSystemStatusEnum' + latency_ms: + type: number + format: float + nullable: true + endpoint: + type: string + nullable: true + http_status: + type: integer + nullable: true + buckets: + type: array + items: + type: object + properties: + name: + type: string + status: + type: string + error: + type: string + nullable: true + checked_at: + type: string + format: date-time + error: + type: string + nullable: true + replication: + $ref: '#/components/schemas/SuperuserReplicationStatus' + + SuperuserModuleStatus: + type: object + properties: + key: + type: string + enabled: + type: boolean + configured: + type: boolean + probe_supported: + type: boolean + status: + $ref: '#/components/schemas/SuperuserModuleStatusEnum' + status_reason: + type: string + nullable: true + checked_at: + type: string + format: date-time + required: + - key + - enabled + - configured + - probe_supported + - status + - checked_at + + SuperuserSessionStatus: + type: object + properties: + active_window_minutes: + type: integer + active_users: + type: integer + active_sessions: + type: integer + recent_sessions: + type: array + items: + type: object + properties: + session_kind: + type: string + principal_id: + type: integer + display_name: + type: string + context_label: + type: string + nullable: true + customer_number_context: + type: integer + nullable: true + device_type: + type: string + user_agent: + type: string + last_route: + type: string + first_seen_at: + type: string + format: date-time + nullable: true + last_seen_at: + type: string + format: date-time + nullable: true + active: + type: boolean + required: + - active_window_minutes + - active_users + - active_sessions + - recent_sessions + + SystemSearchEntityType: + type: string + enum: + - objects + - module_config + - orders + - order_items + - customers + - employees + - users + - subusers + - customer_discounts + - customer_fixed_prices + - departments + - permissions + - roles + - invoices + - vehicles + - bookings + - bookings_new + - branding + - categories + - currency_conversion_rates + - customer_codes + - customer_default_department + - customer_notes + - customer_vehicles_addons + - department_categories + - department_daily_reports + - department_gates + - department_goals + - department_lanes + - department_notification_sms + - department_relays + - department_selfserve_condition_rules + - department_selfserve_conditions + - department_selfserve_questions + - department_selfserve_tasks + - department_selfserve_vehicle_conditions + - department_time_bookings_entries + - department_time_bookings_opening_hours + - department_time_bookings_types + - department_variables + - fxratesapi_conversion_rates + - module_action_logs + - motorapi_lookups + - notifications + - order_bookings + - plate_scanners + - plate_scans + - product_options + - products + - stripe_module_customers + - stripe_module_orders + - stripe_payment_intents + - subuser_grants + - xlvask_customers + - xlvask_potential_order_matches + - xlvask_usage_log_wash_items + - xlvask_usage_logs + - xlvask_vehicle_types + - xlvask_vehicles + + SystemSearchRequest: + type: object + required: + - query + properties: + query: + type: string + description: Free-text query to search for. Customer lookups include local e-conomic index fields and lexical synonym expansion (for example `rabat` -> `discount`). + include_types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + description: Limit search to these entity types. + exclude_types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + description: Exclude these entity types from search. + include_associations: + type: boolean + default: true + description: Include associated records for matched core entities. + debug_intent: + type: boolean + default: false + description: Include parser diagnostics in `meta.intent_parser`. + limit: + type: integer + minimum: 1 + maximum: 200 + default: 50 + offset: + type: integer + minimum: 0 + default: 0 + + SystemSearchResult: + type: object + properties: + entity_type: + $ref: '#/components/schemas/SystemSearchEntityType' + entity_id: + type: string + title: + type: string + description: + type: string + customer_number: + type: integer + nullable: true + department_id: + type: integer + nullable: true + score: + type: integer + association_reason: + type: string + nullable: true + payload: + type: object + additionalProperties: true + required: + - entity_type + - entity_id + - title + - score + + SystemSearchIntentParserMeta: + type: object + properties: + invoked: + type: boolean + source: + type: string + enum: [cache, openai, none] + status: + type: string + confidence: + type: number + minimum: 0 + maximum: 1 + expanded_terms: + type: array + items: + type: string + entity_hints: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + fallback_reason: + type: string + nullable: true + required: + - invoked + - source + - status + - confidence + - expanded_terms + - entity_hints + + SystemSearchMeta: + type: object + properties: + query: + type: string + limit: + type: integer + offset: + type: integer + total: + type: integer + allowed_types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + cache: + type: object + properties: + hit: + type: boolean + required: [hit] + intent_parser: + $ref: '#/components/schemas/SystemSearchIntentParserMeta' + required: + - query + - limit + - offset + - total + - allowed_types + - cache + + SystemSearchPayload: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/SystemSearchResult' + grouped_results: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SystemSearchResult' + meta: + $ref: '#/components/schemas/SystemSearchMeta' + required: + - results + - grouped_results + - meta + + SystemSearchResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SystemSearchPayload' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + SystemSearchCacheClearResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + query_cache_cleared: + type: boolean + intent_cache_cleared: + type: boolean + required: + - message + - query_cache_cleared + - intent_cache_cleared + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + SystemSearchCacheRebuildRequest: + type: object + properties: + scope: + type: string + enum: [all, types, dirty] + default: all + types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + + SystemSearchCacheRebuildResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + request: + type: object + properties: + scope: + type: string + enum: [all, types, dirty] + types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + requested_at: + type: integer + required: + - scope + - types + - requested_at + query_cache_cleared: + type: boolean + intent_cache_cleared: + type: boolean + required: + - message + - request + - query_cache_cleared + - intent_cache_cleared + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + ModuleConfigValue: + oneOf: + - type: string + - type: integer + - type: number + - type: boolean + - type: array + items: {} + - type: object + additionalProperties: true + nullable: true + + ModuleConfigEnvelopeBase: + type: object + properties: + success: + type: boolean + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - meta + - includes + + EconomicConfigEntry: + type: object + properties: + module: { type: string, enum: [economic] } + variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber, defaultDepartmentId] } + type: { type: string, enum: [string, int] } + value: + oneOf: + - type: string + - type: integer + nullable: true + required: [module, variable, type, value] + + RecaptchaConfigEntry: + type: object + properties: + module: { type: string, enum: [reCAPTCHA] } + variable: { type: string, enum: [enabled, secret_key_v2, site_key_v2] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + EmailConfigEntry: + type: object + properties: + module: { type: string, enum: [Email] } + variable: + type: string + enum: [enabled, mailersend_api_key, mailersend_enabled, smtp_encryption, smtp_from, smtp_from_name, smtp_host, smtp_password, smtp_port, smtp_reply_to, smtp_reply_to_name, smtp_username] + type: { type: string, enum: [bool, string, int] } + value: + oneOf: + - type: boolean + - type: string + - type: integer + required: [module, variable, type, value] + + BackupsConfigEntry: + type: object + properties: + module: { type: string, enum: [Backups] } + variable: { type: string, enum: [enabled] } + type: { type: string, enum: [bool] } + value: { type: boolean } + required: [module, variable, type, value] + + BirdConfigEntry: + type: object + properties: + module: { type: string, enum: [bird] } + variable: { type: string, enum: [api_key, enabled, server_url, workplaceId, channelId] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + MotorApiConfigEntry: + type: object + properties: + module: { type: string, enum: [motorapi] } + variable: { type: string, enum: [daily_limit, enabled, secret_key] } + type: { type: string, enum: [int, bool, string] } + value: + oneOf: + - type: integer + - type: boolean + - type: string + required: [module, variable, type, value] + + StripeConfigEntry: + type: object + properties: + module: { type: string, enum: [Stripe] } + variable: { type: string, enum: [economic_customer_number, enabled, publishable_key, secret_key] } + type: { type: string, enum: [int, bool, string] } + value: + oneOf: + - type: integer + - type: boolean + - type: string + required: [module, variable, type, value] + + FxRatesApiConfigEntry: + type: object + properties: + module: { type: string, enum: [fxratesapi] } + variable: { type: string, enum: [daily_limit, enabled, secret_key] } + type: { type: string, enum: [int, bool, string] } + value: + oneOf: + - type: integer + - type: boolean + - type: string + required: [module, variable, type, value] + + + WeatherApiConfigEntry: + type: object + properties: + module: { type: string, enum: [weatherapi] } + variable: { type: string, enum: [enabled, secret_key] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + WorkfeedConfigEnabledEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [enabled] } + type: { type: string, enum: [bool] } + value: { type: boolean } + required: [module, variable, type, value] + + WorkfeedConfigApiUrlEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [api_url] } + type: { type: string, enum: [string] } + value: { type: string, example: "https://europe-west1-production-eu-327a3.cloudfunctions.net/api" } + required: [module, variable, type, value] + + WorkfeedConfigApiKeyEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [api_key] } + type: { type: string, enum: [string] } + value: { type: string, example: "wf_live_xxxxxxxxxxxxxxxxx" } + required: [module, variable, type, value] + + WorkfeedConfigCompanyIdEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [CompanyID] } + type: { type: string, enum: [string] } + value: { type: string, example: "123456" } + required: [module, variable, type, value] + + WorkfeedConfigEntry: + oneOf: + - $ref: '#/components/schemas/WorkfeedConfigEnabledEntry' + - $ref: '#/components/schemas/WorkfeedConfigApiUrlEntry' + - $ref: '#/components/schemas/WorkfeedConfigApiKeyEntry' + - $ref: '#/components/schemas/WorkfeedConfigCompanyIdEntry' + discriminator: + propertyName: variable + mapping: + enabled: '#/components/schemas/WorkfeedConfigEnabledEntry' + api_url: '#/components/schemas/WorkfeedConfigApiUrlEntry' + api_key: '#/components/schemas/WorkfeedConfigApiKeyEntry' + CompanyID: '#/components/schemas/WorkfeedConfigCompanyIdEntry' + + GatewayApiConfigEntry: + type: object + properties: + module: { type: string, enum: [GatewayAPI] } + variable: { type: string, enum: [api_key, api_secret, api_token, enabled, sender] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + XlvaskConfigEntry: + type: object + properties: + module: { type: string, enum: [xlvask] } + variable: { type: string, enum: [enabled, password, synchronization_enabled, username] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + EntraConfigEntry: + type: object + properties: + module: { type: string, enum: [Entra] } + variable: { type: string, enum: [enabled, entra_client_id, entra_client_secret, entra_tenant_id] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + LimbleConfigEntry: + type: object + properties: + module: { type: string, enum: [limble] } + variable: { type: string, enum: [client_id, client_secret, enabled, webhooks_enabled] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + OcrSpaceConfigEntry: + type: object + properties: + module: { type: string, enum: [ocrSpace] } + variable: { type: string, enum: [api_key, enabled] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + OpenAiConfigEntry: + type: object + properties: + module: { type: string, enum: [openAI] } + variable: { type: string, enum: [api_key, enabled] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + LicensePlateRecognizerConfigEntry: + type: object + properties: + module: { type: string, enum: [licenseplaterecognizer] } + variable: { type: string, enum: [api_key, enabled] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + VirkdataConfigEntry: + type: object + properties: + module: { type: string, enum: [virkdata] } + variable: { type: string, enum: [enabled, monthly_limit, secret_key] } + type: { type: string, enum: [bool, int, string] } + value: + oneOf: + - type: boolean + - type: integer + - type: string + required: [module, variable, type, value] + + ShellyConfigEntry: + type: object + properties: + module: { type: string, enum: [shelly] } + variable: { type: string, enum: [enabled, secret_key, server_url] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + SelfServeConfigEntry: + type: object + properties: + module: { type: string, enum: [selfserve] } + variable: { type: string, enum: [enabled, minute_product, machine_wash_minutes_included] } + type: { type: string, enum: [bool, int] } + value: + oneOf: + - type: boolean + - type: integer + required: [module, variable, type, value] + + EconomicConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/EconomicConfigEntry' } } + required: [data] + + RecaptchaConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/RecaptchaConfigEntry' } } + required: [data] + + EmailConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/EmailConfigEntry' } } + required: [data] + + BackupsConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/BackupsConfigEntry' } } + required: [data] + + BirdConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/BirdConfigEntry' } } + required: [data] + + MotorApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/MotorApiConfigEntry' } } + required: [data] + + StripeConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/StripeConfigEntry' } } + required: [data] + + FxRatesApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/FxRatesApiConfigEntry' } } + required: [data] + + + WeatherApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WeatherApiConfigEntry' } } + required: [data] + + WorkfeedConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WorkfeedConfigEntry' } } + required: [data] + example: + success: true + data: + - module: workfeed + variable: enabled + type: bool + value: true + - module: workfeed + variable: api_url + type: string + value: https://europe-west1-production-eu-327a3.cloudfunctions.net/api + - module: workfeed + variable: api_key + type: string + value: wf_live_xxxxxxxxxxxxxxxxx + - module: workfeed + variable: CompanyID + type: string + value: "123456" + meta: [] + includes: [] + + GatewayApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/GatewayApiConfigEntry' } } + required: [data] + + XlvaskConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/XlvaskConfigEntry' } } + required: [data] + + EntraConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/EntraConfigEntry' } } + required: [data] + + LimbleConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/LimbleConfigEntry' } } + required: [data] + + OcrSpaceConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/OcrSpaceConfigEntry' } } + required: [data] + + OpenAiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/OpenAiConfigEntry' } } + required: [data] + + LicensePlateRecognizerConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/LicensePlateRecognizerConfigEntry' } } + required: [data] + + VirkdataConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/VirkdataConfigEntry' } } + required: [data] + + ShellyConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/ShellyConfigEntry' } } + required: [data] + + SelfServeConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/SelfServeConfigEntry' } } + required: [data] + + + DepartmentWeatherStatus: + type: string + description: Productivity health for the slot. `unknown` is returned when the slot has not started yet, has no employee-hours, or one or more selected departments are missing department weather targets. + enum: [unknown, healthy, degraded, unhealthy] + + DepartmentWeatherCondition: + type: string + enum: [clear, mostly_clear, partly_cloudy, mostly_cloudy, overcast, rain, showers, thunderstorm, snow, fog] + + DepartmentWeatherTarget: + type: object + properties: + department_id: + type: integer + minimum: 1 + degraded_threshold: + type: number + nullable: true + minimum: 0 + description: Minimum washes per hour ratio required for `degraded`. `null` when target is not configured. + healthy_threshold: + type: number + nullable: true + minimum: 0 + description: Minimum washes per hour ratio required for `healthy`. `null` when target is not configured. + configured: + type: boolean + description: Whether both weather status thresholds are configured and valid for the department. + required: [department_id, degraded_threshold, healthy_threshold, configured] + + DepartmentWeatherTargetUpsertRequest: + type: object + required: [department_id, degraded_threshold, healthy_threshold] + properties: + department_id: + type: integer + minimum: 1 + degraded_threshold: + type: number + minimum: 0 + healthy_threshold: + type: number + minimum: 0 + description: Must be greater than or equal to `degraded_threshold`. + + DepartmentWeatherTimelineEntry: + type: object + properties: + date: + type: string + format: date + description: Calendar date for the hourly slot (`YYYY-MM-DD`). + example: '2026-03-24' + time: + type: string + description: Hour label for the slot in 24-hour format (`HH:00`). + example: '01:00' + current: + type: boolean + description: True when this slot matches the current server hour. + example: false + weather: + $ref: '#/components/schemas/DepartmentWeatherCondition' + washes: + type: integer + minimum: 0 + example: 0 + hours: + type: number + format: float + minimum: 0 + example: 2.5 + description: Sum of Workfeed employee-hours in the department for this exact hour slot + status: + $ref: '#/components/schemas/DepartmentWeatherStatus' + required: [date, time, current, weather, washes, hours, status] + + WeatherApiObjectResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: object + additionalProperties: true + required: [data] + + WorkfeedDepartment: + type: object + properties: + id: { type: string, example: "PKHaOSgFA4uguOqmLfWv" } + name: { type: string, example: "API Testing Account 😎" } + isDeleted: { type: boolean, example: false } + createTime: { type: string, format: date-time, example: "2023-10-25T09:43:06.650Z" } + updateTime: { type: string, format: date-time, example: "2023-10-25T09:43:06.650Z" } + additionalProperties: true + + WorkfeedEmployee: + type: object + properties: + id: { type: string, example: "J9QAIiTG0nRC1OsC5YvHfLWdDHn1" } + firstname: { type: string, example: "API 2" } + lastname: { type: string, example: "Test 2" } + email: { type: string, nullable: true, example: "test2@example.com" } + phone: { type: string, nullable: true, example: "12345678" } + roleIDs: + type: array + items: { type: string } + example: ["hLbEKIPTlMh3ehotXl0w"] + departmentIDs: + type: array + items: { type: string } + example: ["PKHaOSgFA4uguOqmLfWv"] + primaryDepartmentID: { type: string, nullable: true } + street: { type: string, nullable: true, example: "" } + city: { type: string, nullable: true, example: "" } + zip: { type: string, nullable: true, example: "" } + accessLevel: { type: string, nullable: true, example: "employee" } + wage: { type: number, nullable: true } + minHours: { type: number, nullable: true } + maxHours: { type: number, nullable: true } + isDeleted: { type: boolean, example: false } + ssn: { type: string, nullable: true, example: "" } + imageURL: { type: string, nullable: true, format: uri } + createTime: { type: string, format: date-time, example: "2023-10-28T18:22:45.695Z" } + updateTime: { type: string, format: date-time, example: "2023-10-28T18:34:37.191Z" } + additionalProperties: true + + WorkfeedShiftComment: + type: object + properties: + message: { type: string, nullable: true, example: "Comments! 😍" } + creatorID: { type: string, nullable: true, example: "API" } + createdOn: { type: string, format: date-time, nullable: true, example: "2023-10-27T09:43:36.542Z" } + additionalProperties: true + + WorkfeedShiftCustomBreak: + type: object + properties: + creatorID: { type: string, nullable: true, example: "automatic" } + duration: { type: number, nullable: true, example: 1 } + createdOn: { type: string, format: date-time, nullable: true, example: "2023-10-27T09:10:30.336Z" } + additionalProperties: true + + WorkfeedShiftApproval: + type: object + properties: + approver: { type: string, nullable: true, example: "automatic" } + date: { type: string, format: date-time, nullable: true, example: "2023-12-27T09:10:30.336Z" } + originalStart: { type: string, format: date-time, nullable: true, example: "2023-12-07T09:10:30.336Z" } + originalEnd: { type: string, format: date-time, nullable: true, example: "2023-12-08T09:10:30.336Z" } + additionalProperties: true + + WorkfeedShift: + type: object + properties: + id: { type: string, example: "Trcu7MKFomu5y5zv1B8G" } + start: { type: string, format: date-time, example: "2023-10-23T08:00:00.000Z" } + end: { type: string, format: date-time, example: "2023-10-23T16:00:00.000Z" } + employeeID: { type: string, nullable: true } + roleID: { type: string, nullable: true, example: "hLbEKIPTlMh3ehotXl0w" } + departmentID: { type: string, nullable: true, example: "PKHaOSgFA4uguOqmLfWv" } + released: { type: boolean, example: false } + isForSale: { type: boolean, nullable: true, example: false } + comment: + allOf: + - $ref: '#/components/schemas/WorkfeedShiftComment' + nullable: true + customBreak: + allOf: + - $ref: '#/components/schemas/WorkfeedShiftCustomBreak' + nullable: true + overlappingLeaveID: { type: string, nullable: true } + approval: + allOf: + - $ref: '#/components/schemas/WorkfeedShiftApproval' + nullable: true + grossPay: { type: number, nullable: true } + tagIDs: + type: array + items: { type: string } + createTime: { type: string, format: date-time, example: "2023-10-27T09:10:30.336Z" } + updateTime: { type: string, format: date-time, example: "2023-10-27T09:10:30.422Z" } + additionalProperties: true + + WorkfeedEmployeeListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkfeedEmployee' + required: [data] + + WorkfeedEmployeeSingleResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/WorkfeedEmployee' + required: [data] + + WorkfeedShiftListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkfeedShift' + required: [data] + + WorkfeedShiftSingleResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/WorkfeedShift' + required: [data] + + WorkfeedDepartmentListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkfeedDepartment' + required: [data] + + DepartmentWeatherTimelineResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + description: Hourly contiguous slots from start of range (`00:00`) to end of range (`23:00`, inclusive). Defaults to yesterday+today (48 entries) when date_from/date_to are not provided. + items: + $ref: '#/components/schemas/DepartmentWeatherTimelineEntry' + required: [data] + + DepartmentWeatherTargetsResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/DepartmentWeatherTarget' + required: [data] + + ModuleConfigEntry: + type: object + properties: + module: + type: string + description: Module name + variable: + type: string + description: Configuration variable key + type: + type: string + description: Stored value type in module_config + value: + $ref: '#/components/schemas/ModuleConfigValue' + required: + - module + - variable + - type + - value + + ModuleConfigListResponse: + type: object + properties: + success: + type: boolean + data: + type: array + items: + $ref: '#/components/schemas/ModuleConfigEntry' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + ModuleConfigUpdateResponse: + type: object + properties: + success: + type: boolean + data: + type: boolean + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + ModuleConfigTestResponse: + type: object + properties: + success: + type: boolean + data: + type: string + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + User: + type: object + properties: + id: + type: integer + description: User ID + customer_number: + type: integer + description: e-conomic customer number + display_name: + type: string + description: User's display name + group_id: + type: integer + description: User group/role ID + phone_country_code: + type: integer + description: Phone country code + phone: + type: integer + description: Phone number + email: + type: string + format: email + description: Email address + sms_notifications_enabled: + type: boolean + description: SMS notifications enabled + email_notifications_enabled: + type: boolean + description: Email notifications enabled + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + SubuserGrant: + type: object + properties: + id: + type: integer + billing_customer_number: + type: integer + description: e-conomic customer number + subuser: + type: integer + description: Subuser ID + enabled: + type: boolean + note: + type: string + nullable: true + permissions: + type: array + description: List of permission node keys + items: + type: string + example: BOOKINGS_LIST + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + deleted_at: + type: string + format: date-time + nullable: true + + SubuserGrantCreateRequest: + type: object + required: + - customer_number + - subuser_id + properties: + customer_number: + type: integer + description: e-conomic customer number + subuser_id: + type: integer + description: Subuser ID to grant permissions for + enabled: + type: boolean + default: true + note: + type: string + nullable: true + maxLength: 65535 + permissions: + type: array + description: Optional list of permission keys; defaults will be applied if omitted + items: + type: string + + SubuserGrantUpdateRequest: + type: object + properties: + enabled: + type: boolean + note: + type: string + nullable: true + maxLength: 65535 + permissions: + type: array + items: + type: string + + SubuserGrantSummary: + type: object + description: Summary of a subuser grant grouped by billing customer number + properties: + billing_customer_number: + type: integer + description: e-conomic customer number this grant applies to + permissions: + type: array + description: List of permission node keys enabled for this customer + items: + type: string + + SubuserSelf: + type: object + description: Authenticated subuser profile with enabled grants + properties: + id: + type: integer + username: + type: string + name: + type: string + nullable: true + email: + type: string + format: email + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: integer + nullable: true + grants: + type: array + description: Enabled, non-deleted grants for the subuser grouped by billing customer number + items: + $ref: '#/components/schemas/SubuserGrantSummary' + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + suspended_at: + type: string + format: date-time + nullable: true + two_factor_enabled: + type: boolean + description: Indicates if 2FA is enabled for this account + + PermissionNode: + type: object + properties: + key: + type: string + description: Permission node key + name: + type: string + description: + type: string + type: + type: string + description: Permission type (e.g., TOGGLE) + default: + type: boolean + + PermissionNodeGroup: + type: object + properties: + group: + type: string + description: + type: string + nodes: + type: array + items: + $ref: '#/components/schemas/PermissionNode' + + UserCreate: + type: object + required: + - customer_number + - password + properties: + customer_number: + type: integer + password: + type: string + format: password + display_name: + type: string + group_id: + type: integer + email: + type: string + format: email + phone: + type: integer + phone_country_code: + type: integer + + UserUpdate: + type: object + properties: + id: + type: integer + customer_number: + type: integer + display_name: + type: string + group_id: + type: integer + email: + type: string + format: email + phone: + type: integer + phone_country_code: + type: integer + + Order: + type: object + properties: + id: + type: integer + description: Order ID + customer_id: + type: integer + description: Customer number + customer_name: + type: string + description: Customer name + user_id: + type: integer + description: User ID + cashier_id: + type: integer + description: Cashier user ID + + cashier_name: + type: string + description: Cashier name + department_id: + type: integer + description: Department ID + status: + type: string + description: Order status + total_net_amount: + type: number + format: float + description: Total order amount + po: + type: string + description: Purchase order number + nullable: true + lane: + type: string + description: Lane information + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + EconomicTransferQueueStatus: + type: string + enum: + - QUEUED + - PROCESSING + - COMPLETED + - FAILED + + EconomicTransferQueueJob: + type: object + properties: + id: + type: integer + transfer_type: + type: string + enum: + - ORDER_DRAFT_EXPORT + - ORDER_INVOICE_EXPORT + - COLLECTED_INVOICE_EXPORT + status: + $ref: '#/components/schemas/EconomicTransferQueueStatus' + progress_percent: + type: integer + minimum: 0 + maximum: 100 + progress_message: + type: string + nullable: true + attempts: + type: integer + minimum: 0 + max_attempts: + type: integer + minimum: 1 + error_message: + type: string + nullable: true + payload: + type: object + nullable: true + additionalProperties: true + result: + type: object + nullable: true + additionalProperties: true + details_summary: + type: object + nullable: true + additionalProperties: true + created_by: + type: integer + nullable: true + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + next_retry_at: + type: string + format: date-time + nullable: true + required: + - id + - transfer_type + - status + - progress_percent + - attempts + - max_attempts + + EconomicTransferQueueEnqueueResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job_id: + type: integer + minimum: 1 + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job_id + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferSynchronousFallbackResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + mode: + type: string + enum: [synchronous_fallback] + result: + type: object + additionalProperties: true + required: + - message + - mode + - result + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueStatusResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/EconomicTransferQueueJob' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueRetryResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueRunResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + processed: + type: integer + minimum: 0 + completed: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + jobs: + type: array + items: + type: integer + minimum: 1 + limit: + type: integer + minimum: 1 + maximum: 10 + transfer_type: + type: string + enum: + - COLLECTED_INVOICE_EXPORT + required: + - message + - processed + - completed + - failed + - jobs + - limit + - transfer_type + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueListResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + count: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + has_more: + type: boolean + required: + - items + - count + - total + - limit + - offset + - has_more + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueMonitorResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + jobs: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + counts: + type: object + properties: + queued: + type: integer + minimum: 0 + in_progress: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + completed: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + required: + - queued + - in_progress + - failed + - completed + - total + progress_percent: + type: integer + minimum: 0 + maximum: 100 + limit: + type: integer + minimum: 1 + maximum: 100 + required: + - jobs + - counts + - progress_percent + - limit + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissTerminalResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + dismissed_count: + type: integer + minimum: 0 + required: + - message + - dismissed_count + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + CollectedInvoiceEconomicCompareResponse: + type: object + description: Result of comparing a collected invoice with its E-conomic counterpart + properties: + collected_invoice_id: + type: integer + description: The internal collected invoice ID + example: 123 + draft_id: + type: integer + nullable: true + description: E-conomic draft invoice ID, if present + example: 456 + booked_id: + type: integer + nullable: true + description: E-conomic booked invoice ID, if present + example: 28368 + warnings: + type: array + description: List of warnings detected during comparison + items: + type: string + example: + - "Total amount mismatch for draft invoice ID 456: E-Conomic total is 867.5, internal total is 694" + draft_total: + type: number + format: float + nullable: true + description: Total amount from the E-conomic draft (gross) + example: 867.5 + booked_total: + type: number + format: float + nullable: true + description: Total amount from the E-conomic booked invoice (gross minus VAT if applicable) + example: 694 + difference: + type: number + format: float + nullable: true + description: Selected e-conomic total (draft when available, otherwise booked) minus internal_total + example: 0 + internal_total: + type: number + format: float + description: Internal total amount for the collected invoice + example: 694 + required: + - collected_invoice_id + - internal_total + + CollectedInvoiceEconomicV2DetailsResponse: + type: object + properties: + collected_invoice_id: + type: integer + external_id: + type: string + order_ids: + type: array + items: + type: integer + economic: + type: object + properties: + draft_id: + type: integer + nullable: true + booked_id: + type: integer + nullable: true + customer: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary' + internal: + type: object + required: [normalized] + properties: + normalized: + $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + draft: + type: object + properties: + exists: + type: boolean + raw: + type: object + nullable: true + additionalProperties: true + normalized: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + nullable: true + booked: + type: object + properties: + exists: + type: boolean + raw: + type: object + nullable: true + additionalProperties: true + normalized: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + nullable: true + warnings: + type: array + items: + type: string + required: + - collected_invoice_id + - order_ids + - economic + - customer + - internal + - draft + - booked + - warnings + + CollectedInvoiceEconomicV2CustomerSummary: + type: object + properties: + internal_customer_number: + type: integer + nullable: true + draft_customer_number: + type: integer + nullable: true + booked_customer_number: + type: integer + nullable: true + exists: + type: boolean + name: + type: string + nullable: true + barred: + type: boolean + nullable: true + required: + - exists + + CollectedInvoiceEconomicV2CompareResponse: + type: object + properties: + collected_invoice_id: + type: integer + details: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse' + comparison: + $ref: '#/components/schemas/EconomicV2Comparison' + warnings: + type: array + items: + type: string + required: + - collected_invoice_id + - details + - comparison + - warnings + + CollectedInvoiceEconomicV2CompareBulkResponse: + type: object + properties: + requested: + type: integer + compared: + type: integer + failed: + type: integer + results: + type: array + items: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareResponse' + errors: + type: array + items: + type: object + properties: + collected_invoice_id: + type: integer + error: + type: string + required: + - requested + - compared + - failed + - results + - errors + + CollectedInvoiceEconomicV2RevenueStatisticsResponse: + type: object + properties: + filters: + type: object + properties: + dateFrom: + type: string + format: date + dateTo: + type: string + format: date + customer_numbers: + type: array + items: + type: integer + department_numbers: + type: array + items: + type: integer + currency: + type: string + nullable: true + barred: + type: string + enum: [all, barred, active] + max_pages: + type: integer + summary: + $ref: '#/components/schemas/EconomicV2RevenueSummary' + customers: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueCustomerStat' + departments: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueDepartmentStat' + currencies: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueCurrencyStat' + warnings: + type: array + items: + type: string + required: + - filters + - summary + - customers + - departments + - currencies + - warnings + + EconomicV2RevenueSummary: + type: object + properties: + invoice_count: + type: integer + line_count: + type: integer + unique_customers: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + average_invoice_net_amount: + type: number + required: + - invoice_count + - line_count + - unique_customers + - net_amount + - vat_amount + - gross_amount + - average_invoice_net_amount + + EconomicV2RevenueCustomerStat: + type: object + properties: + customer_number: + type: integer + customer_name: + type: string + nullable: true + barred: + type: boolean + nullable: true + invoice_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - customer_number + - invoice_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2RevenueDepartmentStat: + type: object + properties: + department_key: + type: string + department_number: + type: integer + nullable: true + invoice_count: + type: integer + line_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - department_key + - invoice_count + - line_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2RevenueCurrencyStat: + type: object + properties: + currency: + type: string + invoice_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - currency + - invoice_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2NormalizedInvoice: + type: object + properties: + source: + type: string + enum: [internal, draft, booked] + totals: + type: object + properties: + net_total: + type: number + line_net_total: + type: number + line_count: + type: integer + billable_line_count: + type: integer + difference_from_line_sum: + type: number + nullable: true + departments: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + lines: + type: array + items: + $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + warnings: + type: array + items: + type: string + required: + - source + - totals + - departments + - lines + - warnings + + EconomicV2NormalizedLineItem: + type: object + properties: + index: + type: integer + source: + type: string + source_order_id: + type: integer + nullable: true + source_line_id: + type: integer + nullable: true + line_type: + type: string + enum: [product, discount, text] + billable: + type: boolean + product_number: + type: string + nullable: true + product_id: + type: integer + nullable: true + description: + type: string + reference: + type: string + quantity: + type: number + unit_net_price: + type: number + line_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + match_key: + type: string + required: + - source + - line_type + - billable + - description + - reference + - quantity + - unit_net_price + - line_net_amount + - department_distribution + - match_key + + EconomicV2DepartmentDistribution: + type: object + additionalProperties: + type: number + example: + "75": 100 + + EconomicV2Comparison: + type: object + properties: + totals: + type: object + properties: + internal_net_total: + type: number + targets: + type: object + properties: + draft: + $ref: '#/components/schemas/EconomicV2TargetComparison' + booked: + $ref: '#/components/schemas/EconomicV2TargetComparison' + warnings: + type: array + items: + type: string + required: + - totals + - targets + - warnings + + EconomicV2TargetComparison: + type: object + properties: + target: + type: string + enum: [draft, booked] + status: + type: string + enum: [exact_match, partial_mismatch, total_mismatch, missing_target] + overall_match: + type: boolean + totals: + $ref: '#/components/schemas/EconomicV2TotalsComparison' + lines: + type: object + properties: + summary: + type: object + properties: + internal_billable_count: + type: integer + target_billable_count: + type: integer + mismatch_count: + type: integer + diff: + type: array + items: + $ref: '#/components/schemas/EconomicV2LineDiffEntry' + departments: + type: object + properties: + matches: + type: boolean + diff: + type: array + items: + $ref: '#/components/schemas/EconomicV2DepartmentDiffEntry' + mismatch_reasons: + type: array + items: + type: string + warnings: + type: array + items: + type: string + required: + - target + - status + - overall_match + - totals + - lines + - departments + - mismatch_reasons + - warnings + + EconomicV2TotalsComparison: + type: object + properties: + internal_net_total: + type: number + nullable: true + target_net_total: + type: number + nullable: true + difference: + type: number + nullable: true + abs_difference: + type: number + nullable: true + matches: + type: boolean + required: + - matches + + EconomicV2LineDiffEntry: + type: object + properties: + match_key: + type: string + reasons: + type: array + items: + type: string + internal_line: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + nullable: true + target_line: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + nullable: true + required: + - match_key + - reasons + + EconomicV2DepartmentDiffEntry: + type: object + properties: + department_key: + type: string + internal_amount: + type: number + target_amount: + type: number + difference: + type: number + matches: + type: boolean + required: + - department_key + - internal_amount + - target_amount + - difference + - matches + + InvoicingDistributionV2Transaction: + type: object + properties: + id: + type: integer + date: + type: string + format: date-time + amount: + type: number + booked: + type: boolean + department_id: + type: integer + excluded: + type: boolean + required: [id, date, amount, booked, department_id, excluded] + + InvoicingDistributionV2Customer: + type: object + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2Transaction' + requires_action: + type: boolean + meta: + type: object + additionalProperties: true + required: [customer_number, customer_name, transactions, requires_action, meta] + + InvoicingDistributionV2CategoryResponse: + type: object + properties: + customers: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2Customer' + collective_results: + type: object + additionalProperties: true + warnings: + type: array + items: + type: string + required: [customers, collective_results, warnings] + + InvoicingDistributionV2FixedPricingResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2WashSubscriptionsResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2CustomerPricesResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2BookedDepartment75Group: + type: object + properties: + month: + type: string + example: '2026-01' + source_category: + type: string + enum: [fixed_pricing, wash_subscriptions, unclassified] + invoice_ids: + type: array + items: + type: integer + booked_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + undistributed_net_amount: + type: number + required: + - month + - source_category + - invoice_ids + - booked_net_amount + - department_distribution + - undistributed_net_amount + + InvoicingDistributionV2BookedDepartment75Meta: + type: object + properties: + booked_net_amount: + type: number + distributed_net_amount: + type: number + undistributed_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + booked_groups: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Group' + required: + - booked_net_amount + - distributed_net_amount + - undistributed_net_amount + - department_distribution + - booked_groups + + InvoicingDistributionV2BookedDepartment75Customer: + allOf: + - $ref: '#/components/schemas/InvoicingDistributionV2Customer' + - type: object + properties: + meta: + type: object + properties: + booked_department_75: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Meta' + required: + - booked_department_75 + + InvoicingDistributionV2BookedDepartment75CollectiveResults: + type: object + properties: + booked_net_amount: + type: number + distributed_net_amount: + type: number + undistributed_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + department_distribution_parsed: + type: object + additionalProperties: + type: number + required: + - booked_net_amount + - distributed_net_amount + - undistributed_net_amount + - department_distribution + - department_distribution_parsed + + InvoicingDistributionV2BookedDepartment75Response: + type: object + properties: + customers: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Customer' + collective_results: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75CollectiveResults' + warnings: + type: array + items: + type: string + required: [customers, collective_results, warnings] + + InvoicingDistributionV2AllResponse: + type: object + properties: + fixed_pricing: + $ref: '#/components/schemas/InvoicingDistributionV2FixedPricingResponse' + wash_subscriptions: + $ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse' + customer_prices: + $ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse' + booked_department_75: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response' + required: [fixed_pricing, wash_subscriptions, customer_prices, booked_department_75] + + PricingHistoryVersionEntry: + type: object + properties: + id: + type: integer + type: + type: string + enum: [fixed_pricing, vehicle_subscription, discount_override] + customer_number: + type: integer + effective_from: + type: string + format: date-time + effective_to: + type: string + format: date-time + nullable: true + source: + type: string + confidence: + type: number + minimum: 0 + maximum: 1 + inferred: + type: boolean + metadata_json: + oneOf: + - type: string + - type: object + additionalProperties: true + - type: array + items: {} + nullable: true + required: + - id + - type + - customer_number + - effective_from + - source + - confidence + - inferred + + CustomerPricingHistoryResponse: + type: object + properties: + customer_number: + type: integer + fixed_pricing: + type: array + items: + type: object + additionalProperties: true + vehicle_subscriptions: + type: array + items: + type: object + additionalProperties: true + discount_overrides: + type: array + items: + type: object + additionalProperties: true + timeline: + type: array + items: + $ref: '#/components/schemas/PricingHistoryVersionEntry' + required: + - customer_number + - fixed_pricing + - vehicle_subscriptions + - discount_overrides + - timeline + + InvoicingWashSubscriptionsDistributionResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + $ref: '#/components/schemas/InvoicingWashSubscriptionsDistributionCustomer' + meta: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionMeta' + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + InvoicingWashSubscriptionsDistributionCustomer: + type: object + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionTransaction' + requires_action: + type: boolean + meta: + type: object + properties: + subscription: + type: object + additionalProperties: true + required: + - subscription + required: + - customer_number + - customer_name + - transactions + - requires_action + - meta + + InvoicingFixedPricingDistributionResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionCustomer' + meta: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionMeta' + includes: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionIncludes' + required: + - success + - data + - meta + - includes + + InvoicingFixedPricingDistributionCustomer: + type: object + properties: + id: + type: integer + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionTransaction' + requires_action: + type: boolean + meta: + type: object + properties: + fixed_pricing: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionFixedPricing' + required: + - fixed_pricing + required: + - id + - customer_number + - customer_name + - transactions + - requires_action + - meta + + InvoicingFixedPricingDistributionTransaction: + type: object + properties: + id: + type: integer + date: + type: string + description: Datetime in `YYYY-MM-DD HH:mm:ss` format. + example: '2026-02-02 10:43:41' + amount: + type: number + booked: + type: boolean + excluded: + type: boolean + required: + - id + - date + - amount + - booked + - excluded + + InvoicingFixedPricingDistributionFixedPricing: + type: object + properties: + customer_number: + type: integer + price: + type: number + description: + type: string + original_price: + type: number + department_totals: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + department_totals_relative: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + required: + - customer_number + - price + - description + - original_price + - department_totals + - department_totals_relative + + InvoicingFixedPricingDistributionMeta: + type: object + properties: + date_from: + type: string + description: Datetime in `YYYY-MM-DD HH:mm:ss` format. + example: '2026-02-01 00:00:00' + date_to: + type: string + description: Datetime in `YYYY-MM-DD HH:mm:ss` format. + example: '2026-02-28 23:59:59' + required: + - date_from + - date_to + + InvoicingFixedPricingDistributionIncludes: + type: object + properties: + debug_invoicing_period_customers_with_orders_in_date_range: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_process_customer_numbers: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_get_transactions_for_customers_in_date_range: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_calculate_transaction_totals: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_construct_customer_objects: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + collective_fixed_pricing_results: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionCollectiveResults' + additionalProperties: true + + InvoicingFixedPricingDistributionExecutionTime: + type: object + properties: + execution_time: + type: number + required: + - execution_time + + InvoicingFixedPricingDistributionCollectiveResults: + type: object + properties: + total_fixed_price: + type: number + total_original_price: + type: number + total_department_totals: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + total_department_totals_relative: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + total_department_totals_parsed: + type: object + additionalProperties: + type: number + total_department_totals_relative_parsed: + type: object + additionalProperties: + type: number + required: + - total_fixed_price + - total_original_price + - total_department_totals + - total_department_totals_relative + - total_department_totals_parsed + - total_department_totals_relative_parsed + + InvoicingFixedPricingDistributionNumberMapOrEmptyArray: + oneOf: + - type: object + additionalProperties: + type: number + - type: array + maxItems: 0 + + SelfServeLaneStatus: + type: object + properties: + id: + type: integer + description: Lane ID + status: + type: string + description: Current lane status (e.g., IDLE, OCCUPIED) + mode: + type: string + description: Current lane mode (e.g., AUTOMATIC, MANUAL) + state: + type: string + description: Current lane state (e.g., READY, WASHING) + wash_start_time: + type: integer + description: Timestamp when the wash started (0 if not washing) + nullable: true + elapsed_wash_time: + type: integer + description: Elapsed wash time in seconds + nullable: true + license_plate: + type: string + description: License plate of the vehicle in the lane + nullable: true + customer_number: + type: integer + description: Customer number associated with the current lane use + nullable: true + + SelfServeLaneMachineRelayStatus: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE, MACHINE_PROGRAM_PICKER, MACHINE_CLEANER] + relay_id: + type: string + online: + type: boolean + on: + type: boolean + + SelfServeConfig: + type: object + properties: + enabled: + type: boolean + description: Whether the self-serve module is enabled + minute_product: + type: integer + description: The product ID used for minute-based billing + machine_wash_minutes_included: + type: integer + description: Included machine wash minutes before minute-based billing starts + + SelfserveLaneService: + type: string + description: Allowed self-serve lane service name + enum: + - MACHINE + + SelfserveStudioNode: + type: object + required: [id, position, data] + properties: + id: + type: string + type: + type: string + nullable: true + position: + type: object + required: [x, y] + properties: + x: { type: number } + y: { type: number } + data: + type: object + additionalProperties: true + properties: + kind: + type: string + enum: [question, condition, rule, task, lane, machine_type, vehicle_type, edge_gateway, relay_binding, relay, runtime_checkpoint] + object_id: + oneOf: + - type: integer + - type: string + nullable: true + label: + type: string + raw: + type: object + additionalProperties: true + SelfserveStudioEdge: + type: object + required: [id, source, target] + properties: + id: { type: string } + source: { type: string } + target: { type: string } + type: { type: string, nullable: true } + label: { type: string, nullable: true } + data: + type: object + additionalProperties: true + SelfserveStudioLayout: + type: object + properties: + nodes: + type: object + additionalProperties: + type: object + properties: + x: { type: number } + y: { type: number } + viewport: + type: object + additionalProperties: true + runtime_affecting: + type: boolean + enum: [false] + SelfserveStudioValidation: + type: object + properties: + valid: + type: boolean + errors: + type: array + items: { type: string } + warnings: + type: array + items: { type: string } + items: + type: array + items: + type: object + properties: + severity: + type: string + enum: [error, warning] + message: + type: string + stats: + type: object + additionalProperties: true + validated_at: + type: string + format: date-time + SelfserveConfigVersion: + type: object + properties: + id: { type: integer } + department_id: { type: integer } + status: + type: string + enum: [DRAFT, PUBLISHED, ARCHIVED] + version_number: { type: integer } + config: + $ref: '#/components/schemas/SelfserveStudioV2Config' + validation_result: + $ref: '#/components/schemas/SelfserveStudioValidation' + source_version_id: { type: integer, nullable: true } + created_by: { type: integer, nullable: true } + published_at: { type: string, nullable: true } + created_at: { type: string, nullable: true } + updated_at: { type: string, nullable: true } + SelfserveStudioV2Config: + type: object + required: [schema_version, questions, conditions, rules, tasks] + properties: + schema_version: + type: integer + enum: [2] + department_id: + type: integer + questions: + type: array + items: + type: object + additionalProperties: true + conditions: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioV2Condition' + rules: + type: array + description: Empty in schema_version 2; retained only for backward-compatible payload shape. + maxItems: 0 + items: + type: object + tasks: + type: array + items: + type: object + additionalProperties: true + v2_meta: + type: object + additionalProperties: true + migration_issues: + type: array + items: + type: object + additionalProperties: true + SelfserveStudioV2Condition: + type: object + required: [id, expression] + properties: + id: + type: integer + name: + type: string + description: + type: string + nullable: true + expression: + $ref: '#/components/schemas/SelfserveStudioV2Expression' + additionalProperties: true + SelfserveStudioV2Expression: + oneOf: + - $ref: '#/components/schemas/SelfserveStudioV2ExpressionGroup' + - $ref: '#/components/schemas/SelfserveStudioV2ExpressionPredicate' + SelfserveStudioV2ExpressionGroup: + type: object + required: [type, operator, children] + properties: + type: + type: string + enum: [group] + operator: + type: string + enum: [ALL, ANY] + children: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioV2Expression' + SelfserveStudioV2ExpressionPredicate: + type: object + required: [type, subject_type, subject_id, operator] + properties: + type: + type: string + enum: [predicate] + subject_type: + type: string + enum: [question, condition] + subject_id: + type: integer + operator: + type: string + enum: [IS_TRUE, IS_FALSE, IS_SET, IS_TRUE_OR_NOT_SET, IS_FALSE_OR_NOT_SET] + SelfserveStudioGraph: + type: object + required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions] + properties: + nodes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioNode' + edges: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioEdge' + lookups: + type: object + additionalProperties: true + validation: + $ref: '#/components/schemas/SelfserveStudioValidation' + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' + versions: + type: array + items: + $ref: '#/components/schemas/SelfserveConfigVersion' + active_config: + allOf: + - $ref: '#/components/schemas/SelfserveStudioV2Config' + nullable: true + draft: + type: object + additionalProperties: true + simulator_defaults: + type: object + additionalProperties: true + gateway_workspace: + type: object + additionalProperties: true + permissions: + type: object + additionalProperties: + type: boolean + meta: + type: object + additionalProperties: true + SelfserveStudioGraphOperation: + type: object + properties: + action: + type: string + enum: [create, update, delete, connect, disconnect, reorder, upsert, upsert_path] + entity: + type: string + enum: [question, condition, task, action, path] + description: Standalone rule operations are not accepted for schema_version 2 drafts. + id: + type: integer + nullable: true + source: + type: string + nullable: true + target: + type: string + nullable: true + data: + type: object + additionalProperties: true + items: + type: array + items: + type: object + additionalProperties: true + SelfserveStudioGraphSaveRequest: + type: object + required: [department] + properties: + department: { type: integer } + operations: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioGraphOperation' + nodes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioNode' + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' + SelfserveStudioLayoutSaveRequest: + type: object + required: [department, layout] + properties: + department: { type: integer } + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' + SelfserveMachineType: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + nullable: true + + SelfserveVisibleQuestion: + type: object + properties: + id: + type: integer + question: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + answer: + type: boolean + nullable: true + + SelfserveTaskDecision: + type: object + properties: + id: + type: integer + task: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: + type: integer + + SelfserveWashSession: + type: object + properties: + id: + type: integer + lane_id: + type: integer + department_id: + type: integer + machine_type_id: + type: integer + nullable: true + customer_number: + type: integer + nullable: true + vehicle_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + reg: + type: string + status: + type: string + enum: + - PENDING_QUESTIONS + - READY_FOR_MACHINE_START + - MACHINE_NOT_ALLOWED + - MACHINE_RELAY_ENABLED + - MACHINE_STARTED + - COMPLETED + - FORCE_STOPPED + allowed: + type: boolean + machine_relay_enabled: + type: boolean + machine_relay_enabled_at: + type: string + format: date-time + nullable: true + machine_start_triggered: + type: boolean + machine_start_triggered_at: + type: string + format: date-time + nullable: true + wash_started_at: + type: string + format: date-time + nullable: true + order_id: + type: integer + nullable: true + completed_at: + type: string + format: date-time + nullable: true + metadata: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + nullable: true + + SelfserveWashQuestionAnswer: + type: object + properties: + question_id: + type: integer + question: + type: string + answer: + type: boolean + nullable: true + answered_at: + type: string + format: date-time + nullable: true + + SelfserveWashTaskSnapshot: + type: object + properties: + task_id: + type: integer + nullable: true + task: + type: string + description: + type: string + nullable: true + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: + type: integer + + SelfserveWashEvent: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - SESSION_SYNCED + - MACHINE_RELAY_ENABLED + - MACHINE_START_TRIGGERED + - SESSION_COMPLETED + - SESSION_FORCE_STOPPED + payload: + type: object + additionalProperties: true + nullable: true + created_at: + type: string + format: date-time + + SelfserveStudioSimulationDebug: + type: object + required: [summary, parameters, stages, questions, conditions, rules, tasks, hardware, graph_annotations, recommendations] + properties: + summary: + type: object + additionalProperties: true + parameters: + type: object + additionalProperties: true + stages: + type: array + items: + type: object + additionalProperties: true + questions: + type: array + items: + type: object + additionalProperties: true + conditions: + type: array + items: + type: object + additionalProperties: true + rules: + type: array + items: + type: object + additionalProperties: true + tasks: + type: array + items: + type: object + additionalProperties: true + actions: + type: array + items: + type: object + additionalProperties: true + dynamic_image_buttons: + type: array + items: + type: object + additionalProperties: true + decisions: + type: array + items: + type: object + required: [kind, id, label, state, reason, node_ids, causes] + properties: + kind: + type: string + id: + oneOf: + - type: integer + - type: string + nullable: true + label: + type: string + state: + type: string + reason: + type: string + node_ids: + type: array + items: + type: string + causes: + type: array + items: + type: object + additionalProperties: true + additionalProperties: true + hardware: + type: object + additionalProperties: true + graph_annotations: + type: object + properties: + nodes: + type: object + additionalProperties: + type: object + additionalProperties: true + edges: + type: object + additionalProperties: + type: object + additionalProperties: true + recommendations: + type: array + items: + type: object + additionalProperties: true + + SelfserveStudioSimulationResponse: + allOf: + - $ref: '#/components/schemas/SelfserveVehicleAllowedResponse' + - type: object + properties: + simulator_version: { type: integer } + dry_run: { type: boolean, enum: [true] } + mode: { type: string, enum: [full_dry_run] } + config_source: { type: string, enum: [draft, published] } + debug: + $ref: '#/components/schemas/SelfserveStudioSimulationDebug' + + SelfserveStudioPathOutcomesRequest: + type: object + required: [department] + properties: + department: + type: integer + lane_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + config_source: + type: string + enum: [draft, published] + default: draft + hardware_mode: + type: string + enum: [studio, real, none] + default: studio + include_hardware: + type: boolean + default: true + max_states: + type: integer + minimum: 1 + nullable: true + description: Optional debug cap. Omit for complete path projection. + path_sample_limit: + type: integer + minimum: 1 + nullable: true + description: Optional debug cap for returned path rows. Omit to return every terminal path row. + + SelfserveStudioPathOutcomesResponse: + type: object + required: [scope, summary, outcomes, paths, warnings, truncated, progress] + properties: + scope: + type: object + additionalProperties: true + summary: + type: object + required: [state_count, terminal_path_count, outcome_count, question_count, max_states, path_sample_count] + properties: + state_count: { type: integer } + terminal_path_count: { type: integer } + outcome_count: { type: integer } + question_count: { type: integer } + question_ids: + type: array + items: { type: integer } + max_states: { type: integer } + path_sample_count: { type: integer } + confirmations: + $ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary' + outcomes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathOutcome' + paths: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathResult' + warnings: + type: array + items: { type: string } + truncated: + type: boolean + progress: + $ref: '#/components/schemas/SelfserveStudioPathProgress' + confirmations: + type: object + properties: + summary: + $ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary' + removed: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathConfirmation' + + SelfserveStudioPathProgress: + type: object + required: [complete, percent, state_count, pending_state_count, terminal_path_count] + properties: + complete: { type: boolean } + percent: + type: integer + minimum: 0 + maximum: 100 + state_count: { type: integer } + pending_state_count: { type: integer } + terminal_path_count: { type: integer } + scenario_index: + type: integer + nullable: true + scenario_count: + type: integer + nullable: true + + SelfserveStudioPathOutcome: + type: object + required: [id, summary, path_count, allowed, services, tasks, signals, sample_chains, node_ids] + properties: + id: { type: string } + summary: { type: string } + path_count: { type: integer } + allowed: { type: boolean } + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathTask' + signals: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathSignal' + sample_chains: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathSampleChain' + scopes: + type: array + items: + type: object + additionalProperties: true + node_ids: + type: array + items: { type: string } + + SelfserveStudioPathResult: + type: object + required: [id, result, summary, allowed, services, tasks, signals, task_count, signal_count, answers, scope, node_ids] + properties: + id: { type: string } + result: { type: string } + summary: { type: string } + allowed: { type: boolean } + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathTask' + signals: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathSignal' + task_count: { type: integer } + signal_count: { type: integer } + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + scope: + type: object + additionalProperties: true + node_ids: + type: array + items: { type: string } + path_signature: { type: string } + result_signature: { type: string } + confirmation_status: + type: string + enum: [unconfirmed, confirmed, stale] + confirmed_at: + type: string + nullable: true + confirmed_by: + type: integer + nullable: true + stale_reason: + type: string + nullable: true + + SelfserveStudioPathConfirmationSummary: + type: object + properties: + confirmed: { type: integer } + unconfirmed: { type: integer } + stale: { type: integer } + removed: { type: integer } + total: { type: integer } + + SelfserveStudioPathConfirmationRequest: + type: object + required: [department, path_signature] + properties: + department: { type: integer } + action: + type: string + enum: [confirm, reset, delete, clear] + default: confirm + path_signature: { type: string } + result_signature: + type: string + description: Required when action is confirm. + scope: + type: object + additionalProperties: true + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + result: + type: object + additionalProperties: true + + SelfserveStudioPathConfirmation: + type: object + properties: + id: + type: integer + nullable: true + department_id: { type: integer } + lane_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + config_version_id: + type: integer + nullable: true + config_source: { type: string } + path_signature: { type: string } + result_signature: { type: string } + confirmation_status: + type: string + enum: [unconfirmed, confirmed, stale] + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + result: + type: object + additionalProperties: true + scope: + type: object + additionalProperties: true + confirmed_at: + type: string + nullable: true + confirmed_by: + type: integer + nullable: true + stale_reason: + type: string + nullable: true + + SelfserveStudioPathTask: + type: object + properties: + id: { type: integer } + node_id: { type: string } + label: { type: string } + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: {} + order_priority: { type: integer } + + SelfserveStudioPathSignal: + type: object + properties: + sequence: { type: integer } + runtime_stage: { type: string } + signal_type: { type: string } + relay_role: { type: string } + relay_id: + type: string + nullable: true + target_gateway_label: + type: string + nullable: true + target_binding: + type: string + nullable: true + source: { type: string } + virtual: { type: boolean } + predicted_status: { type: string } + payload: + type: object + additionalProperties: true + skip_block_reason: + type: string + nullable: true + + SelfserveStudioPathSampleChain: + type: object + properties: + scope: + type: object + additionalProperties: true + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + + SelfserveStudioPathAnswer: + type: object + properties: + question_id: { type: integer } + question: { type: string } + node_id: { type: string } + answer: { type: boolean } + answer_label: { type: string } + + SelfserveVehicleAllowedResponse: + type: object + properties: + lane: + $ref: '#/components/schemas/DepartmentLane' + machine_type: + allOf: + - $ref: '#/components/schemas/SelfserveMachineType' + nullable: true + vehicle: + type: object + additionalProperties: true + nullable: true + reg: + type: string + customer_number: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + questions: + type: array + items: + $ref: '#/components/schemas/SelfserveVisibleQuestion' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveTaskDecision' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean + blocked_reason: + type: string + nullable: true + session: + allOf: + - $ref: '#/components/schemas/SelfserveWashSession' + nullable: true + config_source: + type: string + nullable: true + evaluation_trace: + type: object + nullable: true + additionalProperties: true + + SelfserveWashSummary: + type: object + properties: + session: + $ref: '#/components/schemas/SelfserveWashSession' + lane: + allOf: + - $ref: '#/components/schemas/DepartmentLane' + nullable: true + machine_type: + allOf: + - $ref: '#/components/schemas/SelfserveMachineType' + nullable: true + questions: + type: array + items: + $ref: '#/components/schemas/SelfserveWashQuestionAnswer' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveWashTaskSnapshot' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean + events: + type: array + items: + $ref: '#/components/schemas/SelfserveWashEvent' + + SelfserveForceStopResponse: + type: object + properties: + lane_id: + type: integer + forced: + type: boolean + bill: + type: boolean + order_id: + type: integer + nullable: true + session: + allOf: + - $ref: '#/components/schemas/SelfserveWashSummary' + nullable: true + runtime_before_reset: + type: object + additionalProperties: true + + DepartmentSelfserveVehicleConditionMutationResponse: + type: object + properties: + condition: + $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + selfserve: + $ref: '#/components/schemas/SelfserveWashSummary' + + MachineButtonPressWebhookResponse: + type: object + properties: + message: + type: string + scanner: + type: string + lane_id: + type: integer + selfserve: + $ref: '#/components/schemas/SelfserveWashSummary' + + DepartmentSelfserveQuestion: + type: object + properties: + id: + type: integer + description: Question ID + department: + type: integer + description: Department ID + lane: + type: integer + description: Lane ID + product: + type: integer + description: Product ID + condition_id: + type: integer + description: Question condition object ID + nullable: true + question: + type: string + description: Question text + description: + type: string + description: Question description + order_priority: + type: integer + description: Display order priority (lower numbers shown first) + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentSelfserveTask: + type: object + properties: + id: + type: integer + description: Task ID + department: + type: integer + description: Department ID + lane: + type: integer + description: Lane ID + product: + type: integer + description: Product ID + machine_type_id: + type: integer + description: Reusable machine type ID + nullable: true + condition_id: + type: integer + description: Condition ID (if conditional task) + nullable: true + task: + type: string + description: Task text + description: + type: string + description: Task description + order_priority: + type: integer + description: Display order priority (lower numbers shown first) + services: + type: array + description: Services enabled by this task. Each item must be a valid service enum name. + items: + $ref: '#/components/schemas/SelfserveLaneService' + default: [] + buttons: + type: array + description: Dynamic image button IDs enabled by this task. + items: + type: integer + default: [] + dynamic_images_vehicle_type: + type: integer + nullable: true + description: Optional vehicle type selection override for the machine UI. + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentSelfserveCondition: + type: object + properties: + id: + type: integer + description: Condition ID + department: + type: integer + description: Department ID + lane: + type: integer + description: Lane ID + product: + type: integer + description: Product ID + machine_type_id: + type: integer + description: Reusable machine type ID + nullable: true + condition_id: + type: integer + description: Optional condition ID + nullable: true + name: + type: string + description: Condition name + description: + type: string + description: Condition description + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentSelfserveConditionRule: + type: object + properties: + id: + type: integer + description: Rule ID + condition_id: + type: integer + description: Condition object ID + type: + type: string + description: Condition type (e.g., IS_TRUE, IS_FALSE) + object_type: + type: string + description: The object type to which the condition applies (e.g., question, task, etc.) + object_id: + type: integer + description: The object id to which the condition applies + name: + type: string + description: Condition name + description: + type: string + description: Condition description + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentSelfserveVehicleCondition: + type: object + properties: + id: + type: integer + description: Vehicle condition ID + department: + type: integer + description: Department ID + lane: + type: integer + description: Lane ID + customer_id: + type: integer + description: Customer ID + nullable: true + reg: + type: string + description: Vehicle registration number + question: + type: integer + description: Question ID + value: + type: boolean + description: Answer value + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + deleted_at: + type: string + format: date-time + nullable: true + + OrderCreate: + type: object + required: + - customer_id + - department_id + properties: + customer_id: + type: integer + department_id: + type: integer + cashier_id: + type: integer + po: + type: string + lane: + type: string + + OrderUpdate: + type: object + properties: + id: + type: integer + customer_id: + type: integer + department_id: + type: integer + status: + type: string + po: + type: string + lane: + type: string + + OrderItem: + type: object + properties: + id: + type: integer + order_id: + type: integer + product_id: + type: integer + product_name: + type: string + quantity: + type: integer + unit_price: + type: number + format: float + discount: + type: number + format: float + total_price: + type: number + format: float + + OrderItemCreate: + type: object + required: + - order_id + - product_id + - quantity + properties: + order_id: + type: integer + product_id: + type: integer + quantity: + type: integer + discount: + type: number + format: float + + OrderItemUpdate: + type: object + required: + - id + properties: + id: + type: integer + quantity: + type: integer + discount: + type: number + format: float + + Department: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + economic_department_id: + type: integer + visible: + type: boolean + dimension: + type: integer + branding: + type: integer + longitude: + type: number + format: float + latitude: + type: number + format: float + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentGuest: + type: object + properties: + id: + type: integer + name: + type: string + longitude: + type: number + format: float + latitude: + type: number + format: float + address: + type: string + description: Department address (same as description) + self_serve_enabled: + type: boolean + lanes: + type: array + items: + $ref: '#/components/schemas/DepartmentLaneGuest' + + DepartmentLaneGuest: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + products: + type: array + items: + type: integer + machine_available: + type: boolean + selfserve_enabled: + type: boolean + + DepartmentCreate: + type: object + required: + - name + - economic_department_id + properties: + name: + type: string + description: + type: string + economic_department_id: + type: integer + visible: + type: boolean + longitude: + type: number + format: float + latitude: + type: number + format: float + + DepartmentUpdate: + type: object + required: + - id + properties: + id: + type: integer + name: + type: string + description: + type: string + visible: + type: boolean + longitude: + type: number + format: float + latitude: + type: number + format: float + + DepartmentLane: + type: object + properties: + id: + type: integer + department: + type: integer + name: + type: string + relay_in_id: + type: string + relay_out_id: + type: string + relay_machine_id: + type: string + relay_machine_program_picker_id: + type: string + relay_machine_cleaner_id: + type: string + dynamic_image_id: + type: integer + nullable: true + minimum: 1 + machine_type_id: + type: integer + nullable: true + selfserve_enabled: + type: boolean + default: true + status: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentLaneCreate: + type: object + required: + - department + - name + properties: + department: + type: integer + name: + type: string + relay_in_id: + type: string + relay_out_id: + type: string + relay_machine_id: + type: string + relay_machine_program_picker_id: + type: string + relay_machine_cleaner_id: + type: string + dynamic_image_id: + type: integer + nullable: true + minimum: 1 + machine_type_id: + type: integer + nullable: true + selfserve_enabled: + type: boolean + default: true + + DepartmentLaneUpdate: + type: object + required: + - id + properties: + id: + type: integer + department: + type: integer + name: + type: string + relay_in_id: + type: string + relay_out_id: + type: string + relay_machine_id: + type: string + relay_machine_program_picker_id: + type: string + relay_machine_cleaner_id: + type: string + dynamic_image_id: + type: integer + nullable: true + minimum: 1 + machine_type_id: + type: integer + nullable: true + selfserve_enabled: + type: boolean + + DepartmentGate: + type: object + properties: + id: + type: integer + department: + type: integer + is_entrance: + type: boolean + is_exit: + type: boolean + name: + type: string + config: + $ref: '#/components/schemas/DepartmentGateConfig' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentGateConfig: + type: object + required: + - type + properties: + type: + type: string + example: PHONE_CALL + phone_number: + type: string + nullable: true + example: +4512345678 + call_duration_threshold: + type: integer + nullable: true + example: 10 + description: | + Configuration for the department gate. + If type is 'PHONE_CALL', 'phone_number' and 'call_duration_threshold' are required. + + DepartmentGateCreate: + type: object + required: + - department + - is_entrance + - is_exit + - name + - config + properties: + department: + type: integer + is_entrance: + type: boolean + is_exit: + type: boolean + name: + type: string + config: + $ref: '#/components/schemas/DepartmentGateConfig' + + DepartmentGateUpdate: + type: object + required: + - id + properties: + id: + type: integer + is_entrance: + type: boolean + is_exit: + type: boolean + name: + type: string + config: + $ref: '#/components/schemas/DepartmentGateConfig' + + DepartmentRelay: + type: object + properties: + id: + type: integer + department: + type: integer + relay_id: + type: string + name: + type: string + type: + type: string + enum: [SWITCH, TRIGGER] + config: + $ref: '#/components/schemas/DepartmentRelayConfig' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentRelayConfig: + type: object + properties: + what_happens: + type: string + nullable: true + example: open_gate + webhook_token: + type: string + nullable: true + example: secret_token + description: | + Configuration for the department relay. + If the relay 'type' is 'TRIGGER', 'what_happens' and 'webhook_token' are required. + + DepartmentRelayCreate: + type: object + required: + - department + - relay_id + - name + - type + - config + properties: + department: + type: integer + relay_id: + type: string + name: + type: string + type: + type: string + enum: [SWITCH, TRIGGER] + config: + $ref: '#/components/schemas/DepartmentRelayConfig' + + DepartmentRelayUpdate: + type: object + required: + - id + properties: + id: + type: integer + relay_id: + type: string + name: + type: string + type: + type: string + enum: [SWITCH, TRIGGER] + config: + $ref: '#/components/schemas/DepartmentRelayConfig' + + Product: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + price: + type: number + format: float + category_id: + type: integer + category_name: + type: string + visible: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + ProductCreate: + type: object + required: + - name + - price + - category_id + properties: + name: + type: string + description: + type: string + price: + type: number + format: float + category_id: + type: integer + visible: + type: boolean + + ProductUpdate: + type: object + required: + - id + properties: + id: + type: integer + name: + type: string + description: + type: string + price: + type: number + format: float + category_id: + type: integer + visible: + type: boolean + + Category: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + CategoryCreate: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + + CategoryUpdate: + type: object + required: + - id + properties: + id: + type: integer + name: + type: string + description: + type: string + + ModuleActionLog: + type: object + properties: + id: + type: integer + description: Log ID + module: + type: string + description: The module name + action: + type: string + description: The action name + status_code: + type: integer + description: HTTP status code + data: + type: object + description: The action data (JSON decoded) + created_at: + type: string + format: date-time + description: Log creation timestamp + + Booking: + type: object + properties: + id: + type: integer + customer_id: + type: integer + department_id: + type: integer + booking_time: + type: string + format: date-time + status: + type: string + created_at: + type: string + format: date-time + + BookingUpdate: + type: object + required: + - id + properties: + id: + type: integer + status: + type: string + booking_time: + type: string + format: date-time + + Vehicle: + type: object + properties: + id: + type: integer + user_id: + type: integer + description: Internal user ID owning the customer account + reg: + type: string + description: Vehicle registration number + customer_id: + type: integer + customer_name: + type: string + type: + type: integer + description: Product ID representing the vehicle wash type + reference: + type: string + nullable: true + description: Optional external reference/label + wash_subscription: + type: boolean + barred: + type: boolean + description: True if the associated customer is barred + addons: + type: object + properties: + enabled: { type: integer } + available: { type: integer } + list: + type: array + items: + type: object + last_order_id: + type: integer + nullable: true + xlvask: + type: object + nullable: true + description: XL Vask vehicle data when available + vehicle_types: + type: array + items: + type: object + created_at: + type: string + format: date-time + + Notification: + type: object + properties: + id: + type: integer + user_id: + type: integer + title: + type: string + message: + type: string + read: + type: boolean + created_at: + type: string + format: date-time + + NotificationCreate: + type: object + required: + - user_id + - title + - message + properties: + user_id: + type: integer + title: + type: string + message: + type: string + + Permission: + type: object + properties: + name: + type: string + description: Permission identifier + description: + type: string + description: Human-readable description + + GoalsCriteria: + type: object + description: Goal evaluation criteria + properties: + type: + type: string + description: Criteria type + enum: [PRODUCT, REVENUE, VISITS, NONE] + example: PRODUCT + target: + type: number + description: Target value for the goal + example: 100 + target_duration: + type: string + nullable: true + description: | + Optional advanced target duration mode. + Accepted values: ENTIRE_DURATION, WEEKS, MONTHS, YEARS. + When omitted, legacy target behavior is preserved for backward compatibility. + The canonical field name is snake_case `target_duration`. + For backward-compatibility the API also accepts camelCase `targetDuration` on input. + enum: [ENTIRE_DURATION, WEEKS, MONTHS, YEARS] + example: WEEKS + target_duration_every: + type: integer + nullable: true + minimum: 1 + description: | + Optional cadence value used with `target_duration` WEEKS, MONTHS, or YEARS. + Example: with `target_duration=WEEKS` and `target_duration_every=2`, + the target applies every second week. + Ignored when `target_duration=ENTIRE_DURATION`. + The canonical field name is snake_case `target_duration_every`. + For backward-compatibility the API also accepts camelCase `targetDurationEvery` on input. + example: 1 + label: + type: string + description: Optional short label/title for this goal criteria (max 255 characters) + example: Q1 Revenue Goal + start: + type: string + format: date-time + description: Start of the evaluation window (ISO 8601) + end: + type: string + format: date-time + description: End of the evaluation window (ISO 8601) + users: + type: array + description: List of user customer numbers included in the criteria + items: { type: integer } + departments: + type: array + description: List of department IDs included in the criteria + items: { type: integer } + products: + type: array + description: List of product IDs included in the criteria + items: { type: integer } + progress_alert_frequency: + type: string + description: | + Frequency of progress alerts for the goal. + Accepted values: DAILY, WEEKLY, MONTHLY, CHANGED, NONE. + The canonical field name is snake_case `progress_alert_frequency`. + For backward-compatibility the API also accepts camelCase `progressAlertFrequency` on input. + enum: [DAILY, WEEKLY, MONTHLY, CHANGED, NONE] + example: DAILY + progress_alert_destination: + type: string + description: | + Destination/channel where progress alerts should be delivered. + Accepted values: SLACK, EMAIL, SMS, NONE. + The canonical field name is snake_case `progress_alert_destination`. + For backward-compatibility the API also accepts camelCase `progressAlertDestination` on input. + enum: [SLACK, EMAIL, SMS, NONE] + example: NONE + progress_alert_progress_type: + type: string + description: | + What part of the progress should be included in alert messages. + Accepted values: ALL, PERCENTAGE_ONLY, COUNT_ONLY, COUNT_AND_TARGET, NONE. + The canonical field name is snake_case `progress_alert_progress_type`. + For backward-compatibility the API also accepts camelCase `progressAlertProgressType` on input. + enum: [ALL, PERCENTAGE_ONLY, COUNT_ONLY, COUNT_AND_TARGET, NONE] + example: ALL + progress_alert_style: + type: string + description: | + Presentation style of the alert. + Accepted values: DEPARTMENT_COMPARE, COLLECTIVE, SINGLE_DEPARTMENT, NONE. + The canonical field name is snake_case `progress_alert_style`. + For backward-compatibility the API also accepts camelCase `progressAlertStyle` on input. + enum: [DEPARTMENT_COMPARE, COLLECTIVE, SINGLE_DEPARTMENT, NONE] + example: NONE + progress_alert_format: + type: string + nullable: true + description: | + Optional custom template for the alert body. Supports tokens `{label}`, `{percent}`, `{count}`, `{target}`, `{timeframe}`, `{departments}`, `{prefix}`, `{body}`. + Max length depends on destination: 160 characters for SMS; 1024 characters for EMAIL/SLACK/other. + The canonical field name is snake_case `progress_alert_format`. + For backward-compatibility the API also accepts camelCase `progressAlertFormat` on input. + progress_alert_weekdays: + type: array + description: | + Weekdays on which progress alerts should be sent. + Use one or more of: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY. + The canonical field name is snake_case `progress_alert_weekdays`. + For backward-compatibility the API also accepts camelCase `progressAlertWeekdays` on input. + items: + type: string + enum: [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY] + example: [MONDAY, WEDNESDAY, FRIDAY] + progress_alert_time_of_day: + type: string + nullable: true + description: | + Time of day (with timezone) when progress alerts should be sent. + Format: `HH:MMZ` or `HH:MM±HH:MM` (24-hour clock with UTC offset). Examples: `14:30Z`, `09:15+02:00`, `18:45-05:00`. + The canonical field name is snake_case `progress_alert_time_of_day`. + For backward-compatibility the API also accepts camelCase `progressAlertTimeOfDay` on input. + pattern: '^([01]\d|2[0-3]):[0-5]\d(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$' + example: "14:30+02:00" + department_daily_targets: + type: object + description: | + Optional per-department custom daily targets. Keys are department IDs and values are non-negative numbers representing the target per operating day for that department. + If omitted, the daily target is split evenly across selected departments. The canonical field name is snake_case `department_daily_targets`. + For backward-compatibility the API also accepts camelCase `departmentDailyTargets` on input. + x-additionalPropertiesName: department_id + additionalProperties: + type: number + minimum: 0 + example: + "12": 3 + "15": 5 + + GoalProgressDetails: + type: object + properties: + count: + type: number + description: Current progress value + target: + type: number + description: Target value for the period + date_from: + type: string + nullable: true + description: Inclusive period start datetime (ISO-8601), null when no lower bound applies + example: "2026-01-01T00:00:00+00:00" + date_end: + type: string + nullable: true + description: Inclusive period end datetime (ISO-8601), null when no upper bound applies + example: "2026-02-26T23:59:59+00:00" + + DepartmentGoalProgress: + type: object + title: Department progress details + description: | + Goal progress details for a single department across multiple timeframes. + Timeframes are clamped to the goal timeframe (never before goal start and never after goal end). + properties: + all: + $ref: '#/components/schemas/GoalProgressDetails' + today: + $ref: '#/components/schemas/GoalProgressDetails' + week: + $ref: '#/components/schemas/GoalProgressDetails' + month: + $ref: '#/components/schemas/GoalProgressDetails' + year: + $ref: '#/components/schemas/GoalProgressDetails' + to_date: + $ref: '#/components/schemas/GoalProgressDetails' + + DepartmentGoal: + type: object + properties: + id: + type: integer + created_by: + type: integer + description: ID of the user who created the goal + departments: + type: array + items: { type: integer } + criteria: + $ref: '#/components/schemas/GoalsCriteria' + progress: + type: object + description: | + Goal progress details for various timeframes. + `year` starts at January 1 of the current year or the goal start, whichever is later. + `to_date` starts at the goal start and ends at today (also clamped by goal end). + properties: + all: + $ref: '#/components/schemas/GoalProgressDetails' + today: + $ref: '#/components/schemas/GoalProgressDetails' + week: + $ref: '#/components/schemas/GoalProgressDetails' + month: + $ref: '#/components/schemas/GoalProgressDetails' + year: + $ref: '#/components/schemas/GoalProgressDetails' + to_date: + $ref: '#/components/schemas/GoalProgressDetails' + departmental_distribution: + type: object + description: | + Progress details broken down by department. Keys are department IDs. + Includes `all`, `today`, `week`, `month`, `year`, and `to_date` timeframes. + x-additionalPropertiesName: department_id + additionalProperties: + $ref: '#/components/schemas/DepartmentGoalProgress' + example: + "12": + all: + count: 15 + target: 100 + today: + count: 2 + target: 5 + week: + count: 10 + target: 35 + month: + count: 15 + target: 100 + year: + count: 15 + target: 100 + to_date: + count: 15 + target: 100 + created_at: + type: string + description: Creation timestamp + updated_at: + type: string + description: Update timestamp + + DepartmentGoalCreate: + type: object + required: [departments, criteria] + properties: + departments: + type: array + items: { type: integer } + criteria: + $ref: '#/components/schemas/GoalsCriteria' + + DepartmentGoalUpdate: + type: object + required: [id] + properties: + id: + type: integer + departments: + type: array + items: { type: integer } + criteria: + $ref: '#/components/schemas/GoalsCriteria' + Passkey: + type: object + properties: + id: + type: integer + credential_id: + type: string + description: Base64URL-encoded credential ID + name: + type: string + nullable: true + algorithm: + type: string + example: ES256 + transports: + type: array + items: + type: string + example: ["usb", "nfc", "ble", "internal"] + sign_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + PasskeyCreateRequest: + type: object + required: [credential_id, public_key, algorithm, transports] + properties: + credential_id: + type: string + description: Base64URL-encoded credential ID returned from WebAuthn + public_key: + type: string + description: Base64URL-encoded public key (COSE or PEM as stored) + algorithm: + type: string + example: ES256 + transports: + type: array + items: + type: string + name: + type: string + nullable: true + PasskeyRenameRequest: + type: object + required: [name] + properties: + name: + type: string + + BirdVoiceCall: + type: object + properties: + id: + type: string + format: uuid + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + workspaceId: + type: string + format: uuid + example: "3d5fae4f-9c2d-41aa-9840-28b18e6a94bc" + channelId: + type: string + format: uuid + example: "a2545e48-fe8c-5741-9bdc-42a081076bc9" + callFlowId: + type: string + format: uuid + nullable: true + originator: + type: object + additionalProperties: true + receiver: + type: object + additionalProperties: true + from: + type: string + example: "+4532330288" + to: + type: string + example: "+4542331128" + direction: + type: string + example: "outgoing" + status: + type: string + example: "completed" + type: + type: string + example: "pstn" + duration: + type: integer + example: 3 + hangupCauseCode: + type: integer + nullable: true + hangupSource: + type: string + nullable: true + sipInsights: + type: object + additionalProperties: true + qualityInsights: + type: object + additionalProperties: true + price: + type: object + additionalProperties: true + createdAt: { type: string, format: date-time, nullable: true } + updatedAt: { type: string, format: date-time, nullable: true } + ringingAt: { type: string, format: date-time, nullable: true } + answeredAt: { type: string, format: date-time, nullable: true } + endedAt: { type: string, format: date-time, nullable: true } + + BirdVoiceCallCommandCondition: + type: object + properties: + variable: { type: string } + operator: { type: string } + value: { type: string } + + BirdVoiceCallCommandResult: + type: object + properties: + id: + type: string + format: uuid + callId: + type: string + format: uuid + callFlowId: + type: string + format: uuid + nullable: true + status: + type: string + command: + type: string + conditions: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCallCommandCondition' + + BirdVoiceCallBridgeResult: + allOf: + - $ref: '#/components/schemas/BirdVoiceCallCommandResult' + - type: object + properties: + bridgeCallId: + type: string + format: uuid + nullable: true + + BirdVoiceCallRecording: + type: object + properties: + id: + type: string + format: uuid + callId: + type: string + format: uuid + status: + type: string + example: ongoing + duration: + type: integer + nullable: true + stereo: + type: boolean + nullable: true + mediaUrl: + type: string + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + + BirdVoiceCallInsights: + type: object + description: Voice call insights payload as returned by Bird. + additionalProperties: true + + BirdFlashCall: + type: object + properties: + id: + type: string + format: uuid + workspaceId: + type: string + format: uuid + nullable: true + channelId: + type: string + format: uuid + nullable: true + from: + type: string + nullable: true + to: + type: string + nullable: true + receivedCli: + type: string + nullable: true + result: + type: string + nullable: true + status: + type: string + nullable: true + duration: + type: integer + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + + BirdVoiceCallListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + example: "WzE3NzIxNTY5NTI0MDUsIjk5ZDU4M2VkLTQyMzAtNDExNy1hOTQ0LTllY2JjNzhmYWJlMSJd" + results: + type: array + items: { $ref: '#/components/schemas/BirdVoiceCall' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: { $ref: '#/components/schemas/BirdVoiceCall' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallCommandResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallCommandResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallBridgeResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallBridgeResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallRecordingListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCallRecording' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallRecordingSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallRecording' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallInsightsResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallInsights' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallsLogResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdTestOutboundCallRequest: + type: object + additionalProperties: false + properties: + from: + type: string + description: Caller E.164 number to use for the test call + example: "+4599988877" + to: + type: string + description: Target E.164 number. Defaults to configured test number if omitted. + example: "+4542331128" + timeout: + type: integer + minimum: 1 + description: Backward-compatible alias mapped to ringTimeout + ringTimeout: + type: integer + minimum: 3 + maximum: 120 + pollIntervalSeconds: + type: integer + minimum: 1 + description: Poll interval while waiting for accepted status + example: 2 + maxPollSeconds: + type: integer + minimum: 5 + description: Max time to wait before timing out + example: 30 + hangupCause: + type: string + enum: [rejected, busy] + description: Optional hangup cause passed through to Bird + + BirdTestOutboundCallResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + to: { type: string, example: "+45 42 33 11 28" } + to_e164: { type: string, example: "+4542331128" } + call_id: { type: string, nullable: true, example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" } + final_status: { type: string, nullable: true, example: "completed" } + hangup_sent: { type: boolean, example: true } + created_call: { $ref: '#/components/schemas/BirdVoiceCall' } + last_call_snapshot: { $ref: '#/components/schemas/BirdVoiceCall' } + hangup_response: { $ref: '#/components/schemas/BirdVoiceCallCommandResult' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdInboundCallWebhookRequest: + type: object + additionalProperties: true + properties: + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + workspaceId: + type: string + format: uuid + channelId: + type: string + format: uuid + payload: + type: object + additionalProperties: true + properties: + endKey: + type: string + example: "#" + retries: + type: integer + example: 3 + timeout: + type: integer + example: 30 + say: + type: object + additionalProperties: true + properties: + locale: + type: string + example: "en-US" + voice: + type: string + example: "female" + request: + type: object + additionalProperties: true + properties: + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + workspaceId: + type: string + format: uuid + channelId: + type: string + format: uuid + waitConditions: + type: object + additionalProperties: true + properties: + timeout: + type: string + example: "PT10M" + events: + type: array + items: + type: object + additionalProperties: true + properties: + action: + type: string + example: "continue" + name: + type: string + example: "call_command_gather_finished" + event: + type: object + additionalProperties: true + result: + type: object + additionalProperties: true + resumeData: + type: object + additionalProperties: true + dtmf: + type: string + description: DTMF value when present, for example `1`, `1#`, or `10#` + example: "10#" + digit: + type: string + description: Alternate DTMF field, also accepts values such as `10#` + example: "10#" + digits: + type: string + description: Alternate DTMF field + example: "10#" + keys: + type: string + description: Alternate DTMF field returned by gather results + example: "10#" + key: + type: string + example: "1" + input: + oneOf: + - type: string + - type: object + additionalProperties: true + conditions: + type: array + items: + type: object + additionalProperties: true + + BirdInboundCallWebhookResponse: + type: object + oneOf: + - $ref: '#/components/schemas/BirdInboundCallWebhookFlowGatherResponse' + - $ref: '#/components/schemas/BirdInboundCallWebhookActionResultResponse' + - $ref: '#/components/schemas/BirdInboundCallWebhookGatherAcceptedResponse' + - $ref: '#/components/schemas/BirdInboundCallWebhookTransportErrorResponse' + + BirdInboundCallWebhookFlowGatherResponse: + type: object + properties: + requestId: + type: string + example: "request-123" + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + status: + type: string + enum: [gather] + completed: + type: boolean + enum: [false] + stage: + type: string + enum: [department_select, gate_type_select] + prompt: + type: string + gather: + type: object + properties: + input: + type: string + enum: [dtmf] + maxNumKeys: + type: integer + endKey: + type: string + example: "#" + timeout: + type: integer + retries: + type: integer + say: + type: object + properties: + locale: + type: string + example: "en-US" + voice: + type: string + example: "female" + text: + type: string + selection: + type: object + properties: + departmentId: + type: integer + nullable: true + departmentName: + type: string + nullable: true + gateType: + type: string + nullable: true + enum: [entrance, exit] + gateId: + type: integer + nullable: true + invalidSelectionCount: + type: integer + resumed: + type: boolean + statusCode: + type: integer + enum: [200] + statusText: + type: string + enum: [OK] + + BirdInboundCallWebhookActionResultResponse: + type: object + properties: + requestId: + type: string + example: "request-123" + result: + type: object + properties: + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + status: + type: string + enum: [completed, failed, ignored] + action: + type: string + enum: [gate_opened, gate_open_failed, no_action, ignored] + message: + type: string + departmentId: + type: integer + nullable: true + gateType: + type: string + nullable: true + enum: [entrance, exit] + gateId: + type: integer + nullable: true + gateOpened: + type: boolean + resumeData: + type: object + additionalProperties: true + properties: + action: + type: string + example: "continue" + completed: + type: boolean + example: true + result: + type: string + example: "gate_opened" + gateOpened: + type: boolean + example: true + completedAt: + type: string + format: date-time + statusCode: + type: integer + enum: [200] + statusText: + type: string + enum: [OK] + + BirdInboundCallWebhookGatherAcceptedResponse: + type: object + properties: + event: + type: object + additionalProperties: true + requestId: + type: string + example: "request-123" + result: + type: object + properties: + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + command: + type: string + enum: [gather] + id: + type: string + status: + type: string + example: "accepted" + resumeData: + type: object + properties: + action: + type: string + example: "continue" + resumedAt: + type: string + format: date-time + nullable: true + suspendedAt: + type: string + format: date-time + statusCode: + type: integer + enum: [202] + statusText: + type: string + enum: [Accepted] + + BirdInboundCallWebhookTransportErrorResponse: + type: object + properties: + requestId: + type: string + statusCode: + type: integer + enum: [400, 500] + statusText: + type: string + enum: [Bad Request, Internal Server Error] + error: + type: object + properties: + message: + type: string + + BirdVoiceCallCreateRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + maxDuration: { type: integer, minimum: 1 } + sendKeys: { type: string } + record: { type: boolean } + recordStart: { type: string, enum: [record-from-answer, record-from-ringing] } + flowStart: { type: string, enum: [from-answer, from-ringing] } + stereo: { type: boolean } + callFlow: + type: array + items: + type: object + additionalProperties: true + scheduledFor: { type: string, format: date-time } + notification: + type: object + additionalProperties: false + properties: + url: { type: string } + amdSettings: + type: object + additionalProperties: true + tags: + type: array + items: { type: string } + + BirdVoiceCallUpdateRequest: + type: object + additionalProperties: false + properties: + status: + type: string + enum: [completed] + callFlow: + type: array + items: + type: object + additionalProperties: true + + BirdVoiceCallAnswerRequest: + type: object + additionalProperties: false + properties: {} + + BirdVoiceCallRingingRequest: + type: object + additionalProperties: false + properties: {} + + BirdVoiceCallHangupRequest: + type: object + additionalProperties: false + properties: + cause: + type: string + enum: [rejected, busy] + + BirdVoiceCallPlaybackRequest: + type: object + additionalProperties: false + required: [media] + properties: + media: + type: array + minItems: 1 + items: { type: string } + loop: { type: integer, minimum: 0 } + timeout: { type: integer, minimum: 0 } + pauseMilliseconds: { type: integer, minimum: 0, maximum: 30000 } + + BirdVoiceCallSayRequest: + type: object + additionalProperties: false + required: [text] + properties: + text: { type: string } + locale: { type: string } + voice: { type: string } + loop: { type: integer, minimum: 0 } + timeout: { type: integer, minimum: 0 } + hangup: { type: boolean } + + BirdVoiceCallGatherRequest: + type: object + additionalProperties: false + properties: + maxNumKeys: { type: integer, minimum: 1 } + endKey: { type: string, enum: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] } + timeout: { type: integer, minimum: 0 } + retries: { type: integer, minimum: 0 } + input: { type: string, enum: [dtmf, speech, 'dtmf speech'] } + speechLocale: { type: string } + playback: + $ref: '#/components/schemas/BirdVoiceCallPlaybackRequest' + say: + $ref: '#/components/schemas/BirdVoiceCallSayRequest' + + BirdVoiceCallBridgeRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + maxDuration: { type: integer, minimum: 1 } + ringTone: { type: string } + hangupAfterBridge: { type: boolean } + record: { type: boolean } + recordStart: { type: string, enum: [record-from-answer, record-from-ringing] } + recordStereo: { type: boolean } + callFlow: + type: array + items: + type: object + additionalProperties: true + notification: + type: object + additionalProperties: false + properties: + url: { type: string } + amdSettings: + type: object + additionalProperties: true + + BirdVoiceCallRecordRequest: + type: object + additionalProperties: false + properties: + endKey: { type: string, enum: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] } + maxLength: { type: integer, minimum: 1 } + timeout: { type: integer, minimum: 0 } + beep: { type: boolean } + transcribe: { type: boolean } + transcribeLocale: { type: string } + + BirdVoiceCallRecordingCreateRequest: + type: object + additionalProperties: false + properties: + maxLength: { type: integer, minimum: 1 } + stereo: { type: boolean } + + BirdVoiceCallRecordingUpdateRequest: + type: object + additionalProperties: false + required: [status] + properties: + status: + type: string + enum: [paused, ongoing, completed] + + BirdFlashCallCreateRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + + BirdFlashCallEndRequest: + type: object + additionalProperties: false + required: [result] + properties: + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + + BirdFlashCallHangupRequest: + oneOf: + - type: object + additionalProperties: false + required: [result] + properties: + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + - type: object + additionalProperties: false + required: [from, to] + properties: + from: { type: string } + to: { type: string } + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + + BirdFlashCallListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdFlashCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdFlashCallSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdFlashCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdFlashCallHangupResult: + type: object + properties: + id: + type: string + format: uuid + nullable: true + result: + type: string + nullable: true + receivedCli: + type: string + nullable: true + from: + type: string + nullable: true + to: + type: string + nullable: true + + BirdFlashCallHangupResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdFlashCallHangupResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdNumber: + type: object + properties: + id: + type: string + example: "019c73dc-60f1-76c4-98b0-c4318706b938" + workspaceId: + type: string + example: "3d5fae4f-9c2d-41aa-9840-28b18e6a94bc" + type: + type: string + example: "national" + country: + type: string + example: "DK" + number: + type: string + example: "+4532330288" + status: + type: string + example: "active" + capabilities: + type: object + properties: + voice: { $ref: '#/components/schemas/BirdNumberCapability' } + sms: { $ref: '#/components/schemas/BirdNumberCapability' } + mms: { $ref: '#/components/schemas/BirdNumberCapability' } + fax: { $ref: '#/components/schemas/BirdNumberCapability' } + whatsapp: { $ref: '#/components/schemas/BirdNumberCapability' } + monthlyRecurringPrice: + type: object + properties: + currencyCode: { type: string, example: "EUR" } + amount: { type: integer, example: 1000000 } + exponent: { type: integer, example: -6 } + complianceRequirements: + type: array + items: + type: object + additionalProperties: true + configurations: + type: object + additionalProperties: true + createdAt: { type: string, format: date-time, example: "2026-02-19T03:05:48.529Z" } + updatedAt: { type: string, format: date-time, example: "2026-02-19T03:08:17.753Z" } + activatedAt: { type: string, format: date-time, nullable: true, example: "2026-02-19T03:05:48.529Z" } + deactivatedAt: { type: string, format: date-time, nullable: true } + deactivatesAt: { type: string, format: date-time, nullable: true } + subscription: + type: object + additionalProperties: true + endpointSubscription: + type: object + additionalProperties: true + requirements: + type: array + items: + type: object + additionalProperties: true + whatsApp: + type: object + additionalProperties: true + endpoint: + type: object + additionalProperties: true + + BirdNumberCapability: + type: object + properties: + inbound: { type: boolean } + outbound: { type: boolean } + + BirdNumberListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + results: + type: array + items: { $ref: '#/components/schemas/BirdNumber' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdNumberSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: { $ref: '#/components/schemas/BirdNumber' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + DepartmentDailyReportOutsideHoursBreakdown: + type: object + properties: + orders: { type: integer } + xlvask: { type: integer } + selfserve: { type: integer } + + DepartmentDailyReportOutsideHoursSummary: + type: object + properties: + department_ids: + type: array + items: { type: integer } + date: { type: string } + date_to: { type: string } + total: { type: integer } + by_source: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursBreakdown' + has_missing_opening_hours: { type: boolean } + missing_department_ids: + type: array + items: { type: integer } + + DepartmentDailyReportMetric: + type: object + properties: + state: { type: string } + value: + type: number + nullable: true + out_of: + type: number + nullable: true + message: + type: string + nullable: true + by_source: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursBreakdown' + has_missing_opening_hours: { type: boolean } + missing_department_ids: + type: array + items: { type: integer } + + DepartmentDailyReportComplaintCreateRequest: + type: object + required: [department_id, wash_date, category, description] + properties: + department_id: { type: integer } + customer_number: + type: integer + nullable: true + wash_date: + type: string + format: date + category: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCategory' + description: + type: string + minLength: 1 + maxLength: 4000 + + DepartmentDailyReportComplaintUpdateRequest: + type: object + required: [id] + properties: + id: { type: integer } + department_id: { type: integer } + customer_number: + type: integer + nullable: true + wash_date: + type: string + format: date + category: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCategory' + description: + type: string + minLength: 1 + maxLength: 4000 + + DepartmentDailyReportComplaintCategory: + type: string + enum: + - wash_quality + - wash_price + - damage_paint + - damage_mirrors + - damage_cables_electronics + - damage_plastic_parts + - damage_other + - service + - other + + DepartmentDailyReportComplaintCustomerSearchResult: + type: object + properties: + customer_number: + type: integer + customer_name: + type: string + nullable: true + + DepartmentDailyReportComplaint: + type: object + properties: + id: { type: integer } + department_id: { type: integer } + department_name: + type: string + nullable: true + customer_number: + type: integer + nullable: true + customer_name: + type: string + nullable: true + wash_date: + type: string + format: date + nullable: true + category: + allOf: + - $ref: '#/components/schemas/DepartmentDailyReportComplaintCategory' + nullable: true + description: { type: string } + created_by: { type: integer } + created_by_name: + type: string + nullable: true + created_at: + type: string + format: date-time + + DepartmentDailyReportComplaintResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/DepartmentDailyReportComplaint' + + DepartmentDailyReportComplaintCollectionResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + oneOf: + - $ref: '#/components/schemas/DepartmentDailyReportComplaint' + - type: array + items: + $ref: '#/components/schemas/DepartmentDailyReportComplaint' + + DepartmentDailyReportComplaintCustomerSearchResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: array + items: + $ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResult' + + DepartmentDailyReportComplaintDeleteResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + message: { type: string } + + DepartmentDailyReportProductTile: + type: object + properties: + product_id: { type: integer } + slug: { type: string } + title: { type: string } + state: { type: string } + value: { type: integer } + out_of: { type: integer } + + DepartmentDailyReportOverviewPayload: + type: object + properties: + department_ids: + type: array + items: { type: integer } + date: { type: string } + date_to: { type: string } + metrics: + type: object + additionalProperties: + $ref: '#/components/schemas/DepartmentDailyReportMetric' + products: + type: array + items: + $ref: '#/components/schemas/DepartmentDailyReportProductTile' + + DepartmentDailyReportOverviewResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/DepartmentDailyReportOverviewPayload' + + DepartmentDailyReportTransactionCountPayload: + type: object + properties: + quantity: { type: integer } + products: { type: integer } + earnings: { type: integer } + washes: { type: integer } + water_usage: { type: integer } + date: { type: string } + date_to: { type: string } + department_id: { type: integer } + outside_hours: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursSummary' + + DepartmentDailyReportTransactionCountResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/DepartmentDailyReportTransactionCountPayload' + + DepartmentDailyReportOutsideHoursTrendPoint: + type: object + properties: + date: { type: string } + total: { type: integer } + by_source: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursBreakdown' + has_missing_opening_hours: { type: boolean } + missing_department_ids: + type: array + items: { type: integer } + + DepartmentDailyReportOutsideHoursTrendPayload: + type: object + properties: + department_ids: + type: array + items: { type: integer } + date: { type: string } + date_to: { type: string } + points: + type: array + items: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPoint' + has_missing_opening_hours: { type: boolean } + missing_department_ids: + type: array + items: { type: integer } + + DepartmentDailyReportOutsideHoursTrendResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload' diff --git a/services/nginx/app/phpunit.xml b/services/nginx/app/phpunit.xml index 76ae970c..dd363826 100644 --- a/services/nginx/app/phpunit.xml +++ b/services/nginx/app/phpunit.xml @@ -11,6 +11,12 @@ tests/Integration + + tests/Api + + + tests/Legacy + diff --git a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater new file mode 100644 index 00000000..b85149bc --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater @@ -0,0 +1,13 @@ +ARG BASE_IMAGE=php:8.2-cli-bookworm +FROM ${BASE_IMAGE} + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \ + docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \ + rm -rf /var/lib/apt/lists/* + +COPY auto-updater.php /usr/local/bin/auto-updater.php + +ENTRYPOINT ["php", "/usr/local/bin/auto-updater.php"] diff --git a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent new file mode 100644 index 00000000..ab09f969 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent @@ -0,0 +1,16 @@ +ARG BASE_IMAGE=php:8.2-cli-bookworm +FROM ${BASE_IMAGE} + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \ + docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/truckwash-edge-agent + +COPY agent.php /opt/truckwash-edge-agent/agent.php + +ENTRYPOINT ["php", "/opt/truckwash-edge-agent/agent.php"] +CMD ["--config", "/config/config.json"] diff --git a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker new file mode 100644 index 00000000..e7e916d4 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker @@ -0,0 +1,15 @@ +ARG BASE_IMAGE=php:8.2-cli-bookworm +FROM ${BASE_IMAGE} + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \ + docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/truckwash-edge-agent + +COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php + +CMD ["php", "-S", "0.0.0.0:8090", "/opt/truckwash-edge-agent/lan-worker.php"] diff --git a/services/nginx/app/resources/edge-gateway-agent/agent.php b/services/nginx/app/resources/edge-gateway-agent/agent.php new file mode 100644 index 00000000..136c613a --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/agent.php @@ -0,0 +1,2924 @@ +#!/usr/bin/env php +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 HttpRequestTimeoutException extends RuntimeException +{ +} + +final class OperationAbortException extends RuntimeException +{ + public function __construct(string $message, private readonly bool $cancelled = false) + { + parent::__construct($message); + } + + public function isCancellation(): bool + { + return $this->cancelled; + } +} + +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); + $errno = curl_errno($ch); + $error = curl_error($ch); + curl_close($ch); + + if ($raw === false) { + $message = sprintf('%s %s failed: %s', $method, $url, $error !== '' ? $error : 'unknown curl error'); + if ($errno === CURLE_OPERATION_TIMEDOUT) { + throw new HttpRequestTimeoutException($message); + } + + throw new RuntimeException($message); + } + + 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 +{ + /** @var callable|null */ + private $sink = null; + + public function __construct(private readonly string $logFile) + { + } + + public function setSink(?callable $sink): void + { + $this->sink = $sink; + } + + 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); + if ($this->sink !== null) { + try { + call_user_func($this->sink, $level, $message); + } catch (Throwable) { + // Logging must never break the agent loop. + } + } + } +} + +final class LocalStateStore +{ + private SQLite3 $db; + + public function __construct(string $path) + { + if (!class_exists('SQLite3')) { + throw new RuntimeException('The edge-agent container requires sqlite3 support.'); + } + + $directory = dirname($path); + if (!is_dir($directory)) { + @mkdir($directory, 0777, true); + } + + $this->db = new SQLite3($path); + $this->db->busyTimeout(5000); + $this->db->exec('PRAGMA journal_mode = WAL;'); + $this->db->exec('PRAGMA synchronous = NORMAL;'); + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS kv ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL + )' + ); + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_type TEXT NOT NULL, + endpoint TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + )' + ); + } + + public function getJson(string $key, mixed $default = null): mixed + { + $statement = $this->db->prepare('SELECT value_json FROM kv WHERE key = :key LIMIT 1'); + $statement->bindValue(':key', $key, SQLITE3_TEXT); + $result = $statement->execute(); + $row = $result instanceof SQLite3Result ? $result->fetchArray(SQLITE3_ASSOC) : false; + if (!is_array($row) || !array_key_exists('value_json', $row)) { + return $default; + } + + $decoded = json_decode((string)$row['value_json'], true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : $default; + } + + public function setJson(string $key, mixed $value): void + { + $statement = $this->db->prepare( + 'INSERT INTO kv (key, value_json, updated_at) + VALUES (:key, :value_json, :updated_at) + ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at' + ); + $statement->bindValue(':key', $key, SQLITE3_TEXT); + $statement->bindValue(':value_json', json_encode($value, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $statement->bindValue(':updated_at', date('c'), SQLITE3_TEXT); + $statement->execute(); + } + + public function enqueue(string $type, string $endpoint, array $payload): void + { + $statement = $this->db->prepare( + 'INSERT INTO outbox (item_type, endpoint, payload_json, created_at) + VALUES (:item_type, :endpoint, :payload_json, :created_at)' + ); + $statement->bindValue(':item_type', $type, SQLITE3_TEXT); + $statement->bindValue(':endpoint', $endpoint, SQLITE3_TEXT); + $statement->bindValue(':payload_json', json_encode($payload, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $statement->bindValue(':created_at', date('c'), SQLITE3_TEXT); + $statement->execute(); + } + + /** + * @return array> + */ + public function queuedItems(int $limit = 25): array + { + $statement = $this->db->prepare( + 'SELECT id, item_type, endpoint, payload_json, created_at + FROM outbox + ORDER BY id ASC + LIMIT :limit' + ); + $statement->bindValue(':limit', max(1, $limit), SQLITE3_INTEGER); + $result = $statement->execute(); + $items = []; + if (!$result instanceof SQLite3Result) { + return $items; + } + + while (($row = $result->fetchArray(SQLITE3_ASSOC)) !== false) { + $items[] = [ + 'id' => (int)$row['id'], + 'type' => (string)$row['item_type'], + 'endpoint' => (string)$row['endpoint'], + 'payload' => json_decode((string)$row['payload_json'], true) ?: [], + 'created_at' => (string)$row['created_at'], + ]; + } + + return $items; + } + + public function removeOutboxItem(int $id): void + { + $statement = $this->db->prepare('DELETE FROM outbox WHERE id = :id'); + $statement->bindValue(':id', $id, SQLITE3_INTEGER); + $statement->execute(); + } + + public function outboxSummary(): array + { + $countResult = $this->db->querySingle('SELECT COUNT(*) FROM outbox'); + $oldestResult = $this->db->querySingle('SELECT created_at FROM outbox ORDER BY id ASC LIMIT 1'); + $lastSyncAt = $this->getJson('last_sync_at'); + + return [ + 'queued' => (int)$countResult, + 'oldest_queued_at' => is_string($oldestResult) && $oldestResult !== '' ? $oldestResult : null, + 'last_replayed_at' => is_string($lastSyncAt) && $lastSyncAt !== '' ? $lastSyncAt : null, + ]; + } +} + +final class BrokerWebSocketClient +{ + private const CONNECT_TIMEOUT_SECONDS = 15; + private const RECONNECT_DELAY_SECONDS = 2; + + /** @var resource|null */ + private $socket = null; + private ?string $brokerUrl = null; + private ?int $gatewayId = null; + private ?string $agentToken = null; + private ?string $agentInstanceId = null; + private string $readBuffer = ''; + private int $reconnectAfterEpoch = 0; + private array $state = [ + 'url' => null, + 'connected' => false, + 'lastError' => null, + 'disconnectReason' => null, + 'lastConnectedAt' => null, + 'lastDisconnectedAt' => null, + ]; + + public function __construct(private readonly Logger $logger) + { + } + + public function configure(?string $brokerUrl, ?int $gatewayId, ?string $agentToken, ?string $agentInstanceId): void + { + $normalizedUrl = $this->normalizeBrokerBaseUrl($brokerUrl); + $gatewayId = $gatewayId !== null && $gatewayId > 0 ? $gatewayId : null; + $agentToken = trim((string)($agentToken ?? '')) !== '' ? trim((string)$agentToken) : null; + $agentInstanceId = trim((string)($agentInstanceId ?? '')) !== '' ? trim((string)$agentInstanceId) : null; + + $didChange = $this->brokerUrl !== $normalizedUrl + || $this->gatewayId !== $gatewayId + || $this->agentToken !== $agentToken + || $this->agentInstanceId !== $agentInstanceId; + + $this->brokerUrl = $normalizedUrl; + $this->gatewayId = $gatewayId; + $this->agentToken = $agentToken; + $this->agentInstanceId = $agentInstanceId; + $this->state['url'] = $normalizedUrl; + + if ($didChange && $this->socket !== null) { + $this->disconnect('config_changed'); + } + } + + public function isConnected(): bool + { + return is_resource($this->socket) && ($this->state['connected'] ?? false) === true; + } + + public function state(): array + { + return $this->state; + } + + public function ensureConnected(): bool + { + if ($this->isConnected()) { + return true; + } + + if ($this->brokerUrl === null || $this->gatewayId === null || $this->agentToken === null) { + return false; + } + + if (time() < $this->reconnectAfterEpoch) { + return false; + } + + try { + $this->connect(); + return true; + } catch (Throwable $throwable) { + $this->state['connected'] = false; + $this->state['lastError'] = $throwable->getMessage(); + $this->state['disconnectReason'] = 'connect_failed'; + $this->state['lastDisconnectedAt'] = date('c'); + $this->reconnectAfterEpoch = time() + self::RECONNECT_DELAY_SECONDS; + return false; + } + } + + public function disconnect(string $reason = 'closed'): void + { + if (is_resource($this->socket)) { + @fclose($this->socket); + } + + $this->socket = null; + $this->readBuffer = ''; + $this->state['connected'] = false; + $this->state['disconnectReason'] = $reason; + $this->state['lastDisconnectedAt'] = date('c'); + $this->reconnectAfterEpoch = time() + self::RECONNECT_DELAY_SECONDS; + } + + public function send(array $message): bool + { + if (!$this->ensureConnected()) { + return false; + } + + try { + $this->writeFrame(json_encode($message, JSON_UNESCAPED_SLASHES)); + return true; + } catch (Throwable $throwable) { + $this->state['lastError'] = $throwable->getMessage(); + $this->disconnect('send_failed'); + return false; + } + } + + /** + * @return array> + */ + public function readMessages(int $maxMessages = 16): array + { + if (!$this->ensureConnected()) { + return []; + } + + try { + $this->readAvailableBytes(); + } catch (Throwable $throwable) { + $this->state['lastError'] = $throwable->getMessage(); + $this->disconnect('read_failed'); + return []; + } + + $messages = []; + for ($index = 0; $index < max(1, $maxMessages); $index++) { + $frame = $this->extractFrame(); + if ($frame === null) { + break; + } + + $opcode = (int)($frame['opcode'] ?? 0x1); + $payload = (string)($frame['payload'] ?? ''); + + if ($opcode === 0x8) { + $this->disconnect('server_closed'); + break; + } + + if ($opcode === 0x9) { + try { + $this->writeFrame($payload, 0xA); + } catch (Throwable) { + $this->disconnect('pong_failed'); + } + continue; + } + + if ($opcode !== 0x1 || trim($payload) === '') { + continue; + } + + $decoded = json_decode($payload, true); + if (is_array($decoded)) { + $messages[] = $decoded; + } + } + + return $messages; + } + + private function connect(): void + { + $parts = $this->buildSocketParts(); + $transport = ($parts['scheme'] ?? 'ws') === 'wss' ? 'ssl' : 'tcp'; + $host = (string)$parts['host']; + $port = (int)$parts['port']; + $path = (string)$parts['path']; + + $socket = @stream_socket_client( + sprintf('%s://%s:%d', $transport, $host, $port), + $errno, + $error, + self::CONNECT_TIMEOUT_SECONDS, + STREAM_CLIENT_CONNECT + ); + if (!is_resource($socket)) { + throw new RuntimeException(sprintf( + 'Unable to connect to broker %s:%d: %s', + $host, + $port, + trim((string)$error) !== '' ? trim((string)$error) : 'connection refused' + )); + } + + stream_set_timeout($socket, self::CONNECT_TIMEOUT_SECONDS); + stream_set_blocking($socket, true); + + $key = base64_encode(random_bytes(16)); + $request = implode("\r\n", [ + 'GET ' . $path . ' HTTP/1.1', + 'Host: ' . $host . ($this->isDefaultPort($parts) ? '' : ':' . $port), + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Version: 13', + 'Sec-WebSocket-Key: ' . $key, + "\r\n", + ]); + + $written = fwrite($socket, $request); + if ($written === false || $written < strlen($request)) { + fclose($socket); + throw new RuntimeException('Unable to write broker handshake request'); + } + + $response = ''; + $startedAt = microtime(true); + while (!str_contains($response, "\r\n\r\n")) { + $chunk = fread($socket, 2048); + if ($chunk === false || $chunk === '') { + if ((microtime(true) - $startedAt) >= self::CONNECT_TIMEOUT_SECONDS) { + fclose($socket); + throw new RuntimeException('Timed out while reading broker handshake response'); + } + usleep(25000); + continue; + } + + $response .= $chunk; + } + + if (!preg_match('/\AHTTP\/1\.[01]\s+101\b/i', $response)) { + fclose($socket); + throw new RuntimeException('Broker rejected websocket upgrade: ' . trim(strtok($response, "\r\n"))); + } + + $expectedAccept = base64_encode( + sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true) + ); + if (!preg_match('/Sec-WebSocket-Accept:\s*(.+)\r\n/i', $response, $matches)) { + fclose($socket); + throw new RuntimeException('Broker handshake missing Sec-WebSocket-Accept header'); + } + + if (!hash_equals($expectedAccept, trim((string)$matches[1]))) { + fclose($socket); + throw new RuntimeException('Broker handshake validation failed'); + } + + stream_set_blocking($socket, false); + stream_set_timeout($socket, 0); + $this->socket = $socket; + $this->readBuffer = ''; + $this->reconnectAfterEpoch = 0; + $this->state['connected'] = true; + $this->state['lastError'] = null; + $this->state['disconnectReason'] = null; + $this->state['lastConnectedAt'] = date('c'); + } + + private function readAvailableBytes(): void + { + if (!is_resource($this->socket)) { + return; + } + + while (true) { + $chunk = @fread($this->socket, 8192); + if ($chunk === false) { + throw new RuntimeException('Unable to read from broker socket'); + } + + if ($chunk === '') { + if (feof($this->socket)) { + $this->disconnect('server_eof'); + } + break; + } + + $this->readBuffer .= $chunk; + if (strlen($chunk) < 8192) { + break; + } + } + } + + private function extractFrame(): ?array + { + $length = strlen($this->readBuffer); + if ($length < 2) { + return null; + } + + $first = ord($this->readBuffer[0]); + $second = ord($this->readBuffer[1]); + $opcode = $first & 0x0F; + $masked = ($second & 0x80) === 0x80; + $payloadLength = $second & 0x7F; + $offset = 2; + + if ($payloadLength === 126) { + if ($length < 4) { + return null; + } + $payloadLength = unpack('n', substr($this->readBuffer, $offset, 2))[1]; + $offset += 2; + } elseif ($payloadLength === 127) { + if ($length < 10) { + return null; + } + $parts = unpack('Nhigh/Nlow', substr($this->readBuffer, $offset, 8)); + $payloadLength = ((int)$parts['high'] << 32) | (int)$parts['low']; + $offset += 8; + } + + $maskingKey = ''; + if ($masked) { + if ($length < ($offset + 4)) { + return null; + } + $maskingKey = substr($this->readBuffer, $offset, 4); + $offset += 4; + } + + if ($length < ($offset + $payloadLength)) { + return null; + } + + $payload = substr($this->readBuffer, $offset, $payloadLength); + $this->readBuffer = (string)substr($this->readBuffer, $offset + $payloadLength); + + if ($masked) { + $payload = $this->unmaskPayload($payload, $maskingKey); + } + + return [ + 'opcode' => $opcode, + 'payload' => $payload, + ]; + } + + private function writeFrame(string $payload, int $opcode = 0x1): void + { + if (!is_resource($this->socket)) { + throw new RuntimeException('Broker socket is not connected'); + } + + $frame = chr(0x80 | ($opcode & 0x0F)); + $length = strlen($payload); + if ($length < 126) { + $frame .= chr(0x80 | $length); + } elseif ($length <= 0xFFFF) { + $frame .= chr(0x80 | 126) . pack('n', $length); + } else { + $frame .= chr(0x80 | 127) . pack('NN', 0, $length); + } + + $mask = random_bytes(4); + $frame .= $mask . $this->maskPayload($payload, $mask); + + $written = @fwrite($this->socket, $frame); + if ($written === false || $written < strlen($frame)) { + throw new RuntimeException('Unable to write websocket frame to broker'); + } + } + + private function buildSocketParts(): array + { + $url = $this->brokerUrl; + if ($url === null || $this->gatewayId === null || $this->agentToken === null) { + throw new RuntimeException('Broker websocket is not configured'); + } + + $query = http_build_query([ + 'gatewayId' => $this->gatewayId, + 'token' => $this->agentToken, + 'agentInstanceId' => $this->agentInstanceId, + ]); + $socketUrl = rtrim($url, '/') . '/ws/agent?' . $query; + $parts = parse_url($socketUrl); + if (!is_array($parts) || empty($parts['host'])) { + throw new RuntimeException('Invalid broker websocket URL: ' . $socketUrl); + } + + return [ + 'scheme' => strtolower((string)($parts['scheme'] ?? 'ws')), + 'host' => (string)$parts['host'], + 'port' => (int)($parts['port'] ?? (((string)($parts['scheme'] ?? 'ws')) === 'wss' ? 443 : 80)), + 'path' => (string)($parts['path'] ?? '/') + . (isset($parts['query']) && trim((string)$parts['query']) !== '' ? '?' . $parts['query'] : ''), + ]; + } + + private function isDefaultPort(array $parts): bool + { + $scheme = (string)($parts['scheme'] ?? 'ws'); + $port = (int)($parts['port'] ?? 0); + return ($scheme === 'wss' && $port === 443) || ($scheme !== 'wss' && $port === 80); + } + + private function normalizeBrokerBaseUrl(?string $value): ?string + { + $trimmed = rtrim(trim((string)($value ?? '')), '/'); + if ($trimmed === '') { + return null; + } + + if (str_starts_with($trimmed, 'ws://') || str_starts_with($trimmed, 'wss://')) { + return $trimmed; + } + + if (str_starts_with($trimmed, 'https://')) { + return 'wss://' . substr($trimmed, 8); + } + + if (str_starts_with($trimmed, 'http://')) { + return 'ws://' . substr($trimmed, 7); + } + + return 'ws://' . $trimmed; + } + + private function maskPayload(string $payload, string $mask): string + { + $length = strlen($payload); + $result = ''; + for ($index = 0; $index < $length; $index++) { + $result .= chr(ord($payload[$index]) ^ ord($mask[$index % 4])); + } + return $result; + } + + private function unmaskPayload(string $payload, string $mask): string + { + return $this->maskPayload($payload, $mask); + } +} + +final class AgentShellBridge +{ + /** @var array> */ + private array $sessions = []; + + public function __construct(private readonly string $defaultCwd) + { + } + + public function open(array $payload, callable $send): void + { + $sessionId = trim((string)($payload['sessionId'] ?? '')); + if ($sessionId === '') { + return; + } + + $this->close(['sessionId' => $sessionId], $send); + + $cwd = trim((string)($payload['cwd'] ?? $this->defaultCwd)); + if ($cwd === '' || !is_dir($cwd)) { + $cwd = $this->defaultCwd; + } + + [$command, $args] = $this->defaultShellCommand( + trim((string)($payload['shellCommand'] ?? '')), + isset($payload['shellArgs']) && is_array($payload['shellArgs']) ? (array)$payload['shellArgs'] : [] + ); + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = @proc_open( + array_merge([$command], $args), + $descriptorSpec, + $pipes, + $cwd, + array_merge($_ENV, ['TERM' => 'xterm-256color']) + ); + + if (!is_resource($process)) { + $send([ + 'type' => 'SHELL_OUTPUT', + 'sessionId' => $sessionId, + 'data' => "Failed to start root shell.\r\n", + ]); + $send([ + 'type' => 'SHELL_EXIT', + 'sessionId' => $sessionId, + 'code' => 1, + ]); + return; + } + + foreach ($pipes as $pipe) { + stream_set_blocking($pipe, false); + } + + $this->sessions[$sessionId] = [ + 'process' => $process, + 'pipes' => $pipes, + ]; + + $send([ + 'type' => 'SHELL_OPENED', + 'sessionId' => $sessionId, + ]); + $send([ + 'type' => 'SHELL_OUTPUT', + 'sessionId' => $sessionId, + 'data' => sprintf( + "Connected to gateway host shell.\r\nWorking directory: %s\r\nShortcuts: docker ps | systemctl status truckwash-edge-gateway-stack.service | ./gateway-launcher.sh reconcile\r\n\r\n", + $cwd + ), + ]); + } + + public function input(array $payload): void + { + $sessionId = trim((string)($payload['sessionId'] ?? '')); + if ($sessionId === '' || !isset($this->sessions[$sessionId])) { + return; + } + + $stdin = $this->sessions[$sessionId]['pipes'][0] ?? null; + if (is_resource($stdin)) { + @fwrite($stdin, (string)($payload['data'] ?? '')); + } + } + + public function resize(array $payload): void + { + $sessionId = trim((string)($payload['sessionId'] ?? '')); + if ($sessionId === '' || !isset($this->sessions[$sessionId])) { + return; + } + + $this->sessions[$sessionId]['cols'] = (int)($payload['cols'] ?? 0); + $this->sessions[$sessionId]['rows'] = (int)($payload['rows'] ?? 0); + } + + public function close(array $payload, callable $send): void + { + $sessionId = trim((string)($payload['sessionId'] ?? '')); + if ($sessionId === '' || !isset($this->sessions[$sessionId])) { + return; + } + + $session = $this->sessions[$sessionId]; + $process = $session['process'] ?? null; + if (is_resource($process)) { + @proc_terminate($process); + } + + $this->finalizeSession($sessionId, $send); + } + + public function pump(callable $send): void + { + foreach (array_keys($this->sessions) as $sessionId) { + $session = $this->sessions[$sessionId]; + $stdout = $session['pipes'][1] ?? null; + $stderr = $session['pipes'][2] ?? null; + + foreach ([$stdout, $stderr] as $pipe) { + if (!is_resource($pipe)) { + continue; + } + + $chunk = stream_get_contents($pipe); + if ($chunk !== false && $chunk !== '') { + $send([ + 'type' => 'SHELL_OUTPUT', + 'sessionId' => $sessionId, + 'data' => $chunk, + ]); + } + } + + $process = $session['process'] ?? null; + if (!is_resource($process)) { + $this->finalizeSession($sessionId, $send); + continue; + } + + $status = proc_get_status($process); + if (!is_array($status) || ($status['running'] ?? false) === true) { + continue; + } + + $this->finalizeSession($sessionId, $send, (int)($status['exitcode'] ?? 0)); + } + } + + public function dispose(callable $send): void + { + foreach (array_keys($this->sessions) as $sessionId) { + $this->close(['sessionId' => $sessionId], $send); + } + } + + /** + * @return array{0:string,1:array} + */ + private function defaultShellCommand(string $command, array $args): array + { + if ($command !== '') { + return [$command, array_values(array_map('strval', $args))]; + } + + if (DIRECTORY_SEPARATOR === '\\') { + return ['cmd.exe', ['/Q']]; + } + + return ['/bin/bash', ['-l']]; + } + + private function finalizeSession(string $sessionId, callable $send, int $exitCode = 0): void + { + if (!isset($this->sessions[$sessionId])) { + return; + } + + $session = $this->sessions[$sessionId]; + foreach ((array)($session['pipes'] ?? []) as $pipe) { + if (is_resource($pipe)) { + @fclose($pipe); + } + } + + $process = $session['process'] ?? null; + if (is_resource($process)) { + $status = proc_get_status($process); + $exitCode = is_array($status) && isset($status['exitcode']) ? (int)$status['exitcode'] : $exitCode; + @proc_close($process); + } + + unset($this->sessions[$sessionId]); + $send([ + 'type' => 'SHELL_EXIT', + 'sessionId' => $sessionId, + 'code' => $exitCode >= 0 ? $exitCode : 0, + ]); + } +} + +final class TruckwashEdgeAgent +{ + private const DEFAULT_INSTALL_DIR = '/opt/truckwash-edge-agent'; + private const DEFAULT_RUNTIME_DIR = '/opt/truckwash-edge-agent/runtime'; + private const DEFAULT_STATE_DATABASE = '/opt/truckwash-edge-agent/runtime/gateway-state.sqlite'; + private const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090'; + private const DEFAULT_UPDATE_WINDOW = '02:00-04:00'; + private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120; + private const BROKER_MESSAGE_PUMP_LIMIT = 12; + private const LOOP_STALE_AFTER_SECONDS = 30; + private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90; + + private AgentConfig $config; + private HttpJsonClient $http; + private HttpJsonClient $workerHttp; + private Logger $logger; + private LocalStateStore $stateStore; + private BrokerWebSocketClient $brokerClient; + private AgentShellBridge $shellBridge; + private string $installDir; + private string $runtimeDir; + private string $statePath; + private string $lastOperationSnapshotPath; + private string $lastHeartbeatMarkerPath; + private string $controlPlaneStatusPath; + private string $stagedUpdatePath; + private int $lastHeartbeatAt = 0; + private int $lastMachineSignalPollAt = 0; + private int $lastMachineSignalMonitorRefreshAt = 0; + private ?array $lastControlPlaneResponse = null; + private string $agentInstanceId; + + public function __construct(string $configPath) + { + $this->config = AgentConfig::load($configPath); + $this->installDir = rtrim((string)$this->config->get('installDir', self::DEFAULT_INSTALL_DIR), DIRECTORY_SEPARATOR); + $this->runtimeDir = rtrim((string)$this->config->get('runtimeDir', self::DEFAULT_RUNTIME_DIR), DIRECTORY_SEPARATOR); + if (!is_dir($this->runtimeDir)) { + @mkdir($this->runtimeDir, 0777, true); + } + + $this->http = new HttpJsonClient((string)$this->config->get('apiUrl')); + $this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL)); + $this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log'); + $this->stateStore = new LocalStateStore((string)$this->config->get('stateDatabasePath', self::DEFAULT_STATE_DATABASE)); + $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->controlPlaneStatusPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'control-plane-status.json'; + $this->stagedUpdatePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json'; + $this->agentInstanceId = $this->ensureAgentInstanceId(); + $this->brokerClient = new BrokerWebSocketClient($this->logger); + $this->shellBridge = new AgentShellBridge($this->installDir); + $this->logger->setSink(fn(string $level, string $message): bool => $this->emitLogFrame($level, $message)); + $this->configureBrokerClient(); + $this->initializeControlPlaneStatus(); + } + + public function run(): void + { + $this->logger->info('Truckwash compose edge-agent starting.'); + $this->recoverPreviousOperationState(); + + while (true) { + try { + $this->touchLoopHeartbeat(); + $this->reloadConfigFromDisk(); + $this->ensureClaimed(); + $this->configureBrokerClient(); + $this->flushOutbox(); + $this->pumpBrokerTransport(); + $this->heartbeat(); + $this->pollMachineStartSignals(); + if ($this->resumePendingOperationCompletion()) { + $this->pumpBrokerTransport(); + continue; + } + $processedManagementOperation = false; + if (!$this->isBrokerConnected()) { + $processedManagementOperation = $this->processManagementOperation(); + } + if (!$processedManagementOperation && !$this->isBrokerConnected()) { + $this->processCommandQueue(); + } + $this->pumpBrokerTransport(); + $this->flushOutbox(); + } 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; + } + + $installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2'); + try { + $response = $this->http->post('/edge-agent/claim', [ + 'token' => (string)$this->config->get('installToken'), + 'hostname' => gethostname() ?: 'truckwash-edge', + 'installed_version' => $installedVersion, + 'metadata' => [ + 'runtime' => 'compose-php', + 'runtime_mode' => 'compose', + 'php_version' => PHP_VERSION, + 'agent_instance_id' => $this->agentInstanceId, + 'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW), + 'container_health' => $this->buildContainerHealth(), + 'outbox_status' => $this->buildOutboxStatus(), + 'last_sync_at' => $this->currentLastSuccessfulSyncAt(), + 'control_plane_status' => $this->buildControlPlaneStatusPayload(), + 'rollback_status' => $this->readRollbackStatus(), + 'staged_version' => $this->currentStagedUpdate(), + ], + ]); + } catch (Throwable $throwable) { + $this->recordTransportFailure('Gateway claim failed', $throwable); + throw $throwable; + } + + $payload = $response['data'] ?? []; + $gateway = is_array($payload['gateway'] ?? null) ? (array)$payload['gateway'] : []; + $this->config->set('gatewayId', $gateway['id'] ?? null); + $this->config->set('agentToken', $payload['agent_token'] ?? null); + if (!empty($payload['broker_url'])) { + $this->config->set('brokerUrl', (string)$payload['broker_url']); + } + $this->config->set('installedVersion', $installedVersion); + $this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion)); + $this->config->set('agentInstanceId', $this->agentInstanceId); + $this->config->save(); + $this->recordSuccessfulSync(); + $this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.'); + } + + private function configureBrokerClient(): void + { + $this->brokerClient->configure( + $this->config->get('brokerUrl'), + (int)$this->config->get('gatewayId', 0), + $this->config->get('agentToken'), + $this->agentInstanceId + ); + } + + private function isBrokerConnected(): bool + { + return $this->brokerClient->isConnected(); + } + + private function pumpBrokerTransport(): void + { + $this->configureBrokerClient(); + $this->brokerClient->ensureConnected(); + $messages = $this->brokerClient->readMessages(self::BROKER_MESSAGE_PUMP_LIMIT); + foreach ($messages as $message) { + $this->handleBrokerMessage($message); + } + + $this->shellBridge->pump(fn(array $message): bool => $this->sendBrokerMessage($message)); + } + + private function sendBrokerMessage(array $message): bool + { + $sent = $this->brokerClient->send($message); + if ($sent) { + $this->recordSuccessfulSync(); + } + return $sent; + } + + private function handleBrokerMessage(array $message): void + { + $type = strtoupper(trim((string)($message['type'] ?? ''))); + if ($type === '') { + return; + } + + switch ($type) { + case 'COMMAND': + $this->processBrokerCommand($message); + return; + + case 'TASK_DISPATCH': + $taskType = strtoupper(trim((string)($message['taskType'] ?? ''))); + if ($taskType === 'OPERATION' && isset($message['operation']) && is_array($message['operation'])) { + $this->executeManagementOperation((array)$message['operation']); + } + return; + + case 'TASK_CANCEL': + $taskType = strtoupper(trim((string)($message['taskType'] ?? ''))); + if ($taskType === 'OPERATION') { + $operation = isset($message['operation']) && is_array($message['operation']) ? (array)$message['operation'] : []; + $operationId = (int)($operation['id'] ?? 0); + if ($operationId > 0) { + $this->markOperationCancellationRequested($operationId); + $this->logger->warning('Cancellation requested for operation ' . $operationId . ' via broker.'); + } + } + return; + + case 'OPEN_ROOT_SHELL': + $this->shellBridge->open( + isset($message['payload']) && is_array($message['payload']) ? (array)$message['payload'] : [], + fn(array $payload): bool => $this->sendBrokerMessage($payload) + ); + return; + + case 'SHELL_INPUT': + $this->shellBridge->input( + isset($message['payload']) && is_array($message['payload']) ? (array)$message['payload'] : [] + ); + return; + + case 'RESIZE_ROOT_SHELL': + $this->shellBridge->resize( + isset($message['payload']) && is_array($message['payload']) ? (array)$message['payload'] : [] + ); + return; + + case 'CLOSE_ROOT_SHELL': + $this->shellBridge->close( + isset($message['payload']) && is_array($message['payload']) ? (array)$message['payload'] : [], + fn(array $payload): bool => $this->sendBrokerMessage($payload) + ); + return; + + default: + return; + } + } + + 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; + } + + $this->recordHeartbeatAttempt(); + $operationState = $this->readOperationState(); + $payload = [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'status' => 'ONLINE', + 'hostname' => gethostname() ?: 'truckwash-edge', + 'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'), + 'target_version' => (string)$this->config->get( + 'targetVersion', + $this->config->get('installedVersion', 'compose-php-agent-v2') + ), + 'metadata' => array_merge([ + 'agent_instance_id' => $this->agentInstanceId, + 'runtime' => 'compose-php', + 'runtime_mode' => 'compose', + 'current_operation' => $operationState, + 'system_metrics' => $this->buildSystemMetrics(), + 'container_health' => $this->buildContainerHealth(), + 'outbox_status' => $this->buildOutboxStatus(), + 'last_sync_at' => $this->currentLastSuccessfulSyncAt(), + 'control_plane_status' => $this->buildControlPlaneStatusPayload(), + 'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW), + 'staged_version' => $this->currentStagedUpdate(), + 'rollback_status' => $this->readRollbackStatus(), + 'command_transport' => $this->isBrokerConnected() ? 'BROKER_WS' : 'API_POLLING', + 'broker_connected' => $this->isBrokerConnected(), + 'broker_url' => $this->brokerClient->state()['url'] ?? $this->config->get('brokerUrl'), + 'broker_last_error' => $this->brokerClient->state()['lastError'] ?? null, + 'broker_last_connected_at' => $this->brokerClient->state()['lastConnectedAt'] ?? null, + 'broker_last_disconnected_at' => $this->brokerClient->state()['lastDisconnectedAt'] ?? null, + 'broker_disconnect_reason' => $this->brokerClient->state()['disconnectReason'] ?? null, + ], $metadata), + ]; + + $posted = $this->sendControlPlaneEvent( + '/edge-agent/gateways/' . $gatewayId . '/heartbeat', + $payload, + 'heartbeat' + ); + if (!$posted) { + return; + } + + $heartbeatSucceededAt = date('c'); + $this->applyBrokerUrlFromControlPlaneResponse($this->lastControlPlaneResponse); + $this->lastHeartbeatAt = time(); + $this->recordSuccessfulSync($heartbeatSucceededAt, [ + 'last_heartbeat_success_at' => $heartbeatSucceededAt, + ]); + file_put_contents($this->lastHeartbeatMarkerPath, json_encode([ + 'gateway_id' => $gatewayId, + 'at' => $heartbeatSucceededAt, + 'agent_instance_id' => $this->agentInstanceId, + ], JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + private function pollMachineStartSignals(): void + { + $interval = max(1, (int)$this->config->get('machineSignalPollIntervalSeconds', 2)); + if ((time() - $this->lastMachineSignalPollAt) < $interval) { + return; + } + $this->lastMachineSignalPollAt = time(); + + $gatewayId = (int)$this->config->get('gatewayId'); + $agentToken = (string)$this->config->get('agentToken'); + if ($gatewayId <= 0 || trim($agentToken) === '') { + return; + } + + foreach ($this->machineSignalMonitors($gatewayId, $agentToken) as $monitor) { + $localIp = trim((string)($monitor['local_ip'] ?? '')); + if ($localIp === '') { + continue; + } + + try { + $component = strtolower(trim((string)($monitor['component'] ?? 'input'))) === 'switch' ? 'switch' : 'input'; + $channel = (int)($monitor['channel'] ?? 0); + $status = $this->machineSignalMonitorStatus($localIp, $channel, $component); + $on = $this->machineSignalOnState($status, $component); + if ($on === null) { + continue; + } + + $stateKey = sprintf( + 'selfserve_machine_signal:%s:%s:%d', + preg_replace('/[^A-Za-z0-9_\-:.]+/', '_', (string)($monitor['relay_id'] ?? 'relay')), + $component, + $channel + ); + $previous = $this->stateStore->getJson($stateKey, null); + $previousOn = is_array($previous) && array_key_exists('on', $previous) ? (bool)$previous['on'] : false; + + if ($on && !$previousOn) { + $event = $component === 'switch' ? 'switch.on' : 'input.toggle_on'; + $payload = [ + 'agent_token' => $agentToken, + 'agent_instance_id' => $this->agentInstanceId, + 'lane_id' => (int)($monitor['lane_id'] ?? 0), + 'relay_id' => (string)($monitor['relay_id'] ?? ''), + 'device_id' => (string)($monitor['device_id'] ?? ''), + 'component' => $component, + 'channel' => $channel, + 'event' => $event, + 'source' => 'edge_gateway_poll', + 'status' => $status, + ]; + $payload[$component === 'switch' ? 'output' : 'state'] = true; + + $this->sendControlPlaneEvent( + '/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal', + $payload, + 'machine_signal' + ); + } + + $this->stateStore->setJson($stateKey, [ + 'on' => $on, + 'component' => $component, + 'channel' => $channel, + 'relay_id' => (string)($monitor['relay_id'] ?? ''), + 'updated_at' => date('c'), + ]); + } catch (Throwable $throwable) { + $this->logger->warning('Machine start signal poll failed: ' . $throwable->getMessage()); + } + } + } + + /** + * @return array> + */ + private function machineSignalMonitors(int $gatewayId, string $agentToken): array + { + $cache = $this->stateStore->getJson('selfserve_machine_signal_monitors', []); + if ( + is_array($cache) + && isset($cache['monitors'], $cache['refreshed_at']) + && is_array($cache['monitors']) + && (time() - (int)$cache['refreshed_at']) < 60 + ) { + return array_values(array_filter($cache['monitors'], 'is_array')); + } + + try { + $response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal-bindings', [ + 'agent_token' => $agentToken, + 'agent_instance_id' => $this->agentInstanceId, + ], 10); + $payload = is_array($response['data'] ?? null) ? (array)$response['data'] : (is_array($response) ? $response : []); + $monitors = is_array($payload['monitors'] ?? null) ? array_values(array_filter((array)$payload['monitors'], 'is_array')) : []; + $this->lastMachineSignalMonitorRefreshAt = time(); + $this->stateStore->setJson('selfserve_machine_signal_monitors', [ + 'refreshed_at' => $this->lastMachineSignalMonitorRefreshAt, + 'monitors' => $monitors, + ]); + + return $monitors; + } catch (Throwable $throwable) { + if (is_array($cache) && isset($cache['monitors']) && is_array($cache['monitors'])) { + return array_values(array_filter($cache['monitors'], 'is_array')); + } + $this->logger->warning('Machine start signal monitor refresh failed: ' . $throwable->getMessage()); + return []; + } + } + + private function machineSignalMonitorStatus(string $localIp, int $channel, string $component): array + { + if ($component === 'input') { + try { + $input = $this->workerHttp->post('/relay/input-status', [ + 'local_ip' => $localIp, + 'channel' => $channel, + ], 5) ?? []; + return [ + 'input_state' => $input['state'] ?? null, + 'input' => $input, + ]; + } catch (Throwable) { + // Fall back to the combined status endpoint below. + } + } + + return $this->workerHttp->post('/relay/status', [ + 'local_ip' => $localIp, + 'channel' => $channel, + 'include_input' => $component === 'input', + ], 8) ?? []; + } + + private function machineSignalOnState(array $status, string $component): ?bool + { + if ($component === 'switch') { + if (array_key_exists('output', $status)) { + return (bool)$status['output']; + } + if (array_key_exists('on', $status)) { + return (bool)$status['on']; + } + if (isset($status['raw']) && is_array($status['raw']) && array_key_exists('output', $status['raw'])) { + return (bool)$status['raw']['output']; + } + + return null; + } + + if (array_key_exists('input_state', $status)) { + return $status['input_state'] === null ? null : (bool)$status['input_state']; + } + if (isset($status['input']) && is_array($status['input']) && array_key_exists('state', $status['input'])) { + return (bool)$status['input']['state']; + } + if (array_key_exists('state', $status)) { + return (bool)$status['state']; + } + + return null; + } + + private function processCommandQueue(): void + { + $gatewayId = (int)$this->config->get('gatewayId'); + if ($gatewayId <= 0) { + return; + } + + $waitSeconds = (int)$this->config->get('operationPollTimeoutSeconds', 20); + try { + $response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'wait_seconds' => $waitSeconds, + ], $this->pollRequestTimeoutSeconds($waitSeconds)); + } catch (HttpRequestTimeoutException) { + return; + } catch (Throwable $throwable) { + $this->logger->warning('Command polling failed: ' . $throwable->getMessage()); + return; + } + + $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 = $this->executeEdgeCommand($type, $request); + + $this->sendControlPlaneEvent( + '/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', + [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'ok' => true, + 'result' => $result, + ], + 'command_result' + ); + } catch (Throwable $throwable) { + $this->sendControlPlaneEvent( + '/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', + [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'ok' => false, + 'error' => $throwable->getMessage(), + ], + 'command_result' + ); + } + } + + private function processBrokerCommand(array $message): void + { + $commandId = trim((string)($message['commandId'] ?? '')); + $type = (string)($message['commandType'] ?? ''); + $request = isset($message['payload']) && is_array($message['payload']) ? (array)$message['payload'] : []; + if ($commandId === '') { + return; + } + + try { + $result = $this->executeEdgeCommand($type, $request); + $this->sendBrokerMessage([ + 'type' => 'COMMAND_RESULT', + 'commandId' => $commandId, + 'ok' => true, + 'payload' => $result, + ]); + } catch (Throwable $throwable) { + $this->sendBrokerMessage([ + 'type' => 'COMMAND_RESULT', + 'commandId' => $commandId, + 'ok' => false, + 'error' => $throwable->getMessage(), + ]); + } + } + + private function executeEdgeCommand(string $type, array $request): array + { + return match (strtoupper(trim($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), + }; + } + + private function processManagementOperation(): bool + { + $gatewayId = (int)$this->config->get('gatewayId'); + if ($gatewayId <= 0) { + return false; + } + + $waitSeconds = (int)$this->config->get('operationPollTimeoutSeconds', 20); + try { + $response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/next', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'wait_seconds' => $waitSeconds, + 'agent_instance_id' => $this->agentInstanceId, + ], $this->pollRequestTimeoutSeconds($waitSeconds)); + } catch (HttpRequestTimeoutException) { + return false; + } catch (Throwable $throwable) { + $this->logger->warning('Operation polling failed: ' . $throwable->getMessage()); + return false; + } + + $operation = $response['data'] ?? null; + if (!is_array($operation) || empty($operation['id'])) { + return false; + } + + return $this->executeManagementOperation((array)$operation); + } + + private function executeManagementOperation(array $operation): bool + { + $gatewayId = (int)$this->config->get('gatewayId'); + if ($gatewayId <= 0) { + return false; + } + + $operationId = (int)($operation['id'] ?? 0); + $type = (string)($operation['type'] ?? ''); + $request = is_array($operation['request'] ?? null) ? (array)$operation['request'] : []; + if ($operationId <= 0 || trim($type) === '') { + return false; + } + + $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', + 'Compose 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->abortIfOperationCancelled($operationId); + + $this->finalizeOperationCompletion( + $gatewayId, + $operationId, + [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'ok' => true, + 'result' => $result, + ], + 'COMPLETED', + ['result' => $result] + ); + } catch (Throwable $throwable) { + $errorCode = $throwable instanceof OperationAbortException && $throwable->isCancellation() + ? 'EDGE_GATEWAY_CANCELLED' + : $this->classifyManagementError($throwable, $type); + if (!($throwable instanceof OperationAbortException)) { + $this->postOperationEvent($gatewayId, $operationId, [ + 'level' => 'ERROR', + 'code' => $errorCode, + 'message' => $throwable->getMessage(), + 'context' => ['type' => $type, 'progress' => 100, 'agent_instance_id' => $this->agentInstanceId], + ]); + } + + $this->finalizeOperationCompletion( + $gatewayId, + $operationId, + [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'ok' => false, + 'error_code' => $errorCode, + 'error_message' => $throwable->getMessage(), + ], + 'FAILED', + [ + 'error_code' => $errorCode, + 'error_message' => $throwable->getMessage(), + ] + ); + } + + $this->clearOperationCancellationRequested($operationId); + return true; + } + + private function postOperationEvent(int $gatewayId, int $operationId, array $payload): ?array + { + return $this->requestControlPlaneEvent( + '/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'] : [], + ], + 'operation_event' + ); + } + + private function emitOperationProgress( + int $gatewayId, + int $operationId, + string $code, + string $message, + int $progress, + array $context = [], + ?string $stage = null + ): void { + $this->abortIfOperationCancelled($operationId); + + $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(), + ]); + + $response = $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, + ]), + ]); + $this->guardOperationEventResponse($response); + } + + private function runDiscovery(): array + { + try { + $response = $this->workerHttp->post('/discover', [ + 'hostname' => gethostname() ?: 'truckwash-edge', + 'gateway_id' => (int)$this->config->get('gatewayId', 0), + ], 20); + $inventory = isset($response['inventory']) && is_array($response['inventory']) ? (array)$response['inventory'] : []; + return ['inventory' => $inventory]; + } catch (Throwable) { + $hostname = gethostname() ?: 'truckwash-edge'; + return ['inventory' => [[ + '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' => 'compose-php', + 'php_version' => PHP_VERSION, + ], + ]]]; + } + } + + private function runManagementDiscovery(int $gatewayId, int $operationId, array $request): array + { + $this->emitOperationProgress( + $gatewayId, + $operationId, + 'DISCOVERY_COLLECTING', + 'Collecting local gateway inventory from lan-worker', + 35, + [], + 'discovering' + ); + $inventory = isset($request['inventory']) && is_array($request['inventory']) && $request['inventory'] !== [] + ? (array)$request['inventory'] + : (array)($this->runDiscovery()['inventory'] ?? []); + + $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'); + } + + $updateWindow = trim((string)($request['updateWindow'] ?? $this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW))); + if ($updateWindow === '') { + $updateWindow = self::DEFAULT_UPDATE_WINDOW; + } + + $requiredArtifacts = [ + [ + 'url' => (string)($request['artifactUrl'] ?? ''), + 'sha256' => (string)($request['artifactSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'agent.php', + 'label' => 'edge-agent runtime', + 'progress' => 35, + 'code' => 'UPDATE_DOWNLOAD_AGENT', + ], + [ + 'url' => (string)($request['lanWorkerArtifactUrl'] ?? ''), + 'sha256' => (string)($request['lanWorkerArtifactSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'lan-worker.php', + 'label' => 'lan-worker runtime', + 'progress' => 45, + 'code' => 'UPDATE_DOWNLOAD_WORKER', + ], + [ + 'url' => (string)($request['autoUpdaterArtifactUrl'] ?? ''), + 'sha256' => (string)($request['autoUpdaterArtifactSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'auto-updater.php', + 'label' => 'auto-updater runtime', + 'progress' => 50, + 'code' => 'UPDATE_DOWNLOAD_AUTO_UPDATER', + ], + [ + 'url' => (string)($request['composeFileUrl'] ?? ''), + 'sha256' => (string)($request['composeFileSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'docker-compose.gateway.yml', + 'label' => 'compose stack', + 'progress' => 55, + 'code' => 'UPDATE_DOWNLOAD_COMPOSE', + ], + [ + 'url' => (string)($request['edgeAgentDockerfileUrl'] ?? ''), + 'sha256' => (string)($request['edgeAgentDockerfileSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'Dockerfile.edge-agent', + 'label' => 'edge-agent Dockerfile', + 'progress' => 60, + 'code' => 'UPDATE_DOWNLOAD_EDGE_DOCKERFILE', + ], + [ + 'url' => (string)($request['lanWorkerDockerfileUrl'] ?? ''), + 'sha256' => (string)($request['lanWorkerDockerfileSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'Dockerfile.lan-worker', + 'label' => 'lan-worker Dockerfile', + 'progress' => 65, + 'code' => 'UPDATE_DOWNLOAD_WORKER_DOCKERFILE', + ], + [ + 'url' => (string)($request['autoUpdaterDockerfileUrl'] ?? ''), + 'sha256' => (string)($request['autoUpdaterDockerfileSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'Dockerfile.auto-updater', + 'label' => 'auto-updater Dockerfile', + 'progress' => 68, + 'code' => 'UPDATE_DOWNLOAD_AUTO_UPDATER_DOCKERFILE', + ], + [ + 'url' => (string)($request['launcherScriptUrl'] ?? ''), + 'sha256' => (string)($request['launcherScriptSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'gateway-launcher.sh', + 'label' => 'gateway launcher', + 'progress' => 72, + 'code' => 'UPDATE_DOWNLOAD_LAUNCHER', + ], + [ + 'url' => (string)($request['stackServiceUnitUrl'] ?? ''), + 'sha256' => (string)($request['stackServiceUnitSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-gateway-stack.service', + 'label' => 'compose systemd unit', + 'progress' => 80, + 'code' => 'UPDATE_DOWNLOAD_STACK_SERVICE', + ], + [ + 'url' => (string)($request['serviceUnitUrl'] ?? ''), + 'sha256' => (string)($request['serviceUnitSha256'] ?? ''), + 'path' => $this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-agent.service', + 'label' => 'compatibility systemd unit', + 'progress' => 84, + 'code' => 'UPDATE_DOWNLOAD_LEGACY_SERVICE', + ], + ]; + + foreach ([ + 'composeFileUrl', + 'launcherScriptUrl', + 'stackServiceUnitUrl', + 'autoUpdaterArtifactUrl', + 'autoUpdaterDockerfileUrl', + ] as $requiredKey) { + if (trim((string)($request[$requiredKey] ?? '')) === '') { + throw new RuntimeException('Update request is missing compose artifact ' . $requiredKey); + } + } + + $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_VALIDATED', 'Validated compose update request', 20, [ + 'target_version' => $targetVersion, + 'update_window' => $updateWindow, + ], 'validating'); + + $changedFiles = []; + foreach ($requiredArtifacts as $artifact) { + if ($artifact['url'] === '') { + continue; + } + + $this->emitOperationProgress( + $gatewayId, + $operationId, + (string)$artifact['code'], + 'Downloading ' . (string)$artifact['label'], + (int)$artifact['progress'], + ['url' => (string)$artifact['url']], + 'downloading' + ); + $changedFiles[] = $this->downloadToFileAtomic( + (string)$artifact['url'], + (string)$artifact['path'], + (string)$artifact['sha256'] + ); + } + + $this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'agent.php'); + $this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'lan-worker.php'); + $this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'auto-updater.php'); + $this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'gateway-launcher.sh'); + + $stagedAt = date('c'); + $stagedUpdate = [ + 'target_version' => $targetVersion, + 'staged_at' => $stagedAt, + 'apply_after' => $this->nextUpdateWindowStartIso($updateWindow), + 'status' => 'STAGED', + 'update_window' => $updateWindow, + 'compose_project_name' => (string)($request['composeProjectName'] ?? $this->config->get('composeProjectName', 'truckwash-edge-gateway')), + 'stack_service_name' => (string)($request['stackServiceName'] ?? $this->config->get('stackServiceName', 'truckwash-edge-gateway-stack.service')), + 'changed_files' => $changedFiles, + ]; + + $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGE_METADATA', 'Writing staged update metadata', 90, [], 'writing-config'); + $this->config->set('targetVersion', $targetVersion); + $this->config->set('lastStagedUpdate', $stagedUpdate); + $this->config->save(); + $this->stateStore->setJson('staged_update', $stagedUpdate); + $this->stateStore->setJson('rollback_status', [ + 'state' => 'IDLE', + 'reason' => null, + 'rolled_back_to' => null, + 'at' => null, + ]); + file_put_contents($this->stagedUpdatePath, json_encode($stagedUpdate, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + + $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGED', 'Compose rollout staged for the maintenance window', 96, [ + 'target_version' => $targetVersion, + 'apply_after' => $stagedUpdate['apply_after'], + ], 'staged'); + + return [ + 'applied' => false, + 'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'), + 'staged_version' => $targetVersion, + 'target_version' => $targetVersion, + 'staged_at' => $stagedAt, + 'apply_after' => $stagedUpdate['apply_after'], + 'update_window' => $updateWindow, + 'restart_required' => true, + 'service_name' => (string)($request['stackServiceName'] ?? $this->config->get('stackServiceName', 'truckwash-edge-gateway-stack.service')), + 'changed_files' => $changedFiles, + 'agent_instance_id' => $this->agentInstanceId, + 'rollback_status' => $this->readRollbackStatus(), + ]; + } + + 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('stackServiceName', 'truckwash-edge-gateway-stack.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); + + try { + return $this->workerHttp->post('/relay/status', [ + 'local_ip' => $localIp, + 'channel' => $channel, + ], 8) ?? []; + } catch (Throwable) { + 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); + + try { + return $this->workerHttp->post('/relay/switch', [ + 'local_ip' => $localIp, + 'channel' => $channel, + 'on' => $on, + ], 8) ?? []; + } catch (Throwable) { + $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 buildSystemMetrics(): array + { + $diskTotal = @disk_total_space($this->installDir); + $diskFree = @disk_free_space($this->installDir); + $diskUsed = (is_numeric($diskTotal) && is_numeric($diskFree)) ? ((float)$diskTotal - (float)$diskFree) : null; + $diskUsagePct = ($diskTotal && $diskUsed !== null && $diskTotal > 0) + ? (int)round(($diskUsed / (float)$diskTotal) * 100) + : null; + $memoryLimitBytes = $this->iniBytes((string)ini_get('memory_limit')); + $memoryUsage = memory_get_usage(true); + $memoryUsagePct = ($memoryLimitBytes !== null && $memoryLimitBytes > 0) + ? (int)round(($memoryUsage / $memoryLimitBytes) * 100) + : null; + $loadAverage = function_exists('sys_getloadavg') ? sys_getloadavg() : []; + + return [ + 'memory_usage_bytes' => $memoryUsage, + 'memory_peak_bytes' => memory_get_peak_usage(true), + 'memory_usage_pct' => $memoryUsagePct, + 'cpu_usage_pct' => is_array($loadAverage) && isset($loadAverage[0]) ? max(0, (int)round((float)$loadAverage[0] * 100)) : null, + 'load_average' => $loadAverage, + 'disk_usage_pct' => $diskUsagePct, + 'disk_used_bytes' => $diskUsed, + 'disk_total_bytes' => is_numeric($diskTotal) ? (int)$diskTotal : null, + 'disk_mount' => $this->installDir, + 'latency_ms' => null, + ]; + } + + private function buildContainerHealth(): array + { + $services = [[ + 'name' => 'edge-agent', + 'status' => 'healthy', + 'updated_at' => date('c'), + ]]; + + $services[] = $this->probeWorkerHealth(); + $services[] = $this->probeTcpService('redis', 'redis', 6379); + $services[] = $this->probeTcpService('mariadb', 'mariadb', 3306); + $services[] = $this->probeHttpService('minio', 'http://minio:9000/minio/health/live'); + $services[] = $this->probeAutoUpdaterHealth(); + + $healthyCount = count(array_filter($services, static fn(array $service): bool => (string)($service['status'] ?? '') === 'healthy')); + $state = $healthyCount === count($services) ? 'ONLINE' : 'DEGRADED'; + + return [ + 'state' => $state, + 'summary' => sprintf('%d/%d containers healthy', $healthyCount, count($services)), + 'services' => $services, + ]; + } + + private function buildOutboxStatus(): array + { + $summary = $this->stateStore->outboxSummary(); + $queued = (int)($summary['queued'] ?? 0); + return [ + 'state' => $queued > 0 ? 'QUEUED' : 'IN_SYNC', + 'queued' => $queued, + 'oldest_queued_at' => $summary['oldest_queued_at'] ?? null, + 'last_replayed_at' => $summary['last_replayed_at'] ?? null, + 'summary' => $queued > 0 ? sprintf('%d outbound items queued', $queued) : 'Outbox is empty', + ]; + } + + private function initializeControlPlaneStatus(): void + { + $this->writeControlPlaneStatus(); + } + + private function touchLoopHeartbeat(): void + { + $this->writeControlPlaneStatus([ + 'last_loop_at' => date('c'), + ]); + } + + private function currentLastSuccessfulSyncAt(): ?string + { + $current = $this->readControlPlaneStatus()['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at'); + return $this->normalizeControlPlaneStatusTimestamp($current); + } + + private function recordHeartbeatAttempt(): string + { + $attemptedAt = date('c'); + $this->writeControlPlaneStatus([ + 'last_heartbeat_attempt_at' => $attemptedAt, + ]); + return $attemptedAt; + } + + private function recordSuccessfulSync(?string $at = null, array $statusOverrides = []): void + { + $syncedAt = $this->normalizeControlPlaneStatusTimestamp($at ?? date('c')) ?? date('c'); + $this->stateStore->setJson('last_sync_at', $syncedAt); + $statusOverrides['last_successful_sync_at'] = $syncedAt; + $this->writeControlPlaneStatus($statusOverrides); + } + + private function recordTransportFailure(string $context, Throwable $throwable): void + { + $message = $this->normalizeControlPlaneStatusString($throwable->getMessage()) ?? $throwable::class; + $this->writeControlPlaneStatus([ + 'last_transport_failure_at' => date('c'), + 'last_transport_error' => $message, + ]); + $this->logger->warning($context . ': ' . $message); + } + + /** + * @return array + */ + private function buildControlPlaneStatusPayload(): array + { + return $this->writeControlPlaneStatus(); + } + + /** + * @return array + */ + private function readControlPlaneStatus(): array + { + if (!is_file($this->controlPlaneStatusPath)) { + return []; + } + + $decoded = json_decode((string)file_get_contents($this->controlPlaneStatusPath), true); + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $overrides + * @return array + */ + private function writeControlPlaneStatus(array $overrides = []): array + { + $current = $this->readControlPlaneStatus(); + $outboxSummary = $this->stateStore->outboxSummary(); + $lastSuccessfulSyncAt = array_key_exists('last_successful_sync_at', $overrides) + ? $overrides['last_successful_sync_at'] + : ($current['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at')); + $lastTransportError = array_key_exists('last_transport_error', $overrides) + ? $overrides['last_transport_error'] + : ($current['last_transport_error'] ?? null); + $lastTransportFailureAt = array_key_exists('last_transport_failure_at', $overrides) + ? $overrides['last_transport_failure_at'] + : ($current['last_transport_failure_at'] ?? null); + $lastHeartbeatAttemptAt = array_key_exists('last_heartbeat_attempt_at', $overrides) + ? $overrides['last_heartbeat_attempt_at'] + : ($current['last_heartbeat_attempt_at'] ?? null); + $lastHeartbeatSuccessAt = array_key_exists('last_heartbeat_success_at', $overrides) + ? $overrides['last_heartbeat_success_at'] + : ($current['last_heartbeat_success_at'] ?? null); + $lastLoopAt = array_key_exists('last_loop_at', $overrides) + ? $overrides['last_loop_at'] + : ($current['last_loop_at'] ?? null); + + $status = [ + 'started_at' => $this->normalizeControlPlaneStatusTimestamp($current['started_at'] ?? null) ?? date('c'), + 'agent_instance_id' => $this->agentInstanceId, + 'last_loop_at' => $this->normalizeControlPlaneStatusTimestamp($lastLoopAt), + 'last_heartbeat_attempt_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatAttemptAt), + 'last_heartbeat_success_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatSuccessAt), + 'last_successful_sync_at' => $this->normalizeControlPlaneStatusTimestamp($lastSuccessfulSyncAt), + 'outbox_queued' => array_key_exists('outbox_queued', $overrides) + ? max(0, (int)$overrides['outbox_queued']) + : max(0, (int)($outboxSummary['queued'] ?? 0)), + 'broker_connected' => array_key_exists('broker_connected', $overrides) + ? (bool)$overrides['broker_connected'] + : $this->isBrokerConnected(), + 'last_transport_error' => $this->normalizeControlPlaneStatusString($lastTransportError), + 'last_transport_failure_at' => $this->normalizeControlPlaneStatusTimestamp($lastTransportFailureAt), + ]; + + file_put_contents( + $this->controlPlaneStatusPath, + json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + + return $status; + } + + private function normalizeControlPlaneStatusTimestamp(mixed $value): ?string + { + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } + + private function normalizeControlPlaneStatusString(mixed $value): ?string + { + if (is_string($value)) { + $normalized = trim($value); + return $normalized !== '' ? $normalized : null; + } + if (is_scalar($value)) { + $normalized = trim((string)$value); + return $normalized !== '' ? $normalized : null; + } + + return null; + } + + private function flushOutbox(): void + { + $items = $this->stateStore->queuedItems(25); + foreach ($items as $item) { + try { + $timeoutSeconds = (string)($item['type'] ?? '') === 'operation_complete' + ? self::OPERATION_COMPLETE_TIMEOUT_SECONDS + : 20; + $endpoint = (string)$item['endpoint']; + $payload = is_array($item['payload'] ?? null) ? (array)$item['payload'] : []; + $brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload); + if ($brokerDispatch !== true || $this->shouldPersistControlPlaneEventOverHttp($endpoint)) { + $this->http->post($endpoint, $payload, $timeoutSeconds); + } + $this->stateStore->removeOutboxItem((int)$item['id']); + $this->recordSuccessfulSync(); + } catch (Throwable $throwable) { + $this->recordTransportFailure( + 'Outbox replay blocked on ' . (string)$item['type'] . ' for ' . (string)$item['endpoint'], + $throwable + ); + break; + } + } + } + + private function probeWorkerHealth(): array + { + try { + $workerHealth = $this->workerHttp->getJson( + rtrim((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL), '/') . '/health', + 5 + ); + return [ + 'name' => 'lan-worker', + 'status' => (string)($workerHealth['status'] ?? 'healthy'), + 'updated_at' => (string)($workerHealth['timestamp'] ?? date('c')), + ]; + } catch (Throwable $throwable) { + return [ + 'name' => 'lan-worker', + 'status' => 'degraded', + 'updated_at' => date('c'), + 'error' => $throwable->getMessage(), + ]; + } + } + + private function probeHttpService(string $name, string $url): array + { + try { + $this->http->download($url, 5); + return [ + 'name' => $name, + 'status' => 'healthy', + 'updated_at' => date('c'), + ]; + } catch (Throwable $throwable) { + return [ + 'name' => $name, + 'status' => 'degraded', + 'updated_at' => date('c'), + 'error' => $throwable->getMessage(), + ]; + } + } + + private function probeTcpService(string $name, string $host, int $port): array + { + $socket = @fsockopen($host, $port, $errno, $error, 3); + if (is_resource($socket)) { + fclose($socket); + return [ + 'name' => $name, + 'status' => 'healthy', + 'updated_at' => date('c'), + ]; + } + + return [ + 'name' => $name, + 'status' => 'degraded', + 'updated_at' => date('c'), + 'error' => trim((string)$error) !== '' ? trim((string)$error) : 'TCP connection failed', + 'code' => $errno > 0 ? $errno : null, + ]; + } + + private function probeAutoUpdaterHealth(): array + { + $path = $this->runtimeDir . DIRECTORY_SEPARATOR . 'auto-updater-heartbeat.json'; + if (!is_file($path)) { + return [ + 'name' => 'auto-updater', + 'status' => 'degraded', + 'updated_at' => date('c'), + 'error' => 'No auto-updater heartbeat recorded', + ]; + } + + $decoded = json_decode((string)file_get_contents($path), true); + $status = trim((string)($decoded['status'] ?? 'idle')); + $ageSeconds = max(0, time() - filemtime($path)); + $healthy = $ageSeconds <= 90 && $status !== 'error'; + + return [ + 'name' => 'auto-updater', + 'status' => $healthy ? 'healthy' : 'degraded', + 'updated_at' => (string)($decoded['updated_at'] ?? date('c')), + 'mode' => $status, + 'error' => $healthy ? null : (string)($decoded['last_output'] ?? 'Auto-updater heartbeat is stale'), + ]; + } + + private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool + { + $this->lastControlPlaneResponse = null; + $brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload); + if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint)) { + return true; + } + + try { + $response = $this->http->post($endpoint, $payload, 20); + $this->lastControlPlaneResponse = is_array($response) ? $response : null; + $this->recordSuccessfulSync(); + return true; + } catch (Throwable $throwable) { + if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint)) { + $this->recordTransportFailure( + 'Broker dispatched ' . $type . ' but HTTP persistence failed on ' . $endpoint, + $throwable + ); + return true; + } + + $this->stateStore->enqueue($type, $endpoint, $payload); + $this->recordTransportFailure( + 'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint, + $throwable + ); + return false; + } + } + + private function applyBrokerUrlFromControlPlaneResponse(?array $response): void + { + if (!is_array($response)) { + return; + } + + $payload = is_array($response['data'] ?? null) ? (array)$response['data'] : $response; + $brokerUrl = trim((string)($payload['broker_url'] ?? $payload['gateway']['broker_url'] ?? '')); + if ($brokerUrl === '') { + return; + } + + $current = trim((string)$this->config->get('brokerUrl')); + if (rtrim($current, '/') === rtrim($brokerUrl, '/')) { + return; + } + + $this->config->set('brokerUrl', rtrim($brokerUrl, '/')); + $this->config->save(); + $this->configureBrokerClient(); + $this->logger->info('Updated broker URL from control plane heartbeat response.'); + } + + private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool + { + return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1; + } + + private function requestControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): ?array + { + $brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload); + if ($brokerDispatch === true) { + return ['data' => ['status' => 'ACKNOWLEDGED']]; + } + + try { + $response = $this->http->post($endpoint, $payload, $timeoutSeconds); + $this->recordSuccessfulSync(); + return is_array($response) ? $response : null; + } catch (Throwable $throwable) { + $this->stateStore->enqueue($type, $endpoint, $payload); + $this->recordTransportFailure( + 'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint, + $throwable + ); + return null; + } + } + + private function guardOperationEventResponse(?array $response): void + { + $operation = is_array($response['data'] ?? null) ? (array)$response['data'] : null; + if ($operation === null) { + return; + } + + $status = strtoupper(trim((string)($operation['status'] ?? ''))); + if ($status === 'CANCEL_REQUESTED' || $status === 'CANCELLED') { + throw new OperationAbortException('Operation cancelled by operator', true); + } + + if ($status === 'FAILED') { + throw new OperationAbortException( + trim((string)($operation['error_message'] ?? '')) !== '' + ? (string)$operation['error_message'] + : 'Gateway operation failed' + ); + } + } + + private function markOperationCancellationRequested(int $operationId): void + { + $ids = $this->operationCancellationRequests(); + if (!in_array($operationId, $ids, true)) { + $ids[] = $operationId; + $this->stateStore->setJson('cancel_requested_operations', array_values($ids)); + } + } + + private function clearOperationCancellationRequested(int $operationId): void + { + $ids = array_values(array_filter( + $this->operationCancellationRequests(), + static fn(int $currentId): bool => $currentId !== $operationId + )); + $this->stateStore->setJson('cancel_requested_operations', $ids); + } + + /** + * @return array + */ + private function operationCancellationRequests(): array + { + $stored = $this->stateStore->getJson('cancel_requested_operations', []); + if (!is_array($stored)) { + return []; + } + + return array_values(array_filter( + array_map(static fn(mixed $value): int => (int)$value, $stored), + static fn(int $value): bool => $value > 0 + )); + } + + private function abortIfOperationCancelled(int $operationId): void + { + if (in_array($operationId, $this->operationCancellationRequests(), true)) { + throw new OperationAbortException('Operation cancelled by operator', true); + } + } + + private function readRollbackStatus(): array + { + $rollbackPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'rollback-status.json'; + if (is_file($rollbackPath)) { + $decoded = json_decode((string)file_get_contents($rollbackPath), true); + if (is_array($decoded)) { + return $decoded; + } + } + + $rollback = $this->stateStore->getJson('rollback_status', null); + if (is_array($rollback)) { + return $rollback; + } + + return [ + 'state' => 'IDLE', + 'reason' => null, + 'rolled_back_to' => null, + 'at' => null, + ]; + } + + private function currentStagedUpdate(): ?array + { + if (is_file($this->stagedUpdatePath)) { + $decoded = json_decode((string)file_get_contents($this->stagedUpdatePath), true); + if (is_array($decoded)) { + return $decoded; + } + } + + $staged = $this->stateStore->getJson('staged_update', null); + return is_array($staged) ? $staged : null; + } + + private function ensureExecutable(string $path): void + { + if (is_file($path)) { + @chmod($path, 0755); + } + } + + 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; + } + + $completion = is_array($state['completion'] ?? null) ? (array)$state['completion'] : null; + $message = $completion !== null + ? 'Recovered previous operation awaiting backend completion acknowledgement: operation %s (%s) last stage %s.' + : 'Recovered previous unfinished operation snapshot: operation %s (%s) last stage %s.'; + + $this->logger->warning(sprintf( + $message, + (string)($state['operation_id'] ?? 'unknown'), + (string)($state['type'] ?? 'unknown'), + (string)($state['stage'] ?? 'unknown') + )); + } + + private function finalizeOperationCompletion( + int $gatewayId, + int $operationId, + array $payload, + string $snapshotStatus, + array $snapshotExtra = [] + ): void { + $state = $this->readOperationState() ?? []; + $completion = [ + 'endpoint' => '/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', + 'payload' => $payload, + 'snapshot_status' => $snapshotStatus, + 'snapshot_extra' => $snapshotExtra, + ]; + + $state['status'] = 'COMPLETION_PENDING'; + $state['stage'] = 'awaiting_completion_ack'; + $state['updated_at'] = date('c'); + $state['completion'] = $completion; + $this->persistOperationState($state); + + if (!$this->dispatchOperationCompletion($completion, true)) { + return; + } + + $this->snapshotOperationState($snapshotStatus, $snapshotExtra); + $this->clearOperationState(); + } + + private function resumePendingOperationCompletion(): bool + { + $state = $this->readOperationState(); + if ($state === null) { + return false; + } + + $completion = is_array($state['completion'] ?? null) ? (array)$state['completion'] : null; + if ($completion === null) { + return false; + } + + if (!$this->dispatchOperationCompletion($completion, false)) { + return true; + } + + $snapshotStatus = trim((string)($completion['snapshot_status'] ?? $state['status'] ?? 'FAILED')); + if ($snapshotStatus === '') { + $snapshotStatus = 'FAILED'; + } + + $snapshotExtra = is_array($completion['snapshot_extra'] ?? null) + ? (array)$completion['snapshot_extra'] + : []; + $this->snapshotOperationState($snapshotStatus, $snapshotExtra); + $this->clearOperationState(); + $this->logger->info(sprintf( + 'Acknowledged completion for recovered operation %s.', + (string)($state['operation_id'] ?? 'unknown') + )); + + return true; + } + + private function dispatchOperationCompletion(array $completion, bool $queueOnFailure): bool + { + $endpoint = trim((string)($completion['endpoint'] ?? '')); + $payload = is_array($completion['payload'] ?? null) ? (array)$completion['payload'] : []; + if ($endpoint === '') { + $this->logger->error('Unable to dispatch operation completion: missing endpoint.'); + return false; + } + + $brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload); + if ($brokerDispatch === true) { + return true; + } + + try { + $this->http->post($endpoint, $payload, self::OPERATION_COMPLETE_TIMEOUT_SECONDS); + $this->recordSuccessfulSync(); + return true; + } catch (Throwable $throwable) { + if ($queueOnFailure) { + $this->stateStore->enqueue('operation_complete', $endpoint, $payload); + $this->recordTransportFailure( + 'Queued operation_complete to local outbox after completion acknowledgement failure on ' . $endpoint, + $throwable + ); + return false; + } + + $this->recordTransportFailure( + 'Retrying backend completion acknowledgement later for ' . $endpoint, + $throwable + ); + return false; + } + } + + private function dispatchBrokerControlPlaneEvent(string $endpoint, array $payload): ?bool + { + if (!$this->isBrokerConnected()) { + return null; + } + + if (preg_match('#/edge-agent/gateways/(\d+)/heartbeat$#', $endpoint)) { + return $this->sendBrokerMessage([ + 'type' => 'TELEMETRY', + 'payload' => $this->stripAgentAuthentication($payload), + ]); + } + + if (preg_match('#/edge-agent/gateways/\d+/operations/(\d+)/events$#', $endpoint, $matches)) { + return $this->sendBrokerMessage([ + 'type' => 'TASK_EVENT', + 'operationId' => (int)$matches[1], + 'payload' => $this->stripAgentAuthentication($payload), + ]); + } + + if (preg_match('#/edge-agent/gateways/\d+/operations/(\d+)/complete$#', $endpoint, $matches)) { + return $this->sendBrokerMessage([ + 'type' => 'TASK_RESULT', + 'operationId' => (int)$matches[1], + 'payload' => $this->stripAgentAuthentication($payload), + ]); + } + + if (preg_match('#/edge-agent/gateways/\d+/logs$#', $endpoint)) { + return $this->sendBrokerMessage([ + 'type' => 'LOG_FRAME', + 'payload' => $this->stripAgentAuthentication($payload), + ]); + } + + return null; + } + + private function stripAgentAuthentication(array $payload): array + { + unset($payload['agent_token']); + return $payload; + } + + private function emitLogFrame(string $level, string $message): bool + { + $gatewayId = (int)$this->config->get('gatewayId', 0); + if ($gatewayId <= 0 || trim($message) === '') { + return false; + } + + $payload = [ + 'level' => strtoupper(trim($level)) ?: 'INFO', + 'message' => $message, + 'stream' => 'agent', + 'source' => 'EDGE_AGENT', + 'context' => [ + 'agent_instance_id' => $this->agentInstanceId, + ], + ]; + + $endpoint = '/edge-agent/gateways/' . $gatewayId . '/logs'; + $brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload); + if ($brokerDispatch === true) { + return true; + } + + try { + $this->http->post($endpoint, $payload, 10); + return true; + } catch (Throwable) { + return false; + } + } + + 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() ?? []; + unset($state['completion']); + $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; + } + + private function nextUpdateWindowStartIso(string $window): string + { + $parts = explode('-', $window, 2); + $start = trim((string)($parts[0] ?? '02:00')); + if (!preg_match('/^\d{2}:\d{2}$/', $start)) { + $start = '02:00'; + } + + $now = new DateTimeImmutable('now'); + [$hour, $minute] = array_map('intval', explode(':', $start)); + $candidate = $now->setTime($hour, $minute, 0); + if ($candidate <= $now) { + $candidate = $candidate->modify('+1 day'); + } + + return $candidate->format(DateTimeInterface::ATOM); + } + + private function reloadConfigFromDisk(): void + { + $reloaded = AgentConfig::load($this->config->path); + $this->config = $reloaded; + $this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL)); + $this->configureBrokerClient(); + } + + private function pollRequestTimeoutSeconds(int $waitSeconds): int + { + return max(5, $waitSeconds + 2); + } + + private function iniBytes(string $value): ?int + { + $normalized = trim(strtolower($value)); + if ($normalized === '' || $normalized === '-1') { + return null; + } + + $unit = substr($normalized, -1); + $number = (float)$normalized; + return match ($unit) { + 'g' => (int)round($number * 1024 * 1024 * 1024), + 'm' => (int)round($number * 1024 * 1024), + 'k' => (int)round($number * 1024), + default => is_numeric($normalized) ? (int)$normalized : null, + }; + } +} + +$configPath = null; +foreach ($argv as $index => $argument) { + if ($argument === '--config' && isset($argv[$index + 1])) { + $configPath = $argv[$index + 1]; + } +} + +if ($configPath === null) { + fwrite(STDERR, "Usage: php agent.php --config /path/to/config.json\n"); + exit(1); +} + +$agent = new TruckwashEdgeAgent($configPath); +$agent->run(); diff --git a/services/nginx/app/resources/edge-gateway-agent/auto-updater.php b/services/nginx/app/resources/edge-gateway-agent/auto-updater.php new file mode 100644 index 00000000..b780cc88 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/auto-updater.php @@ -0,0 +1,60 @@ +#!/usr/bin/env php + 'starting', + 'interval_seconds' => $intervalSeconds, +]); + +while (true) { + $stagedUpdatePresent = is_file($stagedUpdatePath); + $writeHeartbeat([ + 'status' => $stagedUpdatePresent ? 'waiting_for_window' : 'idle', + 'interval_seconds' => $intervalSeconds, + 'staged_update_present' => $stagedUpdatePresent, + ]); + + if ($stagedUpdatePresent) { + $writeHeartbeat([ + 'status' => 'reconciling', + 'interval_seconds' => $intervalSeconds, + 'staged_update_present' => true, + ]); + + $output = []; + $exitCode = 0; + exec('/bin/bash ' . escapeshellarg($launcherPath) . ' reconcile 2>&1', $output, $exitCode); + + $writeHeartbeat([ + 'status' => $exitCode === 0 ? 'idle' : 'error', + 'interval_seconds' => $intervalSeconds, + 'staged_update_present' => is_file($stagedUpdatePath), + 'last_exit_code' => $exitCode, + 'last_output' => implode("\n", array_slice($output, -40)), + 'last_reconciled_at' => date(DATE_ATOM), + ]); + } + + sleep($intervalSeconds); +} diff --git a/services/nginx/app/resources/edge-gateway-agent/docker-compose.gateway.yml b/services/nginx/app/resources/edge-gateway-agent/docker-compose.gateway.yml new file mode 100644 index 00000000..67caedb4 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/docker-compose.gateway.yml @@ -0,0 +1,125 @@ +version: "2.4" + +services: + redis: + image: ${REDIS_BASE_IMAGE:-redis:7-alpine} + container_name: truckwash-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - ./runtime/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 5s + retries: 5 + + mariadb: + image: ${MARIADB_BASE_IMAGE:-mariadb:11} + container_name: truckwash-mariadb + restart: unless-stopped + environment: + MARIADB_DATABASE: truckwash_edge + MARIADB_USER: truckwash_edge + MARIADB_PASSWORD: truckwash_edge + MARIADB_ROOT_PASSWORD: truckwash_edge_root + volumes: + - ./runtime/mariadb:/var/lib/mysql + + minio: + image: ${MINIO_BASE_IMAGE:-minio/minio:latest} + container_name: truckwash-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: truckwashminio + MINIO_ROOT_PASSWORD: truckwash_edge_storage + volumes: + - ./runtime/minio:/data + + lan-worker: + build: + context: . + dockerfile: Dockerfile.lan-worker + args: + BASE_IMAGE: ${LAN_WORKER_BASE_IMAGE:-php:8.2-cli-bookworm} + container_name: truckwash-lan-worker + restart: unless-stopped + ports: + - "127.0.0.1:8090:8090" + depends_on: + redis: + condition: service_healthy + mariadb: + condition: service_started + minio: + condition: service_started + volumes: + - ./runtime:/opt/truckwash-edge-agent/runtime + healthcheck: + test: + [ + "CMD", + "php", + "-r", + "$$json=@file_get_contents('http://127.0.0.1:8090/health'); if ($$json===false) exit(1); $$data=json_decode($$json,true); exit((($$data['status'] ?? '') === 'healthy') ? 0 : 1);", + ] + interval: 30s + timeout: 5s + retries: 3 + + edge-agent: + build: + context: . + dockerfile: Dockerfile.edge-agent + args: + BASE_IMAGE: ${EDGE_AGENT_BASE_IMAGE:-php:8.2-cli-bookworm} + container_name: truckwash-edge-agent + restart: unless-stopped + depends_on: + redis: + condition: service_healthy + mariadb: + condition: service_started + minio: + condition: service_started + lan-worker: + condition: service_healthy + volumes: + - ./config.json:/config/config.json + - ./runtime:/opt/truckwash-edge-agent/runtime + healthcheck: + test: + [ + "CMD-SHELL", + "php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? \"\")); $$syncAt=strtotime((string)($$data[\"last_successful_sync_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false || $$syncAt === false) { exit(1); } $$now=time(); exit((($$now - $$loopAt) <= 30 && ($$now - $$syncAt) <= 90) ? 0 : 1);'", + ] + interval: 30s + timeout: 5s + retries: 3 + + auto-updater: + build: + context: . + dockerfile: Dockerfile.auto-updater + args: + BASE_IMAGE: ${AUTO_UPDATER_BASE_IMAGE:-php:8.2-cli-bookworm} + container_name: truckwash-auto-updater + restart: unless-stopped + depends_on: + edge-agent: + condition: service_started + environment: + AUTO_UPDATER_INTERVAL_SECONDS: 30 + volumes: + - .:/opt/truckwash-edge-agent + - /var/run/docker.sock:/var/run/docker.sock + healthcheck: + test: + [ + "CMD-SHELL", + "php -r '$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"; if (!is_file($$path)) { exit(1); } exit((time() - filemtime($$path)) <= 90 ? 0 : 1);'", + ] + interval: 30s + timeout: 5s + retries: 3 diff --git a/services/nginx/app/resources/edge-gateway-agent/gateway-launcher.sh b/services/nginx/app/resources/edge-gateway-agent/gateway-launcher.sh new file mode 100644 index 00000000..5a4b2032 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/gateway-launcher.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ACTION="${1:-up}" +INSTALL_DIR="${TRUCKWASH_INSTALL_DIR:-/opt/truckwash-edge-agent}" +CONFIG_PATH="$INSTALL_DIR/config.json" +COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml" +RUNTIME_DIR="$INSTALL_DIR/runtime" +ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json" +STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json" +STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}" + +log() { + printf '[gateway-launcher] %s\n' "$1" +} + +compose_cmd() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + return + fi + + if command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + return + fi + + echo "Docker Compose is required." >&2 + return 1 +} + +print_compose_diagnostics() { + log "docker ps --format '{{.Names}} {{.Status}}'" + docker ps --format '{{.Names}} {{.Status}}' || true + + log "compose ps" + compose_cmd -f "$COMPOSE_FILE" ps || true + + log "compose logs --tail=80" + compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true +} + +config_value() { + local key="$1" + local fallback="${2:-}" + php -r ' + $path = $argv[1]; + $key = $argv[2]; + $fallback = $argv[3]; + if (!is_file($path)) { + echo $fallback; + exit(0); + } + $decoded = json_decode((string)file_get_contents($path), true); + if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null || $decoded[$key] === "") { + echo $fallback; + exit(0); + } + echo is_scalar($decoded[$key]) ? (string)$decoded[$key] : json_encode($decoded[$key], JSON_UNESCAPED_SLASHES); + ' "$CONFIG_PATH" "$key" "$fallback" +} + +ensure_dirs() { + mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups" +} + +within_update_window() { + local window + window="$(config_value updateWindow '02:00-04:00')" + php -r ' + $window = $argv[1]; + [$start, $end] = array_pad(explode("-", $window, 2), 2, ""); + $parse = static function (string $value): ?int { + if (!preg_match("/^(\d{2}):(\d{2})$/", trim($value), $matches)) { + return null; + } + return ((int)$matches[1] * 60) + (int)$matches[2]; + }; + $startMinutes = $parse($start); + $endMinutes = $parse($end); + if ($startMinutes === null || $endMinutes === null) { + exit(1); + } + $nowMinutes = ((int)date("G") * 60) + (int)date("i"); + if ($startMinutes <= $endMinutes) { + exit(($nowMinutes >= $startMinutes && $nowMinutes <= $endMinutes) ? 0 : 1); + } + exit(($nowMinutes >= $startMinutes || $nowMinutes <= $endMinutes) ? 0 : 1); + ' "$window" +} + +write_rollback_status() { + local state="$1" + local reason="${2:-}" + local rolled_back_to="${3:-}" + cat > "$ROLLBACK_STATUS_PATH" </dev/null || echo missing)" + [ "$health" = "healthy" ] || [ "$health" = "running" ] +} + +healthcheck_stack() { + container_is_healthy truckwash-redis && + container_is_healthy truckwash-mariadb && + container_is_healthy truckwash-minio && + container_is_healthy truckwash-lan-worker && + container_is_healthy truckwash-edge-agent && + container_is_healthy truckwash-auto-updater +} + +wait_for_stack_health() { + local timeout_seconds="${1:-$STACK_HEALTHCHECK_TIMEOUT_SECONDS}" + local elapsed=0 + + while [ "$elapsed" -lt "$timeout_seconds" ]; do + if healthcheck_stack; then + return 0 + fi + + sleep 2 + elapsed=$((elapsed + 2)) + done + + return 1 +} + +reconcile_stack() { + local installed_version + installed_version="$(config_value installedVersion '')" + ensure_dirs + log "Reconciling compose stack" + if ! apply_stack; then + log "Compose rollout failed during build/startup" + print_compose_diagnostics + write_rollback_status "FAILED" "compose_up_failed" "$installed_version" + return 1 + fi + + if ! wait_for_stack_health "$STACK_HEALTHCHECK_TIMEOUT_SECONDS"; then + log "Healthcheck failed after compose rollout; reverting to previous artifacts" + print_compose_diagnostics + rollback_stack + return 1 + fi + + write_rollback_status "IDLE" "" "" + if [ -f "$STAGED_UPDATE_PATH" ] && within_update_window; then + php -r ' + $configPath = $argv[1]; + $stagedPath = $argv[2]; + $config = is_file($configPath) ? json_decode((string)file_get_contents($configPath), true) : []; + $staged = is_file($stagedPath) ? json_decode((string)file_get_contents($stagedPath), true) : []; + if (!is_array($config) || !is_array($staged)) { + exit(0); + } + $targetVersion = trim((string)($staged["target_version"] ?? "")); + if ($targetVersion !== "") { + $config["installedVersion"] = $targetVersion; + $config["targetVersion"] = $targetVersion; + file_put_contents($configPath, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + $staged["status"] = "APPLIED"; + $staged["applied_at"] = date(DATE_ATOM); + file_put_contents($stagedPath, json_encode($staged, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + ' "$CONFIG_PATH" "$STAGED_UPDATE_PATH" + fi +} + +case "$ACTION" in + up) + reconcile_stack + ;; + reconcile) + if [ -f "$STAGED_UPDATE_PATH" ] && ! within_update_window; then + log "Update is staged but outside the maintenance window; keeping current stack running" + exit 0 + fi + reconcile_stack + ;; + down) + cd "$INSTALL_DIR" + compose_cmd -f "$COMPOSE_FILE" down + ;; + *) + echo "Usage: gateway-launcher.sh [up|reconcile|down]" >&2 + exit 1 + ;; +esac diff --git a/services/nginx/app/resources/edge-gateway-agent/lan-worker.php b/services/nginx/app/resources/edge-gateway-agent/lan-worker.php new file mode 100644 index 00000000..df542c61 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/lan-worker.php @@ -0,0 +1,182 @@ + true, + CURLOPT_TIMEOUT => $timeoutSeconds, + CURLOPT_CONNECTTIMEOUT => min(5, $timeoutSeconds), + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + $raw = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($raw === false || $status >= 400) { + throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status); + } + + $decoded = json_decode((string)$raw, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from device'); + } + + return $decoded; +} + +function worker_fetch_shelly_state(string $localIp, int $channel, bool $includeInput = false): array +{ + if ($localIp === '') { + throw new RuntimeException('Missing Shelly IP address'); + } + + $input = $includeInput ? worker_fetch_shelly_input_state($localIp, $channel) : null; + + try { + $payload = worker_http_get_json(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel)); + return [ + 'online' => true, + 'on' => (bool)($payload['output'] ?? false), + 'output' => (bool)($payload['output'] ?? false), + 'input_state' => $input['state'] ?? null, + 'input' => $input, + 'raw' => $payload, + ]; + } catch (Throwable) { + $payload = worker_http_get_json(sprintf('http://%s/relay/%d', $localIp, $channel)); + return [ + 'online' => true, + 'on' => (bool)($payload['ison'] ?? false), + 'output' => (bool)($payload['ison'] ?? false), + 'input_state' => $input['state'] ?? null, + 'input' => $input, + 'raw' => $payload, + ]; + } +} + +function worker_fetch_shelly_input_state(string $localIp, int $channel): ?array +{ + if ($localIp === '') { + throw new RuntimeException('Missing Shelly IP address'); + } + + try { + $payload = worker_http_get_json(sprintf('http://%s/rpc/Input.GetStatus?id=%d', $localIp, $channel), 2); + return [ + 'online' => true, + 'state' => (bool)($payload['state'] ?? false), + 'raw' => $payload, + ]; + } catch (Throwable) { + return null; + } +} + +function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array +{ + try { + worker_http_get_json(sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false')); + } catch (Throwable) { + worker_http_get_json(sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off')); + } + + return worker_fetch_shelly_state($localIp, $channel); +} + +$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); +$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/'); +$body = worker_read_json_body(); +$hostname = gethostname() ?: 'truckwash-edge'; + +try { + if ($method === 'GET' && $path === '/health') { + worker_json_response(200, [ + 'status' => 'healthy', + 'service' => 'lan-worker', + 'timestamp' => date(DateTimeInterface::ATOM), + ]); + return; + } + + if ($method === 'POST' && $path === '/discover') { + worker_json_response(200, [ + 'inventory' => [[ + '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' => 'compose-lan-worker', + 'php_version' => PHP_VERSION, + ], + ]], + ]); + return; + } + + if ($method === 'POST' && $path === '/relay/status') { + $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); + $channel = (int)($body['channel'] ?? 0); + $includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false); + worker_json_response(200, worker_fetch_shelly_state($localIp, $channel, $includeInput)); + return; + } + + if ($method === 'POST' && $path === '/relay/input-status') { + $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); + $channel = (int)($body['channel'] ?? 0); + $input = worker_fetch_shelly_input_state($localIp, $channel); + if ($input === null) { + throw new RuntimeException('Shelly input status is not available'); + } + worker_json_response(200, $input); + return; + } + + if ($method === 'POST' && $path === '/relay/switch') { + $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); + $channel = (int)($body['channel'] ?? 0); + $on = (bool)($body['on'] ?? false); + worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on)); + return; + } + + worker_json_response(404, ['message' => 'Not found']); +} catch (Throwable $throwable) { + worker_json_response(422, [ + 'message' => $throwable->getMessage(), + 'error_code' => 'EDGE_GATEWAY_WORKER_FAILED', + ]); +} diff --git a/services/nginx/app/resources/edge-gateway-agent/truckwash-edge-agent.service b/services/nginx/app/resources/edge-gateway-agent/truckwash-edge-agent.service new file mode 100644 index 00000000..5a6bdaa3 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/truckwash-edge-agent.service @@ -0,0 +1,16 @@ +[Unit] +Description=TruckWash Edge Agent Compatibility Unit +After=network-online.target docker.service +Wants=network-online.target docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/truckwash-edge-agent +ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up +ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile +ExecStop=/opt/truckwash-edge-agent/gateway-launcher.sh down +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target diff --git a/services/nginx/app/resources/edge-gateway-agent/truckwash-edge-gateway-stack.service b/services/nginx/app/resources/edge-gateway-agent/truckwash-edge-gateway-stack.service new file mode 100644 index 00000000..065bc0a5 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/truckwash-edge-gateway-stack.service @@ -0,0 +1,16 @@ +[Unit] +Description=TruckWash Edge Gateway Compose Stack +After=network-online.target docker.service +Wants=network-online.target docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/truckwash-edge-agent +ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up +ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile +ExecStop=/opt/truckwash-edge-agent/gateway-launcher.sh down +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target diff --git a/services/nginx/app/routes/BrandingRoute.php b/services/nginx/app/routes/BrandingRoute.php index b8983e6a..d959a102 100644 --- a/services/nginx/app/routes/BrandingRoute.php +++ b/services/nginx/app/routes/BrandingRoute.php @@ -11,6 +11,87 @@ class BrandingRoute { use route_t; + private const BRANDING_FIELDS = [ + 'name' => 'string', + 'description' => 'string', + 'cvr' => 'int', + 'address' => 'string', + 'phone_country_code' => 'int', + 'phone' => 'int', + 'email' => 'string', + 'website' => 'string', + 'banner' => 'string', + 'logo' => 'string', + 'favicon' => 'string', + 'signature' => 'string', + ]; + + private function readBrandingPayload(array $requiredFields = []): array + { + global $response; + + $payload = []; + foreach (self::BRANDING_FIELDS as $field => $type) { + if (!self::isParametersSet([$field])) { + continue; + } + + $value = self::getParameter($field); + if ($value === '') { + $value = null; + } + + if ($value === null) { + if (in_array($field, $requiredFields, true)) { + $response->error($field . ' is required', 400); + } + $payload[$field] = null; + continue; + } + + if ($type === 'int') { + if (is_int($value)) { + $payload[$field] = $value; + continue; + } + + if (is_string($value) && preg_match('/^-?\d+$/', $value) === 1) { + $payload[$field] = (int)$value; + continue; + } + + $response->error($field . ' must be an integer', 400); + } + + if (!is_string($value)) { + $response->error($field . ' must be a string', 400); + } + + $payload[$field] = $value; + } + + foreach ($requiredFields as $requiredField) { + if (!array_key_exists($requiredField, $payload)) { + $response->error($requiredField . ' is required', 400); + } + } + + return $payload; + } + + private function applyBrandingPayload(branding_o $branding, array $payload): void + { + foreach ($payload as $field => $value) { + if (!array_key_exists($field, self::BRANDING_FIELDS) || !property_exists($branding, $field)) { + continue; + } + + $branding->{$field}->set($value); + } + + $branding->objectChanged(); + } + public function run(): void { $this->get('/branding', function () { @@ -35,7 +116,7 @@ class BrandingRoute if ($branding->exists()) { // Return the object as an array $response->success( - (new branding_o())->select(self::getParameter('id'))->__toString() + $branding->asArray() ); } else { // Log the incident @@ -77,25 +158,15 @@ class BrandingRoute if ($user) { // Check if the required parameters are set self::requireParameters(['name', 'description', 'cvr']); - // Check if the parameters are of the correct type - self::requireType(self::getParameter('name'), self::TYPE_STRING()); - self::requireType(self::getParameter('description'), self::TYPE_STRING()); - self::requireType(self::getParameter('cvr'), self::TYPE_INT()); // Create the object $branding = new branding_o(); // Add the object - $branding->add( - [ - 'name' => self::getParameter('name'), - 'description' => self::getParameter('description'), - 'cvr' => self::getParameter('cvr') - ] - ); + $branding->add($this->readBrandingPayload(['name', 'description', 'cvr'])); // Log the incident (new logs_o())->add('branding', 'global', 1, $user->id, 'ADD_BRANDING_OPTION', 'User added a branding option'); // Return the object $response->success( - $branding->__toString() + $branding->asArray() ); } else { // Log the incident @@ -125,41 +196,18 @@ class BrandingRoute $branding = new branding_o(); // Select the object $branding->select(self::getParameter('id')); - // Check what the user wants to edit + if (!$branding->exists()) { + $response->error('Invalid id', 400); + } - // Option name - if (self::isParametersSet(['name'])) { - // Check if the parameters are of the correct type - self::requireType(self::getParameter('name'), self::TYPE_STRING()); - // Set the name - $branding->name->set( - (string)self::getParameter('name') - ); - } - // Option description - if (self::isParametersSet(['description'])) { - // Check if the parameters are of the correct type - self::requireType(self::getParameter('description'), self::TYPE_STRING()); - // Set the description - $branding->description->set( - (string)self::getParameter('description') - ); - } - // Option cvr - if (self::isParametersSet(['cvr'])) { - // Check if the parameters are of the correct type - self::requireType(self::getParameter('cvr'), self::TYPE_INT()); - // Set the cvr value - $branding->cvr->set( - (int)self::getParameter('cvr') - ); - } + $payload = $this->readBrandingPayload(); + $this->applyBrandingPayload($branding, $payload); // Log the incident (new logs_o())->add('branding', 'global', 1, $user->id, 'EDIT_BRANDING_OPTION', 'User edited a branding option'); // Return the object $response->success( - $branding->__toString() + $branding->asArray() ); } else { // Log the incident @@ -173,4 +221,4 @@ class BrandingRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 54615782..e9fa78fd 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -3,8 +3,15 @@ namespace routes; use classes\authentication; +use classes\economic; +use classes\economic_transfer_queue; +use classes\economic_v2_distribution_service; +use classes\economic_v2_versioning_service; +use classes\invoice_period_flag_service; +use classes\invoicing_period_utils; use classes\slack; use Exception; +use objects\collected_order_invoices_o; use objects\customer_vehicles_o; use objects\logs_o; use objects\order_items_o; @@ -17,6 +24,246 @@ class InvoicingPeriodRoute { use route_t; + /** + * Cache department metadata to avoid repeated object loads in large loops. + * @var array + */ + private static array $departmentNameCache = []; + + /** + * Cache whether a department is excluded from invoicing. + * @var array + */ + private static array $departmentExcludedFromInvoicingCache = []; + + private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null; + + /** + * Local-only booked status caches used by the period response. + * The period endpoint must not call e-conomic for each order. + * @var array + */ + private static array $periodOrderBookedCache = []; + private static array $periodInvoiceCollectionBookedCache = []; + + /** + * @throws Exception + */ + private static function getDepartmentNameCached(int $departmentId): string + { + if (!isset(self::$departmentNameCache[$departmentId])) { + $departmentName = (new \objects\departments_o())->select($departmentId)->name->value(); + self::$departmentNameCache[$departmentId] = !empty($departmentName) ? $departmentName : 'Unknown Department (' . $departmentId . ')'; + } + return self::$departmentNameCache[$departmentId]; + } + + /** + * @throws Exception + */ + private static function isDepartmentExcludedFromInvoicingCached(int $departmentId): bool + { + if (!array_key_exists($departmentId, self::$departmentExcludedFromInvoicingCache)) { + self::$departmentExcludedFromInvoicingCache[$departmentId] = (new \objects\departments_o()) + ->select($departmentId) + ->isExcludedFromInvoicing(); + } + return self::$departmentExcludedFromInvoicingCache[$departmentId]; + } + + private static function getLocalCustomerName(int $customerNumber): string + { + $names = (new users_o())->getCustomerNames([$customerNumber], false); + return (string)($names[$customerNumber] ?? 'Unknown Customer'); + } + + private static function getEconomicFallbackDepartmentId(): int + { + $department_id = (new economic())->getDefaultDistributionDepartmentId(); + return $department_id > 0 ? $department_id : economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; + } + + /** + * Slack summaries are expensive on request latency, so they are opt-in. + * Enable with query param `sendSlackSummary=1` or env `INVOICING_PERIOD_SEND_SLACK_SUMMARY=true`. + */ + private static function shouldSendSlackSummary(): bool + { + $requestOverride = $_GET['sendSlackSummary'] ?? null; + if ($requestOverride !== null) { + return in_array(strtolower((string)$requestOverride), ['1', 'true', 'yes'], true); + } + + $envFlag = getenv('INVOICING_PERIOD_SEND_SLACK_SUMMARY'); + if ($envFlag === false) { + return false; + } + + return in_array(strtolower((string)$envFlag), ['1', 'true', 'yes'], true); + } + + /** + * @return array{dateFrom:string,dateTo:string} + */ + private function requireAndNormalizeDateRange(): array + { + global $response; + + self::requireParameters([ + 'dateFrom', + 'dateTo', + ]); + + $dateFrom = (string)$this->getParameter('dateFrom'); + $dateTo = (string)$this->getParameter('dateTo'); + + try { + return invoicing_period_utils::normalizeDateRange($dateFrom, $dateTo); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } + + return [ + 'dateFrom' => '', + 'dateTo' => '', + ]; + } + + /** + * @return int[]|null + */ + private function getOptionalCustomerNumbersParameter(): ?array + { + if (!self::isParametersSet(['customerNumbers'])) { + return null; + } + + return self::normalizeCustomerNumbers(self::getParameter('customerNumbers')); + } + + /** + * @return int[] + */ + private static function normalizeCustomerNumbers(mixed $customerNumbers): array + { + if ($customerNumbers === null || $customerNumbers === '') { + return []; + } + + $rawValues = is_array($customerNumbers) + ? $customerNumbers + : explode(',', (string)$customerNumbers); + + $normalized = []; + foreach ($rawValues as $value) { + $parsed = (int)trim((string)$value); + if ($parsed < 1) { + continue; + } + $normalized[$parsed] = $parsed; + } + + return array_values($normalized); + } + + /** + * @param int[]|null $onlyCustomerNumbers + * @return int[] + */ + private static function filterCustomerNumbers(array $customerNumbers, ?array $onlyCustomerNumbers = null): array + { + $customerNumbers = self::normalizeCustomerNumbers($customerNumbers); + if ($onlyCustomerNumbers === null) { + return $customerNumbers; + } + + $allowed = array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true); + if (empty($allowed)) { + return []; + } + + return array_values(array_filter($customerNumbers, static function (int $customerNumber) use ($allowed): bool { + return isset($allowed[$customerNumber]); + })); + } + + /** + * @param array> $customers + * @return array> + */ + private static function indexCustomersByNumber(array $customers): array + { + $customersByNumber = []; + foreach ($customers as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0) { + $customersByNumber[$customerNumber] = $customer; + } + } + return $customersByNumber; + } + + /** + * Response cache TTL (seconds) for v2 distribution endpoints. + * Set `INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL` to override. + */ + private function getDistributionV2CacheTtl(): int + { + $raw = getenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL'); + if ($raw === false || trim((string)$raw) === '') { + return 300; + } + + return max(0, (int)$raw); + } + + private function getDistributionV2CacheKey(string $scope, string $dateFrom, string $dateTo): string + { + return 'invoicing_period:distribution:v2:' . $scope . ':' . md5($dateFrom . '|' . $dateTo); + } + + /** + * Best-effort Redis cache wrapper for v2 distribution payloads. + * Falls back to direct computation when Redis is unavailable or TTL is disabled. + * + * @param callable():array $resolver + * @return array + */ + private function withCachedDistributionV2(string $scope, string $dateFrom, string $dateTo, callable $resolver): array + { + $cacheTtl = $this->getDistributionV2CacheTtl(); + if ($cacheTtl <= 0 || !defined('redis')) { + return (array)$resolver(); + } + + $cacheKey = $this->getDistributionV2CacheKey($scope, $dateFrom, $dateTo); + + try { + $cached = redis->get($cacheKey); + if (is_string($cached) && $cached !== '') { + $decoded = json_decode($cached, true); + if (is_array($decoded)) { + return $decoded; + } + } + } catch (\Throwable $e) { + // Best-effort cache read. + } + + $result = (array)$resolver(); + + try { + $encoded = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (is_string($encoded)) { + redis->setEx($cacheKey, $encoded, $cacheTtl); + } + } catch (\Throwable $e) { + // Best-effort cache write. + } + + return $result; + } + /** * @param array $collective_results @@ -26,22 +273,547 @@ class InvoicingPeriodRoute private static function parseTheDepartmentIdsToDepartmentNames(array $collective_results): array { foreach ( $collective_results['total_department_totals'] as $department_id => $amount ) { - $department_name = (new \objects\departments_o())->select((int)$department_id)->name->value(); - if (empty($department_name)) { - $department_name = 'Unknown Department (' . $department_id . ')'; - } - $collective_results['total_department_totals_parsed'][$department_name] = $amount; + $collective_results['total_department_totals_parsed'][self::getDepartmentNameCached((int)$department_id)] = $amount; } foreach ( $collective_results['total_department_totals_relative'] as $department_id => $amount ) { - $department_name = (new \objects\departments_o())->select((int)$department_id)->name->value(); - if (empty($department_name)) { - $department_name = 'Unknown Department (' . $department_id . ')'; - } - $collective_results['total_department_totals_relative_parsed'][$department_name] = $amount; + $collective_results['total_department_totals_relative_parsed'][self::getDepartmentNameCached((int)$department_id)] = $amount; } return $collective_results; } + private static function jsonFragment(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return is_string($json) ? $json : 'null'; + } + + private static function streamInvoicingPeriodResponse(array $period): void + { + global $response; + + header('Content-Type: application/json; charset=utf-8'); + http_response_code(200); + + echo '{"success":true,"data":{'; + echo '"dateFrom":' . self::jsonFragment($period['dateFrom'] ?? null); + echo ',"dateTo":' . self::jsonFragment($period['dateTo'] ?? null); + echo ',"types":{'; + + $types = is_array($period['types'] ?? null) ? $period['types'] : []; + $firstType = true; + foreach ($types as $typeName => $customers) { + if (!$firstType) { + echo ','; + } + $firstType = false; + echo self::jsonFragment((string)$typeName) . ':['; + + $firstCustomer = true; + foreach ((array)$customers as $customer) { + if (!$firstCustomer) { + echo ','; + } + $firstCustomer = false; + echo self::jsonFragment($customer); + } + echo ']'; + } + + echo '}'; + foreach ($period as $key => $value) { + if (in_array((string)$key, ['dateFrom', 'dateTo', 'types'], true)) { + continue; + } + echo ',' . self::jsonFragment((string)$key) . ':' . self::jsonFragment($value); + } + echo '}'; + echo ',"meta":' . self::jsonFragment($response->get_meta()); + echo ',"includes":' . self::jsonFragment($response->get_includes()); + echo '}'; + exit; + } + + private static function periodTypeNames(): array + { + return [ + 'all', + 'vehicle_subscriptions', + 'fixed_pricing', + 'tank_cleaning', + 'special_arrangements', + 'invoice_per_order', + 'possible_duplicates', + ]; + } + + private static function getPeriodPaginationOptionsFromRequest(): ?array + { + global $response; + + $paginationKeys = [ + 'periodView', + 'page', + 'limit', + 'search', + 'includeRequiresAction', + 'includeBooked', + ]; + + $isPaginatedRequest = false; + foreach ($paginationKeys as $key) { + if ($response->isRequestParameterSet($key)) { + $isPaginatedRequest = true; + break; + } + } + + if (!$isPaginatedRequest) { + return null; + } + + return self::normalizePeriodPaginationOptions($response->getAllRequestParameters()); + } + + private static function normalizePeriodPaginationOptions(array $parameters): array + { + $allowedViews = array_fill_keys(self::periodTypeNames(), true); + $periodView = trim((string)($parameters['periodView'] ?? 'all')); + if ($periodView === '' || !isset($allowedViews[$periodView])) { + $periodView = 'all'; + } + + $page = (int)($parameters['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + + $limitParameter = strtolower(trim((string)($parameters['limit'] ?? '100'))); + if ($limitParameter === 'all') { + $limit = 'all'; + } else { + $limit = (int)$limitParameter; + if ($limit < 1) { + $limit = 100; + } + $limit = min(500, $limit); + } + + return [ + 'periodView' => $periodView, + 'page' => $page, + 'limit' => $limit, + 'search' => trim((string)($parameters['search'] ?? '')), + 'flagTab' => trim((string)($parameters['flagTab'] ?? 'all')), + 'includeRequiresAction' => self::parsePeriodBooleanOption( + $parameters['includeRequiresAction'] ?? null, + true + ), + 'includeBooked' => self::parsePeriodBooleanOption($parameters['includeBooked'] ?? null, true), + ]; + } + + private static function parsePeriodBooleanOption(mixed $value, bool $default): bool + { + if ($value === null || $value === '') { + return $default; + } + + if (is_bool($value)) { + return $value; + } + + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + + return $default; + } + + private static function applyPeriodPagination(array $period, array $options): array + { + $types = is_array($period['types'] ?? null) ? $period['types'] : []; + $types = self::ensurePeriodTypeKeys($types); + $types = self::enrichPeriodCustomerMetaFromTypes($types); + $types = self::filterPeriodTypesBySearch($types, (string)($options['search'] ?? '')); + $types = self::filterPeriodTypesByVisibility( + $types, + (bool)($options['includeRequiresAction'] ?? true), + (bool)($options['includeBooked'] ?? true) + ); + + $periodView = (string)($options['periodView'] ?? 'all'); + if (!array_key_exists($periodView, $types)) { + $periodView = 'all'; + } + + $typeCounts = self::summarizePeriodTypes($types); + + if (!empty($options['flagTab'])) { + $types = self::filterPeriodTypesByFlagTab($types, (string)$options['flagTab']); + } + + $total = count($types[$periodView] ?? []); + $limit = $options['limit'] ?? 100; + $isAllLimit = $limit === 'all'; + $perPage = $isAllLimit ? 'all' : max(1, min(500, (int)$limit)); + $totalPages = $isAllLimit || $total === 0 ? 1 : (int)ceil($total / $perPage); + $page = $isAllLimit ? 1 : max(1, (int)($options['page'] ?? 1)); + $page = min($page, $totalPages); + + $pagedTypes = array_fill_keys(array_keys($types), []); + if ($isAllLimit) { + $pagedTypes[$periodView] = array_values($types[$periodView] ?? []); + } else { + $offset = ($page - 1) * $perPage; + $pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage); + } + + $period['types'] = $pagedTypes; + $period['type_counts'] = $typeCounts; + $period['type_totals'] = self::summarizePeriodTypeTotals($types); + + return [ + 'period' => $period, + 'pagination' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'total_pages' => $totalPages, + 'search' => (string)($options['search'] ?? ''), + 'filters' => [ + 'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? true), + 'includeBooked' => (bool)($options['includeBooked'] ?? true), + ], + 'order' => [ + 'field' => 'customer_name', + 'direction' => 'asc', + ], + ], + ]; + } + + private static function filterPeriodTypesByFlagTab(array $types, string $flagTab): array + { + if (in_array($flagTab, ['all', 'filters', ''], true)) { + return $types; + } + + foreach ($types as $viewName => $entries) { + $types[$viewName] = array_values(array_filter( + is_array($entries) ? $entries : [], + static function (array $customer) use ($flagTab): bool { + $hasManual = false; + $hasAutomatic = false; + if (is_array($customer['flags'] ?? null)) { + foreach ($customer['flags'] as $flag) { + if (!empty($flag['order_id']) || !empty($flag['invoice_collection_id'])) { + continue; + } + if ($flag['is_manual'] ?? ($flag['source'] ?? '') === 'manual') { + $hasManual = true; + } else { + $hasAutomatic = true; + } + } + } + + $tab = 'none'; + if ($hasManual) { + $tab = 'red'; + } elseif ($hasAutomatic) { + $tab = 'yellow'; + } + + return $tab === $flagTab; + } + )); + } + + return $types; + } + + private static function ensurePeriodTypeKeys(array $types): array + { + foreach (self::periodTypeNames() as $typeName) { + if (!array_key_exists($typeName, $types) || !is_array($types[$typeName])) { + $types[$typeName] = []; + } + } + + return $types; + } + + private static function enrichPeriodCustomerMetaFromTypes(array $types): array + { + $metaByCustomerNumber = []; + foreach (['fixed_pricing', 'vehicle_subscriptions'] as $typeName) { + foreach (($types[$typeName] ?? []) as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber < 1) { + continue; + } + $meta = is_array($customer['meta'] ?? null) ? $customer['meta'] : []; + if ($meta === []) { + continue; + } + $metaByCustomerNumber[$customerNumber] = array_merge( + $metaByCustomerNumber[$customerNumber] ?? [], + $meta + ); + } + } + + if ($metaByCustomerNumber === []) { + return $types; + } + + foreach ($types as $typeName => $customers) { + foreach ($customers as $index => $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber < 1 || !isset($metaByCustomerNumber[$customerNumber])) { + continue; + } + + $types[$typeName][$index]['meta'] = array_merge( + is_array($customer['meta'] ?? null) ? $customer['meta'] : [], + $metaByCustomerNumber[$customerNumber] + ); + } + } + + return $types; + } + + private static function filterPeriodTypesBySearch(array $types, string $search): array + { + $search = self::normalizePeriodSearchTerm($search); + if ($search === '') { + return $types; + } + + foreach ($types as $typeName => $customers) { + $types[$typeName] = array_values(array_filter( + is_array($customers) ? $customers : [], + static fn(array $customer): bool => self::periodCustomerMatchesSearch($customer, $search) + )); + } + + return $types; + } + + private static function filterPeriodTypesByVisibility( + array $types, + bool $includeRequiresAction, + bool $includeBooked + ): array { + foreach ($types as $typeName => $customers) { + $types[$typeName] = array_values(array_filter( + is_array($customers) ? $customers : [], + static function (array $customer) use ($includeRequiresAction, $includeBooked): bool { + if (!$includeRequiresAction && (bool)($customer['requires_action'] ?? false)) { + return false; + } + + if ( + !$includeBooked + && !((bool)($customer['requires_action'] ?? false)) + && self::areAllPeriodCustomerTransactionsBooked($customer) + ) { + return false; + } + + return true; + } + )); + } + + return $types; + } + + private static function periodCustomerMatchesSearch(array $customer, string $search): bool + { + $values = [ + $customer['customer_number'] ?? '', + $customer['customer_name'] ?? '', + ]; + + foreach (($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction)) { + continue; + } + foreach (['id', 'reference', 'po', 'notes', 'reg_1', 'reg_2', 'reg_3'] as $field) { + $values[] = $transaction[$field] ?? ''; + } + } + + foreach ($values as $value) { + if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) { + return true; + } + } + + return false; + } + + private static function normalizePeriodSearchTerm(string $value): string + { + return mb_strtolower(trim($value), 'UTF-8'); + } + + private static function areAllPeriodCustomerTransactionsBooked(array $customer): bool + { + $transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : []; + foreach ($transactions as $transaction) { + if (!is_array($transaction) || (bool)($transaction['booked'] ?? false) !== true) { + return false; + } + } + + return true; + } + + private static function summarizePeriodTypes(array $types): array + { + $counts = []; + foreach ($types as $typeName => $customers) { + $counts[$typeName] = self::summarizePeriodType(is_array($customers) ? $customers : []); + } + + return $counts; + } + + private static function summarizePeriodTypeTotals(array $types): array + { + $totals = []; + foreach ($types as $typeName => $customers) { + $totals[$typeName] = self::summarizePeriodTypeTotalsForCustomers( + is_array($customers) ? $customers : [] + ); + } + + return $totals; + } + + private static function summarizePeriodTypeTotalsForCustomers(array $customers): array + { + $total = 0.0; + $booked = 0.0; + + foreach ($customers as $customer) { + if (!is_array($customer)) { + continue; + } + + $total += self::getPeriodCustomerTotalAmount($customer); + $booked += self::sumPeriodCustomerTransactions($customer, true); + } + + return [ + 'total' => $total, + 'booked' => $booked, + 'not_booked' => $total - $booked, + ]; + } + + private static function getPeriodCustomerTotalAmount(array $customer): float + { + $fixedPrice = $customer['meta']['fixed_pricing']['price'] ?? null; + if ($fixedPrice !== null && $fixedPrice !== '') { + return (float)$fixedPrice; + } + + return self::sumPeriodCustomerTransactions($customer, false); + } + + private static function sumPeriodCustomerTransactions(array $customer, bool $bookedOnly): float + { + $total = 0.0; + $transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : []; + foreach ($transactions as $transaction) { + if (!is_array($transaction)) { + continue; + } + if ((bool)($transaction['excluded'] ?? false)) { + continue; + } + if ($bookedOnly && (bool)($transaction['booked'] ?? false) !== true) { + continue; + } + + $total += (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0); + } + + return $total; + } + + private static function summarizePeriodType(array $customers): array + { + $requiresAction = 0; + $draft = 0; + $manualFlags = 0; + $automaticFlags = 0; + + foreach ($customers as $customer) { + if ((bool)($customer['requires_action'] ?? false)) { + $requiresAction++; + } + if (($customer['draft']['is_action_blocked'] ?? false) === true) { + $draft++; + } + + $flagCounts = self::getActivePeriodFlagCounts($customer); + if ($flagCounts['manual'] > 0) { + $manualFlags++; + } + if ($flagCounts['automatic'] > 0) { + $automaticFlags++; + } + } + + return [ + 'requires_action' => $requiresAction, + 'draft' => $draft, + 'manual_flags' => $manualFlags, + 'automatic_flags' => $automaticFlags, + 'completed' => max(0, count($customers) - $requiresAction - $draft), + 'total' => count($customers), + ]; + } + + private static function getActivePeriodFlagCounts(array $customer): array + { + $manual = 0; + $automatic = 0; + if (is_array($customer['flags'] ?? null)) { + foreach ($customer['flags'] as $flag) { + if (!is_array($flag) || (string)($flag['status'] ?? 'active') !== 'active') { + continue; + } + if (($flag['source'] ?? null) === 'manual') { + $manual++; + } elseif (($flag['source'] ?? null) === 'automatic') { + $automatic++; + } + } + + return [ + 'manual' => $manual, + 'automatic' => $automatic, + 'total' => $manual + $automatic, + ]; + } + + return [ + 'manual' => (int)($customer['flag_counts']['manual'] ?? 0), + 'automatic' => (int)($customer['flag_counts']['automatic'] ?? 0), + 'total' => (int)($customer['flag_counts']['total'] ?? 0), + ]; + } + public function run(): void { $this->get('/superuser/invoicing/period', function () { @@ -52,25 +824,136 @@ class InvoicingPeriodRoute $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { - self::requireParameters([ - 'dateFrom', - 'dateTo', - ]); - $dateFrom = $this->getParameter('dateFrom'); - $dateTo = $this->getParameter('dateTo'); - // Require the dateFrom and dateTo parameters to be valid dates - self::requireDateFormat($dateFrom, 'Y-m-d'); - self::requireDateFormat($dateTo, 'Y-m-d'); - // Add a day to the dateTo parameter to include the end date in the range - $dateTo = date('Y-m-d', strtotime($dateTo . ' +1 day')); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $customerNumbers = $this->getOptionalCustomerNumbersParameter(); + // Add date from and date to to the response meta + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + if ($customerNumbers !== null) { + $response->add_meta('customer_numbers', $customerNumbers); + } + $paginationOptions = self::getPeriodPaginationOptionsFromRequest(); // Get the invoicing period for the user - $response->success([...self::getInvoicingPeriod($dateFrom, $dateTo)]); + $period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers); + if ($paginationOptions !== null) { + $paginated = self::applyPeriodPagination($period, $paginationOptions); + $period = $paginated['period']; + $response->add_meta('pagination', $paginated['pagination']); + } + self::streamInvoicingPeriodResponse($period); } else { // Log the incident (new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_OWN_VEHICLES', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } + }, + [ + 'superuser_invoicing_period' => 'Get the invoicing period for superusers', + 'list_invoice_period_flags' => 'List invoice period flags in the period response', + ] + ); + + $this->post('/superuser/invoicing/period/flags', function () { + global $response; + $this->requirePermission('add_invoice_period_flag'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + try { + $flag = (new invoice_period_flag_service())->createManualFlag( + $this->getParametersAsArray(), + (int)$user->id + ); + $response->success($flag); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 500); + } + }, + [ + 'add_invoice_period_flag' => 'Add a manual invoice period flag', + ] + ); + + $this->patch('/superuser/invoicing/period/flags/{id}/status', function () { + global $response; + $this->requirePermission('update_invoice_period_flag_status'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + try { + $flag = (new invoice_period_flag_service())->updateManualFlagStatus( + $id, + (string)$this->getParameter('status'), + $this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : null, + (int)$user->id + ); + $response->success($flag); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 500); + } + }, + [ + 'update_invoice_period_flag_status' => 'Update a manual invoice period flag status', + ] + ); + + $this->post('/superuser/invoicing/period/flags/automatic/status', function () { + global $response; + $this->requirePermission('update_invoice_period_flag_status'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + try { + $flag = (new invoice_period_flag_service())->updateAutomaticFlagStatus( + $this->getParametersAsArray(), + (int)$user->id + ); + $response->success($flag); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 500); + } + }, + [ + 'update_invoice_period_flag_status' => 'Update an automatic invoice period flag status', + ] + ); + + + $this->get('/superuser/invoicing/period/distribution/all', function () { + // Require the user to be logged in + global $response; + $this->requirePermission('superuser_invoicing_period'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + // Add date from and date to to the response meta + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + $result = [ + 'subscriptions' => self::getTransactionsWithItemsNotIncludedInInvoices(self::getVehicleSubscriptions($dateFrom, $dateTo)), + 'fixed_pricing' => self::getOriginalPrice(self::getFixedPricing($dateFrom, $dateTo)), + + ]; + $response->success($result); }, [ 'superuser_invoicing_period' => 'Get the invoicing period for superusers', @@ -81,17 +964,12 @@ class InvoicingPeriodRoute // Require the user to be logged in global $response; $this->requirePermission('superuser_invoicing_period'); - self::requireParameters([ - 'dateFrom', - 'dateTo', - ]); - $dateFrom = $this->getParameter('dateFrom'); - $dateTo = $this->getParameter('dateTo'); - // Require the dateFrom and dateTo parameters to be valid dates - self::requireDateFormat($dateFrom, 'Y-m-d'); - self::requireDateFormat($dateTo, 'Y-m-d'); - // Add a day to the dateTo parameter to include the end date in the range - $dateTo = date('Y-m-d', strtotime($dateTo . ' +1 day')); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + // Add date from and date to to the response meta + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); $response->success([...self::getOriginalPrice(self::getFixedPricing($dateFrom, $dateTo))]); }, [ @@ -103,17 +981,12 @@ class InvoicingPeriodRoute // Require the user to be logged in global $response; $this->requirePermission('superuser_invoicing_period'); - self::requireParameters([ - 'dateFrom', - 'dateTo', - ]); - $dateFrom = $this->getParameter('dateFrom'); - $dateTo = $this->getParameter('dateTo'); - // Require the dateFrom and dateTo parameters to be valid dates - self::requireDateFormat($dateFrom, 'Y-m-d'); - self::requireDateFormat($dateTo, 'Y-m-d'); - // Add a day to the dateTo parameter to include the end date in the range - $dateTo = date('Y-m-d', strtotime($dateTo . ' +1 day')); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + // Add date from and date to to the response meta + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); $response->success([...self::getTransactionsWithItemsNotIncludedInInvoices(self::getVehicleSubscriptions($dateFrom, $dateTo))]); }, [ @@ -121,22 +994,164 @@ class InvoicingPeriodRoute ] ); + $this->get('/superuser/invoicing/period/distribution/v2/all', function () { + global $response; + $this->requirePermission('superuser_invoicing_period_distribution_v2'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + + $response->success($this->withCachedDistributionV2('all', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { + return (new economic_v2_distribution_service())->getAllDistributions($dateFrom, $dateTo); + })); + }, + [ + 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware departmental distribution (all categories).', + ] + ); + + $this->get('/superuser/invoicing/period/distribution/v2/fixed-pricing', function () { + global $response; + $this->requirePermission('superuser_invoicing_period_distribution_v2'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + + $response->success($this->withCachedDistributionV2('fixed-pricing', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { + return (new economic_v2_distribution_service())->getFixedPricingDistribution($dateFrom, $dateTo); + })); + }, + [ + 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware fixed pricing distribution.', + ] + ); + + $this->get('/superuser/invoicing/period/distribution/v2/wash-subscriptions', function () { + global $response; + $this->requirePermission('superuser_invoicing_period_distribution_v2'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + + $response->success($this->withCachedDistributionV2('wash-subscriptions', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { + return (new economic_v2_distribution_service())->getWashSubscriptionsDistribution($dateFrom, $dateTo); + })); + }, + [ + 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware wash subscription distribution.', + ] + ); + + $this->get('/superuser/invoicing/period/distribution/v2/customer-prices', function () { + global $response; + $this->requirePermission('superuser_invoicing_period_distribution_v2'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + + $response->success($this->withCachedDistributionV2('customer-prices', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { + return (new economic_v2_distribution_service())->getCustomerPricesDistribution($dateFrom, $dateTo); + })); + }, + [ + 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware customer discount distribution.', + ] + ); + + $this->get('/superuser/invoicing/period/distribution/v2/booked-department-75', function () { + global $response; + $this->requirePermission('superuser_invoicing_period_distribution_v2'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + + $response->success($this->withCachedDistributionV2('booked-department-75', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { + return (new economic_v2_distribution_service())->getBookedDepartment75Distribution($dateFrom, $dateTo); + })); + }, + [ + 'superuser_invoicing_period_distribution_v2' => 'Get booked e-conomic department 75 redistribution based on actual booked net amounts.', + ] + ); + + $this->get('/superuser/customers/pricing-history', function () { + global $response; + $this->requirePermission('superuser_customer_pricing_history_v2'); + self::requireParameters(['customer_number']); + self::requireType((int)self::getParameter('customer_number'), self::type_int()); + $customer_number = (int)self::getParameter('customer_number'); + self::requireMinValue($customer_number, 1); + self::requireMaxValue($customer_number, 999999999); + + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + + $versioning = new economic_v2_versioning_service(); + $fixed_pricing = $versioning->listFixedPricingVersions($customer_number, $dateFrom, $dateTo); + $vehicle_subscriptions = $versioning->listVehicleSubscriptionVersions($customer_number, $dateFrom, $dateTo); + $discount_overrides = $versioning->listDiscountOverrideVersions($customer_number, $dateFrom, $dateTo); + + $timeline = []; + foreach ($fixed_pricing as $row) { + $timeline[] = [ + 'type' => 'fixed_pricing', + ...$row, + ]; + } + foreach ($vehicle_subscriptions as $row) { + $timeline[] = [ + 'type' => 'vehicle_subscription', + ...$row, + ]; + } + foreach ($discount_overrides as $row) { + $timeline[] = [ + 'type' => 'discount_override', + ...$row, + ]; + } + usort($timeline, static function ($a, $b) { + $left = strtotime((string)($a['effective_from'] ?? '1970-01-01 00:00:00')); + $right = strtotime((string)($b['effective_from'] ?? '1970-01-01 00:00:00')); + if ($left === $right) { + return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); + } + return $left <=> $right; + }); + + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + $response->success([ + 'customer_number' => $customer_number, + 'fixed_pricing' => $fixed_pricing, + 'vehicle_subscriptions' => $vehicle_subscriptions, + 'discount_overrides' => $discount_overrides, + 'timeline' => $timeline, + ]); + }, + [ + 'superuser_customer_pricing_history_v2' => 'Get customer pricing/subscription/discount timeline with confidence and provenance.', + ] + ); + $this->get('/superuser/invoicing/period/distribution/wash-subscriptions/historical', function () { // Require the user to be logged in global $response; $this->requirePermission('superuser_invoicing_period'); - self::requireParameters([ - 'dateFrom', - 'dateTo', - ]); - $dateFrom = $this->getParameter('dateFrom'); - $dateTo = $this->getParameter('dateTo'); - // Require the dateFrom and dateTo parameters to be valid dates - $this->requireDateFormat($dateFrom, 'Y-m-d'); - $this->requireDateFormat($dateTo, 'Y-m-d'); - $dateFrom = date('Y-m-d 00:00:00', strtotime($dateFrom)); - // Add a day to the dateTo parameter to include the end date in the range - $dateTo = date('Y-m-d 23:59:59', strtotime($dateTo)); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; // Get all orders in the date range that have: // - Department ID: 10 // - Reference: Vaskeabonnementer @@ -445,7 +1460,7 @@ class InvoicingPeriodRoute 'difference' => $difference, ]; // Send slack alert - $message = "Subscription price distribution mismatch for customer " . $customer['customer_number'] . " (" . (new users_o())->getCustomerName((int)$customer['customer_number']) . ")\n"; + $message = "Subscription price distribution mismatch for customer " . $customer['customer_number'] . " (" . self::getLocalCustomerName((int)$customer['customer_number']) . ")\n"; $message .= "Subscription total: " . $customer['meta']['subscription']['subscription_total'] . "\n"; $message .= "Distribution total: " . $sum_of_distribution . "\n"; $message .= "Difference: " . $difference . "\n"; @@ -460,26 +1475,24 @@ class InvoicingPeriodRoute // Parse the department ids to department names $parsed_distribution = []; foreach ( $collective_results['subscription_price_department_distribution'] as $department_id => $price ) { - $department_name = (new \objects\departments_o())->select((int)$department_id)->name->value(); - if (empty($department_name)) { - $department_name = 'Unknown Department (' . $department_id . ')'; - } - $parsed_distribution[$department_name] = $price; + $parsed_distribution[self::getDepartmentNameCached((int)$department_id)] = $price; } $collective_results['subscription_price_department_distribution_parsed'] = $parsed_distribution; // Include the collective results in the response $response->add_include('collective_subscription_results', $collective_results); - // Send summary to slack - $slack_message = "Subscription Price Distribution Summary:\n"; - $slack_message .= "Total Subscription Price: " . $collective_results['total_subscription_price'] . "\n"; - $slack_message .= "Department Distribution:\n"; - $tmp_total = 0; - foreach ( $collective_results['subscription_price_department_distribution_parsed'] as $department_name => $price ) { - $slack_message .= "- " . $department_name . ": " . $price . "\n"; - $tmp_total += $price; + if (self::shouldSendSlackSummary()) { + // Send summary to slack + $slack_message = "Subscription Price Distribution Summary:\n"; + $slack_message .= "Total Subscription Price: " . $collective_results['total_subscription_price'] . "\n"; + $slack_message .= "Department Distribution:\n"; + $tmp_total = 0; + foreach ( $collective_results['subscription_price_department_distribution_parsed'] as $department_name => $price ) { + $slack_message .= "- " . $department_name . ": " . $price . "\n"; + $tmp_total += $price; + } + $slack_message .= "Total Distribution: " . $tmp_total . "\n"; + (new slack())->send_message($slack_message, 'Subscription Price Distribution Summary'); } - $slack_message .= "Total Distribution: " . $tmp_total . "\n"; - (new slack())->send_message($slack_message, 'Subscription Price Distribution Summary'); return $customersWithSubscriptions; } @@ -498,7 +1511,7 @@ class InvoicingPeriodRoute */ private static function attemptSubscriptionFallbacks(customer_vehicles_o $vehicle, array &$customer): bool { - if (self::debug) echo "Attempting fallbacks for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . " - " . (new users_o())->getCustomerName((int)$customer['customer_number']) . "\n"; + if (self::debug) echo "Attempting fallbacks for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . " - " . self::getLocalCustomerName((int)$customer['customer_number']) . "\n"; // Run the fallback options in order $subscription_price = (int)(new products_o())->select((int)$vehicle->type->value())->getSubscriptionMonthlyPrice(); if (self::divideSubscriptionAcrossCustomerDepartments($customer, $subscription_price)) { @@ -643,12 +1656,17 @@ class InvoicingPeriodRoute if (self::debug) echo "Fallback 3 not applied: No recent transactions found for customer\n"; return false; // No recent transactions found } - // 4. If no departments are found, assign the subscription to the customers default department (e.g., department ID 1). + // 4. If no departments are found, assign the subscription to customer default department + // or fallback to e-conomic default distribution department. private static function useDefaultDepartment(array &$customer, int $subscription_price): bool { if (self::debug) echo "Fallback 4: Using default department\n"; - $default_department_id = (new users_o)->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment(); - if (!empty($default_department_id)) { + $customer_default_department_id = (int)(new users_o)->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment(); + $default_department_id = $customer_default_department_id > 0 + ? $customer_default_department_id + : self::getEconomicFallbackDepartmentId(); + + if ($default_department_id > 0) { if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id])) { $customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id] = 0; } @@ -671,31 +1689,84 @@ class InvoicingPeriodRoute private static function getOriginalPrice(array $fixed_pricing): array { global $response; + $order_items = new order_items_o(); foreach ( $fixed_pricing as &$customer ) { if (isset($customer['meta']['fixed_pricing'])) { $original_price = 0; $department_totals = []; // Array to hold totals per department + + $eligible_transaction_department_ids = []; foreach ( $customer['transactions'] as $transaction ) { - $transaction_obj = (new orders_o())->select((int)$transaction['id']); - // Check if the transaction is included in invoicing - if (!$transaction_obj->isIncludedInInvoicing()) { - continue; // Skip transactions not included in invoicing - } - // Get the transaction department id - $department_id = (int)$transaction_obj->department_id->value(); - // If the department id is 10 (automatic), skip it - if ($department_id === 10) { + $department_id = (int)($transaction['department_id'] ?? 0); + $excluded = (bool)($transaction['excluded'] ?? false); + + // Skip excluded transactions and automatic department. + if ($excluded || $department_id === 10) { continue; } - $transaction['original_price'] = $transaction_obj->getNetAmountForOrderItemsOriginal(); - $original_price += $transaction['original_price']; - // Initialize the department total if it doesn't exist - if (!isset($department_totals[$department_id])) { - $department_totals[$department_id] = 0; - } - // Add the transaction amount to the department total - $department_totals[$department_id] += $transaction['original_price']; + $eligible_transaction_department_ids[(int)$transaction['id']] = $department_id; } + + if (!empty($eligible_transaction_department_ids)) { + $transaction_original_prices = []; + $product_cache = []; + $department_price_cache = []; + $discount_cache = []; + $user = (new users_o())->getUserByCustomerNumber((int)$customer['customer_number']); + $rows = $order_items->getFieldsWhere( + ['order_id' => array_keys($eligible_transaction_department_ids)], + ['order_id', 'product_id', 'price', 'quantity'] + ); + + foreach ( $rows as $row ) { + $order_id = (int)$row['order_id']; + $department_id = (int)($eligible_transaction_department_ids[$order_id] ?? 0); + if ($department_id <= 0) { + continue; + } + $product_id = (int)$row['product_id']; + $price = (int)$row['price']; + $quantity = (int)$row['quantity']; + + if ($price > 0 && $quantity > 0) { + $transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + ($price * $quantity)); + continue; + } + + if (!isset($product_cache[$product_id])) { + $product_cache[$product_id] = (new products_o())->select($product_id); + } + if (!isset($department_price_cache[$department_id][$product_id])) { + $department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id); + } + if (!array_key_exists($product_id, $discount_cache)) { + $discount_cache[$product_id] = $user->getCustomPrice($product_id, false); + } + $post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity; + $transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount); + } + + foreach ( $eligible_transaction_department_ids as $transaction_id => $department_id ) { + $transaction_original_price = (int)($transaction_original_prices[$transaction_id] ?? 0); + $original_price += $transaction_original_price; + // Initialize the department total if it doesn't exist + if (!isset($department_totals[$department_id])) { + $department_totals[$department_id] = 0; + } + // Add the transaction amount to the department total + $department_totals[$department_id] += $transaction_original_price; + } + } else { + $customer_default_department_id = (int)(new users_o())->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment(); + $fallback_department_id = $customer_default_department_id > 0 + ? $customer_default_department_id + : self::getEconomicFallbackDepartmentId(); + + if ($fallback_department_id > 0) { + $department_totals[$fallback_department_id] = (float)$customer['meta']['fixed_pricing']['price']; + } + } + $customer['meta']['fixed_pricing']['original_price'] = $original_price; $customer['meta']['fixed_pricing']['department_totals'] = $department_totals; // Take the relative price of the department totals in relation to the fixed price @@ -743,59 +1814,88 @@ class InvoicingPeriodRoute $collective_results = self::parseTheDepartmentIdsToDepartmentNames($collective_results); // Include the collective results in the response $response->add_include('collective_fixed_pricing_results', $collective_results); - // Send slack message with the collective results - $slack_message = "Fixed Pricing Invoicing Period Summary:\n"; - $slack_message .= "Total Fixed Price: " . number_format($collective_results['total_fixed_price'], 2) . " DKK\n"; - $slack_message .= "Total Original Price: " . number_format($collective_results['total_original_price'], 2) . " DKK\n"; - $slack_message .= "Department Totals:\n"; - $tmp_sum = 0; - foreach ( $collective_results['total_department_totals_parsed'] as $department_name => $amount ) { - $slack_message .= "- " . $department_name . ": " . number_format($amount, 2) . " DKK\n"; - $tmp_sum += $amount; + if (self::shouldSendSlackSummary()) { + // Send slack message with the collective results + $slack_message = "Fixed Pricing Invoicing Period Summary:\n"; + $slack_message .= "Total Fixed Price: " . number_format($collective_results['total_fixed_price'], 2) . " DKK\n"; + $slack_message .= "Total Original Price: " . number_format($collective_results['total_original_price'], 2) . " DKK\n"; + $slack_message .= "Department Totals:\n"; + $tmp_sum = 0; + foreach ( $collective_results['total_department_totals_parsed'] as $department_name => $amount ) { + $slack_message .= "- " . $department_name . ": " . number_format($amount, 2) . " DKK\n"; + $tmp_sum += $amount; + } + $slack_message .= "Total Department Totals: " . number_format($tmp_sum, 2) . " DKK\n"; + $slack_message .= "Relative Department Totals:\n"; + $tmp_sum = 0; + foreach ( $collective_results['total_department_totals_relative_parsed'] as $department_name => $amount ) { + $slack_message .= "- " . $department_name . ": " . number_format($amount, 2) . " DKK\n"; + $tmp_sum += $amount; + } + $slack_message .= "Total Relative Department Totals: " . number_format($tmp_sum, 2) . " DKK\n"; + (new slack())->send_message($slack_message, 'Fixed Pricing Invoicing Period Summary'); } - $slack_message .= "Total Department Totals: " . number_format($tmp_sum, 2) . " DKK\n"; - $slack_message .= "Relative Department Totals:\n"; - $tmp_sum = 0; - foreach ( $collective_results['total_department_totals_relative_parsed'] as $department_name => $amount ) { - $slack_message .= "- " . $department_name . ": " . number_format($amount, 2) . " DKK\n"; - $tmp_sum += $amount; - } - $slack_message .= "Total Relative Department Totals: " . number_format($tmp_sum, 2) . " DKK\n"; - (new slack())->send_message($slack_message, 'Fixed Pricing Invoicing Period Summary'); return $fixed_pricing; } /** * @throws Exception */ - private static function getInvoicingPeriod(string $dateFrom, string $dateTo): array + private static function getInvoicingPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array { //$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo) + $onlyCustomerNumbers = $onlyCustomerNumbers !== null + ? self::normalizeCustomerNumbers($onlyCustomerNumbers) + : null; - $customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo) { - return self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo, $onlyCustomerNumbers) { + return self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); }, 'customers_with_transactions'); $types = []; // Add the customers with transactions to the types array $types['all'] = $customersWithTransactions; - $types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions); + $types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'vehicle_subscriptions'); - $types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions); + $types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'fixed_pricing'); - $types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions); + $types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'tank_cleaning'); - $types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions); + $types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'special_arrangements'); - $types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions); + $types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'invoice_per_order'); - $types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions); + $types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'possible_duplicates'); + $queueOverlay = self::debugGetTime(function () use ($dateFrom, $dateTo) { + return self::getActiveCollectedInvoiceQueueOverlay($dateFrom, $dateTo); + }, 'active_collected_invoice_queue_overlay'); + $types = self::applyCollectedInvoiceQueueOverlayToPeriodTypes( + $types, + $queueOverlay['by_collection_id'] ?? [], + $queueOverlay['by_customer_number'] ?? [], + ); + $draftOverlay = self::debugGetTime(function () use ($types, $dateFrom, $dateTo) { + return self::getValidCollectedInvoiceDraftOverlay($types, $dateFrom, $dateTo); + }, 'valid_collected_invoice_draft_overlay'); + $types = self::applyCollectedInvoiceDraftOverlayToPeriodTypes( + $types, + $draftOverlay['by_collection_id'] ?? [], + $draftOverlay['by_customer_number'] ?? [], + ); + $types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) { + return (new invoice_period_flag_service())->applyFlagsToPeriodTypes( + $types, + $dateFrom, + $dateTo, + $onlyCustomerNumbers + ); + }, 'invoice_period_flags'); return [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, @@ -828,81 +1928,45 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getCustomersWithTransactions(string $dateFrom, string $dateTo): array + private static function getCustomersWithTransactions(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array { - // Define the customers with orders in the specified date range - $customers = self::debugGetTime(function () use ($dateFrom, $dateTo) { - return (new orders_o())->getCustomersWithOrdersInDateRange($dateFrom, $dateTo); - }, 'customers_with_orders_in_date_range'); - //$customers = (new orders_o)->getCustomersWithOrdersInDateRange($dateFrom, $dateTo); + $onlyCustomerNumbers = $onlyCustomerNumbers !== null + ? self::normalizeCustomerNumbers($onlyCustomerNumbers) + : null; + if ($onlyCustomerNumbers !== null && empty($onlyCustomerNumbers)) { + return []; + } + + $customer_number_transactions = []; + self::debugGetTime(function () use ($onlyCustomerNumbers, $dateFrom, $dateTo, &$customer_number_transactions) { + $customer_number_transactions = (new orders_o())->getPeriodTransactionsForCustomersInDateRange( + $onlyCustomerNumbers, + $dateFrom, + $dateTo + ); + }, 'get_transactions_for_customers_in_date_range'); - /** - * // user_id => customer_number, - * @example - * [ - * '123' => '12345678', - * '456' => '87654321' - * ] - */ $customer_numbers = []; $tmp = []; - self::debugGetTime(function () use ($customers, &$customer_numbers) { - // Process the customer numbers to ensure they are unique - foreach ( $customers as $customer ) { - $customer_number = (int)$customer->customer_number->value(); + self::debugGetTime(function () use ($customer_number_transactions, &$customer_numbers) { + foreach ( $customer_number_transactions as $customer_number => $transactions ) { + $customer_number = (int)$customer_number; if (empty($customer_number)) { - // Skip if the customer number is empty continue; } - // Check if the customer number is already in the array - // This ensures that we only process each customer number once - // We use (int)$customer_number to ensure that the customer number is an integer if (isset($customer_numbers[$customer_number])) { continue; } - // Add the customer number to the array - $customer_numbers[$customer_number] = $customer->id; + $firstTransaction = is_array($transactions) ? ($transactions[0] ?? []) : []; + $userId = (int)($firstTransaction['user_id'] ?? 0); + $customer_numbers[$customer_number] = $userId > 0 ? $userId : null; } }, 'process_customer_numbers'); - // Get the transactions for the customers in the specified date range - /** - * @example - * [ - * '12345678' => [ - * orders_o, - * orders_o, - * ] - * ] - * @var $customer_number_transactions - */ - self::debugGetTime(function () use ($customer_numbers, $dateFrom, $dateTo, &$customer_number_transactions) { - $customer_number_transactions = (new orders_o())->getTransactionsForCustomersInDateRange(array_keys($customer_numbers), $dateFrom, $dateTo); - }, 'get_transactions_for_customers_in_date_range'); - // Calculate the total amount for each transaction, to minimize the number of queries - self::debugGetTime(function () use ($customer_number_transactions) { - // Get all transaction ids - $transaction_ids = []; - foreach ( $customer_number_transactions as $customer_number => $transactions ) { - foreach ( $transactions as $transaction ) { - if ($transaction instanceof orders_o) { - $transaction_ids[] = $transaction->id; - } - } - } - // Get the total amount for each transaction - $transaction_totals = (new orders_o())->getNetAmountForOrders($transaction_ids); - // Add the total amount to each transaction - foreach ( $customer_number_transactions as $customer_number => $transactions ) { - foreach ( $transactions as $transaction ) { - if ($transaction instanceof orders_o) { - // Set the total amount for the transaction - $transaction->setTemporaryNetAmount($transaction_totals[$transaction->id] ?? 0); - } - } - } + self::debugGetTime(static function (): void { + // Net totals are resolved by getPeriodTransactionsForCustomersInDateRange(). }, 'calculate_transaction_totals'); // Get the customer names from the cache - $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_numbers)); + $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_numbers), false); // Process the customer numbers to ensure they are unique self::debugGetTime(function () use ($customer_numbers, $customer_number_transactions, &$tmp, $customer_names) { foreach ( $customer_numbers as $customer_number => $user_id ) { @@ -920,10 +1984,9 @@ class InvoicingPeriodRoute $tmp[] = self::constructCustomerObject( (int)$customer_number, $customer_names[(int)$customer_number] ?? 'Unknown Customer', - //(new \objects\users_o())->getCustomerName((int)$customer_number), $customer_number_transactions[(int)$customer_number] ?? [], false, - (int)$user_id, + (int)$user_id > 0 ? (int)$user_id : null, ); } }, 'construct_customer_objects'); @@ -947,9 +2010,12 @@ class InvoicingPeriodRoute ?array $meta = null ): array { - $user = (new users_o())->getUserByCustomerNumber((int)$customer_number); + $user = null; + if ($user_id === null) { + $user = (new users_o())->getUserByCustomerNumber((int)$customer_number); + } return [ - 'id' => $user_id ?? ($user->exists() ? $user->id : null), + 'id' => $user_id ?? ($user !== null && $user->exists() ? $user->id : null), 'customer_number' => $customer_number, 'customer_name' => $customer_name, 'transactions' => $parsed_transactions = array_map(function ($transaction) { @@ -957,23 +2023,100 @@ class InvoicingPeriodRoute }, $transactions), 'requires_action' => self::checkRequiresAction($parsed_transactions, $requires_action), 'meta' => $meta ?? [], + 'queue' => self::getDefaultQueueSummary(), + 'draft' => self::getDefaultDraftSummary(), ]; } /** * @throws Exception */ - private static function constructTransactionObject(orders_o $transaction): array + private static function constructTransactionObject(mixed $transaction): array { + if (is_array($transaction)) { + return self::constructTransactionObjectFromPeriodRow($transaction); + } + if (!$transaction instanceof orders_o) { + throw new \InvalidArgumentException('Invalid period transaction row.'); + } + + $departmentId = (int)$transaction->department_id->value(); + $invoiceCollectionId = (int)$transaction->invoice_collection_id->value(); return [ 'id' => $transaction->id, 'date' => $transaction->created_at->value(), 'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier - 'booked' => $transaction->isBooked(true), - 'excluded' => !$transaction->isIncludedInInvoicing() + 'booked' => self::isTransactionBookedFromLocalState($transaction), + 'department_id' => $departmentId, + 'customer_number' => (int)$transaction->customer_id->value(), + 'reference' => (string)$transaction->reference->value(), + 'po' => (string)$transaction->po->value(), + 'notes' => (string)$transaction->notes->value(), + 'reg_1' => (string)$transaction->reg_1->value(), + 'reg_2' => (string)$transaction->reg_2->value(), + 'reg_3' => (string)$transaction->reg_3->value(), + 'excluded' => !$transaction->isIncludedInInvoicing(), + 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, + 'queue_status' => null, + 'queue_job_id' => null, ]; } + private static function constructTransactionObjectFromPeriodRow(array $transaction): array + { + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + return [ + 'id' => (int)($transaction['id'] ?? $transaction['order_id'] ?? 0), + 'date' => (string)($transaction['date'] ?? $transaction['created_at'] ?? ''), + 'amount' => (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0), + 'booked' => (bool)($transaction['booked'] ?? false), + 'department_id' => (int)($transaction['department_id'] ?? 0), + 'customer_number' => (int)($transaction['customer_number'] ?? $transaction['customer_id'] ?? 0), + 'reference' => (string)($transaction['reference'] ?? $transaction['order_reference'] ?? ''), + 'po' => (string)($transaction['po'] ?? $transaction['order_po'] ?? ''), + 'notes' => (string)($transaction['notes'] ?? $transaction['order_notes'] ?? ''), + 'reg_1' => (string)($transaction['reg_1'] ?? ''), + 'reg_2' => (string)($transaction['reg_2'] ?? ''), + 'reg_3' => (string)($transaction['reg_3'] ?? ''), + 'excluded' => (bool)($transaction['excluded'] ?? ((int)($transaction['include_in_invoice_effective'] ?? 1) !== 1)), + 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, + 'queue_status' => $transaction['queue_status'] ?? null, + 'queue_job_id' => $transaction['queue_job_id'] ?? null, + ]; + } + + /** + * Resolve booked state from local stored invoice metadata only. + * Remote e-conomic invoice lookups are intentionally avoided here because this method runs for every + * transaction in the period response. + */ + private static function isTransactionBookedFromLocalState(orders_o $transaction): bool + { + global $db; + + $orderId = (int)$transaction->id; + if ($orderId < 1) { + return false; + } + if (array_key_exists($orderId, self::$periodOrderBookedCache)) { + return self::$periodOrderBookedCache[$orderId]; + } + + $invoiceCollectionId = (int)$transaction->invoice_collection_id->value(); + if ($invoiceCollectionId > 0) { + if (!array_key_exists($invoiceCollectionId, self::$periodInvoiceCollectionBookedCache)) { + $result = $db->query("SELECT booked_invoice_id FROM collected_order_invoices WHERE id = {$invoiceCollectionId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId] = !empty($row['booked_invoice_id'] ?? null); + } + return self::$periodOrderBookedCache[$orderId] = self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId]; + } + + $result = $db->query("SELECT invoice_id FROM economic_module_orders WHERE id = {$orderId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + return self::$periodOrderBookedCache[$orderId] = !empty($row['invoice_id'] ?? null); + } + private static function checkRequiresAction(array $parsed_transactions, bool $requires_action): bool { // If requires_action is already set to true, return true @@ -990,26 +2133,541 @@ class InvoicingPeriodRoute return false; } + private static function getDefaultQueueSummary(): array + { + return [ + 'has_active_job' => false, + 'statuses' => [], + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ]; + } + + private static function getDefaultDraftSummary(): array + { + return [ + 'has_valid_draft' => false, + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ]; + } + + private static function getActiveCollectedInvoiceQueueOverlay(string $dateFrom, string $dateTo): array + { + $overlay = [ + 'by_collection_id' => [], + 'by_customer_number' => [], + ]; + + try { + $queue = new economic_transfer_queue(); + $offset = 0; + $limit = 250; + + do { + $jobs = $queue->listJobs( + [ + economic_transfer_queue::STATUS_QUEUED, + economic_transfer_queue::STATUS_PROCESSING, + ], + $limit, + $offset, + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT + ); + + foreach ($jobs as $job) { + $normalizedJob = self::normalizeCollectedInvoiceQueueJob($job, $dateFrom, $dateTo); + if ($normalizedJob === null || empty($normalizedJob['is_period_relevant'])) { + continue; + } + + $invoiceCollectionId = (int)($normalizedJob['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0 && !isset($overlay['by_collection_id'][$invoiceCollectionId])) { + $overlay['by_collection_id'][$invoiceCollectionId] = $normalizedJob; + } + + $customerNumber = (int)($normalizedJob['customer_number'] ?? 0); + if ($customerNumber > 0) { + $overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? []; + $overlay['by_customer_number'][$customerNumber][] = $normalizedJob; + } + } + + $offset += count($jobs); + } while (count($jobs) === $limit); + } catch (\Throwable) { + return $overlay; + } + + return $overlay; + } + + private static function normalizeCollectedInvoiceQueueJob(array $job, string $dateFrom, string $dateTo): ?array + { + $invoiceCollectionId = (int)( + $job['payload']['collected_invoice_id'] + ?? $job['collected_invoice_id'] + ?? $job['invoice_collection_id'] + ?? 0 + ); + + if ($invoiceCollectionId < 1) { + return null; + } + + try { + $invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId); + if (!$invoiceCollection->exists()) { + return null; + } + + $customerNumber = (int)$invoiceCollection->customer_number->value(); + $closedAt = (string)$invoiceCollection->closed_at->value(); + $createdAt = (string)$invoiceCollection->created_at->value(); + + return [ + 'queue_job_id' => (int)($job['id'] ?? $job['queue_job_id'] ?? 0), + 'queue_status' => (string)($job['status'] ?? $job['queue_status'] ?? ''), + 'invoice_collection_id' => $invoiceCollectionId, + 'customer_number' => $customerNumber, + 'created_at' => $createdAt, + 'closed_at' => $closedAt, + 'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod($createdAt, $closedAt, $dateFrom, $dateTo), + ]; + } catch (\Throwable) { + return null; + } + } + + private static function isInvoiceCollectionRelevantToPeriod( + ?string $createdAt, + ?string $closedAt, + string $dateFrom, + string $dateTo + ): bool { + return self::isTimestampWithinPeriod($closedAt, $dateFrom, $dateTo) + || self::isTimestampWithinPeriod($createdAt, $dateFrom, $dateTo); + } + + private static function isTimestampWithinPeriod(?string $timestamp, string $dateFrom, string $dateTo): bool + { + if (empty($timestamp)) { + return false; + } + + $normalizedTimestamp = substr((string)$timestamp, 0, 10); + return $normalizedTimestamp >= $dateFrom && $normalizedTimestamp <= $dateTo; + } + + private static function collectedOrderInvoicesHasDeletedAtColumn(): bool + { + if (self::$collectedOrderInvoicesHasDeletedAtColumn !== null) { + return self::$collectedOrderInvoicesHasDeletedAtColumn; + } + + global $db; + + try { + $result = $db->query("SHOW COLUMNS FROM `collected_order_invoices` LIKE 'deleted_at'"); + self::$collectedOrderInvoicesHasDeletedAtColumn = $result !== false && (int)$result->num_rows > 0; + } catch (\Throwable) { + self::$collectedOrderInvoicesHasDeletedAtColumn = false; + } + + return self::$collectedOrderInvoicesHasDeletedAtColumn; + } + + private static function applyCollectedInvoiceQueueOverlayToPeriodTypes( + array $types, + array $queueJobsByCollectionId, + array $queueJobsByCustomerNumber + ): array { + foreach ($types as $type => $customers) { + if (!is_array($customers)) { + continue; + } + + $types[$type] = array_map(function ($customer) use ($queueJobsByCollectionId, $queueJobsByCustomerNumber) { + if (!is_array($customer)) { + return $customer; + } + + return self::applyCollectedInvoiceQueueOverlayToCustomer( + $customer, + $queueJobsByCollectionId, + $queueJobsByCustomerNumber + ); + }, $customers); + } + + return $types; + } + + private static function applyCollectedInvoiceQueueOverlayToCustomer( + array $customer, + array $queueJobsByCollectionId, + array $queueJobsByCustomerNumber + ): array { + $customerNumber = (int)($customer['customer_number'] ?? 0); + $activeCustomerJobs = array_values(array_filter( + $queueJobsByCustomerNumber[$customerNumber] ?? [], + static function ($job): bool { + return !empty($job['is_period_relevant']); + } + )); + + $transactions = []; + $actionableTransactionCount = 0; + $queuedActionableTransactionCount = 0; + + foreach (($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction)) { + continue; + } + + $transaction['invoice_collection_id'] = isset($transaction['invoice_collection_id']) && (int)$transaction['invoice_collection_id'] > 0 + ? (int)$transaction['invoice_collection_id'] + : null; + $transaction['queue_status'] = $transaction['queue_status'] ?? null; + $transaction['queue_job_id'] = $transaction['queue_job_id'] ?? null; + + $isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false); + if ($isActionable) { + $actionableTransactionCount++; + } + + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0 && isset($queueJobsByCollectionId[$invoiceCollectionId])) { + $queueJob = $queueJobsByCollectionId[$invoiceCollectionId]; + $transaction['queue_status'] = $queueJob['queue_status'] ?? null; + $transaction['queue_job_id'] = $queueJob['queue_job_id'] ?? null; + + if ($isActionable) { + $queuedActionableTransactionCount++; + } + } + + $transactions[] = $transaction; + } + + $customerLevelQueueBlock = false; + if ($actionableTransactionCount === 0 && self::customerSupportsCustomerLevelQueueBlocking($customer) && !empty($activeCustomerJobs)) { + $customerLevelQueueBlock = true; + } + + $isActionBlocked = false; + if ($actionableTransactionCount > 0) { + $isActionBlocked = $queuedActionableTransactionCount > 0 + && $queuedActionableTransactionCount === $actionableTransactionCount; + } elseif ($customerLevelQueueBlock) { + $isActionBlocked = true; + } + + $statuses = []; + $invoiceCollectionIds = []; + foreach ($activeCustomerJobs as $job) { + $status = (string)($job['queue_status'] ?? ''); + if ($status !== '' && !in_array($status, $statuses, true)) { + $statuses[] = $status; + } + + $invoiceCollectionId = (int)($job['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0 && !in_array($invoiceCollectionId, $invoiceCollectionIds, true)) { + $invoiceCollectionIds[] = $invoiceCollectionId; + } + } + + $customer['transactions'] = $transactions; + $customer['queue'] = [ + 'has_active_job' => !empty($activeCustomerJobs), + 'statuses' => $statuses, + 'invoice_collection_ids' => $invoiceCollectionIds, + 'is_action_blocked' => $isActionBlocked, + ]; + + if ($isActionBlocked) { + $customer['requires_action'] = false; + } + + return $customer; + } + + private static function getValidCollectedInvoiceDraftOverlay(array $types, string $dateFrom, string $dateTo): array + { + $overlay = [ + 'by_collection_id' => [], + 'by_customer_number' => [], + ]; + + $candidateInvoiceCollectionIds = []; + $customerLevelCandidateNumbers = []; + + foreach ($types as $customers) { + if (!is_array($customers)) { + continue; + } + + foreach ($customers as $customer) { + if (!is_array($customer)) { + continue; + } + + foreach (($customer['transactions'] ?? []) as $transaction) { + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $candidateInvoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + } + + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0 && self::customerSupportsCustomerLevelQueueBlocking($customer)) { + $customerLevelCandidateNumbers[$customerNumber] = $customerNumber; + } + } + } + + if (empty($candidateInvoiceCollectionIds) && empty($customerLevelCandidateNumbers)) { + return $overlay; + } + + global $db; + + try { + $whereCandidates = []; + if (!empty($candidateInvoiceCollectionIds)) { + $whereCandidates[] = 'id IN (' . implode(',', array_map('intval', array_values($candidateInvoiceCollectionIds))) . ')'; + } + if (!empty($customerLevelCandidateNumbers)) { + $dateFromEscaped = $db->escape_string($dateFrom); + $dateToEscaped = $db->escape_string($dateTo); + $whereCandidates[] = '(customer_number IN (' . implode(',', array_map('intval', array_values($customerLevelCandidateNumbers))) . ') + AND ( + DATE(closed_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\' + OR DATE(created_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\' + ))'; + } + + if (!defined('\objects\ECONOMIC_PROCESSOR')) { + class_exists(collected_order_invoices_o::class); + } + $processor = defined('\objects\ECONOMIC_PROCESSOR') + ? (int)constant('\objects\ECONOMIC_PROCESSOR') + : 1; + $deletedAtFilter = self::collectedOrderInvoicesHasDeletedAtColumn() + ? 'deleted_at IS NULL + AND ' + : ''; + $sql = "SELECT id, customer_number, created_at, closed_at + FROM collected_order_invoices + WHERE {$deletedAtFilter}processor = $processor + AND external_id IS NOT NULL + AND external_id <> '' + AND booked_invoice_id IS NULL + AND error_message IS NULL + AND (" . implode(' OR ', $whereCandidates) . ")"; + + $result = $db->query($sql); + if (!$result) { + return $overlay; + } + + while ($row = $result->fetch_assoc()) { + $invoiceCollectionId = (int)($row['id'] ?? 0); + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($invoiceCollectionId < 1 || $customerNumber < 1) { + continue; + } + + $normalizedDraft = [ + 'invoice_collection_id' => $invoiceCollectionId, + 'customer_number' => $customerNumber, + 'created_at' => (string)($row['created_at'] ?? ''), + 'closed_at' => (string)($row['closed_at'] ?? ''), + 'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod( + (string)($row['created_at'] ?? ''), + (string)($row['closed_at'] ?? ''), + $dateFrom, + $dateTo + ), + ]; + + $overlay['by_collection_id'][$invoiceCollectionId] = $normalizedDraft; + $overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? []; + $overlay['by_customer_number'][$customerNumber][] = $normalizedDraft; + } + } catch (\Throwable) { + return $overlay; + } + + return $overlay; + } + + private static function applyCollectedInvoiceDraftOverlayToPeriodTypes( + array $types, + array $draftsByCollectionId, + array $draftsByCustomerNumber + ): array { + foreach ($types as $type => $customers) { + if (!is_array($customers)) { + continue; + } + + $types[$type] = array_map(function ($customer) use ($draftsByCollectionId, $draftsByCustomerNumber) { + if (!is_array($customer)) { + return $customer; + } + + return self::applyCollectedInvoiceDraftOverlayToCustomer( + $customer, + $draftsByCollectionId, + $draftsByCustomerNumber + ); + }, $customers); + } + + return $types; + } + + private static function applyCollectedInvoiceDraftOverlayToCustomer( + array $customer, + array $draftsByCollectionId, + array $draftsByCustomerNumber + ): array { + $customerNumber = (int)($customer['customer_number'] ?? 0); + $activeCustomerDrafts = array_values(array_filter( + $draftsByCustomerNumber[$customerNumber] ?? [], + static function ($draft): bool { + return !empty($draft['is_period_relevant']); + } + )); + + $transactions = []; + $actionableTransactionCount = 0; + $coveredActionableTransactionCount = 0; + $queuedActionableTransactionCount = 0; + $draftActionableTransactionCount = 0; + $invoiceCollectionIds = []; + + foreach (($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction)) { + continue; + } + + $transaction['invoice_collection_id'] = isset($transaction['invoice_collection_id']) && (int)$transaction['invoice_collection_id'] > 0 + ? (int)$transaction['invoice_collection_id'] + : null; + + $isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false); + if (!$isActionable) { + $transactions[] = $transaction; + continue; + } + + $actionableTransactionCount++; + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + $isQueued = !empty($transaction['queue_status']); + $isDraft = $invoiceCollectionId > 0 && isset($draftsByCollectionId[$invoiceCollectionId]); + + if ($isQueued || $isDraft) { + $coveredActionableTransactionCount++; + } + if ($isQueued) { + $queuedActionableTransactionCount++; + } + if ($isDraft) { + $draftActionableTransactionCount++; + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + + $transactions[] = $transaction; + } + + foreach ($activeCustomerDrafts as $draft) { + $invoiceCollectionId = (int)($draft['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + } + + $isDraftActionBlocked = false; + $queue = is_array($customer['queue'] ?? null) ? $customer['queue'] : self::getDefaultQueueSummary(); + if ($actionableTransactionCount > 0 && $coveredActionableTransactionCount === $actionableTransactionCount) { + if ($queuedActionableTransactionCount > 0) { + $queue['is_action_blocked'] = true; + } elseif ($draftActionableTransactionCount > 0) { + $isDraftActionBlocked = true; + } + } elseif ( + $actionableTransactionCount === 0 + && self::customerSupportsCustomerLevelQueueBlocking($customer) + && !empty($activeCustomerDrafts) + && empty($queue['is_action_blocked']) + ) { + $isDraftActionBlocked = true; + } + + $customer['transactions'] = $transactions; + $customer['queue'] = $queue; + $customer['draft'] = [ + 'has_valid_draft' => !empty($invoiceCollectionIds), + 'invoice_collection_ids' => array_values($invoiceCollectionIds), + 'is_action_blocked' => $isDraftActionBlocked, + ]; + + if ($isDraftActionBlocked || !empty($queue['is_action_blocked'])) { + $customer['requires_action'] = false; + } + + return $customer; + } + + private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool + { + $meta = $customer['meta'] ?? []; + + return isset($meta['fixed_pricing']) + || isset($meta['wash_subscription']) + || !empty($meta['has_vehicle_subscription']); + } + /** * @throws Exception */ - private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Since these are monthly subscriptions, we don't need to filter by transactions - $customer_numbers = (new \objects\users_o())->getCustomersWithVehicleSubscriptions(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithVehicleSubscriptions(), + $onlyCustomerNumbers + ); // Get all customers with vehicle subscriptions $subscriptions = []; + $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false); + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $subscriptions[] = (self::getCustomerFromList((int)$customer_number, $customersWithTransactions)) ?? self::constructCustomerObject( + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $customer['meta'] = array_merge($customer['meta'] ?? [], [ + 'has_vehicle_subscription' => true, + ]); + $subscriptions[] = $customer; + continue; + } + + $subscriptions[] = self::constructCustomerObject( (int)$customer_number, - (new \objects\users_o())->getCustomerName((int)$customer_number) ?? 'Unknown Customer', + $customer_names[(int)$customer_number] ?? 'Unknown Customer', [], - true + true, + null, + [ + 'has_vehicle_subscription' => true, + ] ); } return $subscriptions; @@ -1024,6 +2682,11 @@ class InvoicingPeriodRoute */ private static function getCustomerFromList(int $customer_number, array $customersWithTransactions): ?array { + $direct = $customersWithTransactions[$customer_number] ?? null; + if (is_array($direct) && (int)($direct['customer_number'] ?? 0) === $customer_number) { + return $direct; + } + // Search for the customer in the list of customers with transactions foreach ( $customersWithTransactions as $customer ) { if ($customer['customer_number'] === $customer_number) { @@ -1037,15 +2700,21 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { - // If customersWithTransactions is not provided, get all customers with transactions in the specified date range - if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); - } // Get all customers with fixed pricing + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithFixedPricing(), + $onlyCustomerNumbers + ); + + // If customersWithTransactions is not provided, only resolve transaction customers for fixed-pricing customers. + if ($customersWithTransactions === null) { + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $customer_numbers); + } + + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); - $customer_numbers = (new \objects\users_o())->getCustomersWithFixedPricing(); // Get the customers fixed pricing $tmp_fixed_pricing = array_map(function ($arr) { // Return the customer number and price @@ -1056,30 +2725,30 @@ class InvoicingPeriodRoute ]; }, (new \objects\customer_fixed_pricing_o())->getFieldsWhere(['customer_number' => $customer_numbers], ['customer_number', 'price', 'description'])); + $fixed_pricing_by_customer_number = []; + foreach ( $tmp_fixed_pricing as $item ) { + $fixed_pricing_by_customer_number[(int)$item['customer_number']] = $item; + } + + $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false); + // Get all customers with fixed pricing $fixed_pricing = []; /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $fixed_pricing[] = self::getCustomerFromList( - (int)$customer_number, - $customersWithTransactions - ); + $customer_number = (int)$customer_number; + $fixed_pricing[] = $customers_by_number[$customer_number] ?? null; // Check if the last entry is null, if so, create a new customer object if (end($fixed_pricing) === null) { $fixed_pricing[count($fixed_pricing) - 1] = self::constructCustomerObject( - (int)$customer_number, - (new \objects\users_o())->getCustomerName((int)$customer_number) ?? (new \objects\users_o())->select((int)$customer_number)->display_name->value(), + $customer_number, + $customer_names[$customer_number] ?? 'Unknown Customer', [], true, ); } // Add the fixed pricing to the customer object - $fixed_pricing[count($fixed_pricing) - 1]['meta']['fixed_pricing'] = self::getObjectFromArray( - $tmp_fixed_pricing, - function ($item) use ($customer_number) { - return $item['customer_number'] === $customer_number; - } - ); + $fixed_pricing[count($fixed_pricing) - 1]['meta']['fixed_pricing'] = $fixed_pricing_by_customer_number[$customer_number] ?? null; } return $fixed_pricing; } @@ -1107,23 +2776,27 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Filter out customers that do not have any transactions in the specified date range - $customer_numbers = (new \objects\users_o())->getCustomersWithTankCleaning(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithTankCleaning(), + $onlyCustomerNumbers + ); self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); // Get all customers with tank cleaning $tank_cleaning = []; + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $tank_cleaning[] = self::getCustomerFromList( - (int)$customer_number, - $customersWithTransactions - ); + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $tank_cleaning[] = $customer; + } // Add the tank cleaning to the list if it has transactions } return $tank_cleaning; @@ -1137,38 +2810,36 @@ class InvoicingPeriodRoute */ private static function filterCustomersWithTransactions(array &$customer_numbers, array $customersWithTransactions): void { - // Filter out customers that do not have any transactions in the specified date range - $customer_numbers = array_filter($customer_numbers, function ($customer_number) use ($customersWithTransactions) { - // Check if the customer has any transactions in the specified date range - foreach ( $customersWithTransactions as $customer ) { - if ($customer['customer_number'] === $customer_number) { - return true; - } - } - return false; - }); + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); + $customer_numbers = array_values(array_filter($customer_numbers, static function ($customer_number) use ($customers_by_number): bool { + return isset($customers_by_number[(int)$customer_number]); + })); } /** * @throws Exception */ - private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Get all customers with tank cleaning - $customer_numbers = (new \objects\users_o())->getCustomersWithSpecialArrangements(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithSpecialArrangements(), + $onlyCustomerNumbers + ); // Filter out customers that do not have any transactions in the specified date range self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); $special_arrangements = []; + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $special_arrangements[] = self::getCustomerFromList( - (int)$customer_number, - $customersWithTransactions - ); + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $special_arrangements[] = $customer; + } } return $special_arrangements; } @@ -1176,21 +2847,28 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // Get all customers with the invoicing per order attribute - $customer_numbers = (new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']); + $customer_numbers = self::filterCustomerNumbers( + (new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']), + $onlyCustomerNumbers + ); // Filter out customers that do not have any transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); $invoicing_per_order = []; + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { // Add the invoicing per order to the list - $invoicing_per_order[] = self::getCustomerFromList((int)$customer_number, $customersWithTransactions); + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $invoicing_per_order[] = $customer; + } } return $invoicing_per_order; } @@ -1198,22 +2876,52 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } - // Get orders with the same reg_1, that has been created within 24 hours of each other - $orders = (new orders_o())->getOrdersWithPossibleDuplicates($dateFrom, $dateTo); - // Get the customer numbers from the orders + $allowedCustomerNumbers = $onlyCustomerNumbers !== null + ? array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true) + : null; + $ordersByRegistration = []; + foreach ($customersWithTransactions as $customer) { + foreach (($customer['transactions'] ?? []) as $transaction) { + $transaction = self::constructTransactionObject($transaction); + $registration = trim((string)($transaction['reg_1'] ?? '')); + if ($registration === '') { + continue; + } + $customerNumber = (int)($transaction['customer_number'] ?? 0); + if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customerNumber])) { + continue; + } + $ordersByRegistration[$registration][] = [ + 'id' => (int)$transaction['id'], + 'created_at' => (string)$transaction['date'], + 'customer_number' => $customerNumber, + 'object' => $transaction, + ]; + } + } + $orders = invoicing_period_utils::filterPossibleDuplicates($ordersByRegistration, 86400); $tmp_customer_arr = []; // Remove duplicates from the customer numbers $possible_duplicates = []; + $customer_names = []; + foreach ($orders as $order) { + $customer_names[(int)($order[0]['customer_number'] ?? 0)] = true; + } + $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_names), false); + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $orders as $order ) { // Get the customer number from the order - $customer_number = (int)$order[0]['object']->customer_id->value(); + $customer_number = (int)($order[0]['customer_number'] ?? 0); + if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customer_number])) { + continue; + } // Check if the customer number is already in the array if (isset($tmp_customer_arr[$customer_number])) { continue; @@ -1221,11 +2929,11 @@ class InvoicingPeriodRoute // Add the customer number to the array $tmp_customer_arr[$customer_number] = true; // Get the customer from the list of customers with transactions - $customer = self::getCustomerFromList($customer_number, $customersWithTransactions); + $customer = $customers_by_number[$customer_number] ?? null; // Add the customer to the possible duplicates array $possible_duplicates[] = self::constructCustomerObject( $customer_number, - (new \objects\users_o())->getCustomerName($customer_number) ?? 'Unknown Customer', + $customer_names[$customer_number] ?? 'Unknown Customer', array_map(function ($transaction) { // Construct the transaction object from the order return $transaction['object']; @@ -1236,4 +2944,4 @@ class InvoicingPeriodRoute } return $possible_duplicates; } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index b85cd71a..5149ebff 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -5,6 +5,7 @@ namespace routes; use classes\authentication; use classes\economic; use classes\email; +use classes\release_manager; use classes\recaptcha; use classes\totp; use classes\virkdata; @@ -95,6 +96,8 @@ class authRoute (new tokens_o())->delete($token); // Clear any cached session for this token try { redis->clear_auth_session($token); } catch (\Throwable $e) {} + // Clear any cached subuser session for this token + try { (new subusers_o())->invalidateSessionToken($token); } catch (\Throwable $e) {} // Return a success message $response->success(['message' => 'Logged out']); }); @@ -114,6 +117,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) { @@ -129,6 +138,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 { @@ -397,53 +407,96 @@ class authRoute /** * Check if the cvr already exists */ - $economic_response = ((new economic())->customers->customers->search([ + $economic = new economic(); + $economic_response = ($economic->customers->customers->search([ 'corporateIdentificationNumber' => (string)$cvr, ], [ 'skipPages' => 0, 'pageSize' => 1, // Since the limit is 1000, we need to set the page size to 1000. ])->collection); - // Check if the customer number is already in use in our system - if ((new users_o())->getUserByCustomerNumber((int)$companyPhone)->exists()) { + if (!is_array($economic_response)) { + $economic_response = []; + } + + $localUserExists = $this->localCustomerNumberExists($companyPhone); + $matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $companyPhone); + + if ($matchingEconomicCustomer !== null) { + if ($localUserExists) { + $response->error('Company phone number already registered', 400); + } + + $this->bootstrapLocalCustomerOrFail($companyPhone); + $this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail); + $response->success($matchingEconomicCustomer, 200); + } + + if (count($economic_response) > 0) { + $existingEconomicCustomerNumber = $this->extractEconomicCustomerNumber($economic_response[0]); + $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CONFLICT', [ + 'phase' => 'search', + 'cvr' => (string)$cvr, + 'requestedCustomerNumber' => $companyPhone, + 'existingCustomerNumber' => $existingEconomicCustomerNumber, + ]); + $response->error( + 'CVR already registered under customer number ' + . $existingEconomicCustomerNumber + . '. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.', + 409 + ); + } + + if ($localUserExists) { $response->error('Company phone number already registered', 400); } - if (count($economic_response) === 0) { - /** - * Create the customer in E-conomic - */ - // Get the customer name - $name = (new virkdata())->getCompanyInformation($cvr, '', [])->name; - /** - * $economic = new economic(); - * $economic->createCustomer( - * $customer_number, - * $name, - * $cvr, - * $invoiceEmail, - * $companyPhone, - * ); - */ - $economic = new economic(); - $result = $economic->createCustomer( - (int)$companyPhone, - $name, - (string)$cvr, - (string)$invoiceEmail, - (string)$companyPhone, - ); - $email = new email(); - $jimmyEmail = "jm@truckwash.dk"; - $infoEmail = "info@truckwash.dk"; - $email->sendWelcomeEmailToCustomer((int)$companyPhone, (string)$infoEmail); - $email->sendWelcomeEmailToCustomer((int)$companyPhone, (string)$jimmyEmail); - $email->sendWelcomeEmailToCustomer((int)$companyPhone, (string)$invoiceEmail); - /** - * Return the result - */ - $response->success($result, 201); - } else { - $response->error('CVR already registered', 400); + + // Get the CVR company information used for the e-conomic customer payload. + $companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []); + $name = (string)($companyInformation->name ?? ''); + $result = $economic->createCustomer( + (int)$companyPhone, + $name, + (int)$cvr, + (string)$invoiceEmail, + (int)$companyPhone, + (int)$contactPhone, + $companyInformation, + ); + + if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) { + $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_INVALID_CREATE_RESPONSE', [ + 'phase' => 'create', + 'cvr' => (string)$cvr, + 'requestedCustomerNumber' => $companyPhone, + 'response' => $result, + ]); + $response->error('Customer creation did not return a valid customer number.', 500); } + + $createdCustomerNumber = (int)$result->customerNumber; + if ($createdCustomerNumber !== $companyPhone) { + $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CONFLICT', [ + 'phase' => 'create', + 'cvr' => (string)$cvr, + 'requestedCustomerNumber' => $companyPhone, + 'createdCustomerNumber' => $createdCustomerNumber, + 'logId' => isset($result->logId) ? (string)$result->logId : null, + 'message' => isset($result->message) ? (string)$result->message : null, + ]); + $response->error( + 'E-conomic created the customer under customer number ' + . $createdCustomerNumber + . ' instead of the submitted phone number ' + . $companyPhone + . '. Manual cleanup or reassignment is required before retrying.', + 409 + ); + } + + $this->bootstrapLocalCustomerOrFail($companyPhone); + $this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail); + $response->success($result, 201); }); $this->post('/auth/password-reset/request', function () { @@ -584,7 +637,10 @@ class authRoute $challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '='); // rpId for passkeys - $rpId = parse_url((string)$_SERVER['HTTP_ORIGIN'], PHP_URL_HOST) ?: $_SERVER['SERVER_NAME']; + $originHost = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST); + $httpHost = isset($_SERVER['HTTP_HOST']) ? explode(':', (string)$_SERVER['HTTP_HOST'])[0] : null; + $serverName = $_SERVER['SERVER_NAME'] ?? null; + $rpId = $originHost ?: $httpHost ?: $serverName ?: 'truckwash.io'; // Create an ephemeral token to bind the challenge to the (potential) user (new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE'); @@ -691,4 +747,94 @@ class authRoute }); } -} \ No newline at end of file + + private function localCustomerNumberExists(int $customerNumber): bool + { + $rows = (new users_o())->getFieldsWhere([ + 'customer_number' => (string)$customerNumber, + ], ['id']); + + return count($rows) > 0; + } + + private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object + { + foreach ($customers as $customer) { + if (!is_object($customer)) { + continue; + } + + if ($this->extractEconomicCustomerNumber($customer) === $customerNumber) { + return $customer; + } + } + + return null; + } + + private function extractEconomicCustomerNumber(object $customer): int + { + if (!isset($customer->customerNumber) || !is_numeric($customer->customerNumber)) { + return 0; + } + + return (int)$customer->customerNumber; + } + + /** + * @throws Exception + */ + private function bootstrapLocalCustomerOrFail(int $customerNumber): users_o + { + global $response; + + $customer = (new users_o())->getUserByCustomerNumber($customerNumber); + if (method_exists($customer, 'exists') && $customer->exists()) { + return $customer; + } + + $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_FAILED', [ + 'customerNumber' => $customerNumber, + ]); + $response->error('Customer was created in e-conomic but could not be imported locally.', 500); + } + + /** + * @throws Exception + */ + private function sendRegistrationWelcomeEmails(int $customerNumber, string $invoiceEmail): void + { + $email = new email(); + $jimmyEmail = 'jm@truckwash.dk'; + //$infoEmail = "info@truckwash.dk"; + //$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian. + $email->sendWelcomeEmailToCustomer($customerNumber, $jimmyEmail); + $email->sendWelcomeEmailToCustomer($customerNumber, $invoiceEmail); + } + + private function logRegisterCvrIssue(string $action, array $context): void + { + $message = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($message === false) { + $message = 'Unable to encode register/cvr context'; + } + + (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(), + 'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(), + ], + 'release' => (new release_manager())->runtimeForPayload($payload, $this->getParametersAsArray()), + ] + ); + + return $payload; + } +} diff --git a/services/nginx/app/routes/birdVoiceCallsRoute.php b/services/nginx/app/routes/birdVoiceCallsRoute.php index 6f8e536b..f6197a3e 100644 --- a/services/nginx/app/routes/birdVoiceCallsRoute.php +++ b/services/nginx/app/routes/birdVoiceCallsRoute.php @@ -2,158 +2,221 @@ namespace routes; +require_once WD . '/classes/bird.php'; +require_once WD . '/traits/route_t.php'; +require_once WD . '/traits/bird_route_helpers_t.php'; +require_once WD . '/traits/bird_route_validation_t.php'; +require_once WD . '/modules/bird/helpers/bird_request_schemas.php'; +require_once WD . '/modules/bird/helpers/bird_payloads.php'; + +use bird\helpers\bird_no_body_payload; +use bird\helpers\bird_request_schemas; +use bird\helpers\bird_voice_bridge_payload; +use bird\helpers\bird_voice_calls_log_query_payload; +use bird\helpers\bird_voice_create_call_payload; +use bird\helpers\bird_voice_gather_payload; +use bird\helpers\bird_voice_hangup_payload; +use bird\helpers\bird_voice_list_calls_query_payload; +use bird\helpers\bird_voice_playback_payload; +use bird\helpers\bird_voice_record_payload; +use bird\helpers\bird_voice_recording_update_payload; +use bird\helpers\bird_voice_recordings_create_payload; +use bird\helpers\bird_voice_recordings_list_query_payload; +use bird\helpers\bird_voice_say_payload; +use bird\helpers\bird_voice_test_outbound_payload; +use bird\helpers\bird_voice_update_call_payload; use classes\bird; use traits\bird_route_helpers_t; +use traits\bird_route_validation_t; use traits\route_t; class birdVoiceCallsRoute { - use route_t, bird_route_helpers_t; + use route_t, bird_route_helpers_t, bird_route_validation_t; public function run(): void { + // List workspace call log + $this->get('/bird/voice/calls/log', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_log_list'); + + $client = new bird(); + $workspaceId = $this->birdResolveWorkspaceId($client); + + $query = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $query = $this->birdNormalizeCsvField($query, 'channelId'); + $query = $this->birdNormalizeCsvField($query, 'tag'); + $query = $this->birdValidateSchema($query, bird_request_schemas::voiceCallsLogQuery()); + $query = bird_voice_calls_log_query_payload::fromArray($query)->toArray(); + + $res = $client->getVoiceCallsLog($workspaceId, $query); + $response->success($res ?? []); + }, [ + 'modules_bird_voice_calls_log_list' => 'List voice call log entries via Bird', + ]); + // Create a voice call $this->post('/bird/voice/calls', function () { global $response; - // Permission: create/place a voice call via Bird self::requirePermission('modules_bird_voice_calls_create'); + $client = new bird(); - // Require workspace/channel per Bird API docs - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - $res = $client->createVoiceCall($ws, $ch, $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceCreateCallBody()); + $payload = bird_voice_create_call_payload::fromArray($payload)->toArray(); + + $res = $client->createVoiceCall($workspaceId, $channelId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_calls_create' => 'Create/place a voice call via Bird', ]); - // List voice calls (passthrough of query filters) + // List voice calls $this->get('/bird/voice/calls', function () { global $response; - // Permission: list voice calls via Bird self::requirePermission('modules_bird_voice_calls_list'); + $client = new bird(); - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $query = $this->getParametersAsArray(); - unset($query['workspaceId'], $query['channelId']); - $res = $client->listVoiceCalls($ws, $ch, $query); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $query = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $query = $this->birdNormalizeCsvField($query, 'tag'); + $query = $this->birdValidateSchema($query, bird_request_schemas::voiceListCallsQuery()); + $query = bird_voice_list_calls_query_payload::fromArray($query)->toArray(); + + $res = $client->listVoiceCalls($workspaceId, $channelId, $query); $response->success($res ?? []); }, [ 'modules_bird_voice_calls_list' => 'List voice calls via Bird', ]); - // Get a specific call by ID + // Get a voice call by ID $this->get('/bird/voice/calls/{id}', function () { global $response; - // Permission: get a specific voice call via Bird self::requirePermission('modules_bird_voice_calls_get'); + $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $res = $client->getVoiceCall($ws, $ch, $id); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $res = $client->getVoiceCall($workspaceId, $channelId, $callId); $response->success($res ?? []); }, [ 'modules_bird_voice_calls_get' => 'Get a voice call by ID via Bird', ]); + // Update a voice call by ID + $this->patch('/bird/voice/calls/{id}', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_update'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceUpdateCallBody()); + $payload = bird_voice_update_call_payload::fromArray($payload)->toArray(); + + $res = $client->updateVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_update' => 'Update a voice call by ID via Bird', + ]); + + // Answer an incoming call by ID + $this->post('/bird/voice/calls/{id}/answer', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_answer'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::noBody()); + $payload = bird_no_body_payload::fromArray($payload)->toArray(); + + $res = $client->answerVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_answer' => 'Answer a voice call by ID via Bird', + ]); + + // Mark call as ringing by ID + $this->post('/bird/voice/calls/{id}/ringing', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_ringing'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::noBody()); + $payload = bird_no_body_payload::fromArray($payload)->toArray(); + + $res = $client->ringVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_ringing' => 'Mark a voice call as ringing via Bird', + ]); + // Hang up an active call by ID $this->post('/bird/voice/calls/{id}/hangup', function () { global $response; - // Permission: hang up a specific voice call via Bird self::requirePermission('modules_bird_voice_calls_hangup'); + $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $payload = []; - $cause = $this->normalizeOptionalString($this->fromRequest('cause') ?? $this->fromQuery('cause')); - if ($cause !== '') { - $payload['cause'] = $cause; - } - $res = $client->hangupVoiceCall($ws, $ch, $id, $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceHangupBody()); + $payload = bird_voice_hangup_payload::fromArray($payload)->toArray(); + + $res = $client->hangupVoiceCall($workspaceId, $channelId, $callId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_calls_hangup' => 'Hang up a voice call by ID via Bird', ]); - // Say a message on an active call and hang up afterwards + // Playback media on an active call by ID + $this->post('/bird/voice/calls/{id}/playback', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_playback'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voicePlaybackBody()); + $payload = bird_voice_playback_payload::fromArray($payload)->toArray(); + + $res = $client->playbackVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_playback' => 'Playback media on a voice call by ID via Bird', + ]); + + // Say a message on an active call $this->post('/bird/voice/calls/{id}/say', function () { global $response; - // Permission: say a message on a voice call via Bird self::requirePermission('modules_bird_voice_calls_say'); + $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); - // Bird's /say REST API endpoint plays the message. - // To fulfill the requirement of hanging up afterwards, we should ensure the call is terminated. - // Many Bird actions support a 'hangup' field in the payload to terminate the call after the action is complete. - if (!isset($payload['hangup'])) { - $payload['hangup'] = true; - } + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceSayBody()); + $payload = bird_voice_say_payload::fromArray($payload)->toArray(); - $res = $client->sayMessage($ws, $ch, $id, $payload); + $res = $client->sayMessage($workspaceId, $channelId, $callId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_calls_say' => 'Say a message on a voice call by ID via Bird', @@ -162,56 +225,163 @@ class birdVoiceCallsRoute // Gather input on an active call $this->post('/bird/voice/calls/{id}/gather', function () { global $response; - // Permission: gather input on a voice call via Bird self::requirePermission('modules_bird_voice_calls_gather'); - $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - $res = $client->gatherMessage($ws, $ch, $id, $payload); + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceGatherBody()); + $payload = bird_voice_gather_payload::fromArray($payload)->toArray(); + + $res = $client->gatherMessage($workspaceId, $channelId, $callId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_calls_gather' => 'Gather input on a voice call by ID via Bird', ]); + // Bridge current call to another destination + $this->post('/bird/voice/calls/{id}/bridge', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_bridge'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceBridgeBody()); + $payload = bird_voice_bridge_payload::fromArray($payload)->toArray(); + + $res = $client->bridgeVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_bridge' => 'Bridge a voice call by ID via Bird', + ]); + + // Record call command endpoint + $this->post('/bird/voice/calls/{id}/record', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_record'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceRecordBody()); + $payload = bird_voice_record_payload::fromArray($payload)->toArray(); + + $res = $client->recordVoiceCall($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_record' => 'Record a voice call by ID via Bird', + ]); + + // Start call recording session + $this->post('/bird/voice/calls/{id}/recordings', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_recordings_create'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceRecordingsCreateBody()); + $payload = bird_voice_recordings_create_payload::fromArray($payload)->toArray(); + + $res = $client->createVoiceCallRecordingSession($workspaceId, $channelId, $callId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_recordings_create' => 'Create a voice call recording session via Bird', + ]); + + // List call recordings + $this->get('/bird/voice/calls/{id}/recordings', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_recordings_list'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $query = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $query = $this->birdValidateSchema($query, bird_request_schemas::voiceRecordingsListQuery()); + $query = bird_voice_recordings_list_query_payload::fromArray($query)->toArray(); + + $res = $client->listVoiceCallRecordings($workspaceId, $channelId, $callId, $query); + $response->success($res ?? []); + }, [ + 'modules_bird_voice_calls_recordings_list' => 'List voice call recordings via Bird', + ]); + + // Get single call recording + $this->get('/bird/voice/calls/{id}/recordings/{recordingId}', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_recordings_get'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + $recordingId = $this->birdResolveRouteRecordingId(); + + $res = $client->getVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId); + $response->success($res ?? []); + }, [ + 'modules_bird_voice_calls_recordings_get' => 'Get a voice call recording by ID via Bird', + ]); + + // Update single call recording + $this->patch('/bird/voice/calls/{id}/recordings/{recordingId}', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_recordings_update'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + $recordingId = $this->birdResolveRouteRecordingId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceRecordingUpdateBody()); + $payload = bird_voice_recording_update_payload::fromArray($payload)->toArray(); + + $res = $client->updateVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_calls_recordings_update' => 'Update a voice call recording via Bird', + ]); + + // Get call insights + $this->get('/bird/voice/calls/{id}/insights', function () { + global $response; + self::requirePermission('modules_bird_voice_calls_insights_get'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $res = $client->getVoiceCallInsights($workspaceId, $channelId, $callId); + $response->success($res ?? []); + }, [ + 'modules_bird_voice_calls_insights_get' => 'Get voice call insights by call ID via Bird', + ]); + // Place test outbound call and hang up when accepted $this->post('/bird/voice/calls/test-outbound', function () { global $response; self::requirePermission('modules_bird_voice_calls_test_outbound'); + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceTestOutboundBody()); + $payload = bird_voice_test_outbound_payload::fromArray($payload)->toArray(); try { - $result = $client->createOutboundTestCallAndHangupWhenAccepted($ws, $ch, $payload); + $result = $client->createOutboundTestCallAndHangupWhenAccepted($workspaceId, $channelId, $payload); $response->success($result); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); diff --git a/services/nginx/app/routes/birdVoiceFlashCallsRoute.php b/services/nginx/app/routes/birdVoiceFlashCallsRoute.php index 6b178c5c..132be2d8 100644 --- a/services/nginx/app/routes/birdVoiceFlashCallsRoute.php +++ b/services/nginx/app/routes/birdVoiceFlashCallsRoute.php @@ -2,38 +2,42 @@ namespace routes; +require_once WD . '/classes/bird.php'; +require_once WD . '/traits/route_t.php'; +require_once WD . '/traits/bird_route_helpers_t.php'; +require_once WD . '/traits/bird_route_validation_t.php'; +require_once WD . '/modules/bird/helpers/bird_request_schemas.php'; +require_once WD . '/modules/bird/helpers/bird_payloads.php'; + +use bird\helpers\bird_flash_create_payload; +use bird\helpers\bird_flash_end_payload; +use bird\helpers\bird_flash_hangup_payload; +use bird\helpers\bird_flash_list_query_payload; +use bird\helpers\bird_request_schemas; use classes\bird; use traits\bird_route_helpers_t; +use traits\bird_route_validation_t; use traits\route_t; class birdVoiceFlashCallsRoute { - use route_t, bird_route_helpers_t; + use route_t, bird_route_helpers_t, bird_route_validation_t; public function run(): void { // Create a flash call $this->post('/bird/voice/flash-calls', function () { global $response; - // Permission: create/place a flash call via Bird self::requirePermission('modules_bird_voice_flash_calls_create'); + $client = new bird(); - // Require workspace/channel per Bird API docs - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - $res = $client->sendPostRequest($base, $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::flashCreateBody()); + $payload = bird_flash_create_payload::fromArray($payload)->toArray(); + + $res = $client->createFlashCall($workspaceId, $channelId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_flash_calls_create' => 'Create/place a flash call via Bird', @@ -42,24 +46,16 @@ class birdVoiceFlashCallsRoute // List flash calls $this->get('/bird/voice/flash-calls', function () { global $response; - // Permission: list flash calls via Bird self::requirePermission('modules_bird_voice_flash_calls_list'); + $client = new bird(); - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $query = $this->getParametersAsArray(); - unset($query['workspaceId'], $query['channelId']); - $res = $client->sendGetRequest($base, $query); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $query = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $query = $this->birdValidateSchema($query, bird_request_schemas::flashListQuery()); + $query = bird_flash_list_query_payload::fromArray($query)->toArray(); + + $res = $client->listFlashCalls($workspaceId, $channelId, $query); $response->success($res ?? []); }, [ 'modules_bird_voice_flash_calls_list' => 'List flash calls via Bird', @@ -68,87 +64,71 @@ class birdVoiceFlashCallsRoute // Get a specific flash call by ID $this->get('/bird/voice/flash-calls/{id}', function () { global $response; - // Permission: get a specific flash call via Bird self::requirePermission('modules_bird_voice_flash_calls_get'); + $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $res = $client->sendGetRequest($base . '/' . rawurlencode($id)); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $res = $client->getFlashCall($workspaceId, $channelId, $callId); $response->success($res ?? []); }, [ 'modules_bird_voice_flash_calls_get' => 'Get a flash call by ID via Bird', ]); - // Complete/end a flash call by ID (POST to the resource) + // Complete/end a flash call by ID $this->post('/bird/voice/flash-calls/{id}', function () { global $response; - // Permission: complete/end a flash call via Bird self::requirePermission('modules_bird_voice_flash_calls_end'); + $client = new bird(); - $id = (string)$this->fromRoute('id'); - if ($id === null || $id === '') { - $response->error('Missing id', 400); - } - // Forward any body fields (e.g., result/status) transparently - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - $res = $client->sendPostRequest($base . '/' . rawurlencode($id), $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + $callId = $this->birdResolveRouteCallId(); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::flashEndBody()); + $payload = bird_flash_end_payload::fromArray($payload)->toArray(); + + $res = $client->endFlashCall($workspaceId, $channelId, $callId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ 'modules_bird_voice_flash_calls_end' => 'Complete/end a flash call by ID via Bird', ]); - // Complete/end a flash call by using from/to numbers - $this->post('/bird/voice/flash-calls/end', function () { + // Hang up flash calls by payload + $this->post('/bird/voice/flash-calls/hangup', function () { global $response; - // Permission: complete/end a flash call by numbers via Bird - self::requirePermission('modules_bird_voice_flash_calls_end_by_numbers'); + self::requirePermission('modules_bird_voice_flash_calls_hangup'); + $client = new bird(); - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); - } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - $base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls'; - $payload = $this->getParametersAsArray(); - unset($payload['workspaceId'], $payload['channelId']); - // Basic validation hints: expect 'from' and 'to' but let Bird validate strictly - $res = $client->sendPostRequest($base . '/end', $payload); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::flashHangupBody()); + $payload = bird_flash_hangup_payload::fromArray($payload)->toArray(); + + $res = $client->hangupFlashCall($workspaceId, $channelId, $payload); $response->success($res ?? ['status' => 'ok']); }, [ - 'modules_bird_voice_flash_calls_end_by_numbers' => 'Complete/end a flash call using from/to numbers via Bird', + 'modules_bird_voice_flash_calls_hangup' => 'Hang up flash calls via Bird', + ]); + + // Compatibility alias for flash hangup endpoint + $this->post('/bird/voice/flash-calls/end', function () { + global $response; + self::requirePermission('modules_bird_voice_flash_calls_end_by_numbers'); + + $client = new bird(); + [$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client); + + $payload = $this->birdPayloadWithout(['workspaceId', 'channelId']); + $payload = $this->birdValidateSchema($payload, bird_request_schemas::flashHangupBody()); + $payload = bird_flash_hangup_payload::fromArray($payload)->toArray(); + + $res = $client->hangupFlashCall($workspaceId, $channelId, $payload); + $response->success($res ?? ['status' => 'ok']); + }, [ + 'modules_bird_voice_flash_calls_end_by_numbers' => 'Compatibility alias for hanging up flash calls via Bird', ]); } } diff --git a/services/nginx/app/routes/birdVoiceWebhooksRoute.php b/services/nginx/app/routes/birdVoiceWebhooksRoute.php index 084a3263..a985942b 100644 --- a/services/nginx/app/routes/birdVoiceWebhooksRoute.php +++ b/services/nginx/app/routes/birdVoiceWebhooksRoute.php @@ -4,11 +4,11 @@ namespace routes; use classes\bird; use classes\redis; -use classes\slack; +use DateTimeImmutable; +use DateTimeZone; +use InvalidArgumentException; use objects\department_gates_o; use objects\departments_o; -use objects\subusers_o; -use objects\users_o; use traits\bird_route_helpers_t; use traits\route_t; @@ -16,288 +16,1321 @@ class birdVoiceWebhooksRoute { use route_t, bird_route_helpers_t; - private const IVR_STATE_TTL_SECONDS = 600; + private const IVR_STATE_TTL_SECONDS = 1200; private const IVR_STATE_PREFIX = 'bird_ivr_state:'; + private const IVR_LOCK_PREFIX = 'bird_ivr_lock:'; + private const IVR_LOCK_TTL_SECONDS = 180; private const IVR_STAGE_DEPARTMENT_SELECT = 'department_select'; private const IVR_STAGE_GATE_TYPE_SELECT = 'gate_type_select'; private const IVR_GATE_TYPE_ENTRANCE = 'entrance'; private const IVR_GATE_TYPE_EXIT = 'exit'; + private const DEFAULT_WAIT_TIMEOUT = 'PT10M'; + private const DEFAULT_GATHER_RETRIES = 3; + private const DEFAULT_GATHER_TIMEOUT_SECONDS = 30; + private const DEFAULT_GATHER_END_KEY = '#'; + private const DEFAULT_GATHER_SAY_LOCALE = 'en-US'; + private const DEFAULT_GATHER_SAY_VOICE = 'female'; + private const RESPONSE_MODE_COMMAND = 'command'; + private const RESPONSE_MODE_FLOW = 'flow'; public function run(): void { - $this->post('/bird/voice/calls/webhook/inbound', function () { + $this->post('/bird/voice/calls/webhook/inbound', function (): void { global $response; - //self::requirePermission('modules_bird_voice_call_webhooks_trigger'); - $client = new bird(); + $client = $this->resolveBirdClient(); + $payload = $this->readInboundWebhookPayload(); - $ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); - $ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); - if ($ws === '') { - $ws = $this->getConfiguredWorkspaceId($client); + try { + if (!$this->isSupportedInboundRequest($payload)) { + throw new InvalidArgumentException('Malformed inbound Bird webhook payload'); + } + + $result = $this->handleInboundCallLifecycle( + $client, + $payload, + $this->resolveInboundCallId($payload), + $this->resolveInboundWorkspaceId($client, $payload), + $this->resolveInboundChannelId($client, $payload), + ); + + $response->rawJson($result, (int)($result['statusCode'] ?? 200)); + } catch (InvalidArgumentException $e) { + $body = $this->buildTransportErrorResponse($payload, 400, $e->getMessage()); + $response->rawJson($body, 400); + } catch (\Throwable $e) { + $body = $this->buildTransportErrorResponse($payload, 500, $e->getMessage()); + $response->rawJson($body, 500); } - if ($ch === '') { - $ch = $this->getConfiguredChannelId($client); - } - if ($ws === '' || $ch === '') { - $response->error('Missing required parameters: workspaceId, channelId', 400); - } - - $payload = $this->getParametersAsArray(); - $callId = $this->normalizeOptionalString($this->fromRequest('call_id') ?? $this->fromQuery('call_id')); - $digits = $this->normalizeOptionalString($payload['digits'] ?? $this->fromRequest('digits') ?? $this->fromQuery('digits')); - if ($callId === '') { - $response->error('Missing required parameter: call_id', 400); - } - - $caller = $this->extractCallerPhone($payload); - if ($caller === null) { - $this->say($client, $ws, $ch, $callId, 'Vi kunne ikke identificere dit telefonnummer. Kontakt venligst support.', true); - return; - } - - [$countryCode, $localPhone] = $caller; - - $slack = new Slack(); - $slack->send_message('Webhook received for incoming voice call (ID: ' . $callId . '): ' . json_encode($payload), 'Bird Voice Call Webhooks'); - - if (!$this->isRegisteredCaller($countryCode, $localPhone)) { - $this->say($client, $ws, $ch, $callId, 'Du er ikke sat op til automatisk portaabning med landekode ' . $countryCode . ' og telefonnummer ' . $localPhone . '. Kontakt venligst support for at blive sat op.', true); - return; - } - - $departments = $this->getCallerDepartments($countryCode, $localPhone); - if (empty($departments)) { - $this->clearIvrState($callId); - $this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde nogen afdelinger tilknyttet din profil. Kontakt venligst support.', true); - return; - } - - if ($digits !== '' && $callId !== '') { - $this->handleGatherResponse($client, $ws, $ch, $callId, $digits, $countryCode, $localPhone, $departments); - return; - } - - $this->promptForDepartmentSelection($client, $ws, $ch, $callId, $departments, $countryCode, $localPhone); - }, [ 'modules_bird_voice_call_webhooks_trigger' => 'Trigger a voice call via Bird webhooks', ]); } - protected function handleGatherResponse( + public function handleInboundCallLifecycle( bird $client, - string $ws, - string $ch, + array $payload, string $callId, - string $digits, - int $countryCode, - int $phone, - array $departments - ): void { - $state = $this->readIvrState($callId); - if ($state === null || !$this->isStateForCaller($state, $countryCode, $phone)) { - $this->promptForDepartmentSelection($client, $ws, $ch, $callId, $departments, $countryCode, $phone); - return; + string $workspaceId = '', + string $channelId = '' + ): array { + $callId = $this->normalizeOptionalString($callId); + if ($callId === '') { + throw new InvalidArgumentException('Missing required parameter: callId'); } - $stage = (string)($state['stage'] ?? ''); - if ($stage === self::IVR_STAGE_DEPARTMENT_SELECT) { - $this->handleDepartmentSelectionDigit($client, $ws, $ch, $callId, $digits, $state, $countryCode, $phone); - return; - } - if ($stage === self::IVR_STAGE_GATE_TYPE_SELECT) { - $this->handleGateTypeSelectionDigit($client, $ws, $ch, $callId, $digits, $state); - return; + $requestId = $this->extractRequestIdFromPayload($payload); + if ($requestId === '') { + $requestId = $this->generateUuidV4(); } - $this->clearIvrState($callId); - $this->promptForDepartmentSelection($client, $ws, $ch, $callId, $departments, $countryCode, $phone); + $resolvedWorkspaceId = $this->normalizeOptionalString($workspaceId); + if ($resolvedWorkspaceId === '') { + $resolvedWorkspaceId = $this->getConfiguredWorkspaceId($client); + } + + $resolvedChannelId = $this->normalizeOptionalString($channelId); + if ($resolvedChannelId === '') { + $resolvedChannelId = $this->getConfiguredChannelId($client); + } + + if ($resolvedWorkspaceId === '' || $resolvedChannelId === '') { + throw new InvalidArgumentException('Missing required parameters: workspaceId, channelId'); + } + + $state = $this->normalizeState($this->readIvrState($callId) ?? []); + $responseMode = $this->responseModeForPayload($payload, $state); + + if (!$this->acquireIvrLock($callId)) { + return $this->buildLifecycleCompletionResponse( + $responseMode, + $callId, + [ + 'request_id' => $requestId, + 'workspace_id' => $resolvedWorkspaceId, + 'channel_id' => $resolvedChannelId, + ], + 'ignored', + 'ignored', + 'Call is already being processed.', + false, + null, + null, + null, + 'lock_not_acquired', + ); + } + + try { + if ($this->shouldBootstrapIvrState($payload, $state)) { + $state = $this->buildInitialState( + $callId, + $resolvedWorkspaceId, + $resolvedChannelId, + $payload, + $requestId, + $responseMode, + ); + + $this->acceptInboundCall($client, $resolvedWorkspaceId, $resolvedChannelId, $callId); + + $result = $this->bootstrapIvrState($state); + $state = $result['state']; + + if (($result['action'] ?? '') === 'complete') { + $this->clearIvrState($callId); + + return $this->buildLifecycleCompletionResponse( + $responseMode, + $callId, + $state, + 'completed', + 'no_action', + (string)($result['message'] ?? 'No phone-controlled gates are configured.'), + false, + null, + null, + null, + (string)($result['reason'] ?? 'no_eligible_departments'), + ); + } + + if (($result['action'] ?? '') === 'open_gate') { + return $this->openSelectedGateAndComplete($callId, $state, $responseMode); + } + + $this->saveIvrState($callId, $state); + + return $this->buildLifecycleGatherResponse( + $responseMode, + $callId, + $state, + $this->buildPromptTextForState($state), + false, + ); + } + + if ($state === [] || $this->normalizeOptionalString($state['stage'] ?? '') === '') { + return $this->buildLifecycleCompletionResponse( + $responseMode, + $callId, + [ + 'request_id' => $requestId, + 'workspace_id' => $resolvedWorkspaceId, + 'channel_id' => $resolvedChannelId, + ], + 'ignored', + 'ignored', + 'No active IVR state was found for this call.', + false, + null, + null, + null, + 'state_not_found', + ); + } + + $state['request_id'] = $this->normalizeOptionalString($state['request_id'] ?? '') !== '' + ? $state['request_id'] + : $requestId; + $state['workspace_id'] = $resolvedWorkspaceId; + $state['channel_id'] = $resolvedChannelId; + + $rawInput = $this->extractGatherKeysFromEvent($payload); + if ($rawInput === null) { + $rawInput = $this->extractDtmfInput($payload); + } + + $selection = $this->normalizeMenuDigitInput($rawInput); + if ($selection === null) { + $isInvalid = $rawInput !== null && trim($rawInput) !== ''; + if ($isInvalid) { + $state['last_invalid_input'] = $rawInput; + $state['invalid_selection_count'] = max(0, (int)($state['invalid_selection_count'] ?? 0)) + 1; + } + + $this->saveIvrState($callId, $state); + + return $this->buildLifecycleGatherResponse( + $responseMode, + $callId, + $state, + $this->buildPromptTextForState($state, $isInvalid), + true, + ); + } + + $result = $this->applyMenuSelection($state, $selection); + $state = $result['state']; + + if (($result['action'] ?? '') === 'open_gate') { + return $this->openSelectedGateAndComplete($callId, $state, $responseMode); + } + + if (($result['action'] ?? '') === 'complete') { + $this->clearIvrState($callId); + + return $this->buildLifecycleCompletionResponse( + $responseMode, + $callId, + $state, + 'completed', + 'no_action', + (string)($result['message'] ?? 'No phone-controlled gates are configured.'), + false, + $this->selectedDepartmentId($state), + $this->selectedGateType($state), + $this->selectedGateId($state), + (string)($result['reason'] ?? 'no_eligible_gates_for_department'), + ); + } + + if (($result['action'] ?? '') === 'invalid_selection') { + $state['last_invalid_input'] = $selection; + $state['invalid_selection_count'] = max(0, (int)($state['invalid_selection_count'] ?? 0)) + 1; + } else { + $state['last_invalid_input'] = ''; + $state['invalid_selection_count'] = 0; + } + + $this->saveIvrState($callId, $state); + + return $this->buildLifecycleGatherResponse( + $responseMode, + $callId, + $state, + $this->buildPromptTextForState($state, ($result['action'] ?? '') === 'invalid_selection'), + true, + ); + } finally { + $this->releaseIvrLock($callId); + } + } + protected function buildInitialState( + string $callId, + string $workspaceId, + string $channelId, + array $payload, + string $requestId, + string $responseMode + ): array { + return [ + 'call_id' => $callId, + 'workspace_id' => $workspaceId, + 'channel_id' => $channelId, + 'request_id' => $requestId, + 'response_mode' => $responseMode, + 'gather_template' => $this->normalizeGatherTemplate($this->extractInitialGatherTemplate($payload)), + 'resume_action' => $this->extractResumeActionFromPayload($payload) ?: 'continue', + 'wait_timeout' => $this->extractWaitTimeoutFromPayload($payload) ?: self::DEFAULT_WAIT_TIMEOUT, + 'invalid_selection_count' => 0, + 'last_invalid_input' => '', + ]; } - protected function handleDepartmentSelectionDigit( - bird $client, - string $ws, - string $ch, - string $callId, - string $digits, - array $state, - int $countryCode, - int $phone - ): void { - $optionMap = is_array($state['department_options'] ?? null) ? $state['department_options'] : []; - $selected = $this->resolveDepartmentIdByDigit($optionMap, $digits); - if ($selected === null) { - $this->promptForDepartmentRetry($client, $ws, $ch, $callId, $state); - return; + protected function bootstrapIvrState(array $state): array + { + $eligibleDepartments = $this->loadEligibleDepartmentSummaries(); + if ($eligibleDepartments === []) { + return [ + 'action' => 'complete', + 'reason' => 'no_eligible_departments', + 'message' => 'No phone-controlled gates are configured.', + 'state' => $state, + ]; + } + + $departmentOptions = $this->buildDepartmentOptionMap($eligibleDepartments); + if ($departmentOptions === []) { + return [ + 'action' => 'complete', + 'reason' => 'no_eligible_departments', + 'message' => 'No phone-controlled gates are configured.', + 'state' => $state, + ]; + } + + $state['stage'] = self::IVR_STAGE_DEPARTMENT_SELECT; + $state['department_options'] = $departmentOptions; + $state['selected_department_id'] = null; + $state['selected_department_name'] = ''; + $state['selected_gate_type'] = ''; + $state['available_gate_types'] = []; + $state['gate_options'] = []; + $state['gate_id'] = null; + + return ['action' => 'gather', 'state' => $state]; + } + + protected function applyMenuSelection(array $state, string $digit): array + { + $stage = $this->normalizeOptionalString($state['stage'] ?? ''); + + if ($stage === self::IVR_STAGE_DEPARTMENT_SELECT) { + $departmentOption = $this->resolveSelectedDepartmentOption((array)($state['department_options'] ?? []), $digit); + if ($departmentOption === null) { + return ['action' => 'invalid_selection', 'state' => $state]; + } + + return $this->advanceStateForDepartment($state, $departmentOption); + } + + if ($stage === self::IVR_STAGE_GATE_TYPE_SELECT) { + $gateOptions = $this->normalizeGateOptions((array)($state['gate_options'] ?? [])); + $gateType = $this->resolveGateTypeByDigit($digit, $gateOptions); + if ($gateType === null) { + return ['action' => 'invalid_selection', 'state' => $state]; + } + + $departmentId = $this->selectedDepartmentId($state); + if ($departmentId === null) { + return [ + 'action' => 'complete', + 'reason' => 'missing_department', + 'message' => 'The selected department is no longer available.', + 'state' => $state, + ]; + } + + $gate = $this->resolvePhoneCallGate($departmentId, $gateType); + if ($gate === null || !$gate->exists()) { + $state['selected_gate_type'] = $gateType; + $state['gate_id'] = null; + + return [ + 'action' => 'complete', + 'reason' => 'gate_not_found', + 'message' => $this->buildMissingGateMessage($gateType), + 'state' => $state, + ]; + } + + $state['selected_gate_type'] = $gateType; + $state['gate_id'] = (int)$gate->id; + + return ['action' => 'open_gate', 'state' => $state]; + } + + return ['action' => 'invalid_selection', 'state' => $state]; + } + + protected function advanceStateForDepartment(array $state, array $departmentOption): array + { + $departmentId = (int)($departmentOption['department_id'] ?? 0); + if ($departmentId <= 0) { + return [ + 'action' => 'complete', + 'reason' => 'missing_department', + 'message' => 'The selected department is no longer available.', + 'state' => $state, + ]; + } + + $state['selected_department_id'] = $departmentId; + $state['selected_department_name'] = (string)($departmentOption['department_name'] ?? $this->getDepartmentNameById($departmentId)); + $state['department_options'] = (array)($state['department_options'] ?? []); + $state['gate_id'] = null; + $state['selected_gate_type'] = ''; + + $availableGateTypes = $this->extractAvailableGateTypes($departmentOption); + $state['available_gate_types'] = $availableGateTypes; + $state['gate_options'] = $this->buildGateOptionMap($availableGateTypes); + + if ($availableGateTypes === []) { + return [ + 'action' => 'complete', + 'reason' => 'no_eligible_gates_for_department', + 'message' => 'The selected department has no phone-controlled gates.', + 'state' => $state, + ]; + } + + $sharedGate = $this->resolveSharedPhoneCallGate($departmentId, $availableGateTypes); + if ($sharedGate !== null && $sharedGate->exists()) { + $state['gate_id'] = (int)$sharedGate->id; + $state['gate_options'] = []; + + return ['action' => 'open_gate', 'state' => $state]; } $state['stage'] = self::IVR_STAGE_GATE_TYPE_SELECT; - $state['selected_department'] = $selected; - $state['country_code'] = $countryCode; - $state['phone'] = $phone; - $state['selected_department_name'] = $this->getDepartmentNameById($selected); - $this->saveIvrState($callId, $state); - $this->promptForGateTypeSelection($client, $ws, $ch, $callId, $selected, (string)$state['selected_department_name']); + return ['action' => 'gather', 'state' => $state]; } - protected function handleGateTypeSelectionDigit( - bird $client, - string $ws, - string $ch, - string $callId, - string $digits, - array $state - ): void { - $departmentId = (int)($state['selected_department'] ?? 0); - if ($departmentId <= 0) { + protected function openSelectedGateAndComplete(string $callId, array $state, string $responseMode): array + { + $departmentId = $this->selectedDepartmentId($state); + $gateType = $this->selectedGateType($state); + $gateId = $this->selectedGateId($state); + + if ($departmentId === null) { $this->clearIvrState($callId); - $this->say($client, $ws, $ch, $callId, 'Sessionen er udloeebet. Ring venligst op igen.', true); - return; + + return $this->buildLifecycleCompletionResponse( + $responseMode, + $callId, + $state, + 'completed', + 'no_action', + 'The selected gate is no longer available.', + false, + $departmentId, + $gateType, + $gateId, + 'gate_not_found', + ); } - $gateType = $this->resolveGateTypeByDigit($digits); - if ($gateType === null) { - $departmentName = (string)($state['selected_department_name'] ?? $this->getDepartmentNameById($departmentId)); - $this->promptForGateTypeRetry($client, $ws, $ch, $callId, $departmentId, $departmentName); - return; + $gate = $gateId !== null ? $this->resolvePhoneCallGateById($gateId) : null; + if (($gate === null || !$gate->exists()) && $gateType !== null) { + $gate = $this->resolvePhoneCallGate($departmentId, $gateType); } - $gate = $gateType === self::IVR_GATE_TYPE_ENTRANCE - ? (new department_gates_o())->getEntranceGate($departmentId) - : (new department_gates_o())->getExitGate($departmentId); - - $this->clearIvrState($callId); - if ($gate === null || !$gate->exists()) { - $directionLabel = $gateType === self::IVR_GATE_TYPE_ENTRANCE ? 'indgang' : 'udgang'; - $this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde en ' . $directionLabel . 'sport for den valgte afdeling.', true); - return; - } - - if ($this->callGateToOpen($gate)) { - $this->say($client, $ws, $ch, $callId, 'Aabner port: ' . $gate->name->value(), true); - return; - } - - $this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relaeet for ' . $gate->name->value() . '. Kontakt venligst support.', true); - } - - protected function getCallerDepartments(int $countryCode, int $phone): array - { - // Return all visible departments. - $tmp = (new departments_o())->getFieldsWhere([ - 'visible' => 1, - ], ['id']); - return array_map(function ($row) { - return (int)$row['id']; - }, $tmp); - } - - protected function promptForDepartmentSelection( - bird $client, - string $ws, - string $ch, - string $callId, - array $departments, - int $countryCode, - int $phone - ): void { - $optionMap = $this->buildDepartmentOptionMap($departments); - if (empty($optionMap)) { - $this->say($client, $ws, $ch, $callId, 'Der er ingen valgbare afdelinger lige nu. Kontakt venligst support.', true); - return; - } - - $state = [ - 'stage' => self::IVR_STAGE_DEPARTMENT_SELECT, - 'department_options' => $optionMap, - 'selected_department' => null, - 'country_code' => $countryCode, - 'phone' => $phone, - ]; - $this->saveIvrState($callId, $state); - - $text = $this->buildDepartmentPromptText($optionMap); - $this->gather($client, $ws, $ch, $callId, $text); - } - - protected function promptForDepartmentRetry(bird $client, string $ws, string $ch, string $callId, array $state): void - { - $optionMap = is_array($state['department_options'] ?? null) ? $state['department_options'] : []; - if (empty($optionMap)) { $this->clearIvrState($callId); - $this->say($client, $ws, $ch, $callId, 'Sessionen er udloeebet. Ring venligst op igen.', true); + + return $this->buildLifecycleCompletionResponse( + $responseMode, + $callId, + $state, + 'completed', + 'no_action', + $this->buildMissingGateMessage($gateType), + false, + $departmentId, + $gateType, + $gateId, + 'gate_not_found', + ); + } + + try { + $this->triggerGateOpen($gate); + $this->clearIvrState($callId); + + return $this->buildLifecycleCompletionResponse( + $responseMode, + $callId, + $state, + 'completed', + 'gate_opened', + $this->buildGateOpenedMessage($gateType), + true, + $departmentId, + $gateType, + (int)$gate->id, + 'gate_opened', + ); + } catch (\Throwable $e) { + $this->clearIvrState($callId); + + return $this->buildLifecycleCompletionResponse( + $responseMode, + $callId, + $state, + 'failed', + 'gate_open_failed', + $this->buildGateOpenFailedMessage($gateType, $e->getMessage()), + false, + $departmentId, + $gateType, + (int)$gate->id, + 'gate_open_failed', + ); + } + } + + protected function completeActionResponse( + string $callId, + array $state, + string $status, + string $action, + string $message, + bool $gateOpened, + ?int $departmentId, + ?string $gateType, + ?int $gateId, + string $reason + ): array { + return [ + 'requestId' => $this->requestIdForState($state), + 'result' => [ + 'callId' => $callId, + 'status' => $status, + 'action' => $action, + 'message' => $message, + 'departmentId' => $departmentId, + 'gateType' => $gateType, + 'gateId' => $gateId, + 'gateOpened' => $gateOpened, + ], + 'resumeData' => [ + 'action' => $this->resumeActionForState($state), + 'completed' => true, + 'result' => $reason, + 'gateOpened' => $gateOpened, + ], + 'completedAt' => $this->currentIsoTimestamp(), + 'statusCode' => 200, + 'statusText' => 'OK', + ]; + } + + protected function buildLifecycleCompletionResponse( + string $responseMode, + string $callId, + array $state, + string $status, + string $action, + string $message, + bool $gateOpened, + ?int $departmentId, + ?string $gateType, + ?int $gateId, + string $reason + ): array { + if ($responseMode === self::RESPONSE_MODE_COMMAND) { + return $this->completeActionResponse( + $callId, + $state, + $status, + $action, + $message, + $gateOpened, + $departmentId, + $gateType, + $gateId, + $reason, + ); + } + + return $this->buildFlowCompletionResponse( + $callId, + $state, + $status, + $action, + $message, + $gateOpened, + $departmentId, + $gateType, + $gateId, + $reason, + ); + } + + protected function buildGatherAcceptedResponse( + string $callId, + array $state, + string $prompt, + bool $resumed + ): array { + $commandId = $this->generateUuidV4(); + $gatherOptions = $this->buildGatherCommandOptions($state, $prompt); + $gatherPayload = [ + 'duration' => 0, + 'keys' => '', + 'speech' => '', + 'speechConfidence' => 0, + ]; + + foreach ($gatherOptions as $key => $value) { + $gatherPayload[$key] = $value; + } + + $response = [ + 'event' => [ + 'callCommand' => [ + 'callId' => $callId, + 'channelId' => $this->normalizeOptionalString($state['channel_id'] ?? ''), + 'commandId' => $commandId, + 'gather' => $gatherPayload, + 'platformId' => 'voice-messagebird', + 'platformReferenceId' => $callId, + 'type' => 'gather', + ], + ], + 'requestId' => $this->requestIdForState($state), + 'result' => [ + 'callId' => $callId, + 'command' => 'gather', + 'id' => $commandId, + 'status' => 'accepted', + ], + 'resumeData' => [ + 'action' => $this->resumeActionForState($state), + ], + 'statusCode' => 202, + 'statusText' => 'Accepted', + 'suspendedAt' => $this->currentIsoTimestamp(), + ]; + + if ($resumed) { + $response['resumedAt'] = $this->currentIsoTimestamp(); + } + + return $response; + } + + protected function buildLifecycleGatherResponse( + string $responseMode, + string $callId, + array $state, + string $prompt, + bool $resumed + ): array { + if ($responseMode === self::RESPONSE_MODE_COMMAND) { + return $this->buildGatherAcceptedResponse($callId, $state, $prompt, $resumed); + } + + return $this->buildFlowGatherResponse($callId, $state, $prompt, $resumed); + } + + protected function buildFlowGatherResponse( + string $callId, + array $state, + string $prompt, + bool $resumed + ): array { + $gatherOptions = $this->buildGatherCommandOptions($state, $prompt); + + return [ + 'requestId' => $this->requestIdForState($state), + 'callId' => $callId, + 'status' => 'gather', + 'completed' => false, + 'stage' => $this->normalizeOptionalString($state['stage'] ?? ''), + 'prompt' => $prompt, + 'gather' => [ + 'input' => (string)($gatherOptions['input'] ?? 'dtmf'), + 'maxNumKeys' => (int)($gatherOptions['maxNumKeys'] ?? 1), + 'endKey' => $this->normalizeOptionalString($gatherOptions['endKey'] ?? self::DEFAULT_GATHER_END_KEY), + 'timeout' => (int)($gatherOptions['timeout'] ?? self::DEFAULT_GATHER_TIMEOUT_SECONDS), + 'retries' => (int)($gatherOptions['retries'] ?? self::DEFAULT_GATHER_RETRIES), + 'say' => [ + 'locale' => (string)((is_array($gatherOptions['say'] ?? null) ? ($gatherOptions['say']['locale'] ?? '') : '') ?: self::DEFAULT_GATHER_SAY_LOCALE), + 'voice' => (string)((is_array($gatherOptions['say'] ?? null) ? ($gatherOptions['say']['voice'] ?? '') : '') ?: self::DEFAULT_GATHER_SAY_VOICE), + 'text' => $prompt, + ], + ], + // Bird's relay step is unreliable with nested JSON paths from HTTP responses, + // so expose the active gather contract as flat aliases as well. + 'gatherInput' => (string)($gatherOptions['input'] ?? 'dtmf'), + 'gatherMaxNumKeys' => (int)($gatherOptions['maxNumKeys'] ?? 1), + 'gatherEndKey' => $this->normalizeOptionalString($gatherOptions['endKey'] ?? self::DEFAULT_GATHER_END_KEY), + 'gatherTimeout' => (int)($gatherOptions['timeout'] ?? self::DEFAULT_GATHER_TIMEOUT_SECONDS), + 'gatherRetries' => (int)($gatherOptions['retries'] ?? self::DEFAULT_GATHER_RETRIES), + 'gatherSayLocale' => (string)((is_array($gatherOptions['say'] ?? null) ? ($gatherOptions['say']['locale'] ?? '') : '') ?: self::DEFAULT_GATHER_SAY_LOCALE), + 'gatherSayVoice' => (string)((is_array($gatherOptions['say'] ?? null) ? ($gatherOptions['say']['voice'] ?? '') : '') ?: self::DEFAULT_GATHER_SAY_VOICE), + 'selection' => [ + 'departmentId' => $this->selectedDepartmentId($state), + 'departmentName' => $this->normalizeOptionalString($state['selected_department_name'] ?? ''), + 'gateType' => $this->selectedGateType($state), + 'gateId' => $this->selectedGateId($state), + ], + 'invalidSelectionCount' => max(0, (int)($state['invalid_selection_count'] ?? 0)), + 'resumed' => $resumed, + 'statusCode' => 200, + 'statusText' => 'OK', + ]; + } + + protected function buildFlowCompletionResponse( + string $callId, + array $state, + string $status, + string $action, + string $message, + bool $gateOpened, + ?int $departmentId, + ?string $gateType, + ?int $gateId, + string $reason + ): array { + return [ + 'requestId' => $this->requestIdForState($state), + 'callId' => $callId, + 'status' => $status, + 'action' => $action, + 'message' => $message, + 'completed' => true, + 'result' => $reason, + 'gateOpened' => $gateOpened, + 'departmentId' => $departmentId, + 'departmentName' => $this->normalizeOptionalString($state['selected_department_name'] ?? ''), + 'gateType' => $gateType, + 'gateId' => $gateId, + 'statusCode' => 200, + 'statusText' => 'OK', + 'completedAt' => $this->currentIsoTimestamp(), + ]; + } + protected function buildGatherCommandOptions(array $state, string $prompt): array + { + $options = (array)($state['gather_template'] ?? []); + $options['input'] = 'dtmf'; + $options['maxNumKeys'] = $this->maxNumKeysForState($state); + if ((int)$options['maxNumKeys'] > 1) { + $options['endKey'] = '#'; + } + + $say = is_array($options['say'] ?? null) ? $options['say'] : []; + if (!isset($say['locale']) || !is_scalar($say['locale']) || trim((string)$say['locale']) === '') { + $say['locale'] = self::DEFAULT_GATHER_SAY_LOCALE; + } + if (!isset($say['voice']) || !is_scalar($say['voice']) || trim((string)$say['voice']) === '') { + $say['voice'] = self::DEFAULT_GATHER_SAY_VOICE; + } + $say['text'] = $prompt; + $options['say'] = $say; + + return $options; + } + + protected function normalizeGatherTemplate(array $payload): array + { + $template = $payload; + $template['input'] = 'dtmf'; + $template['maxNumKeys'] = 1; + if (!isset($template['retries']) || !is_numeric($template['retries'])) { + $template['retries'] = self::DEFAULT_GATHER_RETRIES; + } + if (!isset($template['timeout']) || !is_numeric($template['timeout'])) { + $template['timeout'] = self::DEFAULT_GATHER_TIMEOUT_SECONDS; + } + if (!isset($template['endKey']) || !is_scalar($template['endKey']) || trim((string)$template['endKey']) === '') { + $template['endKey'] = self::DEFAULT_GATHER_END_KEY; + } + + $say = is_array($template['say'] ?? null) ? $template['say'] : []; + if (!isset($say['locale']) || !is_scalar($say['locale']) || trim((string)$say['locale']) === '') { + $say['locale'] = self::DEFAULT_GATHER_SAY_LOCALE; + } + if (!isset($say['voice']) || !is_scalar($say['voice']) || trim((string)$say['voice']) === '') { + $say['voice'] = self::DEFAULT_GATHER_SAY_VOICE; + } + $say['text'] = ''; + $template['say'] = $say; + + return $template; + } + + protected function extractInitialGatherTemplate(array $payload): array + { + if (isset($payload['payload']) && is_array($payload['payload'])) { + return $payload['payload']; + } + + $template = []; + foreach (['endKey', 'input', 'maxNumKeys', 'retries', 'speechLocale', 'timeout'] as $field) { + if (array_key_exists($field, $payload)) { + $template[$field] = $payload[$field]; + } + } + + if (isset($payload['say']) && is_array($payload['say'])) { + $template['say'] = $payload['say']; + } + + return $template; + } + + protected function buildPromptTextForState(array $state, bool $withInvalidPrefix = false): string + { + $stage = $this->normalizeOptionalString($state['stage'] ?? ''); + + if ($stage === self::IVR_STAGE_GATE_TYPE_SELECT) { + $prompt = $this->buildGateTypePromptText( + (string)($state['selected_department_name'] ?? ''), + $this->normalizeGateOptions((array)($state['gate_options'] ?? [])), + ); + } else { + $prompt = $this->buildDepartmentPromptText((array)($state['department_options'] ?? [])); + } + + if ($withInvalidPrefix) { + return 'Invalid selection. ' . $prompt; + } + + return $prompt; + } + + protected function loadEligibleDepartmentSummaries(): array + { + return (new department_gates_o())->getPhoneCallDepartmentSummaries(); + } + + protected function resolvePhoneCallGate(int $departmentId, string $gateType): ?department_gates_o + { + $gates = new department_gates_o(); + + return $gateType === self::IVR_GATE_TYPE_EXIT + ? $gates->getExitPhoneCallGate($departmentId) + : $gates->getEntrancePhoneCallGate($departmentId); + } + + protected function resolvePhoneCallGateById(int $gateId): ?department_gates_o + { + if ($gateId <= 0) { + return null; + } + + $gate = (new department_gates_o())->select($gateId); + + return $gate instanceof department_gates_o && $gate->exists() ? $gate : null; + } + + protected function resolveSharedPhoneCallGate(int $departmentId, array $availableGateTypes): ?department_gates_o + { + if ( + !in_array(self::IVR_GATE_TYPE_ENTRANCE, $availableGateTypes, true) + || !in_array(self::IVR_GATE_TYPE_EXIT, $availableGateTypes, true) + ) { + return null; + } + + $entranceGate = $this->resolvePhoneCallGate($departmentId, self::IVR_GATE_TYPE_ENTRANCE); + $exitGate = $this->resolvePhoneCallGate($departmentId, self::IVR_GATE_TYPE_EXIT); + if ( + $entranceGate === null + || !$entranceGate->exists() + || $exitGate === null + || !$exitGate->exists() + ) { + return null; + } + + return (int)$entranceGate->id === (int)$exitGate->id ? $entranceGate : null; + } + + protected function triggerGateOpen(department_gates_o $gate): void + { + $gate->openGate(); + } + + protected function resolveBirdClient(): bird + { + return new bird(); + } + + protected function acceptInboundCall( + bird $client, + string $workspaceId, + string $channelId, + string $callId + ): void { + if (!$this->shouldAcceptInboundCall($client)) { return; } - $text = 'Ugyldigt valg. ' . $this->buildDepartmentPromptText($optionMap); - $this->gather($client, $ws, $ch, $callId, $text); + + try { + $client->answerVoiceCall($workspaceId, $channelId, $callId, []); + } catch (\Throwable) { + // Flow Builder should answer inbound calls before invoking this webhook. + // Treat backend acceptance as a best-effort fallback. + } } - protected function promptForGateTypeSelection( - bird $client, - string $ws, - string $ch, - string $callId, - int $departmentId, - string $departmentName - ): void { - $text = $this->buildGateTypePromptText($departmentName); - $this->gather($client, $ws, $ch, $callId, $text); + protected function shouldAcceptInboundCall(bird $client): bool + { + return $this->isBirdModuleEnabled($client) + && $this->readBirdConfigValue($client, 'api_key') !== '' + && $this->readBirdConfigValue($client, 'server_url') !== ''; } - protected function promptForGateTypeRetry( - bird $client, - string $ws, - string $ch, - string $callId, - int $departmentId, - string $departmentName - ): void { - $text = 'Ugyldigt valg. ' . $this->buildGateTypePromptText($departmentName); - $this->gather($client, $ws, $ch, $callId, $text); + protected function isBirdModuleEnabled(bird $client): bool + { + try { + if (!isset($client->config) || !is_object($client->config) || !property_exists($client->config, 'enabled')) { + return false; + } + + $enabledConfig = $client->config->enabled; + if (!is_object($enabledConfig)) { + return false; + } + + if (method_exists($enabledConfig, 'isTrue')) { + return $enabledConfig->isTrue(); + } + + if (method_exists($enabledConfig, 'getVariableValue')) { + return filter_var($enabledConfig->getVariableValue(), FILTER_VALIDATE_BOOLEAN); + } + } catch (\Throwable) { + return false; + } + + return false; + } + + protected function readBirdConfigValue(bird $client, string $property): string + { + try { + if ( + !isset($client->config) + || !is_object($client->config) + || !property_exists($client->config, $property) + ) { + return ''; + } + + $configValue = $client->config->{$property}; + if (!is_object($configValue) || !method_exists($configValue, 'getVariableValue')) { + return ''; + } + + return $this->normalizeOptionalString($configValue->getVariableValue()); + } catch (\Throwable) { + return ''; + } + } + + protected function readInboundWebhookPayload(): array + { + $rawBody = file_get_contents('php://input'); + if (is_string($rawBody) && trim($rawBody) !== '') { + $decoded = json_decode($rawBody, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return is_array($_POST) ? $_POST : []; + } + + protected function isSupportedInboundRequest(array $payload): bool + { + if ($this->isInitialGatherRequest($payload) || $this->isResumedGatherRequest($payload)) { + return true; + } + + return $this->extractCallIdFromPayload($payload) !== '' + && $this->extractWorkspaceIdFromPayload($payload) !== '' + && $this->extractChannelIdFromPayload($payload) !== ''; + } + + protected function isInitialGatherRequest(array $payload): bool + { + return isset($payload['payload'], $payload['request'], $payload['waitConditions']) + && is_array($payload['payload']) + && is_array($payload['request']) + && is_array($payload['waitConditions']); + } + + protected function isResumedGatherRequest(array $payload): bool + { + return isset($payload['event']) && is_array($payload['event']); + } + + protected function shouldBootstrapIvrState(array $payload, array $state): bool + { + if ($this->isInitialGatherRequest($payload)) { + return true; + } + + if ($this->isResumedGatherRequest($payload)) { + return false; + } + + return !$this->hasActiveIvrState($state); + } + + protected function hasActiveIvrState(array $state): bool + { + return $state !== [] && $this->normalizeOptionalString($state['stage'] ?? '') !== ''; + } + + protected function responseModeForPayload(array $payload, array $state = []): string + { + $stateMode = $this->responseModeForState($state); + if ($stateMode !== '') { + return $stateMode; + } + + if ($this->isResumedGatherRequest($payload) || $this->isInitialGatherRequest($payload)) { + return self::RESPONSE_MODE_COMMAND; + } + + return self::RESPONSE_MODE_FLOW; + } + + protected function responseModeForState(array $state): string + { + $mode = $this->normalizeOptionalString($state['response_mode'] ?? ''); + + return in_array($mode, [self::RESPONSE_MODE_COMMAND, self::RESPONSE_MODE_FLOW], true) + ? $mode + : ''; + } + + protected function resolveInboundCallId(array $payload): string + { + $queryCallId = $this->normalizeOptionalString($this->fromQuery('callId')); + if ($queryCallId !== '') { + return $queryCallId; + } + + if (isset($_POST['callId']) && is_scalar($_POST['callId'])) { + $postCallId = $this->normalizeOptionalString((string)$_POST['callId']); + if ($postCallId !== '') { + return $postCallId; + } + } + + return $this->extractCallIdFromPayload($payload); + } + + protected function resolveInboundWorkspaceId(bird $client, array $payload): string + { + $queryWorkspaceId = $this->normalizeOptionalString($this->fromQuery('workspaceId')); + if ($queryWorkspaceId !== '') { + return $queryWorkspaceId; + } + + if (isset($_POST['workspaceId']) && is_scalar($_POST['workspaceId'])) { + $postWorkspaceId = $this->normalizeOptionalString((string)$_POST['workspaceId']); + if ($postWorkspaceId !== '') { + return $postWorkspaceId; + } + } + + $payloadWorkspaceId = $this->extractWorkspaceIdFromPayload($payload); + if ($payloadWorkspaceId !== '') { + return $payloadWorkspaceId; + } + + return $this->getConfiguredWorkspaceId($client); + } + + protected function resolveInboundChannelId(bird $client, array $payload): string + { + $queryChannelId = $this->normalizeOptionalString($this->fromQuery('channelId')); + if ($queryChannelId !== '') { + return $queryChannelId; + } + + if (isset($_POST['channelId']) && is_scalar($_POST['channelId'])) { + $postChannelId = $this->normalizeOptionalString((string)$_POST['channelId']); + if ($postChannelId !== '') { + return $postChannelId; + } + } + + $payloadChannelId = $this->extractChannelIdFromPayload($payload); + if ($payloadChannelId !== '') { + return $payloadChannelId; + } + + return $this->getConfiguredChannelId($client); + } + + protected function extractCallIdFromPayload(array $payload): string + { + return $this->extractPayloadStringByPaths($payload, [ + ['request', 'callId'], + ['body', 'callId'], + ['event', 'callCommand', 'callId'], + ['callId'], + ['call_id'], + ['call', 'id'], + ['data', 'callId'], + ['payload', 'callId'], + ['event', 'callId'], + ['voice', 'callId'], + ]); + } + + protected function extractWorkspaceIdFromPayload(array $payload): string + { + return $this->extractPayloadStringByPaths($payload, [ + ['request', 'workspaceId'], + ['body', 'workspaceId'], + ['workspaceId'], + ['workspace_id'], + ['call', 'workspaceId'], + ['data', 'workspaceId'], + ['payload', 'workspaceId'], + ]); + } + + protected function extractChannelIdFromPayload(array $payload): string + { + return $this->extractPayloadStringByPaths($payload, [ + ['request', 'channelId'], + ['body', 'channelId'], + ['event', 'callCommand', 'channelId'], + ['channelId'], + ['channel_id'], + ['call', 'channelId'], + ['data', 'channelId'], + ['payload', 'channelId'], + ]); + } + + protected function extractRequestIdFromPayload(array $payload): string + { + return $this->extractPayloadStringByPaths($payload, [ + ['requestId'], + ['request', 'requestId'], + ['event', 'requestId'], + ]); + } + + protected function extractResumeActionFromPayload(array $payload): string + { + return $this->extractPayloadStringByPaths($payload, [ + ['resumeData', 'action'], + ['waitConditions', 'events', 0, 'action'], + ]); + } + + protected function extractWaitTimeoutFromPayload(array $payload): string + { + return $this->extractPayloadStringByPaths($payload, [ + ['waitConditions', 'timeout'], + ]); + } + + protected function extractGatherKeysFromEvent(array $payload): ?string + { + $value = $this->extractPayloadStringByPaths($payload, [ + ['event', 'callCommand', 'gather', 'keys'], + ['event', 'callCommand', 'gather', 'key'], + ]); + + return $value !== '' ? $value : null; + } + + protected function extractDtmfInput(array $payload): ?string + { + $value = $this->extractPayloadStringByPaths($payload, [ + ['dtmf'], + ['digit'], + ['digits'], + ['keys'], + ['key'], + ['input'], + ['input', 'dtmf'], + ['input', 'digit'], + ['input', 'digits'], + ['input', 'keys'], + ['input', 'key'], + ['gather', 'dtmf'], + ['gather', 'digit'], + ['gather', 'digits'], + ['gather', 'keys'], + ['gather', 'key'], + ['event', 'data', 'dtmf'], + ['event', 'data', 'digit'], + ['event', 'data', 'digits'], + ['event', 'data', 'keys'], + ['event', 'data', 'key'], + ['result', 'dtmf'], + ['result', 'digit'], + ['result', 'digits'], + ['result', 'keys'], + ['result', 'key'], + ['response', 'dtmf'], + ['response', 'digit'], + ['response', 'digits'], + ['response', 'keys'], + ['response', 'key'], + ['variables', 'keys'], + ['conditions', 0, 'value'], + ]); + + return $value !== '' ? $value : null; + } + protected function normalizeMenuDigitInput(?string $input): ?string + { + if ($input === null) { + return null; + } + + $normalized = trim($input); + while ($normalized !== '' && str_ends_with($normalized, '#')) { + $normalized = substr($normalized, 0, -1); + } + + $normalized = preg_replace('/[^0-9]/', '', $normalized); + if (!is_string($normalized) || $normalized === '') { + return null; + } + + return preg_match('/^[1-9][0-9]*$/', $normalized) === 1 ? $normalized : null; + } + + protected function extractPayloadStringByPaths(array $payload, array $candidatePaths): string + { + foreach ($candidatePaths as $path) { + if (!is_array($path) || $path === []) { + continue; + } + + $value = $this->extractPayloadValueByPath($payload, $path); + if (!is_scalar($value)) { + continue; + } + + $normalized = $this->normalizeOptionalString((string)$value); + if ($normalized !== '') { + return $normalized; + } + } + + return ''; + } + + protected function extractPayloadValueByPath(array $payload, array $path): mixed + { + $cursor = $payload; + + foreach ($path as $segment) { + if (is_array($cursor) && array_key_exists($segment, $cursor)) { + $cursor = $cursor[$segment]; + continue; + } + + return null; + } + + return $cursor; } protected function buildDepartmentOptionMap(array $departmentIds): array { $map = []; $index = 1; - foreach ($departmentIds as $departmentId) { - if ($index > 9) { - break; + + foreach ($departmentIds as $departmentEntry) { + $departmentName = ''; + $hasEntranceGate = false; + $hasExitGate = false; + + if (is_array($departmentEntry)) { + $id = (int)($departmentEntry['department_id'] ?? 0); + $departmentName = $this->normalizeOptionalString($departmentEntry['department_name'] ?? ''); + $hasEntranceGate = ($departmentEntry['has_entrance_gate'] ?? false) === true; + $hasExitGate = ($departmentEntry['has_exit_gate'] ?? false) === true; + } else { + $id = (int)$departmentEntry; } - $id = (int)$departmentId; + if ($id <= 0) { continue; } + $digit = (string)$index; $map[$digit] = [ 'department_id' => $id, - 'department_name' => $this->getDepartmentNameById($id), + 'department_name' => $departmentName !== '' ? $departmentName : $this->getDepartmentNameById($id), + 'has_entrance_gate' => $hasEntranceGate, + 'has_exit_gate' => $hasExitGate, ]; $index++; } + return $map; } protected function buildDepartmentPromptText(array $optionMap): string { - $parts = []; + if ($optionMap === []) { + return 'Choose department.'; + } + + $parts = ['Choose department.']; + if ($this->requiresDepartmentTerminator($optionMap)) { + $parts[] = 'Enter the option number followed by pound.'; + } + foreach ($optionMap as $digit => $department) { $name = (string)($department['department_name'] ?? ''); if ($name === '') { - $name = 'afdeling ' . (string)($department['department_id'] ?? ''); + $name = 'department ' . (string)($department['department_id'] ?? ''); } - $parts[] = 'Tast ' . $digit . ' for ' . $name; + $parts[] = 'Press ' . $digit . ' for ' . $name . '.'; } - return 'Vaelg afdeling. ' . implode('. ', $parts) . '. Afslut med firkantstasten.'; + + return implode(' ', $parts); } - protected function buildGateTypePromptText(string $departmentName): string + protected function buildGateTypePromptText(string $departmentName, array $gateOptions = []): string { - $prefix = $departmentName !== '' ? 'Du valgte ' . $departmentName . '. ' : ''; - return $prefix . 'Tast 1 for indgang. Tast 2 for udgang. Afslut med firkantstasten.'; + $prefix = $departmentName !== '' ? 'You selected ' . $departmentName . '. ' : ''; + $gateOptions = $this->normalizeGateOptions($gateOptions); + + if ($gateOptions === []) { + return $prefix . 'Press 1 for entrance. Press 2 for exit.'; + } + + $parts = []; + foreach ($gateOptions as $digit => $gateType) { + $parts[] = 'Press ' . $digit . ' for ' . $gateType . '.'; + } + + return $prefix . implode(' ', $parts); } protected function resolveDepartmentIdByDigit(array $optionMap, string $digits): ?int @@ -306,45 +1339,109 @@ class birdVoiceWebhooksRoute if ($key === '' || !isset($optionMap[$key])) { return null; } + $id = (int)($optionMap[$key]['department_id'] ?? 0); + return $id > 0 ? $id : null; } - protected function resolveGateTypeByDigit(string $digits): ?string + protected function resolveGateTypeByDigit(string $digits, array $gateOptions = []): ?string { $key = trim($digits); + $gateOptions = $this->normalizeGateOptions($gateOptions); + if ($gateOptions !== []) { + return $gateOptions[$key] ?? null; + } + if ($key === '1') { return self::IVR_GATE_TYPE_ENTRANCE; } if ($key === '2') { return self::IVR_GATE_TYPE_EXIT; } + return null; } + protected function resolveSelectedDepartmentOption(array $optionMap, string $digit): ?array + { + $key = trim($digit); + + return isset($optionMap[$key]) && is_array($optionMap[$key]) ? $optionMap[$key] : null; + } + + protected function extractAvailableGateTypes(array $departmentOption): array + { + $availableGateTypes = []; + + if (($departmentOption['has_entrance_gate'] ?? false) === true) { + $availableGateTypes[] = self::IVR_GATE_TYPE_ENTRANCE; + } + if (($departmentOption['has_exit_gate'] ?? false) === true) { + $availableGateTypes[] = self::IVR_GATE_TYPE_EXIT; + } + + return $availableGateTypes; + } + + protected function buildGateOptionMap(array $availableGateTypes): array + { + $gateOptions = []; + $digit = 1; + + foreach ($availableGateTypes as $gateType) { + $normalizedGateType = $this->normalizeOptionalString((string)$gateType); + if ($normalizedGateType === '') { + continue; + } + + $gateOptions[(string)$digit] = $normalizedGateType; + $digit++; + } + + return $gateOptions; + } + + protected function normalizeGateOptions(array $gateOptions): array + { + $normalized = []; + + foreach ($gateOptions as $digit => $gateType) { + $normalizedDigit = $this->normalizeMenuDigitInput((string)$digit); + $normalizedGateType = $this->normalizeOptionalString((string)$gateType); + if ($normalizedDigit === null || $normalizedGateType === '') { + continue; + } + + $normalized[$normalizedDigit] = $normalizedGateType; + } + + return $normalized; + } + + protected function maxNumKeysForState(array $state): int + { + $stage = $this->normalizeOptionalString($state['stage'] ?? ''); + if ($stage !== self::IVR_STAGE_DEPARTMENT_SELECT) { + return 1; + } + + $departmentOptions = is_array($state['department_options'] ?? null) ? $state['department_options'] : []; + $count = count($departmentOptions); + + return max(1, strlen((string)$count)); + } + + protected function requiresDepartmentTerminator(array $optionMap): bool + { + return count($optionMap) > 9; + } + protected function getDepartmentNameById(int $departmentId): string { $row = (new departments_o())->getDepartmentById($departmentId); - return trim((string)($row['name'] ?? 'Afdeling ' . $departmentId)); - } - protected function gather(bird $client, string $ws, string $ch, string $callId, string $text): void - { - if ($callId === '') { - throw new \Exception('Call ID is missing, cannot gather input'); - } - - $client->gatherMessage($ws, $ch, $callId, [ - 'input' => 'dtmf', - 'maxNumKeys' => 1, - 'retries' => 1, - 'timeout' => 8, - 'endKey' => '#', - 'say' => [ - 'text' => $text, - ], - ]); - $this->respondJsonAndExit(); + return trim((string)($row['name'] ?? ('Department ' . $departmentId))); } protected function saveIvrState(string $callId, array $state): void @@ -352,14 +1449,15 @@ class birdVoiceWebhooksRoute if ($callId === '') { return; } + try { - $encoded = json_encode($state); + $encoded = json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($encoded) || $encoded === '') { return; } + $this->writeIvrStateRaw($this->ivrStateKey($callId), $encoded, self::IVR_STATE_TTL_SECONDS); } catch (\Throwable) { - // State caching should not break webhook flow. } } @@ -368,12 +1466,15 @@ class birdVoiceWebhooksRoute if ($callId === '') { return null; } + try { $raw = $this->readIvrStateRaw($this->ivrStateKey($callId)); if (!is_string($raw) || trim($raw) === '') { return null; } + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; } catch (\Throwable) { return null; @@ -385,17 +1486,34 @@ class birdVoiceWebhooksRoute if ($callId === '') { return; } + try { $this->deleteIvrStateRaw($this->ivrStateKey($callId)); } catch (\Throwable) { - // Best effort only. } } - protected function isStateForCaller(array $state, int $countryCode, int $phone): bool + protected function normalizeState(array $state): array { - return (int)($state['country_code'] ?? 0) === $countryCode - && (int)($state['phone'] ?? 0) === $phone; + if ($state === []) { + return []; + } + + $state['response_mode'] = $this->responseModeForState($state) ?: self::RESPONSE_MODE_FLOW; + $state['department_options'] = is_array($state['department_options'] ?? null) ? $state['department_options'] : []; + $state['available_gate_types'] = array_values(array_filter( + is_array($state['available_gate_types'] ?? null) ? $state['available_gate_types'] : [], + static fn(mixed $value): bool => is_string($value) && $value !== '' + )); + $state['gate_options'] = $this->normalizeGateOptions( + is_array($state['gate_options'] ?? null) ? $state['gate_options'] : [] + ); + if ($state['gate_options'] === [] && $state['available_gate_types'] !== []) { + $state['gate_options'] = $this->buildGateOptionMap($state['available_gate_types']); + } + $state['gather_template'] = is_array($state['gather_template'] ?? null) ? $state['gather_template'] : []; + + return $state; } protected function ivrStateKey(string $callId): string @@ -403,12 +1521,70 @@ class birdVoiceWebhooksRoute return self::IVR_STATE_PREFIX . $callId; } + protected function ivrLockKey(string $callId): string + { + return self::IVR_LOCK_PREFIX . $callId; + } + + protected function acquireIvrLock(string $callId): bool + { + if ($callId === '') { + return false; + } + + try { + return $this->acquireIvrLockRaw($this->ivrLockKey($callId), self::IVR_LOCK_TTL_SECONDS); + } catch (\Throwable) { + return true; + } + } + + protected function releaseIvrLock(string $callId): void + { + if ($callId === '') { + return; + } + + try { + $this->releaseIvrLockRaw($this->ivrLockKey($callId)); + } catch (\Throwable) { + } + } + + protected function acquireIvrLockRaw(string $key, int $ttlSeconds): bool + { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return true; + } + + if ($redis->exists($key)) { + return false; + } + + $redis->set($key, $this->generateUuidV4()); + $redis->expire($key, $ttlSeconds); + + return true; + } + + protected function releaseIvrLockRaw(string $key): void + { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return; + } + + $redis->delete($key); + } + protected function writeIvrStateRaw(string $key, string $value, int $ttlSeconds): void { $redis = $this->resolveRedisClient(); if ($redis === null) { return; } + $redis->set($key, $value); $redis->expire($key, $ttlSeconds); } @@ -419,6 +1595,7 @@ class birdVoiceWebhooksRoute if ($redis === null) { return null; } + return $redis->get($key); } @@ -428,6 +1605,7 @@ class birdVoiceWebhooksRoute if ($redis === null) { return; } + $redis->delete($key); } @@ -440,178 +1618,103 @@ class birdVoiceWebhooksRoute } } - public function callGateToOpen(department_gates_o $gate): bool + protected function requestIdForState(array $state): string { - $config = (array)$gate->config->value(); + $requestId = $this->normalizeOptionalString($state['request_id'] ?? ''); - if (!isset($config['type']) || $config['type'] !== 'PHONE_CALL') { - return false; - } - if (!isset($config['phone_number'])) { - return false; - } - $phoneConfig = $config['phone_number']; - $normalized = $this->normalizePhoneCandidate($phoneConfig); - if ($normalized === null) { - return false; - } - [$countryCode, $phone] = $normalized; - - // Use the configured threshold or default to 10 seconds. - $timeout = (int)($config['call_duration_threshold'] ?? 10); - - // Use bird voice call to call the phone number, and then hang up after it is accepted to trigger the gate. - $client = new bird(); - try { - $client->callGateAndHangupWhenAccepted( - (int)$countryCode, - (int)$phone, - $timeout, - ); - return true; - } catch (\Throwable $e) { - // If the call fails, log the error and return false - $slack = new Slack(); - $slack->send_message('Failed to call gate for phone ' . $countryCode . $phone . ': ' . $e->getMessage(), 'Bird Voice Call Webhooks'); - return false; - } + return $requestId !== '' ? $requestId : $this->generateUuidV4(); } - protected function extractCallerPhone(array $payload): ?array + protected function resumeActionForState(array $state): string { - $candidates = [ - $payload['phone_number'] ?? null, - $payload['source'] ?? null, - $payload['from'] ?? null, + $action = $this->normalizeOptionalString($state['resume_action'] ?? ''); + + return $action !== '' ? $action : 'continue'; + } + + protected function selectedDepartmentId(array $state): ?int + { + $departmentId = (int)($state['selected_department_id'] ?? 0); + + return $departmentId > 0 ? $departmentId : null; + } + + protected function selectedGateType(array $state): ?string + { + $gateType = $this->normalizeOptionalString($state['selected_gate_type'] ?? ''); + + return $gateType !== '' ? $gateType : null; + } + + protected function selectedGateId(array $state): ?int + { + $gateId = (int)($state['gate_id'] ?? 0); + + return $gateId > 0 ? $gateId : null; + } + + protected function buildGateOpenedMessage(?string $gateType): string + { + return $gateType !== null ? 'Opening the ' . $gateType . ' gate now.' : 'Opening the gate now.'; + } + + protected function buildGateOpenFailedMessage(?string $gateType, string $errorMessage): string + { + $prefix = $gateType !== null ? 'Failed to open the ' . $gateType . ' gate.' : 'Failed to open the gate.'; + + return $errorMessage !== '' ? $prefix . ' ' . $errorMessage : $prefix; + } + + protected function buildMissingGateMessage(?string $gateType): string + { + if ($gateType === null) { + return 'The selected gate is no longer available.'; + } + + return 'No phone-controlled ' . $gateType . ' gate is configured for the selected department.'; + } + + protected function buildTransportErrorResponse(array $payload, int $statusCode, string $message): array + { + return [ + 'requestId' => $this->extractRequestIdFromPayload($payload) ?: $this->generateUuidV4(), + 'statusCode' => $statusCode, + 'statusText' => $this->statusTextForCode($statusCode), + 'error' => [ + 'message' => $message, + ], ]; - - foreach ($candidates as $candidate) { - $normalized = $this->normalizePhoneCandidate($candidate); - if ($normalized !== null) { - return $normalized; - } - } - - return null; } - protected function normalizePhoneCandidate(mixed $candidate): ?array + protected function statusTextForCode(int $statusCode): string { - if (is_array($candidate)) { - // Determine the country code from the phone number if possible, otherwise default to 45 (Denmark) - $phone = $candidate['phone_number'] ?? null; // E.g. +4512345678 - // If the phone number starts with a + followed by the country code and then the local phone number, we can extract the country code and local phone number - - if ($phone !== null) { - $country = $this->extractCountryCodeFromPhoneNumber($phone); - return $this->normalizePhone((string)$phone, $country); - } - return null; - } - - if (is_string($candidate) && trim($candidate) !== '') { - return $this->normalizePhone($candidate); - } - - return null; + return match ($statusCode) { + 202 => 'Accepted', + 400 => 'Bad Request', + 500 => 'Internal Server Error', + default => 'OK', + }; } - protected function normalizePhone(string $raw, ?int $defaultCountryCode = 45): ?array + protected function currentIsoTimestamp(): string { - $trimmed = trim($raw); - if ($trimmed === '') { - return null; - } - $digits = preg_replace('/\D+/', '', $trimmed); - if (!is_string($digits) || $digits === '') { - return null; - } - - $country = $defaultCountryCode; - $phone = $digits; - - if (str_starts_with($trimmed, '+')) { - $extractedCountry = $this->extractCountryCodeFromPhoneNumber($trimmed); - if ($extractedCountry !== null) { - $country = $extractedCountry; - $phone = substr($digits, strlen((string)$extractedCountry)); - } elseif (strlen($digits) > 8) { - $country = (int)substr($digits, 0, 2); - $phone = substr($digits, 2); - } - } elseif ($country !== null && str_starts_with($digits, (string)$country) && strlen($digits) > 8) { - $phone = substr($digits, strlen((string)$country)); - } - - if ($country === null || $country <= 0 || $phone === '') { - return null; - } - - return [$country, (int)$phone]; + return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.u\Z'); } - protected function isRegisteredCaller(int $countryCode, int $phone): bool + protected function generateUuidV4(): string { - $userRows = (new users_o())->getFieldsWhere([ - 'phone_country_code' => $countryCode, - 'phone' => $phone, - ], ['id']); - if (!empty($userRows)) { - return true; - } + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + $hex = bin2hex($bytes); - return (new subusers_o())->getSubuserByPhone($countryCode, $phone) !== null; - } - - protected function say(bird $client, string $ws, string $ch, string $callId, string $text, bool $hangup = false): void - { - if ($callId !== '') { - try { - $payload = ['text' => $text]; - if ($hangup) { - $payload['hangup'] = true; - } - $client->sayMessage($ws, $ch, $callId, $payload); - $this->respondJsonAndExit(); - } catch (\Throwable $e) { - // If REST API fails, fallback to standard response - throw new \Exception('Failed to say message and hang up: ' . $e->getMessage(), 0, $e); - } - } else { - throw new \Exception('Call ID is missing, cannot say message'); - } - } - - protected function respondJsonAndExit(): void - { - header('Content-Type: application/json'); - echo json_encode([]); - exit; - } - - protected function extractCountryCodeFromPhoneNumber(string $phone): ?int - { - if (str_starts_with($phone, '+45')) { - return 45; - } - if (str_starts_with($phone, '+46')) { - return 46; - } - if (str_starts_with($phone, '+47')) { - return 47; - } - if (str_starts_with($phone, '+358')) { - return 358; - } - if (str_starts_with($phone, '+49')) { - return 49; - } - if (str_starts_with($phone, '+44')) { - return 44; - } - if (str_starts_with($phone, '+1')) { - return 1; - } - return null; + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12), + ); } } diff --git a/services/nginx/app/routes/bookingsRoute.php b/services/nginx/app/routes/bookingsRoute.php index d4ef953c..01c79042 100644 --- a/services/nginx/app/routes/bookingsRoute.php +++ b/services/nginx/app/routes/bookingsRoute.php @@ -49,7 +49,7 @@ class bookingsRoute } // Check if the user has access to the booking if (!$user->hasAccessToBooking((int)self::getParameter('id'))) { - $response->error('You are not allowed to access this booking', 403); + $response->forbidden(['list_bookings']); } // Return the booking $response->success( @@ -158,7 +158,7 @@ class bookingsRoute } // Check if the user has access to the booking if (!$user->hasAccessToBooking((int)$this->getParameter('id'))) { - $response->error('You are not allowed to update this booking', 403); + $response->forbidden(['list_bookings']); } // Check if the optional parameters are set if (self::isParametersSet(['reference_number'])) { @@ -468,6 +468,7 @@ class bookingsRoute // Require the user to be logged in global /** @var response $response */ $response; + $response->error('Booking completion must be completed through POS desktop or mobile steps.', 410); $this->requirePermission('complete_wash_without_wash_certificate'); // Get the user object $user = (new authentication())->get_user(); @@ -530,4 +531,4 @@ class bookingsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/cronRoute.php b/services/nginx/app/routes/cronRoute.php index b0417ed0..4862d554 100644 --- a/services/nginx/app/routes/cronRoute.php +++ b/services/nginx/app/routes/cronRoute.php @@ -16,9 +16,7 @@ class cronRoute // Get the post data global $response; // Make sure the user has the SUPERUSER_RUN_CRON permission - if (!$this->requirePermission('SUPERUSER_RUN_CRON')) { - $response->error('Permission denied', 403); - } + $this->requirePermission('SUPERUSER_RUN_CRON'); // Get the user object $user = (new authentication())->get_user(); // Get the post data @@ -49,4 +47,4 @@ class cronRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/customerFixedPricingRoute.php b/services/nginx/app/routes/customerFixedPricingRoute.php index 5d6af848..7005c895 100644 --- a/services/nginx/app/routes/customerFixedPricingRoute.php +++ b/services/nginx/app/routes/customerFixedPricingRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\economic_v2_versioning_service; use objects\customer_fixed_pricing_o; use objects\logs_o; use objects\users_o; @@ -98,6 +99,31 @@ class customerFixedPricingRoute } // Add the fixed price $customer_fixed_pricing_o->add((int)$customer_number, (int)$price, (string)$description); + try { + (new economic_v2_versioning_service())->recordFixedPricingVersion( + (int)$customer_number, + (int)$price, + (string)$description, + date('Y-m-d H:i:s'), + 'live.fixed_pricing.route', + 1.0, + false, + [ + 'route' => '/customer/pricing/fixed', + 'method' => 'POST', + 'actor_user_id' => (int)$user->id, + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add( + 'customer_fixed_pricing', + 'global', + 0, + (int)$user->id, + 'CUSTOMER_ADD_FIXED_PRICING_VERSIONING_FAILED', + $e->getMessage() + ); + } // Log the action (new logs_o())->add('customer_fixed_pricing', 'global', 0, $user->id, 'CUSTOMER_ADD_FIXED_PRICING', 'Fixed price added'); // Return success @@ -136,6 +162,29 @@ class customerFixedPricingRoute $fixed_pricing_object = $customer_fixed_pricing_o->selectByCustomerNumber((int)$customer_number); // Delete the fixed price $fixed_pricing_object->delete(); + try { + (new economic_v2_versioning_service())->closeActiveFixedPricingVersion( + (int)$customer_number, + date('Y-m-d H:i:s'), + 'live.fixed_pricing.route', + 1.0, + false, + [ + 'route' => '/customer/pricing/fixed', + 'method' => 'DELETE', + 'actor_user_id' => (int)$user->id, + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add( + 'customer_fixed_pricing', + 'global', + 0, + (int)$user->id, + 'CUSTOMER_DELETE_FIXED_PRICING_VERSIONING_FAILED', + $e->getMessage() + ); + } // Log the action (new logs_o())->add('customer_fixed_pricing', 'global', 0, $user->id, 'CUSTOMER_DELETE_FIXED_PRICING', 'Fixed price deleted'); // Return success @@ -146,4 +195,4 @@ class customerFixedPricingRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/customerSearchRoute.php b/services/nginx/app/routes/customerSearchRoute.php index dd6b7263..6633335a 100644 --- a/services/nginx/app/routes/customerSearchRoute.php +++ b/services/nginx/app/routes/customerSearchRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\customer_mass_import_service; use customers\economicCustomers; use objects\logs_o; use objects\users_o; @@ -66,39 +67,72 @@ class customerSearchRoute $page = self::fromRequest('page') ?? 1; $limit = self::fromRequest('limit') ?? 100; $search = self::fromRequest('search') ?? null; - $filter = self::fromRequest('filter') ?? null; - // Log the incident - (new logs_o())->add('customers', 'global', 1, $user->id, 'LIST_CUSTOMERS', 'Successfully listed customers'); + $barred = self::fromRequest('barred') ?? null; // Create the economic customers object $economicCustomers = new economicCustomers(); - $result = (object)$economicCustomers->listCustomers( - (int)$page, - (int)$limit, - $search, - $filter - ); + try { + $result = (object)$economicCustomers->listCustomers( + (int)$page, + (int)$limit, + $search, + $barred + ); + + if (!isset($result->pagination) || !is_object($result->pagination) || !isset($result->pagination->results) || !is_numeric($result->pagination->results)) { + throw new \RuntimeException('Malformed e-conomic customers response: missing pagination results.'); + } + if (!isset($result->collection) || !is_array($result->collection)) { + throw new \RuntimeException('Malformed e-conomic customers response: missing customer collection.'); + } + } catch (\Throwable $throwable) { + $upstreamMessage = self::sanitizeUpstreamErrorMessage($throwable); + $searchProvided = (is_string($search) && trim($search) !== '') ? 'true' : 'false'; + $context = json_encode([ + 'page' => (int)$page, + 'limit' => (int)$limit, + 'search_provided' => $searchProvided, + 'barred' => $barred, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + (new logs_o())->add( + 'customers', + 'global', + 1, + $user->id, + 'LIST_CUSTOMERS_FAILED', + 'Failed to list customers from e-conomic: ' . $upstreamMessage . ' | context=' . $context + ); + + $response->error([ + 'message' => 'Failed to fetch customers from e-conomic', + 'upstream_message' => $upstreamMessage, + ], 502); + } + // Parse the pagination meta from E-conomic to the standard format used in this application $response->paginate( - $page, - $limit, - $result->pagination->results, + (int)$page, + (int)$limit, + (int)$result->pagination->results, $search, - $filter + ['barred' => $barred] ); // Create the users object $users_o = new users_o(); + $customers = self::parseFunction($result->collection, function ($customer) use ($users_o) { + // Add the user id to the customer object + $customer->id = null; + // Update the user id if the customer is imported from E-conomic + if ($users_o->isImportedFromEconomic($customer->customerNumber)) { + $customer->id = $users_o->getUserIdFromEconomic($customer->customerNumber); + } + return $customer; + }); + + // Log the incident + (new logs_o())->add('customers', 'global', 1, $user->id, 'LIST_CUSTOMERS', 'Successfully listed customers'); // Return the list of users - $response->success( - self::parseFunction($result->collection, function ($customer) use ($users_o) { - // Add the user id to the customer object - $customer->id = null; - // Update the user id if the customer is imported from E-conomic - if ($users_o->isImportedFromEconomic($customer->customerNumber)) { - $customer->id = $users_o->getUserIdFromEconomic($customer->customerNumber); - } - return $customer; - }) - ); + $response->success($customers); } else { // Log the incident (new logs_o())->add('customers', 'global', 1, 0, 'LIST_CUSTOMERS', 'No user found, or invalid session'); @@ -110,6 +144,59 @@ class customerSearchRoute 'search_customers' => 'Search for customers, and list all customers if no search is provided' ] ); + + $this->post('/customers/import', function () { + global $response; + $this->requirePermission('add_user'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('customers', 'global', 1, 0, 'IMPORT_CUSTOMER', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + $data = json_decode(file_get_contents('php://input'), true); + if (!is_array($data)) { + $data = []; + } + + try { + $result = (new customer_mass_import_service())->import($data); + } catch (\RuntimeException $throwable) { + $statusCode = (int)$throwable->getCode(); + if ($statusCode < 400 || $statusCode > 599) { + $statusCode = 400; + } + + (new logs_o())->add( + 'customers', + 'global', + 1, + $user->id, + 'IMPORT_CUSTOMER_FAILED', + $throwable->getMessage() + ); + + $response->error([ + 'message' => $throwable->getMessage(), + ], $statusCode); + } + + (new logs_o())->add( + 'customers', + 'global', + 1, + $user->id, + 'IMPORT_CUSTOMER', + 'Successfully imported or created customer ' . ($result['customer_number'] ?? 'unknown') + ); + + $response->success($result); + }, + [ + 'add_user' => 'Add a user' + ] + ); } private static function parseFunction($collection, \Closure $param): array @@ -120,4 +207,19 @@ class customerSearchRoute } return $result; } -} \ No newline at end of file + + private static function sanitizeUpstreamErrorMessage(\Throwable $throwable): string + { + $message = trim($throwable->getMessage()); + if ($message === '') { + return 'Unexpected e-conomic integration error.'; + } + + $message = preg_replace('/\s+/', ' ', $message); + if (!is_string($message)) { + return 'Unexpected e-conomic integration error.'; + } + + return substr($message, 0, 500); + } +} diff --git a/services/nginx/app/routes/departmentDailyReportsRoute.php b/services/nginx/app/routes/departmentDailyReportsRoute.php index c502837b..bf2cef3f 100644 --- a/services/nginx/app/routes/departmentDailyReportsRoute.php +++ b/services/nginx/app/routes/departmentDailyReportsRoute.php @@ -3,10 +3,19 @@ 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; +use DateTimeZone; use Exception; +use objects\department_daily_report_complaints_o; use objects\department_daily_reports_o; use objects\departments_o; use objects\logs_o; +use objects\users_o; use traits\route_t; class departmentDailyReportsRoute @@ -248,6 +257,459 @@ class departmentDailyReportsRoute ] ); + $this->post('/departments/daily-reports/complaints', function () { + global $response; + $this->requirePermission('create_department_daily_report_complaints'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'CREATE_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + self::requireParameters([ + 'department_id', + 'wash_date', + 'category', + 'description', + ]); + + self::requireType( + (int)self::getParameter('department_id'), + self::type_int() + ); + self::requireMinValue( + (int)self::getParameter('department_id'), + 1 + ); + + self::requireType( + self::getParameter('description'), + self::type_string() + ); + self::requireMinLength( + 'description', + 1 + ); + self::requireMaxLength( + 'description', + 4000 + ); + + $description = trim((string)self::getParameter('description')); + if ($description === '') { + $response->error('Description is required', 400); + return; + } + + $wash_date = $this->requireComplaintWashDateParameter('wash_date'); + if ($wash_date === null) { + $response->error('Wash date is required', 400); + return; + } + + $category = $this->requireComplaintCategoryParameter('category'); + if ($category === null) { + $response->error('Category is required', 400); + return; + } + + $customer_number = null; + if ( + self::isParametersSet(['customer_number']) + && self::getParameter('customer_number') !== null + && trim((string)self::getParameter('customer_number')) !== '' + ) { + self::requireType( + (int)self::getParameter('customer_number'), + self::type_int() + ); + self::requireMinValue( + (int)self::getParameter('customer_number'), + 1 + ); + + $customer_number = (int)self::getParameter('customer_number'); + $customer = (new users_o())->getOrImportCustomerByCustomerNumber($customer_number); + if ($customer === false || !$customer->exists()) { + $response->error('Customer not found', 400); + return; + } + } + + self::requireDepartmentAccess((int)self::getParameter('department_id')); + + $complaint = $this->dailyReportComplaintsRepository()->addComplaint( + (int)self::getParameter('department_id'), + $customer_number, + $wash_date, + $category, + $description, + (int)$user->id + ); + + (new logs_o())->add('departments', 'global', 1, $user->id, 'CREATE_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully created department daily report complaint'); + + $response->success( + $this->dailyReportComplaintsRepository()->parseComplaint($complaint->asArray()) + ); + }, + [ + 'create_department_daily_report_complaints' => 'Create department daily report customer complaints', + 'department_access_:id' => 'Access the department' + ] + ); + + $this->get('/departments/daily-reports/complaints/customers', function () { + global $response; + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + $has_create_permission = $this->hasPermission('create_department_daily_report_complaints'); + $has_edit_permission = $this->hasPermission('edit_department_daily_report_complaints'); + if (!$has_create_permission && !$has_edit_permission) { + $this->emitForbidden([ + 'create_department_daily_report_complaints', + 'edit_department_daily_report_complaints', + ]); + return; + } + + self::requireParameters(['search']); + self::requireType( + self::getParameter('search'), + self::type_string() + ); + + $search = trim((string)self::getParameter('search')); + if (mb_strlen($search) < 2) { + $response->error('Search must be at least 2 characters', 400); + return; + } + + $limit = 10; + if ( + self::isParametersSet(['limit']) + && self::getParameter('limit') !== null + && trim((string)self::getParameter('limit')) !== '' + ) { + self::requireType( + (int)self::getParameter('limit'), + self::type_int() + ); + self::requireMinValue( + (int)self::getParameter('limit'), + 1 + ); + self::requireMaxValue( + (int)self::getParameter('limit'), + 20 + ); + + $limit = (int)self::getParameter('limit'); + } + + try { + $result = $this->complaintCustomerSearchService()->listCustomers(1, $limit, $search, null); + $collection = is_array($result->collection ?? null) ? $result->collection : []; + } catch (\Throwable $throwable) { + $upstream_message = $this->sanitizeComplaintCustomerLookupUpstreamErrorMessage($throwable); + + (new logs_o())->add( + 'departments', + 'global', + 1, + $user->id, + 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS_FAILED', + 'Failed to search complaint customers from e-conomic: ' . $upstream_message + ); + + $response->error([ + 'message' => 'Failed to fetch complaint customers from e-conomic', + 'upstream_message' => $upstream_message, + ], 502); + return; + } + + $matches = array_values(array_filter(array_map( + static function (mixed $customer): ?array { + $customer_number = 0; + $customer_name = null; + + if (is_object($customer)) { + $customer_number = (int)($customer->customerNumber ?? 0); + $name = trim((string)($customer->name ?? '')); + $customer_name = $name === '' ? null : $name; + } elseif (is_array($customer)) { + $customer_number = (int)($customer['customerNumber'] ?? 0); + $name = trim((string)($customer['name'] ?? '')); + $customer_name = $name === '' ? null : $name; + } + + if ($customer_number <= 0) { + return null; + } + + return [ + 'customer_number' => $customer_number, + 'customer_name' => $customer_name, + ]; + }, + $collection + ))); + + (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS', 'Successfully searched complaint customers'); + + $response->success($matches); + }, + [ + 'create_department_daily_report_complaints' => 'Search customers when creating department daily report customer complaints', + 'edit_department_daily_report_complaints' => 'Search customers when editing department daily report customer complaints', + ] + ); + + $this->get('/departments/daily-reports/complaints', function () { + global $response; + $this->requirePermission('list_department_daily_report_complaints'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINTS', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + $repository = $this->dailyReportComplaintsRepository(); + + if (self::isParametersSet(['id'])) { + self::requireType( + (int)self::getParameter('id'), + self::type_int() + ); + self::requireMinValue( + (int)self::getParameter('id'), + 1 + ); + + $complaint = $repository->select((int)self::getParameter('id')); + if (!$complaint->exists()) { + $response->error('Complaint not found', 404); + return; + } + + (new logs_o())->add('departments', 'global', 1, $user->id, 'GET_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully retrieved department daily report complaint'); + + $response->success($repository->parseComplaint($complaint->asArray())); + return; + } + + (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINTS', 'Successfully listed department daily report complaints'); + + $response->success( + $repository + ->setSearchableFields([ + 'id', + 'department_id', + 'customer_number', + 'wash_date', + 'category', + 'description', + 'created_by', + 'created_at', + ]) + ->listObjectsWithPaginationIfSet( + fn (array $complaint): array => $repository->parseComplaint($complaint) + ) + ); + }, + [ + 'list_department_daily_report_complaints' => 'List department daily report customer complaints' + ] + ); + + $this->put('/departments/daily-reports/complaints', function () { + global $response; + $this->requirePermission('edit_department_daily_report_complaints'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'EDIT_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['id']); + self::requireType( + (int)self::getParameter('id'), + self::type_int() + ); + self::requireMinValue( + (int)self::getParameter('id'), + 1 + ); + + $complaint = $this->dailyReportComplaintsRepository()->select((int)self::getParameter('id')); + if (!$complaint->exists()) { + $response->error('Complaint not found', 404); + return; + } + + $updates = []; + + if (self::isParametersSet(['department_id'])) { + self::requireType( + (int)self::getParameter('department_id'), + self::type_int() + ); + self::requireMinValue( + (int)self::getParameter('department_id'), + 1 + ); + + $department = (new departments_o())->select((int)self::getParameter('department_id')); + if (!$department->exists()) { + $response->error('Department not found', 400); + return; + } + + $updates['department_id'] = (int)self::getParameter('department_id'); + } + + if (self::isParametersSet(['description'])) { + self::requireType( + self::getParameter('description'), + self::type_string() + ); + self::requireMinLength( + 'description', + 1 + ); + self::requireMaxLength( + 'description', + 4000 + ); + + $description = trim((string)self::getParameter('description')); + if ($description === '') { + $response->error('Description is required', 400); + return; + } + + $updates['description'] = $description; + } + + if (self::isParametersSet(['wash_date'])) { + $wash_date = $this->requireComplaintWashDateParameter('wash_date'); + if ($wash_date === null) { + $response->error('Wash date is required', 400); + return; + } + + $updates['wash_date'] = $wash_date; + } + + if (self::isParametersSet(['category'])) { + $category = $this->requireComplaintCategoryParameter('category'); + if ($category === null) { + $response->error('Category is required', 400); + return; + } + + $updates['category'] = $category; + } + + if (self::isParametersSet(['customer_number'])) { + $raw_customer_number = self::getParameter('customer_number'); + + if ($raw_customer_number === null || trim((string)$raw_customer_number) === '') { + $updates['customer_number'] = null; + } else { + self::requireType( + (int)$raw_customer_number, + self::type_int() + ); + self::requireMinValue( + (int)$raw_customer_number, + 1 + ); + + $customer_number = (int)$raw_customer_number; + $customer = (new users_o())->getOrImportCustomerByCustomerNumber($customer_number); + if ($customer === false || !$customer->exists()) { + $response->error('Customer not found', 400); + return; + } + + $updates['customer_number'] = $customer_number; + } + } + + if ($updates === []) { + $response->success( + $this->dailyReportComplaintsRepository()->parseComplaint($complaint->asArray()) + ); + return; + } + + $complaint->update($updates); + + (new logs_o())->add('departments', 'global', 1, $user->id, 'EDIT_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully edited department daily report complaint'); + + $response->success( + $this->dailyReportComplaintsRepository()->parseComplaint($complaint->asArray()) + ); + }, + [ + 'edit_department_daily_report_complaints' => 'Edit department daily report customer complaints' + ] + ); + + $this->delete('/departments/daily-reports/complaints', function () { + global $response; + $this->requirePermission('delete_department_daily_report_complaints'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'DELETE_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['id']); + self::requireType( + (int)self::getParameter('id'), + self::type_int() + ); + self::requireMinValue( + (int)self::getParameter('id'), + 1 + ); + + $complaint = $this->dailyReportComplaintsRepository()->select((int)self::getParameter('id')); + if (!$complaint->exists()) { + $response->error('Complaint not found', 404); + return; + } + + $complaint->deletePermanently(); + + (new logs_o())->add('departments', 'global', 1, $user->id, 'DELETE_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully deleted department daily report complaint'); + + $response->success([ + 'message' => 'Complaint deleted successfully', + ]); + }, + [ + 'delete_department_daily_report_complaints' => 'Delete department daily report customer complaints' + ] + ); + $this->put('/departments/daily-reports', function () { // Require the user to be logged in global $response; @@ -339,6 +801,53 @@ class departmentDailyReportsRoute ] ); + $this->get('/departments/daily-reports/overview', function () { + global $response; + $this->requirePermission('list_department_daily_reports'); + $this->requirePermission('list_bookings'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORTS_OVERVIEW', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + self::requireParameters([ + 'date', + 'department_ids', + ]); + + self::validateDateLocally(); + $date_to = $this->getDate_to(); + $department_ids = $this->normalizeDepartmentIdsParameter(self::getParameter('department_ids')); + + if ($department_ids === []) { + $response->error('At least one department_id must be provided', 400); + return; + } + + foreach ($department_ids as $department_id) { + self::requireDepartmentAccess($department_id); + } + + (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_DAILY_REPORTS_OVERVIEW', 'Successfully listed departments daily reports overview'); + + $response->success( + $this->buildDailyReportOverview( + $department_ids, + (string)self::getParameter('date'), + $date_to + ) + ); + }, + [ + 'list_department_daily_reports' => 'List the department daily reports overview for departments the user can access', + 'list_bookings' => 'List order bookings for the overview bookings tile', + 'department_access_:id' => 'Access the department' + ] + ); + $this->get('/departments/daily-reports/product-count', function () { // Require the user to be logged in global $response; @@ -402,6 +911,7 @@ class departmentDailyReportsRoute // Determine if the user has access to the department self::requireDepartmentAccess((int)self::getParameter('department_id')); + $outside_hours_service = $this->outsideHoursStatisticsService(); // Log the incident (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_DAILY_REPORTS_PRODUCTS', 'Successfully listed departments daily reports products'); // Return the list of products sold on the selected date @@ -479,6 +989,7 @@ class departmentDailyReportsRoute // Determine if the user has access to the department self::requireDepartmentAccess((int)self::getParameter('department_id')); + $outside_hours_service = $this->outsideHoursStatisticsService(); // Log the incident (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_DAILY_REPORTS_PRODUCTS', 'Successfully listed departments daily reports products'); // Return the list of products sold on the selected date @@ -522,6 +1033,11 @@ class departmentDailyReportsRoute 'date' => (string)self::getParameter('date'), 'department_id' => (int)self::getParameter('department_id'), 'date_to' => $date_to, + 'outside_hours' => $outside_hours_service->getSummary( + (string)self::getParameter('date'), + [(int)self::getParameter('department_id')], + $date_to + ), ] ); } else { @@ -537,6 +1053,55 @@ class departmentDailyReportsRoute ] ); + $this->get('/departments/daily-reports/outside-hours-trend', function () { + global $response; + $this->requirePermission('list_department_daily_reports'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORTS_OUTSIDE_HOURS_TREND', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + self::requireParameters([ + 'date', + 'date_to', + 'department_ids', + ]); + + self::validateDateLocally(); + self::requireDateFormat( + (string)self::getParameter('date_to'), + 'Y-m-d' + ); + + $department_ids = $this->normalizeDepartmentIdsParameter(self::getParameter('department_ids')); + if ($department_ids === []) { + $response->error('At least one department_id must be provided', 400); + return; + } + + foreach ($department_ids as $department_id) { + self::requireDepartmentAccess($department_id); + } + + (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_DAILY_REPORTS_OUTSIDE_HOURS_TREND', 'Successfully listed outside hours trend'); + + $response->success( + $this->outsideHoursStatisticsService()->getTrend( + (string)self::getParameter('date'), + (string)self::getParameter('date_to'), + $department_ids + ) + ); + }, + [ + 'list_department_daily_reports' => 'List outside-hours trend for departments the user can access', + 'department_access_:id' => 'Access the department' + ] + ); + $this->get('/departments/daily-reports/bookings-count', function () { // Require the user to be logged in global $response; @@ -628,6 +1193,62 @@ class departmentDailyReportsRoute ); } + private function requireComplaintWashDateParameter(string $parameter_name): ?string + { + self::requireType( + self::getParameter($parameter_name), + self::type_string() + ); + self::requireMinLength( + $parameter_name, + 1 + ); + self::requireMaxLength( + $parameter_name, + 10 + ); + + $wash_date = trim((string)self::getParameter($parameter_name)); + if ($wash_date === '') { + return null; + } + + self::requireDateFormat( + $wash_date, + self::FORMAT_DATE() + ); + + return $wash_date; + } + + private function requireComplaintCategoryParameter(string $parameter_name): ?string + { + self::requireType( + self::getParameter($parameter_name), + self::type_string() + ); + self::requireMinLength( + $parameter_name, + 1 + ); + self::requireMaxLength( + $parameter_name, + 64 + ); + + $category = trim((string)self::getParameter($parameter_name)); + if ($category === '') { + return null; + } + + if (!department_daily_report_complaints_o::isValidCategory($category)) { + global $response; + $response->error('Invalid complaint category', 400); + } + + return $category; + } + /** * Get the date_to parameter, or return the date parameter if not set * @return string @@ -679,4 +1300,835 @@ class departmentDailyReportsRoute ); } -} \ No newline at end of file + /** + * @param array $department_ids + * @return array{ + * department_ids:array, + * date:string, + * date_to:string, + * metrics:array>, + * products:array> + * } + * @throws Exception + */ + private function buildDailyReportOverview(array $department_ids, string $date, string $date_to): array + { + $repository = $this->dailyReportRepository(); + $transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to); + $booking_summary = $repository->getBookingSummaryForDepartments($date, $department_ids, $date_to); + + $product_definitions = $this->getDailyReportProductDefinitions(); + $product_summary_lookup = $repository->getProductOverviewForDepartments( + $date, + $department_ids, + array_column($product_definitions, 'product_id'), + $date_to + ); + + $complaints_metric = $this->buildComplaintsMetric($department_ids, $date, $date_to); + + $night_wash_metric = $this->buildNightWashMetric($department_ids, $date, $date_to); + + $overtime_metric = $this->buildOvertimeMetric($department_ids, $date, $date_to); + + return $this->assembleDailyReportOverview( + $department_ids, + $date, + $date_to, + $transaction_summary, + $booking_summary, + $product_definitions, + $product_summary_lookup, + $complaints_metric, + $night_wash_metric, + $overtime_metric + ); + } + + /** + * @param array $department_ids + * @param array{quantity:int,products:int,earnings:int,washes:int,water_usage:int} $transaction_summary + * @param array{completed:int,total:int} $booking_summary + * @param array $product_definitions + * @param array $product_summary_lookup + * @param array $complaints_metric + * @param array $night_wash_metric + * @param array $overtime_metric + * @return array{ + * department_ids:array, + * date:string, + * date_to:string, + * metrics:array>, + * products:array> + * } + */ + private function assembleDailyReportOverview( + array $department_ids, + string $date, + string $date_to, + array $transaction_summary, + array $booking_summary, + array $product_definitions, + array $product_summary_lookup, + array $complaints_metric, + array $night_wash_metric, + array $overtime_metric + ): array { + $products = []; + foreach ($product_definitions as $definition) { + $product_id = (int)$definition['product_id']; + $product_summary = $product_summary_lookup[$product_id] ?? [ + 'product_id' => $product_id, + 'quantity' => 0, + 'out_of' => 0, + ]; + + $products[] = [ + 'product_id' => $product_id, + 'slug' => (string)$definition['slug'], + 'title' => (string)$definition['title'], + 'state' => 'ready', + 'value' => (int)($product_summary['quantity'] ?? 0), + 'out_of' => (int)($product_summary['out_of'] ?? 0), + ]; + } + + return [ + 'department_ids' => array_values(array_map('intval', $department_ids)), + 'date' => $date, + 'date_to' => $date_to, + 'metrics' => [ + 'bookings' => $this->metricPayload( + (int)($booking_summary['completed'] ?? 0), + (int)($booking_summary['total'] ?? 0) + ), + 'complaints' => $complaints_metric, + 'night_washes' => $night_wash_metric, + 'revenue' => $this->metricPayload((int)($transaction_summary['earnings'] ?? 0)), + 'washes' => $this->metricPayload((int)($transaction_summary['washes'] ?? 0)), + 'products_sold' => $this->metricPayload((int)($transaction_summary['products'] ?? 0)), + 'transactions' => $this->metricPayload((int)($transaction_summary['quantity'] ?? 0)), + 'water_usage' => $this->metricPayload((int)($transaction_summary['water_usage'] ?? 0)), + 'overtime' => $overtime_metric, + ], + 'products' => $products, + ]; + } + + /** + * @param mixed $department_ids + * @return array + */ + private function normalizeDepartmentIdsParameter(mixed $department_ids): array + { + $normalized = []; + $appendDepartmentIds = function (mixed $value) use (&$appendDepartmentIds, &$normalized): void { + if (is_array($value)) { + foreach ($value as $nested_value) { + $appendDepartmentIds($nested_value); + } + return; + } + + if (is_string($value) && str_contains($value, ',')) { + foreach (explode(',', $value) as $segment) { + $appendDepartmentIds(trim($segment)); + } + return; + } + + $id = (int)$value; + if ($id > 0) { + $normalized[$id] = $id; + } + }; + + $appendDepartmentIds($department_ids); + + return array_values($normalized); + } + + /** + * @return array + */ + private function getDailyReportProductDefinitions(): array + { + return [ + ['product_id' => 24, 'slug' => 'spot-free-lastbil', 'title' => 'Spot Free (Lastbil)'], + ['product_id' => 25, 'slug' => 'faelg-flex', 'title' => 'Fælg flex pr. enhed'], + ['product_id' => 27, 'slug' => 'extraordinary-10-min', 'title' => 'Ekstraordinær pr. 10 min inkl. kemi'], + ['product_id' => 26, 'slug' => 'hoejglans', 'title' => 'Højglans - Voksforsegling pr. enhed'], + ['product_id' => 21, 'slug' => 'undervognsskyl', 'title' => 'Undervognsskyl pr. enhed'], + ['product_id' => 22, 'slug' => 'double-duty-kemi', 'title' => 'Tillæg for Specialsæbe - DD'], + ]; + } + + /** + * @param array $department_ids + * @return array + * @throws Exception + */ + private function buildNightWashMetric(array $department_ids, string $date, string $date_to): array + { + return $this->outsideHoursStatisticsService()->toOverviewMetric( + $this->outsideHoursStatisticsService()->getSummary($date, $department_ids, $date_to) + ); + + foreach ($department_ids as $department_id) { + if (!isset($opening_hours_by_department_id[$department_id])) { + return $this->metricPayload( + null, + null, + 'unavailable', + 'Døgnvask kræver åbningstider for alle valgte afdelinger.' + ); + } + } + + $night_wash_count = 0; + foreach ($wash_transactions as $wash_transaction) { + $department_id = (int)($wash_transaction['department_id'] ?? 0); + $created_at = (string)($wash_transaction['created_at'] ?? ''); + if ($created_at === '') { + continue; + } + + $opening_hours = $opening_hours_by_department_id[$department_id] ?? null; + if (!is_array($opening_hours)) { + continue; + } + + if ($this->isOutsideOpeningHours($created_at, $opening_hours)) { + $night_wash_count++; + } + } + + return $this->metricPayload($night_wash_count); + } + + /** + * @param array $department_ids + * @return array + */ + private function buildComplaintsMetric(array $department_ids, string $date, string $date_to): array + { + return $this->metricPayload( + $this->dailyReportComplaintsRepository()->countForDepartmentsInRange($department_ids, $date, $date_to) + ); + } + + /** + * @param array $department_ids + * @return array + * @throws Exception + */ + private function buildOvertimeMetric(array $department_ids, string $date, string $date_to): array + { + $departments = $this->fetchDepartmentsByIds($department_ids); + if (count($departments) !== count($department_ids)) { + return $this->metricPayload( + null, + null, + 'unavailable', + 'Overarbejde er ikke tilgængelig for alle valgte afdelinger.' + ); + } + + $workfeed_department_ids_by_department = $this->resolveWorkfeedDepartmentIdsByDepartmentId($departments); + if (count($workfeed_department_ids_by_department) !== count($department_ids)) { + return $this->metricPayload( + null, + null, + 'unavailable', + 'Overarbejde kræver Workfeed-kobling for alle valgte afdelinger.' + ); + } + + $range_start = new DateTime($date . ' 00:00:00'); + $range_end_exclusive = new DateTime($date_to . ' 00:00:00'); + $range_end_exclusive->add(new DateInterval('P1D')); + + $query_start = clone $range_start; + $query_start->sub(new DateInterval('P1D')); + + $overtime_hours = $this->sumOvertimeHoursForShifts( + $this->fetchWorkfeedShifts($query_start, $range_end_exclusive), + $workfeed_department_ids_by_department, + $range_start, + $range_end_exclusive + ); + + return $this->metricPayload($overtime_hours); + } + + /** + * @param array $metric + * @return array + */ + private function metricPayload(mixed $value, mixed $out_of = null, string $state = 'ready', string $message = null): array + { + return [ + 'state' => $state, + 'value' => $value, + 'out_of' => $out_of, + 'message' => $message, + ]; + } + + /** + * @param array> $opening_hours_by_department_id + * @return bool + */ + private function isOutsideOpeningHours(string $created_at, array $opening_hours): bool + { + $timestamp = strtotime($created_at); + if ($timestamp === false) { + return false; + } + + $weekday = match ((int)date('N', $timestamp)) { + 1 => 'monday', + 2 => 'tuesday', + 3 => 'wednesday', + 4 => 'thursday', + 5 => 'friday', + 6 => 'saturday', + 7 => 'sunday', + default => null, + }; + + if ($weekday === null) { + return false; + } + + $opening_start = $opening_hours[$weekday . '_start'] ?? null; + $opening_end = $opening_hours[$weekday . '_end'] ?? null; + if (!is_string($opening_start) || trim($opening_start) === '' || !is_string($opening_end) || trim($opening_end) === '') { + return true; + } + + $wash_time = date('H:i', $timestamp); + $opening_start_time = date('H:i', strtotime($opening_start)); + $opening_end_time = date('H:i', strtotime($opening_end)); + + return !($wash_time >= $opening_start_time && $wash_time <= $opening_end_time); + } + + /** + * @param array $shifts + * @param array $workfeed_department_ids_by_department + */ + private function sumOvertimeHoursForShifts( + array $shifts, + array $workfeed_department_ids_by_department, + DateTime $range_start, + DateTime $range_end_exclusive + ): float { + $department_lookup = []; + foreach ($workfeed_department_ids_by_department as $workfeed_department_id) { + $normalized = trim((string)$workfeed_department_id); + if ($normalized !== '') { + $department_lookup[$normalized] = true; + } + } + + if ($department_lookup === []) { + return 0.0; + } + + $total_hours = 0.0; + foreach ($shifts as $shift) { + $shift_department_id = $this->extractWorkfeedDepartmentId($shift); + if ($shift_department_id === null || !isset($department_lookup[$shift_department_id])) { + continue; + } + + $total_hours += $this->calculateShiftOvertimeHoursInRange( + $this->normalizeWorkfeedRecord($shift), + $range_start, + $range_end_exclusive + ); + } + + return round($total_hours, 2); + } + + private function calculateShiftOvertimeHoursInRange(array $record, DateTime $range_start, DateTime $range_end_exclusive): float + { + return workfeed_shift_time_resolver::calculateOvertimeHoursInRange($record, $range_start, $range_end_exclusive); + } + + /** + * @param array $departments + * @return array + */ + private function resolveWorkfeedDepartmentIdsByDepartmentId(array $departments): array + { + $resolved_ids = []; + $workfeed_departments = null; + + foreach ($departments as $department) { + $department_id = $this->departmentIdFromValue($department); + if ($department_id < 1) { + continue; + } + + $configured_id = $this->getConfiguredWorkfeedDepartmentId($department); + if ($configured_id !== null) { + $resolved_ids[$department_id] = $configured_id; + continue; + } + + if ($workfeed_departments === null) { + $workfeed_departments = $this->fetchWorkfeedDepartments(); + } + if ($workfeed_departments === []) { + continue; + } + + $department_name = $this->departmentNameFromValue($department); + if ($department_name === '') { + continue; + } + + $matched_id = $this->matchWorkfeedDepartmentIdByName($department_name, $workfeed_departments); + if ($matched_id !== null) { + $resolved_ids[$department_id] = $matched_id; + } + } + + return $resolved_ids; + } + + private function getConfiguredWorkfeedDepartmentId(mixed $department): ?string + { + foreach ([ + 'workfeed_department_id', + 'workfeedDepartmentId', + 'workfeed_departmentID', + 'workfeed_department', + ] as $key) { + $value = $this->departmentVariableValue($department, $key); + if (!is_string($value)) { + continue; + } + + $trimmed = trim($value); + if ($trimmed !== '') { + return $trimmed; + } + } + + return null; + } + + /** + * @param array $workfeed_departments + */ + private function matchWorkfeedDepartmentIdByName(string $department_name, array $workfeed_departments): ?string + { + $needle = $this->normalizeDepartmentName($department_name); + foreach ($workfeed_departments as $entry) { + $record = $this->normalizeWorkfeedRecord($entry); + $name = trim((string)($record['name'] ?? '')); + $id = trim((string)($record['id'] ?? '')); + if ($name === '' || $id === '') { + continue; + } + + if ($this->normalizeDepartmentName($name) === $needle) { + return $id; + } + } + + return null; + } + + private function normalizeDepartmentName(string $name): string + { + $collapsed = preg_replace('/\s+/', ' ', trim($name)); + + return strtolower($collapsed ?? trim($name)); + } + + /** + * @return array + */ + private function normalizeWorkfeedCollection(mixed $payload): array + { + if (is_array($payload)) { + return $payload; + } + + if (!is_object($payload)) { + return []; + } + + foreach (['data', 'items', 'results', 'shifts', 'departments'] as $key) { + if (!isset($payload->$key)) { + continue; + } + + $value = $payload->$key; + if (is_array($value)) { + return $value; + } + if (is_object($value)) { + return array_values(get_object_vars($value)); + } + } + + return []; + } + + /** + * @return array + */ + private function normalizeWorkfeedRecord(mixed $record): array + { + if (is_array($record)) { + return $record; + } + if (is_object($record)) { + return get_object_vars($record); + } + + return []; + } + + private function extractWorkfeedDepartmentId(mixed $shift): ?string + { + $record = $this->normalizeWorkfeedRecord($shift); + + $department_id = $record['departmentID'] ?? $record['departmentId'] ?? null; + if (($department_id === null || $department_id === '') && isset($record['department'])) { + $department = $this->normalizeWorkfeedRecord($record['department']); + $department_id = $department['id'] ?? $department['departmentID'] ?? $department['departmentId'] ?? null; + } + + if ($department_id === null) { + return null; + } + + $normalized = trim((string)$department_id); + return $normalized === '' ? null : $normalized; + } + + private 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 = $this->normalizeWorkfeedRecord($value); + foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) { + if (!array_key_exists($key, $record)) { + continue; + } + + $parsed = $this->parseDateTimeValue($record[$key]); + if ($parsed !== null) { + return $parsed; + } + } + + return null; + } + + private 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 $paths + */ + private function firstShiftDateTimeFromPaths(array $record, array $paths): ?DateTime + { + foreach ($paths as $path) { + $parsed = $this->parseDateTimeValue($this->getNestedRecordValue($record, $path)); + if ($parsed !== null) { + return $parsed; + } + } + + return null; + } + + /** + * @param array $paths + */ + private function lastShiftDateTimeFromPaths(array $record, array $paths): ?DateTime + { + $latest = null; + foreach ($paths as $path) { + $parsed = $this->parseDateTimeValue($this->getNestedRecordValue($record, $path)); + if ($parsed === null) { + continue; + } + + if ($latest === null || $parsed->getTimestamp() > $latest->getTimestamp()) { + $latest = $parsed; + } + } + + return $latest; + } + + private 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 function resolveEffectiveShiftEnd(array $record, DateTime $shift_start, DateTime $shift_end): DateTime + { + if ($this->hasShiftApproval($record)) { + return $shift_end; + } + + $update_time = $this->parseDateTimeValue($record['updateTime'] ?? null); + if ($update_time === null) { + return $shift_end; + } + + $shift_start_ts = $shift_start->getTimestamp(); + $shift_end_ts = $shift_end->getTimestamp(); + $update_ts = $update_time->getTimestamp(); + + if ($update_ts <= $shift_end_ts) { + return $shift_end; + } + + $max_unapproved_extension_seconds = 6 * 3600; + if (($update_ts - $shift_end_ts) > $max_unapproved_extension_seconds) { + return $shift_end; + } + if (($update_ts - $shift_start_ts) > 24 * 3600) { + return $shift_end; + } + + return $update_time; + } + + /** + * @param array $department_ids + * @return array> + */ + protected function fetchOpeningHoursByDepartmentId(array $department_ids): array + { + global $db; + + $normalized_department_ids = array_values(array_unique(array_map('intval', $department_ids))); + $normalized_department_ids = array_values(array_filter($normalized_department_ids, static fn(int $id): bool => $id > 0)); + if ($normalized_department_ids === []) { + return []; + } + + $department_ids_sql = implode(',', $normalized_department_ids); + $sql = "SELECT * FROM department_time_bookings_opening_hours WHERE department IN ($department_ids_sql)"; + $result = $db->query($sql); + if (!is_object($result) || $result->num_rows === 0) { + return []; + } + + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[(int)($row['department'] ?? 0)] = $row; + } + + return $rows; + } + + /** + * @param array $department_ids + * @return array + * @throws Exception + */ + protected function fetchDepartmentsByIds(array $department_ids): array + { + $departments = []; + foreach ($department_ids as $department_id) { + $department = (new departments_o())->select((int)$department_id); + if (!$department->exists()) { + continue; + } + $departments[] = $department; + } + + return $departments; + } + + /** + * @return array + */ + protected function fetchWorkfeedDepartments(): array + { + try { + return $this->normalizeWorkfeedCollection((new workfeed())->listDepartments()); + } catch (Exception) { + return []; + } + } + + /** + * @return array + */ + protected function fetchWorkfeedShifts(DateTime $query_start, DateTime $range_end_exclusive): array + { + try { + return $this->normalizeWorkfeedCollection((new workfeed())->listShifts([ + 'startFrom' => $query_start->format(DateTime::ATOM), + 'startTo' => $range_end_exclusive->format(DateTime::ATOM), + ])); + } catch (Exception) { + return []; + } + } + + protected function dailyReportRepository(): object + { + return new department_daily_reports_o(); + } + + protected function dailyReportComplaintsRepository(): object + { + return new department_daily_report_complaints_o(); + } + + protected function complaintCustomerSearchService(): economicCustomers + { + return new economicCustomers(); + } + + protected function outsideHoursStatisticsService(): department_outside_hours_statistics_service + { + return new department_outside_hours_statistics_service(); + } + + private function sanitizeComplaintCustomerLookupUpstreamErrorMessage(\Throwable $throwable): string + { + $message = trim($throwable->getMessage()); + if ($message === '') { + return 'Unexpected e-conomic integration error.'; + } + + $message = preg_replace('/\s+/', ' ', $message); + if (!is_string($message)) { + return 'Unexpected e-conomic integration error.'; + } + + return substr($message, 0, 500); + } + + private function departmentIdFromValue(mixed $department): int + { + if (is_array($department)) { + return (int)($department['id'] ?? 0); + } + if (is_object($department)) { + return (int)($department->id ?? 0); + } + + return 0; + } + + private function departmentNameFromValue(mixed $department): string + { + if (is_array($department)) { + return trim((string)($department['name'] ?? '')); + } + if (is_object($department) && isset($department->name)) { + $value = $department->name; + if (is_object($value) && method_exists($value, 'value')) { + return trim((string)$value->value()); + } + + return trim((string)$value); + } + + return ''; + } + + private function departmentVariableValue(mixed $department, string $key): mixed + { + if (is_array($department)) { + return $department[$key] ?? null; + } + + if (is_object($department) && isset($department->variables) && is_object($department->variables) && method_exists($department->variables, 'getVariable')) { + return $department->variables->getVariable($key); + } + + return null; + } + +} diff --git a/services/nginx/app/routes/departmentGatesRelaysRoute.php b/services/nginx/app/routes/departmentGatesRelaysRoute.php index 90edf626..bc45d53e 100644 --- a/services/nginx/app/routes/departmentGatesRelaysRoute.php +++ b/services/nginx/app/routes/departmentGatesRelaysRoute.php @@ -82,6 +82,7 @@ class departmentGatesRelaysRoute $config = is_array($config) ? $config : (array)$config; $gateConfig = new department_gate_config($config); + $gateConfig->validate(); $gate = (new department_gates_o())->add( $department, diff --git a/services/nginx/app/routes/departmentGoalsRoute.php b/services/nginx/app/routes/departmentGoalsRoute.php index ba76c685..76507892 100644 --- a/services/nginx/app/routes/departmentGoalsRoute.php +++ b/services/nginx/app/routes/departmentGoalsRoute.php @@ -54,7 +54,11 @@ class departmentGoalsRoute } // Access control: must have all departments unless superuser if (!$isSuperuser && !$hasAllDepartments((array)$goal->departments->value())) { - $response->error('You are not allowed to access this goal', 403); + $missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', (array)$goal->departments->value()), array_map('intval', $userDepartments)))); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments), + 'superuser' + ]); } $response->success($goal->asArray()); } @@ -115,7 +119,10 @@ class departmentGoalsRoute $goalDepartments = array_map(fn($d) => (int)$d->id, $departments); $missing = array_diff($goalDepartments, array_map('intval', $userDepartments)); if (count($missing) > 0) { - $response->error('You are not allowed to create goals for one or more selected departments', 403); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . (int)$departmentId, $missing), + 'superuser' + ]); } } @@ -159,7 +166,11 @@ class departmentGoalsRoute // If not superuser or creator, require access to existing goal departments if (!$isSuperuser && !$isCreator && !$hasAllDepartments((array)$goal->departments->value(), $userDepartments)) { - $response->error('You are not allowed to update this goal', 403); + $missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', (array)$goal->departments->value()), array_map('intval', $userDepartments)))); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments), + 'superuser' + ]); } $dataToUpdate = []; @@ -177,7 +188,10 @@ class departmentGoalsRoute if (!$isSuperuser && !$isCreator) { $missing = array_diff(array_map('intval', $departmentsInput), array_map('intval', $userDepartments)); if (count($missing) > 0) { - $response->error('You are not allowed to assign one or more selected departments to this goal', 403); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . (int)$departmentId, $missing), + 'superuser' + ]); } } // store as ids array in column @@ -247,7 +261,11 @@ class departmentGoalsRoute $hasAll = count(array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments))) === 0; if (!$isSuperuser && !$isCreator && !$hasAll) { - $response->error('You are not allowed to delete this goal', 403); + $missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments)))); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments), + 'superuser' + ]); } $goal->delete(); $response->success(['message' => 'Deleted']); @@ -282,7 +300,11 @@ class departmentGoalsRoute $goalDepartments = (array)$goal->departments->value(); $hasAll = count(array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments))) === 0; if (!$isSuperuser && !$hasAll) { - $response->error('You are not allowed to send progress alerts for this goal', 403); + $missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments)))); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments), + 'superuser' + ]); } // Rebuild criteria from stored array diff --git a/services/nginx/app/routes/departmentLanesRoute.php b/services/nginx/app/routes/departmentLanesRoute.php index e3f3de0a..3a6bb12f 100644 --- a/services/nginx/app/routes/departmentLanesRoute.php +++ b/services/nginx/app/routes/departmentLanesRoute.php @@ -4,6 +4,7 @@ namespace routes; use classes\authentication; use classes\response; +use classes\shelly_relay_inventory; use dynamicimages\images\machine_1; use objects\categories_o; use objects\department_lanes_o; @@ -17,6 +18,35 @@ class departmentLanesRoute public function run(): void { + $this->get('/department/lanes/status-toggles', function () { + global $response; + + $this->requirePermission('list_department_lanes'); + self::requireParameters(['department_id']); + $department_id = (int)self::getParameter('department_id'); + self::requireType($department_id, self::type_int()); + self::requireMinValue($department_id, 1); + self::requireDepartmentAccess($department_id); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('department_lanes', 'global', 1, 0, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User tried to list department lane status toggles without being logged in'); + $response->error('Invalid session', 400); + } + + (new logs_o())->add('department_lanes', 'global', 1, $user->id, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User listed department lane status toggles for department ' . $department_id); + $response->success( + array_map( + static fn (department_lanes_o $department_lane): array => $department_lane->asArray(), + (new department_lanes_o())->getDepartmentLanes($department_id) + ) + ); + }, + [ + 'list_department_lanes' => 'List department lane status toggles' + ] + ); + $this->get('/department/lanes', function () { // Require the user to be logged in @@ -55,7 +85,11 @@ class departmentLanesRoute 'relay_in_id', 'relay_out_id', 'relay_machine_id', + 'relay_machine_program_picker_id', + 'relay_machine_cleaner_id', 'dynamic_image_id', + 'machine_type_id', + 'selfserve_enabled', ]) ->listObjectsWithPaginationIfSet( function ($department_lane) use ($user) { @@ -78,13 +112,59 @@ class departmentLanesRoute ] ); + $this->get('/department/lanes/relay-options', function () { + + global $response; + $this->requirePermission('list_department_lanes'); + $user = (new authentication())->get_user(); + + if ($user) { + try { + $options = (new shelly_relay_inventory())->listRelayOptions(); + (new logs_o())->add( + 'department_lanes', + 'global', + 1, + $user->id, + 'LIST_DEPARTMENT_LANE_RELAY_OPTIONS', + 'User listed available Shelly relay options' + ); + $response->success($options); + } catch (\Throwable $e) { + (new logs_o())->add( + 'department_lanes', + 'global', + 0, + $user->id, + 'LIST_DEPARTMENT_LANE_RELAY_OPTIONS', + 'Failed to list Shelly relay options: ' . $e->getMessage() + ); + $response->error('Failed to fetch Shelly relay options: ' . $e->getMessage(), 400); + } + } else { + (new logs_o())->add( + 'department_lanes', + 'global', + 1, + 0, + 'LIST_DEPARTMENT_LANE_RELAY_OPTIONS', + 'User tried to list Shelly relay options without being logged in' + ); + $response->error('Invalid session', 400); + } + }, + [ + 'list_department_lanes' => 'List available Shelly relay options for department lanes' + ] + ); + /** * Generate dynamic image for a department lane (machine UI) * * Query parameters: * - department (int, required) * - lane (int, required) - * - buttons (array|json|csv, optional) → highlighted button IDs (0-indexed) + * - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", "start", or "program_picker" * - current_step (int >= 0, optional) → current click/step indicator * - only_current_step (bool/int, optional) → if true, only draw current step highlight * - vehicle_type (int|null, optional) → normalized but currently not used by machine_1 @@ -119,6 +199,15 @@ class departmentLanesRoute // Resolve dynamic image id → class (support id=1 for now) $dynamic_image_id = $lane->dynamic_image_id->value(); + if ($response->isRequestParameterSet('dynamic_image_id')) { + $dynamic_image_override = $response->getRequestParameter('dynamic_image_id'); + if ($dynamic_image_override === null || $dynamic_image_override === '' || strtolower((string)$dynamic_image_override) === 'null') { + $dynamic_image_id = null; + } else { + $dynamic_image_id = (int)$dynamic_image_override; + self::requireMinValue($dynamic_image_id, 1); + } + } if ($dynamic_image_id === null) { $response->error('No dynamic image configured for this lane', 404); } @@ -159,6 +248,14 @@ class departmentLanesRoute } } + // Thumb position (normalized) + $thumb_position = null; + if ($response->isRequestParameterSet('thumb_position')) { + $thumb_position = (int)$response->getRequestParameter('thumb_position'); + self::requireMinValue($thumb_position, 1); + self::requireMaxValue($thumb_position, 12); + } + // Cache check $cacheKey = null; if (defined('redis')) { @@ -167,9 +264,10 @@ class departmentLanesRoute 'buttons' => $buttons, 'current_step' => $current_step, 'only_current_step' => (bool)$only_current_step, - 'vehicle_type' => $vehicle_type + 'vehicle_type' => $vehicle_type, + 'thumb_position' => $thumb_position, ]; - $cacheKey = 'dynamic_image:' . md5(json_encode($cacheParams)); + $cacheKey = 'dynamic_image_v2:' . md5(json_encode($cacheParams)); $cachedImage = redis->get($cacheKey); if ($cachedImage) { header('Content-Type: image/png'); @@ -183,6 +281,9 @@ class departmentLanesRoute switch ($dynamic_image_id) { case 1: $image = new machine_1(); + if ($thumb_position !== null) { + $image->thumb_position = $thumb_position; + } break; default: $response->error('Unsupported dynamic image id: ' . $dynamic_image_id, 400); @@ -238,23 +339,39 @@ class departmentLanesRoute // Get the request data $name = $response->getRequestParameter('name') ?? null; $department = $response->getRequestParameter('department') ?? null; - $relay_in_id = $response->getRequestParameter('relay_in_id') ?? null; - $relay_out_id = $response->getRequestParameter('relay_out_id') ?? null; - $relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null; + $relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null); + $relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null); + $relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null); + $relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null); + $relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null); + $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; + $machine_type_id = $response->getRequestParameter('machine_type_id') ?? null; + $selfserve_enabled = self::isParametersSet(['selfserve_enabled']) + ? department_lanes_o::normalizeSelfServeEnabledValue($response->getRequestParameter('selfserve_enabled')) + : true; if ($dynamic_image_id !== null) { $did = (int)$dynamic_image_id; $this->requireType($did, $this->type_int()); $this->requireMinValue($did, 1); $dynamic_image_id = $did; } - // Remove spaces from the relay_in_id and relay_out_id + if ($machine_type_id !== null && $machine_type_id !== '' && strtolower((string)$machine_type_id) !== 'null') { + $machine_type_id = (int)$machine_type_id; + $this->requireType($machine_type_id, $this->type_int()); + $this->requireMinValue($machine_type_id, 1); + } else { + $machine_type_id = null; + } // Check if the required fields are set if ($name && $department) { // Add the department lane - (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $dynamic_image_id); + $created_lane = (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id, $selfserve_enabled); // Return a success message - $response->success('Department lane added'); + $response->success([ + 'message' => 'Department lane added', + 'lane' => $created_lane->asArray(), + ]); } else { // Return an error $response->error('Missing required fields', 400); @@ -287,10 +404,13 @@ class departmentLanesRoute $id = $response->getRequestParameter('id') ?? null; $name = $response->getRequestParameter('name') ?? null; $department = $response->getRequestParameter('department') ?? null; - $relay_in_id = $response->getRequestParameter('relay_in_id') ?? null; - $relay_out_id = $response->getRequestParameter('relay_out_id') ?? null; - $relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null; + $relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null); + $relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null); + $relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null); + $relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null); + $relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null); $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; + $machine_type_id = $response->getRequestParameter('machine_type_id') ?? null; // Check what fields are set self::requireParameters(['id']); @@ -301,6 +421,7 @@ class departmentLanesRoute // Return an error $response->error('Department lane not found', 404); } + $was_selfserve_enabled = $department_lane->isSelfServeEnabled(); // Update the department lane fields that are set if (self::isParametersSet(['name'])) { $department_lane->name->set($name); @@ -309,13 +430,19 @@ class departmentLanesRoute $department_lane->department->set((int)$department); } if (self::isParametersSet(['relay_in_id'])) { - $department_lane->relay_in_id->set((string)$relay_in_id); + self::syncDepartmentLaneRelayValue($department_lane->relay_in_id, $relay_in_id); } if (self::isParametersSet(['relay_out_id'])) { - $department_lane->relay_out_id->set((string)$relay_out_id); + self::syncDepartmentLaneRelayValue($department_lane->relay_out_id, $relay_out_id); } if (self::isParametersSet(['relay_machine_id'])) { - $department_lane->relay_machine_id->set((string)$relay_machine_id); + self::syncDepartmentLaneRelayValue($department_lane->relay_machine_id, $relay_machine_id); + } + if (self::isParametersSet(['relay_machine_program_picker_id'])) { + self::syncDepartmentLaneRelayValue($department_lane->relay_machine_program_picker_id, $relay_machine_program_picker_id); + } + if (self::isParametersSet(['relay_machine_cleaner_id'])) { + self::syncDepartmentLaneRelayValue($department_lane->relay_machine_cleaner_id, $relay_machine_cleaner_id); } if (self::isParametersSet(['dynamic_image_id'])) { $param = $response->getRequestParameter('dynamic_image_id'); @@ -328,8 +455,29 @@ class departmentLanesRoute $department_lane->dynamic_image_id->set($did); } } + if (self::isParametersSet(['machine_type_id'])) { + $param = $machine_type_id; + if ($param === null || $param === '' || (is_string($param) && strtolower($param) === 'null')) { + $department_lane->machine_type_id->nullify(); + } else { + $machineTypeId = (int)$param; + $this->requireType($machineTypeId, $this->type_int()); + $this->requireMinValue($machineTypeId, 1); + $department_lane->machine_type_id->set($machineTypeId); + } + } + if (self::isParametersSet(['selfserve_enabled'])) { + $next_selfserve_enabled = department_lanes_o::normalizeSelfServeEnabledValue($response->getRequestParameter('selfserve_enabled')); + $department_lane->selfserve_enabled->set($next_selfserve_enabled); + if ($was_selfserve_enabled && !$next_selfserve_enabled) { + department_lanes_o::disableSelfServeRelaysBestEffort((int)$department_lane->id); + } + } // Return a success message - $response->success('Department lane updated'); + $response->success([ + 'message' => 'Department lane updated', + 'lane' => $department_lane->asArray(), + ]); } else { // Log the incident (new logs_o())->add('department_lanes', 'global', 1, 0, 'EDIT_DEPARTMENT_LANE', 'User tried to edit a department lane without being logged in'); @@ -342,4 +490,25 @@ class departmentLanesRoute ] ); } -} \ No newline at end of file + + private static function normalizeRelayRequestParameter(mixed $value): ?string + { + $normalized = trim((string)($value ?? '')); + if ($normalized === '' || strtolower($normalized) === 'null') { + return null; + } + + return $normalized; + } + + private static function syncDepartmentLaneRelayValue(mixed $field, mixed $value): void + { + $normalized = self::normalizeRelayRequestParameter($value); + if ($normalized === null) { + $field->nullify(); + return; + } + + $field->set($normalized); + } +} diff --git a/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php b/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php index 31b39e10..a068024e 100644 --- a/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php +++ b/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php @@ -6,6 +6,7 @@ namespace routes; use classes\authentication; +use modules\selfserve\classes\selfserve_config_versioning; use classes\response; use objects\department_selfserve_conditions_o; use objects\department_selfserve_condition_rules_o; @@ -39,8 +40,8 @@ class departmentSelfserveConditionRulesRoute $condition_o->select((int)$rules_o->condition_id->value()); $authorized_department_ids = $user->getGroup()->getDepartments(); if ($condition_o->exists()) { - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value()) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { + $this->forbidDepartmentAccess((int)$condition_o->department->value(), ['view_all_department_selfserve_condition_rules']); } } $response->success($rules_o->asArray()); @@ -58,8 +59,8 @@ class departmentSelfserveConditionRulesRoute $condition_o->select($filters['condition_id']); if ($condition_o->exists()) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value()) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { + $this->forbidDepartmentAccess((int)$condition_o->department->value(), ['view_all_department_selfserve_condition_rules']); } } } @@ -118,8 +119,8 @@ class departmentSelfserveConditionRulesRoute $response->error('Condition not found', 404); } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } try { @@ -131,6 +132,7 @@ class departmentSelfserveConditionRulesRoute $name, $description ); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$condition_o->department->value()); (new logs_o())->add('department_selfserve_condition_rules', 'global', 1, $user->id, 'ADD_RULE', 'User added department self-serve condition rule ' . $rule_o->id); $response->success($rule_o->asArray()); } catch (\Exception $e) { @@ -166,9 +168,12 @@ class departmentSelfserveConditionRulesRoute $condition_o = new department_selfserve_conditions_o(); $condition_o->select((int)$rule_o->condition_id->value()); $authorized_department_ids = $user->getGroup()->getDepartments(); - if ($condition_o->exists() && !in_array((int)$condition_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if ($condition_o->exists() && !$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } + $originalDepartment = $condition_o->exists() + ? (int)$condition_o->department->value() + : 0; if ($response->isRequestParameterSet('condition_id')) { $new_condition_id = (int)$response->getRequestParameter('condition_id'); @@ -177,8 +182,8 @@ class departmentSelfserveConditionRulesRoute if (!$new_condition_o->exists()) { $response->error('Target condition not found', 404); } - if (!in_array((int)$new_condition_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to the target department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$new_condition_o->department->value())) { + $this->forbidDepartmentAccess((int)$new_condition_o->department->value()); } $rule_o->condition_id->update($new_condition_id); } @@ -198,6 +203,11 @@ class departmentSelfserveConditionRulesRoute $rule_o->description->update((string)$response->getRequestParameter('description')); } + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($originalDepartment); + $updatedCondition = (new department_selfserve_conditions_o())->select((int)$rule_o->condition_id->value()); + if ($updatedCondition->exists()) { + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$updatedCondition->department->value()); + } (new logs_o())->add('department_selfserve_condition_rules', 'global', 1, $user->id, 'UPDATE_RULE', 'User updated department self-serve condition rule ' . $id); $response->success($rule_o->asArray()); } else { @@ -230,11 +240,14 @@ class departmentSelfserveConditionRulesRoute $condition_o = new department_selfserve_conditions_o(); $condition_o->select((int)$rule_o->condition_id->value()); $authorized_department_ids = $user->getGroup()->getDepartments(); - if ($condition_o->exists() && !in_array((int)$condition_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if ($condition_o->exists() && !$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } $rule_o->delete(); + if ($condition_o->exists()) { + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$condition_o->department->value()); + } (new logs_o())->add('department_selfserve_condition_rules', 'global', 1, $user->id, 'DELETE_RULE', 'User deleted department self-serve condition rule ' . $id); $response->success('Rule deleted'); } else { @@ -244,4 +257,9 @@ class departmentSelfserveConditionRulesRoute 'delete_department_selfserve_condition_rules' => 'Delete a department self-serve condition rule' ]); } + + private function canAccessDepartment(array $authorizedDepartmentIds, int $departmentId): bool + { + return $departmentId === 0 || in_array($departmentId, $authorizedDepartmentIds, true); + } } diff --git a/services/nginx/app/routes/departmentSelfserveConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveConditionsRoute.php index 0bcb6248..9f00bb3b 100644 --- a/services/nginx/app/routes/departmentSelfserveConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveConditionsRoute.php @@ -6,6 +6,7 @@ namespace routes; use classes\authentication; +use modules\selfserve\classes\selfserve_config_versioning; use classes\response; use objects\department_selfserve_conditions_o; use objects\logs_o; @@ -35,9 +36,9 @@ class departmentSelfserveConditionsRoute if (self::isParametersSet(['id'])) { $conditions_o->select((int)self::getParameter('id')); if ($conditions_o->exists()) { - if (!in_array((int)$conditions_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$conditions_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$conditions_o->department->value(), ['view_all_department_selfserve_conditions']); } } $response->success($conditions_o->asArray()); @@ -49,15 +50,16 @@ class departmentSelfserveConditionsRoute $filters = []; if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); - if (!in_array($requested_department, $authorized_department_ids) && !$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, $requested_department) && !$has_view_all_permission) { + $this->forbidDepartmentAccess($requested_department, ['view_all_department_selfserve_conditions']); } $filters['department'] = $requested_department; } else { if (!$has_view_all_permission) { if (empty($authorized_department_ids)) { - $response->success([]); - return; + $authorized_department_ids = [0]; + } else { + $authorized_department_ids[] = 0; } $filters['department'] = $authorized_department_ids; } @@ -71,8 +73,12 @@ class departmentSelfserveConditionsRoute $filters['product'] = (int)self::getParameter('product'); } + if (self::isParametersSet(['machine_type_id'])) { + $filters['machine_type_id'] = (int)self::getParameter('machine_type_id'); + } + $response->success( - $conditions_o->setSearchableFields(['id', 'department', 'lane', 'product', 'condition_id', 'name', 'description', 'deleted_at']) + $conditions_o->setSearchableFields(['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'deleted_at']) ->listObjectsWithPaginationIfSet(function ($condition) { $c = new department_selfserve_conditions_o(); $c->select((int)$condition['id']); @@ -95,21 +101,32 @@ class departmentSelfserveConditionsRoute $this->requirePermission('add_department_selfserve_conditions'); $user = (new authentication())->get_user(); if ($user) { - $department = (int)$response->getRequestParameter('department'); - $lane = (int)$response->getRequestParameter('lane'); - $product = (int)$response->getRequestParameter('product'); - $condition_id = $response->getRequestParameter('condition_id'); + $department = $response->isRequestParameterSet('department') ? (int)$response->getRequestParameter('department') : 0; + $lane = $response->isRequestParameterSet('lane') ? (int)$response->getRequestParameter('lane') : 0; + $product = $response->isRequestParameterSet('product') ? (int)$response->getRequestParameter('product') : 0; + $machine_type_id = null; + if ($response->isRequestParameterSet('machine_type_id')) { + $machine_type_param = $response->getRequestParameter('machine_type_id'); + if (!is_null($machine_type_param) && $machine_type_param !== '' && $machine_type_param !== 'null') { + $machine_type_id = (int)$machine_type_param; + } + } + $condition_id = $response->getRequestParameter('condition_id'); // parent condition id for nested condition trees $condition_id = is_null($condition_id) || $condition_id === 'null' ? null : (int)$condition_id; $name = (string)$response->getRequestParameter('name'); $description = (string)$response->getRequestParameter('description'); - if (!$department || !$lane || !$product || !$name || !$description) { - $response->error('Missing required fields', 400); + if (!$name || !$description) { + $response->error('Missing required fields: name and description', 400); + } + + if ($machine_type_id === null && (!$department || !$lane || !$product)) { + $response->error('Missing required fields: either machine_type_id or department, lane, and product', 400); } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($department, $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, $department)) { + $this->forbidDepartmentAccess($department); } try { @@ -119,8 +136,10 @@ class departmentSelfserveConditionsRoute $product, $name, $description, - $condition_id + $condition_id, + $machine_type_id ); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($department); (new logs_o())->add('department_selfserve_conditions', 'global', 1, $user->id, 'ADD_CONDITION', 'User added department self-serve condition ' . $condition_o->id); $response->success($condition_o->asArray()); } catch (\Exception $e) { @@ -143,7 +162,7 @@ class departmentSelfserveConditionsRoute if ($user) { $id = (int)$response->getRequestParameter('id'); if (!$id) { - $response->error('Missing required fields', 400); + $response->error('Missing required fields: id', 400); } $condition_o = new department_selfserve_conditions_o(); @@ -151,16 +170,17 @@ class departmentSelfserveConditionsRoute if (!$condition_o->exists()) { $response->error('Condition not found', 404); } + $originalDepartment = (int)$condition_o->department->value(); $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } if ($response->isRequestParameterSet('department')) { $new_department = (int)$response->getRequestParameter('department'); - if (!in_array($new_department, $authorized_department_ids)) { - $response->error('You do not have access to the target department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, $new_department)) { + $this->forbidDepartmentAccess($new_department); } $condition_o->department->update($new_department); } @@ -170,8 +190,12 @@ class departmentSelfserveConditionsRoute if ($response->isRequestParameterSet('product')) { $condition_o->product->update((int)$response->getRequestParameter('product')); } + if ($response->isRequestParameterSet('machine_type_id')) { + $machine_type_id = $response->getRequestParameter('machine_type_id'); + $condition_o->machine_type_id->update(is_null($machine_type_id) || $machine_type_id === '' || $machine_type_id === 'null' ? null : (int)$machine_type_id); + } if ($response->isRequestParameterSet('condition_id')) { - $condition_id = $response->getRequestParameter('condition_id'); + $condition_id = $response->getRequestParameter('condition_id'); // parent condition id for nested condition trees $condition_o->condition_id->update(is_null($condition_id) || $condition_id === 'null' ? null : (int)$condition_id); } if ($response->isRequestParameterSet('name')) { @@ -181,6 +205,8 @@ class departmentSelfserveConditionsRoute $condition_o->description->update((string)$response->getRequestParameter('description')); } + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($originalDepartment); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$condition_o->department->value()); (new logs_o())->add('department_selfserve_conditions', 'global', 1, $user->id, 'UPDATE_CONDITION', 'User updated department self-serve condition ' . $id); $response->success($condition_o->asArray()); } else { @@ -200,7 +226,7 @@ class departmentSelfserveConditionsRoute if ($user) { $id = (int)$response->getRequestParameter('id'); if (!$id) { - $response->error('Missing required fields', 400); + $response->error('Missing required fields: id', 400); } $condition_o = new department_selfserve_conditions_o(); @@ -210,11 +236,12 @@ class departmentSelfserveConditionsRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } $condition_o->delete(); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$condition_o->department->value()); (new logs_o())->add('department_selfserve_conditions', 'global', 1, $user->id, 'DELETE_CONDITION', 'User deleted department self-serve condition ' . $id); $response->success('Condition deleted'); } else { @@ -224,4 +251,9 @@ class departmentSelfserveConditionsRoute 'delete_department_selfserve_conditions' => 'Delete a department self-serve condition' ]); } + + private function canAccessDepartment(array $authorizedDepartmentIds, int $departmentId): bool + { + return $departmentId === 0 || in_array($departmentId, $authorizedDepartmentIds, true); + } } diff --git a/services/nginx/app/routes/departmentSelfserveConfigVersionsRoute.php b/services/nginx/app/routes/departmentSelfserveConfigVersionsRoute.php new file mode 100644 index 00000000..df579ff9 --- /dev/null +++ b/services/nginx/app/routes/departmentSelfserveConfigVersionsRoute.php @@ -0,0 +1,205 @@ +get('/department/selfserve/config/versions', function () { + global $response; + $this->requirePermission('list_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (!in_array($departmentId, $authorizedDepartmentIds, true) && !$this->hasPermission('view_all_department_selfserve_config_versions')) { + $this->forbidDepartmentAccess($departmentId, ['view_all_department_selfserve_config_versions']); + } + + $service = new selfserve_config_versioning(); + $versions = $service->listVersions($departmentId); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'LIST_CONFIG_VERSIONS', 'User listed self-serve config versions'); + $response->success($versions); + }, [ + 'list_department_selfserve_config_versions' => 'List self-serve config versions for a department', + 'view_all_department_selfserve_config_versions' => 'List self-serve config versions across all departments', + ]); + + $this->get('/department/selfserve/config/history', function () { + global $response; + $this->requirePermission('list_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (!in_array($departmentId, $authorizedDepartmentIds, true) && !$this->hasPermission('view_all_department_selfserve_config_versions')) { + $this->forbidDepartmentAccess($departmentId, ['view_all_department_selfserve_config_versions']); + } + + $service = new selfserve_config_versioning(); + $response->success($service->listVersions($departmentId)); + }, [ + 'list_department_selfserve_config_versions' => 'List self-serve config history for a department', + 'view_all_department_selfserve_config_versions' => 'List self-serve config history across all departments', + ]); + + $this->get('/department/selfserve/config/active', function () { + global $response; + $this->requirePermission('list_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (!in_array($departmentId, $authorizedDepartmentIds, true) && !$this->hasPermission('view_all_department_selfserve_config_versions')) { + $this->forbidDepartmentAccess($departmentId, ['view_all_department_selfserve_config_versions']); + } + + $service = new selfserve_config_versioning(); + $published = $service->getPublishedConfig($departmentId); + if ($published === null) { + $response->success([ + 'version_id' => null, + 'config' => null, + 'source' => 'legacy', + ]); + } + + $response->success([ + 'version_id' => (int)$published['version_id'], + 'config' => (array)$published['config'], + 'source' => 'published', + ]); + }, [ + 'list_department_selfserve_config_versions' => 'View active self-serve config version for a department', + 'view_all_department_selfserve_config_versions' => 'View active self-serve config version across all departments', + ]); + + $this->post('/department/selfserve/config/draft', function () { + global $response; + $this->requirePermission('edit_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + $forceRefresh = self::isParametersSet(['force']) + ? filter_var(self::getParameter('force'), FILTER_VALIDATE_BOOLEAN) + : false; + + $service = new selfserve_config_versioning(); + $draft = $service->ensureDraftFromLegacy($departmentId, (int)$user->id, $forceRefresh === true); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'UPSERT_CONFIG_DRAFT', 'User upserted self-serve config draft'); + $response->success($draft); + }, [ + 'edit_department_selfserve_config_versions' => 'Create or update a self-serve config draft for a department', + ]); + + $this->post('/department/selfserve/config/validate', function () { + global $response; + $this->requirePermission('edit_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + $service = new selfserve_config_versioning(); + $validation = $service->validateDraft($departmentId); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'VALIDATE_CONFIG_DRAFT', 'User validated self-serve config draft'); + $response->success($validation); + }, [ + 'edit_department_selfserve_config_versions' => 'Validate a self-serve config draft for a department', + ]); + + $this->post('/department/selfserve/config/publish', function () { + global $response; + $this->requirePermission('publish_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + $service = new selfserve_config_versioning(); + try { + $published = $service->publishDraft($departmentId, (int)$user->id); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'PUBLISH_CONFIG_DRAFT', 'User published self-serve config draft'); + $response->success($published); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'publish_department_selfserve_config_versions' => 'Publish self-serve config draft for a department', + ]); + + $this->post('/department/selfserve/config/rollback', function () { + global $response; + $this->requirePermission('rollback_department_selfserve_config_versions'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['department', 'target_version_id']); + $departmentId = (int)self::getParameter('department'); + $targetVersionId = (int)self::getParameter('target_version_id'); + if ($targetVersionId <= 0) { + $response->error('Invalid target_version_id', 400); + } + + $this->assertDepartmentAccess($user, $departmentId); + + $service = new selfserve_config_versioning(); + try { + $rolledBack = $service->rollbackToVersion($departmentId, $targetVersionId, (int)$user->id); + (new logs_o())->add('selfserve_config_versions', $departmentId, 1, $user->id, 'ROLLBACK_CONFIG_VERSION', 'User rolled back self-serve config to version ' . $targetVersionId); + $response->success($rolledBack); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'rollback_department_selfserve_config_versions' => 'Rollback self-serve config to a previous version for a department', + ]); + } + + private function assertDepartmentAccess(object $user, int $departmentId): void + { + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (!in_array($departmentId, $authorizedDepartmentIds, true)) { + $this->forbidDepartmentAccess($departmentId); + } + } +} diff --git a/services/nginx/app/routes/departmentSelfserveMachineTypesRoute.php b/services/nginx/app/routes/departmentSelfserveMachineTypesRoute.php new file mode 100644 index 00000000..557530bf --- /dev/null +++ b/services/nginx/app/routes/departmentSelfserveMachineTypesRoute.php @@ -0,0 +1,125 @@ +get('/department/selfserve/machine-types', function () { + global $response; + $this->requirePermission('list_department_selfserve_machine_types'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $machine_types_o = new selfserve_machine_types_o(); + if (self::isParametersSet(['id'])) { + $machine_types_o->select((int)self::getParameter('id')); + if (!$machine_types_o->exists()) { + $response->error('Machine type not found', 404); + } + $response->success($machine_types_o->asArray()); + } + + (new logs_o())->add('selfserve_machine_types', 'global', 1, $user->id, 'LIST_MACHINE_TYPES', 'User listed self-serve machine types'); + $response->success( + $machine_types_o->setSearchableFields(['id', 'name', 'description', 'created_at', 'updated_at', 'deleted_at']) + ->listObjectsWithPaginationIfSet(function (array $machine_type): array { + return (new selfserve_machine_types_o())->select((int)$machine_type['id'])->asArray(); + }) + ); + }, [ + 'list_department_selfserve_machine_types' => 'List reusable self-serve machine types' + ]); + + $this->post('/department/selfserve/machine-types', function () { + global $response; + $this->requirePermission('add_department_selfserve_machine_types'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $name = trim((string)$response->getRequestParameter('name')); + $description = $response->isRequestParameterSet('description') ? (string)$response->getRequestParameter('description') : null; + if ($name === '') { + $response->error('Missing required fields: name', 400); + } + + try { + $machine_type = (new selfserve_machine_types_o())->add($name, $description); + (new logs_o())->add('selfserve_machine_types', 'global', 1, $user->id, 'ADD_MACHINE_TYPE', 'User added self-serve machine type ' . $machine_type->id); + $response->success($machine_type->asArray()); + } catch (\Exception $e) { + $response->error($e->getMessage(), 500); + } + }, [ + 'add_department_selfserve_machine_types' => 'Add reusable self-serve machine types' + ]); + + $this->put('/department/selfserve/machine-types', function () { + global $response; + $this->requirePermission('update_department_selfserve_machine_types'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['id']); + $machine_type = (new selfserve_machine_types_o())->select((int)self::getParameter('id')); + if (!$machine_type->exists()) { + $response->error('Machine type not found', 404); + } + + if (self::isParametersSet(['name'])) { + $name = trim((string)self::getParameter('name')); + if ($name === '') { + $response->error('name cannot be empty', 400); + } + $machine_type->name->set($name); + } + if (self::isParametersSet(['description'])) { + $description = self::getParameter('description'); + $machine_type->description->set($description === null || $description === '' ? null : (string)$description); + } + + (new logs_o())->add('selfserve_machine_types', 'global', 1, $user->id, 'UPDATE_MACHINE_TYPE', 'User updated self-serve machine type ' . $machine_type->id); + $response->success($machine_type->asArray()); + }, [ + 'update_department_selfserve_machine_types' => 'Update reusable self-serve machine types' + ]); + + $this->delete('/department/selfserve/machine-types', function () { + global $response; + $this->requirePermission('delete_department_selfserve_machine_types'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['id']); + $machine_type = (new selfserve_machine_types_o())->select((int)self::getParameter('id')); + if (!$machine_type->exists()) { + $response->error('Machine type not found', 404); + } + + $machine_type->delete(); + (new logs_o())->add('selfserve_machine_types', 'global', 1, $user->id, 'DELETE_MACHINE_TYPE', 'User deleted self-serve machine type ' . $machine_type->id); + $response->success('Machine type deleted'); + }, [ + 'delete_department_selfserve_machine_types' => 'Delete reusable self-serve machine types' + ]); + } +} diff --git a/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php b/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php index fa59d80f..a4003212 100644 --- a/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php @@ -6,6 +6,7 @@ namespace routes; use classes\authentication; +use modules\selfserve\classes\selfserve_config_versioning; use classes\response; use objects\department_selfserve_questions_o; use objects\logs_o; @@ -35,9 +36,9 @@ class departmentSelfserveQuestionsRoute if (self::isParametersSet(['id'])) { $questions_o->select((int)self::getParameter('id')); if ($questions_o->exists()) { - if (!in_array((int)$questions_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$questions_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$questions_o->department->value(), ['view_all_department_selfserve_questions']); } } $response->success($questions_o->asArray()); @@ -49,15 +50,16 @@ class departmentSelfserveQuestionsRoute $filters = []; if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); - if (!in_array($requested_department, $authorized_department_ids) && !$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, $requested_department) && !$has_view_all_permission) { + $this->forbidDepartmentAccess($requested_department, ['view_all_department_selfserve_questions']); } $filters['department'] = $requested_department; } else { if (!$has_view_all_permission) { if (empty($authorized_department_ids)) { - $response->success([]); - return; + $authorized_department_ids = [0]; + } else { + $authorized_department_ids[] = 0; } $filters['department'] = $authorized_department_ids; } @@ -95,21 +97,27 @@ class departmentSelfserveQuestionsRoute $this->requirePermission('add_department_selfserve_questions'); $user = (new authentication())->get_user(); if ($user) { - $department = (int)$response->getRequestParameter('department'); - $lane = (int)$response->getRequestParameter('lane'); - $product = (int)$response->getRequestParameter('product'); + $department = $response->isRequestParameterSet('department') ? (int)$response->getRequestParameter('department') : 0; + $lane = $response->isRequestParameterSet('lane') ? (int)$response->getRequestParameter('lane') : 0; + $product = $response->isRequestParameterSet('product') ? (int)$response->getRequestParameter('product') : 0; $question = (string)$response->getRequestParameter('question'); $description = (string)$response->getRequestParameter('description'); - $condition_id = $response->isRequestParameterSet('condition_id') ? (int)$response->getRequestParameter('condition_id') : null; + $condition_id = null; + if ($response->isRequestParameterSet('condition_id')) { + $condition_id_param = $response->getRequestParameter('condition_id'); + if (!is_null($condition_id_param) && $condition_id_param !== '' && $condition_id_param !== 'null') { + $condition_id = (int)$condition_id_param; + } + } $order_priority = (int)($response->getRequestParameter('order_priority') ?? 0); - if (!$department || !$lane || !$product || !$question || !$description) { - $response->error('Missing required fields', 400); + if (!$question || !$description) { + $response->error('Missing required fields: question and description', 400); } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($department, $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, $department)) { + $this->forbidDepartmentAccess($department); } try { @@ -122,6 +130,7 @@ class departmentSelfserveQuestionsRoute $condition_id, $order_priority ); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($department); (new logs_o())->add('department_selfserve_questions', 'global', 1, $user->id, 'ADD_QUESTION', 'User added a department self-serve question: ' . $question); $response->success($question_o->asArray()); } catch (\Exception $e) { @@ -150,16 +159,17 @@ class departmentSelfserveQuestionsRoute if (!$question_o->exists()) { $response->error('Question not found', 404); } + $originalDepartment = (int)$question_o->department->value(); $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$question_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$question_o->department->value())) { + $this->forbidDepartmentAccess((int)$question_o->department->value()); } if (self::isParametersSet(['department'])) { $new_department = (int)self::getParameter('department'); - if (!in_array($new_department, $authorized_department_ids)) { - $response->error('You do not have access to the new department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, $new_department)) { + $this->forbidDepartmentAccess($new_department); } $question_o->department->set($new_department); } @@ -176,12 +186,15 @@ class departmentSelfserveQuestionsRoute $question_o->description->set((string)self::getParameter('description')); } if (self::isParametersSet(['condition_id'])) { - $question_o->condition_id->set(self::getParameter('condition_id') === null ? null : (int)self::getParameter('condition_id')); + $condition_id = self::getParameter('condition_id'); + $question_o->condition_id->set($condition_id === null || $condition_id === '' || $condition_id === 'null' ? null : (int)$condition_id); } if (self::isParametersSet(['order_priority'])) { $question_o->order_priority->set((int)self::getParameter('order_priority')); } + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($originalDepartment); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$question_o->department->value()); (new logs_o())->add('department_selfserve_questions', 'global', 1, $user->id, 'EDIT_QUESTION', 'User updated department self-serve question ID: ' . $id); $response->success($question_o->asArray()); } else { @@ -209,11 +222,12 @@ class departmentSelfserveQuestionsRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$question_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$question_o->department->value())) { + $this->forbidDepartmentAccess((int)$question_o->department->value()); } $question_o->delete(); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$question_o->department->value()); (new logs_o())->add('department_selfserve_questions', 'global', 1, $user->id, 'DELETE_QUESTION', 'User deleted department self-serve question ID: ' . $id); $response->success('Question deleted'); } else { @@ -223,4 +237,9 @@ class departmentSelfserveQuestionsRoute 'delete_department_selfserve_questions' => 'Delete a department self-serve question' ]); } + + private function canAccessDepartment(array $authorizedDepartmentIds, int $departmentId): bool + { + return $departmentId === 0 || in_array($departmentId, $authorizedDepartmentIds, true); + } } diff --git a/services/nginx/app/routes/departmentSelfserveStudioRoute.php b/services/nginx/app/routes/departmentSelfserveStudioRoute.php new file mode 100644 index 00000000..adf3e87c --- /dev/null +++ b/services/nginx/app/routes/departmentSelfserveStudioRoute.php @@ -0,0 +1,340 @@ +get('/department/selfserve/studio/graph', function (): void { + global $response; + $user = $this->requireStudioUser('list_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId, ['view_all_department_selfserve_config_versions']); + + $service = new selfserve_studio_graph(); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'GET_STUDIO_GRAPH', 'Fetched self-serve studio graph'); + $response->success($service->buildGraph($departmentId, (int)$user->id, $this->studioPermissions())); + }, [ + 'list_department_selfserve_config_versions' => 'View the all-in-one self-serve studio graph', + 'view_all_department_selfserve_config_versions' => 'View the all-in-one self-serve studio graph across departments', + ]); + + $this->put('/department/selfserve/studio/graph', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $payload = self::getParametersAsArray(); + $service = new selfserve_studio_graph(); + $graph = $service->applyGraphSave($departmentId, $payload, (int)$user->id, $this->studioPermissions()); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_GRAPH', 'Saved self-serve studio graph'); + $response->success($graph); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Create, update, delete, connect, and reorder self-serve studio graph objects', + ]); + + $this->put('/department/selfserve/studio/layout', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department', 'layout']); + self::requireType(self::getParameter('layout'), self::TYPE_ARRAY()); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $layout = (new selfserve_studio_graph())->saveLayout($departmentId, (int)$user->id, (array)self::getParameter('layout')); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_LAYOUT', 'Saved self-serve studio layout'); + $response->success($layout); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Save canvas-only self-serve studio layout', + ]); + + $this->put('/department/selfserve/studio/virtual-hardware', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department', 'operation']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $payload = self::getParametersAsArray(); + $graph = (new selfserve_studio_graph())->applyVirtualHardwareOperation( + $departmentId, + $payload, + (int)$user->id, + $this->studioPermissions() + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_VIRTUAL_HARDWARE', 'Saved self-serve studio virtual hardware'); + $response->success($graph); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Generate and edit studio-only virtual hardware', + ]); + + $this->post('/department/selfserve/studio/validate', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + $validation = (new selfserve_studio_graph())->validatePayload($departmentId, self::getParametersAsArray()); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'VALIDATE_STUDIO_GRAPH', 'Validated self-serve studio graph'); + $response->success($validation); + }, [ + 'edit_department_selfserve_config_versions' => 'Validate the self-serve studio graph', + ]); + + $this->post('/department/selfserve/studio/simulate', function (): void { + global $response; + $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); + self::requireParameters(['department', 'lane_id', 'reg']); + $departmentId = (int)self::getParameter('department'); + $laneId = (int)self::getParameter('lane_id'); + self::requireParameterIntPositive($laneId, 'lane_id'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $result = (new selfserve_studio_graph())->simulateGraph( + $departmentId, + self::getParametersAsArray(), + (int)$user->id, + $this->studioPermissions() + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SIMULATE_STUDIO_GRAPH', 'Simulated self-serve studio graph'); + $response->success($result); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator', + ]); + + $this->post('/department/selfserve/studio/path-outcomes/stream', function (): void { + $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + ini_set('display_errors', '0'); + ini_set('html_errors', '0'); + header('Content-Type: application/x-ndjson; charset=utf-8'); + header('Cache-Control: no-cache, no-transform'); + header('X-Accel-Buffering: no'); + + $emit = static function (array $event): void { + echo json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + if (function_exists('ob_flush')) { + @ob_flush(); + } + @flush(); + }; + + try { + $result = (new selfserve_studio_graph())->projectPathOutcomes( + $departmentId, + self::getParametersAsArray(), + (int)$user->id, + $this->studioPermissions(), + static function (array $partial) use ($emit): void { + $emit(['type' => 'progress', 'data' => $partial]); + } + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PROJECT_STUDIO_PATH_OUTCOMES', 'Projected self-serve studio path outcomes'); + $emit(['type' => 'complete', 'data' => $result]); + } catch (\Throwable $exception) { + $emit(['type' => 'error', 'message' => $exception->getMessage()]); + } + exit; + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Stream grouped self-serve studio question path outcome progress', + ]); + + $this->post('/department/selfserve/studio/path-outcomes', function (): void { + global $response; + $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $result = (new selfserve_studio_graph())->projectPathOutcomes( + $departmentId, + self::getParametersAsArray(), + (int)$user->id, + $this->studioPermissions() + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PROJECT_STUDIO_PATH_OUTCOMES', 'Projected self-serve studio path outcomes'); + $response->success($result); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes', + ]); + + $this->post('/department/selfserve/studio/path-confirmations', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department', 'path_signature']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $payload = self::getParametersAsArray(); + $action = strtolower(trim((string)($payload['action'] ?? 'confirm'))); + $service = new selfserve_studio_graph(); + $result = in_array($action, ['delete', 'reset', 'clear'], true) + ? $service->resetPathConfirmation($departmentId, $payload) + : $service->confirmPathOutcome($departmentId, $payload, (int)$user->id); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation'); + $response->success($result); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths', + ]); + + $this->post('/department/selfserve/studio/publish', function (): void { + global $response; + $user = $this->requireStudioUser('publish_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $published = (new selfserve_config_versioning())->publishDraft($departmentId, (int)$user->id); + $validation = (new selfserve_studio_graph())->validatePayload($departmentId); + $published['warnings'] = (array)($validation['warnings'] ?? []); + $published['validation'] = $validation; + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PUBLISH_STUDIO_GRAPH', 'Published self-serve studio graph'); + $response->success($published); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'publish_department_selfserve_config_versions' => 'Publish the self-serve studio draft', + ]); + + $this->post('/department/selfserve/studio/rollback', function (): void { + global $response; + $user = $this->requireStudioUser('rollback_department_selfserve_config_versions'); + self::requireParameters(['department', 'target_version_id']); + $departmentId = (int)self::getParameter('department'); + $targetVersionId = (int)self::getParameter('target_version_id'); + self::requireParameterIntPositive($targetVersionId, 'target_version_id'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $rolledBack = (new selfserve_config_versioning())->rollbackToVersion($departmentId, $targetVersionId, (int)$user->id); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'ROLLBACK_STUDIO_GRAPH', 'Rolled back self-serve studio graph'); + $response->success($rolledBack); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'rollback_department_selfserve_config_versions' => 'Rollback the self-serve studio draft to an earlier version', + ]); + + $this->post('/department/selfserve/studio/gateway-action', function (): void { + global $response; + $user = $this->requireStudioUser('modules_shelly_config'); + self::requireParameters(['department', 'gateway_id', 'action']); + $departmentId = (int)self::getParameter('department'); + $gatewayId = (int)self::getParameter('gateway_id'); + self::requireParameterIntPositive($gatewayId, 'gateway_id'); + $this->assertDepartmentAccess($user, $departmentId); + + $action = strtolower((string)self::getParameter('action')); + $confirmed = filter_var(self::getParameter('confirm'), FILTER_VALIDATE_BOOLEAN); + if (in_array($action, ['uninstall', 'rotate_credentials'], true) && $confirmed !== true) { + $response->error('This gateway action requires explicit confirmation.', 428); + } + + try { + $payload = self::getParametersAsArray(); + $result = (new selfserve_studio_graph())->runGatewayAction($departmentId, $gatewayId, $action, $payload, (int)$user->id); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'GATEWAY_STUDIO_ACTION', 'Ran self-serve studio gateway action: ' . $action); + $response->success($result); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'modules_shelly_config' => 'Run permission-gated self-serve studio edge gateway actions', + ]); + } + + private function requireStudioUser(string $permission): object + { + global $response; + $this->requirePermission($permission); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + return $user; + } + + /** + * @param array $bypassPermissions + */ + private function assertDepartmentAccess(object $user, int $departmentId, array $bypassPermissions = []): void + { + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (in_array($departmentId, $authorizedDepartmentIds, true)) { + return; + } + + foreach ($bypassPermissions as $permission) { + if ($this->hasPermission($permission)) { + return; + } + } + + $this->forbidDepartmentAccess($departmentId, $bypassPermissions); + } + + /** + * @return array + */ + private function studioPermissions(): array + { + return [ + 'can_view' => $this->hasPermission('list_department_selfserve_config_versions'), + 'can_edit' => $this->hasPermission('edit_department_selfserve_config_versions'), + 'can_publish' => $this->hasPermission('publish_department_selfserve_config_versions'), + 'can_rollback' => $this->hasPermission('rollback_department_selfserve_config_versions'), + 'can_simulate' => $this->hasPermission('list_department_selfserve_vehicle_conditions'), + 'can_add_department_lane' => $this->hasPermission('add_department_lane'), + 'can_edit_department_lane' => $this->hasPermission('edit_department_lane'), + 'modules_shelly_config' => $this->hasPermission('modules_shelly_config'), + 'can_manage_gateways' => $this->hasPermission('modules_shelly_config'), + 'can_run_gateway_destructive_actions' => $this->hasPermission('modules_shelly_config'), + 'can_run_live_lane_actions' => $this->hasPermission('modules_selfserve_sessions_force_stop'), + ]; + } +} diff --git a/services/nginx/app/routes/departmentSelfserveTasksRoute.php b/services/nginx/app/routes/departmentSelfserveTasksRoute.php index 6280354c..d6eababa 100644 --- a/services/nginx/app/routes/departmentSelfserveTasksRoute.php +++ b/services/nginx/app/routes/departmentSelfserveTasksRoute.php @@ -6,6 +6,7 @@ namespace routes; use classes\authentication; +use modules\selfserve\classes\selfserve_config_versioning; use classes\response; use objects\department_selfserve_tasks_o; use objects\logs_o; @@ -13,6 +14,7 @@ use attachments\helpers\attachment_content; use classes\attachment_store; use classes\attachments; use modules\selfserve\helpers\selfserve_lane_services; +use modules\selfserve\helpers\selfserve_task_gate_type; use traits\route_t; class departmentSelfserveTasksRoute @@ -39,9 +41,9 @@ class departmentSelfserveTasksRoute if (self::isParametersSet(['id'])) { $tasks_o->select((int)self::getParameter('id')); if ($tasks_o->exists()) { - if (!in_array((int)$tasks_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$tasks_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$tasks_o->department->value(), ['view_all_department_selfserve_tasks']); } } $response->success($tasks_o->asArray()); @@ -53,15 +55,16 @@ class departmentSelfserveTasksRoute $filters = []; if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); - if (!in_array($requested_department, $authorized_department_ids) && !$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, $requested_department) && !$has_view_all_permission) { + $this->forbidDepartmentAccess($requested_department, ['view_all_department_selfserve_tasks']); } $filters['department'] = $requested_department; } else { if (!$has_view_all_permission) { if (empty($authorized_department_ids)) { - $response->success([]); - return; + $authorized_department_ids = [0]; + } else { + $authorized_department_ids[] = 0; } $filters['department'] = $authorized_department_ids; } @@ -78,9 +81,19 @@ class departmentSelfserveTasksRoute if (self::isParametersSet(['condition_id'])) { $filters['condition_id'] = (int)self::getParameter('condition_id'); } + if (self::isParametersSet(['gate_type'])) { + $filters['gate_type'] = (string)self::getParameter('gate_type'); + } + if (self::isParametersSet(['gate_ref_id'])) { + $filters['gate_ref_id'] = (int)self::getParameter('gate_ref_id'); + } + + if (self::isParametersSet(['machine_type_id'])) { + $filters['machine_type_id'] = (int)self::getParameter('machine_type_id'); + } $response->success( - $tasks_o->setSearchableFields(['id', 'department', 'lane', 'product', 'condition_id', 'task', 'description', 'deleted_at']) + $tasks_o->setSearchableFields(['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'deleted_at']) ->listObjectsWithPaginationIfSet(function ($task) { $t = new department_selfserve_tasks_o(); $t->select((int)$task['id']); @@ -103,10 +116,55 @@ class departmentSelfserveTasksRoute $this->requirePermission('add_department_selfserve_tasks'); $user = (new authentication())->get_user(); if ($user) { - $department = (int)$response->getRequestParameter('department'); - $lane = (int)$response->getRequestParameter('lane'); - $product = (int)$response->getRequestParameter('product'); - $condition_id = $response->isRequestParameterSet('condition_id') ? (int)$response->getRequestParameter('condition_id') : null; + $department = $response->isRequestParameterSet('department') ? (int)$response->getRequestParameter('department') : 0; + $lane = $response->isRequestParameterSet('lane') ? (int)$response->getRequestParameter('lane') : 0; + $product = $response->isRequestParameterSet('product') ? (int)$response->getRequestParameter('product') : 0; + $machine_type_id = null; + if ($response->isRequestParameterSet('machine_type_id')) { + $machine_type_param = $response->getRequestParameter('machine_type_id'); + if (!is_null($machine_type_param) && $machine_type_param !== '' && $machine_type_param !== 'null') { + $machine_type_id = (int)$machine_type_param; + } + } + $question_id = null; + if ($response->isRequestParameterSet('condition_id')) { + $condition_id_param = $response->getRequestParameter('condition_id'); + if (!is_null($condition_id_param) && $condition_id_param !== '' && $condition_id_param !== 'null') { + $question_id = (int)$condition_id_param; + } + } + $gate_type = null; + if ($response->isRequestParameterSet('gate_type')) { + try { + $gate_type = department_selfserve_tasks_o::normalizeGateTypeInput($response->getRequestParameter('gate_type')); + } catch (\Exception $e) { + $response->error($e->getMessage(), 400); + } + } + $gate_ref_id = null; + if ($response->isRequestParameterSet('gate_ref_id')) { + $gate_ref_param = $response->getRequestParameter('gate_ref_id'); + if (!is_null($gate_ref_param) && $gate_ref_param !== '' && $gate_ref_param !== 'null') { + $gate_ref_id = (int)$gate_ref_param; + } + } + if ($gate_type !== null) { + if ($gate_type === selfserve_task_gate_type::ALWAYS) { + $gate_ref_id = null; + $question_id = null; + } else { + if ($gate_ref_id === null) { + $gate_ref_id = $question_id; + } + if ($gate_ref_id === null || $gate_ref_id <= 0) { + $response->error('gate_ref_id is required when gate_type is CONDITION or QUESTION', 400); + } + // Keep legacy contract field in sync. + $question_id = $gate_ref_id; + } + } elseif ($gate_ref_id !== null && $gate_ref_id > 0) { + $question_id = $gate_ref_id; + } $task = (string)$response->getRequestParameter('task'); $description = (string)$response->getRequestParameter('description'); $order_priority = (int)($response->getRequestParameter('order_priority') ?? 0); @@ -167,13 +225,17 @@ class departmentSelfserveTasksRoute } } - if (!$department || !$lane || !$product || !$task || !$description) { - $response->error('Missing required fields', 400); + if (!$task || !$description) { + $response->error('Missing required fields: task and description', 400); + } + + if ($machine_type_id === null && (!$department || !$lane || !$product)) { + $response->error('Missing required fields: either machine_type_id or department, lane, and product', 400); } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($department, $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, $department)) { + $this->forbidDepartmentAccess($department); } try { @@ -181,14 +243,18 @@ class departmentSelfserveTasksRoute $department, $lane, $product, - $condition_id, + $question_id, $task, $description, $order_priority, $services_enums, $buttons_ids, $vehicle_type, + $machine_type_id, + $gate_type, + $gate_ref_id, ); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($department); (new logs_o())->add('department_selfserve_tasks', 'global', 1, $user->id, 'ADD_TASK', 'User added a department self-serve task: ' . $task); $response->success($task_o->asArray()); } catch (\Exception $e) { @@ -217,16 +283,17 @@ class departmentSelfserveTasksRoute if (!$task_o->exists()) { $response->error('Task not found', 404); } + $originalDepartment = (int)$task_o->department->value(); $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { + $this->forbidDepartmentAccess((int)$task_o->department->value()); } if (self::isParametersSet(['department'])) { $new_department = (int)self::getParameter('department'); - if (!in_array($new_department, $authorized_department_ids)) { - $response->error('You do not have access to the new department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, $new_department)) { + $this->forbidDepartmentAccess($new_department); } $task_o->department->set($new_department); } @@ -236,8 +303,47 @@ class departmentSelfserveTasksRoute if (self::isParametersSet(['product'])) { $task_o->product->set((int)self::getParameter('product')); } + if (self::isParametersSet(['machine_type_id'])) { + $param = self::getParameter('machine_type_id'); + $task_o->machine_type_id->set($param === null || $param === '' || $param === 'null' ? null : (int)$param); + } if (self::isParametersSet(['condition_id'])) { - $task_o->condition_id->set(self::getParameter('condition_id') === null ? null : (int)self::getParameter('condition_id')); + $param = self::getParameter('condition_id'); + $task_o->setQuestionId($param === null || $param === '' || $param === 'null' ? null : (int)$param); + } + if (self::isParametersSet(['gate_type']) || self::isParametersSet(['gate_ref_id'])) { + try { + $effectiveGateType = self::isParametersSet(['gate_type']) + ? department_selfserve_tasks_o::normalizeGateTypeInput(self::getParameter('gate_type')) + : department_selfserve_tasks_o::normalizeGateTypeInput((string)($task_o->gate_type->value() ?? selfserve_task_gate_type::ALWAYS->value)); + $effectiveGateRefId = self::isParametersSet(['gate_ref_id']) + ? ( + (self::getParameter('gate_ref_id') === null || self::getParameter('gate_ref_id') === '' || self::getParameter('gate_ref_id') === 'null') + ? null + : (int)self::getParameter('gate_ref_id') + ) + : ($task_o->gate_ref_id->value() === null ? null : (int)$task_o->gate_ref_id->value()); + + if (!self::isParametersSet(['gate_ref_id']) && self::isParametersSet(['condition_id']) && $effectiveGateType !== selfserve_task_gate_type::ALWAYS) { + $conditionParam = self::getParameter('condition_id'); + $effectiveGateRefId = ($conditionParam === null || $conditionParam === '' || $conditionParam === 'null') + ? null + : (int)$conditionParam; + } + + if ($effectiveGateType === selfserve_task_gate_type::ALWAYS) { + $effectiveGateRefId = null; + } elseif ($effectiveGateRefId === null || $effectiveGateRefId <= 0) { + $response->error('gate_ref_id is required when gate_type is CONDITION or QUESTION', 400); + } + + $task_o->gate_type->set($effectiveGateType->value); + $task_o->gate_ref_id->set($effectiveGateRefId); + // Preserve legacy contract field. + $task_o->condition_id->set($effectiveGateRefId); + } catch (\Exception $e) { + $response->error($e->getMessage(), 400); + } } if (self::isParametersSet(['task'])) { $task_o->task->set((string)self::getParameter('task')); @@ -316,6 +422,8 @@ class departmentSelfserveTasksRoute } } + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($originalDepartment); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$task_o->department->value()); (new logs_o())->add('department_selfserve_tasks', 'global', 1, $user->id, 'EDIT_TASK', 'User updated department self-serve task ID: ' . $id); $response->success($task_o->asArray()); } else { @@ -343,11 +451,12 @@ class departmentSelfserveTasksRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { + $this->forbidDepartmentAccess((int)$task_o->department->value()); } $task_o->delete(); + (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment((int)$task_o->department->value()); (new logs_o())->add('department_selfserve_tasks', 'global', 1, $user->id, 'DELETE_TASK', 'User deleted department self-serve task ID: ' . $id); $response->success('Task deleted'); } else { @@ -378,9 +487,9 @@ class departmentSelfserveTasksRoute $authorized_department_ids = $user->getGroup()->getDepartments(); $has_view_all_permission = $this->hasPermission('view_all_department_selfserve_tasks'); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$task_o->department->value(), ['view_all_department_selfserve_tasks']); } } @@ -424,9 +533,9 @@ class departmentSelfserveTasksRoute $authorized_department_ids = $user->getGroup()->getDepartments(); $has_view_all_permission = $this->hasPermission('view_all_department_selfserve_tasks'); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$task_o->department->value(), ['view_all_department_selfserve_tasks']); } } @@ -462,8 +571,8 @@ class departmentSelfserveTasksRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { + $this->forbidDepartmentAccess((int)$task_o->department->value()); } $attachment_store = new attachment_store(); @@ -507,8 +616,8 @@ class departmentSelfserveTasksRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { + $this->forbidDepartmentAccess((int)$task_o->department->value()); } $task_o->removeAttachment($attachment_id); @@ -522,4 +631,9 @@ class departmentSelfserveTasksRoute 'delete_department_selfserve_task_attachments' => 'Delete attachments for a department self-serve task' ]); } + + private function canAccessDepartment(array $authorizedDepartmentIds, int $departmentId): bool + { + return $departmentId === 0 || in_array($departmentId, $authorizedDepartmentIds, true); + } } diff --git a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php index 4328d530..d6efafe6 100644 --- a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php @@ -8,8 +8,11 @@ namespace routes; use classes\authentication; use classes\response; use classes\selfserve; -use objects\department_selfserve_vehicle_conditions_o; +use modules\selfserve\classes\selfserve_wash_flow; use objects\customer_vehicles_o; +use objects\department_lanes_o; +use objects\department_selfserve_tasks_o; +use objects\department_selfserve_vehicle_conditions_o; use objects\logs_o; use traits\route_t; @@ -33,27 +36,25 @@ class departmentSelfserveVehicleConditionsRoute $has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']); } (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'LIST_VEHICLE_CONDITIONS', 'User listed department self-serve vehicle conditions'); - + $conditions_o = new department_selfserve_vehicle_conditions_o(); $customer_number = (int)$user->customer_number->value(); - - // If an ID is provided, return that specific condition + if (self::isParametersSet(['id'])) { $conditions_o->select((int)self::getParameter('id')); if ($conditions_o->exists()) { if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$conditions_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!in_array((int)$conditions_o->department->value(), $authorized_department_ids, true)) { + $this->forbidDepartmentAccess((int)$conditions_o->department->value()); } } else { - // Own permission only if ((int)$conditions_o->customer_id->value() !== $customer_number) { - $response->error('You do not have access to this condition', 403); + $response->forbidden(['list_department_selfserve_vehicle_conditions']); } } $response->success($conditions_o->asArray()); @@ -67,8 +68,8 @@ class departmentSelfserveVehicleConditionsRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); - if (!in_array($requested_department, $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!in_array($requested_department, $authorized_department_ids, true)) { + $this->forbidDepartmentAccess($requested_department); } $filters['department'] = $requested_department; } else { @@ -79,7 +80,6 @@ class departmentSelfserveVehicleConditionsRoute $filters['customer_id'] = (int)self::getParameter('customer_id'); } } else { - // Own permission only $filters['customer_id'] = $customer_number; if (self::isParametersSet(['department'])) { $filters['department'] = (int)self::getParameter('department'); @@ -91,26 +91,129 @@ class departmentSelfserveVehicleConditionsRoute } if (self::isParametersSet(['reg'])) { - $filters['reg'] = (string)self::getParameter('reg'); + $filters['reg'] = selfserve::standardize_registration((string)self::getParameter('reg')); } if (self::isParametersSet(['question'])) { $filters['question'] = (int)self::getParameter('question'); } - $response->success( - $conditions_o->setSearchableFields(['id', 'department', 'lane', 'customer_id', 'reg', 'question', 'value', 'created_at', 'updated_at', 'deleted_at']) - ->listObjectsWithPaginationIfSet(function ($condition) { - $c = new department_selfserve_vehicle_conditions_o(); - $c->select((int)$condition['id']); - return $c->asArray(); - }, $conditions_o->forceRestrictFilters($filters)) - ); + $response->success( + $conditions_o->setSearchableFields(['id', 'department', 'lane', 'customer_id', 'reg', 'question', 'value', 'created_at', 'updated_at', 'deleted_at']) + ->listObjectsWithPaginationIfSet(function ($condition) { + $c = new department_selfserve_vehicle_conditions_o(); + $c->select((int)$condition['id']); + return $c->asArray(); + }, $conditions_o->forceRestrictFilters($filters)) + ); }, [ 'list_department_selfserve_vehicle_conditions' => 'List all department self-serve vehicle conditions', 'list_own_department_selfserve_vehicle_conditions' => 'List own department self-serve vehicle conditions' ]); + /** + * Check whether self-serve is allowed for a specific vehicle and lane + */ + $this->get('/department/selfserve/vehicle/allowed', function () { + global $response; + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions'); + $has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions'); + if (!$has_global && !$has_own) { + $response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']); + } + + self::requireParameters(['lane_id', 'reg']); + $lane_id = (int)self::getParameter('lane_id'); + $reg = selfserve::standardize_registration((string)self::getParameter('reg')); + + $lane = $this->assertLaneAccess($user, $lane_id, $has_global); + $customer_number = null; + if (!$has_global && $has_own) { + $customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions'); + } + $vehicle_type_id = $this->resolveVehicleTypeIdFromQuery(); + $flow = $this->getWashFlow(); + + if ($vehicle_type_id !== null) { + $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false); + } + + (new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $user->id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg); + $response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)); + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle', + 'list_own_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a customer-scoped vehicle' + ]); + + /** + * Get self-serve wash summary + */ + $this->get('/department/selfserve/washes/summary', function () { + global $response; + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions'); + $has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions'); + if (!$has_global && !$has_own) { + $response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']); + } + + $flow = $this->getWashFlow(); + $vehicle_type_id = $this->resolveVehicleTypeIdFromQuery(); + try { + if (self::isParametersSet(['session_id'])) { + $summary = $flow->getSessionSummary((int)self::getParameter('session_id')); + $this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions'); + + if ($this->shouldRefreshSummaryForVehicleType($summary, $vehicle_type_id)) { + $summary = $flow->synchronizeSession( + (int)($summary['session']['lane_id'] ?? 0), + (string)($summary['session']['reg'] ?? ''), + isset($summary['session']['customer_number']) && $summary['session']['customer_number'] !== null + ? (int)$summary['session']['customer_number'] + : null, + false, + $vehicle_type_id, + false + ); + } + + $response->success($summary); + } + + self::requireParameters(['lane_id', 'reg']); + $lane_id = (int)self::getParameter('lane_id'); + $reg = selfserve::standardize_registration((string)self::getParameter('reg')); + + $this->assertLaneAccess($user, $lane_id, $has_global); + $customer_number = null; + if (!$has_global && $has_own) { + $customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions'); + } + + if ($vehicle_type_id !== null) { + $summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false); + } else { + $summary = $flow->getLatestSessionSummary($lane_id, $reg); + } + $this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions'); + $response->success($summary); + } catch (\RuntimeException $e) { + $response->error($e->getMessage(), 404); + } + }, [ + 'list_department_selfserve_vehicle_conditions' => 'View self-serve wash summaries', + 'list_own_department_selfserve_vehicle_conditions' => 'View self-serve wash summaries for customer-scoped vehicles' + ]); + /** * Add a department self-serve vehicle condition */ @@ -125,46 +228,47 @@ class departmentSelfserveVehicleConditionsRoute $has_own = $user->hasPermission('add_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['add_department_selfserve_vehicle_conditions', 'add_own_department_selfserve_vehicle_conditions']); } $department = (int)$response->getRequestParameter('department'); $lane = (int)$response->getRequestParameter('lane'); - $reg = (string)$response->getRequestParameter('reg'); + $reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg')); $question = (int)$response->getRequestParameter('question'); $value = (bool)$response->getRequestParameter('value'); if (!$department || !$lane || !$reg || !$question) { $response->error('Missing required fields', 400); } + $vehicle_type_id = $this->resolveVehicleTypeIdFromRequest(); + $activate_machine = $this->requestBooleanFlag('activate_machine', true); + $sync_relay_state = $this->requestBooleanFlag('sync_relay_state', true); if ($has_global) { $customer_id = $response->isRequestParameterSet('customer_id') ? (int)$response->getRequestParameter('customer_id') : null; $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($department, $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!in_array($department, $authorized_department_ids, true)) { + $this->forbidDepartmentAccess($department); } } else { - // Own permission only - $customer_id = (int)$user->customer_number->value(); - // Verify vehicle ownership - $vehicle_o = (new customer_vehicles_o())->selectByPlate($reg); - if (!$vehicle_o->exists() || (int)$vehicle_o->customer_id->value() !== $customer_id) { - $response->error('You do not own this vehicle', 403); - } + $customer_id = $this->requireAuthenticatedCustomerNumber($user, 'add_department_selfserve_vehicle_conditions'); } try { $condition_o = new department_selfserve_vehicle_conditions_o(); $condition_o->add($department, $lane, $reg, $question, $value, $customer_id); + $summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state); (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id); - $response->success($condition_o->asArray()); + $response->success([ + 'condition' => $condition_o->asArray(), + 'selfserve' => $summary, + ]); } catch (\Exception $e) { $response->error($e->getMessage(), 500); } }, [ 'add_department_selfserve_vehicle_conditions' => 'Add a department self-serve vehicle condition', - 'add_own_department_selfserve_vehicle_conditions' => 'Add own department self-serve vehicle condition' + 'add_own_department_selfserve_vehicle_conditions' => 'Add customer-scoped department self-serve vehicle condition' ]); /** @@ -181,7 +285,7 @@ class departmentSelfserveVehicleConditionsRoute $has_own = $user->hasPermission('update_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['update_department_selfserve_vehicle_conditions', 'update_own_department_selfserve_vehicle_conditions']); } $id = (int)$response->getRequestParameter('id'); @@ -199,13 +303,12 @@ class departmentSelfserveVehicleConditionsRoute if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!in_array((int)$condition_o->department->value(), $authorized_department_ids, true)) { + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } } else { - // Own permission only if ((int)$condition_o->customer_id->value() !== $customer_number) { - $response->error('You do not have access to this condition', 403); + $response->forbidden(['update_department_selfserve_vehicle_conditions']); } } @@ -213,8 +316,8 @@ class departmentSelfserveVehicleConditionsRoute $new_department = (int)$response->getRequestParameter('department'); if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($new_department, $authorized_department_ids)) { - $response->error('You do not have access to the target department', 403); + if (!in_array($new_department, $authorized_department_ids, true)) { + $this->forbidDepartmentAccess($new_department); } } $condition_o->department->update($new_department); @@ -224,13 +327,6 @@ class departmentSelfserveVehicleConditionsRoute } if ($response->isRequestParameterSet('reg')) { $new_reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg')); - if (!$has_global && $has_own) { - // Verify ownership of the new reg - $vehicle_o = (new customer_vehicles_o())->selectByPlate($new_reg); - if (!$vehicle_o->exists() || (int)$vehicle_o->customer_id->value() !== $customer_number) { - $response->error('You do not own this vehicle', 403); - } - } $condition_o->reg->update($new_reg); } if ($response->isRequestParameterSet('question')) { @@ -242,16 +338,34 @@ class departmentSelfserveVehicleConditionsRoute if ($response->isRequestParameterSet('customer_id')) { $new_customer_id = (int)$response->getRequestParameter('customer_id'); if (!$has_global && $has_own && $new_customer_id !== $customer_number) { - $response->error('You cannot change the customer ID to another customer', 403); + $response->forbidden(['update_department_selfserve_vehicle_conditions']); } $condition_o->customer_id->update($new_customer_id); } + $vehicle_type_id = $this->resolveVehicleTypeIdFromRequest(); + $activate_machine = $this->requestBooleanFlag('activate_machine', true); + $sync_relay_state = $this->requestBooleanFlag('sync_relay_state', true); - (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'UPDATE_VEHICLE_CONDITION', 'User updated department self-serve vehicle condition ' . $id); - $response->success($condition_o->asArray()); + try { + $summary = $this->getWashFlow()->synchronizeSession( + (int)$condition_o->lane->value(), + (string)$condition_o->reg->value(), + $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value(), + $activate_machine, + $vehicle_type_id, + $sync_relay_state + ); + (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'UPDATE_VEHICLE_CONDITION', 'User updated department self-serve vehicle condition ' . $id); + $response->success([ + 'condition' => $condition_o->asArray(), + 'selfserve' => $summary, + ]); + } catch (\Exception $e) { + $response->error($e->getMessage(), 500); + } }, [ 'update_department_selfserve_vehicle_conditions' => 'Update a department self-serve vehicle condition', - 'update_own_department_selfserve_vehicle_conditions' => 'Update own department self-serve vehicle condition' + 'update_own_department_selfserve_vehicle_conditions' => 'Update customer-scoped department self-serve vehicle condition' ]); /** @@ -268,7 +382,7 @@ class departmentSelfserveVehicleConditionsRoute $has_own = $user->hasPermission('delete_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['delete_department_selfserve_vehicle_conditions', 'delete_own_department_selfserve_vehicle_conditions']); } $id = (int)$response->getRequestParameter('id'); @@ -284,22 +398,190 @@ class departmentSelfserveVehicleConditionsRoute if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { - $response->error('You do not have access to this department', 403); + if (!in_array((int)$condition_o->department->value(), $authorized_department_ids, true)) { + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } } else { - // Own permission only if ((int)$condition_o->customer_id->value() !== (int)$user->customer_number->value()) { - $response->error('You do not have access to this condition', 403); + $response->forbidden(['delete_department_selfserve_vehicle_conditions']); } } + $lane_id = (int)$condition_o->lane->value(); + $reg = (string)$condition_o->reg->value(); + $customer_id = $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value(); + $vehicle_type_id = $this->resolveVehicleTypeIdFromRequest(); + $activate_machine = $this->requestBooleanFlag('activate_machine', true); + $sync_relay_state = $this->requestBooleanFlag('sync_relay_state', true); + $condition_o->delete(); + + try { + $summary = $this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state); + } catch (\Throwable) { + $summary = null; + } + (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'DELETE_VEHICLE_CONDITION', 'User deleted department self-serve vehicle condition ' . $id); - $response->success('Condition deleted'); + $response->success([ + 'message' => 'Condition deleted', + 'selfserve' => $summary, + ]); }, [ 'delete_department_selfserve_vehicle_conditions' => 'Delete a department self-serve vehicle condition', 'delete_own_department_selfserve_vehicle_conditions' => 'Delete own department self-serve vehicle condition' ]); } + + private function getWashFlow(): selfserve_wash_flow + { + return new selfserve_wash_flow(); + } + + private function resolveVehicleTypeIdFromQuery(): ?int + { + $rawVehicleType = null; + if (self::isParametersSet(['vehicle_type_id'])) { + $rawVehicleType = self::getParameter('vehicle_type_id'); + } elseif (self::isParametersSet(['vehicle_type'])) { + $rawVehicleType = self::getParameter('vehicle_type'); + } + + return $this->normalizeVehicleTypeOverride($rawVehicleType); + } + + private function resolveVehicleTypeIdFromRequest(): ?int + { + global $response; + + $rawVehicleType = null; + if ($response->isRequestParameterSet('vehicle_type_id')) { + $rawVehicleType = $response->getRequestParameter('vehicle_type_id'); + } elseif ($response->isRequestParameterSet('vehicle_type')) { + $rawVehicleType = $response->getRequestParameter('vehicle_type'); + } elseif (self::isParametersSet(['vehicle_type_id'])) { + $rawVehicleType = self::getParameter('vehicle_type_id'); + } elseif (self::isParametersSet(['vehicle_type'])) { + $rawVehicleType = self::getParameter('vehicle_type'); + } + + return $this->normalizeVehicleTypeOverride($rawVehicleType); + } + + private function normalizeVehicleTypeOverride(mixed $rawVehicleType): ?int + { + global $response; + + if ($rawVehicleType === null) { + return null; + } + + try { + return department_selfserve_tasks_o::normalizeVehicleTypeInput($rawVehicleType); + } catch (\Exception $e) { + $response->error('Invalid vehicle_type_id parameter: ' . $e->getMessage(), 400); + } + } + + private function shouldRefreshSummaryForVehicleType(array $summary, ?int $vehicleTypeIdOverride): bool + { + if ($vehicleTypeIdOverride === null) { + return false; + } + + $session = is_array($summary['session'] ?? null) ? $summary['session'] : []; + $sessionVehicleTypeId = isset($session['vehicle_type_id']) && $session['vehicle_type_id'] !== null + ? (int)$session['vehicle_type_id'] + : null; + if ($sessionVehicleTypeId !== $vehicleTypeIdOverride) { + return true; + } + + $questions = is_array($summary['questions'] ?? null) ? $summary['questions'] : []; + $tasks = is_array($summary['tasks'] ?? null) ? $summary['tasks'] : []; + return $questions === [] && $tasks === []; + } + + private function requestBooleanFlag(string $parameter, bool $default): bool + { + if (!$this->isParametersSet([$parameter])) { + return $default; + } + + $value = $this->getParameter($parameter); + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return $value !== 0; + } + + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + + return $default; + } + + private function assertLaneAccess(object $user, int $laneId, bool $hasGlobalPermission): department_lanes_o + { + global $response; + + $lane = (new department_lanes_o())->select($laneId); + if (!$lane->exists()) { + $response->error('Department lane not found', 404); + } + + if ($hasGlobalPermission) { + $authorized_department_ids = $user->getGroup()->getDepartments(); + if (!in_array((int)$lane->department->value(), $authorized_department_ids, true)) { + $this->forbidDepartmentAccess((int)$lane->department->value()); + } + } + + return $lane; + } + + private function requireAuthenticatedCustomerNumber(object $user, string $elevatedPermission): int + { + global $response; + + $customer_number = (int)$user->customer_number->value(); + if ($customer_number <= 0) { + $response->forbidden([$elevatedPermission]); + } + + return $customer_number; + } + + private function assertSummaryAccess(object $user, array $summary, bool $hasGlobalPermission, string $elevatedPermission): void + { + global $response; + + if ($hasGlobalPermission) { + $authorized_department_ids = $user->getGroup()->getDepartments(); + $lane_department = (int)($summary['lane']['department'] ?? 0); + if (!in_array($lane_department, $authorized_department_ids, true)) { + $this->forbidDepartmentAccess($lane_department); + } + return; + } + + $session_customer_number = $summary['session']['customer_number'] ?? null; + if ($session_customer_number !== null && (int)$session_customer_number === (int)$user->customer_number->value()) { + return; + } + + $reg = (string)($summary['session']['reg'] ?? ''); + $vehicle_o = (new customer_vehicles_o())->selectByPlate($reg); + if ($vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === (int)$user->customer_number->value()) { + return; + } + + $response->forbidden([$elevatedPermission]); + } } diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index 5330b75e..db0484a1 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -3,8 +3,10 @@ namespace routes; use classes\authentication; +use classes\selfserve; use objects\categories_o; use objects\department_categories_o; +use objects\department_lanes_o; use objects\department_variables_o; use objects\departments_o; use objects\logs_o; @@ -15,6 +17,59 @@ class departmentsRoute { use route_t; + private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): string + { + global $response; + + $filters = $response->getRequestParameter('filters') ?? []; + if (is_string($filters) || is_array($filters)) { + $filters = $departments->filter_string_to_array($filters); + } else { + $filters = []; + } + + $archived = 0; + if ( + $canListArchived + && array_key_exists('archived', $filters) + && self::isTruthyBooleanValue($filters['archived']) + ) { + $archived = 1; + } + + unset($filters['visible'], $filters['archived']); + $filters['visible'] = 1; + $filters['archived'] = $archived; + + return $departments->array_to_filters($filters); + } + + private static function isTruthyBooleanValue(mixed $value): bool + { + if (is_array($value)) { + foreach ($value as $singleValue) { + if (self::isTruthyBooleanValue($singleValue)) { + return true; + } + } + return false; + } + + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int)$value === 1; + } + + if (is_string($value)) { + return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true); + } + + return false; + } + public function run(): void { $this->get('/departments', function () { @@ -47,6 +102,7 @@ class departmentsRoute 'description', 'economic_department_id', 'visible', + 'archived', 'longitude', 'latitude', ]) @@ -59,8 +115,10 @@ class departmentsRoute 'economic_department_id' => (int)$department['economic_department_id'], 'created_at' => (string)$department['created_at'], 'updated_at' => (string)$department['updated_at'], + 'visible' => (int)$department['visible'], 'dimension' => (int)$department['dimension'], 'branding' => (int)$department['branding'], + 'archived' => (bool)(int)($department['archived'] ?? 0), 'longitude' => (float)$department['longitude'], 'latitude' => (float)$department['latitude'], 'order_priority' => (int)$department['order_priority'], @@ -71,9 +129,10 @@ class departmentsRoute } return $tmp_department; }, - $departments_o->forceRestrictFilters([ - 'visible' => 1, // Only show visible departments, this is to prevent showing internal system departments to the end-user. - ]) + $this->buildDepartmentListFilters( + $departments_o, + $user->hasPermission('superuser_fetch_department') + ) ) ); } else { @@ -158,6 +217,10 @@ class departmentsRoute if (self::isParametersSet(['order_priority'])) { $department->order_priority->set((int)self::getParameter('order_priority')); } + if (self::isParametersSet(['archived'])) { + $department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived'))); + } + $department->objectChanged(); // Log the incident (new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department'); // Return a success message @@ -287,6 +350,7 @@ class departmentsRoute $department_variables = (new department_variables_o())->selectDepartment($department->id); $enabled = self::getParameter('enabled') === 'true' || self::getParameter('enabled') === true || self::getParameter('enabled') === 1 || self::getParameter('enabled') === '1'; $department_variables->set('selfserve_enabled', $enabled ? 'true' : 'false'); + $this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled); // Log the incident (new logs_o())->add('departments', $department->id, 1, $user->id, 'EDIT_DEPARTMENT_SELFSERVE_ENABLED', 'Successfully edited department self-serve enabled status to ' . ($enabled ? 'true' : 'false')); @@ -429,5 +493,123 @@ class departmentsRoute 'department_access_:id' => 'Access the department' ] ); + + self::get('/departments/weekly-results', function () { + // Require the user to be logged in + global $response; + self::requirePermission('view_department_weekly_results'); + // Get the user object + $user = (new authentication())->get_user(); + // Check if the request was successful + if ($user) { + // Get the department results for the week + $departments_o = new departments_o(); + $weeks = 3; + $results = []; + for ($i = 0; $i < $weeks; $i++) { + // Get the week number + $week_number = date('W', strtotime("-$i week")); + // Skip the next week, only show show weeks where at least monday at 00:00:00 is in the past, to prevent showing incomplete data for the current week + // Get the weeks monday + $week_start = strtotime("-$i week Monday"); + if ($week_start < strtotime('now')) { + $week_monday = date('Y-m-d 00:00:00', $week_start); + // Get the weeks sunday from monday + $week_sunday = date('Y-m-d 23:59:59', strtotime("$week_monday +6 days")); + $results[$week_number] = [ + 'week_number' => $week_number, + 'week_monday' => $week_monday, + 'week_sunday' => $week_sunday, + 'results' => $departments_o->sendSlackInternalStatisticNotification( + $week_monday, + $week_sunday, + [1, 2, 3, 4, 5, 6, 7], + // Default value + [ + 25, + [23, 24], // Used to merge two products into one percentage (Spot Free) + 22, + 27, + 21, + 26 + ], + true // Return the results instead of sending the notification (This is used to show the results in the frontend, instead of sending them to slack. + ) + ]; + } + } + // Log the incident + (new logs_o())->add('departments', 'global', 1, $user->id, 'VIEW_DEPARTMENT_WEEKLY_RESULTS', 'Successfully viewed department weekly results'); + // Return the results + $response->success($results); + } else { + // Log the incident + (new logs_o())->add('departments', 'global', 1, 0, 'VIEW_DEPARTMENT_WEEKLY_RESULTS', 'No user found, or invalid session'); + // Return an error + $response->error('Invalid session', 400); + } + }, + [ + 'view_department_weekly_results' => 'View department weekly results' + ] + ); } -} \ No newline at end of file + + protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void + { + if (!$enabled) { + // Self-serve disabled: do not mutate lane relay states. + return; + } + + $selfserve = new selfserve(); + $lanes = (new department_lanes_o())->getDepartmentLanes($departmentId); + + foreach ($lanes as $department_lane) { + $lane_id = (int)$department_lane->id; + if ($lane_id <= 0) { + continue; + } + + try { + $lane = $selfserve->lane($lane_id); + } catch (\Throwable) { + continue; + } + + // Self-serve enabled: keep machine stack off. + $this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { + $lane->setMachineProgramPickerRelayStatus(false); + }); + $this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { + $lane->setMachineCleanerRelayStatus(false); + }); + try { + $lane->setMachineRelayStatus(false); + } catch (\Throwable) {} + } + } + + protected function setOptionalLaneRelayState(object $lane, string $relayProperty, callable $callback): void + { + if ( + empty($lane->department_lane) + || !isset($lane->department_lane->{$relayProperty}) + || !is_object($lane->department_lane->{$relayProperty}) + || !method_exists($lane->department_lane->{$relayProperty}, 'value') + ) { + return; + } + + $relay_id = trim((string)$lane->department_lane->{$relayProperty}->value()); + if ($relay_id === '') { + return; + } + + try { + $callback(); + } catch (\Throwable) { + // Best effort only; this endpoint should still update the department variable. + } + } +} diff --git a/services/nginx/app/routes/economicInvoiceRoute.php b/services/nginx/app/routes/economicInvoiceRoute.php index bb1226b9..4997e818 100644 --- a/services/nginx/app/routes/economicInvoiceRoute.php +++ b/services/nginx/app/routes/economicInvoiceRoute.php @@ -3,6 +3,8 @@ namespace routes; use classes\authentication; +use classes\economic; +use classes\economic_transfer_queue; use classes\response; use classes\router; use economic_invoice_draft_mo; @@ -23,118 +25,81 @@ class economicInvoiceRoute /** @var router $router */ $router, $response; - $this->post('/economic/invoice/draft/export', function () { global $response; $this->requirePermission('economic_invoice_draft_export'); $user = (new authentication())->get_user(); - if ($user) { - $order_id = $response->getRequestParameter('order_id'); - if (!isset($order_id)) { - $response->error('Order ID is required', 400); - } - // Validate the order ID is a number - if (!is_numeric($order_id)) { - $response->error('Order ID must be a number', 400); - } - $order = (new orders_o())->getOrderById($order_id); - // Check if the order exists - if (!$order->exists()) { - $response->error('Order not found', 404); - } - // Get the order items - $order_items = (new orders_o())->getOrderItems($order_id); - // Apply the department pricing - $order_items = (new orders_o())->applyDepartmentPrices($order_items, $order->department_id->value()); - // Get the customer - $customer = (new orders_o())->getCustomerByOrderId($order_id); - // Check if the customer exists - if (!$customer->exists()) { - $response->error('Customer not found', 404); - } - // Get the customer economic number - $customer_economic = $customer->getCustomerEcocomicData()->economic_customer; - $economic_invoice_draft = (new economic_invoice_draft_mo()); - // Set the customer number - $economic_invoice_draft->setCustomerNumber((int)$customer_economic->customer_number); - // Set the recipient - $economic_invoice_draft->setRecipient( - $customer_economic->name ?? 'Ukendt', - $customer_economic->address ?? 'Ukendt', - $customer_economic->zip ?? 'Ukendt', - $customer_economic->city ?? 'Ukendt' - ); - // Make sure there are order items - if (count($order_items) === 0) { - $response->error('No order items found', 404); - } - // Get the department - $department = (new departments_o())->getDepartmentById($order->department_id->value()); - // Add the department, date, reference - $this->addTheDepartmentDateReference($economic_invoice_draft, $department['name'], $order); - - // Add the lines to the invoice - foreach ( $order_items as $order_item ) { - // Add the order item to the invoice draft - $this->addOrderItemToInvoice( - $customer, - $order, - $order_item, - $economic_invoice_draft, - $order_item['quantity'] ?? 1); - } - // Check if the user has an open invoice draft - $hasOpenInvoiceDraft = $customer->hasOpenInvoiceDraft(); - if ($hasOpenInvoiceDraft && !$customer->invoicePerOrder()) { - // Get the open invoice draft - $openInvoiceDraft = $customer->getOpenInvoiceDraft(); - // Add the order to the invoice draft - $result = $this->addOrderToInvoiceDraft($openInvoiceDraft, $order, $customer, $order_items); - } - // If the customer doesn't want to be billed per order, or if there's no open invoice draft, we'll create a new one - // Create the invoice draft - if (!isset($result)) { - $result = $economic_invoice_draft->createInvoiceDraftExample(); - } - // Check if the invoice draft was created, or if the lines were added (lines is an array, and should not be empty) - if (!isset($result->draftInvoiceNumber) && !isset($result->lines[0])) { - // Log the error - (new logs_o())->add('economic_invoice_draft', 'global', 3, $user->id, 'ECONOMIC_INVOICE_DRAFT_EXPORT', 'Failed to create economic invoice draft'); - // Check if we can get the errors from the response - if (isset($result->errors)) { - $response->add_meta('economic_errors', $result->errors); - } - $response->add_meta('economic_result', $result); - // Try to parse the error message - $response->error($result->message ?? 'Failed to create economic invoice draft', 500); - } - // Add the economic invoice draft to the order - $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); - // Remove the existing invoice draft (if any) - if ($economic_module_orders->economic_invoice_draft_id->value() > 0) { - $economic_invoice_draft->deleteInvoiceDraft($economic_module_orders->economic_invoice_draft_id->value()); - } - $economic_module_orders->economic_invoice_draft_id->set($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft()); - if (!$customer->invoicePerOrder()) { - // If the customer wants to be billed per order, we'll add the order to the invoice draft - $customer->setOpenInvoiceDraft($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft()); - } else { - // If the customer doesn't want to be billed per order, we'll remove the open invoice draft - $customer->unsetOpenInvoiceDraft(); - } - // Return the response - (new logs_o())->add('economic_invoice_draft', 'global', 1, $user->id, 'ECONOMIC_INVOICE_DRAFT_EXPORT', 'Successfully exported an economic invoice draft'); - $response->success($economic_module_orders->getArray()); - } else { + if (!$user) { (new logs_o())->add('economic_invoice_draft', 'global', 1, 0, 'ECONOMIC_INVOICE_DRAFT_EXPORT', 'No user found, or invalid session'); $response->error('Invalid session', 400); } + + $order_id = $response->getRequestParameter('order_id'); + if (!isset($order_id)) { + $response->error('Order ID is required', 400); + } + if (!is_numeric($order_id) || (int)$order_id < 1) { + $response->error('Order ID must be a positive number', 400); + } + + $order = (new orders_o())->getOrderById((int)$order_id); + 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 { + $result = $this->exportOrderDraftSynchronously((int)$order_id, (int)$user->id); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + (new logs_o())->add( + 'economic_invoice_draft', + 'global', + 1, + (int)$user->id, + 'ECONOMIC_INVOICE_DRAFT_EXPORT_FALLBACK', + 'Processed economic invoice draft export synchronously because queue dependencies are unavailable' + ); + $response->success([ + 'message' => 'Economic invoice draft export processed synchronously', + 'mode' => 'synchronous_fallback', + 'result' => $result, + ]); + return; + } + + $queue = new economic_transfer_queue(); + $job = $queue->enqueue( + economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT, + [ + 'order_id' => (int)$order_id, + 'requested_by' => (int)$user->id, + ], + (int)$user->id + ); + $job_id = (int)($job['id'] ?? 0); + if ($job_id < 1) { + $response->error('Failed to enqueue draft export job: missing queue job id in enqueue response', 500); + } + + (new logs_o())->add('economic_invoice_draft', 'global', 1, $user->id, 'ECONOMIC_INVOICE_DRAFT_EXPORT_QUEUED', 'Queued economic invoice draft export'); + $response->success([ + 'message' => 'Economic invoice draft export queued', + 'job_id' => $job_id, + 'job' => $job, + ], 202); }, [ 'economic_invoice_draft_export' => 'Export an economic invoice draft' ]); $this->delete('/economic/invoice/draft/delete', function () { - global /** @var response $response */ $response; $this->requirePermission('economic_invoice_draft_delete'); @@ -145,34 +110,25 @@ class economicInvoiceRoute $response->error('Order ID is required', 400); } $order = (new orders_o())->getOrderById($order_id); - // Check if the order exists if (!$order->exists()) { $response->error('Order not found', 404); } - // Make sure the order has an economic invoice draft $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); if ($economic_module_orders->economic_invoice_draft_id->value() === 0) { $response->error('No economic invoice draft found', 404); } $economic_invoice_draft = (new economic_invoice_draft_mo()); - // Get the invoice draft number $invoiceDraftId = $economic_module_orders->economic_invoice_draft_id->value(); $customer = (new orders_o())->getCustomerByOrderId($order_id); if (!$customer->invoicePerOrder()) { - // Check if the customer has an open invoice draft if ($customer->hasOpenInvoiceDraft()) { - // Check if it's the same as the order's invoice draft if ((int)$customer->getOpenInvoiceDraft() === (int)$invoiceDraftId) { - // Remove the open invoice draft from the customer $customer->deleteOpenInvoiceDraft(); } } } - // Delete the invoice draft $economic_invoice_draft->deleteInvoiceDraft($invoiceDraftId); - // Remove the economic invoice draft from the order $economic_module_orders->economic_invoice_draft_id->set(null); - // Return the response (new logs_o())->add('economic_invoice_draft', 'global', 1, $user->id, 'ECONOMIC_INVOICE_DRAFT_DELETE', 'Successfully deleted an economic invoice draft'); $response->success($economic_module_orders->getArray()); } else { @@ -187,112 +143,401 @@ class economicInvoiceRoute global $response; $this->requirePermission('economic_invoice_export'); $user = (new authentication())->get_user(); - if ($user) { - $order_id = $response->getRequestParameter('order_id'); - if (!isset($order_id)) { - $response->error('Order ID is required', 400); - } - $order = (new orders_o())->getOrderById($order_id); - // Check if the order exists - if (!$order->exists()) { - $response->error('Order not found', 404); - } - // Get the customer - $customer = (new orders_o())->getCustomerByOrderId($order_id); - // Check if the customer exists - if (!$customer->exists()) { - $response->error('Customer not found', 404); - } - // Make sure the order has an economic invoice draft - $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); - if ($economic_module_orders->economic_invoice_draft_id->value() === 0) { - $response->error('No economic invoice draft found', 404); - } - $economic_invoice_draft = (new economic_invoice_draft_mo()); - // Get the invoice draft number - $invoiceDraftId = $economic_module_orders->economic_invoice_draft_id->value(); - // Make sure there's not already an invoice created - $invoiceId = $economic_module_orders->economic_invoice_id->value(); - if ($invoiceId > 0) { - $response->error('An invoice has already been created, invoice ID: ' . $invoiceId, 400); - } - // Publish the invoice draft - $result = $economic_invoice_draft->publishInvoiceDraft((int)$invoiceDraftId); - // Check if the invoice was created - if (!isset($result->bookedInvoiceNumber)) { - // Log the error - (new logs_o())->add('economic_invoice', 'global', 3, $user->id, 'ECONOMIC_INVOICE_EXPORT', 'Failed to create economic invoice from draft: ' . $invoiceDraftId); - // Check if we can get the errors from the response - if (isset($result->errors)) { - $response->add_meta('economic_errors', $result->errors); - } - // Try to parse the error message - $response->error($result->message ?? 'Failed to create economic invoice', 500); - } - // Add the economic invoice to the order - $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); - $economic_module_orders->economic_invoice_id->set($result->bookedInvoiceNumber); - // Remove the economic invoice draft from the customer - $customer->unsetOpenInvoiceDraft(); - // Return the response - (new logs_o())->add('economic_invoice', 'global', 1, $user->id, 'ECONOMIC_INVOICE_EXPORT', 'Successfully exported an economic invoice'); - $response->success($economic_module_orders->getArray()); - } else { + if (!$user) { (new logs_o())->add('economic_invoice', 'global', 1, 0, 'ECONOMIC_INVOICE_EXPORT', 'No user found, or invalid session'); $response->error('Invalid session', 400); } + + $order_id = $response->getRequestParameter('order_id'); + if (!isset($order_id)) { + $response->error('Order ID is required', 400); + } + if (!is_numeric($order_id) || (int)$order_id < 1) { + $response->error('Order ID must be a positive number', 400); + } + + $order = (new orders_o())->getOrderById((int)$order_id); + if (!$order->exists()) { + $response->error('Order not found', 404); + } + $customer = (new orders_o())->getCustomerByOrderId((int)$order_id); + 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); + } + if ((int)$economic_module_orders->economic_invoice_id->value() > 0) { + $response->error('An invoice has already been created, invoice ID: ' . (int)$economic_module_orders->economic_invoice_id->value(), 400); + } + + if (!$this->isEconomicTransferQueueAvailable()) { + try { + $result = $this->exportOrderInvoiceSynchronously((int)$order_id, (int)$user->id); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + (new logs_o())->add( + 'economic_invoice', + 'global', + 1, + (int)$user->id, + 'ECONOMIC_INVOICE_EXPORT_FALLBACK', + 'Processed economic invoice export synchronously because queue dependencies are unavailable' + ); + $response->success([ + 'message' => 'Economic invoice export processed synchronously', + 'mode' => 'synchronous_fallback', + 'result' => $result, + ]); + return; + } + + $queue = new economic_transfer_queue(); + $job = $queue->enqueue( + economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT, + [ + 'order_id' => (int)$order_id, + 'requested_by' => (int)$user->id, + ], + (int)$user->id + ); + $job_id = (int)($job['id'] ?? 0); + if ($job_id < 1) { + $response->error('Failed to enqueue invoice export job: missing queue job id in enqueue response', 500); + } + + (new logs_o())->add('economic_invoice', 'global', 1, $user->id, 'ECONOMIC_INVOICE_EXPORT_QUEUED', 'Queued economic invoice export'); + $response->success([ + 'message' => 'Economic invoice export queued', + 'job_id' => $job_id, + 'job' => $job, + ], 202); }, [ 'economic_invoice_export' => 'Export an economic invoice' ]); + + $this->get('/economic/invoice/draft/export/status', function () { + global $response; + $this->requirePermission('economic_invoice_draft_export'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $job_id = $response->getRequestParameter('job_id'); + if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) { + $response->error('job_id is required and must be a positive number', 400); + } + + $queue = new economic_transfer_queue(); + $job = $queue->getJobById((int)$job_id); + if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) { + $response->error('Draft export queue job not found', 404); + } + + $response->success($job); + }, [ + 'economic_invoice_draft_export' => 'Read queue status for economic draft invoice export' + ]); + + $this->post('/economic/invoice/draft/export/retry', function () { + global $response; + $this->requirePermission('economic_invoice_draft_export'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $job_id = $response->getRequestParameter('job_id'); + if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) { + $response->error('job_id is required and must be a positive number', 400); + } + + $queue = new economic_transfer_queue(); + $existing_job = $queue->getJobById((int)$job_id); + if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) { + $response->error('Draft export queue job not found', 404); + } + + try { + $job = $queue->retryJob((int)$job_id); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + $response->success([ + 'message' => 'Draft export queue job retried', + 'job' => $job, + ]); + }, [ + 'economic_invoice_draft_export' => 'Retry failed queue job for economic draft invoice export' + ]); + + $this->get('/economic/invoice/export/status', function () { + global $response; + $this->requirePermission('economic_invoice_export'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $job_id = $response->getRequestParameter('job_id'); + if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) { + $response->error('job_id is required and must be a positive number', 400); + } + + $queue = new economic_transfer_queue(); + $job = $queue->getJobById((int)$job_id); + if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) { + $response->error('Invoice export queue job not found', 404); + } + + $response->success($job); + }, [ + 'economic_invoice_export' => 'Read queue status for economic invoice export' + ]); + + $this->post('/economic/invoice/export/retry', function () { + global $response; + $this->requirePermission('economic_invoice_export'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $job_id = $response->getRequestParameter('job_id'); + if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) { + $response->error('job_id is required and must be a positive number', 400); + } + + $queue = new economic_transfer_queue(); + $existing_job = $queue->getJobById((int)$job_id); + if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) { + $response->error('Invoice export queue job not found', 404); + } + + try { + $job = $queue->retryJob((int)$job_id); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + $response->success([ + 'message' => 'Invoice export queue job retried', + 'job' => $job, + ]); + }, [ + 'economic_invoice_export' => 'Retry failed queue job for economic invoice export' + ]); + } + + private function isEconomicTransferQueueAvailable(): bool + { + return class_exists('\\classes\\economic_transfer_executor') + && class_exists('\\classes\\economic_transfer_queue_schema_bootstrap') + && class_exists(economic_transfer_queue::class); } /** - * @param economic_invoice_draft_mo $economic_invoice_draft - * @param $department_name - * @param orders_o $order - * @return void + * @throws \Exception */ - function addTheDepartmentDateReference(economic_invoice_draft_mo $economic_invoice_draft, $department_name, orders_o $order): void + private function exportOrderDraftSynchronously(int $order_id, int $user_id): array + { + $order = (new orders_o())->getOrderById($order_id); + if (!$order->exists()) { + throw new \Exception('Order not found'); + } + + $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); + $invoice_id = (int)$economic_module_orders->economic_invoice_id->value(); + if ($invoice_id > 0) { + throw new \Exception('An invoice has already been created, invoice ID: ' . $invoice_id); + } + + $order_items = (new orders_o())->getOrderItems($order_id); + $order_items = (new orders_o())->applyDepartmentPrices($order_items, $order->department_id->value()); + if (count($order_items) === 0) { + throw new \Exception('No order items found'); + } + + $customer = (new orders_o())->getCustomerByOrderId($order_id); + 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(); + $economic_invoice_draft->setCustomerNumber((int)$customer_economic->customer_number); + $economic_invoice_draft->setRecipient( + $customer_economic->name ?? 'Ukendt', + $customer_economic->address ?? 'Ukendt', + $customer_economic->zip ?? 'Ukendt', + $customer_economic->city ?? 'Ukendt' + ); + + $department = (new departments_o())->getDepartmentById($order->department_id->value()); + $this->addTheDepartmentDateReference($economic_invoice_draft, $department['name'], $order); + + foreach ($order_items as $order_item) { + $this->addOrderItemToInvoice( + $customer, + $order, + $order_item, + $economic_invoice_draft, + (int)($order_item['quantity'] ?? 1) + ); + } + + $result = null; + if ($customer->hasOpenInvoiceDraft() && !$customer->invoicePerOrder()) { + $open_invoice_draft = (int)$customer->getOpenInvoiceDraft(); + $result = $this->addOrderToInvoiceDraft($open_invoice_draft, $order, $customer, $order_items); + } + if ($result === null) { + $result = $economic_invoice_draft->createInvoiceDraftExample(); + } + + if (!isset($result->draftInvoiceNumber) && !isset($result->lines[0])) { + (new logs_o())->add( + 'economic_invoice_draft', + 'global', + 3, + $user_id, + 'ECONOMIC_INVOICE_DRAFT_EXPORT', + 'Failed to create economic invoice draft' + ); + $message = $result->message ?? 'Failed to create economic invoice draft'; + throw new \Exception((string)$message); + } + + if ($economic_module_orders->economic_invoice_draft_id->value() > 0) { + $economic_invoice_draft->deleteInvoiceDraft($economic_module_orders->economic_invoice_draft_id->value()); + } + + $new_draft_id = (int)($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft()); + $economic_module_orders->economic_invoice_draft_id->set($new_draft_id); + if (!$customer->invoicePerOrder()) { + $customer->setOpenInvoiceDraft($new_draft_id); + } else { + $customer->unsetOpenInvoiceDraft(); + } + + (new logs_o())->add( + 'economic_invoice_draft', + 'global', + 1, + $user_id, + 'ECONOMIC_INVOICE_DRAFT_EXPORT', + 'Successfully exported an economic invoice draft' + ); + + return $economic_module_orders->getArray(); + } + + /** + * @throws \Exception + */ + private function exportOrderInvoiceSynchronously(int $order_id, int $user_id): array + { + $order = (new orders_o())->getOrderById($order_id); + if (!$order->exists()) { + throw new \Exception('Order not found'); + } + + $customer = (new orders_o())->getCustomerByOrderId($order_id); + 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) { + throw new \Exception('No economic invoice draft found'); + } + + $invoice_id = (int)$economic_module_orders->economic_invoice_id->value(); + if ($invoice_id > 0) { + throw new \Exception('An invoice has already been created, invoice ID: ' . $invoice_id); + } + + $invoice_draft_id = (int)$economic_module_orders->economic_invoice_draft_id->value(); + $economic_invoice_draft = new economic_invoice_draft_mo(); + $result = $economic_invoice_draft->publishInvoiceDraft($invoice_draft_id); + + if (!isset($result->bookedInvoiceNumber)) { + (new logs_o())->add( + 'economic_invoice', + 'global', + 3, + $user_id, + 'ECONOMIC_INVOICE_EXPORT', + 'Failed to create economic invoice from draft: ' . $invoice_draft_id + ); + $message = $result->message ?? 'Failed to create economic invoice'; + throw new \Exception((string)$message); + } + + $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); + $economic_module_orders->economic_invoice_id->set((int)$result->bookedInvoiceNumber); + $customer->unsetOpenInvoiceDraft(); + + (new logs_o())->add( + 'economic_invoice', + 'global', + 1, + $user_id, + 'ECONOMIC_INVOICE_EXPORT', + 'Successfully exported an economic invoice' + ); + + return $economic_module_orders->getArray(); + } + + private function addTheDepartmentDateReference(economic_invoice_draft_mo $economic_invoice_draft, mixed $department_name, orders_o $order): void { - // The format is: - // Truck Wash - [department name], [date], (?)Ref(erence): [reference], Reg 1: [reg_1], (?)Reg 2: [reg_2], (?)Reg 3: [reg_3] - // (?)[note] - // Example: - // 12-02-2025 13:27, Truck Wash - Administration, Ref: 123456, Reg 1: ABC123, Reg 2: DEF456, Reg 3: GHI789 - // This is a note for the invoice - // - // (?) = Optional $parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value())); $economic_invoice_draft->addLineTEXT("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]"); - // If there's a reference, add it to the invoice + if ($order->reference->value() !== '') { $economic_invoice_draft->addLineTEXT('Reference:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) if (str_contains($order->reference->value(), "\n")) { - foreach ( explode("\n", $order->reference->value()) as $line ) { + foreach (explode("\n", $order->reference->value()) as $line) { $economic_invoice_draft->addLineTEXT('# ' . $line); } } else { $economic_invoice_draft->addLineTEXT('# ' . $order->reference->value()); } } - // Add the registration numbers (if any) + $line_reg = ''; if ($order->reg_1->value() !== '') { $line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value()); } - if ($order->reg_2->value() !== '') + if ($order->reg_2->value() !== '') { $line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value()); - if ($order->reg_3->value() !== '') + } + if ($order->reg_3->value() !== '') { $line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value()); - // Add the line to the invoice + } $economic_invoice_draft->addLineTEXT($line_reg); - // If there's a note, add it to the invoice + if ($order->notes->value() !== '') { $economic_invoice_draft->addLineTEXT('Notat:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) if (str_contains($order->notes->value(), "\n")) { - foreach ( explode("\n", $order->notes->value()) as $line ) { + foreach (explode("\n", $order->notes->value()) as $line) { $economic_invoice_draft->addLineTEXT('# ' . $line); } } else { @@ -302,51 +547,33 @@ class economicInvoiceRoute } /** - * @param users_o $customer - * @param orders_o $order - * @param mixed $order_item - * @param economic_invoice_draft_mo $economic_invoice_draft - * @param int $quantity - * @return void * @throws \Exception */ - function addOrderItemToInvoice(users_o $customer, orders_o $order, mixed $order_item, economic_invoice_draft_mo $economic_invoice_draft, int $quantity = 1): void + private function addOrderItemToInvoice(users_o $customer, orders_o $order, mixed $order_item, economic_invoice_draft_mo $economic_invoice_draft, int $quantity = 1): void { - // The format is: - // [product name], (?)Ref(erence): [reference], (!?)Reg 1: [reg 1], (!?)Reg 2: [reg 2], (!?)Reg 3: [reg 3], (?)Note: [note], (?)Discount: ([order item price] - [product price]) DKK ([discount percentage] %) - // (?) = Optional - // (!) = Required if the customer requires it - // (!?) = Should be added if the customer requires it - // Example: - // Tankcleaning 4 spulehoveder, Ref: 123456, Reg 1: ABC123, Reg 2: DEF456, Reg 3: GHI789, Note: This is a note, Discount: -100 DKK (20%) - // Get the department $department = $order->getDepartmentByOrderId($order->id); - $economic_department_id = $department['economic_department_id']; + $economic_department_id = $department['economic_department_id'] ?? 0; $economic_dimension_id = $department['economic_dimension_id'] ?? 0; - // Add the line to the invoice $economic_invoice_draft->addLine( (string)$order_item['product']['economic_product_id'], (string)$order_item['product']['name'], (int)$quantity, (int)$order_item['price'], - 0, // Since we can't be specific about the discount, we set it to 0. (The API has a limit of 2 decimals, and that's not enough for our needs) - (int)$economic_department_id ?? 0, - (int)$economic_dimension_id ?? 0 // If the department is not set, we'll set it to 0 + 0, + (int)$economic_department_id, + (int)$economic_dimension_id ); - // Calculate the discount percentage - $discountPercentage = round((($order_item['product']['price'] - $order_item['price']) / $order_item['product']['price']) * 100, 0); - // If the price is different from the product price, add it to the line - if ($order_item['price'] !== $order_item['product']['price']) - $economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item['price'] - $order_item['product']['price']) . ' DKK (' . $discountPercentage . '%)'); + $discount_percentage = round((($order_item['product']['price'] - $order_item['price']) / $order_item['product']['price']) * 100, 0); + if ($order_item['price'] !== $order_item['product']['price']) { + $economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item['price'] - $order_item['product']['price']) . ' DKK (' . $discount_percentage . '%)'); + } - // If there's a reference, add it to the line if ($order_item['reference'] !== '') { $economic_invoice_draft->addLineTEXT('Reference:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) if (str_contains($order_item['reference'], "\n")) { - foreach ( explode("\n", $order_item['reference']) as $line ) { + foreach (explode("\n", $order_item['reference']) as $line) { $economic_invoice_draft->addLineTEXT('# ' . $line); } } else { @@ -354,7 +581,6 @@ class economicInvoiceRoute } } - // Add the registration numbers (if they exist, and the customer requires it) if ($customer->doesUserHaveAttribute('requiresRegistrationNumbersInvoice')) { $line_reg = ''; if ($order->reg_1->value() !== '') { @@ -369,34 +595,57 @@ class economicInvoiceRoute $economic_invoice_draft->addLineTEXT($line_reg); } - // If there's a note, add it to the line if ($order_item['notes'] !== '') { $economic_invoice_draft->addLineTEXT('Notat:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) if (str_contains($order_item['notes'], "\n")) { - foreach ( explode("\n", $order_item['notes']) as $line ) { + foreach (explode("\n", $order_item['notes']) as $line) { $economic_invoice_draft->addLineTEXT('# ' . $line); } } else { $economic_invoice_draft->addLineTEXT('# ' . $order_item['notes']); } } - } - function addOrderToInvoiceDraft(int $economic_invoice_draft_id, orders_o $order, users_o $customer, array $order_items): object + private function addOrderToInvoiceDraft(int $economic_invoice_draft_id, orders_o $order, users_o $customer, array $order_items): object { - $economic_invoice_draft = (new economic_invoice_draft_mo()); - // Add two empty lines to the invoice, to separate the orders + $economic_invoice_draft = new economic_invoice_draft_mo(); $economic_invoice_draft->addLineTEXT(''); $economic_invoice_draft->addLineTEXT(''); - // Add the department, date, reference $this->addTheDepartmentDateReference($economic_invoice_draft, $order->getDepartmentByOrderId($order->id)['name'], $order); - // Add the lines to the invoice - foreach ( $order_items as $order_item ) { - // Add the order item to the invoice draft - $this->addOrderItemToInvoice($customer, $order, $order_item, $economic_invoice_draft, $order_item['quantity'] ?? 1); + + foreach ($order_items as $order_item) { + $this->addOrderItemToInvoice( + $customer, + $order, + $order_item, + $economic_invoice_draft, + (int)($order_item['quantity'] ?? 1) + ); } + return $economic_invoice_draft->addLinesToInvoiceDraft($economic_invoice_draft_id); } -} \ No newline at end of file + + private function ensureEconomicTransferQueueIsAvailable(): void + { + global $response; + + if (!$this->isEconomicTransferQueueAvailable()) { + $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()); + } +} diff --git a/services/nginx/app/routes/economicPaymentTermsRoute.php b/services/nginx/app/routes/economicPaymentTermsRoute.php index 5cec920f..80da0a5f 100644 --- a/services/nginx/app/routes/economicPaymentTermsRoute.php +++ b/services/nginx/app/routes/economicPaymentTermsRoute.php @@ -26,8 +26,9 @@ class economicPaymentTermsRoute $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('economic_payment_terms', 'global', 1, $user->id, 'ECONOMIC_PAYMENT_TERMS', 'Successfully fetched economic payment terms'); + $paymentTermsResponse = (new economic())->payment_terms->get(); $response->success( - (new economic())->payment_terms->get()->collection + self::extractPaymentTermsCollection($paymentTermsResponse) ); } else { (new logs_o())->add('economic_payment_terms', 'global', 1, 0, 'ECONOMIC_PAYMENT_TERMS', 'No user found, or invalid session'); @@ -39,4 +40,34 @@ class economicPaymentTermsRoute ] ); } -} \ No newline at end of file + + private static function extractPaymentTermsCollection(mixed $response): array + { + if (is_array($response)) { + return array_values($response); + } + + if (!is_object($response)) { + return []; + } + + foreach (['collection', 'paymentTerms', 'items', 'results'] as $property) { + if (!property_exists($response, $property)) { + continue; + } + + $candidate = $response->{$property}; + if ($candidate instanceof \Traversable) { + return iterator_to_array($candidate, false); + } + if (is_array($candidate)) { + return array_values($candidate); + } + if (is_object($candidate)) { + return array_values(get_object_vars($candidate)); + } + } + + return []; + } +} diff --git a/services/nginx/app/routes/edgeGatewayConfigRoute.php b/services/nginx/app/routes/edgeGatewayConfigRoute.php new file mode 100644 index 00000000..96758795 --- /dev/null +++ b/services/nginx/app/routes/edgeGatewayConfigRoute.php @@ -0,0 +1,11 @@ +isEnabled()) { + return; + } +} catch (\Throwable $exception) { + return; +} + +require_once __DIR__ . '/../modules/edgegateway/routes/edgeGatewayConfigRoute.php'; diff --git a/services/nginx/app/routes/edgeGatewaysRoute.php b/services/nginx/app/routes/edgeGatewaysRoute.php new file mode 100644 index 00000000..be5b84c3 --- /dev/null +++ b/services/nginx/app/routes/edgeGatewaysRoute.php @@ -0,0 +1,11 @@ +isEnabled()) { + return; + } +} catch (\Throwable $exception) { + return; +} + +require_once __DIR__ . '/../modules/edgegateway/routes/edgeGatewaysRoute.php'; diff --git a/services/nginx/app/routes/errorReportRoute.php b/services/nginx/app/routes/errorReportRoute.php new file mode 100644 index 00000000..7d691f61 --- /dev/null +++ b/services/nginx/app/routes/errorReportRoute.php @@ -0,0 +1,95 @@ +post('/error-reports', function () { + global $response; + try { + $response->success((new error_report_service())->createFromCurrentPrincipal($this->requestPayload()), 201); + } catch (Throwable $throwable) { + $status = str_contains(strtolower($throwable->getMessage()), 'authentication failed') ? 401 : 400; + $response->error(['message' => $throwable->getMessage()], $status); + } + }); + + $this->get('/superuser/error-reports', function () { + global $response; + $this->requirePermission('superuser_error_reports_view'); + $response->success((new error_report_service())->list($this->getParametersAsArray())); + }, [ + 'superuser_error_reports_view' => 'View authenticated user error reports', + ]); + + $this->get('/superuser/error-reports/{id}', function () { + global $response; + $this->requirePermission('superuser_error_reports_view'); + try { + $response->success((new error_report_service())->get($this->routeId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 404); + } + }, [ + 'superuser_error_reports_view' => 'View authenticated user error report details', + ]); + + $this->patch('/superuser/error-reports/{id}/status', function () { + global $response; + $this->requirePermission('superuser_error_reports_resolve'); + try { + $payload = $this->requestPayload(); + $response->success((new error_report_service())->updateStatus( + $this->routeId(), + (string)($payload['status'] ?? ''), + isset($payload['resolution_note']) ? (string)$payload['resolution_note'] : null, + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_error_reports_resolve' => 'Resolve and reopen authenticated user error reports', + ]); + } + + private function routeId(): int + { + $id = (int)$this->fromRoute('id'); + $this->requireParameterIntPositive($id, 'id'); + return $id; + } + + private function actorUserId(): ?int + { + try { + $user = (new authentication())->get_user(); + return $user !== false && isset($user->id) ? (int)$user->id : null; + } catch (Throwable) { + return null; + } + } + + private function requestPayload(): array + { + $payload = json_decode(file_get_contents('php://input'), true); + if (!is_array($payload)) { + $payload = []; + } + + if ($_GET !== []) { + $payload = array_replace($payload, $_GET); + } + + return $payload; + } +} diff --git a/services/nginx/app/routes/guestRoute.php b/services/nginx/app/routes/guestRoute.php index 4259bdd7..13fd7de0 100644 --- a/services/nginx/app/routes/guestRoute.php +++ b/services/nginx/app/routes/guestRoute.php @@ -76,7 +76,8 @@ class guestRoute 'name' => (string)$lane->name->value(), 'status' => (string)$lane->getLaneStatus()->name, 'products' => $lane->getSelfServeLaneProducts(), - 'machine_available' => !empty($lane->relay_machine_id->value()), + 'selfserve_enabled' => $lane->isSelfServeEnabled(), + 'machine_available' => $lane->isSelfServeEnabled() && !empty($lane->relay_machine_id->value()), 'dynamic_image_id' => $lane->dynamic_image_id->value() ? (int)$lane->dynamic_image_id->value() : null, ]; }, $department->getLanes()); @@ -96,4 +97,4 @@ class guestRoute ])), 200); }); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/intimidateRoute.php b/services/nginx/app/routes/intimidateRoute.php index 25a275b1..4ec39c84 100644 --- a/services/nginx/app/routes/intimidateRoute.php +++ b/services/nginx/app/routes/intimidateRoute.php @@ -17,9 +17,7 @@ class intimidateRoute // Get the post data global $response; // Make sure the user has the SUPERUSER_INTIMIDATE permission - if (!$this->requirePermission('SUPERUSER_INTIMIDATE')) { - $response->error('Permission denied', 403); - } + $this->requirePermission('SUPERUSER_INTIMIDATE'); // Get the user object $user = (new authentication())->get_user(); // Get the post data @@ -42,4 +40,4 @@ class intimidateRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/machineButtonPressRoute.php b/services/nginx/app/routes/machineButtonPressRoute.php index 159bdc40..b423eba7 100644 --- a/services/nginx/app/routes/machineButtonPressRoute.php +++ b/services/nginx/app/routes/machineButtonPressRoute.php @@ -3,12 +3,12 @@ namespace routes; use classes\authentication; -use classes\slack; -use objects\customer_vehicles_o; +use classes\selfserve; +use modules\selfserve\classes\selfserve_machine_signal; +use modules\selfserve\classes\selfserve_wash_flow; +use objects\department_lanes_o; use objects\logs_o; -use objects\orders_o; -use objects\plate_scans_o; -use objects\users_o; +use objects\plate_scanners_o; use traits\route_t; class machineButtonPressRoute @@ -17,28 +17,129 @@ class machineButtonPressRoute public function run(): void { - - self::get('/relay/button/press/post', function () { - /** - * This is here because the post method is not supported by the software some of the cameras are running on. - */ + $handler = function () { global $response; + self::requirePlateScannerAuth(); $plate_scanner = (new authentication())->get_plate_scanner(); - self::requireParameters(['token']); - // Validate the token - self::requireType('token', 'string'); - self::requireMinLength('token', 1); - self::requireMaxLength('token', 100); - // TODO: Implement action - //$slack = new slack(); - //$slack->send_department_booking_notification((int)$plate_scanner->department_id->value(), 'Button was pressed on the machine with the plate scanner: ' . $plate_scanner->id); - // Log the incident - (new logs_o())->add('relay', $plate_scanner->department_id->value(), 1, 0, 'TRIGGER_BUTTON_PRESS', 'Button was pressed on the machine with the plate scanner: ' . $plate_scanner->id); - // Return a success message - $response->success(['message' => 'Button press recorded.', 'scanner' => $plate_scanner->name->value()], 201); - }, [ + if (!$plate_scanner) { + $response->error('Invalid plate scanner session', 403); + } + + $lane_id = $this->resolveLaneId($plate_scanner); + $reg = self::isParametersSet(['reg']) ? selfserve::standardize_registration((string)self::getParameter('reg')) : null; + $payload = $this->getParametersAsArray(); + unset($payload['token']); + + try { + $summary = (new selfserve_wash_flow())->recordMachineStartWebhook($lane_id, $reg, $payload); + } catch (\RuntimeException $e) { + $response->error($e->getMessage(), 404); + } + + (new logs_o())->add('relay', $plate_scanner->department_id->value(), 1, 0, 'TRIGGER_BUTTON_PRESS', 'Button was pressed on the machine with the plate scanner: ' . $plate_scanner->id . ' for lane ' . $lane_id); + $response->success([ + 'message' => 'Button press recorded.', + 'scanner' => $plate_scanner->name->value(), + 'lane_id' => $lane_id, + 'selfserve' => $summary, + ], 201); + }; + + $this->get('/relay/button/press/post', $handler, [ 'add_button_press' => 'Add a button press' ]); + + $this->post('/relay/button/press/post', $handler, [ + 'add_button_press' => 'Add a button press' + ]); + + $shellyHandler = function () { + global $response; + + self::requirePlateScannerAuth(); + $plate_scanner = (new authentication())->get_plate_scanner(); + if (!$plate_scanner) { + $response->error('Invalid plate scanner session', 403); + } + + $payload = $this->getParametersAsArray(); + unset($payload['token']); + $lane_id = self::isParametersSet(['lane_id']) ? (int)self::getParameter('lane_id') : null; + + try { + $result = (new selfserve_machine_signal())->recordCloudShellySignal( + (int)$plate_scanner->department_id->value(), + $lane_id, + $payload, + [ + 'scanner_id' => (int)$plate_scanner->id, + 'scanner_name' => (string)$plate_scanner->name->value(), + ] + ); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + (new logs_o())->add( + 'relay', + $plate_scanner->department_id->value(), + 1, + 0, + 'TRIGGER_MACHINE_ON_SIGNAL', + 'Shelly machine ON signal received from plate scanner: ' . $plate_scanner->id + ); + $response->success([ + 'message' => !empty($result['recorded']) ? 'Machine ON signal recorded.' : 'Shelly signal ignored.', + 'scanner' => $plate_scanner->name->value(), + 'lane_id' => $result['lane_id'] ?? $lane_id, + 'signal' => $result['signal'] ?? null, + 'selfserve' => $result['selfserve'] ?? null, + 'ignored' => $result['ignored'] ?? false, + 'reason' => $result['reason'] ?? null, + ], !empty($result['recorded']) ? 201 : 202); + }; + + $this->get('/relay/machine/on/post', $shellyHandler, [ + 'add_button_press' => 'Record a Shelly machine ON signal' + ]); + + $this->post('/relay/machine/on/post', $shellyHandler, [ + 'add_button_press' => 'Record a Shelly machine ON signal' + ]); } -} \ No newline at end of file + + private function resolveLaneId(plate_scanners_o $plateScanner): int + { + global $response; + + if (self::isParametersSet(['lane_id'])) { + $lane = (new department_lanes_o())->select((int)self::getParameter('lane_id')); + if (!$lane->exists()) { + $response->error('Department lane not found', 404); + } + if ((int)$lane->department->value() !== (int)$plateScanner->department_id->value()) { + $response->error('The lane does not belong to the plate scanner department', 403); + } + return (int)$lane->id; + } + + if ($plateScanner->lane_id->value() !== null) { + $lane = (new department_lanes_o())->select((int)$plateScanner->lane_id->value()); + if (!$lane->exists()) { + $response->error('The default lane configured for the plate scanner no longer exists', 404); + } + if ((int)$lane->department->value() !== (int)$plateScanner->department_id->value()) { + $response->error('The default lane does not belong to the plate scanner department', 403); + } + return (int)$lane->id; + } + + $lanes = (new department_lanes_o())->getDepartmentLanes((int)$plateScanner->department_id->value()); + if (count($lanes) === 1) { + return (int)$lanes[0]->id; + } + + $response->error('lane_id is required when the department has multiple self-serve lanes', 400); + } +} diff --git a/services/nginx/app/routes/moduleConfigRoute.php b/services/nginx/app/routes/moduleConfigRoute.php index 20b94322..040b5e49 100644 --- a/services/nginx/app/routes/moduleConfigRoute.php +++ b/services/nginx/app/routes/moduleConfigRoute.php @@ -8,10 +8,13 @@ use classes\economic; use classes\email; use classes\fxratesapi; use classes\motorapi; +use classes\n8n; use classes\recaptcha; use classes\response; use classes\router; use classes\stripe; +use classes\weatherapi; +use classes\workfeed; use objects\logs_o; use traits\route_t; @@ -215,6 +218,82 @@ class moduleConfigRoute ] ); + $this->get('/failover/config', function () { + global $response; + $this->requirePermission('modules_failover_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('failover_config', 'global', 1, $user->id, 'FAILOVER_CONFIG', 'Successfully fetched failover config'); + $response->success( + (new \classes\failover())->config->getConfigRequest() + ); + } else { + (new logs_o())->add('failover_config', 'global', 1, 0, 'FAILOVER_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_failover_config' => 'Get failover config' + ] + ); + + $this->post('/failover/config', function () { + global $response; + $this->requirePermission('modules_failover_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('failover_config', 'global', 1, $user->id, 'FAILOVER_CONFIG', 'Successfully updated failover config'); + $response->success( + (new \classes\failover())->config->postConfigRequest() + ); + } else { + (new logs_o())->add('failover_config', 'global', 1, 0, 'FAILOVER_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_failover_config' => 'Update failover config' + ] + ); + + $this->get('/coolify/config', function () { + global $response; + $this->requirePermission('superuser_coolify_manage'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('coolify_config', 'global', 1, $user->id, 'COOLIFY_CONFIG', 'Successfully fetched Coolify config'); + $response->success( + (new \classes\coolify())->config->getConfigRequest() + ); + } else { + (new logs_o())->add('coolify_config', 'global', 1, 0, 'COOLIFY_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'superuser_coolify_manage' => 'Get Coolify config' + ] + ); + + $this->post('/coolify/config', function () { + global $response; + $this->requirePermission('superuser_coolify_manage'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('coolify_config', 'global', 1, $user->id, 'COOLIFY_CONFIG', 'Successfully updated Coolify config'); + $response->success( + (new \classes\coolify())->config->postConfigRequest() + ); + } else { + (new logs_o())->add('coolify_config', 'global', 1, 0, 'COOLIFY_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'superuser_coolify_manage' => 'Update Coolify config' + ] + ); + /** Bird config > GET */ $this->get('/bird/config', function () { global $response; @@ -412,6 +491,123 @@ class moduleConfigRoute ] ); + /** WeatherAPI config > GET */ + $this->get('/weatherapi/config', function () { + global $response; + $this->requirePermission('weatherapi_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('weatherapi_config', 'global', 1, $user->id, 'WEATHERAPI_CONFIG', 'Successfully fetched weatherapi config'); + $response->success( + (new weatherapi())->config->getConfigRequest() + ); + } else { + (new logs_o())->add('weatherapi_config', 'global', 1, 0, 'WEATHERAPI_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'weatherapi_config' => 'Get weatherapi config' + ] + ); + /** WeatherAPI config > POST */ + $this->post('/weatherapi/config', function () { + global $response; + $this->requirePermission('weatherapi_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('weatherapi_config', 'global', 1, $user->id, 'WEATHERAPI_CONFIG', 'Successfully updated weatherapi config'); + $response->success( + (new weatherapi())->config->postConfigRequest() + ); + } else { + (new logs_o())->add('weatherapi_config', 'global', 1, 0, 'WEATHERAPI_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'weatherapi_config' => 'Update weatherapi config' + ] + ); + + /** n8n config > GET */ + $this->get('/n8n/config', function () { + global $response; + $this->requirePermission('modules_n8n_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('n8n_config', 'global', 1, $user->id, 'N8N_CONFIG', 'Successfully fetched n8n config'); + $response->success( + (new n8n())->config->getConfigRequest() + ); + } else { + (new logs_o())->add('n8n_config', 'global', 1, 0, 'N8N_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_n8n_config' => 'Get n8n config' + ] + ); + /** n8n config > POST */ + $this->post('/n8n/config', function () { + global $response; + $this->requirePermission('modules_n8n_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('n8n_config', 'global', 1, $user->id, 'N8N_CONFIG', 'Successfully updated n8n config'); + $response->success( + (new n8n())->config->postConfigRequest() + ); + } else { + (new logs_o())->add('n8n_config', 'global', 1, 0, 'N8N_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_n8n_config' => 'Update n8n config' + ] + ); + + /** Workfeed config > GET */ + $this->get('/workfeed/config', function () { + global $response; + $this->requirePermission('modules_workfeed_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('workfeed_config', 'global', 1, $user->id, 'WORKFEED_CONFIG', 'Successfully fetched workfeed config'); + $response->success( + (new workfeed())->config->getConfigRequest() + ); + } else { + (new logs_o())->add('workfeed_config', 'global', 1, 0, 'WORKFEED_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_workfeed_config' => 'Get workfeed config' + ] + ); + /** Workfeed config > POST */ + $this->post('/workfeed/config', function () { + global $response; + $this->requirePermission('modules_workfeed_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('workfeed_config', 'global', 1, $user->id, 'WORKFEED_CONFIG', 'Successfully updated workfeed config'); + $response->success( + (new workfeed())->config->postConfigRequest() + ); + } else { + (new logs_o())->add('workfeed_config', 'global', 1, 0, 'WORKFEED_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_workfeed_config' => 'Update workfeed config' + ] + ); + /** XLVask config > GET */ $this->get('/xlvask/config', function () { global $response; diff --git a/services/nginx/app/routes/moduleEdgeGatewayRoute.php b/services/nginx/app/routes/moduleEdgeGatewayRoute.php new file mode 100644 index 00000000..e733b9de --- /dev/null +++ b/services/nginx/app/routes/moduleEdgeGatewayRoute.php @@ -0,0 +1,11 @@ +isEnabled()) { + return; + } +} catch (\Throwable $exception) { + return; +} + +require_once __DIR__ . '/../modules/edgegateway/routes/moduleEdgeGatewayRoute.php'; diff --git a/services/nginx/app/routes/moduleN8nRoute.php b/services/nginx/app/routes/moduleN8nRoute.php new file mode 100644 index 00000000..0725ac37 --- /dev/null +++ b/services/nginx/app/routes/moduleN8nRoute.php @@ -0,0 +1,351 @@ +get('/modules/n8n/workflows', function () { + global $response; + self::requirePermission('modules_n8n_workflows_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $filters = $this->filterRequestParameters($_GET, [ + 'active', + 'tags', + 'name', + 'projectId', + 'excludePinnedData', + 'limit', + 'cursor', + ]); + + $result = (new n8n())->listWorkflows($filters); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOWS_LIST', 'Listed n8n workflows'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_view' => 'List n8n workflows', + ]); + + $this->get('/modules/n8n/workflows/{id}', function () { + global $response; + self::requirePermission('modules_n8n_workflows_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $workflowId = (string)$this->fromRoute('id'); + $result = (new n8n())->getWorkflow($workflowId, $this->toBool($this->fromQuery('excludePinnedData'))); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_GET', 'Fetched n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_view' => 'Get a specific n8n workflow', + ]); + + $this->post('/modules/n8n/workflows', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(); + $workflow = $this->extractWorkflowPayload($body); + + $result = (new n8n())->createWorkflow($workflow); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_CREATE', 'Created n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Create n8n workflows', + ]); + + $this->put('/modules/n8n/workflows/{id}', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(); + $workflow = $this->extractWorkflowPayload($body); + + $result = (new n8n())->updateWorkflow((string)$this->fromRoute('id'), $workflow); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_UPDATE', 'Updated n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Update n8n workflows', + ]); + + $this->post('/modules/n8n/workflows/{id}/publish', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(false); + $options = $body !== null ? $this->filterObjectProperties($body, ['versionId', 'name', 'description']) : null; + + $result = (new n8n())->publishWorkflow((string)$this->fromRoute('id'), $options); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_PUBLISH', 'Published n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Publish n8n workflows', + ]); + + $this->post('/modules/n8n/workflows/{id}/deactivate', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new n8n())->deactivateWorkflow((string)$this->fromRoute('id')); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_DEACTIVATE', 'Deactivated n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Deactivate n8n workflows', + ]); + + $this->post('/modules/n8n/webhooks/trigger', function () { + global $response; + self::requirePermission('modules_n8n_workflows_run'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(); + $webhookTarget = $this->extractWebhookTarget($body); + $payload = property_exists($body, 'payload') ? $body->payload : null; + $query = property_exists($body, 'query') ? $this->objectToArray($body->query) : []; + $method = $this->normalizeHttpMethod(property_exists($body, 'method') ? (string)$body->method : 'POST'); + + $result = (new n8n())->runWebhook($webhookTarget, $payload, $method, $query); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WEBHOOK_TRIGGER', 'Triggered n8n webhook'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_run' => 'Trigger n8n workflows through webhooks', + ]); + + $this->get('/modules/n8n/executions', function () { + global $response; + self::requirePermission('modules_n8n_executions_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $filters = $this->filterRequestParameters($_GET, [ + 'includeData', + 'status', + 'workflowId', + 'projectId', + 'limit', + 'cursor', + ]); + + $result = (new n8n())->listExecutions($filters); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_EXECUTIONS_LIST', 'Listed n8n executions'); + $response->success($result, 200); + }, [ + 'modules_n8n_executions_view' => 'List n8n executions', + ]); + + $this->get('/modules/n8n/executions/{id}', function () { + global $response; + self::requirePermission('modules_n8n_executions_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new n8n())->getExecution((int)$this->fromRoute('id'), $this->toBool($this->fromQuery('includeData'))); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_EXECUTION_GET', 'Fetched n8n execution'); + $response->success($result, 200); + }, [ + 'modules_n8n_executions_view' => 'Get a specific n8n execution', + ]); + + $this->post('/modules/n8n/executions/{id}/retry', function () { + global $response; + self::requirePermission('modules_n8n_workflows_run'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(false); + $loadWorkflow = $body !== null && property_exists($body, 'loadWorkflow') + ? $this->toBool($body->loadWorkflow) + : false; + + $result = (new n8n())->retryExecution((int)$this->fromRoute('id'), $loadWorkflow); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_EXECUTION_RETRY', 'Retried n8n execution'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_run' => 'Retry n8n executions', + ]); + + $this->post('/modules/n8n/executions/{id}/stop', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new n8n())->stopExecution((int)$this->fromRoute('id')); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_EXECUTION_STOP', 'Stopped n8n execution'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Stop running n8n executions', + ]); + } + + private function readJsonBody(bool $required = true): ?object + { + global $response; + + $raw = file_get_contents('php://input'); + if ($raw === false || trim($raw) === '') { + if ($required) { + $response->error('Request body must contain valid JSON.', 400); + } + + return null; + } + + $decoded = json_decode($raw); + if (json_last_error() !== JSON_ERROR_NONE || !is_object($decoded)) { + $response->error('Request body must contain valid JSON.', 400); + } + + return $decoded; + } + + private function extractWorkflowPayload(object $body): object + { + global $response; + + $workflow = property_exists($body, 'workflow') && is_object($body->workflow) + ? $body->workflow + : $body; + + if (!property_exists($workflow, 'name') && !property_exists($workflow, 'nodes') && !property_exists($workflow, 'connections')) { + $response->error('Workflow payload is missing. Provide a workflow object or workflow fields in the request body.', 400); + } + + return $workflow; + } + + private function extractWebhookTarget(object $body): string + { + global $response; + + foreach (['webhook_url', 'webhookUrl', 'webhook_path', 'webhookPath'] as $field) { + if (property_exists($body, $field) && is_string($body->{$field}) && trim($body->{$field}) !== '') { + return trim($body->{$field}); + } + } + + $response->error('Provide webhook_url or webhook_path to trigger an n8n workflow.', 400); + } + + private function normalizeHttpMethod(string $method): string + { + $normalized = strtoupper(trim($method)); + if (!in_array($normalized, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return 'POST'; + } + + return $normalized; + } + + private function filterRequestParameters(array $parameters, array $allowedKeys): array + { + $allowed = array_flip($allowedKeys); + $filtered = []; + + foreach ($parameters as $key => $value) { + if (isset($allowed[$key]) && $value !== '' && $value !== null) { + $filtered[$key] = $value; + } + } + + return $filtered; + } + + private function filterObjectProperties(object $source, array $allowedKeys): object + { + $filtered = new stdClass(); + + foreach ($allowedKeys as $key) { + if (property_exists($source, $key) && $source->{$key} !== null && $source->{$key} !== '') { + $filtered->{$key} = $source->{$key}; + } + } + + return $filtered; + } + + private function objectToArray(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (is_object($value)) { + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE); + if ($encoded !== false) { + $decoded = json_decode($encoded, true); + if (is_array($decoded)) { + return $decoded; + } + } + } + + return []; + } + + private function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + return in_array($normalized, ['1', 'true', 'yes', 'on'], true); + } + + if (is_int($value)) { + return $value === 1; + } + + return false; + } +} diff --git a/services/nginx/app/routes/moduleScannerRoute.php b/services/nginx/app/routes/moduleScannerRoute.php index cc53a85f..1cb3049e 100644 --- a/services/nginx/app/routes/moduleScannerRoute.php +++ b/services/nginx/app/routes/moduleScannerRoute.php @@ -49,8 +49,10 @@ class moduleScannerRoute // Success $response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]); } else { - throw new Exception('License plate extraction failed.'); - //$response->error($lpr_result['message'] ?? 'License plate recognition failed.', $lpr_result); + $response->response(false, [ + 'message' => $lpr_result['message'] ?? 'No license plate detected.', + 'reason' => 'no_license_plate_detected', + ], 200); } exit; // For future use with OpenAI. @@ -73,4 +75,4 @@ class moduleScannerRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 653956f6..d69c70ae 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -8,18 +8,30 @@ use classes\response; use classes\router; use classes\selfserve; use classes\stripe; +use modules\selfserve\classes\selfserve_lane; +use modules\selfserve\classes\selfserve_wash_flow; use modules\selfserve\helpers\selfserve_lane_command; +use modules\selfserve\helpers\selfserve_lane_port; use modules\selfserve\helpers\selfserve_lane_relay; +use modules\selfserve\helpers\selfserve_lane_state; +use modules\selfserve\helpers\selfserve_lane_status; +use modules\selfserve\helpers\selfserve_wash_session_status; +use objects\department_lanes_o; use objects\departments_o; use objects\logs_o; use objects\orders_o; +use objects\customer_vehicles_o; +use objects\selfserve_wash_sessions_o; use objects\stripe_module_customers_o; +use objects\users_o; use traits\route_t; class moduleSelfServeRoute { use route_t; + private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions'; + public function run(): void { global /** @var response $response */ @@ -50,10 +62,389 @@ class moduleSelfServeRoute ] ); + $this->put('/modules/self-serve/lane/status', function () { + global $response; + + self::requirePermission('modules_selfserve_lane_status_set'); + self::requireParameters(['lane_id', 'enabled']); + $lane_id = (int)self::getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + + $department_lane = (new department_lanes_o())->select($lane_id); + if (!$department_lane->exists()) { + $response->error('Department lane not found', 404); + } + self::requireDepartmentAccess((int)$department_lane->department->value()); + + $enabled = $this->requestedBoolean('enabled'); + $target_status = $enabled ? selfserve_lane_status::AVAILABLE : selfserve_lane_status::MAINTENANCE; + $lane = (new selfserve())->lane($lane_id); + $lane->setLaneStatus($target_status); + + $user = (new authentication())->get_user(); + $machine_status_audit = [ + 'modified_at' => date(DATE_ATOM), + 'modified_by_user_id' => $user ? (int)$user->id : null, + 'modified_by_name' => $this->machineStatusAuditUserName($user), + ]; + $lane->setLaneStatusAudit($machine_status_audit); + + (new logs_o())->add( + 'selfserve', + 'global', + 1, + $user ? $user->id : 0, + 'SET_LANE_MACHINE_STATUS', + 'User set self-serve lane ' . $lane_id . ' machine status to ' . $target_status->name + ); + + $status = (string)$lane->getLaneStatus()->name; + $response->success([ + 'id' => $lane->id, + 'status' => $status, + 'machine_status_enabled' => department_lanes_o::isOperationalStatusName($status), + 'machine_status_audit' => $machine_status_audit, + 'machine_status_modified_at' => $machine_status_audit['modified_at'], + 'machine_status_modified_by' => $machine_status_audit['modified_by_name'], + 'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'], + 'lane' => $department_lane->asArray(), + ]); + }, + [ + 'modules_selfserve_lane_status_set' => 'Set self-serve lane machine status', + ] + ); + + /** Modules > Self Serve > Lane > Wash > In-progress details */ + $this->get('/modules/self-serve/lane/wash/in-progress', function () { + global $response; + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $customer_scope = $this->requireInProgressWashDetailsAccess(); + + $build_customer = static function (?int $customer_number): ?array { + if ($customer_number === null || $customer_number <= 0) { + return null; + } + + $customer_obj = (new users_o())->getUserByCustomerNumber($customer_number); + if ($customer_obj->exists()) { + return [ + 'id' => (int)$customer_obj->id, + 'customer_number' => $customer_number, + 'display_name' => $customer_obj->display_name->value() === null ? null : (string)$customer_obj->display_name->value(), + 'email' => $customer_obj->email->value() === null ? null : (string)$customer_obj->email->value(), + 'phone_country_code' => $customer_obj->phone_country_code->value() === null ? null : (int)$customer_obj->phone_country_code->value(), + 'phone' => $customer_obj->phone->value() === null ? null : (string)$customer_obj->phone->value(), + ]; + } + + return [ + 'id' => null, + 'customer_number' => $customer_number, + 'display_name' => null, + 'email' => null, + 'phone_country_code' => null, + 'phone' => null, + ]; + }; + + $build_vehicle = static function (?int $vehicle_id, ?string $reg): ?array { + $vehicle_obj = null; + if ($vehicle_id !== null && $vehicle_id > 0) { + $tmp_vehicle = (new customer_vehicles_o())->select($vehicle_id); + if ($tmp_vehicle->exists()) { + $vehicle_obj = $tmp_vehicle; + } + } + if ($vehicle_obj === null && $reg !== null && trim($reg) !== '') { + $tmp_vehicle = (new customer_vehicles_o())->selectByPlate(trim($reg)); + if ($tmp_vehicle->exists()) { + $vehicle_obj = $tmp_vehicle; + } + } + if ($vehicle_obj === null) { + return null; + } + + return [ + 'id' => (int)$vehicle_obj->id, + 'customer_id' => (int)$vehicle_obj->customer_id->value(), + 'type' => (int)$vehicle_obj->type->value(), + 'reg' => (string)$vehicle_obj->reg->value(), + 'reference' => $vehicle_obj->reference->value() === null ? null : (string)$vehicle_obj->reference->value(), + ]; + }; + + $format_wash_started_at = static function (?int $wash_start_time): ?string { + if ($wash_start_time === null || $wash_start_time <= 0) { + return null; + } + + return date('Y-m-d H:i:s', $wash_start_time); + }; + $selfserve = new selfserve(); + $included_minutes_when_machine_enabled = (int)$selfserve + ->config + ->machine_wash_minutes_included + ->getVariableValue(); + if ($included_minutes_when_machine_enabled < 0) { + $included_minutes_when_machine_enabled = 0; + } + + $rows = (new selfserve_wash_sessions_o())->getFieldsWhere([ + 'lane_id' => $lane_id, + 'completed_at' => null, + 'deleted_at' => null, + ], ['id']); + $session = null; + if ($rows !== []) { + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $tmp_session = (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']); + if ($tmp_session->exists()) { + $session = $tmp_session; + } + } + + if ($session === null) { + $lane = $selfserve->lane($lane_id); + $lane_status = $lane->getLaneStatus(); + $lane_state = $lane->getLaneState(); + $wash_started_at = $format_wash_started_at($lane->getWashStartTime()); + $machine_start_triggered = $wash_started_at !== null; + $machine_relay_enabled = $machine_start_triggered + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED) + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON) + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH) + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN_QUEUED) + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN); + $included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null; + $in_progress = $lane_status->equals(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED) + || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); + + if (!$in_progress) { + $response->success($this->scopeInProgressWashResponseForCustomer([ + 'lane_id' => $lane_id, + 'in_progress' => false, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ], $customer_scope)); + return; + } + + $runtime_customer_number = (int)$lane->getCustomerNumber(); + if ($runtime_customer_number <= 0) { + $runtime_customer_number = null; + } + $runtime_reg = trim((string)$lane->getLicensePlate()); + if ($runtime_reg === '') { + $runtime_reg = null; + } + + $customer = $build_customer($runtime_customer_number); + $vehicle = $build_vehicle(null, $runtime_reg); + $response->success($this->scopeInProgressWashResponseForCustomer([ + 'lane_id' => $lane_id, + 'in_progress' => true, + 'session' => [ + 'id' => null, + 'status' => $lane_state->name, + 'reg' => $runtime_reg, + 'customer_number' => $runtime_customer_number, + 'vehicle_id' => $vehicle['id'] ?? null, + 'vehicle_type_id' => $vehicle['type'] ?? null, + 'included_minutes' => $included_minutes, + 'machine_type_id' => null, + 'machine_relay_enabled' => $machine_relay_enabled, + 'machine_relay_enabled_at' => $machine_relay_enabled ? $wash_started_at : null, + 'machine_start_triggered' => $machine_start_triggered, + 'machine_start_triggered_at' => $wash_started_at, + 'wash_started_at' => $wash_started_at, + 'created_at' => null, + 'updated_at' => null, + ], + 'customer' => $customer, + 'vehicle' => $vehicle, + ], $customer_scope)); + return; + } + + $customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value(); + $vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value(); + $session_reg = trim((string)$session->reg->value()); + if ($session_reg === '') { + $session_reg = null; + } + $customer = $build_customer($customer_number); + $vehicle = $build_vehicle($vehicle_id, $session_reg); + $machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value(); + $wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value(); + if ($wash_started_at === null) { + $wash_started_at = $machine_start_triggered_at; + } + if ($wash_started_at === null) { + $wash_started_at = $format_wash_started_at($selfserve->lane($lane_id)->getWashStartTime()); + } + $machine_relay_enabled = (bool)$session->machine_relay_enabled->value(); + $included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null; + $status = $session->status->value(); + $in_progress_statusses = [ + selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value, + selfserve_wash_session_status::READY_FOR_MACHINE_START->value, + selfserve_wash_session_status::MACHINE_STARTED->value, + selfserve_wash_session_status::PENDING_QUESTIONS->value, + selfserve_wash_session_status::MACHINE_NOT_ALLOWED->value + ]; + $in_progress = in_array($status, $in_progress_statusses); + + $response->success($this->scopeInProgressWashResponseForCustomer([ + 'lane_id' => $lane_id, + 'status' => (string)$session->status->value(), + 'in_progress' => $in_progress, + 'elapsed_minutes' => $session->getElapsedMinutes(), + 'session' => [ + 'id' => (int)$session->id, + 'status' => (string)$session->status->value(), + 'reg' => $session_reg, + 'customer_number' => $customer_number, + 'vehicle_id' => $vehicle_id, + 'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), + 'included_minutes' => $included_minutes ?? 0, + 'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(), + 'machine_relay_enabled' => $machine_relay_enabled, + 'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(), + 'machine_start_triggered' => (bool)$session->machine_start_triggered->value(), + 'machine_start_triggered_at' => $machine_start_triggered_at, + 'wash_started_at' => $wash_started_at, + 'created_at' => (string)$session->created_at->value(), + 'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(), + ], + 'customer' => $customer, + 'vehicle' => $vehicle, + ], $customer_scope)); + }, + [ + 'modules_selfserve_lane_wash_in_progress_view' => 'View customer and vehicle details for an in-progress self-serve wash on a lane', + 'list_own_department_selfserve_vehicle_conditions' => 'View in-progress self-serve wash details for the authenticated customer', + ] + ); + + /** Modules > Self Serve > Sessions */ + $this->get('/modules/self-serve/sessions', function () { + global $response; + self::requirePermission('modules_selfserve_sessions_view'); + + $sessions = new selfserve_wash_sessions_o(); + $sessions->setSearchableFields([ + 'id', + 'lane_id', + 'department_id', + 'machine_type_id', + 'customer_number', + 'vehicle_id', + 'vehicle_type_id', + 'reg', + 'status', + 'order_id', + 'created_at', + 'completed_at', + ]); + + $additional_where = null; + if ($this->requestedBoolean('open_only', false) || $this->requestedBoolean('active_only', false)) { + $additional_where = '`completed_at` IS NULL AND `status` NOT IN (' . selfserve_wash_sessions_o::terminalStatusSqlList() . ')'; + } + + $response->success($sessions->listObjectsWithPaginationIfSet( + function (array $row): array { + $session = (new selfserve_wash_sessions_o())->select((int)$row['id']); + if (!$session->exists()) { + return $row; + } + + return [ + ...$session->asArray(), + 'elapsed_minutes' => $session->getElapsedMinutes(), + 'open' => $session->isOpen(), + ]; + }, + null, + [], + $additional_where + )); + }, + [ + 'modules_selfserve_sessions_view' => 'View self-serve wash sessions', + ] + ); + + /** Modules > Self Serve > Session detail */ + $this->get('/modules/self-serve/sessions/{id}', function () { + global $response; + self::requirePermission('modules_selfserve_sessions_view'); + $session_id = (int)$this->fromRoute('id'); + self::requireType($session_id, self::type_int()); + self::requireMinValue($session_id, 1); + + try { + $response->success((new selfserve_wash_flow())->getSessionSummary($session_id)); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 404); + } + }, + [ + 'modules_selfserve_sessions_view' => 'View self-serve wash session details', + ] + ); + + /** Modules > Self Serve > Lane > Force > Stop */ + $this->post('/modules/self-serve/lane/force/stop', function () { + global $response; + self::requirePermission('modules_selfserve_sessions_force_stop'); + self::requireParameters(['lane_id', 'bill']); + + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + + $session_id = null; + if ($this->isParametersSet(['session_id']) && $this->getParameter('session_id') !== null && $this->getParameter('session_id') !== '') { + $session_id = (int)$this->getParameter('session_id'); + self::requireType($session_id, self::type_int()); + self::requireMinValue($session_id, 1); + } + + $bill = $this->requestedBoolean('bill'); + if ($bill) { + self::requirePermission('modules_selfserve_sessions_force_stop_bill'); + } + $reason = null; + if ($this->isParametersSet(['reason'])) { + $reason = trim((string)$this->getParameter('reason')); + $reason = $reason === '' ? null : $reason; + } + $user = (new authentication())->get_user(); + $user_id = $user instanceof users_o ? (int)$user->id : null; + + try { + $response->success((new selfserve_wash_flow())->forceStopLane($lane_id, $session_id, $bill, $reason, $user_id)); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 409); + } + }, + [ + 'modules_selfserve_sessions_force_stop' => 'Force stop a self-serve wash session without relay or gate signaling', + 'modules_selfserve_sessions_force_stop_bill' => 'Bill elapsed minutes when force stopping a self-serve wash session', + ] + ); + /** Modules > Self Serve > Lane > Command */ $this->post('/modules/self-serve/lane/command', function () { global $response; - self::requirePermission('modules_selfserve_lane_command_execute'); $selfserve = new selfserve(); // Get the request user $user = (new authentication())->get_user(); @@ -81,30 +472,67 @@ class moduleSelfServeRoute if ($command === null) { $response->error("Invalid command: " . $commandParam); } + $customer_number = $this->resolveEffectiveCustomerNumber(); + $customer_number = $customer_number === null ? 0 : (int)$customer_number; // Require permissions for specific commands switch ($command) { case selfserve_lane_command::START: - self::requirePermission('modules_selfserve_lane_command_execute_start'); + $this->requireSelfServeLaneCommandPermission( + $lane, + $customer_number, + 'modules_selfserve_lane_command_execute_start', + false + ); break; case selfserve_lane_command::STOP: - self::requirePermission('modules_selfserve_lane_command_execute_stop'); + $this->requireSelfServeLaneCommandPermission( + $lane, + $customer_number, + 'modules_selfserve_lane_command_execute_stop', + true, + true + ); break; case selfserve_lane_command::RESERVE: - self::requirePermission('modules_selfserve_lane_command_execute_reserve'); + $this->requireSelfServeLaneCommandPermission( + $lane, + $customer_number, + 'modules_selfserve_lane_command_execute_reserve', + false + ); break; case selfserve_lane_command::RELEASE: - self::requirePermission('modules_selfserve_lane_command_execute_release'); + $this->requireSelfServeLaneCommandPermission( + $lane, + $customer_number, + 'modules_selfserve_lane_command_execute_release', + false + ); break; case selfserve_lane_command::RESET: - self::requirePermission('modules_selfserve_lane_command_execute_reset'); + $this->requireSelfServeLaneCommandPermission( + $lane, + $customer_number, + 'modules_selfserve_lane_command_execute_reset', + false + ); + break; + case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE: + self::requirePermission('modules_selfserve_lane_command_execute_open_property_access_gate'); + break; + case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE: + self::requirePermission('modules_selfserve_lane_command_execute_open_property_exit_gate'); break; } // Execute the command try { + $this->applyShellyTransportOverride($lane); + $subuser = (new authentication())->get_subuser(); $args = new \modules\selfserve\classes\selfserve_lane_command_arguments(); $args->setParameters([ ...$this->getParametersAsArray(), // Pass all parameters - 'customer_number' => (int)$user->customer_number->value(), // Get customer number from request user + 'customer_number' => $customer_number, // Get customer number from request user + 'subuser_id' => $subuser === false ? null : (int)$subuser->id, ]); $lane->execute($command, $args); $response->success([ @@ -116,8 +544,18 @@ class moduleSelfServeRoute 'elapsed_wash_time' => $lane->getElapsedWashTime(), 'license_plate' => $lane->getLicensePlate(), 'customer_number' => $lane->getCustomerNumber(), + 'transport' => $this->requestedShellyTransportOverride(), ]); } catch (\Exception $e) { + if ( + $command === selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE || + $command === selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE + ) { + error_log( + 'Failed to execute self-serve property gate command ' . $command->name . + ' for lane ' . $lane_id . ': ' . $e->getMessage() + ); + } $response->error("Failed to execute command: " . $e->getMessage()); } }, @@ -128,6 +566,8 @@ class moduleSelfServeRoute 'modules_selfserve_lane_command_execute_reserve' => 'Execute self-serve lane RESERVE command', 'modules_selfserve_lane_command_execute_release' => 'Execute self-serve lane RELEASE command (Release the lane reservation and reset its state)', 'modules_selfserve_lane_command_execute_reset' => 'Execute self-serve lane RESET command', + 'modules_selfserve_lane_command_execute_open_property_access_gate' => 'Execute self-serve lane OPEN_PROPERTY_ACCESS_GATE command', + 'modules_selfserve_lane_command_execute_open_property_exit_gate' => 'Execute self-serve lane OPEN_PROPERTY_EXIT_GATE command', 'modules_selfserve_lane_command_bypass_customer_number_validation' => 'Bypass customer number validation when executing commands', ] ); @@ -135,7 +575,6 @@ class moduleSelfServeRoute /** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */ $this->post('/modules/self-serve/lane/services/allowed', function () { global $response; - self::requirePermission('modules_selfserve_lane_services_set_allowed'); $selfserve = new selfserve(); // Validate parameters self::requireParameters(['lane_id']); @@ -158,6 +597,8 @@ class moduleSelfServeRoute $task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param))); // Build allowed services from provided tasks $lane = $selfserve->lane($lane_id); + $customer_number = $this->resolveEffectiveCustomerNumber(); + self::requirePermission('modules_selfserve_lane_services_set_allowed'); $allowed_services = []; foreach ($task_ids as $tid) { if ($tid <= 0) continue; @@ -176,16 +617,242 @@ class moduleSelfServeRoute } } // Persist on lane cache (overwrites previous allowed services) - $lane->setLaneCache($lane_id, $lane::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $allowed_services); - $response->success(['lane_id' => $lane_id, 'allowed_services' => $allowed_services]); + try { + $relay_sync = $lane->setAllowedServicesFromVisibleTasks($allowed_services); + $response->success([ + 'lane_id' => $lane_id, + 'allowed_services' => $allowed_services, + 'relay_sync' => $relay_sync, + 'transport' => $this->requestedShellyTransportOverride(), + ]); + } catch (\Exception $e) { + $response->error('Failed to set allowed services: ' . $e->getMessage(), 400); + } }, [ 'modules_selfserve_lane_services_set_allowed' => 'Set allowed services for a lane based on currently shown tasks (post-Q&A)' ]); - /** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */ - $this->post('/modules/self-serve/lane/relay/machine/enable', function () { + /** Modules > Self Serve > Lane > Gate > Open (ENTRANCE/EXIT) */ + $this->post('/modules/self-serve/lane/gate/open', function () { global $response; - self::requirePermission('modules_selfserve_lane_relay_enable_machine'); + self::requirePermission('modules_selfserve_lane_gate_open'); + $selfserve = new selfserve(); + self::requireParameters(['lane_id', 'gate']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + + $gate_name = strtoupper(trim((string)$this->getParameter('gate'))); + $gate = match ($gate_name) { + 'ENTRANCE' => selfserve_lane_port::ENTRANCE, + 'EXIT' => selfserve_lane_port::EXIT, + default => null, + }; + if ($gate === null) { + $response->error('Invalid gate value. Expected ENTRANCE or EXIT.', 400); + } + $toggle_after = $this->requestedRelayToggleAfter(1); + + $lane = $selfserve->lane($lane_id); + try { + $this->applyShellyTransportOverride($lane); + $lane->open($gate, $toggle_after); + $response->success([ + 'lane_id' => $lane_id, + 'gate' => $gate->name, + 'opened' => true, + 'toggle_after' => $toggle_after, + 'state' => $lane->getLaneState()->name, + 'transport' => $this->requestedShellyTransportOverride(), + ]); + } catch (\Exception $e) { + error_log('Failed to open self-serve lane gate ' . $gate->name . ' for lane ' . $lane_id . ': ' . $e->getMessage()); + $response->error('Failed to open ' . strtolower($gate->name) . ' gate.', 400); + } + }, [ + 'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane' + ]); + + /** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */ + $this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () { + global $response; + self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view'); + $selfserve = new selfserve(); + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $lane = $selfserve->lane($lane_id); + + try { + $this->applyShellyTransportOverride($lane); + $status = $lane->getMachineProgramPickerRelayStatus(); + $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_PROGRAM_PICKER', $status)); + } catch (\Exception $e) { + $response->error('Failed to get MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_relay_machine_program_picker_status_view' => 'Get MACHINE_PROGRAM_PICKER relay status for a lane' + ]); + + /** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER set on/off */ + $this->post('/modules/self-serve/lane/relay/machine_program_picker/set', function () { + global $response; + self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set'); + $selfserve = new selfserve(); + self::requireParameters(['lane_id', 'on']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + + $raw_on = $this->getParameter('on'); + $on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($on === null) { + $response->error('Invalid on value. Expected boolean true/false.', 400); + } + + $lane = $selfserve->lane($lane_id); + try { + $this->applyShellyTransportOverride($lane); + $lane->setMachineProgramPickerRelayStatus((bool)$on); + $status = $lane->getMachineProgramPickerRelayStatus(); + $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_PROGRAM_PICKER', $status, [ + 'requested_on' => (bool)$on, + ])); + } catch (\Exception $e) { + $response->error('Failed to set MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_relay_machine_program_picker_status_set' => 'Set MACHINE_PROGRAM_PICKER relay status (on/off) for a lane' + ]); + + /** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER status */ + $this->get('/modules/self-serve/lane/relay/machine_cleaner/status', function () { + global $response; + self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view'); + $selfserve = new selfserve(); + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $lane = $selfserve->lane($lane_id); + + try { + $this->applyShellyTransportOverride($lane); + $status = $lane->getMachineCleanerRelayStatus(); + $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_CLEANER', $status)); + } catch (\Exception $e) { + $response->error('Failed to get MACHINE_CLEANER relay status: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_relay_machine_cleaner_status_view' => 'Get MACHINE_CLEANER relay status for a lane' + ]); + + /** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER set on/off */ + $this->post('/modules/self-serve/lane/relay/machine_cleaner/set', function () { + global $response; + self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set'); + $selfserve = new selfserve(); + self::requireParameters(['lane_id', 'on']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + + $raw_on = $this->getParameter('on'); + $on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($on === null) { + $response->error('Invalid on value. Expected boolean true/false.', 400); + } + + $lane = $selfserve->lane($lane_id); + try { + $this->applyShellyTransportOverride($lane); + $lane->setMachineCleanerRelayStatus((bool)$on); + $status = $lane->getMachineCleanerRelayStatus(); + $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_CLEANER', $status, [ + 'requested_on' => (bool)$on, + ])); + } catch (\Exception $e) { + $response->error('Failed to set MACHINE_CLEANER relay status: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_relay_machine_cleaner_status_set' => 'Set MACHINE_CLEANER relay status (on/off) for a lane' + ]); + + /** Modules > Self Serve > Lane > Relay > MACHINE status */ + $this->get('/modules/self-serve/lane/relay/machine/status', function () { + global $response; + self::requirePermission('modules_selfserve_lane_relay_machine_status_view'); + $selfserve = new selfserve(); + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $lane = $selfserve->lane($lane_id); + + try { + $this->applyShellyTransportOverride($lane); + $status = $lane->getMachineRelayStatus(); + $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE', $status)); + } catch (\Exception $e) { + $response->error('Failed to get MACHINE relay status: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_relay_machine_status_view' => 'Get MACHINE relay status for a lane' + ]); + + /** Modules > Self Serve > Lane > Relay > MACHINE set on/off */ + $this->post('/modules/self-serve/lane/relay/machine/set', function () { + global $response; + self::requirePermission('modules_selfserve_lane_relay_machine_status_set'); + $selfserve = new selfserve(); + self::requireParameters(['lane_id', 'on']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + + $raw_on = $this->getParameter('on'); + $on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($on === null) { + $response->error('Invalid on value. Expected boolean true/false.', 400); + } + + $lane = $selfserve->lane($lane_id); + try { + $this->applyShellyTransportOverride($lane); + $lane->setMachineRelayStatus((bool)$on); + // Keep lane cache state aligned with the latest explicit relay action + try { + $machine_relay_id = ''; + if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) { + $machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value()); + } + $is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-'); + $lane->setLaneState((bool)$on + ? ($is_demo_machine_relay + ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED + : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON) + : ($is_demo_machine_relay + ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED + : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF) + ); + } catch (\Throwable $ignored) {} + + $status = $lane->getMachineRelayStatus(); + $response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE', $status, [ + 'requested_on' => (bool)$on, + ])); + } catch (\Exception $e) { + $response->error('Failed to set MACHINE relay status: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_relay_machine_status_set' => 'Set MACHINE relay status (on/off) for a lane' + ]); + + /** Modules > Self Serve > Lane > Relay > Enable MACHINE_PROGRAM_PICKER (manual) */ + $this->post('/modules/self-serve/lane/relay/machine_program_picker/enable', function () { + global $response; + self::requirePermission('modules_selfserve_lane_relay_enable_machine_program_picker'); $selfserve = new selfserve(); // Validate parameters self::requireParameters(['lane_id']); @@ -199,8 +866,92 @@ class moduleSelfServeRoute } $lane = $selfserve->lane($lane_id); try { + $this->applyShellyTransportOverride($lane); + $lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration); + $response->success([ + 'lane_id' => $lane_id, + 'relay' => 'MACHINE_PROGRAM_PICKER', + 'enabled' => true, + 'duration' => $duration, + 'transport' => $this->requestedShellyTransportOverride(), + ]); + } catch (\Exception $e) { + $response->error('Failed to enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_relay_enable_machine_program_picker' => 'Manually enable MACHINE_PROGRAM_PICKER relay for a lane' + ]); + + /** Modules > Self Serve > Lane > Relay > Enable MACHINE_CLEANER (manual) */ + $this->post('/modules/self-serve/lane/relay/machine_cleaner/enable', function () { + global $response; + self::requirePermission('modules_selfserve_lane_relay_enable_machine_cleaner'); + $selfserve = new selfserve(); + // Validate parameters + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $duration = null; + if ($this->isParametersSet(['duration'])) { + $duration = (int)$this->getParameter('duration'); + self::requireMinValue($duration, 1); + } + $lane = $selfserve->lane($lane_id); + try { + $this->applyShellyTransportOverride($lane); + $lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration); + $response->success([ + 'lane_id' => $lane_id, + 'relay' => 'MACHINE_CLEANER', + 'enabled' => true, + 'duration' => $duration, + 'transport' => $this->requestedShellyTransportOverride(), + ]); + } catch (\Exception $e) { + $response->error('Failed to enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_relay_enable_machine_cleaner' => 'Manually enable MACHINE_CLEANER relay for a lane' + ]); + + /** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */ + $this->post('/modules/self-serve/lane/relay/machine/enable', function () { + global $response; + $selfserve = new selfserve(); + // Validate parameters + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $duration = null; + if ($this->isParametersSet(['duration'])) { + $duration = (int)$this->getParameter('duration'); + self::requireMinValue($duration, 1); + } + $lane = $selfserve->lane($lane_id); + $customer_number = $this->resolveEffectiveCustomerNumber(); + self::requirePermission('modules_selfserve_lane_relay_enable_machine'); + try { + $this->applyShellyTransportOverride($lane); $lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration); - $response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE', 'enabled' => true, 'duration' => $duration]); + // A started wash should also turn on cleaner when configured. + try { + if ( + !empty($lane->department_lane) + && !empty($lane->department_lane->relay_machine_cleaner_id) + && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== '' + ) { + $lane->setMachineCleanerRelayStatusHard(true); + } + } catch (\Throwable $ignored) {} + $response->success([ + 'lane_id' => $lane_id, + 'relay' => 'MACHINE', + 'enabled' => true, + 'duration' => $duration, + 'transport' => $this->requestedShellyTransportOverride(), + ]); } catch (\Exception $e) { $msg = $e->getMessage(); // If gating prevented activation, respond with 403 @@ -213,6 +964,126 @@ class moduleSelfServeRoute 'modules_selfserve_lane_relay_enable_machine' => 'Manually enable MACHINE relay for a lane (requires allowed task to be present)' ]); + /** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER enable */ + $this->post('/modules/self-serve/lane/force/machine_program_picker/enable', function () { + global $response; + self::requirePermission('modules_selfserve_lane_force_machine_program_picker_enable'); + $selfserve = new selfserve(); + // Validate parameters + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $duration = null; + if ($this->isParametersSet(['duration'])) { + $duration = (int)$this->getParameter('duration'); + self::requireMinValue($duration, 1); + } + $lane = $selfserve->lane($lane_id); + try { + $this->applyShellyTransportOverride($lane); + $lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration); + $response->success([ + 'lane_id' => $lane_id, + 'relay' => 'MACHINE_PROGRAM_PICKER', + 'enabled' => true, + 'duration' => $duration, + 'transport' => $this->requestedShellyTransportOverride(), + ]); + } catch (\Exception $e) { + $response->error('Failed to force enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_force_machine_program_picker_enable' => 'Force enable MACHINE_PROGRAM_PICKER relay for a lane' + ]); + + /** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER disable */ + $this->post('/modules/self-serve/lane/force/machine_program_picker/disable', function () { + global $response; + self::requirePermission('modules_selfserve_lane_force_machine_program_picker_disable'); + $selfserve = new selfserve(); + // Validate parameters + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $lane = $selfserve->lane($lane_id); + try { + $this->applyShellyTransportOverride($lane); + $lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER); + $response->success([ + 'lane_id' => $lane_id, + 'relay' => 'MACHINE_PROGRAM_PICKER', + 'disabled' => true, + 'transport' => $this->requestedShellyTransportOverride(), + ]); + } catch (\Exception $e) { + $response->error('Failed to force disable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_force_machine_program_picker_disable' => 'Force disable MACHINE_PROGRAM_PICKER relay for a lane' + ]); + + /** Modules > Self Serve > Lane > Force > MACHINE_CLEANER enable */ + $this->post('/modules/self-serve/lane/force/machine_cleaner/enable', function () { + global $response; + self::requirePermission('modules_selfserve_lane_force_machine_cleaner_enable'); + $selfserve = new selfserve(); + // Validate parameters + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $duration = null; + if ($this->isParametersSet(['duration'])) { + $duration = (int)$this->getParameter('duration'); + self::requireMinValue($duration, 1); + } + $lane = $selfserve->lane($lane_id); + try { + $this->applyShellyTransportOverride($lane); + $lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration); + $response->success([ + 'lane_id' => $lane_id, + 'relay' => 'MACHINE_CLEANER', + 'enabled' => true, + 'duration' => $duration, + 'transport' => $this->requestedShellyTransportOverride(), + ]); + } catch (\Exception $e) { + $response->error('Failed to force enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_force_machine_cleaner_enable' => 'Force enable MACHINE_CLEANER relay for a lane' + ]); + + /** Modules > Self Serve > Lane > Force > MACHINE_CLEANER disable */ + $this->post('/modules/self-serve/lane/force/machine_cleaner/disable', function () { + global $response; + self::requirePermission('modules_selfserve_lane_force_machine_cleaner_disable'); + $selfserve = new selfserve(); + // Validate parameters + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + $lane = $selfserve->lane($lane_id); + try { + $this->applyShellyTransportOverride($lane); + $lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_CLEANER); + $response->success([ + 'lane_id' => $lane_id, + 'relay' => 'MACHINE_CLEANER', + 'disabled' => true, + 'transport' => $this->requestedShellyTransportOverride(), + ]); + } catch (\Exception $e) { + $response->error('Failed to force disable MACHINE_CLEANER relay: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_force_machine_cleaner_disable' => 'Force disable MACHINE_CLEANER relay for a lane' + ]); + /** Modules > Self Serve > Lane > Force > MACHINE enable (simulate started wash) */ $this->post('/modules/self-serve/lane/force/machine/enable', function () { global $response; @@ -231,6 +1102,7 @@ class moduleSelfServeRoute $license_plate = $this->isParametersSet(['license_plate']) ? (string)$this->getParameter('license_plate') : null; $lane = $selfserve->lane($lane_id); try { + $this->applyShellyTransportOverride($lane); // If lane looks idle/available, mark as occupied and in wash, set timers if ($lane->getLaneStatus()->equals(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE)) { $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED); @@ -244,10 +1116,31 @@ class moduleSelfServeRoute if ((int)$lane->getWashStartTime() <= 0) { $lane->setWashStartTime(time()); } + // A started wash should always have cleaner relay enabled when configured. + try { + if ( + !empty($lane->department_lane) + && !empty($lane->department_lane->relay_machine_cleaner_id) + && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== '' + ) { + $lane->setMachineCleanerRelayStatusHard(true); + } + } catch (\Throwable $ignored) {} // Force enable the machine relay (bypass gating) $lane->forceTurnOnMachineRelay($duration); // Reflect relay state explicitly - try { $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON); } catch (\Throwable $ignored) {} + try { + $machine_relay_id = ''; + if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) { + $machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value()); + } + $is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-'); + $lane->setLaneState( + $is_demo_machine_relay + ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED + : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON + ); + } catch (\Throwable $ignored) {} $response->success([ 'lane_id' => $lane_id, 'forced' => true, @@ -256,6 +1149,7 @@ class moduleSelfServeRoute 'status' => $lane->getLaneStatus()->name, 'state' => $lane->getLaneState()->name, 'wash_start_time' => $lane->getWashStartTime(), + 'transport' => $this->requestedShellyTransportOverride(), ]); } catch (\Exception $e) { $response->error('Failed to force-enable MACHINE: ' . $e->getMessage(), 400); @@ -277,6 +1171,7 @@ class moduleSelfServeRoute $license_plate = $this->isParametersSet(['license_plate']) ? (string)$this->getParameter('license_plate') : null; $lane = $selfserve->lane($lane_id); try { + $this->applyShellyTransportOverride($lane); // Ensure lane reflects an ongoing wash, but with machine off if ($lane->getLaneStatus()->equals(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE)) { $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED); @@ -290,10 +1185,31 @@ class moduleSelfServeRoute if ((int)$lane->getWashStartTime() <= 0) { $lane->setWashStartTime(time()); } + // A started wash should always have cleaner relay enabled when configured. + try { + if ( + !empty($lane->department_lane) + && !empty($lane->department_lane->relay_machine_cleaner_id) + && trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== '' + ) { + $lane->setMachineCleanerRelayStatusHard(true); + } + } catch (\Throwable $ignored) {} // Turn off the machine relay (do not swallow errors) $lane->forceTurnOffMachineRelay(); // Reflect relay state explicitly - try { $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF); } catch (\Throwable $ignored) {} + try { + $machine_relay_id = ''; + if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) { + $machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value()); + } + $is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-'); + $lane->setLaneState( + $is_demo_machine_relay + ? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED + : \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF + ); + } catch (\Throwable $ignored) {} $response->success([ 'lane_id' => $lane_id, 'forced' => true, @@ -301,6 +1217,7 @@ class moduleSelfServeRoute 'status' => $lane->getLaneStatus()->name, 'state' => $lane->getLaneState()->name, 'wash_start_time' => $lane->getWashStartTime(), + 'transport' => $this->requestedShellyTransportOverride(), ]); } catch (\Exception $e) { $response->error('Failed to force-disable MACHINE: ' . $e->getMessage(), 400); @@ -309,4 +1226,420 @@ class moduleSelfServeRoute 'modules_selfserve_lane_force_machine_disable' => 'Force disable MACHINE relay but keep lane as in-wash (superusers only)' ]); } + + private function requireInProgressWashDetailsAccess(): ?int + { + global $response; + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Authentication failed. Invalid or missing token.', 401); + } + + if (self::hasPermission('modules_selfserve_lane_wash_in_progress_view')) { + return null; + } + + if (self::hasPermission('list_own_department_selfserve_vehicle_conditions')) { + $customer_number = $this->resolveEffectiveCustomerNumber(); + if ($customer_number !== null && $customer_number > 0) { + return (int)$customer_number; + } + } + + $this->emitForbidden([ + 'modules_selfserve_lane_wash_in_progress_view', + 'list_own_department_selfserve_vehicle_conditions', + ]); + return null; + } + + /** + * Customers poll all visible lanes to restore their own active wash. Keep that + * poll successful without exposing another customer's session details. + * + * @param array $payload + * @return array + */ + protected function scopeInProgressWashResponseForCustomer(array $payload, ?int $customer_number): array + { + if ($customer_number === null || $customer_number <= 0 || ($payload['in_progress'] ?? false) !== true) { + return $payload; + } + + $session_customer_number = $this->extractInProgressWashCustomerNumber($payload); + if ($session_customer_number === $customer_number) { + return $payload; + } + + return [ + 'lane_id' => (int)($payload['lane_id'] ?? 0), + 'in_progress' => true, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]; + } + + /** + * @param array $payload + */ + private function extractInProgressWashCustomerNumber(array $payload): ?int + { + $candidates = [ + $payload['session']['customer_number'] ?? null, + $payload['customer']['customer_number'] ?? null, + ]; + + foreach ($candidates as $candidate) { + if ($candidate === null || $candidate === '') { + continue; + } + $customer_number = (int)$candidate; + if ($customer_number > 0) { + return $customer_number; + } + } + + return null; + } + + /** + * @param array $status + * @param array $extra + * @return array + */ + private function buildRelayStatusResponse(int $lane_id, string $relay, array $status, array $extra = []): array + { + $on = (bool)($status['on'] ?? false); + $switch_status = is_array($status['status'] ?? null) ? $status['status'] : []; + if (!isset($switch_status['switch:0']) || !is_array($switch_status['switch:0'])) { + $switch_status['switch:0'] = []; + } + $switch_status['switch:0']['output'] = (bool)($switch_status['switch:0']['output'] ?? $on); + + $payload = [ + 'lane_id' => $lane_id, + 'relay' => $relay, + ...$extra, + 'relay_id' => (string)($status['relay_id'] ?? ''), + 'online' => (bool)($status['online'] ?? false), + 'on' => $on, + 'status' => $switch_status, + 'transport' => $this->requestedShellyTransportOverride(), + ]; + + foreach (['binding', 'execution', 'raw'] as $metadata_key) { + if (isset($status[$metadata_key]) && is_array($status[$metadata_key]) && $status[$metadata_key] !== []) { + $payload[$metadata_key] = $status[$metadata_key]; + } + } + + return $payload; + } + + /** + * @throws \Exception + */ + private function applyShellyTransportOverride(object $lane): void + { + $transport = $this->requestedShellyTransportOverride(); + if ($transport === null) { + return; + } + + if (!method_exists($lane, 'setShellyTransportOverride')) { + throw new \Exception('This lane does not support Shelly transport overrides.'); + } + + $lane->setShellyTransportOverride($transport); + } + + /** + * @param array $permissions + */ + private function hasAllPermissions(array $permissions): bool + { + foreach ($permissions as $permission) { + if (!$this->hasPermission($permission)) { + return false; + } + } + + return true; + } + + /** + * @param array $elevated_permissions + */ + private function requireSelfServeLaneAccess( + selfserve_lane $lane, + int $customer_number, + array $elevated_permissions, + bool $requires_active_wash = false, + bool $requires_operational_lane = true + ): void { + if ($this->hasAllPermissions($elevated_permissions)) { + return; + } + + if ($requires_active_wash) { + $customer_allowed = $this->canCustomerUseActiveSelfServeLane($lane, $customer_number) + && (!$requires_operational_lane || $this->isLaneSelfServeOperationallyEnabled($lane)); + } else { + $customer_allowed = $this->canCustomerUseSelfServeLane($lane, $customer_number); + } + + if ($customer_allowed) { + return; + } + + $this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]); + } + + private function requireSelfServeLaneCommandPermission( + selfserve_lane $lane, + int $customer_number, + string $command_permission, + bool $allow_customer_self_serve, + bool $requires_active_wash = false + ): void { + $elevated_permissions = [ + 'modules_selfserve_lane_command_execute', + $command_permission, + ]; + if ($this->hasAllPermissions($elevated_permissions)) { + return; + } + + if ($allow_customer_self_serve) { + $customer_allowed = $requires_active_wash + ? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number) + : $this->canCustomerUseSelfServeLane($lane, $customer_number); + + if ($customer_allowed) { + return; + } + } + + $this->emitForbidden( + $allow_customer_self_serve + ? [...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION] + : $elevated_permissions + ); + } + + private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void + { + $elevated_permissions = [ + 'modules_selfserve_lane_command_execute', + $permission, + ]; + if ($this->hasAllPermissions($elevated_permissions)) { + return; + } + + if ($this->canCustomerUsePropertyGateForLane($lane, $customer_number)) { + return; + } + + $this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]); + } + + protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool + { + return $customer_number > 0 + && $this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION) + && $this->isLaneSelfServeOperationallyEnabled($lane); + } + + protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool + { + if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) { + return false; + } + + try { + if ((int)$lane->getCustomerNumber() === $customer_number) { + return true; + } + } catch (\Throwable) { + // Fall back to the persisted session lookup below. + } + + $department_id = $this->departmentIdForLane($lane); + return $department_id > 0 + && $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number); + } + + protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool + { + return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number); + } + + protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool + { + try { + if (empty($lane->department_lane) || !$lane->department_lane->isSelfServeEnabled()) { + return false; + } + } catch (\Throwable) { + return false; + } + + $department_id = $this->departmentIdForLane($lane); + if ($department_id <= 0) { + return false; + } + + try { + $department = (new departments_o())->select($department_id); + return $department->exists() && $department->getSelfServeEnabled(); + } catch (\Throwable) { + return false; + } + } + + protected function departmentIdForLane(selfserve_lane $lane): int + { + try { + return (int)$lane->department_lane?->department?->value(); + } catch (\Throwable) { + return 0; + } + } + + protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool + { + if ($department_id <= 0 || $customer_number <= 0) { + return false; + } + + $active_statuses = array_map( + static fn(selfserve_wash_session_status $status): string => $status->value, + [ + selfserve_wash_session_status::MACHINE_RELAY_ENABLED, + selfserve_wash_session_status::READY_FOR_MACHINE_START, + selfserve_wash_session_status::MACHINE_STARTED, + selfserve_wash_session_status::PENDING_QUESTIONS, + selfserve_wash_session_status::MACHINE_NOT_ALLOWED, + ] + ); + + $sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([ + 'department_id' => $department_id, + 'customer_number' => $customer_number, + 'completed_at' => null, + 'deleted_at' => null, + ], ['id', 'status']); + + foreach ($sessions as $session) { + if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) { + return true; + } + } + + foreach ((new department_lanes_o())->getDepartmentLanes($department_id) as $department_lane) { + try { + $lane = (new selfserve())->lane((int)$department_lane->id); + if ((int)$lane->getCustomerNumber() !== $customer_number) { + continue; + } + + if ( + $lane->getLaneStatus()->equals(selfserve_lane_status::OCCUPIED) + || $lane->getLaneState()->equals(selfserve_lane_state::IN_WASH) + ) { + return true; + } + } catch (\Throwable) { + continue; + } + } + + return false; + } + private function requestedShellyTransportOverride(): ?string + { + $transport = null; + foreach (['transport', 'test_transport', 'transport_mode'] as $parameter) { + if (self::isParametersSet([$parameter])) { + $transport = self::getParameter($parameter); + break; + } + } + + $normalized = strtolower(trim((string)$transport)); + if ($normalized === '') { + return null; + } + + if ($normalized === 'gateway') { + return 'local'; + } + + if (!in_array($normalized, ['local', 'cloud'], true)) { + throw new \InvalidArgumentException('Invalid transport. Expected local, gateway, or cloud.'); + } + + return $normalized; + } + + private function requestedRelayToggleAfter(int $default_seconds): int + { + $toggle_after = $default_seconds; + foreach (['toggle_after', 'toggleAfter', 'timer'] as $parameter) { + if (self::isParametersSet([$parameter])) { + $toggle_after = (int)self::getParameter($parameter); + break; + } + } + + self::requireMinValue($toggle_after, 1); + return $toggle_after; + } + + private function machineStatusAuditUserName(?object $user): ?string + { + if (!$user) { + return null; + } + + foreach (['display_name', 'email'] as $property) { + if (!isset($user->{$property}) || !is_object($user->{$property}) || !method_exists($user->{$property}, 'value')) { + continue; + } + + $value = trim((string)$user->{$property}->value()); + if ($value !== '' && strtolower($value) !== 'unnamed') { + return $value; + } + } + + if (isset($user->customer_number) && is_object($user->customer_number) && method_exists($user->customer_number, 'value')) { + $customer_number = (int)$user->customer_number->value(); + if ($customer_number > 0) { + return 'Kunde ' . $customer_number; + } + } + + return isset($user->id) ? 'Bruger #' . (int)$user->id : null; + } + + private function requestedBoolean(string $parameter, bool $default = false): bool + { + if (!self::isParametersSet([$parameter])) { + return $default; + } + + $value = self::getParameter($parameter); + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return $value === 1; + } + $normalized = strtolower(trim((string)$value)); + return in_array($normalized, ['1', 'true', 'yes', 'on'], true); + } } diff --git a/services/nginx/app/routes/moduleStripeRoute.php b/services/nginx/app/routes/moduleStripeRoute.php index c3b6718e..7c1c1e5f 100644 --- a/services/nginx/app/routes/moduleStripeRoute.php +++ b/services/nginx/app/routes/moduleStripeRoute.php @@ -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'); @@ -272,6 +346,12 @@ class moduleStripeRoute $department = (new departments_o())->select((int)self::fromRequest('id')); $department->requireSelected(); (new logs_o())->add('modules_stripe', 'global', 1, 0, 'MODULES_STRIPE', 'User accessed the department terminal readers'); + if (!$department->isStripeTerminalLocationSet()) { + $response->error([ + 'message' => 'Card payments are not ready for this department. Open Stripe setup and choose a terminal location.', + 'code' => 'stripe_terminal_setup_required', + ], 409); + } // Get the department terminal readers $readers = $department->getStripeTerminalReaders(); // Return the result @@ -287,4 +367,4 @@ class moduleStripeRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php new file mode 100644 index 00000000..b57885ee --- /dev/null +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -0,0 +1,2449 @@ + 0, + 'healthy' => 1, + 'degraded' => 2, + 'unhealthy' => 3, + ]; + + public function run(): void + { + global /** @var response $response */ + /** @var router $router */ + $router, $response; + + $this->get('/modules/weatherapi/current', function () { + global $response; + self::requirePermission('modules_weatherapi_current'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['q']); + self::requireType('q', self::type_string()); + $result = (new weatherapi())->current(self::getParameter('q')); + (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Current weather request completed'); + $response->success($result, 200); + }, [ + 'modules_weatherapi_current' => 'Get current weather data from WeatherAPI', + ]); + + $this->get('/modules/weatherapi/forecast', function () { + global $response; + self::requirePermission('modules_weatherapi_forecast'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['q']); + self::requireType('q', self::type_string()); + $days = (int)(self::getParameter('days') ?? 1); + $result = (new weatherapi())->forecast(self::getParameter('q'), $days); + (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Forecast weather request completed'); + $response->success($result, 200); + }, [ + 'modules_weatherapi_forecast' => 'Get weather forecast data from WeatherAPI', + ]); + + $this->get('/modules/weatherapi/search', function () { + global $response; + self::requirePermission('modules_weatherapi_search'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['q']); + self::requireType('q', self::type_string()); + $result = (new weatherapi())->search(self::getParameter('q')); + (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Weather location search completed'); + $response->success($result, 200); + }, [ + 'modules_weatherapi_search' => 'Search location data from WeatherAPI', + ]); + + $this->get('/departments/weather', function () { + global $response; + self::requirePermission('departments_weather_get'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $department_ids = self::parseDepartmentIdsFromRequest(); + foreach ($department_ids as $department_id) { + self::requireDepartmentAccess((string)$department_id); + } + + $selected_date_range = self::parseSelectedTimelineDateRangeFromRequest(); + $timeline_range = self::getDepartmentWeatherTimelineRange( + $selected_date_range['date_from'] ?? null, + $selected_date_range['date_to'] ?? null + ); + $departments = self::loadDepartmentsByIds($department_ids); + $status_targets_by_department = $this->loadDepartmentWeatherTargetsByDepartmentId($departments); + $coordinates = self::resolveWeatherCoordinates($departments); + $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids); + $timeline = $this->withCachedDepartmentWeatherTimeline( + $department_ids, + $timeline_range, + function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user, $status_targets_by_department): array { + $weather_days = $this->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']); + $forecast_result = $this->fetchDepartmentForecastOrFallback($coordinates, $weather_days); + if (is_string($forecast_result['fallback_reason'])) { + $fallback_message = match ($forecast_result['fallback_reason']) { + 'invalid_department_coordinates' => 'Department weather fallback used due to missing or invalid department coordinates', + 'weatherapi_location_not_found' => 'Department weather fallback used because WeatherAPI could not resolve the selected department location', + default => 'Department weather fallback used due to location lookup failure', + }; + (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_FALLBACK', $fallback_message); + } + + return $this->buildDepartmentWeatherTimeline( + $department_ids, + $departments, + $forecast_result['forecast'], + $timeline_range, + $status_targets_by_department + ); + }, + true, + $status_targets_by_department + ); + + (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched'); + $response->success($timeline, 200); + }, [ + 'departments_weather_get' => 'Get department weather timeline with washes and productivity status', + 'department_access_:id' => 'Access weather timeline for one or more specific departments', + ]); + + $this->get('/departments/weather/hours/details', function () { + global $response; + self::requirePermission('departments_weather_get'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $department_ids = self::parseDepartmentIdsFromRequest(); + foreach ($department_ids as $department_id) { + self::requireDepartmentAccess((string)$department_id); + } + + $slot = self::parseDepartmentWeatherHourSlotFromRequest(); + $departments = self::loadDepartmentsByIds($department_ids); + $departments_with_employees = $this->loadDepartmentWeatherEmployeeHourDetailsByDepartment($departments, $slot['slotStart']); + $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids); + $total_hours = 0.0; + foreach ($departments_with_employees as $department) { + $total_hours += (float)($department['hours'] ?? 0.0); + } + + (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_HOUR_DETAILS_GET', 'Department weather hour details fetched'); + $response->success([ + 'date' => $slot['date'], + 'time' => $slot['time'], + 'slot' => $slot['slotKey'], + 'hours' => round($total_hours, 2), + 'departments' => $departments_with_employees, + ], 200); + }, [ + 'departments_weather_get' => 'Get department weather employee hour details for one hour slot', + 'department_access_:id' => 'Access weather hour details for one or more specific departments', + ]); + + $this->get('/departments/weather/targets', function () { + global $response; + self::requirePermission('departments_weather_targets_get'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $department_ids = self::parseDepartmentIdsFromRequest(); + foreach ($department_ids as $department_id) { + self::requireDepartmentAccess((string)$department_id); + } + + $departments = self::loadDepartmentsByIds($department_ids); + $targets_by_department = $this->loadDepartmentWeatherTargetsByDepartmentId($departments); + $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids); + + $data = []; + foreach ($department_ids as $department_id) { + $target = $this->normalizeDepartmentWeatherTarget($targets_by_department[$department_id] ?? null); + $data[] = self::buildDepartmentWeatherTargetResponse($department_id, $target); + } + + (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_TARGETS_GET', 'Department weather targets fetched'); + $response->success($data, 200); + }, [ + 'departments_weather_targets_get' => 'Get department weather productivity thresholds', + 'department_access_:id' => 'Access weather targets for one or more specific departments', + ]); + + $this->put('/departments/weather/targets', function () { + global $response; + self::requirePermission('departments_weather_targets_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['department_id', 'degraded_threshold', 'healthy_threshold']); + + $department_id_raw = self::getParameter('department_id'); + if (!is_scalar($department_id_raw)) { + $response->error('Invalid department_id value', 400); + } + $department_id_value = trim((string)$department_id_raw); + if (!preg_match('/^\d+$/', $department_id_value)) { + $response->error('Invalid department_id: ' . $department_id_value, 400); + } + $department_id = (int)$department_id_value; + if ($department_id < 1) { + $response->error('department_id must be at least 1', 400); + } + + self::requireDepartmentAccess((string)$department_id); + + $degraded_threshold = self::normalizeDepartmentWeatherThresholdValue(self::getParameter('degraded_threshold')); + if ($degraded_threshold === null) { + $response->error('degraded_threshold must be a non-negative number', 400); + } + + $healthy_threshold = self::normalizeDepartmentWeatherThresholdValue(self::getParameter('healthy_threshold')); + if ($healthy_threshold === null) { + $response->error('healthy_threshold must be a non-negative number', 400); + } + + if ($healthy_threshold < $degraded_threshold) { + $response->error('healthy_threshold must be greater than or equal to degraded_threshold', 400); + } + + $department = (new departments_o())->select($department_id); + if (!$department->exists()) { + $response->error('Department not found', 404); + } + + $department->variables->set(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY, self::formatDepartmentWeatherThreshold($degraded_threshold)); + $department->variables->set(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY, self::formatDepartmentWeatherThreshold($healthy_threshold)); + + $target = [ + 'degraded_threshold' => $degraded_threshold, + 'healthy_threshold' => $healthy_threshold, + ]; + (new logs_o())->add('modules_weatherapi', (string)$department_id, 1, $user->id, 'DEPARTMENTS_WEATHER_TARGETS_UPDATE', 'Department weather targets updated'); + $response->success(self::buildDepartmentWeatherTargetResponse($department_id, $target), 200); + }, [ + 'departments_weather_targets_manage' => 'Manage department weather productivity thresholds', + 'department_access_:id' => 'Manage weather targets for a specific department', + ]); + } + + /** + * Response cache TTL (seconds) for department weather timeline endpoint. + * Set `DEPARTMENTS_WEATHER_CACHE_TTL` to override. + */ + private function getDepartmentWeatherCacheTtl(): int + { + $raw = getenv('DEPARTMENTS_WEATHER_CACHE_TTL'); + if ($raw === false || trim((string)$raw) === '') { + return 60; + } + + return max(0, (int)$raw); + } + + private function getDepartmentWeatherStaleCacheTtl(int $fresh_ttl): int + { + if ($fresh_ttl <= 0) { + return 0; + } + + $raw = getenv('DEPARTMENTS_WEATHER_STALE_TTL'); + if ($raw === false || trim((string)$raw) === '') { + return max($fresh_ttl, 300); + } + + return max($fresh_ttl, max(0, (int)$raw)); + } + + private static function getDepartmentWeatherHotActivityTtl(): int + { + $raw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); + if ($raw === false || trim((string)$raw) === '') { + return 900; + } + + return max(1, (int)$raw); + } + + private function getDepartmentWeatherCacheKey(array $department_ids, array $timeline_range, array $status_targets_by_department = []): string + { + $normalized_ids = array_values(array_unique(array_map(static function (mixed $department_id): int { + return (int)$department_id; + }, $department_ids))); + sort($normalized_ids, SORT_NUMERIC); + $normalized_targets = $this->normalizeDepartmentWeatherTargetsByDepartmentId($normalized_ids, $status_targets_by_department); + + $start = ($timeline_range['start'] ?? null) instanceof DateTime + ? $timeline_range['start']->format('Y-m-d H:i:s') + : ''; + $end_exclusive = ($timeline_range['endExclusive'] ?? null) instanceof DateTime + ? $timeline_range['endExclusive']->format('Y-m-d H:i:s') + : ''; + + return 'departments_weather:timeline:v2:' . md5((string)json_encode([ + 'department_ids' => $normalized_ids, + 'start' => $start, + 'end_exclusive' => $end_exclusive, + 'targets' => $this->buildDepartmentWeatherTargetsCacheKeyPayload($normalized_ids, $normalized_targets), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + private static function getDepartmentWeatherHotDescriptorKey(string $hash): string + { + return self::DEPARTMENT_WEATHER_HOT_DESCRIPTOR_PREFIX . $hash; + } + + private function buildDepartmentWeatherHotDescriptor(array $department_ids, array $timeline_range): ?array + { + $normalized_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids); + if ($normalized_ids === []) { + return null; + } + + $start = ($timeline_range['start'] ?? null) instanceof DateTime + ? $timeline_range['start']->format('Y-m-d H:i:s') + : null; + $end_exclusive = ($timeline_range['endExclusive'] ?? null) instanceof DateTime + ? $timeline_range['endExclusive']->format('Y-m-d H:i:s') + : null; + if (!is_string($start) || !is_string($end_exclusive) || $start === '' || $end_exclusive === '') { + return null; + } + + return [ + 'department_ids' => $normalized_ids, + 'start' => $start, + 'end_exclusive' => $end_exclusive, + ]; + } + + private function normalizeDepartmentWeatherHotDescriptor(array $descriptor): ?array + { + $department_ids = $this->normalizeDepartmentIdsForCachePreload((array)($descriptor['department_ids'] ?? [])); + if ($department_ids === []) { + return null; + } + + $start_raw = $descriptor['start'] ?? null; + $end_raw = $descriptor['end_exclusive'] ?? null; + if (!is_string($start_raw) || !is_string($end_raw)) { + return null; + } + + try { + $start = new DateTime($start_raw); + $end_exclusive = new DateTime($end_raw); + } catch (Exception) { + return null; + } + + if ($end_exclusive <= $start) { + return null; + } + + return [ + 'department_ids' => $department_ids, + 'timeline_range' => [ + 'start' => $start, + 'endExclusive' => $end_exclusive, + ], + ]; + } + + private function upsertDepartmentWeatherHotDescriptor(array $descriptor, int $score): ?string + { + if (!defined('redis')) { + return null; + } + + $encoded = json_encode($descriptor, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($encoded) || $encoded === '') { + return null; + } + + $hash = md5($encoded); + $activity_ttl = self::getDepartmentWeatherHotActivityTtl(); + $cutoff = (string)($score - $activity_ttl); + + try { + redis->setEx(self::getDepartmentWeatherHotDescriptorKey($hash), $encoded, $activity_ttl); + $client = redis->get_client(); + $client->zadd(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, $score, $hash); + $client->zremrangebyscore(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, '-inf', $cutoff); + } catch (Throwable) { + return null; + } + + return $hash; + } + + private function recordDepartmentWeatherHotRequest(array $department_ids, array $timeline_range): ?string + { + $descriptor = $this->buildDepartmentWeatherHotDescriptor($department_ids, $timeline_range); + if ($descriptor === null) { + return null; + } + + return $this->upsertDepartmentWeatherHotDescriptor($descriptor, time()); + } + + private function enqueueDepartmentWeatherRefreshSignal(string $cache_key, array $department_ids, array $timeline_range): void + { + if (!defined('redis')) { + return; + } + + $descriptor = $this->buildDepartmentWeatherHotDescriptor($department_ids, $timeline_range); + if ($descriptor === null) { + return; + } + + $hash = $this->upsertDepartmentWeatherHotDescriptor($descriptor, time()); + if (!is_string($hash) || $hash === '') { + return; + } + + try { + $lock_key = self::DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX . md5($cache_key); + if (!redis->set_if_absent_with_expiration($lock_key, '1', 30)) { + return; + } + + $activity_ttl = self::getDepartmentWeatherHotActivityTtl(); + $cutoff = (string)(time() - $activity_ttl); + $client = redis->get_client(); + $client->zadd(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, time(), $hash); + $client->zremrangebyscore(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, '-inf', $cutoff); + } catch (Throwable) { + // Best-effort refresh signal enqueue. + } + } + + private function decodeDepartmentWeatherCachePayload(string $cached): ?array + { + $decoded = json_decode($cached, true); + if (!is_array($decoded)) { + return null; + } + + if (isset($decoded['timeline']) && is_array($decoded['timeline'])) { + $generated_at = null; + if (isset($decoded['generated_at']) && is_numeric($decoded['generated_at'])) { + $generated_at = max(0, (int)$decoded['generated_at']); + } + + return [ + 'generated_at' => $generated_at, + 'timeline' => $decoded['timeline'], + ]; + } + + return [ + 'generated_at' => null, + 'timeline' => $decoded, + ]; + } + + private function encodeDepartmentWeatherCachePayload(array $timeline): ?string + { + $encoded = json_encode([ + 'generated_at' => time(), + 'timeline' => $timeline, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return is_string($encoded) ? $encoded : null; + } + + /** + * Best-effort Redis cache wrapper for department weather timeline payloads. + * Falls back to direct computation when Redis is unavailable or TTL is disabled. + * + * @param callable():array $resolver + * @return array + */ + private function withCachedDepartmentWeatherTimeline( + array $department_ids, + array $timeline_range, + callable $resolver, + bool $record_hot_key = true, + array $status_targets_by_department = [] + ): array + { + $fresh_ttl = $this->getDepartmentWeatherCacheTtl(); + if ($fresh_ttl <= 0 || !defined('redis')) { + return (array)$resolver(); + } + $stale_ttl = $this->getDepartmentWeatherStaleCacheTtl($fresh_ttl); + + $cache_key = $this->getDepartmentWeatherCacheKey($department_ids, $timeline_range, $status_targets_by_department); + if ($record_hot_key) { + $this->recordDepartmentWeatherHotRequest($department_ids, $timeline_range); + } + + try { + $cached = redis->get($cache_key); + if (is_string($cached) && $cached !== '') { + $decoded = $this->decodeDepartmentWeatherCachePayload($cached); + if (is_array($decoded) && isset($decoded['timeline']) && is_array($decoded['timeline'])) { + $age_seconds = $decoded['generated_at'] === null + ? ($fresh_ttl + 1) + : max(0, time() - (int)$decoded['generated_at']); + if ($age_seconds <= $stale_ttl) { + if ($age_seconds > $fresh_ttl && $record_hot_key) { + $this->enqueueDepartmentWeatherRefreshSignal($cache_key, $department_ids, $timeline_range); + } + + return $decoded['timeline']; + } + } + } + } catch (Throwable) { + // Best-effort cache read. + } + + $result = (array)$resolver(); + + try { + $encoded = $this->encodeDepartmentWeatherCachePayload($result); + if (is_string($encoded)) { + redis->setEx($cache_key, $encoded, $stale_ttl); + } + } catch (Throwable) { + // Best-effort cache write. + } + + return $result; + } + + /** + * @return array,timeline_range:array{start:DateTime,endExclusive:DateTime}}> + */ + public static function getDepartmentWeatherHotPreloadTargets(int $limit, int $activity_ttl): array + { + if (!defined('redis')) { + return []; + } + + $limit = max(1, $limit); + $activity_ttl = max(1, $activity_ttl); + $route = new self(); + $targets = []; + $seen_hashes = []; + + try { + $client = redis->get_client(); + $cutoff = (string)(time() - $activity_ttl); + $client->zremrangebyscore(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, '-inf', $cutoff); + $client->zremrangebyscore(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, '-inf', $cutoff); + + $candidate_hashes = []; + foreach ((array)$client->zrevrange(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, 0, max(0, $limit - 1)) as $hash) { + $hash = trim((string)$hash); + if ($hash === '' || isset($seen_hashes[$hash])) { + continue; + } + $seen_hashes[$hash] = true; + $candidate_hashes[] = $hash; + $client->zrem(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, $hash); + } + + if (count($candidate_hashes) < $limit) { + $spill_limit = max($limit * 5, $limit); + foreach ((array)$client->zrevrange(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, 0, max(0, $spill_limit - 1)) as $hash) { + $hash = trim((string)$hash); + if ($hash === '' || isset($seen_hashes[$hash])) { + continue; + } + $seen_hashes[$hash] = true; + $candidate_hashes[] = $hash; + if (count($candidate_hashes) >= $spill_limit) { + break; + } + } + } + + foreach ($candidate_hashes as $hash) { + $payload = redis->get(self::getDepartmentWeatherHotDescriptorKey($hash)); + if (!is_string($payload) || $payload === '') { + continue; + } + + $decoded = json_decode($payload, true); + if (!is_array($decoded)) { + continue; + } + + $target = $route->normalizeDepartmentWeatherHotDescriptor($decoded); + if ($target === null) { + continue; + } + + $targets[] = $target; + if (count($targets) >= $limit) { + break; + } + } + } catch (Throwable) { + return []; + } + + return $targets; + } + + /** + * Pre-compute and cache a department weather timeline for background preloading jobs. + * + * @return array{warmed:bool,department_ids:array,cache_key:string|null,entries:int} + * @throws Exception + */ + public static function preloadDepartmentWeatherTimelineCache( + array $department_ids, + ?string $date_from = null, + ?string $date_to = null, + ?array $timeline_range_override = null + ): array + { + $route = new self(); + $normalized_ids = $route->normalizeDepartmentIdsForCachePreload($department_ids); + if ($normalized_ids === []) { + return [ + 'warmed' => false, + 'department_ids' => [], + 'cache_key' => null, + 'entries' => 0, + ]; + } + + if ( + is_array($timeline_range_override) + && ($timeline_range_override['start'] ?? null) instanceof DateTime + && ($timeline_range_override['endExclusive'] ?? null) instanceof DateTime + ) { + $timeline_range = [ + 'start' => clone $timeline_range_override['start'], + 'endExclusive' => clone $timeline_range_override['endExclusive'], + ]; + } else { + if (($date_from === null) xor ($date_to === null)) { + $date_from = null; + $date_to = null; + } + + $timeline_range = $route->getDepartmentWeatherTimelineRange($date_from, $date_to); + } + + $departments = $route->loadDepartmentsByIds($normalized_ids); + $status_targets_by_department = $route->loadDepartmentWeatherTargetsByDepartmentId($departments); + $coordinates = $route->resolveWeatherCoordinates($departments); + $timeline = $route->withCachedDepartmentWeatherTimeline( + $normalized_ids, + $timeline_range, + function () use ($coordinates, $normalized_ids, $departments, $timeline_range, $status_targets_by_department, $route): array { + $weather_days = $route->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']); + $forecast_result = $route->fetchDepartmentForecastOrFallback($coordinates, $weather_days); + + return $route->buildDepartmentWeatherTimeline( + $normalized_ids, + $departments, + $forecast_result['forecast'], + $timeline_range, + $status_targets_by_department + ); + }, + false, + $status_targets_by_department + ); + + return [ + 'warmed' => true, + 'department_ids' => $normalized_ids, + 'cache_key' => $route->getDepartmentWeatherCacheKey($normalized_ids, $timeline_range, $status_targets_by_department), + 'entries' => count($timeline), + ]; + } + + /** + * @param array $department_ids + * @return array + */ + private function normalizeDepartmentIdsForCachePreload(array $department_ids): array + { + $normalized = []; + foreach ($department_ids as $department_id) { + if (!is_numeric($department_id)) { + continue; + } + + $value = (int)$department_id; + if ($value < 1) { + continue; + } + $normalized[$value] = true; + } + + $ids = array_map('intval', array_keys($normalized)); + sort($ids, SORT_NUMERIC); + + return $ids; + } + + private function parseDepartmentIdsFromRequest(): array + { + global $response; + + $parameters = self::getParametersAsArray(); + $raw_ids = []; + + if (array_key_exists('id', $parameters)) { + $raw_ids = array_merge($raw_ids, self::normalizeDepartmentIdInput($parameters['id'])); + } + if (array_key_exists('ids', $parameters)) { + $raw_ids = array_merge($raw_ids, self::normalizeDepartmentIdInput($parameters['ids'])); + } + + if ($raw_ids === []) { + $response->error('Missing required parameters: id', 400); + } + + $department_ids = []; + foreach ($raw_ids as $raw_id) { + if (is_array($raw_id) || is_object($raw_id)) { + $response->error('Invalid department id value', 400); + } + + $value = trim((string)$raw_id); + if ($value === '') { + continue; + } + if (!preg_match('/^\d+$/', $value)) { + $response->error('Invalid department id: ' . $value, 400); + } + + $department_id = (int)$value; + if ($department_id < 1) { + $response->error('Parameter must be at least 1', 400); + } + + $department_ids[] = $department_id; + } + + $department_ids = array_values(array_unique($department_ids)); + if ($department_ids === []) { + $response->error('Missing required parameters: id', 400); + } + + return $department_ids; + } + + private function normalizeDepartmentIdInput(mixed $value): array + { + if (is_array($value)) { + $result = []; + foreach ($value as $item) { + $result = array_merge($result, self::normalizeDepartmentIdInput($item)); + } + return $result; + } + + if (is_string($value) && str_contains($value, ',')) { + $parts = array_map('trim', explode(',', $value)); + return array_values(array_filter($parts, static function (string $part): bool { + return $part !== ''; + })); + } + + return [$value]; + } + + private function parseSelectedTimelineDateRangeFromRequest(): ?array + { + $parameters = self::getParametersAsArray(); + $has_date_from = array_key_exists('date_from', $parameters); + $has_date_to = array_key_exists('date_to', $parameters); + + if (!$has_date_from && !$has_date_to) { + return null; + } + + global $response; + if (!$has_date_from || !$has_date_to) { + $missing = []; + if (!$has_date_from) { + $missing[] = 'date_from'; + } + if (!$has_date_to) { + $missing[] = 'date_to'; + } + $response->error('Missing required parameters: ' . implode(', ', $missing), 400); + } + + $date_from_value = self::getParameter('date_from'); + $date_to_value = self::getParameter('date_to'); + if (!is_string($date_from_value) || !is_string($date_to_value)) { + $response->error('Invalid type. Expected: string for date_from/date_to', 400); + } + + $date_from = trim((string)$date_from_value); + $date_to = trim((string)$date_to_value); + self::requireDateFormat($date_from, self::FORMAT_DATE()); + self::requireDateFormat($date_to, self::FORMAT_DATE()); + + $from_dt = new DateTime($date_from . ' 00:00:00'); + $to_dt = new DateTime($date_to . ' 00:00:00'); + if ($to_dt < $from_dt) { + $response->error('Invalid date range. date_to must be on or after date_from', 400); + } + + return [ + 'date_from' => $date_from, + 'date_to' => $date_to, + ]; + } + + private function parseDepartmentWeatherHourSlotFromRequest(): array + { + global $response; + + self::requireParameters(['date', 'time']); + $date_value = self::getParameter('date'); + $time_value = self::getParameter('time'); + if (!is_string($date_value) || !is_string($time_value)) { + $response->error('Invalid type. Expected: string for date/time', 400); + } + + $date = trim((string)$date_value); + $time = trim((string)$time_value); + self::requireDateFormat($date, self::FORMAT_DATE()); + if (!preg_match('/^\d{2}:\d{2}(?::\d{2})?$/', $time)) { + $response->error('Invalid time format. Expected HH:MM', 400); + } + + $time_parts = explode(':', $time); + $hour = (int)($time_parts[0] ?? -1); + $minute = (int)($time_parts[1] ?? -1); + if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) { + $response->error('Invalid time value', 400); + } + + $normalized_time = sprintf('%02d:%02d', $hour, $minute); + $slot_start = new DateTime($date . ' ' . $normalized_time . ':00'); + $slot_end = clone $slot_start; + $slot_end->add(new DateInterval('PT1H')); + + return [ + 'date' => $date, + 'time' => $normalized_time, + 'slotKey' => $slot_start->format('Y-m-d H:00'), + 'slotStart' => $slot_start, + 'slotEnd' => $slot_end, + ]; + } + + private function loadDepartmentsByIds(array $department_ids): array + { + $departments = []; + foreach ($department_ids as $department_id) { + $departments[] = (new departments_o())->select((int)$department_id); + } + + return $departments; + } + + private static function normalizeDepartmentWeatherThresholdValue(mixed $value): ?float + { + if (is_string($value)) { + $value = trim($value); + if ($value === '') { + return null; + } + } + + if (!is_numeric($value)) { + return null; + } + + $threshold = (float)$value; + if (!is_finite($threshold) || $threshold < 0) { + return null; + } + + return round($threshold, 6); + } + + private static function formatDepartmentWeatherThreshold(float $threshold): string + { + $formatted = number_format($threshold, 6, '.', ''); + $trimmed = rtrim(rtrim($formatted, '0'), '.'); + + return $trimmed === '' ? '0' : $trimmed; + } + + private function normalizeDepartmentWeatherTarget(mixed $target): ?array + { + if (!is_array($target)) { + return null; + } + + $degraded_threshold = self::normalizeDepartmentWeatherThresholdValue($target['degraded_threshold'] ?? null); + $healthy_threshold = self::normalizeDepartmentWeatherThresholdValue($target['healthy_threshold'] ?? null); + if ($degraded_threshold === null || $healthy_threshold === null) { + return null; + } + if ($healthy_threshold < $degraded_threshold) { + return null; + } + + return [ + 'degraded_threshold' => $degraded_threshold, + 'healthy_threshold' => $healthy_threshold, + ]; + } + + private function normalizeDepartmentWeatherTargetsByDepartmentId(array $department_ids, array $status_targets_by_department): array + { + $normalized = []; + foreach ($department_ids as $department_id) { + $normalized_department_id = (int)$department_id; + if ($normalized_department_id < 1) { + continue; + } + + $target = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null); + if ($target !== null) { + $normalized[$normalized_department_id] = $target; + } + } + + ksort($normalized, SORT_NUMERIC); + + return $normalized; + } + + private function loadDepartmentWeatherTargetsByDepartmentId(array $departments): array + { + $targets_by_department = []; + foreach ($departments as $department) { + $department_id = (int)($department->id ?? 0); + if ($department_id < 1) { + continue; + } + + $target = $this->normalizeDepartmentWeatherTarget([ + 'degraded_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD, + 'healthy_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD, + ]); + if ($target !== null) { + $targets_by_department[$department_id] = $target; + } + } + + ksort($targets_by_department, SORT_NUMERIC); + + return $targets_by_department; + } + + private static function buildDepartmentWeatherTargetResponse(int $department_id, ?array $target): array + { + return [ + 'department_id' => $department_id, + 'degraded_threshold' => $target['degraded_threshold'] ?? null, + 'healthy_threshold' => $target['healthy_threshold'] ?? null, + 'configured' => is_array($target), + ]; + } + + private function buildDepartmentWeatherTargetsCacheKeyPayload(array $department_ids, array $status_targets_by_department): array + { + $payload = []; + foreach ($department_ids as $department_id) { + $normalized_department_id = (int)$department_id; + if ($normalized_department_id < 1) { + continue; + } + + $target = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null); + $payload[(string)$normalized_department_id] = $target; + } + + ksort($payload, SORT_STRING); + + return $payload; + } + + private static function sumDepartmentHoursForSlot(array $department_ids, array $values_by_department_and_slot, string $slot_key): float + { + $total = 0.0; + foreach ($department_ids as $department_id) { + $normalized_department_id = (int)$department_id; + if ($normalized_department_id < 1) { + continue; + } + + $total += (float)($values_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0.0); + } + + return $total; + } + + private static function sumDepartmentWashesForSlot(array $department_ids, array $values_by_department_and_slot, string $slot_key): int + { + $total = 0; + foreach ($department_ids as $department_id) { + $normalized_department_id = (int)$department_id; + if ($normalized_department_id < 1) { + continue; + } + + $total += (int)($values_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0); + } + + return $total; + } + + private function resolveWeatherCoordinates(array $departments): ?array + { + $lat_sum = 0.0; + $lon_sum = 0.0; + $count = 0; + + foreach ($departments as $department) { + $lat = self::normalizeCoordinateValue($department->latitude->value(), -90.0, 90.0); + $lon = self::normalizeCoordinateValue($department->longitude->value(), -180.0, 180.0); + if ($lat === null || $lon === null) { + continue; + } + if ($lat === 0.0 && $lon === 0.0) { + continue; + } + + $lat_sum += $lat; + $lon_sum += $lon; + $count++; + } + + if ($count === 0) { + return null; + } + + return [ + 'lat' => $lat_sum / $count, + 'lon' => $lon_sum / $count, + ]; + } + + /** + * @throws Exception + */ + private function fetchDepartmentForecastOrFallback(?array $coordinates, int $weather_days): array + { + if ($coordinates === null) { + return [ + 'forecast' => self::createEmptyForecastPayload(), + 'fallback_reason' => 'invalid_department_coordinates', + ]; + } + + try { + return [ + 'forecast' => (new weatherapi())->forecast($coordinates['lat'] . ',' . $coordinates['lon'], $weather_days), + 'fallback_reason' => null, + ]; + } catch (Exception $exception) { + if (!self::isWeatherLocationLookupFailure($exception)) { + throw $exception; + } + + return [ + 'forecast' => self::createEmptyForecastPayload(), + 'fallback_reason' => 'weatherapi_location_not_found', + ]; + } + } + + private function createEmptyForecastPayload(): object + { + return (object)[ + 'forecast' => (object)[ + 'forecastday' => [], + ], + ]; + } + + private function isWeatherLocationLookupFailure(Exception $exception): bool + { + $message = strtolower(trim($exception->getMessage())); + + return str_contains($message, 'weatherapi request failed with http 400') + && str_contains($message, 'no matching location found'); + } + + private function normalizeCoordinateValue(mixed $value, float $min, float $max): ?float + { + if ($value === null) { + return null; + } + + if (is_string($value)) { + $value = trim($value); + if ($value === '') { + return null; + } + } + + if (!is_numeric($value)) { + return null; + } + + $coordinate = (float)$value; + if (!is_finite($coordinate)) { + return null; + } + if ($coordinate < $min || $coordinate > $max) { + return null; + } + + return $coordinate; + } + + /** + * @throws Exception + */ + private function buildDepartmentWeatherTimeline( + array $department_ids, + array $departments, + object $forecast, + array $timeline_range, + array $status_targets_by_department = [] + ): array + { + $hourly_weather = []; + foreach (($forecast->forecast->forecastday ?? []) as $day) { + foreach (($day->hour ?? []) as $hour) { + $key = (new DateTime((string)$hour->time))->format('Y-m-d H:00'); + $hourly_weather[$key] = self::mapWeatherCondition((int)($hour->condition->code ?? 1000), (string)($hour->condition->text ?? '')); + } + } + + $timeline_start = $timeline_range['start']; + $timeline_end_exclusive = $timeline_range['endExclusive']; + $normalized_targets = $this->normalizeDepartmentWeatherTargetsByDepartmentId($department_ids, $status_targets_by_department); + $normalized_department_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids); + + $entries = []; + $slot = clone $timeline_start; + $workfeed_hours_by_department_and_slot = $departments === [] + ? [] + : self::loadWorkfeedDepartmentHoursBySlotByDepartment($departments, $timeline_start, $timeline_end_exclusive); + $washes_by_department_and_slot = $normalized_department_ids === [] + ? [] + : self::loadDepartmentWashCountsBySlotByDepartment($normalized_department_ids, $timeline_start, $timeline_end_exclusive); + $current_slot_start = new DateTime(date('Y-m-d H:00:00')); + $current_slot_key = $current_slot_start->format('Y-m-d H:00'); + + while ($slot < $timeline_end_exclusive) { + $slot_key = $slot->format('Y-m-d H:00'); + $weather = $hourly_weather[$slot_key] ?? 'mostly_clear'; + $hours = self::sumDepartmentHoursForSlot($normalized_department_ids, $workfeed_hours_by_department_and_slot, $slot_key); + $washes = self::sumDepartmentWashesForSlot($normalized_department_ids, $washes_by_department_and_slot, $slot_key); + $slot_started = $slot <= $current_slot_start; + $entries[] = [ + 'date' => $slot->format('Y-m-d'), + 'time' => $slot->format('H:00'), + 'current' => $slot_key === $current_slot_key, + 'weather' => $weather, + 'washes' => $washes, + 'hours' => $hours, + 'status' => self::calculateAggregatedDepartmentStatus( + $normalized_department_ids, + $washes_by_department_and_slot, + $workfeed_hours_by_department_and_slot, + $slot_key, + $slot_started, + $normalized_targets + ), + ]; + $slot->add(new DateInterval('PT1H')); + } + + return $entries; + } + + private function calculateStatus(int $washes, float $hours, bool $slot_started = true, ?array $targets = null): string + { + if (!$slot_started) { + return 'unknown'; + } + + if ($hours <= 0) { + return 'unknown'; + } + + $normalized_targets = $this->normalizeDepartmentWeatherTarget($targets); + if ($normalized_targets === null) { + return 'unknown'; + } + + $ratio = $washes / $hours; + if ($ratio >= $normalized_targets['healthy_threshold']) { + return 'healthy'; + } + if ($ratio >= $normalized_targets['degraded_threshold']) { + return 'degraded'; + } + + return 'unhealthy'; + } + + private function calculateAggregatedDepartmentStatus( + array $department_ids, + array $washes_by_department_and_slot, + array $hours_by_department_and_slot, + string $slot_key, + bool $slot_started, + array $status_targets_by_department + ): string + { + if (!$slot_started) { + return 'unknown'; + } + + $worst_status = 'unknown'; + $worst_severity = 0; + $has_evaluable_department = false; + + foreach ($department_ids as $department_id) { + $normalized_department_id = (int)$department_id; + if ($normalized_department_id < 1) { + continue; + } + + $targets = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null); + if ($targets === null) { + return 'unknown'; + } + + $hours = (float)($hours_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0.0); + if ($hours <= 0) { + continue; + } + + $has_evaluable_department = true; + $washes = (int)($washes_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0); + $status = $this->calculateStatus($washes, $hours, true, $targets); + $severity = self::DEPARTMENT_WEATHER_STATUS_SEVERITY[$status] ?? 0; + if ($severity > $worst_severity) { + $worst_severity = $severity; + $worst_status = $status; + } + } + + if (!$has_evaluable_department) { + return 'unknown'; + } + + return $worst_status; + } + + /** + * @throws Exception + */ + private function loadWorkfeedDepartmentHoursBySlot(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array + { + $hours_by_department_and_slot = $this->loadWorkfeedDepartmentHoursBySlotByDepartment($departments, $timeline_start, $timeline_end_exclusive); + if ($hours_by_department_and_slot === []) { + return []; + } + + $hours_by_slot = []; + foreach ($hours_by_department_and_slot as $department_hours_by_slot) { + foreach ($department_hours_by_slot as $slot_key => $hours) { + if (!isset($hours_by_slot[$slot_key])) { + $hours_by_slot[$slot_key] = 0.0; + } + $hours_by_slot[$slot_key] += (float)$hours; + } + } + + return $hours_by_slot; + } + + /** + * @throws Exception + */ + private function loadWorkfeedDepartmentHoursBySlotByDepartment(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array + { + try { + $workfeed = new workfeed(); + $workfeed_department_ids_by_department = $this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed); + if ($workfeed_department_ids_by_department === []) { + return []; + } + + $query_start = clone $timeline_start; + $query_start->sub(new DateInterval('P1D')); + $shifts_response = $workfeed->listShifts([ + 'startFrom' => $query_start->format(DateTime::ATOM), + 'startTo' => $timeline_end_exclusive->format(DateTime::ATOM), + ]); + $shifts = self::normalizeWorkfeedCollection($shifts_response); + if ($shifts === []) { + return []; + } + + $hours_by_department_and_slot = []; + foreach ($workfeed_department_ids_by_department as $department_id => $workfeed_department_id) { + $slot = clone $timeline_start; + while ($slot < $timeline_end_exclusive) { + $slot_key = $slot->format('Y-m-d H:00'); + if (!isset($hours_by_department_and_slot[$department_id])) { + $hours_by_department_and_slot[$department_id] = []; + } + $hours_by_department_and_slot[$department_id][$slot_key] = self::calculateWorkfeedEmployeeHoursForHour($shifts, $workfeed_department_id, $slot); + $slot->add(new DateInterval('PT1H')); + } + } + + return $hours_by_department_and_slot; + } catch (Exception) { + return []; + } + } + + private function getDepartmentWeatherTimelineRange(?string $date_from = null, ?string $date_to = null): array + { + if ($date_from !== null && $date_to !== null) { + $start = new DateTime($date_from . ' 00:00:00'); + $end_exclusive = new DateTime($date_to . ' 00:00:00'); + $end_exclusive->add(new DateInterval('P1D')); + } else { + $start = new DateTime(date('Y-m-d 00:00:00')); + $start->sub(new DateInterval('P1D')); + + $end_exclusive = new DateTime(date('Y-m-d 00:00:00')); + $end_exclusive->add(new DateInterval('P1D')); + } + + return [ + 'start' => $start, + 'endExclusive' => $end_exclusive, + ]; + } + + private function resolveForecastDaysForTimelineRange(DateTime $timeline_start, DateTime $timeline_end_exclusive): int + { + $today_start = new DateTime(date('Y-m-d 00:00:00')); + $effective_start = $timeline_start->getTimestamp() > $today_start->getTimestamp() ? clone $timeline_start : $today_start; + $seconds = max(0, $timeline_end_exclusive->getTimestamp() - $effective_start->getTimestamp()); + $days = (int)ceil($seconds / 86400); + + return max(1, min(14, $days)); + } + + private function resolveWorkfeedDepartmentIds(array $departments, workfeed $workfeed): array + { + $resolved_ids = array_values(array_unique(array_values($this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed)))); + sort($resolved_ids, SORT_STRING); + + return $resolved_ids; + } + + private function resolveWorkfeedDepartmentIdsByDepartmentId(array $departments, workfeed $workfeed): array + { + $resolved_ids = []; + $workfeed_departments = null; + + foreach ($departments as $department) { + $department_id = (int)($department->id ?? 0); + if ($department_id < 1) { + continue; + } + + $configured_id = self::getConfiguredWorkfeedDepartmentId($department); + if ($configured_id !== null) { + $resolved_ids[$department_id] = $configured_id; + continue; + } + + if ($workfeed_departments === null) { + $workfeed_departments = self::normalizeWorkfeedCollection($workfeed->listDepartments()); + } + if ($workfeed_departments === []) { + continue; + } + + $department_name = trim((string)$department->name->value()); + if ($department_name === '') { + continue; + } + + $matched_id = self::matchWorkfeedDepartmentIdByName($department_name, $workfeed_departments); + if ($matched_id !== null) { + $resolved_ids[$department_id] = $matched_id; + } + } + + return $resolved_ids; + } + + private function getConfiguredWorkfeedDepartmentId(departments_o $department): ?string + { + $keys = [ + 'workfeed_department_id', + 'workfeedDepartmentId', + 'workfeed_departmentID', + 'workfeed_department', + ]; + + foreach ($keys as $key) { + $value = $department->variables->getVariable($key); + if (!is_string($value)) { + continue; + } + + $trimmed = trim($value); + if ($trimmed !== '') { + return $trimmed; + } + } + + return null; + } + + private function matchWorkfeedDepartmentIdByName(string $department_name, array $workfeed_departments): ?string + { + $needle = self::normalizeDepartmentName($department_name); + foreach ($workfeed_departments as $entry) { + $record = self::normalizeWorkfeedRecord($entry); + $name = trim((string)($record['name'] ?? '')); + $id = trim((string)($record['id'] ?? '')); + if ($name === '' || $id === '') { + continue; + } + + if (self::normalizeDepartmentName($name) === $needle) { + return $id; + } + } + + return null; + } + + private static function normalizeDepartmentName(string $name): string + { + $collapsed = preg_replace('/\s+/', ' ', trim($name)); + + return strtolower($collapsed ?? trim($name)); + } + + private static function normalizeWorkfeedCollection(array|object $payload): array + { + if (is_array($payload)) { + return $payload; + } + + foreach (['data', 'items', 'results', 'shifts', 'departments'] as $key) { + if (!isset($payload->$key)) { + continue; + } + + $value = $payload->$key; + if (is_array($value)) { + return $value; + } + if (is_object($value)) { + return array_values(get_object_vars($value)); + } + } + + return []; + } + + private static function normalizeWorkfeedRecord(mixed $record): array + { + if (is_array($record)) { + return $record; + } + if (is_object($record)) { + return get_object_vars($record); + } + + return []; + } + + private static function extractWorkfeedDepartmentId(mixed $shift): ?string + { + $record = self::normalizeWorkfeedRecord($shift); + + $department_id = $record['departmentID'] ?? $record['departmentId'] ?? null; + if (($department_id === null || $department_id === '') && isset($record['department'])) { + $department = self::normalizeWorkfeedRecord($record['department']); + $department_id = $department['id'] ?? $department['departmentID'] ?? $department['departmentId'] ?? null; + } + + if ($department_id === null) { + return null; + } + + $normalized = trim((string)$department_id); + + return $normalized === '' ? null : $normalized; + } + + 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::normalizeWorkfeedRecord($value); + if ($record !== []) { + 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 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; + } + + private static function firstShiftDateTimeFromPaths(array $record, array $paths): ?DateTime + { + foreach ($paths as $path) { + $value = self::getNestedRecordValue($record, $path); + $parsed = self::parseDateTimeValue($value); + if ($parsed !== null) { + return $parsed; + } + } + + return null; + } + + private static function lastShiftDateTimeFromPaths(array $record, array $paths): ?DateTime + { + $latest = null; + + foreach ($paths as $path) { + $value = self::getNestedRecordValue($record, $path); + $parsed = self::parseDateTimeValue($value); + if ($parsed === null) { + continue; + } + + if ($latest === null || $parsed->getTimestamp() > $latest->getTimestamp()) { + $latest = $parsed; + } + } + + return $latest; + } + + 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 resolveEffectiveShiftEnd(array $record, DateTime $shift_start, DateTime $shift_end): DateTime + { + if (self::hasShiftApproval($record)) { + return $shift_end; + } + + $update_time = self::parseDateTimeValue($record['updateTime'] ?? null); + if ($update_time === null) { + return $shift_end; + } + + $shift_start_ts = $shift_start->getTimestamp(); + $shift_end_ts = $shift_end->getTimestamp(); + $update_ts = $update_time->getTimestamp(); + + if ($update_ts <= $shift_end_ts) { + return $shift_end; + } + + // Guard against counting late administrative edits as overtime. + $max_unapproved_extension_seconds = 6 * 3600; + if (($update_ts - $shift_end_ts) > $max_unapproved_extension_seconds) { + return $shift_end; + } + if (($update_ts - $shift_start_ts) > 24 * 3600) { + return $shift_end; + } + + return $update_time; + } + + private static 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 = []; + foreach ($department_id_values as $department_id_value) { + $normalized = trim((string)$department_id_value); + if ($normalized !== '') { + $department_id_lookup[$normalized] = true; + } + } + if ($department_id_lookup === []) { + return 0.0; + } + + $slot_end = clone $slot_start; + $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) { + $shift_department_id = self::extractWorkfeedDepartmentId($shift); + if ($shift_department_id === null || !isset($department_id_lookup[$shift_department_id])) { + continue; + } + + $timing = workfeed_shift_time_resolver::resolveShiftTiming($shift); + if ($timing === null) { + continue; + } + + $shift_start_ts = $timing['actualStart']->getTimestamp(); + $shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts); + if ($shift_end_ts <= $shift_start_ts) { + continue; + } + + $overlap_start = max($slot_start_ts, $shift_start_ts); + $overlap_end = min($slot_end_ts, $shift_end_ts); + if ($overlap_end > $overlap_start) { + $hours += ($overlap_end - $overlap_start) / 3600; + } + } + + return round($hours, 2); + } + + /** + * @throws Exception + */ + private function loadDepartmentWeatherEmployeeHourDetailsByDepartment(array $departments, DateTime $slot_start): array + { + try { + $workfeed = new workfeed(); + $employeeNameCache = null; + if (defined('redis')) { + try { + $employeeNameCache = new redis(); + } catch (Exception $e) { + // Redis is unavailable, proceed without caching. + } + } + + $workfeed_department_ids_by_department = $this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed); + if ($workfeed_department_ids_by_department === []) { + return []; + } + + $slot_end = clone $slot_start; + $slot_end->add(new DateInterval('PT1H')); + $query_start = clone $slot_start; + $query_start->sub(new DateInterval('P1D')); + + $shifts_response = $workfeed->listShifts([ + 'startFrom' => $query_start->format(DateTime::ATOM), + 'startTo' => $slot_end->format(DateTime::ATOM), + ]); + $shifts = self::normalizeWorkfeedCollection($shifts_response); + if ($shifts === []) { + return []; + } + + $occurred_until = new DateTime(); + $resolved_employee_names_by_id = []; + $details = []; + foreach ($departments as $department) { + $department_id = (int)($department->id ?? 0); + if ($department_id < 1) { + continue; + } + + $workfeed_department_ids = $workfeed_department_ids_by_department[$department_id] ?? null; + if ($workfeed_department_ids === null) { + continue; + } + + $employees = self::calculateWorkfeedEmployeeHoursForHourByEmployee( + $shifts, + $workfeed_department_ids, + $slot_start, + $occurred_until + ); + $employees = $this->resolveMissingWorkfeedEmployeeNames( + $employees, + $employeeNameCache, + $resolved_employee_names_by_id, + $workfeed + ); + if ($employees === []) { + continue; + } + + $department_hours = 0.0; + foreach ($employees as $employee) { + $department_hours += (float)($employee['hours'] ?? 0.0); + } + + $details[] = [ + 'department_id' => $department_id, + 'department_name' => trim((string)($department->name ?? '')) ?: ('Department ' . $department_id), + 'hours' => round($department_hours, 2), + 'employees' => $employees, + ]; + } + + usort($details, static function (array $left, array $right): int { + return strcasecmp((string)($left['department_name'] ?? ''), (string)($right['department_name'] ?? '')); + }); + + return $details; + } catch (Exception) { + return []; + } + } + + private static function calculateWorkfeedEmployeeHoursForHourByEmployee( + array $shifts, + string|array $workfeed_department_ids, + DateTime $slot_start, + ?DateTime $occurred_until = null + ): array + { + $department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids]; + $department_id_lookup = []; + foreach ($department_id_values as $department_id_value) { + $normalized = trim((string)$department_id_value); + if ($normalized !== '') { + $department_id_lookup[$normalized] = true; + } + } + if ($department_id_lookup === []) { + return []; + } + + $slot_end = clone $slot_start; + $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_by_employee_key = []; + foreach ($shifts as $shift) { + $shift_department_id = self::extractWorkfeedDepartmentId($shift); + if ($shift_department_id === null || !isset($department_id_lookup[$shift_department_id])) { + continue; + } + + $timing = workfeed_shift_time_resolver::resolveShiftTiming($shift); + if ($timing === null) { + continue; + } + + $shift_start_ts = $timing['actualStart']->getTimestamp(); + $shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts); + if ($shift_end_ts <= $shift_start_ts) { + continue; + } + + $overlap_start = max($slot_start_ts, $shift_start_ts); + $overlap_end = min($slot_end_ts, $shift_end_ts); + if ($overlap_end <= $overlap_start) { + continue; + } + + $employee_identity = self::extractWorkfeedEmployeeIdentity($shift); + $employee_id = $employee_identity['id']; + $employee_name = self::normalizeShiftTextValue($employee_identity['name'] ?? null); + if ($employee_id === null && $employee_name === null) { + continue; + } + + $employee_key = $employee_id ?? ('name:' . strtolower($employee_name)); + if (!isset($hours_by_employee_key[$employee_key])) { + $hours_by_employee_key[$employee_key] = [ + 'employee_id' => $employee_id, + 'employee_name' => $employee_name, + 'hours' => 0.0, + ]; + } + + $hours_by_employee_key[$employee_key]['hours'] += ($overlap_end - $overlap_start) / 3600; + } + + foreach ($hours_by_employee_key as &$employee) { + $employee['hours'] = round((float)$employee['hours'], 2); + } + unset($employee); + + usort($hours_by_employee_key, static function (array $left, array $right): int { + return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? '')); + }); + + return array_values(array_filter($hours_by_employee_key, static function (array $employee): bool { + return (float)($employee['hours'] ?? 0.0) > 0; + })); + } + + private function resolveMissingWorkfeedEmployeeNames( + array $employees, + ?redis $employee_name_cache, + array &$resolved_names_by_employee_id, + ?workfeed $workfeed = null + ): array + { + $ids_to_fetch = []; + foreach ($employees as &$employee) { + $employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null); + if ($employee_id === null) { + continue; + } + + $employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null); + if (!self::isMissingEmployeeDisplayName($employee_name, $employee_id)) { + continue; + } + + if (!array_key_exists($employee_id, $resolved_names_by_employee_id)) { + $resolved_names_by_employee_id[$employee_id] = $this->fetchCachedWorkfeedEmployeeDisplayName( + $employee_name_cache, + $employee_id + ); + } + + if ($resolved_names_by_employee_id[$employee_id] === null) { + $ids_to_fetch[$employee_id] = true; + } + } + unset($employee); + + if ($ids_to_fetch !== [] && $workfeed !== null) { + $api_names_by_employee_id = $this->fetchWorkfeedEmployeeDisplayNames( + $workfeed, + array_keys($ids_to_fetch), + $employee_name_cache + ); + + foreach (array_keys($ids_to_fetch) as $employee_id) { + $resolved_names_by_employee_id[$employee_id] = $api_names_by_employee_id[$employee_id] ?? null; + } + } + + foreach ($employees as &$employee) { + $employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null); + if ($employee_id === null) { + continue; + } + + $employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null); + if (!self::isMissingEmployeeDisplayName($employee_name, $employee_id)) { + continue; + } + + $resolved_name = $resolved_names_by_employee_id[$employee_id] ?? null; + if ($resolved_name !== null) { + $employee['employee_name'] = $resolved_name; + } + } + unset($employee); + + usort($employees, static function (array $left, array $right): int { + return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? '')); + }); + + return array_values(array_filter($employees, static function (array $employee): bool { + $employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null); + $employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null); + + return !self::isMissingEmployeeDisplayName($employee_name, $employee_id); + })); + } + + /** + * @param array $employee_ids + * @return array + */ + private function fetchWorkfeedEmployeeDisplayNames(workfeed $workfeed, array $employee_ids, ?redis $employee_name_cache): array + { + $employee_id_lookup = []; + foreach ($employee_ids as $employee_id) { + $normalized = self::normalizeShiftTextValue($employee_id); + if ($normalized !== null) { + $employee_id_lookup[$normalized] = true; + } + } + if ($employee_id_lookup === []) { + return []; + } + + $names_by_employee_id = []; + try { + $employees = self::normalizeWorkfeedCollection($workfeed->listEmployees()); + } catch (Exception) { + $employees = []; + } + + foreach ($employees as $employee) { + $this->appendWorkfeedEmployeeDisplayNames($employee, $employee_id_lookup, $names_by_employee_id, $employee_name_cache); + } + + foreach (array_keys($employee_id_lookup) as $employee_id) { + if (isset($names_by_employee_id[$employee_id])) { + continue; + } + + try { + $employee = $workfeed->getEmployee($employee_id); + } catch (Exception) { + continue; + } + + $this->appendWorkfeedEmployeeDisplayNames($employee, $employee_id_lookup, $names_by_employee_id, $employee_name_cache); + } + + return $names_by_employee_id; + } + + /** + * @param array $employee_id_lookup + * @param array $names_by_employee_id + */ + private function appendWorkfeedEmployeeDisplayNames( + mixed $employee, + array $employee_id_lookup, + array &$names_by_employee_id, + ?redis $employee_name_cache + ): void { + foreach (self::extractWorkfeedEmployeeIds($employee) as $employee_id) { + if (!isset($employee_id_lookup[$employee_id])) { + continue; + } + + $employee_name = self::extractWorkfeedEmployeeDisplayName($employee, $employee_id); + if ($employee_name === null) { + continue; + } + + $names_by_employee_id[$employee_id] = $employee_name; + $this->cacheWorkfeedEmployeeDisplayName($employee_name_cache, $employee_id, $employee_name); + } + } + + private static function extractWorkfeedEmployeeDisplayName(mixed $employee, ?string $employee_id = null): ?string + { + return workfeed_employee_name_formatter::fromRecord($employee, [ + 'firstname', + 'firstName', + 'first_name', + 'employee.firstname', + 'employee.firstName', + 'employee.first_name', + 'user.firstname', + 'user.firstName', + 'user.first_name', + ], [ + 'lastname', + 'lastName', + 'last_name', + 'employee.lastname', + 'employee.lastName', + 'employee.last_name', + 'user.lastname', + 'user.lastName', + 'user.last_name', + ], [ + 'employeeName', + 'employee.name', + 'employee.fullName', + 'employee.full_name', + 'employee.displayName', + 'employee.display_name', + 'name', + 'fullName', + 'full_name', + 'displayName', + 'display_name', + 'user.name', + 'user.fullName', + 'user.full_name', + 'user.displayName', + 'user.display_name', + ], $employee_id); + } + + /** + * @return array + */ + private static function extractWorkfeedEmployeeIds(mixed $employee): array + { + $record = self::normalizeWorkfeedRecord($employee); + if ($record === []) { + return []; + } + + $employee_ids = []; + foreach ([ + 'id', + 'employeeID', + 'employeeId', + 'employee_id', + 'uuid', + 'employee.id', + 'employee.employeeID', + 'employee.employeeId', + 'employee.employee_id', + 'employee.uuid', + 'employeeUUID', + 'employee_uuid', + 'user.id', + 'userId', + ] as $path) { + $employee_id = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path)); + if ($employee_id !== null) { + $employee_ids[$employee_id] = true; + } + } + + return array_keys($employee_ids); + } + + private function cacheWorkfeedEmployeeDisplayName(?redis $employee_name_cache, string $employee_id, string $employee_name): void + { + if ($employee_name_cache === null) { + return; + } + + try { + $employee_name_cache->cache_workfeed_employee_name($employee_id, $employee_name); + } catch (Exception) { + } + } + + private function fetchCachedWorkfeedEmployeeDisplayName(?redis $employee_name_cache, string $employee_id): ?string + { + if ($employee_name_cache === null) { + return null; + } + + try { + $employee_name = $employee_name_cache->get_workfeed_employee_name($employee_id); + } catch (Exception) { + return null; + } + + return self::isMissingEmployeeDisplayName($employee_name, $employee_id) ? null : $employee_name; + } + + private static function isMissingEmployeeDisplayName(?string $employee_name, ?string $employee_id = null): bool + { + return workfeed_employee_name_formatter::isMissingDisplayName($employee_name, $employee_id); + } + + private static function extractWorkfeedEmployeeIdentity(mixed $shift): array + { + $record = self::normalizeWorkfeedRecord($shift); + + $employee_id = null; + foreach ([ + 'employeeID', + 'employeeId', + 'employee_id', + 'employee.id', + 'employee.employeeID', + 'employee.employeeId', + 'employee.employee_id', + 'employee.uuid', + 'employeeUUID', + 'employee_uuid', + 'user.id', + 'userId', + ] as $path) { + $value = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path)); + if ($value !== null) { + $employee_id = $value; + break; + } + } + + $employee_name = workfeed_employee_name_formatter::fromRecord($record, [ + 'employee.firstname', + 'employee.firstName', + 'employee.first_name', + 'firstname', + 'firstName', + 'first_name', + 'user.firstname', + 'user.firstName', + 'user.first_name', + ], [ + 'employee.lastname', + 'employee.lastName', + 'employee.last_name', + 'lastname', + 'lastName', + 'last_name', + 'user.lastname', + 'user.lastName', + 'user.last_name', + ], [ + 'employeeName', + 'employee.name', + 'employee.fullName', + 'employee.full_name', + 'employee.displayName', + 'employee.display_name', + 'name', + 'fullName', + 'full_name', + 'displayName', + 'display_name', + 'user.name', + 'user.fullName', + 'user.full_name', + 'user.displayName', + 'user.display_name', + ], $employee_id); + + return [ + 'id' => $employee_id, + 'name' => $employee_name, + ]; + } + + private static function normalizeShiftTextValue(mixed $value): ?string + { + if (!is_scalar($value)) { + return null; + } + + $normalized = trim((string)$value); + if ($normalized === '') { + return null; + } + + return $normalized; + } + + /** + * @throws Exception + */ + private function loadDepartmentWashCountsBySlot(array $department_ids, DateTime $timeline_start, DateTime $timeline_end_exclusive): array + { + $counts_by_department_and_slot = $this->loadDepartmentWashCountsBySlotByDepartment($department_ids, $timeline_start, $timeline_end_exclusive); + if ($counts_by_department_and_slot === []) { + return []; + } + + $counts = []; + foreach ($counts_by_department_and_slot as $department_counts_by_slot) { + foreach ($department_counts_by_slot as $slot_key => $wash_count) { + if (!isset($counts[$slot_key])) { + $counts[$slot_key] = 0; + } + $counts[$slot_key] += (int)$wash_count; + } + } + + return $counts; + } + + /** + * @throws Exception + */ + private function loadDepartmentWashCountsBySlotByDepartment(array $department_ids, DateTime $timeline_start, DateTime $timeline_end_exclusive): array + { + $normalized_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids); + if ($normalized_ids === []) { + return []; + } + + $range_end = clone $timeline_end_exclusive; + $range_end->sub(new DateInterval('PT1S')); + if ($range_end < $timeline_start) { + return []; + } + + try { + $rows = (new orders_o())->countWashesByHourForDepartments( + $timeline_start->format('Y-m-d H:i:s'), + $range_end->format('Y-m-d H:i:s'), + $normalized_ids + ); + + return $this->normalizeDepartmentWashCountRowsByDepartment($rows); + } catch (Exception) { + return []; + } + } + + private function normalizeDepartmentWashCountRows(array $rows): array + { + $counts_by_department_and_slot = $this->normalizeDepartmentWashCountRowsByDepartment($rows); + + $counts = []; + foreach ($counts_by_department_and_slot as $department_counts_by_slot) { + foreach ($department_counts_by_slot as $slot_key => $wash_count) { + if (!isset($counts[$slot_key])) { + $counts[$slot_key] = 0; + } + $counts[$slot_key] += (int)$wash_count; + } + } + + return $counts; + } + + private function normalizeDepartmentWashCountRowsByDepartment(array $rows): array + { + $counts = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $department_id = (int)($row['department_id'] ?? $row['departmentId'] ?? $row['department'] ?? 0); + if ($department_id < 1) { + continue; + } + + $bucket = trim((string)($row['hour_bucket'] ?? $row['hour'] ?? $row['slot'] ?? '')); + if ($bucket === '') { + continue; + } + + try { + $slot_key = (new DateTime($bucket))->format('Y-m-d H:00'); + } catch (Exception) { + continue; + } + + $wash_count = (int)($row['wash_count'] ?? $row['count'] ?? 0); + if ($wash_count < 0) { + $wash_count = 0; + } + + if (!isset($counts[$department_id])) { + $counts[$department_id] = []; + } + if (!isset($counts[$department_id][$slot_key])) { + $counts[$department_id][$slot_key] = 0; + } + $counts[$department_id][$slot_key] += $wash_count; + } + + return $counts; + } + + /** + * @throws Exception + */ + private function countWashesForHour(array $department_ids, DateTime $hour_start): int + { + $start = clone $hour_start; + $end = clone $hour_start; + $end->add(new DateInterval('PT59M59S')); + + $orders = new orders_o(); + $total = 0; + foreach ($department_ids as $department_id) { + $total += $orders->countWashesInDateRange( + $start->format('Y-m-d H:i:s'), + $end->format('Y-m-d H:i:s'), + (int)$department_id + ); + } + + return $total; + } + + private function mapWeatherCondition(int $code, string $text): string + { + $rain = [1063, 1150, 1153, 1180, 1183, 1186, 1189, 1192, 1195, 1240, 1243, 1246]; + $showers = [1072, 1168, 1171, 1198, 1201, 1249, 1252]; + $snow = [1066, 1069, 1114, 1117, 1204, 1207, 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1255, 1258, 1261, 1264]; + $thunder = [1087, 1273, 1276, 1279, 1282]; + + if ($code === 1000) { + return 'clear'; + } + if ($code === 1003) { + return 'mostly_clear'; + } + if ($code === 1006) { + return 'partly_cloudy'; + } + if ($code === 1009) { + return 'mostly_cloudy'; + } + if ($code === 1030 || str_contains(strtolower($text), 'overcast')) { + return 'overcast'; + } + if ($code === 1135 || $code === 1147) { + return 'fog'; + } + if (in_array($code, $thunder, true)) { + return 'thunderstorm'; + } + if (in_array($code, $snow, true)) { + return 'snow'; + } + if (in_array($code, $showers, true)) { + return 'showers'; + } + if (in_array($code, $rain, true)) { + return 'rain'; + } + + return 'mostly_clear'; + } +} diff --git a/services/nginx/app/routes/moduleWorkfeedRoute.php b/services/nginx/app/routes/moduleWorkfeedRoute.php new file mode 100644 index 00000000..bd01c53e --- /dev/null +++ b/services/nginx/app/routes/moduleWorkfeedRoute.php @@ -0,0 +1,140 @@ +get('/modules/workfeed/employees', function () { + global $response; + self::requirePermission('modules_workfeed_employees_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new workfeed())->listEmployees(); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_EMPLOYEES_LIST', 'Listed Workfeed employees'); + $response->success($result, 200); + }, [ + 'modules_workfeed_employees_view' => 'List Workfeed employees', + ]); + + $this->get('/modules/workfeed/employees/{id}', function () { + global $response; + self::requirePermission('modules_workfeed_employees_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new workfeed())->getEmployee((string)$this->fromRoute('id')); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_EMPLOYEE_GET', 'Fetched Workfeed employee'); + $response->success($result, 200); + }, [ + 'modules_workfeed_employees_view' => 'Get a specific Workfeed employee', + ]); + + $this->get('/modules/workfeed/shifts', function () { + global $response; + self::requirePermission('modules_workfeed_shifts_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $filters = $this->filterRequestParameters($_GET, [ + 'startFrom', + 'startTo', + 'from', + 'to', + 'employeeID', + 'employeeId', + 'released', + ]); + + if (!isset($filters['startFrom']) && isset($filters['from'])) { + $filters['startFrom'] = $filters['from']; + } + if (!isset($filters['startTo']) && isset($filters['to'])) { + $filters['startTo'] = $filters['to']; + } + if (!isset($filters['employeeID']) && isset($filters['employeeId'])) { + $filters['employeeID'] = $filters['employeeId']; + } + + unset($filters['from'], $filters['to'], $filters['employeeId']); + + if (!isset($filters['startFrom']) || trim((string)$filters['startFrom']) === '') { + $response->error('Missing required query parameter: startFrom', 400); + } + if (!isset($filters['startTo']) || trim((string)$filters['startTo']) === '') { + $response->error('Missing required query parameter: startTo', 400); + } + + $result = (new workfeed())->listShifts($filters); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_SHIFTS_LIST', 'Listed Workfeed shifts'); + $response->success($result, 200); + }, [ + 'modules_workfeed_shifts_view' => 'List Workfeed shifts', + ]); + + $this->get('/modules/workfeed/shifts/{id}', function () { + global $response; + self::requirePermission('modules_workfeed_shifts_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new workfeed())->getShift((string)$this->fromRoute('id')); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_SHIFT_GET', 'Fetched Workfeed shift'); + $response->success($result, 200); + }, [ + 'modules_workfeed_shifts_view' => 'Get a specific Workfeed shift', + ]); + + $this->get('/modules/workfeed/departments', function () { + global $response; + self::requirePermission('modules_workfeed_departments_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new workfeed())->listDepartments(); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_DEPARTMENTS_LIST', 'Listed Workfeed departments'); + $response->success($result, 200); + }, [ + 'modules_workfeed_departments_view' => 'List Workfeed departments', + ]); + } + + private function filterRequestParameters(array $parameters, array $allowedKeys): array + { + $allowed = array_flip($allowedKeys); + $filtered = []; + + foreach ($parameters as $key => $value) { + if (isset($allowed[$key]) && $value !== '' && $value !== null) { + $filtered[$key] = $value; + } + } + + return $filtered; + } +} diff --git a/services/nginx/app/routes/moduleXLVaskRoute.php b/services/nginx/app/routes/moduleXLVaskRoute.php index d8b52a6d..9070abe8 100644 --- a/services/nginx/app/routes/moduleXLVaskRoute.php +++ b/services/nginx/app/routes/moduleXLVaskRoute.php @@ -2,10 +2,13 @@ namespace routes; +require_once WD . '/classes/xlvask_automation_service.php'; + use classes\authentication; use classes\response; use classes\router; use classes\xlvask; +use classes\xlvask_automation_service; use objects\orders_o; use objects\users_o; use objects\xlvask_customers_o; @@ -256,6 +259,7 @@ class moduleXLVaskRoute $xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o(); // Import usage logs $xlvask_usage_logs_o->importUsageLogs(); + (new xlvask_automation_service())->runPending(null, null, [], 100, null); // Response $response->success( 'Usage logs imported', @@ -267,4 +271,4 @@ class moduleXLVaskRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/optionsRoute.php b/services/nginx/app/routes/optionsRoute.php index 04426b34..b568c078 100644 --- a/services/nginx/app/routes/optionsRoute.php +++ b/services/nginx/app/routes/optionsRoute.php @@ -4,19 +4,20 @@ namespace routes; use traits\route_t; +require_once dirname(__DIR__) . '/classes/cors_policy.php'; + class optionsRoute { use route_t; public function run(): void { - // When the OPTIONS method is requested, accept all using regex $this->options('/.*', function () { - header('Access-Control-Allow-Origin: *'); - header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); - header('Access-Control-Allow-Headers: *'); - header('Content-Type: application/json'); - http_response_code(200); + global $CORS; + $preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? '')); + \classes\cors_policy::emitHeaders($preflight['headers']); + http_response_code($preflight['status']); + echo $preflight['body']; }); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index f7eae14c..e2e784f0 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -3,12 +3,16 @@ 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; use objects\departments_o; use objects\logs_o; use objects\order_bookings_o; use objects\order_items_o; +use objects\orders_o; use objects\products_o; use objects\users_o; use traits\route_t; @@ -65,11 +69,29 @@ class orderBookingRoute 'pickup' => $pickup, 'items' => $items, ]; + $fingerprint = $this->buildBookingCreationFingerprint($data); + $cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint); + if ($cachedBooking !== null) { + $response->success($cachedBooking->asArray()); + } + if (!$this->reserveBookingCreationSlot($fingerprint)) { + $cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint); + if ($cachedBooking !== null) { + $response->success($cachedBooking->asArray()); + } + $response->error('A similar booking request is already being processed. Please wait a moment and retry.', 429); + } /** * Create the object */ $order_bookings_o = new order_bookings_o(); - $order_bookings_o->add($data); + try { + $order_bookings_o->add($data); + $this->storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id); + } catch (\Throwable $e) { + $this->clearBookingCreationSlot($fingerprint); + throw $e; + } $response->success($order_bookings_o->asArray()); }, [ @@ -113,17 +135,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.', @@ -131,6 +177,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; @@ -149,6 +266,7 @@ class orderBookingRoute $po = self::getTargetPo(false); // String | Null $pickup = self::getTargetPickup(false); // Bool | Null $items = self::getTargetItems(false); // Array of order_items_o objects + $order_id_was_set = self::isParametersSet(['order_id']); $order_id = self::getTargetOrderId(false); // Int | Null /** Authentication */ $auth = new authentication(); @@ -177,6 +295,7 @@ class orderBookingRoute /** * Update the object */ + $previous_order_id = (int)($object->order_id->value() ?? 0); $data = [ ...(self::hasPermission($permission_other) && !empty($customer_number) ? [ 'customer_number' => (int)$customer_number->customer_number->value(), @@ -191,9 +310,12 @@ class orderBookingRoute ...(isset($po) ? ['po' => $po] : []), ...(isset($pickup) ? ['pickup' => $pickup] : []), ...(isset($items) ? ['items' => $items] : []), - ...(isset($order_id) ? ['order_id' => $order_id] : []), + ...($order_id_was_set ? ['order_id' => $order_id] : []), ]; $object->update($data); + if ($order_id_was_set && $order_id === null) { + self::clearMatchingOrderBookingLink($object, $previous_order_id); + } /** * Return the object */ @@ -253,7 +375,7 @@ class orderBookingRoute * Parameters */ $object = self::getTargetObject(); - $safetySeal = self::getSafetySeal(false); // Int | Null + $safetySeal = self::getSafetySeal(false); /** * Authentication */ @@ -265,6 +387,9 @@ class orderBookingRoute if (!$object || !$object->exists()) { $response->error('Order booking does not exist.', 400); } + if (!(int)$object->order_id->value()) { + $response->error('Booking completion must be completed through POS desktop or mobile steps.', 409); + } /** * Complete the booking */ @@ -281,6 +406,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 */ @@ -309,6 +511,37 @@ class orderBookingRoute $error = 'Invalid safety seal'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); + $rawValue = self::getParameter($parameter); + if (!$required && $rawValue === null) return null; + if (is_string($rawValue)) { + $rawValue = trim($rawValue); + if (!$required && $rawValue === '') return null; + if (!ctype_digit($rawValue)) $response->error($error, 400); + } elseif (!is_int($rawValue)) { + if ($required) { + self::requireType($rawValue, self::type_int()); + } else { + self::requireTypeIn($rawValue, [self::type_int(), self::type_null()]); + } + } + if ($required || $rawValue !== null) { + $valueLength = strlen((string)$rawValue); + if ($valueLength < 1) $response->error('Parameter ' . $parameter . ' must be at least 1 characters long', 400); + if ($valueLength > 9) $response->error('Parameter ' . $parameter . ' must be at most 9 characters long', 400); + } + $value = (int)$rawValue; + self::requireMinValue($value, 1); + self::requireMaxValue($value, 999999999); + return $value; + } + + private function getTargetOrderId(bool $required = true): int|null + { + global $response; + $parameter = 'order_id'; + $error = 'Invalid order ID'; + if (!$required && !$this->isParametersSet([$parameter])) return null; + self::requireParameters([$parameter]); if ($required) { self::requireType(self::getParameter($parameter), self::type_int()); } else { @@ -324,26 +557,26 @@ class orderBookingRoute return $value; } - private function getTargetOrderId(bool $required = true): int|string|null + /** + * @throws Exception + */ + private function clearMatchingOrderBookingLink(order_bookings_o $booking, int $previous_order_id): void { - global $response; - $parameter = 'order_id'; - $error = 'Invalid order ID'; - if (!$required && !$this->isParametersSet([$parameter])) return null; - self::requireParameters([$parameter]); - if ($required) { - self::requireType(self::getParameter($parameter), self::type_int()); - } else { - self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]); - // Check if the value is null - if ($this->getParameter($parameter) === null) return "null"; + if ($previous_order_id <= 0) { + return; } - self::requireMinLength($parameter, 1); - self::requireMaxLength($parameter, 9); - $value = (int)self::getParameter($parameter); - self::requireMinValue($value, 1); - self::requireMaxValue($value, 999999999); - return $value; + + $previous_order = (new orders_o())->select($previous_order_id); + if (!$previous_order->exists()) { + return; + } + + if ((int)($previous_order->booking_id->value() ?? 0) !== (int)$booking->id) { + return; + } + + $previous_order->booking_id->set(null); + $previous_order->objectChanged(); } private function getTargetCustomer(bool $required = true): users_o|null @@ -534,4 +767,194 @@ class orderBookingRoute } } -} \ No newline at end of file + private function buildBookingCreationFingerprint(array $data): string + { + $payload = [ + 'customer_number' => (int)($data['customer_number'] ?? 0), + 'department' => (int)($data['department'] ?? 0), + 'reg_1' => (string)($data['reg_1'] ?? ''), + 'reg_2' => $this->normalizeNullableString($data['reg_2'] ?? null), + 'reg_3' => $this->normalizeNullableString($data['reg_3'] ?? null), + 'datetime' => $this->normalizeDatetimeForFingerprint((string)($data['datetime'] ?? '')), + 'note' => $this->normalizeNullableString($data['note'] ?? null), + 'reference' => $this->normalizeNullableString($data['reference'] ?? null), + 'po' => $this->normalizeNullableString($data['po'] ?? null), + 'pickup' => (bool)($data['pickup'] ?? false), + 'items' => $this->normalizeItemsForFingerprint((array)($data['items'] ?? [])), + ]; + + $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($encoded === false) { + return md5((string)microtime(true)); + } + + return hash('sha256', $encoded); + } + + private function normalizeNullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + if (strtolower($normalized) === 'null') { + return null; + } + + return $normalized; + } + + private function normalizeItemsForFingerprint(array $items): array + { + $normalized = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $id = (int)($item['id'] ?? 0); + $quantity = (int)($item['quantity'] ?? 0); + if ($id < 1 || $quantity < 1) { + continue; + } + $normalized[] = [ + 'id' => $id, + 'quantity' => $quantity, + ]; + } + + usort($normalized, static function (array $a, array $b): int { + if ($a['id'] === $b['id']) { + return $a['quantity'] <=> $b['quantity']; + } + return $a['id'] <=> $b['id']; + }); + + return $normalized; + } + + private function normalizeDatetimeForFingerprint(string $datetime): string + { + $value = trim($datetime); + if ($value === '') { + return ''; + } + + $formats = ['Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d']; + foreach ($formats as $format) { + $parsed = \DateTime::createFromFormat($format, $value); + if ($parsed !== false) { + // Use minute precision so rapid retries do not bypass idempotency because of second-level drift. + return $parsed->format('Y-m-d H:i'); + } + } + + return $value; + } + + private function getBookingFromIdempotencyCache(string $fingerprint): ?order_bookings_o + { + try { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return null; + } + $id = (int)($redis->get($this->bookingIdempotencyResultKey($fingerprint)) ?? 0); + if ($id < 1) { + return null; + } + $booking = (new order_bookings_o())->select($id); + if (!$booking->exists()) { + return null; + } + return $booking; + } catch (\Throwable) { + return null; + } + } + + private function reserveBookingCreationSlot(string $fingerprint): bool + { + try { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return true; + } + return $redis->set_if_absent_with_expiration( + $this->bookingIdempotencyLockKey($fingerprint), + '1', + $this->bookingIdempotencyLockTtlSeconds() + ); + } catch (\Throwable) { + // Fail open when Redis is unavailable to avoid blocking all bookings. + return true; + } + } + + private function storeBookingIdempotencyResult(string $fingerprint, int $bookingId): void + { + try { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return; + } + $redis->setEx( + $this->bookingIdempotencyResultKey($fingerprint), + (string)$bookingId, + $this->bookingIdempotencyResultTtlSeconds() + ); + $redis->delete($this->bookingIdempotencyLockKey($fingerprint)); + } catch (\Throwable) { + // Best effort only. + } + } + + private function clearBookingCreationSlot(string $fingerprint): void + { + try { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return; + } + $redis->delete($this->bookingIdempotencyLockKey($fingerprint)); + } catch (\Throwable) { + // Best effort only. + } + } + + private function resolveRedisClient(): ?redis + { + try { + if (defined('redis')) { + $instance = constant('redis'); + if ($instance instanceof redis) { + return $instance; + } + } + return (new redis())->connect(); + } catch (\Throwable) { + return null; + } + } + + private function bookingIdempotencyLockKey(string $fingerprint): string + { + return 'order_booking:idempotency:lock:' . $fingerprint; + } + + private function bookingIdempotencyResultKey(string $fingerprint): string + { + return 'order_booking:idempotency:result:' . $fingerprint; + } + + private function bookingIdempotencyLockTtlSeconds(): int + { + return 30; + } + + private function bookingIdempotencyResultTtlSeconds(): int + { + return 300; + } + +} diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 25e90065..01da8864 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -4,12 +4,19 @@ namespace routes; use classes\authentication; use classes\economic; +use classes\economic_transfer_queue; +use classes\economic_transfer_queue_details_summary; +use classes\economic_v2_compare_engine; +use classes\economic_v2_line_normalizer; +use classes\economic_v2_revenue_statistics_service; +use classes\invoicing_period_utils; use classes\response; use classes\router; use Exception; use objects\collected_order_invoices_o; use objects\customer_fixed_pricing_o; use objects\logs_o; +use objects\orders_o; use objects\users_o; use traits\route_t; @@ -227,6 +234,158 @@ class orderInvoicesRoute ] ); + /** Collected order invoices > E-conomic V2 details > GET */ + $this->get('/collected-invoices/economic/v2/details', function () { + global $response; + self::requirePermission('view_collected_invoice_economic_v2_details'); + $collected_invoice_id = $this->requireCollectedInvoiceId(); + + $payload = $this->buildEconomicV2DetailsPayload($collected_invoice_id); + $response->success($payload); + }, + [ + 'view_collected_invoice_economic_v2_details' => 'View normalized internal/draft/booked e-conomic invoice details (V2).', + ] + ); + + /** Collected order invoices > E-conomic V2 compare > GET */ + $this->get('/collected-invoices/economic/v2/compare', function () { + global $response; + self::requirePermission('compare_collected_invoice_economic_v2'); + $collected_invoice_id = $this->requireCollectedInvoiceId(); + + $details = $this->buildEconomicV2DetailsPayload($collected_invoice_id); + $comparison = economic_v2_compare_engine::compare( + $details['internal']['normalized'], + $details['draft']['exists'] ? $details['draft']['normalized'] : null, + $details['booked']['exists'] ? $details['booked']['normalized'] : null + ); + + $response->success([ + 'collected_invoice_id' => $collected_invoice_id, + 'details' => $details, + 'comparison' => $comparison, + 'warnings' => array_values(array_unique(array_merge( + (array)($details['warnings'] ?? []), + (array)($comparison['warnings'] ?? []) + ))), + ]); + }, + [ + 'compare_collected_invoice_economic_v2' => 'Compare normalized internal invoice with draft/booked e-conomic targets (V2).', + ] + ); + + /** Collected order invoices > E-conomic V2 compare bulk > POST */ + $this->post('/collected-invoices/economic/v2/compare/bulk', function () { + global $response; + self::requirePermission('compare_collected_invoice_economic_v2_bulk'); + self::requireParameters(['collected_invoice_ids']); + + $collected_invoice_ids = self::getParameter('collected_invoice_ids'); + if (!is_array($collected_invoice_ids)) { + $response->error('collected_invoice_ids must be an array', 400); + } + + $normalized_ids = array_values(array_unique(array_filter(array_map(static function ($id) { + return (int)$id; + }, $collected_invoice_ids), static function ($id) { + return $id > 0; + }))); + + if (empty($normalized_ids)) { + $response->error('collected_invoice_ids must contain at least one positive integer', 400); + } + + if (count($normalized_ids) > 200) { + $response->error('Maximum 200 collected_invoice_ids per bulk compare request', 400); + } + + $results = []; + $errors = []; + + foreach ($normalized_ids as $collected_invoice_id) { + try { + $details = $this->buildEconomicV2DetailsPayload((int)$collected_invoice_id); + $comparison = economic_v2_compare_engine::compare( + $details['internal']['normalized'], + $details['draft']['exists'] ? $details['draft']['normalized'] : null, + $details['booked']['exists'] ? $details['booked']['normalized'] : null + ); + + $results[] = [ + 'collected_invoice_id' => (int)$collected_invoice_id, + 'details' => $details, + 'comparison' => $comparison, + 'warnings' => array_values(array_unique(array_merge( + (array)($details['warnings'] ?? []), + (array)($comparison['warnings'] ?? []) + ))), + ]; + } catch (Exception $e) { + $errors[] = [ + 'collected_invoice_id' => (int)$collected_invoice_id, + 'error' => $e->getMessage(), + ]; + } + } + + $response->success([ + 'requested' => count($normalized_ids), + 'compared' => count($results), + 'failed' => count($errors), + 'results' => $results, + 'errors' => $errors, + ]); + }, + [ + 'compare_collected_invoice_economic_v2_bulk' => 'Compare multiple collected invoices against draft/booked e-conomic targets (V2).', + ] + ); + + /** Collected order invoices > E-conomic V2 revenue statistics > GET */ + $this->get('/collected-invoices/economic/v2/revenue-statistics', function () { + global $response; + self::requirePermission('view_collected_invoice_economic_v2_revenue_statistics'); + + $dateFrom = (string)(self::fromRequest('dateFrom') ?? date('Y-m-01')); + $dateTo = (string)(self::fromRequest('dateTo') ?? date('Y-m-d')); + self::requireDateFormat($dateFrom, self::FORMAT_DATE()); + self::requireDateFormat($dateTo, self::FORMAT_DATE()); + if (strtotime($dateFrom) > strtotime($dateTo)) { + $response->error('dateFrom must be before or equal to dateTo', 400); + } + + $barred = strtolower(trim((string)(self::fromRequest('barred') ?? 'all'))); + self::requireInArray($barred, ['all', 'barred', 'active']); + + $currency = self::fromRequest('currency'); + $currency = ($currency !== null && trim($currency) !== '') ? strtoupper(trim($currency)) : null; + if ($currency !== null && !preg_match('/^[A-Z]{3}$/', $currency)) { + $response->error('currency must be a 3-letter ISO code (e.g. DKK)', 400); + } + + $max_pages = (int)(self::fromRequest('max_pages') ?? 10); + self::requireMinValue($max_pages, 1); + self::requireMaxValue($max_pages, 200); + + $payload = (new economic_v2_revenue_statistics_service())->getBookedRevenueStatistics([ + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'customer_numbers' => $this->parseIntegerListParameter('customer_numbers'), + 'department_numbers' => $this->parseIntegerListParameter('department_numbers'), + 'currency' => $currency, + 'barred' => $barred, + 'max_pages' => $max_pages, + ]); + + $response->success($payload); + }, + [ + 'view_collected_invoice_economic_v2_revenue_statistics' => 'View aggregated booked revenue statistics from e-conomic (V2), including barred-customer filtering.', + ] + ); + /** Collected order invoices > Ready to invoice > GET */ $this->get('/collected-invoices/ready-to-invoice', function () { global $response; @@ -367,77 +526,209 @@ class orderInvoicesRoute ] ); - /** Collected order invoices > E-Conomic > POST */ + /** Collected order invoices > Split by month > POST */ + $this->post('/collected-invoices/split-by-month', function () { + global $response, $db; + self::requirePermission('split_collected_invoice'); + $user = (new authentication())->get_user(); + if ($user) { + self::requireParameters(['dateFrom', 'dateTo']); + + try { + $date_range = invoicing_period_utils::normalizeDateRange( + (string)self::getParameter('dateFrom'), + (string)self::getParameter('dateTo') + ); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } + + $preview = false; + if (self::isParametersSet(['preview'])) { + $preview_raw = self::getParameter('preview'); + if (is_bool($preview_raw)) { + $preview = $preview_raw; + } elseif (is_numeric($preview_raw)) { + $preview = ((int)$preview_raw) === 1; + } elseif (is_string($preview_raw)) { + $normalized_preview = strtolower(trim($preview_raw)); + if (!in_array($normalized_preview, ['true', 'false', '1', '0'], true)) { + $response->error('preview must be a boolean', 400); + } + $preview = in_array($normalized_preview, ['true', '1'], true); + } else { + $response->error('preview must be a boolean', 400); + } + } + (new logs_o())->add( + 'orderInvoices', + 'global', + 1, + $user->id, + $preview ? 'PREVIEW_SPLIT_COLLECTED_INVOICE_BY_MONTH' : 'SPLIT_COLLECTED_INVOICE_BY_MONTH', + $preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month' + ); + + $date_from = $db->escape_string($date_range['dateFrom']); + $date_to = $db->escape_string($date_range['dateTo']); + $sql = "SELECT DISTINCT invoice_collection_id + FROM orders + WHERE created_at BETWEEN '$date_from' AND '$date_to' + AND invoice_collection_id IS NOT NULL + AND invoice_collection_id > 0 + AND deleted_at IS NULL"; + $query_result = $db->query($sql); + $invoice_collection_ids = []; + while ($row = $query_result->fetch_assoc()) { + $invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0); + if ($invoice_collection_id > 0) { + $invoice_collection_ids[] = $invoice_collection_id; + } + } + + $items = []; + $changed = []; + $skipped = []; + foreach ( array_values(array_unique($invoice_collection_ids)) as $invoice_collection_id ) { + try { + $collected_order_invoices = (new collected_order_invoices_o())->select($invoice_collection_id); + $collected_order_invoices->requireSelected(); + $split_result = $preview + ? $collected_order_invoices->previewSplitByOrderMonth() + : $collected_order_invoices->splitByOrderMonth(); + $item = [ + 'invoice_collection_id' => $invoice_collection_id, + ...$split_result, + ]; + if (($split_result['status'] ?? '') === 'changed') { + $changed[] = $item; + } else { + $skipped[] = $item; + } + $items[] = $item; + } catch (\Throwable $e) { + $item = [ + 'status' => 'skipped', + 'invoice_collection_id' => $invoice_collection_id, + 'preview' => $preview, + 'reason' => 'not_splittable', + 'message' => $e->getMessage(), + ]; + $skipped[] = $item; + $items[] = $item; + } + } + + $response->success([ + 'message' => $preview + ? 'Collected invoice monthly split preview completed' + : 'Collected invoice monthly split completed', + 'preview' => $preview, + 'dateFrom' => $date_range['dateFrom'], + 'dateTo' => $date_range['dateTo'], + 'processed_count' => count($items), + 'changed_count' => count($changed), + 'skipped_count' => count($skipped), + 'changed' => $changed, + 'skipped' => $skipped, + 'items' => $items, + ]); + } else { + (new logs_o())->add('orderInvoices', 'global', 0, 0, 'SPLIT_COLLECTED_INVOICE_BY_MONTH', 'User tried to split collected order invoices by month without a valid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'split_collected_invoice' => 'Split collected order invoices by order month. This is a superuser-only route.' + ] + ); + + /** Collected order invoices > E-Conomic > POST (queued) */ $this->post('/collected-invoices/economic', function () { global $response; self::requirePermission('add_collected_invoice_economic'); $user = (new authentication())->get_user(); if ($user) { - (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC', 'User added a collected order invoice to E-Conomic'); - // Require the ID, and validate its type and length self::requireParameters(['id']); self::requireType((int)self::getParameter('id'), self::type_int()); self::requireMinLength('id', 1); self::requireMaxLength('id', 10); - // Require the ID to be above 0 self::requireMinValue((int)self::getParameter('id'), 1); - // If the send_as_is parameter is set, validate its type - $send_as_is = false; // This is false by default, when true it will send the invoice as is, without adding vehicle subscriptions or fixed pricing. This is used when a customer with fixed pricing has already been invoiced with the fixed price, and we just need to send the invoice to E-Conomic. + + $send_as_is = false; if (self::isParametersSet(['send_as_is'])) { - self::requireType((bool)self::getParameter('send_as_is'), self::type_bool()); - $send_as_is = (bool)self::getParameter('send_as_is'); + $send_as_is_raw = self::getParameter('send_as_is'); + if (is_bool($send_as_is_raw)) { + $send_as_is = $send_as_is_raw; + } elseif (is_numeric($send_as_is_raw)) { + $send_as_is = ((int)$send_as_is_raw) === 1; + } elseif (is_string($send_as_is_raw)) { + $normalized_send_as_is = strtolower(trim($send_as_is_raw)); + if (!in_array($normalized_send_as_is, ['true', 'false', '1', '0'], true)) { + $response->error('send_as_is must be a boolean', 400); + } + $send_as_is = in_array($normalized_send_as_is, ['true', '1'], true); + } else { + $response->error('send_as_is must be a boolean', 400); + } } - // Validate the ID against the database + $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); - // Check if the collected order invoice has an external ID - if ($collected_order_invoices->external_id->value() === null) { - // Check if the send_as_is parameter is set to true - if (!$send_as_is) { - // Add fixed price to the collected order invoice - $customer_fixed_pricing_o = new customer_fixed_pricing_o(); - if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { - $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); - $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); - $collected_order_invoices->overridePricesFixed((int)$customer_fixed_pricing_price); - } else { - // Apply vehicle subscriptions if the customer does not have fixed pricing - $collected_order_invoices->addVehicleSubscriptionsTransaction(); - } - } else { - // Since we are sending the invoice as is, we need to check if the invoice has any left-over subscription / fixed price transactions - $collected_order_invoices->removeSpecialArrangements(); // Remove any left-over special arrangement transactions (subscriptions / fixed price) - // Reset the price of all items set not to be included in the invoice - //$collected_order_invoices->resetPricesOfItemsNotIncludedInInvoice(); - // Set all items to be included in the invoice - $collected_order_invoices->setAllItemsToBeIncludedInInvoice(); // Set all items to be included in the invoice, since we are sending the invoice as is. - } - // Add the collected order invoice to E-Conomic - $collected_order_invoices->addToEconomic(); - $response->success($collected_order_invoices->asArray()); + try { + $this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); } - // Check if the invoice has been booked + if ($collected_order_invoices->booked_invoice_id->value() !== null) { $response->error('Invoice has already been booked', 400); } - // Check if the invoice draft exists in E-Conomic - if ($collected_order_invoices->isDraftExisting()) { - $response->error('Invoice draft already exists in E-Conomic', 400); + + if (!$this->isEconomicTransferQueueAvailable()) { + try { + $result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + (new logs_o())->add( + 'orderInvoices', + 'global', + 1, + (int)$user->id, + 'ADD_COLLECTED_INVOICE_ECONOMIC_FALLBACK', + 'Processed collected invoice transfer synchronously because queue dependencies are unavailable' + ); + $response->success([ + 'message' => 'Collected invoice export processed synchronously', + 'mode' => 'synchronous_fallback', + 'result' => $result, + ]); + return; } - // Add fixed price to the collected order invoice - $customer_fixed_pricing_o = new customer_fixed_pricing_o(); - if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { - $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); - $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); - $collected_order_invoices->overridePricesFixed((int)$customer_fixed_pricing_price); - } else { - // Apply vehicle subscriptions if the customer does not have fixed pricing - $collected_order_invoices->addVehicleSubscriptionsTransaction(); + + $queue = new economic_transfer_queue(); + $job = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => (int)self::getParameter('id'), + 'send_as_is' => $send_as_is, + 'requested_by' => (int)$user->id, + ], + (int)$user->id + ); + $job_id = (int)($job['id'] ?? 0); + if ($job_id < 1) { + $response->error('Failed to enqueue collected invoice export job: missing queue job id in enqueue response', 500); } - // Create the invoice in E-Conomic - $collected_order_invoices->addToEconomic(true); - // Return the collected order invoice - $response->success($collected_order_invoices->asArray()); + + (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC_QUEUED', 'Queued collected invoice transfer to E-Conomic'); + $response->success([ + 'message' => 'Collected invoice export queued', + 'job_id' => $job_id, + 'job' => $job, + ], 202); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE_ECONOMIC', 'User tried to add a collected order invoice to E-Conomic without a valid session'); $response->error('Invalid session', 400); @@ -448,6 +739,353 @@ class orderInvoicesRoute ] ); + $this->get('/collected-invoices/economic/queue', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $statuses = $this->parseCollectedInvoiceQueueStatuses(); + ['limit' => $limit, 'offset' => $offset] = $this->parseCollectedInvoiceQueuePagination(); + + $queue = new economic_transfer_queue(); + $jobs = $queue->listJobs( + $statuses, + $limit, + $offset, + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT + ); + $total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses); + $has_more = ($offset + count($jobs)) < $total_jobs; + + $response->success([ + 'items' => $this->withCollectedInvoiceQueueDetailsSummaryList($jobs), + 'count' => count($jobs), + 'total' => $total_jobs, + 'limit' => $limit, + 'offset' => $offset, + 'has_more' => $has_more, + ]); + }, + [ + 'add_collected_invoice_economic' => 'List queued collected invoice transfer jobs.' + ] + ); + + $this->get('/collected-invoices/economic/queue/status', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $job_id = $this->requireCollectedInvoiceQueueJobId(); + $job = $this->requireCollectedInvoiceQueueJobById($job_id); + + $response->success($this->withCollectedInvoiceQueueDetailsSummary($job)); + }, + [ + 'add_collected_invoice_economic' => 'Get queued collected invoice transfer job status.' + ] + ); + + $this->get('/collected-invoices/economic/queue/monitor', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $limit = $this->parseCollectedInvoiceQueueMonitorLimit(); + $queue = new economic_transfer_queue(); + + $response->success($this->buildCollectedInvoiceQueueMonitorPayload( + $queue, + (int)$user->id, + $limit + )); + }, + [ + 'add_collected_invoice_economic' => 'Monitor visible collected invoice transfer queue jobs.' + ] + ); + + $this->post('/collected-invoices/economic/queue/retry', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $job_id = $this->requireCollectedInvoiceQueueJobId(); + $job = $this->requireCollectedInvoiceQueueJobById($job_id, true); + if ((int)($job['attempts'] ?? 0) >= (int)($job['max_attempts'] ?? 1)) { + $response->error('Collected invoice queue job reached max retry attempts', 409); + } + + try { + $queue = new economic_transfer_queue(); + $retried = $queue->retryJob($job_id); + } catch (\Throwable $e) { + $message = trim((string)$e->getMessage()); + $status_code = $this->resolveCollectedInvoiceQueueRetryErrorStatus($message); + $response->error('Failed to retry collected invoice queue job: ' . $message, $status_code); + } + + $response->success([ + 'message' => 'Collected invoice queue job retried', + 'job' => $this->withCollectedInvoiceQueueDetailsSummary($retried), + ]); + }, + [ + 'add_collected_invoice_economic' => 'Retry failed queued collected invoice transfer job.' + ] + ); + + $this->post('/collected-invoices/economic/queue/dismiss', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $job_id = $this->requireCollectedInvoiceQueueJobId(); + $job = $this->requireCollectedInvoiceQueueJobById($job_id); + $status = strtoupper((string)($job['status'] ?? '')); + if (!in_array($status, [ + economic_transfer_queue::STATUS_COMPLETED, + economic_transfer_queue::STATUS_FAILED, + ], true)) { + $response->error('Only completed or failed collected invoice queue jobs can be cleared', 409); + } + + try { + $queue = new economic_transfer_queue(); + $dismissed = $queue->dismissTerminalJobForUser($job_id, (int)$user->id); + } catch (\Throwable $e) { + $response->error('Failed to clear collected invoice queue job: ' . $e->getMessage(), 400); + } + + $response->success([ + 'message' => 'Collected invoice queue job cleared', + 'job' => $this->withCollectedInvoiceQueueDetailsSummary($dismissed), + ]); + }, + [ + 'add_collected_invoice_economic' => 'Clear one completed or failed queued collected invoice transfer job for the current user.' + ] + ); + + $this->post('/collected-invoices/economic/queue/dismiss-terminal', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $queue = new economic_transfer_queue(); + $dismissed_count = $queue->dismissTerminalJobsForUser( + (int)$user->id, + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT + ); + + $response->success([ + 'message' => 'Completed and failed collected invoice queue jobs cleared', + 'dismissed_count' => $dismissed_count, + ]); + }, + [ + 'add_collected_invoice_economic' => 'Clear all visible completed or failed queued collected invoice transfer jobs for the current user.' + ] + ); + + $this->post('/collected-invoices/economic/queue/run', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $limit = 10; + if (self::isParametersSet(['limit'])) { + $limit_raw = self::getParameter('limit'); + if (!is_numeric($limit_raw)) { + $response->error('limit must be a positive integer', 400); + } + $limit = (int)$limit_raw; + } + $limit = max(1, min(10, $limit)); + + $queue = new economic_transfer_queue(); + $batch = $this->runCollectedInvoiceQueueBatch($queue, $limit); + $result = (array)($batch['result'] ?? []); + + $response->success([ + 'message' => (bool)($batch['fallback'] ?? false) + ? 'Collected invoice queue batch processed using compatibility fallback' + : 'Collected invoice queue batch processed', + 'processed' => (int)($result['processed'] ?? 0), + 'completed' => (int)($result['completed'] ?? 0), + 'failed' => (int)($result['failed'] ?? 0), + 'jobs' => array_values(array_map('intval', (array)($result['jobs'] ?? []))), + 'limit' => $limit, + 'transfer_type' => economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + 'fallback' => (bool)($batch['fallback'] ?? false), + ]); + }, + [ + 'add_collected_invoice_economic' => 'Run one queued collected invoice transfer batch immediately.' + ] + ); + + /** Collected order invoices > Move multiple > Registration numbers > POST */ + $this->post('/collected-invoices/move-multiple/registration-numbers', function () { + global $response; + self::requirePermission('move_collected_invoice'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + self::requireParameters(['registration_numbers', 'target_customer_number', 'from_date', 'to_date', 'closed_at']); + $registration_numbers = self::getParameter('registration_numbers'); + if (!is_array($registration_numbers) || empty($registration_numbers)) { + $response->error('registration_numbers must be a non-empty array', 400); + } + $target_customer_number = self::getParameter('target_customer_number'); + self::requireType((int)$target_customer_number, self::type_int()); + self::requireMinValue((int)$target_customer_number, 1); + $closed_at = self::getParameter('closed_at'); + if (!!$closed_at) { + self::requireDateFormat($closed_at, self::FORMAT_DATE()); + $closed_at = date('Y-m-d H:i:s', strtotime($closed_at . ' 23:59:59')); + } else { + $closed_at = null; + } + $from_date = self::getParameter('from_date'); + self::requireDateFormat($from_date, self::FORMAT_DATE()); + $to_date = self::getParameter('to_date'); + self::requireDateFormat($to_date, self::FORMAT_DATE()); + $from_date = date('Y-m-d 00:00:00', strtotime($from_date)); + $to_date = date('Y-m-d 23:59:59', strtotime($to_date)); + if (strtotime($from_date) > strtotime($to_date)) { + $response->error('from_date must be before or equal to to_date', 400); + } + $customer = (new users_o())->getUserByCustomerNumber((int)$target_customer_number); + $customer->requireSelected(); + + $collected_order_invoices = new collected_order_invoices_o(); + $moved_invoices = []; + // Create a new collected order invoice for the target customer + $new_invoice_collection = $collected_order_invoices->add( + $customer->customer_number->value(), + null, + null, + null, + $closed_at + ); + if (!$new_invoice_collection->exists()) { + $response->error('Failed to create new invoice collection'); + } + + foreach ($registration_numbers as $registration_number) { + if (!is_string($registration_number) || trim($registration_number) === '') { + $response->error('Each registration number must be a non-empty string', 400); + } + $orders = (new orders_o())->getOrdersWithRegistrationNumberInDateRange($registration_number, $from_date, $to_date); + if (empty($orders)) { + continue; // Skip if no orders found for this registration number in the date range + } + foreach ($orders as $order) { + $order->customer_id->set((int)$new_invoice_collection->customer_number->value()); + $order->assignToInvoiceCollection((int)$new_invoice_collection->id); + $order->objectChanged(); + $moved_invoices[] = $order->id; + } + } + $response->success([ + 'message' => 'Orders moved to new invoice collection', + 'moved_invoices' => $moved_invoices, + 'from_date' => $from_date, + 'to_date' => $to_date, + 'target_customer_number' => $target_customer_number, + ]); + }); + /** Collected order invoices > Move multiple > POST */ + $this->post('/collected-invoices/move-multiple', function () { + // Used to move multiple orders, to a new collected order invoice, in one request, instead of having to move each order one by one + global $response; + self::requirePermission('move_collected_invoice'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + self::requireParameters(['order_ids', 'target_customer_number', 'closed_at']); + $order_ids = self::getParameter('order_ids'); + if (!is_array($order_ids) || empty($order_ids)) { + $response->error('order_ids must be a non-empty array', 400); + } + $target_customer_number = self::getParameter('target_customer_number'); + self::requireType((int)$target_customer_number, self::type_int()); + self::requireMinValue((int)$target_customer_number, 1); + $closed_at = self::getParameter('closed_at'); + if (!!$closed_at) { + self::requireDateFormat($closed_at, self::FORMAT_DATE()); + $closed_at = date('Y-m-d H:i:s', strtotime($closed_at . ' 23:59:59')); + } else { + $closed_at = null; + } + $customer = (new users_o())->select((int)$target_customer_number); + $customer->requireSelected(); + + $collected_order_invoices = new collected_order_invoices_o(); + $moved_invoices = []; + // Create a new collected order invoice for the target customer + $new_invoice_collection = $collected_order_invoices->add( + $customer->id, + null, + null, + null, + $closed_at + ); + if (!$new_invoice_collection->exists()) { + $response->error('Failed to create new invoice collection'); + } + foreach ($order_ids as $order_id) { + self::requireType((int)$order_id, self::type_int()); + self::requireMinValue((int)$order_id, 1); + $order = (new orders_o())->select((int)$order_id); + $order->requireSelected(); + $order->customer_id->set((int)$new_invoice_collection->customer_number->value()); + $order->objectChanged(); + $order->assignToInvoiceCollection((int)$new_invoice_collection->id); + $moved_invoices[] = $order->id; + } + $new_invoice_collection->clearCachedData(); + $response->success([ + 'message' => 'Orders moved to new invoice collection', + 'moved_invoices' => $moved_invoices, + ]); + }, + [ + 'move_collected_invoice' => 'Move multiple orders to a new collected order invoice. This is a superuser-only route.' + ] + ); + /** Collected order invoices > E-Conomic > Unlink and clear cached data > POST */ $this->post('/collected-invoices/economic/unlink', function () { global $response; @@ -561,6 +1199,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); @@ -569,10 +1212,49 @@ class orderInvoicesRoute if ($collected_order_invoices->booked_invoice_id->value() !== null) { $response->error('Invoice has already been booked', 400); } - // Add the collected order invoice to Stripe - $collected_order_invoices->addToEconomic(true); - // Return the collected order invoice - $response->success($collected_order_invoices->asArray()); + if (!$this->isEconomicTransferQueueAvailable()) { + try { + $result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, false); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + (new logs_o())->add( + 'orderInvoices', + 'global', + 1, + (int)$user->id, + 'ADD_COLLECTED_INVOICE_STRIPE_FALLBACK', + 'Processed Stripe collected invoice export synchronously because queue dependencies are unavailable' + ); + $response->success([ + 'message' => 'Stripe collected invoice export processed synchronously', + 'mode' => 'synchronous_fallback', + 'result' => $result, + ]); + return; + } + + $queue = new economic_transfer_queue(); + $job = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => (int)self::getParameter('id'), + 'requested_by' => (int)$user->id, + ], + (int)$user->id + ); + $job_id = (int)($job['id'] ?? 0); + if ($job_id < 1) { + $response->error('Failed to enqueue stripe collected invoice export job: missing queue job id in enqueue response', 500); + } + + (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_STRIPE_QUEUED', 'Queued Stripe collected invoice export to E-Conomic'); + $response->success([ + 'message' => 'Stripe collected invoice export queued', + 'job_id' => $job_id, + 'job' => $job, + ], 202); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE_STRIPE', 'User tried to add a collected order invoice to Stripe without a valid session'); $response->error('Invalid session', 400); @@ -1115,6 +1797,603 @@ class orderInvoicesRoute ); } + private function requireCollectedInvoiceId(): int + { + self::requireParameters(['collected_invoice_id']); + self::requireType((int)self::getParameter('collected_invoice_id'), self::type_int()); + $collected_invoice_id = (int)self::getParameter('collected_invoice_id'); + self::requireMinValue($collected_invoice_id, 1); + self::requireMaxValue($collected_invoice_id, 999999999); + return $collected_invoice_id; + } + + /** + * Build normalized V2 details for a collected invoice and available e-conomic targets. + * @throws Exception + */ + private function buildEconomicV2DetailsPayload(int $collected_invoice_id): array + { + $warnings = []; + $invoice = (new collected_order_invoices_o())->select($collected_invoice_id); + $invoice->requireSelected(); + + $draft_id = null; + $booked_id = null; + $draft_raw = null; + $booked_raw = null; + $customer = [ + 'internal_customer_number' => (int)$invoice->customer_number->value() > 0 ? (int)$invoice->customer_number->value() : null, + 'draft_customer_number' => null, + 'booked_customer_number' => null, + 'exists' => false, + 'name' => null, + 'barred' => null, + ]; + + $economic = new economic(); + + try { + $draft_id = $invoice->getInvoiceDraftId(); + } catch (Exception $e) { + $warnings[] = 'Draft id unavailable: ' . $e->getMessage(); + } + + try { + $booked_id = $invoice->getInvoiceBookedId(); + } catch (Exception $e) { + $warnings[] = 'Booked id unavailable: ' . $e->getMessage(); + } + + if ($draft_id !== null) { + try { + $draft_raw = $economic->invoices->draft->get((int)$draft_id); + } catch (Exception $e) { + $warnings[] = 'Unable to fetch draft invoice ' . (int)$draft_id . ': ' . $e->getMessage(); + } + } + + if ($booked_id !== null) { + try { + $booked_raw = $economic->invoices->booked->getFromId((int)$booked_id); + } catch (Exception $e) { + $warnings[] = 'Unable to fetch booked invoice ' . (int)$booked_id . ': ' . $e->getMessage(); + } + } + + $customer['draft_customer_number'] = $this->extractEconomicCustomerNumber($draft_raw); + $customer['booked_customer_number'] = $this->extractEconomicCustomerNumber($booked_raw); + if ( + $customer['internal_customer_number'] !== null && + $customer['draft_customer_number'] !== null && + (int)$customer['internal_customer_number'] !== (int)$customer['draft_customer_number'] + ) { + $warnings[] = 'Draft invoice customer number mismatch: internal=' . (int)$customer['internal_customer_number'] . ', draft=' . (int)$customer['draft_customer_number']; + } + if ( + $customer['internal_customer_number'] !== null && + $customer['booked_customer_number'] !== null && + (int)$customer['internal_customer_number'] !== (int)$customer['booked_customer_number'] + ) { + $warnings[] = 'Booked invoice customer number mismatch: internal=' . (int)$customer['internal_customer_number'] . ', booked=' . (int)$customer['booked_customer_number']; + } + if ($customer['internal_customer_number'] !== null) { + try { + $economic_customer_raw = $economic->customers->customers->get((int)$customer['internal_customer_number']); + if (isset($economic_customer_raw->customerNumber)) { + $customer['exists'] = true; + $customer['name'] = isset($economic_customer_raw->name) ? (string)$economic_customer_raw->name : null; + $customer['barred'] = isset($economic_customer_raw->barred) ? (bool)$economic_customer_raw->barred : null; + if ($customer['barred'] === true) { + $warnings[] = 'The e-conomic customer is barred.'; + } + } else { + $warnings[] = 'Unable to resolve e-conomic customer ' . (int)$customer['internal_customer_number'] . '.'; + } + } catch (\Throwable $e) { + $warnings[] = 'Failed to fetch e-conomic customer ' . (int)$customer['internal_customer_number'] . ': ' . $e->getMessage(); + } + } + + $internal_normalized = economic_v2_line_normalizer::normalizeInternalCollectedInvoice($invoice); + $draft_normalized = $draft_raw !== null + ? economic_v2_line_normalizer::normalizeDraftInvoice($draft_raw) + : null; + $booked_normalized = $booked_raw !== null + ? economic_v2_line_normalizer::normalizeBookedInvoice($booked_raw) + : null; + + return [ + 'collected_invoice_id' => $collected_invoice_id, + 'external_id' => (string)$invoice->external_id->value(), + 'order_ids' => array_values(array_map(static function ($row) { + return (int)($row['id'] ?? 0); + }, $invoice->getOrderIds())), + 'economic' => [ + 'draft_id' => $draft_id !== null ? (int)$draft_id : null, + 'booked_id' => $booked_id !== null ? (int)$booked_id : null, + ], + 'customer' => $customer, + 'internal' => [ + 'normalized' => $internal_normalized, + ], + 'draft' => [ + 'exists' => $draft_raw !== null, + 'raw' => $this->toPlainArray($draft_raw), + 'normalized' => $draft_normalized, + ], + 'booked' => [ + 'exists' => $booked_raw !== null, + 'raw' => $this->toPlainArray($booked_raw), + 'normalized' => $booked_normalized, + ], + 'warnings' => array_values(array_unique(array_merge( + $warnings, + (array)($internal_normalized['warnings'] ?? []), + (array)($draft_normalized['warnings'] ?? []), + (array)($booked_normalized['warnings'] ?? []) + ))), + ]; + } + + private function extractEconomicCustomerNumber(mixed $invoice_raw): ?int + { + if ($invoice_raw === null) { + return null; + } + $data = is_array($invoice_raw) ? $invoice_raw : $this->toPlainArray($invoice_raw); + $value = $data['customer']['customerNumber'] + ?? $data['customer']['customer_number'] + ?? $data['customerNumber'] + ?? $data['customer_number'] + ?? null; + if ($value === null) { + return null; + } + $customer_number = (int)$value; + return $customer_number > 0 ? $customer_number : null; + } + + private function parseIntegerListParameter(string $parameter): array + { + if (!self::isParametersSet([$parameter])) { + return []; + } + + $raw = self::getParameter($parameter); + $values = []; + if (is_array($raw)) { + $values = $raw; + } elseif (is_string($raw)) { + $values = explode(',', $raw); + } elseif (is_numeric($raw)) { + $values = [$raw]; + } + + $normalized = []; + foreach ($values as $value) { + $int_value = (int)$value; + if ($int_value > 0) { + $normalized[$int_value] = true; + } + } + + return array_values(array_map('intval', array_keys($normalized))); + } + + private function toPlainArray(mixed $value): mixed + { + if ($value === null || is_scalar($value)) { + return $value; + } + return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true); + } + + /** + * Synchronous fallback when queue components are unavailable in this deployment. + * @throws Exception + */ + 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(); + if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { + $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); + $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); + $collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price); + } else { + $collected_order_invoices->addVehicleSubscriptionsTransaction(); + } + } else { + $collected_order_invoices->removeSpecialArrangements(); + $collected_order_invoices->setAllItemsToBeIncludedInInvoice(); + } + + $collected_order_invoices->addToEconomic(); + } else { + if ($collected_order_invoices->booked_invoice_id->value() !== null) { + throw new Exception('Invoice has already been booked'); + } + if ($collected_order_invoices->isDraftExisting()) { + throw new Exception('Invoice draft already exists in E-Conomic'); + } + + $customer_fixed_pricing_o = new customer_fixed_pricing_o(); + if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { + $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); + $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); + $collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price); + } else { + $collected_order_invoices->addVehicleSubscriptionsTransaction(); + } + + $collected_order_invoices->addToEconomic(true); + } + + return $collected_order_invoices->asArray(); + } + + private function isEconomicTransferQueueAvailable(): bool + { + return class_exists('\\classes\\economic_transfer_executor') + && class_exists('\\classes\\economic_transfer_queue_schema_bootstrap') + && class_exists(economic_transfer_queue::class); + } + + private function ensureEconomicTransferQueueIsAvailable(): void + { + global $response; + + if (!$this->isEconomicTransferQueueAvailable()) { + $response->error('Economic transfer queue is unavailable in this deployment', 503); + } + } + + private function parseCollectedInvoiceQueueStatuses(): array + { + global $response; + + if (!self::isParametersSet(['status'])) { + return []; + } + + $status_raw = (string)self::getParameter('status'); + $statuses = array_values(array_filter(array_map('trim', explode(',', $status_raw)))); + if ($statuses === []) { + return []; + } + + $allowed = [ + economic_transfer_queue::STATUS_QUEUED, + economic_transfer_queue::STATUS_PROCESSING, + economic_transfer_queue::STATUS_COMPLETED, + economic_transfer_queue::STATUS_FAILED, + ]; + + $normalized = []; + foreach ($statuses as $status) { + $status = strtoupper($status); + if (!in_array($status, $allowed, true)) { + $response->error('status must contain only: ' . implode(', ', $allowed), 400); + } + $normalized[] = $status; + } + + return array_values(array_unique($normalized)); + } + + private function parseCollectedInvoiceQueuePagination(): array + { + global $response; + + $limit = 50; + if (self::isParametersSet(['limit'])) { + $limit_raw = self::getParameter('limit'); + if (!is_numeric($limit_raw)) { + $response->error('limit must be between 1 and 500', 400); + } + $limit = (int)$limit_raw; + if ($limit < 1 || $limit > 500) { + $response->error('limit must be between 1 and 500', 400); + } + } + + $offset = 0; + if (self::isParametersSet(['offset'])) { + $offset_raw = self::getParameter('offset'); + if (!is_numeric($offset_raw)) { + $response->error('offset must be at least 0', 400); + } + $offset = (int)$offset_raw; + if ($offset < 0) { + $response->error('offset must be at least 0', 400); + } + } + + return [ + 'limit' => $limit, + 'offset' => $offset, + ]; + } + + private function parseCollectedInvoiceQueueMonitorLimit(): int + { + global $response; + + $limit = 50; + if (self::isParametersSet(['limit'])) { + $limit_raw = self::getParameter('limit'); + if (!is_numeric($limit_raw)) { + $response->error('limit must be between 1 and 100', 400); + } + $limit = (int)$limit_raw; + if ($limit < 1 || $limit > 100) { + $response->error('limit must be between 1 and 100', 400); + } + } + + return $limit; + } + + private function requireCollectedInvoiceQueueJobId(): int + { + global $response; + + if (!self::isParametersSet(['job_id'])) { + $response->error('job_id is required', 400); + } + + $job_id = self::getParameter('job_id'); + if (!is_numeric($job_id) || (int)$job_id < 1) { + $response->error('job_id must be a positive integer', 400); + } + + return (int)$job_id; + } + + private function requireCollectedInvoiceQueueJobById(int $job_id, bool $mustBeFailed = false): array + { + global $response; + + $queue = new economic_transfer_queue(); + $job = $queue->getJobById($job_id); + if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) { + $response->error('Collected invoice queue job not found', 404); + } + + if ($mustBeFailed && (string)($job['status'] ?? '') !== economic_transfer_queue::STATUS_FAILED) { + $response->error('Collected invoice queue job can only be retried when status is FAILED', 409); + } + + return $job; + } + + private function resolveCollectedInvoiceQueueRetryErrorStatus(string $message): int + { + $normalized = strtolower(trim($message)); + if ($normalized === '') { + return 400; + } + + if (str_contains($normalized, 'not found')) { + return 404; + } + + $is_conflict = str_contains($normalized, 'only failed jobs can be retried') + || str_contains($normalized, 'can only be retried when status is failed') + || str_contains($normalized, 'max retry attempts') + || str_contains($normalized, 'failed to retry queue job'); + + return $is_conflict ? 409 : 400; + } + + private function runCollectedInvoiceQueueBatch(economic_transfer_queue $queue, int $limit): array + { + if (method_exists($queue, 'processPendingByTransferType')) { + return [ + 'result' => $queue->processPendingByTransferType( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + $limit + ), + 'fallback' => false, + ]; + } + + return [ + 'result' => $queue->processPending($limit), + 'fallback' => true, + ]; + } + + private function buildCollectedInvoiceQueueMonitorPayload(economic_transfer_queue $queue, int $user_id, int $limit): array + { + $jobs = $queue->listMonitorJobsForUser( + $user_id, + $limit, + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT + ); + $jobs = $this->withCollectedInvoiceQueueDetailsSummaryList($jobs); + + $counts = [ + 'queued' => 0, + 'in_progress' => 0, + 'failed' => 0, + 'completed' => 0, + 'total' => count($jobs), + ]; + $progress_sum = 0; + + foreach ($jobs as $job) { + $status = strtoupper((string)($job['status'] ?? '')); + $job_progress = max(0, min(100, (int)($job['progress_percent'] ?? 0))); + + if ($status === economic_transfer_queue::STATUS_QUEUED) { + $counts['queued']++; + $progress_sum += 0; + continue; + } + + if ($status === economic_transfer_queue::STATUS_PROCESSING) { + $counts['in_progress']++; + $progress_sum += $job_progress; + continue; + } + + if ($status === economic_transfer_queue::STATUS_FAILED) { + $counts['failed']++; + $progress_sum += 100; + continue; + } + + if ($status === economic_transfer_queue::STATUS_COMPLETED) { + $counts['completed']++; + $progress_sum += 100; + } + } + + return [ + 'jobs' => $jobs, + 'counts' => $counts, + 'progress_percent' => $counts['total'] > 0 + ? (int)round($progress_sum / $counts['total']) + : 0, + 'limit' => $limit, + ]; + } + + private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int + { + global $db; + + if (method_exists($queue, 'countJobs')) { + return max(0, (int)$queue->countJobs( + $statuses, + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT + )); + } + + $conditions = [ + "transfer_type = '" . $db->escape_string(economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) . "'", + ]; + + if ($statuses !== []) { + $escaped_statuses = array_map(static function (string $status) use ($db): string { + return "'" . $db->escape_string(strtoupper(trim($status))) . "'"; + }, $statuses); + $conditions[] = 'status IN (' . implode(',', $escaped_statuses) . ')'; + } + + $sql = 'SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs WHERE ' . implode(' AND ', $conditions); + $result = $db->query($sql); + if (!$result instanceof \mysqli_result) { + return 0; + } + + $row = $result->fetch_assoc(); + if (!is_array($row) || !isset($row['total'])) { + return 0; + } + + return max(0, (int)$row['total']); + } + + private function withCollectedInvoiceQueueDetailsSummary(array $job): array + { + $job['details_summary'] = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary($job); + $collected_invoice_id = (int)($job['details_summary']['target']['collected_invoice_id'] ?? 0); + if ($collected_invoice_id > 0) { + $job['details_summary']['customer'] = $this->resolveCollectedInvoiceQueueCustomerSummary( + $job['details_summary']['customer'] ?? [], + $collected_invoice_id + ); + } + return $job; + } + + private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array + { + return array_values(array_map(function (array $job): array { + return $this->withCollectedInvoiceQueueDetailsSummary($job); + }, $jobs)); + } + + private function resolveCollectedInvoiceQueueCustomerSummary(array $customer, int $collected_invoice_id): array + { + global $db; + + $customer_number = isset($customer['customer_number']) && is_numeric($customer['customer_number']) + ? (int)$customer['customer_number'] + : null; + $customer_name = is_string($customer['name'] ?? null) && trim((string)$customer['name']) !== '' + ? trim((string)$customer['name']) + : null; + + if ($customer_number !== null && $customer_name !== null) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $collected_invoice_id = max(0, $collected_invoice_id); + if ($collected_invoice_id < 1) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $sql = "SELECT coi.customer_number, coi.name AS invoice_name, u.display_name + FROM collected_order_invoices coi + LEFT JOIN users u ON u.customer_number = coi.customer_number + WHERE coi.id = $collected_invoice_id + LIMIT 1"; + $result = $db->query($sql); + if (!$result instanceof \mysqli_result) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $row = $result->fetch_assoc(); + if (!is_array($row)) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $resolved_customer_number = isset($row['customer_number']) && is_numeric($row['customer_number']) + ? (int)$row['customer_number'] + : $customer_number; + $display_name = trim((string)($row['display_name'] ?? '')); + $invoice_name = trim((string)($row['invoice_name'] ?? '')); + $resolved_name = $customer_name; + if ($resolved_name === null && $display_name !== '' && strtolower($display_name) !== 'unnamed') { + $resolved_name = $display_name; + } + if ($resolved_name === null && $invoice_name !== '') { + $resolved_name = $invoice_name; + } + + return [ + 'customer_number' => $resolved_customer_number, + 'name' => $resolved_name, + ]; + } + + /** + * @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 @@ -1139,4 +2418,4 @@ class orderInvoicesRoute 'total_net_amount' => (float)$tmp_collected_order_invoices->getTotalAmount(), ]; } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/orderItemsRoute.php b/services/nginx/app/routes/orderItemsRoute.php index 87074bb6..20d532ba 100644 --- a/services/nginx/app/routes/orderItemsRoute.php +++ b/services/nginx/app/routes/orderItemsRoute.php @@ -6,6 +6,7 @@ use classes\authentication; use objects\logs_o; use objects\order_items_o; use objects\orders_o; +use objects\products_o; use traits\route_t; class orderItemsRoute @@ -69,6 +70,13 @@ class orderItemsRoute $price = (int)self::getParameter('price'); } } + $product = (new products_o())->getProductById((int)$data['product_id']); + if (!$product->exists()) { + $response->error('Product not found', 404); + } + if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') { + $response->error('Notes is required for this product', 400); + } // Add the order item to the order This is done individually, to make the notes to the individual order items possible $order_items = (new order_items_o()); @@ -100,7 +108,7 @@ class orderItemsRoute $hasPermission = $this->hasPermission('list_own_order_items'); $hasAttribute = $user->showPricesOnBookingPage(); // Check if the user has the attribute to show prices on the booking page if (!$hasPermission && !$hasAttribute) { - $response->error('You do not have permission to list order items, neither your own nor all orders', 403); + $response->forbidden(['list_own_order_items', 'list_order_items']); } } else { $this->requirePermission('list_order_items'); @@ -203,6 +211,14 @@ class orderItemsRoute if (!isset($data['quantity'])) { $response->error('Quantity is required', 400); } + $orderItem = (new order_items_o())->getOrderItemById((int)$data['id']); + if (!$orderItem->exists()) { + $response->error('Order item not found', 404); + } + $product = (new products_o())->getProductById((int)$orderItem->product_id->value()); + if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') { + $response->error('Notes is required for this product', 400); + } // Update the order item (new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity']); // Log the incident @@ -223,4 +239,4 @@ class orderItemsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/orderRoute.php b/services/nginx/app/routes/orderRoute.php index 900f1811..06fe201d 100644 --- a/services/nginx/app/routes/orderRoute.php +++ b/services/nginx/app/routes/orderRoute.php @@ -85,9 +85,11 @@ class orderRoute self::requireDepartmentAccess($orders_o->department_id->value()); // Collect optional params - $safety_seal = null; - if (isset($data['safety_seal']) && $data['safety_seal'] !== '') { - $safety_seal = (int)$data['safety_seal']; + $safety_seal = $orders_o->getSafetySealValue(); + if (array_key_exists('safety_seal', $data)) { + $orders_o->setSafetySealValue($data['safety_seal']); + $orders_o->objectChanged(); + $safety_seal = $orders_o->getSafetySealValue(); } $operator = isset($data['operator']) && $data['operator'] !== '' ? (string)$data['operator'] : (string)$user->display_name->value(); // Set the date to the creation date of the order if not provided @@ -162,4 +164,4 @@ class orderRoute * } */ } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index a05a6a97..5f86f74b 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -6,6 +6,9 @@ use attachments\helpers\attachment_content; use classes\attachment_store; use classes\attachments; use classes\authentication; +use classes\economic; +use classes\order_reference_suggestions_service; +use classes\orders_input_normalizer; use classes\response; use classes\stripe; use JetBrains\PhpStorm\NoReturn; @@ -13,6 +16,7 @@ use objects\collected_order_invoices_o; use objects\departments_o; use objects\economic_module_orders; use objects\logs_o; +use objects\order_bookings_o; use objects\orders_o; use objects\stripe_module_orders_o; use objects\stripe_payment_intents_o; @@ -26,6 +30,52 @@ class ordersRoute public function run(): void { + $this->get('/orders/reference-suggestions', function () { + global $response; + $auth = new authentication(); + $user = $auth->get_user(); + if ($user === false) { + (new logs_o())->add('orders', 'global', 1, 0, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + $this->requirePermission('list_orders'); + self::requireParameters(['department_id']); + $departmentId = (int)self::getParameter('department_id'); + self::requireParameterIntPositive($departmentId, 'department_id'); + self::requireDepartmentAccess((string)$departmentId); + + $search = trim((string)(self::getParameter('search') ?? '')); + if (strlen($search) > 255) { + $response->error('Parameter search must be at most 255 characters long', 400); + } + + foreach (['reg_1', 'reg_2', 'reg_3'] as $plateParameter) { + $plateValue = (string)(self::getParameter($plateParameter) ?? ''); + if (strlen($plateValue) > 32) { + $response->error('Parameter ' . $plateParameter . ' must be at most 32 characters long', 400); + } + } + + $suggestions = (new order_reference_suggestions_service())->suggest([ + 'search' => $search, + 'department_id' => $departmentId, + 'customer_id' => self::getParameter('customer_id') ?? null, + 'reg_1' => self::getParameter('reg_1') ?? '', + 'reg_2' => self::getParameter('reg_2') ?? '', + 'reg_3' => self::getParameter('reg_3') ?? '', + 'limit' => self::getParameter('limit') ?? null, + ]); + + (new logs_o())->add('orders', (string)$departmentId, 1, (int)$user->id, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'Successfully listed POS reference suggestions'); + $response->success($suggestions); + }, + [ + 'list_orders' => 'List POS order reference suggestions', + 'department_access_:id' => 'Access to the department used for reference suggestions', + ] + ); + $this->get('/orders', function () { // Require the user to be logged in global $response; @@ -67,56 +117,21 @@ class ordersRoute $orders = new orders_o(); $orders->setView('orders_with_invoice_collections'); $effectiveCustomer = self::resolveEffectiveCustomerNumber(); + $forcedFilters = $orders->forceRestrictFilters([ + ...($has_permission_other ? [ + 'department_id' => $department_ids + ] : []), + ...(!$has_permission_other && $effectiveCustomer !== null ? [ + 'customer_id' => $effectiveCustomer + ] : []), + ]); + $rawOrders = $orders->listObjectsWithPaginationIfSet( + null, + $forcedFilters + ); $response->success( - $orders->listObjectsWithPaginationIfSet( - function ($order) { - $order_obj = new orders_o(); - // Get the order object - $order_obj->select((int)$order['id']); - // Add the invoice status to the order - $order['economic_invoice_module'] = (new economic_module_orders())->getByOrderId($order['id'])->asArray(); - // Add the total amount to the order - $order['total_net_amount'] = $order_obj->getNetAmount(); - // Add the stripe status to the order - $stripe_module_orders = (new stripe_module_orders_o())->select($order['id']); - if ($stripe_module_orders->exists()) { - $order['stripe_invoice_module'] = $stripe_module_orders->asArray(); - } - // If the invoice collection is set, add it to the order - if (!empty($order['invoice_collection_id'])) { - $collected_order_invoices_obj = new collected_order_invoices_o(); - $collected_order_invoices_obj->select((int)$order['invoice_collection_id']); - $order['invoice_collection'] = [ - 'id' => $order['invoice_collection_id'], - 'closed_at' => $collected_order_invoices_obj->closed_at->value(), - 'booked_invoice_id' => $collected_order_invoices_obj->booked_invoice_id->value() ?? null, - 'processor' => (int)$collected_order_invoices_obj->processor->value() ?? null, - ]; - } - // Get the customer - $tmp_customer = (new users_o())->getUserByCustomerNumber((int)$order['customer_id']); - // Add the customer name to the order - $order['customer_name'] = (new users_o())->getCustomerName((int)$tmp_customer->customer_number->value()); - $order['user_id'] = (int)$tmp_customer->id; - // Add the cashier name to the order - $order['cashier_name'] = (new users_o())->getCashierName((int)$order['cashier_id']); - $order['pending_handheld'] = $order_obj->isPendingHandheld(); - $order['attachments'] = $order_obj->listAttachments(); - $order['po'] = $order['po'] ?? null; - $order['lane'] = $order['lane'] ?? null; - /** @var array $order */ - return $order; - }, - $orders->forceRestrictFilters([ - ...($has_permission_other ? [ - 'department_id' => $department_ids - ] : []), - ...(!$has_permission_other && $effectiveCustomer !== null ? [ - 'customer_id' => $effectiveCustomer - ] : []), - ]) - ) + $this->enrichOrderListRows($rawOrders) ); }, [ @@ -167,28 +182,40 @@ class ordersRoute if ($targetUser->requiresReference() && empty($data['reference'])) { $response->error('Reference is required by the customer', 400); } - // Get the registration number - $reg_1 = $data['reg_1']; - // Get the registration numbers (If they are set, they 2-3 are optional) - $reg_2 = $data['reg_2'] ?? ''; - $reg_3 = $data['reg_3'] ?? ''; - // Strip the registration numbers of any whitespace - $reg_1 = preg_replace('/\s+/', '', $reg_1); - $reg_2 = preg_replace('/\s+/', '', $reg_2); - $reg_3 = preg_replace('/\s+/', '', $reg_3); + try { + $reg_1 = orders_input_normalizer::normalizeRegistrationNumber($data['reg_1']); + $reg_2 = orders_input_normalizer::normalizeRegistrationNumber($data['reg_2'] ?? ''); + $reg_3 = orders_input_normalizer::normalizeRegistrationNumber($data['reg_3'] ?? ''); + $createdAt = orders_input_normalizer::normalizeCreatedAt($data['created_at'] ?? date('Y-m-d H:i:s')); + $includeInInvoice = array_key_exists('include_in_invoice', $data) + ? orders_input_normalizer::normalizeIncludeInInvoice($data['include_in_invoice']) + : null; + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } + + $bookingId = !empty($data['booking_id']) ? (int)$data['booking_id'] : null; + $po = $this->resolveOrderPoForBookingDefault( + array_key_exists('po', $data) ? $data['po'] : null, + array_key_exists('po', $data), + $bookingId + ); $new_data = [ 'customer_id' => (int)$data['customer_id'], 'department_id' => (int)$data['department_id'], 'reference' => (string)$data['reference'] ?? '', 'cashier_id' => (int)$user->id, // The user who created the order 'notes' => (string)$data['notes'] ?? '', + ...($po !== null ? ['po' => $po] : []), 'reg_1' => (string)$reg_1, 'reg_2' => (string)$reg_2, 'reg_3' => (string)$reg_3, ...(!empty($data['lane']) ? ['lane' => (int)$data['lane']] : []), // Optional lane ...(!empty($data['wash_id']) ? ['wash_id' => (string)$data['wash_id']] : []), // Optional wash ID - ...(!empty($data['booking_id']) ? ['booking_id' => (int)$data['booking_id']] : []), // Optional booking ID - 'created_at' => (string)($data['created_at'] ?? date('Y-m-d H:i:s')), // Default to current time if not set + ...($bookingId !== null ? ['booking_id' => $bookingId] : []), // Optional booking ID + 'created_at' => $createdAt, // Default to current time if not set + ...(array_key_exists('include_in_invoice', $data) ? ['include_in_invoice' => $includeInInvoice] : []), + ...(array_key_exists('safety_seal', $data) ? ['safety_seal' => orders_o::normalizeSafetySealValue($data['safety_seal'])] : []), ]; // Create the order //$order = (new orders_o())->add((int)$data['customer_id'], $user->id, $data['reference'], $data['notes'], (int)$data['department_id'], (string)$reg_1, (string)$reg_2, (string)$reg_3); @@ -246,15 +273,48 @@ class ordersRoute // Get the current order $order = (new orders_o())->getOrderById((int)$id); // Check if the order exists - if (!$order->exists()) { + if (!isset($order->id) || (int)$order->id < 1 || !$order->exists()) { $response->error('Order not found', 400); } // Check if the user has access to the department self::requireDepartmentAccess((int)$order->department_id->value()); + $confirmed = filter_var($this->fromRequest('confirmed'), FILTER_VALIDATE_BOOLEAN) === true; + $deleteProtection = $order->getDeleteProtectionSummary(); + if ($deleteProtection['requires_confirmation'] && !$confirmed) { + (new logs_o())->add( + 'orders', + $order->department_id->value(), + 1, + $user->id, + 'DELETE_ORDER_CONFIRMATION_REQUIRED', + 'Order deletion requires confirmation (ID: ' . $id + . '; reasons: ' . implode(',', $deleteProtection['protected_reasons']) + . '; order_items: ' . $deleteProtection['order_item_count'] + . '; attachments: ' . $deleteProtection['attachment_count'] . ')' + ); + $response->error([ + 'message' => 'Order deletion requires confirmation', + ...$deleteProtection, + ], 409); + } // Delete the order $order->delete(); // Log the incident - (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER', 'Successfully deleted an order (ID: ' . $id . ')'); + if ($deleteProtection['requires_confirmation']) { + (new logs_o())->add( + 'orders', + $order->department_id->value(), + 1, + $user->id, + 'DELETE_ORDER_CONFIRMED', + 'Successfully deleted a protected order after confirmation (ID: ' . $id + . '; reasons: ' . implode(',', $deleteProtection['protected_reasons']) + . '; order_items: ' . $deleteProtection['order_item_count'] + . '; attachments: ' . $deleteProtection['attachment_count'] . ')' + ); + } else { + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER', 'Successfully deleted an order (ID: ' . $id . ')'); + } // Return a success message $response->success(['message' => 'Order deleted successfully']); } else { @@ -306,7 +366,7 @@ class ordersRoute if (!$has_permission_other) { $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { - $response->error('You do not have permission to download this order attachment', 403); + $response->forbidden([$permission_other->permission]); } } // Get the attachment @@ -368,7 +428,7 @@ class ordersRoute self::requirePermission($permission_own); $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { - $response->error('You do not have permission to view attachments for this order.', 403); + $response->forbidden([$permission_other->permission]); } } // Get the attachments @@ -507,7 +567,7 @@ class ordersRoute $response->error('Order not found', 400); } // Mark the order as completed - $order->markAsCompleted(); + $order->markAsCompleted((string)$user->display_name->value()); // Log the incident (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'MARK_ORDER_AS_COMPLETED', 'Successfully marked an order as completed (ID: ' . $data['id'] . ')'); // Return a success message @@ -525,105 +585,124 @@ class ordersRoute ); $this->post('/orders/module/stripe/payment_intent', function () { - // Require the user to be logged in global $response; $this->requirePermission('charge_order'); - // Get the user object $user = (new authentication())->get_user(); - // Check if the request was successful - if ($user) { - // Get the post data - $data = json_decode(file_get_contents('php://input'), true); - // Check if the required fields are set - if (!isset($data['id'])) { - $response->error('ID is required', 400); - } - // Get the current order - $order = (new orders_o())->getOrderById((int)$data['id']); - // Check if the order exists - if (!$order->exists()) { - $response->error('Order not found', 400); - } - // Get the department - $department = (new departments_o())->selectId((int)$order->department_id->value()); - // Check if the department is configured for Stripe payments - if (!$department->isStripeConfigured()) { - $response->error('Department is not configured for Stripe payments', 400); - } - // Check if the reader is set - if (!isset($data['reader'])) { - $response->error('Reader ID is required', 400); - } - // Get the tax percentage (if any) - $tax_percentage = (isset($data['tax_percentage'])) ? (int)$data['tax_percentage'] : null; - // Check if the tax percentage is valid - if ($tax_percentage !== null && ($tax_percentage < 0 || $tax_percentage > 100)) { - $response->error('Invalid tax percentage', 400); - } - function addTaxNetAmount($net_amount, $tax_percentage): float - { - // Check if the tax percentage is above 0 - if (empty($tax_percentage) || $tax_percentage <= 0) { - return $net_amount; - } - return $net_amount + ($net_amount * ($tax_percentage / 100)); - } - - // Get the Stripe payment intent - $stripe = new stripe(); - $paymentIntent = $stripe->payment_intents->create( - addTaxNetAmount( - (float)$order->getNetAmount() * 100, - $tax_percentage ?? 0 - ), - [ - 'description' => 'Order ID: ' . $order->id, - 'metadata' => [ - 'order_id' => $order->id, - 'customer_id' => $order->customer_id->value(), - 'department_id' => $order->department_id->value(), - 'tax_percentage' => $tax_percentage ?? 0, - ], - 'payment_method_types' => ['card_present'], - 'capture_method' => 'manual', - ] - ); - // Validate the payment intent - try { - $stripe->payment_intents->get($paymentIntent->id); - } catch (\Stripe\Exception\InvalidRequestException $e) { - $response->error('Payment intent not found', 400); - } - // Set the payment intent ID in the order - $stripe_payment_intents = new stripe_payment_intents_o(); - $stripe_payment_intents->add( - (int)$order->id, - $paymentIntent->id, - $paymentIntent->client_secret, - $paymentIntent->toJSON() - ); - - // Send the payment intent to the reader - $stripe->readers->sendPaymentIntent( - $data['reader'], - $paymentIntent->id, - ); - // Set the reader on the stripe payment intent - $stripe_payment_intents->reader_id->set($data['reader']); - - // Log the incident - (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Successfully charged an order (ID: ' . $data['id'] . ')'); - - $response->success([ - 'payment_intent' => $paymentIntent->id, - 'client_secret' => $paymentIntent->client_secret, - ]); - } else { - // Log the incident + if (!$user) { (new logs_o())->add('orders', 'global', 1, 0, 'CHARGE_ORDER', 'No user found, or invalid session'); - // Return an error $response->error('Invalid session', 400); } + + $data = json_decode(file_get_contents('php://input'), true); + if (!is_array($data)) { + $data = []; + } + if (!isset($data['id'])) { + $response->error('ID is required', 400); + } + + $order = (new orders_o())->getOrderById((int)$data['id']); + if (!$order->exists()) { + $response->error('Order not found', 400); + } + + $department = (new departments_o())->selectId((int)$order->department_id->value()); + if (!$department->isStripeConfigured()) { + $response->error([ + 'message' => 'Card payments are not ready for this department. Open Stripe setup and choose a terminal location.', + 'code' => 'stripe_terminal_setup_required', + ], 409); + } + + $readerId = trim((string)($data['reader'] ?? '')); + if ($readerId === '') { + $response->error('Reader ID is required', 400); + } + + $tax_percentage = isset($data['tax_percentage']) ? (int)$data['tax_percentage'] : null; + if ($tax_percentage !== null && ($tax_percentage < 0 || $tax_percentage > 100)) { + $response->error('Invalid tax percentage', 400); + } + + $stripe = new stripe(); + $stripePaymentIntents = new stripe_payment_intents_o(); + + if ($stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { + $stripePaymentIntents->selectOrderPaymentIntent((int)$order->id); + + try { + $storedPaymentIntent = $stripe->payment_intents->get($stripePaymentIntents->payment_intent_id->value()); + $stripePaymentIntents->updateStoredPaymentIntent($storedPaymentIntent); + $stripePaymentIntents->setReaderId($readerId); + if ($tax_percentage !== null) { + $stripePaymentIntents->tax_percentage->set($tax_percentage); + } + + if ($this->isStripePaymentIntentReusable($storedPaymentIntent)) { + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused Stripe payment intent for order (ID: ' . $data['id'] . ')'); + $response->success($this->buildStripePaymentIntentResponse($storedPaymentIntent, $stripePaymentIntents, [ + 'reused' => true, + ])); + } + + $stripePaymentIntents->deletePermanently(); + } catch (\Stripe\Exception\InvalidRequestException) { + $stripePaymentIntents->deletePermanently(); + } + } + + $paymentIntent = $stripe->payment_intents->create( + (int)round($this->addTaxNetAmount( + (float)$order->getNetAmount() * 100, + $tax_percentage ?? 0 + )), + [ + 'description' => 'Order ID: ' . $order->id, + 'metadata' => [ + 'order_id' => (string)$order->id, + 'customer_id' => (string)$order->customer_id->value(), + 'department_id' => (string)$order->department_id->value(), + 'tax_percentage' => (string)($tax_percentage ?? 0), + 'reader_id' => $readerId, + 'reader' => $readerId, + ], + 'payment_method_types' => ['card_present'], + 'capture_method' => 'manual', + ] + ); + + $stripePaymentIntents->add( + (int)$order->id, + $paymentIntent->id, + $paymentIntent->client_secret, + $paymentIntent->toJSON(), + $readerId, + $tax_percentage + ); + + try { + $stripe->readers->sendPaymentIntent($readerId, $paymentIntent->id); + } catch (\Stripe\Exception\InvalidRequestException) { + try { + $stripePaymentIntents->delete(); + } catch (Exception) { + $stripePaymentIntents->deletePermanently(); + } + $response->error('Unable to start payment on the selected reader', 409); + } + + try { + $paymentIntent = $stripe->payment_intents->get($paymentIntent->id); + $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); + } catch (\Stripe\Exception\InvalidRequestException) { + // Keep the created intent payload if Stripe retrieve is temporarily unavailable. + } + + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Successfully charged an order (ID: ' . $data['id'] . ')'); + + $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents, [ + 'reused' => false, + ])); }, [ 'charge_order' => 'Charge an order' @@ -631,55 +710,56 @@ class ordersRoute ); $this->get('/orders/module/stripe/payment_intent', function () { - // Require the user to be logged in global $response; $this->requirePermission('get_payment_intent'); - // Get the user object $user = (new authentication())->get_user(); - // Check if the request was successful - if ($user) { - // Get the post data - self::requireParameters([ - 'id' - ]); - // Check if the required fields are set - $id = self::getParameter('id'); - if (!isset($id)) { - $response->error('ID is required', 400); - } - // Get the current order - $order = (new orders_o())->getOrderById((int)$id); - // Check if the order exists - if (!$order->exists()) { - $response->error('Order not found', 400); - } - // Check if the order has a payment intent - $stripe_payment_intents = new stripe_payment_intents_o(); - if (!$stripe_payment_intents->doesOrderHavePaymentIntent((int)$order->id)) { - $response->error('Order does not have a payment intent', 400); - } - $stripe_payment_intents->selectOrderPaymentIntent((int)$order->id); - // Get the Stripe payment intent - $stripe = new stripe(); - try { - $payment_intent = $stripe->payment_intents->get( - $stripe_payment_intents->payment_intent_id->value(), - [ - //'expand' => ['latest_charge'], // This is used to get the latest charge, that can be used to check if the payment has been refunded. - ] - ); - } catch (\Stripe\Exception\InvalidRequestException $e) { - $response->error('Payment intent not found', 400); - } - $response->success( - $payment_intent->toJSON() - ); - } else { - // Log the incident + if (!$user) { (new logs_o())->add('orders', 'global', 1, 0, 'GET_PAYMENT_INTENT', 'No user found, or invalid session'); - // Return an error $response->error('Invalid session', 400); } + + self::requireParameters([ + 'id' + ]); + $id = self::getParameter('id'); + if (!isset($id)) { + $response->error('ID is required', 400); + } + + $order = (new orders_o())->getOrderById((int)$id); + if (!$order->exists()) { + $response->error('Order not found', 400); + } + + $stripePaymentIntents = new stripe_payment_intents_o(); + if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { + $response->success($this->buildStripePaymentIntentResponse(null, null, [ + 'message' => 'No active payment intent for this order.', + ])); + } + + $stripePaymentIntents->selectOrderPaymentIntent((int)$order->id); + $stripe = new stripe(); + + try { + $paymentIntent = $stripe->payment_intents->get($stripePaymentIntents->payment_intent_id->value()); + } catch (\Stripe\Exception\InvalidRequestException) { + $stripePaymentIntents->deletePermanently(); + $response->success($this->buildStripePaymentIntentResponse(null, null, [ + 'message' => 'No active payment intent for this order.', + ])); + } + + $status = strtolower((string)($paymentIntent->status ?? '')); + if ($status === 'canceled') { + $stripePaymentIntents->deletePermanently(); + $response->success($this->buildStripePaymentIntentResponse(null, null, [ + 'message' => 'No active payment intent for this order.', + ])); + } + + $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); + $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents)); }, [ 'get_payment_intent' => 'Get a payment intent' @@ -687,45 +767,49 @@ class ordersRoute ); $this->delete('/orders/module/stripe/payment_intent', function () { - // Require the user to be logged in global $response; $this->requirePermission('charge_order'); - // Get the user object $user = (new authentication())->get_user(); - // Check if the request was successful - if ($user) { - // Get the data - self::requireParameters([ - 'id' - ]); - $id = self::fromRequest('id'); - // Get the current order - $order = (new orders_o())->getOrderById((int)$id); - // Check if the order exists - if (!$order->exists()) { - $response->error('Order not found', 400); - } - // Check if the order has a payment intent - $stripe_payment_intents = new stripe_payment_intents_o(); - if (!$stripe_payment_intents->doesOrderHavePaymentIntent((int)$order->id)) { - $response->error('Order does not have a payment intent', 400); - } - $stripe_payment_intents->selectOrderPaymentIntent((int)$order->id); - try { - $stripe_payment_intents->delete(); - // Log the incident - (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_PAYMENT_INTENT', 'Successfully deleted a payment intent (ID: ' . $id . ')'); - // Return a success message - $response->success(['message' => 'Payment intent deleted successfully']); - } catch (\Stripe\Exception\InvalidRequestException $e) { - $response->error('Payment intent not found', 400); - } - } else { - // Log the incident + if (!$user) { (new logs_o())->add('orders', 'global', 1, 0, 'DELETE_PAYMENT_INTENT', 'No user found, or invalid session'); - // Return an error $response->error('Invalid session', 400); } + + $data = json_decode(file_get_contents('php://input'), true); + if (!is_array($data)) { + $data = []; + } + $id = $data['id'] ?? self::fromRequest('id'); + if (!isset($id)) { + $response->error('ID is required', 400); + } + + $order = (new orders_o())->getOrderById((int)$id); + if (!$order->exists()) { + $response->error('Order not found', 400); + } + + $stripePaymentIntents = new stripe_payment_intents_o(); + if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { + $response->success($this->buildStripePaymentIntentResponse(null, null, [ + 'message' => 'Payment intent cleared successfully.', + 'cleared' => true, + ])); + } + + $stripePaymentIntents->selectOrderPaymentIntent((int)$order->id); + + try { + $stripePaymentIntents->delete(); + } catch (\Stripe\Exception\InvalidRequestException) { + $stripePaymentIntents->deletePermanently(); + } + + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_PAYMENT_INTENT', 'Successfully deleted a payment intent (ID: ' . $id . ')'); + $response->success($this->buildStripePaymentIntentResponse(null, null, [ + 'message' => 'Payment intent cleared successfully.', + 'cleared' => true, + ])); }, [ 'charge_order' => 'Delete a payment intent' @@ -733,59 +817,76 @@ class ordersRoute ); $this->post('/orders/module/stripe/payment_intent/capture', function () { - // Require the user to be logged in global $response; $this->requirePermission('confirm_payment_intent'); - // Get the user object $user = (new authentication())->get_user(); - // Check if the request was successful - if ($user) { - // Get the post data - $data = json_decode(file_get_contents('php://input'), true); - // Check if the required fields are set - if (!isset($data['id'])) { - $response->error('ID is required', 400); - } - // Get the current order - $order = (new orders_o())->getOrderById((int)$data['id']); - // Check if the order exists - if (!$order->exists()) { - $response->error('Order not found', 400); - } - // Check if the order has a payment intent - $stripe_payment_intents = new stripe_payment_intents_o(); - if (!$stripe_payment_intents->doesOrderHavePaymentIntent((int)$order->id)) { - $response->error('Order does not have a payment intent', 400); - } - $stripe_payment_intents->selectOrderPaymentIntent((int)$order->id); - // Confirm the payment intent - $stripe = new stripe(); - try { - $paymentIntent = $stripe->payment_intents->capture( - $stripe_payment_intents->payment_intent_id->value(), - [] // Since we are capturing the payment, we don't need to pass any data - ); - // Log the incident - (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CONFIRM_PAYMENT_INTENT', 'Successfully confirmed a payment intent (ID: ' . $data['id'] . ')'); - // Check if the payment intent was successful - if ($paymentIntent->status !== 'succeeded') { - $response->error('Payment intent not successful', 400); - } else { - // Update the order collection to reflect the payment - $order_collection = $order->getOrderCollection(); - $order_collection->paidWithStripe($paymentIntent->id); - } - // Return a success message - $response->success($paymentIntent->toJSON()); - } catch (\Stripe\Exception\InvalidRequestException $e) { - $response->error('Payment intent not found', 400); - } - } else { - // Log the incident + if (!$user) { (new logs_o())->add('orders', 'global', 1, 0, 'CONFIRM_PAYMENT_INTENT', 'No user found, or invalid session'); - // Return an error $response->error('Invalid session', 400); } + + $data = json_decode(file_get_contents('php://input'), true); + if (!is_array($data)) { + $data = []; + } + if (!isset($data['id'])) { + $response->error('ID is required', 400); + } + + $order = (new orders_o())->getOrderById((int)$data['id']); + if (!$order->exists()) { + $response->error('Order not found', 400); + } + + $stripePaymentIntents = new stripe_payment_intents_o(); + if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { + $response->error('No active payment intent for this order.', 409); + } + + $stripePaymentIntents->selectOrderPaymentIntent((int)$order->id); + $stripe = new stripe(); + + try { + $paymentIntent = $stripe->payment_intents->get($stripePaymentIntents->payment_intent_id->value()); + } catch (\Stripe\Exception\InvalidRequestException) { + $stripePaymentIntents->deletePermanently(); + $response->error('Stored payment intent is stale. Start the payment again.', 409); + } + + $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); + $status = strtolower((string)($paymentIntent->status ?? '')); + + if ($status === 'succeeded') { + $response->error('Payment intent has already been captured.', 409); + } + if ($status === 'canceled') { + $stripePaymentIntents->deletePermanently(); + $response->error('Payment intent was cancelled. Start the payment again.', 409); + } + if ($status !== 'requires_capture') { + $response->error('Payment intent is not ready to capture.', 409); + } + + try { + $paymentIntent = $stripe->payment_intents->capture( + $stripePaymentIntents->payment_intent_id->value(), + [] + ); + } catch (\Stripe\Exception\InvalidRequestException) { + $stripePaymentIntents->deletePermanently(); + $response->error('Stored payment intent is stale. Start the payment again.', 409); + } + + $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); + if (strtolower((string)($paymentIntent->status ?? '')) !== 'succeeded') { + $response->error('Payment intent is not ready to capture.', 409); + } + + $order_collection = $order->getOrderCollection(); + $order_collection->paidWithStripe($paymentIntent->id); + + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CONFIRM_PAYMENT_INTENT', 'Successfully confirmed a payment intent (ID: ' . $data['id'] . ')'); + $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents)); }, [ 'confirm_payment_intent' => 'Confirm a payment intent' @@ -837,6 +938,68 @@ class ordersRoute ); } + private function addTaxNetAmount(float $net_amount, ?int $tax_percentage): float + { + if (empty($tax_percentage) || $tax_percentage <= 0) { + return $net_amount; + } + + return $net_amount + ($net_amount * ($tax_percentage / 100)); + } + + private function isStripePaymentIntentReusable(object $paymentIntent): bool + { + $status = strtolower((string)($paymentIntent->status ?? '')); + + return in_array($status, [ + 'requires_payment_method', + 'requires_confirmation', + 'requires_action', + 'processing', + 'requires_capture', + 'succeeded', + ], true); + } + + private function buildStripePaymentIntentResponse(?object $paymentIntent, ?stripe_payment_intents_o $storedIntent, array $extra = []): array + { + $paymentIntentPayload = null; + if ($paymentIntent !== null) { + if (method_exists($paymentIntent, 'toJSON')) { + $decoded = json_decode($paymentIntent->toJSON(), true); + $paymentIntentPayload = is_array($decoded) ? $decoded : null; + } else { + $decoded = json_decode(json_encode($paymentIntent), true); + $paymentIntentPayload = is_array($decoded) ? $decoded : null; + } + } + + if ($paymentIntentPayload !== null) { + $metadata = $paymentIntentPayload['metadata'] ?? []; + if (!is_array($metadata)) { + $metadata = []; + } + + if ($storedIntent !== null && isset($storedIntent->reader_id) && !empty($storedIntent->reader_id->value())) { + $metadata['reader_id'] = (string)$storedIntent->reader_id->value(); + if (empty($metadata['reader'])) { + $metadata['reader'] = $metadata['reader_id']; + } + } + + if ($storedIntent !== null && isset($storedIntent->tax_percentage) && $storedIntent->tax_percentage->value() !== null) { + $metadata['tax_percentage'] = (string)$storedIntent->tax_percentage->value(); + } + + $paymentIntentPayload['metadata'] = $metadata; + } + + return array_merge([ + 'payment_intent' => $paymentIntentPayload, + 'has_payment_intent' => $paymentIntentPayload !== null, + ], $extra); + } + /** * @throws \Exception */ @@ -866,6 +1029,7 @@ class ordersRoute if (!is_array($data)) { $data = []; } + $data = $this->normalizeLegacyEditableFieldPayload($data, $response); // Check if the required fields are set if (!isset($data['id'])) { $response->error('ID is required', 400); @@ -882,12 +1046,12 @@ class ordersRoute if ($subuser_own_path) { $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { - $response->error('You do not have permission to edit this order', 403); + $response->forbidden([$permission_other->permission]); } } else { // Classic own path — compare against authenticated user customer number if ($order->customer_id->value() !== $user->customer_number->value()) { - $response->error('You do not have permission to edit this order', 403); + $response->forbidden([$permission_other->permission]); } // Ensure classic own-path requires user permission $this->requirePermission('user'); @@ -897,6 +1061,12 @@ class ordersRoute // Include the order ID (Even though it is not editable) 'id', 'po', + 'reference', + 'notes', + 'safety_seal', + 'reg_1', + 'reg_2', + 'reg_3', ]; // Check if the $data contains any non-allowed keys foreach ( $data as $key => $value ) { @@ -905,19 +1075,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((string)$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((string)$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((string)$data['reg_3']); + $normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']); + $shouldRefreshAttachedWashCertificate = true; + $order->reg_3->set($normalizedReg3); } // Reference if (isset($data['reference'])) { @@ -927,6 +1104,14 @@ class ordersRoute if (isset($data['notes'])) { $order->notes->set((string)$data['notes']); } + if (array_key_exists('safety_seal', $data)) { + $normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']); + $shouldRefreshAttachedWashCertificate = true; + $order->setSafetySealValue($normalizedSafetySeal); + } + if ($shouldRefreshAttachedWashCertificate) { + $order->regenerateAttachedWashCertificate(); + } // Register the change $order->objectChanged(); // Return a success message @@ -935,12 +1120,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'])) { @@ -952,20 +1147,31 @@ class ordersRoute } // If the registration number is set, validate it if (isset($data['reg_1'])) { - $order->reg_1->set($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($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($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)) { + $normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']); + $shouldRefreshAttachedWashCertificate = true; + $order->setSafetySealValue($normalizedSafetySeal); + } // If the lane is set, validate it if (isset($data['lane'])) { $order->lane->set((int)$data['lane']); @@ -979,10 +1185,12 @@ class ordersRoute } // If the booking ID is set, validate it if (isset($data['booking_id'])) { - $order->booking_id->set((int)$data['booking_id']); + $bookingId = (int)$data['booking_id']; + $order->booking_id->set($bookingId); + $this->applyBookingPoDefaultToOrder($order, $bookingId); } // 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 @@ -991,7 +1199,27 @@ class ordersRoute } // Check if the created_at is set if (isset($data['created_at'])) { - $order->created_at->set($data['created_at']); + try { + $order->created_at->set(orders_input_normalizer::normalizeCreatedAt($data['created_at'])); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } + } + // Check if include_in_invoice is set + if (array_key_exists('include_in_invoice', $data)) { + try { + $order->include_in_invoice->set( + orders_input_normalizer::normalizeIncludeInInvoice($data['include_in_invoice']) + ); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } + } + if ($shouldAutoReassignInvoiceCollection) { + $order->assignToInvoiceCollection(null, false); + } + if ($shouldRefreshAttachedWashCertificate) { + $order->regenerateAttachedWashCertificate(); } // Void any cached key for the order $order->objectChanged(); @@ -1007,6 +1235,345 @@ class ordersRoute } } + private function resolveOrderPoForBookingDefault(mixed $po, bool $poProvided, ?int $bookingId): ?string + { + $currentPo = is_scalar($po) || $po === null ? trim((string)$po) : ''; + if ($currentPo !== '') { + return $currentPo; + } + + $bookingPo = $this->getBookingPoDefault($bookingId); + if ($bookingPo !== null) { + return $bookingPo; + } + + return $poProvided ? '' : null; + } + + private function applyBookingPoDefaultToOrder(orders_o $order, ?int $bookingId = null): void + { + $currentPo = trim((string)($order->po->value() ?? '')); + if ($currentPo !== '') { + return; + } + + $bookingPo = $this->getBookingPoDefault($bookingId ?? (int)($order->booking_id->value() ?? 0)); + if ($bookingPo === null) { + return; + } + + $order->po->set($bookingPo); + } + + private function getBookingPoDefault(?int $bookingId): ?string + { + if ($bookingId === null || $bookingId <= 0) { + return null; + } + + try { + $booking = (new order_bookings_o())->select($bookingId); + if (!$booking->exists()) { + return null; + } + + $bookingPo = trim((string)($booking->po->value() ?? '')); + return $bookingPo !== '' ? $bookingPo : null; + } catch (\Throwable) { + return null; + } + } + + private function normalizeLegacyEditableFieldPayload(array $data, response $response): array + { + if (!array_key_exists('field', $data) && !array_key_exists('value', $data)) { + return $data; + } + + if (!array_key_exists('field', $data) || !array_key_exists('value', $data)) { + $response->error('Both legacy field and value are required', 400); + } + + $field = is_string($data['field']) ? trim($data['field']) : ''; + $allowedLegacyFields = ['reference', 'notes', 'safety_seal', 'reg_1', 'reg_2', 'reg_3']; + + if ($field === '' || !in_array($field, $allowedLegacyFields, true)) { + $displayField = is_scalar($data['field']) || $data['field'] === null + ? (string)$data['field'] + : gettype($data['field']); + + $response->error('Unsupported legacy order field: ' . $displayField, 400); + } + + if (!array_key_exists($field, $data)) { + $data[$field] = $data['value']; + } + + unset($data['field'], $data['value']); + + return $data; + } + + private function normalizeRegistrationNumberOrError(mixed $value): string + { + global $response; + + try { + return orders_input_normalizer::normalizeRegistrationNumber($value); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } + } + + 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> $orders + * @return array> + */ + private function enrichOrderListRows(array $orders): array + { + if (empty($orders)) { + return []; + } + + $orderIds = array_values(array_unique(array_filter(array_map('intval', array_column($orders, 'id')), static fn(int $id): bool => $id > 0))); + $customerNumbers = array_values(array_unique(array_filter(array_map('intval', array_column($orders, 'customer_id')), static fn(int $id): bool => $id > 0))); + $cashierIds = array_values(array_unique(array_filter(array_map('intval', array_column($orders, 'cashier_id')), static fn(int $id): bool => $id > 0))); + + $netAmountsByOrderId = (new orders_o())->getNetAmountForOrders($orderIds); + $economicModules = new economic_module_orders(); + $economicModules->ensureRowsForOrderIds($orderIds); + $economicByOrderId = $economicModules->getByOrderIdsAsArray($orderIds); + $stripeByOrderId = $this->getStripeModulesByOrderIds($orderIds); + + $users = new users_o(); + $customerNamesByCustomerNumber = $users->getCustomerNames($customerNumbers); + $userIdsByCustomerNumber = $this->getUserIdsByCustomerNumbers($customerNumbers); + $cashierNamesById = $users->getCashierNames($cashierIds); + $pendingHandheldByOrderId = $this->getPendingHandheldFlags($orderIds); + $attachmentsByOrderId = (new attachments())->listMany('orders', $orderIds); + + foreach ($orders as &$order) { + $orderId = (int)($order['id'] ?? 0); + $customerNumber = (int)($order['customer_id'] ?? 0); + $cashierId = (int)($order['cashier_id'] ?? 0); + + $order['economic_invoice_module'] = $economicByOrderId[$orderId] ?? [ + 'id' => $orderId, + 'invoice_draft_id' => null, + 'invoice_id' => null, + ]; + $order['total_net_amount'] = (float)($netAmountsByOrderId[$orderId] ?? 0); + + if (isset($stripeByOrderId[$orderId])) { + $order['stripe_invoice_module'] = $stripeByOrderId[$orderId]; + } + + if (!empty($order['invoice_collection_id'])) { + $order['invoice_collection'] = [ + 'id' => $order['invoice_collection_id'], + 'closed_at' => $order['closed_at'] ?? null, + 'booked_invoice_id' => $order['booked_invoice_id'] ?? null, + 'processor' => (int)($order['processor'] ?? 0), + ]; + } + + $customerName = $customerNamesByCustomerNumber[(string)$customerNumber] ?? null; + if ($customerName === null && $customerNumber > 0) { + $customerName = $users->getCustomerName($customerNumber); + } + $order['customer_name'] = $customerName; + $order['user_id'] = (int)($userIdsByCustomerNumber[$customerNumber] ?? 0); + $order['cashier_name'] = $cashierNamesById[$cashierId] ?? 'Unknown Cashier'; + $order['pending_handheld'] = (bool)($pendingHandheldByOrderId[$orderId] ?? false); + $order['attachments'] = $attachmentsByOrderId[$orderId] ?? []; + $order['po'] = $order['po'] ?? null; + $order['safety_seal'] = $order['safety_seal'] ?? null; + $order['lane'] = $order['lane'] ?? null; + } + unset($order); + + return $orders; + } + + /** + * @param int[] $orderIds + * @return array> + */ + private function getStripeModulesByOrderIds(array $orderIds): array + { + $orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn(int $id): bool => $id > 0))); + if (empty($orderIds)) { + return []; + } + + $rows = (new stripe_module_orders_o())->getFieldsWhereIn( + ['id' => $orderIds], + ['id', 'invoice_id', 'customer_id', 'url', 'created_at'] + ); + $byOrderId = []; + foreach ($rows as $row) { + $orderId = (int)($row['id'] ?? 0); + if ($orderId <= 0) { + continue; + } + $invoiceId = trim((string)($row['invoice_id'] ?? '')); + if ($invoiceId === '') { + continue; + } + $stripeSnapshot = $this->getStripeInvoiceSnapshot($invoiceId); + $byOrderId[$orderId] = [ + 'id' => $orderId, + 'invoice_id' => $invoiceId, + 'customer_id' => (string)($row['customer_id'] ?? ''), + 'url' => (string)($row['url'] ?? ''), + 'created_at' => (string)($row['created_at'] ?? ''), + 'paid' => (bool)($stripeSnapshot['paid'] ?? false), + 'status' => $stripeSnapshot['status'] ?? null, + 'amount_due' => $stripeSnapshot['amount_due'] ?? null, + 'amount_paid' => $stripeSnapshot['amount_paid'] ?? null, + ]; + } + + return $byOrderId; + } + + /** + * @return array{paid: bool, status: mixed, amount_due: mixed, amount_paid: mixed} + */ + private function getStripeInvoiceSnapshot(string $invoiceId): array + { + $cached = $this->getCachedStripeInvoiceSnapshot($invoiceId); + if ($cached !== null) { + return $cached; + } + + $invoice = (new stripe())->invoice->retrieve($invoiceId); + $snapshot = [ + 'paid' => (bool)($invoice->paid ?? false), + 'status' => $invoice->status ?? null, + 'amount_due' => $invoice->amount_due ?? null, + 'amount_paid' => $invoice->amount_paid ?? null, + ]; + $this->cacheStripeInvoiceSnapshot($invoiceId, $snapshot); + + return $snapshot; + } + + /** + * @return array{paid: bool, status: mixed, amount_due: mixed, amount_paid: mixed}|null + */ + private function getCachedStripeInvoiceSnapshot(string $invoiceId): ?array + { + if (!defined('redis')) { + return null; + } + + $cacheKey = 'orders_stripe_invoice_snapshot_' . $invoiceId; + $cachedRaw = redis->get($cacheKey); + if (!is_string($cachedRaw) || $cachedRaw === '') { + return null; + } + $decoded = json_decode($cachedRaw, true); + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array{paid: bool, status: mixed, amount_due: mixed, amount_paid: mixed} $snapshot + */ + private function cacheStripeInvoiceSnapshot(string $invoiceId, array $snapshot): void + { + if (!defined('redis')) { + return; + } + + $encoded = json_encode($snapshot); + if (!is_string($encoded) || $encoded === '') { + return; + } + + $cacheKey = 'orders_stripe_invoice_snapshot_' . $invoiceId; + redis->set($cacheKey, $encoded); + redis->expire($cacheKey, 30); + } + + /** + * @param int[] $customerNumbers + * @return array map: customer_number => user_id + */ + private function getUserIdsByCustomerNumbers(array $customerNumbers): array + { + $customerNumbers = array_values(array_unique(array_filter(array_map('intval', $customerNumbers), static fn(int $id): bool => $id > 0))); + if (empty($customerNumbers)) { + return []; + } + + $rows = (new users_o())->getFieldsWhereIn( + ['customer_number' => $customerNumbers], + ['id', 'customer_number'] + ); + + usort($rows, static fn(array $a, array $b): int => ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0))); + $map = []; + foreach ($rows as $row) { + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($customerNumber <= 0 || isset($map[$customerNumber])) { + continue; + } + $map[$customerNumber] = (int)($row['id'] ?? 0); + } + + // Keep parity with existing behavior that imports missing customer users via getUserByCustomerNumber. + foreach ($customerNumbers as $customerNumber) { + if (isset($map[$customerNumber])) { + continue; + } + $user = (new users_o())->getUserByCustomerNumber($customerNumber); + if ($user->exists()) { + $map[$customerNumber] = (int)$user->id; + } + } + + return $map; + } + + /** + * @param int[] $orderIds + * @return array map: order_id => pending_handheld + */ + private function getPendingHandheldFlags(array $orderIds): array + { + $orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn(int $id): bool => $id > 0))); + if (empty($orderIds)) { + return []; + } + + $flags = []; + foreach ($orderIds as $orderId) { + $flags[$orderId] = false; + } + if (!defined('redis')) { + return $flags; + } + + $cached = (new orders_o())->getCachedForMultipleObjects('pending_handheld_cache_indicator', $orderIds); + foreach ($orderIds as $index => $orderId) { + $flags[$orderId] = ((int)($cached[$index] ?? 0) === 1); + } + + return $flags; + } + /** * @param mixed $data * @param response $response @@ -1032,10 +1599,21 @@ class ordersRoute if (!isset($data['reg_1'])) { $response->error('Registration number 1 is required', 400); } + try { + $data['reg_1'] = orders_input_normalizer::normalizeRegistrationNumber($data['reg_1']); + if (array_key_exists('reg_2', $data)) { + $data['reg_2'] = orders_input_normalizer::normalizeRegistrationNumber($data['reg_2']); + } + if (array_key_exists('reg_3', $data)) { + $data['reg_3'] = orders_input_normalizer::normalizeRegistrationNumber($data['reg_3']); + } + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } if (strlen($data['reg_1']) < 4) { $response->error('Registration number 1 must be at least 4 characters', 400); } // Optional fields are not checked here, as they are optional and can be empty return $data; } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/pingRoute.php b/services/nginx/app/routes/pingRoute.php index 3ea9297f..4d48d45f 100644 --- a/services/nginx/app/routes/pingRoute.php +++ b/services/nginx/app/routes/pingRoute.php @@ -2,6 +2,7 @@ namespace routes; +use classes\release_manager; use traits\route_t; class pingRoute @@ -15,6 +16,8 @@ class pingRoute $response->success([ 'message' => 'pong', 'time' => date('c'), + 'backend_version' => release_manager::backendVersion(), + 'api_commit_sha' => release_manager::backendCommitSha(), ]); }); } diff --git a/services/nginx/app/routes/plateScannersRoute.php b/services/nginx/app/routes/plateScannersRoute.php index 7ce0d3dd..ab9c9f97 100644 --- a/services/nginx/app/routes/plateScannersRoute.php +++ b/services/nginx/app/routes/plateScannersRoute.php @@ -34,7 +34,11 @@ class plateScannersRoute 'name', 'notes' ]) - ->listObjectsWithPaginationIfSet() + ->listObjectsWithPaginationIfSet( + static function (array $scanner): array { + return (new plate_scanners_o())->select((int)$scanner['id'])->asArray(); + } + ) ); } else { // Log the incident @@ -68,12 +72,19 @@ class plateScannersRoute if (!isset($data['notes'])) { $response->error('Notes is required', 400); } + $laneId = array_key_exists('lane_id', $data) && $data['lane_id'] !== null + ? (int)$data['lane_id'] + : null; // Add the number plate scanner - (new plate_scanners_o())->add($data['department_id'], $data['name'], $data['notes']); + $scanner = new plate_scanners_o(); + $scanner->add((int)$data['department_id'], (string)$data['name'], (string)$data['notes'], $laneId); // Log the incident (new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ADD_NUMBER_PLATE_SCANNER', 'Successfully added a number plate scanner'); // Return a success message - $response->success(['message' => 'Number plate scanner added']); + $response->success([ + 'message' => 'Number plate scanner added', + 'scanner' => $scanner->asArray(), + ]); } else { // Log the incident (new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ADD_NUMBER_PLATE_SCANNER', 'No user found, or invalid session'); @@ -109,12 +120,25 @@ class plateScannersRoute if (!isset($data['notes'])) { $response->error('Notes is required', 400); } + $laneIdProvided = array_key_exists('lane_id', $data); + $laneId = $laneIdProvided && $data['lane_id'] !== null ? (int)$data['lane_id'] : null; // Edit the number plate scanner - (new plate_scanners_o())->edit($data['id'], $data['department_id'], $data['name'], $data['notes']); + $scanner = new plate_scanners_o(); + $scanner->edit( + (int)$data['id'], + (int)$data['department_id'], + (string)$data['name'], + (string)$data['notes'], + $laneId, + $laneIdProvided + ); // Log the incident (new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'EDIT_NUMBER_PLATE_SCANNER', 'Successfully edited a number plate scanner'); // Return a success message - $response->success(['message' => 'Number plate scanner edited']); + $response->success([ + 'message' => 'Number plate scanner edited', + 'scanner' => $scanner->asArray(), + ]); } else { // Log the incident (new logs_o())->add('numberplatescanners', 'global', 1, 0, 'EDIT_NUMBER_PLATE_SCANNER', 'No user found, or invalid session'); @@ -127,6 +151,35 @@ class plateScannersRoute ] ); + $this->post('/numberplatescanners/{id}/rotate-key', function () { + global $response; + $this->requirePermission('edit_number_plate_scanner'); + $user = (new authentication())->get_user(); + + if (!$user) { + (new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + $scannerId = (int)$this->fromRoute('id'); + self::requireParameterIntPositive($scannerId, 'id'); + $scanner = (new plate_scanners_o())->select($scannerId); + if (!$scanner->exists()) { + $response->error('Number plate scanner not found', 404); + } + + self::requireDepartmentAccess((int)$scanner->department_id->value()); + $rotatedScanner = (new plate_scanners_o())->rotateApiKey($scannerId); + (new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'Successfully rotated a number plate scanner API key'); + $response->success([ + 'message' => 'Number plate scanner API key rotated', + 'scanner' => $rotatedScanner, + 'api_key' => (string)$rotatedScanner['api_key'], + ]); + }, [ + 'edit_number_plate_scanner' => 'Rotate a number plate scanner API key', + ]); + self::get('/department/numberplatescanners', function () { // Require the user to be logged in global $response; @@ -152,12 +205,14 @@ class plateScannersRoute 'department_id' => (int)self::getParameter('id') ], [ 'id', + 'lane_id', 'name', 'notes' ]); // Parse the result foreach ( $result as $key => $value ) { $result[$key]['id'] = (int)$value['id']; + $result[$key]['lane_id'] = $value['lane_id'] === null ? null : (int)$value['lane_id']; } // Return the list of plate scanners $response->success( @@ -176,4 +231,4 @@ class plateScannersRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/productsRoute.php b/services/nginx/app/routes/productsRoute.php index 65d2cc48..f715873e 100644 --- a/services/nginx/app/routes/productsRoute.php +++ b/services/nginx/app/routes/productsRoute.php @@ -152,7 +152,7 @@ class productsRoute 'piktogram' => (string)$product['piktogram'], 'economic_product_id' => (int)$product['economic_product_id'], 'apply_category_discount' => (boolean)$product['apply_category_discount'], - 'requires_note' => (boolean)$product['requires_note'], + 'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product), 'created_at' => (string)$product['created_at'], 'updated_at' => (string)$product['updated_at'], 'addons' => (new product_options_o())->getProductOptions($product['id']), @@ -432,4 +432,4 @@ class productsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/releaseManagerRoute.php b/services/nginx/app/routes/releaseManagerRoute.php new file mode 100644 index 00000000..af6e3650 --- /dev/null +++ b/services/nginx/app/routes/releaseManagerRoute.php @@ -0,0 +1,533 @@ +get('/release/bootstrap', function () { + global $response; + $response->success((new release_manager())->bootstrap()); + }); + + $this->get('/release/runtime', function () { + global $response; + $response->success((new release_manager())->runtimeForCurrentPrincipal($this->getParametersAsArray())); + }); + + $this->post('/release/timeline/events', function () { + global $response; + $payload = $this->requestPayload(); + $events = is_array($payload['events'] ?? null) ? $payload['events'] : ($payload['event'] ?? $payload); + $context = is_array($payload['context'] ?? null) ? $payload['context'] : []; + $response->success((new release_manager())->ingestTimelineEvents( + is_array($events) ? $events : [], + $context + ), 202); + }); + + $this->post('/release/github/webhook', function () { + global $response; + try { + $headers = function_exists('getallheaders') ? getallheaders() : []; + $rawBody = file_get_contents('php://input') ?: ''; + $response->success((new release_manager())->handleGithubWebhook($headers, $rawBody), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 401); + } + }); + + $this->post('/release/gate/test-runs', function () { + global $response; + $manager = new release_manager(); + if (!$manager->verifyReleaseGateToken($this->releaseGateToken())) { + $response->error(['message' => 'Invalid release gate token.'], 401); + return; + } + + try { + $response->success($manager->runReleaseTest($this->requestPayload(), null), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }); + + $this->get('/superuser/releases', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->summary()); + }, [ + 'superuser_release_manager_view' => 'View release manager channels, deployments, and health', + ]); + + $this->get('/superuser/releases/config', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->releaseConfig()); + }, [ + 'superuser_release_manager_view' => 'View Release Manager source configuration', + ]); + + $this->get('/superuser/releases/operations', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->listOperations($this->getParametersAsArray())); + }, [ + 'superuser_release_manager_view' => 'View release operation runs', + ]); + + $this->get('/superuser/releases/operations/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + try { + $response->success((new release_manager())->operationDetail($this->routeId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 404); + } + }, [ + 'superuser_release_manager_view' => 'Inspect release operation diagnostics', + ]); + + $this->post('/superuser/releases/test-runs', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + $response->success((new release_manager())->runReleaseTest($this->requestPayload(), $this->actorUserId()), 202); + }, [ + 'superuser_release_manager_deploy' => 'Run Release Manager tests with operation diagnostics', + ]); + + $this->post('/superuser/releases/config', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->updateReleaseConfig($this->requestPayload(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_manage' => 'Update Release Manager source configuration', + ]); + + $this->get('/superuser/releases/github/repositories', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + try { + $response->success((new release_manager())->listGithubRepositories($this->getParametersAsArray())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_view' => 'List private GitHub repositories available to Release Manager', + ]); + + $this->get('/superuser/releases/github/branches', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->listGithubBranches($this->getParametersAsArray())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'List GitHub branches for a Release Manager repository', + ]); + + $this->get('/superuser/releases/github/commits', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->listGithubCommits($this->getParametersAsArray())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'List or resolve GitHub commits for a Release Manager repository', + ]); + + $this->post('/superuser/releases/github/test', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + $response->success((new release_manager())->testGithubRepositoryAccess($this->requestPayload())); + }, [ + 'superuser_release_manager_deploy' => 'Test Release Manager access to a GitHub repository, branch, and commit', + ]); + + $this->get('/superuser/releases/channels', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->listChannels()); + }, [ + 'superuser_release_manager_view' => 'View release channels', + ]); + + $this->post('/superuser/releases/channels', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->createChannel($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_manage' => 'Create release channels', + ]); + + $this->patch('/superuser/releases/channels/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->updateChannel($this->routeId(), $this->requestPayload(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_manage' => 'Update release channels', + ]); + + $this->post('/superuser/releases/channels/{id}/rollback', function () { + global $response; + $this->requirePermission('superuser_release_manager_rollback'); + try { + $response->success((new release_manager())->rollbackChannel($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_rollback' => 'Rollback an active release channel', + ]); + + $this->post('/superuser/releases/channels/{id}/sync', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->syncChannel($this->routeId(), $this->actorUserId()), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Sync the latest frontend and API branch commits into a release channel', + ]); + + $this->post('/superuser/releases/channels/{id}/bundle', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->setChannelBundle($this->routeId(), $this->requestPayload(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Set the active release bundle for a channel', + ]); + + $this->get('/superuser/releases/assignments', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->listAssignments()); + }, [ + 'superuser_release_manager_view' => 'View release channel assignments', + ]); + + $this->get('/superuser/releases/assignment-subjects', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + $response->success((new release_manager())->searchAssignmentSubjects($this->getParametersAsArray())); + }, [ + 'superuser_release_manager_manage' => 'Search users, subusers, and customers for Release Manager assignments', + ]); + + $this->post('/superuser/releases/assignments', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->createAssignment($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_manage' => 'Assign users, subusers, or customers to release channels', + ]); + + $this->delete('/superuser/releases/assignments/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->deleteAssignment($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 404); + } + }, [ + 'superuser_release_manager_manage' => 'Remove release channel assignments', + ]); + + $this->get('/superuser/releases/targets', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + $response->success((new release_manager())->listDeploymentTargets()); + }, [ + 'superuser_release_manager_deploy' => 'View release deployment targets', + ]); + + $this->post('/superuser/releases/targets', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->upsertDeploymentTarget($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Create or update GitHub to Coolify release deployment targets', + ]); + + $this->delete('/superuser/releases/targets/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->deleteDeploymentTarget($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 404); + } + }, [ + 'superuser_release_manager_deploy' => 'Delete release deployment targets', + ]); + + $this->get('/superuser/releases/service-sets', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->listServiceSets()); + }, [ + 'superuser_release_manager_view' => 'View reusable Release Manager service sets', + ]); + + $this->post('/superuser/releases/service-sets', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->createServiceSet($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Create reusable Release Manager service sets', + ]); + + $this->delete('/superuser/releases/service-sets/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success( + (new release_manager())->deleteServiceSet( + $this->routeId(), + $this->requestPayload(), + $this->actorUserId() + ) + ); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Remove inactive isolated Release Manager service sets', + ]); + + $this->post('/superuser/releases/service-sets/{id}/isolated-data-services', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success( + (new release_manager())->completeIsolatedStackDataServices( + $this->routeId(), + $this->requestPayload(), + $this->actorUserId() + ), + 202 + ); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Create missing isolated stack database, Redis, and MinIO services', + ]); + + $this->get('/superuser/releases/bundles', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $limit = (int)($this->getParameter('limit') ?? 50); + $response->success((new release_manager())->listBundles($limit)); + }, [ + 'superuser_release_manager_view' => 'View Release Manager bundles', + ]); + + $this->post('/superuser/releases/bundles', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->createBundle($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Create Release Manager full-stack bundles', + ]); + + $this->post('/superuser/releases/bundles/{id}/deploy', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->deployBundle($this->routeId(), $this->actorUserId()), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Deploy Release Manager full-stack bundles', + ]); + + $this->post('/superuser/releases/bundles/{id}/promote', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->promoteBundle($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Promote Release Manager bundles without data failover', + ]); + + $this->get('/superuser/releases/deployments', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $limit = (int)($this->getParameter('limit') ?? 50); + $response->success((new release_manager())->listDeployments($limit)); + }, [ + 'superuser_release_manager_view' => 'View release deployments', + ]); + + $this->post('/superuser/releases/deployments', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->startDeployment($this->requestPayload(), $this->actorUserId()), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Trigger release deployments from GitHub/Coolify targets', + ]); + + $this->post('/superuser/releases/deployments/{id}/promote', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->promoteDeployment($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Promote a deployment to its release channel', + ]); + + $this->post('/superuser/releases/issues/actions', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + $response->success((new release_manager())->runIssueAction($this->requestPayload(), $this->actorUserId())); + }, [ + 'superuser_release_manager_deploy' => 'Run a safe Release Manager issue resolution action', + ]); + + $this->post('/superuser/releases/replay-targets', function () { + global $response; + $this->requirePermission('superuser_release_manager_replay'); + try { + $response->success((new release_manager())->setReplayTarget($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_replay' => 'Enable release timeline replay capture for a user, customer, subuser, or channel', + ]); + + $this->get('/superuser/releases/timeline/sessions', function () { + global $response; + $this->requirePermission('superuser_release_manager_replay'); + $response->success((new release_manager())->listTimelineSessions($this->getParametersAsArray())); + }, [ + 'superuser_release_manager_replay' => 'List release timeline replay sessions', + ]); + + $this->get('/superuser/releases/timeline/sessions/{traceId}', function () { + global $response; + $this->requirePermission('superuser_release_manager_replay'); + try { + $response->success((new release_manager())->timelineSessionDetail((string)$this->fromRoute('traceId'))); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 404); + } + }, [ + 'superuser_release_manager_replay' => 'Inspect one release timeline replay session', + ]); + + $this->get('/superuser/releases/timeline', function () { + global $response; + $this->requirePermission('superuser_release_manager_replay'); + $response->success((new release_manager())->searchTimeline($this->getParametersAsArray())); + }, [ + 'superuser_release_manager_replay' => 'Replay release failure timelines', + ]); + } + + private function routeId(): int + { + $id = (int)$this->fromRoute('id'); + $this->requireParameterIntPositive($id, 'id'); + return $id; + } + + private function actorUserId(): ?int + { + try { + $user = (new authentication())->get_user(); + return $user !== false && isset($user->id) ? (int)$user->id : null; + } catch (Throwable) { + return null; + } + } + + private function requestPayload(): array + { + $payload = json_decode(file_get_contents('php://input'), true); + if (!is_array($payload)) { + $payload = []; + } + + if ($_GET !== []) { + $payload = array_replace($payload, $_GET); + } + + return $payload; + } + + private function releaseGateToken(): string + { + $headers = function_exists('getallheaders') ? getallheaders() : []; + $authorization = (string)($headers['Authorization'] ?? $headers['authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? ''); + if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches) === 1) { + return trim($matches[1]); + } + + return trim((string)( + $headers['X-Release-Gate-Token'] + ?? $headers['x-release-gate-token'] + ?? $_SERVER['HTTP_X_RELEASE_GATE_TOKEN'] + ?? '' + )); + } +} diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 2c272bb7..b0b36e6e 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -25,6 +25,17 @@ class subusersRoute { use route_t; + private function getOwnPermissionForNode(subusers_permission_node_key $node) + { + return match ($node) { + subusers_permission_node_key::SUBUSERS_LIST => self::definePermission('list_own_subusers', subusers_permission_node_key::SUBUSERS_LIST), + subusers_permission_node_key::SUBUSERS_ADD => self::definePermission('add_own_subusers', subusers_permission_node_key::SUBUSERS_ADD), + subusers_permission_node_key::SUBUSERS_EDIT => self::definePermission('edit_own_subusers', subusers_permission_node_key::SUBUSERS_EDIT), + subusers_permission_node_key::SUBUSERS_DELETE => self::definePermission('delete_own_subusers', subusers_permission_node_key::SUBUSERS_DELETE), + default => self::definePermission(strtolower($node->name), $node), + }; + } + private static function requireRegex(string $input, string $regex, string $error_message): void { if (!preg_match($regex, $input)) { @@ -33,6 +44,610 @@ class subusersRoute } } + private function requireSubuserPasswordPolicy(string $password): void + { + self::requireType($password, self::type_string()); + self::requireMinLength('password', subusers_o::PASSWORD_MIN_LENGTH); + self::requireMaxLength('password', subusers_o::PASSWORD_MAX_LENGTH); + self::requireRegex($password, subusers_o::PASSWORD_PATTERN, subusers_o::PASSWORD_COMPLEXITY_MESSAGE); + } + + private function requireManagedCustomerScope(subusers_permission_node_key $node, ?int $targetCustomerNumber = null): int + { + global $response; + + $auth = new authentication(); + $permission = $this->getOwnPermissionForNode($node); + + $subuser = $auth->get_subuser(); + if ($subuser !== false) { + $customerNumber = (int)$auth->get_subuser_customer_number_target(); + if ($customerNumber <= 0) { + $response->error('Unauthorized', 401); + } + if ($targetCustomerNumber !== null && $customerNumber !== (int)$targetCustomerNumber) { + $this->emitForbidden([$permission]); + } + self::requirePermission($permission); + return $customerNumber; + } + + $user = $auth->get_user(); + if ($user !== false) { + $customerNumber = (int)$user->customer_number->value(); + if ($customerNumber <= 0) { + $response->error('Unauthorized', 401); + } + if ($targetCustomerNumber !== null && $customerNumber !== (int)$targetCustomerNumber) { + $this->emitForbidden([$permission]); + } + return $customerNumber; + } + + $response->error('Unauthorized', 401); + return 0; + } + + private function parsePermissionsPayload(mixed $raw, ?array $default = null): ?array + { + global $response; + + if ($raw === null) { + return $default; + } + + if (is_string($raw)) { + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + $response->error('Invalid permissions payload', 400); + } + $raw = $decoded; + } + + if (!is_array($raw)) { + $response->error('Invalid permissions type', 400); + } + + $permissions = []; + foreach ($raw as $permission) { + if (!is_string($permission) || subusers_permission_node_key::tryFrom($permission) === null) { + $response->error('Unknown permission key: ' . (string)$permission, 400); + } + $permissions[] = strtoupper(trim($permission)); + } + + return array_values(array_unique($permissions)); + } + + private function normalizeOptionalString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + return $normalized === '' ? null : $normalized; + } + + private function resolveCustomerNames(array $customerNumbers): array + { + $customerNumbers = array_values(array_unique(array_filter( + array_map('intval', $customerNumbers), + static fn (int $customerNumber): bool => $customerNumber > 0 + ))); + + if ($customerNumbers === []) { + return []; + } + + return (new users_o())->getCustomerNames($customerNumbers, false); + } + + private function resolveCustomerName(int $customerNumber, array $customerNames = []): ?string + { + if ($customerNumber <= 0) { + return null; + } + + $key = (string)$customerNumber; + $name = $customerNames[$key] ?? null; + if (!is_string($name)) { + $name = $this->resolveCustomerNames([$customerNumber])[$key] ?? null; + } + + $name = trim((string)$name); + return $name === '' || $name === 'Unknown Customer' ? null : $name; + } + + private function assertSubuserIdentifiersAvailable( + ?int $phoneCountryCode, + ?int $phone, + ?string $username = null, + ?string $email = null, + ?int $ignoreSubuserId = null + ): void { + global $response; + + if ($phoneCountryCode !== null && $phone !== null) { + $existingByPhone = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone); + if ($existingByPhone !== null && (int)$existingByPhone->id !== (int)$ignoreSubuserId) { + $response->error('Account already exists with this phone number', 400); + } + } + + if ($username !== null) { + $existingByUsername = (new subusers_o())->getSubuserByUsername($username); + if ($existingByUsername !== null && (int)$existingByUsername->id !== (int)$ignoreSubuserId) { + $response->error('Account already exists with this username', 400); + } + } + + if ($email !== null) { + $existingByEmail = (new subusers_o())->getSubuserByEmail($email); + if ($existingByEmail !== null && (int)$existingByEmail->id !== (int)$ignoreSubuserId) { + $response->error('Account already exists with this email address', 400); + } + } + } + + private function resolveSubuserByIdentifiers( + ?int $phoneCountryCode, + ?int $phone, + ?string $username = null, + ?string $email = null + ): ?subusers_o { + global $response; + + $matches = []; + + if ($phoneCountryCode !== null && $phone !== null) { + $existingByPhone = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone); + if ($existingByPhone !== null) { + $matches[(int)$existingByPhone->id] = $existingByPhone; + } + } + + if ($username !== null) { + $existingByUsername = (new subusers_o())->getSubuserByUsername($username); + if ($existingByUsername !== null) { + $matches[(int)$existingByUsername->id] = $existingByUsername; + } + } + + if ($email !== null) { + $existingByEmail = (new subusers_o())->getSubuserByEmail($email); + if ($existingByEmail !== null) { + $matches[(int)$existingByEmail->id] = $existingByEmail; + } + } + + if (count($matches) > 1) { + $response->error('Provided driver identifiers match multiple existing accounts', 409); + } + + return count($matches) === 1 ? array_values($matches)[0] : null; + } + + private function buildSetupLink(string $token): string + { + return 'https://truckwash.io/complete-registration?token=' . $token; + } + + private function issueSetupInvite(subusers_o $subuser): array + { + if (!$subuser->requiresSetup()) { + return [ + 'setup_token' => null, + 'setup_link' => null, + 'delivery' => [ + 'channel' => 'sms', + 'status' => 'not_required', + 'message' => 'Driver account already accepted the invitation.', + ], + ]; + } + + $token = $subuser->generateSetupToken(); + $link = $this->buildSetupLink($token); + $delivery = [ + 'channel' => 'sms', + 'status' => 'unavailable', + 'message' => 'SMS delivery is not configured.', + ]; + + try { + $gatewayAPI = new gatewayapi(); + if ($gatewayAPI->isEnabled()) { + $phoneNumber = (string)$subuser->phone_country_code->value() . (string)$subuser->phone->value(); + $message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link; + $gatewayAPI->send([$phoneNumber], $message); + $delivery = [ + 'channel' => 'sms', + 'status' => 'sent', + 'message' => 'Invite sent successfully.', + ]; + } + } catch (Exception $exception) { + $delivery = [ + 'channel' => 'sms', + 'status' => 'failed', + 'message' => $exception->getMessage(), + ]; + } + + return [ + 'setup_token' => $token, + 'setup_link' => $link, + 'delivery' => $delivery, + ]; + } + + private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array + { + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); + $grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : []; + $setupRequired = $subuser->requiresSetup(); + $grantEnabled = $grant ? (bool)$grant->enabled->value() : false; + $inviteAccepted = !$setupRequired; + + $accessState = 'inactive'; + if ($grant !== null && $grantEnabled) { + $accessState = $setupRequired ? 'pending_setup' : 'active'; + } elseif ($grant !== null) { + $accessState = 'disabled'; + } + + return [ + 'id' => (int)$subuser->id, + 'username' => $subuser->username->value(), + 'name' => $subuser->name->value(), + 'email' => $subuser->email->value(), + 'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null, + 'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, + 'created_at' => $subuser->created_at->value() ?? null, + 'updated_at' => $subuser->updated_at->value() ?? null, + 'suspended_at' => $subuser->suspended_at->value() ?? null, + 'two_factor_enabled' => $subuser->isTwoFactorEnabled(), + 'setup_required' => $setupRequired, + 'invite_accepted' => $inviteAccepted, + 'can_resend_invite' => $setupRequired, + 'profile_editable_by_manager' => false, + 'customer_number' => $customerNumber, + 'customer_name' => $customerName ?? $this->resolveCustomerName($customerNumber), + 'grant_id' => $grant ? (int)$grant->id : null, + 'grant_enabled' => $grantEnabled, + 'grant_note' => $grant ? $grant->note->value() : null, + 'grant_permissions' => $grantPermissions, + 'permissions' => $grantPermissions, + 'access_state' => $accessState, + ]; + } + + private function buildCurrentSubuserPayload(subusers_o $subuser): array + { + $grants = (new subuser_grants_o())->getFieldsWhere([ + 'subuser' => $subuser->id, + 'enabled' => 1, + 'deleted_at' => null, + ], ['permissions', 'billing_customer_number']); + $customerNames = $this->resolveCustomerNames(array_map( + static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0), + $grants + )); + + return [ + 'id' => (int)$subuser->id, + 'username' => $subuser->username->value(), + 'name' => $subuser->name->value(), + 'email' => $subuser->email->value(), + 'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null, + 'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, + 'grants' => array_map(function ($grant) use ($customerNames) { + $customerNumber = (int)$grant['billing_customer_number']; + return [ + 'name' => $this->resolveCustomerName($customerNumber, $customerNames), + 'billing_customer_number' => $customerNumber, + 'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null), + ]; + }, $grants), + 'created_at' => $subuser->created_at->value() ?? null, + 'updated_at' => $subuser->updated_at->value() ?? null, + 'suspended_at' => $subuser->suspended_at->value() ?? null, + 'two_factor_enabled' => $subuser->isTwoFactorEnabled(), + ]; + } + + private function parseSuperuserPaginationRequest(): array + { + global $response; + + $page = max(1, (int)($response->getRequestParameter('page') ?: 1)); + $limitRaw = $response->getRequestParameter('limit'); + $limit = is_string($limitRaw) && strtolower($limitRaw) === 'all' + ? 1000 + : (int)($limitRaw ?: 100); + $limit = max(1, min($limit, 1000)); + + $search = $this->normalizeOptionalString($response->getRequestParameter('search')); + $orderRaw = (string)($response->getRequestParameter('order') ?: 'created_at:DESC'); + $orderParts = explode(':', $orderRaw, 2); + $orderField = $orderParts[0] ?? 'created_at'; + $orderDirection = strtoupper($orderParts[1] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; + $allowedOrderFields = [ + 'id' => 's.`id`', + 'created_at' => 's.`created_at`', + 'updated_at' => 'row_updated_at', + 'customer_number' => 'g.`billing_customer_number`', + 'grant_id' => 'g.`id`', + 'name' => 's.`name`', + ]; + + if (!isset($allowedOrderFields[$orderField])) { + $orderField = 'created_at'; + } + + return [ + 'page' => $page, + 'limit' => $limit, + 'search' => $search, + 'order_field' => $orderField, + 'order_sql' => $allowedOrderFields[$orderField], + 'order_direction' => $orderDirection, + ]; + } + + private function bindStatementParameters(\mysqli_stmt $statement, string $types, array $params): void + { + if ($params === []) { + return; + } + + $refs = []; + foreach ($params as $key => $value) { + $refs[$key] = &$params[$key]; + } + + $statement->bind_param($types, ...$refs); + } + + private function buildSuperuserSubuserManagementPayload(array $row): array + { + $grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null); + $setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === ''; + $grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0)); + + $accessState = 'inactive'; + if (!empty($row['grant_id']) && $grantEnabled) { + $accessState = $setupRequired ? 'pending_setup' : 'active'; + } elseif (!empty($row['grant_id'])) { + $accessState = 'disabled'; + } + + return [ + 'id' => (int)$row['id'], + 'username' => $row['username'] ?? null, + 'name' => $row['name'] ?? null, + 'email' => $row['email'] ?? null, + 'phone_country_code' => $row['phone_country_code'] !== null ? (int)$row['phone_country_code'] : null, + 'phone' => $row['phone'] !== null ? (int)$row['phone'] : null, + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['row_updated_at'] ?? $row['updated_at'] ?? null, + 'suspended_at' => $row['suspended_at'] ?? null, + 'two_factor_enabled' => (bool)((int)($row['two_factor_enabled'] ?? 0)), + 'setup_required' => $setupRequired, + 'invite_accepted' => !$setupRequired, + 'can_resend_invite' => $setupRequired, + 'profile_editable_by_manager' => false, + 'customer_number' => (int)$row['customer_number'], + 'customer_name' => $row['customer_name'] ?: null, + 'grant_id' => (int)$row['grant_id'], + 'grant_enabled' => $grantEnabled, + 'grant_note' => $row['grant_note'] ?? null, + 'grant_permissions' => $grantPermissions, + 'permissions' => $grantPermissions, + 'grant_created_at' => $row['grant_created_at'] ?? null, + 'grant_updated_at' => $row['grant_updated_at'] ?? null, + 'access_state' => $accessState, + ]; + } + + private function listSuperuserSubusers(): array + { + global $db, $response; + + $pagination = $this->parseSuperuserPaginationRequest(); + $offset = ((int)$pagination['page'] - 1) * (int)$pagination['limit']; + $where = ['g.`deleted_at` IS NULL']; + $params = []; + $types = ''; + + $includeNonEnabled = true; + if (self::isParametersSet(['include_non_enabled'])) { + $tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $includeNonEnabled = $tmp === null ? true : (bool)$tmp; + } + if (!$includeNonEnabled) { + $where[] = 'g.`enabled` = 1'; + } + + if ($pagination['search'] !== null) { + $where[] = "( + CAST(s.`id` AS CHAR) LIKE ? + OR s.`username` LIKE ? + OR s.`name` LIKE ? + OR s.`email` LIKE ? + OR CAST(s.`phone_country_code` AS CHAR) LIKE ? + OR CAST(s.`phone` AS CHAR) LIKE ? + OR CAST(g.`billing_customer_number` AS CHAR) LIKE ? + OR g.`note` LIKE ? + OR u.`display_name` LIKE ? + )"; + $search = '%' . $pagination['search'] . '%'; + for ($i = 0; $i < 9; $i++) { + $params[] = $search; + $types .= 's'; + } + } + + $whereSql = 'WHERE ' . implode(' AND ', $where); + $fromSql = " + FROM `subuser_grants` g + INNER JOIN `subusers` s ON s.`id` = g.`subuser` + LEFT JOIN `users` u ON u.`customer_number` = g.`billing_customer_number` + "; + + $countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql"; + $countStatement = $db->conn->prepare($countSql); + if ($countStatement === false) { + throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error); + } + $this->bindStatementParameters($countStatement, $types, $params); + $countStatement->execute(); + $countResult = $countStatement->get_result(); + $total = (int)($countResult->fetch_assoc()['count'] ?? 0); + $countStatement->close(); + + $dataSql = " + SELECT + s.`id`, + s.`username`, + s.`password`, + s.`name`, + s.`email`, + s.`phone_country_code`, + s.`phone`, + s.`two_factor_enabled`, + s.`created_at`, + s.`updated_at`, + s.`suspended_at`, + g.`id` AS `grant_id`, + g.`billing_customer_number` AS `customer_number`, + g.`enabled` AS `grant_enabled`, + g.`note` AS `grant_note`, + g.`permissions` AS `grant_permissions`, + g.`created_at` AS `grant_created_at`, + g.`updated_at` AS `grant_updated_at`, + COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`, + u.`display_name` AS `customer_name` + $fromSql + $whereSql + ORDER BY {$pagination['order_sql']} {$pagination['order_direction']} + LIMIT ? OFFSET ? + "; + + $dataStatement = $db->conn->prepare($dataSql); + if ($dataStatement === false) { + throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error); + } + $dataParams = [...$params, (int)$pagination['limit'], $offset]; + $this->bindStatementParameters($dataStatement, $types . 'ii', $dataParams); + $dataStatement->execute(); + $result = $dataStatement->get_result(); + $rows = $result->fetch_all(MYSQLI_ASSOC); + $dataStatement->close(); + + $response->paginate( + (int)$pagination['page'], + (int)$pagination['limit'], + $total, + $pagination['search'], + null, + [$pagination['order_field'] => $pagination['order_direction']] + ); + + return array_map(fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row), $rows); + } + + private function handleInviteSubuserForCustomer(int $customerNumber): void + { + global $response; + + if ($customerNumber <= 0) { + $response->error('Customer number is required', 400); + } + + self::requireParameters(['name', 'phone_country_code', 'phone']); + + $name = $this->normalizeOptionalString(self::getParameter('name')); + $phoneCountryCode = (int)self::getParameter('phone_country_code'); + $phone = (int)self::getParameter('phone'); + $note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null; + $enabled = true; + if (self::isParametersSet(['enabled'])) { + $tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $enabled = $tmp === null ? true : (bool)$tmp; + } + $permissions = self::isParametersSet(['permissions']) + ? $this->parsePermissionsPayload(self::getParameter('permissions'), []) + : null; + + if ($name === null || strlen($name) < 3 || strlen($name) > 255) { + $response->error('Name must be between 3 and 255 characters long', 400); + } + self::requireType($phoneCountryCode, self::type_int()); + self::requireType($phone, self::type_int()); + self::requireMinLength('phone_country_code', 1); + self::requireMaxLength('phone_country_code', 3); + self::requireMinLength('phone', 4); + self::requireMaxLength('phone', 15); + if ($note !== null && strlen($note) > 65535) { + $response->error('Note must be at most 65535 characters long', 400); + } + + $subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone); + if ($subuser === null) { + try { + $subuser = (new subusers_o())->add( + null, + null, + $name, + null, + $phoneCountryCode, + $phone + ); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + } + + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); + if ($grant === null) { + try { + $grant = (new subuser_grants_o())->add( + $customerNumber, + (int)$subuser->id, + $enabled, + $note, + $permissions ?? subuser_grants_o::defaultPermissions + ); + } catch (Exception $exception) { + $response->error('Failed to create subuser grant', 500); + } + } else { + $grantUpdates = ['enabled' => $enabled]; + if (self::isParametersSet(['note'])) { + $grantUpdates['note'] = $note; + } + if ($permissions !== null) { + $grantUpdates['permissions'] = $permissions; + } + try { + $grant->update($grantUpdates); + } catch (Exception $exception) { + $response->error('Failed to update subuser grant', 500); + } + $grant = (new subuser_grants_o())->select((int)$grant->id); + $grant->getObjectProperties(); + } + + $invite = $this->issueSetupInvite($subuser); + $response->success([ + 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), + 'grant' => $grant->asArray(), + 'invite' => $invite, + ]); + } + public function run(): void { // ============================= @@ -79,7 +694,7 @@ class subusersRoute // If not admin/department permission, force restrict to effective customer if (!$has_permission_other) { if ($effectiveCustomer === null) { - $response->error('Missing customer context. Subusers must provide X-Customer-Number header.', 403); + $response->forbidden([$permission_other->permission]); } $filters['billing_customer_number'] = (int)$effectiveCustomer; $hasFilter = true; // ensure we don't fail below @@ -108,7 +723,7 @@ class subusersRoute 'name' => $subuser->name->value(), 'enabled' => $o->enabled, 'note' => $o->note, - 'permissions' => json_decode($o->permissions, true) ?: [], + 'permissions' => subuser_grants_o::normalizePermissionsValue($o->permissions ?? null), 'created_at' => $o->created_at, 'updated_at' => $o->updated_at, ]; @@ -131,15 +746,9 @@ class subusersRoute self::requireType($customer_number, self::type_int()); self::requireType($subuser_id, self::type_int()); - // Enforce own-vs-admin access using target customer number for context - self::allowOwnOrDepartmentAccess( - $permission_own, - $permission_other, - (int)$customer_number, - null, - null, - 'You do not have permission to create subuser grants for this customer.' - ); + if (!self::hasPermission($permission_other, (int)$customer_number)) { + $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD, (int)$customer_number); + } $enabled = true; if (self::isParametersSet(['enabled'])) { @@ -189,6 +798,7 @@ class subusersRoute global $response; /** Permissions (subuser-aware) */ $permission_own = self::definePermission('edit_own_subusers', subusers_permission_node_key::SUBUSERS_EDIT); + $permission_delete_own = self::definePermission('delete_own_subusers', subusers_permission_node_key::SUBUSERS_DELETE); $permission_other = self::definePermission('manage_subuser_grants'); self::requireParameters(['id']); @@ -198,16 +808,17 @@ class subusersRoute $response->error('Grant not found', 404); } - // Enforce own-vs-admin using the grant's customer number $targetCustomer = (int)$grant->billing_customer_number->value(); - self::allowOwnOrDepartmentAccess( - $permission_own, - $permission_other, - $targetCustomer, - null, - null, - 'You do not have permission to modify this subuser grant.' - ); + if (!self::hasPermission($permission_other, $targetCustomer)) { + $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT, $targetCustomer); + } + + if (self::isParametersSet(['enabled'])) { + $enabledPreview = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($enabledPreview === false && !self::hasPermission($permission_other)) { + $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_DELETE, $targetCustomer); + } + } // Update fields provided in the request if (self::isParametersSet(['enabled'])) { @@ -247,7 +858,8 @@ class subusersRoute }, [ 'manage_subuser_grants' => 'Edit subuser grants for any customer (admin).', - 'edit_own_subusers' => 'Edit subuser grants for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.' + 'edit_own_subusers' => 'Edit subuser grants for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.', + 'delete_own_subusers' => 'Disable subuser grants for own customer. Subusers require node: SUBUSERS_DELETE and X-Customer-Number header.' ] ); @@ -373,10 +985,7 @@ class subusersRoute $token = (string)self::getParameter('token'); $password = (string)self::getParameter('password'); $name = (string)self::getParameter('name'); - self::requireType($password, self::type_string()); - self::requireMinLength('password', 8); - self::requireMaxLength('password', 255); - self::requireRegex($password, '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/', 'Password must contain at least one uppercase letter, one lowercase letter, and one number'); + $this->requireSubuserPasswordPolicy($password); self::requireType($name, self::type_string()); self::requireMinLength('name', 3); self::requireMaxLength('name', 255); @@ -471,9 +1080,7 @@ class subusersRoute } self::requireParameters(['password']); $password = (string)self::getParameter('password'); - self::requireType($password, self::type_string()); - self::requireMinLength('password', 8); - self::requireMaxLength('password', 255); + $this->requireSubuserPasswordPolicy($password); try { if (password_verify($password, $subuser->password->value())) { if ($subuser->isTwoFactorEnabled()) { @@ -494,71 +1101,48 @@ class subusersRoute // ============================= // Subusers - List & Get (with grant visibility) // ============================= + $this->get('/superuser/subusers', function () { + global $response; + $this->requirePermission('list_subusers'); + $response->success($this->listSuperuserSubusers()); + }, [ + 'list_subusers' => 'List all chauffeur access grants for superusers.', + ]); + $this->get('/subusers', function () { global $response; - // Require authenticated principal (user or subuser) - $auth = new authentication(); - $user = $auth->get_user(); - $subuser = $auth->get_subuser(); - if ($user === false && $subuser === false) { - $response->error('Unauthorized', 401); - } - - // Link route permission to Subusers node so subusers can be constrained by grants. - // For classic users we keep current behavior (no extra user permission enforced here). - $permission_list = self::definePermission('list_own_subusers', subusers_permission_node_key::SUBUSERS_LIST); - if ($subuser !== false) { - // Only enforce for subuser principals; classic users are governed by existing user ACLs elsewhere. - self::requirePermission($permission_list); - } - - // Determine effective customer number (user's customer number or subuser's target header) - if ($user !== false) { - $customerNumber = (int)$user->customer_number->value(); - } else { - $customerNumber = (int)$auth->get_subuser_customer_number_target(); - } - if (empty($customerNumber)) { - $response->error('Unauthorized', 401); - } - - // Optional: include_non_enabled (boolean) — when true, include subusers that only have non-enabled grants + $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST); + $customerName = $this->resolveCustomerName($customerNumber); $includeNonEnabled = false; if (self::isParametersSet(['include_non_enabled'])) { $tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $includeNonEnabled = $tmp === null ? false : (bool)$tmp; } - // Only list subusers that have an enabled grant for the caller's customer number $existsClause = sprintf( "EXISTS (SELECT 1 FROM `subuser_grants` sg WHERE sg.`subuser` = `subusers`.`id` AND %ssg.`deleted_at` IS NULL AND sg.`billing_customer_number` = %d)", $includeNonEnabled ? '' : 'sg.`enabled` = 1 AND ', $customerNumber ); - // Use pagination helper with additional where $objects = (new subusers_o()) - ->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber) { - $o = (object)$o; - $permissions = (new subuser_grants_o()) - ->getGrantsForSubuserAndCustomer((int)$o->id, $customerNumber); - return [ - 'id' => (int)$o->id, - 'username' => $o->username, - 'name' => $o->name, - 'email' => $o->email, - 'phone_country_code' => isset($o->phone_country_code) ? (int)$o->phone_country_code : null, - 'phone' => isset($o->phone) ? (int)$o->phone : null, - 'created_at' => $o->created_at ?? null, - 'updated_at' => $o->updated_at ?? null, - 'suspended_at' => $o->suspended_at ?? null, - 'permissions' => $permissions, - 'two_factor_enabled' => $o->isTwoFactorEnabled(), - ]; + ->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber, $customerName) { + $subuser = (new subusers_o())->select((int)$o['id']); + if (!$subuser->exists()) { + return null; + } + $subuser->getObjectProperties(); + return $this->buildSubuserManagementPayload($subuser, $customerNumber, $customerName); }, null, [], $existsClause); + if (is_array($objects)) { + $objects = array_values(array_filter($objects, static fn ($item) => $item !== null)); + } + $response->success($objects); - }, []); + }, [ + 'list_own_subusers' => 'List chauffeurs for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.', + ]); $this->get('/subusers/me', function () { global $response; @@ -567,34 +1151,160 @@ class subusersRoute $response->error('Unauthorized', 401); } - $grants = (new subuser_grants_o())->getFieldsWhere([ - 'subuser' => $subuser->id, - 'enabled' => 1, - 'deleted_at' => null, - ], ['permissions', 'billing_customer_number']); - - $result = [ - "id" => (int)$subuser->id, - "username" => $subuser->username->value(), - "name" => $subuser->name->value(), - "email" => $subuser->email->value(), - "phone_country_code" => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null, - "phone" => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, - "grants" => array_map(function ($grant) { - return [ - 'name' => (new users_o())->getCustomerName((int)$grant['billing_customer_number']), - 'billing_customer_number' => (int)$grant['billing_customer_number'], - 'permissions' => json_decode($grant['permissions'], true) ?: [], - ]; - }, $grants), - "created_at" => $subuser->created_at->value() ?? null, - "updated_at" => $subuser->updated_at->value() ?? null, - "suspended_at" => $subuser->suspended_at->value() ?? null, - "two_factor_enabled" => $subuser->isTwoFactorEnabled(), - ]; - $response->success($result); + $response->success($this->buildCurrentSubuserPayload($subuser)); }, []); + $this->put('/subusers/me', function () { + global $response; + $subuser = (new authentication())->get_subuser(); + if ($subuser === false) { + $response->error('Unauthorized', 401); + } + + $updates = []; + + if (self::isParametersSet(['name'])) { + $name = $this->normalizeOptionalString(self::getParameter('name')); + if ($name === null || strlen($name) < 3 || strlen($name) > 255) { + $response->error('Name must be between 3 and 255 characters long', 400); + } + $updates['name'] = $name; + } + + if (self::isParametersSet(['username'])) { + $username = $this->normalizeOptionalString(self::getParameter('username')); + if ($username !== null && (strlen($username) < 3 || strlen($username) > 50)) { + $response->error('Username must be between 3 and 50 characters long', 400); + } + $updates['username'] = $username; + } + + if (self::isParametersSet(['email'])) { + $email = $this->normalizeOptionalString(self::getParameter('email')); + if ($email !== null && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $response->error('Invalid email format', 400); + } + if ($email !== null && strlen($email) > 255) { + $response->error('Email must be at most 255 characters long', 400); + } + $updates['email'] = $email; + } + + if (count($updates) === 0) { + $response->error('No fields to update', 400); + } + + $this->assertSubuserIdentifiersAvailable( + null, + null, + $updates['username'] ?? null, + $updates['email'] ?? null, + (int)$subuser->id + ); + + try { + $subuser->update($updates); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + + $subuser = (new subusers_o())->select((int)$subuser->id); + $subuser->getObjectProperties(); + $response->success($this->buildCurrentSubuserPayload($subuser)); + }, []); + + $this->post('/superuser/subusers/invite', function () { + self::requireParameters(['customer_number']); + $this->requirePermission('add_subusers'); + $customerNumber = (int)self::getParameter('customer_number'); + self::requireType($customerNumber, self::type_int()); + $this->handleInviteSubuserForCustomer($customerNumber); + }, [ + 'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).', + ]); + + $this->post('/subusers/invite', function () { + $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD); + $this->handleInviteSubuserForCustomer($customerNumber); + }, [ + 'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.', + ]); + + $this->post('/superuser/subusers/invite/resend', function () { + global $response; + + $this->requirePermission('edit_subusers'); + self::requireParameters(['id', 'customer_number']); + $subuserId = (int)self::getParameter('id'); + self::requireType($subuserId, self::type_int()); + $customerNumber = (int)self::getParameter('customer_number'); + self::requireType($customerNumber, self::type_int()); + + $subuser = (new subusers_o())->select($subuserId); + if (!$subuser->exists()) { + $response->error('Subuser not found', 404); + } + $subuser->getObjectProperties(); + + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true); + if ($grant === null) { + $response->error('Subuser grant not found for selected customer', 404); + } + + if (!$subuser->requiresSetup()) { + $response->error('Driver account already accepted the invitation.', 409); + } + + $invite = $this->issueSetupInvite($subuser); + $response->success([ + 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), + 'grant' => $grant->asArray(), + 'invite' => $invite, + ]); + }, [ + 'edit_subusers' => 'Resend chauffeur invites for a selected customer (superuser).', + ]); + + $this->post('/subusers/invite/resend', function () { + global $response; + + self::requireParameters(['id']); + $subuserId = (int)self::getParameter('id'); + self::requireType($subuserId, self::type_int()); + + $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT); + $subuser = (new subusers_o())->select($subuserId); + if (!$subuser->exists()) { + $response->error('Subuser not found', 404); + } + $subuser->getObjectProperties(); + + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true); + if ($grant === null) { + $response->error('Subuser grant not found for selected customer', 404); + } + + if (!$subuser->requiresSetup()) { + $response->error('Driver account already accepted the invitation.', 409); + } + + $invite = $this->issueSetupInvite($subuser); + $response->success([ + 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), + 'grant' => $grant->asArray(), + 'invite' => $invite, + ]); + }, [ + 'edit_own_subusers' => 'Resend chauffeur invite for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.', + ]); + + $this->put('/subusers', function () { + global $response; + $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT); + $response->error('Customers can only manage subuser grants. Drivers own their account profile.', 403); + }, [ + 'edit_own_subusers' => 'Customers cannot edit chauffeur account profiles. They may only manage grants, permissions, and enabled state.', + ]); // Public registration endpoint (alias of POST /subusers) matching OpenAPI: POST /subusers/me $this->post('/subusers/me', function () { global /** @var response $response */ @@ -673,4 +1383,4 @@ class subusersRoute $response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]); }); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/superuserCoolifyRoute.php b/services/nginx/app/routes/superuserCoolifyRoute.php new file mode 100644 index 00000000..7cab8d70 --- /dev/null +++ b/services/nginx/app/routes/superuserCoolifyRoute.php @@ -0,0 +1,303 @@ +get('/superuser/coolify', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + $response->success((new coolify_manager())->summary()); + }, [ + 'superuser_coolify_view' => 'View Coolify-managed replicated infrastructure targets', + ]); + + $this->get('/superuser/coolify/load-balancer', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + $response->success((new coolify_manager())->loadBalancerSummary()); + }, [ + 'superuser_coolify_view' => 'View the Coolify public gateway Load Balancer state', + ]); + + $this->post('/superuser/coolify/load-balancer/reconcile', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $parameters = $this->getParametersAsArray(); + $dryRun = array_key_exists('dry_run', $parameters) + ? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN) + : !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN); + $result = (new coolify_manager())->reconcileLoadBalancer($dryRun, $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Reconcile Hetzner Load Balancer targets and services for the Coolify gateway', + ]); + + $this->post('/superuser/coolify/load-balancer/routes/deploy', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $parameters = $this->getParametersAsArray(); + $dryRun = array_key_exists('dry_run', $parameters) + ? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN) + : !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN); + $result = (new coolify_manager())->deployGatewayApplicationRoutes($dryRun, $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Deploy the Coolify API application route for the public gateway host', + ]); + + $this->post('/superuser/coolify/load-balancer/api/deploy', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $parameters = $this->getParametersAsArray(); + $dryRun = array_key_exists('dry_run', $parameters) + ? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN) + : !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN); + $deployRoutes = array_key_exists('deploy_routes', $parameters) + ? filter_var($parameters['deploy_routes'], FILTER_VALIDATE_BOOLEAN) + : true; + $result = (new coolify_manager())->deployGatewayApiCode($dryRun, $deployRoutes, $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Deploy the latest Coolify API code for the public gateway host', + ]); + + $this->get('/superuser/coolify/gateways', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + $response->success((new coolify_manager())->listLoadBalancerGateways()); + }, [ + 'superuser_coolify_view' => 'List Coolify public gateway Load Balancer targets', + ]); + + $this->post('/superuser/coolify/gateways', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->saveLoadBalancerGateway( + $this->getParametersAsArray(), + $this->actorUserId() + ), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_coolify_manage' => 'Create or update Coolify public gateway Load Balancer targets', + ]); + + $this->post('/superuser/coolify/gateways/{id}/test', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->testLoadBalancerGateway( + $this->routeId(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Probe an individual Coolify public gateway target', + ]); + + $this->post('/superuser/coolify/instances', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->createInstance( + $this->getParametersAsArray(), + $this->actorUserId() + ), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_coolify_manage' => 'Create and update Coolify API connections', + ]); + + $this->post('/superuser/coolify/instances/{id}/test', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + $response->success((new coolify_manager())->testInstance($this->routeId(), $this->actorUserId())); + }, [ + 'superuser_coolify_manage' => 'Test Coolify API connectivity', + ]); + + $this->get('/superuser/coolify/instances/{id}/placement', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + try { + $response->success((new coolify_manager())->discoverInstancePlacement($this->routeId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 502); + } + }, [ + 'superuser_coolify_view' => 'Discover Coolify projects, environments, and servers for target placement', + ]); + + $this->get('/superuser/coolify/targets', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + $kind = (string)($this->getParameter('kind') ?? ''); + $response->success((new coolify_manager())->listTargets($kind !== '' ? $kind : null)); + }, [ + 'superuser_coolify_view' => 'List Coolify-managed replication targets', + ]); + + $this->post('/superuser/coolify/targets', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->createTarget( + $this->getParametersAsArray(), + $this->actorUserId() + ), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Create Coolify-managed MariaDB, Redis, and MinIO replication targets', + ]); + + $this->post('/superuser/coolify/targets/{id}/reconcile', function () { + global $response; + + $this->requirePermission('superuser_coolify_reconcile'); + try { + $result = (new coolify_manager())->reconcileTarget($this->routeId(), $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_reconcile' => 'Reconcile expected Coolify deployment state without primary downtime', + ]); + + $this->post('/superuser/coolify/targets/{id}/deploy', function () { + global $response; + + $this->requirePermission('superuser_coolify_reconcile'); + try { + $result = (new coolify_manager())->deployTarget($this->routeId(), $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_reconcile' => 'Deploy and provision a passive Coolify-managed replication target', + ]); + + $this->post('/superuser/coolify/targets/{id}/restart', function () { + global $response; + + $this->requirePermission('superuser_coolify_reconcile'); + try { + $result = (new coolify_manager())->restartTarget($this->routeId(), $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_reconcile' => 'Restart a passive Coolify target without restarting the active primary', + ]); + + $this->post('/superuser/coolify/targets/{id}/failover', function () { + global $response; + + $this->requirePermission('superuser_coolify_failover'); + try { + $response->success((new coolify_manager())->failoverTarget($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_failover' => 'Promote a healthy Coolify-managed replica through the replication module', + ]); + + $this->delete('/superuser/coolify/targets/{id}', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->deleteTarget( + $this->routeId(), + $this->getParametersAsArray(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Delete Coolify target mappings with explicit destructive confirmation', + ]); + } + + private function routeId(): int + { + $id = (int)$this->fromRoute('id'); + $this->requireParameterIntPositive($id, 'id'); + return $id; + } + + private function actorUserId(): ?int + { + try { + $user = (new authentication())->get_user(); + return $user !== false && isset($user->id) ? (int)$user->id : null; + } catch (Throwable) { + return null; + } + } +} diff --git a/services/nginx/app/routes/superuserDepartmentRoute.php b/services/nginx/app/routes/superuserDepartmentRoute.php index 675fedfb..ff76ddc9 100644 --- a/services/nginx/app/routes/superuserDepartmentRoute.php +++ b/services/nginx/app/routes/superuserDepartmentRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use objects\branding_o; use objects\department_variables_o; use objects\departments_o; use objects\logs_o; @@ -50,6 +51,63 @@ class superuserDepartmentRoute 'superuser_fetch_department' => 'Fetch department' ]); + $this->put('/superuser/department/branding', function () { + global $response; + $this->requirePermission('superuser_set_department_branding'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_SET_DEPARTMENT_BRANDING', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + self::requireParameters(['department_id', 'branding_id']); + + $departmentId = self::getParameter('department_id'); + if (!is_int($departmentId) && !(is_string($departmentId) && preg_match('/^\d+$/', $departmentId) === 1)) { + $response->error('Department ID must be a number', 400); + } + $departmentId = (int)$departmentId; + if ($departmentId <= 0) { + $response->error('Department ID must be a positive number', 400); + } + + $department = (new departments_o())->selectId($departmentId); + if (!$department->exists()) { + $response->error('Department not found', 404); + } + + $brandingId = self::getParameter('branding_id'); + if ($brandingId === '' || $brandingId === null || $brandingId === 0 || $brandingId === '0') { + $department->branding->set(null); + } else { + if (!is_int($brandingId) && !(is_string($brandingId) && preg_match('/^\d+$/', $brandingId) === 1)) { + $response->error('Branding ID must be a number', 400); + } + + $brandingId = (int)$brandingId; + if ($brandingId <= 0) { + $response->error('Branding ID must be a positive number', 400); + } + + $branding = (new branding_o())->select($brandingId); + if (!$branding->exists()) { + $response->error('Branding not found', 404); + } + + $department->branding->set($brandingId); + } + + $department->objectChanged(); + (new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_SET_DEPARTMENT_BRANDING', 'Successfully set department branding'); + + $response->success( + (new departments_o())->getDepartmentById($departmentId, true) + ); + }, [ + 'superuser_set_department_branding' => 'Set department branding' + ]); + $this->post('/superuser/department/prices', function () { // Require the user to be logged in global $response; @@ -237,4 +295,4 @@ class superuserDepartmentRoute ]); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/superuserReplicationRoute.php b/services/nginx/app/routes/superuserReplicationRoute.php new file mode 100644 index 00000000..3a76574e --- /dev/null +++ b/services/nginx/app/routes/superuserReplicationRoute.php @@ -0,0 +1,217 @@ +get('/superuser/replication', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_view'); + $refresh = $this->toBool($this->getParameter('refresh'), false); + $response->success((new replication_manager())->summary($refresh)); + }, [ + 'superuser_replication_view' => 'View database, Redis, and MinIO replication topology and status', + ]); + + $this->post('/superuser/replication/databases', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId()); + $response->success($host, 201); + }, [ + 'superuser_replication_manage' => 'Add and manage database replication host credentials', + ]); + + $this->post('/superuser/replication/redis', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId()); + $response->success($host, 201); + }, [ + 'superuser_replication_manage' => 'Add and manage Redis replication host credentials', + ]); + + $this->post('/superuser/replication/minio', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId()); + $response->success($host, 201); + }, [ + 'superuser_replication_manage' => 'Add and manage MinIO replication host credentials', + ]); + + $this->post('/superuser/replication/compose-template', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $response->success(replication_manager::composeTemplate($this->getParametersAsArray())); + }, [ + 'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts', + ]); + + $this->post('/superuser/replication/test-credentials', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $parameters = $this->getParametersAsArray(); + $response->success((new replication_manager())->testCredentials( + (string)($parameters['kind'] ?? ''), + $parameters + )); + }, [ + 'superuser_replication_manage' => 'Test database, Redis, and MinIO replication host credentials before saving them', + ]); + + $this->post('/superuser/replication/{kind}/{id}/test', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $response->success((new replication_manager())->testHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->actorUserId() + )); + }, [ + 'superuser_replication_manage' => 'Validate database, Redis, and MinIO replication host connectivity and privileges', + ]); + + $this->post('/superuser/replication/{kind}/{id}/provision', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + try { + $result = (new replication_manager())->provisionHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->actorUserId(), + true + ); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_replication_manage' => 'Provision a database, Redis, or MinIO host as a replica of the current primary', + ]); + + $this->post('/superuser/replication/{kind}/{id}/promote', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_promote'); + try { + $response->success((new replication_manager())->promoteHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_replication_promote' => 'Promote a healthy caught-up database, Redis, or MinIO replica to primary', + ]); + + $this->patch('/superuser/replication/{kind}/{id}', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + try { + $response->success((new replication_manager())->renameHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->getParametersAsArray(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_replication_manage' => 'Rename database, Redis, and MinIO replication hosts', + ]); + + $this->delete('/superuser/replication/{kind}/{id}', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_remove'); + try { + $response->success((new replication_manager())->removeHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_replication_remove' => 'Remove inactive prior hosts and unhealthy database, Redis, or MinIO replicas', + ]); + } + + /** + * Replication controls alter infrastructure state and must only be used by + * a classic superuser session. Subuser bearer tokens can carry a delegated + * customer context via X-Customer-Number, so do not allow them to fall back + * to plain string user permission checks for these routes. + */ + private function requireClassicSuperuserPermission(string $permission): bool + { + global $response; + + if ((new authentication())->get_subuser() !== false) { + $response->error('Subuser sessions cannot manage replication.', 403); + } + + return $this->requirePermission($permission); + } + + private function routeId(): int + { + $id = (int)$this->fromRoute('id'); + $this->requireParameterIntPositive($id, 'id'); + return $id; + } + + private function actorUserId(): ?int + { + try { + $user = (new authentication())->get_user(); + return $user !== false && isset($user->id) ? (int)$user->id : null; + } catch (Throwable) { + return null; + } + } + + private function toBool(mixed $value, bool $default): bool + { + if (is_bool($value)) { + return $value; + } + if ($value === null) { + return $default; + } + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + return $default; + } +} diff --git a/services/nginx/app/routes/superuserSystemStatusRoute.php b/services/nginx/app/routes/superuserSystemStatusRoute.php new file mode 100644 index 00000000..181a15d9 --- /dev/null +++ b/services/nginx/app/routes/superuserSystemStatusRoute.php @@ -0,0 +1,58 @@ +get('/superuser/system/status', function () { + global $response; + + $this->requirePermission('superuser_system_status_view'); + $force = $this->toBool($this->getParameter('force'), false); + $snapshot = (new superuser_system_status_service())->getSnapshot($force); + $response->success($snapshot); + }, [ + 'superuser_system_status_view' => 'View the aggregated superuser system status snapshot', + ]); + + $this->get('/superuser/system/database/status', function () { + global $response; + + $this->requirePermission('superuser_system_status_view'); + $snapshot = (new superuser_system_status_service())->getSnapshot(false); + $response->success([ + 'status' => $snapshot['dependencies']['database'] ?? null, + ]); + }, [ + 'superuser_system_status_view' => 'View the aggregated superuser system status snapshot', + ]); + } + + private function toBool(mixed $value, bool $default): bool + { + if (is_bool($value)) { + return $value; + } + + if ($value === null) { + return $default; + } + + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + + return $default; + } +} diff --git a/services/nginx/app/routes/systemSearchRoute.php b/services/nginx/app/routes/systemSearchRoute.php new file mode 100644 index 00000000..467047e9 --- /dev/null +++ b/services/nginx/app/routes/systemSearchRoute.php @@ -0,0 +1,604 @@ +get('/search/system', function () { + $this->handleSearchRequest(); + }); + + $this->post('/search/system', function () { + $this->handleSearchRequest(); + }); + + $this->delete('/superuser/search/system/cache', function () { + global /** @var response $response */ + $response; + $this->requirePermission('superuser_search_system_cache_clear'); + + system_search_cache::clearAll(); + $response->success([ + 'message' => 'System search cache cleared', + 'query_cache_cleared' => true, + 'intent_cache_cleared' => true, + ]); + }, [ + 'superuser_search_system_cache_clear' => 'Clear system-wide search query and intent caches', + ]); + + $this->post('/superuser/search/system/cache/rebuild', function () { + global /** @var response $response */ + $response; + $this->requirePermission('superuser_search_system_cache_rebuild'); + + $params = $this->getRequestPayload(); + $scope = strtolower(trim((string)($params['scope'] ?? 'all'))); + $types = $this->parseTypeList($params['types'] ?? []); + $request = system_search_cache::enqueueRebuild($scope, $types); + + // Rebuild endpoint also clears parser namespace immediately. + system_search_cache::clearQueryCaches(); + system_search_cache::clearIntentCaches(); + + $response->success([ + 'message' => 'System search cache rebuild queued', + 'request' => $request, + 'query_cache_cleared' => true, + 'intent_cache_cleared' => true, + ]); + }, [ + 'superuser_search_system_cache_rebuild' => 'Queue and trigger a system-wide search cache rebuild', + ]); + } + + private function handleSearchRequest(): void + { + global /** @var response $response */ + /** @var router $router */ + $response, $router; + + $auth = new authentication(); + $user = $auth->get_user(); + $subuser = $auth->get_subuser(); + if ($user === false && $subuser === false) { + $response->error('Invalid session', 401); + } + + $params = $this->getRequestPayload(); + $query = trim((string)($params['q'] ?? $params['query'] ?? $params['search'] ?? '')); + if ($query === '') { + $response->error('Missing required parameter: query', 400); + } + + $includeTypes = $this->parseTypeList($params['include_types'] ?? []); + $excludeTypes = $this->parseTypeList($params['exclude_types'] ?? []); + $includeAssociations = $this->toBool($params['include_associations'] ?? true, true); + $debugIntent = $this->toBool($params['debug_intent'] ?? false, false); + $limit = $this->clampInt((int)($params['limit'] ?? 50), 1, 200, 50); + $offset = max(0, (int)($params['offset'] ?? 0)); + + [$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes(); + if (empty($allowedTypes)) { + $response->forbidden($this->searchAccessPermissionCandidates()); + } + + $permissionsCatalogAll = $this->flattenPermissionCatalog((array)$router->getPermissions()); + $permissionsCatalogOwn = []; + if ($user !== false) { + try { + $permissionsCatalogOwn = array_values(array_unique(array_map('strval', (array)$user->getGroup()->getPermissions()))); + } catch (Throwable) { + $permissionsCatalogOwn = []; + } + } + + $service = new system_search_service(); + $result = $service->search([ + 'query' => $query, + 'include_types' => $includeTypes, + 'exclude_types' => $excludeTypes, + 'allowed_types' => $allowedTypes, + 'own_only_types' => $ownOnlyTypes, + 'own_customer_number' => $this->resolveEffectiveCustomerNumber(), + 'permissions_catalog_all' => $permissionsCatalogAll, + 'permissions_catalog_own' => $permissionsCatalogOwn, + 'module_config_visibility' => $this->buildModuleConfigVisibility(), + 'include_associations' => $includeAssociations, + 'debug_intent' => $debugIntent, + 'limit' => $limit, + 'offset' => $offset, + ]); + + $response->success($result); + } + + private function getRequestPayload(): array + { + $payload = $this->getParametersAsArray(); + return is_array($payload) ? $payload : []; + } + + private function resolveAllowedTypes(): array + { + $allowed = []; + $ownOnly = []; + foreach ($this->entityPermissionMap() as $type => $permissionSets) { + $hasAll = $this->hasAnyPermission((array)($permissionSets['all'] ?? [])); + $hasOwn = $this->hasAnyPermission((array)($permissionSets['own'] ?? [])); + if (!$hasAll && !$hasOwn) { + continue; + } + $allowed[] = $type; + if (!$hasAll && $hasOwn) { + $ownOnly[] = $type; + } + } + return [ + array_values(array_intersect($this->allEntityTypes(), array_values(array_unique($allowed)))), + array_values(array_intersect($this->allEntityTypes(), array_values(array_unique($ownOnly)))), + ]; + } + + private function entityPermissionMap(): array + { + return [ + 'objects' => [ + 'all' => ['list_order_attachments', 'download_order_attachments', 'list_department_selfserve_task_attachments'], + 'own' => ['list_own_order_attachments', 'download_order_attachments_own'], + ], + 'module_config' => [ + 'all' => $this->moduleConfigPermissions(), + 'own' => [], + ], + 'orders' => [ + 'all' => ['list_orders', 'fetch_order'], + 'own' => ['list_own_orders', 'fetch_own_order'], + ], + 'order_items' => [ + 'all' => ['list_order_items'], + 'own' => ['list_own_order_items'], + ], + 'customers' => [ + 'all' => ['search_customers', 'list_users', 'get_user_from_customer_number'], + 'own' => ['user'], + ], + 'employees' => [ + 'all' => ['list_users'], + 'own' => [], + ], + 'users' => [ + 'all' => ['list_users', 'get_user', 'get_user_id', 'get_user_name'], + 'own' => ['user'], + ], + 'subusers' => [ + 'all' => ['list_subuser_grants', 'manage_subuser_grants'], + 'own' => ['list_own_subusers', 'list_own_subuser_grants'], + ], + 'customer_discounts' => [ + 'all' => ['get_custom_prices_other', 'set_custom_price'], + 'own' => [], + ], + 'customer_fixed_prices' => [ + 'all' => ['get_customer_fixed_pricing'], + 'own' => [], + ], + 'departments' => [ + 'all' => ['list_departments'], + 'own' => [], + ], + 'permissions' => [ + 'all' => ['permissions_list'], + 'own' => ['permissions_list_own'], + ], + 'roles' => [ + 'all' => ['list_roles'], + 'own' => [], + ], + 'invoices' => [ + 'all' => ['list_collected_invoices', 'list_collected_invoices_economic_overview'], + 'own' => ['user_invoices'], + ], + 'vehicles' => [ + 'all' => ['list_vehicles_other', 'list_unknown_customer_vehicles'], + 'own' => ['list_own_vehicles'], + ], + 'bookings' => [ + 'all' => ['list_bookings'], + 'own' => ['list_own_bookings'], + ], + 'bookings_new' => [ + 'all' => ['list_bookings', 'statistics_bookings_new'], + 'own' => ['list_own_bookings'], + ], + 'branding' => [ + 'all' => ['list_branding_options'], + 'own' => [], + ], + 'categories' => [ + 'all' => ['list_categories'], + 'own' => [], + ], + 'currency_conversion_rates' => [ + 'all' => ['modules_fxratesapi_rate', 'modules_fxratesapi_rates'], + 'own' => [], + ], + 'customer_codes' => [ + 'all' => ['get_customer_code', 'add_customer_code'], + 'own' => [], + ], + 'customer_default_department' => [ + 'all' => ['get_customer_default_department_other', 'add_customer_default_department_other', 'delete_customer_default_department_other'], + 'own' => ['get_customer_default_department', 'add_customer_default_department', 'delete_customer_default_department'], + ], + 'customer_notes' => [ + 'all' => ['list_customer_notes'], + 'own' => [], + ], + 'customer_vehicles_addons' => [ + 'all' => ['list_vehicles_addon_other', 'list_vehicle_customer_suggestions'], + 'own' => ['list_vehicle_addon_own'], + ], + 'department_categories' => [ + 'all' => ['list_department_categories'], + 'own' => [], + ], + 'department_daily_reports' => [ + 'all' => ['list_department_daily_reports'], + 'own' => [], + ], + 'department_gates' => [ + 'all' => ['list_department_gates'], + 'own' => [], + ], + 'department_goals' => [ + 'all' => ['goals_department_list'], + 'own' => [], + ], + 'department_lanes' => [ + 'all' => ['list_department_lanes'], + 'own' => [], + ], + 'department_notification_sms' => [ + 'all' => ['department_notification_sms_get'], + 'own' => [], + ], + 'department_relays' => [ + 'all' => ['list_department_relays'], + 'own' => [], + ], + 'department_selfserve_condition_rules' => [ + 'all' => ['list_department_selfserve_condition_rules'], + 'own' => [], + ], + 'department_selfserve_conditions' => [ + 'all' => ['list_department_selfserve_conditions'], + 'own' => [], + ], + 'department_selfserve_questions' => [ + 'all' => ['list_department_selfserve_questions'], + 'own' => [], + ], + 'department_selfserve_tasks' => [ + 'all' => ['list_department_selfserve_tasks'], + 'own' => [], + ], + 'department_selfserve_vehicle_conditions' => [ + 'all' => ['list_department_selfserve_vehicle_conditions'], + 'own' => ['list_own_department_selfserve_vehicle_conditions'], + ], + 'department_time_bookings_entries' => [ + 'all' => ['department_timebookings_entries_get'], + 'own' => [], + ], + 'department_time_bookings_opening_hours' => [ + 'all' => ['department_timebookings_opening_hours_get'], + 'own' => [], + ], + 'department_time_bookings_types' => [ + 'all' => ['department_timebookings_types_get'], + 'own' => [], + ], + 'department_variables' => [ + 'all' => ['superuser_fetch_department_variables', 'superuser_set_department_variables'], + 'own' => [], + ], + 'fxratesapi_conversion_rates' => [ + 'all' => ['modules_fxratesapi_rate', 'modules_fxratesapi_rates'], + 'own' => [], + ], + 'module_action_logs' => [ + 'all' => ['modules_action_logs_view'], + 'own' => [], + ], + 'motorapi_lookups' => [ + 'all' => ['modules_motorapi_lookup', 'department_license_plate_lookup'], + 'own' => [], + ], + 'notifications' => [ + 'all' => ['list_all_notifications', 'list_notifications'], + 'own' => ['list_own_notifications'], + ], + 'order_bookings' => [ + 'all' => ['list_bookings'], + 'own' => ['list_own_bookings'], + ], + 'plate_scanners' => [ + 'all' => ['list_number_plate_scanners', 'list_department_number_plate_scanners'], + 'own' => [], + ], + 'plate_scans' => [ + 'all' => ['list_number_plate_scans', 'list_number_plate_scans_department'], + 'own' => [], + ], + 'product_options' => [ + 'all' => ['list_product_options'], + 'own' => [], + ], + 'products' => [ + 'all' => ['list_products', 'economic_products_get'], + 'own' => [], + ], + 'stripe_module_customers' => [ + 'all' => ['modules_stripe_customers_list'], + 'own' => [], + ], + 'stripe_module_orders' => [ + 'all' => ['list_orders', 'fetch_order'], + 'own' => ['list_own_orders', 'fetch_own_order'], + ], + 'stripe_payment_intents' => [ + 'all' => ['get_payment_intent', 'confirm_payment_intent'], + 'own' => [], + ], + 'subuser_grants' => [ + 'all' => ['list_subuser_grants', 'manage_subuser_grants'], + 'own' => ['list_own_subuser_grants'], + ], + 'xlvask_customers' => [ + 'all' => ['modules_xlvask_customers'], + 'own' => [], + ], + 'xlvask_potential_order_matches' => [ + 'all' => ['list_potential_order_matches'], + 'own' => ['list_own_potential_order_matches'], + ], + 'xlvask_usage_log_wash_items' => [ + 'all' => ['modules_xlvask_usageLog', 'list_xlvask_usage_orders_all'], + 'own' => ['list_xlvask_usage_orders_own'], + ], + 'xlvask_usage_logs' => [ + 'all' => ['modules_xlvask_usageLog', 'list_xlvask_usage_orders_all'], + 'own' => ['list_xlvask_usage_orders_own'], + ], + 'xlvask_vehicle_types' => [ + 'all' => ['modules_xlvask_internal_vehicle_types'], + 'own' => [], + ], + 'xlvask_vehicles' => [ + 'all' => ['modules_xlvask_vehicles'], + 'own' => [], + ], + ]; + } + + private function moduleConfigPermissions(): array + { + return [ + 'economic_config', + 'recaptcha_config', + 'email_config', + 'backups_config', + 'modules_bird_config', + 'motorapi_config', + 'stripe_config', + 'fxratesapi_config', + 'weatherapi_config', + 'gatewayapi_config', + 'xlvask_config', + 'entra_config', + 'modules_limble_config', + 'modules_ocrspace_config', + 'modules_openai_config', + 'modules_licenseplaterecognizer_config', + 'modules_virkdata_config', + 'modules_shelly_config', + 'modules_selfserve_config', + ]; + } + + private function buildModuleConfigVisibility(): array + { + global $db; + + $modulePermissions = [ + 'economic' => ['economic_config'], + 'reCAPTCHA' => ['recaptcha_config'], + 'Email' => ['email_config'], + 'Backups' => ['backups_config'], + 'bird' => ['modules_bird_config'], + 'motorapi' => ['motorapi_config'], + 'Stripe' => ['stripe_config'], + 'fxratesapi' => ['fxratesapi_config'], + 'weatherapi' => ['weatherapi_config'], + 'GatewayAPI' => ['gatewayapi_config'], + 'xlvask' => ['xlvask_config'], + 'Entra' => ['entra_config'], + 'limble' => ['modules_limble_config'], + 'ocrSpace' => ['modules_ocrspace_config'], + 'openAI' => ['modules_openai_config'], + 'licenseplaterecognizer' => ['modules_licenseplaterecognizer_config'], + 'virkdata' => ['modules_virkdata_config'], + 'shelly' => ['modules_shelly_config'], + 'selfserve' => ['modules_selfserve_config'], + ]; + + $visibility = []; + try { + $result = $db->query('SELECT DISTINCT module FROM module_config'); + if ($result instanceof \mysqli_result) { + $rows = $db->fetch_all($result); + foreach ($rows as $row) { + $module = (string)($row['module'] ?? ''); + if ($module === '') { + continue; + } + $candidates = $modulePermissions[$module] ?? []; + if (empty($candidates)) { + $slug = strtolower(preg_replace('/[^a-z0-9]+/i', '', $module) ?? ''); + if ($slug !== '') { + $candidates[] = $slug . '_config'; + $candidates[] = 'modules_' . $slug . '_config'; + } + } + $visibility[$module] = $this->hasAnyPermission($candidates); + } + } + } catch (Throwable) { + // Fail open for compatibility if module map cannot be loaded. + } + + return $visibility; + } + + private function hasAnyPermission(array $permissions): bool + { + foreach ($permissions as $permission) { + if (!is_string($permission) || $permission === '') { + continue; + } + if ($this->hasPermission($permission)) { + return true; + } + } + return false; + } + + private function flattenPermissionCatalog(array $permissions): array + { + $flat = []; + $walker = function (mixed $node) use (&$flat, &$walker): void { + if (!is_array($node)) { + return; + } + foreach ($node as $key => $value) { + if (is_string($key) && is_string($value)) { + $flat[$key] = $value; + continue; + } + if (is_array($value)) { + $walker($value); + } + } + }; + $walker($permissions); + return $flat; + } + + private function parseTypeList(mixed $value): array + { + $result = []; + $raw = []; + if (is_array($value)) { + $raw = $value; + } elseif (is_string($value)) { + $trimmed = trim($value); + if ($trimmed === '') { + return []; + } + if (str_starts_with($trimmed, '[')) { + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + $raw = $decoded; + } else { + $raw = explode(',', $trimmed); + } + } else { + $raw = explode(',', $trimmed); + } + } elseif ($value !== null) { + $raw = [$value]; + } + + foreach ($raw as $item) { + if (!is_string($item)) { + continue; + } + $normalized = strtolower(trim($item)); + if ($normalized === '') { + continue; + } + $result[] = $normalized; + } + return array_values(array_unique($result)); + } + + private function toBool(mixed $value, bool $default): bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return (bool)$value; + } + if (is_string($value)) { + $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + return $parsed ?? $default; + } + return $default; + } + + private function clampInt(int $value, int $min, int $max, int $default): int + { + if ($value === 0) { + $value = $default; + } + if ($value < $min) { + return $min; + } + if ($value > $max) { + return $max; + } + return $value; + } + + private function allEntityTypes(): array + { + return array_keys($this->entityPermissionMap()); + } + + /** + * Return all permission keys that can unlock at least one searchable entity type. + * + * @return array + */ + private function searchAccessPermissionCandidates(): array + { + $permissions = []; + foreach ($this->entityPermissionMap() as $permissionSets) { + foreach ((array)($permissionSets['all'] ?? []) as $permission) { + if (is_string($permission) && $permission !== '') { + $permissions[] = $permission; + } + } + foreach ((array)($permissionSets['own'] ?? []) as $permission) { + if (is_string($permission) && $permission !== '') { + $permissions[] = $permission; + } + } + } + return array_values(array_unique($permissions)); + } +} diff --git a/services/nginx/app/routes/userInvoicesRoute.php b/services/nginx/app/routes/userInvoicesRoute.php index 6aa001a6..b8dfa7ab 100644 --- a/services/nginx/app/routes/userInvoicesRoute.php +++ b/services/nginx/app/routes/userInvoicesRoute.php @@ -54,27 +54,56 @@ class userInvoicesRoute (new logs_o())->add('user_invoices', 'global', 0, 0, 'USER_INVOICES', 'User not logged in'); $response->error('Invalid session', 400); } - self::requireParameters(['id', 'po_number']); + self::requireParameters(['id']); self::requireType((int)self::getParameter('id'), self::type_int()); $id = (int)self::getParameter('id'); // Make sure the id is valid self::requireMinValue($id, 1); self::requireSameLength($id, self::getParameter('id')); - // Make sure the po_number is valid - self::requireType((string)self::getParameter('po_number'), self::type_string()); - self::requireMinLength('po_number', 0); - self::requireMaxLength('po_number', 255); + $is_superuser = self::hasPermission('superuser'); + if (!self::isParametersSet(['po_number']) && !self::isParametersSet(['closed_at'])) { + $response->error('Missing required parameters: po_number, closed_at', 400); + } + if (self::isParametersSet(['closed_at']) && !$is_superuser) { + $response->error('Forbidden: only superusers can update closed_at', 403); + } + // Make sure optional fields are valid + if (self::isParametersSet(['po_number'])) { + self::requireType((string)self::getParameter('po_number'), self::type_string()); + self::requireMinLength('po_number', 0); + self::requireMaxLength('po_number', 255); + } + $closed_at = null; + if (self::isParametersSet(['closed_at'])) { + $closed_at = self::getParameter('closed_at'); + if ($closed_at !== null && $closed_at !== '') { + self::requireType((string)$closed_at, self::type_string()); + self::requireDateFormat((string)$closed_at, self::FORMAT_DATE()); + } + } // Get the invoice $collected_order_invoices = new collected_order_invoices_o(); $invoice = $collected_order_invoices->select((int)$id); $invoice->requireSelected(); // Make sure the invoice belongs to the user - if ((int)$invoice->customer_number->value() !== (int)$user->customer_number->value()) { - (new logs_o())->add('user_invoices', 'global', 0, 0, 'USER_INVOICES', 'User not allowed to access this invoice'); - $response->error('Invalid session', 400); + if ((int)$invoice->customer_number->value() !== (int)$user->customer_number->value() && !$is_superuser) { + (new logs_o())->add( + 'user_invoices', + 'global', + 0, + 0, + 'USER_INVOICES', + 'User not allowed to access this invoice (invoice_customer=' . (int)$invoice->customer_number->value() . ', user_customer=' . (int)$user->customer_number->value() . ')' + ); + $response->error('Forbidden: invoice does not belong to authenticated user', 403); } // Update the invoice - $invoice->po_number->set((string)self::getParameter('po_number')); + if (self::isParametersSet(['po_number'])) { + $invoice->po_number->set((string)self::getParameter('po_number')); + } + if (self::isParametersSet(['closed_at'])) { + $invoice->closed_at->set($closed_at === null || $closed_at === '' ? null : date('Y-m-d 23:59:59', strtotime((string)$closed_at . ' 00:00:01'))); + } // Return success $response->success($invoice->asArray()); }, @@ -83,4 +112,4 @@ class userInvoicesRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/userRoute.php b/services/nginx/app/routes/userRoute.php index a447c849..6d3e2b68 100644 --- a/services/nginx/app/routes/userRoute.php +++ b/services/nginx/app/routes/userRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\economic_v2_versioning_service; use classes\response; use objects\logs_o; use objects\users_o; @@ -129,6 +130,33 @@ class userRoute } // Set the custom price $targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category); + try { + (new economic_v2_versioning_service())->recordDiscountOverrideVersion( + (int)$targetUser->id, + (int)$targetUser->customer_number->value(), + (bool)$is_category, + (string)$object_id, + (int)$discount, + date('Y-m-d H:i:s'), + 'live.discount_override.route', + 1.0, + false, + [ + 'route' => '/superuser/user/discounts', + 'method' => 'POST', + 'actor_user_id' => (int)$user->id, + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add( + 'users', + 'global', + 0, + (int)$user->id, + 'SET_CUSTOM_PRICE_VERSIONING_FAILED', + $e->getMessage() + ); + } // Log the incident (new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'Successfully set custom price'); // Return a success message @@ -368,4 +396,4 @@ class userRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/vehiclesRoute.php b/services/nginx/app/routes/vehiclesRoute.php index 8c31730e..e897fb1e 100644 --- a/services/nginx/app/routes/vehiclesRoute.php +++ b/services/nginx/app/routes/vehiclesRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\economic_v2_versioning_service; use customers\economic_customer_mo; use objects\bookings_o; use objects\customer_vehicles_o; @@ -180,6 +181,28 @@ class vehiclesRoute $subscription ? 1 : 0, $reference ); + try { + (new economic_v2_versioning_service())->recordVehicleSubscriptionVersion( + [ + 'vehicle_id' => (int)$vehicle->id, + 'customer_number' => (int)$targetCustomer, + 'reg' => (string)$reg, + 'vehicle_type' => (int)$type, + 'wash_subscription' => (bool)$subscription, + ], + date('Y-m-d H:i:s'), + 'live.vehicle.route', + 1.0, + false, + [ + 'route' => '/vehicles', + 'method' => 'POST', + 'actor_user_id' => (int)($user->id ?? 0), + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $e->getMessage()); + } (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'ADD_VEHICLE', 'Successfully added vehicle'); $response->success($vehicle->asArray()); @@ -204,6 +227,14 @@ class vehiclesRoute (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'EDIT_VEHICLE', 'Vehicle not found'); $response->error('Vehicle not found', 404); } + + $before_state = [ + 'vehicle_id' => (int)$vehicle->id, + 'customer_number' => (int)$vehicle->customer_id->value(), + 'reg' => (string)$vehicle->reg->value(), + 'vehicle_type' => (int)$vehicle->type->value(), + 'wash_subscription' => (bool)$vehicle->wash_subscription->value(), + ]; // Enforce access (own vs broader) self::allowOwnOrDepartmentAccess( $permission_own, @@ -213,6 +244,22 @@ class vehiclesRoute null, 'You are not allowed to edit vehicles from other users' ); + if (self::isParametersSet(['customer_id'])) { + $new_customer_number = (int)self::getParameter('customer_id'); + self::requireType($new_customer_number, self::type_int()); + self::requireMinValue($new_customer_number, 1); + self::requireMaxValue($new_customer_number, 9999999999); + // Require access for the destination customer context as well. + self::allowOwnOrDepartmentAccess( + $permission_own, + $permission_other, + $new_customer_number, + null, + null, + 'You are not allowed to move vehicles to this customer' + ); + $vehicle->customer_id->set($new_customer_number); + } // Check all the fields, and if they are set, validate and set them if (self::isParametersSet(['type'])) { $type = (int)self::getParameter('type'); @@ -225,7 +272,6 @@ class vehiclesRoute $vehicle->type->set(0); // Turn off the subscription $vehicle->wash_subscription->set(0); - return; } else { $products_o = new products_o(); $products_o->select((int)$type); @@ -280,6 +326,61 @@ class vehiclesRoute } } $vehicle->objectChanged(); + + $after_state = [ + 'vehicle_id' => (int)$vehicle->id, + 'customer_number' => (int)$vehicle->customer_id->value(), + 'reg' => (string)$vehicle->reg->value(), + 'vehicle_type' => (int)$vehicle->type->value(), + 'wash_subscription' => (bool)$vehicle->wash_subscription->value(), + ]; + + $version_relevant_change = ( + (int)$before_state['customer_number'] !== (int)$after_state['customer_number'] || + (string)$before_state['reg'] !== (string)$after_state['reg'] || + (int)$before_state['vehicle_type'] !== (int)$after_state['vehicle_type'] || + (bool)$before_state['wash_subscription'] !== (bool)$after_state['wash_subscription'] + ); + if ($version_relevant_change) { + try { + $versioning = new economic_v2_versioning_service(); + $effective_at = date('Y-m-d H:i:s'); + $identity_changed = ( + (int)$before_state['customer_number'] !== (int)$after_state['customer_number'] || + (string)$before_state['reg'] !== (string)$after_state['reg'] + ); + if ($identity_changed) { + $versioning->closeActiveVehicleSubscriptionVersion( + (int)$before_state['customer_number'], + (string)$before_state['reg'], + $effective_at, + 'live.vehicle.route', + 1.0, + false, + [ + 'route' => '/vehicles', + 'method' => 'PUT', + 'actor_user_id' => (int)($user->id ?? 0), + 'reason' => 'identity_change', + ] + ); + } + $versioning->recordVehicleSubscriptionVersion( + $after_state, + $effective_at, + 'live.vehicle.route', + 1.0, + false, + [ + 'route' => '/vehicles', + 'method' => 'PUT', + 'actor_user_id' => (int)($user->id ?? 0), + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'EDIT_VEHICLE_VERSIONING_FAILED', $e->getMessage()); + } + } // Return the vehicle $response->success($vehicle->asArray()); }, @@ -303,6 +404,11 @@ class vehiclesRoute (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'DELETE_VEHICLE', 'Vehicle not found'); $response->error('Vehicle not found', 404); } + + $before_state = [ + 'customer_number' => (int)$vehicle->customer_id->value(), + 'reg' => (string)$vehicle->reg->value(), + ]; // Enforce access (own vs broader) self::allowOwnOrDepartmentAccess( $permission_own, @@ -314,6 +420,23 @@ class vehiclesRoute ); // Delete the vehicle $vehicle->delete(); + try { + (new economic_v2_versioning_service())->closeActiveVehicleSubscriptionVersion( + (int)$before_state['customer_number'], + (string)$before_state['reg'], + date('Y-m-d H:i:s'), + 'live.vehicle.route', + 1.0, + false, + [ + 'route' => '/vehicles', + 'method' => 'DELETE', + 'actor_user_id' => (int)($user->id ?? 0), + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'DELETE_VEHICLE_VERSIONING_FAILED', $e->getMessage()); + } $response->success([ 'success' => true, 'message' => 'Vehicle deleted successfully' @@ -624,6 +747,7 @@ class vehiclesRoute /** Get the different lists of registration numbers */ $options = ['limit' => 10]; $booked_filters = [...($department ? ['department' => $department] : [])]; + $booking_lookup_filters = ['order_id' => null, 'deleted_at' => null, ...$booked_filters]; $verified_regs = self::getVerifiedRegs($search, $options); $known_regs = self::getKnownRegs($search, $options); $booked_regs = self::getBookedRegs($search, $options, $booked_filters); @@ -659,7 +783,7 @@ class vehiclesRoute // Limit the results to 10 items total, while keeping the relevance order $vehicles = array_slice($relevant, 0, 10); /** Format the relevance results */ - $vehicles = array_map(function ($reg) use ($verified_regs, $known_regs, $booked_regs, $unknown_regs) { + $vehicles = array_map(function ($reg) use ($verified_regs, $known_regs, $booked_regs, $unknown_regs, $booking_lookup_filters) { // Determine the status of the vehicle $status = null; $isVerified = in_array($reg, $verified_regs); @@ -677,43 +801,18 @@ class vehiclesRoute // Booking related variables $booking_id = null; $notes = null; // Booking notes + $booking_datetime = null; // Set the status based on priority: booked > verified > known > unknown if ($isBooked) { $status = 'booked'; - /** - * // Get the booking - * $booking = (new bookings_o())->getFieldsWhere([ - * 'regNrTraekker' => $reg, - * 'status' => 'pending', - * ], ['id', 'customer_number', 'reference_number', 'notes']); - * // If there is no booking with the tractor reg, check the trailer reg - * if (!$booking) { - * $booking = (new bookings_o())->getFieldsWhere([ - * 'regNrTrailer' => $reg, - * 'status' => 'pending', - * ], ['id', 'customer_number', 'reference_number', 'notes']); - * } - */ - // Get the booking - $booking = (new order_bookings_o())->getFieldsWhere([ - 'reg_1' => $reg, - 'order_id' => null, - 'deleted_at' => null, - ], ['id', 'customer_number', 'reference', 'note']); - // If there is no booking with the tractor reg, check the trailer reg - if (!$booking) { - $booking = (new order_bookings_o())->getFieldsWhere([ - 'reg_2' => $reg, - 'order_id' => null, - 'deleted_at' => null, - ], ['id', 'customer_number', 'reference', 'note']); - } + $booking = self::getPendingOrderBookingSummaryByPlate($reg, $booking_lookup_filters); // Populate other variables - $booking_id = $booking ? (int)$booking[0]['id'] : null; - $customer_number = $booking ? (int)$booking[0]['customer_number'] : null; - $reference = $booking ? (string)$booking[0]['reference'] : null; - $notes = $booking ? (string)$booking[0]['note'] : null; + $booking_id = $booking ? (int)$booking['id'] : null; + $customer_number = $booking ? (int)$booking['customer_number'] : null; + $reference = $booking ? (string)$booking['reference'] : null; + $notes = $booking ? (string)$booking['note'] : null; + $booking_datetime = $booking ? (string)($booking['datetime'] ?? '') : null; // If the booking has a customer number, check if the customer is barred if ($customer_number && (new users_o())->isCustomerBarred((int)$customer_number)) { $status = 'card'; @@ -774,6 +873,7 @@ class vehiclesRoute 'last_order_id' => $last_order_id, 'reference' => $reference, 'booking_id' => $booking_id, + 'booking_datetime' => $booking_datetime, 'notes' => $notes, 'customer_name' => $customer_name, 'barred' => $status === 'card', // If the status is 'card', the customer is barred @@ -871,6 +971,79 @@ class vehiclesRoute )); } + private static function getPendingOrderBookingSummaryByPlate(string $reg, array $filters = []): ?array + { + $bookings_o = new order_bookings_o(); + $fields = ['id', 'customer_number', 'reference', 'note', 'datetime']; + $results1 = $bookings_o->getFieldsWhere([ + 'reg_1' => $reg, + ...$filters + ], $fields) ?: []; + $results2 = $bookings_o->getFieldsWhere([ + 'reg_2' => $reg, + ...$filters + ], $fields) ?: []; + + $matches_by_key = []; + foreach (array_merge($results1, $results2) as $booking) { + if (!is_array($booking)) { + continue; + } + + $booking_id = isset($booking['id']) ? (int)$booking['id'] : 0; + $dedupe_key = $booking_id > 0 + ? 'booking:' . $booking_id + : implode('|', [ + (string)($booking['reference'] ?? ''), + (string)($booking['note'] ?? ''), + (string)($booking['datetime'] ?? ''), + ]); + + $matches_by_key[$dedupe_key] = $booking; + } + + if (empty($matches_by_key)) { + return null; + } + + $matches = array_values($matches_by_key); + usort($matches, function (array $left, array $right): int { + $left_has_datetime = self::hasPendingOrderBookingSummaryDatetime($left); + $right_has_datetime = self::hasPendingOrderBookingSummaryDatetime($right); + if ($left_has_datetime !== $right_has_datetime) { + return $right_has_datetime <=> $left_has_datetime; + } + + $timestamp_difference = self::getPendingOrderBookingSummaryTimestamp($left) <=> self::getPendingOrderBookingSummaryTimestamp($right); + if ($timestamp_difference !== 0) { + return $timestamp_difference; + } + + return ((int)($left['id'] ?? 0)) <=> ((int)($right['id'] ?? 0)); + }); + + return $matches[0] ?? null; + } + + private static function hasPendingOrderBookingSummaryDatetime(array $booking): bool + { + return trim((string)($booking['datetime'] ?? '')) !== ''; + } + + private static function getPendingOrderBookingSummaryTimestamp(array $booking): int + { + $raw_value = (string)($booking['datetime'] ?? ''); + if ($raw_value !== '') { + $parsed_value = strtotime($raw_value); + if ($parsed_value !== false) { + return $parsed_value; + } + } + + $fallback_id = (int)($booking['id'] ?? PHP_INT_MAX); + return $fallback_id > 0 ? $fallback_id : PHP_INT_MAX; + } + private static function getUnknownRegs(string $search, array $options = ['limit' => null]): array { // Unknown vehicles are vehicles that has been scanned by the LPR system, but are not in any of the other lists @@ -927,4 +1100,4 @@ class vehiclesRoute $response->error('Notes are too long, they must be less than 250 characters', 400); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/workerRoute.php b/services/nginx/app/routes/workerRoute.php index 742af8c9..b74bce9e 100644 --- a/services/nginx/app/routes/workerRoute.php +++ b/services/nginx/app/routes/workerRoute.php @@ -2,7 +2,9 @@ namespace routes; +use classes\db; use classes\economic; +use classes\release_manager; use classes\router; use classes\shelly; use classes\slack; @@ -124,7 +126,7 @@ class workerRoute }); $this->get('/worker/status', function () { global /** @var router $router */ - $response, $router; + $response, $router, $db, $REDIS_CONFIG, $CONFIG_DB; $response->success([ 'message' => 'Worker is running', 'status' => 'OK', @@ -132,7 +134,22 @@ class workerRoute 'timezone' => date_default_timezone_get(), 'host' => gethostname(), 'version' => '1.0.1', - 'routes' => $router->countRoutes() + 'api_commit_sha' => release_manager::backendCommitSha(), + 'routes' => $router->countRoutes(), + 'redis' => [ + 'host' => $REDIS_CONFIG['host'], + 'user' => $REDIS_CONFIG['user'], + 'database' => $REDIS_CONFIG['database'], + 'password' => $REDIS_CONFIG['password'] ? '********' : 'NOT_SET', + 'port' => $REDIS_CONFIG['port'], + 'status' => (new \classes\redis())->ping() ? 'OK' : 'ERROR', + ], + 'database' => [ + 'host' => $CONFIG_DB['host'], + 'database' => $CONFIG_DB['database'], + 'user' => $CONFIG_DB['user'], + 'status' => $db->testConnection() ? 'OK' : 'ERROR', + ], ]); }); $this->get('/worker/debug', function () { @@ -284,4 +301,4 @@ class workerRoute // Convert to uppercase return strtoupper($cleaned); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index 5cb0ae19..c081de0f 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -2,11 +2,14 @@ namespace routes; +require_once WD . '/classes/xlvask_automation_service.php'; + use classes\authentication; use classes\redis; use classes\response; use classes\stripe; use classes\xlvask; +use classes\xlvask_automation_service; use objects\collected_order_invoices_o; use objects\departments_o; use objects\economic_module_orders; @@ -14,7 +17,6 @@ use objects\logs_o; use objects\orders_o; use objects\stripe_module_orders_o; use objects\stripe_payment_intents_o; -use objects\users_o; use objects\xlvask_usage_logs_o; use traits\route_t; @@ -47,6 +49,8 @@ class xlvaskUsageLogsRoute (new logs_o())->add('xlvask_usage_orders', 'global', 1, $user->id, 'LIST_XLVASK_USAGE_ORDERS', 'User accessed the list of xlvask usage orders'); $xlvask_usage_logs = new xlvask_usage_logs_o(); $xlvask = new xlvask(); + $automation_service = new xlvask_automation_service(); + $linked_order_ids_by_wash_id = []; $xlvask->new($xlvask->helpers->xlvask_usage_log)->getDepartment(); $orders_o = new orders_o(); $xlvask_usage_log = $xlvask->new($xlvask->helpers->xlvask_usage_log); @@ -62,16 +66,49 @@ class xlvaskUsageLogsRoute // Make sure the Customer is not in the default customers list ->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')") ->listObjectsWithPaginationIfSet( - function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask) { + function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) { + $automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false); // Remove the 'id' field from the log $id = (int)$log['id']; + $amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log); unset($log['id']); // Convert the 'WashItems' field from JSON to an array $log['WashItems'] = json_decode($log['WashItems'], true); + $usage_log_payload = array_intersect_key($log, array_flip([ + 'WashId', + 'CustomerId', + 'Customer', + 'VatNumber', + 'Location', + 'Hall', + 'HallId', + 'StartTime', + 'FinishTime', + 'RegistrationNumber', + 'VehicleType', + 'IdentificationType', + 'IdentificationId', + 'Info', + 'Updated', + 'Prepaid', + 'FinishStatus', + 'CustomerGuid', + 'VehicleId', + 'WashItems', + 'ignored_at', + 'ignored_by', + 'ignored_reason', + ])); // Create a new xlvask usage log object $tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log); // Set the properties of the temporary object - $tmp->setProperties($log); + $tmp->setProperties($usage_log_payload); + $wash_id = (string)$tmp->WashId; + if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) { + $linked_order = (new orders_o())->selectByWashId($wash_id); + $linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null ? (int)$linked_order->id : null; + } + $linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null; // Define the result structure $isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true); // Generate a fast link key for the order, used to retrieve the order with items later. @@ -87,6 +124,9 @@ class xlvaskUsageLogsRoute // Return the result $tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []); $tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order + $tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount']; + $tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name']; + $tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached']; // Clear memory unset($tmp); unset($log); @@ -94,7 +134,10 @@ class xlvaskUsageLogsRoute return [ 'id' => $id, // Return the ID of the log 'fast_link_key' => $fast_link_key ?? null, // Return the fast link key if it was generated + 'automation' => $automation, ...$tmp_res['order'], // Return the simulated order from XLVask (with or without items) + 'usage_log_id' => $id, + 'linked_order_id' => $linked_order_id, ]; }, $xlvask_usage_logs->forceRestrictFilters( @@ -120,6 +163,155 @@ class xlvaskUsageLogsRoute ] ); + $this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () { + global $db, $response; + $this->requirePermission('ignore_xlvask_usage_order'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; + $reasonSql = $reason === null || $reason === '' + ? 'NULL' + : "'" . $db->escape_string($reason) . "'"; + + (new xlvask_usage_logs_o())->structure(); + $db->query( + "UPDATE xlvask_usage_logs + SET ignored_at = NOW(), + ignored_by = " . (int)$user->id . ", + ignored_reason = {$reasonSql} + WHERE id = {$id}" + ); + + $response->success([ + 'id' => $id, + 'ignored' => true, + ]); + }, + [ + 'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/automation/run', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $ids = $this->isParametersSet(['ids']) ? $this->getParameter('ids') : []; + if (!is_array($ids)) { + $ids = []; + } + + $dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null; + $dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null; + $limit = $this->isParametersSet(['limit']) ? (int)$this->getParameter('limit') : 100; + + $response->success( + (new xlvask_automation_service())->runPending($dateFrom, $dateTo, $ids, $limit, (int)$user->id) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $response->success( + (new xlvask_automation_service())->evaluateUsageLogById($id, (int)$user->id, true) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null; + $reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; + + $response->success( + (new xlvask_automation_service())->acceptUsageLogById($id, (int)$user->id, $suggestionId, $reason) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/{id}/automation/deny', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null; + $reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; + + $response->success( + (new xlvask_automation_service())->denyUsageLogById($id, (int)$user->id, $suggestionId, $reason) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion', + ] + ); + $this->get('/modules/xlvask/services/usage/orders/fast-link', function () { global $response; self::requireParameters([ @@ -204,4 +396,4 @@ class xlvaskUsageLogsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Api/ApiCoverageManifestTest.php b/services/nginx/app/tests/Api/ApiCoverageManifestTest.php new file mode 100644 index 00000000..cc13f0b2 --- /dev/null +++ b/services/nginx/app/tests/Api/ApiCoverageManifestTest.php @@ -0,0 +1,76 @@ +markTestSkipped('API tests are disabled. Run with RUN_API_TESTS=1.'); + } + + $manifest = require app_path('tests/Api/api_coverage_manifest.php'); + $allOperations = array_merge($manifest['openapi_operations'], $manifest['manual_operations']); + $happyOnlyOperations = $manifest['happy_only_operations']; + $coverage = []; + + foreach (glob(app_path('tests/Api/*Test.php')) as $file) { + if (basename($file) === 'ApiCoverageManifestTest.php') { + continue; + } + + $contents = (string)file_get_contents($file); + preg_match_all( + '/api_test_covers\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/', + $contents, + $matches, + PREG_SET_ORDER + ); + + foreach ($matches as $match) { + $coverage[$match[1]][$match[2]] = true; + } + } + + foreach ($allOperations as $operation) { + expect($coverage[$operation]['happy'] ?? false) + ->toBeTrue('Missing happy-path coverage marker for ' . $operation . '.'); + + if (in_array($operation, $happyOnlyOperations, true)) { + continue; + } + + expect(($coverage[$operation]['failure'] ?? false) || ($coverage[$operation]['auth'] ?? false)) + ->toBeTrue('Missing failure/auth coverage marker for ' . $operation . '.'); + } +}); + +it('keeps the OpenAPI manifest entries aligned with the API spec', function (): void { + if (!api_tests_enabled()) { + $this->markTestSkipped('API tests are disabled. Run with RUN_API_TESTS=1.'); + } + + $manifest = require app_path('tests/Api/api_coverage_manifest.php'); + $lines = file(app_path('openapi.yaml'), FILE_IGNORE_NEW_LINES); + + expect($lines)->not->toBeFalse(); + + $operationsInSpec = []; + $currentPath = null; + foreach ($lines as $line) { + if (preg_match('/^ (\/[^:]+):\s*$/', $line, $pathMatch) === 1) { + $currentPath = $pathMatch[1]; + continue; + } + + if ($currentPath === null) { + continue; + } + + if (preg_match('/^ ([a-z]+):\s*$/', $line, $methodMatch) === 1) { + $operationsInSpec[] = strtoupper($methodMatch[1]) . ' ' . $currentPath; + } + } + + foreach ($manifest['openapi_operations'] as $operation) { + expect($operationsInSpec)->toContain($operation); + } +}); diff --git a/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php b/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php new file mode 100644 index 00000000..233b21a8 --- /dev/null +++ b/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php @@ -0,0 +1,592 @@ +db(); + api_fixture_cleanup_ensure_optional_trace_tables($db); + + $department = api_fixtures()->createDepartment(['name' => 'Fixture Cleanup Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Fixture Cleanup Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Fixture Cleanup Cashier']); + + $userId = (int)$customer['id']; + $customerNumber = (int)$customer['customer_number']; + $cashierId = (int)$cashier['id']; + $departmentId = (int)$department['id']; + + api_fixture_cleanup_seed_customer_traces($db, $userId, $customerNumber, $cashierId, $departmentId); + + expect(api_fixture_cleanup_trace_counts($db, $userId, $customerNumber))->toMatchArray([ + 'users' => 1, + 'customer_attributes' => 1, + 'tokens' => 1, + 'user_key_value_pairs' => 1, + 'price_overrides' => 1, + 'customer_default_department' => 1, + 'customer_fixed_pricing' => 1, + 'customer_fixed_pricing_versions' => 1, + 'customer_vehicle_subscription_versions' => 1, + 'customer_discount_override_versions' => 1, + 'system_search_economic_customer_index' => 1, + 'subuser_grants' => 1, + 'collected_order_invoices' => 1, + 'orders' => 1, + 'order_items' => 1, + 'economic_module_orders' => 1, + 'stripe_module_orders' => 1, + 'stripe_payment_intents' => 1, + 'customer_vehicles' => 1, + 'customer_vehicles_addons' => 1, + 'object_attachments' => 4, + ]); + + api_test_runtime()->endTest(); + + expect(api_fixture_cleanup_trace_counts($db, $userId, $customerNumber))->toMatchArray([ + 'users' => 0, + 'customer_attributes' => 0, + 'tokens' => 0, + 'user_key_value_pairs' => 0, + 'price_overrides' => 0, + 'customer_default_department' => 0, + 'customer_fixed_pricing' => 0, + 'customer_fixed_pricing_versions' => 0, + 'customer_vehicle_subscription_versions' => 0, + 'customer_discount_override_versions' => 0, + 'system_search_economic_customer_index' => 0, + 'subuser_grants' => 0, + 'collected_order_invoices' => 0, + 'orders' => 0, + 'order_items' => 0, + 'economic_module_orders' => 0, + 'stripe_module_orders' => 0, + 'stripe_payment_intents' => 0, + 'customer_vehicles' => 0, + 'customer_vehicles_addons' => 0, + 'object_attachments' => 0, + ]); +}); + +it('restores preserved module config rows during fixture cleanup', function (): void { + $db = api_test_runtime()->db(); + $module = 'fixture_cleanup'; + $variable = 'preserve_module_config_' . bin2hex(random_bytes(4)); + $moduleEscaped = $db->real_escape_string($module); + $variableEscaped = $db->real_escape_string($variable); + $ended = false; + + try { + $db->query( + "INSERT INTO `module_config` (`module`, `variable`, `value`, `type`, `created_at`, `updated_at`) " . + "VALUES ('{$moduleEscaped}', '{$variableEscaped}', '600100', 'int', '2026-04-14 12:00:00', '2026-04-14 12:00:00')" + ); + + api_fixtures()->preserveModuleConfig($module, $variable); + + $db->query( + "UPDATE `module_config` " . + "SET `value` = NULL, `type` = 'string', `updated_at` = '2026-04-14 12:05:00' " . + "WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}'" + ); + + api_test_runtime()->endTest(); + $ended = true; + + $row = api_fixture_cleanup_module_config_row($db, $module, $variable); + + expect($row) + ->not->toBeNull() + ->and($row['value'] ?? null)->toBe('600100') + ->and($row['type'] ?? null)->toBe('int') + ->and($row['created_at'] ?? null)->toBe('2026-04-14 12:00:00') + ->and($row['updated_at'] ?? null)->toBe('2026-04-14 12:00:00'); + } finally { + if (!$ended) { + api_test_runtime()->endTest(); + } + + $db->query( + "DELETE FROM `module_config` WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}'" + ); + } +}); + +function api_fixture_cleanup_ensure_optional_trace_tables(mysqli $db): void +{ + $statements = [ + <<<'SQL' +CREATE TABLE IF NOT EXISTS `customer_default_department` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_number` INT NOT NULL, + `department` INT NOT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_customer_default_department_customer_number` (`customer_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `customer_fixed_pricing` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_number` INT NOT NULL, + `price` INT NOT NULL, + `description` VARCHAR(255) NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_customer_fixed_pricing_customer_number` (`customer_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `customer_fixed_pricing_versions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_number` INT NOT NULL, + `price` INT NOT NULL, + `description` VARCHAR(255) NULL, + `effective_from` DATETIME NOT NULL, + `effective_to` DATETIME NULL, + `source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test', + `confidence` DECIMAL(6,5) NOT NULL DEFAULT 1.00000, + `inferred` TINYINT(1) NOT NULL DEFAULT 0, + `metadata_json` TEXT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_customer_fixed_pricing_versions_customer_number` (`customer_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `customer_vehicle_subscription_versions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `vehicle_id` INT NULL, + `customer_number` INT NOT NULL, + `reg` VARCHAR(64) NOT NULL, + `vehicle_type` INT NOT NULL, + `wash_subscription` TINYINT(1) NOT NULL, + `effective_from` DATETIME NOT NULL, + `effective_to` DATETIME NULL, + `source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test', + `confidence` DECIMAL(6,5) NOT NULL DEFAULT 1.00000, + `inferred` TINYINT(1) NOT NULL DEFAULT 0, + `metadata_json` TEXT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_customer_vehicle_subscription_versions_customer_number` (`customer_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `customer_discount_override_versions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `customer_number` INT NOT NULL, + `is_category` TINYINT(1) NOT NULL, + `object_id` VARCHAR(64) NOT NULL, + `discount` INT NOT NULL, + `effective_from` DATETIME NOT NULL, + `effective_to` DATETIME NULL, + `source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test', + `confidence` DECIMAL(6,5) NOT NULL DEFAULT 1.00000, + `inferred` TINYINT(1) NOT NULL DEFAULT 0, + `metadata_json` TEXT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_customer_discount_override_versions_customer_number` (`customer_number`), + KEY `idx_customer_discount_override_versions_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `system_search_economic_customer_index` ( + `customer_number` INT NOT NULL, + `user_id` INT NULL, + `local_display_name` VARCHAR(255) NULL, + `local_email` VARCHAR(255) NULL, + `local_phone` VARCHAR(64) NULL, + `economic_name` VARCHAR(255) NULL, + `economic_address` VARCHAR(255) NULL, + `economic_city` VARCHAR(255) NULL, + `economic_zip` VARCHAR(64) NULL, + `economic_email` VARCHAR(255) NULL, + `economic_cvr` VARCHAR(64) NULL, + `economic_mobile_phone` VARCHAR(64) NULL, + `economic_barred` TINYINT(1) NULL, + `search_text` TEXT NULL, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`customer_number`), + KEY `idx_system_search_economic_customer_index_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + ]; + + foreach ($statements as $sql) { + $db->query($sql); + } +} + +function api_fixture_cleanup_seed_customer_traces( + mysqli $db, + int $userId, + int $customerNumber, + int $cashierId, + int $departmentId +): void { + $now = '2026-04-14 12:00:00'; + api_fixture_cleanup_insert($db, 'customer_attributes', [ + 'user_id' => $userId, + 'attribute' => 'fixture_cleanup_attribute', + 'created_at' => $now, + ]); + api_fixture_cleanup_insert($db, 'tokens', [ + 'user_id' => $userId, + 'type' => 'AUTH_TOKEN', + 'description' => 'Fixture cleanup token', + 'token' => 'fixture-cleanup-' . $userId, + 'created_at' => $now, + ]); + api_fixture_cleanup_insert($db, 'user_key_value_pairs', [ + 'user_id' => $userId, + 'var' => 'fixture_cleanup_key', + 'val' => 'fixture_cleanup_value', + 'created_at' => $now, + 'updated_at' => $now, + ]); + api_fixture_cleanup_insert($db, 'price_overrides', [ + 'user_id' => $userId, + 'is_category' => 1, + 'product_or_category_id' => 'fixture_cleanup_category', + 'percentage' => 25, + 'created_at' => $now, + 'updated_at' => $now, + ]); + api_fixture_cleanup_insert($db, 'customer_default_department', [ + 'customer_number' => $customerNumber, + 'department' => $departmentId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + api_fixture_cleanup_insert($db, 'customer_fixed_pricing', [ + 'customer_number' => $customerNumber, + 'price' => 1999, + 'description' => 'Fixture cleanup fixed pricing', + 'created_at' => $now, + 'updated_at' => $now, + ]); + api_fixture_cleanup_insert($db, 'customer_fixed_pricing_versions', [ + 'customer_number' => $customerNumber, + 'price' => 1999, + 'description' => 'Fixture cleanup fixed pricing', + 'effective_from' => $now, + 'source' => 'fixture.test', + 'confidence' => 1.00000, + 'inferred' => 0, + 'metadata_json' => '{}', + ]); + api_fixture_cleanup_insert($db, 'customer_discount_override_versions', [ + 'user_id' => $userId, + 'customer_number' => $customerNumber, + 'is_category' => 1, + 'object_id' => 'fixture_cleanup_category', + 'discount' => 25, + 'effective_from' => $now, + 'source' => 'fixture.test', + 'confidence' => 1.00000, + 'inferred' => 0, + 'metadata_json' => '{}', + ]); + api_fixture_cleanup_insert($db, 'system_search_economic_customer_index', [ + 'customer_number' => $customerNumber, + 'user_id' => $userId, + 'local_display_name' => 'Fixture Cleanup Customer', + 'local_email' => 'fixture-cleanup@example.test', + 'search_text' => 'fixture cleanup customer', + ]); + api_fixture_cleanup_insert($db, 'subuser_grants', [ + 'billing_customer_number' => $customerNumber, + 'subuser' => $cashierId, + 'enabled' => 1, + 'note' => 'Fixture cleanup grant', + 'permissions' => '[]', + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + + $invoiceCollectionId = api_fixture_cleanup_insert($db, 'collected_order_invoices', [ + 'customer_number' => $customerNumber, + 'name' => 'Fixture cleanup invoice', + 'notes' => 'Fixture cleanup notes', + 'processor' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $orderId = api_fixture_cleanup_insert($db, 'orders', [ + 'customer_id' => $customerNumber, + 'cashier_id' => $cashierId, + 'department_id' => $departmentId, + 'reference' => 'FIXTURE-CLEANUP', + 'notes' => 'Fixture cleanup order', + 'reg_1' => 'FC12345', + 'invoice_collection_id' => $invoiceCollectionId, + 'using_hand_held' => 0, + 'include_in_invoice' => 1, + 'created_at' => $now, + 'updated_at' => $now, + 'completed_at' => $now, + 'deleted_at' => null, + ]); + + api_fixture_cleanup_insert($db, 'order_items', [ + 'order_id' => $orderId, + 'product_id' => 61, + 'reference' => 'FIXTURE-CLEANUP-ITEM', + 'notes' => 'Fixture cleanup item', + 'cashier_id' => $cashierId, + 'price' => 250, + 'quantity' => 1, + 'include_in_invoice' => 1, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + api_fixture_cleanup_insert($db, 'economic_module_orders', [ + 'id' => $orderId, + 'invoice_draft_id' => 9001, + 'invoice_id' => 9002, + 'created_at' => $now, + 'updated_at' => $now, + ]); + api_fixture_cleanup_insert($db, 'stripe_module_orders', [ + 'id' => $orderId, + 'invoice_id' => 'in_fixture_cleanup', + 'customer_id' => 'cus_fixture_cleanup', + 'created_at' => $now, + 'updated_at' => $now, + ]); + api_fixture_cleanup_insert($db, 'stripe_payment_intents', [ + 'order_id' => $orderId, + 'payment_intent_id' => 'pi_fixture_cleanup', + 'client_secret' => 'secret_fixture_cleanup', + 'data' => '{}', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $vehicleId = api_fixture_cleanup_insert($db, 'customer_vehicles', [ + 'customer_id' => $customerNumber, + 'type' => 61, + 'reg' => 'FC12345', + 'wash_subscription' => 1, + 'notes' => 'Fixture cleanup vehicle', + 'reference' => 'Fixture cleanup reference', + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + + api_fixture_cleanup_insert($db, 'customer_vehicles_addons', [ + 'vehicle_id' => $vehicleId, + 'addon_id' => 71, + 'amount' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + api_fixture_cleanup_insert($db, 'customer_vehicle_subscription_versions', [ + 'vehicle_id' => $vehicleId, + 'customer_number' => $customerNumber, + 'reg' => 'FC12345', + 'vehicle_type' => 61, + 'wash_subscription' => 1, + 'effective_from' => $now, + 'source' => 'fixture.test', + 'confidence' => 1.00000, + 'inferred' => 0, + 'metadata_json' => '{}', + ]); + + $attachmentPayload = '{"fixture_cleanup":true}'; + api_fixture_cleanup_insert($db, 'object_attachments', [ + 'object_type' => 'users', + 'object_id' => $userId, + 'content' => $attachmentPayload, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + api_fixture_cleanup_insert($db, 'object_attachments', [ + 'object_type' => 'orders', + 'object_id' => $orderId, + 'content' => $attachmentPayload, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + api_fixture_cleanup_insert($db, 'object_attachments', [ + 'object_type' => 'customer_vehicles', + 'object_id' => $vehicleId, + 'content' => $attachmentPayload, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + api_fixture_cleanup_insert($db, 'object_attachments', [ + 'object_type' => 'collected_order_invoices', + 'object_id' => $invoiceCollectionId, + 'content' => $attachmentPayload, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); +} + +/** + * @return array + */ +function api_fixture_cleanup_trace_counts(mysqli $db, int $userId, int $customerNumber): array +{ + return [ + 'users' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `users` WHERE `id` = {$userId}"), + 'customer_attributes' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_attributes` WHERE `user_id` = {$userId}"), + 'tokens' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `tokens` WHERE `user_id` = {$userId}"), + 'user_key_value_pairs' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `user_key_value_pairs` WHERE `user_id` = {$userId}"), + 'price_overrides' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `price_overrides` WHERE `user_id` = {$userId}"), + 'customer_default_department' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_default_department` WHERE `customer_number` = {$customerNumber}"), + 'customer_fixed_pricing' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_fixed_pricing` WHERE `customer_number` = {$customerNumber}"), + 'customer_fixed_pricing_versions' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_fixed_pricing_versions` WHERE `customer_number` = {$customerNumber}"), + 'customer_vehicle_subscription_versions' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_vehicle_subscription_versions` WHERE `customer_number` = {$customerNumber}"), + 'customer_discount_override_versions' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_discount_override_versions` WHERE `customer_number` = {$customerNumber}"), + 'system_search_economic_customer_index' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `system_search_economic_customer_index` WHERE `customer_number` = {$customerNumber}"), + 'subuser_grants' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `subuser_grants` WHERE `billing_customer_number` = {$customerNumber}"), + 'collected_order_invoices' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `collected_order_invoices` WHERE `customer_number` = {$customerNumber}"), + 'orders' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `orders` WHERE `customer_id` = {$customerNumber}"), + 'order_items' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `order_items` WHERE `order_id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber})"), + 'economic_module_orders' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `economic_module_orders` WHERE `id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber})"), + 'stripe_module_orders' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `stripe_module_orders` WHERE `id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber})"), + 'stripe_payment_intents' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `stripe_payment_intents` WHERE `order_id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber})"), + 'customer_vehicles' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_vehicles` WHERE `customer_id` = {$customerNumber}"), + 'customer_vehicles_addons' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_vehicles_addons` WHERE `vehicle_id` IN (SELECT `id` FROM `customer_vehicles` WHERE `customer_id` = {$customerNumber})"), + 'object_attachments' => api_fixture_cleanup_count( + $db, + "SELECT COUNT(*) AS c + FROM `object_attachments` + WHERE (`object_type` = 'users' AND `object_id` = {$userId}) + OR (`object_type` = 'orders' AND `object_id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber})) + OR (`object_type` = 'customer_vehicles' AND `object_id` IN (SELECT `id` FROM `customer_vehicles` WHERE `customer_id` = {$customerNumber})) + OR (`object_type` = 'collected_order_invoices' AND `object_id` IN (SELECT `id` FROM `collected_order_invoices` WHERE `customer_number` = {$customerNumber}))" + ), + ]; +} + +function api_fixture_cleanup_count(mysqli $db, string $sql): int +{ + $result = $db->query($sql); + if ($result === false) { + throw new RuntimeException('Fixture cleanup assertion query failed: ' . $sql); + } + + $row = $result->fetch_assoc(); + $result->free(); + + return (int)($row['c'] ?? 0); +} + +function api_fixture_cleanup_module_config_row(mysqli $db, string $module, string $variable): ?array +{ + $moduleEscaped = $db->real_escape_string($module); + $variableEscaped = $db->real_escape_string($variable); + $result = $db->query( + "SELECT * FROM `module_config` WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}' LIMIT 1" + ); + + if ($result === false) { + throw new RuntimeException('Fixture cleanup module_config query failed.'); + } + + $row = $result->fetch_assoc(); + $result->free(); + + return $row ?: null; +} + +/** + * @param array $data + */ +function api_fixture_cleanup_insert(mysqli $db, string $table, array $data): int +{ + $filtered = []; + foreach ($data as $column => $value) { + if (api_fixture_cleanup_has_column($db, $table, $column)) { + $filtered[$column] = $value; + } + } + + if ($filtered === []) { + throw new RuntimeException('No matching columns available for fixture cleanup insert into ' . $table . '.'); + } + + $columns = array_map( + static fn(string $column): string => '`' . $column . '`', + array_keys($filtered) + ); + $values = array_map( + static fn(mixed $value): string => api_fixture_cleanup_sql_value($db, $value), + array_values($filtered) + ); + + $sql = 'INSERT INTO `' . $table . '` (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ')'; + if ($db->query($sql) === false) { + throw new RuntimeException('Fixture cleanup insert failed: ' . $sql); + } + + return (int)$db->insert_id; +} + +function api_fixture_cleanup_has_column(mysqli $db, string $table, string $column): bool +{ + static $cache = []; + + $cacheKey = $table . ':' . $column; + if (array_key_exists($cacheKey, $cache)) { + return $cache[$cacheKey]; + } + + $escapedTable = $db->real_escape_string($table); + $escapedColumn = $db->real_escape_string($column); + $result = $db->query( + "SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$escapedTable}' + AND COLUMN_NAME = '{$escapedColumn}' + LIMIT 1" + ); + if ($result === false) { + return $cache[$cacheKey] = false; + } + + $exists = $result->fetch_assoc() !== null; + $result->free(); + + return $cache[$cacheKey] = $exists; +} + +function api_fixture_cleanup_sql_value(mysqli $db, mixed $value): string +{ + if ($value === null) { + return 'NULL'; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (is_int($value) || is_float($value)) { + return (string)$value; + } + + return "'" . $db->real_escape_string((string)$value) . "'"; +} diff --git a/services/nginx/app/tests/Api/AuthApiTest.php b/services/nginx/app/tests/Api/AuthApiTest.php new file mode 100644 index 00000000..24d76508 --- /dev/null +++ b/services/nginx/app/tests/Api/AuthApiTest.php @@ -0,0 +1,215 @@ +setModuleConfig('reCAPTCHA', 'enabled', 'false'); + $user = api_fixtures()->createUser([ + 'display_name' => 'API Login User', + 'password_plaintext' => 'Secret123!', + ]); + + $response = api_client()->post('/auth/login', [ + 'customer_number' => $user['customer_number'], + 'password' => $user['password_plaintext'], + ]); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('token') + ->and($response->data()['token']) + ->toBeString() + ->toHaveLength(64); +}); + +it('rejects invalid login payloads and credentials', function (): void { + api_test_covers('POST /auth/login', 'failure'); + + api_fixtures()->setModuleConfig('reCAPTCHA', 'enabled', 'false'); + $user = api_fixtures()->createUser([ + 'password_plaintext' => 'Secret123!', + ]); + + $missingPassword = api_client()->post('/auth/login', [ + 'customer_number' => $user['customer_number'], + ]); + + $missingPassword + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Missing required parameters: password'); + + $invalidCredentials = api_client()->post('/auth/login', [ + 'customer_number' => $user['customer_number'], + 'password' => 'wrong-password', + ]); + + $invalidCredentials + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid credentials'); +}); + +it('returns the cached auth session payload for a valid token', function (): void { + api_test_covers('GET /auth/session', 'happy'); + + $session = api_fixtures()->createUserSession(['list_departments'], [ + 'display_name' => 'Session User', + ]); + + api_fixtures()->cacheAuthSessionForUser( + $session['user'], + $session['token'], + ['list_departments'], + [ + 'two_factor_enabled' => false, + ] + ); + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '445566', 'int'); + api_fixtures()->setModuleConfig('economic', 'defaultDepartmentId', '75', 'int'); + + $response = api_client()->get('/auth/session', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('customer_number', $session['user']['customer_number']) + ->toHaveKey('two_factor_enabled', false) + ->and($response->data()['permissions']) + ->toContain('list_departments') + ->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null) + ->toBe(445566) + ->and($response->data()['runtime_config']['economic']['default_distribution_department_id'] ?? null) + ->toBe(75); +}); + +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'); + api_fixtures()->setModuleConfig('economic', 'defaultDepartmentId', '65', '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) + ->and($response->data()['runtime_config']['economic']['default_distribution_department_id'] ?? null) + ->toBe(65); +}); + +it('rejects invalid auth session tokens', function (): void { + api_test_covers('GET /auth/session', 'auth'); + + $response = api_client()->get('/auth/session', [ + 'Authorization' => 'Bearer invalid-token', + ]); + + $response + ->assertStatus(500) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessageContains('Token not found'); +}); + +it('logs out and invalidates the token for future session calls', function (): void { + api_test_covers('GET /auth/logout', 'happy'); + + $session = api_fixtures()->createUserSession(['list_departments'], [ + 'display_name' => 'Logout User', + ]); + + api_fixtures()->cacheAuthSessionForUser( + $session['user'], + $session['token'], + ['list_departments'] + ); + + $logoutResponse = api_client()->get('/auth/logout', $session['headers']); + + $logoutResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Logged out'); + + $followUpSession = api_client()->get('/auth/session', $session['headers']); + + $followUpSession + ->assertStatus(500) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessageContains('Token not found'); +}); + +it('logs out and invalidates cached subuser sessions', function (): void { + api_test_covers('GET /auth/logout', 'happy'); + + $user = api_fixtures()->createUser(); + + $session = api_fixtures()->createSubuserSession((int)$user['customer_number'], [], [ + 'username' => 'logout-driver', + ]); + + $warmCache = api_client()->get('/subusers/me', $session['headers']); + + $warmCache + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $logoutResponse = api_client()->get('/auth/logout', $session['headers']); + + $logoutResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Logged out'); + + $followUpProfile = api_client()->get('/subusers/me', $session['headers']); + + $followUpProfile + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Unauthorized'); +}); + +it('rejects invalid logout tokens', function (): void { + api_test_covers('GET /auth/logout', 'auth'); + + $response = api_client()->get('/auth/logout', [ + 'Authorization' => 'Bearer invalid-token', + ]); + + $response + ->assertStatus(500) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessageContains('Token not found'); +}); diff --git a/services/nginx/app/tests/Api/BirdVoiceWebhookApiTest.php b/services/nginx/app/tests/Api/BirdVoiceWebhookApiTest.php new file mode 100644 index 00000000..5c602110 --- /dev/null +++ b/services/nginx/app/tests/Api/BirdVoiceWebhookApiTest.php @@ -0,0 +1,400 @@ +setModuleConfig('bird', 'enabled', 'false'); +} + +it('returns the raw 202 gather webhook contract over HTTP', function (): void { + api_test_covers('POST /bird/voice/calls/webhook/inbound', 'happy'); + + disable_bird_transport_for_api_test(); + + $session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']); + $north = api_fixtures()->createDepartment([ + 'name' => 'North HTTP', + 'order_priority' => 1, + ]); + $south = api_fixtures()->createDepartment([ + 'name' => 'South HTTP', + 'order_priority' => 2, + ]); + + api_fixtures()->createDepartmentGate([ + 'department' => $north['id'], + 'is_entrance' => true, + 'is_exit' => false, + 'name' => 'North Gate', + 'config' => [ + 'type' => 'PHONE_CALL', + 'phone_number' => '+4511111111', + 'call_duration_threshold' => 5, + ], + ]); + api_fixtures()->createDepartmentGate([ + 'department' => $south['id'], + 'is_entrance' => true, + 'is_exit' => false, + 'name' => 'South Gate', + 'config' => [ + 'type' => 'PHONE_CALL', + 'phone_number' => '+4522222222', + 'call_duration_threshold' => 5, + ], + ]); + + $response = api_client()->post('/bird/voice/calls/webhook/inbound', [ + 'payload' => [ + 'endKey' => '#', + 'input' => 'dtmf', + 'maxNumKeys' => 1, + 'retries' => 3, + 'say' => [ + 'locale' => 'en-US', + 'voice' => 'female', + ], + 'speechLocale' => 'en-US', + 'timeout' => 30, + ], + 'request' => [ + 'callId' => '212c606f-a906-4a50-be0b-db95d74f2ffd', + 'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9', + 'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc', + ], + 'waitConditions' => [ + 'events' => [ + [ + 'action' => 'continue', + 'name' => 'call_command_gather_finished', + ], + ], + 'timeout' => 'PT10M', + ], + ], $session['headers']); + + $response->assertStatus(202); + + expect($response->headers['content-type'] ?? '')->toContain('application/json'); + expect($response->json)->toBeArray(); + expect($response->json)->not->toHaveKey('success'); + expect($response->json['statusCode'] ?? null)->toBe(202); + expect($response->json['statusText'] ?? null)->toBe('Accepted'); + expect($response->json['result']['status'] ?? null)->toBe('accepted'); + expect($response->json['event']['callCommand']['type'] ?? null)->toBe('gather'); + expect($response->json['event']['callCommand']['gather']['input'] ?? null)->toBe('dtmf'); + expect($response->json['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1); + expect($response->json['event']['callCommand']['gather']['say']['locale'] ?? null)->toBe('en-US'); + expect($response->json['event']['callCommand']['gather']['say']['voice'] ?? null)->toBe('female'); + expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.'); +}); + +it('still prompts for department selection when only one department is eligible', function (): void { + disable_bird_transport_for_api_test(); + + $session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']); + $solo = api_fixtures()->createDepartment([ + 'name' => 'Solo HTTP', + 'order_priority' => 1, + ]); + + api_fixtures()->createDepartmentGate([ + 'department' => $solo['id'], + 'is_entrance' => true, + 'is_exit' => false, + 'name' => 'Solo Gate', + 'config' => [ + 'type' => 'PHONE_CALL', + 'phone_number' => '+4533333333', + 'call_duration_threshold' => 5, + ], + ]); + + $response = api_client()->post('/bird/voice/calls/webhook/inbound', [ + 'payload' => [ + 'endKey' => '#', + 'retries' => 3, + 'timeout' => 30, + ], + 'request' => [ + 'callId' => '212c606f-a906-4a50-be0b-db95d74f2ffe', + 'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9', + 'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc', + ], + 'waitConditions' => [ + 'events' => [ + [ + 'action' => 'continue', + 'name' => 'call_command_gather_finished', + ], + ], + 'timeout' => 'PT10M', + ], + ], $session['headers']); + + $response->assertStatus(202); + + expect($response->json['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1); + expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.'); + expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for Solo HTTP.'); +}); + +it('accepts the legacy initial webhook body with top-level call identifiers', function (): void { + disable_bird_transport_for_api_test(); + + $session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']); + $solo = api_fixtures()->createDepartment([ + 'name' => 'Legacy HTTP', + 'order_priority' => 1, + ]); + + api_fixtures()->createDepartmentGate([ + 'department' => $solo['id'], + 'is_entrance' => true, + 'is_exit' => false, + 'name' => 'Legacy Gate', + 'config' => [ + 'type' => 'PHONE_CALL', + 'phone_number' => '+4533333399', + 'call_duration_threshold' => 5, + ], + ]); + + $response = api_client()->post('/bird/voice/calls/webhook/inbound', [ + 'callId' => '212c606f-a906-4a50-be0b-db95d74f2ff1', + 'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9', + 'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc', + ], $session['headers']); + + $response->assertStatus(200); + + expect($response->json['status'] ?? null)->toBe('gather'); + expect($response->json['completed'] ?? null)->toBeFalse(); + expect($response->json['stage'] ?? null)->toBe('department_select'); + expect($response->json['gather']['input'] ?? null)->toBe('dtmf'); + expect($response->json['gather']['maxNumKeys'] ?? null)->toBe(1); + expect($response->json['gather']['retries'] ?? null)->toBe(3); + expect($response->json['gather']['timeout'] ?? null)->toBe(30); + expect($response->json['gather']['endKey'] ?? null)->toBe('#'); + expect($response->json['gatherInput'] ?? null)->toBe('dtmf'); + expect($response->json['gatherMaxNumKeys'] ?? null)->toBe(1); + expect($response->json['gatherRetries'] ?? null)->toBe(3); + expect($response->json['gatherTimeout'] ?? null)->toBe(30); + expect($response->json['gatherEndKey'] ?? null)->toBe('#'); + expect($response->json['gatherSayLocale'] ?? null)->toBe('en-US'); + expect($response->json['gatherSayVoice'] ?? null)->toBe('female'); + expect($response->json['gather']['say']['locale'] ?? null)->toBe('en-US'); + expect($response->json['gather']['say']['voice'] ?? null)->toBe('female'); + expect($response->json['gather']['say']['text'] ?? null)->toContain('Choose department.'); + expect($response->json['gather']['say']['text'] ?? null)->toContain('Press 1 for Legacy HTTP.'); +}); + +it('returns native flow gather data after a top-level department selection when distinct gate choices exist', function (): void { + disable_bird_transport_for_api_test(); + + $session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']); + $north = api_fixtures()->createDepartment([ + 'name' => 'North Flow', + 'order_priority' => 1, + ]); + $south = api_fixtures()->createDepartment([ + 'name' => 'South Flow', + 'order_priority' => 2, + ]); + + api_fixtures()->createDepartmentGate([ + 'department' => $north['id'], + 'is_entrance' => true, + 'is_exit' => false, + 'name' => 'North Entrance', + 'config' => [ + 'type' => 'PHONE_CALL', + 'phone_number' => '+4511111199', + 'call_duration_threshold' => 5, + ], + ]); + api_fixtures()->createDepartmentGate([ + 'department' => $north['id'], + 'is_entrance' => false, + 'is_exit' => true, + 'name' => 'North Exit', + 'config' => [ + 'type' => 'PHONE_CALL', + 'phone_number' => '+4511111188', + 'call_duration_threshold' => 5, + ], + ]); + api_fixtures()->createDepartmentGate([ + 'department' => $south['id'], + 'is_entrance' => true, + 'is_exit' => false, + 'name' => 'South Entrance', + 'config' => [ + 'type' => 'PHONE_CALL', + 'phone_number' => '+4522222299', + 'call_duration_threshold' => 5, + ], + ]); + + $callId = 'bird-flow-' . bin2hex(random_bytes(8)); + $initialResponse = api_client()->post('/bird/voice/calls/webhook/inbound', [ + 'callId' => $callId, + 'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9', + 'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc', + ], $session['headers']); + + $initialResponse->assertStatus(200); + + $prompt = (string)($initialResponse->json['prompt'] ?? ''); + expect($prompt)->toContain('Press 1 for North Flow.'); + + $response = api_client()->post('/bird/voice/calls/webhook/inbound', [ + 'callId' => $callId, + 'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9', + 'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc', + 'keys' => '1', + ], $session['headers']); + + $response->assertStatus(200); + + expect($response->json['status'] ?? null)->toBe('gather'); + expect($response->json['completed'] ?? null)->toBeFalse(); + expect($response->json['stage'] ?? null)->toBe('gate_type_select'); + expect($response->json['prompt'] ?? null)->toContain('You selected North Flow.'); + expect($response->json['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.'); + expect($response->json['selection']['departmentId'] ?? null)->toBe((int)$north['id']); + expect($response->json['selection']['departmentName'] ?? null)->toBe('North Flow'); +}); + +it('opens the gate immediately after a top-level department selection when entrance and exit share the same gate', function (): void { + disable_bird_transport_for_api_test(); + + $session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']); + $shared = api_fixtures()->createDepartment([ + 'name' => 'Shared Flow', + 'order_priority' => 1, + ]); + + $sharedGate = api_fixtures()->createDepartmentGate([ + 'department' => $shared['id'], + 'is_entrance' => true, + 'is_exit' => true, + 'name' => 'Shared Both', + 'config' => [ + 'type' => 'PHONE_CALL', + 'phone_number' => '+4511111177', + 'call_duration_threshold' => 5, + ], + ]); + + $callId = 'bird-shared-' . bin2hex(random_bytes(8)); + $initialResponse = api_client()->post('/bird/voice/calls/webhook/inbound', [ + 'callId' => $callId, + 'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9', + 'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc', + ], $session['headers']); + + $initialResponse->assertStatus(200); + + $prompt = (string)($initialResponse->json['prompt'] ?? ''); + expect($prompt)->toContain('Press 1 for Shared Flow.'); + + $response = api_client()->post('/bird/voice/calls/webhook/inbound', [ + 'callId' => $callId, + 'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9', + 'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc', + 'keys' => '1', + ], $session['headers']); + + $response->assertStatus(200); + + expect($response->json['status'] ?? null)->toBeIn(['completed', 'failed']); + expect($response->json['completed'] ?? null)->toBeTrue(); + expect($response->json['action'] ?? null)->toBeIn(['gate_opened', 'gate_open_failed']); + expect($response->json['departmentId'] ?? null)->toBe((int)$shared['id']); + expect($response->json['departmentName'] ?? null)->toBe('Shared Flow'); + expect($response->json['gateType'] ?? null)->toBeNull(); + expect($response->json['gateId'] ?? null)->toBe((int)$sharedGate['id']); +}); + +it('uses a multi-digit gather contract when 10 departments are eligible', function (): void { + disable_bird_transport_for_api_test(); + + $session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']); + + for ($index = 1; $index <= 10; $index++) { + $department = api_fixtures()->createDepartment([ + 'name' => 'Department ' . $index . ' HTTP', + 'order_priority' => $index, + ]); + + api_fixtures()->createDepartmentGate([ + 'department' => $department['id'], + 'is_entrance' => true, + 'is_exit' => false, + 'name' => 'Gate ' . $index, + 'config' => [ + 'type' => 'PHONE_CALL', + 'phone_number' => sprintf('+45444444%02d', $index), + 'call_duration_threshold' => 5, + ], + ]); + } + + $response = api_client()->post('/bird/voice/calls/webhook/inbound', [ + 'payload' => [ + 'endKey' => '#', + 'retries' => 3, + 'timeout' => 30, + ], + 'request' => [ + 'callId' => '212c606f-a906-4a50-be0b-db95d74f2fff', + 'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9', + 'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc', + ], + 'waitConditions' => [ + 'events' => [ + [ + 'action' => 'continue', + 'name' => 'call_command_gather_finished', + ], + ], + 'timeout' => 'PT10M', + ], + ], $session['headers']); + + $response->assertStatus(202); + + expect($response->json['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(2); + expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Enter the option number followed by pound.'); + expect(preg_match( + '/Press [1-9][0-9]+ for Department 10 HTTP\./', + (string)($response->json['event']['callCommand']['gather']['say']['text'] ?? '') + ))->toBe(1); +}); + +it('returns a raw 400 transport error for malformed webhook payloads', function (): void { + api_test_covers('POST /bird/voice/calls/webhook/inbound', 'failure'); + + disable_bird_transport_for_api_test(); + + $session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']); + + $response = api_client()->post('/bird/voice/calls/webhook/inbound', [ + 'request' => [ + 'callId' => 'broken', + ], + ], $session['headers']); + + $response->assertStatus(400); + + expect($response->headers['content-type'] ?? '')->toContain('application/json'); + expect($response->json)->toBeArray(); + expect($response->json)->not->toHaveKey('success'); + expect($response->json['statusCode'] ?? null)->toBe(400); + expect($response->json['statusText'] ?? null)->toBe('Bad Request'); + expect($response->json['error']['message'] ?? null)->toContain('Malformed inbound Bird webhook payload'); +}); diff --git a/services/nginx/app/tests/Api/BrandingApiTest.php b/services/nginx/app/tests/Api/BrandingApiTest.php new file mode 100644 index 00000000..5a097f91 --- /dev/null +++ b/services/nginx/app/tests/Api/BrandingApiTest.php @@ -0,0 +1,233 @@ +createUserSession([ + 'list_branding_options', + 'add_branding_option', + 'edit_branding_option', + ]); + + $createPayload = [ + 'name' => 'API Brand ' . uniqid('', false), + 'description' => 'Created through the branding API', + 'cvr' => 41004355, + 'address' => 'Skagerrakvej 15, 6715 Esbjerg', + 'phone_country_code' => 45, + 'phone' => 76123456, + 'email' => 'brand@example.test', + 'website' => 'https://brand.example.test', + 'banner' => 'https://cdn.example.test/banner.png', + 'logo' => 'https://cdn.example.test/logo.png', + 'favicon' => 'https://cdn.example.test/favicon.ico', + 'signature' => 'https://cdn.example.test/signature.png', + ]; + + $createResponse = api_client()->post('/branding', $createPayload, $session['headers']); + + $createResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $createdId = (int)($createResponse->data()['id'] ?? 0); + expect($createdId)->toBeGreaterThan(0); + api_fixtures()->cleanupDeleteById('branding', $createdId); + + $singleResponse = api_client()->get('/branding?id=' . $createdId, $session['headers']); + + $singleResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($singleResponse->data()) + ->toBeArray() + ->toHaveKey('id', $createdId) + ->toHaveKey('name', $createPayload['name']) + ->toHaveKey('logo', $createPayload['logo']); + + $listResponse = api_client()->get('/branding', $session['headers']); + + $listResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $listedIds = array_map( + static fn(array $branding): int => (int)($branding['id'] ?? 0), + is_array($listResponse->data()) ? $listResponse->data() : [] + ); + expect($listedIds)->toContain($createdId); + + $updatePayload = [ + 'id' => $createdId, + 'name' => 'Updated Brand', + 'description' => 'Updated description', + 'cvr' => 43423010, + 'address' => 'Updatedvej 1, 1000 Kobenhavn', + 'phone_country_code' => 46, + 'phone' => 87654321, + 'email' => 'updated@example.test', + 'website' => 'https://updated.example.test', + 'banner' => 'https://cdn.example.test/updated-banner.png', + 'logo' => 'https://cdn.example.test/updated-logo.png', + 'favicon' => 'https://cdn.example.test/updated-favicon.ico', + 'signature' => '', + ]; + + $updateResponse = api_client()->put('/branding', $updatePayload, $session['headers']); + + $updateResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $updatedRow = api_fixtures()->fetchRowById('branding', $createdId); + expect($updatedRow)->not->toBeNull(); + expect($updatedRow['name'] ?? null)->toBe('Updated Brand'); + expect((int)($updatedRow['cvr'] ?? 0))->toBe(43423010); + expect($updatedRow['logo'] ?? null)->toBe('https://cdn.example.test/updated-logo.png'); + expect(array_key_exists('signature', $updatedRow))->toBeTrue(); + expect($updatedRow['signature'])->toBeNull(); +}); + +it('rejects branding requests without permissions or valid input', function (): void { + api_test_covers('GET /branding', 'auth'); + api_test_covers('POST /branding', 'failure'); + api_test_covers('PUT /branding', 'failure'); + + $unauthorizedSession = api_fixtures()->createUserSession([]); + + api_client()->get('/branding', $unauthorizedSession['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_branding_options']); + + $createSession = api_fixtures()->createUserSession(['add_branding_option']); + + api_client()->post('/branding', [ + 'name' => 'Invalid Brand', + 'description' => 'Missing CVR', + ], $createSession['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Missing required parameters: cvr'); + + api_client()->post('/branding', [ + 'name' => 'Invalid Brand', + 'description' => 'Bad CVR', + 'cvr' => 'not-a-number', + ], $createSession['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('cvr must be an integer'); + + $editSession = api_fixtures()->createUserSession(['edit_branding_option']); + + api_client()->put('/branding', [ + 'id' => 99999999, + 'name' => 'Missing Brand', + ], $editSession['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid id'); +}); + +it('assigns and clears department branding for superusers', function (): void { + api_test_covers('PUT /superuser/department/branding', 'happy'); + + $branding = api_fixtures()->createBranding([ + 'name' => 'Assignable Brand', + 'description' => 'Brand for assignment', + ]); + $department = api_fixtures()->createDepartment([ + 'name' => 'Branding Department', + ]); + $session = api_fixtures()->createUserSession([ + 'superuser_set_department_branding', + 'superuser_fetch_department', + ]); + + $assignResponse = api_client()->put('/superuser/department/branding', [ + 'department_id' => $department['id'], + 'branding_id' => $branding['id'], + ], $session['headers']); + + $assignResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $assignedRow = api_fixtures()->fetchRowById('departments', (int)$department['id']); + expect((int)($assignedRow['branding'] ?? 0))->toBe((int)$branding['id']); + + $departmentResponse = api_client()->get('/superuser/department?department_id=' . $department['id'], $session['headers']); + + $departmentResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + expect((int)($departmentResponse->data()['branding'] ?? 0))->toBe((int)$branding['id']); + + $clearResponse = api_client()->put('/superuser/department/branding', [ + 'department_id' => $department['id'], + 'branding_id' => null, + ], $session['headers']); + + $clearResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $clearedRow = api_fixtures()->fetchRowById('departments', (int)$department['id']); + expect(array_key_exists('branding', $clearedRow))->toBeTrue(); + expect($clearedRow['branding'])->toBeNull(); +}); + +it('rejects invalid department branding assignments', function (): void { + api_test_covers('PUT /superuser/department/branding', 'failure'); + + $department = api_fixtures()->createDepartment(); + $unauthorizedSession = api_fixtures()->createUserSession([]); + + api_client()->put('/superuser/department/branding', [ + 'department_id' => $department['id'], + 'branding_id' => null, + ], $unauthorizedSession['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['superuser_set_department_branding']); + + $session = api_fixtures()->createUserSession(['superuser_set_department_branding']); + + api_client()->put('/superuser/department/branding', [ + 'department_id' => $department['id'], + 'branding_id' => 99999999, + ], $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Branding not found'); + + api_client()->put('/superuser/department/branding', [ + 'department_id' => 99999999, + 'branding_id' => null, + ], $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Department not found'); +}); diff --git a/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php b/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php new file mode 100644 index 00000000..336129dc --- /dev/null +++ b/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php @@ -0,0 +1,347 @@ +queryOne('SELECT invoice_collection_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1'); + return (int)($row['invoice_collection_id'] ?? 0); +} + +function monthly_split_invoice_row(int $invoiceCollectionId): array +{ + return api_test_runtime()->queryOne('SELECT id, created_at, closed_at FROM collected_order_invoices WHERE id = ' . $invoiceCollectionId . ' LIMIT 1') ?? []; +} + +function monthly_split_customer_collection_count(int $customerNumber): int +{ + $row = api_test_runtime()->queryOne('SELECT COUNT(*) AS count FROM collected_order_invoices WHERE customer_number = ' . $customerNumber); + return (int)($row['count'] ?? 0); +} + +function monthly_split_cleanup_collections(array $invoiceCollectionIds): void +{ + $ids = array_values(array_unique(array_filter(array_map('intval', $invoiceCollectionIds), static fn(int $id): bool => $id > 0))); + if ($ids === []) { + return; + } + + api_test_runtime()->db()->query('UPDATE orders SET invoice_collection_id = NULL WHERE invoice_collection_id IN (' . implode(',', $ids) . ')'); + api_test_runtime()->db()->query('DELETE FROM collected_order_invoices WHERE id IN (' . implode(',', $ids) . ')'); +} + +it('previews monthly split changes without moving orders or creating collections', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'preview'); + + $customer = api_fixtures()->createUser(['display_name' => 'Preview Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-15 10:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-02 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $collectionCountBefore = monthly_split_customer_collection_count((int)$customer['customer_number']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + 'preview' => true, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $months = (array)($payload['changed'][0]['months'] ?? []); + expect($payload['preview'] ?? null)->toBeTrue() + ->and($payload['processed_count'] ?? null)->toBe(1) + ->and($payload['changed_count'] ?? null)->toBe(1) + ->and($payload['changed'][0]['created_invoice_collection_ids'] ?? null)->toBe([]) + ->and($months[0]['month'] ?? null)->toBe('2096-03') + ->and($months[0]['will_create_collection'] ?? null)->toBeFalse() + ->and($months[0]['target_invoice_collection_id'] ?? null)->toBe((int)$invoiceCollection['id']) + ->and($months[0]['closed_at'] ?? null)->toBeNull() + ->and($months[1]['month'] ?? null)->toBe('2096-04') + ->and($months[1]['will_create_collection'] ?? null)->toBeTrue() + ->and($months[1]['target_invoice_collection_id'] ?? null)->toBeNull() + ->and($months[1]['closed_at'] ?? null)->toBeNull() + ->and(monthly_split_customer_collection_count((int)$customer['customer_number']))->toBe($collectionCountBefore) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']); +}); + +it('splits a selected March and April collected invoice into monthly collections', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'happy'); + + $customer = api_fixtures()->createUser(['display_name' => 'Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'created_at' => '2096-03-01 00:00:01', + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-15 10:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-02 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['processed_count'] ?? null)->toBe(1) + ->and($payload['changed_count'] ?? null)->toBe(1) + ->and($payload['skipped_count'] ?? null)->toBe(0) + ->and($aprilCollectionId)->toBeGreaterThan(0) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId) + ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['created_at'] ?? null)->toBe('2096-03-01 00:00:01') + ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['closed_at'] ?? null)->toBeNull() + ->and(monthly_split_invoice_row($aprilCollectionId)['created_at'] ?? null)->toBe('2096-04-01 00:00:01') + ->and(monthly_split_invoice_row($aprilCollectionId)['closed_at'] ?? null)->toBeNull(); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + +it('sets closed_at to month end when split month has ended', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'closed-at'); + + $customer = api_fixtures()->createUser(['display_name' => 'Ended Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'created_at' => '2001-03-01 00:00:01', + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2001-03-15 10:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2001-04-02 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2001-03-01', + 'dateTo' => '2001-04-30', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['changed_count'] ?? null)->toBe(1) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId) + ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['closed_at'] ?? null)->toBe('2001-03-31 23:59:59') + ->and(monthly_split_invoice_row($aprilCollectionId)['closed_at'] ?? null)->toBe('2001-04-30 23:59:59'); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + +it('splits the whole affected collection even when only one month is selected', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'partial'); + + $customer = api_fixtures()->createUser(['display_name' => 'Partial Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-20 09:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-10 09:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-03-31', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['changed_count'] ?? null)->toBe(1) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + +it('does not affect booked collected invoices', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'booked-skip'); + + $customer = api_fixtures()->createUser(['display_name' => 'Booked Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'booked_invoice_id' => 987654, + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-05 12:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-05 12:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-03-31', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + expect($payload['changed_count'] ?? null)->toBe(0) + ->and($payload['skipped_count'] ?? null)->toBe(1) + ->and((string)($payload['skipped'][0]['message'] ?? ''))->toContain('booked') + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']); +}); + +it('skips draft linked, Stripe, and single month collections', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'skip'); + + $customer = api_fixtures()->createUser(['display_name' => 'Skipped Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $draftCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'external_id' => 'draft-external-reference', + ]); + $stripeCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'processor' => 2, + ]); + $singleMonthCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + + foreach ([$draftCollection, $stripeCollection] as $collection) { + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $collection['id'], + 'created_at' => '2096-03-05 12:00:00', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $collection['id'], + 'created_at' => '2096-04-05 12:00:00', + ]); + } + + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $singleMonthCollection['id'], + 'created_at' => '2096-03-10 12:00:00', + ]); + + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-03-31', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + expect($payload['processed_count'] ?? null)->toBe(3) + ->and($payload['changed_count'] ?? null)->toBe(0) + ->and($payload['skipped_count'] ?? null)->toBe(3); +}); + +it('rejects invalid monthly split date ranges', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'failure'); + + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-04-30', + 'dateTo' => '2096-03-01', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); +}); diff --git a/services/nginx/app/tests/Api/DepartmentsApiTest.php b/services/nginx/app/tests/Api/DepartmentsApiTest.php new file mode 100644 index 00000000..2f4cb73a --- /dev/null +++ b/services/nginx/app/tests/Api/DepartmentsApiTest.php @@ -0,0 +1,308 @@ +createUserSession([ + 'list_departments', + 'view_slack_webhook', + ]); + + $visibleDepartment = api_fixtures()->createDepartment([ + 'name' => 'Visible Department', + 'visible' => 1, + ]); + $hiddenDepartment = api_fixtures()->createDepartment([ + 'name' => 'Hidden Department', + 'visible' => 0, + ]); + $archivedDepartment = api_fixtures()->createDepartment([ + 'name' => 'Archived Department', + 'visible' => 1, + 'archived' => 1, + ]); + $webhookDepartment = api_fixtures()->createDepartment([ + 'name' => 'Webhook Department', + 'slack_webhook' => 'https://hooks.slack.test/example', + 'visible' => 1, + ]); + + $listResponse = api_client()->get('/departments', $session['headers']); + + $listResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $departmentIds = array_map( + static fn(array $department): int => (int)($department['id'] ?? 0), + is_array($listResponse->data()) ? $listResponse->data() : [] + ); + + expect($departmentIds) + ->toContain($visibleDepartment['id']) + ->toContain($webhookDepartment['id']) + ->not->toContain($hiddenDepartment['id']) + ->not->toContain($archivedDepartment['id']); + + $singleResponse = api_client()->get('/departments?id=' . $webhookDepartment['id'], $session['headers']); + + $singleResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($singleResponse->data()) + ->toBeArray() + ->toHaveKey('id', $webhookDepartment['id']) + ->toHaveKey('slack_webhook', 'https://hooks.slack.test/example'); +}); + +it('allows superusers to filter archived departments', function (): void { + api_test_covers('GET /departments', 'happy'); + + $session = api_fixtures()->createUserSession([ + 'list_departments', + 'superuser_fetch_department', + ]); + + $activeDepartment = api_fixtures()->createDepartment([ + 'name' => 'Active Department', + 'visible' => 1, + 'archived' => 0, + ]); + $archivedDepartment = api_fixtures()->createDepartment([ + 'name' => 'Archived Department', + 'visible' => 1, + 'archived' => 1, + ]); + + $response = api_client()->get('/departments?filters=archived:1', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $departmentIds = array_map( + static fn(array $department): int => (int)($department['id'] ?? 0), + is_array($response->data()) ? $response->data() : [] + ); + + expect($departmentIds) + ->toContain($archivedDepartment['id']) + ->not->toContain($activeDepartment['id']); + + foreach ($response->data() as $department) { + expect((bool)($department['archived'] ?? false))->toBeTrue(); + } +}); + +it('does not allow regular department listings to reveal archived departments through filters', function (): void { + api_test_covers('GET /departments', 'auth'); + + $session = api_fixtures()->createUserSession(['list_departments']); + + $activeDepartment = api_fixtures()->createDepartment([ + 'name' => 'Regular Active Department', + 'visible' => 1, + 'archived' => 0, + ]); + $archivedDepartment = api_fixtures()->createDepartment([ + 'name' => 'Regular Archived Department', + 'visible' => 1, + 'archived' => 1, + ]); + + $response = api_client()->get('/departments?filters=archived:1', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $departmentIds = array_map( + static fn(array $department): int => (int)($department['id'] ?? 0), + is_array($response->data()) ? $response->data() : [] + ); + + expect($departmentIds) + ->toContain($activeDepartment['id']) + ->not->toContain($archivedDepartment['id']); +}); + +it('rejects department listing when the permission is missing', function (): void { + api_test_covers('GET /departments', 'auth'); + + $session = api_fixtures()->createUserSession([]); + + $response = api_client()->get('/departments', $session['headers']); + + $response + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_departments']); +}); + +it('creates departments through the real endpoint', function (): void { + api_test_covers('POST /departments', 'happy'); + + $session = api_fixtures()->createUserSession(['add_department']); + $name = 'Created Department ' . uniqid('', false); + + $response = api_client()->post('/departments', [ + 'name' => $name, + 'description' => 'Created by API test', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Department added successfully'); + + $createdRow = api_test_runtime()->queryOne( + "SELECT * FROM departments WHERE name = '" . api_test_runtime()->db()->real_escape_string($name) . "' ORDER BY id DESC LIMIT 1" + ); + + expect($createdRow)->not->toBeNull(); + api_fixtures()->cleanupDeleteById('departments', (int)$createdRow['id']); +}); + +it('rejects invalid department create requests', function (): void { + api_test_covers('POST /departments', 'failure'); + + $authorizedSession = api_fixtures()->createUserSession(['add_department']); + $missingDescription = api_client()->post('/departments', [ + 'name' => 'Broken Department', + ], $authorizedSession['headers']); + + $missingDescription + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Description is required'); + + $unauthorizedSession = api_fixtures()->createUserSession([]); + $missingPermission = api_client()->post('/departments', [ + 'name' => 'No Permission Department', + 'description' => 'Should fail', + ], $unauthorizedSession['headers']); + + $missingPermission + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['add_department']); +}); + +it('updates departments through the real endpoint', function (): void { + api_test_covers('PUT /departments', 'happy'); + + $session = api_fixtures()->createUserSession(['edit_department']); + $department = api_fixtures()->createDepartment([ + 'name' => 'Original Department', + 'description' => 'Original description', + 'order_priority' => 1, + ]); + + $response = api_client()->put('/departments', [ + 'id' => $department['id'], + 'name' => 'Updated Department', + 'description' => 'Updated description', + 'order_priority' => 5, + 'archived' => true, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Department updated successfully'); + + $row = api_fixtures()->fetchRowById('departments', (int)$department['id']); + + expect($row)->not->toBeNull(); + expect($row['name'] ?? null)->toBe('Updated Department'); + expect($row['description'] ?? null)->toBe('Updated description'); + expect((int)($row['order_priority'] ?? 0))->toBe(5); + expect((int)($row['archived'] ?? 0))->toBe(1); +}); + +it('rejects invalid department update requests', function (): void { + api_test_covers('PUT /departments', 'failure'); + + $authorizedSession = api_fixtures()->createUserSession(['edit_department']); + $missingId = api_client()->put('/departments', [ + 'name' => 'Missing ID', + ], $authorizedSession['headers']); + + $missingId + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Missing required parameters: id'); + + $unauthorizedSession = api_fixtures()->createUserSession([]); + $missingPermission = api_client()->put('/departments', [ + 'id' => 123, + 'name' => 'No Permission', + ], $unauthorizedSession['headers']); + + $missingPermission + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['edit_department']); +}); + +it('lists department categories for a department', function (): void { + api_test_covers('GET /departments/categories', 'happy'); + + $session = api_fixtures()->createUserSession(['list_department_categories']); + $department = api_fixtures()->createDepartment(); + $category = api_fixtures()->createCategory([ + 'name' => 'Department Category', + ]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + + $response = api_client()->get('/departments/categories?id=' . $department['id'], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toBeArray() + ->toHaveCount(1) + ->and($response->data()[0]['department_id'] ?? null)->toBe($department['id']) + ->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']); +}); + +it('rejects invalid department category requests', function (): void { + api_test_covers('GET /departments/categories', 'failure'); + + $authorizedSession = api_fixtures()->createUserSession(['list_department_categories']); + $missingId = api_client()->get('/departments/categories', $authorizedSession['headers']); + + $missingId + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Missing required parameters: id'); + + $unauthorizedSession = api_fixtures()->createUserSession([]); + $missingPermission = api_client()->get('/departments/categories?id=1', $unauthorizedSession['headers']); + + $missingPermission + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_department_categories']); +}); diff --git a/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php new file mode 100644 index 00000000..b5bfd3ab --- /dev/null +++ b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php @@ -0,0 +1,183 @@ +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'); + + api_fixtures()->preserveModuleConfig('economic', 'transactionDraftCustomerNumber'); + $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('round-trips the default distribution department config value through economic config updates', function (): void { + api_test_covers('POST /economic/config', 'happy'); + + api_fixtures()->preserveModuleConfig('economic', 'defaultDepartmentId'); + $session = api_fixtures()->createUserSession(['economic_config']); + + api_client()->post('/economic/config', [ + 'variable' => 'defaultDepartmentId', + 'value' => 75, + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $configuredResponse = api_client()->get('/economic/config?variable=defaultDepartmentId', $session['headers']); + $configuredResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $configuredEntry = economic_draft_customer_find_config_entry((array)$configuredResponse->data(), 'defaultDepartmentId'); + expect($configuredEntry) + ->not->toBeNull() + ->and($configuredEntry['type'] ?? null)->toBe('int') + ->and($configuredEntry['value'] ?? null)->toBe(75); +}); + +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); +}); diff --git a/services/nginx/app/tests/Api/EdgeGatewayAgentApiTest.php b/services/nginx/app/tests/Api/EdgeGatewayAgentApiTest.php new file mode 100644 index 00000000..f56d354a --- /dev/null +++ b/services/nginx/app/tests/Api/EdgeGatewayAgentApiTest.php @@ -0,0 +1,367 @@ +createDepartment([ + 'name' => 'Edge Agent Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + $installTokenResponse = api_client()->post('/edge-gateways/install-token', [ + 'department_id' => (int)$department['id'], + 'label' => 'Edge Agent Install', + ], $session['headers']); + + $installTokenResponse + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $installToken = $installTokenResponse->data(); + + $installScript = api_client()->get('/edge-agent/install.sh?token=' . urlencode((string)$installToken['token'])); + + expect($installScript->status)->toBe(200) + ->and($installScript->body) + ->toContain('/edge-agent/install-token/status') + ->toContain('agent.php') + ->toContain((string)$installToken['token']); + + $artifact = api_client()->get('/edge-agent/artifacts/agent.php'); + expect($artifact->status)->toBe(200) + ->and($artifact->body) + ->toContain('post('/edge-agent/claim', [ + 'token' => (string)$installToken['token'], + 'hostname' => 'edge-agent-api', + 'installed_version' => 'php-agent-v1', + ]); + + $claimResponse + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $gatewayId = (int)($claimResponse->data()['gateway']['id'] ?? 0); + $agentToken = (string)($claimResponse->data()['agent_token'] ?? ''); + + expect($gatewayId)->toBeGreaterThan(0) + ->and($agentToken)->not->toBe(''); + + edge_agent_test_set_heartbeat_age($gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1); + + $degradedDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']); + + $degradedDetail + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($degradedDetail->data()) + ->toHaveKey('status', 'DEGRADED'); + + edge_agent_test_set_heartbeat_age($gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1); + + $offlineDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']); + + $offlineDetail + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($offlineDetail->data()) + ->toHaveKey('status', 'OFFLINE'); + + $heartbeatResponse = api_client()->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [ + 'agent_token' => $agentToken, + 'status' => 'ONLINE', + 'hostname' => 'edge-agent-api', + 'metadata' => [ + 'agent_instance_id' => 'edge-agent-api-test', + 'broker_connected' => true, + 'broker_url' => 'wss://broker.example.test/edge-broker', + 'broker_last_connected_at' => '2026-04-08T10:05:00+00:00', + 'system_metrics' => [ + 'cpu_percent' => 21, + 'memory_mb' => 128, + ], + ], + 'inventory' => edge_agent_test_inventory('heartbeat'), + ]); + + $heartbeatResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($heartbeatResponse->data()) + ->toHaveKey('status', 'ONLINE') + ->and($heartbeatResponse->data()['broker_url'] ?? null) + ->toBeString() + ->toContain('/edge-broker'); + + $recoveredDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']); + + $recoveredDetail + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($recoveredDetail->data()) + ->toHaveKey('status', 'ONLINE') + ->and($recoveredDetail->data()['metadata']['system_metrics']['cpu_percent'] ?? null) + ->toBe(21) + ->and($recoveredDetail->data()['metadata']['broker_presence']['connected'] ?? null) + ->toBeTrue() + ->and($recoveredDetail->data()['channel_status']['broker']['connected'] ?? null) + ->toBeTrue() + ->and($recoveredDetail->data()['channel_status']['command']['preferred'] ?? null) + ->toBe(edge_gateway_manager::DELIVERY_CHANNEL_BROKER); +}); + +it('polls operations and commands, submits results, and records broker presence for task pages', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Edge Agent Operations Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + $gateway = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + 'label' => 'Edge Agent Runtime', + ]); + + $operationResponse = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [ + 'type' => 'DISCOVERY', + 'request' => [ + 'inventory' => edge_agent_test_inventory('operation'), + ], + ], $session['headers']); + + $operationResponse + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $operationId = (int)($operationResponse->data()['operation']['id'] ?? 0); + expect($operationId)->toBeGreaterThan(0); + + $operationLease = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/next', [ + 'agent_token' => (string)$gateway['agent_token'], + 'wait_seconds' => 0, + 'agent_instance_id' => 'edge-agent-api-test', + ]); + + $operationLease + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($operationLease->data()) + ->toHaveKey('id', $operationId) + ->toHaveKey('status', 'IN_PROGRESS'); + + $eventResponse = api_client()->post( + '/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events', + [ + 'agent_token' => (string)$gateway['agent_token'], + 'level' => 'INFO', + 'code' => 'DISCOVERY_RUNNING', + 'message' => 'Discovery is running through the agent API.', + 'context' => [ + 'progress' => 55, + 'label' => 'Discovery running', + ], + ] + ); + + $eventResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $completeResponse = api_client()->post( + '/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/complete', + [ + 'agent_token' => (string)$gateway['agent_token'], + 'ok' => true, + 'result' => [ + 'inventory' => edge_agent_test_inventory('completed'), + ], + ] + ); + + $completeResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($completeResponse->data()) + ->toHaveKey('status', 'COMPLETED'); + + $job = api_fixtures()->createEdgeCommandJob([ + 'gateway_id' => (int)$gateway['id'], + 'command_type' => 'GET_RELAY_STATUS', + 'request' => [ + 'relayId' => 'relay-main', + 'localIp' => '10.0.0.18', + ], + ]); + + $commandPoll = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/commands/poll', [ + 'agent_token' => (string)$gateway['agent_token'], + 'wait_seconds' => 0, + ]); + + $commandPoll + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($commandPoll->data()) + ->toHaveKey('id', (int)$job['id']) + ->toHaveKey('command_type', 'GET_RELAY_STATUS'); + + $commandResult = api_client()->post( + '/edge-agent/gateways/' . (int)$gateway['id'] . '/commands/' . (int)$job['id'] . '/result', + [ + 'agent_token' => (string)$gateway['agent_token'], + 'ok' => true, + 'result' => [ + 'relayId' => 'relay-main', + 'online' => true, + ], + ] + ); + + $commandResult + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($commandResult->data()) + ->toHaveKey('acknowledged', true) + ->and($commandResult->data()['job']['status'] ?? null) + ->toBe('COMPLETED'); + + $presenceResponse = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/presence', [ + 'agent_token' => (string)$gateway['agent_token'], + 'status' => 'connected', + 'connection_id' => 'broker-connection-1', + 'metadata' => [ + 'transport' => 'ws', + ], + ]); + + $presenceResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($presenceResponse->data()) + ->toHaveKey('connected', true); + + $tasksPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/tasks', $session['headers']); + + $tasksPage + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($tasksPage->data()['operations'] ?? []) + ->toBeArray() + ->not->toBeEmpty() + ->and($tasksPage->data()['recent_commands'] ?? []) + ->toBeArray() + ->not->toBeEmpty(); + + expect($tasksPage->data()['recent_operations_summary']['completed'] ?? 0) + ->toBeGreaterThanOrEqual(1); +}); + +it('rejects missing and invalid edge agent tokens', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Edge Agent Auth Department', + ]); + $gateway = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + ]); + + $missingToken = api_client()->get('/edge-agent/install-token/verify'); + + $missingToken + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Missing token'); + + $missingAgentToken = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/heartbeat', [ + 'status' => 'ONLINE', + ]); + + $missingAgentToken + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Missing edge gateway agent token'); + + $invalidAgentToken = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/next', [ + 'agent_token' => 'invalid-token', + 'wait_seconds' => 0, + ]); + + $invalidAgentToken + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid edge gateway token'); +}); + +function edge_agent_test_set_heartbeat_age(int $gatewayId, int $secondsAgo): void +{ + $db = api_test_runtime()->db(); + $row = api_fixtures()->fetchRowById('edge_gateways', $gatewayId) ?? []; + $referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? '')); + if ($referenceTimestamp === false || $referenceTimestamp <= 0) { + $referenceTimestamp = time(); + } + $timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo)); + $escapedTimestamp = $db->real_escape_string($timestamp); + $db->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escapedTimestamp}', status = 'ONLINE' WHERE id = " . (int)$gatewayId); + + $redis = api_test_runtime()->redis(); + if ($redis !== null) { + foreach ([ + 'obj_prop:*:' . $gatewayId . ':status', + 'obj_prop:*:' . $gatewayId . ':last_heartbeat_at', + 'obj_prop:*:' . $gatewayId . ':updated_at', + ] as $pattern) { + $keys = $redis->keys($pattern); + if (is_array($keys) && $keys !== []) { + $redis->del($keys); + } + } + } + + api_fixtures()->clearEdgeGatewayViewCache(); +} + +function edge_agent_test_inventory(string $suffix): array +{ + return [[ + 'device_id' => 'agent-' . $suffix, + 'local_ip' => '10.30.40.50', + 'model' => 'TruckWash Edge Agent', + 'channel_count' => 1, + 'online' => true, + 'capabilities' => [ + 'gateway_management_v2' => true, + ], + 'metadata' => [ + 'hostname' => 'edge-' . $suffix, + ], + ]]; +} diff --git a/services/nginx/app/tests/Api/EdgeGatewayBrokerApiTest.php b/services/nginx/app/tests/Api/EdgeGatewayBrokerApiTest.php new file mode 100644 index 00000000..dad86162 --- /dev/null +++ b/services/nginx/app/tests/Api/EdgeGatewayBrokerApiTest.php @@ -0,0 +1,493 @@ +createDepartment([ + 'name' => 'Edge Broker Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + $gateway = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + 'label' => 'Broker Gateway', + ]); + + $validateGateway = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/validate', + ['token' => (string)$gateway['agent_token']], + edge_test_broker_headers() + ); + + $validateGateway + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($validateGateway->data()) + ->toHaveKey('gateway_id', (int)$gateway['id']) + ->toHaveKey('department_id', (int)$department['id']); + + $presence = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence', + [ + 'status' => 'connected', + 'connection_id' => 'broker-presence-1', + 'metadata' => [ + 'transport' => 'ws', + ], + ], + edge_test_broker_headers() + ); + + $presence + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($presence->data()) + ->toHaveKey('connected', true) + ->toHaveKey('connection_id', 'broker-presence-1'); + + $telemetry = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/telemetry', + [ + 'status' => 'ONLINE', + 'metadata' => [ + 'system_metrics' => [ + 'cpu_load' => 0.42, + 'memory_mb' => 512, + ], + ], + 'inventory' => edge_broker_test_inventory('telemetry'), + ], + edge_test_broker_headers() + ); + + $telemetry + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($telemetry->data()['metadata']['system_metrics']['cpu_load'] ?? null) + ->toBe(0.42); + + $logEntry = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/logs', + [ + 'level' => 'INFO', + 'stream' => 'agent', + 'source' => 'BROKER', + 'message' => 'Broker forwarded a live gateway log.', + 'context' => [ + 'source' => 'broker-test', + ], + ], + edge_test_broker_headers() + ); + + $logEntry + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($logEntry->data()) + ->toHaveKey('message', 'Broker forwarded a live gateway log.'); + + $relayLogEntry = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/logs', + [ + 'level' => 'INFO', + 'stream' => 'relay', + 'source' => 'RELAY_DISPATCH', + 'message' => 'MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH', + 'context' => [ + 'module' => 'selfserve', + 'module_responsible' => 'selfserve', + 'reason' => 'Broker relayed machine start', + 'handler' => 'local', + 'delivery_channel' => 'BROKER_FAST_PATH', + 'relay_id' => 'M-7', + 'relay_name' => 'Roskilde Maskine', + 'relay_role' => 'MACHINE', + 'description' => 'MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH', + 'associated' => [ + 'admin_user_id' => 77, + 'customer_number' => 700123, + ], + 'signal' => [ + 'command_type' => 'SET_RELAY_STATE', + 'request' => [ + 'relayId' => 'M-7', + 'on' => true, + ], + ], + 'response' => [ + 'online' => true, + 'on' => true, + ], + ], + ], + edge_test_broker_headers() + ); + + $relayLogEntry + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $streamSession = api_client()->post( + '/edge-gateways/' . (int)$gateway['id'] . '/stream-session', + ['scopes' => ['logs', 'statistics', 'tasks']], + $session['headers'] + ); + + $streamSession + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $streamValidation = api_client()->post( + '/edge-agent/internal/browser-streams/validate', + ['token' => (string)$streamSession->data()['token']], + edge_test_broker_headers() + ); + + $streamValidation + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($streamValidation->data()) + ->toHaveKey('session_type', 'gateway-stream') + ->toHaveKey('gateway_id', (int)$gateway['id']); + + $shellSession = api_client()->post( + '/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions', + ['reason' => 'Broker shell validation'], + $session['headers'] + ); + + $shellSession + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + expect($shellSession->data()['diagnostics'] ?? []) + ->toHaveKey('ready', true) + ->toHaveKey('reason_code', 'READY') + ->and($shellSession->data()['diagnostics']['broker_presence']['connection_id'] ?? null) + ->toBe('broker-presence-1'); + + $shellToken = (string)$shellSession->data()['token']; + + $validateShell = api_client()->post( + '/edge-agent/internal/shell-sessions/validate', + ['token' => $shellToken], + edge_test_broker_headers() + ); + + $validateShell + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($validateShell->data()) + ->toHaveKey('status', 'PENDING'); + + $openedShell = api_client()->post( + '/edge-agent/internal/shell-sessions/opened', + [ + 'token' => $shellToken, + 'connection_id' => 'shell-connection-1', + ], + edge_test_broker_headers() + ); + + $openedShell + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($openedShell->data()) + ->toHaveKey('status', 'OPEN') + ->toHaveKey('connection_id', 'shell-connection-1'); + + $closedShell = api_client()->post( + '/edge-agent/internal/shell-sessions/close', + [ + 'token' => $shellToken, + 'transcript' => "edge-broker-shell\n", + 'reason' => 'agent_exit', + 'message' => 'Shell exited cleanly.', + 'code' => 0, + 'stage' => 'shell_active', + 'ws_url' => 'wss://broker.example.test/ws/browser-shell?token=raw-shell-token', + ], + edge_test_broker_headers() + ); + + $closedShell + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($closedShell->data()) + ->toHaveKey('status', 'COMPLETED') + ->and($closedShell->data()['transcript'] ?? null) + ->toBe("edge-broker-shell\n") + ->and($closedShell->data()['metadata']['close_reason'] ?? null) + ->toBe('agent_exit') + ->and($closedShell->data()['metadata']['close_message'] ?? null) + ->toBe('Shell exited cleanly.') + ->and($closedShell->data()['metadata']['close_code'] ?? null) + ->toBe(0) + ->and($closedShell->data()['metadata']['close_stage'] ?? null) + ->toBe('shell_active') + ->and($closedShell->data()['metadata']['close_diagnostics']['ws_url'] ?? null) + ->toBe('wss://broker.example.test/ws/browser-shell?token=***'); + + $logsPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/logs', $session['headers']); + + $logsPage + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect(collect_gateway_messages($logsPage->data()['log_entries'] ?? [])) + ->toContain('Broker forwarded a live gateway log.'); + expect(collect_gateway_messages($logsPage->data()['relay_logs'] ?? [])) + ->toContain('MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH'); + expect($logsPage->data()['relay_logs'][0]['context']['associated']['customer_number'] ?? null) + ->toBe(700123) + ->and($logsPage->data()['relay_logs'][0]['context']['module_responsible'] ?? null) + ->toBe('selfserve') + ->and($logsPage->data()['relay_logs'][0]['context']['relay_name'] ?? null) + ->toBe('Roskilde Maskine') + ->and($logsPage->data()['relay_logs'][0]['context']['relay_role'] ?? null) + ->toBe('MACHINE') + ->and($logsPage->data()['relay_logs'][0]['context']['reason'] ?? null) + ->toBe('Broker relayed machine start'); + expect(collect_gateway_messages($logsPage->data()['timeline'] ?? [])) + ->toContain('GATEWAY_SHELL_SESSION_OPENED') + ->toContain('GATEWAY_SHELL_SESSION_CLOSED') + ->toContain('MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH'); + expect($logsPage->data()['shell_sessions'][0]['transcript'] ?? null) + ->toBe("edge-broker-shell\n"); + + $statisticsPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/statistics', $session['headers']); + + $statisticsPage + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($statisticsPage->data()) + ->toHaveKey('system_metrics') + ->and($statisticsPage->data()['system_metrics']['cpu_load'] ?? null) + ->toBe(0.42); +}); + +it('rejects shell session creation while broker presence is unavailable', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Edge Broker Shell Readiness Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + $gateway = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + 'label' => 'Disconnected Broker Gateway', + ]); + + $response = api_client()->post( + '/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions', + ['reason' => 'Should fail without broker presence'], + $session['headers'] + ); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false); + + expect($response->data()) + ->toHaveKey('error_code', 'BROKER_DISCONNECTED') + ->and($response->data()['diagnostics']['ready'] ?? true) + ->toBeFalse() + ->and($response->data()['diagnostics']['broker_presence']['connected'] ?? true) + ->toBeFalse(); +}); + +it('builds broker backlog and completes gateway operations through broker endpoints', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Edge Broker Backlog Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + $gateway = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + 'label' => 'Broker Backlog Gateway', + ]); + + $queuedOperation = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [ + 'type' => 'DISCOVERY', + 'request' => [ + 'inventory' => edge_broker_test_inventory('backlog'), + ], + ], $session['headers']); + + $queuedOperation + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $operationId = (int)($queuedOperation->data()['operation']['id'] ?? 0); + expect($operationId)->toBeGreaterThan(0); + + $backlog = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/backlog', + ['agent_instance_id' => 'broker-agent-1'], + edge_test_broker_headers() + ); + + $backlog + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($backlog->data()['dispatch'] ?? []) + ->toBeArray() + ->not->toBeEmpty() + ->and($backlog->data()['dispatch'][0]['type'] ?? null) + ->toBe('TASK_DISPATCH') + ->and((int)($backlog->data()['dispatch'][0]['operation']['id'] ?? 0)) + ->toBe($operationId); + + $event = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events', + [ + 'level' => 'INFO', + 'code' => 'BROKER_EXECUTING', + 'message' => 'Broker is executing the operation.', + 'context' => [ + 'progress' => 50, + ], + ], + edge_test_broker_headers() + ); + + $event + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $complete = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/complete', + [ + 'ok' => true, + 'result' => [ + 'inventory' => edge_broker_test_inventory('completed'), + ], + ], + edge_test_broker_headers() + ); + + $complete + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($complete->data()) + ->toHaveKey('status', 'COMPLETED'); + + $operations = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/operations', $session['headers']); + + $operations + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect(($operations->data()[0]['status'] ?? null)) + ->toBe('COMPLETED'); + + $events = api_client()->get( + '/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events', + $session['headers'] + ); + + $events + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect(collect_gateway_messages($events->data())) + ->toContain('Broker is executing the operation.') + ->toContain('Operation completed successfully'); + + $tasksPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/tasks', $session['headers']); + + $tasksPage + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($tasksPage->data()['recent_operations_summary']['completed'] ?? 0) + ->toBeGreaterThanOrEqual(1); +}); + +it('rejects invalid edge broker shared secrets', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Edge Broker Forbidden Department', + ]); + $gateway = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + ]); + + $response = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence', + ['status' => 'connected'], + ['X-Edge-Broker-Secret' => 'wrong-secret'] + ); + + $response + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid edge broker secret'); +}); + +function edge_broker_test_inventory(string $suffix): array +{ + return [[ + 'device_id' => 'broker-' . $suffix, + 'local_ip' => '10.40.50.60', + 'model' => 'TruckWash Edge Broker', + 'channel_count' => 1, + 'online' => true, + 'capabilities' => [ + 'relay_commands' => true, + ], + 'metadata' => [ + 'hostname' => 'broker-' . $suffix, + ], + ]]; +} + +/** + * @param mixed $items + * @return array + */ +function collect_gateway_messages(mixed $items): array +{ + if (!is_array($items)) { + return []; + } + + $messages = []; + foreach ($items as $item) { + if (is_array($item) && isset($item['message']) && is_string($item['message'])) { + $messages[] = $item['message']; + } + } + + return $messages; +} diff --git a/services/nginx/app/tests/Api/EdgeGatewayConfigApiTest.php b/services/nginx/app/tests/Api/EdgeGatewayConfigApiTest.php new file mode 100644 index 00000000..cad8b978 --- /dev/null +++ b/services/nginx/app/tests/Api/EdgeGatewayConfigApiTest.php @@ -0,0 +1,129 @@ +createDepartment([ + 'name' => 'Edge Gateway Config Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + $gateway = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + 'label' => 'Config Gateway', + ]); + + api_fixtures()->setModuleConfig('edgegateway', 'enabled', 'true', 'bool'); + api_fixtures()->setModuleConfig('edgegateway', 'default_release_channel', 'stable', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'default_update_window', '02:00-04:00', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_url', 'http://edge-broker:4300', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'public_broker_url', '', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_auth_mode', 'manager', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_shared_secret', edge_test_broker_secret(), 'string'); + + $update = api_client()->post('/edgegateway/config', [ + 'enabled' => true, + 'default_release_channel' => 'canary', + 'default_update_window' => '01:00-02:00', + 'broker_url' => 'http://edge-broker.internal:4300', + 'public_broker_url' => 'https://broker.example.test/edge-broker', + 'broker_auth_mode' => 'manager', + ], $session['headers']); + + $update + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $config = api_client()->get('/edgegateway/config', $session['headers']); + + $config + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $values = []; + foreach ($config->data() as $entry) { + $values[(string)$entry['variable']] = $entry['value']; + } + + expect($values) + ->toHaveKey('default_release_channel', 'canary') + ->toHaveKey('broker_url', 'http://edge-broker.internal:4300') + ->toHaveKey('public_broker_url', 'https://broker.example.test/edge-broker') + ->toHaveKey('broker_auth_mode', 'manager') + ->toHaveKey('broker_shared_secret', ''); + + $presence = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence', + [ + 'status' => 'connected', + 'connection_id' => 'module-config-broker-presence', + ], + edge_test_broker_headers(['X-Edge-Broker-Secret' => edge_test_broker_secret()]) + ); + + $presence + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $shellSession = api_client()->post( + '/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions', + ['reason' => 'Config broker URL validation'], + $session['headers'] + ); + + $shellSession + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + expect($shellSession->data()) + ->toHaveKey('broker_url', 'https://broker.example.test/edge-broker') + ->toHaveKey('ws_url', 'wss://broker.example.test/edge-broker/ws/browser-shell') + ->and($shellSession->data()['diagnostics']['public_broker_url_configured'] ?? null) + ->toBeTrue() + ->and($shellSession->data()['diagnostics']['broker_auth_mode'] ?? null) + ->toBe('manager'); +}); + +it('returns broker diagnostics for the current edge gateway module config values', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Edge Gateway Diagnostics Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + + api_fixtures()->setModuleConfig('edgegateway', 'enabled', 'true', 'bool'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_url', 'http://127.0.0.1:1', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'public_broker_url', 'http://127.0.0.1:1/edge-broker', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_auth_mode', 'manager', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_shared_secret', 'diagnostic-secret', 'string'); + + $response = api_client()->post('/edgegateway/config/broker-diagnostics', [ + 'target' => 'all', + 'broker_url' => 'http://127.0.0.1:1', + 'public_broker_url' => 'http://127.0.0.1:1/edge-broker', + 'broker_auth_mode' => 'manager', + 'broker_shared_secret' => 'diagnostic-secret', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toHaveKey('internal_broker_connection') + ->toHaveKey('public_broker_url') + ->toHaveKey('broker_shared_secret') + ->toHaveKey('broker_auth_mode', 'manager') + ->toHaveKey('broker_shared_secret_configured', true) + ->and($response->data()['internal_broker_connection']['ok'] ?? null) + ->toBeFalse() + ->and($response->data()['public_broker_url']['ok'] ?? null) + ->toBeFalse() + ->and($response->data()['broker_shared_secret']['ok'] ?? null) + ->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Api/EdgeGatewayOperatorApiTest.php b/services/nginx/app/tests/Api/EdgeGatewayOperatorApiTest.php new file mode 100644 index 00000000..b4d449b2 --- /dev/null +++ b/services/nginx/app/tests/Api/EdgeGatewayOperatorApiTest.php @@ -0,0 +1,496 @@ +createDepartment([ + 'name' => 'Edge Operator Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + + $createResponse = api_client()->post('/edge-gateways/install-token', [ + 'department_id' => (int)$department['id'], + 'label' => 'Dock 7 Gateway', + ], $session['headers']); + + $createResponse + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $installToken = $createResponse->data(); + expect($installToken) + ->toBeArray() + ->toHaveKeys(['claim_token_id', 'token', 'install_command']); + + $statusResponse = api_client()->get( + '/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status', + $session['headers'] + ); + + $statusResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($statusResponse->data()) + ->toHaveKey('status', 'PENDING') + ->toHaveKey('gateway_id', null); + + $verifyResponse = api_client()->get('/edge-agent/install-token/verify?token=' . urlencode((string)$installToken['token'])); + + $verifyResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($verifyResponse->data()) + ->toHaveKey('valid', true) + ->toHaveKey('claim_token_id', (int)$installToken['claim_token_id']); + + $runningStatus = api_client()->post('/edge-agent/install-token/status', [ + 'token' => (string)$installToken['token'], + 'status' => 'RUNNING', + 'step' => 'BOOTSTRAP', + 'message' => 'Installer is downloading gateway artifacts.', + 'diagnostics' => [ + 'download-1', + 'download-2', + 'download-3', + 'download-4', + 'download-5', + 'download-6', + 'download-7', + ], + ]); + + $runningStatus + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $runningStatusDetail = api_client()->get( + '/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status', + $session['headers'] + ); + + $runningStatusDetail + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($runningStatusDetail->data()) + ->toHaveKey('status', 'RUNNING') + ->toHaveKey('step', 'BOOTSTRAP') + ->and($runningStatusDetail->data()['diagnostics'] ?? []) + ->toBeArray() + ->toHaveCount(6); + + $claimResponse = api_client()->post('/edge-agent/claim', [ + 'token' => (string)$installToken['token'], + 'hostname' => 'edge-operator-api', + 'installed_version' => 'php-agent-v1', + 'metadata' => [ + 'agent_runtime' => 'compose-php', + ], + ]); + + $claimResponse + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $claimedPayload = $claimResponse->data(); + $gatewayId = (int)($claimedPayload['gateway']['id'] ?? 0); + + expect($claimedPayload) + ->toHaveKey('agent_token') + ->and($gatewayId) + ->toBeGreaterThan(0) + ->and($claimedPayload['gateway']['status'] ?? null) + ->toBe('ONLINE'); + + $claimedStatus = api_client()->get( + '/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status', + $session['headers'] + ); + + $claimedStatus + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($claimedStatus->data()) + ->toHaveKey('status', 'CLAIMED') + ->toHaveKey('gateway_id', $gatewayId) + ->toHaveKey('last_error', null); + + $listResponse = api_client()->get('/edge-gateways?department_id=' . (int)$department['id'], $session['headers']); + + $listResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect(collect_gateway_ids_from_api_response($listResponse->data())) + ->toContain($gatewayId); + + $detailResponse = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']); + + $detailResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($detailResponse->data()) + ->toHaveKey('id', $gatewayId) + ->toHaveKey('department_id', (int)$department['id']) + ->toHaveKey('status', 'ONLINE'); +}); + +it('manages edge gateway metadata, bindings, operations, sessions, rotation, cutover, and deletion', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Edge Operator Control Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + $gateway = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + 'label' => 'Original Gateway Label', + ]); + api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + 'label' => 'Alternate Gateway Label', + 'is_primary' => 0, + ]); + + $updateResponse = api_client()->put('/edge-gateways/' . (int)$gateway['id'], [ + 'label' => 'Renamed Gateway', + 'is_primary' => false, + ], $session['headers']); + + $updateResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($updateResponse->data()) + ->toHaveKey('label', 'Renamed Gateway') + ->toHaveKey('is_primary', false); + + $bindingsResponse = api_client()->put('/edge-gateways/' . (int)$gateway['id'] . '/bindings', [ + 'bindings' => [[ + 'relay_id' => 'relay-main', + 'device_id' => 'device-main', + 'local_ip' => '10.0.0.18', + 'channel' => 0, + 'binding_source' => 'MANUAL', + ]], + ], $session['headers']); + + $bindingsResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($bindingsResponse->data()['bindings'] ?? []) + ->toBeArray() + ->toHaveCount(1) + ->and(($bindingsResponse->data()['bindings'][0]['relay_id'] ?? null)) + ->toBe('relay-main'); + + $operationResponse = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [ + 'type' => 'DISCOVERY', + 'request' => [ + 'inventory' => edge_operator_test_inventory('operator'), + ], + ], $session['headers']); + + $operationResponse + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $operationId = (int)($operationResponse->data()['operation']['id'] ?? 0); + expect($operationId)->toBeGreaterThan(0); + + $operationsList = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/operations', $session['headers']); + + $operationsList + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect(collect_gateway_ids_from_api_response($operationsList->data(), 'id')) + ->toContain($operationId); + + $eventsResponse = api_client()->get( + '/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events', + $session['headers'] + ); + + $eventsResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($eventsResponse->data()) + ->toBeArray() + ->not->toBeEmpty() + ->and($eventsResponse->data()[0]['code'] ?? null) + ->toBe('OPERATION_QUEUED'); + + $cancelResponse = api_client()->post( + '/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/cancel', + [], + $session['headers'] + ); + + $cancelResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($cancelResponse->data()['operation']['status'] ?? null) + ->toBe('CANCELLED'); + + $streamSession = api_client()->post( + '/edge-gateways/' . (int)$gateway['id'] . '/stream-session', + ['scopes' => ['logs', 'tasks']], + $session['headers'] + ); + + $streamSession + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + expect($streamSession->data()) + ->toHaveKey('token') + ->toHaveKey('ws_url'); + + $brokerPresence = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence', + [ + 'status' => 'connected', + 'connection_id' => 'operator-shell-broker-1', + ], + edge_test_broker_headers() + ); + + $brokerPresence + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $shellSession = api_client()->post( + '/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions', + [ + 'reason' => 'Operator smoke session', + 'cwd' => '/opt/truckwash-edge-agent', + 'cols' => 120, + 'rows' => 40, + ], + $session['headers'] + ); + + $shellSession + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + expect($shellSession->data()) + ->toHaveKey('token') + ->toHaveKey('session') + ->and($shellSession->data()['session']['reason'] ?? null) + ->toBe('Operator smoke session') + ->and($shellSession->data()['diagnostics']['broker_presence']['connection_id'] ?? null) + ->toBe('operator-shell-broker-1'); + + $rotateResponse = api_client()->post( + '/edge-gateways/' . (int)$gateway['id'] . '/rotate-credentials', + [], + $session['headers'] + ); + + $rotateResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($rotateResponse->data()) + ->toHaveKey('gateway_id', (int)$gateway['id']) + ->toHaveKey('agent_token') + ->toHaveKey('config_json'); + + $cutoverResponse = api_client()->post( + '/departments/' . (int)$department['id'] . '/gateway-cutover', + ['transport_mode' => 'gateway'], + $session['headers'] + ); + + $cutoverResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($cutoverResponse->data()) + ->toHaveKey('department_id', (int)$department['id']) + ->toHaveKey('transport_mode', 'gateway'); + + $deleteResponse = api_client()->delete('/edge-gateways/' . (int)$gateway['id'], null, $session['headers']); + + $deleteResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($deleteResponse->data()) + ->toHaveKey('deleted', true) + ->toHaveKey('gateway_id', (int)$gateway['id']); +}); + +it('rejects operator edge routes when module permission or department access is missing', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Edge Operator Access Department', + ]); + $allowed = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + $token = api_client()->post('/edge-gateways/install-token', [ + 'department_id' => (int)$department['id'], + 'label' => 'Restricted Gateway', + ], $allowed['headers']); + + $token + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $missingModule = api_fixtures()->createUserSession([ + 'department_access_' . (int)$department['id'], + ]); + + $missingModuleResponse = api_client()->get( + '/edge-gateways?department_id=' . (int)$department['id'], + $missingModule['headers'] + ); + + $missingModuleResponse + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['modules_shelly_config']); + + $missingDepartment = api_fixtures()->createUserSession(['modules_shelly_config']); + + $missingDepartmentResponse = api_client()->get( + '/edge-gateways/install-token/' . (int)$token->data()['claim_token_id'] . '/status', + $missingDepartment['headers'] + ); + + $missingDepartmentResponse + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['department_access_' . (int)$department['id']]); +}); + +it('ignores and soft-deletes edge gateways whose department no longer exists', function (): void { + $session = api_fixtures()->createUserSession(['modules_shelly_config']); + $missingDepartmentId = 2147483000; + + $listOrphan = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => $missingDepartmentId, + 'label' => 'Orphaned Fleet Gateway', + ]); + api_fixtures()->clearEdgeGatewayViewCache(); + + $listResponse = api_client()->get('/edge-gateways', $session['headers']); + + $listResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect(collect_gateway_ids_from_api_response($listResponse->data())) + ->not->toContain((int)$listOrphan['id']) + ->and(api_fixtures()->fetchRowById('edge_gateways', (int)$listOrphan['id'])['deleted_at'] ?? null) + ->not->toBeNull(); + + $workspaceMissingDepartmentId = $missingDepartmentId - 1; + $workspaceOrphan = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => $workspaceMissingDepartmentId, + 'label' => 'Orphaned Workspace Gateway', + ]); + api_fixtures()->clearEdgeGatewayViewCache(); + + $redis = api_test_runtime()->redis(); + expect($redis)->not->toBeNull(); + + $redis->set('departments', json_encode([[ + 'id' => $workspaceMissingDepartmentId, + 'name' => 'Stale Department Cache', + 'description' => 'This department row is no longer in the database.', + 'order_priority' => 0, + 'visible' => 1, + ]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + try { + $workspaceResponse = api_client()->get( + '/modules/edge-gateways/workspace/departments', + $session['headers'] + ); + } finally { + $redis->del(['departments']); + } + + $workspaceResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($workspaceResponse->data()) + ->toBeArray() + ->and(collect_gateway_ids_from_api_response($workspaceResponse->data())) + ->not->toContain((int)$workspaceOrphan['id']) + ->and(api_fixtures()->fetchRowById('edge_gateways', (int)$workspaceOrphan['id'])['deleted_at'] ?? null) + ->not->toBeNull(); +}); + +function edge_operator_test_inventory(string $suffix): array +{ + return [[ + 'device_id' => 'gateway-' . $suffix, + 'local_ip' => '10.20.30.40', + 'model' => 'TruckWash Edge Test', + 'channel_count' => 2, + 'online' => true, + 'capabilities' => [ + 'local_discovery' => true, + 'relay_commands' => true, + ], + 'metadata' => [ + 'hostname' => 'edge-' . $suffix, + ], + ]]; +} + +/** + * @param mixed $items + * @return array + */ +function collect_gateway_ids_from_api_response(mixed $items, string $key = 'id'): array +{ + if (!is_array($items)) { + return []; + } + + $ids = []; + foreach ($items as $item) { + if (is_array($item) && isset($item[$key]) && is_numeric($item[$key])) { + $ids[] = (int)$item[$key]; + } + } + + return $ids; +} diff --git a/services/nginx/app/tests/Api/OrderBookingsCompletionApiTest.php b/services/nginx/app/tests/Api/OrderBookingsCompletionApiTest.php new file mode 100644 index 00000000..bf7fb24b --- /dev/null +++ b/services/nginx/app/tests/Api/OrderBookingsCompletionApiTest.php @@ -0,0 +1,132 @@ +createUser(['display_name' => 'Standalone Booking Customer']); + $department = api_fixtures()->createDepartment(); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => null, + ]); + $session = api_fixtures()->createUserSession([ + 'complete_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post('/order-bookings/complete', [ + 'id' => $booking['id'], + ], $session['headers']); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Booking completion must be completed through POS desktop or mobile steps.'); + + $result = api_test_runtime()->db() + ->query('SELECT order_id FROM order_bookings WHERE id = ' . (int)$booking['id'] . ' LIMIT 1'); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect($row['order_id'] ?? null)->toBeNull(); + + $result = api_test_runtime()->db() + ->query('SELECT COUNT(*) AS total FROM orders WHERE customer_id = ' . (int)$customer['customer_number']); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect((int)($row['total'] ?? -1))->toBe(0); +}); + +it('allows linked POS order booking completion for mobile POS compatibility', function (): void { + $customer = api_fixtures()->createUser(['display_name' => 'Linked Booking Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Linked Booking Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'safety_seal' => null, + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => $order['id'], + ]); + $session = api_fixtures()->createUserSession([ + 'complete_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post('/order-bookings/complete', [ + 'id' => $booking['id'], + 'safety_seal' => 123456, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['id'] ?? null)->toBe($booking['id']); + expect($response->data()['order_id'] ?? null)->toBe($order['id']); + + $result = api_test_runtime()->db() + ->query('SELECT order_id FROM order_bookings WHERE id = ' . (int)$booking['id'] . ' LIMIT 1'); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect((int)($row['order_id'] ?? 0))->toBe($order['id']); +}); + +it('accepts numeric safety seal strings when completing a linked POS order booking', function (): void { + $customer = api_fixtures()->createUser(['display_name' => 'Linked Booking String Seal Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Linked Booking String Seal Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'safety_seal' => null, + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => $order['id'], + ]); + $session = api_fixtures()->createUserSession([ + 'complete_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post('/order-bookings/complete', [ + 'id' => $booking['id'], + 'safety_seal' => '123456', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['id'] ?? null)->toBe($booking['id']); + expect($response->data()['order_id'] ?? null)->toBe($order['id']); +}); + +it('disables the legacy complete wash without certificate route', function (): void { + $response = api_client()->post('/admin/bookings/completeWashWithoutWashCertificate', [ + 'id' => 123, + ]); + + $response + ->assertStatus(410) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Booking completion must be completed through POS desktop or mobile steps.'); +}); diff --git a/services/nginx/app/tests/Api/OrderBookingsUpdateApiTest.php b/services/nginx/app/tests/Api/OrderBookingsUpdateApiTest.php new file mode 100644 index 00000000..cdbbb90e --- /dev/null +++ b/services/nginx/app/tests/Api/OrderBookingsUpdateApiTest.php @@ -0,0 +1,108 @@ +createUser(['display_name' => 'Detached Booking Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Detached Booking Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => $order['id'], + ]); + + api_test_runtime()->db()->query( + 'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id'] + ); + + $session = api_fixtures()->createUserSession([ + 'edit_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->put('/order-bookings', [ + 'id' => $booking['id'], + 'order_id' => null, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toHaveKey('order_id') + ->and($response->data()['order_id']) + ->toBeNull(); + + $result = api_test_runtime()->db()->query( + 'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' . + 'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' . + 'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1' + ); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect($row['booking_order_id'] ?? null)->toBeNull(); + expect($row['order_booking_id'] ?? null)->toBeNull(); +}); + +it('keeps the linked order when order_id is omitted from an order booking update', function (): void { + $customer = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => $order['id'], + 'reference' => 'ORIGINAL-BOOKING-REF', + ]); + + api_test_runtime()->db()->query( + 'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id'] + ); + + $session = api_fixtures()->createUserSession([ + 'edit_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->put('/order-bookings', [ + 'id' => $booking['id'], + 'reference' => 'UPDATED-BOOKING-REF', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect((int)($response->data()['order_id'] ?? 0))->toBe($order['id']); + expect($response->data()['reference'] ?? null)->toBe('UPDATED-BOOKING-REF'); + + $result = api_test_runtime()->db()->query( + 'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' . + 'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' . + 'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1' + ); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect((int)($row['booking_order_id'] ?? 0))->toBe($order['id']); + expect((int)($row['order_booking_id'] ?? 0))->toBe($booking['id']); +}); diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php new file mode 100644 index 00000000..577c80f6 --- /dev/null +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -0,0 +1,113 @@ +createUser(['display_name' => 'Order Item Customer']); + $department = api_fixtures()->createDepartment(); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'NOTE-REQUIRED', + ]); + $product = api_fixtures()->createProduct([ + 'id' => 902701, + 'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME, + 'price' => 299, + 'requires_note' => 0, + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + api_client() + ->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + 'notes' => ' ', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Notes is required for this product'); + + $response = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + 'notes' => 'Graffiti removal on left side', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side'); +}); + +it('does not allow clearing notes for order items whose product requires notes', function (): void { + api_test_covers('PUT /order/items', 'validation'); + + $customer = api_fixtures()->createUser(['display_name' => 'Order Item Edit Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Item Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'cashier_id' => $cashier['id'], + 'reference' => 'NOTE-EDIT', + ]); + $product = api_fixtures()->createProduct([ + 'id' => 902702, + 'name' => 'API Note Required Product', + 'price' => 199, + 'requires_note' => 1, + ]); + $orderItem = api_fixtures()->createOrderItem([ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'cashier_id' => $cashier['id'], + 'price' => 199, + 'quantity' => 1, + 'notes' => 'Initial note', + ]); + $session = api_fixtures()->createUserSession(['edit_order_items']); + + api_client() + ->put('/order/items', [ + 'id' => $orderItem['id'], + 'price' => 199, + 'quantity' => 1, + 'reference' => '', + 'notes' => '', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Notes is required for this product'); +}); + +it('returns the extraordinary chemistry product with requires_note enabled', function (): void { + api_test_covers('GET /products', 'happy'); + + $product = api_fixtures()->createProduct([ + 'id' => 902703, + 'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME, + 'price' => 299, + 'requires_note' => 0, + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + $response = api_client()->get('/products?id=' . $product['id'], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['requires_note'] ?? null)->toBeTrue(); +}); diff --git a/services/nginx/app/tests/Api/OrdersApiTest.php b/services/nginx/app/tests/Api/OrdersApiTest.php new file mode 100644 index 00000000..6e65d063 --- /dev/null +++ b/services/nginx/app/tests/Api/OrdersApiTest.php @@ -0,0 +1,227 @@ +createDepartment(['name' => 'Orders List Visible']); + $hiddenDepartment = api_fixtures()->createDepartment(['name' => 'Orders List Hidden']); + $visibleCustomer = api_fixtures()->createUser(['display_name' => 'Orders List Visible Customer']); + $hiddenCustomer = api_fixtures()->createUser(['display_name' => 'Orders List Hidden Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Orders List Cashier']); + + $visibleOrder = api_fixtures()->createOrder([ + 'customer_id' => $visibleCustomer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $visibleDepartment['id'], + 'reference' => 'VISIBLE-ORDER', + 'reg_1' => 'VISIBLE1', + ]); + $hiddenOrder = api_fixtures()->createOrder([ + 'customer_id' => $hiddenCustomer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $hiddenDepartment['id'], + 'reference' => 'HIDDEN-ORDER', + 'reg_1' => 'HIDDEN1', + ]); + + $session = api_fixtures()->createUserSession([ + 'list_orders', + 'department_access_' . $visibleDepartment['id'], + ]); + + $response = api_client()->get('/orders', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $orderIds = array_map( + static fn(array $order): int => (int)($order['id'] ?? 0), + is_array($response->data()) ? $response->data() : [] + ); + + expect($orderIds) + ->toContain($visibleOrder['id']) + ->not->toContain($hiddenOrder['id']); +}); + +it('returns auth and permission failures when order listing is not allowed', function (): void { + api_test_covers('GET /orders', 'auth'); + + api_client()->get('/orders') + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid session'); + + $session = api_fixtures()->createUserSession([]); + + api_client()->get('/orders', $session['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_own_orders', 'list_orders']); +}); + +it('creates orders through the orders endpoint', function (): void { + api_test_covers('POST /orders', 'happy'); + + $customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']); + $department = api_fixtures()->createDepartment(['name' => 'Order Create Department']); + $session = api_fixtures()->createUserSession(['add_order']); + + $response = api_client()->post('/orders', [ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'ORDER-CREATE', + 'notes' => 'Created through HTTP', + 'reg_1' => ' create-123 ', + 'safety_seal' => 'SEAL-CREATE', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $orderId = (int)($response->data()['id'] ?? 0); + expect($orderId)->toBeGreaterThan(0); + + $row = api_fixtures()->fetchRowById('orders', $orderId); + + expect($row)->not->toBeNull(); + expect($row['reference'] ?? null)->toBe('ORDER-CREATE'); + expect($row['reg_1'] ?? null)->toBe('CREATE123'); + expect($row['safety_seal'] ?? null)->toBe('SEAL-CREATE'); + + api_fixtures()->cleanupDeleteById('orders', $orderId); +}); + +it('rejects invalid order creation requests', function (): void { + api_test_covers('POST /orders', 'failure'); + + $customer = api_fixtures()->createUser(); + $department = api_fixtures()->createDepartment(); + $session = api_fixtures()->createUserSession(['add_order']); + + api_client()->post('/orders', [ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'notes' => 'Missing reference', + 'reg_1' => 'MISSREF', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Reference is required'); +}); + +it('updates orders through the primary and legacy endpoints', function (): void { + api_test_covers('PUT /orders', 'happy'); + api_test_covers('PUT /order', 'happy'); + + $department = api_fixtures()->createDepartment(['name' => 'Order Update Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Order Update Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Update Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'BEFORE-UPDATE', + 'notes' => 'Before update', + 'reg_1' => 'BEFORE1', + ]); + $session = api_fixtures()->createUserSession(['edit_order']); + + api_client()->put('/orders', [ + 'id' => $order['id'], + 'reference' => 'AFTER-UPDATE', + 'notes' => 'After update', + 'reg_1' => ' after-123 ', + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Order updated successfully'); + + api_client()->put('/order', [ + 'id' => $order['id'], + 'field' => 'reg_2', + 'value' => ' legacy-456 ', + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Order updated successfully'); + + $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); + + expect($row)->not->toBeNull(); + expect($row['reference'] ?? null)->toBe('AFTER-UPDATE'); + expect($row['notes'] ?? null)->toBe('After update'); + expect($row['reg_1'] ?? null)->toBe('AFTER123'); + expect($row['reg_2'] ?? null)->toBe('LEGACY456'); +}); + +it('rejects invalid order update requests', function (): void { + api_test_covers('PUT /orders', 'failure'); + api_test_covers('PUT /order', 'failure'); + + $session = api_fixtures()->createUserSession(['edit_order']); + + foreach (['/orders', '/order'] as $endpoint) { + api_client()->put($endpoint, [ + 'notes' => 'Missing id', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('ID is required'); + } +}); + +it('deletes orders through the orders endpoint', function (): void { + api_test_covers('DELETE /orders', 'happy'); + + $department = api_fixtures()->createDepartment(['name' => 'Order Delete Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Order Delete Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Delete Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + ]); + $session = api_fixtures()->createUserSession([ + 'delete_order', + 'department_access_' . $department['id'], + ]); + + api_client()->delete('/orders', [ + 'id' => $order['id'], + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Order deleted successfully'); + + $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); + expect($row)->not->toBeNull(); + expect($row['deleted_at'] ?? null)->not->toBeNull(); +}); + +it('rejects invalid order delete requests', function (): void { + api_test_covers('DELETE /orders', 'failure'); + + $session = api_fixtures()->createUserSession(['delete_order']); + + api_client()->delete('/orders', [], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('ID is required'); +}); diff --git a/services/nginx/app/tests/Api/PingApiTest.php b/services/nginx/app/tests/Api/PingApiTest.php new file mode 100644 index 00000000..920e90cd --- /dev/null +++ b/services/nginx/app/tests/Api/PingApiTest.php @@ -0,0 +1,23 @@ +get('/ping'); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('message', 'pong') + ->toHaveKey('time') + ->toHaveKey('backend_version') + ->toHaveKey('api_commit_sha'); +}); diff --git a/services/nginx/app/tests/Api/PlateScannersApiTest.php b/services/nginx/app/tests/Api/PlateScannersApiTest.php new file mode 100644 index 00000000..5db94a62 --- /dev/null +++ b/services/nginx/app/tests/Api/PlateScannersApiTest.php @@ -0,0 +1,125 @@ +createDepartment([ + 'name' => 'Scanner Department ' . uniqid('', false), + ]); + $session = api_fixtures()->createUserSession([ + 'add_department_lane', + 'add_number_plate_scanner', + 'edit_number_plate_scanner', + ]); + + $laneName = 'Lane ' . uniqid('', false); + $laneResponse = api_client()->post('/department/lanes', [ + 'department' => $department['id'], + 'name' => $laneName, + ], $session['headers']); + + $laneResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $lane = api_test_runtime()->queryOne( + "SELECT id FROM department_lanes WHERE department = " . (int)$department['id'] . + " AND name = '" . api_test_runtime()->db()->real_escape_string($laneName) . "'" . + ' ORDER BY id DESC LIMIT 1' + ); + + expect($lane)->not->toBeNull(); + $laneId = (int)($lane['id'] ?? 0); + expect($laneId)->toBeGreaterThan(0); + api_fixtures()->cleanupDeleteById('department_lanes', $laneId); + + $scannerName = 'Scanner ' . uniqid('', false); + $createResponse = api_client()->post('/numberplatescanners', [ + 'department_id' => $department['id'], + 'name' => $scannerName, + 'notes' => 'Original notes', + ], $session['headers']); + + $createResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Number plate scanner added'); + + $createdScanner = api_test_runtime()->queryOne( + "SELECT id FROM plate_scanners WHERE department_id = " . (int)$department['id'] . + " AND name = '" . api_test_runtime()->db()->real_escape_string($scannerName) . "'" . + ' ORDER BY id DESC LIMIT 1' + ); + + expect($createdScanner)->not->toBeNull(); + $scannerId = (int)($createdScanner['id'] ?? 0); + expect($scannerId)->toBeGreaterThan(0); + api_fixtures()->cleanupDeleteById('plate_scanners', $scannerId); + + $redis = api_test_runtime()->redis(); + expect($redis)->not->toBeNull(); + $cacheKeys = [ + "obj_prop:plate_scanners:{$scannerId}:lane_id" => '___NULL___', + "obj_prop:plate_scanners:{$scannerId}:name" => $scannerName, + "obj_prop:plate_scanners:{$scannerId}:notes" => 'Original notes', + ]; + foreach ($cacheKeys as $cacheKey => $value) { + $redis->set($cacheKey, $value); + } + + $updateResponse = api_client()->put('/numberplatescanners', [ + 'id' => $scannerId, + 'department_id' => $department['id'], + 'lane_id' => $laneId, + 'name' => 'Updated scanner', + 'notes' => 'Updated notes', + ], $session['headers']); + + $updateResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Number plate scanner edited'); + + $redis->del(array_keys($cacheKeys)); + + $updatedScanner = $updateResponse->data()['scanner'] ?? null; + expect($updatedScanner)->toBeArray(); + expect($updatedScanner['id'] ?? null)->toBe($scannerId); + expect($updatedScanner['department_id'] ?? null)->toBe($department['id']); + expect($updatedScanner['lane_id'] ?? null)->toBe($laneId); + expect($updatedScanner['name'] ?? null)->toBe('Updated scanner'); + expect($updatedScanner['notes'] ?? null)->toBe('Updated notes'); + + $row = api_fixtures()->fetchRowById('plate_scanners', $scannerId); + + expect($row)->not->toBeNull(); + expect((int)($row['lane_id'] ?? 0))->toBe($laneId); + expect($row['name'] ?? null)->toBe('Updated scanner'); + expect($row['notes'] ?? null)->toBe('Updated notes'); +}); + +it('rejects plate scanner edits when the permission is missing', function (): void { + api_test_covers('PUT /numberplatescanners', 'auth'); + + $session = api_fixtures()->createUserSession([]); + + $response = api_client()->put('/numberplatescanners', [ + 'id' => 1, + 'department_id' => 1, + 'name' => 'No permission', + 'notes' => 'Should fail', + ], $session['headers']); + + $response + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['edit_number_plate_scanner']); +}); diff --git a/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php new file mode 100644 index 00000000..3ffd449e --- /dev/null +++ b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php @@ -0,0 +1,208 @@ +createDepartment(); + $customer = api_fixtures()->createUser(['display_name' => 'Reference Suggestion Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Reference Suggestion Cashier']); + + api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'REF-BOOKING', + 'datetime' => '2026-05-13 09:00:00', + 'created_at' => '2026-05-01 08:00:00', + 'reg_1' => 'BOOK1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'REF-HISTORY', + 'created_at' => '2026-05-10 10:00:00', + 'reg_1' => 'HIST1', + ]); + api_fixtures()->createVehicle([ + 'customer_id' => $customer['customer_number'], + 'type' => 53, + 'reg' => 'VEH1', + 'reference' => 'REF-VEHICLE', + 'created_at' => '2026-05-08 12:00:00', + ]); + + api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'REF-SHARED', + 'datetime' => '2026-05-15 11:00:00', + 'reg_1' => 'SHARED1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'REF-SHARED', + 'created_at' => '2026-05-12 10:00:00', + 'reg_1' => 'SHARED1', + ]); + api_fixtures()->createVehicle([ + 'customer_id' => $customer['customer_number'], + 'type' => 53, + 'reg' => 'SHARED1', + 'reference' => 'REF-SHARED', + 'created_at' => '2026-05-09 12:00:00', + ]); + + $deletedOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'REF-DELETED', + ]); + api_test_runtime()->db() + ->query("UPDATE orders SET deleted_at = '2026-05-10 12:00:00' WHERE id = " . (int)$deletedOrder['id']); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => '', + ]); + + $session = api_fixtures()->createUserSession([ + 'list_orders', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->get('/orders/reference-suggestions?' . http_build_query([ + 'search' => 'REF', + 'department_id' => $department['id'], + 'customer_id' => $customer['customer_number'], + 'reg_1' => 'SHARED1', + 'limit' => 10, + ]), $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $suggestions = $response->data(); + expect($suggestions)->toBeArray(); + + $booking = reference_suggestion_by_reference($suggestions, 'REF-BOOKING'); + $history = reference_suggestion_by_reference($suggestions, 'REF-HISTORY'); + $vehicle = reference_suggestion_by_reference($suggestions, 'REF-VEHICLE'); + $shared = reference_suggestion_by_reference($suggestions, 'REF-SHARED'); + + expect($booking['source'] ?? null)->toBe('booking'); + expect($booking['source_created_at'] ?? null)->toBe('2026-05-13 09:00:00'); + expect($history['source'] ?? null)->toBe('order'); + expect($vehicle['source'] ?? null)->toBe('vehicle'); + expect($shared['source'] ?? null)->toBe('booking'); + expect($shared['section'] ?? null)->toBe('this_vehicle'); + expect($booking['section'] ?? null)->toBe('other_customer_vehicle'); + expect($history['section'] ?? null)->toBe('other_customer_vehicle'); + expect($vehicle['section'] ?? null)->toBe('other_customer_vehicle'); + expect($shared['usage_count'] ?? null)->toBe(3); + expect($shared['last_used_at'] ?? null)->toBe('2026-05-15 11:00:00'); + expect(reference_suggestion_by_reference($suggestions, 'REF-DELETED'))->toBeNull(); + expect(reference_suggestion_by_reference($suggestions, ''))->toBeNull(); +}); + +it('orders reference suggestions by match relevance before context and frequency', function (): void { + $department = api_fixtures()->createDepartment(); + $customer = api_fixtures()->createUser(['display_name' => 'Reference Ranking Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Reference Ranking Cashier']); + + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'ABC', + 'created_at' => '2026-05-01 08:00:00', + 'reg_1' => 'OTHER1', + ]); + api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'ABC-PREFIX', + 'datetime' => '2026-05-16 08:00:00', + 'reg_1' => 'MATCH1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'X-ABC-CONTAINS', + 'created_at' => '2026-05-17 08:00:00', + 'reg_1' => 'MATCH1', + ]); + + $session = api_fixtures()->createUserSession([ + 'list_orders', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->get('/orders/reference-suggestions?' . http_build_query([ + 'search' => 'ABC', + 'department_id' => $department['id'], + 'customer_id' => $customer['customer_number'], + 'reg_1' => 'MATCH1', + ]), $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $references = array_map( + static fn(array $suggestion): string => (string)($suggestion['reference'] ?? ''), + is_array($response->data()) ? $response->data() : [] + ); + + expect(array_slice($references, 0, 3))->toBe(['ABC', 'ABC-PREFIX', 'X-ABC-CONTAINS']); +}); + +it('enforces authentication, list permission, and department access for reference suggestions', function (): void { + api_test_covers('GET /orders/reference-suggestions', 'auth'); + + $department = api_fixtures()->createDepartment(); + + api_client()->get('/orders/reference-suggestions?department_id=' . $department['id']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid session'); + + $missingListPermission = api_fixtures()->createUserSession([ + 'department_access_' . $department['id'], + ]); + api_client()->get('/orders/reference-suggestions?department_id=' . $department['id'], $missingListPermission['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_orders']); + + $missingDepartmentAccess = api_fixtures()->createUserSession(['list_orders']); + api_client()->get('/orders/reference-suggestions?department_id=' . $department['id'], $missingDepartmentAccess['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['department_access_' . $department['id']]); +}); diff --git a/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php b/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php new file mode 100644 index 00000000..127d7f4e --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php @@ -0,0 +1,106 @@ + getenv('REDIS_CONFIG_HOST') ?: getenv('REDIS_CONFIG_DEBUG_HOST') ?: 'redis', + 'user' => getenv('REDIS_CONFIG_USER') ?: getenv('REDIS_CONFIG_DEBUG_USER') ?: 'default', + 'database' => getenv('REDIS_CONFIG_DATABASE') ?: getenv('REDIS_CONFIG_DEBUG_DATABASE') ?: '0', + 'password' => getenv('REDIS_CONFIG_PASSWORD') ?: getenv('REDIS_CONFIG_DEBUG_PASSWORD') ?: '', + 'port' => getenv('REDIS_CONFIG_PORT') ?: getenv('REDIS_CONFIG_DEBUG_PORT') ?: '6379', + ]; + + define('redis', (new \classes\redis())->connect()); +} + +it('creates a comprehensive self-serve API scenario with demo relays', function (): void { + $scenario = api_fixtures()->createSelfServeScenario(); + + expect($scenario['relay_ids']['machine'])->toStartWith('demo-') + ->and($scenario['relay_ids']['entry'])->toStartWith('demo-') + ->and($scenario['lane']['relay_machine_id'])->toStartWith('demo-') + ->and($scenario['session']['status'])->toBe('MACHINE_STARTED') + ->and($scenario['session']['metadata_json']['relay_ids']['machine'])->toBe($scenario['relay_ids']['machine']) + ->and($scenario['tasks'])->toHaveCount(2) + ->and($scenario['events'])->toHaveCount(3); + + $lane = api_fixtures()->fetchRowById('department_lanes', (int)$scenario['lane']['id']); + $session = api_fixtures()->fetchRowById('selfserve_wash_sessions', (int)$scenario['session']['id']); + + expect($lane)->not->toBeNull() + ->and($lane['relay_machine_id'])->toBe($scenario['relay_ids']['machine']) + ->and($session)->not->toBeNull() + ->and($session['reg'])->toBe($scenario['vehicle']['reg']); +}); + +it('creates self-serve invoice orders on the draft customer with original customer and driver metadata attached', function (): void { + selfserve_fixture_ensure_legacy_redis_constant(); + + $draftCustomer = api_fixtures()->createUser(['display_name' => 'Self-Serve Draft Customer']); + $scenario = api_fixtures()->createSelfServeScenario(); + $subuser = api_fixtures()->createSubuser([ + 'name' => 'Self-Serve Driver', + 'username' => 'selfserve-driver-' . $scenario['vehicle']['reg'], + ]); + + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int'); + api_fixtures()->setModuleConfig('selfserve', 'minute_product', (string)$scenario['product']['id'], 'int'); + + $lane = (new \classes\selfserve())->lane((int)$scenario['lane']['id']); + $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED); + $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); + $lane->setLaneMode(\modules\selfserve\helpers\selfserve_lane_mode::MANUAL); + $lane->setCustomerNumber((int)$scenario['customer']['customer_number']); + $lane->setLicensePlate((string)$scenario['vehicle']['reg']); + $lane->setWashStartTime(time() - 620); + + $arguments = (new \modules\selfserve\classes\selfserve_lane_command_arguments()) + ->setCustomerNumber((int)$scenario['customer']['customer_number']) + ->setSubuserId((int)$subuser['id']); + + expect($lane->invoice($arguments))->toBeTrue(); + + $orderId = $lane->getLastInvoiceOrderId(); + expect($orderId)->toBeInt()->toBeGreaterThan(0); + + $order = api_fixtures()->fetchRowById('orders', $orderId); + $attachmentObjectType = '`orders`'; + $invoiceCollectionId = (int)($order['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + api_fixtures()->cleanupDeleteById('collected_order_invoices', $invoiceCollectionId); + } + api_fixtures()->cleanupDeleteById('orders', $orderId); + api_fixtures()->cleanupDeleteWhere('order_items', ['order_id' => $orderId]); + api_fixtures()->cleanupDeleteWhere('object_attachments', ['object_type' => $attachmentObjectType, 'object_id' => $orderId]); + + expect($order)->not->toBeNull() + ->and((int)$order['customer_id'])->toBe((int)$draftCustomer['customer_number']) + ->and((int)$order['department_id'])->toBe((int)$scenario['department']['id']) + ->and((string)$order['reg_1'])->toBe((string)$scenario['vehicle']['reg']) + ->and((int)$order['lane'])->toBe((int)$scenario['lane']['id']) + ->and($order['completed_at'])->toBeNull(); + + $db = api_test_runtime()->db(); + $result = $db->query( + "SELECT content FROM object_attachments WHERE object_type = '{$attachmentObjectType}' AND object_id = " . (int)$orderId . ' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1' + ); + $attachment = $result ? $result->fetch_assoc() : null; + $content = json_decode((string)($attachment['content'] ?? ''), true); + $metadata = is_array($content) ? ($content['other'] ?? null) : null; + + expect($metadata)->toBeArray() + ->and($metadata['type'] ?? null)->toBe(\attachments\helpers\attachment_content::OTHER_TYPE_SELF_SERVE_WASH) + ->and((int)($metadata['customer_number'] ?? 0))->toBe((int)$scenario['customer']['customer_number']) + ->and((int)($metadata['draft_customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number']) + ->and((int)($metadata['subuser_id'] ?? 0))->toBe((int)$subuser['id']) + ->and((int)($metadata['session_id'] ?? 0))->toBe((int)$scenario['session']['id']) + ->and($metadata['subuser']['name'] ?? null)->toBe('Self-Serve Driver'); +}); diff --git a/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php new file mode 100644 index 00000000..8e6cd284 --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php @@ -0,0 +1,76 @@ +get('/modules/self-serve/lane/wash/in-progress?lane_id=1'); + + $response + ->assertStatus(401) + ->assertMessage('Authentication failed. Invalid or missing token.'); +}); + +it('reports both elevated and customer self-serve permissions when lane polling is not allowed', function (): void { + $session = api_fixtures()->createUserSession([]); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/in-progress?lane_id=1', + $session['headers'] + ); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'modules_selfserve_lane_wash_in_progress_view', + 'list_own_department_selfserve_vehicle_conditions', + ]); +}); + +it('allows customer self-serve permission to view their own in-progress wash details', function (): void { + $group = api_fixtures()->createGroup([], [ + 'list_own_department_selfserve_vehicle_conditions', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => [ + 'group_id' => $group['id'], + ], + ]); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/in-progress?lane_id=' . (int)$scenario['lane']['id'], + api_fixtures()->bearerHeaders($token) + ); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data()['in_progress'] ?? null)->toBeTrue() + ->and($response->data()['session']['customer_number'] ?? null)->toBe((int)$scenario['customer']['customer_number']) + ->and($response->data()['vehicle']['reg'] ?? null)->toBe($scenario['vehicle']['reg']); +}); + +it('redacts another customers in-progress wash from customer self-serve lane polling', function (): void { + $scenario = api_fixtures()->createSelfServeScenario(); + $otherSession = api_fixtures()->createUserSession([ + 'list_own_department_selfserve_vehicle_conditions', + ]); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/in-progress?lane_id=' . (int)$scenario['lane']['id'], + $otherSession['headers'] + ); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data())->toMatchArray([ + 'lane_id' => (int)$scenario['lane']['id'], + 'in_progress' => true, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]); +}); diff --git a/services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php b/services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php new file mode 100644 index 00000000..5100c0de --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php @@ -0,0 +1,7 @@ +toBe([]); +}); diff --git a/services/nginx/app/tests/Api/StripeApiTest.php b/services/nginx/app/tests/Api/StripeApiTest.php new file mode 100644 index 00000000..84991bca --- /dev/null +++ b/services/nginx/app/tests/Api/StripeApiTest.php @@ -0,0 +1,324 @@ +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', + ]); + $session = api_fixtures()->createUserSession([ + 'modules_stripe_department_terminal_readers_list', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->get( + '/modules/stripe/department/terminal/readers?id=' . $department['id'], + $session['headers'] + ); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Card payments are not ready for this department. Open Stripe setup and choose a terminal location.'); + + expect($response->data()) + ->toBeArray() + ->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', + ]); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Stripe Payment Intent Customer', + ]); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'STRIPE-SETUP-REQUIRED', + 'reg_1' => 'STRIPE01', + ]); + $session = api_fixtures()->createUserSession([ + 'charge_order', + ]); + + $response = api_client()->post('/orders/module/stripe/payment_intent', [ + 'id' => $order['id'], + 'reader' => 'reader_pending_setup', + 'tax_percentage' => 25, + ], $session['headers']); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Card payments are not ready for this department. Open Stripe setup and choose a terminal location.'); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('code', 'stripe_terminal_setup_required'); +}); diff --git a/services/nginx/app/tests/Api/SubusersApiTest.php b/services/nginx/app/tests/Api/SubusersApiTest.php new file mode 100644 index 00000000..5535293a --- /dev/null +++ b/services/nginx/app/tests/Api/SubusersApiTest.php @@ -0,0 +1,176 @@ +createUserSession(['list_own_subusers']); + $subuser = api_fixtures()->createSubuser([ + 'name' => 'Legacy Permission Driver', + ]); + $grantId = api_fixtures()->grantSubuser( + $subuser['id'], + $session['user']['customer_number'], + ['VEHICLES_LIST'] + ); + + $legacyPermissions = '0'; + $statement = api_test_runtime()->db()->prepare( + 'UPDATE `subuser_grants` SET `permissions` = ? WHERE `id` = ?' + ); + $statement->bind_param('si', $legacyPermissions, $grantId); + $statement->execute(); + $statement->close(); + + $response = api_client()->get('/subusers?page=1&limit=5&include_non_enabled=true', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $matchingSubusers = array_values(array_filter( + is_array($response->data()) ? $response->data() : [], + static fn (mixed $item): bool => is_array($item) && (int)($item['id'] ?? 0) === (int)$subuser['id'] + )); + + expect($matchingSubusers)->toHaveCount(1); + expect($matchingSubusers[0]['grant_permissions'] ?? null)->toBe([]); + expect($matchingSubusers[0]['permissions'] ?? null)->toBe([]); +}); + +it('uses the same password policy for subuser setup and password auth', function (): void { + api_test_covers('POST /subusers/setup', 'failure'); + api_test_covers('POST /subusers/auth/password', 'failure'); + + $subuser = api_fixtures()->createSubuser(); + + $setupResponse = api_client()->post('/subusers/setup', [ + 'token' => 'policy-test-token', + 'name' => 'Policy Driver', + 'password' => 'invalidpassword', + ]); + + $authResponse = api_client()->post('/subusers/auth/password', [ + 'subuser_id' => $subuser['id'], + 'password' => 'invalidpassword', + ]); + + $setupResponse + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE); + + $authResponse + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE); +}); + +it('lists chauffeur grants across customers for superusers', function (): void { + $session = api_fixtures()->createUserSession(['list_subusers']); + $firstCustomer = api_fixtures()->createUser([ + 'display_name' => 'Fleet Customer Alpha', + 'economic_customer_name' => 'Fleet Customer Alpha', + ]); + $secondCustomer = api_fixtures()->createUser([ + 'display_name' => 'Fleet Customer Beta', + 'economic_customer_name' => 'Fleet Customer Beta', + ]); + $firstSubuser = api_fixtures()->createSubuser(['name' => 'Alpha Driver']); + $secondSubuser = api_fixtures()->createSubuser(['name' => 'Beta Driver']); + $firstGrantId = api_fixtures()->grantSubuser( + (int)$firstSubuser['id'], + (int)$firstCustomer['customer_number'], + ['VEHICLES_LIST', 'SUBUSERS_LIST'] + ); + $secondGrantId = api_fixtures()->grantSubuser( + (int)$secondSubuser['id'], + (int)$secondCustomer['customer_number'], + ['BOOKINGS_LIST'] + ); + + $response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $rows = array_values(array_filter( + is_array($response->data()) ? $response->data() : [], + static fn (mixed $item): bool => is_array($item) + && in_array((int)($item['grant_id'] ?? 0), [$firstGrantId, $secondGrantId], true) + )); + + expect($rows)->toHaveCount(2); + + $byGrantId = []; + foreach ($rows as $row) { + $byGrantId[(int)$row['grant_id']] = $row; + } + + expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']); + expect($byGrantId[$firstGrantId]['customer_name'])->toBe('Fleet Customer Alpha'); + expect($byGrantId[$firstGrantId]['grant_permissions'])->toBe(['VEHICLES_LIST', 'SUBUSERS_LIST']); + expect($byGrantId[$secondGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']); + expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta'); + expect($byGrantId[$secondGrantId]['grant_permissions'])->toBe(['BOOKINGS_LIST']); +}); + +it('lets superusers invite chauffeurs for a selected customer', function (): void { + $session = api_fixtures()->createUserSession(['add_subusers']); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Invite Target Customer', + 'economic_customer_name' => 'Invite Target Customer', + ]); + $phone = 71000000 + ((int)$customer['customer_number'] % 1000000); + $createdSubuserId = null; + $createdGrantId = null; + $setupToken = null; + + try { + $response = api_client()->post('/superuser/subusers/invite', [ + 'customer_number' => (int)$customer['customer_number'], + 'name' => 'Invited Driver', + 'phone_country_code' => 45, + 'phone' => $phone, + 'permissions' => ['VEHICLES_LIST'], + 'note' => 'Created by superuser test', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null; + $createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null; + $setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null; + + expect($payload['subuser']['customer_number'] ?? null)->toBe((int)$customer['customer_number']); + expect($payload['subuser']['name'] ?? null)->toBe('Invited Driver'); + expect($payload['subuser']['grant_permissions'] ?? null)->toBe(['VEHICLES_LIST']); + expect($payload['grant']['note'] ?? null)->toBe('Created by superuser test'); + expect($payload['invite']['setup_link'] ?? null)->toBeString(); + } finally { + if ($setupToken !== null && $setupToken !== '') { + (new \objects\subusers_o())->invalidateSetupToken($setupToken); + } + if ($createdGrantId !== null) { + api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId); + } + if ($createdSubuserId !== null) { + api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'"); + api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId); + } + } +}); diff --git a/services/nginx/app/tests/Api/VehiclesApiTest.php b/services/nginx/app/tests/Api/VehiclesApiTest.php new file mode 100644 index 00000000..2298e39f --- /dev/null +++ b/services/nginx/app/tests/Api/VehiclesApiTest.php @@ -0,0 +1,96 @@ +createUserSession(['list_own_vehicles']); + $department = api_fixtures()->createDepartment(['name' => 'Vehicle API Department']); + $vehicle = api_fixtures()->createVehicle([ + 'customer_id' => $session['user']['customer_number'], + 'type' => 53, + 'reg' => 'SIDSTEV1', + 'reference' => 'VEHICLE-LAST-WASH', + ]); + + $olderOrder = api_fixtures()->createOrder([ + 'customer_id' => $session['user']['customer_number'], + 'cashier_id' => $session['user']['id'], + 'department_id' => $department['id'], + 'reference' => 'OLDER-ORDER', + 'reg_1' => $vehicle['reg'], + ]); + api_fixtures()->createOrderItem([ + 'order_id' => $olderOrder['id'], + 'product_id' => 53, + 'cashier_id' => $session['user']['id'], + 'price' => 599, + 'quantity' => 1, + ]); + + $newerOrder = api_fixtures()->createOrder([ + 'customer_id' => $session['user']['customer_number'], + 'cashier_id' => $session['user']['id'], + 'department_id' => $department['id'], + 'reference' => 'NEWER-ORDER', + 'reg_1' => $vehicle['reg'], + ]); + + $response = api_client()->get('/vehicles?id=' . $vehicle['id'], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($newerOrder['id'])->toBeGreaterThan($olderOrder['id']); + expect($response->data())->toMatchArray([ + 'id' => $vehicle['id'], + 'reg' => $vehicle['reg'], + 'last_order_id' => $olderOrder['id'], + ]); +}); + +it('returns a null vehicle last_order_id when no order with items exists', function (): void { + api_test_covers('GET /vehicles', 'happy'); + + $session = api_fixtures()->createUserSession(['list_own_vehicles']); + $department = api_fixtures()->createDepartment(['name' => 'Vehicle API Department Null']); + $vehicle = api_fixtures()->createVehicle([ + 'customer_id' => $session['user']['customer_number'], + 'type' => 53, + 'reg' => 'SIDSTEV2', + 'reference' => 'VEHICLE-NO-LAST-WASH', + ]); + + api_fixtures()->createOrder([ + 'customer_id' => $session['user']['customer_number'], + 'cashier_id' => $session['user']['id'], + 'department_id' => $department['id'], + 'reference' => 'EMPTY-ORDER-1', + 'reg_1' => $vehicle['reg'], + ]); + api_fixtures()->createOrder([ + 'customer_id' => $session['user']['customer_number'], + 'cashier_id' => $session['user']['id'], + 'department_id' => $department['id'], + 'reference' => 'EMPTY-ORDER-2', + 'reg_1' => $vehicle['reg'], + ]); + + $response = api_client()->get('/vehicles?id=' . $vehicle['id'], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data())->toMatchArray([ + 'id' => $vehicle['id'], + 'reg' => $vehicle['reg'], + 'last_order_id' => null, + ]); +}); diff --git a/services/nginx/app/tests/Api/WorkerStatusApiTest.php b/services/nginx/app/tests/Api/WorkerStatusApiTest.php new file mode 100644 index 00000000..45a7f359 --- /dev/null +++ b/services/nginx/app/tests/Api/WorkerStatusApiTest.php @@ -0,0 +1,24 @@ +get('/worker/status'); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('api_commit_sha'); + + expect($response->data()['api_commit_sha']) + ->toBeString() + ->not->toBe(''); +}); diff --git a/services/nginx/app/tests/Api/api_coverage_manifest.php b/services/nginx/app/tests/Api/api_coverage_manifest.php new file mode 100644 index 00000000..f6ffdd09 --- /dev/null +++ b/services/nginx/app/tests/Api/api_coverage_manifest.php @@ -0,0 +1,33 @@ + [ + 'POST /auth/login', + 'GET /auth/session', + 'GET /auth/logout', + 'GET /departments', + 'POST /departments', + 'PUT /departments', + 'GET /departments/categories', + 'GET /orders', + 'POST /orders', + 'PUT /orders', + 'PUT /numberplatescanners', + 'DELETE /orders', + 'GET /branding', + 'POST /branding', + 'PUT /branding', + 'PUT /superuser/department/branding', + 'POST /bird/voice/calls/webhook/inbound', + ], + 'manual_operations' => [ + 'GET /ping', + 'PUT /order', + 'GET /orders/reference-suggestions', + ], + 'happy_only_operations' => [ + 'GET /ping', + ], +]; diff --git a/services/nginx/app/tests/Integration/DailyReports/DepartmentDailyReportComplaintsIntegrationTest.php b/services/nginx/app/tests/Integration/DailyReports/DepartmentDailyReportComplaintsIntegrationTest.php new file mode 100644 index 00000000..688f7844 --- /dev/null +++ b/services/nginx/app/tests/Integration/DailyReports/DepartmentDailyReportComplaintsIntegrationTest.php @@ -0,0 +1,240 @@ +markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.'); + } + + $host = getenv('CONFIG_DB_HOST') ?: null; + $user = getenv('CONFIG_DB_USER') ?: null; + $password = getenv('CONFIG_DB_PASSWORD') ?: ''; + $database = getenv('CONFIG_DB_DATABASE') ?: null; + $port = getenv('CONFIG_DB_PORT'); + if (!$host || !$user || !$database) { + test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.'); + } + + app_require('classes/db.php'); + app_require('classes/department_daily_report_complaints_schema_bootstrap.php'); + app_require('objects/department_daily_report_complaints_o.php'); + + if (!defined('objects\redis')) { + define('objects\redis', new class { + private array $department_names = []; + private array $user_ids_by_customer_number = []; + private array $customer_numbers_by_user_id = []; + + public function get_department_name(int $department_id): ?string + { + return $this->department_names[$department_id] ?? null; + } + + public function cache_department_name(int $department_id, string $department_name): void + { + $this->department_names[$department_id] = $department_name; + } + + public function cache_user_id_from_customer_number(int $customer_number, int $user_id): void + { + $this->user_ids_by_customer_number[$customer_number] = $user_id; + } + + public function cache_customer_number_from_user_id(int $user_id, int $customer_number): void + { + $this->customer_numbers_by_user_id[$user_id] = $customer_number; + } + + public function get_user_id_from_customer_number(int $customer_number): ?int + { + return $this->user_ids_by_customer_number[$customer_number] ?? null; + } + + public function get_customer_number_from_user_id(int $user_id): ?int + { + return $this->customer_numbers_by_user_id[$user_id] ?? null; + } + }); + } + + $GLOBALS['response'] = new class { + public function error(string $message): void + { + throw new RuntimeException($message); + } + + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $db = new db([ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + 'port' => $port !== false && $port !== null ? (int)$port : 3306, + ]); + $db->connect(); + $GLOBALS['db'] = $db; + department_daily_report_complaints_schema_bootstrap::ensureTables(); + + return $db; +} + +it('counts complaint rows by department and created_at reporting range', function (): void { + $db = department_daily_report_complaints_integration_db(); + $repository = new department_daily_report_complaints_o(); + $suffix = (string)random_int(10000, 99999); + $department_ids = []; + $complaint_ids = []; + + try { + $db->query("INSERT INTO departments (name, description) VALUES ('" . $db->escape_string('Complaints A ' . $suffix) . "', 'Integration A')"); + $department_a_id = (int)$db->insert_id(); + $department_ids[] = $department_a_id; + + $db->query("INSERT INTO departments (name, description) VALUES ('" . $db->escape_string('Complaints B ' . $suffix) . "', 'Integration B')"); + $department_b_id = (int)$db->insert_id(); + $department_ids[] = $department_b_id; + + $complaint_one = $repository->addComplaint($department_a_id, null, '2026-03-23', 'service', 'Complaint one', 1); + $complaint_two = (new department_daily_report_complaints_o())->addComplaint($department_a_id, 12345, '2026-03-24', 'damage_mirrors', 'Complaint two', 2); + $complaint_three = (new department_daily_report_complaints_o())->addComplaint($department_b_id, null, '2026-03-24', 'other', 'Complaint three', 3); + $complaint_ids = [ + (int)$complaint_one->id, + (int)$complaint_two->id, + (int)$complaint_three->id, + ]; + + $db->query("UPDATE department_daily_report_complaints SET wash_date = NULL, category = NULL WHERE id = " . (int)$complaint_one->id); + $db->query("UPDATE department_daily_report_complaints SET created_at = '2026-03-23 08:00:00' WHERE id = " . (int)$complaint_one->id); + $db->query("UPDATE department_daily_report_complaints SET created_at = '2026-03-24 09:15:00' WHERE id = " . (int)$complaint_two->id); + $db->query("UPDATE department_daily_report_complaints SET created_at = '2026-03-24 10:30:00' WHERE id = " . (int)$complaint_three->id); + + $single_department_day_count = $repository->countForDepartmentsInRange([$department_a_id], '2026-03-23', '2026-03-23'); + $combined_range_count = $repository->countForDepartmentsInRange([$department_a_id, $department_b_id], '2026-03-23', '2026-03-24'); + $second_day_count = $repository->countForDepartmentsInRange([$department_a_id, $department_b_id], '2026-03-24', '2026-03-24'); + + expect($single_department_day_count)->toBe(1); + expect($combined_range_count)->toBe(3); + expect($second_day_count)->toBe(2); + $complaint_two_data = $complaint_two->asArray(); + expect($complaint_two_data['department_id'])->toBe($department_a_id); + expect($complaint_two_data['customer_number'])->toBe(12345); + expect($complaint_two_data['wash_date'])->toBe('2026-03-24'); + expect($complaint_two_data['category'])->toBe('damage_mirrors'); + expect($complaint_two_data['description'])->toBe('Complaint two'); + expect($complaint_two_data['created_by'])->toBe(2); + } finally { + if ($complaint_ids !== []) { + $db->query('DELETE FROM department_daily_report_complaints WHERE id IN (' . implode(',', array_map('intval', $complaint_ids)) . ')'); + } + if ($department_ids !== []) { + $db->query('DELETE FROM departments WHERE id IN (' . implode(',', array_map('intval', $department_ids)) . ')'); + } + $db->close(); + } +}); + +it('updates and deletes complaint rows', function (): void { + $db = department_daily_report_complaints_integration_db(); + $repository = new department_daily_report_complaints_o(); + $suffix = (string)random_int(10000, 99999); + $department_ids = []; + $complaint_id = null; + + try { + $db->query("INSERT INTO departments (name, description) VALUES ('" . $db->escape_string('Complaints Update A ' . $suffix) . "', 'Integration update A')"); + $department_a_id = (int)$db->insert_id(); + $department_ids[] = $department_a_id; + + $db->query("INSERT INTO departments (name, description) VALUES ('" . $db->escape_string('Complaints Update B ' . $suffix) . "', 'Integration update B')"); + $department_b_id = (int)$db->insert_id(); + $department_ids[] = $department_b_id; + + $complaint = $repository->addComplaint($department_a_id, null, '2026-03-23', 'service', 'Original complaint', 11); + $complaint_id = (int)$complaint->id; + + $complaint->update([ + 'department_id' => $department_b_id, + 'customer_number' => 54321, + 'wash_date' => '2026-03-24', + 'category' => 'damage_paint', + 'description' => 'Updated complaint', + ]); + + $updated = (new department_daily_report_complaints_o())->select($complaint_id); + $updated_data = $updated->asArray(); + expect($updated_data['id'])->toBe($complaint_id); + expect($updated_data['department_id'])->toBe($department_b_id); + expect($updated_data['customer_number'])->toBe(54321); + expect($updated_data['wash_date'])->toBe('2026-03-24'); + expect($updated_data['category'])->toBe('damage_paint'); + expect($updated_data['description'])->toBe('Updated complaint'); + expect($updated_data['created_by'])->toBe(11); + + $updated->deletePermanently(); + expect((new department_daily_report_complaints_o())->select($complaint_id)->exists())->toBeFalse(); + } finally { + if ($complaint_id !== null) { + $db->query('DELETE FROM department_daily_report_complaints WHERE id = ' . (int)$complaint_id); + } + if ($department_ids !== []) { + $db->query('DELETE FROM departments WHERE id IN (' . implode(',', array_map('intval', $department_ids)) . ')'); + } + $db->close(); + } +}); + +it('parses created_by_name from the users table', function (): void { + $db = department_daily_report_complaints_integration_db(); + $repository = new department_daily_report_complaints_o(); + $suffix = (string)random_int(10000, 99999); + $department_ids = []; + $complaint_id = null; + $user_id = null; + $customer_number = (string)random_int(70000000, 79999999); + + try { + $db->query("INSERT INTO departments (name, description) VALUES ('" . $db->escape_string('Complaints Parse ' . $suffix) . "', 'Integration parse')"); + $department_id = (int)$db->insert_id(); + $department_ids[] = $department_id; + + $escaped_customer_number = $db->escape_string($customer_number); + $escaped_display_name = $db->escape_string('Complaint Parser User ' . $suffix); + $escaped_password = $db->escape_string(password_hash('integration-password', PASSWORD_DEFAULT)); + $db->query( + "INSERT INTO users (customer_number, password, group_id, display_name) + VALUES ('$escaped_customer_number', '$escaped_password', 1, '$escaped_display_name')" + ); + $user_id = (int)$db->insert_id(); + + $complaint = $repository->addComplaint($department_id, null, '2026-03-23', 'wash_quality', 'Complaint with parsed staff name', $user_id); + $complaint_id = (int)$complaint->id; + + $parsed = $repository->parseComplaint($complaint->asArray()); + + expect($parsed['created_by'])->toBe($user_id); + expect($parsed['created_by_name'])->toBe('Complaint Parser User ' . $suffix); + expect($parsed['department_name'])->toBe('Complaints Parse ' . $suffix); + expect($parsed['wash_date'])->toBe('2026-03-23'); + expect($parsed['category'])->toBe('wash_quality'); + } finally { + if ($complaint_id !== null) { + $db->query('DELETE FROM department_daily_report_complaints WHERE id = ' . (int)$complaint_id); + } + if ($user_id !== null) { + $db->query('DELETE FROM users WHERE id = ' . (int)$user_id); + } + if ($department_ids !== []) { + $db->query('DELETE FROM departments WHERE id IN (' . implode(',', array_map('intval', $department_ids)) . ')'); + } + $db->close(); + } +}); diff --git a/services/nginx/app/tests/Integration/DailyReports/DepartmentOutsideHoursStatisticsServiceIntegrationTest.php b/services/nginx/app/tests/Integration/DailyReports/DepartmentOutsideHoursStatisticsServiceIntegrationTest.php new file mode 100644 index 00000000..fe390750 --- /dev/null +++ b/services/nginx/app/tests/Integration/DailyReports/DepartmentOutsideHoursStatisticsServiceIntegrationTest.php @@ -0,0 +1,334 @@ +markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.'); + } + + $host = getenv('CONFIG_DB_HOST') ?: null; + $user = getenv('CONFIG_DB_USER') ?: null; + $password = getenv('CONFIG_DB_PASSWORD') ?: ''; + $database = getenv('CONFIG_DB_DATABASE') ?: null; + if (!$host || !$user || !$database) { + test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.'); + } + + app_require('classes/db.php'); + app_require('classes/department_outside_hours_statistics_service.php'); + + $GLOBALS['response'] = new class { + public function error(string $message): void + { + throw new RuntimeException($message); + } + + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $db = new db([ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + 'port' => (int)(getenv('CONFIG_DB_PORT') ?: 3306), + ]); + $db->connect(); + $GLOBALS['db'] = $db; + + department_outside_hours_prepare_tables($db); + + return $db; +} + +function department_outside_hours_prepare_tables(db $db): void +{ + $db->query( + 'CREATE TABLE IF NOT EXISTS departments ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NULL + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS department_time_bookings_opening_hours ( + id INT AUTO_INCREMENT PRIMARY KEY, + department INT NOT NULL, + monday_start TIME NULL, + monday_end TIME NULL, + tuesday_start TIME NULL, + tuesday_end TIME NULL + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS products ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + is_wash TINYINT(1) NOT NULL DEFAULT 0 + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS orders ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id INT NOT NULL, + cashier_id INT NOT NULL, + reference VARCHAR(255) NULL, + notes TEXT NULL, + department_id INT NOT NULL, + reg_1 VARCHAR(64) NULL, + reg_2 VARCHAR(64) NULL, + reg_3 VARCHAR(64) NULL, + created_at DATETIME NOT NULL, + include_in_invoice TINYINT(1) NULL, + wash_id VARCHAR(255) NULL, + deleted_at DATETIME NULL + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS order_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + order_id INT NOT NULL, + product_id INT NOT NULL, + reference VARCHAR(255) NULL, + notes TEXT NULL, + cashier_id INT NOT NULL, + price DECIMAL(10,2) NOT NULL DEFAULT 0, + quantity INT NOT NULL DEFAULT 1, + deleted_at DATETIME NULL + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS selfserve_wash_sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + lane_id INT NOT NULL, + department_id INT NOT NULL, + reg VARCHAR(64) NOT NULL, + status VARCHAR(64) NOT NULL, + allowed TINYINT(1) NOT NULL DEFAULT 0, + wash_started_at DATETIME NULL, + machine_start_triggered_at DATETIME NULL, + order_id INT NULL, + completed_at DATETIME NULL, + created_at DATETIME NULL, + deleted_at DATETIME NULL + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS xlvask_usage_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + WashId VARCHAR(255) NOT NULL, + CustomerId VARCHAR(255) NULL, + Customer VARCHAR(255) NULL, + VatNumber VARCHAR(255) NULL, + Location VARCHAR(255) NULL, + Hall VARCHAR(255) NULL, + HallId VARCHAR(255) NULL, + StartTime VARCHAR(64) NULL, + FinishTime VARCHAR(64) NULL, + RegistrationNumber VARCHAR(255) NULL, + VehicleType VARCHAR(255) NULL, + IdentificationType VARCHAR(255) NULL, + IdentificationId VARCHAR(255) NULL, + Info TEXT NULL, + Updated VARCHAR(64) NULL, + Prepaid VARCHAR(64) NULL, + FinishStatus VARCHAR(16) NULL, + CustomerGuid VARCHAR(255) NULL, + VehicleId VARCHAR(255) NULL, + WashItems TEXT NULL + )' + ); +} + +it('integrates orders, xlvask, and self-serve into one outside-hours summary with missing-hours diagnostics', function (): void { + $db = department_outside_hours_integration_db(); + $service = new department_outside_hours_statistics_service(); + $suffix = (string)random_int(10000, 99999); + $departmentIds = []; + $orderIds = []; + $sessionIds = []; + $washIds = []; + $xlvaskCustomerGuids = []; + $productId = null; + + $createWashOrder = function (int $departmentId, int $productId, string $reference, string $createdAt, ?string $washId = null) use ($db, &$orderIds): int { + $escapedReference = $db->escape_string($reference); + $escapedCreatedAt = $db->escape_string($createdAt); + $escapedWashId = $washId === null ? 'NULL' : "'" . $db->escape_string($washId) . "'"; + + $db->query( + "INSERT INTO orders (customer_id, cashier_id, reference, notes, department_id, reg_1, reg_2, reg_3, created_at, wash_id) + VALUES (1, 1, '$escapedReference', 'Integration test', $departmentId, 'ZZ$departmentId', '', '', '$escapedCreatedAt', $escapedWashId)" + ); + $orderId = (int)$db->insert_id(); + $orderIds[] = $orderId; + + $db->query( + "INSERT INTO order_items (order_id, product_id, reference, notes, cashier_id, price, quantity) + VALUES ($orderId, $productId, '$escapedReference', 'Integration test', 1, 100, 1)" + ); + + return $orderId; + }; + + $insertSelfserveSession = function (int $departmentId, string $reg, string $startedAt, ?int $orderId = null) use ($db, &$sessionIds): int { + $escapedReg = $db->escape_string($reg); + $escapedStartedAt = $db->escape_string($startedAt); + $orderIdSql = $orderId === null ? 'NULL' : (string)$orderId; + + $db->query( + "INSERT INTO selfserve_wash_sessions (lane_id, department_id, reg, status, allowed, wash_started_at, machine_start_triggered_at, order_id, completed_at, created_at) + VALUES (1, $departmentId, '$escapedReg', 'COMPLETED', 1, '$escapedStartedAt', '$escapedStartedAt', $orderIdSql, '$escapedStartedAt', '$escapedStartedAt')" + ); + + $sessionId = (int)$db->insert_id(); + $sessionIds[] = $sessionId; + return $sessionId; + }; + + $insertXlvaskLog = function (int $departmentId, string $departmentName, string $washId, string $startTime) use ($db, &$washIds, &$xlvaskCustomerGuids): void { + $escapedDepartmentName = $db->escape_string($departmentName); + $escapedWashId = $db->escape_string($washId); + $escapedStartTime = $db->escape_string($startTime); + $escapedFinishTime = $db->escape_string(substr($startTime, 0, 19) . '.000'); + $hall = $db->escape_string($departmentName . '_1'); + $washIds[] = $washId; + $customerGuidSql = 'NULL'; + + if ($db->num_rows($db->query("SHOW TABLES LIKE 'xlvask_customers'")) > 0) { + $customerGuid = 'guid-' . $washId; + $escapedCustomerGuid = $db->escape_string($customerGuid); + $escapedVendorId = $db->escape_string('vendor-' . $washId); + $escapedCustomerName = $db->escape_string('Integration Customer ' . $washId); + + $db->query( + "INSERT IGNORE INTO xlvask_customers (`customerId`, `vendorId`, `name`) + VALUES ('$escapedCustomerGuid', '$escapedVendorId', '$escapedCustomerName')" + ); + + $xlvaskCustomerGuids[] = $customerGuid; + $customerGuidSql = "'" . $escapedCustomerGuid . "'"; + } + + $db->query( + "INSERT INTO xlvask_usage_logs + (`WashId`, `CustomerId`, `Customer`, `VatNumber`, `Location`, `Hall`, `HallId`, `StartTime`, `FinishTime`, + `RegistrationNumber`, `VehicleType`, `IdentificationType`, `IdentificationId`, `Info`, `Updated`, `Prepaid`, + `FinishStatus`, `CustomerGuid`, `VehicleId`, `WashItems`) + VALUES + ('$escapedWashId', '123456', 'Integration Customer', '12345678', '$escapedDepartmentName', '$hall', 'hall-$departmentId', + '$escapedStartTime', '$escapedFinishTime', 'ZZ$departmentId', 'Truck', 'LPR', 'ZZ$departmentId', 'ZZ$departmentId', + NULL, '', '1', $customerGuidSql, 'vehicle-$departmentId', '[]')" + ); + }; + + try { + $departmentAName = 'DognDeptA' . $suffix; + $departmentBName = 'DognDeptB' . $suffix; + + $db->query("INSERT INTO departments (name, description) VALUES ('" . $db->escape_string($departmentAName) . "', 'Integration A')"); + $departmentAId = (int)$db->insert_id(); + $departmentIds[] = $departmentAId; + + $db->query("INSERT INTO departments (name, description) VALUES ('" . $db->escape_string($departmentBName) . "', 'Integration B')"); + $departmentBId = (int)$db->insert_id(); + $departmentIds[] = $departmentBId; + + $db->query( + "INSERT INTO department_time_bookings_opening_hours + (department, monday_start, monday_end, tuesday_start, tuesday_end) + VALUES + ($departmentAId, '08:00:00', '17:00:00', '08:00:00', '17:00:00')" + ); + $db->query( + "INSERT INTO department_time_bookings_opening_hours + (department, tuesday_start, tuesday_end) + VALUES + ($departmentBId, '08:00:00', '17:00:00')" + ); + + $productNameColumn = null; + $productHasDescriptionColumn = false; + if ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'title'")) > 0) { + $productNameColumn = 'title'; + } elseif ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'name'")) > 0) { + $productNameColumn = 'name'; + } else { + test()->markTestSkipped('Products table is missing both title and name columns required by this integration test.'); + } + $productHasDescriptionColumn = $db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'description'")) > 0; + + $productNameValue = $db->escape_string('Outside hours test wash ' . $suffix); + if ($productHasDescriptionColumn) { + $productDescriptionValue = $db->escape_string('Integration outside-hours wash'); + $db->query("INSERT INTO products ($productNameColumn, description, is_wash) VALUES ('$productNameValue', '$productDescriptionValue', 1)"); + } else { + $db->query("INSERT INTO products ($productNameColumn, is_wash) VALUES ('$productNameValue', 1)"); + } + $productId = (int)$db->insert_id(); + + $linkedXlvaskWashId = 'wash-linked-' . $suffix; + $unmatchedXlvaskWashId = 'wash-unmatched-' . $suffix; + + $createWashOrder($departmentAId, $productId, 'plain-order-' . $suffix, '2026-03-23 05:30:00'); + $linkedSelfserveOrderId = $createWashOrder($departmentAId, $productId, 'linked-selfserve-' . $suffix, '2026-03-23 06:00:00'); + $createWashOrder($departmentAId, $productId, 'linked-xlvask-' . $suffix, '2026-03-23 06:10:00', $linkedXlvaskWashId); + $createWashOrder($departmentBId, $productId, 'missing-hours-' . $suffix, '2026-03-23 05:00:00'); + $createWashOrder($departmentAId, $productId, 'inside-hours-' . $suffix, '2026-03-23 10:00:00'); + + $insertSelfserveSession($departmentAId, 'SELF' . $suffix, '2026-03-23 06:05:00', $linkedSelfserveOrderId); + $insertSelfserveSession($departmentAId, 'FREE' . $suffix, '2026-03-23 22:15:00'); + + $insertXlvaskLog($departmentAId, $departmentAName, $linkedXlvaskWashId, '2026-03-23T06:15:00.000'); + $insertXlvaskLog($departmentAId, $departmentAName, $unmatchedXlvaskWashId, '2026-03-23T23:05:00.000'); + + $summary = $service->getSummary('2026-03-23', [$departmentAId, $departmentBId], '2026-03-23'); + + expect($summary['total'])->toBe(5); + expect($summary['by_source'])->toBe([ + 'orders' => 1, + 'xlvask' => 2, + 'selfserve' => 2, + ]); + expect($summary['has_missing_opening_hours'])->toBeTrue(); + expect($summary['missing_department_ids'])->toBe([$departmentBId]); + } finally { + if ($sessionIds !== []) { + $db->query('DELETE FROM selfserve_wash_sessions WHERE id IN (' . implode(',', array_map('intval', $sessionIds)) . ')'); + } + if ($washIds !== []) { + $escapedWashIds = implode(',', array_map(static fn(string $washId): string => "'" . $db->escape_string($washId) . "'", $washIds)); + $db->query("DELETE FROM xlvask_usage_logs WHERE WashId IN ($escapedWashIds)"); + } + if ($xlvaskCustomerGuids !== [] && $db->num_rows($db->query("SHOW TABLES LIKE 'xlvask_customers'")) > 0) { + $escapedCustomerGuids = implode(',', array_map(static fn(string $customerGuid): string => "'" . $db->escape_string($customerGuid) . "'", $xlvaskCustomerGuids)); + $db->query("DELETE FROM xlvask_customers WHERE customerId IN ($escapedCustomerGuids)"); + } + if ($orderIds !== []) { + $orderIdsSql = implode(',', array_map('intval', $orderIds)); + $db->query("DELETE FROM order_items WHERE order_id IN ($orderIdsSql)"); + $db->query("DELETE FROM orders WHERE id IN ($orderIdsSql)"); + } + if ($productId !== null) { + $db->query('DELETE FROM products WHERE id = ' . (int)$productId); + } + if ($departmentIds !== []) { + $departmentIdsSql = implode(',', array_map('intval', $departmentIds)); + $db->query("DELETE FROM department_time_bookings_opening_hours WHERE department IN ($departmentIdsSql)"); + $db->query("DELETE FROM departments WHERE id IN ($departmentIdsSql)"); + } + $db->close(); + } +}); diff --git a/services/nginx/app/tests/Integration/Database/DbConnectionTest.php b/services/nginx/app/tests/Integration/Database/DbConnectionTest.php index 81dbb7c9..fdae7e68 100644 --- a/services/nginx/app/tests/Integration/Database/DbConnectionTest.php +++ b/services/nginx/app/tests/Integration/Database/DbConnectionTest.php @@ -11,6 +11,7 @@ it('can connect to a configured MySQL instance in integration mode', function () $user = getenv('CONFIG_DB_USER') ?: null; $password = getenv('CONFIG_DB_PASSWORD') ?: ''; $database = getenv('CONFIG_DB_DATABASE') ?: null; + $port = getenv('CONFIG_DB_PORT') ?: 3306; if (!$host || !$user || !$database) { test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.'); @@ -30,6 +31,7 @@ it('can connect to a configured MySQL instance in integration mode', function () 'user' => $user, 'password' => $password, 'database' => $database, + 'port' => $port, ]); $db->connect(); diff --git a/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php b/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php new file mode 100644 index 00000000..03672d7c --- /dev/null +++ b/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php @@ -0,0 +1,569 @@ +createDepartment([ + 'name' => 'Edge Integration Install Department', + ]); + + $token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Install Token'); + $claimTokenId = (int)($token['claim_token_id'] ?? 0); + + $context['manager']->reportInstallTokenStatus((string)$token['token'], [ + 'status' => 'FAILED', + 'step' => 'DOWNLOAD_FAILED', + 'message' => 'The installer could not download the runtime bundle.', + 'diagnostics' => [ + 'diag-1', + 'diag-2', + 'diag-3', + 'diag-4', + 'diag-5', + 'diag-6', + 'diag-7', + 'diag-8', + ], + ]); + + $failedStatus = $context['manager']->getInstallTokenStatus($claimTokenId); + + expect($failedStatus) + ->toHaveKey('status', 'FAILED') + ->toHaveKey('step', 'DOWNLOAD_FAILED') + ->toHaveKey('last_error', 'The installer could not download the runtime bundle.') + ->and($failedStatus['diagnostics'] ?? []) + ->toBeArray() + ->toHaveCount(6); + + $claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-integration-host', 'php-agent-v1', [ + 'source' => 'integration-test', + ]); + + $gatewayId = (int)($claimed['gateway']['id'] ?? 0); + $agentToken = (string)($claimed['agent_token'] ?? ''); + expect($gatewayId)->toBeGreaterThan(0) + ->and($agentToken)->not->toBe(''); + + $claimedStatus = $context['manager']->getInstallTokenStatus($claimTokenId); + expect($claimedStatus) + ->toHaveKey('status', 'CLAIMED') + ->toHaveKey('gateway_id', $gatewayId) + ->toHaveKey('last_error', null); + + edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1); + $degraded = $context['manager']->getGateway($gatewayId); + expect($degraded)->toHaveKey('status', 'DEGRADED'); + + edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1); + $offline = $context['manager']->getGateway($gatewayId); + expect($offline)->toHaveKey('status', 'OFFLINE'); + + $context['manager']->recordHeartbeat($gatewayId, $agentToken, [ + 'status' => 'ONLINE', + 'hostname' => 'edge-integration-host', + 'metadata' => [ + 'system_metrics' => [ + 'cpu_percent' => 44, + ], + ], + ]); + + $online = $context['manager']->getGateway($gatewayId); + expect($online) + ->toHaveKey('status', 'ONLINE') + ->and($online['metadata']['system_metrics']['cpu_percent'] ?? null) + ->toBe(44); + } finally { + $context['cleanup']->run(); + } +}); + +it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle state from persisted records', function (): void { + $context = edge_gateway_integration_context(); + + try { + $department = $context['fixtures']->createDepartment([ + 'name' => 'Edge Integration Runtime Department', + ]); + $user = $context['fixtures']->createUser([ + 'display_name' => 'Edge Integration User', + ]); + + $token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Runtime Token', (int)$user['id']); + $claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-runtime-host', 'php-agent-v1'); + $gatewayId = (int)($claimed['gateway']['id'] ?? 0); + $agentToken = (string)($claimed['agent_token'] ?? ''); + + $operation = $context['operations']->queueOperation( + $gatewayId, + edge_gateway_operation_service::TYPE_DISCOVERY, + ['inventory' => edge_gateway_integration_inventory('runtime')], + (int)$user['id'] + ); + $operationId = (int)($operation['id'] ?? 0); + expect($operationId)->toBeGreaterThan(0); + + $claimedOperation = $context['operations']->claimNextOperation($gatewayId, $agentToken, 0, 'integration-agent-1'); + expect($claimedOperation) + ->toBeArray() + ->toHaveKey('status', 'IN_PROGRESS'); + + $context['operations']->appendAgentOperationEvent($gatewayId, $operationId, $agentToken, [ + 'level' => 'INFO', + 'code' => 'DISCOVERY_RUNNING', + 'message' => 'Integration discovery is executing.', + 'context' => [ + 'progress' => 70, + ], + ]); + + $context['operations']->completeAgentOperation($gatewayId, $operationId, $agentToken, [ + 'ok' => true, + 'result' => [ + 'inventory' => edge_gateway_integration_inventory('completed'), + ], + ]); + + $job = $context['fixtures']->createEdgeCommandJob([ + 'gateway_id' => $gatewayId, + 'command_type' => 'GET_RELAY_STATUS', + 'request' => [ + 'relayId' => 'relay-main', + ], + 'requested_by' => (int)$user['id'], + ]); + $jobId = (int)($job['id'] ?? 0); + + $polledCommand = $context['manager']->pollCommand($gatewayId, $agentToken, 0); + expect($polledCommand) + ->toBeArray() + ->toHaveKey('id', $jobId) + ->toHaveKey('command_type', 'GET_RELAY_STATUS'); + + $commandResult = $context['manager']->submitCommandResult($gatewayId, $jobId, $agentToken, true, [ + 'relayId' => 'relay-main', + 'online' => true, + ]); + expect($commandResult) + ->toHaveKey('acknowledged', true) + ->and($commandResult['job']['status'] ?? null) + ->toBe('COMPLETED'); + + $context['manager']->recordBrokerPresence( + $gatewayId, + 'connected', + 'broker-connection-1', + null, + ['transport' => 'ws'] + ); + + $shellSession = $context['manager']->createShellSession( + $gatewayId, + (int)$user['id'], + 'Integration shell session', + 120, + 40, + '/opt/truckwash-edge-agent' + ); + $shellToken = (string)($shellSession['token'] ?? ''); + expect($shellToken)->not->toBe(''); + expect($shellSession['diagnostics']['broker_presence']['connection_id'] ?? null) + ->toBe('broker-connection-1'); + + $validatedShell = $context['manager']->validateShellSessionToken($shellToken); + expect($validatedShell)->toHaveKey('status', 'PENDING'); + + $openedShell = $context['manager']->markShellSessionOpened($shellToken, 'shell-connection-1'); + expect($openedShell)->toHaveKey('status', 'OPEN'); + + $closedShell = $context['manager']->closeShellSessionByToken( + $shellToken, + "edge-shell-output\n", + 'agent_exit', + [ + 'message' => 'Integration shell completed.', + 'code' => 0, + 'stage' => 'shell_active', + ] + ); + expect($closedShell) + ->toHaveKey('status', 'COMPLETED') + ->and($closedShell['transcript'] ?? null) + ->toBe("edge-shell-output\n") + ->and($closedShell['metadata']['close_reason'] ?? null) + ->toBe('agent_exit') + ->and($closedShell['metadata']['close_stage'] ?? null) + ->toBe('shell_active'); + + $context['manager']->appendGatewayLogEntry( + $gatewayId, + 'Integration log line', + 'INFO', + 'agent', + 'BROKER', + ['source' => 'integration'] + ); + $context['manager']->appendRelayTransportLog( + (int)$department['id'], + '/v2/devices/api/set/switch', + ['id' => 'M-7', 'on' => true, 'toggle_after' => 3], + [['id' => 'M-7', 'online' => true, 'on' => true]], + 'cloud', + null, + [ + 'module' => 'selfserve', + 'reason' => 'Integration relay start', + 'relay_name' => 'Roskilde Maskine', + 'relay_role' => 'MACHINE', + 'customer_number' => 700123, + 'actor' => [ + 'admin_user_id' => 42, + ], + ] + ); + + $context['manager']->recordTelemetryFromBroker($gatewayId, [ + 'status' => 'ONLINE', + 'metadata' => [ + 'system_metrics' => [ + 'cpu_percent' => 17, + 'memory_mb' => 256, + ], + ], + 'inventory' => edge_gateway_integration_inventory('telemetry'), + ]); + + $tasks = $context['manager']->buildGatewayTasksPage($gatewayId); + $logs = $context['manager']->buildGatewayLogsPage($gatewayId); + $statistics = $context['manager']->buildGatewayStatisticsPage($gatewayId); + + expect($tasks['operations'] ?? []) + ->toBeArray() + ->not->toBeEmpty() + ->and(($tasks['operations'][0]['status'] ?? null)) + ->toBe('COMPLETED'); + expect($tasks['recent_commands'] ?? []) + ->toBeArray() + ->not->toBeEmpty() + ->and(($tasks['recent_commands'][0]['status'] ?? null)) + ->toBe('COMPLETED'); + + expect(edge_gateway_integration_messages($logs['log_entries'] ?? [])) + ->toContain('Integration log line'); + expect(edge_gateway_integration_messages($logs['relay_logs'] ?? [])) + ->toContain('MACHINE ON Roskilde Maskine handled by cloud via CLOUD'); + expect($logs['relay_logs'][0]['context']['module_responsible'] ?? null) + ->toBe('selfserve') + ->and($logs['relay_logs'][0]['context']['description'] ?? null) + ->toBe('MACHINE ON Roskilde Maskine handled by cloud via CLOUD') + ->and($logs['relay_logs'][0]['context']['relay_name'] ?? null) + ->toBe('Roskilde Maskine') + ->and($logs['relay_logs'][0]['context']['relay_role'] ?? null) + ->toBe('MACHINE') + ->and($logs['relay_logs'][0]['context']['associated']['customer_number'] ?? null) + ->toBe(700123) + ->and($logs['relay_logs'][0]['context']['associated']['admin_user_id'] ?? null) + ->toBe(42) + ->and($logs['relay_logs'][0]['context']['reason'] ?? null) + ->toBe('Integration relay start') + ->and($logs['relay_logs'][0]['context']['handler'] ?? null) + ->toBe('cloud') + ->and($logs['relay_logs'][0]['context']['signal']['request']['id'] ?? null) + ->toBe('M-7') + ->and($logs['relay_logs'][0]['context']['response']['on'] ?? null) + ->toBeTrue(); + expect(edge_gateway_integration_messages($logs['timeline'] ?? [])) + ->toContain('Integration discovery is executing.'); + expect(array_values(array_filter($logs['timeline'] ?? [], static fn(array $entry): bool => ($entry['type'] ?? null) === 'relay'))) + ->not->toBeEmpty(); + expect($logs['shell_sessions'] ?? []) + ->toBeArray() + ->not->toBeEmpty() + ->and(($logs['shell_sessions'][0]['transcript'] ?? null)) + ->toBe("edge-shell-output\n"); + + expect($statistics['system_metrics']['cpu_percent'] ?? null) + ->toBe(17); + expect($statistics['gateway']['inventory'] ?? []) + ->toBeArray() + ->not->toBeEmpty(); + } finally { + $context['cleanup']->run(); + } +}); + +it('persists gateway cutover relay bindings used by self-serve Shelly dispatch', function (): void { + $context = edge_gateway_integration_context(); + + try { + $department = $context['fixtures']->createDepartment([ + 'name' => 'Edge Integration Self Serve Relay Department', + ]); + $user = $context['fixtures']->createUser([ + 'display_name' => 'Edge Integration Relay User', + ]); + + $token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Relay Token', (int)$user['id']); + $claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-relay-host', 'php-agent-v1'); + $gatewayId = (int)($claimed['gateway']['id'] ?? 0); + + $mode = $context['manager']->setDepartmentTransportMode( + (int)$department['id'], + edge_gateway_manager::TRANSPORT_MODE_GATEWAY, + (int)$user['id'] + ); + $bindings = $context['manager']->setRelayBindings($gatewayId, [[ + 'relay_id' => 'relay-machine', + 'device_id' => 'device-machine', + 'local_ip' => '10.50.60.70', + 'channel' => 0, + 'metadata' => [ + 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL, + ], + ]], (int)$user['id']); + $resolved = $context['manager']->resolveRelayBinding((int)$department['id'], 'relay-machine'); + + expect($mode)->toHaveKey('transport_mode', edge_gateway_manager::TRANSPORT_MODE_GATEWAY) + ->and($bindings)->toHaveCount(1) + ->and($resolved)->toMatchArray([ + 'gateway_id' => $gatewayId, + 'department_id' => (int)$department['id'], + 'relay_id' => 'relay-machine', + 'device_id' => 'device-machine', + 'local_ip' => '10.50.60.70', + 'channel' => 0, + 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL, + ]) + ->and((array)($resolved['metadata'] ?? [])) + ->toHaveKey('fallback_mode', edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL) + ->and($context['manager']->getDepartmentTransportMode((int)$department['id'])) + ->toBe(edge_gateway_manager::TRANSPORT_MODE_GATEWAY); + } finally { + $context['cleanup']->run(); + } +}); + +/** + * @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli,redis:?PredisClient} + */ +function edge_gateway_integration_context(): array +{ + if (!integration_enabled()) { + test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run edge gateway integration tests.'); + } + + static $bootstrapped = null; + + if ($bootstrapped === null) { + $dbConfig = edge_gateway_integration_db_config(); + $GLOBALS['CONFIG_DB'] = $dbConfig; + $GLOBALS['response'] = new class { + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $db = edge_gateway_integration_wait_for_db($dbConfig); + $GLOBALS['db'] = $db; + + $mysqli = $db->conn(); + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + $mysqli->set_charset('utf8mb4'); + + (new ApiSchemaBootstrap($mysqli))->ensureSchema(); + + $bootstrapped = [ + 'mysqli' => $mysqli, + 'redis' => edge_gateway_integration_redis_client(), + ]; + } + + $cleanup = new ApiCleanup(); + + return [ + 'cleanup' => $cleanup, + 'fixtures' => new ApiFixtures($bootstrapped['mysqli'], $bootstrapped['redis'], $cleanup), + 'manager' => new edge_gateway_manager(), + 'operations' => new edge_gateway_operation_service(), + 'mysqli' => $bootstrapped['mysqli'], + 'redis' => $bootstrapped['redis'], + ]; +} + +function edge_gateway_integration_wait_for_db(array $dbConfig): db +{ + $deadline = microtime(true) + 60; + $lastError = null; + + do { + try { + $db = new db($dbConfig); + $db->connect(); + return $db; + } catch (RuntimeException $exception) { + $lastError = $exception; + usleep(500000); + } + } while (microtime(true) < $deadline); + + throw $lastError ?? new RuntimeException('Database connection failed before a connection attempt completed.'); +} + +/** + * @return array{host:string,user:string,password:string,database:string,port:int} + */ +function edge_gateway_integration_db_config(): array +{ + $target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live'))); + if ($target !== 'debug') { + $target = 'live'; + } + + $host = edge_gateway_integration_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target); + $user = edge_gateway_integration_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target); + $password = edge_gateway_integration_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target); + $database = edge_gateway_integration_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target); + $port = (int)(edge_gateway_integration_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306'); + + if ($host === '' || $user === '' || $database === '') { + test()->markTestSkipped('Edge gateway integration tests require configured database environment variables.'); + } + + return [ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + 'port' => $port > 0 ? $port : 3306, + ]; +} + +function edge_gateway_integration_redis_client(): ?PredisClient +{ + $target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live'))); + if ($target !== 'debug') { + $target = 'live'; + } + + $host = edge_gateway_integration_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target); + if ($host === '') { + return null; + } + + $parameters = [ + 'scheme' => 'tcp', + 'host' => $host, + 'port' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'), + 'database' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'), + 'password' => edge_gateway_integration_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target), + ]; + + $user = edge_gateway_integration_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target); + if ($user !== '') { + $parameters['username'] = $user; + } + + return new PredisClient($parameters); +} + +function edge_gateway_integration_config_value(string $liveKey, string $debugKey, string $target): string +{ + $liveValue = trim((string)(getenv($liveKey) ?: '')); + $debugValue = trim((string)(getenv($debugKey) ?: '')); + + if ($target === 'debug' && $debugValue !== '') { + return $debugValue; + } + + return $liveValue; +} + +function edge_gateway_integration_set_heartbeat_age(mysqli $mysqli, ?PredisClient $redis, int $gatewayId, int $secondsAgo): void +{ + $result = $mysqli->query("SELECT last_heartbeat_at, updated_at, created_at FROM edge_gateways WHERE id = " . (int)$gatewayId . " LIMIT 1"); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + if ($result instanceof mysqli_result) { + $result->free(); + } + + $referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? '')); + if ($referenceTimestamp === false || $referenceTimestamp <= 0) { + $referenceTimestamp = time(); + } + + $timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo)); + $escaped = $mysqli->real_escape_string($timestamp); + $mysqli->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escaped}', status = 'ONLINE' WHERE id = " . (int)$gatewayId); + + if ($redis !== null) { + foreach ([ + 'obj_prop:*:' . $gatewayId . ':status', + 'obj_prop:*:' . $gatewayId . ':last_heartbeat_at', + 'obj_prop:*:' . $gatewayId . ':updated_at', + 'edge_gateway:view:v1:*', + ] as $pattern) { + $keys = $redis->keys($pattern); + if (is_array($keys) && $keys !== []) { + $redis->del($keys); + } + } + } +} + +function edge_gateway_integration_inventory(string $suffix): array +{ + return [[ + 'device_id' => 'integration-' . $suffix, + 'local_ip' => '10.50.60.70', + 'model' => 'TruckWash Integration Gateway', + 'channel_count' => 1, + 'online' => true, + 'capabilities' => [ + 'gateway_management_v2' => true, + ], + 'metadata' => [ + 'hostname' => 'integration-' . $suffix, + ], + ]]; +} + +/** + * @param mixed $rows + * @return array + */ +function edge_gateway_integration_messages(mixed $rows): array +{ + if (!is_array($rows)) { + return []; + } + + $messages = []; + foreach ($rows as $row) { + if (is_array($row) && isset($row['message']) && is_string($row['message'])) { + $messages[] = $row['message']; + } + } + + return $messages; +} diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php new file mode 100644 index 00000000..af746b7d --- /dev/null +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php @@ -0,0 +1,336 @@ + */ + private array $fail_once_order_draft = []; + + public function failNextOrderDraft(int $order_id): void + { + $this->fail_once_order_draft[$order_id] = true; + } + + public function exportOrderDraftInvoice(int $order_id, int $user_id = 0): array + { + if (($this->fail_once_order_draft[$order_id] ?? false) === true) { + unset($this->fail_once_order_draft[$order_id]); + throw new Exception('Simulated draft export failure for order ' . $order_id); + } + + return [ + 'order_id' => $order_id, + 'user_id' => $user_id, + 'mode' => 'draft', + ]; + } + + public function exportOrderInvoice(int $order_id, int $user_id = 0): array + { + return [ + 'order_id' => $order_id, + 'user_id' => $user_id, + 'mode' => 'invoice', + ]; + } + + public function exportCollectedInvoice(int $collected_invoice_id, bool $send_as_is = false, int $user_id = 0): array + { + return [ + 'collected_invoice_id' => $collected_invoice_id, + 'send_as_is' => $send_as_is, + 'user_id' => $user_id, + 'mode' => 'collected', + ]; + } + } +} + +function economic_transfer_queue_integration_db(): db +{ + if (!integration_enabled()) { + test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.'); + } + + $host = getenv('CONFIG_DB_HOST') ?: null; + $user = getenv('CONFIG_DB_USER') ?: null; + $password = getenv('CONFIG_DB_PASSWORD') ?: ''; + $database = getenv('CONFIG_DB_DATABASE') ?: null; + $port = (int)(getenv('CONFIG_DB_PORT') ?: 3306); + if (!$host || !$user || !$database) { + test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.'); + } + + app_require('classes/db.php'); + app_require('classes/economic_transfer_executor.php'); + app_require('classes/economic_transfer_queue_schema_bootstrap.php'); + app_require('classes/economic_transfer_queue.php'); + + $GLOBALS['response'] = new class { + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $db = new db([ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + 'port' => $port, + ]); + try { + $db->connect(); + } catch (Throwable $throwable) { + test()->markTestSkipped('Integration DB unavailable: ' . $throwable->getMessage()); + } + $GLOBALS['db'] = $db; + return $db; +} + +function economic_transfer_queue_cleanup_for_created_by(db $db, int $created_by): void +{ + $db->query("DELETE FROM economic_transfer_queue_jobs WHERE created_by = $created_by"); +} + +it('processes queued transfer jobs to completion', function (): void { + $db = economic_transfer_queue_integration_db(); + $created_by = 920000 + random_int(1000, 9999); + $order_id = 930000 + random_int(1000, 9999); + $queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor()); + + try { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + + $job = $queue->enqueue( + economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT, + [ + 'order_id' => $order_id, + 'requested_by' => $created_by, + ], + $created_by + ); + + expect((int)$job['id'])->toBeGreaterThan(0); + expect((string)$job['status'])->toBe(economic_transfer_queue::STATUS_QUEUED); + + $summary = $queue->processPending(1); + expect((int)$summary['processed'])->toBe(1); + expect((int)$summary['completed'])->toBe(1); + expect((int)$summary['failed'])->toBe(0); + + $processed_job = $queue->getJobById((int)$job['id']); + expect($processed_job)->not->toBeNull(); + expect((string)$processed_job['status'])->toBe(economic_transfer_queue::STATUS_COMPLETED); + expect((int)($processed_job['result']['order_id'] ?? 0))->toBe($order_id); + } finally { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + $db->close(); + } +}); + +it('deduplicates active jobs per transfer target', function (): void { + $db = economic_transfer_queue_integration_db(); + $created_by = 921000 + random_int(1000, 9999); + $collected_invoice_id = 931000 + random_int(1000, 9999); + $queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor()); + + try { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + + $first = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => $collected_invoice_id, + 'send_as_is' => false, + 'requested_by' => $created_by, + ], + $created_by + ); + + $second = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => $collected_invoice_id, + 'send_as_is' => true, + 'requested_by' => $created_by, + ], + $created_by + ); + + expect((int)$first['id'])->toBeGreaterThan(0); + expect((int)$second['id'])->toBe((int)$first['id']); + + $row = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS cnt + FROM economic_transfer_queue_jobs + WHERE created_by = $created_by + AND transfer_type = '" . economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT . "'" + )); + expect((int)($row['cnt'] ?? 0))->toBe(1); + } finally { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + $db->close(); + } +}); + +it('processes only collected-invoice jobs and respects the manual batch limit', function (): void { + $db = economic_transfer_queue_integration_db(); + $created_by = 921500 + random_int(1000, 9999); + $order_id = 932000 + random_int(1000, 9999); + $first_collected_invoice_id = 932500 + random_int(1000, 9999); + $second_collected_invoice_id = 933000 + random_int(1000, 9999); + $queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor()); + + try { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + + $order_job = $queue->enqueue( + economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT, + [ + 'order_id' => $order_id, + 'requested_by' => $created_by, + ], + $created_by + ); + + $first_collected_job = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => $first_collected_invoice_id, + 'send_as_is' => false, + 'requested_by' => $created_by, + ], + $created_by + ); + + $second_collected_job = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => $second_collected_invoice_id, + 'send_as_is' => true, + 'requested_by' => $created_by, + ], + $created_by + ); + + $summary = $queue->processPendingByTransferType( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + 1 + ); + + expect((int)$summary['processed'])->toBe(1); + expect((int)$summary['completed'])->toBe(1); + expect((int)$summary['failed'])->toBe(0); + expect($summary['jobs'])->toHaveCount(1); + expect($summary['jobs'][0])->toBe((int)$first_collected_job['id']); + + $processed_collected_job = $queue->getJobById((int)$first_collected_job['id']); + $queued_collected_job = $queue->getJobById((int)$second_collected_job['id']); + $queued_order_job = $queue->getJobById((int)$order_job['id']); + + expect($processed_collected_job)->not->toBeNull(); + expect((string)$processed_collected_job['status'])->toBe(economic_transfer_queue::STATUS_COMPLETED); + expect((int)($processed_collected_job['result']['collected_invoice_id'] ?? 0))->toBe($first_collected_invoice_id); + + expect($queued_collected_job)->not->toBeNull(); + expect((string)$queued_collected_job['status'])->toBe(economic_transfer_queue::STATUS_QUEUED); + + expect($queued_order_job)->not->toBeNull(); + expect((string)$queued_order_job['status'])->toBe(economic_transfer_queue::STATUS_QUEUED); + } finally { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + $db->close(); + } +}); + +it('rejects invalid payloads without inserting queue rows', function (): void { + $db = economic_transfer_queue_integration_db(); + $created_by = 922000 + random_int(1000, 9999); + $queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor()); + + try { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + + $before = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS cnt + FROM economic_transfer_queue_jobs + WHERE created_by = $created_by" + )); + $before_count = (int)($before['cnt'] ?? 0); + + expect(fn () => $queue->enqueue( + economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT, + [ + 'order_id' => 0, + 'requested_by' => $created_by, + ], + $created_by + ))->toThrow(Exception::class, 'order_id is required and must be a positive number'); + + $after = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS cnt + FROM economic_transfer_queue_jobs + WHERE created_by = $created_by" + )); + expect((int)($after['cnt'] ?? 0))->toBe($before_count); + } finally { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + $db->close(); + } +}); + +it('supports fail retry and reprocess lifecycle transitions', function (): void { + $db = economic_transfer_queue_integration_db(); + $created_by = 923000 + random_int(1000, 9999); + $order_id = 933000 + random_int(1000, 9999); + $executor = new EconomicTransferQueueIntegrationStubExecutor(); + $executor->failNextOrderDraft($order_id); + $queue = new economic_transfer_queue($executor); + + try { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + + $job = $queue->enqueue( + economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT, + [ + 'order_id' => $order_id, + 'requested_by' => $created_by, + ], + $created_by + ); + $job_id = (int)$job['id']; + expect($job_id)->toBeGreaterThan(0); + + $first = $queue->processPending(1); + expect((int)$first['processed'])->toBe(1); + expect((int)$first['failed'])->toBe(1); + + $failed_job = $queue->getJobById($job_id); + expect($failed_job)->not->toBeNull(); + expect((string)$failed_job['status'])->toBe(economic_transfer_queue::STATUS_FAILED); + expect((int)$failed_job['attempts'])->toBe(1); + + $retried = $queue->retryJob($job_id); + expect((string)$retried['status'])->toBe(economic_transfer_queue::STATUS_QUEUED); + + $second = $queue->processPending(1); + expect((int)$second['processed'])->toBe(1); + expect((int)$second['completed'])->toBe(1); + + $completed_job = $queue->getJobById($job_id); + expect($completed_job)->not->toBeNull(); + expect((string)$completed_job['status'])->toBe(economic_transfer_queue::STATUS_COMPLETED); + expect((int)($completed_job['result']['order_id'] ?? 0))->toBe($order_id); + } finally { + economic_transfer_queue_cleanup_for_created_by($db, $created_by); + $db->close(); + } +}); diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicV2BackfillAndDistributionIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicV2BackfillAndDistributionIntegrationTest.php new file mode 100644 index 00000000..7547349e --- /dev/null +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicV2BackfillAndDistributionIntegrationTest.php @@ -0,0 +1,177 @@ +markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.'); + } + + $host = getenv('CONFIG_DB_HOST') ?: null; + $user = getenv('CONFIG_DB_USER') ?: null; + $password = getenv('CONFIG_DB_PASSWORD') ?: ''; + $database = getenv('CONFIG_DB_DATABASE') ?: null; + $port = (int)(getenv('CONFIG_DB_PORT') ?: 3306); + if (!$host || !$user || !$database) { + test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.'); + } + + app_require('classes/db.php'); + app_require('classes/orders_schema_bootstrap.php'); + app_require('classes/economic_v2_schema_bootstrap.php'); + app_require('classes/economic_v2_versioning_service.php'); + app_require('classes/economic_v2_distribution_service.php'); + + if (!defined('redis')) { + define('redis', new class { + public function get(string $key) + { + return null; + } + + public function set(string $key, $value, int $ttl = 0): bool + { + return true; + } + + public function delete(string $key): bool + { + return true; + } + + public function clear_keys(string $pattern): int + { + return 0; + } + + public function __call(string $name, array $arguments) + { + return null; + } + }); + } + + global $ECONOMIC_API; + $ECONOMIC_API = [ + 'app_access_grant' => (string)(getenv('ECONOMIC_API_APP_ACCESS_GRANT') ?: ''), + 'app_access_grant2' => (string)(getenv('ECONOMIC_API_APP_ACCESS_GRANT2') ?: ''), + 'app_secret_token' => (string)(getenv('ECONOMIC_API_APP_SECRET_TOKEN') ?: ''), + ]; + + $GLOBALS['response'] = new class { + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $db = new \classes\db([ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + 'port' => $port, + ]); + $db->connect(); + $GLOBALS['db'] = $db; + return $db; + } +} + +if (!function_exists('economic_v2_distribution_credentials_available')) { + function economic_v2_distribution_credentials_available(): bool + { + $accessGrant = trim((string)(getenv('ECONOMIC_API_APP_ACCESS_GRANT') ?: '')); + $secretToken = trim((string)(getenv('ECONOMIC_API_APP_SECRET_TOKEN') ?: '')); + + return $accessGrant !== '' && $secretToken !== ''; + } +} + +it('runs best-effort backfill repeatedly without introducing duplicate same-start rows', function (): void { + if (getenv('RUN_BACKFILL_INTEGRATION_TESTS') !== '1') { + test()->markTestSkipped('Set RUN_BACKFILL_INTEGRATION_TESTS=1 to run backfill integration test.'); + } + + $db = economic_v2_integration_db(); + try { + $service = new economic_v2_versioning_service(); + $first = $service->runBestEffortBackfill(); + $second = $service->runBestEffortBackfill(); + + expect($first)->toHaveKey('fixed_pricing'); + expect($first)->toHaveKey('vehicle_subscriptions'); + expect($first)->toHaveKey('discount_overrides'); + expect($second)->toHaveKey('fixed_pricing'); + + $dupFixed = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS c + FROM ( + SELECT customer_number, effective_from, COUNT(*) AS cc + FROM customer_fixed_pricing_versions + WHERE source LIKE 'backfill.%' + GROUP BY customer_number, effective_from + HAVING cc > 1 + ) t" + )); + $dupVehicle = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS c + FROM ( + SELECT customer_number, reg, effective_from, COUNT(*) AS cc + FROM customer_vehicle_subscription_versions + WHERE source LIKE 'backfill.%' + GROUP BY customer_number, reg, effective_from + HAVING cc > 1 + ) t" + )); + $dupDiscount = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS c + FROM ( + SELECT user_id, customer_number, is_category, object_id, effective_from, COUNT(*) AS cc + FROM customer_discount_override_versions + WHERE source LIKE 'backfill.%' + GROUP BY user_id, customer_number, is_category, object_id, effective_from + HAVING cc > 1 + ) t" + )); + + expect((int)($dupFixed['c'] ?? 0))->toBe(0); + expect((int)($dupVehicle['c'] ?? 0))->toBe(0); + expect((int)($dupDiscount['c'] ?? 0))->toBe(0); + } finally { + $db->close(); + } +}); + +it('resolves version-aware distribution payload shapes over a real date range', function (): void { + if (getenv('RUN_ECONOMIC_V2_DISTRIBUTION_INTEGRATION_TESTS') !== '1') { + test()->markTestSkipped('Set RUN_ECONOMIC_V2_DISTRIBUTION_INTEGRATION_TESTS=1 to run e-conomic distribution integration test.'); + } + + if (!economic_v2_distribution_credentials_available()) { + test()->markTestSkipped('Missing ECONOMIC_API_APP_ACCESS_GRANT / ECONOMIC_API_APP_SECRET_TOKEN for live e-conomic distribution integration run.'); + } + + $db = economic_v2_integration_db(); + try { + $service = new economic_v2_distribution_service(); + $dateFrom = date('Y-m-01'); + $dateTo = date('Y-m-d'); + + $fixed = $service->getFixedPricingDistribution($dateFrom, $dateTo); + $subscriptions = $service->getWashSubscriptionsDistribution($dateFrom, $dateTo); + $prices = $service->getCustomerPricesDistribution($dateFrom, $dateTo); + + expect($fixed)->toHaveKey('customers'); + expect($fixed)->toHaveKey('collective_results'); + expect($subscriptions)->toHaveKey('customers'); + expect($subscriptions)->toHaveKey('collective_results'); + expect($prices)->toHaveKey('customers'); + expect($prices)->toHaveKey('collective_results'); + } finally { + $db->close(); + } +}); diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicV2VersioningServiceIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicV2VersioningServiceIntegrationTest.php new file mode 100644 index 00000000..2c7c2506 --- /dev/null +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicV2VersioningServiceIntegrationTest.php @@ -0,0 +1,155 @@ +markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.'); + } + + $host = getenv('CONFIG_DB_HOST') ?: null; + $user = getenv('CONFIG_DB_USER') ?: null; + $password = getenv('CONFIG_DB_PASSWORD') ?: ''; + $database = getenv('CONFIG_DB_DATABASE') ?: null; + $port = (int)(getenv('CONFIG_DB_PORT') ?: 3306); + if (!$host || !$user || !$database) { + test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.'); + } + + app_require('classes/db.php'); + app_require('classes/economic_v2_schema_bootstrap.php'); + app_require('classes/economic_v2_versioning_service.php'); + + $GLOBALS['response'] = new class { + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $db = new db([ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + 'port' => $port, + ]); + $db->connect(); + $GLOBALS['db'] = $db; + return $db; +} + +it('creates closes and rotates fixed pricing versions without overlap', function (): void { + $db = economic_v2_versioning_integration_db(); + $service = new economic_v2_versioning_service(); + $customer = 99000000 + random_int(1000, 9999); + + try { + $db->query("DELETE FROM customer_fixed_pricing_versions WHERE customer_number = $customer"); + + $first = $service->recordFixedPricingVersion($customer, 1000, 'Initial', '2026-01-01 00:00:00'); + $second = $service->recordFixedPricingVersion($customer, 1200, 'Updated', '2026-02-01 00:00:00'); + + expect($first['action'])->toBe('inserted'); + expect($second['action'])->toBe('inserted'); + + $rows = $db->fetch_all($db->query( + "SELECT id, effective_from, effective_to, price + FROM customer_fixed_pricing_versions + WHERE customer_number = $customer + ORDER BY effective_from ASC, id ASC" + )); + expect(count($rows))->toBe(2); + expect((string)$rows[0]['effective_to'])->toBe('2026-01-31 23:59:59'); + expect((int)$rows[1]['price'])->toBe(1200); + + $service->closeActiveFixedPricingVersion($customer, '2026-02-15 00:00:00'); + $row = $db->fetch_assoc($db->query( + "SELECT effective_to + FROM customer_fixed_pricing_versions + WHERE customer_number = $customer + ORDER BY effective_from DESC + LIMIT 1" + )); + expect((string)$row['effective_to'])->toBe('2026-02-15 00:00:00'); + } finally { + $db->query("DELETE FROM customer_fixed_pricing_versions WHERE customer_number = $customer"); + $db->close(); + } +}); + +it('tracks vehicle and discount version lifecycles with closure semantics', function (): void { + $db = economic_v2_versioning_integration_db(); + $service = new economic_v2_versioning_service(); + $customer = 99100000 + random_int(1000, 9999); + $userId = 700000 + random_int(1000, 9999); + $reg = 'ZZ' . random_int(1000, 9999); + + try { + $db->query("DELETE FROM customer_vehicle_subscription_versions WHERE customer_number = $customer AND reg = '" . $db->escape_string($reg) . "'"); + $db->query("DELETE FROM customer_discount_override_versions WHERE customer_number = $customer AND user_id = $userId"); + + $service->recordVehicleSubscriptionVersion([ + 'vehicle_id' => null, + 'customer_number' => $customer, + 'reg' => $reg, + 'vehicle_type' => 1, + 'wash_subscription' => true, + ], '2026-01-01 00:00:00'); + $service->recordVehicleSubscriptionVersion([ + 'vehicle_id' => null, + 'customer_number' => $customer, + 'reg' => $reg, + 'vehicle_type' => 33, + 'wash_subscription' => true, + ], '2026-01-10 00:00:00'); + $service->closeActiveVehicleSubscriptionVersion($customer, $reg, '2026-01-20 00:00:00'); + + $vehicleRows = $db->fetch_all($db->query( + "SELECT vehicle_type, effective_from, effective_to + FROM customer_vehicle_subscription_versions + WHERE customer_number = $customer + AND reg = '" . $db->escape_string($reg) . "' + ORDER BY effective_from ASC" + )); + expect(count($vehicleRows))->toBe(2); + expect((string)$vehicleRows[0]['effective_to'])->toBe('2026-01-09 23:59:59'); + expect((string)$vehicleRows[1]['effective_to'])->toBe('2026-01-20 00:00:00'); + + $service->recordDiscountOverrideVersion( + $userId, + $customer, + false, + 33, + 25, + '2026-01-01 00:00:00' + ); + $service->recordDiscountOverrideVersion( + $userId, + $customer, + false, + 33, + 0, + '2026-01-15 00:00:00' + ); + + $discountRows = $db->fetch_all($db->query( + "SELECT discount, effective_from, effective_to + FROM customer_discount_override_versions + WHERE customer_number = $customer + AND user_id = $userId + AND is_category = 0 + AND object_id = '33' + ORDER BY effective_from ASC" + )); + expect(count($discountRows))->toBe(1); + expect((int)$discountRows[0]['discount'])->toBe(25); + expect((string)$discountRows[0]['effective_to'])->toBe('2026-01-15 00:00:00'); + } finally { + $db->query("DELETE FROM customer_vehicle_subscription_versions WHERE customer_number = $customer"); + $db->query("DELETE FROM customer_discount_override_versions WHERE customer_number = $customer AND user_id = $userId"); + $db->close(); + } +}); diff --git a/services/nginx/app/tests/Integration/Search/SystemSearchCacheIntegrationTest.php b/services/nginx/app/tests/Integration/Search/SystemSearchCacheIntegrationTest.php new file mode 100644 index 00000000..aba87b38 --- /dev/null +++ b/services/nginx/app/tests/Integration/Search/SystemSearchCacheIntegrationTest.php @@ -0,0 +1,160 @@ +store[$key] ?? null; + } + + public function set(string $key, string $value): void + { + $this->store[$key] = $value; + } + + public function setEx(string $key, string $value, int $ttl): void + { + $this->store[$key] = $value; + } + + public function delete(string $key): void + { + unset($this->store[$key]); + } + + public function expire(string $key, int $ttl): void + { + // TTL is not simulated in this test adapter. + } + + public function set_array(string $key, array $value): void + { + $this->store[$key] = json_encode($value, JSON_UNESCAPED_UNICODE); + } + + public function get_array(string $key): ?array + { + $value = $this->store[$key] ?? null; + if (!is_string($value)) { + return null; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : null; + } + + 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)) { + unset($this->store[$key]); + } + } + } + } +} + +beforeEach(function (): void { + system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter()); +}); + +afterEach(function (): void { + system_search_cache::setAdapterForTests(null); +}); + +it('reuses parser cache entries for repeated natural-language intent requests', function (): void { + $calls = 0; + $parser = new system_search_openai_intent_parser( + function (array $payload, string $apiKey) use (&$calls): array { + $calls++; + return [ + 'output' => [ + [ + 'content' => [ + [ + 'text' => json_encode([ + 'success' => true, + 'normalized_query' => 'acme unpaid invoices', + 'aliases' => ['acme'], + 'entity_hints' => ['invoices'], + 'confidence' => 0.91, + 'association_hint' => true, + ], JSON_UNESCAPED_UNICODE), + ], + ], + ], + ], + ]; + }, + true, + 'test-key' + ); + + $first = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']); + $second = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']); + + expect($first['source'])->toBe('openai'); + expect($second['source'])->toBe('cache'); + expect($calls)->toBe(1); +}); + +it('clears parser cache namespace via clearAll to force a fresh parse', function (): void { + $calls = 0; + $parser = new system_search_openai_intent_parser( + function (array $payload, string $apiKey) use (&$calls): array { + $calls++; + return [ + 'output' => [ + [ + 'content' => [ + [ + 'text' => json_encode([ + 'success' => true, + 'normalized_query' => 'acme invoices', + 'aliases' => ['acme'], + 'entity_hints' => ['invoices'], + 'confidence' => 0.8, + 'association_hint' => false, + ], JSON_UNESCAPED_UNICODE), + ], + ], + ], + ], + ]; + }, + true, + 'test-key' + ); + + $parser->parse('acme invoices', ['invoices']); + system_search_cache::clearAll(); + $result = $parser->parse('acme invoices', ['invoices']); + + expect($result['source'])->toBe('openai'); + expect($calls)->toBe(2); +}); + +it('bumps per-table cache versions when a dirty-table marker is registered', function (): void { + $hash = md5('test-query'); + system_search_cache::setQuery($hash, ['results' => [], 'grouped_results' => [], 'meta' => []], 120); + expect(system_search_cache::getQuery($hash))->not->toBeNull(); + $before = system_search_cache::tableVersionFingerprint(['orders']); + + system_search_cache::markDirtyTable('orders'); + $dirty = system_search_cache::consumeDirtyTables(); + $after = system_search_cache::tableVersionFingerprint(['orders']); + + expect(system_search_cache::getQuery($hash))->not->toBeNull(); + expect($dirty)->toContain('orders'); + expect($after)->not->toBe($before); +}); diff --git a/services/nginx/app/tests/Integration/SystemStatus/SuperuserSystemStatusInfrastructureProbeTest.php b/services/nginx/app/tests/Integration/SystemStatus/SuperuserSystemStatusInfrastructureProbeTest.php new file mode 100644 index 00000000..37648a4a --- /dev/null +++ b/services/nginx/app/tests/Integration/SystemStatus/SuperuserSystemStatusInfrastructureProbeTest.php @@ -0,0 +1,70 @@ +markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run system status integration tests.'); + } + + $GLOBALS['response'] = new class { + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $GLOBALS['db'] = new db($GLOBALS['CONFIG_DB']); + $GLOBALS['db']->connect(); + + if (!defined('redis') && isset($GLOBALS['REDIS_CONFIG']) && is_array($GLOBALS['REDIS_CONFIG'])) { + try { + define('redis', (new redis())->connect()); + } catch (Throwable) { + // Redis-specific expectations are skipped below when the client is unavailable. + } + } +}); + +afterEach(function (): void { + if (isset($GLOBALS['db']) && $GLOBALS['db'] instanceof db) { + $GLOBALS['db']->close(); + } +}); + +it('can probe the configured database in integration mode', function (): void { + $probe = (new superuser_system_status_service())->probeDatabase(); + + expect($probe['status'])->toBe('ok'); + expect($probe['database'])->not->toBe(''); + expect($probe)->toHaveKey('server_version'); +}); + +it('can probe redis in integration mode when configured', function (): void { + if (!isset($GLOBALS['REDIS_CONFIG']) || !defined('redis')) { + test()->markTestSkipped('Redis is not configured in this integration environment.'); + } + + $probe = (new superuser_system_status_service())->probeRedis(); + + expect(in_array($probe['status'], ['ok', 'degraded', 'down'], true))->toBeTrue(); + expect($probe)->toHaveKey('checked_at'); +}); + +it('can probe minio in integration mode when configured', function (): void { + if (!isset($GLOBALS['MINIO']) || !is_array($GLOBALS['MINIO']) || empty($GLOBALS['MINIO']['endpoint'])) { + test()->markTestSkipped('MinIO is not configured in this integration environment.'); + } + + $probe = (new superuser_system_status_service())->probeMinio(); + + expect(in_array($probe['status'], ['ok', 'degraded', 'down'], true))->toBeTrue(); + expect($probe)->toHaveKey('buckets'); +}); diff --git a/services/nginx/app/tests/Legacy/LegacyPhpScriptsTest.php b/services/nginx/app/tests/Legacy/LegacyPhpScriptsTest.php new file mode 100644 index 00000000..367a6af6 --- /dev/null +++ b/services/nginx/app/tests/Legacy/LegacyPhpScriptsTest.php @@ -0,0 +1,64 @@ + + */ +function legacy_test_manifest(): array +{ + return require app_path('tests/Support/legacy_test_manifest.php'); +} + +/** + * @param array{path:string, classification:string, type:string, bootstrap?:string} $entry + * @return array{exitCode:int, output:string} + */ +function run_legacy_manifest_entry(array $entry): array +{ + $path = app_path($entry['path']); + $php = escapeshellarg(PHP_BINARY); + + if ($entry['type'] === 'phpunit') { + $command = $php + . ' ' . escapeshellarg(app_path('vendor/bin/phpunit')) + . ' --bootstrap ' . escapeshellarg(app_path('tests/Support/legacy_bootstrap.php')) + . ' ' . escapeshellarg($path) + . ' 2>&1'; + } else { + $bootstrap = $entry['bootstrap'] ?? 'app'; + $command = $php + . ' ' . escapeshellarg(app_path('tests/Support/run_legacy_script.php')) + . ' ' . escapeshellarg($entry['path']) + . ' ' . escapeshellarg($bootstrap) + . ' 2>&1'; + } + + $lines = []; + $exitCode = 0; + exec($command, $lines, $exitCode); + + return [ + 'exitCode' => $exitCode, + 'output' => implode(PHP_EOL, $lines), + ]; +} + +foreach (legacy_test_manifest() as $entry) { + it('runs legacy PHP test ' . $entry['path'], function () use ($entry): void { + if ($entry['classification'] === 'manual-external') { + test()->markTestSkipped($entry['reason'] ?? 'Legacy test requires an external dependency.'); + } + + if (getenv('RUN_LEGACY_TESTS') !== '1') { + test()->markTestSkipped('Set RUN_LEGACY_TESTS=1 to run legacy PHP tests.'); + } + + $result = run_legacy_manifest_entry($entry); + + expect($result['exitCode'])->toBe( + 0, + 'Legacy test failed: ' . $entry['path'] . PHP_EOL . $result['output'] + ); + })->group('legacy', $entry['classification']); +} diff --git a/services/nginx/app/tests/Pest.php b/services/nginx/app/tests/Pest.php index 5f46fdc8..3dbefd0d 100644 --- a/services/nginx/app/tests/Pest.php +++ b/services/nginx/app/tests/Pest.php @@ -2,5 +2,9 @@ require_once __DIR__ . '/Support/bootstrap.php'; +use Tests\Support\Api\ApiTestCase; + uses()->group('unit')->in('Unit'); uses()->group('integration')->in('Integration'); +uses(ApiTestCase::class)->group('api')->in('Api'); +uses()->group('legacy')->in('Legacy'); diff --git a/services/nginx/app/tests/Support/Api/ApiCleanup.php b/services/nginx/app/tests/Support/Api/ApiCleanup.php new file mode 100644 index 00000000..643b5b97 --- /dev/null +++ b/services/nginx/app/tests/Support/Api/ApiCleanup.php @@ -0,0 +1,33 @@ + + */ + private array $callbacks = []; + + public function add(Closure $callback): void + { + $this->callbacks[] = $callback; + } + + public function run(): void + { + for ($index = count($this->callbacks) - 1; $index >= 0; $index--) { + try { + ($this->callbacks[$index])(); + } catch (Throwable) { + } + } + + $this->callbacks = []; + } +} diff --git a/services/nginx/app/tests/Support/Api/ApiClient.php b/services/nginx/app/tests/Support/Api/ApiClient.php new file mode 100644 index 00000000..b2869985 --- /dev/null +++ b/services/nginx/app/tests/Support/Api/ApiClient.php @@ -0,0 +1,101 @@ +request('GET', $path, null, $headers); + } + + public function post(string $path, ?array $payload = null, array $headers = []): ApiResponse + { + return $this->request('POST', $path, $payload, $headers); + } + + public function put(string $path, ?array $payload = null, array $headers = []): ApiResponse + { + return $this->request('PUT', $path, $payload, $headers); + } + + public function delete(string $path, ?array $payload = null, array $headers = []): ApiResponse + { + return $this->request('DELETE', $path, $payload, $headers); + } + + public function request(string $method, string $path, ?array $payload = null, array $headers = []): ApiResponse + { + $curl = curl_init(); + if ($curl === false) { + throw new RuntimeException('Unable to initialize cURL for API tests.'); + } + + $timeoutSeconds = (int)(getenv('API_TEST_REQUEST_TIMEOUT') ?: self::DEFAULT_TIMEOUT_SECONDS); + if ($timeoutSeconds <= 0) { + $timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS; + } + + $responseHeaders = []; + $normalizedHeaders = []; + foreach ($headers as $name => $value) { + $normalizedHeaders[] = $name . ': ' . $value; + } + + if ($payload !== null) { + $normalizedHeaders[] = 'Content-Type: application/json'; + } + + curl_setopt_array($curl, [ + CURLOPT_URL => rtrim($this->baseUrl, '/') . $path, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_HTTPHEADER => $normalizedHeaders, + CURLOPT_CONNECTTIMEOUT => $timeoutSeconds, + CURLOPT_TIMEOUT => $timeoutSeconds, + CURLOPT_HEADERFUNCTION => static function ($curlHandle, string $headerLine) use (&$responseHeaders): int { + $length = strlen($headerLine); + $parts = explode(':', $headerLine, 2); + if (count($parts) === 2) { + $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); + } + + return $length; + }, + ]); + + if ($payload !== null) { + $encodedPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($encodedPayload)) { + throw new RuntimeException('Unable to encode the API request payload.'); + } + + curl_setopt($curl, CURLOPT_POSTFIELDS, $encodedPayload); + } + + $body = curl_exec($curl); + if ($body === false) { + $error = curl_error($curl); + curl_close($curl); + throw new RuntimeException('API request failed: ' . $error); + } + + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + $decoded = json_decode($body, true); + + return new ApiResponse($status, $responseHeaders, $decoded, (string)$body); + } +} diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php new file mode 100644 index 00000000..537b064a --- /dev/null +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -0,0 +1,2118 @@ + + */ + private array $tableExistsCache = []; + + /** + * @var array + */ + private array $tableColumnExistsCache = []; + + public function __construct( + private readonly mysqli $db, + private readonly ?PredisClient $redis, + private readonly ApiCleanup $cleanup, + ) { + } + + /** + * @param array $attributes + * @param array $permissions + * @return array + */ + public function createUser(array $attributes = [], array $permissions = []): array + { + $groupId = $attributes['group_id'] ?? null; + if ($groupId === null && $permissions !== []) { + $group = $this->createGroup([], $permissions); + $groupId = $group['id']; + } + + $customerNumber = (int)($attributes['customer_number'] ?? $this->uniqueCustomerNumber()); + $displayName = (string)($attributes['display_name'] ?? ('API User ' . $customerNumber)); + $email = (string)($attributes['email'] ?? ('api+' . $customerNumber . '@example.test')); + $passwordPlaintext = (string)($attributes['password_plaintext'] ?? 'Secret123!'); + $now = $this->now(); + + $userId = $this->insertRow('users', [ + 'customer_number' => $customerNumber, + 'display_name' => $displayName, + 'email' => $email, + 'phone_country_code' => 45, + 'phone' => (int)substr((string)$customerNumber, -8), + 'password' => password_hash($passwordPlaintext, PASSWORD_DEFAULT), + 'group_id' => (int)($groupId ?? 0), + 'two_factor_enabled' => (int)($attributes['two_factor_enabled'] ?? 0), + 'two_factor_secret' => $attributes['two_factor_secret'] ?? null, + 'created_at' => $attributes['created_at'] ?? $now, + 'updated_at' => $attributes['updated_at'] ?? $now, + ]); + + $this->deleteRedisPattern('perm:user:' . $userId . ':*'); + $this->deleteRedisPattern('obj_prop:users:' . $userId . ':*'); + $this->cleanup->add(function () use ($userId, $customerNumber): void { + $this->purgeCustomerTraceData($userId, $customerNumber); + $this->deleteRedisKey('user_id_from_customer_number_' . $customerNumber); + $this->deleteRedisKey('customer_number_from_user_id_' . $userId); + $this->deleteRedisKey('users_' . $customerNumber . '_economic_customer_name'); + $this->deleteRedisKey('`users`_' . $customerNumber . '_economic_customer_name'); + $this->deleteRedisKey('users_' . $userId . '_economic_customer'); + $this->deleteRedisKey('`users`_' . $userId . '_economic_customer'); + $this->deleteRedisPattern('perm:user:' . $userId . ':*'); + $this->deleteRedisPattern('obj_prop:users:' . $userId . ':*'); + }); + + $economicName = (string)($attributes['economic_customer_name'] ?? $displayName); + $this->seedCustomerNameCache($customerNumber, $economicName); + $this->seedEconomicCustomerCache($userId, $customerNumber, $economicName, $email); + + return [ + 'id' => $userId, + 'group_id' => (int)($groupId ?? 0), + 'customer_number' => $customerNumber, + 'display_name' => $displayName, + 'email' => $email, + 'password_plaintext' => $passwordPlaintext, + ]; + } + + /** + * @param array $attributes + * @param array $permissions + * @return array + */ + public function createGroup(array $attributes = [], array $permissions = []): array + { + $groupId = $this->insertRow('groups', [ + 'name' => (string)($attributes['name'] ?? ('API Group ' . $this->uniqueSuffix())), + 'description' => (string)($attributes['description'] ?? 'API test group'), + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + ]); + + foreach ($permissions as $permission) { + $permissionId = $this->insertRow('groups_permissions', [ + 'group_id' => $groupId, + 'permission' => $permission, + 'created_at' => $this->now(), + ]); + $this->cleanup->add(fn() => $this->deleteById('groups_permissions', $permissionId)); + } + + $this->cleanup->add(fn() => $this->deleteById('groups', $groupId)); + + return ['id' => $groupId]; + } + + /** + * @param array $attributes + * @return array + */ + public function createDepartment(array $attributes = []): array + { + $departmentId = $this->insertRow('departments', [ + 'name' => (string)($attributes['name'] ?? ('API Department ' . $this->uniqueSuffix())), + 'description' => (string)($attributes['description'] ?? 'API department'), + 'economic_department_id' => (int)($attributes['economic_department_id'] ?? 0), + 'slack_webhook' => $attributes['slack_webhook'] ?? null, + 'dimension' => (int)($attributes['dimension'] ?? 0), + 'branding' => (int)($attributes['branding'] ?? 0), + 'visible' => (int)($attributes['visible'] ?? 1), + 'archived' => (int)($attributes['archived'] ?? 0), + 'latitude' => $attributes['latitude'] ?? 0.0, + 'longitude' => $attributes['longitude'] ?? 0.0, + 'order_priority' => (int)($attributes['order_priority'] ?? 0), + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + ]); + + $this->cleanup->add(fn() => $this->deleteById('departments', $departmentId)); + $this->cleanup->add(fn() => $this->deleteRedisPattern('department_*')); + + return ['id' => $departmentId]; + } + + /** + * @param array $attributes + * @return array + */ + public function createBranding(array $attributes = []): array + { + $brandingId = $this->insertRow('branding', [ + 'name' => (string)($attributes['name'] ?? ('API Brand ' . $this->uniqueSuffix())), + 'description' => (string)($attributes['description'] ?? 'API branding'), + 'cvr' => (int)($attributes['cvr'] ?? 41004355), + 'address' => $attributes['address'] ?? null, + 'phone_country_code' => $attributes['phone_country_code'] ?? null, + 'phone' => $attributes['phone'] ?? null, + 'email' => $attributes['email'] ?? null, + 'website' => $attributes['website'] ?? null, + 'banner' => $attributes['banner'] ?? null, + 'logo' => $attributes['logo'] ?? null, + 'favicon' => $attributes['favicon'] ?? null, + 'signature' => $attributes['signature'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('branding', $brandingId)); + + return array_merge(['id' => $brandingId], $this->fetchRowById('branding', $brandingId) ?? []); + } + + /** + * @param array $attributes + * @return array + */ + public function createDepartmentGate(array $attributes): array + { + $departmentId = (int)($attributes['department'] ?? $attributes['department_id'] ?? 0); + if ($departmentId <= 0) { + throw new RuntimeException('Department gates require a department id.'); + } + + $gateId = $this->insertRow('department_gates', [ + 'department' => $departmentId, + 'is_entrance' => (bool)($attributes['is_entrance'] ?? false), + 'is_exit' => (bool)($attributes['is_exit'] ?? false), + 'name' => (string)($attributes['name'] ?? ('API Gate ' . $this->uniqueSuffix())), + 'config' => $attributes['config'] ?? [ + 'type' => 'PHONE_CALL', + 'phone_number' => '+4511122233', + 'call_duration_threshold' => 5, + ], + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('department_gates', $gateId)); + + return ['id' => $gateId, 'department' => $departmentId]; + } + + /** + * @param array $overrides + * @return array + */ + public function createSelfServeScenario(array $overrides = []): array + { + foreach ([ + 'department_lanes', + 'department_selfserve_conditions', + 'department_selfserve_condition_rules', + 'department_selfserve_questions', + 'department_selfserve_tasks', + 'department_selfserve_vehicle_conditions', + 'selfserve_machine_types', + 'selfserve_wash_sessions', + 'selfserve_wash_session_answers', + 'selfserve_wash_session_tasks', + 'selfserve_wash_session_events', + ] as $table) { + if (!$this->tableExists($table)) { + throw new RuntimeException('Self-serve API fixtures require table ' . $table . '.'); + } + } + + $suffix = strtolower($this->uniqueSuffix()); + $now = $this->now(); + $relayIds = array_merge([ + 'entry' => 'demo-selfserve-' . $suffix . '-entry', + 'exit' => 'demo-selfserve-' . $suffix . '-exit', + 'machine' => 'demo-selfserve-' . $suffix . '-machine', + 'program_picker' => 'demo-selfserve-' . $suffix . '-program-picker', + 'cleaner' => 'demo-selfserve-' . $suffix . '-cleaner', + ], is_array($overrides['relay_ids'] ?? null) ? $overrides['relay_ids'] : []); + + $department = $this->createDepartment(array_merge([ + 'name' => 'API Self-Serve Department ' . strtoupper($suffix), + 'description' => 'API self-serve fixture department', + 'visible' => 1, + 'latitude' => 55.6415, + 'longitude' => 12.0803, + ], is_array($overrides['department'] ?? null) ? $overrides['department'] : [])); + + $category = $this->createCategory([ + 'name' => 'API Self-Serve Category ' . strtoupper($suffix), + 'description' => 'API self-serve fixture category', + ]); + $this->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + + $productData = array_merge([ + 'name' => 'API Self-Serve Wash ' . strtoupper($suffix), + 'description' => 'Comprehensive self-serve fixture wash', + 'price' => 100, + 'subscription_allowed' => 1, + 'category' => (int)$category['id'], + 'piktogram' => 'truck', + 'economic_product_id' => 0, + 'apply_category_discount' => 0, + 'requires_note' => 0, + 'is_wash' => 1, + 'display_in_booking_form' => 1, + 'order_priority' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], is_array($overrides['product'] ?? null) ? $overrides['product'] : []); + $productId = $this->insertRowWithExistingColumns('products', $productData); + $this->cleanup->add(fn() => $this->deleteById('products', $productId)); + + $machineTypeId = $this->insertRowWithExistingColumns('selfserve_machine_types', [ + 'name' => 'Portal Machine ' . strtoupper($suffix), + 'description' => 'Fixture machine type with shared task configuration', + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('selfserve_machine_types', $machineTypeId)); + + $laneData = array_merge([ + 'department' => (int)$department['id'], + 'name' => 'Demo Lane ' . strtoupper($suffix), + 'relay_in_id' => $relayIds['entry'], + 'relay_out_id' => $relayIds['exit'], + 'relay_machine_id' => $relayIds['machine'], + 'relay_machine_program_picker_id' => $relayIds['program_picker'], + 'relay_machine_cleaner_id' => $relayIds['cleaner'], + 'dynamic_image_id' => 7000 + self::$sequence, + 'machine_type_id' => $machineTypeId, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ], is_array($overrides['lane'] ?? null) ? $overrides['lane'] : []); + $laneId = $this->insertRowWithExistingColumns('department_lanes', $laneData); + $this->cleanup->add(fn() => $this->deleteById('department_lanes', $laneId)); + + $customer = $this->createUser(array_merge([ + 'display_name' => 'API Self-Serve Customer ' . strtoupper($suffix), + 'economic_customer_name' => 'API Self-Serve Customer ' . strtoupper($suffix), + ], is_array($overrides['customer'] ?? null) ? $overrides['customer'] : [])); + $vehicle = $this->createVehicle(array_merge([ + 'customer_id' => (int)$customer['customer_number'], + 'type' => $productId, + 'reg' => 'TW' . strtoupper(substr($suffix, -4)) . '42', + 'wash_subscription' => 1, + 'reference' => 'fixture-vehicle-' . $suffix, + ], is_array($overrides['vehicle'] ?? null) ? $overrides['vehicle'] : [])); + + $conditionId = $this->insertRowWithExistingColumns('department_selfserve_conditions', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'product' => $productId, + 'machine_type_id' => $machineTypeId, + 'condition_id' => null, + 'name' => 'Vehicle preparation complete', + 'description' => 'The driver has completed the required pre-wash checks.', + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_conditions', $conditionId)); + + $questionId = $this->insertRowWithExistingColumns('department_selfserve_questions', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'product' => $productId, + 'condition_id' => null, + 'question' => 'Is the tarp removed?', + 'description' => 'Required before the machine relay can be enabled.', + 'order_priority' => 1, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_questions', $questionId)); + + $ruleId = $this->insertRowWithExistingColumns('department_selfserve_condition_rules', [ + 'condition_id' => $conditionId, + 'type' => 'IS_TRUE', + 'object_type' => 'question', + 'object_id' => $questionId, + 'name' => 'Tarp removed', + 'description' => 'Driver confirmed tarp removal.', + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_condition_rules', $ruleId)); + + $prepareTaskId = $this->insertRowWithExistingColumns('department_selfserve_tasks', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'product' => $productId, + 'machine_type_id' => $machineTypeId, + 'condition_id' => $questionId, + 'gate_type' => 'QUESTION', + 'gate_ref_id' => $questionId, + 'task' => 'Prepare the vehicle', + 'description' => 'Remove loose equipment before starting the machine.', + 'order_priority' => 1, + 'services' => [], + 'buttons' => [1], + 'dynamic_images_vehicle_type' => $productId, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_tasks', $prepareTaskId)); + + $machineTaskId = $this->insertRowWithExistingColumns('department_selfserve_tasks', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'product' => $productId, + 'machine_type_id' => $machineTypeId, + 'condition_id' => null, + 'gate_type' => 'CONDITION', + 'gate_ref_id' => $conditionId, + 'task' => 'Machine wash access', + 'description' => 'Enables the machine relay after the pre-wash checks pass.', + 'order_priority' => 2, + 'services' => ['MACHINE'], + 'buttons' => [2, 3], + 'dynamic_images_vehicle_type' => $productId, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_tasks', $machineTaskId)); + + $vehicleConditionId = $this->insertRowWithExistingColumns('department_selfserve_vehicle_conditions', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'customer_id' => (int)$customer['customer_number'], + 'reg' => (string)$vehicle['reg'], + 'question' => $questionId, + 'value' => 1, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_vehicle_conditions', $vehicleConditionId)); + + $sessionData = array_merge([ + 'lane_id' => $laneId, + 'department_id' => (int)$department['id'], + 'machine_type_id' => $machineTypeId, + 'customer_number' => (int)$customer['customer_number'], + 'vehicle_id' => (int)$vehicle['id'], + 'vehicle_type_id' => $productId, + 'reg' => (string)$vehicle['reg'], + 'status' => 'MACHINE_STARTED', + 'allowed' => 1, + 'machine_relay_enabled' => 1, + 'machine_relay_enabled_at' => $now, + 'machine_start_triggered' => 1, + 'machine_start_triggered_at' => $now, + 'wash_started_at' => $now, + 'order_id' => null, + 'completed_at' => null, + 'metadata_json' => [ + 'fixture' => 'selfserve', + 'relay_ids' => $relayIds, + 'evaluation_trace' => [ + ['task_id' => $machineTaskId, 'satisfied' => true], + ], + ], + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ], is_array($overrides['session'] ?? null) ? $overrides['session'] : []); + $sessionId = $this->insertRowWithExistingColumns('selfserve_wash_sessions', $sessionData); + $this->cleanup->add(fn() => $this->deleteById('selfserve_wash_sessions', $sessionId)); + + $answerId = $this->insertRowWithExistingColumns('selfserve_wash_session_answers', [ + 'session_id' => $sessionId, + 'question_id' => $questionId, + 'question_text' => 'Is the tarp removed?', + 'answer_value' => 1, + 'answered_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('selfserve_wash_session_answers', $answerId)); + + $sessionTaskIds = []; + foreach ([ + [ + 'task_id' => $prepareTaskId, + 'task_text' => 'Prepare the vehicle', + 'description' => 'Remove loose equipment before starting the machine.', + 'services' => [], + 'buttons' => [1], + ], + [ + 'task_id' => $machineTaskId, + 'task_text' => 'Machine wash access', + 'description' => 'Enables the machine relay after the pre-wash checks pass.', + 'services' => ['MACHINE'], + 'buttons' => [2, 3], + ], + ] as $taskSnapshot) { + $sessionTaskId = $this->insertRowWithExistingColumns('selfserve_wash_session_tasks', [ + 'session_id' => $sessionId, + 'task_id' => $taskSnapshot['task_id'], + 'task_text' => $taskSnapshot['task_text'], + 'description' => $taskSnapshot['description'], + 'services' => $taskSnapshot['services'], + 'buttons' => $taskSnapshot['buttons'], + 'dynamic_image_id' => null, + 'dynamic_images_vehicle_type' => $productId, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $sessionTaskIds[] = $sessionTaskId; + $this->cleanup->add(fn() => $this->deleteById('selfserve_wash_session_tasks', $sessionTaskId)); + } + + $eventIds = []; + foreach ([ + ['SESSION_SYNCED', ['allowed' => true, 'source' => 'fixture']], + ['MACHINE_RELAY_ENABLED', ['relay_id' => $relayIds['machine']]], + ['MACHINE_START_TRIGGERED', ['lane_id' => $laneId]], + ] as [$eventType, $payload]) { + $eventId = $this->insertRowWithExistingColumns('selfserve_wash_session_events', [ + 'session_id' => $sessionId, + 'event_type' => $eventType, + 'payload_json' => $payload, + 'created_at' => $now, + ]); + $eventIds[] = $eventId; + $this->cleanup->add(fn() => $this->deleteById('selfserve_wash_session_events', $eventId)); + } + + $this->setModuleConfig('selfserve', 'enabled', 'true'); + + return [ + 'department' => $department, + 'category' => $category, + 'product' => ['id' => $productId] + $productData, + 'machine_type' => ['id' => $machineTypeId], + 'lane' => ['id' => $laneId] + $laneData, + 'customer' => $customer, + 'vehicle' => $vehicle, + 'condition' => ['id' => $conditionId], + 'question' => ['id' => $questionId], + 'rule' => ['id' => $ruleId], + 'tasks' => [ + ['id' => $prepareTaskId], + ['id' => $machineTaskId], + ], + 'vehicle_condition' => ['id' => $vehicleConditionId], + 'session' => ['id' => $sessionId] + $sessionData, + 'answer' => ['id' => $answerId], + 'session_tasks' => array_map(static fn(int $id): array => ['id' => $id], $sessionTaskIds), + 'events' => array_map(static fn(int $id): array => ['id' => $id], $eventIds), + 'relay_ids' => $relayIds, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createCategory(array $attributes = []): array + { + $categoryId = $this->insertRow('categories', [ + 'name' => (string)($attributes['name'] ?? ('API Category ' . $this->uniqueSuffix())), + 'description' => (string)($attributes['description'] ?? 'API category'), + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + ]); + + $this->cleanup->add(fn() => $this->deleteById('categories', $categoryId)); + + return ['id' => $categoryId]; + } + + /** + * @param array $attributes + * @return array + */ + public function createProduct(array $attributes = []): array + { + $categoryId = (int)($attributes['category'] ?? 0); + if ($categoryId <= 0) { + $category = $this->createCategory(); + $categoryId = (int)$category['id']; + } + + $productData = [ + 'name' => (string)($attributes['name'] ?? ('API Product ' . $this->uniqueSuffix())), + 'description' => (string)($attributes['description'] ?? 'API product'), + 'price' => (int)($attributes['price'] ?? 100), + 'subscription_allowed' => (int)($attributes['subscription_allowed'] ?? 1), + 'category' => $categoryId, + 'piktogram' => $attributes['piktogram'] ?? 'truck', + 'economic_product_id' => $attributes['economic_product_id'] ?? 0, + 'apply_category_discount' => (int)($attributes['apply_category_discount'] ?? 0), + 'requires_note' => (int)($attributes['requires_note'] ?? 0), + 'is_wash' => (int)($attributes['is_wash'] ?? 0), + 'display_in_booking_form' => (int)($attributes['display_in_booking_form'] ?? 1), + 'order_priority' => (int)($attributes['order_priority'] ?? 0), + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]; + if (isset($attributes['id'])) { + $productData = ['id' => (int)$attributes['id']] + $productData; + } + + $productId = $this->insertRowWithExistingColumns('products', $productData); + + $this->deleteRedisPattern('obj_prop:products:' . $productId . ':*'); + $this->cleanup->add(fn() => $this->deleteById('products', $productId)); + $this->cleanup->add(fn() => $this->deleteRedisPattern('obj_prop:products:' . $productId . ':*')); + + return array_merge(['id' => $productId, 'category' => $categoryId], $this->fetchRowById('products', $productId) ?? []); + } + + public function linkDepartmentCategory(int $departmentId, int $categoryId): int + { + $linkId = $this->insertRow('department_categories', [ + 'department_id' => $departmentId, + 'category_id' => $categoryId, + 'created_at' => $this->now(), + 'updated_at' => $this->now(), + 'deleted_at' => null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('department_categories', $linkId)); + + return $linkId; + } + + /** + * @param array $attributes + * @return array + */ + public function createInvoiceCollection(array $attributes): array + { + $customerNumber = (int)($attributes['customer_number'] ?? 0); + if ($customerNumber <= 0) { + throw new RuntimeException('Invoice collections require a customer_number.'); + } + + $invoiceId = $this->insertRow('collected_order_invoices', [ + 'customer_number' => $customerNumber, + 'name' => (string)($attributes['name'] ?? ('API Invoice ' . $customerNumber)), + 'notes' => $attributes['notes'] ?? '', + 'processor' => (int)($attributes['processor'] ?? 0), + 'external_id' => $attributes['external_id'] ?? null, + 'booked_invoice_id' => $attributes['booked_invoice_id'] ?? null, + 'po_number' => $attributes['po_number'] ?? null, + 'error_message' => $attributes['error_message'] ?? null, + 'closed_at' => $attributes['closed_at'] ?? null, + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + ]); + + $this->cleanup->add(fn() => $this->deleteById('collected_order_invoices', $invoiceId)); + + return [ + 'id' => $invoiceId, + 'customer_number' => $customerNumber, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createOrder(array $attributes): array + { + $customerId = (int)($attributes['customer_id'] ?? 0); + $departmentId = (int)($attributes['department_id'] ?? 0); + if ($customerId <= 0 || $departmentId <= 0) { + throw new RuntimeException('Orders require customer_id and department_id.'); + } + + $invoiceCollectionId = (int)($attributes['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId <= 0) { + $invoiceCollection = $this->createInvoiceCollection([ + 'customer_number' => $customerId, + ]); + $invoiceCollectionId = (int)$invoiceCollection['id']; + } + + $orderId = $this->insertRow('orders', [ + 'customer_id' => $customerId, + 'cashier_id' => (int)($attributes['cashier_id'] ?? 1), + 'department_id' => $departmentId, + 'reference' => (string)($attributes['reference'] ?? 'API-REF'), + 'notes' => (string)($attributes['notes'] ?? 'API order'), + 'reg_1' => (string)($attributes['reg_1'] ?? 'ABCD123'), + 'reg_2' => (string)($attributes['reg_2'] ?? ''), + 'reg_3' => (string)($attributes['reg_3'] ?? ''), + 'invoice_collection_id' => $invoiceCollectionId, + 'booking_id' => $attributes['booking_id'] ?? null, + 'wash_id' => $attributes['wash_id'] ?? null, + 'lane' => $attributes['lane'] ?? null, + 'po' => $attributes['po'] ?? null, + 'safety_seal' => $attributes['safety_seal'] ?? null, + 'using_hand_held' => (int)($attributes['using_hand_held'] ?? 0), + 'include_in_invoice' => $attributes['include_in_invoice'] ?? 1, + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'completed_at' => $attributes['completed_at'] ?? null, + 'deleted_at' => null, + ]); + + $this->cleanup->add(function () use ($orderId): void { + $this->deleteWhere('order_items', ['order_id' => $orderId]); + $this->deleteById('orders', $orderId); + $this->deleteRedisKey('orders_' . $orderId . '_asArray'); + $this->deleteRedisKey('orders_' . $orderId . '_pending_handheld_cache_indicator'); + }); + + return [ + 'id' => $orderId, + 'invoice_collection_id' => $invoiceCollectionId, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createOrderBooking(array $attributes): array + { + $customerNumber = (int)($attributes['customer_number'] ?? 0); + $departmentId = (int)($attributes['department'] ?? $attributes['department_id'] ?? 0); + if ($customerNumber <= 0 || $departmentId <= 0) { + throw new RuntimeException('Order bookings require customer_number and department.'); + } + + $bookingId = $this->insertRow('order_bookings', [ + 'customer_number' => $customerNumber, + 'department' => $departmentId, + 'reg_1' => (string)($attributes['reg_1'] ?? 'BOOK123'), + 'reg_2' => (string)($attributes['reg_2'] ?? ''), + 'reg_3' => (string)($attributes['reg_3'] ?? ''), + 'datetime' => $attributes['datetime'] ?? $this->now(), + 'note' => (string)($attributes['note'] ?? ''), + 'reference' => (string)($attributes['reference'] ?? 'API-BOOKING'), + 'po' => (string)($attributes['po'] ?? ''), + 'pickup' => (int)($attributes['pickup'] ?? 0), + 'items' => $attributes['items'] ?? [], + 'order_id' => $attributes['order_id'] ?? null, + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(function () use ($bookingId): void { + $this->deleteById('order_bookings', $bookingId); + $this->deleteRedisKey('order_bookings_' . $bookingId . '_asArray'); + $this->deleteRedisPattern('order_bookings:*'); + }); + + return [ + 'id' => $bookingId, + 'customer_number' => $customerNumber, + 'department' => $departmentId, + 'order_id' => $attributes['order_id'] ?? null, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createVehicle(array $attributes): array + { + $customerId = (int)($attributes['customer_id'] ?? 0); + $type = (int)($attributes['type'] ?? 0); + $reg = trim((string)($attributes['reg'] ?? '')); + if ($customerId <= 0 || $type <= 0 || $reg === '') { + throw new RuntimeException('Vehicles require customer_id, type, and reg.'); + } + + $vehicleId = $this->insertRow('customer_vehicles', [ + 'customer_id' => $customerId, + 'type' => $type, + 'reg' => $reg, + 'wash_subscription' => (int)($attributes['wash_subscription'] ?? 0), + 'notes' => $attributes['notes'] ?? null, + 'reference' => $attributes['reference'] ?? null, + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + ]); + + $this->cleanup->add(fn() => $this->deleteById('customer_vehicles', $vehicleId)); + + return [ + 'id' => $vehicleId, + 'customer_id' => $customerId, + 'type' => $type, + 'reg' => $reg, + 'wash_subscription' => (bool)($attributes['wash_subscription'] ?? false), + 'reference' => $attributes['reference'] ?? null, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createOrderItem(array $attributes): array + { + $orderId = (int)($attributes['order_id'] ?? 0); + $productId = (int)($attributes['product_id'] ?? 0); + $cashierId = (int)($attributes['cashier_id'] ?? 0); + if ($orderId <= 0 || $productId <= 0 || $cashierId <= 0) { + throw new RuntimeException('Order items require order_id, product_id, and cashier_id.'); + } + + $orderItemId = $this->insertRow('order_items', [ + 'order_id' => $orderId, + 'product_id' => $productId, + 'reference' => (string)($attributes['reference'] ?? ''), + 'notes' => $attributes['notes'] ?? null, + 'cashier_id' => $cashierId, + 'price' => (int)($attributes['price'] ?? 0), + 'quantity' => (int)($attributes['quantity'] ?? 1), + 'related_item_id' => $attributes['related_item_id'] ?? null, + 'include_in_invoice' => (int)($attributes['include_in_invoice'] ?? 1), + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('order_items', $orderItemId)); + + return [ + 'id' => $orderItemId, + 'order_id' => $orderId, + 'product_id' => $productId, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createOrderAttachment(array $attributes): array + { + $orderId = (int)($attributes['order_id'] ?? 0); + if ($orderId <= 0) { + throw new RuntimeException('Order attachments require order_id.'); + } + + $attachmentId = $this->insertRow('object_attachments', [ + 'object_type' => 'orders', + 'object_id' => $orderId, + 'content' => $attributes['content'] ?? '{"document":"api-test.pdf","other":"api-test.pdf"}', + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('object_attachments', $attachmentId)); + + return [ + 'id' => $attachmentId, + 'order_id' => $orderId, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createSubuser(array $attributes = []): array + { + $username = (string)($attributes['username'] ?? ('api-subuser-' . strtolower($this->uniqueSuffix()))); + $passwordPlaintext = (string)($attributes['password_plaintext'] ?? 'Secret123!'); + $name = (string)($attributes['name'] ?? 'API Subuser'); + $email = (string)($attributes['email'] ?? ($username . '@example.test')); + $now = $this->now(); + + $subuserId = $this->insertRow('subusers', [ + 'username' => $username, + 'password' => password_hash($passwordPlaintext, PASSWORD_DEFAULT), + 'name' => $name, + 'email' => $email, + 'phone_country_code' => 45, + 'phone' => 10000000 + (++self::$sequence), + 'two_factor_enabled' => 0, + 'two_factor_secret' => null, + 'created_at' => $attributes['created_at'] ?? $now, + 'updated_at' => $attributes['updated_at'] ?? $now, + 'suspended_at' => $attributes['suspended_at'] ?? null, + ]); + + $this->cleanup->add(function () use ($subuserId): void { + $this->deleteWhere('subuser_grants', ['subuser' => $subuserId]); + $this->deleteWhere('tokens', ['user_id' => $subuserId, 'type' => 'AUTH_TOKEN_SUBUSER']); + $this->deleteById('subusers', $subuserId); + $this->deleteRedisPattern('session_token:*'); + }); + + return [ + 'id' => $subuserId, + 'username' => $username, + 'password_plaintext' => $passwordPlaintext, + ]; + } + + public function grantSubuser(int $subuserId, int $customerNumber, array $permissions): int + { + $grantId = $this->insertRow('subuser_grants', [ + 'billing_customer_number' => $customerNumber, + 'subuser' => $subuserId, + 'enabled' => 1, + 'note' => 'API test grant', + 'permissions' => json_encode(array_values($permissions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'created_at' => $this->now(), + 'updated_at' => $this->now(), + 'deleted_at' => null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('subuser_grants', $grantId)); + + return $grantId; + } + + /** + * @return array{user:array,token:string,headers:array} + */ + public function createUserSession(array $permissions = [], array $userAttributes = []): array + { + if ($permissions === [] && !array_key_exists('group_id', $userAttributes)) { + $group = $this->createGroup(); + $userAttributes['group_id'] = $group['id']; + } + + $user = $this->createUser($userAttributes, $permissions); + $token = $this->createAuthToken((int)$user['id']); + + return [ + 'user' => $user, + 'token' => $token, + 'headers' => $this->bearerHeaders($token), + ]; + } + + /** + * @param array $permissions + * @param array $userAttributes + * @return array{user:array,token:string,headers:array} + */ + public function createEdgeOperatorSession(int $departmentId, array $permissions = [], array $userAttributes = []): array + { + if ($departmentId <= 0) { + throw new RuntimeException('Edge operator sessions require a positive department id.'); + } + + $permissions = array_values(array_unique(array_merge( + ['modules_shelly_config', 'department_access_' . $departmentId], + $permissions + ))); + + return $this->createUserSession($permissions, $userAttributes); + } + + /** + * @param array $permissions + * @return array{user:array,subuser:array,token:string,headers:array} + */ + public function createSubuserSession(int $customerNumber, array $permissions, array $subuserAttributes = []): array + { + $subuser = $this->createSubuser($subuserAttributes); + $this->grantSubuser((int)$subuser['id'], $customerNumber, $permissions); + $token = $this->createAuthToken((int)$subuser['id'], 'AUTH_TOKEN_SUBUSER'); + + return [ + 'user' => [ + 'customer_number' => $customerNumber, + ], + 'subuser' => $subuser, + 'token' => $token, + 'headers' => $this->bearerHeaders($token, [ + 'X-Customer-Number' => (string)$customerNumber, + ]), + ]; + } + + public function createAuthToken(int $userId, string $type = 'AUTH_TOKEN', ?string $token = null): string + { + $token = $token ?: bin2hex(random_bytes(32)); + $tokenId = $this->insertRow('tokens', [ + 'user_id' => $userId, + 'type' => $type, + 'description' => 'API test token', + 'token' => $token, + 'created_at' => $this->now(), + ]); + + $this->cleanup->add(function () use ($tokenId, $token): void { + $this->deleteById('tokens', $tokenId); + $this->deleteRedisKey('token_' . $token); + $this->deleteRedisKey('auth_session_' . $token); + }); + + return $token; + } + + public function addCustomerAttribute(int $userId, string $attribute): int + { + $attributeId = $this->insertRow('customer_attributes', [ + 'user_id' => $userId, + 'attribute' => $attribute, + ]); + + $this->cleanup->add(fn() => $this->deleteById('customer_attributes', $attributeId)); + + return $attributeId; + } + + public function preserveModuleConfig(string $module, string $variable): void + { + $existing = $this->fetchModuleConfig($module, $variable); + $conditions = [ + 'module' => $module, + 'variable' => $variable, + ]; + + $this->cleanup->add(function () use ($conditions, $existing): void { + if ($existing === null) { + $this->deleteWhereIfPossible('module_config', $conditions); + return; + } + + $current = $this->fetchModuleConfig((string)$conditions['module'], (string)$conditions['variable']); + $data = [ + 'value' => $existing['value'] ?? null, + 'type' => $existing['type'] ?? null, + 'created_at' => $existing['created_at'] ?? null, + 'updated_at' => $existing['updated_at'] ?? null, + ]; + + if ($current === null) { + $this->insertRow('module_config', [ + 'module' => $existing['module'] ?? $conditions['module'], + 'variable' => $existing['variable'] ?? $conditions['variable'], + ...$data, + ]); + return; + } + + $this->updateWhere('module_config', $conditions, $data); + }); + } + + public function setModuleConfig(string $module, string $variable, string $value, string $type = 'bool'): void + { + $existing = $this->fetchModuleConfig($module, $variable); + + if ($existing !== null) { + $conditions = [ + 'module' => $module, + 'variable' => $variable, + ]; + + $this->updateWhere('module_config', $conditions, [ + 'value' => $value, + 'type' => $type, + 'updated_at' => $this->now(), + ]); + + $this->cleanup->add(function () use ($conditions, $existing): void { + $this->updateWhere('module_config', $conditions, [ + 'value' => $existing['value'] ?? null, + 'type' => $existing['type'] ?? null, + 'updated_at' => $existing['updated_at'] ?? null, + 'created_at' => $existing['created_at'] ?? null, + ]); + }); + + return; + } + + $id = $this->insertRow('module_config', [ + 'module' => $module, + 'variable' => $variable, + 'value' => $value, + 'type' => $type, + 'created_at' => $this->now(), + 'updated_at' => $this->now(), + ]); + + $this->cleanup->add(fn() => $this->deleteById('module_config', $id)); + } + + /** + * @param array $permissions + * @param array|null $overrides + */ + public function cacheAuthSessionForUser(array $user, string $token, array $permissions, ?array $overrides = null): void + { + $payload = [ + 'id' => (int)$user['id'], + 'customer_number' => (int)$user['customer_number'], + 'customer_name' => (string)$user['display_name'], + 'display_name' => (string)$user['display_name'], + 'group_id' => (int)$user['group_id'], + 'phone' => [ + 'country_code' => 45, + 'number' => (int)substr((string)$user['customer_number'], -8), + ], + 'email' => (string)$user['email'], + 'notifications' => [ + 'sms_notifications_enabled' => false, + 'email_notifications_enabled' => false, + 'wash_certificate_email' => null, + ], + 'created_at' => $this->now(), + 'updated_at' => $this->now(), + 'economic_customer' => [ + 'customerNumber' => (int)$user['customer_number'], + 'name' => (string)$user['display_name'], + ], + 'permissions' => array_values($permissions), + 'two_factor_enabled' => false, + ]; + + if ($overrides !== null) { + $payload = array_replace_recursive($payload, $overrides); + } + + $this->setRedisJson('auth_session_' . $token, $payload); + } + + /** + * @param array $extraHeaders + * @return array + */ + public function bearerHeaders(string $token, array $extraHeaders = []): array + { + return array_merge([ + 'Authorization' => 'Bearer ' . $token, + ], $extraHeaders); + } + + public function clearEdgeGatewayViewCache(): void + { + $this->deleteRedisPattern('edge_gateway:view:v1:*'); + + if (class_exists(\classes\edge_gateway_view_cache::class)) { + \classes\edge_gateway_view_cache::clearAll(); + } + } + + public function fetchRowById(string $table, int $id): ?array + { + $table = $this->sanitizeIdentifier($table); + + return $this->queryOneBySql("SELECT * FROM `{$table}` WHERE id = {$id} LIMIT 1"); + } + + /** + * @param array $attributes + * @return array + */ + public function createEdgeInstallToken(array $attributes): array + { + $departmentId = (int)($attributes['department_id'] ?? 0); + if ($departmentId <= 0) { + throw new RuntimeException('Edge install tokens require department_id.'); + } + + $token = (string)($attributes['token'] ?? (bin2hex(random_bytes(18)) . $this->uniqueSuffix())); + $createdAt = (string)($attributes['created_at'] ?? $this->now()); + $expiresAt = (string)($attributes['expires_at'] ?? date('Y-m-d H:i:s', strtotime($createdAt) + 1800)); + $installSession = [ + 'status' => (string)($attributes['status'] ?? 'PENDING'), + 'step' => (string)($attributes['step'] ?? 'PENDING'), + 'message' => (string)($attributes['message'] ?? 'Installer command generated. Run it on the gateway host.'), + 'started_at' => $createdAt, + 'updated_at' => $createdAt, + 'terminal' => false, + 'gateway_id' => $attributes['gateway_id'] ?? null, + 'last_error' => $attributes['last_error'] ?? null, + 'diagnostics' => isset($attributes['diagnostics']) && is_array($attributes['diagnostics']) + ? (array)$attributes['diagnostics'] + : [], + 'events' => isset($attributes['events']) && is_array($attributes['events']) + ? (array)$attributes['events'] + : [ + [ + 'status' => (string)($attributes['status'] ?? 'PENDING'), + 'step' => (string)($attributes['step'] ?? 'PENDING'), + 'message' => (string)($attributes['message'] ?? 'Installer command generated. Run it on the gateway host.'), + 'at' => $createdAt, + ], + ], + ]; + + $claimTokenId = $this->insertRow('edge_gateway_claim_tokens', [ + 'department_id' => $departmentId, + 'label' => $attributes['label'] ?? ('Edge Install ' . $this->uniqueSuffix()), + 'token_hash' => hash('sha256', $token), + 'created_by' => $attributes['created_by'] ?? null, + 'expires_at' => $expiresAt, + 'used_at' => $attributes['used_at'] ?? null, + 'metadata_json' => array_merge( + isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : [], + ['install_session' => $installSession] + ), + 'created_at' => $createdAt, + 'updated_at' => $attributes['updated_at'] ?? $createdAt, + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('edge_gateway_claim_tokens', $claimTokenId)); + + return [ + 'claim_token_id' => $claimTokenId, + 'department_id' => $departmentId, + 'label' => $attributes['label'] ?? null, + 'token' => $token, + 'expires_at' => $expiresAt, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createClaimedEdgeGateway(array $attributes): array + { + $departmentId = (int)($attributes['department_id'] ?? 0); + if ($departmentId <= 0) { + throw new RuntimeException('Claimed edge gateways require department_id.'); + } + + $agentToken = (string)($attributes['agent_token'] ?? (bin2hex(random_bytes(24)) . $this->uniqueSuffix())); + $createdAt = (string)($attributes['created_at'] ?? $this->now()); + $metadata = array_merge([ + 'credentials_rotated_at' => $createdAt, + 'agent_runtime' => 'compose-php', + 'runtime_mode' => 'compose', + 'update_window' => '02:00-04:00', + 'container_health' => [ + 'overall_status' => 'PENDING', + 'services' => [], + ], + 'outbox_status' => [ + 'depth' => 0, + 'oldest_age_seconds' => 0, + 'last_flushed_at' => null, + 'pending_types' => [], + ], + 'rollback_status' => [ + 'state' => 'NONE', + 'reason' => null, + 'at' => null, + ], + 'last_sync_at' => null, + ], isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : []); + + $gatewayId = $this->insertRow('edge_gateways', [ + 'department_id' => $departmentId, + 'label' => (string)($attributes['label'] ?? ('Edge Gateway ' . $this->uniqueSuffix())), + 'hostname' => $attributes['hostname'] ?? ('edge-' . $this->uniqueSuffix()), + 'agent_token_hash' => hash('sha256', $agentToken), + 'status' => (string)($attributes['status'] ?? 'ONLINE'), + 'transport_mode' => (string)($attributes['transport_mode'] ?? 'gateway'), + 'release_channel' => (string)($attributes['release_channel'] ?? 'stable'), + 'installed_version' => $attributes['installed_version'] ?? 'php-agent-v1', + 'target_version' => $attributes['target_version'] ?? ($attributes['installed_version'] ?? 'php-agent-v1'), + 'last_heartbeat_at' => $attributes['last_heartbeat_at'] ?? $createdAt, + 'last_seen_ip' => $attributes['last_seen_ip'] ?? '127.0.0.1', + 'discovery_status' => (string)($attributes['discovery_status'] ?? 'PENDING'), + 'is_primary' => $attributes['is_primary'] ?? 1, + 'metadata_json' => $metadata, + 'created_at' => $createdAt, + 'updated_at' => $attributes['updated_at'] ?? $createdAt, + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(function () use ($gatewayId): void { + $this->deleteWhere('edge_gateway_operation_events', ['gateway_id' => $gatewayId]); + $this->deleteWhere('edge_gateway_operations', ['gateway_id' => $gatewayId]); + $this->deleteWhere('edge_gateway_command_jobs', ['gateway_id' => $gatewayId]); + $this->deleteWhere('edge_gateway_device_inventory', ['gateway_id' => $gatewayId]); + $this->deleteWhere('edge_gateway_relay_bindings', ['gateway_id' => $gatewayId]); + $this->deleteWhere('edge_gateway_log_entries', ['gateway_id' => $gatewayId]); + $this->deleteWhere('edge_gateway_shell_sessions', ['gateway_id' => $gatewayId]); + $this->deleteWhere('edge_gateway_audit_logs', ['gateway_id' => $gatewayId]); + $this->deleteById('edge_gateways', $gatewayId); + $this->clearEdgeGatewayViewCache(); + }); + + return [ + 'id' => $gatewayId, + 'department_id' => $departmentId, + 'label' => (string)($attributes['label'] ?? ''), + 'agent_token' => $agentToken, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createEdgeCommandJob(array $attributes): array + { + $gatewayId = (int)($attributes['gateway_id'] ?? 0); + if ($gatewayId <= 0) { + throw new RuntimeException('Edge command jobs require gateway_id.'); + } + + $jobId = $this->insertRow('edge_gateway_command_jobs', [ + 'gateway_id' => $gatewayId, + 'command_type' => (string)($attributes['command_type'] ?? 'DISCOVER_SHELLY'), + 'status' => (string)($attributes['status'] ?? 'PENDING'), + 'request_json' => isset($attributes['request']) && is_array($attributes['request']) ? (array)$attributes['request'] : [], + 'response_json' => isset($attributes['response']) && is_array($attributes['response']) ? (array)$attributes['response'] : [], + 'delivery_json' => isset($attributes['delivery']) && is_array($attributes['delivery']) ? (array)$attributes['delivery'] : [], + 'correlation_id' => (string)($attributes['correlation_id'] ?? ('edge-command-' . $this->uniqueSuffix())), + 'requested_by' => $attributes['requested_by'] ?? null, + 'requested_at' => $attributes['requested_at'] ?? $this->now(), + 'completed_at' => $attributes['completed_at'] ?? null, + 'error_message' => $attributes['error_message'] ?? null, + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('edge_gateway_command_jobs', $jobId)); + + return [ + 'id' => $jobId, + 'gateway_id' => $gatewayId, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createEdgeOperation(array $attributes): array + { + $gatewayId = (int)($attributes['gateway_id'] ?? 0); + if ($gatewayId <= 0) { + throw new RuntimeException('Edge operations require gateway_id.'); + } + + $operationId = $this->insertRow('edge_gateway_operations', [ + 'gateway_id' => $gatewayId, + 'type' => (string)($attributes['type'] ?? 'DISCOVERY'), + 'operation_type' => (string)($attributes['operation_type'] ?? ($attributes['type'] ?? 'DISCOVERY')), + 'status' => (string)($attributes['status'] ?? 'PENDING'), + 'request_json' => isset($attributes['request']) && is_array($attributes['request']) ? (array)$attributes['request'] : [], + 'summary_json' => isset($attributes['summary']) && is_array($attributes['summary']) ? (array)$attributes['summary'] : [ + 'label' => 'Queued', + 'progress' => 0, + 'retryable' => true, + ], + 'result_json' => isset($attributes['result']) && is_array($attributes['result']) ? (array)$attributes['result'] : [], + 'error_code' => $attributes['error_code'] ?? null, + 'error_message' => $attributes['error_message'] ?? null, + 'correlation_id' => (string)($attributes['correlation_id'] ?? ('edge-operation-' . $this->uniqueSuffix())), + 'agent_instance_id' => $attributes['agent_instance_id'] ?? null, + 'lease_expires_at' => $attributes['lease_expires_at'] ?? null, + 'last_progress_at' => $attributes['last_progress_at'] ?? null, + 'attempt_count' => $attributes['attempt_count'] ?? 0, + 'requested_by' => $attributes['requested_by'] ?? null, + 'requested_at' => $attributes['requested_at'] ?? $this->now(), + 'started_at' => $attributes['started_at'] ?? null, + 'completed_at' => $attributes['completed_at'] ?? null, + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(function () use ($gatewayId, $operationId): void { + $this->deleteWhere('edge_gateway_operation_events', [ + 'gateway_id' => $gatewayId, + 'operation_id' => $operationId, + ]); + $this->deleteById('edge_gateway_operations', $operationId); + }); + + return [ + 'id' => $operationId, + 'gateway_id' => $gatewayId, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createEdgeOperationEvent(array $attributes): array + { + $gatewayId = (int)($attributes['gateway_id'] ?? 0); + $operationId = (int)($attributes['operation_id'] ?? 0); + if ($gatewayId <= 0 || $operationId <= 0) { + throw new RuntimeException('Edge operation events require gateway_id and operation_id.'); + } + + $eventId = $this->insertRow('edge_gateway_operation_events', [ + 'gateway_id' => $gatewayId, + 'operation_id' => $operationId, + 'stage' => (string)($attributes['stage'] ?? 'RECORDED'), + 'level' => (string)($attributes['level'] ?? 'INFO'), + 'code' => $attributes['code'] ?? null, + 'message' => (string)($attributes['message'] ?? 'Edge operation event'), + 'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [], + 'counts_json' => isset($attributes['counts']) && is_array($attributes['counts']) ? (array)$attributes['counts'] : [], + 'payload_json' => isset($attributes['payload']) && is_array($attributes['payload']) ? (array)$attributes['payload'] : [], + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('edge_gateway_operation_events', $eventId)); + + return [ + 'id' => $eventId, + 'gateway_id' => $gatewayId, + 'operation_id' => $operationId, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createEdgeAuditLog(array $attributes): array + { + $gatewayId = (int)($attributes['gateway_id'] ?? 0); + $departmentId = (int)($attributes['department_id'] ?? 0); + if ($gatewayId <= 0 || $departmentId <= 0) { + throw new RuntimeException('Edge audit logs require gateway_id and department_id.'); + } + + $auditLogId = $this->insertRow('edge_gateway_audit_logs', [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'action' => (string)($attributes['action'] ?? 'EDGE_AUDIT'), + 'actor_user_id' => $attributes['actor_user_id'] ?? null, + 'actor_type' => (string)($attributes['actor_type'] ?? 'USER'), + 'severity' => (string)($attributes['severity'] ?? 'INFO'), + 'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [], + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + ]); + + $this->cleanup->add(fn() => $this->deleteById('edge_gateway_audit_logs', $auditLogId)); + + return [ + 'id' => $auditLogId, + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createEdgeLogEntry(array $attributes): array + { + $gatewayId = (int)($attributes['gateway_id'] ?? 0); + if ($gatewayId <= 0) { + throw new RuntimeException('Edge log entries require gateway_id.'); + } + + $gatewayRow = $this->fetchRowById('edge_gateways', $gatewayId); + $departmentId = (int)($attributes['department_id'] ?? ($gatewayRow['department_id'] ?? 0)); + + $logEntryId = $this->insertRow('edge_gateway_log_entries', [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId > 0 ? $departmentId : null, + 'level' => (string)($attributes['level'] ?? 'INFO'), + 'stream' => (string)($attributes['stream'] ?? 'agent'), + 'source' => (string)($attributes['source'] ?? 'BROKER'), + 'message' => (string)($attributes['message'] ?? 'Edge gateway log entry'), + 'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [], + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + ]); + + $this->cleanup->add(fn() => $this->deleteById('edge_gateway_log_entries', $logEntryId)); + + return [ + 'id' => $logEntryId, + 'gateway_id' => $gatewayId, + ]; + } + + /** + * @param array $attributes + * @return array + */ + public function createEdgeShellSession(array $attributes): array + { + $gatewayId = (int)($attributes['gateway_id'] ?? 0); + if ($gatewayId <= 0) { + throw new RuntimeException('Edge shell sessions require gateway_id.'); + } + + $gatewayRow = $this->fetchRowById('edge_gateways', $gatewayId); + $departmentId = (int)($attributes['department_id'] ?? ($gatewayRow['department_id'] ?? 0)); + if ($departmentId <= 0) { + throw new RuntimeException('Edge shell sessions require department_id or a valid gateway row.'); + } + + $sessionToken = (string)($attributes['token'] ?? bin2hex(random_bytes(24))); + $createdAt = (string)($attributes['created_at'] ?? $this->now()); + $expiresAt = (string)($attributes['expires_at'] ?? date('Y-m-d H:i:s', strtotime($createdAt) + 900)); + + $sessionId = $this->insertRow('edge_gateway_shell_sessions', [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'actor_user_id' => $attributes['actor_user_id'] ?? null, + 'session_token_hash' => hash('sha256', $sessionToken), + 'status' => (string)($attributes['status'] ?? 'PENDING'), + 'reason' => (string)($attributes['reason'] ?? 'Diagnostic shell session'), + 'connection_id' => $attributes['connection_id'] ?? null, + 'cwd' => $attributes['cwd'] ?? '/opt/truckwash-edge-agent', + 'shell_command' => $attributes['shell_command'] ?? null, + 'shell_args_json' => isset($attributes['shell_args']) && is_array($attributes['shell_args']) ? (array)$attributes['shell_args'] : [], + 'cols' => $attributes['cols'] ?? 120, + 'terminal_rows' => $attributes['rows'] ?? 32, + 'transcript' => $attributes['transcript'] ?? null, + 'metadata_json' => isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : [], + 'expires_at' => $expiresAt, + 'approved_at' => $attributes['approved_at'] ?? $createdAt, + 'opened_at' => $attributes['opened_at'] ?? null, + 'closed_at' => $attributes['closed_at'] ?? null, + 'created_at' => $createdAt, + 'updated_at' => $attributes['updated_at'] ?? $createdAt, + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('edge_gateway_shell_sessions', $sessionId)); + + return [ + 'id' => $sessionId, + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'token' => $sessionToken, + 'expires_at' => $expiresAt, + ]; + } + + public function cleanupDeleteById(string $table, int $id): void + { + $this->cleanup->add(fn() => $this->deleteById($table, $id)); + } + + /** + * @param array $conditions + */ + public function cleanupDeleteWhere(string $table, array $conditions): void + { + $this->cleanup->add(fn() => $this->deleteWhere($table, $conditions)); + } + + private function purgeCustomerTraceData(int $userId, int $customerNumber): void + { + $invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [ + 'customer_number' => $customerNumber, + ]); + $orderIds = $this->fetchIntColumnWhere('orders', 'id', [ + 'customer_id' => $customerNumber, + ]); + $vehicleIds = $this->fetchIntColumnWhere('customer_vehicles', 'id', [ + 'customer_id' => $customerNumber, + ]); + + if ($orderIds !== []) { + $this->deleteWhereInIfPossible('order_items', 'order_id', $orderIds); + $this->deleteWhereInIfPossible('economic_module_orders', 'id', $orderIds); + $this->deleteWhereInIfPossible('stripe_module_orders', 'id', $orderIds); + $this->deleteWhereInIfPossible('stripe_payment_intents', 'order_id', $orderIds); + $this->deleteWhereInIfPossible('object_attachments', 'object_id', $orderIds, [ + 'object_type' => 'orders', + ]); + } + + if ($vehicleIds !== []) { + $this->deleteWhereInIfPossible('customer_vehicles_addons', 'vehicle_id', $vehicleIds); + $this->deleteWhereInIfPossible('object_attachments', 'object_id', $vehicleIds, [ + 'object_type' => 'customer_vehicles', + ]); + } + + if ($invoiceCollectionIds !== []) { + $this->deleteWhereInIfPossible('object_attachments', 'object_id', $invoiceCollectionIds, [ + 'object_type' => 'collected_order_invoices', + ]); + } + + $this->deleteWhereIfPossible('customer_attributes', ['user_id' => $userId]); + $this->deleteWhereIfPossible('tokens', ['user_id' => $userId]); + $this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]); + $this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]); + $this->deleteWhereIfPossible('customer_default_department', ['customer_number' => $customerNumber]); + $this->deleteWhereIfPossible('customer_fixed_pricing', ['customer_number' => $customerNumber]); + $this->deleteWhereIfPossible('customer_fixed_pricing_versions', ['customer_number' => $customerNumber]); + $this->deleteWhereIfPossible('customer_vehicle_subscription_versions', ['customer_number' => $customerNumber]); + $this->deleteWhereIfPossible('customer_discount_override_versions', ['customer_number' => $customerNumber]); + $this->deleteWhereIfPossible('customer_discount_override_versions', ['user_id' => $userId]); + $this->deleteWhereIfPossible('subuser_grants', ['billing_customer_number' => $customerNumber]); + $this->deleteWhereIfPossible('system_search_economic_customer_index', ['customer_number' => $customerNumber]); + $this->deleteWhereIfPossible('system_search_economic_customer_index', ['user_id' => $userId]); + $this->deleteWhereIfPossible('object_attachments', [ + 'object_type' => 'users', + 'object_id' => $userId, + ]); + $this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]); + $this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]); + $this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $customerNumber]); + $this->deleteById('users', $userId); + } + + private function seedCustomerNameCache(int $customerNumber, string $name): void + { + $payload = [ + 'name' => $name, + ]; + + $this->setRedisJson('users_' . $customerNumber . '_economic_customer_name', $payload); + $this->setRedisJson('`users`_' . $customerNumber . '_economic_customer_name', $payload); + } + + private function seedEconomicCustomerCache(int $userId, int $customerNumber, string $name, string $email): void + { + $payload = [ + 'customerNumber' => $customerNumber, + 'name' => $name, + 'email' => $email, + 'country' => 'DK', + 'currency' => 'DKK', + 'barred' => false, + ]; + + $this->setRedisJson('users_' . $userId . '_economic_customer', $payload); + $this->setRedisJson('`users`_' . $userId . '_economic_customer', $payload); + } + + /** + * @param array $data + */ + private function insertRowWithExistingColumns(string $table, array $data): int + { + $filtered = []; + foreach ($data as $column => $value) { + if ($this->tableHasColumn($table, (string)$column)) { + $filtered[(string)$column] = $value; + } + } + + return $this->insertRow($table, $filtered); + } + + /** + * @param array $data + */ + private function insertRow(string $table, array $data): int + { + $table = $this->sanitizeIdentifier($table); + if ($data === []) { + throw new RuntimeException('Cannot insert an empty row into ' . $table . '.'); + } + + $columns = []; + $placeholders = []; + $types = ''; + $values = []; + + foreach ($data as $column => $value) { + $columns[] = '`' . $this->sanitizeIdentifier((string)$column) . '`'; + $placeholders[] = '?'; + [$type, $normalizedValue] = $this->normalizeValue($value); + $types .= $type; + $values[] = $normalizedValue; + } + + $sql = sprintf( + 'INSERT INTO `%s` (%s) VALUES (%s)', + $table, + implode(', ', $columns), + implode(', ', $placeholders), + ); + + $statement = $this->db->prepare($sql); + if ($statement === false) { + throw new RuntimeException('Failed to prepare insert for ' . $table . '.'); + } + + $statement->bind_param($types, ...$values); + $statement->execute(); + $statement->close(); + + return (int)$this->db->insert_id; + } + + /** + * @param array $conditions + */ + private function deleteWhere(string $table, array $conditions): void + { + if ($conditions === []) { + return; + } + + $table = $this->sanitizeIdentifier($table); + $parts = []; + $types = ''; + $values = []; + + foreach ($conditions as $column => $value) { + $column = $this->sanitizeIdentifier((string)$column); + if ($value === null) { + $parts[] = '`' . $column . '` IS NULL'; + continue; + } + + $parts[] = '`' . $column . '` = ?'; + [$type, $normalizedValue] = $this->normalizeValue($value); + $types .= $type; + $values[] = $normalizedValue; + } + + $sql = 'DELETE FROM `' . $table . '` WHERE ' . implode(' AND ', $parts); + $statement = $this->db->prepare($sql); + if ($statement === false) { + throw new RuntimeException('Failed to prepare delete for ' . $table . '.'); + } + + if ($values !== []) { + $statement->bind_param($types, ...$values); + } + $statement->execute(); + $statement->close(); + } + + /** + * @param array $conditions + */ + private function deleteWhereIfPossible(string $table, array $conditions): void + { + if ($conditions === [] || !$this->tableExists($table)) { + return; + } + + foreach (array_keys($conditions) as $column) { + if (!$this->tableHasColumn($table, (string)$column)) { + return; + } + } + + $this->deleteWhere($table, $conditions); + } + + /** + * @param array $conditions + * @return array + */ + private function fetchIntColumnWhere(string $table, string $column, array $conditions): array + { + if (!$this->tableExists($table) || !$this->tableHasColumn($table, $column)) { + return []; + } + + foreach (array_keys($conditions) as $conditionColumn) { + if (!$this->tableHasColumn($table, (string)$conditionColumn)) { + return []; + } + } + + $table = $this->sanitizeIdentifier($table); + $column = $this->sanitizeIdentifier($column); + $parts = []; + $types = ''; + $values = []; + + foreach ($conditions as $conditionColumn => $value) { + $conditionColumn = $this->sanitizeIdentifier((string)$conditionColumn); + if ($value === null) { + $parts[] = '`' . $conditionColumn . '` IS NULL'; + continue; + } + + $parts[] = '`' . $conditionColumn . '` = ?'; + [$type, $normalizedValue] = $this->normalizeValue($value); + $types .= $type; + $values[] = $normalizedValue; + } + + $sql = 'SELECT `' . $column . '` FROM `' . $table . '`'; + if ($parts !== []) { + $sql .= ' WHERE ' . implode(' AND ', $parts); + } + + $statement = $this->db->prepare($sql); + if ($statement === false) { + throw new RuntimeException('Failed to prepare select for ' . $table . '.'); + } + + if ($values !== []) { + $statement->bind_param($types, ...$values); + } + + $statement->execute(); + $statement->bind_result($selectedValue); + + $selected = []; + while ($statement->fetch()) { + $selectedValue = (int)$selectedValue; + if ($selectedValue > 0) { + $selected[] = $selectedValue; + } + } + + $statement->close(); + + return array_values(array_unique($selected)); + } + + /** + * @param array $values + * @param array $conditions + */ + private function deleteWhereInIfPossible(string $table, string $column, array $values, array $conditions = []): void + { + $values = array_values(array_unique(array_filter( + array_map('intval', $values), + static fn(int $value): bool => $value > 0 + ))); + if ($values === [] || !$this->tableExists($table) || !$this->tableHasColumn($table, $column)) { + return; + } + + foreach (array_keys($conditions) as $conditionColumn) { + if (!$this->tableHasColumn($table, (string)$conditionColumn)) { + return; + } + } + + $table = $this->sanitizeIdentifier($table); + $column = $this->sanitizeIdentifier($column); + $parts = ['`' . $column . '` IN (' . implode(', ', array_fill(0, count($values), '?')) . ')']; + $types = str_repeat('i', count($values)); + $boundValues = $values; + + foreach ($conditions as $conditionColumn => $value) { + $conditionColumn = $this->sanitizeIdentifier((string)$conditionColumn); + if ($value === null) { + $parts[] = '`' . $conditionColumn . '` IS NULL'; + continue; + } + + $parts[] = '`' . $conditionColumn . '` = ?'; + [$type, $normalizedValue] = $this->normalizeValue($value); + $types .= $type; + $boundValues[] = $normalizedValue; + } + + $sql = 'DELETE FROM `' . $table . '` WHERE ' . implode(' AND ', $parts); + $statement = $this->db->prepare($sql); + if ($statement === false) { + throw new RuntimeException('Failed to prepare delete for ' . $table . '.'); + } + + $statement->bind_param($types, ...$boundValues); + $statement->execute(); + $statement->close(); + } + + private function deleteById(string $table, int $id): void + { + $table = $this->sanitizeIdentifier($table); + if ($id <= 0) { + return; + } + + $this->db->query('DELETE FROM `' . $table . '` WHERE id = ' . $id . ' LIMIT 1'); + } + + /** + * @param array $data + */ + private function updateById(string $table, int $id, array $data): void + { + $table = $this->sanitizeIdentifier($table); + if ($id <= 0 || $data === []) { + return; + } + + $parts = []; + $types = ''; + $values = []; + foreach ($data as $column => $value) { + $column = $this->sanitizeIdentifier((string)$column); + if ($value === null) { + $parts[] = '`' . $column . '` = NULL'; + continue; + } + + $parts[] = '`' . $column . '` = ?'; + [$type, $normalizedValue] = $this->normalizeValue($value); + $types .= $type; + $values[] = $normalizedValue; + } + + $sql = 'UPDATE `' . $table . '` SET ' . implode(', ', $parts) . ' WHERE id = ? LIMIT 1'; + $types .= 'i'; + $values[] = $id; + + $statement = $this->db->prepare($sql); + if ($statement === false) { + throw new RuntimeException('Failed to prepare update for ' . $table . '.'); + } + + $statement->bind_param($types, ...$values); + $statement->execute(); + $statement->close(); + } + + /** + * @param array $conditions + * @param array $data + */ + private function updateWhere(string $table, array $conditions, array $data): void + { + $table = $this->sanitizeIdentifier($table); + if ($conditions === [] || $data === []) { + return; + } + + $setParts = []; + $whereParts = []; + $types = ''; + $values = []; + + foreach ($data as $column => $value) { + $column = $this->sanitizeIdentifier((string)$column); + if ($value === null) { + $setParts[] = '`' . $column . '` = NULL'; + continue; + } + + $setParts[] = '`' . $column . '` = ?'; + [$type, $normalizedValue] = $this->normalizeValue($value); + $types .= $type; + $values[] = $normalizedValue; + } + + foreach ($conditions as $column => $value) { + $column = $this->sanitizeIdentifier((string)$column); + if ($value === null) { + $whereParts[] = '`' . $column . '` IS NULL'; + continue; + } + + $whereParts[] = '`' . $column . '` = ?'; + [$type, $normalizedValue] = $this->normalizeValue($value); + $types .= $type; + $values[] = $normalizedValue; + } + + $sql = 'UPDATE `' . $table . '` SET ' . implode(', ', $setParts) . ' WHERE ' . implode(' AND ', $whereParts); + $statement = $this->db->prepare($sql); + if ($statement === false) { + throw new RuntimeException('Failed to prepare update for ' . $table . '.'); + } + + $statement->bind_param($types, ...$values); + $statement->execute(); + $statement->close(); + } + + private function queryOneBySql(string $sql): ?array + { + $result = $this->db->query($sql); + if ($result === false) { + throw new RuntimeException('Query failed: ' . $sql); + } + + $row = $result->fetch_assoc(); + $result->free(); + + return $row ?: null; + } + + private function fetchModuleConfig(string $module, string $variable): ?array + { + $moduleEscaped = $this->db->real_escape_string($module); + $variableEscaped = $this->db->real_escape_string($variable); + + return $this->queryOneBySql( + "SELECT * FROM module_config WHERE module = '{$moduleEscaped}' AND variable = '{$variableEscaped}' LIMIT 1" + ); + } + + private function setRedisJson(string $key, array $payload): void + { + if ($this->redis === null) { + throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.'); + } + + $encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($encoded)) { + throw new RuntimeException('Unable to encode Redis payload for API tests.'); + } + + $this->redis->set($key, $encoded); + $this->cleanup->add(fn() => $this->deleteRedisKey($key)); + } + + private function deleteRedisKey(string $key): void + { + if ($this->redis === null) { + return; + } + + $this->redis->del([$key]); + } + + private function deleteRedisPattern(string $pattern): void + { + if ($this->redis === null) { + return; + } + + $keys = $this->redis->keys($pattern); + if ($keys === []) { + return; + } + + $this->redis->del($keys); + } + + /** + * @return array{0:string,1:mixed} + */ + private function normalizeValue(mixed $value): array + { + if (is_bool($value)) { + return ['i', $value ? 1 : 0]; + } + + if (is_int($value)) { + return ['i', $value]; + } + + if (is_float($value)) { + return ['d', $value]; + } + + if (is_array($value)) { + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($encoded)) { + throw new RuntimeException('Unable to encode array value for API fixture data.'); + } + + return ['s', $encoded]; + } + + if ($value === null) { + return ['s', null]; + } + + return ['s', (string)$value]; + } + + private function sanitizeIdentifier(string $identifier): string + { + if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) { + throw new RuntimeException('Invalid SQL identifier: ' . $identifier); + } + + return $identifier; + } + + private function now(): string + { + return date('Y-m-d H:i:s'); + } + + private function tableExists(string $table): bool + { + $table = $this->sanitizeIdentifier($table); + if (array_key_exists($table, $this->tableExistsCache)) { + return $this->tableExistsCache[$table]; + } + + $escapedTable = $this->db->real_escape_string($table); + $result = $this->db->query( + "SELECT 1 + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$escapedTable}' + LIMIT 1" + ); + if ($result === false) { + return $this->tableExistsCache[$table] = false; + } + + $exists = $result->fetch_assoc() !== null; + $result->free(); + + return $this->tableExistsCache[$table] = $exists; + } + + private function tableHasColumn(string $table, string $column): bool + { + $table = $this->sanitizeIdentifier($table); + $column = $this->sanitizeIdentifier($column); + $cacheKey = $table . ':' . $column; + + if (array_key_exists($cacheKey, $this->tableColumnExistsCache)) { + return $this->tableColumnExistsCache[$cacheKey]; + } + + if (!$this->tableExists($table)) { + return $this->tableColumnExistsCache[$cacheKey] = false; + } + + $escapedTable = $this->db->real_escape_string($table); + $escapedColumn = $this->db->real_escape_string($column); + $result = $this->db->query( + "SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$escapedTable}' + AND COLUMN_NAME = '{$escapedColumn}' + LIMIT 1" + ); + if ($result === false) { + return $this->tableColumnExistsCache[$cacheKey] = false; + } + + $exists = $result->fetch_assoc() !== null; + $result->free(); + + return $this->tableColumnExistsCache[$cacheKey] = $exists; + } + + private function uniqueCustomerNumber(): int + { + return 80000000 + (++self::$sequence); + } + + private function uniqueSuffix(): string + { + return strtoupper(dechex(time()) . dechex(getmypid()) . dechex(++self::$sequence)); + } +} diff --git a/services/nginx/app/tests/Support/Api/ApiResponse.php b/services/nginx/app/tests/Support/Api/ApiResponse.php new file mode 100644 index 00000000..979dd0d4 --- /dev/null +++ b/services/nginx/app/tests/Support/Api/ApiResponse.php @@ -0,0 +1,101 @@ + $headers + * @param array|null $json + */ + public function __construct( + public readonly int $status, + public readonly array $headers, + public readonly ?array $json, + public readonly string $body, + ) { + } + + public function assertStatus(int $expectedStatus): self + { + Assert::assertSame($expectedStatus, $this->status, $this->body); + + return $this; + } + + public function assertEnvelope(): self + { + Assert::assertIsArray($this->json, 'Expected a JSON response body. Raw body: ' . $this->body); + Assert::assertArrayHasKey('success', $this->json); + Assert::assertArrayHasKey('data', $this->json); + Assert::assertArrayHasKey('meta', $this->json); + Assert::assertArrayHasKey('includes', $this->json); + Assert::assertArrayHasKey('content-type', $this->headers); + Assert::assertStringContainsString('application/json', strtolower($this->headers['content-type'])); + + return $this; + } + + public function assertSuccess(bool $expected = true): self + { + $this->assertEnvelope(); + Assert::assertSame($expected, $this->json['success']); + + return $this; + } + + public function assertMessage(string $expectedMessage): self + { + $this->assertEnvelope(); + Assert::assertIsArray($this->json['data']); + Assert::assertSame($expectedMessage, $this->json['data']['message'] ?? null, $this->body); + + return $this; + } + + public function assertMessageContains(string $expectedFragment): self + { + $this->assertEnvelope(); + Assert::assertIsArray($this->json['data']); + Assert::assertIsString($this->json['data']['message'] ?? null, $this->body); + Assert::assertStringContainsString($expectedFragment, $this->json['data']['message'], $this->body); + + return $this; + } + + /** + * @param array $expectedPermissions + */ + public function assertMissingPermissions(array $expectedPermissions): self + { + $this->assertEnvelope(); + Assert::assertIsArray($this->json['data']); + Assert::assertSame('Missing permission(s)', $this->json['data']['message'] ?? null, $this->body); + + $actual = $this->json['data']['permissions'] ?? null; + Assert::assertIsArray($actual); + + $expected = $expectedPermissions; + sort($expected); + sort($actual); + Assert::assertSame($expected, $actual, $this->body); + + return $this; + } + + public function data(): mixed + { + return $this->json['data'] ?? null; + } + + public function meta(): array + { + $meta = $this->json['meta'] ?? []; + + return is_array($meta) ? $meta : []; + } +} diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php new file mode 100644 index 00000000..d3df88ce --- /dev/null +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -0,0 +1,915 @@ +tableStatements() as $name => $sql) { + $this->execute($name, $sql); + } + + $this->ensureDepartmentArchiveSchema(); + $this->ensureOrderInvoiceCollectionSchema(); + + foreach ($this->viewStatements() as $name => $sql) { + $this->execute($name, $sql); + } + } + + /** + * @return array + */ + private function tableStatements(): array + { + return [ + 'groups' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `groups` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'users' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_number` INT NOT NULL, + `display_name` VARCHAR(255) NULL, + `email` VARCHAR(255) NULL, + `phone_country_code` INT NULL, + `phone` BIGINT NULL, + `password` VARCHAR(255) NULL, + `group_id` INT NOT NULL DEFAULT 0, + `xlvask_customer_id` VARCHAR(255) NULL, + `sms_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `email_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `wash_certificate_email` VARCHAR(255) NULL, + `two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `two_factor_secret` VARCHAR(255) NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_users_customer_number` (`customer_number`), + KEY `idx_users_group_id` (`group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'groups_permissions' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `groups_permissions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `group_id` INT NOT NULL, + `permission` VARCHAR(191) NOT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_groups_permissions_group_id` (`group_id`), + KEY `idx_groups_permissions_permission` (`permission`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'departments' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `departments` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `economic_department_id` INT NOT NULL DEFAULT 0, + `slack_webhook` TEXT NULL, + `dimension` INT NOT NULL DEFAULT 0, + `branding` INT NULL DEFAULT NULL, + `visible` TINYINT(1) NOT NULL DEFAULT 1, + `archived` TINYINT(1) NOT NULL DEFAULT 0, + `latitude` DECIMAL(10,7) NOT NULL DEFAULT 0, + `longitude` DECIMAL(10,7) NOT NULL DEFAULT 0, + `order_priority` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_departments_visible` (`visible`), + KEY `idx_departments_archived` (`archived`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_variables' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_variables` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department_id` INT NOT NULL, + `variable` VARCHAR(191) NOT NULL, + `value` TEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_department_variables_department_id` (`department_id`), + KEY `idx_department_variables_variable` (`variable`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_gates' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_gates` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `is_entrance` TINYINT(1) NOT NULL DEFAULT 0, + `is_exit` TINYINT(1) NOT NULL DEFAULT 0, + `name` VARCHAR(255) NOT NULL, + `config` LONGTEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_gates_department` (`department`), + KEY `idx_department_gates_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'branding' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `branding` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NULL, + `description` TEXT NULL, + `cvr` INT NULL, + `address` VARCHAR(255) NULL, + `phone_country_code` INT NULL, + `phone` INT NULL, + `email` VARCHAR(255) NULL, + `website` VARCHAR(255) NULL, + `banner` VARCHAR(255) NULL, + `logo` VARCHAR(255) NULL, + `favicon` VARCHAR(255) NULL, + `signature` VARCHAR(255) NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_branding_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_lanes' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_lanes` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `name` VARCHAR(255) NOT NULL, + `relay_in_id` VARCHAR(255) NULL, + `relay_out_id` VARCHAR(255) NULL, + `relay_machine_id` VARCHAR(255) NULL, + `relay_machine_program_picker_id` VARCHAR(255) NULL, + `relay_machine_cleaner_id` VARCHAR(255) NULL, + `dynamic_image_id` INT NULL, + `machine_type_id` INT NULL, + `selfserve_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_lanes_department` (`department`), + KEY `idx_department_lanes_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_conditions' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_conditions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `lane` INT NULL, + `product` INT NULL, + `machine_type_id` INT NULL, + `condition_id` INT NULL, + `name` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_conditions_department` (`department`), + KEY `idx_department_selfserve_conditions_lane` (`lane`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_condition_rules' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_condition_rules` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `condition_id` INT NOT NULL, + `type` VARCHAR(64) NOT NULL, + `object_type` VARCHAR(64) NOT NULL, + `object_id` INT NOT NULL, + `name` VARCHAR(255) NULL, + `description` TEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_condition_rules_condition` (`condition_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_questions' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_questions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `lane` INT NULL, + `product` INT NULL, + `condition_id` INT NULL, + `question` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `order_priority` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_questions_department` (`department`), + KEY `idx_department_selfserve_questions_lane` (`lane`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_tasks' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_tasks` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `lane` INT NULL, + `product` INT NULL, + `machine_type_id` INT NULL, + `condition_id` INT NULL, + `gate_type` VARCHAR(16) NULL DEFAULT 'ALWAYS', + `gate_ref_id` INT NULL, + `task` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `order_priority` INT NOT NULL DEFAULT 0, + `services` JSON NULL, + `buttons` JSON NULL, + `dynamic_images_vehicle_type` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_tasks_department` (`department`), + KEY `idx_department_selfserve_tasks_lane` (`lane`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_vehicle_conditions' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_vehicle_conditions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `lane` INT NULL, + `customer_id` INT NULL, + `reg` VARCHAR(64) NULL, + `question` INT NOT NULL, + `value` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_vehicle_conditions_department` (`department`), + KEY `idx_department_selfserve_vehicle_conditions_reg` (`reg`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_machine_types' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_machine_types` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `description` VARCHAR(255) NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_selfserve_machine_types_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_wash_sessions' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_wash_sessions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `lane_id` INT NOT NULL, + `department_id` INT NOT NULL, + `machine_type_id` INT NULL, + `customer_number` INT NULL, + `vehicle_id` INT NULL, + `vehicle_type_id` INT NULL, + `reg` VARCHAR(255) NOT NULL, + `status` VARCHAR(64) NOT NULL DEFAULT 'PENDING_QUESTIONS', + `allowed` TINYINT(1) NOT NULL DEFAULT 0, + `machine_relay_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `machine_relay_enabled_at` DATETIME NULL, + `machine_start_triggered` TINYINT(1) NOT NULL DEFAULT 0, + `machine_start_triggered_at` DATETIME NULL, + `wash_started_at` DATETIME NULL, + `order_id` INT NULL, + `completed_at` DATETIME NULL, + `metadata_json` JSON NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_selfserve_wash_sessions_lane_reg` (`lane_id`, `reg`), + KEY `idx_selfserve_wash_sessions_status` (`status`), + KEY `idx_selfserve_wash_sessions_customer` (`customer_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_wash_session_answers' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_wash_session_answers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `session_id` INT NOT NULL, + `question_id` INT NOT NULL, + `question_text` VARCHAR(255) NOT NULL, + `answer_value` TINYINT(1) NOT NULL, + `answered_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_selfserve_wash_session_answer` (`session_id`, `question_id`), + KEY `idx_selfserve_wash_session_answers_session` (`session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_wash_session_tasks' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_wash_session_tasks` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `session_id` INT NOT NULL, + `task_id` INT NULL, + `task_text` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `services` JSON NULL, + `buttons` JSON NULL, + `dynamic_image_id` INT NULL, + `dynamic_images_vehicle_type` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_selfserve_wash_session_tasks_session` (`session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_wash_session_events' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_wash_session_events` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `session_id` INT NOT NULL, + `event_type` VARCHAR(64) NOT NULL, + `payload_json` JSON NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_selfserve_wash_session_events_session` (`session_id`), + KEY `idx_selfserve_wash_session_events_type` (`event_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'plate_scanners' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `plate_scanners` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department_id` INT NOT NULL, + `lane_id` INT NULL, + `name` VARCHAR(255) NOT NULL, + `notes` TEXT NULL, + `api_key` VARCHAR(191) NOT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_plate_scanners_department_id` (`department_id`), + KEY `idx_plate_scanners_lane_id` (`lane_id`), + KEY `idx_plate_scanners_api_key` (`api_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'categories' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `categories` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'products' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `products` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `price` INT NOT NULL DEFAULT 0, + `subscription_allowed` TINYINT(1) NOT NULL DEFAULT 0, + `category` INT NOT NULL DEFAULT 0, + `piktogram` VARCHAR(255) NULL, + `economic_product_id` INT NULL, + `apply_category_discount` TINYINT(1) NOT NULL DEFAULT 1, + `requires_note` TINYINT(1) NOT NULL DEFAULT 0, + `is_wash` TINYINT(1) NOT NULL DEFAULT 0, + `display_in_booking_form` TINYINT(1) NOT NULL DEFAULT 0, + `order_priority` INT NOT NULL DEFAULT 0, + `max_quantity_per_order` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_products_category` (`category`), + KEY `idx_products_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_categories' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_categories` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department_id` INT NOT NULL, + `category_id` INT NOT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_categories_department_id` (`department_id`), + KEY `idx_department_categories_category_id` (`category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'product_department_prices' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `product_department_prices` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department_id` INT NOT NULL, + `product_id` INT NOT NULL, + `price` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_product_department_prices_lookup` (`department_id`, `product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'collected_order_invoices' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `collected_order_invoices` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_number` INT NOT NULL, + `name` VARCHAR(255) NOT NULL, + `notes` TEXT NULL, + `processor` INT NOT NULL DEFAULT 0, + `external_id` VARCHAR(255) NULL, + `booked_invoice_id` INT NULL, + `po_number` VARCHAR(255) NULL, + `error_message` TEXT NULL, + `closed_at` DATETIME NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_collected_order_invoices_customer_number` (`customer_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'orders' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `orders` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_id` INT NOT NULL, + `cashier_id` INT NOT NULL DEFAULT 0, + `department_id` INT NOT NULL, + `reference` VARCHAR(255) NOT NULL, + `notes` TEXT NULL, + `reg_1` VARCHAR(32) NULL, + `reg_2` VARCHAR(32) NULL, + `reg_3` VARCHAR(32) NULL, + `invoice_collection_id` INT NULL, + `booking_id` INT NULL, + `wash_id` VARCHAR(255) NULL, + `lane` INT NULL, + `po` VARCHAR(255) NULL, + `safety_seal` VARCHAR(255) NULL, + `using_hand_held` TINYINT(1) NOT NULL DEFAULT 0, + `include_in_invoice` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `completed_at` DATETIME NULL, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_orders_customer_id` (`customer_id`), + KEY `idx_orders_department_id` (`department_id`), + KEY `idx_orders_invoice_collection_id` (`invoice_collection_id`), + KEY `idx_orders_reg_1` (`reg_1`), + KEY `idx_orders_period_customer_created_deleted` (`customer_id`, `created_at`, `deleted_at`), + KEY `idx_orders_period_created_deleted_customer` (`created_at`, `deleted_at`, `customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'order_bookings' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `order_bookings` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_number` INT NOT NULL, + `department` INT NOT NULL, + `reg_1` VARCHAR(32) NULL, + `reg_2` VARCHAR(32) NULL, + `reg_3` VARCHAR(32) NULL, + `datetime` DATETIME NULL, + `note` TEXT NULL, + `reference` VARCHAR(255) NULL, + `po` VARCHAR(255) NULL, + `pickup` TINYINT(1) NOT NULL DEFAULT 0, + `items` LONGTEXT NULL, + `order_id` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_order_bookings_department` (`department`), + KEY `idx_order_bookings_customer_number` (`customer_number`), + KEY `idx_order_bookings_order_id` (`order_id`), + KEY `idx_order_bookings_reg_1` (`reg_1`), + KEY `idx_order_bookings_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'order_items' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `order_items` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` INT NOT NULL, + `product_id` INT NOT NULL, + `reference` VARCHAR(255) NULL, + `notes` TEXT NULL, + `cashier_id` INT NOT NULL DEFAULT 0, + `price` INT NOT NULL DEFAULT 0, + `quantity` INT NOT NULL DEFAULT 1, + `related_item_id` INT NULL, + `include_in_invoice` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_order_items_order_id` (`order_id`), + KEY `idx_order_items_deleted_at` (`deleted_at`), + KEY `idx_order_items_order_deleted` (`order_id`, `deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'customer_vehicles' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `customer_vehicles` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_id` INT NOT NULL, + `type` INT NOT NULL DEFAULT 0, + `reg` VARCHAR(32) NOT NULL, + `wash_subscription` TINYINT(1) NOT NULL DEFAULT 0, + `notes` TEXT NULL, + `reference` VARCHAR(255) NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_customer_vehicles_customer_id` (`customer_id`), + KEY `idx_customer_vehicles_reg` (`reg`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'xlvask_vehicle_types' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `xlvask_vehicle_types` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `vehicleTypeId` VARCHAR(255) NOT NULL, + `name` VARCHAR(255) NULL, + `product` INT NOT NULL DEFAULT 0, + `created_at` INT NOT NULL DEFAULT 0, + `updated_at` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_xlvask_vehicle_types_product` (`product`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'xlvask_usage_logs' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `xlvask_usage_logs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `WashId` VARCHAR(191) NOT NULL, + `CustomerId` VARCHAR(191) NULL, + `Customer` VARCHAR(255) NULL, + `VatNumber` VARCHAR(64) NULL, + `Location` VARCHAR(255) NULL, + `Hall` VARCHAR(255) NULL, + `HallId` VARCHAR(191) NULL, + `StartTime` VARCHAR(64) NULL, + `FinishTime` VARCHAR(64) NULL, + `RegistrationNumber` VARCHAR(64) NULL, + `VehicleType` VARCHAR(191) NULL, + `IdentificationType` VARCHAR(191) NULL, + `IdentificationId` VARCHAR(191) NULL, + `Info` TEXT NULL, + `Updated` VARCHAR(64) NULL, + `Prepaid` VARCHAR(64) NULL, + `FinishStatus` VARCHAR(64) NULL, + `CustomerGuid` VARCHAR(191) NULL, + `VehicleId` VARCHAR(191) NULL, + `WashItems` LONGTEXT NULL, + `ignored_at` DATETIME NULL, + `ignored_by` INT NULL, + `ignored_reason` TEXT NULL, + `cached_total_net_amount` DECIMAL(12,2) NULL, + `cached_primary_product_name` VARCHAR(255) NULL, + `cached_amount_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_xlvask_usage_logs_wash_id` (`WashId`), + KEY `idx_xlvask_usage_logs_customer` (`CustomerId`), + KEY `idx_xlvask_usage_logs_start` (`StartTime`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'customer_vehicles_addons' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `customer_vehicles_addons` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `vehicle_id` INT NOT NULL, + `addon_id` INT NOT NULL, + `amount` INT NOT NULL DEFAULT 1, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_customer_vehicles_addons_vehicle_id` (`vehicle_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'subusers' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `subusers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `username` VARCHAR(255) NULL, + `password` VARCHAR(255) NULL, + `name` VARCHAR(255) NULL, + `email` VARCHAR(255) NULL, + `phone_country_code` INT NULL, + `phone` BIGINT NULL, + `two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `two_factor_secret` VARCHAR(255) NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `suspended_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_subusers_username` (`username`), + KEY `idx_subusers_phone` (`phone`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'subuser_grants' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `subuser_grants` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `billing_customer_number` INT NOT NULL, + `subuser` INT NOT NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `note` TEXT NULL, + `permissions` LONGTEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_subuser_grants_subuser` (`subuser`), + KEY `idx_subuser_grants_billing_customer_number` (`billing_customer_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'tokens' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `tokens` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `type` VARCHAR(64) NOT NULL, + `description` VARCHAR(255) NULL, + `token` VARCHAR(255) NOT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_tokens_token` (`token`), + KEY `idx_tokens_user_id` (`user_id`), + KEY `idx_tokens_type` (`type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'customer_attributes' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `customer_attributes` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `attribute` VARCHAR(191) NOT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_customer_attributes_user_id` (`user_id`), + KEY `idx_customer_attributes_attribute` (`attribute`), + KEY `idx_customer_attributes_attribute_user` (`attribute`, `user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'module_config' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `module_config` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `module` VARCHAR(191) NOT NULL, + `variable` VARCHAR(191) NOT NULL, + `value` LONGTEXT NULL, + `type` VARCHAR(64) NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_module_config_module_variable` (`module`, `variable`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'user_key_value_pairs' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `user_key_value_pairs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `var` VARCHAR(191) NOT NULL, + `val` LONGTEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_key_value_pairs_user_id` (`user_id`), + KEY `idx_user_key_value_pairs_var` (`var`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'price_overrides' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `price_overrides` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `is_category` TINYINT(1) NOT NULL DEFAULT 0, + `product_or_category_id` VARCHAR(191) NOT NULL, + `percentage` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_price_overrides_user_id` (`user_id`), + KEY `idx_price_overrides_lookup` (`user_id`, `is_category`, `product_or_category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'products_options' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `products_options` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `product_id` INT NOT NULL, + `option_id` INT NOT NULL, + `name` VARCHAR(255) NULL, + `min` INT NULL, + `max` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_products_options_product_id` (`product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'economic_module_orders' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `economic_module_orders` ( + `id` INT NOT NULL, + `invoice_draft_id` INT NULL, + `invoice_id` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'stripe_module_orders' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `stripe_module_orders` ( + `id` INT NOT NULL, + `invoice_id` VARCHAR(255) NULL, + `customer_id` VARCHAR(255) NULL, + `email_sent` DATETIME NULL, + `url` TEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'stripe_module_customers' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `stripe_module_customers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `email` VARCHAR(255) NOT NULL, + `customer_id` VARCHAR(255) NOT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_stripe_module_customers_email` (`email`), + KEY `idx_stripe_module_customers_customer_id` (`customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'stripe_payment_intents' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `stripe_payment_intents` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` INT NOT NULL, + `payment_intent_id` VARCHAR(255) NOT NULL, + `client_secret` VARCHAR(255) NULL, + `data` LONGTEXT NULL, + `reader_id` VARCHAR(255) NULL, + `tax_percentage` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_stripe_payment_intents_order_id` (`order_id`), + KEY `idx_stripe_payment_intents_payment_intent_id` (`payment_intent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'object_attachments' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `object_attachments` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `object_type` VARCHAR(191) NOT NULL, + `object_id` INT NOT NULL, + `content` LONGTEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_object_attachments_lookup` (`object_type`, `object_id`), + KEY `idx_object_attachments_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'invoice_period_flags' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `invoice_period_flags` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `source` VARCHAR(32) NOT NULL, + `severity` VARCHAR(32) NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `target_type` VARCHAR(64) NOT NULL, + `target_id` BIGINT NOT NULL, + `field` VARCHAR(64) NULL, + `customer_number` INT NULL, + `order_id` BIGINT NULL, + `order_item_id` BIGINT NULL, + `invoice_collection_id` BIGINT NULL, + `xlvask_usage_log_id` BIGINT NULL, + `definition_key` VARCHAR(128) NULL, + `fingerprint` VARCHAR(191) NULL, + `reason` TEXT NULL, + `status_reason` TEXT NULL, + `context_json` JSON NULL, + `created_by` INT NULL, + `status_changed_by` INT NULL, + `status_changed_at` DATETIME NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_invoice_period_flags_auto_fingerprint` (`source`, `fingerprint`), + KEY `idx_invoice_period_flags_target` (`target_type`, `target_id`, `status`), + KEY `idx_invoice_period_flags_customer_status` (`customer_number`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + ]; + } + + /** + * @return array + */ + private function viewStatements(): array + { + return [ + 'orders_with_invoice_collections' => <<<'SQL' +CREATE OR REPLACE VIEW `orders_with_invoice_collections` AS +SELECT + o.*, + coi.closed_at, + coi.booked_invoice_id, + coi.processor +FROM `orders` o +LEFT JOIN `collected_order_invoices` coi ON coi.id = o.invoice_collection_id +SQL, + ]; + } + + private function execute(string $name, string $sql): void + { + if ($this->db->query($sql) === false) { + throw new RuntimeException( + sprintf('Failed to bootstrap API test schema for "%s": %s', $name, (string)$this->db->error) + ); + } + } + + private function ensureDepartmentArchiveSchema(): void + { + if (!$this->columnExists('departments', 'archived')) { + $this->execute( + 'departments.archived', + 'ALTER TABLE `departments` ADD COLUMN `archived` TINYINT(1) NOT NULL DEFAULT 0 AFTER `visible`' + ); + } + + if (!$this->indexExists('departments', 'idx_departments_archived')) { + $this->execute( + 'departments.idx_departments_archived', + 'ALTER TABLE `departments` ADD INDEX `idx_departments_archived` (`archived`)' + ); + } + } + + private function ensureOrderInvoiceCollectionSchema(): void + { + if (!$this->columnExists('orders', 'invoice_collection_id')) { + $this->execute( + 'orders.invoice_collection_id', + 'ALTER TABLE `orders` ADD COLUMN `invoice_collection_id` INT NULL' + ); + } + + if (!$this->indexExists('orders', 'idx_orders_invoice_collection_id')) { + $this->execute( + 'orders.idx_orders_invoice_collection_id', + 'ALTER TABLE `orders` ADD INDEX `idx_orders_invoice_collection_id` (`invoice_collection_id`)' + ); + } + + if (!$this->columnExists('collected_order_invoices', 'processor')) { + $this->execute( + 'collected_order_invoices.processor', + 'ALTER TABLE `collected_order_invoices` ADD COLUMN `processor` INT NOT NULL DEFAULT 0' + ); + } + + if (!$this->columnExists('collected_order_invoices', 'booked_invoice_id')) { + $this->execute( + 'collected_order_invoices.booked_invoice_id', + 'ALTER TABLE `collected_order_invoices` ADD COLUMN `booked_invoice_id` INT NULL' + ); + } + + if (!$this->columnExists('collected_order_invoices', 'closed_at')) { + $this->execute( + 'collected_order_invoices.closed_at', + 'ALTER TABLE `collected_order_invoices` ADD COLUMN `closed_at` DATETIME NULL' + ); + } + } + + private function columnExists(string $table, string $column): bool + { + $table = $this->db->real_escape_string($table); + $column = $this->db->real_escape_string($column); + $result = $this->db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + return $result !== false && $result->num_rows > 0; + } + + private function indexExists(string $table, string $index): bool + { + $table = $this->db->real_escape_string($table); + $index = $this->db->real_escape_string($index); + $result = $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'"); + + return $result !== false && $result->num_rows > 0; + } +} diff --git a/services/nginx/app/tests/Support/Api/ApiServer.php b/services/nginx/app/tests/Support/Api/ApiServer.php new file mode 100644 index 00000000..b2d409ec --- /dev/null +++ b/services/nginx/app/tests/Support/Api/ApiServer.php @@ -0,0 +1,150 @@ +stdoutLog = $logDirectory . DIRECTORY_SEPARATOR . 'api-server.out.log'; + $this->stderrLog = $logDirectory . DIRECTORY_SEPARATOR . 'api-server.err.log'; + } + + public function start(): void + { + $command = sprintf( + '%s -S %s index.php', + escapeshellarg((string)(PHP_BINARY ?: 'php')), + escapeshellarg($this->host . ':' . $this->port), + ); + $environment = $this->inheritEnvironment(); + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['file', $this->stdoutLog, 'a'], + 2 => ['file', $this->stderrLog, 'a'], + ]; + + $this->process = proc_open($command, $descriptorSpec, $pipes, $this->workingDirectory, $environment); + if (!is_resource($this->process)) { + throw new RuntimeException('Unable to start the PHP test server.'); + } + + if (isset($pipes[0]) && is_resource($pipes[0])) { + fclose($pipes[0]); + } + } + + public function isRunning(): bool + { + if (!is_resource($this->process)) { + return false; + } + + $status = proc_get_status($this->process); + + return (bool)($status['running'] ?? false); + } + + public function stop(): void + { + if (!is_resource($this->process)) { + return; + } + + proc_terminate($this->process); + usleep(250000); + + $status = proc_get_status($this->process); + if (($status['running'] ?? false) && function_exists('posix_kill')) { + @posix_kill((int)$status['pid'], 9); + } + + proc_close($this->process); + $this->process = null; + } + + public function describeFailure(string $message): string + { + $stderr = is_file($this->stderrLog) ? trim((string)file_get_contents($this->stderrLog)) : ''; + if ($stderr !== '') { + return $message . PHP_EOL . $stderr; + } + + return $message; + } + + public static function findAvailablePort(string $host): int + { + $socket = @stream_socket_server('tcp://' . $host . ':0', $errorCode, $errorMessage); + if ($socket === false) { + throw new RuntimeException( + sprintf('Unable to reserve an API test port on %s: %s (%d)', $host, $errorMessage, $errorCode) + ); + } + + $address = stream_socket_get_name($socket, false); + fclose($socket); + + if (!is_string($address) || $address === '') { + throw new RuntimeException('Unable to determine the reserved API test port.'); + } + + $lastSeparator = strrpos($address, ':'); + if ($lastSeparator === false) { + throw new RuntimeException('Unable to parse the reserved API test port.'); + } + + $port = (int)substr($address, $lastSeparator + 1); + if ($port <= 0) { + throw new RuntimeException('Unable to parse a valid API test port.'); + } + + return $port; + } + + /** + * @return array + */ + private function inheritEnvironment(): array + { + $environment = []; + + $sources = [getenv(), $_ENV ?? [], $_SERVER ?? []]; + foreach ($sources as $source) { + if (!is_array($source)) { + continue; + } + + foreach ($source as $key => $value) { + if (!is_string($key) || $key === '') { + continue; + } + + if (!is_scalar($value) && $value !== null) { + continue; + } + + $environment[$key] = $value === null ? '' : (string)$value; + } + } + + return $environment; + } +} diff --git a/services/nginx/app/tests/Support/Api/ApiTestCase.php b/services/nginx/app/tests/Support/Api/ApiTestCase.php new file mode 100644 index 00000000..113ff6d2 --- /dev/null +++ b/services/nginx/app/tests/Support/Api/ApiTestCase.php @@ -0,0 +1,31 @@ +skipReason(); + if ($skipReason !== null) { + $this->markTestSkipped($skipReason); + } + + \api_test_runtime()->beginTest(); + } + + protected function tearDown(): void + { + if (\api_tests_enabled()) { + \api_test_runtime()->endTest(); + } + + parent::tearDown(); + } +} diff --git a/services/nginx/app/tests/Support/Api/ApiTestRuntime.php b/services/nginx/app/tests/Support/Api/ApiTestRuntime.php new file mode 100644 index 00000000..f5d7f940 --- /dev/null +++ b/services/nginx/app/tests/Support/Api/ApiTestRuntime.php @@ -0,0 +1,395 @@ +assertApiDatabaseTargetIsSafe(); + + try { + $this->bootstrapEnvironment(); + $this->bootstrapSchemaIfRequested(); + } catch (Throwable $throwable) { + return $throwable->getMessage(); + } + + $missingTables = $this->missingTables(); + if ($missingTables !== []) { + return 'API tests require an initialized schema. Missing tables: ' . implode(', ', $missingTables); + } + + return null; + } + + public function beginTest(): void + { + $this->bootstrapEnvironment(); + $this->bootstrapSchemaIfRequested(); + $this->cleanup = new ApiCleanup(); + $this->fixtures = new ApiFixtures($this->db(), $this->redis(), $this->cleanup); + } + + public function endTest(): void + { + if ($this->cleanup !== null) { + $this->cleanup->run(); + $this->cleanup = null; + } + + $this->fixtures = null; + } + + public function client(): ApiClient + { + $this->bootstrapEnvironment(); + $this->ensureServerIsRunning(); + + return new ApiClient($this->baseUrl); + } + + public function fixtures(): ApiFixtures + { + if ($this->fixtures === null) { + throw new RuntimeException('API fixtures are only available during an active API test.'); + } + + return $this->fixtures; + } + + public function db(): mysqli + { + if ($this->db === null) { + throw new RuntimeException('The API test database connection has not been initialized.'); + } + + return $this->db; + } + + public function redis(): ?PredisClient + { + return $this->redis; + } + + public function queryOne(string $sql): ?array + { + $result = $this->db()->query($sql); + if ($result === false) { + throw new RuntimeException('Query failed: ' . $sql); + } + + $row = $result->fetch_assoc(); + $result->free(); + + return $row ?: null; + } + + public function shutdown(): void + { + if ($this->server !== null) { + $this->server->stop(); + $this->server = null; + } + + if ($this->db !== null) { + $this->db->close(); + $this->db = null; + } + + if ($this->redis !== null) { + try { + $this->redis->disconnect(); + } catch (Throwable) { + } + $this->redis = null; + } + + $this->bootstrapped = false; + $this->schemaBootstrapped = false; + $this->internalServerPort = null; + } + + private function bootstrapEnvironment(): void + { + if ($this->bootstrapped) { + return; + } + + $dbConfig = $this->readDbConfig(); + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $this->db = new mysqli( + $dbConfig['host'], + $dbConfig['user'], + $dbConfig['password'], + $dbConfig['database'], + $dbConfig['port'] + ); + $this->db->set_charset('utf8mb4'); + + $redisConfig = $this->readRedisConfig(); + if ($redisConfig !== null) { + $parameters = [ + 'scheme' => 'tcp', + 'host' => $redisConfig['host'], + 'port' => $redisConfig['port'], + 'database' => $redisConfig['database'], + 'password' => $redisConfig['password'], + ]; + if ($redisConfig['user'] !== '') { + $parameters['username'] = $redisConfig['user']; + } + $this->redis = new PredisClient($parameters); + } + + $configuredBaseUrl = trim((string)getenv('API_TEST_BASE_URL')); + if ($configuredBaseUrl !== '') { + $this->baseUrl = rtrim($configuredBaseUrl, '/'); + $this->usesExternalBaseUrl = true; + } + + if (!$this->shutdownRegistered) { + register_shutdown_function([$this, 'shutdown']); + $this->shutdownRegistered = true; + } + + $this->bootstrapped = true; + } + + private function bootstrapSchemaIfRequested(): void + { + if ($this->schemaBootstrapped) { + return; + } + + if (getenv('API_TEST_BOOTSTRAP_SCHEMA') !== '1') { + return; + } + + (new ApiSchemaBootstrap($this->db()))->ensureSchema(); + $this->schemaBootstrapped = true; + } + + private function ensureServerIsRunning(): void + { + if ($this->usesExternalBaseUrl) { + return; + } + + if ($this->server !== null && $this->server->isRunning()) { + return; + } + + $url = parse_url($this->baseUrl); + $host = (string)($url['host'] ?? '127.0.0.1'); + $port = $this->internalServerPort ?? ApiServer::findAvailablePort($host); + $this->internalServerPort = $port; + $this->baseUrl = sprintf('http://%s:%d', $host, $port); + + $this->server = new ApiServer(app_path(), $host, $port); + $this->server->start(); + $this->waitForPing(); + } + + private function waitForPing(): void + { + $deadline = microtime(true) + 10; + $client = new ApiClient($this->baseUrl); + $lastError = 'Timed out waiting for /ping.'; + + while (microtime(true) < $deadline) { + try { + $response = $client->get('/ping'); + if ($response->status === 200) { + return; + } + $lastError = 'Unexpected /ping status: ' . $response->status; + } catch (Throwable $throwable) { + $lastError = $throwable->getMessage(); + } + + usleep(200000); + } + + throw new RuntimeException($this->server?->describeFailure($lastError) ?? $lastError); + } + + /** + * @return array + */ + private function missingTables(): array + { + $requiredTables = [ + 'users', + 'groups', + 'groups_permissions', + 'departments', + 'department_categories', + 'categories', + 'orders', + 'collected_order_invoices', + 'tokens', + 'subusers', + 'subuser_grants', + 'module_config', + 'customer_attributes', + ]; + + $missing = []; + foreach ($requiredTables as $table) { + $escaped = $this->db()->real_escape_string($table); + $result = $this->db()->query("SHOW TABLES LIKE '{$escaped}'"); + if ($result === false || $result->num_rows === 0) { + $missing[] = $table; + } + if ($result !== false) { + $result->free(); + } + } + + return $missing; + } + + /** + * @return array{host:string,user:string,password:string,database:string,port:int} + */ + private function readDbConfig(): array + { + $target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live'))); + if ($target !== 'debug') { + $target = 'live'; + } + + $host = $this->readConfigValue('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target); + $user = $this->readConfigValue('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target); + $password = $this->readConfigValue('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target); + $database = $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target); + $port = (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306'); + + $this->assertApiDatabaseTargetIsSafe($target, $host, $user, $database, $port); + + if ($host === '' || $user === '' || $database === '') { + throw new RuntimeException('API tests require CONFIG_DB_HOST, CONFIG_DB_USER and CONFIG_DB_DATABASE to be set.'); + } + + return [ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + 'port' => $port > 0 ? $port : 3306, + ]; + } + + /** + * @return array{host:string,user:string,password:string,database:int,port:int}|null + */ + private function readRedisConfig(): ?array + { + $target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live'))); + if ($target !== 'debug') { + $target = 'live'; + } + + $host = $this->readConfigValue('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target); + if ($host === '') { + return null; + } + + return [ + 'host' => $host, + 'user' => $this->readConfigValue('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target), + 'password' => $this->readConfigValue('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target), + 'database' => (int)($this->readConfigValue('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'), + 'port' => (int)($this->readConfigValue('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'), + ]; + } + + private function readConfigValue(string $liveKey, string $debugKey, string $target): string + { + $liveValue = trim((string)(getenv($liveKey) ?: '')); + $debugValue = trim((string)(getenv($debugKey) ?: '')); + + if ($target === 'debug' && $debugValue !== '') { + return $debugValue; + } + + return $liveValue; + } + + private function assertApiDatabaseTargetIsSafe( + ?string $target = null, + ?string $host = null, + ?string $user = null, + ?string $database = null, + ?int $port = null + ): void { + if ((string)(getenv('API_TEST_ALLOW_LIVE_DB') ?: '') === '1') { + return; + } + + $target = strtolower(trim((string)($target ?? (getenv('CONFIG_DB_TARGET') ?: 'live')))); + if ($target !== 'debug') { + throw new RuntimeException( + 'Refusing to run API tests against CONFIG_DB_TARGET=live. Use CONFIG_DB_TARGET=debug, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.' + ); + } + + $host ??= $this->readConfigValue('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target); + $user ??= $this->readConfigValue('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target); + $database ??= $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target); + $port ??= (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306'); + + $liveHost = trim((string)(getenv('CONFIG_DB_HOST') ?: '')); + $liveUser = trim((string)(getenv('CONFIG_DB_USER') ?: '')); + $liveDatabase = trim((string)(getenv('CONFIG_DB_DATABASE') ?: '')); + $livePort = (int)(trim((string)(getenv('CONFIG_DB_PORT') ?: '3306')) ?: '3306'); + + if ( + $liveHost !== '' && + $liveUser !== '' && + $liveDatabase !== '' && + $host === $liveHost && + $user === $liveUser && + $database === $liveDatabase && + $port === $livePort + ) { + throw new RuntimeException( + 'Refusing to run API tests because CONFIG_DB_TARGET=debug resolves to the configured live database. Point CONFIG_DB_DEBUG_* at an isolated database, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.' + ); + } + } +} diff --git a/services/nginx/app/tests/Support/ApiTestSupport.php b/services/nginx/app/tests/Support/ApiTestSupport.php new file mode 100644 index 00000000..6023373b --- /dev/null +++ b/services/nginx/app/tests/Support/ApiTestSupport.php @@ -0,0 +1,69 @@ +client(); +} + +function api_fixtures(): ApiFixtures +{ + return api_test_runtime()->fixtures(); +} + +function usesApiSuite(): void +{ + // The API lifecycle is bound via Tests\Support\Api\ApiTestCase in tests/Pest.php. +} + +function api_test_covers(string $operation, string $kind = 'happy'): bool +{ + return $operation !== '' && $kind !== ''; +} + +function assert_api_envelope(ApiResponse $response): ApiResponse +{ + return $response->assertEnvelope(); +} + +function edge_test_broker_secret(): string +{ + $secret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); + + return $secret !== '' ? $secret : 'truckwash-edge-test-secret'; +} + +/** + * @param array $extraHeaders + * @return array + */ +function edge_test_broker_headers(array $extraHeaders = []): array +{ + return array_merge([ + 'X-Edge-Broker-Secret' => edge_test_broker_secret(), + ], $extraHeaders); +} diff --git a/services/nginx/app/tests/Support/EdgeGatewayE2eFixture.php b/services/nginx/app/tests/Support/EdgeGatewayE2eFixture.php new file mode 100644 index 00000000..9f68610f --- /dev/null +++ b/services/nginx/app/tests/Support/EdgeGatewayE2eFixture.php @@ -0,0 +1,256 @@ +createDepartment([ + 'name' => 'Edge Gateway E2E Department', + ]); + $session = $fixtures->createEdgeOperatorSession((int)$department['id'], [], [ + 'display_name' => 'Edge Gateway E2E Operator', + ]); + + edge_gateway_e2e_fixture_output([ + 'department_id' => (int)$department['id'], + 'user_id' => (int)$session['user']['id'], + 'group_id' => (int)$session['user']['group_id'], + 'customer_number' => (int)$session['user']['customer_number'], + 'auth_token' => (string)$session['token'], + 'broker_secret' => edge_test_broker_secret(), + ]); + } + + if ($action === 'cleanup') { + $encoded = trim((string)($argv[2] ?? '')); + if ($encoded === '') { + edge_gateway_e2e_fixture_fail('Cleanup requires a base64url payload argument.'); + } + + $payload = json_decode(base64_decode(strtr($encoded, '-_', '+/')) ?: '', true); + if (!is_array($payload)) { + edge_gateway_e2e_fixture_fail('Invalid cleanup payload.'); + } + + $context = edge_gateway_e2e_fixture_context(); + $db = $context['mysqli']; + $departmentId = (int)($payload['department_id'] ?? 0); + $userId = (int)($payload['user_id'] ?? 0); + $groupId = (int)($payload['group_id'] ?? 0); + + if ($departmentId > 0) { + $gatewayIds = edge_gateway_e2e_gateway_ids($db, $departmentId); + edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operation_events', $gatewayIds); + edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operations', $gatewayIds); + edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_command_jobs', $gatewayIds); + edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_log_entries', $gatewayIds); + edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_shell_sessions', $gatewayIds); + edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_device_inventory', $gatewayIds); + edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_relay_bindings', $gatewayIds); + edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_audit_logs', $gatewayIds); + + $db->query('DELETE FROM edge_gateway_claim_tokens WHERE department_id = ' . $departmentId); + $db->query('DELETE FROM edge_gateways WHERE department_id = ' . $departmentId); + $db->query('DELETE FROM department_variables WHERE department_id = ' . $departmentId); + } + + if ($userId > 0) { + $db->query('DELETE FROM tokens WHERE user_id = ' . $userId); + $db->query('DELETE FROM users WHERE id = ' . $userId); + } + + if ($groupId > 0) { + $db->query('DELETE FROM groups_permissions WHERE group_id = ' . $groupId); + $db->query('DELETE FROM groups WHERE id = ' . $groupId); + } + + if ($departmentId > 0) { + $db->query('DELETE FROM departments WHERE id = ' . $departmentId); + } + + edge_gateway_e2e_fixture_output(['ok' => true]); + } + + edge_gateway_e2e_fixture_fail('Unsupported action: ' . $action); +} catch (Throwable $throwable) { + edge_gateway_e2e_fixture_fail($throwable->getMessage()); +} + +/** + * @return array{mysqli:mysqli,fixtures:ApiFixtures} + */ +function edge_gateway_e2e_fixture_context(): array +{ + static $context = null; + + if ($context !== null) { + return $context; + } + + $dbConfig = edge_gateway_e2e_db_config(); + $GLOBALS['CONFIG_DB'] = $dbConfig; + $GLOBALS['response'] = new class { + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $db = new db($dbConfig); + $db->connect(); + $GLOBALS['db'] = $db; + + $mysqli = $db->conn(); + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + $mysqli->set_charset('utf8mb4'); + + (new ApiSchemaBootstrap($mysqli))->ensureSchema(); + + $context = [ + 'mysqli' => $mysqli, + 'fixtures' => new ApiFixtures($mysqli, edge_gateway_e2e_redis_client(), new ApiCleanup()), + ]; + + return $context; +} + +/** + * @return array{host:string,user:string,password:string,database:string,port:int} + */ +function edge_gateway_e2e_db_config(): array +{ + $target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live'))); + if ($target !== 'debug') { + $target = 'live'; + } + + $host = edge_gateway_e2e_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target); + $user = edge_gateway_e2e_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target); + $password = edge_gateway_e2e_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target); + $database = edge_gateway_e2e_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target); + $port = (int)(edge_gateway_e2e_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306'); + + if ($host === '' || $user === '' || $database === '') { + throw new RuntimeException('Missing database configuration for edge gateway E2E fixtures.'); + } + + return [ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + 'port' => $port > 0 ? $port : 3306, + ]; +} + +function edge_gateway_e2e_config_value(string $liveKey, string $debugKey, string $target): string +{ + $liveValue = trim((string)(getenv($liveKey) ?: '')); + $debugValue = trim((string)(getenv($debugKey) ?: '')); + + if ($target === 'debug' && $debugValue !== '') { + return $debugValue; + } + + return $liveValue; +} + +function edge_gateway_e2e_redis_client(): ?PredisClient +{ + $target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live'))); + if ($target !== 'debug') { + $target = 'live'; + } + + $host = edge_gateway_e2e_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target); + if ($host === '') { + return null; + } + + $parameters = [ + 'scheme' => 'tcp', + 'host' => $host, + 'port' => (int)(edge_gateway_e2e_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'), + 'database' => (int)(edge_gateway_e2e_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'), + 'password' => edge_gateway_e2e_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target), + ]; + + $user = edge_gateway_e2e_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target); + if ($user !== '') { + $parameters['username'] = $user; + } + + return new PredisClient($parameters); +} + +/** + * @return array + */ +function edge_gateway_e2e_gateway_ids(mysqli $db, int $departmentId): array +{ + $ids = []; + $result = $db->query('SELECT id FROM edge_gateways WHERE department_id = ' . $departmentId); + if ($result === false) { + return []; + } + + while ($row = $result->fetch_assoc()) { + if (isset($row['id']) && is_numeric($row['id'])) { + $ids[] = (int)$row['id']; + } + } + + $result->free(); + + return $ids; +} + +/** + * @param array $gatewayIds + */ +function edge_gateway_e2e_delete_by_gateway_ids(mysqli $db, string $table, array $gatewayIds): void +{ + $gatewayIds = array_values(array_unique(array_filter(array_map('intval', $gatewayIds), static fn(int $id): bool => $id > 0))); + if ($gatewayIds === []) { + return; + } + + $db->query('DELETE FROM `' . $table . '` WHERE gateway_id IN (' . implode(', ', $gatewayIds) . ')'); +} + +/** + * @param array $payload + */ +function edge_gateway_e2e_fixture_output(array $payload): void +{ + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + exit(0); +} + +function edge_gateway_e2e_fixture_fail(string $message): never +{ + fwrite(STDERR, $message . PHP_EOL); + exit(1); +} diff --git a/services/nginx/app/tests/Support/bootstrap.php b/services/nginx/app/tests/Support/bootstrap.php index 5b0c3ca8..a8691df3 100644 --- a/services/nginx/app/tests/Support/bootstrap.php +++ b/services/nginx/app/tests/Support/bootstrap.php @@ -10,7 +10,19 @@ function app_path(string $relative = ''): string return WD; } - return WD . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative), DIRECTORY_SEPARATOR); + $normalized = ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative), DIRECTORY_SEPARATOR); + + if (str_starts_with($normalized, 'classes' . DIRECTORY_SEPARATOR . 'edge_gateway_')) { + $normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . basename($normalized); + } elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewaysRoute.php') { + $normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewaysRoute.php'; + } elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'moduleEdgeGatewayRoute.php') { + $normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'moduleEdgeGatewayRoute.php'; + } elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewayConfigRoute.php') { + $normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewayConfigRoute.php'; + } + + return WD . DIRECTORY_SEPARATOR . $normalized; } function app_require(string $relative): void @@ -18,11 +30,144 @@ 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)) { + if ($top === 'classes' && str_starts_with(strtolower($relative), 'edge_gateway_')) { + $candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . $relative; + } + if ($top === 'routes' && in_array($relative, ['edgeGatewaysRoute', 'moduleEdgeGatewayRoute', 'edgeGatewayConfigRoute'], true)) { + $candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . $relative; + } + $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'; } +$edgeBrokerSharedSecret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); +if ($edgeBrokerSharedSecret === '') { + $edgeBrokerSharedSecret = 'truckwash-edge-test-secret'; + putenv('EDGE_BROKER_SHARED_SECRET=' . $edgeBrokerSharedSecret); + $_ENV['EDGE_BROKER_SHARED_SECRET'] = $edgeBrokerSharedSecret; + $_SERVER['EDGE_BROKER_SHARED_SECRET'] = $edgeBrokerSharedSecret; +} + +$shellyGuardEnabled = trim((string)(getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY') ?: '')); +if ($shellyGuardEnabled === '') { + putenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1'); + $_ENV['TRUCKWASH_TEST_BLOCK_REAL_SHELLY'] = '1'; + $_SERVER['TRUCKWASH_TEST_BLOCK_REAL_SHELLY'] = '1'; +} + +$shellyGuardLogPath = trim((string)(getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG') ?: '')); +if ($shellyGuardLogPath === '') { + $shellyGuardLogPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-shelly-guard-' . getmypid() . '.jsonl'; + @unlink($shellyGuardLogPath); + putenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG=' . $shellyGuardLogPath); + $_ENV['TRUCKWASH_TEST_SHELLY_GUARD_LOG'] = $shellyGuardLogPath; + $_SERVER['TRUCKWASH_TEST_SHELLY_GUARD_LOG'] = $shellyGuardLogPath; +} + +function shelly_test_guard_log_path(): ?string +{ + $path = trim((string)(getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG') ?: '')); + return $path === '' ? null : $path; +} + +function shelly_test_guard_reset(): void +{ + if (class_exists(\classes\shelly::class, false)) { + \classes\shelly::resetBlockedRequestLog(); + } + + $path = shelly_test_guard_log_path(); + if ($path !== null && is_file($path)) { + @unlink($path); + } +} + +/** + * @return array> + */ +function shelly_test_guard_entries(): array +{ + $entries = []; + + if (class_exists(\classes\shelly::class, false)) { + $entries = array_merge($entries, \classes\shelly::blockedRequestLog()); + } + + $path = shelly_test_guard_log_path(); + if ($path === null || !is_file($path)) { + return $entries; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return $entries; + } + + foreach ($lines as $line) { + $decoded = json_decode($line, true); + if (is_array($decoded)) { + $entries[] = $decoded; + } + } + + return $entries; +} + function run_legacy_script(string $relativeScriptPath): array { $script = app_path($relativeScriptPath); @@ -38,3 +183,8 @@ function run_legacy_script(string $relativeScriptPath): array return ['exitCode' => $exitCode, 'output' => implode(PHP_EOL, $outputLines)]; } + +require_once __DIR__ . '/ApiTestSupport.php'; +if (class_exists(\PHPUnit\Framework\TestCase::class)) { + require_once __DIR__ . '/Api/ApiTestCase.php'; +} diff --git a/services/nginx/app/tests/Support/legacy_bootstrap.php b/services/nginx/app/tests/Support/legacy_bootstrap.php new file mode 100644 index 00000000..03775b98 --- /dev/null +++ b/services/nginx/app/tests/Support/legacy_bootstrap.php @@ -0,0 +1,128 @@ + 'true', + 'DEBUG' => 'true', + 'ENCRYPTION_KEY' => 'ci-test-encryption-key', + 'CORS' => '*', + 'CONFIG_TIMEZONE' => 'Europe/Copenhagen', + 'CONFIG_DB_TARGET' => 'debug', + 'CONFIG_DB_HOST' => 'mysql-debug', + 'CONFIG_DB_USER' => 'root', + 'CONFIG_DB_PASSWORD' => 'debug_root_password', + 'CONFIG_DB_DATABASE' => 'nnks_db_debug', + 'CONFIG_DB_PORT' => '3306', + 'CONFIG_DB_DEBUG_HOST' => 'mysql-debug', + 'CONFIG_DB_DEBUG_USER' => 'root', + 'CONFIG_DB_DEBUG_PASSWORD' => 'debug_root_password', + 'CONFIG_DB_DEBUG_DATABASE' => 'nnks_db_debug', + 'CONFIG_DB_DEBUG_PORT' => '3306', + 'REDIS_CONFIG_HOST' => 'redis', + 'REDIS_CONFIG_USER' => 'default', + 'REDIS_CONFIG_DATABASE' => '0', + 'REDIS_CONFIG_PASSWORD' => '', + 'REDIS_CONFIG_PORT' => '6379', + 'REDIS_CONFIG_DEBUG_HOST' => 'redis', + 'REDIS_CONFIG_DEBUG_USER' => 'default', + 'REDIS_CONFIG_DEBUG_DATABASE' => '0', + 'REDIS_CONFIG_DEBUG_PASSWORD' => '', + 'REDIS_CONFIG_DEBUG_PORT' => '6379', + 'ECONOMIC_API_APP_ACCESS_GRANT' => 'ci-test', + 'ECONOMIC_API_APP_ACCESS_GRANT2' => 'ci-test-secondary', + 'ECONOMIC_API_APP_SECRET_TOKEN' => 'ci-test-secret', + 'WORDPRESS_STATIC_TOKEN' => 'ci-test', + 'EMAIL_WASH_CERTIFICATE_TOKEN' => 'ci-test', + 'WORDPRESS_API_URL' => 'http://localhost', + 'MINIO_ENDPOINT' => '', + 'MINIO_ACCESS_KEY' => '', + 'MINIO_SECRET_KEY' => '', + 'SLACK_DEFAULT_WEBHOOK' => '', + 'EDGE_BROKER_SHARED_SECRET' => 'truckwash-edge-ci', + 'EDGE_GATEWAY_VIEW_CACHE_TTL' => '0', + 'TRUCKWASH_TEST_BLOCK_REAL_SHELLY' => '1', +]; + +foreach ($legacyDefaults as $key => $value) { + if (getenv($key) !== false) { + continue; + } + + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; +} + +require_once WD . 'vendor/autoload.php'; +require_once WD . 'config.php'; + +if (getenv('API_TEST_BOOTSTRAP_SCHEMA') === '1') { + require_once WD . 'tests/Support/Api/ApiSchemaBootstrap.php'; + + $schemaDb = new mysqli( + getenv('CONFIG_DB_HOST') ?: 'mysql-debug', + getenv('CONFIG_DB_USER') ?: 'root', + getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password', + getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug', + (int)(getenv('CONFIG_DB_PORT') ?: 3306) + ); + + if ($schemaDb->connect_errno) { + fwrite(STDERR, 'Unable to bootstrap legacy schema: ' . $schemaDb->connect_error . PHP_EOL); + exit(1); + } + + (new Tests\Support\Api\ApiSchemaBootstrap($schemaDb))->ensureSchema(); + $schemaDb->close(); +} + +if (!isset($GLOBALS['db']) || !$GLOBALS['db'] instanceof classes\db) { + $GLOBALS['db'] = new classes\db($GLOBALS['CONFIG_DB']); + $GLOBALS['db']->connect(); +} + +$GLOBALS['db']->query(" + INSERT INTO departments (id, name, visible, archived) + VALUES (1, 'CI Self-Serve Department', 1, 0) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + visible = VALUES(visible), + archived = VALUES(archived) +"); +$GLOBALS['db']->query(" + INSERT INTO department_lanes (id, department, name, relay_machine_id, selfserve_enabled) + VALUES (1, 1, 'CI Lane', 'demo-machine-relay', 1) + ON DUPLICATE KEY UPDATE + department = VALUES(department), + name = VALUES(name), + relay_machine_id = VALUES(relay_machine_id), + selfserve_enabled = VALUES(selfserve_enabled), + deleted_at = NULL +"); +$GLOBALS['db']->query(" + DELETE FROM department_variables + WHERE department_id = 1 + AND variable = 'selfserve_enabled' +"); +$GLOBALS['db']->query(" + INSERT INTO department_variables (department_id, variable, value) + VALUES (1, 'selfserve_enabled', 'true') +"); + +if (!defined('redis')) { + try { + define('redis', (new classes\redis())->connect()); + } catch (Throwable $throwable) { + fwrite(STDERR, 'Unable to connect Redis for legacy test bootstrap: ' . $throwable->getMessage() . PHP_EOL); + exit(1); + } +} diff --git a/services/nginx/app/tests/Support/legacy_test_manifest.php b/services/nginx/app/tests/Support/legacy_test_manifest.php new file mode 100644 index 00000000..abc43d5a --- /dev/null +++ b/services/nginx/app/tests/Support/legacy_test_manifest.php @@ -0,0 +1,33 @@ + 'tests/auth/CreateTokenUserNotFoundTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/auth/TwoFactorAuthTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/auth/WebAuthnInstallTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/bookingModule/BookingModuleTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/bookingModule/bookingSyncTest.php', 'classification' => 'manual-external', 'type' => 'script', 'reason' => 'Requires the live WordPress bookings API and wash certificate object storage.'], + ['path' => 'tests/dynamicimages/DepartmentLanesImageTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'], + ['path' => 'tests/economicOrderParser/economicOrderParserTest.php', 'classification' => 'manual-external', 'type' => 'script', 'reason' => 'Calls the live e-conomic API and is not deterministic in CI.'], + ['path' => 'tests/goals/DepartmentDailyTargetsRendererTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/goals/MonthlyTargetRendererTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/goalsModule/goalsTest.php', 'classification' => 'unit', 'type' => 'phpunit'], + ['path' => 'tests/lanes/DepartmentLaneDynamicImageIdTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/minio/minioTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/permissions/PermissionNodeTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/permissions/PermissionRedisCacheTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/redis/redisLogSyncTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/redis/redisTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/selfserve/ButtonsNormalizationTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'], + ['path' => 'tests/selfserve/DynamicImagesVehicleTypeNormalizationTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'], + ['path' => 'tests/selfserve/ForceMachineRelayBypassTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/selfserve/SelfserveLaneServicesEnumTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/selfserve/SelfServeRelayGatingTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/selfserve/StopTurnsOffRelayTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'], + ['path' => 'tests/slackModule/SlackModuleTest.php', 'classification' => 'manual-external', 'type' => 'script', 'reason' => 'Requires a configured Slack webhook and department webhook cache state.'], + ['path' => 'tests/subusers/SelfservePermissionInitTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/subusers/SubusersRoutePermissionLinkTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/subusers/SubuserUserGrantInitTest.php', 'classification' => 'unit', 'type' => 'script'], +]; diff --git a/services/nginx/app/tests/Support/run_ci_suite.php b/services/nginx/app/tests/Support/run_ci_suite.php new file mode 100644 index 00000000..586e04a6 --- /dev/null +++ b/services/nginx/app/tests/Support/run_ci_suite.php @@ -0,0 +1,110 @@ + 'true', + 'CONFIG_DB_TARGET' => 'debug', + 'CONFIG_DB_HOST' => 'mysql-debug', + 'CONFIG_DB_USER' => 'root', + 'CONFIG_DB_PASSWORD' => 'debug_root_password', + 'CONFIG_DB_DATABASE' => 'nnks_db_debug', + 'CONFIG_DB_PORT' => '3306', + 'CONFIG_DB_DEBUG_HOST' => 'mysql-debug', + 'CONFIG_DB_DEBUG_USER' => 'root', + 'CONFIG_DB_DEBUG_PASSWORD' => 'debug_root_password', + 'CONFIG_DB_DEBUG_DATABASE' => 'nnks_db_debug', + 'CONFIG_DB_DEBUG_PORT' => '3306', + 'REDIS_CONFIG_HOST' => 'redis', + 'REDIS_CONFIG_PORT' => '6379', + 'REDIS_CONFIG_DATABASE' => '0', + 'REDIS_CONFIG_DEBUG_HOST' => 'redis', + 'REDIS_CONFIG_DEBUG_PORT' => '6379', + 'REDIS_CONFIG_DEBUG_DATABASE' => '0', + 'EDGE_BROKER_SHARED_SECRET' => 'truckwash-edge-ci', + 'EDGE_GATEWAY_VIEW_CACHE_TTL' => '0', + 'TRUCKWASH_TEST_BLOCK_REAL_SHELLY' => '1', +]; + +foreach ($commonEnv as $key => $value) { + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; +} + +$commands = [ + 'unit' => [ + 'vendor/bin/pest --testsuite=Unit --colors=always', + ], + 'integration' => [ + 'RUN_INTEGRATION_TESTS=1 vendor/bin/pest --testsuite=Integration --colors=always', + ], + 'api' => [ + 'RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Api --colors=always', + ], + 'legacy' => [ + 'RUN_LEGACY_TESTS=1 RUN_INTEGRATION_TESTS=1 RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Legacy --colors=always', + ], +]; + +function reset_ci_state(): void +{ + $database = getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug'; + if (!preg_match('/^[A-Za-z0-9_]+$/', $database)) { + fwrite(STDERR, 'Refusing to reset unsafe database name: ' . $database . PHP_EOL); + exit(2); + } + + $db = new mysqli( + getenv('CONFIG_DB_HOST') ?: 'mysql-debug', + getenv('CONFIG_DB_USER') ?: 'root', + getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password', + '', + (int)(getenv('CONFIG_DB_PORT') ?: 3306) + ); + + if ($db->connect_errno) { + fwrite(STDERR, 'Unable to reset CI database: ' . $db->connect_error . PHP_EOL); + exit(1); + } + + $db->query("DROP DATABASE IF EXISTS `{$database}`"); + $db->query("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $db->close(); + + $redisHost = escapeshellarg(getenv('REDIS_CONFIG_HOST') ?: 'redis'); + $redisPort = (int)(getenv('REDIS_CONFIG_PORT') ?: 6379); + $redisDb = (int)(getenv('REDIS_CONFIG_DATABASE') ?: 0); + passthru("redis-cli -h {$redisHost} -p {$redisPort} -n {$redisDb} FLUSHDB >/dev/null", $redisExitCode); + if ($redisExitCode !== 0) { + fwrite(STDERR, 'Unable to reset CI Redis database.' . PHP_EOL); + exit($redisExitCode); + } +} + +if ($suite === 'all') { + foreach (['unit', 'integration', 'api', 'legacy'] as $selectedSuite) { + reset_ci_state(); + foreach ($commands[$selectedSuite] as $command) { + passthru($command, $exitCode); + if ($exitCode !== 0) { + exit($exitCode); + } + } + } + exit(0); +} elseif (isset($commands[$suite])) { + $selectedCommands = $commands[$suite]; +} else { + fwrite(STDERR, "Usage: php tests/Support/run_ci_suite.php " . PHP_EOL); + exit(2); +} + +foreach ($selectedCommands as $command) { + passthru($command, $exitCode); + if ($exitCode !== 0) { + exit($exitCode); + } +} diff --git a/services/nginx/app/tests/Support/run_legacy_script.php b/services/nginx/app/tests/Support/run_legacy_script.php new file mode 100644 index 00000000..305185ae --- /dev/null +++ b/services/nginx/app/tests/Support/run_legacy_script.php @@ -0,0 +1,34 @@ +getMessage() . PHP_EOL); + fwrite(STDERR, $throwable->getFile() . ':' . $throwable->getLine() . PHP_EOL); + exit(1); +} diff --git a/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh b/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh new file mode 100644 index 00000000..c5860829 --- /dev/null +++ b/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh @@ -0,0 +1,118 @@ +#!/bin/sh +set -eu + +tmp_dir="$(mktemp -d)" +psr_tmp_dir="" +cleanup() { + rm -rf "$tmp_dir" + if [ -n "$psr_tmp_dir" ]; then + rm -rf "$psr_tmp_dir" + fi +} +trap cleanup EXIT HUP INT TERM + +cat > "$tmp_dir/composer.json" <<'JSON' +{ + "name": "truckwash/composer-entrypoint-fixture", + "autoload": { + "classmap": [ + "src/" + ] + } +} +JSON + +mkdir -p "$tmp_dir/src" +cat > "$tmp_dir/src/FixtureClass.php" <<'PHP' +/dev/null 2>&1 + +printf '%s\n' ' "$tmp_dir/vendor/composer/autoload_real.php" + +AUTO_COMPOSER_INSTALL=true \ +APP_DIR="$tmp_dir" \ +MODULE_DIR="$tmp_dir/no-module" \ +LOG_FILE="$tmp_dir/composer-install.log" \ +REDIS_CONFIG_HOST= \ +/usr/local/bin/docker-entrypoint.sh \ +php -r "require \$argv[1]; echo class_exists('FixtureClass') ? 'autoload-ok' . PHP_EOL : 'autoload-missing' . PHP_EOL;" \ +"$tmp_dir/vendor/autoload.php" >/dev/null + +php -d display_errors=1 -r "require \$argv[1]; exit(class_exists('FixtureClass') ? 0 : 1);" "$tmp_dir/vendor/autoload.php" + +psr_tmp_dir="$(mktemp -d)" +cat > "$psr_tmp_dir/composer.json" <<'JSON' +{ + "name": "truckwash/composer-entrypoint-psr-fixture", + "require": { + "psr/http-message": "^2.0" + } +} +JSON + +COMPOSER_ALLOW_SUPERUSER=1 composer install \ + --no-dev \ + --prefer-dist \ + --optimize-autoloader \ + --no-interaction \ + -d "$psr_tmp_dir" >/dev/null 2>&1 + +cat > "$psr_tmp_dir/corrupt-autoload.php" <<'PHP' + "__DIR__ . '/../..' . '/modules/washcertificates/vendor/psr/http-message/src/StreamInterface.php'", + "__DIR__ . '/..' . '/psr/http-message/src/UriInterface.php'" => "__DIR__ . '/../..' . '/modules/washcertificates/vendor/psr/http-message/src/UriInterface.php'", + "\$vendorDir . '/psr/http-message/src/StreamInterface.php'" => "\$vendorDir . '/../modules/washcertificates/vendor/psr/http-message/src/StreamInterface.php'", + "\$vendorDir . '/psr/http-message/src/UriInterface.php'" => "\$vendorDir . '/../modules/washcertificates/vendor/psr/http-message/src/UriInterface.php'", +]; + +foreach (['autoload_static.php', 'autoload_classmap.php'] as $file) { + $path = $dir . '/vendor/composer/' . $file; + $contents = file_get_contents($path); + if ($contents === false) { + fwrite(STDERR, "Unable to read $path\n"); + exit(1); + } + + $updated = str_replace(array_keys($replacements), array_values($replacements), $contents); + if ($updated === $contents) { + fwrite(STDERR, "Fixture did not corrupt $path\n"); + exit(1); + } + + file_put_contents($path, $updated); +} +PHP + +php "$psr_tmp_dir/corrupt-autoload.php" "$psr_tmp_dir" + +if php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') ? 0 : 1);" "$psr_tmp_dir/vendor/autoload.php" >/dev/null 2>&1; then + echo "fixture failed to corrupt psr/http-message autoload map" >&2 + exit 1 +fi + +AUTO_COMPOSER_INSTALL=true \ +APP_DIR="$psr_tmp_dir" \ +MODULE_DIR="$psr_tmp_dir/no-module" \ +LOG_FILE="$psr_tmp_dir/composer-install.log" \ +REDIS_CONFIG_HOST= \ +/usr/local/bin/docker-entrypoint.sh \ +php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') && interface_exists('Psr\\\\Http\\\\Message\\\\StreamInterface') ? 0 : 1);" \ +"$psr_tmp_dir/vendor/autoload.php" + +php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') && interface_exists('Psr\\\\Http\\\\Message\\\\StreamInterface') ? 0 : 1);" "$psr_tmp_dir/vendor/autoload.php" + +echo "composer-entrypoint-autoload-recovery-ok" diff --git a/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php b/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php index cffac520..10d7378b 100644 --- a/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php +++ b/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php @@ -1,5 +1,15 @@ null, + 'created' => [], + 'missing_error' => null, + ]; - $token = (new \classes\authentication())->create_token(111111); - - expect($token)->toBeString()->toMatch('/^[a-f0-9]{64}$/'); - expect(\objects\tokens_o::$created)->toHaveCount(1); - expect(\objects\tokens_o::$created[0]['user_id'])->toBe(123); - expect(\objects\tokens_o::$created[0]['type'])->toBe('AUTH_TOKEN'); - }); - - it('throws a clear exception when customer user is missing', function (): void { - \objects\users_o::$existing = []; + \objects\users_o::$existing = [111111]; + \objects\tokens_o::$created = []; + $result['success_token'] = (new \classes\authentication())->create_token(111111); + $result['created'] = \objects\tokens_o::$created; + \objects\users_o::$existing = []; + try { (new \classes\authentication())->create_token(222222); - })->throws(\Exception::class, 'User not found for customer number: 222222'); -} + } catch (\Throwable $exception) { + $result['missing_error'] = $exception->getMessage(); + } + echo json_encode($result, JSON_THROW_ON_ERROR); +} +PHP)); + + try { + $output = []; + $exitCode = 0; + exec(PHP_BINARY . ' ' . escapeshellarg($script), $output, $exitCode); + + expect($exitCode)->toBe(0); + $result = json_decode(implode("\n", $output), true, 512, JSON_THROW_ON_ERROR); + + expect($result['success_token'])->toBeString()->toMatch('/^[a-f0-9]{64}$/') + ->and($result['created'])->toHaveCount(1) + ->and($result['created'][0]['user_id'])->toBe(123) + ->and($result['created'][0]['type'])->toBe('AUTH_TOKEN') + ->and($result['missing_error'])->toBe('User not found for customer number: 222222'); + } finally { + if (is_file($script)) { + unlink($script); + } + } +}); diff --git a/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php b/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php new file mode 100644 index 00000000..08974950 --- /dev/null +++ b/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php @@ -0,0 +1,99 @@ +lastPayload = $data; + + return $this->stubResponse; + } + } +} + +if (!class_exists('EconomicCreateCustomerEndpointStub')) { + class EconomicCreateCustomerEndpointStub extends economic_customers_endpoint + { + public function __construct(public EconomicCreateCustomerInnerStub $inner) + { + $this->customers = $inner; + } + } +} + +if (!class_exists('EconomicCreateCustomerProbe')) { + class EconomicCreateCustomerProbe extends economic + { + public EconomicCreateCustomerInnerStub $inner; + + public function __construct(object $stubResponse) + { + $this->inner = new EconomicCreateCustomerInnerStub($stubResponse); + $this->customers = new EconomicCreateCustomerEndpointStub($this->inner); + } + } +} + +it('returns the raw upstream create response and preserves the requested payload', function (): void { + $stubResponse = (object)[ + 'customerNumber' => 42331123, + 'name' => 'Truckwash ApS', + ]; + + $probe = new EconomicCreateCustomerProbe($stubResponse); + $result = $probe->createCustomer(42331123, 'Truckwash ApS', 37781258, 'jb@truckwash.dk', 42331123); + + expect($result)->toBe($stubResponse); + expect($probe->inner->lastPayload['customerNumber'])->toBe(42331123); + expect($probe->inner->lastPayload['corporateIdentificationNumber'])->toBe('37781258'); + expect($probe->inner->lastPayload['phone'])->toBe(42331123); + expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123'); + expect($probe->inner->lastPayload['mobilePhone'])->toBe('42331123'); +}); + +it('adds supported CVR company fields to the e-conomic customer payload', function (): void { + $stubResponse = (object)[ + 'customerNumber' => 42331123, + 'name' => 'Truckwash ApS', + ]; + $companyInformation = (object)[ + 'address' => 'Testvej 12', + 'zipcode' => 2630, + 'city' => 'Taastrup', + 'website' => 'https://truckwash.test', + 'industrycode' => 953190, + ]; + + $probe = new EconomicCreateCustomerProbe($stubResponse); + $result = $probe->createCustomer( + 42331123, + 'Truckwash ApS', + 37781258, + 'invoice@truckwash.test', + 42331123, + 55667788, + $companyInformation, + ); + + expect($result)->toBe($stubResponse); + expect($probe->inner->lastPayload['address'])->toBe('Testvej 12'); + expect($probe->inner->lastPayload['zip'])->toBe('2630'); + expect($probe->inner->lastPayload['city'])->toBe('Taastrup'); + expect($probe->inner->lastPayload['website'])->toBe('https://truckwash.test'); + expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123'); + expect($probe->inner->lastPayload['mobilePhone'])->toBe('55667788'); + expect(array_key_exists('industrycode', $probe->inner->lastPayload))->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Auth/RegisterCvrLegacyScriptTest.php b/services/nginx/app/tests/Unit/Auth/RegisterCvrLegacyScriptTest.php new file mode 100644 index 00000000..5b5eba6d --- /dev/null +++ b/services/nginx/app/tests/Unit/Auth/RegisterCvrLegacyScriptTest.php @@ -0,0 +1,7 @@ +toBe(0, $result['output']); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php b/services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php new file mode 100644 index 00000000..20139ea1 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php @@ -0,0 +1,353 @@ +> */ + public array $hangupPayloads = []; + /** @var array> */ + public array $createPayloads = []; + /** @var array> */ + public array $flashCreatePayloads = []; + /** @var array|null */ + public ?array $flashCreateResponse = null; + + /** + * @param string[] $statusQueue + */ + public function __construct(mixed $workspaceId = 'workspace_1', mixed $channelId = 'channel_1', array $statusQueue = ['accepted']) + { + $this->statusQueue = $statusQueue; + $this->workspaceIdValue = is_string($workspaceId) ? $workspaceId : ''; + $this->channelIdValue = is_string($channelId) ? $channelId : ''; + } + + protected function getConfiguredWorkspaceId(): string + { + return $this->workspaceIdValue; + } + + protected function getConfiguredChannelId(): string + { + return $this->channelIdValue; + } + + public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null + { + $this->createPayloads[] = [ + 'workspaceId' => $workspaceId, + 'channelId' => $channelId, + 'payload' => $payload, + ]; + return ['id' => 'call_123']; + } + + public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null + { + $status = array_shift($this->statusQueue); + if (!is_string($status) || trim($status) === '') { + $status = 'ringing'; + } + $this->statusesSeen[] = $status; + + return [ + 'id' => $callId, + 'status' => $status, + ]; + } + + public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null + { + $this->hangupCalls++; + $this->hangupPayloads[] = $payload; + return ['status' => 'completed']; + } + + public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null + { + $this->flashCreatePayloads[] = [ + 'workspaceId' => $workspaceId, + 'channelId' => $channelId, + 'payload' => $payload, + ]; + return $this->flashCreateResponse ?? [ + 'id' => 'flash_123', + 'status' => 'starting', + 'from' => '+4532330288', + ]; + } + + public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null + { + $status = array_shift($this->flashStatusQueue); + if (!is_string($status) || trim($status) === '') { + $status = 'ringing'; + } + $this->flashStatusesSeen[] = $status; + + return [ + 'id' => $callId, + 'status' => $status, + ]; + } + + protected function waitForCallPollInterval(int $pollIntervalSeconds): void + { + // Avoid real sleeps in tests. + } +} + +class BirdGateCallFlashFallbackClientFake extends BirdGateCallClientFake +{ + public bool $shouldFlashFail = false; + public int $flashCallAttempts = 0; + public int $regularCallAttempts = 0; + + public function callGateViaFlashCall(int $countryCode, int $phone, int $ringTimeout): void + { + $this->flashCallAttempts++; + if ($this->shouldFlashFail) { + throw new \Exception('Flash call failed'); + } + } + + public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout) + { + $this->regularCallAttempts++; + } +} + +it('fails fast when workspace id is missing for gate calls', function (): void { + $client = new BirdGateCallClientFake(workspaceId: '', channelId: 'channel_1'); + + expect(fn() => $client->callGateAndHangupWhenAccepted(45, 12345678, 10)) + ->toThrow(\Exception::class, 'Bird workspaceId is not configured for gate calls'); +}); + +it('fails fast when channel id is missing for gate calls', function (): void { + $client = new BirdGateCallClientFake(workspaceId: 'workspace_1', channelId: ''); + + expect(fn() => $client->callGateAndHangupWhenAccepted(45, 12345678, 10)) + ->toThrow(\Exception::class, 'Bird channelId is not configured for gate calls'); +}); + +it('fails with terminal status details when call never reaches accepted state', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1', + statusQueue: ['busy'] + ); + + try { + $client->callGateAndHangupWhenAccepted(45, 12345678, 10); + $thrown = null; + } catch (\Exception $e) { + $thrown = $e; + } + + expect($thrown)->toBeInstanceOf(\Exception::class); + expect($thrown?->getMessage())->toContain('terminal status: busy'); + expect($thrown?->getMessage())->not->toContain('Bird API request failed'); + expect($client->hangupCalls)->toBe(0); +}); + +it('hangs up when call reaches accepted state', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1', + statusQueue: ['accepted'] + ); + + $client->callGateAndHangupWhenAccepted(45, 12345678, 10); + + expect($client->hangupCalls)->toBe(1); + expect($client->createPayloads)->toHaveCount(1); + expect($client->createPayloads[0]['workspaceId'])->toBe('workspace_1'); + expect($client->createPayloads[0]['channelId'])->toBe('channel_1'); + expect($client->createPayloads[0]['payload']['from'])->toBe('+4532330288'); + expect($client->createPayloads[0]['payload']['to'])->toBe('+4512345678'); + expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(10); + expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse(); + expect($client->hangupPayloads[0])->toBe([]); +}); + +it('hangs up immediately when status transitions to accepted', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1', + statusQueue: ['ringing', 'accepted', 'ongoing'] + ); + + $client->callGateAndHangupWhenAccepted(45, 12345678, 10); + + expect($client->hangupCalls)->toBe(1); + expect($client->statusesSeen)->toBe(['ringing', 'accepted']); +}); + +it('maps legacy timeout option to documented ringTimeout payload field', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1', + statusQueue: ['accepted'] + ); + + $client->createOutboundTestCallAndHangupWhenAccepted('workspace_1', 'channel_1', [ + 'to' => '+4512345678', + 'timeout' => 12, + ]); + + expect($client->createPayloads)->toHaveCount(1); + expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(12); + expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse(); +}); + +it('clamps derived ringTimeout to Bird documented max when gate timeout is high', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1', + statusQueue: ['accepted'] + ); + + $client->callGateAndHangupWhenAccepted(45, 12345678, 1000); + + expect($client->createPayloads)->toHaveCount(1); + expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(120); + expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse(); +}); + +it('passes documented hangup cause when provided', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1', + statusQueue: ['accepted'] + ); + + $client->createOutboundTestCallAndHangupWhenAccepted('workspace_1', 'channel_1', [ + 'to' => '+4512345678', + 'hangupCause' => 'rejected', + ]); + + expect($client->hangupCalls)->toBe(1); + expect($client->hangupPayloads[0]['cause'])->toBe('rejected'); +}); + +it('drops unsupported hangup cause values from request payload', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1', + statusQueue: ['accepted'] + ); + + $client->createOutboundTestCallAndHangupWhenAccepted('workspace_1', 'channel_1', [ + 'to' => '+4512345678', + 'hangupCause' => 'completed', + ]); + + expect($client->hangupCalls)->toBe(1); + expect($client->hangupPayloads[0])->toBe([]); +}); + +it('fails fast when workspace id is missing for gate flash calls', function (): void { + $client = new BirdGateCallClientFake(workspaceId: '', channelId: 'channel_1'); + + expect(fn() => $client->callGateViaFlashCall(45, 12345678, 10)) + ->toThrow(\Exception::class, 'Bird workspaceId is not configured for gate flash calls'); +}); + +it('creates gate flash call with documented ringTimeout payload', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'accepted', 'from' => '+4532330288']; + + $client->callGateViaFlashCall(45, 12345678, 10); + + expect($client->flashCreatePayloads)->toHaveCount(1); + expect($client->flashCreatePayloads[0]['workspaceId'])->toBe('workspace_1'); + expect($client->flashCreatePayloads[0]['channelId'])->toBe('channel_1'); + expect($client->flashCreatePayloads[0]['payload']['from'])->toBe('+4532330288'); + expect($client->flashCreatePayloads[0]['payload']['to'])->toBe('+4512345678'); + expect($client->flashCreatePayloads[0]['payload']['ringTimeout'])->toBe(10); + expect(array_key_exists('timeout', $client->flashCreatePayloads[0]['payload']))->toBeFalse(); +}); + +it('polls flash call and succeeds once accepted', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting', 'from' => '+4532330288']; + $client->flashStatusQueue = ['ringing', 'accepted', 'completed']; + + $client->callGateViaFlashCall(45, 12345678, 10); + + expect($client->flashStatusesSeen)->toBe(['ringing', 'accepted']); +}); + +it('fails flash gate flow when terminal failure status is returned', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting', 'from' => '+4532330288']; + $client->flashStatusQueue = ['busy']; + + expect(fn() => $client->callGateViaFlashCall(45, 12345678, 10)) + ->toThrow(\Exception::class, 'Gate flash call failed with status: busy'); +}); + +it('falls back to regular gate call when flash caller id is not confirmed', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1', + statusQueue: ['accepted'] + ); + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'accepted']; + + $client->callGatePreferringFlashCall(45, 12345678, 10); + + expect($client->flashCreatePayloads)->toHaveCount(1); + expect($client->createPayloads)->toHaveCount(1); + expect($client->createPayloads[0]['payload']['from'])->toBe('+4532330288'); +}); + +it('does not fallback to regular gate call when flash succeeds', function (): void { + $client = new BirdGateCallFlashFallbackClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + + $client->callGatePreferringFlashCall(45, 12345678, 10); + + expect($client->flashCallAttempts)->toBe(1); + expect($client->regularCallAttempts)->toBe(0); +}); + +it('falls back to regular gate call when flash fails', function (): void { + $client = new BirdGateCallFlashFallbackClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + $client->shouldFlashFail = true; + + $client->callGatePreferringFlashCall(45, 12345678, 10); + + expect($client->flashCallAttempts)->toBe(1); + expect($client->regularCallAttempts)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdModuleClientMappingTest.php b/services/nginx/app/tests/Unit/Bird/BirdModuleClientMappingTest.php new file mode 100644 index 00000000..08fe18e4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdModuleClientMappingTest.php @@ -0,0 +1,283 @@ + */ + public array $calls = []; + + public function __construct() + { + } + + public function sendPostRequest(string $endpoint, array $data): array|object|null + { + $this->calls[] = ['method' => 'POST', 'endpoint' => $endpoint, 'payload' => $data]; + return ['method' => 'POST', 'endpoint' => $endpoint, 'payload' => $data]; + } + + public function sendGetRequest(string $endpoint, array $query = []): array|object|null + { + $this->calls[] = ['method' => 'GET', 'endpoint' => $endpoint, 'payload' => $query]; + return ['method' => 'GET', 'endpoint' => $endpoint, 'payload' => $query]; + } + + public function sendPatchRequest(string $endpoint, array $data = []): array|object|null + { + $this->calls[] = ['method' => 'PATCH', 'endpoint' => $endpoint, 'payload' => $data]; + return ['method' => 'PATCH', 'endpoint' => $endpoint, 'payload' => $data]; + } + + public function sendDeleteRequest(string $endpoint, array $query = []): array|object|null + { + $this->calls[] = ['method' => 'DELETE', 'endpoint' => $endpoint, 'payload' => $query]; + return ['method' => 'DELETE', 'endpoint' => $endpoint, 'payload' => $query]; + } + } +} + +if (!class_exists('BirdEnabledConfigStub')) { + class BirdEnabledConfigStub extends bird_enabled_c + { + public function __construct(private bool $enabled = true) + { + } + + public function isTrue(): bool + { + return $this->enabled; + } + + public function getVariableValue(): mixed + { + return $this->enabled ? 'true' : 'false'; + } + } +} + +if (!class_exists('BirdApiKeyConfigStub')) { + class BirdApiKeyConfigStub extends bird_api_key_c + { + public function __construct(private string $apiKey = 'test-key') + { + } + + public function getVariableValue(): mixed + { + return $this->apiKey; + } + } +} + +if (!class_exists('BirdServerUrlConfigStub')) { + class BirdServerUrlConfigStub extends bird_server_url_c + { + public function __construct(private string $baseUrl = 'https://api.bird.test') + { + } + + public function getVariableValue(): mixed + { + return $this->baseUrl; + } + } +} + +if (!class_exists('BirdModuleConfigStub')) { + class BirdModuleConfigStub extends bird_c + { + public function __construct(string $apiKey = 'test-key', string $baseUrl = 'https://api.bird.test') + { + $this->enabled = new BirdEnabledConfigStub(true); + $this->api_key = new BirdApiKeyConfigStub($apiKey); + $this->server_url = new BirdServerUrlConfigStub($baseUrl); + } + } +} + +if (!class_exists('BirdHttpHarness')) { + class BirdHttpHarness extends bird + { + /** @var array */ + public array $httpCalls = []; + private int $stubStatus; + private string|false|null $stubBody; + + public function __construct(int $status = 200, string|false|null $body = '{"ok":true}', string $apiKey = 'test-key', string $baseUrl = 'https://api.bird.test') + { + $this->stubStatus = $status; + $this->stubBody = $body; + $this->config = new BirdModuleConfigStub($apiKey, $baseUrl); + } + + protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array + { + $this->httpCalls[] = [ + 'method' => strtoupper($method), + 'url' => $url, + 'headers' => $headers, + 'body' => $body, + ]; + + return [ + 'status_code' => $this->stubStatus, + 'body' => $this->stubBody, + ]; + } + } +} + +it('maps voice call facade methods to documented endpoints and methods', function (): void { + $client = new BirdApiTransportCapture(); + + $client->updateVoiceCall('ws', 'ch', 'call', ['status' => 'completed']); + $client->answerVoiceCall('ws', 'ch', 'call'); + $client->ringVoiceCall('ws', 'ch', 'call'); + $client->hangupVoiceCall('ws', 'ch', 'call', ['cause' => 'busy']); + $client->playbackVoiceCall('ws', 'ch', 'call', ['media' => ['https://example.com/a.mp3']]); + $client->sayMessage('ws', 'ch', 'call', ['text' => 'hello']); + $client->gatherMessage('ws', 'ch', 'call', ['maxNumKeys' => 1]); + $client->bridgeVoiceCall('ws', 'ch', 'call', ['to' => '+4511122233']); + $client->recordVoiceCall('ws', 'ch', 'call', ['maxLength' => 60]); + + expect($client->calls[0])->toMatchArray([ + 'method' => 'PATCH', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call', + ]); + expect($client->calls[1])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/answer', + ]); + expect($client->calls[2])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/ringing', + ]); + expect($client->calls[3])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/hangup', + ]); + expect($client->calls[4])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/playback', + ]); + expect($client->calls[5])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/say', + ]); + expect($client->calls[6])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/gather', + ]); + expect($client->calls[7])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/bridge', + ]); + expect($client->calls[8])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/record', + ]); +}); + +it('maps recordings insights log and flash methods to documented resources', function (): void { + $client = new BirdApiTransportCapture(); + + $client->createVoiceCallRecordingSession('ws', 'ch', 'call', ['maxLength' => 30]); + $client->listVoiceCallRecordings('ws', 'ch', 'call', ['limit' => 10]); + $client->getVoiceCallRecording('ws', 'ch', 'call', 'rec'); + $client->updateVoiceCallRecording('ws', 'ch', 'call', 'rec', ['status' => 'completed']); + $client->getVoiceCallInsights('ws', 'ch', 'call'); + $client->getVoiceCallsLog('ws', ['limit' => 25]); + $client->listFlashCalls('ws', 'ch', ['limit' => 5]); + $client->endFlashCall('ws', 'ch', 'flash-call', ['result' => 'verified']); + $client->hangupFlashCall('ws', 'ch', ['from' => '+4511122233', 'to' => '+4544455566']); + + expect($client->calls[0])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings', + ]); + expect($client->calls[1])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings', + ]); + expect($client->calls[2])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings/rec', + ]); + expect($client->calls[3])->toMatchArray([ + 'method' => 'PATCH', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings/rec', + ]); + expect($client->calls[4])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/ch/calls/call/insights', + ]); + expect($client->calls[5])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/calls', + ]); + expect($client->calls[6])->toMatchArray([ + 'method' => 'GET', + 'endpoint' => '/workspaces/ws/channels/ch/flashcalls', + ]); + expect($client->calls[7])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/flashcalls/flash-call', + ]); + expect($client->calls[8])->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/workspaces/ws/channels/ch/flashcalls/hangup', + ]); +}); + +it('encodes path segments before building outbound endpoints', function (): void { + $client = new BirdApiTransportCapture(); + + $client->getVoiceCall('workspace a', 'channel/a', 'call 1'); + $client->getVoiceCallRecording('workspace a', 'channel/a', 'call 1', 'recording/1'); + $client->getFlashCall('workspace a', 'channel/a', 'flash 1'); + + expect($client->calls[0]['endpoint'])->toBe('/workspaces/workspace%20a/channels/channel%2Fa/calls/call%201'); + expect($client->calls[1]['endpoint'])->toBe('/workspaces/workspace%20a/channels/channel%2Fa/calls/call%201/recordings/recording%2F1'); + expect($client->calls[2]['endpoint'])->toBe('/workspaces/workspace%20a/channels/channel%2Fa/flashcalls/flash%201'); +}); + +it('serializes query parameters for GET transport requests', function (): void { + $client = new BirdHttpHarness(); + + $response = $client->sendGetRequest('/workspaces/ws/channels/calls', [ + 'limit' => 10, + 'tag' => ['vip', 'north'], + 'id' => '123', + ]); + + expect($response)->toBeArray(); + expect($client->httpCalls)->toHaveCount(1); + expect($client->httpCalls[0]['method'])->toBe('GET'); + expect($client->httpCalls[0]['url'])->toContain('/workspaces/ws/channels/calls?'); + expect($client->httpCalls[0]['url'])->toContain('limit=10'); + expect($client->httpCalls[0]['url'])->toContain('tag%5B0%5D=vip'); + expect($client->httpCalls[0]['url'])->toContain('tag%5B1%5D=north'); + expect($client->httpCalls[0]['url'])->toContain('id=123'); +}); + +it('maps 4xx and 5xx transport failures into informative exceptions', function (): void { + $client4xx = new BirdHttpHarness(422, '{"message":"invalid payload","errors":[{"message":"to is required"}]}'); + $client5xx = new BirdHttpHarness(503, '{"error":"upstream unavailable"}'); + + expect(fn() => $client4xx->sendPostRequest('/workspaces/ws/channels/ch/calls', ['from' => '+4511122233'])) + ->toThrow(Exception::class, 'Bird API request failed with status 422'); + expect(fn() => $client5xx->sendGetRequest('/workspaces/ws/channels/ch/calls')) + ->toThrow(Exception::class, 'Bird API request failed with status 503'); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Bird/BirdOpenApiSpecTest.php new file mode 100644 index 00000000..fcea4eb0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdOpenApiSpecTest.php @@ -0,0 +1,109 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +function bird_openapi_path_block_or_fail(string $content, string $path): string +{ + $pathMarker = ' ' . $path . ':'; + $start = strpos($content, $pathMarker); + if ($start === false) { + throw new RuntimeException('OpenAPI path block not found: ' . $path); + } + + $rest = substr($content, $start + strlen($pathMarker)); + $nextPath = strpos($rest, "\n /"); + if ($nextPath === false) { + return substr($content, $start); + } + + return substr($content, $start, strlen($pathMarker) + $nextPath); +} + +it('documents all Bird voice call parity endpoints including gather recordings insights and log', function (): void { + $content = bird_openapi_content_or_skip(); + + expect($content)->toContain('/bird/voice/calls:'); + expect($content)->toContain('/bird/voice/calls/log:'); + expect($content)->toContain('/bird/voice/calls/{id}:'); + expect($content)->toContain('/bird/voice/calls/{id}/answer:'); + expect($content)->toContain('/bird/voice/calls/{id}/ringing:'); + expect($content)->toContain('/bird/voice/calls/{id}/hangup:'); + expect($content)->toContain('/bird/voice/calls/{id}/playback:'); + expect($content)->toContain('/bird/voice/calls/{id}/say:'); + expect($content)->toContain('/bird/voice/calls/{id}/gather:'); + expect($content)->toContain('/bird/voice/calls/{id}/bridge:'); + expect($content)->toContain('/bird/voice/calls/{id}/record:'); + expect($content)->toContain('/bird/voice/calls/{id}/recordings:'); + expect($content)->toContain('/bird/voice/calls/{id}/recordings/{recordingId}:'); + expect($content)->toContain('/bird/voice/calls/{id}/insights:'); + expect($content)->toContain('/bird/voice/calls/test-outbound:'); + expect($content)->toContain('/bird/voice/calls/webhook/inbound:'); +}); + +it('documents flash hangup endpoint and marks end alias as deprecated', function (): void { + $content = bird_openapi_content_or_skip(); + $aliasPath = bird_openapi_path_block_or_fail($content, '/bird/voice/flash-calls/end'); + + expect($content)->toContain('/bird/voice/flash-calls:'); + expect($content)->toContain('/bird/voice/flash-calls/{id}:'); + expect($content)->toContain('/bird/voice/flash-calls/hangup:'); + expect($content)->toContain('/bird/voice/flash-calls/end:'); + expect($aliasPath)->toContain('deprecated: true'); + expect($aliasPath)->toContain('birdEndFlashCallByNumbers'); +}); + +it('defines request and response schemas for Bird call command recording insight log and flash payloads', function (): void { + $content = bird_openapi_content_or_skip(); + $webhookPath = bird_openapi_path_block_or_fail($content, '/bird/voice/calls/webhook/inbound'); + + expect($content)->toContain('BirdVoiceCallCommandResponse:'); + expect($content)->toContain('BirdVoiceCallBridgeResponse:'); + expect($content)->toContain('BirdVoiceCallRecording:'); + expect($content)->toContain('BirdVoiceCallRecordingListResponse:'); + expect($content)->toContain('BirdVoiceCallRecordingSingleResponse:'); + expect($content)->toContain('BirdVoiceCallInsightsResponse:'); + expect($content)->toContain('BirdVoiceCallsLogResponse:'); + expect($content)->toContain('BirdFlashCallHangupRequest:'); + expect($content)->toContain('BirdFlashCallHangupResponse:'); + + expect($content)->toContain('BirdVoiceCallCreateRequest:'); + expect($content)->toContain('BirdVoiceCallUpdateRequest:'); + expect($content)->toContain('BirdVoiceCallGatherRequest:'); + expect($content)->toContain('BirdVoiceCallRecordingUpdateRequest:'); + expect($content)->toContain('BirdFlashCallCreateRequest:'); + expect($content)->toContain('BirdFlashCallEndRequest:'); + expect($content)->toContain('BirdTestOutboundCallRequest:'); + expect($content)->toContain('BirdInboundCallWebhookRequest:'); + expect($content)->toContain('BirdInboundCallWebhookFlowGatherResponse:'); + expect($content)->toContain('BirdInboundCallWebhookGatherAcceptedResponse:'); + expect($content)->toContain('BirdInboundCallWebhookActionResultResponse:'); + + expect($webhookPath)->toContain('BirdInboundCallWebhookRequest'); + expect($webhookPath)->toContain('BirdInboundCallWebhookFlowGatherResponse'); + expect($webhookPath)->toContain('BirdInboundCallWebhookGatherAcceptedResponse'); + expect($webhookPath)->toContain('BirdInboundCallWebhookActionResultResponse'); + expect($webhookPath)->toContain("'202'"); + expect($webhookPath)->toContain("'400'"); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdPayloadClassesTest.php b/services/nginx/app/tests/Unit/Bird/BirdPayloadClassesTest.php new file mode 100644 index 00000000..e917d84c --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdPayloadClassesTest.php @@ -0,0 +1,156 @@ + 'hello', + 'loop' => '2', + 'timeout' => '4', + ])->toArray(); + + $withHangup = bird_voice_say_payload::fromArray([ + 'text' => 'hello', + 'hangup' => 'false', + 'unknown' => 'drop-me', + ])->toArray(); + + expect($withoutHangup)->toMatchArray([ + 'text' => 'hello', + 'loop' => 2, + 'timeout' => 4, + 'hangup' => true, + ]); + + expect($withHangup)->toMatchArray([ + 'text' => 'hello', + 'hangup' => false, + ]); + expect($withHangup)->not->toHaveKey('unknown'); +}); + +it('normalizes log query csv and integer fields', function (): void { + $query = bird_voice_calls_log_query_payload::fromArray([ + 'limit' => '10', + 'duration' => '60', + 'channelId' => '2cbde4fd-8899-4f2d-95ea-2ab7cc6b8c97,5a6bd8c1-32b7-4a03-8b5a-b6a4a9d7a8b2', + 'tag' => [' vip ', 'north', ''], + 'unknown' => 'drop-me', + ])->toArray(); + + expect($query['limit'])->toBe(10); + expect($query['duration'])->toBe(60); + expect($query['channelId'])->toBe([ + '2cbde4fd-8899-4f2d-95ea-2ab7cc6b8c97', + '5a6bd8c1-32b7-4a03-8b5a-b6a4a9d7a8b2', + ]); + expect($query['tag'])->toBe(['vip', 'north']); + expect($query)->not->toHaveKey('unknown'); +}); + +it('normalizes nested create-call payload sections and drops unknown keys', function (): void { + $payload = bird_voice_create_call_payload::fromArray([ + 'from' => '+4532330288', + 'to' => '+4542331128', + 'ringTimeout' => '12', + 'maxDuration' => '120', + 'record' => '1', + 'stereo' => '0', + 'tags' => [' vip ', ''], + 'notification' => [ + 'url' => 'https://example.com/webhook', + 'ignored' => 'x', + ], + 'amdSettings' => [ + 'enabled' => 'true', + 'wordCount' => '2', + 'ignored' => 'x', + ], + 'callFlow' => [ + [ + 'command' => 'say', + 'conditions' => [ + [ + 'variable' => 'keys', + 'operator' => 'eq', + 'value' => '1', + 'ignored' => 'x', + ], + ], + 'options' => ['text' => 'hello'], + 'ignored' => 'x', + ], + ], + 'ignored' => 'x', + ])->toArray(); + + expect($payload)->toMatchArray([ + 'from' => '+4532330288', + 'to' => '+4542331128', + 'ringTimeout' => 12, + 'maxDuration' => 120, + 'record' => true, + 'stereo' => false, + 'tags' => ['vip'], + 'notification' => ['url' => 'https://example.com/webhook'], + 'amdSettings' => [ + 'enabled' => true, + 'wordCount' => 2, + ], + 'callFlow' => [ + [ + 'command' => 'say', + 'conditions' => [ + [ + 'variable' => 'keys', + 'operator' => 'eq', + 'value' => '1', + ], + ], + 'options' => ['text' => 'hello'], + ], + ], + ]); + expect($payload)->not->toHaveKey('ignored'); +}); + +it('keeps gather nested say payload unchanged when hangup is omitted', function (): void { + $payload = bird_voice_gather_payload::fromArray([ + 'maxNumKeys' => '1', + 'say' => [ + 'text' => 'Press 1', + 'loop' => '3', + ], + ])->toArray(); + + expect($payload['maxNumKeys'])->toBe(1); + expect($payload['say'])->toMatchArray([ + 'text' => 'Press 1', + 'loop' => 3, + ]); + expect($payload['say'])->not->toHaveKey('hangup'); +}); + +it('supports no-body and flash hangup payload normalization', function (): void { + $empty = bird_no_body_payload::fromArray(['any' => 'value'])->toArray(); + $flashHangup = bird_flash_hangup_payload::fromArray([ + 'from' => '+4532330288', + 'to' => '+4542331128', + 'result' => 'verified', + 'ignored' => 'x', + ])->toArray(); + + expect($empty)->toBe([]); + expect($flashHangup)->toBe([ + 'from' => '+4532330288', + 'to' => '+4542331128', + 'result' => 'verified', + ]); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdRequestValidationTest.php b/services/nginx/app/tests/Unit/Bird/BirdRequestValidationTest.php new file mode 100644 index 00000000..8dd15621 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdRequestValidationTest.php @@ -0,0 +1,136 @@ +validateSchema($payload, $schema); + $this->forwardCalls++; + } + } +} + +beforeEach(function (): void { + global $response; + + $response = new class { + public function error(mixed $data, int $status = null): void + { + $encoded = is_string($data) ? $data : json_encode($data); + throw new RuntimeException('HTTP_' . (string)$status . ':' . $encoded); + } + }; +}); + +it('accepts valid payloads for create and nested call flow commands', function (): void { + $payload = [ + 'from' => '+4532330288', + 'to' => '+4542331128', + 'ringTimeout' => 20, + 'notification' => ['url' => 'https://example.com/webhook'], + 'callFlow' => [ + [ + 'command' => 'say', + 'conditions' => [ + ['variable' => 'keys', 'operator' => 'eq', 'value' => '1'], + ], + 'options' => ['text' => 'hello'], + ], + ], + ]; + + $errors = bird_request_validator::validate($payload, bird_request_schemas::voiceCreateCallBody()); + + expect($errors)->toBe([]); +}); + +it('rejects unknown fields and invalid enum values in strict schemas', function (): void { + $unknownErrors = bird_request_validator::validate( + ['to' => '+4542331128', 'unknownField' => 'x'], + bird_request_schemas::voiceCreateCallBody() + ); + $enumErrors = bird_request_validator::validate( + ['cause' => 'completed'], + bird_request_schemas::voiceHangupBody() + ); + + expect($unknownErrors)->not->toBe([]); + expect(implode(' | ', $unknownErrors))->toContain('$.unknownField is not allowed'); + + expect($enumErrors)->not->toBe([]); + expect(implode(' | ', $enumErrors))->toContain('$.cause must be one of: rejected, busy'); +}); + +it('supports CSV normalization in schema validation for log filters', function (): void { + $validQuery = [ + 'channelId' => '2cbde4fd-8899-4f2d-95ea-2ab7cc6b8c97,5a6bd8c1-32b7-4a03-8b5a-b6a4a9d7a8b2', + 'tag' => 'north,vip', + ]; + $invalidQuery = [ + 'channelId' => 'not-a-uuid', + ]; + + $validErrors = bird_request_validator::validate($validQuery, bird_request_schemas::voiceCallsLogQuery()); + $invalidErrors = bird_request_validator::validate($invalidQuery, bird_request_schemas::voiceCallsLogQuery()); + + expect($validErrors)->toBe([]); + expect($invalidErrors)->not->toBe([]); + expect(implode(' | ', $invalidErrors))->toContain('$.channelId[0] must be a UUID'); +}); + +it('validates flash hangup payloads for both supported request shapes', function (): void { + $resultMode = [ + 'result' => 'verified', + 'receivedCli' => '+4542331128', + ]; + $numbersMode = [ + 'from' => '+4532330288', + 'to' => '+4542331128', + 'result' => 'verified', + ]; + $invalid = [ + 'result' => 'ok', + ]; + + $resultErrors = bird_request_validator::validate($resultMode, bird_request_schemas::flashHangupBody()); + $numbersErrors = bird_request_validator::validate($numbersMode, bird_request_schemas::flashHangupBody()); + $invalidErrors = bird_request_validator::validate($invalid, bird_request_schemas::flashHangupBody()); + + expect($resultErrors)->toBe([]); + expect($numbersErrors)->toBe([]); + expect($invalidErrors)->not->toBe([]); + expect(implode(' | ', $invalidErrors))->toContain('does not match any of the allowed schemas'); +}); + +it('fails before forward step when strict validation fails and forwards when valid', function (): void { + $harness = new BirdRouteValidationHarness(); + + try { + $harness->validateThenForward(['unexpected' => true], bird_request_schemas::noBody()); + $caught = null; + } catch (RuntimeException $e) { + $caught = $e; + } + + expect($caught)->toBeInstanceOf(RuntimeException::class); + expect($caught?->getMessage())->toContain('HTTP_400'); + expect($harness->forwardCalls)->toBe(0); + + $harness->validateThenForward([], bird_request_schemas::noBody()); + expect($harness->forwardCalls)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdRouteWiringTest.php b/services/nginx/app/tests/Unit/Bird/BirdRouteWiringTest.php new file mode 100644 index 00000000..97a791be --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdRouteWiringTest.php @@ -0,0 +1,84 @@ +not->toBeFalse(); + expect($content)->toContain('/bird/voice/calls'); + expect($content)->toContain('/bird/voice/calls/log'); + expect($content)->toContain('/bird/voice/calls/{id}'); + expect($content)->toContain('/bird/voice/calls/{id}/answer'); + expect($content)->toContain('/bird/voice/calls/{id}/ringing'); + expect($content)->toContain('/bird/voice/calls/{id}/hangup'); + expect($content)->toContain('/bird/voice/calls/{id}/playback'); + expect($content)->toContain('/bird/voice/calls/{id}/say'); + expect($content)->toContain('/bird/voice/calls/{id}/gather'); + expect($content)->toContain('/bird/voice/calls/{id}/bridge'); + expect($content)->toContain('/bird/voice/calls/{id}/record'); + expect($content)->toContain('/bird/voice/calls/{id}/recordings'); + expect($content)->toContain('/bird/voice/calls/{id}/recordings/{recordingId}'); + expect($content)->toContain('/bird/voice/calls/{id}/insights'); + expect($content)->toContain('/bird/voice/calls/test-outbound'); + + expect($content)->toContain('modules_bird_voice_calls_create'); + expect($content)->toContain('modules_bird_voice_calls_list'); + expect($content)->toContain('modules_bird_voice_calls_get'); + expect($content)->toContain('modules_bird_voice_calls_update'); + expect($content)->toContain('modules_bird_voice_calls_answer'); + expect($content)->toContain('modules_bird_voice_calls_ringing'); + expect($content)->toContain('modules_bird_voice_calls_hangup'); + expect($content)->toContain('modules_bird_voice_calls_playback'); + expect($content)->toContain('modules_bird_voice_calls_say'); + expect($content)->toContain('modules_bird_voice_calls_gather'); + expect($content)->toContain('modules_bird_voice_calls_bridge'); + expect($content)->toContain('modules_bird_voice_calls_record'); + expect($content)->toContain('modules_bird_voice_calls_recordings_create'); + expect($content)->toContain('modules_bird_voice_calls_recordings_list'); + expect($content)->toContain('modules_bird_voice_calls_recordings_get'); + expect($content)->toContain('modules_bird_voice_calls_recordings_update'); + expect($content)->toContain('modules_bird_voice_calls_insights_get'); + expect($content)->toContain('modules_bird_voice_calls_log_list'); + expect($content)->toContain('modules_bird_voice_calls_test_outbound'); + + expect($content)->toContain('birdValidateSchema'); + expect($content)->toContain('birdResolveWorkspaceAndChannelIds'); + expect($content)->toContain('birdResolveRouteCallId'); + expect($content)->toContain('birdResolveRouteRecordingId'); + expect($content)->toContain('bird_voice_create_call_payload::fromArray'); + expect($content)->toContain('bird_voice_say_payload::fromArray'); + expect($content)->toContain('bird_voice_calls_log_query_payload::fromArray'); +}); + +it('registers flash hangup endpoint and keeps end alias wired as compatibility path', function (): void { + $content = file_get_contents(app_path('routes/birdVoiceFlashCallsRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/bird/voice/flash-calls'); + expect($content)->toContain('/bird/voice/flash-calls/{id}'); + expect($content)->toContain('/bird/voice/flash-calls/hangup'); + expect($content)->toContain('/bird/voice/flash-calls/end'); + + expect($content)->toContain('modules_bird_voice_flash_calls_create'); + expect($content)->toContain('modules_bird_voice_flash_calls_list'); + expect($content)->toContain('modules_bird_voice_flash_calls_get'); + expect($content)->toContain('modules_bird_voice_flash_calls_end'); + expect($content)->toContain('modules_bird_voice_flash_calls_hangup'); + expect($content)->toContain('modules_bird_voice_flash_calls_end_by_numbers'); + + expect($content)->toContain('flashHangupBody'); + expect($content)->toContain('hangupFlashCall($workspaceId, $channelId, $payload)'); + expect($content)->toContain('bird_flash_hangup_payload::fromArray'); + expect($content)->toContain('bird_flash_create_payload::fromArray'); +}); + +it('keeps Bird number and webhook routes intact', function (): void { + $numbers = file_get_contents(app_path('routes/birdNumbersRoute.php')); + $webhooks = file_get_contents(app_path('routes/birdVoiceWebhooksRoute.php')); + + expect($numbers)->not->toBeFalse(); + expect($numbers)->toContain('/bird/numbers'); + expect($numbers)->toContain('/bird/numbers/{id}'); + + expect($webhooks)->not->toBeFalse(); + expect($webhooks)->toContain('/bird/voice/calls/webhook/inbound'); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdVoiceWebhooksRouteCallLifecycleTest.php b/services/nginx/app/tests/Unit/Bird/BirdVoiceWebhooksRouteCallLifecycleTest.php new file mode 100644 index 00000000..b7b38082 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdVoiceWebhooksRouteCallLifecycleTest.php @@ -0,0 +1,659 @@ +answerErrorMessage !== null) { + throw new RuntimeException($this->answerErrorMessage); + } + + $this->answerPayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload'); + + return ['status' => 'accepted']; + } + + public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->gatherPayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload'); + + return ['status' => 'unexpected']; + } + + public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null + { + $this->updatePayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload'); + + return ['status' => 'unexpected']; + } +} + +final class BirdVoiceWebhookLifecycleGateFake extends department_gates_o +{ + public function __construct( + int $id, + private readonly bool $existsFlag = true, + ) { + $this->id = $id; + } + + public function exists(): bool + { + return $this->existsFlag; + } +} + +final class BirdVoiceWebhookLifecycleRouteTestDouble extends \routes\birdVoiceWebhooksRoute +{ + public array $rawState = []; + public array $ttls = []; + public array $activeLocks = []; + public bool $lockAvailable = true; + public ?BirdVoiceWebhookLifecycleBirdClientFake $lastClient = null; + public array $eligibleDepartments = []; + public array $gatesByDepartmentAndType = []; + public array $openedGates = []; + public ?string $gateOpenErrorMessage = null; + + public function __construct() + { + $this->eligibleDepartments = [ + [ + 'department_id' => 11, + 'department_name' => 'Nord', + 'order_priority' => 1, + 'has_entrance_gate' => true, + 'has_exit_gate' => true, + ], + [ + 'department_id' => 22, + 'department_name' => 'Syd', + 'order_priority' => 2, + 'has_entrance_gate' => true, + 'has_exit_gate' => false, + ], + ]; + + $this->gatesByDepartmentAndType = [ + '11:entrance' => new BirdVoiceWebhookLifecycleGateFake(1101), + '11:exit' => new BirdVoiceWebhookLifecycleGateFake(1102), + '22:entrance' => new BirdVoiceWebhookLifecycleGateFake(2201), + ]; + } + + public function runLifecycle(array $payload, string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array + { + $client = $this->lastClient ?? new BirdVoiceWebhookLifecycleBirdClientFake(); + $this->lastClient = $client; + + return $this->handleInboundCallLifecycle($client, $payload, $callId, $workspaceId, $channelId); + } + + public function loadState(string $callId): ?array + { + return $this->readIvrState($callId); + } + + protected function loadEligibleDepartmentSummaries(): array + { + return $this->eligibleDepartments; + } + + protected function resolvePhoneCallGate(int $departmentId, string $gateType): ?department_gates_o + { + return $this->gatesByDepartmentAndType[$departmentId . ':' . $gateType] ?? null; + } + + protected function resolvePhoneCallGateById(int $gateId): ?department_gates_o + { + foreach ($this->gatesByDepartmentAndType as $gate) { + if ((int)$gate->id === $gateId) { + return $gate; + } + } + + return null; + } + + protected function shouldAcceptInboundCall(bird $client): bool + { + return true; + } + + protected function triggerGateOpen(department_gates_o $gate): void + { + if ($this->gateOpenErrorMessage !== null) { + throw new RuntimeException($this->gateOpenErrorMessage); + } + + $this->openedGates[] = (int)$gate->id; + } + + protected function writeIvrStateRaw(string $key, string $value, int $ttlSeconds): void + { + $this->rawState[$key] = $value; + $this->ttls[$key] = $ttlSeconds; + } + + protected function readIvrStateRaw(string $key): ?string + { + return $this->rawState[$key] ?? null; + } + + protected function deleteIvrStateRaw(string $key): void + { + unset($this->rawState[$key], $this->ttls[$key]); + } + + protected function acquireIvrLockRaw(string $key, int $ttlSeconds): bool + { + if (!$this->lockAvailable || isset($this->activeLocks[$key])) { + return false; + } + + $this->activeLocks[$key] = ['ttl' => $ttlSeconds]; + + return true; + } + + protected function releaseIvrLockRaw(string $key): void + { + unset($this->activeLocks[$key]); + } +} + +function bird_webhook_initial_payload(string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array +{ + return [ + 'payload' => [ + 'endKey' => '#', + 'input' => 'speech', + 'maxNumKeys' => 9, + 'retries' => 3, + 'speechLocale' => 'en-US', + 'timeout' => 30, + 'say' => [ + 'locale' => 'en-US', + 'voice' => 'female', + ], + ], + 'request' => [ + 'callId' => $callId, + 'channelId' => $channelId, + 'workspaceId' => $workspaceId, + ], + 'requestId' => 'request-123', + 'waitConditions' => [ + 'events' => [ + [ + 'action' => 'continue', + 'name' => 'call_command_gather_finished', + ], + ], + 'timeout' => 'PT10M', + ], + ]; +} + +function bird_webhook_resumed_payload(?string $eventKeys, ?string $fallbackKeys = null, string $callId = 'call_1', string $channelId = 'channel_1'): array +{ + $payload = [ + 'event' => [ + 'callCommand' => [ + 'callId' => $callId, + 'channelId' => $channelId, + 'gather' => [], + ], + ], + 'resumeData' => [ + 'action' => 'continue', + ], + ]; + + if ($eventKeys !== null) { + $payload['event']['callCommand']['gather']['keys'] = $eventKeys; + } + + if ($fallbackKeys !== null) { + $payload['result'] = ['keys' => $fallbackKeys]; + } + + return $payload; +} + +function bird_webhook_legacy_initial_payload(string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array +{ + return [ + 'callId' => $callId, + 'channelId' => $channelId, + 'workspaceId' => $workspaceId, + ]; +} + +function bird_webhook_flow_selection_payload(string $keys, string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array +{ + return [ + 'callId' => $callId, + 'channelId' => $channelId, + 'workspaceId' => $workspaceId, + 'keys' => $keys, + ]; +} + +it('returns a raw 202 gather response for initial department selection', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + + $result = $route->runLifecycle(bird_webhook_initial_payload()); + + expect($result['statusCode'])->toBe(202); + expect($result)->not->toHaveKey('success'); + expect($result['result']['status'] ?? null)->toBe('accepted'); + expect($result['event']['callCommand']['type'] ?? null)->toBe('gather'); + expect($result['event']['callCommand']['gather']['input'] ?? null)->toBe('dtmf'); + expect($result['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1); + expect($result['event']['callCommand']['gather']['retries'] ?? null)->toBe(3); + expect($result['event']['callCommand']['gather']['timeout'] ?? null)->toBe(30); + expect($result['event']['callCommand']['gather']['say']['locale'] ?? null)->toBe('en-US'); + expect($result['event']['callCommand']['gather']['say']['voice'] ?? null)->toBe('female'); + expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.'); + expect($route->lastClient?->answerPayloads ?? [])->toBe([ + [ + 'workspaceId' => 'workspace_1', + 'channelId' => 'channel_1', + 'callId' => 'call_1', + 'payload' => [], + ], + ]); + expect($route->lastClient?->gatherPayloads ?? [])->toBe([]); + expect($route->lastClient?->updatePayloads ?? [])->toBe([]); + + $state = $route->loadState('call_1'); + expect($state)->not->toBeNull(); + expect($state['stage'] ?? null)->toBe('department_select'); + expect($state['request_id'] ?? null)->toBe('request-123'); +}); + +it('accepts legacy initial webhook payloads with top-level call identifiers', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + + $result = $route->runLifecycle(bird_webhook_legacy_initial_payload()); + + expect($result['statusCode'])->toBe(200); + expect($result['status'])->toBe('gather'); + expect($result['completed'])->toBeFalse(); + expect($result['stage'])->toBe('department_select'); + expect($result['gather']['input'] ?? null)->toBe('dtmf'); + expect($result['gather']['maxNumKeys'] ?? null)->toBe(1); + expect($result['gather']['retries'] ?? null)->toBe(3); + expect($result['gather']['timeout'] ?? null)->toBe(30); + expect($result['gather']['endKey'] ?? null)->toBe('#'); + expect($result['gatherInput'] ?? null)->toBe('dtmf'); + expect($result['gatherMaxNumKeys'] ?? null)->toBe(1); + expect($result['gatherRetries'] ?? null)->toBe(3); + expect($result['gatherTimeout'] ?? null)->toBe(30); + expect($result['gatherEndKey'] ?? null)->toBe('#'); + expect($result['gatherSayLocale'] ?? null)->toBe('en-US'); + expect($result['gatherSayVoice'] ?? null)->toBe('female'); + expect($result['gather']['say']['locale'] ?? null)->toBe('en-US'); + expect($result['gather']['say']['voice'] ?? null)->toBe('female'); + expect($result['gather']['say']['text'] ?? null)->toContain('Choose department.'); + expect($result['prompt'] ?? null)->toContain('Choose department.'); + expect($route->lastClient?->answerPayloads ?? [])->toBe([ + [ + 'workspaceId' => 'workspace_1', + 'channelId' => 'channel_1', + 'callId' => 'call_1', + 'payload' => [], + ], + ]); + + $state = $route->loadState('call_1'); + expect($state)->not->toBeNull(); + expect($state['stage'] ?? null)->toBe('department_select'); + expect($state['resume_action'] ?? null)->toBe('continue'); + expect($state['wait_timeout'] ?? null)->toBe('PT10M'); +}); + +it('returns a flow-data gather payload after department selection in native flow mode when distinct gate choices exist', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + + $route->runLifecycle(bird_webhook_legacy_initial_payload()); + $result = $route->runLifecycle(bird_webhook_flow_selection_payload('1')); + + expect($result['statusCode'])->toBe(200); + expect($result['status'])->toBe('gather'); + expect($result['completed'])->toBeFalse(); + expect($result['stage'])->toBe('gate_type_select'); + expect($result['prompt'] ?? null)->toContain('You selected Nord.'); + expect($result['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.'); + expect($result['selection']['departmentId'] ?? null)->toBe(11); + expect($result['selection']['departmentName'] ?? null)->toBe('Nord'); + + $state = $route->loadState('call_1'); + expect($state)->not->toBeNull(); + expect($state['stage'] ?? null)->toBe('gate_type_select'); + expect($state['selected_department_id'] ?? null)->toBe(11); +}); + +it('opens the gate immediately after department selection when entrance and exit resolve to the same gate in native flow mode', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + $route->eligibleDepartments = [ + [ + 'department_id' => 44, + 'department_name' => 'Shared', + 'order_priority' => 1, + 'has_entrance_gate' => true, + 'has_exit_gate' => true, + ], + ]; + $sharedGate = new BirdVoiceWebhookLifecycleGateFake(4401); + $route->gatesByDepartmentAndType = [ + '44:entrance' => $sharedGate, + '44:exit' => $sharedGate, + ]; + + $route->runLifecycle(bird_webhook_legacy_initial_payload()); + $result = $route->runLifecycle(bird_webhook_flow_selection_payload('1')); + + expect($result['statusCode'])->toBe(200); + expect($result['status'] ?? null)->toBe('completed'); + expect($result['completed'] ?? null)->toBeTrue(); + expect($result['action'] ?? null)->toBe('gate_opened'); + expect($result['gateOpened'] ?? null)->toBeTrue(); + expect($result['departmentId'] ?? null)->toBe(44); + expect($result['departmentName'] ?? null)->toBe('Shared'); + expect($result['gateType'] ?? null)->toBeNull(); + expect($result['gateId'] ?? null)->toBe(4401); + expect($route->openedGates)->toBe([4401]); + expect($route->loadState('call_1'))->toBeNull(); +}); + +it('returns a flow-data completion payload after gate confirmation in native flow mode', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + + $route->runLifecycle(bird_webhook_legacy_initial_payload()); + $route->runLifecycle(bird_webhook_flow_selection_payload('1')); + $result = $route->runLifecycle(bird_webhook_flow_selection_payload('2')); + + expect($result['statusCode'])->toBe(200); + expect($result['status'] ?? null)->toBe('completed'); + expect($result['completed'] ?? null)->toBeTrue(); + expect($result['action'] ?? null)->toBe('gate_opened'); + expect($result['gateOpened'] ?? null)->toBeTrue(); + expect($result['departmentId'] ?? null)->toBe(11); + expect($result['departmentName'] ?? null)->toBe('Nord'); + expect($result['gateType'] ?? null)->toBe('exit'); + expect($result['gateId'] ?? null)->toBe(1102); + expect($route->openedGates)->toBe([1102]); + expect($route->loadState('call_1'))->toBeNull(); +}); + +it('returns a second-stage raw 202 gather response after department selection when distinct gate choices exist', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + + $route->runLifecycle(bird_webhook_initial_payload()); + $result = $route->runLifecycle(bird_webhook_resumed_payload('1')); + + expect($result['statusCode'])->toBe(202); + expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Nord.'); + expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.'); + + $state = $route->loadState('call_1'); + expect($state)->not->toBeNull(); + expect($state['stage'] ?? null)->toBe('gate_type_select'); + expect($state['selected_department_id'] ?? null)->toBe(11); + expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance', '2' => 'exit']); + expect($route->lastClient?->answerPayloads ?? [])->toHaveCount(1); + expect($route->openedGates)->toBe([]); +}); + +it('opens the shared gate immediately after department selection in raw command mode', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + $route->eligibleDepartments = [ + [ + 'department_id' => 44, + 'department_name' => 'Shared', + 'order_priority' => 1, + 'has_entrance_gate' => true, + 'has_exit_gate' => true, + ], + ]; + $sharedGate = new BirdVoiceWebhookLifecycleGateFake(4401); + $route->gatesByDepartmentAndType = [ + '44:entrance' => $sharedGate, + '44:exit' => $sharedGate, + ]; + + $route->runLifecycle(bird_webhook_initial_payload()); + $result = $route->runLifecycle(bird_webhook_resumed_payload('1')); + + expect($result['statusCode'])->toBe(200); + expect($result['result']['action'] ?? null)->toBe('gate_opened'); + expect($result['result']['gateOpened'] ?? null)->toBeTrue(); + expect($result['result']['departmentId'] ?? null)->toBe(44); + expect($result['result']['gateType'] ?? null)->toBeNull(); + expect($result['result']['gateId'] ?? null)->toBe(4401); + expect($route->openedGates)->toBe([4401]); + expect($route->loadState('call_1'))->toBeNull(); +}); + +it('opens the selected gate and clears redis state on final selection', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + + $route->runLifecycle(bird_webhook_initial_payload()); + $route->runLifecycle(bird_webhook_resumed_payload('1')); + $result = $route->runLifecycle(bird_webhook_resumed_payload('2')); + + expect($result['statusCode'])->toBe(200); + expect($result['statusText'])->toBe('OK'); + expect($result['result']['action'] ?? null)->toBe('gate_opened'); + expect($result['result']['gateOpened'] ?? null)->toBeTrue(); + expect($result['result']['departmentId'] ?? null)->toBe(11); + expect($result['result']['gateType'] ?? null)->toBe('exit'); + expect($result['result']['gateId'] ?? null)->toBe(1102); + expect($route->openedGates)->toBe([1102]); + expect($route->loadState('call_1'))->toBeNull(); +}); + +it('reprompts with invalid selection while keeping webhook state', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + + $route->runLifecycle(bird_webhook_initial_payload()); + $result = $route->runLifecycle(bird_webhook_resumed_payload('9')); + + expect($result['statusCode'])->toBe(202); + expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Invalid selection.'); + expect($route->openedGates)->toBe([]); + + $state = $route->loadState('call_1'); + expect($state)->not->toBeNull(); + expect($state['stage'] ?? null)->toBe('department_select'); + expect($state['invalid_selection_count'] ?? 0)->toBe(1); +}); + +it('uses fallback dtmf extraction when event gather keys are missing', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + + $route->runLifecycle(bird_webhook_initial_payload()); + $result = $route->runLifecycle(bird_webhook_resumed_payload(null, '2')); + + expect($result['statusCode'])->toBe(202); + expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Syd.'); + expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.'); + expect($route->openedGates)->toBe([]); + + $state = $route->loadState('call_1'); + expect($state)->not->toBeNull(); + expect($state['selected_department_id'] ?? null)->toBe(22); + expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance']); +}); + +it('prompts for department selection even when only one eligible department exists', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + $route->eligibleDepartments = [ + [ + 'department_id' => 44, + 'department_name' => 'Solo', + 'order_priority' => 1, + 'has_entrance_gate' => true, + 'has_exit_gate' => false, + ], + ]; + $route->gatesByDepartmentAndType = [ + '44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401), + ]; + + $result = $route->runLifecycle(bird_webhook_initial_payload()); + + expect($result['statusCode'])->toBe(202); + expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.'); + expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for Solo.'); + expect($route->openedGates)->toBe([]); + + $state = $route->loadState('call_1'); + expect($state)->not->toBeNull(); + expect($state['stage'] ?? null)->toBe('department_select'); +}); + +it('prompts for a single available gate type and opens only after explicit confirmation', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + $route->eligibleDepartments = [ + [ + 'department_id' => 44, + 'department_name' => 'Solo', + 'order_priority' => 1, + 'has_entrance_gate' => true, + 'has_exit_gate' => false, + ], + ]; + $route->gatesByDepartmentAndType = [ + '44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401), + ]; + + $route->runLifecycle(bird_webhook_initial_payload()); + $gatePrompt = $route->runLifecycle(bird_webhook_resumed_payload('1')); + + expect($gatePrompt['statusCode'])->toBe(202); + expect($gatePrompt['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Solo.'); + expect($gatePrompt['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.'); + expect($route->openedGates)->toBe([]); + + $result = $route->runLifecycle(bird_webhook_resumed_payload('1')); + + expect($result['statusCode'])->toBe(200); + expect($result['result']['action'] ?? null)->toBe('gate_opened'); + expect($result['result']['gateId'] ?? null)->toBe(4401); + expect($route->openedGates)->toBe([4401]); + expect($route->loadState('call_1'))->toBeNull(); +}); + +it('supports multi-digit department selections before gate confirmation', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + $route->eligibleDepartments = []; + $route->gatesByDepartmentAndType = []; + + for ($index = 1; $index <= 10; $index++) { + $departmentId = 100 + $index; + $route->eligibleDepartments[] = [ + 'department_id' => $departmentId, + 'department_name' => 'Department ' . $index, + 'order_priority' => $index, + 'has_entrance_gate' => true, + 'has_exit_gate' => false, + ]; + $route->gatesByDepartmentAndType[$departmentId . ':entrance'] = new BirdVoiceWebhookLifecycleGateFake(1000 + $index); + } + + $initial = $route->runLifecycle(bird_webhook_initial_payload()); + + expect($initial['statusCode'])->toBe(202); + expect($initial['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(2); + expect($initial['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Enter the option number followed by pound.'); + expect($initial['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 10 for Department 10.'); + + $selected = $route->runLifecycle(bird_webhook_resumed_payload('10#')); + + expect($selected['statusCode'])->toBe(202); + expect($selected['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Department 10.'); + expect($selected['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.'); + + $state = $route->loadState('call_1'); + expect($state)->not->toBeNull(); + expect($state['selected_department_id'] ?? null)->toBe(110); + expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance']); +}); + +it('returns a direct raw 200 completion when no departments are eligible', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + $route->eligibleDepartments = []; + + $result = $route->runLifecycle(bird_webhook_initial_payload()); + + expect($result['statusCode'])->toBe(200); + expect($result['result']['action'] ?? null)->toBe('no_action'); + expect($result['result']['message'] ?? null)->toContain('No phone-controlled gates are configured'); + expect($route->lastClient?->answerPayloads ?? [])->toHaveCount(1); + expect($route->openedGates)->toBe([]); +}); + +it('continues with the first gather prompt when backend call acceptance fails', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + $route->lastClient = new BirdVoiceWebhookLifecycleBirdClientFake(); + $route->lastClient->answerErrorMessage = 'already answered'; + + $result = $route->runLifecycle(bird_webhook_initial_payload()); + + expect($result['statusCode'])->toBe(202); + expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.'); + expect($route->lastClient?->answerPayloads ?? [])->toBe([]); + + $state = $route->loadState('call_1'); + expect($state)->not->toBeNull(); + expect($state['stage'] ?? null)->toBe('department_select'); +}); + +it('returns a business-failure raw 200 response when gate opening fails', function (): void { + $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); + $route->eligibleDepartments = [ + [ + 'department_id' => 44, + 'department_name' => 'Solo', + 'order_priority' => 1, + 'has_entrance_gate' => true, + 'has_exit_gate' => false, + ], + ]; + $route->gatesByDepartmentAndType = [ + '44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401), + ]; + $route->gateOpenErrorMessage = 'relay offline'; + + $route->runLifecycle(bird_webhook_initial_payload()); + $route->runLifecycle(bird_webhook_resumed_payload('1')); + $result = $route->runLifecycle(bird_webhook_resumed_payload('1')); + + expect($result['statusCode'])->toBe(200); + expect($result['result']['status'] ?? null)->toBe('failed'); + expect($result['result']['action'] ?? null)->toBe('gate_open_failed'); + expect($result['result']['gateOpened'] ?? null)->toBeFalse(); + expect($result['result']['message'] ?? null)->toContain('relay offline'); + expect($route->loadState('call_1'))->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdVoiceWebhooksRouteIvrTest.php b/services/nginx/app/tests/Unit/Bird/BirdVoiceWebhooksRouteIvrTest.php index b12019e8..737f73c6 100644 --- a/services/nginx/app/tests/Unit/Bird/BirdVoiceWebhooksRouteIvrTest.php +++ b/services/nginx/app/tests/Unit/Bird/BirdVoiceWebhooksRouteIvrTest.php @@ -25,14 +25,29 @@ final class BirdVoiceWebhookRouteTestDouble extends \routes\birdVoiceWebhooksRou return $this->buildGateTypePromptText($name); } + public function gatePromptForOptions(string $name, array $options): string + { + return $this->buildGateTypePromptText($name, $options); + } + public function selectDepartment(array $map, string $digit): ?int { return $this->resolveDepartmentIdByDigit($map, $digit); } - public function selectGateType(string $digit): ?string + public function selectGateType(string $digit, array $options = []): ?string { - return $this->resolveGateTypeByDigit($digit); + return $this->resolveGateTypeByDigit($digit, $options); + } + + public function mapGateOptions(array $types): array + { + return $this->buildGateOptionMap($types); + } + + public function normalizeMenuDigit(?string $input): ?string + { + return $this->normalizeMenuDigitInput($input); } public function storeState(string $callId, array $state): void @@ -50,11 +65,6 @@ final class BirdVoiceWebhookRouteTestDouble extends \routes\birdVoiceWebhooksRou $this->clearIvrState($callId); } - public function stateForCaller(array $state, int $countryCode, int $phone): bool - { - return $this->isStateForCaller($state, $countryCode, $phone); - } - public function stateKey(string $callId): string { return $this->ivrStateKey($callId); @@ -62,7 +72,7 @@ final class BirdVoiceWebhookRouteTestDouble extends \routes\birdVoiceWebhooksRou protected function getDepartmentNameById(int $departmentId): string { - return $this->departmentNames[$departmentId] ?? ('Afdeling ' . $departmentId); + return $this->departmentNames[$departmentId] ?? ('Department ' . $departmentId); } protected function writeIvrStateRaw(string $key, string $value, int $ttlSeconds): void @@ -82,20 +92,50 @@ final class BirdVoiceWebhookRouteTestDouble extends \routes\birdVoiceWebhooksRou } } -it('builds deterministic department digit map and caps to 9 options', function (): void { +it('builds deterministic department digit map without capping options', function (): void { $route = new BirdVoiceWebhookRouteTestDouble(); - $route->departmentNames = [11 => 'Nord', 22 => 'Syd', 33 => 'Vest']; + $route->departmentNames = [ + 11 => 'Nord', + 22 => 'Syd', + 33 => 'Vest', + 44 => 'Ost', + 55 => 'Midt', + 66 => 'Aalborg', + 77 => 'Odense', + 88 => 'Esbjerg', + 99 => 'Kolding', + 111 => 'Roskilde', + ]; - $map = $route->mapDepartments([11, 22, 0, -5, 33, 44, 55, 66, 77, 88, 99]); + $map = $route->mapDepartments([ + ['department_id' => 11, 'department_name' => 'Nord', 'has_entrance_gate' => true, 'has_exit_gate' => false], + ['department_id' => 22, 'department_name' => 'Syd', 'has_entrance_gate' => false, 'has_exit_gate' => true], + 0, + -5, + ['department_id' => 33, 'department_name' => 'Vest', 'has_entrance_gate' => true, 'has_exit_gate' => true], + 44, + 55, + 66, + 77, + 88, + 99, + 111, + ]); - expect($map)->toHaveCount(9); - expect(array_keys($map))->toBe(['1', '2', '3', '4', '5', '6', '7', '8', '9']); + expect($map)->toHaveCount(10); + expect(array_keys($map))->toBe([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); expect($map['1']['department_id'])->toBe(11); expect($map['2']['department_id'])->toBe(22); expect($map['3']['department_id'])->toBe(33); + expect($map['10']['department_id'])->toBe(111); expect($map['1']['department_name'])->toBe('Nord'); expect($map['2']['department_name'])->toBe('Syd'); expect($map['3']['department_name'])->toBe('Vest'); + expect($map['10']['department_name'])->toBe('Roskilde'); + expect($map['1']['has_entrance_gate'])->toBeTrue(); + expect($map['1']['has_exit_gate'])->toBeFalse(); + expect($map['2']['has_entrance_gate'])->toBeFalse(); + expect($map['2']['has_exit_gate'])->toBeTrue(); }); it('resolves department ids by selected digit', function (): void { @@ -107,17 +147,27 @@ it('resolves department ids by selected digit', function (): void { expect($route->selectDepartment($map, '1'))->toBe(101); expect($route->selectDepartment($map, '2'))->toBe(202); + expect($route->selectDepartment($map + ['10' => ['department_id' => 1001, 'department_name' => 'J']], '10'))->toBe(1001); expect($route->selectDepartment($map, '9'))->toBeNull(); expect($route->selectDepartment($map, ''))->toBeNull(); }); -it('resolves gate type digit to entrance or exit', function (): void { +it('builds compact gate option maps and resolves selected gate type by digit', function (): void { $route = new BirdVoiceWebhookRouteTestDouble(); + $both = $route->mapGateOptions(['entrance', 'exit']); + $exitOnly = $route->mapGateOptions(['exit']); + $entranceOnly = $route->mapGateOptions(['entrance']); - expect($route->selectGateType('1'))->toBe('entrance'); - expect($route->selectGateType('2'))->toBe('exit'); - expect($route->selectGateType('0'))->toBeNull(); - expect($route->selectGateType('x'))->toBeNull(); + expect($both)->toBe(['1' => 'entrance', '2' => 'exit']); + expect($exitOnly)->toBe(['1' => 'exit']); + expect($entranceOnly)->toBe(['1' => 'entrance']); + + expect($route->selectGateType('1', $both))->toBe('entrance'); + expect($route->selectGateType('2', $both))->toBe('exit'); + expect($route->selectGateType('1', $exitOnly))->toBe('exit'); + expect($route->selectGateType('2', $exitOnly))->toBeNull(); + expect($route->selectGateType('0', $both))->toBeNull(); + expect($route->selectGateType('x', $both))->toBeNull(); }); it('stores, loads and clears ivr state with ttl', function (): void { @@ -126,41 +176,56 @@ it('stores, loads and clears ivr state with ttl', function (): void { $state = [ 'stage' => 'department_select', 'department_options' => ['1' => ['department_id' => 5, 'department_name' => 'North']], - 'country_code' => 45, - 'phone' => 12345678, + 'gate_options' => ['1' => 'entrance'], + 'gather_template' => ['timeout' => 30], ]; $route->storeState($callId, $state); $key = $route->stateKey($callId); expect($route->loadState($callId))->toBe($state); - expect($route->ttls[$key] ?? null)->toBe(600); + expect($route->ttls[$key] ?? null)->toBe(1200); $route->removeState($callId); expect($route->loadState($callId))->toBeNull(); }); -it('validates state caller fingerprint matching', function (): void { - $route = new BirdVoiceWebhookRouteTestDouble(); - $state = ['country_code' => 45, 'phone' => 12345678]; - - expect($route->stateForCaller($state, 45, 12345678))->toBeTrue(); - expect($route->stateForCaller($state, 46, 12345678))->toBeFalse(); - expect($route->stateForCaller($state, 45, 87654321))->toBeFalse(); -}); - -it('builds department and gate prompts', function (): void { +it('builds department prompts with multi-digit guidance and compact gate prompts', function (): void { $route = new BirdVoiceWebhookRouteTestDouble(); $prompt = $route->departmentPrompt([ '1' => ['department_id' => 1, 'department_name' => 'Nord'], '2' => ['department_id' => 2, 'department_name' => 'Syd'], + '3' => ['department_id' => 3, 'department_name' => 'Vest'], + '4' => ['department_id' => 4, 'department_name' => 'Ost'], + '5' => ['department_id' => 5, 'department_name' => 'Midt'], + '6' => ['department_id' => 6, 'department_name' => 'Aalborg'], + '7' => ['department_id' => 7, 'department_name' => 'Odense'], + '8' => ['department_id' => 8, 'department_name' => 'Esbjerg'], + '9' => ['department_id' => 9, 'department_name' => 'Kolding'], + '10' => ['department_id' => 10, 'department_name' => 'Roskilde'], ]); - expect($prompt)->toContain('Tast 1 for Nord'); - expect($prompt)->toContain('Tast 2 for Syd'); + expect($prompt)->toContain('Choose department.'); + expect($prompt)->toContain('Enter the option number followed by pound.'); + expect($prompt)->toContain('Press 1 for Nord'); + expect($prompt)->toContain('Press 10 for Roskilde'); - $gatePrompt = $route->gatePrompt('Nord'); - expect($gatePrompt)->toContain('Du valgte Nord.'); - expect($gatePrompt)->toContain('Tast 1 for indgang. Tast 2 for udgang.'); + $gatePrompt = $route->gatePromptForOptions('Nord', ['1' => 'exit']); + expect($gatePrompt)->toContain('You selected Nord.'); + expect($gatePrompt)->toContain('Press 1 for exit.'); + expect($gatePrompt)->not->toContain('Press 2 for exit.'); +}); + +it('normalizes menu digit input from Bird dtmf payload values', function (): void { + $route = new BirdVoiceWebhookRouteTestDouble(); + + expect($route->normalizeMenuDigit('1'))->toBe('1'); + expect($route->normalizeMenuDigit('1#'))->toBe('1'); + expect($route->normalizeMenuDigit(' 2## '))->toBe('2'); + expect($route->normalizeMenuDigit('10#'))->toBe('10'); + expect($route->normalizeMenuDigit('12#'))->toBe('12'); + expect($route->normalizeMenuDigit('09'))->toBeNull(); + expect($route->normalizeMenuDigit('*'))->toBeNull(); + expect($route->normalizeMenuDigit(null))->toBeNull(); }); diff --git a/services/nginx/app/tests/Unit/Bird/DepartmentGatesPhoneCallOpenTest.php b/services/nginx/app/tests/Unit/Bird/DepartmentGatesPhoneCallOpenTest.php new file mode 100644 index 00000000..a6a7a8b4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/DepartmentGatesPhoneCallOpenTest.php @@ -0,0 +1,115 @@ +exceptionToThrow !== null) { + throw $this->exceptionToThrow; + } + + $this->gateCalls[] = [ + 'countryCode' => $countryCode, + 'phone' => $phone, + 'timeout' => $timeout, + ]; + } +} + +final class DepartmentGatesPhoneCallSlackFake extends slack +{ + public array $messages = []; + + public function send_message(string $string, string $module = null): void + { + $this->messages[] = [ + 'message' => $string, + 'module' => $module, + ]; + } +} + +final class DepartmentGatesPhoneCallOpenHarness extends department_gates_o +{ + public function __construct( + array $config, + private readonly DepartmentGatesPhoneCallBirdFake $birdClient, + private readonly DepartmentGatesPhoneCallSlackFake $slackClient, + ) { + $this->id = 999; + $configProperty = new object_property('department_gates', -1, 'config', 'json'); + $configProperty->set($config); + $this->config = $configProperty; + } + + public function requireSelected(): void + { + // Test harness objects are always considered selected. + } + + protected function resolveBirdClient(): bird + { + return $this->birdClient; + } + + protected function resolveSlackClient(): slack + { + return $this->slackClient; + } +} + +it('forwards phone number and call duration threshold to the Bird gate helper', function (): void { + $birdClient = new DepartmentGatesPhoneCallBirdFake(); + $slackClient = new DepartmentGatesPhoneCallSlackFake(); + $gate = new DepartmentGatesPhoneCallOpenHarness([ + 'type' => 'PHONE_CALL', + 'phone_number' => '+45 30 87 84 10', + 'call_duration_threshold' => 17, + ], $birdClient, $slackClient); + + $gate->openGate(); + + expect($birdClient->gateCalls)->toBe([ + [ + 'countryCode' => 45, + 'phone' => 30878410, + 'timeout' => 17, + ], + ]); + expect($slackClient->messages)->toBe([]); +}); + +it('wraps Bird helper failures and reports them to Slack', function (): void { + $birdClient = new DepartmentGatesPhoneCallBirdFake(); + $birdClient->exceptionToThrow = new \RuntimeException('provider timeout'); + $slackClient = new DepartmentGatesPhoneCallSlackFake(); + $gate = new DepartmentGatesPhoneCallOpenHarness([ + 'type' => 'PHONE_CALL', + 'phone_number' => '+45 30 87 84 10', + 'call_duration_threshold' => 12, + ], $birdClient, $slackClient); + + expect(fn() => $gate->openGate()) + ->toThrow(\Exception::class, 'Failed to open gate relay via phone call'); + + expect($slackClient->messages)->toHaveCount(1); + expect($slackClient->messages[0]['message'])->toContain('provider timeout'); +}); diff --git a/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php b/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php new file mode 100644 index 00000000..74271a1c --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php @@ -0,0 +1,83 @@ +switchCalls[] = [ + 'department_id' => $departmentId, + 'relay_id' => $logicalRelayId, + 'on' => $on, + ]; + + return [ + 'relay_id' => $logicalRelayId, + 'online' => true, + 'on' => $on, + ]; + } +} + +final class DepartmentGatesRelayOpenHarness extends department_gates_o +{ + public function __construct( + array $config, + int $departmentId, + private readonly DepartmentGatesRelayManagerFake $manager, + ) { + $this->id = 1001; + $departmentProperty = new object_property('department_gates', -1, 'department', 'int'); + $departmentProperty->set($departmentId); + $this->department = $departmentProperty; + + $nameProperty = new object_property('department_gates', -1, 'name', 'string'); + $nameProperty->set('Entry gate'); + $this->name = $nameProperty; + + $configProperty = new object_property('department_gates', -1, 'config', 'json'); + $configProperty->set($config); + $this->config = $configProperty; + } + + public function requireSelected(): void + { + } + + protected function resolveEdgeGatewayManager(): edge_gateway_manager + { + return $this->manager; + } +} + +it('dispatches relay-backed gates through the edge gateway relay manager', function (): void { + $manager = new DepartmentGatesRelayManagerFake(); + $gate = new DepartmentGatesRelayOpenHarness([ + 'type' => 'RELAY', + 'relay_id' => 'ENTRY-GATE-1', + 'pulse_seconds' => 0, + ], 17, $manager); + + $gate->openGate(); + + expect($manager->switchCalls)->toBe([ + [ + 'department_id' => 17, + 'relay_id' => 'ENTRY-GATE-1', + 'on' => true, + ], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Bookings/NonPosBookingCompletionRemovalTest.php b/services/nginx/app/tests/Unit/Bookings/NonPosBookingCompletionRemovalTest.php new file mode 100644 index 00000000..8f4b9a64 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/NonPosBookingCompletionRemovalTest.php @@ -0,0 +1,29 @@ +toBeFalse(); + expect(is_file(app_path('modules/forms/objects/generate_booking_wash_certificate_f.php')))->toBeFalse(); + expect($code)->not->toContain('complete_booking_f'); + expect($code)->not->toContain('generate_booking_wash_certificate_f'); + expect($code)->not->toContain('COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE'); + expect($code)->not->toContain('GENERATE_BOOKING_WASH_CERTIFICATE'); +}); + +it('keeps legacy wash certificate downloads but disables generation and completion', function (): void { + $code = (string)file_get_contents(app_path('modules/washcertificates/index.php')); + + $downloadPosition = strpos($code, "isset(\$_GET['justDownload'])"); + $disabledPosition = strpos($code, 'http_response_code(410)'); + $completionPosition = strpos($code, "\$booking->status->set('completed')"); + + expect($downloadPosition)->not->toBeFalse(); + expect($disabledPosition)->not->toBeFalse(); + expect($completionPosition)->not->toBeFalse(); + expect($downloadPosition)->toBeLessThan($disabledPosition); + expect($disabledPosition)->toBeLessThan($completionPosition); + expect($code)->toContain('Booking completion must be completed through POS desktop or mobile steps.'); +}); + diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingRouteIdempotencyGuardTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingRouteIdempotencyGuardTest.php new file mode 100644 index 00000000..290f930f --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingRouteIdempotencyGuardTest.php @@ -0,0 +1,18 @@ +toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain('$fingerprint = $this->buildBookingCreationFingerprint($data);'); + expect($normalized)->toContain("'datetime' => \$this->normalizeDatetimeForFingerprint((string)(\$data['datetime'] ?? ''))"); + expect($normalized)->toContain("return \$parsed->format('Y-m-d H:i');"); + expect($normalized)->toContain('reserveBookingCreationSlot($fingerprint)'); + expect($normalized)->toContain('storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id)'); + expect($normalized)->toContain('clearBookingCreationSlot($fingerprint)'); + expect($normalized)->toContain("order_booking:idempotency:lock:"); + expect($normalized)->toContain("order_booking:idempotency:result:"); +}); diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsCacheTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsCacheTest.php new file mode 100644 index 00000000..e0011ad9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsCacheTest.php @@ -0,0 +1,119 @@ + */ + 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'); +}); diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsRouteWiringTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsRouteWiringTest.php new file mode 100644 index 00000000..870628ad --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsRouteWiringTest.php @@ -0,0 +1,23 @@ +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();'); +}); diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingsListCacheTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingsListCacheTest.php new file mode 100644 index 00000000..28310400 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingsListCacheTest.php @@ -0,0 +1,143 @@ + */ + 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'); +}); diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingsRouteCacheWiringTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingsRouteCacheWiringTest.php new file mode 100644 index 00000000..a2bf9631 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingsRouteCacheWiringTest.php @@ -0,0 +1,24 @@ +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();'); +}); diff --git a/services/nginx/app/tests/Unit/Bookings/VehicleSearchBookedMetadataRouteContractTest.php b/services/nginx/app/tests/Unit/Bookings/VehicleSearchBookedMetadataRouteContractTest.php new file mode 100644 index 00000000..c34de032 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/VehicleSearchBookedMetadataRouteContractTest.php @@ -0,0 +1,19 @@ +toBeTrue(); + + $code = (string) file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("\$booking_lookup_filters = ['order_id' => null, 'deleted_at' => null, ...\$booked_filters];"); + expect($normalized)->toContain("\$booking = self::getPendingOrderBookingSummaryByPlate(\$reg, \$booking_lookup_filters);"); + expect($normalized)->toContain("'booking_datetime' => \$booking_datetime,"); + expect($normalized)->toContain("\$fields = ['id', 'customer_number', 'reference', 'note', 'datetime'];"); + expect($normalized)->toContain("'reg_1' => \$reg, ...\$filters"); + expect($normalized)->toContain("'reg_2' => \$reg, ...\$filters"); + expect($normalized)->toContain("\$left_has_datetime = self::hasPendingOrderBookingSummaryDatetime(\$left);"); + expect($normalized)->toContain('return $right_has_datetime <=> $left_has_datetime;'); + expect($normalized)->toContain("return trim((string)(\$booking['datetime'] ?? '')) !== '';"); +}); diff --git a/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php new file mode 100644 index 00000000..6dbeb714 --- /dev/null +++ b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php @@ -0,0 +1,739 @@ +targets = array_values($targets); + } + + public function getLoadBalancer(int|string $id): array + { + return [ + 'id' => $id, + 'targets' => array_map( + static fn(string $ip): array => ['type' => 'ip', 'ip' => ['ip' => $ip]], + $this->targets + ), + 'services' => [], + ]; + } + + public function addIpTarget(int|string $loadBalancerId, string $ip): array + { + if (!in_array($ip, $this->targets, true)) { + $this->targets[] = $ip; + } + return []; + } + + public function removeIpTarget(int|string $loadBalancerId, string $ip): array + { + $this->targets = array_values(array_filter($this->targets, static fn(string $target): bool => $target !== $ip)); + return []; + } + + public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array + { + return []; + } + + public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array + { + return []; + } +} + +function coolifyManagerTestLoadBalancerService(string $protocol, int $listenPort, int $destinationPort): array +{ + return [ + 'protocol' => $protocol, + 'listen_port' => $listenPort, + 'destination_port' => $destinationPort, + 'proxyprotocol' => false, + 'health_check' => [ + 'protocol' => 'tcp', + 'port' => $listenPort, + 'interval' => 15, + 'timeout' => 10, + 'retries' => 3, + ], + ]; +} + +it('normalizes Coolify API base URLs to the v1 API root', function (): void { + expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com'))->toBe('https://coolify.example.com/api/v1'); + expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com/api/v1'))->toBe('https://coolify.example.com/api/v1'); + expect(coolify_api_client::normalizeBaseUrl(' https://coolify.example.com/ '))->toBe('https://coolify.example.com/api/v1'); +}); + +it('parses generated env files for Coolify service env bulk updates', function (): void { + $env = implode("\n", [ + '# generated', + 'MARIADB_ROOT_PASSWORD=root-secret', + 'MARIADB_PASSWORD=app-secret', + '', + 'REDIS_PRIMARY_USERNAME=', + ]); + + expect(coolify_manager::parseEnvFile($env))->toBe([ + 'MARIADB_ROOT_PASSWORD' => 'root-secret', + 'MARIADB_PASSWORD' => 'app-secret', + 'REDIS_PRIMARY_USERNAME' => '', + ]); +}); + +it('prefers public Coolify server hosts over Docker-local addresses', function (): void { + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'public_ip' => '94.130.142.41', + 'name' => 'node3.truckwash.io', + ]))->toBe('94.130.142.41'); + + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'node3.truckwash.io', + ]))->toBe('node3.truckwash.io'); + + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'node3.truckwash.io', + ], null, false))->toBeNull(); + + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => '10.0.0.10', + 'name' => 'Production Server', + ]))->toBe('10.0.0.10'); + + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'Production Server', + ]))->toBeNull(); + + expect(coolify_manager::publicDnsServerNameFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'node3.truckwash.io', + ]))->toBe('node3.truckwash.io'); + + expect(coolify_manager::publicDnsServerNameFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'Production Server', + ]))->toBeNull(); +}); + +it('blocks planned downtime operations against active replication primaries', function (): void { + expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'deploy'))->toBeTrue(); + expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'restart'))->toBeTrue(); + expect(coolify_manager::blocksPrimaryMutation(['role' => 'replica'], 'restart'))->toBeFalse(); + expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'failover'))->toBeFalse(); +}); + +it('allows failed Coolify replica targets to be removed after the service disappears', function (): void { + expect(coolify_manager::targetAllowsReplicaRemoval([ + 'role' => 'replica', + 'deployment_status' => 'reconcile_failed', + 'last_reconcile_status' => 'reconcile_failed', + ]))->toBeTrue(); + + expect(coolify_manager::targetAllowsReplicaRemoval([ + 'role' => 'replica', + 'deployment_status' => 'deploying', + 'last_reconcile_json' => json_encode(['message' => 'Coolify API request failed: HTTP 404']), + ]))->toBeTrue(); + + expect(coolify_manager::targetAllowsReplicaRemoval([ + 'role' => 'replica', + 'deployment_status' => 'provisioned', + 'last_reconcile_status' => 'ok', + ]))->toBeFalse(); + + expect(coolify_manager::targetAllowsReplicaRemoval([ + 'role' => 'primary', + 'deployment_status' => 'reconcile_failed', + ]))->toBeFalse(); +}); + +it('retries Coolify maintenance while linked replication provisioning is still incomplete', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'replicationHostStillNeedsProvisioning'); + $method->setAccessible(true); + + expect($method->invoke(null, [ + 'role' => 'replica', + 'status' => 'provisioning', + 'last_status_json' => json_encode([ + 'status' => 'provisioning', + 'replication_percent' => 99.9, + 'blockers' => ['MinIO replica has not caught up.'], + ]), + ]))->toBeTrue(); + + expect($method->invoke(null, [ + 'role' => 'replica', + 'status' => 'ok', + 'last_status_json' => json_encode([ + 'status' => 'ok', + 'replication_percent' => 100, + 'blockers' => [], + ]), + ]))->toBeFalse(); +}); + +it('plans Hetzner load balancer target and service drift without mutating state', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); + $method->setAccessible(true); + + $plan = $method->invoke($manager, [ + 'targets' => [ + ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], + ], + 'services' => [ + coolifyManagerTestLoadBalancerService('http', 80, 80), + ], + ], [ + ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true], + ['hostname' => 'node2.truckwash.io', 'target_ip' => '65.21.214.30', 'enabled' => true], + ]); + + $actionTypes = array_map(static fn(array $action): string => (string)$action['type'], $plan['actions']); + + expect($plan['has_drift'])->toBeTrue() + ->and($plan['missing_targets'])->toContain('65.21.214.30') + ->and($actionTypes)->toContain('add_target') + ->and($actionTypes)->toContain('add_service') + ->and($plan['missing_services'][0])->toMatchArray([ + 'protocol' => 'tcp', + 'listen_port' => 443, + 'destination_port' => 443, + ]); +}); + +it('plans Hetzner load balancer service health check drift updates', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); + $method->setAccessible(true); + + $plan = $method->invoke($manager, [ + 'targets' => [ + ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], + ], + 'services' => [ + [ + 'protocol' => 'http', + 'listen_port' => 80, + 'destination_port' => 80, + 'proxyprotocol' => false, + 'health_check' => [ + 'protocol' => 'http', + 'port' => 80, + 'interval' => 15, + 'timeout' => 10, + 'retries' => 3, + 'http' => [ + 'domain' => '', + 'path' => '/', + 'response' => '', + 'status_codes' => ['2??', '3??'], + 'tls' => false, + ], + ], + ], + coolifyManagerTestLoadBalancerService('tcp', 443, 443), + ], + ], [ + ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true], + ]); + + expect($plan['actions'])->toHaveCount(1) + ->and($plan['actions'][0])->toMatchArray([ + 'type' => 'update_service', + 'reason' => 'health_check_drift', + 'protocol' => 'http', + 'listen_port' => 80, + 'destination_port' => 80, + 'health_check' => [ + 'protocol' => 'tcp', + 'port' => 80, + 'interval' => 15, + 'timeout' => 10, + 'retries' => 3, + ], + ]); +}); + +it('does not plan removal of the last Hetzner load balancer target', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); + $method->setAccessible(true); + + $plan = $method->invoke($manager, [ + 'targets' => [ + ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], + ], + 'services' => [ + coolifyManagerTestLoadBalancerService('http', 80, 80), + coolifyManagerTestLoadBalancerService('tcp', 443, 443), + ], + ], [ + ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => false], + ]); + + expect($plan['actions'][0]) + ->toHaveKey('type', 'skip_remove_target') + ->toHaveKey('reason', 'last_reachable_target_guard'); +}); + +it('plans removal only for disabled or deleted Hetzner load balancer targets', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); + $method->setAccessible(true); + + $plan = $method->invoke($manager, [ + 'targets' => [ + ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], + ['type' => 'ip', 'ip' => ['ip' => '65.21.214.30']], + ], + 'services' => [ + coolifyManagerTestLoadBalancerService('http', 80, 80), + coolifyManagerTestLoadBalancerService('tcp', 443, 443), + ], + ], [ + ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true], + ['hostname' => 'node2.truckwash.io', 'target_ip' => '65.21.214.30', 'enabled' => true, 'deleted_at' => '2026-05-19 10:00:00'], + ]); + + expect($plan['actions']) + ->toHaveCount(1) + ->and($plan['actions'][0]) + ->toHaveKey('type', 'remove_target') + ->toHaveKey('target_ip', '65.21.214.30'); +}); + +it('builds gateway API auto-provision context for connected Coolify servers', function (): void { + $manager = new coolify_manager(); + $contextMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteProvisionDeployContext'); + $contextMethod->setAccessible(true); + $ipMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteServerPublicIp'); + $ipMethod->setAccessible(true); + + $server = [ + 'uuid' => 'server-node1', + 'name' => 'node1.truckwash.io', + 'public_ip' => '94.130.142.41', + 'settings' => ['is_reachable' => true, 'is_usable' => true], + ]; + + expect($ipMethod->invoke(null, $server))->toBe('94.130.142.41'); + + $context = $contextMethod->invoke($manager, [ + 'id' => 42, + 'channel_slug' => 'internal', + 'deploy_context_json' => json_encode([ + 'coolify_project_uuid' => 'project-internal', + 'coolify_base_directory' => 'services/nginx/app', + 'coolify_dockerfile_location' => 'services/php/Dockerfile', + 'coolify_ports_exposes' => '9000', + 'coolify_start_command' => 'php-fpm', + 'coolify_destination_uuid' => 'source-destination', + 'coolify_git_commit_sha' => 'source-commit', + 'coolify_enable_ssl' => false, + ]), + ], $server, '94.130.142.41', 'api-v2.truckwash.io', 'https://api-v2.truckwash.io'); + + expect($context)->toMatchArray([ + 'coolify_project_uuid' => 'project-internal', + 'coolify_auto_create' => true, + 'coolify_enable_ssl' => true, + 'coolify_deploy_now' => true, + 'coolify_build_pack' => 'dockerfile', + 'coolify_dockerfile_location' => '/Dockerfile.coolify-api', + 'coolify_ports_exposes' => '80', + 'coolify_port' => '80', + 'coolify_domain' => 'api-v2.truckwash.io', + 'coolify_public_url' => 'https://api-v2.truckwash.io', + 'coolify_server_uuid' => 'server-node1', + 'server_uuid' => 'server-node1', + 'coolify_destination_uuid' => '', + 'destination_uuid' => '', + 'coolify_service_name' => 'release-internal-api-node1-truckwash-io', + 'gateway_route_autoprovision' => true, + 'gateway_route_source_target_id' => 42, + 'gateway_route_target_ip' => '94.130.142.41', + ]); + expect($context)->not->toHaveKey('coolify_git_commit_sha'); + expect($context)->not->toHaveKey('coolify_base_directory'); + expect($context)->not->toHaveKey('coolify_start_command'); +}); + +it('builds gateway frontend auto-provision context with the release Dockerfile', function (): void { + $manager = new coolify_manager(); + $contextMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteProvisionDeployContext'); + $contextMethod->setAccessible(true); + + $server = [ + 'uuid' => 'server-node3', + 'name' => 'node3.truckwash.io', + 'public_ip' => '23.88.23.183', + 'settings' => ['is_reachable' => true, 'is_usable' => true], + ]; + + $context = $contextMethod->invoke($manager, [ + 'id' => 43, + 'channel_slug' => 'internal', + 'app' => 'frontend', + 'deploy_context_json' => json_encode([ + 'coolify_project_uuid' => 'project-internal', + 'coolify_build_pack' => 'static', + 'coolify_install_command' => 'npm ci', + 'coolify_build_command' => 'npm run build', + 'coolify_publish_directory' => 'dist', + 'coolify_is_static' => true, + 'coolify_is_spa' => true, + ]), + ], $server, '23.88.23.183', 'api-v2.truckwash.io', 'https://api-v2.truckwash.io/internal/frontend'); + + expect($context)->toMatchArray([ + 'coolify_project_uuid' => 'project-internal', + 'coolify_build_pack' => 'dockerfile', + 'coolify_dockerfile_location' => '/Dockerfile.coolify-frontend', + 'coolify_ports_exposes' => '80', + 'coolify_port' => '80', + 'coolify_public_url' => 'https://api-v2.truckwash.io/internal/frontend', + 'coolify_server_uuid' => 'server-node3', + 'coolify_service_name' => 'release-internal-frontend-node3-truckwash-io', + 'gateway_route_autoprovision' => true, + 'gateway_route_source_target_id' => 43, + 'gateway_route_target_ip' => '23.88.23.183', + ]); + expect($context)->not->toHaveKey('coolify_install_command'); + expect($context)->not->toHaveKey('coolify_publish_directory'); + expect($context)->not->toHaveKey('coolify_is_static'); + expect($context)->not->toHaveKey('coolify_is_spa'); +}); + +it('adds explicit Coolify application route labels for gateway API domains', function (): void { + $payloadMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteApplicationPayload'); + $payloadMethod->setAccessible(true); + $publicUrlMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteTargetPublicUrl'); + $publicUrlMethod->setAccessible(true); + + $payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [ + 'custom.keep=true', + 'traefik.http.routers.https-0-api-app-uuid.entryPoints=old', + 'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare', + 'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090', + ]))); + $labels = explode("\n", base64_decode($payload['custom_labels'], true)); + + expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080') + ->and($payload['is_force_https_enabled'])->toBeTrue() + ->and($payload['force_domain_override'])->toBeTrue() + ->and($labels)->toContain('custom.keep=true') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.entryPoints=https') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io') + ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080'); + + expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [ + 'channel_slug' => 'stable', + 'channel_default_channel' => 1, + 'app' => 'api', + ]))->toBe('https://api-v2.truckwash.io'); + expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [ + 'channel_slug' => 'internal', + 'channel_default_channel' => 0, + 'app' => 'api', + ]))->toBe('https://api-v2.truckwash.io/internal/api'); + expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [ + 'channel_slug' => 'internal', + 'channel_default_channel' => 0, + 'app' => 'frontend', + ]))->toBe('https://api-v2.truckwash.io/internal/frontend'); + + $pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, ''); + $pathLabels = explode("\n", base64_decode($pathPayload['custom_labels'], true)); + + expect($pathPayload['domains'])->toBe('https://api-v2.truckwash.io:8080/internal/api') + ->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)') + ->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-stripprefix.stripprefix.prefixes=/internal/api') + ->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-stripprefix,gzip'); + + $frontendPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', 80, ''); + $frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true)); + + expect($frontendPayload['domains'])->toBe('https://api-v2.truckwash.io:80/internal/frontend') + ->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/frontend`)') + ->and($frontendLabels)->toContain('traefik.http.middlewares.https-0-frontend-app-uuid-stripprefix.stripprefix.prefixes=/internal/frontend') + ->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip'); +}); + +it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'setLoadBalancerIpTargets'); + $method->setAccessible(true); + $client = new CoolifyManagerHetznerTargetSetFake([ + '94.130.142.41', + '65.21.214.30', + '23.88.23.183', + ]); + + $method->invoke($manager, $client, '6366569', ['65.21.214.30']); + $isolated = $client->targets; + sort($isolated); + + $method->invoke($manager, $client, '6366569', [ + '94.130.142.41', + '65.21.214.30', + '23.88.23.183', + ]); + $restored = $client->targets; + sort($restored); + + expect($isolated)->toBe(['65.21.214.30']) + ->and($restored)->toBe([ + '23.88.23.183', + '65.21.214.30', + '94.130.142.41', + ]); +}); + +it('requires gateway ping probes to return the API ping contract', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'gatewayProbePingContract'); + $method->setAccessible(true); + + expect($method->invoke(null, json_encode([ + 'success' => true, + 'data' => ['message' => 'pong'], + ])))->toMatchArray(['ok' => true, 'message' => 'pong']); + + expect($method->invoke(null, 'Fatal error'))->toMatchArray([ + 'ok' => false, + 'reason' => 'invalid_json', + ]); +}); + +it('normalizes gateway probe paths for release gateway health checks', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'normalizeGatewayProbePath'); + $method->setAccessible(true); + + expect($method->invoke(null, 'internal/api/ping'))->toBe('/internal/api/ping') + ->and($method->invoke(null, '//internal//api//ping//'))->toBe('/internal/api/ping') + ->and($method->invoke(null, 'https://api-v2.truckwash.io/internal/api/ping'))->toBe('/internal/api/ping') + ->and($method->invoke(null, ''))->toBe(''); +}); + +it('returns structured errors for failed gateway certificate bootstrap and verification', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteHealthErrors'); + $method->setAccessible(true); + + $errors = $method->invoke(null, [ + 'ok' => false, + 'reason' => 'load_balancer_enforce_required', + 'results' => [ + ['target_ip' => '94.130.142.41', 'ok' => false], + ['target_ip' => '65.21.214.30', 'ok' => true], + ['target_ip' => '23.88.23.183', 'ok' => false], + ], + ], [ + 'ok' => false, + 'failed_target_ips' => ['94.130.142.41', '23.88.23.183'], + 'results' => [], + ]); + + expect($errors)->toHaveCount(2) + ->and($errors[0])->toMatchArray([ + 'type' => 'certificate_bootstrap_failed', + 'reason' => 'load_balancer_enforce_required', + 'failed_target_ips' => ['94.130.142.41', '23.88.23.183'], + ]) + ->and($errors[1])->toMatchArray([ + 'type' => 'gateway_route_verification_failed', + 'failed_target_ips' => ['94.130.142.41', '23.88.23.183'], + ]); +}); + +it('defines Coolify schema, route permissions, and replication integration hooks', function (): void { + $schema = file_get_contents(app_path('classes/coolify_schema_bootstrap.php')); + $manager = file_get_contents(app_path('classes/coolify_manager.php')); + $route = file_get_contents(app_path('routes/superuserCoolifyRoute.php')); + $replication = file_get_contents(app_path('classes/replication_manager.php')); + $status = file_get_contents(app_path('classes/superuser_system_status_service.php')); + $cron = file_get_contents(app_path('cron/Cron.php')); + $openapi = file_get_contents(app_path('openapi.yaml')); + $coolifyConfig = file_get_contents(app_path('modules/coolify/coolify_c.php')); + $tokenConfig = file_get_contents(app_path('modules/coolify/config/coolify_hetzner_cloud_api_token_c.php')); + + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_instances'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_targets'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_operations'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_audit_logs'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_instance_gateways'); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'lb_automation_enabled'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'lb_automation_mode'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path'"); + expect($schema)->toContain('94.130.142.41'); + expect($schema)->toContain('65.21.214.30'); + expect($schema)->toContain('23.88.23.183'); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'enabled'"); + + expect($route)->toContain('/superuser/coolify'); + expect($route)->toContain('/superuser/coolify/load-balancer'); + expect($route)->toContain('/superuser/coolify/load-balancer/reconcile'); + expect($route)->toContain('/superuser/coolify/load-balancer/routes/deploy'); + expect($route)->toContain('/superuser/coolify/load-balancer/api/deploy'); + expect($route)->toContain('/superuser/coolify/gateways'); + expect($route)->toContain('/superuser/coolify/gateways/{id}/test'); + expect($route)->toContain('/superuser/coolify/instances/{id}/test'); + expect($route)->toContain('/superuser/coolify/instances/{id}/placement'); + expect($route)->toContain('/superuser/coolify/targets/{id}/reconcile'); + expect($route)->toContain('/superuser/coolify/targets/{id}/deploy'); + expect($route)->toContain('/superuser/coolify/targets/{id}/restart'); + expect($route)->toContain('/superuser/coolify/targets/{id}/failover'); + expect($route)->toContain("requirePermission('superuser_coolify_view')"); + expect($route)->toContain("requirePermission('superuser_coolify_manage')"); + expect($route)->toContain("requirePermission('superuser_coolify_reconcile')"); + expect($route)->toContain("requirePermission('superuser_coolify_failover')"); + + expect($manager)->toContain("Coolify-managed targets must be deployed as replicas first"); + expect($manager)->toContain('ensureFailoverEnabled($kind)'); + expect($manager)->toContain("deployment_provider' => 'coolify'"); + expect($manager)->toContain('discoverInstancePlacement'); + expect($manager)->toContain('applyCoolifyDeploymentDefaults($input, $instance)'); + expect($manager)->toContain('resolveCoolifyServerHost'); + expect($manager)->toContain('publicServerHostFromCoolifyServer'); + expect($manager)->toContain('applyCoolifyPortDefaults'); + expect($manager)->toContain('syncReplicationHostPortsForTarget'); + expect($manager)->toContain('usedPublicPortsForCoolifyServer'); + expect($manager)->toContain('nextAvailablePublicPorts'); + expect($manager)->toContain('syncReplicationHostEndpointForTarget'); + expect($manager)->toContain('knownPublicHostForCoolifyServer'); + expect($manager)->toContain('publicDnsServerNameFromCoolifyServer'); + expect($manager)->toContain('resolvedPublicDnsServerHostFromCoolifyServer'); + expect($manager)->toContain('recordCreatedResource'); + expect($manager)->toContain("'start_requested'"); + expect($manager)->toContain('primaryCredentials'); + expect($manager)->toContain('primary_admin_password'); + expect($manager)->toContain('replication_transfer_limit'); + expect($manager)->toContain('startOrRestartService'); + expect($manager)->toContain('already running'); + expect($manager)->toContain('restart_requested'); + expect($manager)->toContain('isolated_stack'); + expect($manager)->toContain('skip_replication_provisioning'); + expect($manager)->toContain('targetSkipsReplicationProvisioning'); + expect($manager)->toContain('targetComposeRole'); + expect($manager)->toContain('production_data_attached'); + expect($manager)->toContain('deferredProvisionResult'); + expect($manager)->toContain('isTransientProvisionBlock'); + expect($manager)->toContain('provision_deferred'); + expect($manager)->toContain('shouldRetryProvisioning'); + expect($manager)->toContain('shouldRetryProvisioning($target, $host)'); + expect($manager)->toContain('hasRunningReplicationProvisionOperation'); + expect($manager)->toContain('replicationHostStillNeedsProvisioning'); + expect($manager)->toContain('syncDeploymentStateForReplicationHost'); + expect($manager)->toContain('syncLabelForReplicationHost'); + expect($manager)->toContain('syncTargetsForReplicationHost'); + expect($manager)->toContain('targetAllowsReplicaRemoval'); + expect($manager)->toContain('markTargetsRemovedForReplicationHost'); + expect($manager)->toContain('replicationHostIsReady'); + expect($manager)->toContain("'provisioned'"); + expect($manager)->not->toContain('is_container_label_escape_enabled'); + expect($manager)->not->toContain("\$payload['type'] = 'docker-compose';"); + expect($manager)->toContain('encodedDockerCompose'); + expect($manager)->toContain('base64_encode'); + expect($manager)->toContain('if (!$update)'); + expect($manager)->toContain("'project_uuid' => \$target['project_uuid']"); + expect($manager)->toContain('/api/v1/services'); + expect($manager)->toContain('/envs/bulk'); + expect($manager)->toContain('loadBalancerSummary'); + expect($manager)->toContain('reconcileLoadBalancer'); + expect($manager)->toContain('deployGatewayApplicationRoutes'); + expect($manager)->toContain('deployGatewayApiCode'); + expect($manager)->toContain('gateway_api_code_deploy'); + expect($manager)->toContain('deploy_gateway_route_after_code'); + expect($manager)->toContain('loadBalancerReleaseGatewayTargets'); + expect($manager)->toContain('provisionMissingGatewayRouteTargets'); + expect($manager)->toContain('provision_gateway_'); + expect($manager)->toContain('gateway_route_autoprovision'); + expect($manager)->toContain('upsertDeploymentTarget'); + expect($manager)->toContain('startDeployment'); + expect($manager)->toContain("'commit_mode' => \$sourceCommitSha === '' ? 'latest' : 'specific'"); + expect($manager)->toContain("\$deploymentInput['commit_sha'] = \$sourceCommitSha;"); + expect($manager)->toContain('verifyGatewayRoutes'); + expect($manager)->toContain('bootstrapGatewayCertificates'); + expect($manager)->toContain('setLoadBalancerIpTargets'); + expect($manager)->toContain('probeGatewayPublicHost'); + expect($manager)->toContain('GATEWAY_CERT_BOOTSTRAP_ATTEMPTS'); + expect($manager)->toContain('certificate_bootstrap'); + expect($manager)->toContain('certificate_bootstrap_failed'); + expect($manager)->toContain('gateway_route_verification_failed'); + expect($manager)->toContain('recordGatewayProbe'); + expect($manager)->toContain("Gateway route and Let's Encrypt certificate verification is still failing"); + expect($manager)->toContain('CURLOPT_SSL_VERIFYHOST, 2'); + expect($manager)->toContain('CURLOPT_SSL_VERIFYPEER, true'); + expect($manager)->toContain('CURLOPT_CERTINFO'); + expect($manager)->toContain('CURLINFO_SSL_VERIFYRESULT'); + expect($manager)->toContain("Gateway TLS certificate was not issued by Let's Encrypt."); + expect($manager)->toContain('gatewayProbePath'); + expect($manager)->toContain('public_gateway_probe_path'); + expect($manager)->toContain('loadBalancerReleaseApiTargets'); + expect($manager)->toContain('gatewayProbePingContract'); + expect($manager)->toContain('Gateway ping response did not match the expected API contract.'); + expect($manager)->not->toContain('CURLOPT_SSL_VERIFYHOST, 0'); + expect($manager)->not->toContain('CURLOPT_SSL_VERIFYPEER, false'); + expect($manager)->toContain('information_schema.tables'); + expect($manager)->not->toContain('SHOW TABLES LIKE ?'); + expect($manager)->toContain('gatewayRouteApplicationPayload'); + expect($manager)->toContain('gateway_application_routes_deployed'); + expect($manager)->toContain('target_already_defined'); + expect($manager)->toContain('REQUIRED_LOAD_BALANCER_SERVICES'); + expect($manager)->toContain('skip_remove_target'); + + $composer = json_decode((string)file_get_contents(app_path('composer.json')), true); + expect($composer['autoload']['exclude-from-classmap'] ?? [])->toContain('modules/*/vendor/'); + + $client = file_get_contents(app_path('classes/coolify_api_client.php')); + expect($client)->toContain("request('GET', '/health', null, false)"); + expect($client)->toContain('/github-apps'); + expect($client)->toContain('/applications/private-github-app'); + expect($client)->toContain("request('GET', '/applications/' . rawurlencode(\$uuid))"); + expect($client)->toContain("'/deploy?uuid=' . rawurlencode(\$uuid)"); + expect($client)->toContain('/applications/\' . rawurlencode($uuid) . \'/restart'); + expect($client)->toContain('CURL_HTTP_VERSION_1_1'); + expect($client)->toContain('validationErrorSummary'); + + expect($replication)->toContain('deployment_provider'); + expect($replication)->toContain('coolify_manager::deploymentMetadataForReplicationHost'); + expect($replication)->toContain('coolify_manager::syncDeploymentStateForReplicationHost'); + expect($replication)->toContain('databaseEngineKnown'); + expect($status)->toContain("'key' => 'coolify'"); + expect($status)->toContain('probeCoolifyModule'); + expect($cron)->toContain('CoolifyAvailabilityMonitorCron'); + expect($cron)->toContain('CoolifyLoadBalancerReconcileCron'); + expect($coolifyConfig)->toContain('[redacted]'); + expect($coolifyConfig)->toContain('secret_set'); + expect($tokenConfig)->toContain('replication_secret_box::encrypt'); + expect($openapi)->toContain('/superuser/coolify:'); + expect($openapi)->toContain('operationId: getSuperuserCoolifyLoadBalancer'); + expect($openapi)->toContain('operationId: reconcileSuperuserCoolifyLoadBalancer'); + expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayRoutes'); + expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayApiCode'); + expect($openapi)->toContain('operationId: listSuperuserCoolifyGateways'); + expect($openapi)->toContain('operationId: testSuperuserCoolifyGateway'); + expect($openapi)->toContain('operationId: discoverSuperuserCoolifyInstancePlacement'); + expect($openapi)->toContain('operationId: createSuperuserCoolifyTarget'); + expect($openapi)->toContain('SuperuserCoolifyTarget'); + expect($openapi)->toContain('SuperuserCoolifyLoadBalancer'); + expect($openapi)->toContain('SuperuserCoolifyGateway'); +}); diff --git a/services/nginx/app/tests/Unit/Customers/CustomerMassImportServiceTest.php b/services/nginx/app/tests/Unit/Customers/CustomerMassImportServiceTest.php new file mode 100644 index 00000000..11e936a4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Customers/CustomerMassImportServiceTest.php @@ -0,0 +1,229 @@ +id = $id; + $this->has_password = $hasPassword; + $this->display_name = $displayName; + } + + public function exists(): bool + { + return true; + } + + public function hasPassword(): bool + { + return $this->has_password; + } + }; + } +} + +if (!class_exists('CustomerMassImportServiceProbe')) { + class CustomerMassImportServiceProbe extends customer_mass_import_service + { + public array $economicSearchResults = []; + public array $createCalls = []; + public array $bootstrapCalls = []; + public array $syncCalls = []; + public array $logEntries = []; + public bool $localExists = false; + public ?object $localUser = null; + public ?object $bootstrapUser = null; + public ?object $createResponse = null; + public string $companyName = 'Probe Company'; + + protected function searchEconomicCustomersByCvr(string $cvr): array + { + return $this->economicSearchResults; + } + + protected function createEconomicCustomer(array $normalized): object + { + $this->createCalls[] = $normalized; + return $this->createResponse ?? (object)[ + 'customerNumber' => (int)$normalized['customer_number'], + ]; + } + + protected function localCustomerNumberExists(int $customerNumber): bool + { + return $this->localExists; + } + + protected function loadLocalCustomerByNumber(int $customerNumber): ?object + { + return $this->localUser; + } + + protected function bootstrapLocalCustomerOrFail(int $customerNumber): object + { + $this->bootstrapCalls[] = $customerNumber; + + if ($this->bootstrapUser === null) { + throw new RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500); + } + + return $this->bootstrapUser; + } + + protected function fetchCompanyNameByCvr(string $cvr): string + { + return $this->companyName; + } + + protected function logIssue(string $action, array $context): void + { + $this->logEntries[] = [ + 'action' => $action, + 'context' => $context, + ]; + } + + protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void + { + $this->syncCalls[] = [ + 'customer' => $customer, + 'normalized' => $normalized, + ]; + } + } +} + +it('imports a matching e-conomic customer into the local system when no local record exists', function (): void { + $service = new CustomerMassImportServiceProbe(); + $service->economicSearchResults = [ + (object)[ + 'customerNumber' => 76964600, + 'name' => 'SPF-DANMARK A/S', + ], + ]; + $service->bootstrapUser = fakeCustomerMassImportUser(41, false, 'SPF-DANMARK A/S'); + + $result = $service->import([ + 'cvr' => '31744520', + 'name' => 'SPF-DANMARK A/S', + 'email' => 'spf@example.com', + 'ean' => '5790000000001', + 'phone' => '76964600', + ]); + + expect($service->createCalls)->toBe([]); + expect($service->bootstrapCalls)->toBe([76964600]); + expect($result['action'])->toBe('imported_existing_customer'); + expect($result['existing_economic_customer'])->toBeTrue(); + expect($result['created_economic_customer'])->toBeFalse(); + expect($result['has_account'])->toBeFalse(); + expect($result['user_id'])->toBe(41); +}); + +it('reports when the local customer already has a login account', function (): void { + $service = new CustomerMassImportServiceProbe(); + $service->localExists = true; + $service->localUser = fakeCustomerMassImportUser(77, true, 'STEA A/S'); + $service->economicSearchResults = [ + (object)[ + 'customerNumber' => 75773355, + 'name' => 'STEA A/S', + ], + ]; + + $result = $service->import([ + 'cvr' => '26761751', + 'name' => 'STEA A/S', + 'phone' => '75773355', + ]); + + expect($service->bootstrapCalls)->toBe([]); + expect($result['action'])->toBe('account_already_exists'); + expect($result['existing_local_customer'])->toBeTrue(); + expect($result['has_account'])->toBeTrue(); +}); + +it('creates a new e-conomic customer and returns a created result for new rows', function (): void { + $service = new CustomerMassImportServiceProbe(); + $service->bootstrapUser = fakeCustomerMassImportUser(105, false, 'TGP TRANSPORT APS'); + $service->createResponse = (object)[ + 'customerNumber' => 22725567, + ]; + + $result = $service->import([ + 'cvr' => '29424764', + 'name' => 'TGP TRANSPORT APS', + 'email' => 'tgp@example.com', + 'ean' => '5790001234567', + 'phone' => '22725567', + ]); + + expect($service->createCalls)->toHaveCount(1); + expect($service->createCalls[0]['customer_number'])->toBe(22725567); + expect($service->createCalls[0]['ean'])->toBe('5790001234567'); + expect($service->bootstrapCalls)->toBe([22725567]); + expect($result['action'])->toBe('created_customer'); + expect($result['created_economic_customer'])->toBeTrue(); + expect($result['existing_economic_customer'])->toBeFalse(); + expect($result['has_account'])->toBeFalse(); +}); + +it('creates the economic record for an existing local account when no matching upstream customer exists', function (): void { + $service = new CustomerMassImportServiceProbe(); + $service->localExists = true; + $service->localUser = fakeCustomerMassImportUser(222, true, 'Existing Account'); + $service->createResponse = (object)[ + 'customerNumber' => 97120896, + ]; + + $result = $service->import([ + 'cvr' => '49422113', + 'name' => 'VESTERBRO PRODUKTHANDEL', + 'phone' => '97120896', + ]); + + expect($service->createCalls)->toHaveCount(1); + expect($service->bootstrapCalls)->toBe([]); + expect($result['action'])->toBe('economic_customer_created_for_existing_account'); + expect($result['existing_local_customer'])->toBeTrue(); + expect($result['created_economic_customer'])->toBeTrue(); + expect($result['has_account'])->toBeTrue(); +}); + +it('rejects CVR conflicts when the upstream customer number does not match the submitted phone number', function (): void { + $service = new CustomerMassImportServiceProbe(); + $service->economicSearchResults = [ + (object)[ + 'customerNumber' => 87654321, + 'name' => 'Conflict Company', + ], + ]; + + $call = static fn() => $service->import([ + 'cvr' => '33333333', + 'name' => 'Conflict Company', + 'phone' => '22725567', + ]); + + expect($call)->toThrow(RuntimeException::class, 'CVR already registered under customer number 87654321.'); + expect($service->createCalls)->toBe([]); + expect($service->logEntries[0]['action'] ?? null)->toBe('CUSTOMER_MASS_IMPORT_CONFLICT'); +}); + +it('registers the customer import route and wires it through the mass import service', function (): void { + $routeFile = app_path('routes/customerSearchRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("\$this->post('/customers/import'"); + expect($content)->toContain('new customer_mass_import_service()'); + expect($content)->toContain("\$this->requirePermission('add_user');"); +}); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsOpenApiSpecTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsOpenApiSpecTest.php new file mode 100644 index 00000000..bb064190 --- /dev/null +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsOpenApiSpecTest.php @@ -0,0 +1,54 @@ +markTestSkipped('openapi.yaml is not mounted in this test container.'); +} + +it('documents the daily report complaints CRUD and customer lookup endpoints in openapi', function (): void { + $content = department_daily_reports_complaints_openapi_content_or_skip(); + + expect($content)->toContain('/departments/daily-reports/complaints:'); + expect($content)->toContain('/departments/daily-reports/complaints/customers:'); + expect($content)->toContain('operationId: listDailyReportComplaints'); + expect($content)->toContain('operationId: searchDailyReportComplaintCustomers'); + expect($content)->toContain('operationId: createDailyReportComplaint'); + expect($content)->toContain('operationId: updateDailyReportComplaint'); + expect($content)->toContain('operationId: deleteDailyReportComplaint'); + expect($content)->toContain('DepartmentDailyReportComplaintCreateRequest:'); + expect($content)->toContain('DepartmentDailyReportComplaintUpdateRequest:'); + expect($content)->toContain('DepartmentDailyReportComplaintCategory:'); + expect($content)->toContain('DepartmentDailyReportComplaint:'); + expect($content)->toContain('DepartmentDailyReportComplaintCustomerSearchResult:'); + expect($content)->toContain('DepartmentDailyReportComplaintCustomerSearchResponse:'); + expect($content)->toContain('DepartmentDailyReportComplaintCollectionResponse:'); + expect($content)->toContain('DepartmentDailyReportComplaintDeleteResponse:'); + expect($content)->toContain('department_name:'); + expect($content)->toContain('customer_number:'); + expect($content)->toContain('wash_date:'); + expect($content)->toContain('category:'); + expect($content)->toContain('wash_quality'); + expect($content)->toContain('damage_cables_electronics'); +}); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php new file mode 100644 index 00000000..849b28aa --- /dev/null +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php @@ -0,0 +1,30 @@ +toContain('/departments/daily-reports/complaints'); + expect($content)->toContain('/departments/daily-reports/complaints/customers'); + expect($content)->toContain("requirePermission('create_department_daily_report_complaints')"); + expect($content)->toContain("requirePermission('list_department_daily_report_complaints')"); + expect($content)->toContain("requirePermission('edit_department_daily_report_complaints')"); + expect($content)->toContain("requirePermission('delete_department_daily_report_complaints')"); + expect($content)->toContain("hasPermission('create_department_daily_report_complaints')"); + expect($content)->toContain("hasPermission('edit_department_daily_report_complaints')"); + expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))"); + expect($content)->toContain("getOrImportCustomerByCustomerNumber"); + expect($content)->toContain("Search must be at least 2 characters"); + expect($content)->toContain("Failed to fetch complaint customers from e-conomic"); + expect($content)->toContain("Wash date is required"); + expect($content)->toContain("Category is required"); + expect($content)->toContain("Invalid complaint category"); + expect($content)->toContain("Description is required"); + expect($content)->toContain("requireComplaintWashDateParameter"); + expect($content)->toContain("requireComplaintCategoryParameter"); + expect($content)->toContain("dailyReportComplaintsRepository()->addComplaint"); + expect($content)->toContain("Complaint not found"); + expect($content)->toContain("Complaint deleted successfully"); + expect($content)->toContain("parseComplaint"); + expect($content)->toContain("listObjectsWithPaginationIfSet"); + expect($content)->toContain("countForDepartmentsInRange"); +}); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOutsideHoursOpenApiSpecTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOutsideHoursOpenApiSpecTest.php new file mode 100644 index 00000000..a8f7e834 --- /dev/null +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOutsideHoursOpenApiSpecTest.php @@ -0,0 +1,14 @@ +toContain('/departments/daily-reports/transaction-count:'); + expect($content)->toContain('/departments/daily-reports/outside-hours-trend:'); + expect($content)->toContain('DepartmentDailyReportTransactionCountResponse:'); + expect($content)->toContain('DepartmentDailyReportOutsideHoursSummary:'); + expect($content)->toContain('DepartmentDailyReportOutsideHoursTrendResponse:'); + expect($content)->toContain('DepartmentDailyReportOutsideHoursBreakdown:'); + expect($content)->toContain('outside_hours:'); + expect($content)->toContain('missing_department_ids:'); +}); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOutsideHoursRouteContractTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOutsideHoursRouteContractTest.php new file mode 100644 index 00000000..1853828d --- /dev/null +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOutsideHoursRouteContractTest.php @@ -0,0 +1,25 @@ +not->toBeFalse(); + expect($content)->toContain('use classes\department_outside_hours_statistics_service;'); + expect($content)->toContain('/departments/daily-reports/transaction-count'); + expect($content)->toContain("'outside_hours' => \$outside_hours_service->getSummary("); + expect($content)->toContain("/departments/daily-reports/outside-hours-trend"); + expect($content)->toContain("outsideHoursStatisticsService()->getTrend("); + expect($content)->toContain("normalizeDepartmentIdsParameter(self::getParameter('department_ids'))"); + expect($content)->toContain('protected function outsideHoursStatisticsService(): department_outside_hours_statistics_service'); +}); + +it('initializes the outside-hours statistics service before building the transaction-count summary payload', function (): void { + $routeFile = app_path('routes/departmentDailyReportsRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toMatch( + "/\\/departments\\/daily-reports\\/transaction-count'.*?\\\$outside_hours_service = \\\$this->outsideHoursStatisticsService\\(\\);.*?'outside_hours' => \\\$outside_hours_service->getSummary\\(/s" + ); +}); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewOpenApiSpecTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewOpenApiSpecTest.php new file mode 100644 index 00000000..40855994 --- /dev/null +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewOpenApiSpecTest.php @@ -0,0 +1,39 @@ +markTestSkipped('openapi.yaml is not mounted in this test container.'); +} + +it('documents the daily report overview endpoint and reusable schemas in openapi', function (): void { + $content = department_daily_reports_openapi_content_or_skip(); + + expect($content)->toContain('/departments/daily-reports/overview:'); + expect($content)->toContain('operationId: getDailyReportOverview'); + expect($content)->toContain('DepartmentDailyReportOverviewResponse:'); + expect($content)->toContain('DepartmentDailyReportMetric:'); + expect($content)->toContain('DepartmentDailyReportProductTile:'); + expect($content)->toContain('- name: department_ids'); +}); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php new file mode 100644 index 00000000..49af59ec --- /dev/null +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php @@ -0,0 +1,352 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +final class FakeDailyReportValue +{ + public function __construct(private readonly mixed $current) + { + } + + public function value(): mixed + { + return $this->current; + } +} + +final class FakeDailyReportVariables +{ + public function __construct(private readonly array $values = []) + { + } + + public function getVariable(string $key): mixed + { + return $this->values[$key] ?? null; + } +} + +final class FakeDailyReportRepository +{ + public array $transaction_summary = [ + 'quantity' => 0, + 'products' => 0, + 'earnings' => 0, + 'washes' => 0, + 'water_usage' => 0, + ]; + + public array $booking_summary = [ + 'completed' => 0, + 'total' => 0, + ]; + + public array $product_overview = []; + + public array $wash_transactions = []; + + public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array + { + return $this->transaction_summary; + } + + public function getBookingSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array + { + return $this->booking_summary; + } + + public function getProductOverviewForDepartments(string $date, array $department_ids, array $product_ids, string $date_to = null): array + { + return $this->product_overview; + } + + public function getWashTransactionsForDepartments(string $date, array $department_ids, string $date_to = null): array + { + return $this->wash_transactions; + } +} + +final class FakeDailyReportComplaintsRepository +{ + public int $count = 0; + + public function countForDepartmentsInRange(array $department_ids, string $date, ?string $date_to = null): int + { + return $this->count; + } +} + +final class FakeOutsideHoursStatisticsService extends department_outside_hours_statistics_service +{ + public array $summary = [ + 'total' => 0, + 'by_source' => [ + 'orders' => 0, + 'xlvask' => 0, + 'selfserve' => 0, + ], + 'has_missing_opening_hours' => false, + 'missing_department_ids' => [], + ]; + + public function __construct() + { + } + + public function getSummary(string $date, array|int|string $department_ids, ?string $date_to = null): array + { + return [ + 'department_ids' => is_array($department_ids) ? array_values(array_map('intval', $department_ids)) : [(int)$department_ids], + 'date' => $date, + 'date_to' => $date_to ?? $date, + ...$this->summary, + ]; + } +} + +final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyReportsRoute +{ + public object $repository; + public object $complaints_repository; + public array $opening_hours = []; + public array $departments = []; + public array $workfeed_departments = []; + public array $workfeed_shifts = []; + public department_outside_hours_statistics_service $outside_hours_service; + + protected function dailyReportRepository(): object + { + return $this->repository; + } + + protected function dailyReportComplaintsRepository(): object + { + return $this->complaints_repository; + } + + protected function fetchOpeningHoursByDepartmentId(array $department_ids): array + { + return $this->opening_hours; + } + + protected function fetchDepartmentsByIds(array $department_ids): array + { + return $this->departments; + } + + protected function fetchWorkfeedDepartments(): array + { + return $this->workfeed_departments; + } + + protected function fetchWorkfeedShifts(\DateTime $query_start, \DateTime $range_end_exclusive): array + { + return $this->workfeed_shifts; + } + + protected function outsideHoursStatisticsService(): department_outside_hours_statistics_service + { + return $this->outside_hours_service; + } +} + +function fake_daily_report_department(int $id, string $name, array $variables = []): object +{ + return (object)[ + 'id' => $id, + 'name' => new FakeDailyReportValue($name), + 'variables' => new FakeDailyReportVariables($variables), + ]; +} + +beforeEach(function (): void { + $_SERVER['REQUEST_URI'] = '/departments/daily-reports/overview'; +}); + +it('builds the overview payload from batched repository data with deterministic tile states', function (): void { + $repository = new FakeDailyReportRepository(); + $complaints_repository = new FakeDailyReportComplaintsRepository(); + $repository->transaction_summary = [ + 'quantity' => 12, + 'products' => 37, + 'earnings' => 4900, + 'washes' => 14, + 'water_usage' => 56, + ]; + $repository->booking_summary = [ + 'completed' => 9, + 'total' => 11, + ]; + $repository->product_overview = [ + 24 => ['product_id' => 24, 'quantity' => 3, 'out_of' => 14], + 25 => ['product_id' => 25, 'quantity' => 2, 'out_of' => 14], + ]; + $complaints_repository->count = 4; + $route = new DepartmentDailyReportsOverviewRouteDouble(); + $route->repository = $repository; + $route->complaints_repository = $complaints_repository; + $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); + $route->outside_hours_service->summary = [ + 'total' => 1, + 'by_source' => [ + 'orders' => 0, + 'xlvask' => 1, + 'selfserve' => 0, + ], + 'has_missing_opening_hours' => false, + 'missing_department_ids' => [], + ]; + $route->departments = [ + fake_daily_report_department(1, 'North', ['workfeed_department_id' => 'dep_1']), + fake_daily_report_department(2, 'South', ['workfeed_department_id' => 'dep_2']), + ]; + $route->workfeed_shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'start' => '2026-03-23T09:00: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', + 'start' => '2026-03-23T10:00:00+00:00', + 'end' => '2026-03-23T18:00:00+00:00', + 'approval' => null, + 'updateTime' => '2026-03-23T18:15:00+00:00', + ], + ]; + + $overview = department_daily_reports_route_invoke_private($route, 'buildDailyReportOverview', [[1, 2], '2026-03-23', '2026-03-23']); + + expect($overview['department_ids'])->toBe([1, 2]); + expect($overview['metrics']['transactions']['value'])->toBe(12); + expect($overview['metrics']['bookings']['value'])->toBe(9); + expect($overview['metrics']['bookings']['out_of'])->toBe(11); + expect($overview['metrics']['night_washes']['state'])->toBe('ready'); + expect($overview['metrics']['night_washes']['value'])->toBe(1); + expect($overview['metrics']['night_washes']['by_source'])->toBe([ + 'orders' => 0, + 'xlvask' => 1, + 'selfserve' => 0, + ]); + expect($overview['metrics']['overtime']['state'])->toBe('ready'); + expect($overview['metrics']['overtime']['value'])->toBe(0.75); + expect($overview['metrics']['complaints']['state'])->toBe('ready'); + expect($overview['metrics']['complaints']['value'])->toBe(4); + expect(count($overview['products']))->toBe(6); + expect($overview['products'][0]['slug'])->toBe('spot-free-lastbil'); + expect($overview['products'][0]['title'])->toBe('Spot Free (Lastbil)'); + expect($overview['products'][0]['value'])->toBe(3); + expect($overview['products'][1]['title'])->toBe('Fælg flex pr. enhed'); + expect($overview['products'][1]['value'])->toBe(2); + expect(array_column($overview['products'], 'title'))->toBe([ + 'Spot Free (Lastbil)', + 'Fælg flex pr. enhed', + 'Ekstraordinær pr. 10 min inkl. kemi', + 'Højglans - Voksforsegling pr. enhed', + 'Undervognsskyl pr. enhed', + 'Tillæg for Specialsæbe - DD', + ]); +}); + +it('marks overtime unavailable when not every selected department can be mapped to workfeed', function (): void { + $repository = new FakeDailyReportRepository(); + + $route = new DepartmentDailyReportsOverviewRouteDouble(); + $route->repository = $repository; + $route->complaints_repository = new FakeDailyReportComplaintsRepository(); + $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); + $route->departments = [ + fake_daily_report_department(1, 'North', ['workfeed_department_id' => 'dep_1']), + fake_daily_report_department(2, 'South'), + ]; + + $overview = department_daily_reports_route_invoke_private($route, 'buildDailyReportOverview', [[1, 2], '2026-03-23', '2026-03-23']); + + expect($overview['metrics']['overtime']['state'])->toBe('unavailable'); + expect($overview['metrics']['overtime']['message'])->toContain('Workfeed'); +}); + +it('normalizes department id input from csv strings and nested values', function (): void { + $route = new DepartmentDailyReportsOverviewRouteDouble(); + $route->repository = new FakeDailyReportRepository(); + $route->complaints_repository = new FakeDailyReportComplaintsRepository(); + $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); + + $normalized = department_daily_reports_route_invoke_private($route, 'normalizeDepartmentIdsParameter', [['1, 2', [3, '4'], 2]]); + + expect($normalized)->toBe([1, 2, 3, 4]); +}); + +it('limits overtime counting to the selected reporting range', 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-22T20:00: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')); + + expect($routeContent)->toContain('/departments/daily-reports/overview'); + expect($routeContent)->toContain('/departments/daily-reports/complaints'); + expect($routeContent)->toContain('outsideHoursStatisticsService'); + expect($routeContent)->toContain('dailyReportComplaintsRepository'); + expect($routeContent)->toContain('/departments/daily-reports/outside-hours-trend'); + expect($routeContent)->toContain('getTransactionSummaryForDepartments'); + expect($routeContent)->toContain('normalizeDepartmentIdsParameter'); + expect($objectContent)->toContain('public function getBookingSummaryForDepartments'); +}); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentOutsideHoursStatisticsServiceTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentOutsideHoursStatisticsServiceTest.php new file mode 100644 index 00000000..1fa18083 --- /dev/null +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentOutsideHoursStatisticsServiceTest.php @@ -0,0 +1,182 @@ + $department_id, + 'monday_start' => '08:00:00', + 'monday_end' => '17:00:00', + 'tuesday_start' => '08:00:00', + 'tuesday_end' => '17:00:00', + 'wednesday_start' => '08:00:00', + 'wednesday_end' => '17:00:00', + 'thursday_start' => '08:00:00', + 'thursday_end' => '17:00:00', + 'friday_start' => '08:00:00', + 'friday_end' => '17:00:00', + 'saturday_start' => '08:00:00', + 'saturday_end' => '17:00:00', + 'sunday_start' => '08:00:00', + 'sunday_end' => '17:00:00', + ], $overrides); +} + +it('counts only outside-hours washes and flags missing opening hours without counting them as closed', function (): void { + $service = new department_outside_hours_statistics_service(); + + $summary = $service->summarizeCandidates( + [ + [ + 'source' => 'orders', + 'dedupe_key' => 'order:100', + 'department_id' => 1, + 'start_at' => '2026-03-23 06:45:00', + ], + [ + 'source' => 'orders', + 'dedupe_key' => 'order:101', + 'department_id' => 1, + 'start_at' => '2026-03-23 10:15:00', + ], + [ + 'source' => 'orders', + 'dedupe_key' => 'order:102', + 'department_id' => 2, + 'start_at' => '2026-03-23 05:30:00', + ], + ], + [ + 1 => outside_hours_weekday_hours(1), + 2 => outside_hours_weekday_hours(2, [ + 'monday_start' => null, + 'monday_end' => null, + ]), + ], + [1, 2], + '2026-03-23', + '2026-03-23' + ); + + expect($summary['total'])->toBe(1); + expect($summary['by_source'])->toBe([ + 'orders' => 1, + 'xlvask' => 0, + 'selfserve' => 0, + ]); + expect($summary['has_missing_opening_hours'])->toBeTrue(); + expect($summary['missing_department_ids'])->toBe([2]); +}); + +it('deduplicates linked washes with self-serve first, then xlvask, then orders', function (): void { + $service = new department_outside_hours_statistics_service(); + + $deduplicated = $service->deduplicateCandidates([ + [ + 'source' => 'orders', + 'dedupe_key' => 'order:200', + 'department_id' => 1, + 'start_at' => '2026-03-23 06:00:00', + ], + [ + 'source' => 'xlvask', + 'dedupe_key' => 'order:200', + 'department_id' => 1, + 'start_at' => '2026-03-23 06:05:00', + ], + [ + 'source' => 'selfserve', + 'dedupe_key' => 'order:200', + 'department_id' => 1, + 'start_at' => '2026-03-23 06:10:00', + ], + [ + 'source' => 'xlvask', + 'dedupe_key' => 'xlvask:wash-standalone', + 'department_id' => 1, + 'start_at' => '2026-03-23 23:00:00', + ], + [ + 'source' => 'selfserve', + 'dedupe_key' => 'selfserve:501', + 'department_id' => 1, + 'start_at' => '2026-03-23 23:30:00', + ], + ]); + + expect($deduplicated)->toHaveCount(3); + expect($deduplicated[0]['source'])->toBe('selfserve'); + expect($deduplicated[0]['dedupe_key'])->toBe('order:200'); + expect(array_column($deduplicated, 'source'))->toBe([ + 'selfserve', + 'xlvask', + 'selfserve', + ]); +}); + +it('builds daily trend points with per-day missing-hours diagnostics', function (): void { + $service = new department_outside_hours_statistics_service(); + + $trend = $service->buildTrendFromCandidates( + [ + [ + 'source' => 'orders', + 'dedupe_key' => 'order:301', + 'department_id' => 1, + 'start_at' => '2026-03-23 06:00:00', + ], + [ + 'source' => 'orders', + 'dedupe_key' => 'order:302', + 'department_id' => 2, + 'start_at' => '2026-03-23 05:00:00', + ], + [ + 'source' => 'selfserve', + 'dedupe_key' => 'selfserve:701', + 'department_id' => 2, + 'start_at' => '2026-03-24 05:15:00', + ], + ], + [ + 1 => outside_hours_weekday_hours(1), + 2 => outside_hours_weekday_hours(2, [ + 'monday_start' => null, + 'monday_end' => null, + ]), + ], + [1, 2], + '2026-03-23', + '2026-03-24' + ); + + expect($trend['has_missing_opening_hours'])->toBeTrue(); + expect($trend['missing_department_ids'])->toBe([2]); + expect($trend['points'])->toBe([ + [ + 'date' => '2026-03-23', + 'total' => 1, + 'by_source' => [ + 'orders' => 1, + 'xlvask' => 0, + 'selfserve' => 0, + ], + 'has_missing_opening_hours' => true, + 'missing_department_ids' => [2], + ], + [ + 'date' => '2026-03-24', + 'total' => 1, + 'by_source' => [ + 'orders' => 0, + 'xlvask' => 0, + 'selfserve' => 1, + ], + 'has_missing_opening_hours' => false, + 'missing_department_ids' => [], + ], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Database/DbObjectRedisNamespaceSafetyTest.php b/services/nginx/app/tests/Unit/Database/DbObjectRedisNamespaceSafetyTest.php new file mode 100644 index 00000000..7e6e0fe5 --- /dev/null +++ b/services/nginx/app/tests/Unit/Database/DbObjectRedisNamespaceSafetyTest.php @@ -0,0 +1,9 @@ +not->toContain('redis->') + ->and($content)->toContain("constant('redis')"); +}); diff --git a/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php new file mode 100644 index 00000000..d6a7bb48 --- /dev/null +++ b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php @@ -0,0 +1,74 @@ +not->toBeFalse(); + expect($content)->toContain("isRequestParameterSet('dynamic_image_id')"); + expect($content)->toContain("\$dynamic_image_override = \$response->getRequestParameter('dynamic_image_id')"); + expect($content)->toContain("self::requireMinValue(\$dynamic_image_id, 1)"); + expect($content)->toContain("switch (\$dynamic_image_id)"); + }); + + it('accepts ordered dynamic image button tokens including program picker reset start and zero', function (): void { + expect(department_selfserve_tasks_o::normalizeButtonsInput('["program_picker","reset",0,2,"start",5]'))->toBe([ + 'program_picker', + 'reset', + 0, + 2, + 'start', + 5, + ]); + + $route = file_get_contents(app_path('routes/departmentLanesRoute.php')); + expect($route)->not->toBeFalse(); + expect($route)->toContain('ordered highlighted button IDs'); + expect($route)->toContain('"reset", "start", or "program_picker"'); + }); + + it('renders machine one dynamic image steps from the ordered button payload', function (): void { + $content = file_get_contents(app_path('modules/dynamicimages/images/machine_1.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('$deferredStartButtons = $this->drawHighlightedButtonSequence();'); + expect($content)->toContain('getOrderedHighlightedButtonTokens'); + expect($content)->toContain('normalizeHighlightedButtonToken'); + expect($content)->toContain("const BUTTON_PROGRAM_PICKER = 'program_picker'"); + expect($content)->toContain('getProgramPickerStepCoordinates'); + expect($content)->toContain('drawDeferredHighlightedButtons($deferredStartButtons)'); + expect($content)->toContain('self::BUTTON_PROGRAM_PICKER'); + + $setupOffset = strpos($content, 'public function setup'); + expect($setupOffset)->not->toBeFalse(); + $setupBody = substr($content, (int)$setupOffset, 1000); + expect($setupBody)->not->toContain('drawStepThumb();'); + }); +} diff --git a/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php b/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php new file mode 100644 index 00000000..d206591b --- /dev/null +++ b/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php @@ -0,0 +1,16 @@ +not->toBeFalse(); + expect($content)->toContain('PreRenderDynamicImagesCron'); + expect($content)->toContain("'interval' => 900"); + expect($content)->toContain('collectDynamicImageTaskGroupsForLane'); + expect($content)->toContain('buildDynamicImageVariantsForTaskGroup'); + expect($content)->toContain('renderDynamicImageVariant'); + expect($content)->toContain('mergeUniqueButtonValues'); + expect($content)->toContain('normalizeButtonsInput'); + expect($content)->toContain('dynamic_image:'); + expect($content)->toContain('machine_1'); +}); diff --git a/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php b/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php new file mode 100644 index 00000000..b4fa2684 --- /dev/null +++ b/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php @@ -0,0 +1,79 @@ + 'Bearer secret-token', + 'request' => [ + 'headers' => [ + 'X-Api-Key' => 'hidden', + 'Accept' => 'application/json', + ], + 'body' => [ + 'password' => 'not stored', + 'safe' => 'visible', + ], + ], + ]; + + expect(error_report_service::redactPayload($payload))->toBe([ + 'Authorization' => '[redacted]', + 'request' => [ + 'headers' => [ + 'X-Api-Key' => '[redacted]', + 'Accept' => 'application/json', + ], + 'body' => [ + 'password' => '[redacted]', + 'safe' => 'visible', + ], + ], + ]); +}); + +it('validates supported screenshot data uris', function (): void { + $decoded = error_report_service::decodeScreenshotDataUri('data:image/png;base64,' . base64_encode('png-bytes')); + + expect($decoded['mime_type'])->toBe('image/png'); + expect($decoded['contents'])->toBe('png-bytes'); + expect($decoded['size_bytes'])->toBe(strlen('png-bytes')); + + expect(fn() => error_report_service::decodeScreenshotDataUri('data:text/plain;base64,' . base64_encode('nope'))) + ->toThrow(RuntimeException::class, 'Screenshot must be a PNG, JPEG, or WebP data URI.'); +}); + +it('defines error report schema, routes, permissions, storage, and OpenAPI docs', function (): void { + $schema = file_get_contents(app_path('classes/error_report_schema_bootstrap.php')); + $service = file_get_contents(app_path('classes/error_report_service.php')); + $store = file_get_contents(app_path('classes/error_report_store.php')); + $route = file_get_contents(app_path('routes/errorReportRoute.php')); + $openapi = file_get_contents(app_path('openapi.yaml')); + + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS error_reports'); + expect($schema)->toContain('request_errors_json'); + expect($schema)->toContain('vue_errors_json'); + expect($schema)->toContain('data_collection_accepted_at'); + expect($schema)->toContain('resolved_by_user_id'); + + expect($service)->toContain('createFromCurrentPrincipal'); + expect($service)->toContain('decodeScreenshotDataUri'); + expect($service)->toContain('data_collection_accepted'); + expect($service)->toContain('request_error_count'); + expect($service)->toContain('vue_error_count'); + expect($store)->toContain("error-reports/%s/%s.%s"); + + expect($route)->toContain('/error-reports'); + expect($route)->toContain('/superuser/error-reports'); + expect($route)->toContain('/superuser/error-reports/{id}/status'); + expect($route)->toContain("requirePermission('superuser_error_reports_view')"); + expect($route)->toContain("requirePermission('superuser_error_reports_resolve')"); + + expect($openapi)->toContain('/error-reports:'); + expect($openapi)->toContain('ErrorReportSubmissionRequest'); + expect($openapi)->toContain('ErrorReportStatusUpdateRequest'); +}); diff --git a/services/nginx/app/tests/Unit/Goals/GoalsCriteriaTargetDurationTest.php b/services/nginx/app/tests/Unit/Goals/GoalsCriteriaTargetDurationTest.php new file mode 100644 index 00000000..e955ad39 --- /dev/null +++ b/services/nginx/app/tests/Unit/Goals/GoalsCriteriaTargetDurationTest.php @@ -0,0 +1,71 @@ + 10, + 'target_duration' => 'WEEKS', + 'target_duration_every' => 2, + ], JSON_UNESCAPED_UNICODE)); + + expect($criteria->target_duration)->toBe(goals_criteria_target_duration::WEEKS); + expect($criteria->target_duration_every)->toBe(2); + + $payload = $criteria->toArray(); + expect($payload['target_duration'])->toBe('WEEKS'); + expect($payload['target_duration_every'])->toBe(2); +}); + +it('parses advanced target duration from camelCase aliases', function (): void { + $criteria = goals_criteria::fromJson((string)json_encode([ + 'target' => 10, + 'targetDuration' => 'MONTHS', + 'targetDurationEvery' => 3, + ], JSON_UNESCAPED_UNICODE)); + + expect($criteria->target_duration)->toBe(goals_criteria_target_duration::MONTHS); + expect($criteria->target_duration_every)->toBe(3); +}); + +it('normalizes ENTIRE_DURATION to ignore target_duration_every', function (): void { + $criteria = goals_criteria::fromJson((string)json_encode([ + 'target' => 100, + 'target_duration' => 'ENTIRE_DURATION', + 'target_duration_every' => 7, + ], JSON_UNESCAPED_UNICODE)); + + $criteria->validateAndSanitize(); + + expect($criteria->target_duration)->toBe(goals_criteria_target_duration::ENTIRE_DURATION); + expect($criteria->target_duration_every)->toBeNull(); +}); + +it('defaults recurring target_duration_every to 1 when omitted', function (): void { + $criteria = goals_criteria::fromJson((string)json_encode([ + 'target' => 100, + 'target_duration' => 'YEARS', + ], JSON_UNESCAPED_UNICODE)); + + $criteria->validateAndSanitize(); + + expect($criteria->target_duration)->toBe(goals_criteria_target_duration::YEARS); + expect($criteria->target_duration_every)->toBe(1); +}); + +it('keeps strict legacy mode when target_duration is invalid', function (): void { + $criteria = goals_criteria::fromJson((string)json_encode([ + 'target' => 50, + 'target_duration' => 'invalid-value', + 'target_duration_every' => 4, + ], JSON_UNESCAPED_UNICODE)); + + $criteria->validateAndSanitize(); + + expect($criteria->usesAdvancedTargetDuration())->toBeFalse(); + expect($criteria->target_duration)->toBeNull(); + expect($criteria->target_duration_every)->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Goals/GoalsCriteriaTargetMathTest.php b/services/nginx/app/tests/Unit/Goals/GoalsCriteriaTargetMathTest.php new file mode 100644 index 00000000..a6582230 --- /dev/null +++ b/services/nginx/app/tests/Unit/Goals/GoalsCriteriaTargetMathTest.php @@ -0,0 +1,117 @@ +id = $id; + return $department; +} + +it('computes weekly target using touched ISO weeks for March 2026', function (): void { + $criteria = new goals_criteria(); + $criteria->type = CriteriaType::NONE; + $criteria->target = 10; + $criteria->target_duration = TargetDuration::WEEKS; + $criteria->target_duration_every = 1; + $criteria->start = new DateTime('2026-03-01 00:00:00'); + $criteria->end = new DateTime('2026-03-31 23:59:59'); + $criteria->departments->set([goals_test_department(12)]); + $criteria->validateAndSanitize(); + + $target = $criteria->calculateTargetForRange( + new DateTime('2026-03-01 00:00:00'), + new DateTime('2026-03-31 23:59:59') + ); + + expect($target)->toBe(60.0); +}); + +it('applies target_duration_every for weekly cadence', function (): void { + $criteria = new goals_criteria(); + $criteria->type = CriteriaType::NONE; + $criteria->target = 10; + $criteria->target_duration = TargetDuration::WEEKS; + $criteria->target_duration_every = 2; + $criteria->start = new DateTime('2026-03-01 00:00:00'); + $criteria->end = new DateTime('2026-03-31 23:59:59'); + $criteria->departments->set([goals_test_department(12)]); + $criteria->validateAndSanitize(); + + $target = $criteria->calculateTargetForRange( + new DateTime('2026-03-01 00:00:00'), + new DateTime('2026-03-31 23:59:59') + ); + + expect($target)->toBe(30.0); +}); + +it('prorates ENTIRE_DURATION target by overlap days', function (): void { + $criteria = new goals_criteria(); + $criteria->type = CriteriaType::NONE; + $criteria->target = 310; + $criteria->target_duration = TargetDuration::ENTIRE_DURATION; + $criteria->start = new DateTime('2026-01-01 00:00:00'); + $criteria->end = new DateTime('2026-01-31 23:59:59'); + $criteria->departments->set([goals_test_department(12)]); + $criteria->validateAndSanitize(); + + $target = $criteria->calculateTargetForRange( + new DateTime('2026-01-01 00:00:00'), + new DateTime('2026-01-07 23:59:59') + ); + + expect($target)->toBe(70.0); +}); + +it('splits advanced target by override weights per department', function (): void { + $criteria = new goals_criteria(); + $criteria->type = CriteriaType::NONE; + $criteria->target = 10; + $criteria->target_duration = TargetDuration::WEEKS; + $criteria->target_duration_every = 1; + $criteria->start = new DateTime('2026-03-01 00:00:00'); + $criteria->end = new DateTime('2026-03-31 23:59:59'); + $criteria->departments->set([goals_test_department(12), goals_test_department(15)]); + $criteria->department_daily_targets = [12 => 3, 15 => 1]; + $criteria->validateAndSanitize(); + + $dept12 = $criteria->calculateTargetForRange( + new DateTime('2026-03-01 00:00:00'), + new DateTime('2026-03-31 23:59:59'), + 12, + [12, 15] + ); + $dept15 = $criteria->calculateTargetForRange( + new DateTime('2026-03-01 00:00:00'), + new DateTime('2026-03-31 23:59:59'), + 15, + [12, 15] + ); + + expect($dept12)->toBe(45.0); + expect($dept15)->toBe(15.0); +}); + +it('preserves legacy target behavior when target_duration is missing', function (): void { + $criteria = new goals_criteria(); + $criteria->type = CriteriaType::NONE; + $criteria->target = 70; + $criteria->start = new DateTime('first day of this month 00:00:00'); + $criteria->end = new DateTime('now'); + $criteria->departments->set([goals_test_department(12)]); + $criteria->validateAndSanitize(); + + $monthProgress = $criteria->getProgressDetails('month'); + $allProgress = $criteria->getProgressDetails(); + + expect((float)$monthProgress['target'])->toBe(0.0); + expect((float)$allProgress['target'])->toBe(70.0); +}); diff --git a/services/nginx/app/tests/Unit/Goals/GoalsLegacyRendererScriptsTest.php b/services/nginx/app/tests/Unit/Goals/GoalsLegacyRendererScriptsTest.php new file mode 100644 index 00000000..348dff82 --- /dev/null +++ b/services/nginx/app/tests/Unit/Goals/GoalsLegacyRendererScriptsTest.php @@ -0,0 +1,19 @@ +toBe(0); +}); + +it('keeps legacy department daily renderer script green', function (): void { + $result = run_legacy_script('tests/goals/DepartmentDailyTargetsRendererTest.php'); + if ((int)$result['exitCode'] !== 0) { + throw new RuntimeException((string)$result['output']); + } + + expect((int)$result['exitCode'])->toBe(0); +}); diff --git a/services/nginx/app/tests/Unit/Goals/GoalsOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Goals/GoalsOpenApiSpecTest.php new file mode 100644 index 00000000..f044083d --- /dev/null +++ b/services/nginx/app/tests/Unit/Goals/GoalsOpenApiSpecTest.php @@ -0,0 +1,38 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +it('documents advanced goals target duration fields in openapi', function (): void { + $content = goals_openapi_content_or_skip(); + + expect($content)->toContain('GoalsCriteria:'); + expect($content)->toContain('target_duration:'); + expect($content)->toContain('target_duration_every:'); + expect($content)->toContain('enum: [ENTIRE_DURATION, WEEKS, MONTHS, YEARS]'); + expect($content)->toContain('targetDuration'); + expect($content)->toContain('targetDurationEvery'); +}); diff --git a/services/nginx/app/tests/Unit/Http/ResponseRequestParametersTest.php b/services/nginx/app/tests/Unit/Http/ResponseRequestParametersTest.php new file mode 100644 index 00000000..969a6d3d --- /dev/null +++ b/services/nginx/app/tests/Unit/Http/ResponseRequestParametersTest.php @@ -0,0 +1,71 @@ +body; + } + }; +} + +it('reads JSON payloads for DELETE request parameter arrays', function (): void { + $previousMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $previousGet = $_GET; + + try { + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $_GET = []; + + $response = response_request_parameters_response_with_body(json_encode([ + 'confirm' => 'delete-coolify-target-3', + 'delete_resource' => false, + ])); + + expect($response->getAllRequestParameters())->toBe([ + 'confirm' => 'delete-coolify-target-3', + 'delete_resource' => false, + ]); + expect($response->getRequestParameter('confirm'))->toBe('delete-coolify-target-3'); + expect($response->isRequestParameterSet('delete_resource'))->toBeTrue(); + } finally { + $_GET = $previousGet; + if ($previousMethod === null) { + unset($_SERVER['REQUEST_METHOD']); + } else { + $_SERVER['REQUEST_METHOD'] = $previousMethod; + } + } +}); + +it('keeps DELETE query parameters when no JSON body is present', function (): void { + $previousMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $previousGet = $_GET; + + try { + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $_GET = ['confirm' => 'delete-coolify-target-5']; + + $response = response_request_parameters_response_with_body(''); + + expect($response->getAllRequestParameters())->toBe([ + 'confirm' => 'delete-coolify-target-5', + ]); + } finally { + $_GET = $previousGet; + if ($previousMethod === null) { + unset($_SERVER['REQUEST_METHOD']); + } else { + $_SERVER['REQUEST_METHOD'] = $previousMethod; + } + } +}); diff --git a/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php new file mode 100644 index 00000000..b452177c --- /dev/null +++ b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php @@ -0,0 +1,66 @@ +toBe('https://api-v2.truckwash.io'); + expect(cors_policy::normalizeOrigin('https://API-V2.TRUCKWASH.IO/canary/api/')) + ->toBe('https://api-v2.truckwash.io'); + expect(cors_policy::normalizeOrigin('https://api.truckwash.io:4433/ping')) + ->toBe('https://api.truckwash.io:4433'); +}); + +it('merges required release and existing frontend origins into configured CORS', function (): void { + $origins = cors_policy::allowedOrigins('https://example.test/app,https://api-v2.truckwash.io/master/api'); + + expect($origins)->toContain('https://api-v2.truckwash.io'); + expect($origins)->toContain('http://localhost:5173'); + expect($origins)->toContain('https://truckwash.io'); + expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api'); +}); + +it('builds credential-safe normal CORS response headers for allowed origins', function (): void { + $headers = cors_policy::responseHeaders('http://localhost:5173', 'https://truckwash.io'); + + expect($headers['Access-Control-Allow-Origin'])->toBe('http://localhost:5173'); + expect($headers['Access-Control-Allow-Credentials'])->toBe('true'); + expect($headers['Access-Control-Allow-Methods'])->toContain('PATCH'); + expect($headers['Access-Control-Allow-Headers'])->toContain('X-Release-Trace'); + expect($headers['Access-Control-Allow-Headers'])->toContain('Cache-Control'); + expect($headers['Access-Control-Max-Age'])->toBe('86400'); + expect($headers['Vary'])->toBe('Origin'); +}); + +it('builds preflight CORS response headers for api-v2 release URLs', function (): void { + $preflight = cors_policy::preflightResponse( + 'https://api-v2.truckwash.io/master/api', + 'https://truckwash.io' + ); + + expect($preflight['allowed'])->toBeTrue(); + expect($preflight['status'])->toBe(200); + expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io'); + expect($preflight['headers']['Content-Type'])->toBe('application/json'); + expect($preflight['body'])->toBe(''); +}); + +it('rejects unknown CORS origins', function (): void { + $headers = cors_policy::responseHeaders('https://evil.example.test', 'https://truckwash.io'); + $preflight = cors_policy::preflightResponse('https://evil.example.test', 'https://truckwash.io'); + + expect($headers)->toBe([]); + expect($preflight['allowed'])->toBeFalse(); + expect($preflight['status'])->toBe(403); + expect($preflight['body'])->toContain('CORS origin not allowed'); +}); + +it('reflects the request origin for wildcard CORS instead of sending credentialed wildcard headers', function (): void { + $headers = cors_policy::responseHeaders('https://partner.example.test', '*'); + + expect($headers['Access-Control-Allow-Origin'])->toBe('https://partner.example.test'); + expect($headers['Access-Control-Allow-Credentials'])->toBe('true'); + expect($headers['Access-Control-Allow-Origin'])->not->toBe('*'); +}); diff --git a/services/nginx/app/tests/Unit/Infrastructure/CorsReleaseHeadersTest.php b/services/nginx/app/tests/Unit/Infrastructure/CorsReleaseHeadersTest.php new file mode 100644 index 00000000..1f148c02 --- /dev/null +++ b/services/nginx/app/tests/Unit/Infrastructure/CorsReleaseHeadersTest.php @@ -0,0 +1,28 @@ +toBeTrue(); + $content = (string)file_get_contents($file); + + foreach ($requiredHeaders as $header) { + expect($content)->toContain($header); + } + } + + expect((string)file_get_contents(app_path('index.php')))->toContain('cors_policy::preflightResponse'); + expect((string)file_get_contents(app_path('routes/optionsRoute.php')))->toContain('cors_policy::preflightResponse'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicQueueResponseContractTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicQueueResponseContractTest.php new file mode 100644 index 00000000..a92b3a6d --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicQueueResponseContractTest.php @@ -0,0 +1,54 @@ +not->toBeFalse(); + + $content = (string)$content; + $start = strpos($content, "\$this->post('/collected-invoices/economic'"); + $end = strpos($content, "\$this->get('/collected-invoices/economic/queue'"); + + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + expect($end)->toBeGreaterThan($start); + + $endpointBlock = substr($content, (int)$start, (int)$end - (int)$start); + + expect($endpointBlock)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($endpointBlock)->toContain("'message' => 'Collected invoice export processed synchronously'"); + expect($endpointBlock)->toContain("'mode' => 'synchronous_fallback'"); + expect($endpointBlock)->toContain("'result' => \$result"); + expect($endpointBlock)->toContain("\$job_id = (int)(\$job['id'] ?? 0);"); + expect($endpointBlock)->toContain("'job_id' => \$job_id"); + expect($endpointBlock)->toContain("'job' => \$job"); + expect($endpointBlock)->toContain("], 202);"); + expect($endpointBlock)->toContain('exportCollectedInvoiceSynchronously'); +}); + +it('documents both synchronous fallback and queued response contracts for POST /collected-invoices/stripe/book', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + + $content = (string)$content; + $start = strpos($content, "\$this->post('/collected-invoices/stripe/book'"); + $end = strpos($content, "\$this->post('/collected-invoices/vehicle-subscriptions'"); + + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + expect($end)->toBeGreaterThan($start); + + $endpointBlock = substr($content, (int)$start, (int)$end - (int)$start); + + expect($endpointBlock)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($endpointBlock)->toContain("'message' => 'Stripe collected invoice export processed synchronously'"); + expect($endpointBlock)->toContain("'mode' => 'synchronous_fallback'"); + expect($endpointBlock)->toContain("'result' => \$result"); + expect($endpointBlock)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT'); + expect($endpointBlock)->toContain("\$job_id = (int)(\$job['id'] ?? 0);"); + expect($endpointBlock)->toContain("'job_id' => \$job_id"); + expect($endpointBlock)->toContain("'job' => \$job"); + expect($endpointBlock)->toContain("'message' => 'Stripe collected invoice export queued'"); + expect($endpointBlock)->toContain("], 202);"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php new file mode 100644 index 00000000..48abec37 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php @@ -0,0 +1,166 @@ + economic_transfer_queue::STATUS_COMPLETED, + 'payload' => [ + 'collected_invoice_id' => '14578', + 'send_as_is' => '1', + 'requested_by' => '18', + ], + 'result' => [ + 'message' => 'Invoice transferred', + 'customer_number' => '43425425', + 'customer_name' => 'North Freight', + 'economic_invoice_draft_id' => '2001', + 'economic_invoice_booked_id' => '3001', + 'external_id' => 'abc-123', + 'total_net_amount' => '670', + 'orders' => [ + ['id' => 1], + ['id' => 2], + ], + ], + ]); + + expect($summary['message'])->toBe('Invoice transferred'); + expect($summary['target'])->toBe([ + 'collected_invoice_id' => 14578, + 'send_as_is' => true, + 'requested_by' => 18, + ]); + expect($summary['customer'])->toBe([ + 'customer_number' => 43425425, + 'name' => 'North Freight', + ]); + expect($summary['outcome'])->toBe([ + 'economic_invoice_draft_id' => 2001, + 'economic_invoice_booked_id' => 3001, + 'external_id' => 'abc-123', + 'total_net_amount' => 670.0, + 'order_count' => 2, + ]); + expect($summary['raw_available'])->toBe([ + 'payload' => true, + 'result' => true, + ]); +}); + +it('prefers queue error messages for failed jobs and keeps null-safe outcome fields', function (): void { + $summary = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary([ + 'status' => economic_transfer_queue::STATUS_FAILED, + 'collected_invoice_id' => 9001, + 'error_message' => 'No billable order items', + 'payload' => [ + 'collected_invoice_id' => 9001, + ], + 'result' => null, + ]); + + expect($summary['message'])->toBe('No billable order items'); + expect($summary['target']['collected_invoice_id'])->toBe(9001); + expect($summary['target']['send_as_is'])->toBeNull(); + expect($summary['target']['requested_by'])->toBeNull(); + + expect($summary['customer'])->toBe([ + 'customer_number' => null, + 'name' => null, + ]); + expect($summary['outcome'])->toBe([ + 'economic_invoice_draft_id' => null, + 'economic_invoice_booked_id' => null, + 'external_id' => null, + 'total_net_amount' => null, + 'order_count' => null, + ]); + expect($summary['raw_available'])->toBe([ + 'payload' => true, + 'result' => false, + ]); +}); + +it('uses queued job payload customer context before result data exists', function (): void { + $summary = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary([ + 'status' => economic_transfer_queue::STATUS_QUEUED, + 'payload' => [ + 'collected_invoice_id' => 17389, + 'customer_number' => '778899', + 'customer_name' => 'Queued Customer A/S', + ], + 'result' => null, + 'progress_message' => 'Queued', + ]); + + expect($summary['target']['collected_invoice_id'])->toBe(17389); + expect($summary['customer'])->toBe([ + 'customer_number' => 778899, + 'name' => 'Queued Customer A/S', + ]); +}); + +it('uses deterministic status message fallback when no explicit message exists', function (): void { + $expectations = [ + [economic_transfer_queue::STATUS_QUEUED, 'Queued'], + [economic_transfer_queue::STATUS_PROCESSING, 'Processing'], + [economic_transfer_queue::STATUS_COMPLETED, 'Completed'], + [economic_transfer_queue::STATUS_FAILED, 'Failed'], + ['UNKNOWN', null], + ]; + + foreach ($expectations as [$status, $expected]) { + $summary = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary([ + 'status' => $status, + 'payload' => null, + 'result' => null, + 'error_message' => null, + 'progress_message' => null, + ]); + + expect($summary['message'])->toBe($expected); + } +}); + +it('handles object payload/result values and malformed fields without throwing', function (): void { + $summary = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary([ + 'status' => economic_transfer_queue::STATUS_COMPLETED, + 'payload' => (object)[ + 'collected_invoice_id' => '501', + 'send_as_is' => 'invalid', + 'requested_by' => '-1', + ], + 'result' => (object)[ + 'draft_invoice_id' => '44', + 'booked_invoice_id' => '55', + 'total_net_amount' => 'invalid', + 'user' => (object)[ + 'customer_number' => '9345', + 'company_name' => 'Acme Carrier', + ], + ], + 'error_message' => null, + ]); + + expect($summary['target'])->toBe([ + 'collected_invoice_id' => 501, + 'send_as_is' => null, + 'requested_by' => null, + ]); + expect($summary['customer'])->toBe([ + 'customer_number' => 9345, + 'name' => 'Acme Carrier', + ]); + expect($summary['outcome'])->toBe([ + 'economic_invoice_draft_id' => 44, + 'economic_invoice_booked_id' => 55, + 'external_id' => null, + 'total_net_amount' => null, + 'order_count' => null, + ]); + expect($summary['raw_available'])->toBe([ + 'payload' => true, + 'result' => true, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php new file mode 100644 index 00000000..51cb9ab0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php @@ -0,0 +1,134 @@ +not->toBeFalse(); + $content = (string)$content; + + $start = strpos($content, "\$this->get('/collected-invoices/economic/queue'"); + $end = strpos($content, "\$this->get('/collected-invoices/economic/queue/status'"); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + expect($end)->toBeGreaterThan($start); + + $endpointBlock = substr($content, (int)$start, (int)$end - (int)$start); + expect($endpointBlock)->toContain('$statuses = $this->parseCollectedInvoiceQueueStatuses();'); + expect($endpointBlock)->toContain("['limit' => \$limit, 'offset' => \$offset] = \$this->parseCollectedInvoiceQueuePagination();"); + expect($endpointBlock)->toContain('$jobs = $queue->listJobs('); + expect($endpointBlock)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT'); + expect($endpointBlock)->toContain('$total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses);'); + expect($endpointBlock)->toContain("'items' => \$this->withCollectedInvoiceQueueDetailsSummaryList(\$jobs)"); + expect($endpointBlock)->toContain("'total' => \$total_jobs"); + expect($endpointBlock)->toContain("'limit' => \$limit"); + expect($endpointBlock)->toContain("'offset' => \$offset"); + expect($endpointBlock)->toContain("'has_more' => \$has_more"); + + expect($content)->toContain('private function parseCollectedInvoiceQueueStatuses(): array'); + expect($content)->toContain('status must contain only: '); + expect($content)->toContain('private function parseCollectedInvoiceQueuePagination(): array'); + expect($content)->toContain('limit must be between 1 and 500'); + expect($content)->toContain('offset must be at least 0'); + expect($content)->toContain('private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int'); + expect($content)->toContain("method_exists(\$queue, 'countJobs')"); + expect($content)->toContain("SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs WHERE "); +}); + +it('enforces retry constraints for collected-invoice queue jobs before retry execution', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + $start = strpos($content, "\$this->post('/collected-invoices/economic/queue/retry'"); + $end = strpos($content, "\$this->post('/collected-invoices/move-multiple/registration-numbers'"); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + expect($end)->toBeGreaterThan($start); + + $endpointBlock = substr($content, (int)$start, (int)$end - (int)$start); + expect($endpointBlock)->toContain('$job_id = $this->requireCollectedInvoiceQueueJobId();'); + expect($endpointBlock)->toContain('$job = $this->requireCollectedInvoiceQueueJobById($job_id, true);'); + expect($endpointBlock)->toContain("Collected invoice queue job reached max retry attempts', 409"); + expect($endpointBlock)->toContain('Failed to retry collected invoice queue job: '); + expect($endpointBlock)->toContain("'job' => \$this->withCollectedInvoiceQueueDetailsSummary(\$retried)"); + + expect($content)->toContain("\$response->success(\$this->withCollectedInvoiceQueueDetailsSummary(\$job));"); + + expect($content)->toContain('private function requireCollectedInvoiceQueueJobId(): int'); + expect($content)->toContain('private function requireCollectedInvoiceQueueJobById(int $job_id, bool $mustBeFailed = false): array'); + expect($content)->toContain("Collected invoice queue job can only be retried when status is FAILED', 409"); + expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummary(array $job): array'); + expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array'); + expect($content)->toContain('private function resolveCollectedInvoiceQueueCustomerSummary(array $customer, int $collected_invoice_id): array'); + expect($content)->toContain('FROM collected_order_invoices coi'); + expect($content)->toContain('LEFT JOIN users u ON u.customer_number = coi.customer_number'); + expect($content)->toContain('private function resolveCollectedInvoiceQueueRetryErrorStatus(string $message): int'); + expect($content)->toContain('only failed jobs can be retried'); + expect($content)->toContain('max retry attempts'); +}); + +it('exposes collected-invoice queue monitor and per-user terminal clear routes', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + $monitorStart = strpos($content, "\$this->get('/collected-invoices/economic/queue/monitor'"); + $dismissStart = strpos($content, "\$this->post('/collected-invoices/economic/queue/dismiss'"); + $dismissTerminalStart = strpos($content, "\$this->post('/collected-invoices/economic/queue/dismiss-terminal'"); + + expect($monitorStart)->not->toBeFalse(); + expect($dismissStart)->not->toBeFalse(); + expect($dismissTerminalStart)->not->toBeFalse(); + + expect($content)->toContain('$limit = $this->parseCollectedInvoiceQueueMonitorLimit();'); + expect($content)->toContain('$this->buildCollectedInvoiceQueueMonitorPayload('); + expect($content)->toContain('private function parseCollectedInvoiceQueueMonitorLimit(): int'); + expect($content)->toContain('limit must be between 1 and 100'); + expect($content)->toContain('private function buildCollectedInvoiceQueueMonitorPayload(economic_transfer_queue $queue, int $user_id, int $limit): array'); + expect($content)->toContain('$queue->listMonitorJobsForUser('); + expect($content)->toContain("'queued' => 0"); + expect($content)->toContain("'in_progress' => 0"); + expect($content)->toContain("'progress_percent' => \$counts['total'] > 0"); + + expect($content)->toContain('$queue->dismissTerminalJobForUser($job_id, (int)$user->id);'); + expect($content)->toContain('Only completed or failed collected invoice queue jobs can be cleared'); + expect($content)->toContain('$queue->dismissTerminalJobsForUser('); + expect($content)->toContain("'dismissed_count' => \$dismissed_count"); +}); + +it('runs collected-invoice queue batches through an explicit manual endpoint', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + $start = strpos($content, "\$this->post('/collected-invoices/economic/queue/run'"); + $end = strpos($content, "\$this->post('/collected-invoices/move-multiple/registration-numbers'"); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + expect($end)->toBeGreaterThan($start); + + $endpointBlock = substr($content, (int)$start, (int)$end - (int)$start); + expect($endpointBlock)->toContain("\$this->ensureEconomicTransferQueueIsAvailable();"); + expect($endpointBlock)->toContain("\$limit = 10;"); + expect($endpointBlock)->toContain("limit must be a positive integer"); + expect($endpointBlock)->toContain("\$limit = max(1, min(10, \$limit));"); + expect($endpointBlock)->toContain("\$batch = \$this->runCollectedInvoiceQueueBatch(\$queue, \$limit);"); + expect($endpointBlock)->toContain("\$result = (array)(\$batch['result'] ?? []);"); + expect($endpointBlock)->toContain("economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT"); + expect($endpointBlock)->toContain("'message' => (bool)(\$batch['fallback'] ?? false)"); + expect($endpointBlock)->toContain("'processed' => (int)(\$result['processed'] ?? 0)"); + expect($endpointBlock)->toContain("'completed' => (int)(\$result['completed'] ?? 0)"); + expect($endpointBlock)->toContain("'failed' => (int)(\$result['failed'] ?? 0)"); + expect($endpointBlock)->toContain("'jobs' => array_values(array_map('intval', (array)(\$result['jobs'] ?? [])))"); + expect($endpointBlock)->toContain("'limit' => \$limit"); + expect($endpointBlock)->toContain("'transfer_type' => economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT"); + expect($endpointBlock)->toContain("'fallback' => (bool)(\$batch['fallback'] ?? false)"); + + expect($content)->toContain('private function runCollectedInvoiceQueueBatch(economic_transfer_queue $queue, int $limit): array'); + expect($content)->toContain("method_exists(\$queue, 'processPendingByTransferType')"); + expect($content)->toContain("\$queue->processPendingByTransferType("); + expect($content)->toContain("\$queue->processPending(\$limit)"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/CustomerSearchRouteEconomicFailureHandlingTest.php b/services/nginx/app/tests/Unit/Invoicing/CustomerSearchRouteEconomicFailureHandlingTest.php new file mode 100644 index 00000000..be9e5a07 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/CustomerSearchRouteEconomicFailureHandlingTest.php @@ -0,0 +1,36 @@ +not->toBeFalse(); + expect($content)->toContain("catch (\\Throwable \$throwable)"); + expect($content)->toContain("'LIST_CUSTOMERS_FAILED'"); + expect($content)->toContain("'message' => 'Failed to fetch customers from e-conomic'"); + expect($content)->toContain("'upstream_message' => \$upstreamMessage"); + expect($content)->toContain("], 502);"); +}); + +it('logs LIST_CUSTOMERS success only after pagination and customer mapping are completed', function (): void { + $routeFile = app_path('routes/customerSearchRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + + $paginatePosition = strpos($content, '$response->paginate('); + $successLogPosition = strpos($content, "'LIST_CUSTOMERS', 'Successfully listed customers'"); + + expect($paginatePosition)->not->toBeFalse(); + expect($successLogPosition)->not->toBeFalse(); + expect($successLogPosition)->toBeGreaterThan($paginatePosition); +}); + +it('guards against malformed customer list payloads before calling paginate', function (): void { + $routeFile = app_path('routes/customerSearchRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('missing pagination results'); + expect($content)->toContain('missing customer collection'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php new file mode 100644 index 00000000..08f5a822 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php @@ -0,0 +1,192 @@ +setPrivateTokenField('app_token', $appSecretToken); + $this->setPrivateTokenField('appAccessGrant', $primaryGrant); + $this->setPrivateTokenField('appAccessGrant2', $secondaryGrant); + } + + public function resolveAgreementGrantToken(bool $authToken2): string + { + return $this->resolve_agreement_grant_token($authToken2); + } + + public function resolveAppSecretToken(): string + { + return $this->resolve_app_secret_token(); + } + + public function assertSuccessfulResponse(int $httpStatusCode, string|false $response): string + { + return $this->assert_successful_response($httpStatusCode, $response); + } + + private function setPrivateTokenField(string $fieldName, string $value): void + { + $reflection = new ReflectionProperty(economic_m::class, $fieldName); + $reflection->setAccessible(true); + $reflection->setValue($this, $value); + } + } +} + +beforeEach(function (): void { + global $ECONOMIC_API; + + $ECONOMIC_API = [ + 'app_secret_token' => 'test-secret', + 'app_access_grant' => 'primary-grant', + 'app_access_grant2' => 'secondary-grant', + ]; +}); + +it('uses grant #2 in economic_endpoint_t when explicitly requested and configured', function (): void { + $probe = new EconomicEndpointGrantFallbackProbe(); + + expect($probe->resolveAgreementGrantToken(true))->toBe('secondary-grant'); + expect($probe->resolveAgreementGrantToken(false))->toBe('primary-grant'); +}); + +it('falls back to grant #1 in economic_endpoint_t when grant #2 is missing', function (): void { + global $ECONOMIC_API; + $ECONOMIC_API['app_access_grant2'] = ''; + + $probe = new EconomicEndpointGrantFallbackProbe(); + + expect($probe->resolveAgreementGrantToken(true))->toBe('primary-grant'); + expect($probe->resolveAgreementGrantToken(false))->toBe('primary-grant'); +}); + +it('throws for missing required primary grant in economic_endpoint_t', function (): void { + global $ECONOMIC_API; + $ECONOMIC_API['app_access_grant'] = ''; + + $probe = new EconomicEndpointGrantFallbackProbe(); + + expect(fn() => $probe->resolveAgreementGrantToken(false)) + ->toThrow(RuntimeException::class, 'Missing e-conomic agreement grant token'); +}); + +it('throws for missing app secret in economic_endpoint_t', function (): void { + global $ECONOMIC_API; + $ECONOMIC_API['app_secret_token'] = ''; + + $probe = new EconomicEndpointGrantFallbackProbe(); + + expect(fn() => $probe->resolveAppSecretToken()) + ->toThrow(RuntimeException::class, 'Missing e-conomic app secret token'); +}); + +it('uses grant #2 in legacy economic_m when available', function (): void { + $probe = new EconomicMLegacyGrantFallbackProbe(); + $probe->configureTokens('test-secret', 'primary-grant', 'secondary-grant'); + + expect($probe->resolveAgreementGrantToken(true))->toBe('secondary-grant'); + expect($probe->resolveAgreementGrantToken(false))->toBe('primary-grant'); +}); + +it('falls back to grant #1 in legacy economic_m when grant #2 is missing', function (): void { + $probe = new EconomicMLegacyGrantFallbackProbe(); + $probe->configureTokens('test-secret', 'primary-grant', ''); + + expect($probe->resolveAgreementGrantToken(true))->toBe('primary-grant'); +}); + +it('throws for missing required primary grant in legacy economic_m', function (): void { + $probe = new EconomicMLegacyGrantFallbackProbe(); + $probe->configureTokens('test-secret', '', 'secondary-grant'); + + expect(fn() => $probe->resolveAgreementGrantToken(false)) + ->toThrow(RuntimeException::class, 'Missing e-conomic agreement grant token'); +}); + +it('throws for missing app secret in legacy economic_m', function (): void { + $probe = new EconomicMLegacyGrantFallbackProbe(); + $probe->configureTokens('', 'primary-grant', 'secondary-grant'); + + expect(fn() => $probe->resolveAppSecretToken()) + ->toThrow(RuntimeException::class, 'Missing e-conomic app secret token'); +}); + +it('throws deterministic upstream exceptions for non-2xx endpoint trait responses', function (): void { + $probe = new EconomicEndpointGrantFallbackProbe(); + $errorPayload = json_encode([ + 'message' => 'Could not parse query string filter.', + 'errors' => ['Filtering is not allowed on property \'invalidField\'.'], + 'logId' => 'abc123', + 'httpStatusCode' => 400, + ]); + + expect(fn() => $probe->assertSuccessfulResponse(400, $errorPayload)) + ->toThrow(RuntimeException::class, 'Could not parse query string filter.'); + + expect(fn() => $probe->assertSuccessfulResponse(502, 'Gateway timeout')) + ->toThrow(RuntimeException::class, 'HTTP 502'); +}); + +it('throws deterministic upstream exceptions for non-2xx legacy economic_m responses', function (): void { + $probe = new EconomicMLegacyGrantFallbackProbe(); + $probe->configureTokens('test-secret', 'primary-grant', 'secondary-grant'); + $errorPayload = json_encode([ + 'message' => 'Could not parse query string filter.', + 'errors' => ['Filtering is not allowed on property \'invalidField\'.'], + 'logId' => 'abc123', + 'httpStatusCode' => 400, + ]); + + expect(fn() => $probe->assertSuccessfulResponse(400, $errorPayload)) + ->toThrow(RuntimeException::class, 'Could not parse query string filter.'); + + expect(fn() => $probe->assertSuccessfulResponse(500, 'server error')) + ->toThrow(RuntimeException::class, 'HTTP 500'); +}); + +it('returns raw payload unchanged on successful HTTP statuses', function (): void { + $endpointProbe = new EconomicEndpointGrantFallbackProbe(); + $legacyProbe = new EconomicMLegacyGrantFallbackProbe(); + $legacyProbe->configureTokens('test-secret', 'primary-grant', 'secondary-grant'); + + $payload = '{"collection":[{"customerNumber":1000}],"pagination":{"results":1}}'; + + expect($endpointProbe->assertSuccessfulResponse(200, $payload))->toBe($payload); + expect($legacyProbe->assertSuccessfulResponse(200, $payload))->toBe($payload); +}); + +it('bounds e-conomic curl calls below the PHP request timeout', function (): void { + $legacyContent = file_get_contents(app_path('modules/economic/economic_m.php')); + $endpointContent = file_get_contents(app_path('traits/economic_endpoint_t.php')); + + expect($legacyContent)->not->toBeFalse() + ->and($endpointContent)->not->toBeFalse(); + + foreach ([(string)$legacyContent, (string)$endpointContent] as $content) { + expect($content)->toContain('CURLOPT_CONNECTTIMEOUT => 3') + ->and($content)->toContain('CURLOPT_TIMEOUT => 30') + ->and($content)->not->toContain('CURLOPT_TIMEOUT => 0'); + } +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php new file mode 100644 index 00000000..aa591ee1 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php @@ -0,0 +1,80 @@ + */ + public array $product_numbers = []; + + /** @var array */ + public array $discount_responses = []; + + public function __construct() + { + } + + public function getCustomerProducts(int $customer_number, int $limit = 10, int $page = 1): object + { + return (object)[ + 'collection' => array_map( + fn (int|string $product_number): object => (object)[ + 'product' => (object)[ + 'productNumber' => (string)$product_number, + ], + ], + $this->product_numbers + ), + ]; + } + + public function getCustomerProductDiscount(int $customer_number, int $product_id) + { + $response = $this->discount_responses[$product_id] ?? (object)[ + 'discountPercentage' => 0, + ]; + + if ($response instanceof \RuntimeException) { + throw $response; + } + + return $response; + } +} + +it('falls back to a later customer template product when earlier probes fail on missing currency prices', function (): void { + $economic = new economicCustomersDiscountFallbackTestDouble(); + $economic->product_numbers = [1, 5, 9]; + $economic->discount_responses = [ + 1 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'), + 5 => (object)['discountPercentage' => 12], + ]; + + expect($economic->getCustomerDiscountPercentage(23152645))->toBe(12); +}); + +it('returns zero when every customer template lookup fails due to missing currency prices', function (): void { + $economic = new economicCustomersDiscountFallbackTestDouble(); + $economic->product_numbers = [1, 2, 3]; + $economic->discount_responses = [ + 1 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'), + 2 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'), + 3 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'), + ]; + + expect($economic->getCustomerDiscountPercentage(23152645))->toBe(0); +}); + +it('rethrows unrelated discount lookup failures', function (): void { + $economic = new economicCustomersDiscountFallbackTestDouble(); + $economic->product_numbers = [1]; + $economic->discount_responses = [ + 1 => new RuntimeException('HTTP 503 upstream unavailable'), + ]; + + expect(fn () => $economic->getCustomerDiscountPercentage(23152645)) + ->toThrow(RuntimeException::class, 'HTTP 503 upstream unavailable'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicCustomersListResponseValidationTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomersListResponseValidationTest.php new file mode 100644 index 00000000..897ae5c0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomersListResponseValidationTest.php @@ -0,0 +1,72 @@ +stubResponse = $response; + } + + protected function send_request($url, $method, $data = '', bool $authToken2 = false): string + { + return $this->stubResponse; + } + } +} + +it('accepts valid list payloads that include collection and pagination results', function (): void { + $probe = new EconomicCustomersListResponseProbe(); + $probe->setStubResponse(json_encode([ + 'collection' => [ + ['customerNumber' => 1000, 'name' => 'Acme A/S'], + ], + 'pagination' => [ + 'results' => 1, + ], + ])); + + $result = $probe->listCustomers(1, 100, 'acme', null); + + expect($result)->toBeObject(); + expect(isset($result->collection))->toBeTrue(); + expect(is_array($result->collection))->toBeTrue(); + expect($result->collection[0]->customerNumber)->toBe(1000); + expect($result->pagination->results)->toBe(1); +}); + +it('throws when pagination is missing from the response payload', function (): void { + $probe = new EconomicCustomersListResponseProbe(); + $probe->setStubResponse(json_encode([ + 'collection' => [ + ['customerNumber' => 1000, 'name' => 'Acme A/S'], + ], + ])); + + expect(fn() => $probe->listCustomers(1, 100, null, null)) + ->toThrow(RuntimeException::class, 'missing pagination'); +}); + +it('throws when upstream responds with an error-shaped payload', function (): void { + $probe = new EconomicCustomersListResponseProbe(); + $probe->setStubResponse(json_encode([ + 'message' => 'Could not parse query string filter.', + 'errors' => ['Filtering is not allowed on property \'invalidField\'.'], + 'httpStatusCode' => 400, + ])); + + expect(fn() => $probe->listCustomers(1, 100, 'foo', null)) + ->toThrow(RuntimeException::class, 'Could not parse query string filter.'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php new file mode 100644 index 00000000..3286e138 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php @@ -0,0 +1,39 @@ +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'); + } +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicDraftQueueOnlyRouteBehaviorTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicDraftQueueOnlyRouteBehaviorTest.php new file mode 100644 index 00000000..2ea094c8 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicDraftQueueOnlyRouteBehaviorTest.php @@ -0,0 +1,62 @@ +not->toBeFalse(); + $content = (string)$content; + + $start = strpos($content, "\$this->post('/economic/invoice/draft/export'"); + $end = strpos($content, "\$this->delete('/economic/invoice/draft/delete'"); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + expect($end)->toBeGreaterThan($start); + + $endpointBlock = substr($content, (int)$start, (int)$end - (int)$start); + expect($endpointBlock)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($endpointBlock)->toContain("'mode' => 'synchronous_fallback'"); + expect($endpointBlock)->toContain("'result' => \$result"); + expect($endpointBlock)->toContain('economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT'); + expect($endpointBlock)->toContain("'job_id' => \$job_id"); + expect($endpointBlock)->toContain("], 202);"); +}); + +it('supports synchronous fallback and queued processing for collected invoice draft-producing routes', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + $collectedStart = strpos($content, "\$this->post('/collected-invoices/economic'"); + $collectedEnd = strpos($content, "\$this->get('/collected-invoices/economic/queue'"); + expect($collectedStart)->not->toBeFalse(); + expect($collectedEnd)->not->toBeFalse(); + expect($collectedEnd)->toBeGreaterThan($collectedStart); + $collectedBlock = substr($content, (int)$collectedStart, (int)$collectedEnd - (int)$collectedStart); + + expect($collectedBlock)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($collectedBlock)->toContain('exportCollectedInvoiceSynchronously'); + expect($collectedBlock)->toContain("'mode' => 'synchronous_fallback'"); + expect($collectedBlock)->toContain("'result' => \$result"); + expect($collectedBlock)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT'); + expect($collectedBlock)->toContain("'job_id' => \$job_id"); + expect($collectedBlock)->toContain("], 202);"); + + $stripeStart = strpos($content, "\$this->post('/collected-invoices/stripe/book'"); + $stripeEnd = strpos($content, "\$this->post('/collected-invoices/vehicle-subscriptions'"); + expect($stripeStart)->not->toBeFalse(); + expect($stripeEnd)->not->toBeFalse(); + expect($stripeEnd)->toBeGreaterThan($stripeStart); + $stripeBlock = substr($content, (int)$stripeStart, (int)$stripeEnd - (int)$stripeStart); + + expect($stripeBlock)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($stripeBlock)->toContain('exportCollectedInvoiceSynchronously'); + expect($stripeBlock)->toContain("'mode' => 'synchronous_fallback'"); + expect($stripeBlock)->toContain("'result' => \$result"); + expect($stripeBlock)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT'); + expect($stripeBlock)->toContain("'message' => 'Stripe collected invoice export queued'"); + expect($stripeBlock)->toContain("'job_id' => \$job_id"); + expect($stripeBlock)->toContain("], 202);"); + + expect($content)->toContain('private function exportCollectedInvoiceSynchronously'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicEndpointUrlEncodingTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicEndpointUrlEncodingTest.php new file mode 100644 index 00000000..7c1ade4d --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicEndpointUrlEncodingTest.php @@ -0,0 +1,48 @@ + 'test-secret', + 'app_access_grant' => 'test-grant', + 'app_access_grant2' => 'test-grant-2', + ]; +}); + +it('encodes raw filter query values that include timestamps', function (): void { + $probe = new EconomicEndpointUrlEncodingProbe(); + + $url = $probe->buildRequestUrl( + '/invoices/booked/?filter=(date$gte:2026-01-01 00:00:00$and:date$lte:2026-01-31 23:59:59)&pageSize=100&skipPages=0' + ); + + expect($url)->toBe( + 'https://restapi.e-conomic.com/invoices/booked/?filter=%28date%24gte%3A2026-01-01%2000%3A00%3A00%24and%3Adate%24lte%3A2026-01-31%2023%3A59%3A59%29&pageSize=100&skipPages=0' + ); +}); + +it('avoids double encoding query values that are already escaped', function (): void { + $probe = new EconomicEndpointUrlEncodingProbe(); + + $url = $probe->buildRequestUrl( + '/invoices/booked/?filter=references.other%24eq%3AEXT%20123&pageSize=100' + ); + + expect($url)->toBe( + 'https://restapi.e-conomic.com/invoices/booked/?filter=references.other%24eq%3AEXT%20123&pageSize=100' + ); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftZeroItemSkipWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftZeroItemSkipWiringTest.php new file mode 100644 index 00000000..9a547904 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftZeroItemSkipWiringTest.php @@ -0,0 +1,10 @@ +not->toBeFalse(); + expect($content)->toContain('private function shouldSkipOrderItemLine(array $order_item): bool'); + expect($content)->toContain('if ($this->shouldSkipOrderItemLine($order_item))'); + expect($content)->toContain('continue;'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicPaymentTermsRouteCollectionExtractionTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicPaymentTermsRouteCollectionExtractionTest.php new file mode 100644 index 00000000..b58b9591 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicPaymentTermsRouteCollectionExtractionTest.php @@ -0,0 +1,59 @@ +getMethod('extractPaymentTermsCollection'); + $target->setAccessible(true); + + return $target->invokeArgs(null, [$response]); +} + +it('uses collection when present', function (): void { + $response = (object)[ + 'collection' => [ + (object)['paymentTermsNumber' => 1], + (object)['paymentTermsNumber' => 2], + ], + ]; + + expect(economic_payment_terms_route_extract_collection($response)) + ->toBe($response->collection); +}); + +it('falls back to paymentTerms when collection is missing', function (): void { + $response = (object)[ + 'paymentTerms' => [ + (object)['paymentTermsNumber' => 10], + (object)['paymentTermsNumber' => 20], + ], + ]; + + expect(economic_payment_terms_route_extract_collection($response)) + ->toBe($response->paymentTerms); +}); + +it('supports top-level array payloads', function (): void { + $response = [ + (object)['paymentTermsNumber' => 100], + (object)['paymentTermsNumber' => 200], + ]; + + expect(economic_payment_terms_route_extract_collection($response)) + ->toBe($response); +}); + +it('returns an empty array when no known collection shape exists', function (): void { + $response = (object)[ + 'message' => 'unexpected shape', + ]; + + expect(economic_payment_terms_route_extract_collection($response)) + ->toBe([]); + expect(economic_payment_terms_route_extract_collection(null)) + ->toBe([]); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferOrderItemSkipTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferOrderItemSkipTest.php new file mode 100644 index 00000000..e800023c --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferOrderItemSkipTest.php @@ -0,0 +1,55 @@ + 0, + 'price' => 199, + ], 0); + + expect($shouldSkip)->toBeTrue(); +}); + +it('skips order items with zero price for e-conomic export', function (): void { + $shouldSkip = economic_transfer_executor::shouldSkipOrderItemForInvoice([ + 'quantity' => 2, + 'price' => 0, + ], 2); + + expect($shouldSkip)->toBeTrue(); +}); + +it('keeps positive quantity and price items billable for e-conomic export', function (): void { + $shouldSkip = economic_transfer_executor::shouldSkipOrderItemForInvoice([ + 'quantity' => 2, + 'price' => 149, + ], 2); + + expect($shouldSkip)->toBeFalse(); +}); + +it('skips malformed order-item payloads for e-conomic export safety', function (): void { + expect(economic_transfer_executor::shouldSkipOrderItemForInvoice(null, 1))->toBeTrue(); + expect(economic_transfer_executor::shouldSkipOrderItemForInvoice('invalid', 1))->toBeTrue(); + expect(economic_transfer_executor::shouldSkipOrderItemForInvoice([ + 'quantity' => 1, + ], 1))->toBeTrue(); + expect(economic_transfer_executor::shouldSkipOrderItemForInvoice([ + 'quantity' => 1, + 'price' => 'abc', + ], 1))->toBeTrue(); +}); + +it('wires zero-cost and zero-quantity skip guard into transfer line builder', function (): void { + $content = file_get_contents(app_path('classes/economic_transfer_executor.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('if (self::shouldSkipOrderItemForInvoice($order_item, $quantity))'); + expect($content)->toContain('$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);'); + expect($content)->toContain("throw new Exception('An invoice has already been created, invoice ID: ' . \$invoice_id);"); + expect($content)->toContain("throw new Exception('No billable order items found');"); + expect($content)->toContain("throw new Exception('Order item is missing economic product id');"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php new file mode 100644 index 00000000..733752a0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php @@ -0,0 +1,126 @@ +not->toBeFalse(); + + $guard_count = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content); + $queue_init_count = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content); + expect($guard_count)->toBe(4); + expect($queue_init_count)->toBe(6); + + $queue_endpoint_patterns = [ + "/\\\$this->get\\('\\/economic\\/invoice\\/draft\\/export\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/economic\\/invoice\\/draft\\/export\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->get\\('\\/economic\\/invoice\\/export\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/economic\\/invoice\\/export\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + ]; + + foreach ($queue_endpoint_patterns as $pattern) { + expect(preg_match($pattern, (string)$content))->toBe(1); + } +}); + +it('supports synchronous fallback branches before queue enqueue on economic invoice export routes', function (): void { + $content = file_get_contents(app_path('routes/economicInvoiceRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($content)->toContain("'mode' => 'synchronous_fallback'"); + expect($content)->toContain('ECONOMIC_INVOICE_DRAFT_EXPORT_FALLBACK'); + expect($content)->toContain('ECONOMIC_INVOICE_EXPORT_FALLBACK'); + + $draft_start = strpos((string)$content, "\$this->post('/economic/invoice/draft/export'"); + $draft_end = strpos((string)$content, "\$this->delete('/economic/invoice/draft/delete'"); + expect($draft_start)->not->toBeFalse(); + expect($draft_end)->not->toBeFalse(); + expect($draft_end)->toBeGreaterThan($draft_start); + $draft_block = substr((string)$content, (int)$draft_start, (int)$draft_end - (int)$draft_start); + expect($draft_block)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($draft_block)->toContain('exportOrderDraftSynchronously'); + expect($draft_block)->toContain("'mode' => 'synchronous_fallback'"); + expect($draft_block)->toContain('new economic_transfer_queue();'); + + $invoice_start = strpos((string)$content, "\$this->post('/economic/invoice/export'"); + $invoice_end = strpos((string)$content, "\$this->get('/economic/invoice/draft/export/status'"); + expect($invoice_start)->not->toBeFalse(); + expect($invoice_end)->not->toBeFalse(); + expect($invoice_end)->toBeGreaterThan($invoice_start); + $invoice_block = substr((string)$content, (int)$invoice_start, (int)$invoice_end - (int)$invoice_start); + expect($invoice_block)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($invoice_block)->toContain('exportOrderInvoiceSynchronously'); + expect($invoice_block)->toContain("'mode' => 'synchronous_fallback'"); + expect($invoice_block)->toContain('new economic_transfer_queue();'); +}); + +it('keeps queue status endpoints guarded while collected invoice export routes support synchronous fallback', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + + $guard_count = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content); + $queue_init_count = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content); + expect($guard_count)->toBe(7); + expect($queue_init_count)->toBe(9); + + $queue_endpoint_patterns = [ + "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/monitor'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/dismiss'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/dismiss-terminal'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/run'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + ]; + + foreach ($queue_endpoint_patterns as $pattern) { + expect(preg_match($pattern, (string)$content))->toBe(1); + } + + $collected_start = strpos((string)$content, "\$this->post('/collected-invoices/economic'"); + $collected_end = strpos((string)$content, "\$this->get('/collected-invoices/economic/queue'"); + expect($collected_start)->not->toBeFalse(); + expect($collected_end)->not->toBeFalse(); + expect($collected_end)->toBeGreaterThan($collected_start); + $collected_block = substr((string)$content, (int)$collected_start, (int)$collected_end - (int)$collected_start); + expect($collected_block)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($collected_block)->toContain('exportCollectedInvoiceSynchronously'); + expect($collected_block)->toContain("'mode' => 'synchronous_fallback'"); + expect($collected_block)->toContain('new economic_transfer_queue();'); + + $stripe_start = strpos((string)$content, "\$this->post('/collected-invoices/stripe/book'"); + $stripe_end = strpos((string)$content, "\$this->post('/collected-invoices/vehicle-subscriptions'"); + expect($stripe_start)->not->toBeFalse(); + expect($stripe_end)->not->toBeFalse(); + expect($stripe_end)->toBeGreaterThan($stripe_start); + $stripe_block = substr((string)$content, (int)$stripe_start, (int)$stripe_end - (int)$stripe_start); + expect($stripe_block)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($stripe_block)->toContain('exportCollectedInvoiceSynchronously'); + expect($stripe_block)->toContain("'mode' => 'synchronous_fallback'"); + expect($stripe_block)->toContain('new economic_transfer_queue();'); + + expect($content)->toContain('private function exportCollectedInvoiceSynchronously'); + expect($content)->toContain('ADD_COLLECTED_INVOICE_ECONOMIC_FALLBACK'); + expect($content)->toContain('ADD_COLLECTED_INVOICE_STRIPE_FALLBACK'); +}); + +it('uses a consistent unavailable-service contract for queue status lifecycle endpoints', function (): void { + $invoice_route_content = file_get_contents(app_path('routes/economicInvoiceRoute.php')); + $order_invoices_route_content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($invoice_route_content)->not->toBeFalse(); + expect($order_invoices_route_content)->not->toBeFalse(); + + expect($invoice_route_content)->toContain('private function isEconomicTransferQueueAvailable(): bool'); + expect($invoice_route_content)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')"); + expect($invoice_route_content)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')"); + expect($invoice_route_content)->toContain('class_exists(economic_transfer_queue::class)'); + expect($invoice_route_content)->toContain("Economic transfer queue is unavailable in this deployment', 503"); + + expect($order_invoices_route_content)->toContain('private function isEconomicTransferQueueAvailable(): bool'); + expect($order_invoices_route_content)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')"); + expect($order_invoices_route_content)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')"); + expect($order_invoices_route_content)->toContain('class_exists(economic_transfer_queue::class)'); + expect($order_invoices_route_content)->toContain("Economic transfer queue is unavailable in this deployment', 503"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronEntrypointWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronEntrypointWiringTest.php new file mode 100644 index 00000000..9a104c87 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronEntrypointWiringTest.php @@ -0,0 +1,11 @@ +not->toBeFalse(); + expect($content)->toContain("require_once __DIR__ . '/vendor/autoload.php';"); + expect($content)->toContain("require_once __DIR__ . '/config.php';"); + expect($content)->toContain("require_once __DIR__ . '/cron/Cron.php';"); + expect($content)->toContain("Cron scheduler failed:"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronIntegrationTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronIntegrationTest.php new file mode 100644 index 00000000..efeb3caa --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronIntegrationTest.php @@ -0,0 +1,26 @@ +not->toBeFalse(); + expect($content)->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';"); + expect($content)->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';"); + expect($content)->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue.php';"); + expect($content)->toContain("'EconomicTransferQueueCron'"); + expect($content)->toContain("'function' => 'EconomicTransferQueueCron'"); + expect($content)->toContain('function EconomicTransferQueueCron(): void'); + expect($content)->toContain('new economic_transfer_queue()'); + expect($content)->toContain('processPending(10)'); +}); + +it('wires queue worker class loading for CLI queue action', function (): void { + $content = file_get_contents(app_path('cli.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("require_once __DIR__ . '/classes/economic_transfer_executor.php';"); + expect($content)->toContain("require_once __DIR__ . '/classes/economic_transfer_queue_schema_bootstrap.php';"); + expect($content)->toContain("require_once __DIR__ . '/classes/economic_transfer_queue.php';"); + expect($content)->toContain("case 'economic-transfer-queue':"); + expect($content)->toContain('new \\classes\\economic_transfer_queue()'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php new file mode 100644 index 00000000..f94bb759 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php @@ -0,0 +1,35 @@ +not->toBeFalse(); + expect($content)->toContain('private const STALE_PROCESSING_LOCK_SECONDS = 900'); + expect($content)->toContain('$this->validateTransferType($transfer_type)'); + expect($content)->toContain('$this->normalizePayloadForTransferType($transfer_type, $payload, $created_by)'); + expect($content)->toContain('$this->findActiveJobByTarget($transfer_type, $payload)'); + expect($content)->toContain('$max_attempts = max(1, min(10, $max_attempts));'); + expect($content)->toContain('private function normalizePayloadForTransferType(string $transfer_type, array $payload, int $created_by): array'); + expect($content)->toContain('private function normalizeBooleanPayloadValue(mixed $value, string $field_name, int $created_by): bool'); + expect($content)->toContain('private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array'); + expect($content)->toContain('ECONOMIC_TRANSFER_JOB_DEDUPED'); + expect($content)->toContain('ECONOMIC_TRANSFER_JOB_VALIDATION_REJECTED'); + expect($content)->toContain('Queue job reached max retry attempts'); + expect($content)->toContain('AND attempts < max_attempts'); + expect($content)->toContain('public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array'); + expect($content)->toContain('public function countJobs(array $statuses = [], ?string $transfer_type = null): int'); + expect($content)->toContain('public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array'); + expect($content)->toContain('public function dismissTerminalJobForUser(int $job_id, int $user_id): array'); + expect($content)->toContain('public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int'); + expect($content)->toContain('economic_transfer_queue_job_dismissals'); + expect($content)->toContain("Only completed or failed queue jobs can be dismissed"); + expect($content)->toContain('$this->clearDismissalsForJob($job_id);'); + expect($content)->toContain('private function clearDismissalsForJob(int $job_id): void'); + expect($content)->toContain('public function processPendingByTransferType(string $transfer_type, int $limit = 10): array'); + expect($content)->toContain('private function processPendingInternal(int $limit = 5, ?string $transfer_type = null): array'); + expect($content)->toContain('private function claimNextJob(?string $transfer_type = null): ?array'); + expect($content)->toContain("transfer_type = '"); + expect($content)->toContain('private function sanitizeStatuses(array $statuses): array'); + expect($content)->toContain('private function buildListJobsWhereClause(array $statuses = [], ?string $transfer_type = null): string'); + expect($content)->toContain('private function releaseStaleProcessingLocks(): void'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php new file mode 100644 index 00000000..8e68b0dc --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php @@ -0,0 +1,159 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +function economic_transfer_queue_openapi_block(string $content, string $start, string $end): string +{ + $start_pos = strpos($content, $start); + $end_pos = strpos($content, $end); + expect($start_pos)->not->toBeFalse(); + expect($end_pos)->not->toBeFalse(); + expect($end_pos)->toBeGreaterThan($start_pos); + + return substr($content, (int)$start_pos, (int)$end_pos - (int)$start_pos); +} + +it('documents economic transfer queue paths in openapi', function (): void { + $content = economic_transfer_queue_openapi_content_or_skip(); + + expect($content)->toContain('/collected-invoices/economic:'); + expect($content)->toContain('/collected-invoices/stripe/book:'); + expect($content)->toContain('/collected-invoices/economic/queue:'); + expect($content)->toContain('/collected-invoices/economic/queue/monitor:'); + expect($content)->toContain('/collected-invoices/economic/queue/status:'); + expect($content)->toContain('/collected-invoices/economic/queue/retry:'); + expect($content)->toContain('/collected-invoices/economic/queue/dismiss:'); + expect($content)->toContain('/collected-invoices/economic/queue/dismiss-terminal:'); + expect($content)->toContain('/collected-invoices/economic/queue/run:'); + expect($content)->toContain('/economic/invoice/draft/export:'); + expect($content)->toContain('/economic/invoice/draft/export/status:'); + expect($content)->toContain('/economic/invoice/draft/export/retry:'); + expect($content)->toContain('/economic/invoice/export:'); + expect($content)->toContain('/economic/invoice/export/status:'); + expect($content)->toContain('/economic/invoice/export/retry:'); +}); + +it('documents economic transfer queue schemas in openapi', function (): void { + $content = economic_transfer_queue_openapi_content_or_skip(); + + expect($content)->toContain('EconomicTransferQueueStatus:'); + expect($content)->toContain('EconomicTransferQueueJob:'); + expect($content)->toContain('EconomicTransferQueueEnqueueResponse:'); + expect($content)->toContain('EconomicTransferSynchronousFallbackResponse:'); + expect($content)->toContain('EconomicTransferQueueStatusResponse:'); + expect($content)->toContain('EconomicTransferQueueRetryResponse:'); + expect($content)->toContain('EconomicTransferQueueRunResponse:'); + expect($content)->toContain('EconomicTransferQueueListResponse:'); + expect($content)->toContain('EconomicTransferQueueMonitorResponse:'); + expect($content)->toContain('EconomicTransferQueueDismissResponse:'); + expect($content)->toContain('EconomicTransferQueueDismissTerminalResponse:'); +}); + +it('documents 200 fallback plus 202 queue contracts for export endpoints and keeps 503 on queue lifecycle endpoints', function (): void { + $content = economic_transfer_queue_openapi_content_or_skip(); + + expect($content)->toContain('ServiceUnavailable:'); + expect($content)->toContain("#/components/schemas/EconomicTransferSynchronousFallbackResponse"); + + $collected_export = economic_transfer_queue_openapi_block( + $content, + '/collected-invoices/economic:', + '/collected-invoices/stripe/book:' + ); + $stripe_export = economic_transfer_queue_openapi_block( + $content, + '/collected-invoices/stripe/book:', + '/collected-invoices/economic/queue:' + ); + $draft_export = economic_transfer_queue_openapi_block( + $content, + '/economic/invoice/draft/export:', + '/economic/invoice/draft/export/status:' + ); + $invoice_export = economic_transfer_queue_openapi_block( + $content, + '/economic/invoice/export:', + '/economic/invoice/export/status:' + ); + + foreach ([$collected_export, $stripe_export, $draft_export, $invoice_export] as $block) { + expect($block)->toContain("'200':"); + expect($block)->toContain("'202':"); + expect($block)->not->toContain("'503': { \$ref: '#/components/responses/ServiceUnavailable' }"); + } + + $queue_blocks = [ + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue:', '/collected-invoices/economic/queue/monitor:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/monitor:', '/collected-invoices/economic/queue/status:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/status:', '/collected-invoices/economic/queue/retry:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/retry:', '/collected-invoices/economic/queue/dismiss:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/dismiss:', '/collected-invoices/economic/queue/dismiss-terminal:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/dismiss-terminal:', '/collected-invoices/economic/queue/run:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/run:', '/collected-invoices/economic/compare:'), + economic_transfer_queue_openapi_block($content, '/economic/invoice/draft/export/status:', '/economic/invoice/draft/export/retry:'), + economic_transfer_queue_openapi_block($content, '/economic/invoice/export/status:', '/economic/invoice/export/retry:'), + ]; + + foreach ($queue_blocks as $block) { + expect($block)->toContain("'503': { \$ref: '#/components/responses/ServiceUnavailable' }"); + } +}); + +it('documents additive collected-invoice queue pagination metadata and retry conflict semantics', function (): void { + $content = economic_transfer_queue_openapi_content_or_skip(); + + $queue_list_block = economic_transfer_queue_openapi_block( + $content, + '/collected-invoices/economic/queue:', + '/collected-invoices/economic/queue/monitor:' + ); + expect($queue_list_block)->toContain('style: form'); + expect($queue_list_block)->toContain('explode: false'); + expect($queue_list_block)->toContain("type: array"); + expect($queue_list_block)->toContain("\$ref: '#/components/schemas/EconomicTransferQueueStatus'"); + + $retry_block = economic_transfer_queue_openapi_block( + $content, + '/collected-invoices/economic/queue/retry:', + '/collected-invoices/economic/queue/run:' + ); + expect($retry_block)->toContain("'409': { \$ref: '#/components/responses/Conflict' }"); + + $list_schema_block = economic_transfer_queue_openapi_block( + $content, + 'EconomicTransferQueueListResponse:', + 'CollectedInvoiceEconomicCompareResponse:' + ); + expect($list_schema_block)->toContain('total:'); + expect($list_schema_block)->toContain('limit:'); + expect($list_schema_block)->toContain('offset:'); + expect($list_schema_block)->toContain('has_more:'); + expect($list_schema_block)->toContain('- total'); + expect($list_schema_block)->toContain('- limit'); + expect($list_schema_block)->toContain('- offset'); + expect($list_schema_block)->toContain('- has_more'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueuePayloadValidationTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueuePayloadValidationTest.php new file mode 100644 index 00000000..3388d964 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueuePayloadValidationTest.php @@ -0,0 +1,82 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs($queue, $args); +} + +function economic_transfer_queue_new_without_constructor(): economic_transfer_queue +{ + $reflection = new ReflectionClass(economic_transfer_queue::class); + /** @var economic_transfer_queue $instance */ + $instance = $reflection->newInstanceWithoutConstructor(); + return $instance; +} + +it('normalizes order payloads to strict positive integer ids', function (): void { + $queue = economic_transfer_queue_new_without_constructor(); + + $payload = economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [ + economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT, + [ + 'order_id' => '42', + 'requested_by' => '-7', + ], + 123, + ]); + + expect($payload['order_id'])->toBe(42); + expect($payload['requested_by'])->toBe(0); +}); + +it('normalizes collected-invoice payload booleans to strict bool values', function (): void { + $queue = economic_transfer_queue_new_without_constructor(); + + $truePayload = economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [ + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => '77', + 'send_as_is' => 'true', + ], + 456, + ]); + expect($truePayload['collected_invoice_id'])->toBe(77); + expect($truePayload['send_as_is'])->toBeTrue(); + + $falsePayload = economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [ + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => 78, + 'send_as_is' => '0', + ], + 456, + ]); + expect($falsePayload['collected_invoice_id'])->toBe(78); + expect($falsePayload['send_as_is'])->toBeFalse(); +}); + +it('rejects invalid transfer payloads before enqueue write attempts', function (): void { + $queue = economic_transfer_queue_new_without_constructor(); + + expect(fn () => economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [ + economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT, + [], + 1, + ]))->toThrow(Exception::class, 'order_id is required and must be a positive number'); + + expect(fn () => economic_transfer_queue_invoke_private($queue, 'normalizePayloadForTransferType', [ + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => 12, + 'send_as_is' => 'maybe', + ], + 1, + ]))->toThrow(Exception::class, 'send_as_is must be a boolean'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueRouteRegistrationTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueRouteRegistrationTest.php new file mode 100644 index 00000000..eeb6332d --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueRouteRegistrationTest.php @@ -0,0 +1,60 @@ +not->toBeFalse(); + expect($content)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void'); + expect($content)->toContain('private function isEconomicTransferQueueAvailable(): bool'); + expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')"); + expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')"); + expect($content)->toContain("if (!\$this->isEconomicTransferQueueAvailable()) {"); + expect($content)->toContain('$this->ensureEconomicTransferQueueIsAvailable();'); + expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';"); + expect($content)->toContain('/economic/invoice/draft/export'); + expect($content)->toContain('/economic/invoice/export'); + expect($content)->toContain('/economic/invoice/draft/export/status'); + expect($content)->toContain('/economic/invoice/draft/export/retry'); + expect($content)->toContain('/economic/invoice/export/status'); + expect($content)->toContain('/economic/invoice/export/retry'); + expect($content)->toContain('economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT'); + expect($content)->toContain('economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT'); + expect($content)->toContain("'job_id' => \$job_id"); + expect($content)->toContain("missing queue job id in enqueue response"); + expect($content)->toContain("'mode' => 'synchronous_fallback'"); + expect($content)->toContain('ECONOMIC_INVOICE_DRAFT_EXPORT_FALLBACK'); + expect($content)->toContain('ECONOMIC_INVOICE_EXPORT_FALLBACK'); + expect($content)->toContain('private function exportOrderDraftSynchronously'); + expect($content)->toContain('private function exportOrderInvoiceSynchronously'); +}); + +it('registers collected-invoice queue endpoints in orderInvoicesRoute', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void'); + expect($content)->toContain('private function isEconomicTransferQueueAvailable(): bool'); + expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')"); + expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')"); + expect($content)->toContain('$this->ensureEconomicTransferQueueIsAvailable();'); + expect($content)->toContain('private function exportCollectedInvoiceSynchronously'); + expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';"); + expect($content)->toContain('/collected-invoices/economic'); + expect($content)->toContain('/collected-invoices/economic/queue'); + expect($content)->toContain('/collected-invoices/economic/queue/status'); + expect($content)->toContain('/collected-invoices/economic/queue/retry'); + expect($content)->toContain('/collected-invoices/economic/queue/run'); + expect($content)->toContain('/collected-invoices/stripe/book'); + expect($content)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT'); + expect($content)->toContain('processPendingByTransferType('); + expect($content)->toContain("'job_id' => \$job_id"); + expect($content)->toContain("missing queue job id in enqueue response"); + expect($content)->toContain("requirePermission('add_collected_invoice_economic')"); + expect($content)->toContain("requirePermission('add_collected_invoice_stripe')"); + expect($content)->toContain('send_as_is must be a boolean'); + expect($content)->toContain("in_array(\$normalized_send_as_is, ['true', '1'], true)"); + expect($content)->toContain("'mode' => 'synchronous_fallback'"); + expect($content)->toContain('ADD_COLLECTED_INVOICE_ECONOMIC_FALLBACK'); + expect($content)->toContain('ADD_COLLECTED_INVOICE_STRIPE_FALLBACK'); + expect($content)->toContain("'message' => 'Stripe collected invoice export queued'"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php new file mode 100644 index 00000000..b2bb7d0f --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php @@ -0,0 +1,32 @@ +not->toBeFalse(); + expect($content)->toContain('CREATE TABLE IF NOT EXISTS economic_transfer_queue_jobs'); + expect($content)->toContain("status VARCHAR(32) NOT NULL DEFAULT 'QUEUED'"); + expect($content)->toContain('progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0'); + expect($content)->toContain('error_message TEXT NULL'); + expect($content)->toContain('payload_json JSON NOT NULL'); + expect($content)->toContain('result_json JSON NULL'); + expect($content)->toContain('CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals'); + expect($content)->toContain('queue_job_id BIGINT UNSIGNED NOT NULL'); + expect($content)->toContain('user_id INT NOT NULL'); + expect($content)->toContain('dismissed_status VARCHAR(32) NOT NULL'); + expect($content)->toContain('PRIMARY KEY (queue_job_id, user_id)'); +}); + +it('provides queue processor class constants and processing entrypoint', function (): void { + $content = file_get_contents(app_path('classes/economic_transfer_queue.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('public const STATUS_QUEUED'); + expect($content)->toContain('public const STATUS_PROCESSING'); + expect($content)->toContain('public const STATUS_COMPLETED'); + expect($content)->toContain('public const STATUS_FAILED'); + expect($content)->toContain('public const TYPE_ORDER_DRAFT_EXPORT'); + expect($content)->toContain('public const TYPE_ORDER_INVOICE_EXPORT'); + expect($content)->toContain('public const TYPE_COLLECTED_INVOICE_EXPORT'); + expect($content)->toContain('public function processPending(int $limit = 5): array'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueWorkerTickRouteTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueWorkerTickRouteTest.php new file mode 100644 index 00000000..303819cc --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueWorkerTickRouteTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + expect($content)->toContain("\$this->get('/collected-invoices/economic/queue'"); + expect($content)->toContain("\$this->get('/collected-invoices/economic/queue/status'"); + expect($content)->toContain("\$this->post('/collected-invoices/economic/queue/run'"); + expect($content)->toContain('$queue->processPendingByTransferType('); + expect($content)->not->toContain("\$this->processEconomicTransferQueueTick(5);"); + expect($content)->not->toContain("\$this->processEconomicTransferQueueTick(1);"); +}); + +it('keeps order transfer queue status routes read-only', function (): void { + $content = file_get_contents(app_path('routes/economicInvoiceRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("\$this->get('/economic/invoice/draft/export/status'"); + expect($content)->toContain("\$this->get('/economic/invoice/export/status'"); + expect($content)->not->toContain("\$this->processEconomicTransferQueueTick(1);"); + expect($content)->not->toContain('private function processEconomicTransferQueueTick(int $limit = 1): void'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php new file mode 100644 index 00000000..b0abbaac --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php @@ -0,0 +1,464 @@ +fixedVersion; + } + + public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array + { + return $this->subscriptionVersions; + } + + public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array + { + return null; + } + + public function runBestEffortBackfill(): array + { + return [ + 'fixed_pricing' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'vehicle_subscriptions' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'discount_overrides' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'inferred' => ['fixed_pricing' => 0, 'vehicle_subscriptions' => 0], + 'warnings' => [], + ]; + } + } +} + +if (!class_exists('TestableEconomicV2BookedDepartment75DistributionService')) { + class TestableEconomicV2BookedDepartment75DistributionService extends economic_v2_distribution_service + { + public array $stubOrders = []; + public array $stubOrderItems = []; + public array $stubVersionRows = []; + public array $subscriptionPrices = []; + public array $stubBookedInvoices = []; + public array $stubBookedInvoiceLines = []; + public int $fallbackDepartmentId = 8; + public array $customerDefaultDepartments = []; + public ?array $includedCustomers = null; + + public function __construct(?economic_v2_versioning_service $versioning = null) + { + parent::__construct($versioning); + } + + protected function fetchOrdersInRange(string $from_ts, string $to_ts): array + { + return $this->stubOrders; + } + + protected function fetchOrderItemsByOrderIds(array $order_ids): array + { + return $this->stubOrderItems; + } + + protected function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array + { + return $this->stubVersionRows; + } + + protected function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float + { + $total = 0.0; + foreach ($order_items as $item) { + $total += (float)($item['price'] ?? 0.0) * (float)($item['quantity'] ?? 0.0); + } + + return $total; + } + + protected function getSubscriptionMonthlyPrice(int $vehicle_type): float + { + return (float)($this->subscriptionPrices[$vehicle_type] ?? 0.0); + } + + protected function fetchBookedInvoicesInDateRange(string $date_from, string $date_to, array &$warnings): array + { + return $this->stubBookedInvoices; + } + + protected function fetchBookedInvoiceLines(array $invoice_ids, array &$warnings): array + { + return $this->stubBookedInvoiceLines; + } + + protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array + { + return [ + 'id' => $customer_number, + 'customer_number' => $customer_number, + 'customer_name' => 'Customer ' . $customer_number, + 'transactions' => array_values($transaction_map), + 'requires_action' => false, + 'meta' => [], + ]; + } + + protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null, ?bool $included = null): array + { + return [ + 'id' => $order_id, + 'date' => $created_at, + 'amount' => round((float)($amount ?? 0.0), 5), + 'booked' => true, + 'department_id' => $department_id, + 'excluded' => !($included ?? $this->isDepartmentEligible($department_id)), + ]; + } + + protected function isDepartmentEligible(int $department_id): bool + { + return $department_id > 0 && $department_id !== 10; + } + + protected function shouldIncludeCustomerNumber(int $customer_number): bool + { + if ($this->includedCustomers === null) { + return true; + } + + return in_array($customer_number, $this->includedCustomers, true); + } + + protected function getCustomerDefaultDepartmentId(int $customer_number): ?int + { + return $this->customerDefaultDepartments[$customer_number] ?? null; + } + + protected function getFallbackDistributionDepartmentId(): int + { + return $this->fallbackDepartmentId; + } + + protected function parseDepartmentMap(array $department_map): array + { + $parsed = []; + foreach ($department_map as $department_id => $amount) { + $parsed['Department ' . $department_id] = round((float)$amount, 5); + } + + return $parsed; + } + + protected function versionTableHasRows(string $table): bool + { + return true; + } + } +} + +it('redistributes booked department 75 net amounts using fixed pricing and wash subscription weights', function (): void { + $versioning = new FakeEconomicV2BookedDepartment75VersioningService(); + $versioning->fixedVersion = [ + 'id' => 91, + 'price' => 500.0, + 'description' => 'Fixed pricing agreement', + 'source' => 'test.fixed', + 'confidence' => 1.0, + 'inferred' => false, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]; + $versioning->subscriptionVersions = [[ + 'id' => 42, + 'reg' => 'AB12345', + 'vehicle_type' => 77, + 'source' => 'test.subscription', + 'confidence' => 1.0, + 'inferred' => false, + ]]; + + $service = new TestableEconomicV2BookedDepartment75DistributionService($versioning); + $service->subscriptionPrices = [ + 77 => 120.0, + ]; + $service->stubOrders = [ + [ + 'id' => 101, + 'customer_id' => 12345, + 'department_id' => 1, + 'created_at' => '2026-01-10 10:00:00', + 'reg_1' => '', + 'reference' => 'Internal fixed pricing basis', + ], + [ + 'id' => 102, + 'customer_id' => 12345, + 'department_id' => 2, + 'created_at' => '2026-01-11 10:00:00', + 'reg_1' => 'AB12345', + 'reference' => '', + ], + ]; + $service->stubOrderItems = [ + 101 => [[ + 'product_id' => 61, + 'price' => 400.0, + 'quantity' => 1, + 'reference' => '', + ]], + 102 => [[ + 'product_id' => 77, + 'price' => 60.0, + 'quantity' => 2, + 'reference' => 'AB12345', + ]], + ]; + $service->stubBookedInvoices = [[ + 'bookedInvoiceNumber' => 7001, + 'date' => '2026-01-31', + 'customer' => [ + 'customerNumber' => 12345, + ], + ]]; + $service->stubBookedInvoiceLines = [ + 7001 => [ + ['lineNumber' => 1, 'description' => '[ 01/01/2026 00:00 Auto #999 ]'], + ['lineNumber' => 2, 'description' => 'Reference:'], + ['lineNumber' => 3, 'description' => '# Fast pris aftale'], + [ + 'lineNumber' => 4, + 'description' => 'Fixed price', + 'quantity' => 1, + 'unitNetPrice' => 200, + 'totalNetAmount' => 200, + 'product' => ['productNumber' => 61], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 50, 'department' => ['departmentNumber' => 75]], + ['percentage' => 50, 'department' => ['departmentNumber' => 1]], + ], + ], + ], + [ + 'lineNumber' => 5, + 'description' => 'Rabat', + 'quantity' => 1, + 'unitNetPrice' => -40, + 'totalNetAmount' => -40, + 'product' => ['productNumber' => 'TotDiscount'], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 50, 'department' => ['departmentNumber' => 75]], + ['percentage' => 50, 'department' => ['departmentNumber' => 1]], + ], + ], + ], + ['lineNumber' => 6, 'description' => '[ 01/01/2026 00:00 Auto #1000 ]'], + ['lineNumber' => 7, 'description' => 'Reference:'], + ['lineNumber' => 8, 'description' => '# Vaskeabonnementer'], + [ + 'lineNumber' => 9, + 'description' => 'Subscription', + 'quantity' => 1, + 'unitNetPrice' => 120, + 'totalNetAmount' => 120, + 'product' => ['productNumber' => 77], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 100, 'department' => ['departmentNumber' => 75]], + ], + ], + ], + ], + ]; + + $result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31'); + + expect($result['warnings'])->toBe([]); + expect($result['customers'])->toHaveCount(1); + + $meta = $result['customers'][0]['meta']['booked_department_75']; + expect($meta['booked_net_amount'])->toBe(200.0); + expect($meta['distributed_net_amount'])->toBe(200.0); + expect($meta['undistributed_net_amount'])->toBe(0.0); + expect($meta['department_distribution']['1'])->toBe(61.53846); + expect($meta['department_distribution']['2'])->toBe(138.46154); + + $groups = []; + foreach ($meta['booked_groups'] as $group) { + $groups[$group['source_category']] = $group; + } + + expect($groups['fixed_pricing']['booked_net_amount'])->toBe(80.0); + expect($groups['fixed_pricing']['department_distribution']['1'])->toBe(61.53846); + expect($groups['fixed_pricing']['department_distribution']['2'])->toBe(18.46154); + expect($groups['fixed_pricing']['undistributed_net_amount'])->toBe(0.0); + expect($groups['wash_subscriptions']['booked_net_amount'])->toBe(120.0); + expect($groups['wash_subscriptions']['department_distribution']['2'])->toBe(120.0); + expect($groups['wash_subscriptions']['undistributed_net_amount'])->toBe(0.0); + + expect($result['collective_results']['booked_net_amount'])->toBe(200.0); + expect($result['collective_results']['distributed_net_amount'])->toBe(200.0); + expect($result['collective_results']['undistributed_net_amount'])->toBe(0.0); + expect($result['collective_results']['department_distribution']['1'])->toBe(61.53846); + expect($result['collective_results']['department_distribution']['2'])->toBe(138.46154); +}); + +it('assigns classified booked department 75 amounts to fallback when no monthly basis exists', function (): void { + $service = new TestableEconomicV2BookedDepartment75DistributionService(new FakeEconomicV2BookedDepartment75VersioningService()); + $service->stubBookedInvoices = [[ + 'bookedInvoiceNumber' => 7002, + 'date' => '2026-01-31', + 'customer' => [ + 'customerNumber' => 67890, + ], + ]]; + $service->stubBookedInvoiceLines = [ + 7002 => [ + ['lineNumber' => 1, 'description' => '[ 01/01/2026 00:00 Auto #1001 ]'], + ['lineNumber' => 2, 'description' => 'Reference:'], + ['lineNumber' => 3, 'description' => '# Fast pris aftale'], + [ + 'lineNumber' => 4, + 'description' => 'Fixed price', + 'quantity' => 1, + 'unitNetPrice' => 60, + 'totalNetAmount' => 60, + 'product' => ['productNumber' => 61], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 100, 'department' => ['departmentNumber' => 75]], + ], + ], + ], + ], + ]; + + $result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + $meta = $result['customers'][0]['meta']['booked_department_75']; + expect($meta['booked_groups'][0]['source_category'])->toBe('fixed_pricing'); + expect($meta['distributed_net_amount'])->toBe(60.0); + expect($meta['undistributed_net_amount'])->toBe(0.0); + expect($meta['department_distribution']['8'])->toBe(60.0); + expect($meta['booked_groups'][0]['department_distribution']['8'])->toBe(60.0); + expect($meta['booked_groups'][0]['undistributed_net_amount'])->toBe(0.0); + expect($result['collective_results']['distributed_net_amount'])->toBe(60.0); + expect($result['collective_results']['undistributed_net_amount'])->toBe(0.0); + expect($result['collective_results']['department_distribution']['8'])->toBe(60.0); + expect(implode("\n", $result['warnings']))->toContain('has no redistribution basis'); + expect(implode("\n", $result['warnings']))->toContain('assigned to fallback department 8'); +}); + +it('keeps unclassified booked department 75 lines undistributed with warnings', function (): void { + $service = new TestableEconomicV2BookedDepartment75DistributionService(new FakeEconomicV2BookedDepartment75VersioningService()); + $service->stubBookedInvoices = [[ + 'bookedInvoiceNumber' => 7003, + 'date' => '2026-01-31', + 'customer' => [ + 'customerNumber' => 77777, + ], + ]]; + $service->stubBookedInvoiceLines = [ + 7003 => [ + ['lineNumber' => 1, 'description' => '[ 01/01/2026 00:00 Auto #1002 ]'], + [ + 'lineNumber' => 2, + 'description' => 'Unknown booked line', + 'quantity' => 1, + 'unitNetPrice' => 25, + 'totalNetAmount' => 25, + 'product' => ['productNumber' => 99], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 100, 'department' => ['departmentNumber' => 75]], + ], + ], + ], + ], + ]; + + $result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['meta']['booked_department_75']['booked_groups'][0]['source_category'])->toBe('unclassified'); + expect($result['customers'][0]['meta']['booked_department_75']['distributed_net_amount'])->toBe(0.0); + expect($result['customers'][0]['meta']['booked_department_75']['undistributed_net_amount'])->toBe(25.0); + expect($result['collective_results']['undistributed_net_amount'])->toBe(25.0); + $warning_text = implode("\n", $result['warnings']); + expect($warning_text)->toContain('Unable to classify booked department 75 line'); + expect($warning_text)->toContain('could not be classified and remains undistributed'); +}); + +it('supports bulk booked invoice line payloads with top-level department numbers', function (): void { + $versioning = new FakeEconomicV2BookedDepartment75VersioningService(); + $versioning->fixedVersion = [ + 'id' => 92, + 'price' => 100.0, + 'description' => 'Fixed pricing agreement', + 'source' => 'test.fixed.bulk', + 'confidence' => 1.0, + 'inferred' => false, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]; + + $service = new TestableEconomicV2BookedDepartment75DistributionService($versioning); + $service->stubOrders = [[ + 'id' => 201, + 'customer_id' => 54321, + 'department_id' => 3, + 'created_at' => '2026-01-10 09:00:00', + 'reg_1' => '', + 'reference' => 'Internal fixed pricing basis', + ]]; + $service->stubOrderItems = [ + 201 => [[ + 'product_id' => 41, + 'price' => 100.0, + 'quantity' => 1, + 'reference' => '', + ]], + ]; + $service->stubBookedInvoices = [[ + 'bookedInvoiceNumber' => 7004, + 'date' => '2026-01-31', + 'customer' => [ + 'customerNumber' => 54321, + ], + ]]; + $service->stubBookedInvoiceLines = [ + 7004 => [ + ['number' => 1, 'description' => '[ 01/01/2026 00:00 Auto #1002 ]'], + ['number' => 2, 'description' => '# Fast pris aftale'], + [ + 'number' => 3, + 'description' => 'Fixed price', + 'productNumber' => '41', + 'quantity' => 1, + 'unitNetPrice' => 100, + 'totalNetAmount' => 100, + 'departmentNumber' => 75, + ], + ], + ]; + + $result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31'); + + expect($result['warnings'])->toBe([]); + expect($result['collective_results']['booked_net_amount'])->toBe(100.0); + expect($result['collective_results']['distributed_net_amount'])->toBe(100.0); + expect($result['collective_results']['department_distribution']['3'])->toBe(100.0); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2CliBackfillCommandTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2CliBackfillCommandTest.php new file mode 100644 index 00000000..a1031acf --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2CliBackfillCommandTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($content)->toContain("case 'economic-v2-backfill':"); + expect($content)->toContain("require_once 'cron/BackfillEconomicV2History.php';"); +}); + +it('provides a backfill cron script entrypoint', function (): void { + $script = app_path('cron/BackfillEconomicV2History.php'); + expect(is_file($script))->toBeTrue(); + + $content = file_get_contents($script); + expect($content)->not->toBeFalse(); + expect($content)->toContain('runBestEffortBackfill('); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2CompareEngineTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2CompareEngineTest.php new file mode 100644 index 00000000..85af1b4c --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2CompareEngineTest.php @@ -0,0 +1,135 @@ + 'internal', + 'totals' => [ + 'net_total' => 100.0, + 'line_net_total' => 100.0, + 'line_count' => 1, + 'billable_line_count' => 1, + ], + 'departments' => [ + '75' => 100.0, + ], + 'lines' => [ + [ + 'source' => 'internal', + 'source_line_id' => 1, + 'line_type' => 'product', + 'billable' => true, + 'product_number' => '1', + 'description' => 'Trakker', + 'reference' => '', + 'quantity' => 1.0, + 'unit_net_price' => 100.0, + 'line_net_amount' => 100.0, + 'department_distribution' => ['75' => 100.0], + 'match_key' => 'product:1|ref:', + ], + ], + 'warnings' => [], + ]; + + return array_replace_recursive($base, $overrides); +} + +it('returns exact_match when totals lines and departments are identical', function (): void { + $internal = economic_v2_test_invoice(); + $draft = economic_v2_test_invoice(['source' => 'draft']); + $booked = economic_v2_test_invoice(['source' => 'booked']); + + $result = economic_v2_compare_engine::compare($internal, $draft, $booked); + + expect($result['targets']['draft']['status'])->toBe('exact_match'); + expect($result['targets']['booked']['status'])->toBe('exact_match'); + expect($result['targets']['draft']['overall_match'])->toBeTrue(); + expect($result['targets']['booked']['overall_match'])->toBeTrue(); +}); + +it('returns total_mismatch when only totals differ', function (): void { + $internal = economic_v2_test_invoice(); + $draft = economic_v2_test_invoice([ + 'source' => 'draft', + 'totals' => ['net_total' => 125.0], + ]); + + $result = economic_v2_compare_engine::compareTarget($internal, $draft, 'draft'); + + expect($result['status'])->toBe('total_mismatch'); + expect($result['totals']['matches'])->toBeFalse(); + expect($result['mismatch_reasons'])->toContain('total_mismatch'); +}); + +it('detects line-level mismatches for quantity and price', function (): void { + $internal = economic_v2_test_invoice(); + $draft = economic_v2_test_invoice([ + 'source' => 'draft', + 'lines' => [[ + 'source' => 'draft', + 'source_line_id' => 1, + 'line_type' => 'product', + 'billable' => true, + 'product_number' => '1', + 'description' => 'Trakker', + 'reference' => '', + 'quantity' => 2.0, + 'unit_net_price' => 95.0, + 'line_net_amount' => 190.0, + 'department_distribution' => ['75' => 100.0], + 'match_key' => 'product:1|ref:', + ]], + 'totals' => ['net_total' => 100.0], + 'departments' => ['75' => 100.0], + ]); + + $result = economic_v2_compare_engine::compareTarget($internal, $draft, 'draft'); + $reasons = $result['lines']['diff'][0]['reasons'] ?? []; + + expect($result['lines']['summary']['mismatch_count'])->toBeGreaterThan(0); + expect($reasons)->toContain('quantity_mismatch'); + expect($reasons)->toContain('unit_price_mismatch'); +}); + +it('detects departmental distribution mismatches', function (): void { + $internal = economic_v2_test_invoice(); + $draft = economic_v2_test_invoice([ + 'source' => 'draft', + 'lines' => [[ + 'source' => 'draft', + 'source_line_id' => 1, + 'line_type' => 'product', + 'billable' => true, + 'product_number' => '1', + 'description' => 'Trakker', + 'reference' => '', + 'quantity' => 1.0, + 'unit_net_price' => 100.0, + 'line_net_amount' => 100.0, + 'department_distribution' => ['10' => 100.0], + 'match_key' => 'product:1|ref:', + ]], + 'departments' => ['10' => 100.0], + ]); + + $result = economic_v2_compare_engine::compareTarget($internal, $draft, 'draft'); + $lineReasons = $result['lines']['diff'][0]['reasons'] ?? []; + + expect($lineReasons)->toContain('departmental_distribution_mismatch'); + expect($result['departments']['matches'])->toBeFalse(); + expect($result['mismatch_reasons'])->toContain('department_total_mismatch'); +}); + +it('returns missing_target when draft or booked target is unavailable', function (): void { + $internal = economic_v2_test_invoice(); + $result = economic_v2_compare_engine::compareTarget($internal, null, 'booked'); + + expect($result['status'])->toBe('missing_target'); + expect($result['overall_match'])->toBeFalse(); + expect($result['mismatch_reasons'])->toContain('missing_target'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php new file mode 100644 index 00000000..4bb02608 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php @@ -0,0 +1,441 @@ +fixedVersion; + } + + public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array + { + return $this->subscriptionVersions; + } + + public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array + { + return null; + } + + public function runBestEffortBackfill(): array + { + $this->backfillCalls++; + return [ + 'fixed_pricing' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'vehicle_subscriptions' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'discount_overrides' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'inferred' => ['fixed_pricing' => 0, 'vehicle_subscriptions' => 0], + 'warnings' => [], + ]; + } + } +} + +if (!class_exists('TestableEconomicV2DistributionService')) { + class TestableEconomicV2DistributionService extends economic_v2_distribution_service + { + public array $stubOrders = []; + public array $stubOrderItems = []; + public array $stubVersionRows = []; + public array $subscriptionPrices = []; + public array $versionTableState = []; + public int $fallbackDepartmentId = 8; + public array $customerDefaultDepartments = []; + public ?array $includedCustomers = null; + + public function __construct(?economic_v2_versioning_service $versioning = null) + { + parent::__construct($versioning); + } + + protected function fetchOrdersInRange(string $from_ts, string $to_ts): array + { + return $this->stubOrders; + } + + protected function fetchOrderItemsByOrderIds(array $order_ids): array + { + return $this->stubOrderItems; + } + + protected function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array + { + return $this->stubVersionRows; + } + + protected function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float + { + $total = 0.0; + foreach ($order_items as $item) { + $total += (float)($item['price'] ?? 0) * (float)($item['quantity'] ?? 0); + } + return $total; + } + + protected function getSubscriptionMonthlyPrice(int $vehicle_type): float + { + return (float)($this->subscriptionPrices[$vehicle_type] ?? 0.0); + } + + protected function getProductDepartmentPrice(int $product_id, int $department_id): float + { + return 100.0; + } + + protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array + { + return null; + } + + protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array + { + return [ + 'id' => $customer_number, + 'customer_number' => $customer_number, + 'customer_name' => 'Customer ' . $customer_number, + 'transactions' => array_values($transaction_map), + 'requires_action' => false, + 'meta' => [], + ]; + } + + protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null, ?bool $included = null): array + { + return [ + 'id' => $order_id, + 'date' => $created_at, + 'amount' => round((float)($amount ?? 0.0), 5), + 'booked' => true, + 'department_id' => $department_id, + 'excluded' => !($included ?? $this->isDepartmentEligible($department_id)), + ]; + } + + protected function isDepartmentEligible(int $department_id): bool + { + return $department_id > 0 && $department_id !== 10; + } + + protected function shouldIncludeCustomerNumber(int $customer_number): bool + { + if ($this->includedCustomers === null) { + return true; + } + + return in_array($customer_number, $this->includedCustomers, true); + } + + protected function getCustomerDefaultDepartmentId(int $customer_number): ?int + { + return $this->customerDefaultDepartments[$customer_number] ?? null; + } + + protected function getFallbackDistributionDepartmentId(): int + { + return $this->fallbackDepartmentId; + } + + protected function parseDepartmentMap(array $department_map): array + { + $parsed = []; + foreach ($department_map as $department_id => $amount) { + $parsed['Department ' . $department_id] = round((float)$amount, 5); + } + return $parsed; + } + + protected function versionTableHasRows(string $table): bool + { + if (array_key_exists($table, $this->versionTableState)) { + return (bool)$this->versionTableState[$table]; + } + + return true; + } + } +} + +it('runs best-effort backfill when fixed pricing version history is empty', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + $versioning->fixedVersion = [ + 'id' => 91, + 'price' => 499.95, + 'description' => 'Fixed pricing system order', + 'source' => 'backfill.inferred_fixed_pricing_order', + 'confidence' => 0.55, + 'inferred' => true, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]; + + $service = new TestableEconomicV2DistributionService($versioning); + $service->versionTableState = [ + 'customer_fixed_pricing_versions' => false, + ]; + $service->stubOrders = [[ + 'id' => 501, + 'customer_id' => 12345, + 'department_id' => 10, + 'created_at' => '2026-01-15 10:00:00', + 'reg_1' => '', + 'reference' => 'Fast pris aftale', + ]]; + $service->stubOrderItems = [ + 501 => [[ + 'product_id' => 61, + 'price' => 499.95, + 'quantity' => 1, + 'reference' => '', + ]], + ]; + + $result = $service->getFixedPricingDistribution('2026-01-01', '2026-01-31'); + + expect($versioning->backfillCalls)->toBe(1); + expect($result['customers'])->toHaveCount(1); +}); + +it('falls back to system orders for fixed pricing using the configured fallback department', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + $versioning->fixedVersion = [ + 'id' => 91, + 'price' => 499.95, + 'description' => 'Fixed pricing system order', + 'source' => 'backfill.inferred_fixed_pricing_order', + 'confidence' => 0.55, + 'inferred' => true, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]; + + $service = new TestableEconomicV2DistributionService($versioning); + $service->stubOrders = [[ + 'id' => 501, + 'customer_id' => 12345, + 'department_id' => 10, + 'created_at' => '2026-01-15 10:00:00', + 'reg_1' => '', + 'reference' => 'Fast pris aftale', + ]]; + $service->stubOrderItems = [ + 501 => [[ + 'product_id' => 61, + 'price' => 499.95, + 'quantity' => 1, + 'reference' => '', + ]], + ]; + + $result = $service->getFixedPricingDistribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['customer_number'])->toBe(12345); + expect($result['customers'][0]['meta']['fixed_pricing']['price'])->toBe(499.95); + expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['order_ids'])->toBe([501]); + expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['department_totals']['8'])->toBe(499.95); + expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['department_totals'])->not->toHaveKey('10'); + expect($result['collective_results']['total_department_totals']['8'])->toBe(499.95); + expect($result['collective_results']['total_fixed_price'])->toBe(499.95); + expect($result['warnings'])->toContain('System order fallback used for fixed pricing (department 10).'); +}); + +it('falls back to system orders for wash subscriptions using the configured fallback department', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + $versioning->subscriptionVersions = [[ + 'id' => 42, + 'reg' => 'AB12345', + 'vehicle_type' => 77, + 'source' => 'backfill.inferred_subscription_order', + 'confidence' => 0.5, + 'inferred' => true, + ]]; + + $service = new TestableEconomicV2DistributionService($versioning); + $service->subscriptionPrices = [ + 77 => 299.0, + ]; + $service->stubOrders = [[ + 'id' => 601, + 'customer_id' => 12345, + 'department_id' => 10, + 'created_at' => '2026-01-15 10:00:00', + 'reg_1' => '', + 'reference' => 'Vaskeabonnementer', + ]]; + $service->stubOrderItems = [ + 601 => [[ + 'product_id' => 77, + 'price' => 299.0, + 'quantity' => 1, + 'reference' => 'AB12345', + ]], + ]; + + $result = $service->getWashSubscriptionsDistribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['customer_number'])->toBe(12345); + expect($result['customers'][0]['meta']['subscription']['subscription_total'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['reg'])->toBe('AB12345'); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution']['8'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution'])->not->toHaveKey('10'); + expect($result['collective_results']['subscription_price_department_distribution']['8'])->toBe(299.0); + expect($result['collective_results']['total_subscription_price'])->toBe(299.0); + expect($result['warnings'])->toContain('System order fallback used for wash subscriptions (department 10).'); +}); + +it('uses the configured fallback department when a subscription has no customer department basis', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + + $service = new TestableEconomicV2DistributionService($versioning); + $service->subscriptionPrices = [ + 77 => 299.0, + ]; + $service->stubVersionRows = [[ + 'id' => 42, + 'customer_number' => 12345, + 'reg' => 'AB12345', + 'vehicle_type' => 77, + 'wash_subscription' => 1, + 'source' => 'test.version', + 'confidence' => 1.0, + 'inferred' => false, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]]; + + $result = $service->getWashSubscriptionsDistribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution']['8'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution'])->not->toHaveKey('1'); + expect($result['collective_results']['subscription_price_department_distribution']['8'])->toBe(299.0); +}); + +it('excludes orphaned customer traces from fixed pricing and customer price distributions', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + $versioning->fixedVersion = [ + 'id' => 91, + 'price' => 499.95, + 'description' => 'Fixed pricing agreement', + 'source' => 'test.fixed', + 'confidence' => 1.0, + 'inferred' => false, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]; + + $service = new TestableEconomicV2DistributionService($versioning); + $service->includedCustomers = [12345]; + $service->stubOrders = [ + [ + 'id' => 501, + 'customer_id' => 80000038, + 'department_id' => 1, + 'created_at' => '2026-01-15 10:00:00', + 'include_in_invoice' => 1, + 'reg_1' => '', + 'reference' => '', + ], + [ + 'id' => 502, + 'customer_id' => 12345, + 'department_id' => 1, + 'created_at' => '2026-01-16 10:00:00', + 'include_in_invoice' => 1, + 'reg_1' => '', + 'reference' => '', + ], + ]; + $service->stubOrderItems = [ + 501 => [[ + 'product_id' => 61, + 'price' => 250.0, + 'quantity' => 1, + 'reference' => '', + ]], + 502 => [[ + 'product_id' => 61, + 'price' => 300.0, + 'quantity' => 1, + 'reference' => '', + ]], + ]; + + $fixedPricing = $service->getFixedPricingDistribution('2026-01-01', '2026-01-31'); + $customerPrices = $service->getCustomerPricesDistribution('2026-01-01', '2026-01-31'); + + expect(array_column($fixedPricing['customers'], 'customer_number'))->toBe([12345]); + expect($fixedPricing['collective_results']['total_fixed_price'])->toBe(499.95); + expect($fixedPricing['collective_results']['total_original_price'])->toBe(300.0); + + expect(array_column($customerPrices['customers'], 'customer_number'))->toBe([12345]); + expect($customerPrices['collective_results']['total_discount_amount'])->toBe(0.0); +}); + +it('excludes orphaned customers from subscription fallback versions', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + + $service = new TestableEconomicV2DistributionService($versioning); + $service->includedCustomers = [12345]; + $service->stubOrders = [[ + 'id' => 701, + 'customer_id' => 12345, + 'department_id' => 4, + 'created_at' => '2026-01-15 10:00:00', + 'include_in_invoice' => 1, + 'reg_1' => 'AB12345', + 'reference' => '', + ]]; + $service->subscriptionPrices = [ + 77 => 299.0, + ]; + $service->stubVersionRows = [ + [ + 'id' => 41, + 'customer_number' => 80000038, + 'reg' => 'ZZ99999', + 'vehicle_type' => 77, + 'wash_subscription' => 1, + 'source' => 'test.version', + 'confidence' => 1.0, + 'inferred' => false, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ], + [ + 'id' => 42, + 'customer_number' => 12345, + 'reg' => 'AB12345', + 'vehicle_type' => 77, + 'wash_subscription' => 1, + 'source' => 'test.version', + 'confidence' => 1.0, + 'inferred' => false, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ], + ]; + + $result = $service->getWashSubscriptionsDistribution('2026-01-01', '2026-01-31'); + + expect(array_column($result['customers'], 'customer_number'))->toBe([12345]); + expect($result['warnings'])->toBe([ + 'Fallback allocation used for subscription AB12345 customer 12345 in 2026-01', + ]); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceOrderOverrideTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceOrderOverrideTest.php new file mode 100644 index 00000000..713a4464 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceOrderOverrideTest.php @@ -0,0 +1,88 @@ +isOrderEligible($order); + } + + protected function isDepartmentEligible(int $department_id): bool + { + return $department_id === 12; + } + } +} + +it('inherits department eligibility when no order override is set', function (): void { + $service = new EconomicV2DistributionServiceOrderOverrideDouble(); + + expect($service->exposeIsOrderEligible([ + 'department_id' => 12, + 'include_in_invoice' => null, + ]))->toBeTrue(); + + expect($service->exposeIsOrderEligible([ + 'department_id' => 13, + 'include_in_invoice' => null, + ]))->toBeFalse(); +}); + +it('allows an order-level include override to overrule department exclusion', function (): void { + $service = new EconomicV2DistributionServiceOrderOverrideDouble(); + + expect($service->exposeIsOrderEligible([ + 'department_id' => 13, + 'include_in_invoice' => '1', + ]))->toBeTrue(); +}); + +it('allows an order-level exclude override to overrule department inclusion', function (): void { + $service = new EconomicV2DistributionServiceOrderOverrideDouble(); + + expect($service->exposeIsOrderEligible([ + 'department_id' => 12, + 'include_in_invoice' => '0', + ]))->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2LineNormalizerTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2LineNormalizerTest.php new file mode 100644 index 00000000..09e8a4b3 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2LineNormalizerTest.php @@ -0,0 +1,99 @@ + 150, + 'lines' => [ + [ + 'lineNumber' => 1, + 'description' => 'Subscription', + 'quantity' => 2, + 'unitNetPrice' => 75, + 'totalNetAmount' => 150, + 'product' => [ + 'productNumber' => 1, + ], + 'departmentalDistribution' => [ + 'distributions' => [ + [ + 'percentage' => 60, + 'department' => ['departmentNumber' => 75], + ], + [ + 'percentage' => 40, + 'department' => ['departmentNumber' => 10], + ], + ], + ], + ], + ], + ]; + + $normalized = economic_v2_line_normalizer::normalizeDraftInvoice($draft); + + expect($normalized['source'])->toBe('draft'); + expect($normalized['totals']['net_total'])->toBe(150.0); + expect($normalized['totals']['line_count'])->toBe(1); + expect($normalized['lines'][0]['product_number'])->toBe('1'); + expect($normalized['lines'][0]['department_distribution']['75'])->toBe(60.0); + expect($normalized['lines'][0]['department_distribution']['10'])->toBe(40.0); +}); + +it('marks text-only zero-value lines as non-billable and keeps deterministic key', function (): void { + $draft = [ + 'lines' => [ + [ + 'lineNumber' => 1, + 'description' => '# Header line', + 'quantity' => 0, + 'unitNetPrice' => 0, + 'totalNetAmount' => 0, + ], + ], + ]; + + $normalized = economic_v2_line_normalizer::normalizeDraftInvoice($draft); + $line = $normalized['lines'][0]; + + expect($line['billable'])->toBeFalse(); + expect($line['line_type'])->toBe('text'); + expect($line['match_key'])->toStartWith('text:'); + expect($line['department_distribution']['unassigned'])->toBe(100.0); +}); + +it('normalizes booked invoices and computes net total delta from lines', function (): void { + $booked = [ + 'net_amount' => 100, + 'lines' => [ + [ + 'line_number' => 1, + 'description' => 'Wash', + 'quantity' => 1, + 'unit_net_price' => 90, + 'total_net_amount' => 90, + 'product' => ['product_number' => 33], + ], + ], + ]; + + $normalized = economic_v2_line_normalizer::normalizeBookedInvoice($booked); + + expect($normalized['source'])->toBe('booked'); + expect($normalized['totals']['net_total'])->toBe(100.0); + expect($normalized['totals']['line_net_total'])->toBe(90.0); + expect($normalized['totals']['difference_from_line_sum'])->toBe(10.0); +}); + +it('contains internal normalization path with departmental metadata support', function (): void { + $classFile = app_path('classes/economic_v2_line_normalizer.php'); + $content = file_get_contents($classFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('normalizeInternalCollectedInvoice('); + expect($content)->toContain("'department_distribution'"); + expect($content)->toContain('buildMatchKey('); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php new file mode 100644 index 00000000..1af658a0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php @@ -0,0 +1,79 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +it('documents economic v2 invoice paths in openapi', function (): void { + $content = economic_v2_openapi_content_or_skip(); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/collected-invoices/economic/v2/details:'); + expect($content)->toContain('/collected-invoices/economic/v2/compare:'); + expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk:'); + expect($content)->toContain('/collected-invoices/economic/v2/revenue-statistics:'); +}); + +it('documents v2 historical distribution and pricing history paths in openapi', function (): void { + $content = economic_v2_openapi_content_or_skip(); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/all:'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/fixed-pricing:'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/wash-subscriptions:'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/customer-prices:'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/booked-department-75:'); + expect($content)->toContain('/superuser/customers/pricing-history:'); +}); + +it('aligns legacy compare schema with runtime payload by removing stale required order_ids', function (): void { + $content = economic_v2_openapi_content_or_skip(); + + expect($content)->not->toBeFalse(); + $start = strpos($content, 'CollectedInvoiceEconomicCompareResponse:'); + $end = strpos($content, 'CollectedInvoiceEconomicV2DetailsResponse:'); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + + $legacyBlock = substr($content, $start, $end - $start); + expect($legacyBlock)->toContain('- internal_total'); + expect($legacyBlock)->not->toContain('order_ids:'); + expect($legacyBlock)->not->toContain('- order_ids'); +}); + +it('defines new reusable v2 schemas for normalization comparison versioning and distribution', function (): void { + $content = economic_v2_openapi_content_or_skip(); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('EconomicV2NormalizedLineItem:'); + expect($content)->toContain('EconomicV2Comparison:'); + expect($content)->toContain('CollectedInvoiceEconomicV2CustomerSummary:'); + expect($content)->toContain('CollectedInvoiceEconomicV2RevenueStatisticsResponse:'); + expect($content)->toContain('EconomicV2RevenueSummary:'); + expect($content)->toContain('PricingHistoryVersionEntry:'); + expect($content)->toContain('InvoicingDistributionV2AllResponse:'); + expect($content)->toContain('InvoicingDistributionV2BookedDepartment75Response:'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2RevenueAndBarredSupportTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RevenueAndBarredSupportTest.php new file mode 100644 index 00000000..ef15726b --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RevenueAndBarredSupportTest.php @@ -0,0 +1,36 @@ +not->toBeFalse(); + expect($routeContent)->toContain("fromRequest('barred')"); + expect($routeContent)->toContain('listCustomers('); + + $customersFile = app_path('modules/economic/customers/economicCustomers.php'); + $customersContent = file_get_contents($customersFile); + + expect($customersContent)->not->toBeFalse(); + expect($customersContent)->toContain('normalizeBarredFilter('); + expect($customersContent)->toContain('barred$eq:'); +}); + +it('implements a dedicated v2 e-conomic revenue statistics service and route', function (): void { + $routeFile = app_path('routes/orderInvoicesRoute.php'); + $routeContent = file_get_contents($routeFile); + + expect($routeContent)->not->toBeFalse(); + expect($routeContent)->toContain('/collected-invoices/economic/v2/revenue-statistics'); + expect($routeContent)->toContain("requirePermission('view_collected_invoice_economic_v2_revenue_statistics')"); + expect($routeContent)->toContain('getBookedRevenueStatistics('); + + $serviceFile = app_path('classes/economic_v2_revenue_statistics_service.php'); + $serviceContent = file_get_contents($serviceFile); + + expect($serviceContent)->not->toBeFalse(); + expect($serviceContent)->toContain('class economic_v2_revenue_statistics_service'); + expect($serviceContent)->toContain('passesBarredFilter('); + expect($serviceContent)->toContain('reduceInvoiceLines('); +}); + diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php new file mode 100644 index 00000000..edd28917 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php @@ -0,0 +1,79 @@ +not->toBeFalse(); + expect($content)->toContain('/collected-invoices/economic/v2/details'); + expect($content)->toContain('/collected-invoices/economic/v2/compare'); + expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk'); + expect($content)->toContain('/collected-invoices/economic/v2/revenue-statistics'); + expect($content)->toContain("requirePermission('view_collected_invoice_economic_v2_details')"); + expect($content)->toContain("requirePermission('compare_collected_invoice_economic_v2')"); + expect($content)->toContain("requirePermission('compare_collected_invoice_economic_v2_bulk')"); + expect($content)->toContain("requirePermission('view_collected_invoice_economic_v2_revenue_statistics')"); + expect($content)->toContain("requireParameters(['collected_invoice_ids'])"); +}); + +it('registers version-aware distribution and pricing history v2 routes', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/all'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/fixed-pricing'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/wash-subscriptions'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/customer-prices'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/booked-department-75'); + expect($content)->toContain('/superuser/customers/pricing-history'); + expect($content)->toContain("requirePermission('superuser_invoicing_period_distribution_v2')"); + expect($content)->toContain("requirePermission('superuser_customer_pricing_history_v2')"); +}); + +it('caches v2 distribution responses with a configurable redis ttl', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('withCachedDistributionV2('); + expect($content)->toContain('invoicing_period:distribution:v2:'); + expect($content)->toContain('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL'); +}); + +it('writes fixed pricing versions from create and delete flows', function (): void { + $routeFile = app_path('routes/customerFixedPricingRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('recordFixedPricingVersion('); + expect($content)->toContain('closeActiveFixedPricingVersion('); +}); + +it('writes vehicle subscription versions for create update delete flows', function (): void { + $routeFile = app_path('routes/vehiclesRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('recordVehicleSubscriptionVersion('); + expect($content)->toContain('closeActiveVehicleSubscriptionVersion('); + expect($content)->toContain("if (self::isParametersSet(['customer_id']))"); +}); + +it('writes discount override versions from superuser discounts route', function (): void { + $routeFile = app_path('routes/userRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/superuser/user/discounts'); + expect($content)->toContain('recordDiscountOverrideVersion('); +}); + +it('keeps legacy compare endpoint path for backward compatibility', function (): void { + $routeFile = app_path('routes/orderInvoicesRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/collected-invoices/economic/compare'); + expect($content)->toContain("requirePermission('compare_collected_invoice_economic')"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2VersioningServiceStructureTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2VersioningServiceStructureTest.php new file mode 100644 index 00000000..a26870d2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2VersioningServiceStructureTest.php @@ -0,0 +1,45 @@ +not->toBeFalse(); + expect($content)->toContain('recordFixedPricingVersion('); + expect($content)->toContain('closeActiveFixedPricingVersion('); + expect($content)->toContain('recordVehicleSubscriptionVersion('); + expect($content)->toContain('closeActiveVehicleSubscriptionVersion('); + expect($content)->toContain('recordDiscountOverrideVersion('); +}); + +it('closes previous active interval before inserting a new version', function (): void { + $serviceFile = app_path('classes/economic_v2_versioning_service.php'); + $content = file_get_contents($serviceFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('AND effective_from <'); + expect($content)->toContain('AND (effective_to IS NULL OR effective_to >='); + expect($content)->toContain('minusOneSecond('); +}); + +it('includes best-effort backfill with provenance and confidence metadata', function (): void { + $serviceFile = app_path('classes/economic_v2_versioning_service.php'); + $content = file_get_contents($serviceFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('runBestEffortBackfill('); + expect($content)->toContain('backfill.current_fixed_pricing'); + expect($content)->toContain('backfill.current_vehicle'); + expect($content)->toContain('backfill.current_discount_override'); + expect($content)->toContain("'inferred' =>"); +}); + +it('anchors historical resolution on order created_at timestamps in distribution service', function (): void { + $serviceFile = app_path('classes/economic_v2_distribution_service.php'); + $content = file_get_contents($serviceFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('resolveFixedPricingVersionAt($customer_number, $created_at)'); + expect($content)->toContain('resolveVehicleSubscriptionVersionsAt($customer_number, $created_at)'); + expect($content)->toContain('resolveDiscountForProduct($customer_number, $product_id, $created_at)'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php new file mode 100644 index 00000000..dfae664f --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -0,0 +1,978 @@ +newInstanceWithoutConstructor(); + return $service; +} + +function invoice_period_flag_service_invoke(string $method, array $args = []): mixed +{ + $service = invoice_period_flag_service_instance(); + $reflection = new ReflectionClass(invoice_period_flag_service::class); + $target = $reflection->getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs($service, $args); +} + +it('builds deterministic automatic flag fingerprints and interactive price message parts', function (): void { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 9001, + 'order_item_id' => 7001, + 'invoice_collection_id' => 3001, + ]; + $params = [ + 'product' => 'Spot Free', + 'expected_price' => 81, + 'actual_price' => 99, + ]; + $context = [ + 'department_id' => 1, + 'order_id' => 9001, + 'order_item_id' => 7001, + ]; + + $flag = invoice_period_flag_service_invoke('automaticFlag', [ + 'price_mismatch', + 'order_item_field', + 7001, + 'price', + $row, + $params, + $context, + ]); + $sameFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'price_mismatch', + 'order_item_field', + 7001, + 'price', + $row, + $params, + $context, + ]); + $changedPriceFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'price_mismatch', + 'order_item_field', + 7001, + 'price', + $row, + [ + ...$params, + 'actual_price' => 100, + ], + $context, + ]); + + expect($flag['fingerprint'])->toBe($sameFlag['fingerprint']); + expect($flag['fingerprint'])->not->toBe($changedPriceFlag['fingerprint']); + expect($flag['id'])->toBe('auto:' . $flag['fingerprint']); + expect($flag['message_key'])->toBe('invoice_period.flags.automatic.price_mismatch'); + expect($flag['message'])->toBe('Spot Free product price differs from expected.'); + expect($flag['message_parts'])->toBe([ + ['type' => 'order_item', 'text' => 'Spot Free'], + ['type' => 'text', 'text' => ' product price differs from '], + ['type' => 'expected_price', 'text' => 'expected'], + ['type' => 'text', 'text' => '.'], + ]); +}); + +it('builds interactive message parts for order and wash certificate warnings', function (): void { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 9001, + 'order_item_id' => 7001, + 'invoice_collection_id' => 3001, + ]; + + $orderFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'multiple_identical_primary_vehicle_items', + 'order', + 9001, + null, + $row, + [], + ['department_id' => 1, 'order_id' => 9001], + ]); + $washCertificateFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'wash_certificate_item_without_certificate', + 'order_item', + 7001, + null, + $row, + [], + ['department_id' => 1, 'order_id' => 9001, 'order_item_id' => 7001], + ]); + $xlVaskFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'xlvask_missing_order_link', + 'xlvask_usage_log', + 55, + null, + [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'xlvask_usage_log_id' => 55, + ], + [ + 'wash_id' => 'wash-55', + 'registration_number' => 'AB12345', + ], + [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'xlvask_usage_log_id' => 55, + 'wash_id' => 'wash-55', + 'registration_number' => 'AB12345', + 'start_time' => '2026-05-11 10:00:00', + ], + ]); + + expect($orderFlag['message_parts'])->toBe([ + ['type' => 'order', 'text' => 'Order'], + ['type' => 'text', 'text' => ' contains multiple identical primary vehicle items.'], + ]); + expect($washCertificateFlag['message_parts'])->toBe([ + ['type' => 'order_item', 'text' => 'Wash certificate item'], + ['type' => 'text', 'text' => ' is present without a wash certificate.'], + ]); + expect($xlVaskFlag['message_parts'])->toBe([ + ['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], + ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'], + ]); +}); + +it('includes order item preview context for required order field warnings', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public array $queries = []; + + public function query(string $sql): object|false + { + $this->queries[] = $sql; + + if (str_contains($sql, 'FROM order_items')) { + return $this->result([ + [ + 'id' => 91, + 'order_id' => 61415, + 'product_id' => 3, + 'reference' => '', + 'notes' => '', + 'price' => 649, + 'quantity' => 1, + 'related_item_id' => 0, + 'product_name' => 'Forvogn', + 'product_base_price' => 649, + ], + [ + 'id' => 92, + 'order_id' => 61415, + 'product_id' => 4, + 'reference' => '', + 'notes' => '', + 'price' => 599, + 'quantity' => 1, + 'related_item_id' => 0, + 'product_name' => 'Trailer', + 'product_base_price' => 599, + ], + ]); + } + + return false; + } + + public function fetch_all(object $result): array + { + return $result->rows; + } + + private function result(array $rows): object + { + return new class($rows) { + public int $num_rows; + public array $rows; + + public function __construct(array $rows) + { + $this->rows = $rows; + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } + }; + } + }; + + try { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 61415, + 'order_item_id' => 91, + 'invoice_collection_id' => 3001, + 'department_id' => 5, + 'order_reference' => '', + 'order_po' => '', + 'reg_1' => 'EC21233', + ]; + + $flags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [ + [$row], + [424242 => ['requiresReferenceNumber' => true, 'usePONumbers' => true]], + ]); + $byDefinition = []; + foreach ($flags as $flag) { + $byDefinition[$flag['definition_key']] = $flag; + } + + expect($byDefinition['customer_rule_requires_reference'] ?? null)->not->toBeNull(); + expect($byDefinition['customer_rule_requires_po_number'] ?? null)->not->toBeNull(); + expect($byDefinition['customer_rule_requires_reference']['context']['order_items'])->toHaveCount(2); + expect($byDefinition['customer_rule_requires_reference']['context']['order_items'][0]['product_name'])->toBe('Forvogn'); + expect($byDefinition['customer_rule_requires_po_number']['context']['order_items'][1]['product_name'])->toBe('Trailer'); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('allows tank cleaning products for only tank cleaning customers', function (): void { + $baseRow = [ + 'customer_number' => 424242, + 'customer_name' => 'Tank Customer', + 'order_id' => 61426, + 'invoice_collection_id' => 3090, + 'department_id' => 5, + 'related_item_id' => 0, + 'item_price' => 100, + 'order_reference' => 'REF', + 'order_po' => 'PO', + 'reg_1' => 'NI465', + ]; + $tankCleaningRow = $baseRow + [ + 'order_item_id' => 801, + 'product_id' => 30, + 'product_name' => 'Tank cleaning 4 spulehoveder', + 'product_category' => 5, + 'category_name' => 'Tank cleaning', + ]; + $tankCleaningAddonRow = $baseRow + [ + 'order_item_id' => 802, + 'product_id' => 33, + 'product_name' => 'Saebe/kemi, 1-4 spulehoveder', + 'product_category' => 5, + 'category_name' => 'Tank cleaning', + ]; + $washRow = $baseRow + [ + 'order_item_id' => 803, + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'product_category' => 1, + 'category_name' => 'Vask', + ]; + + $onlyTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [ + [$tankCleaningRow, $tankCleaningAddonRow, $washRow], + [424242 => ['onlyTankCleaning' => true]], + ]); + + expect(array_column($onlyTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_only_tank_cleaning']); + expect($onlyTankCleaningFlags[0]['target_id'])->toBe(803); + + $restrictedTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [ + [$tankCleaningRow, $washRow], + [424242 => ['restrictTankCleaning' => true]], + ]); + + expect(array_column($restrictedTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_restrict_tank_cleaning']); + expect($restrictedTankCleaningFlags[0]['target_id'])->toBe(801); +}); + +it('does not flag interior wash variants as historical primary product mismatches', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql): object|false + { + if (str_contains($sql, 'SHOW COLUMNS FROM `customer_vehicles`')) { + return $this->result([]); + } + + if (str_contains($sql, 'FROM customer_vehicles')) { + return $this->result([]); + } + + if (str_contains($sql, 'FROM orders o') && str_contains($sql, 'GROUP BY UPPER(TRIM(o.reg_1))')) { + return $this->result([ + [ + 'reg' => 'CN96636', + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'usage_count' => 5, + ], + ]); + } + + return false; + } + + private function result(array $rows): object + { + return new class($rows) { + public int $num_rows; + public array $rows; + + public function __construct(array $rows) + { + $this->rows = $rows; + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } + }; + } + }; + + try { + $baseRow = [ + 'customer_number' => 424242, + 'customer_name' => 'History Customer', + 'order_id' => 61311, + 'order_item_id' => 901, + 'invoice_collection_id' => 16912, + 'department_id' => 7, + 'reg_1' => 'CN96636', + 'is_wash' => 1, + 'related_item_id' => 0, + 'order_created_at' => '2026-05-11 08:05:21', + ]; + + $interiorVariantFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [ + [ + $baseRow + [ + 'product_id' => 99, + 'product_name' => 'Indvendig vask Forvogn', + ], + ], + '2026-05-11 00:00:00', + ]); + + expect($interiorVariantFlags)->toBe([]); + + $mismatchFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [ + [ + $baseRow + [ + 'product_id' => 17, + 'product_name' => 'Bus', + ], + ], + '2026-05-11 00:00:00', + ]); + + expect(array_column($mismatchFlags, 'definition_key'))->toBe(['historical_primary_product_mismatch']); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('does not report duplicate primary vehicle products from duplicated detector rows for the same order item', function (): void { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 9001, + 'order_item_id' => 7001, + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'category_name' => 'Vask', + 'is_wash' => 1, + 'related_item_id' => 0, + 'item_quantity' => 1, + 'max_quantity_per_order' => null, + 'safety_seal' => '', + 'order_created_at' => '2026-05-11 10:00:00', + 'reg_1' => 'AB12345', + ]; + + $flags = invoice_period_flag_service_invoke('detectAbnormalQuantities', [ + [$row, $row], + '2026-05-11 00:00:00', + '2026-05-11 23:59:59', + ]); + + expect(array_column($flags, 'definition_key'))->not->toContain('multiple_identical_primary_vehicle_items'); +}); + +it('uses attached wash certificate documents instead of safety seal text for certificate presence', function (): void { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 9001, + 'order_item_id' => 7001, + 'product_id' => 41, + 'product_name' => 'Vaskecertifikat - Safety Seal', + 'category_name' => 'Tillæg', + 'is_wash' => 0, + 'related_item_id' => 0, + 'item_quantity' => 1, + 'max_quantity_per_order' => null, + 'safety_seal' => '', + 'has_wash_certificate_attachment' => 1, + 'order_created_at' => '2026-05-11 10:00:00', + 'reg_1' => 'AB12345', + ]; + + $flags = invoice_period_flag_service_invoke('detectAbnormalQuantities', [ + [$row], + '2026-05-11 00:00:00', + '2026-05-11 23:59:59', + ]); + + expect(array_column($flags, 'definition_key'))->not->toContain('wash_certificate_item_without_certificate'); +}); + +it('loads wash certificate attachment presence from order attachment content', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public array $queries = []; + + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql) + { + $this->queries[] = $sql; + + if (str_contains($sql, 'SHOW TABLES LIKE')) { + return $this->result([['table' => 'object_attachments']]); + } + + if (str_contains($sql, 'FROM object_attachments')) { + return $this->result([ + ['object_id' => 9001, 'content' => json_encode(['other' => 'WASH_CERTIFICATE'])], + ['object_id' => 9002, 'content' => json_encode(['other' => 'invoice'])], + ]); + } + + return false; + } + + private function result(array $rows): object + { + return new class($rows) { + public int $num_rows; + private array $rows; + + public function __construct(array $rows) + { + $this->rows = $rows; + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } + }; + } + }; + + try { + $attached = invoice_period_flag_service_invoke('getWashCertificateAttachmentOrderIds', [[9001, 9002, 9001]]); + + expect($attached)->toBe([9001 => true]); + expect($db->queries[1])->toContain("object_type IN ('orders','`orders`')"); + expect($db->queries[1])->toContain('deleted_at IS NULL'); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('uses the highest customer-specific discount in expected price breakdowns', function (): void { + $row = [ + 'customer_number' => 0, + 'product_base_price' => 150, + 'department_price' => 100, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'apply_category_discount' => 1, + ]; + + $expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]); + $breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]); + + expect($expected)->toBe(88); + expect($breakdown)->toMatchArray([ + 'product_price' => 150, + 'department_price' => 100, + 'effective_base_price' => 100, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'economic_customer_discount_percentage' => 0, + 'applied_discount_percentage' => 12, + 'expected_price' => 88, + ]); +}); + +it('uses a preloaded e-conomic global discount in expected price breakdowns', function (): void { + $service = invoice_period_flag_service_instance(); + $reflection = new ReflectionClass(invoice_period_flag_service::class); + + $cache = $reflection->getProperty('economicCustomerDiscountCache'); + $cache->setAccessible(true); + $cache->setValue($service, [ + 35131752 => 18, + ]); + + $calculate = $reflection->getMethod('calculateExpectedPrice'); + $calculate->setAccessible(true); + $breakdownMethod = $reflection->getMethod('priceBreakdown'); + $breakdownMethod->setAccessible(true); + + $row = [ + 'customer_number' => 35131752, + 'user_id' => 411, + 'product_base_price' => 100, + 'department_price' => null, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'apply_category_discount' => 1, + ]; + + $expected = $calculate->invoke($service, $row); + $breakdown = $breakdownMethod->invoke($service, $row, $expected); + + expect($expected)->toBe(82); + expect($breakdown)->toMatchArray([ + 'effective_base_price' => 100, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'economic_customer_discount_percentage' => 18, + 'applied_discount_percentage' => 18, + 'expected_price' => 82, + ]); +}); + +it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void { + $row = [ + 'customer_number' => 35131752, + 'customer_name' => 'BHS Logistics A/S', + 'order_id' => 61359, + 'order_item_id' => 7701, + 'invoice_collection_id' => 16891, + 'department_id' => 1, + 'product_id' => 24, + 'product_name' => 'Spot Free- Lastbil', + 'product_base_price' => 39, + 'department_price' => null, + 'product_discount_percentage' => 100, + 'category_discount_percentage' => 0, + 'apply_category_discount' => 0, + 'item_price' => 0, + 'item_quantity' => 1, + 'item_include_in_invoice' => 1, + ]; + + $expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]); + $breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]); + $flags = invoice_period_flag_service_invoke('detectPriceMismatches', [[$row]]); + + expect($expected)->toBe(0); + expect($breakdown)->toMatchArray([ + 'product_price' => 39, + 'effective_base_price' => 39, + 'product_discount_percentage' => 100, + 'applied_discount_percentage' => 100, + 'expected_price' => 0, + ]); + expect($flags)->toBe([]); +}); + +it('preloads and caches missing e-conomic discounts before price mismatch detection', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('$this->preloadEconomicCustomerDiscounts($rows);'); + expect($content)->toContain('private function preloadEconomicCustomerDiscounts(array $rows): void'); + expect($content)->toContain("constant('redis')->get_economic_customer_discount_percentage(\$userId)"); + expect($content)->toContain('getCustomerDiscountPercentage($customerNumber)'); + expect($content)->toContain("constant('redis')->cache_economic_customer_discount_percentage(\$userId, \$discount)"); +}); + +it('seeds order item preview cache from period rows', function (): void { + $service = invoice_period_flag_service_instance(); + $reflection = new ReflectionClass(invoice_period_flag_service::class); + + $seed = $reflection->getMethod('seedOrderItemsPreviewCacheFromRows'); + $seed->setAccessible(true); + $preview = $reflection->getMethod('getOrderItemsForPreview'); + $preview->setAccessible(true); + + $seed->invoke($service, [ + [ + 'order_id' => 9001, + 'order_item_id' => 13, + 'product_id' => 102, + 'product_name' => 'Addon', + 'item_quantity' => 2, + 'item_price' => 25, + 'related_item_id' => 12, + ], + [ + 'order_id' => 9001, + 'order_item_id' => 12, + 'product_id' => 101, + 'product_name' => 'Wash', + 'item_quantity' => 1, + 'item_price' => 100, + 'related_item_id' => null, + ], + [ + 'order_id' => 9002, + 'order_item_id' => null, + ], + ]); + + expect($preview->invoke($service, 9001))->toBe([ + [ + 'id' => 12, + 'product_id' => 101, + 'product_name' => 'Wash', + 'quantity' => 1, + 'price' => 100, + ], + [ + 'id' => 13, + 'product_id' => 102, + 'product_name' => 'Addon', + 'quantity' => 2, + 'price' => 25, + ], + ])->and($preview->invoke($service, 9002))->toBe([]); +}); + +it('sorts manual flags before automatic warnings and preserves legacy circle indicators without flags', function (): void { + $manual = [ + 'id' => 12, + 'source' => 'manual', + 'status' => 'active', + 'created_at' => '2026-05-11 10:00:00', + ]; + $automatic = [ + 'id' => 'auto:abc', + 'source' => 'automatic', + 'status' => 'active', + 'fingerprint' => 'abc', + ]; + $resolvedManual = [ + 'id' => 13, + 'source' => 'manual', + 'status' => 'resolved', + 'created_at' => '2026-05-11 11:00:00', + ]; + $falsePositiveAutomatic = [ + 'id' => 'auto:def', + 'source' => 'automatic', + 'status' => 'false_positive', + 'fingerprint' => 'def', + ]; + + $flags = [$automatic, $manual]; + usort($flags, static fn(array $a, array $b): int => invoice_period_flag_service_invoke('sortFlags', [$a, $b])); + + expect($flags[0]['source'])->toBe('manual'); + expect(invoice_period_flag_service_invoke('countFlags', [$flags]))->toBe([ + 'manual' => 1, + 'automatic' => 1, + 'total' => 2, + ]); + expect(invoice_period_flag_service_invoke('countFlags', [[$resolvedManual, $falsePositiveAutomatic]]))->toBe([ + 'manual' => 0, + 'automatic' => 0, + 'total' => 0, + ]); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], [$manual]])) + ->toBe('flag_red'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], [$automatic]])) + ->toBe('flag_yellow'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [ + ['requires_action' => true], + [$resolvedManual, $falsePositiveAutomatic], + ]))->toBe('circle_red'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], []])) + ->toBe('circle_red'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => false], []])) + ->toBe('circle_green'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [ + ['requires_action' => false, 'draft' => ['is_action_blocked' => true]], + [], + ]))->toBe('circle_yellow'); +}); + +it('scopes invoice period flags to the customer card that can render them', function (): void { + $customer = [ + 'customer_number' => 424242, + 'transactions' => [ + ['id' => 61311, 'invoice_collection_id' => 16912], + ], + ]; + + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'order_item_field', 'order_id' => 61311], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeTrue(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'order', 'order_id' => 99999], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeFalse(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'collected_order_invoice', 'target_id' => 16912], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeTrue(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'xlvask_usage_log', 'customer_number' => 424242], + [61311 => true], + [16912 => true], + 'vehicle_subscriptions', + ]))->toBeFalse(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'xlvask_usage_log', 'customer_number' => 424242], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeTrue(); +}); + +it('keeps order item preview context compact for the period response', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public string $lastQuery = ''; + + public function query(string $sql): object + { + $this->lastQuery = $sql; + return (object)['ok' => true]; + } + + public function fetch_all(object $result): array + { + return [[ + 'id' => '7001', + 'order_id' => '61311', + 'product_id' => '3', + 'reference' => 'REF', + 'notes' => str_repeat('x', 1024), + 'price' => '649', + 'quantity' => '1', + 'related_item_id' => '0', + 'product_name' => 'Forvogn', + 'product_base_price' => '649', + ]]; + } + }; + + try { + $rows = invoice_period_flag_service_invoke('getOrderItemsForPreview', [61311]); + + expect($rows)->toBe([[ + 'id' => 7001, + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'quantity' => 1, + 'price' => 649, + ]]); + expect($db->lastQuery)->not->toContain('oi.reference'); + expect($db->lastQuery)->not->toContain('oi.notes'); + expect($db->lastQuery)->not->toContain('p.price AS product_base_price'); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('formats stored manual flags with the creating superuser display name', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public function query(string $sql): object|false + { + if (str_contains($sql, 'SELECT display_name FROM users WHERE id = 42')) { + return new class { + public int $num_rows = 1; + + public function fetch_assoc(): array + { + return ['display_name' => 'Jeppe']; + } + }; + } + + return false; + } + }; + + try { + $flag = invoice_period_flag_service_invoke('formatStoredFlag', [[ + 'id' => 12, + 'source' => 'manual', + 'severity' => 'red', + 'status' => 'active', + 'target_type' => 'customer', + 'target_id' => 424242, + 'field' => null, + 'customer_number' => 424242, + 'order_id' => null, + 'order_item_id' => null, + 'invoice_collection_id' => null, + 'xlvask_usage_log_id' => null, + 'definition_key' => null, + 'fingerprint' => null, + 'reason' => 'Manual review', + 'status_reason' => null, + 'context_json' => null, + 'created_by' => 42, + 'status_changed_by' => null, + 'status_changed_at' => null, + 'created_at' => '2026-05-11 10:00:00', + 'updated_at' => '2026-05-11 10:00:00', + ]]); + + expect($flag['created_by'])->toBe(42); + expect($flag['created_by_name'])->toBe('Jeppe'); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('validates supported manual flag fields by target type', function (): void { + expect(invoice_period_flag_service_invoke('normalizeField', ['order_field', 'reference']))->toBe('reference'); + expect(invoice_period_flag_service_invoke('normalizeField', ['order_item_field', 'price']))->toBe('price'); + expect(invoice_period_flag_service_invoke('normalizeField', ['customer', '']))->toBeNull(); + + invoice_period_flag_service_invoke('normalizeField', ['order_field', 'price']); +})->throws(InvalidArgumentException::class, 'Invalid order flag field.'); + +it('wires invoice period flag routes with explicit list create and update permissions', function (): void { + $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain("\$this->get('/superuser/invoicing/period'"); + expect($content)->toContain("'list_invoice_period_flags' => 'List invoice period flags in the period response'"); + expect($content)->toContain("\$this->post('/superuser/invoicing/period/flags'"); + expect($content)->toContain("\$this->requirePermission('add_invoice_period_flag')"); + expect($content)->toContain("\$this->patch('/superuser/invoicing/period/flags/{id}/status'"); + expect($content)->toContain("\$this->post('/superuser/invoicing/period/flags/automatic/status'"); + expect($content)->toContain("\$this->requirePermission('update_invoice_period_flag_status')"); +}); + +it('uses the users display_name column in detector queries', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('u.display_name AS customer_name'); + expect($content)->toContain('COALESCE(u.display_name, x.Customer) AS customer_name'); + expect($content)->not->toContain('u.name'); +}); + +it('aggregates customer price overrides by customer number for price mismatch detection', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('MAX(po.percentage) AS percentage'); + expect($content)->toContain('GROUP BY discount_user.customer_number, po.product_or_category_id'); + expect($content)->toContain('product_discount.customer_number = o.customer_id'); + expect($content)->toContain('category_discount.customer_number = o.customer_id'); + expect($content)->not->toContain('po_product.user_id = u.id'); +}); + +it('guards optional customer vehicle deleted_at filtering behind a column check', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain("\$this->columnExists('customer_vehicles', 'deleted_at')"); + expect($content)->toContain('$deletedFilter'); + expect($content)->toContain('{$deletedFilter}'); +}); + +it('limits historical primary product lookup to current period registrations', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('getPrimaryProductHistory($dateFrom, array_column($primaryRows, \'reg_1\'))'); + expect($content)->toContain('private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array'); + expect($content)->toContain('AND o.reg_1 IN ({$registrationFilter})'); + expect($content)->not->toContain('$byReg = []'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php new file mode 100644 index 00000000..a1b6ea36 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php @@ -0,0 +1,58 @@ +not->toBeFalse(); + expect($content)->toContain('public function getNetAmountForOrders(array $order_ids): array'); + expect($content)->toContain('if (empty($order_ids)) {'); + expect($content)->toContain('return [];'); +}); + +it('guards against empty customer arrays in date-range transaction fetches', function (): void { + $ordersFile = app_path('objects/orders_o.php'); + $content = file_get_contents($ordersFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('public function getTransactionsForCustomersInDateRange(array $customers, string $dateFrom, string $dateTo): array'); + expect($content)->toContain('if (empty($customers)) {'); + expect($content)->toContain('return [];'); +}); + +it('uses centralized duplicate filtering for possible duplicate detection', function (): void { + $ordersFile = app_path('objects/orders_o.php'); + $content = file_get_contents($ordersFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('invoicing_period_utils::filterPossibleDuplicates($orders, 86400)'); +}); + +it('provides a batched plain-row transaction query for invoicing period responses', function (): void { + $ordersFile = app_path('objects/orders_o.php'); + $content = file_get_contents($ordersFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('public function getPeriodTransactionsForCustomersInDateRange(?array $customers, string $dateFrom, string $dateTo): array') + ->and($content)->toContain('COALESCE(SUM(CASE WHEN oi.include_in_invoice = 1 THEN oi.price * oi.quantity ELSE 0 END), 0) AS net_amount') + ->and($content)->toContain('COALESCE(coi.booked_invoice_id, 0)') + ->and($content)->toContain('COALESCE(emo.invoice_id, 0)') + ->and($content)->toContain('department_flags.exclude_from_invoicing') + ->and($content)->not->toContain('$order->select((int)$row[\'id\']);' . PHP_EOL . ' $transactions[$customerNumber][]'); +}); + +it('adds guarded composite indexes for invoicing period lookups', function (): void { + $schemaFile = app_path('classes/orders_schema_bootstrap.php'); + $content = file_get_contents($schemaFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain("self::ensureIndex(\$db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at')") + ->and($content)->toContain("self::ensureIndex(\$db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id')") + ->and($content)->toContain("self::ensureIndex(\$db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at')") + ->and($content)->toContain("self::ensureIndex(\$db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id')") + ->and($content)->toContain('private static function indexExists(object $db, string $table, string $index): bool'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodDraftOverlayTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodDraftOverlayTest.php new file mode 100644 index 00000000..5eae3b40 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodDraftOverlayTest.php @@ -0,0 +1,296 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs(null, $args); +} + +function invoicing_period_draft_overlay_draft( + int $invoice_collection_id, + int $customer_number, + bool $is_period_relevant = true +): array { + return [ + 'invoice_collection_id' => $invoice_collection_id, + 'customer_number' => $customer_number, + 'created_at' => '2026-04-01 00:00:01', + 'closed_at' => '2026-04-01 00:00:01', + 'is_period_relevant' => $is_period_relevant, + ]; +} + +function invoicing_period_draft_overlay_transaction( + int $id, + ?int $invoice_collection_id, + bool $booked = false, + bool $excluded = false, + ?string $queue_status = null +): array { + return [ + 'id' => $id, + 'booked' => $booked, + 'excluded' => $excluded, + 'invoice_collection_id' => $invoice_collection_id, + 'queue_status' => $queue_status, + 'queue_job_id' => $queue_status === null ? null : 99, + ]; +} + +function invoicing_period_draft_overlay_reset_deleted_at_column_cache(): void +{ + $reflection = new ReflectionClass(InvoicingPeriodRoute::class); + $property = $reflection->getProperty('collectedOrderInvoicesHasDeletedAtColumn'); + $property->setAccessible(true); + $property->setValue(null, null); +} + +class InvoicingPeriodDraftOverlayFakeDbResult +{ + public int $num_rows; + + public function __construct(private array $rows = []) + { + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } +} + +class InvoicingPeriodDraftOverlayFakeDb +{ + public string $selectSql = ''; + + public function __construct(private bool $hasDeletedAtColumn) + { + } + + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql): InvoicingPeriodDraftOverlayFakeDbResult|bool + { + if (str_starts_with($sql, 'SHOW COLUMNS')) { + return new InvoicingPeriodDraftOverlayFakeDbResult( + $this->hasDeletedAtColumn ? [['Field' => 'deleted_at']] : [] + ); + } + + $this->selectSql = $sql; + return false; + } +} + +it('blocks invoicing when all actionable transactions are backed by valid e-conomic drafts', function (): void { + $draft = invoicing_period_draft_overlay_draft(14578, 42424242); + + $customer = [ + 'customer_number' => 42424242, + 'customer_name' => 'Draft Customer', + 'requires_action' => true, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(501, 14578), + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [14578 => $draft], + [42424242 => [$draft]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['queue'])->toBe([ + 'has_active_job' => false, + 'statuses' => [], + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ]); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [14578], + 'is_action_blocked' => true, + ]); +}); + +it('keeps invoicing available when valid drafts only cover part of the actionable work', function (): void { + $draft = invoicing_period_draft_overlay_draft(2001, 43434343); + + $customer = [ + 'customer_number' => 43434343, + 'customer_name' => 'Mixed Draft Customer', + 'requires_action' => true, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(601, 2001), + invoicing_period_draft_overlay_transaction(602, null), + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [2001 => $draft], + [43434343 => [$draft]], + ]); + + expect($result['requires_action'])->toBeTrue(); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [2001], + 'is_action_blocked' => false, + ]); +}); + +it('excludes errored, booked, deleted, and missing-external-id invoice collections at query time', function (): void { + $hadDb = array_key_exists('db', $GLOBALS); + $originalDb = $GLOBALS['db'] ?? null; + $fakeDb = new InvoicingPeriodDraftOverlayFakeDb(true); + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + $GLOBALS['db'] = $fakeDb; + + try { + invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [ + [ + 'all' => [ + [ + 'customer_number' => 45454545, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(701, 3001), + ], + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 1200, + ], + ], + ], + ], + ], + '2026-04-01', + '2026-04-30', + ]); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $originalDb; + } else { + unset($GLOBALS['db']); + } + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + } + + expect($fakeDb->selectSql)->toContain('deleted_at IS NULL'); + expect($fakeDb->selectSql)->toContain('processor = 1'); + expect($fakeDb->selectSql)->toContain('external_id IS NOT NULL'); + expect($fakeDb->selectSql)->toContain("external_id <> ''"); + expect($fakeDb->selectSql)->toContain('booked_invoice_id IS NULL'); + expect($fakeDb->selectSql)->toContain('error_message IS NULL'); +}); + +it('still checks valid drafts when the collection table has no deleted marker column', function (): void { + $hadDb = array_key_exists('db', $GLOBALS); + $originalDb = $GLOBALS['db'] ?? null; + $fakeDb = new InvoicingPeriodDraftOverlayFakeDb(false); + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + $GLOBALS['db'] = $fakeDb; + + try { + invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [ + [ + 'all' => [ + [ + 'customer_number' => 12345679, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(61415, 17389), + ], + ], + ], + ], + '2026-05-11', + '2026-05-11', + ]); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $originalDb; + } else { + unset($GLOBALS['db']); + } + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + } + + expect($fakeDb->selectSql)->not->toContain('deleted_at IS NULL'); + expect($fakeDb->selectSql)->toContain('id IN (17389)'); + expect($fakeDb->selectSql)->toContain('processor = 1'); + expect($fakeDb->selectSql)->toContain('error_message IS NULL'); +}); + +it('blocks fixed-pricing and subscription customer-level work when a relevant valid draft exists', function (): void { + $draft = invoicing_period_draft_overlay_draft(3001, 45454545, true); + + $customer = [ + 'customer_number' => 45454545, + 'customer_name' => 'Subscription Draft Customer', + 'requires_action' => true, + 'transactions' => [], + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 1200, + ], + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [], + [45454545 => [$draft]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [3001], + 'is_action_blocked' => true, + ]); +}); + +it('keeps queue blocking ahead of the draft label when all work is covered by queue or draft state', function (): void { + $draft = invoicing_period_draft_overlay_draft(5002, 46464646, true); + + $customer = [ + 'customer_number' => 46464646, + 'customer_name' => 'Queue And Draft Customer', + 'requires_action' => true, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(801, 5001, false, false, 'QUEUED'), + invoicing_period_draft_overlay_transaction(802, 5002), + ], + 'queue' => [ + 'has_active_job' => true, + 'statuses' => ['QUEUED'], + 'invoice_collection_ids' => [5001], + 'is_action_blocked' => false, + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [5002 => $draft], + [46464646 => [$draft]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['queue']['is_action_blocked'])->toBeTrue(); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [5002], + 'is_action_blocked' => false, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php new file mode 100644 index 00000000..c9c4818e --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php @@ -0,0 +1,389 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs(null, $args); +} + +function invoicing_period_customer_card( + int $customerNumber, + string $customerName, + array $transactions, + bool $requiresAction = false, + array $extra = [] +): array { + return array_merge([ + 'id' => $customerNumber, + 'customer_number' => $customerNumber, + 'customer_name' => $customerName, + 'requires_action' => $requiresAction, + 'transactions' => $transactions, + 'meta' => [], + 'queue' => [ + 'has_active_job' => false, + 'statuses' => [], + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ], + 'draft' => [ + 'has_valid_draft' => false, + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ], + ], $extra); +} + +function invoicing_period_transaction(array $overrides = []): array +{ + return array_merge([ + 'id' => 9001, + 'date' => '2026-04-10 12:00:00', + 'amount' => 125.5, + 'booked' => false, + 'department_id' => 1, + 'customer_number' => 1001, + 'reference' => 'REF-9001', + 'po' => 'PO-9001', + 'notes' => 'Gate note', + 'reg_1' => 'AB12345', + 'reg_2' => '', + 'reg_3' => '', + 'excluded' => false, + 'invoice_collection_id' => 3001, + 'queue_status' => null, + 'queue_job_id' => null, + ], $overrides); +} + +it('detects paginated period mode only when pagination parameters are present', function (): void { + global $response; + + $previousResponse = $GLOBALS['response'] ?? null; + $previousGet = $_GET; + $previousMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $response = new response(); + + try { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET = [ + 'dateFrom' => '2026-04-01', + 'dateTo' => '2026-04-30', + ]; + + expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toBeNull(); + + $_GET['page'] = '2'; + expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([ + 'periodView' => 'all', + 'page' => 2, + 'limit' => 100, + 'search' => '', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]); + } finally { + $_GET = $previousGet; + if ($previousMethod === null) { + unset($_SERVER['REQUEST_METHOD']); + } else { + $_SERVER['REQUEST_METHOD'] = $previousMethod; + } + if ($previousResponse === null) { + unset($GLOBALS['response']); + } else { + $response = $previousResponse; + } + } +}); + +it('normalizes period pagination options and clamps invalid page and limit values', function (): void { + $options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'periodView' => 'not-a-view', + 'page' => '-4', + 'limit' => '500', + 'search' => ' Nordic ', + 'includeRequiresAction' => '0', + 'includeBooked' => 'false', + ]]); + + expect($options)->toBe([ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 500, + 'search' => 'Nordic', + 'flagTab' => 'all', + 'includeRequiresAction' => false, + 'includeBooked' => false, + ]); + + expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'limit' => '900', + ]]))->toMatchArray([ + 'limit' => 500, + ]); + + expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'limit' => 'all', + ]]))->toMatchArray([ + 'limit' => 'all', + ]); + + expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'periodView' => 'invoice_per_order', + 'page' => '3', + 'limit' => '0', + ]]))->toMatchArray([ + 'periodView' => 'invoice_per_order', + 'page' => 3, + 'limit' => 100, + ]); +}); + +it('slices only the active period view and keeps exact full-result type counts', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(1001, 'Alpha Transport', [ + invoicing_period_transaction(['id' => 11, 'customer_number' => 1001, 'amount' => 50]), + ], false), + invoicing_period_customer_card(1002, 'Beta Transport', [ + invoicing_period_transaction([ + 'id' => 12, + 'customer_number' => 1002, + 'amount' => 75, + 'booked' => true, + 'excluded' => true, + 'reference' => 'REF-BETA', + 'po' => 'PO-BETA', + 'reg_1' => 'BB22222', + ]), + ], true), + invoicing_period_customer_card(1003, 'Gamma Transport', [ + invoicing_period_transaction([ + 'id' => 13, + 'customer_number' => 1003, + 'amount' => 125, + 'booked' => true, + ]), + ], false, [ + 'draft' => [ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [3013], + 'is_action_blocked' => true, + ], + ]), + ], + 'fixed_pricing' => [ + invoicing_period_customer_card(1002, 'Beta Transport', [], true, [ + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 999, + ], + ], + ]), + ], + 'invoice_per_order' => [ + invoicing_period_customer_card(1001, 'Alpha Transport', [], true, [ + 'flags' => [ + ['source' => 'manual', 'status' => 'active'], + ], + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 2, + 'limit' => 1, + 'search' => '', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['pagination'])->toMatchArray([ + 'page' => 2, + 'per_page' => 1, + 'total' => 3, + 'search' => '', + ]); + + expect($result['period']['types']['all'])->toHaveCount(1); + expect($result['period']['types']['all'][0])->toMatchArray([ + 'customer_number' => 1002, + 'customer_name' => 'Beta Transport', + 'requires_action' => true, + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 999, + ], + ], + ]); + expect($result['period']['types']['all'][0]['transactions'][0])->toMatchArray([ + 'id' => 12, + 'amount' => 75, + 'booked' => true, + 'excluded' => true, + 'reference' => 'REF-BETA', + 'po' => 'PO-BETA', + 'reg_1' => 'BB22222', + ]); + expect($result['period']['types']['fixed_pricing'])->toBe([]); + expect($result['period']['type_counts']['all'])->toBe([ + 'requires_action' => 1, + 'draft' => 1, + 'manual_flags' => 0, + 'automatic_flags' => 0, + 'completed' => 1, + 'total' => 3, + ]); + expect($result['period']['type_totals']['all'])->toBe([ + 'total' => 1174.0, + 'booked' => 125.0, + 'not_booked' => 1049.0, + ]); + expect($result['period']['type_totals']['fixed_pricing'])->toBe([ + 'total' => 999.0, + 'booked' => 0.0, + 'not_booked' => 999.0, + ]); + expect($result['period']['type_counts']['invoice_per_order']['manual_flags'])->toBe(1); +}); + +it('returns the entire active period view when the limit is all', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(1101, 'Alpha', [ + invoicing_period_transaction(['id' => 31, 'customer_number' => 1101]), + ]), + invoicing_period_customer_card(1102, 'Beta', [ + invoicing_period_transaction(['id' => 32, 'customer_number' => 1102]), + ]), + invoicing_period_customer_card(1103, 'Gamma', [ + invoicing_period_transaction(['id' => 33, 'customer_number' => 1103]), + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 3, + 'limit' => 'all', + 'search' => '', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['pagination'])->toMatchArray([ + 'page' => 1, + 'per_page' => 'all', + 'total' => 3, + ]); + expect(array_column($result['period']['types']['all'], 'customer_number'))->toBe([1101, 1102, 1103]); +}); + +it('searches customer fields and order fields at the customer-card level', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(2001, 'Solaris Fleet', [ + invoicing_period_transaction([ + 'id' => 21, + 'customer_number' => 2001, + 'reference' => 'REF-KEEP', + 'po' => 'PO-KEEP', + ]), + ]), + invoicing_period_customer_card(2002, 'Nordic Logistics', [ + invoicing_period_transaction([ + 'id' => 22, + 'customer_number' => 2002, + 'reference' => 'MISS', + 'po' => 'PO-777', + 'notes' => 'Driver waits at gate', + 'reg_1' => 'CD33333', + ]), + invoicing_period_transaction([ + 'id' => 23, + 'customer_number' => 2002, + 'reference' => 'SECOND-LINE', + ]), + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => 'po-777', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['pagination']['total'])->toBe(1); + expect($result['period']['types']['all'])->toHaveCount(1); + expect($result['period']['types']['all'][0]['customer_number'])->toBe(2002); + expect($result['period']['types']['all'][0]['transactions'])->toHaveCount(2); + + $registrationMatch = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => 'cd33333', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + expect($registrationMatch['period']['types']['all'][0]['customer_name'])->toBe('Nordic Logistics'); +}); + +it('applies requires-action and booked visibility filters before counting and slicing', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(3001, 'Needs Action', [ + invoicing_period_transaction(['customer_number' => 3001, 'booked' => false]), + ], true), + invoicing_period_customer_card(3002, 'Already Booked', [ + invoicing_period_transaction(['customer_number' => 3002, 'booked' => true]), + ], false), + invoicing_period_customer_card(3003, 'Still Open', [ + invoicing_period_transaction(['customer_number' => 3003, 'booked' => false]), + ], false), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => '', + 'includeRequiresAction' => false, + 'includeBooked' => false, + ]]); + + expect($result['pagination']['total'])->toBe(1); + expect($result['period']['type_counts']['all']['total'])->toBe(1); + expect($result['period']['types']['all'][0]['customer_number'])->toBe(3003); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php new file mode 100644 index 00000000..2db9e7bf --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php @@ -0,0 +1,160 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs(null, $args); +} + +function invoicing_period_queue_overlay_job( + int $queue_job_id, + string $queue_status, + int $invoice_collection_id, + int $customer_number, + bool $is_period_relevant = true +): array { + return [ + 'queue_job_id' => $queue_job_id, + 'queue_status' => $queue_status, + 'invoice_collection_id' => $invoice_collection_id, + 'customer_number' => $customer_number, + 'created_at' => '2026-04-01 00:00:01', + 'closed_at' => '2026-04-01 00:00:01', + 'is_period_relevant' => $is_period_relevant, + ]; +} + +it('marks queued transactions and clears requires_action when all actionable work is already queued', function (): void { + $queueJob = invoicing_period_queue_overlay_job(93, 'QUEUED', 14578, 42424242); + + $customer = [ + 'customer_number' => 42424242, + 'customer_name' => 'Queue Customer', + 'requires_action' => true, + 'transactions' => [ + [ + 'id' => 501, + 'booked' => false, + 'excluded' => false, + 'invoice_collection_id' => 14578, + 'queue_status' => null, + 'queue_job_id' => null, + ], + ], + ]; + + $result = invoicing_period_queue_overlay_invoke('applyCollectedInvoiceQueueOverlayToCustomer', [ + $customer, + [14578 => $queueJob], + [42424242 => [$queueJob]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['transactions'][0]['queue_status'])->toBe('QUEUED'); + expect($result['transactions'][0]['queue_job_id'])->toBe(93); + expect($result['queue'])->toBe([ + 'has_active_job' => true, + 'statuses' => ['QUEUED'], + 'invoice_collection_ids' => [14578], + 'is_action_blocked' => true, + ]); +}); + +it('keeps requires_action true when a customer still has unqueued actionable transactions', function (): void { + $queueJob = invoicing_period_queue_overlay_job(94, 'PROCESSING', 2001, 43434343); + + $customer = [ + 'customer_number' => 43434343, + 'customer_name' => 'Mixed Customer', + 'requires_action' => true, + 'transactions' => [ + [ + 'id' => 601, + 'booked' => false, + 'excluded' => false, + 'invoice_collection_id' => 2001, + 'queue_status' => null, + 'queue_job_id' => null, + ], + [ + 'id' => 602, + 'booked' => false, + 'excluded' => false, + 'invoice_collection_id' => null, + 'queue_status' => null, + 'queue_job_id' => null, + ], + ], + ]; + + $result = invoicing_period_queue_overlay_invoke('applyCollectedInvoiceQueueOverlayToCustomer', [ + $customer, + [2001 => $queueJob], + [43434343 => [$queueJob]], + ]); + + expect($result['requires_action'])->toBeTrue(); + expect($result['transactions'][0]['queue_status'])->toBe('PROCESSING'); + expect($result['transactions'][1]['queue_status'])->toBeNull(); + expect($result['queue'])->toBe([ + 'has_active_job' => true, + 'statuses' => ['PROCESSING'], + 'invoice_collection_ids' => [2001], + 'is_action_blocked' => false, + ]); +}); + +it('blocks fixed-pricing or subscription customers with no transactions when a relevant queue job exists', function (): void { + $queueJob = invoicing_period_queue_overlay_job(95, 'QUEUED', 3001, 45454545, true); + + $customer = [ + 'customer_number' => 45454545, + 'customer_name' => 'Subscription Customer', + 'requires_action' => true, + 'transactions' => [], + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 1200, + ], + ], + ]; + + $result = invoicing_period_queue_overlay_invoke('applyCollectedInvoiceQueueOverlayToCustomer', [ + $customer, + [], + [45454545 => [$queueJob]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['queue'])->toBe([ + 'has_active_job' => true, + 'statuses' => ['QUEUED'], + 'invoice_collection_ids' => [3001], + 'is_action_blocked' => true, + ]); +}); + +it('normalizes targeted customer number filters from comma-separated or repeated values', function (): void { + expect(invoicing_period_queue_overlay_invoke('normalizeCustomerNumbers', [ + '42424242, 43434343,42424242,0,not-a-number', + ]))->toBe([42424242, 43434343]); + + expect(invoicing_period_queue_overlay_invoke('normalizeCustomerNumbers', [ + ['45454545', 45454545, '46464646'], + ]))->toBe([45454545, 46464646]); +}); + +it('filters period customer number candidates to targeted customers only', function (): void { + $result = invoicing_period_queue_overlay_invoke('filterCustomerNumbers', [ + [42424242, '43434343', 45454545], + [43434343, 99999999], + ]); + + expect($result)->toBe([43434343]); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteCacheHelpersTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteCacheHelpersTest.php new file mode 100644 index 00000000..7eb50a71 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteCacheHelpersTest.php @@ -0,0 +1,84 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs($route, $args); +} + +beforeEach(function (): void { + $this->oldTtl = getenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL'); +}); + +afterEach(function (): void { + if ($this->oldTtl === false) { + putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL'); + return; + } + putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL=' . $this->oldTtl); +}); + +it('uses sane ttl defaults and clamps negative ttl to zero', function (): void { + $_SERVER['REQUEST_URI'] = '/superuser/invoicing/period/distribution/v2/all'; + $route = new InvoicingPeriodRoute(); + + putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL'); + expect(invoicing_period_route_invoke_private($route, 'getDistributionV2CacheTtl'))->toBe(300); + + putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL=120'); + expect(invoicing_period_route_invoke_private($route, 'getDistributionV2CacheTtl'))->toBe(120); + + putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL=-10'); + expect(invoicing_period_route_invoke_private($route, 'getDistributionV2CacheTtl'))->toBe(0); +}); + +it('builds deterministic cache keys per scope and date range', function (): void { + $_SERVER['REQUEST_URI'] = '/superuser/invoicing/period/distribution/v2/all'; + $route = new InvoicingPeriodRoute(); + + $keyA = invoicing_period_route_invoke_private($route, 'getDistributionV2CacheKey', [ + 'all', + '2026-01-01 00:00:00', + '2026-01-31 23:59:59', + ]); + $keyB = invoicing_period_route_invoke_private($route, 'getDistributionV2CacheKey', [ + 'all', + '2026-01-01 00:00:00', + '2026-01-31 23:59:59', + ]); + $keyC = invoicing_period_route_invoke_private($route, 'getDistributionV2CacheKey', [ + 'wash-subscriptions', + '2026-01-01 00:00:00', + '2026-01-31 23:59:59', + ]); + + expect($keyA)->toBe($keyB); + expect($keyA)->not->toBe($keyC); + expect($keyA)->toStartWith('invoicing_period:distribution:v2:all:'); +}); + +it('falls back to resolver directly when cache ttl is disabled', function (): void { + $_SERVER['REQUEST_URI'] = '/superuser/invoicing/period/distribution/v2/all'; + $route = new InvoicingPeriodRoute(); + putenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL=0'); + + $calls = 0; + $result = invoicing_period_route_invoke_private($route, 'withCachedDistributionV2', [ + 'all', + '2026-01-01 00:00:00', + '2026-01-31 23:59:59', + function () use (&$calls): array { + $calls++; + return ['ok' => true]; + }, + ]); + + expect($result)->toBe(['ok' => true]); + expect($calls)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php new file mode 100644 index 00000000..62becc2a --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php @@ -0,0 +1,120 @@ +not->toBeFalse(); + expect($content)->toMatch( + "/\\/superuser\\/invoicing\\/period\\/distribution\\/all'.*?\\\$this->requirePermission\\('superuser_invoicing_period'\\);/s" + ); +}); + +it('uses shared date-range normalization across invoicing period endpoints', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect(substr_count((string)$content, 'requireAndNormalizeDateRange()'))->toBeGreaterThanOrEqual(5); +}); + +it('keeps the main period response local-only for booked state and customer names', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->not->toContain('isBooked(true)') + ->and($content)->toContain('isTransactionBookedFromLocalState($transaction)') + ->and($content)->toContain('SELECT booked_invoice_id FROM collected_order_invoices') + ->and($content)->toContain('SELECT invoice_id FROM economic_module_orders') + ->and($content)->toContain('getCustomerNames(array_keys($customer_numbers), false)') + ->and($content)->toContain('getCustomerNames(array_map(\'intval\', $customer_numbers), false)'); +}); + +it('streams the main period response instead of encoding the full payload at once', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('private static function streamInvoicingPeriodResponse(array $period): void') + ->and($content)->toContain('$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers);') + ->and($content)->toContain('self::streamInvoicingPeriodResponse($period);') + ->and($content)->not->toContain('$response->success([' . PHP_EOL . ' ...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)') + ->and($content)->toContain('echo self::jsonFragment($customer);'); +}); + +it('maps batched period transaction rows to the legacy transaction response shape', function (): void { + $reflection = new ReflectionClass(InvoicingPeriodRoute::class); + $method = $reflection->getMethod('constructTransactionObjectFromPeriodRow'); + $method->setAccessible(true); + + $transaction = $method->invokeArgs(null, [[ + 'id' => '42', + 'created_at' => '2026-04-10 12:34:56', + 'net_amount' => '123.50', + 'booked' => '1', + 'department_id' => '7', + 'customer_id' => '27983', + 'order_reference' => 'REF-42', + 'order_po' => 'PO-42', + 'order_notes' => 'Driver note', + 'reg_1' => 'AB12345', + 'reg_2' => 'CD67890', + 'reg_3' => '', + 'invoice_collection_id' => '314', + 'include_in_invoice_effective' => '0', + ]]); + + expect($transaction)->toMatchArray([ + 'id' => 42, + 'date' => '2026-04-10 12:34:56', + 'amount' => 123.5, + 'booked' => true, + 'department_id' => 7, + 'customer_number' => 27983, + 'reference' => 'REF-42', + 'po' => 'PO-42', + 'notes' => 'Driver note', + 'reg_1' => 'AB12345', + 'reg_2' => 'CD67890', + 'reg_3' => '', + 'excluded' => true, + 'invoice_collection_id' => 314, + 'queue_status' => null, + 'queue_job_id' => null, + ]); +}); + +it('uses batched period transactions and keyed customer maps in the main period route', function (): void { + $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('getPeriodTransactionsForCustomersInDateRange(') + ->and($content)->toContain('private static function indexCustomersByNumber(array $customers): array') + ->and($content)->toContain('$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);') + ->and($content)->toContain('invoicing_period_utils::filterPossibleDuplicates($ordersByRegistration, 86400)') + ->and($content)->not->toContain('getOrdersWithPossibleDuplicates($dateFrom, $dateTo)'); +}); + +it('falls back to configured e-conomic default department for missing customer default department in distributions', function (): void { + $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content) + ->toContain('private static function getEconomicFallbackDepartmentId(): int') + ->and($content)->toContain('(new economic())->getDefaultDistributionDepartmentId()') + ->and($content)->toContain(': self::getEconomicFallbackDepartmentId();') + ->and($content)->toContain("\$department_totals[\$fallback_department_id] = (float)\$customer['meta']['fixed_pricing']['price'];"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodUtilsTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodUtilsTest.php new file mode 100644 index 00000000..17be1bc6 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodUtilsTest.php @@ -0,0 +1,42 @@ +toBe([ + 'dateFrom' => '2026-03-01 00:00:00', + 'dateTo' => '2026-03-31 23:59:59', + ]); +}); + +it('rejects invalid date formats', function (): void { + invoicing_period_utils::normalizeDateRange('2026/03/01', '2026-03-31'); +})->throws(InvalidArgumentException::class, 'Invalid date format. Expected: Y-m-d Got: 2026/03/01'); + +it('rejects descending date ranges', function (): void { + invoicing_period_utils::normalizeDateRange('2026-04-01', '2026-03-31'); +})->throws(InvalidArgumentException::class, 'Invalid date range. dateFrom must be before or equal to dateTo'); + +it('finds duplicate orders regardless of original input order', function (): void { + $input = [ + 'ABC12345' => [ + ['id' => 3, 'created_at' => '2026-03-03 02:00:00'], + ['id' => 1, 'created_at' => '2026-03-01 12:00:00'], + ['id' => 2, 'created_at' => '2026-03-02 07:00:00'], + ], + 'NON_DUP' => [ + ['id' => 10, 'created_at' => '2026-03-01 00:00:00'], + ['id' => 11, 'created_at' => '2026-03-03 00:00:01'], + ], + ]; + + $duplicates = invoicing_period_utils::filterPossibleDuplicates($input, 86400); + + expect(array_keys($duplicates))->toBe(['ABC12345']); + expect(array_column($duplicates['ABC12345'], 'id'))->toBe([1, 2, 3]); +}); + diff --git a/services/nginx/app/tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php b/services/nginx/app/tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php new file mode 100644 index 00000000..c451b703 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php @@ -0,0 +1,30 @@ +not->toBeFalse(); + expect($content)->toContain("\$this->put('/collected-invoices'"); + expect($content)->toContain("self::requireParameters(['id']);"); + expect($content)->toContain("\$is_superuser = self::hasPermission('superuser');"); + expect($content)->toContain("if (!self::isParametersSet(['po_number']) && !self::isParametersSet(['closed_at'])) {"); + expect($content)->toContain("\$response->error('Missing required parameters: po_number, closed_at', 400);"); + expect($content)->toContain("if (self::isParametersSet(['closed_at']) && !\$is_superuser) {"); + expect($content)->toContain("\$response->error('Forbidden: only superusers can update closed_at', 403);"); + expect($content)->toContain("if ((int)\$invoice->customer_number->value() !== (int)\$user->customer_number->value() && !\$is_superuser) {"); +}); + +it('supports independent po_number and closed_at updates for PUT /collected-invoices in user route', function (): void { + $routeFile = app_path('routes/userInvoicesRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("if (self::isParametersSet(['po_number'])) {"); + expect($content)->toContain("\$invoice->po_number->set((string)self::getParameter('po_number'));"); + + expect($content)->toContain("if (self::isParametersSet(['closed_at'])) {"); + expect($content)->toContain("if (\$closed_at !== null && \$closed_at !== '') {"); + expect($content)->toContain("self::requireDateFormat((string)\$closed_at, self::FORMAT_DATE());"); + expect($content)->toContain("\$invoice->closed_at->set(\$closed_at === null || \$closed_at === '' ? null : date('Y-m-d 23:59:59', strtotime((string)\$closed_at . ' 00:00:01')));"); +}); diff --git a/services/nginx/app/tests/Unit/MotorApi/MotorApiCachedResultTest.php b/services/nginx/app/tests/Unit/MotorApi/MotorApiCachedResultTest.php new file mode 100644 index 00000000..4742168d --- /dev/null +++ b/services/nginx/app/tests/Unit/MotorApi/MotorApiCachedResultTest.php @@ -0,0 +1,22 @@ +meta[$key] = $value; + } + }; + + motorapi::addCachedMetaIfPossible($response); + + expect($response->meta)->toBe(['cached' => true]); +}); diff --git a/services/nginx/app/tests/Unit/N8n/N8nRouteHelpersTest.php b/services/nginx/app/tests/Unit/N8n/N8nRouteHelpersTest.php new file mode 100644 index 00000000..fe90ea66 --- /dev/null +++ b/services/nginx/app/tests/Unit/N8n/N8nRouteHelpersTest.php @@ -0,0 +1,46 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +it('normalizes webhook methods and falls back to POST for unsupported verbs', function (): void { + $_SERVER['REQUEST_URI'] = '/modules/n8n/webhooks/trigger'; + $route = new moduleN8nRoute(); + + expect(n8n_route_invoke_private($route, 'normalizeHttpMethod', ['patch']))->toBe('PATCH'); + expect(n8n_route_invoke_private($route, 'normalizeHttpMethod', [' delete ']))->toBe('DELETE'); + expect(n8n_route_invoke_private($route, 'normalizeHttpMethod', ['trace']))->toBe('POST'); +}); + +it('filters request parameters down to the allowed n8n query keys', function (): void { + $_SERVER['REQUEST_URI'] = '/modules/n8n/workflows'; + $route = new moduleN8nRoute(); + + $filtered = n8n_route_invoke_private($route, 'filterRequestParameters', [[ + 'active' => 'true', + 'limit' => '25', + 'cursor' => '', + 'projectId' => 'abc123', + 'ignored' => 'value', + ], [ + 'active', + 'limit', + 'projectId', + ]]); + + expect($filtered)->toBe([ + 'active' => 'true', + 'limit' => '25', + 'projectId' => 'abc123', + ]); +}); diff --git a/services/nginx/app/tests/Unit/N8n/N8nRouteWiringTest.php b/services/nginx/app/tests/Unit/N8n/N8nRouteWiringTest.php new file mode 100644 index 00000000..48ba50b9 --- /dev/null +++ b/services/nginx/app/tests/Unit/N8n/N8nRouteWiringTest.php @@ -0,0 +1,27 @@ +not->toBeFalse(); + expect($content)->toContain('/modules/n8n/workflows'); + expect($content)->toContain('/modules/n8n/workflows/{id}'); + expect($content)->toContain('/modules/n8n/workflows/{id}/publish'); + expect($content)->toContain('/modules/n8n/workflows/{id}/deactivate'); + expect($content)->toContain("requirePermission('modules_n8n_workflows_view')"); + expect($content)->toContain("requirePermission('modules_n8n_workflows_manage')"); +}); + +it('registers execution and webhook trigger endpoints for the n8n module', function (): void { + $routeFile = app_path('routes/moduleN8nRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/modules/n8n/webhooks/trigger'); + expect($content)->toContain('/modules/n8n/executions'); + expect($content)->toContain('/modules/n8n/executions/{id}/retry'); + expect($content)->toContain('/modules/n8n/executions/{id}/stop'); + expect($content)->toContain("requirePermission('modules_n8n_workflows_run')"); + expect($content)->toContain("requirePermission('modules_n8n_executions_view')"); +}); diff --git a/services/nginx/app/tests/Unit/Orders/AttachmentsListManyTest.php b/services/nginx/app/tests/Unit/Orders/AttachmentsListManyTest.php new file mode 100644 index 00000000..dc77a625 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/AttachmentsListManyTest.php @@ -0,0 +1,75 @@ +> */ + public array $fixtureRows = []; + + public function __construct() + { + // Intentionally skip module config bootstrap in unit tests. + } + + protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array + { + $wanted = array_flip(array_map('intval', $object_ids)); + return array_values(array_filter( + $this->fixtureRows, + static fn(array $row): bool => isset($wanted[(int)($row['object_id'] ?? 0)]) + )); + } +} + +/** + * @param array $attachments + * @return array> + */ +function attachments_to_shape(array $attachments): array +{ + return array_map(static function (object $attachment): array { + return [ + 'id' => $attachment->id, + 'object_id' => $attachment->object_id, + 'document' => $attachment->content->document, + 'other' => $attachment->content->other, + ]; + }, $attachments); +} + +it('groups attachments by object id and preserves empty object groups', function (): void { + $attachments = new AttachmentsListManyTestDouble(); + $attachments->fixtureRows = [ + ['id' => 1, 'object_type' => 'orders', 'object_id' => 10, 'content' => '{"document":"a.pdf","other":"x"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'], + ['id' => 2, 'object_type' => 'orders', 'object_id' => 10, 'content' => '{"document":"b.pdf","other":"y"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'], + ['id' => 3, 'object_type' => 'orders', 'object_id' => 12, 'content' => '{"document":"c.pdf","other":"z"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'], + ]; + + $grouped = $attachments->listMany('orders', [10, 11, 12]); + + expect(array_keys($grouped))->toBe([10, 11, 12]); + expect(attachments_to_shape($grouped[10]))->toBe([ + ['id' => 1, 'object_id' => 10, 'document' => 'a.pdf', 'other' => 'x'], + ['id' => 2, 'object_id' => 10, 'document' => 'b.pdf', 'other' => 'y'], + ]); + expect($grouped[11])->toBe([]); + expect(attachments_to_shape($grouped[12]))->toBe([ + ['id' => 3, 'object_id' => 12, 'document' => 'c.pdf', 'other' => 'z'], + ]); +}); + +it('keeps listMany payload parity with list for one object id', function (): void { + $attachments = new AttachmentsListManyTestDouble(); + $attachments->fixtureRows = [ + ['id' => 21, 'object_type' => 'orders', 'object_id' => 100, 'content' => '{"document":"d.pdf","other":"foo"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'], + ['id' => 22, 'object_type' => 'orders', 'object_id' => 100, 'content' => '{"document":"e.pdf","other":"bar"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'], + ]; + + $single = $attachments->list('orders', 100); + $many = $attachments->listMany('orders', [100]); + + expect(attachments_to_shape($single))->toBe(attachments_to_shape($many[100])); +}); diff --git a/services/nginx/app/tests/Unit/Orders/EconomicModuleOrdersBatchHelpersTest.php b/services/nginx/app/tests/Unit/Orders/EconomicModuleOrdersBatchHelpersTest.php new file mode 100644 index 00000000..db9b9a4d --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/EconomicModuleOrdersBatchHelpersTest.php @@ -0,0 +1,60 @@ +> */ + public array $rows = []; + + protected function performEnsureRowsInsert(array $orderIds): void + { + $this->insertedOrderIds = $orderIds; + } + + protected function fetchRowsByOrderIds(array $orderIds): array + { + $this->fetchedOrderIds = $orderIds; + return $this->rows; + } +} + +it('ensures economic module rows in one sanitized batch', function (): void { + $object = new EconomicModuleOrdersBatchHelpersTestDouble(); + $object->ensureRowsForOrderIds([5, 0, -5, 3, 5, 2]); + + expect($object->insertedOrderIds)->toBe([2, 3, 5]); +}); + +it('returns id keyed economic module payload with null defaults', function (): void { + $object = new EconomicModuleOrdersBatchHelpersTestDouble(); + $object->rows = [ + ['id' => 3, 'invoice_draft_id' => 17, 'invoice_id' => null], + ['id' => 5, 'invoice_draft_id' => null, 'invoice_id' => 912], + ]; + + $result = $object->getByOrderIdsAsArray([5, 3, 7, 0, 5]); + + expect($object->fetchedOrderIds)->toBe([3, 5, 7]); + expect($result[3])->toBe([ + 'id' => 3, + 'invoice_draft_id' => 17, + 'invoice_id' => null, + ]); + expect($result[5])->toBe([ + 'id' => 5, + 'invoice_draft_id' => null, + 'invoice_id' => 912, + ]); + expect($result[7])->toBe([ + 'id' => 7, + 'invoice_draft_id' => null, + 'invoice_id' => null, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php b/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php new file mode 100644 index 00000000..d62df2b2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php @@ -0,0 +1,170 @@ +id = -1; + $this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false); + $this->customer_id = new object_property('orders', -1, 'customer_id', 'int', true); + $this->department_id = new object_property('orders', -1, 'department_id', 'int', true); + $this->safety_seal = new object_property('orders', -1, 'safety_seal', 'string', false); + $this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false); + $this->customer_id->set(111111); + $this->department_id->set(10); + } + + public bool $washCertificateAttached = false; + public bool $containsWashCertificate = false; + + public function objectChanged(): void + { + } + + public function containsWashCertificateItem(): bool + { + return $this->containsWashCertificate; + } + + public function hasWashCertificateAttached(): bool + { + return $this->washCertificateAttached; + } + } +} + +if (!class_exists('OrderBookingsCompletionDouble')) { + class OrderBookingsCompletionDouble extends order_bookings_o + { + public bool $containsWashCertificate = false; + public bool $orderWasCreated = false; + public bool $orderItemsWereCreated = false; + public int $attachCalls = 0; + public int $sendCalls = 0; + public orders_o $linkedOrder; + + public function __construct(orders_o $linkedOrder) + { + $this->id = -1; + $this->linkedOrder = $linkedOrder; + $this->customer_number = new object_property('order_bookings', -1, 'customer_number', 'int', true); + $this->department = new object_property('order_bookings', -1, 'department', 'int', true); + $this->order_id = new object_property('order_bookings', -1, 'order_id', 'int', false); + $this->items = new object_property('order_bookings', -1, 'items', 'json', false); + $this->customer_number->set(111111); + $this->department->set(10); + $this->items->set([]); + } + + public function createOrderBy(int $user_id): void + { + $this->orderWasCreated = true; + $this->order_id->set(999); + } + + public function createOrderItemsBy(int $user_id): void + { + $this->orderItemsWereCreated = true; + } + + public function containsWashCertificateItem(): bool + { + return $this->containsWashCertificate; + } + + public function getOrder(): orders_o + { + return $this->linkedOrder; + } + + protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void + { + $this->attachCalls++; + $this->linkedOrder->washCertificateAttached = true; + } + + public function sendWashCertificateToCustomer(): void + { + $this->sendCalls++; + } + } +} + +it('attaches and emails a wash certificate when a booking is already linked to a matching pos order without one', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $booking = new OrderBookingsCompletionDouble($order); + $booking->order_id->set(321); + $booking->containsWashCertificate = true; + + $booking->completeBooking(77, 'LINKED-SEAL'); + + expect($order->getSafetySealValue())->toBe('LINKED-SEAL'); + expect($booking->attachCalls)->toBe(1); + expect($booking->sendCalls)->toBe(1); +}); + +it('rejects wash certificate completion when the linked pos order belongs to another booking context', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $order->customer_id->set(222222); + $order->department_id->set(99); + + $booking = new OrderBookingsCompletionDouble($order); + $booking->order_id->set(321); + $booking->containsWashCertificate = true; + + expect(fn() => $booking->completeBooking(77, 'LINKED-SEAL')) + ->toThrow(Exception::class, 'Linked order does not match booking customer or department'); + expect($order->getSafetySealValue())->toBeNull(); + expect($booking->attachCalls)->toBe(0); + expect($booking->sendCalls)->toBe(0); +}); + +it('uses the linked pos order wash certificate item added during mobile completion', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $order->containsWashCertificate = true; + $booking = new OrderBookingsCompletionDouble($order); + $booking->order_id->set(321); + $booking->containsWashCertificate = false; + + $booking->completeBooking(77, 'MOBILE-SEAL'); + + expect($order->getSafetySealValue())->toBe('MOBILE-SEAL'); + expect($booking->attachCalls)->toBe(1); + expect($booking->sendCalls)->toBe(1); +}); + +it('does not create or email a duplicate wash certificate when a linked pos order already has one', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $order->washCertificateAttached = true; + $booking = new OrderBookingsCompletionDouble($order); + $booking->order_id->set(321); + $booking->containsWashCertificate = true; + + $booking->completeBooking(77, 'LINKED-SEAL'); + + expect($order->getSafetySealValue())->toBe('LINKED-SEAL'); + expect($booking->attachCalls)->toBe(0); + expect($booking->sendCalls)->toBe(0); +}); + +it('keeps standalone booking completion behavior unchanged for wash certificates', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $booking = new OrderBookingsCompletionDouble($order); + $booking->containsWashCertificate = true; + + $booking->completeBooking(77, null); + + expect($booking->orderWasCreated)->toBeTrue(); + expect($booking->orderItemsWereCreated)->toBeTrue(); + expect($booking->attachCalls)->toBe(1); + expect($booking->sendCalls)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersAutoWashCertificateCompletionTest.php b/services/nginx/app/tests/Unit/Orders/OrdersAutoWashCertificateCompletionTest.php new file mode 100644 index 00000000..52f21fcf --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersAutoWashCertificateCompletionTest.php @@ -0,0 +1,143 @@ +send_count++; + } + } +} + +if (!class_exists('OrdersAutoWashCertificateCompletionDouble')) { + class OrdersAutoWashCertificateCompletionDouble extends orders_o + { + public bool $containsWashCertificate = false; + public bool $washCertificateAttached = false; + /** @var array */ + public array $generatedCertificates = []; + public ?order_bookings_o $linkedBooking = null; + + public function __construct() + { + $this->id = -1; + $this->customer_id = new object_property('orders', -1, 'customer_id', 'int', true); + $this->cashier_id = new object_property('orders', -1, 'cashier_id', 'int', true); + $this->reference = new object_property('orders', -1, 'reference', 'string', false); + $this->notes = new object_property('orders', -1, 'notes', 'string', false); + $this->department_id = new object_property('orders', -1, 'department_id', 'int', true); + $this->reg_1 = new object_property('orders', -1, 'reg_1', 'string', false); + $this->reg_2 = new object_property('orders', -1, 'reg_2', 'string', false); + $this->reg_3 = new object_property('orders', -1, 'reg_3', 'string', false); + $this->created_at = new object_property('orders', -1, 'created_at', 'timestamp', false); + $this->include_in_invoice = new object_property('orders', -1, 'include_in_invoice', 'bool', false); + $this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false); + $this->deleted_at = new object_property('orders', -1, 'deleted_at', 'timestamp', false); + $this->invoice_collection_id = new object_property('orders', -1, 'invoice_collection_id', 'int', false); + $this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false); + $this->wash_id = new object_property('orders', -1, 'wash_id', 'string', false); + $this->lane = new object_property('orders', -1, 'lane', 'string', false); + $this->po = new object_property('orders', -1, 'po', 'string', false); + $this->safety_seal = new object_property('orders', -1, 'safety_seal', 'string', false); + $this->using_hand_held = new object_property('orders', -1, 'using_hand_held', 'bool', false); + } + + public function objectChanged(): void + { + } + + protected function resolveDepartmentIncludedInInvoicing(): bool + { + return true; + } + + public function isPendingHandheld(): bool + { + return false; + } + + public function containsWashCertificateItem(): bool + { + return $this->containsWashCertificate; + } + + public function hasWashCertificateAttached(): bool + { + return $this->washCertificateAttached; + } + + public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null, $date = null): void + { + if ($this->washCertificateAttached) { + return; + } + + $this->generatedCertificates[] = [ + 'seal_number' => self::normalizeSafetySealValue($safety_seal), + 'operator' => $operator, + 'date' => $date, + ]; + $this->washCertificateAttached = true; + } + + public function getOrderBooking(): order_bookings_o|null + { + return $this->linkedBooking; + } + } +} + +it('auto-attaches a wash certificate on order completion when a wash certificate item is present', function (): void { + $booking = new OrdersAutoWashCertificateLinkedBookingDouble(); + $order = new OrdersAutoWashCertificateCompletionDouble(); + $order->containsWashCertificate = true; + $order->booking_id->set(123); + $order->linkedBooking = $booking; + $order->safety_seal->set('SEAL-41'); + + $order->markAsCompleted('Operator One'); + + expect($order->completed_at->value())->not->toBeNull(); + expect($order->generatedCertificates)->toHaveCount(1); + expect($order->generatedCertificates[0])->toMatchArray([ + 'seal_number' => 'SEAL-41', + 'operator' => 'Operator One', + ]); + expect($booking->send_count)->toBe(1); +}); + +it('keeps order completion idempotent when a wash certificate is already attached', function (): void { + $booking = new OrdersAutoWashCertificateLinkedBookingDouble(); + $order = new OrdersAutoWashCertificateCompletionDouble(); + $order->containsWashCertificate = true; + $order->washCertificateAttached = true; + $order->booking_id->set(456); + $order->linkedBooking = $booking; + + $order->markAsCompleted('Operator Two'); + + expect($order->generatedCertificates)->toBe([]); + expect($booking->send_count)->toBe(0); +}); + +it('allows blank safety seal values when auto-attaching a wash certificate on completion', function (): void { + $order = new OrdersAutoWashCertificateCompletionDouble(); + $order->containsWashCertificate = true; + $order->safety_seal->set(null); + + $order->markAsCompleted('Operator Three'); + + expect($order->generatedCertificates)->toHaveCount(1); + expect($order->generatedCertificates[0]['seal_number'])->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php b/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php new file mode 100644 index 00000000..2b20e41e --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php @@ -0,0 +1,96 @@ +id = -1; + $this->customer_id = new object_property('orders', -1, 'customer_id', 'int', true); + $this->cashier_id = new object_property('orders', -1, 'cashier_id', 'int', true); + $this->reference = new object_property('orders', -1, 'reference', 'string', false); + $this->notes = new object_property('orders', -1, 'notes', 'string', false); + $this->department_id = new object_property('orders', -1, 'department_id', 'int', true); + $this->reg_1 = new object_property('orders', -1, 'reg_1', 'string', false); + $this->reg_2 = new object_property('orders', -1, 'reg_2', 'string', false); + $this->reg_3 = new object_property('orders', -1, 'reg_3', 'string', false); + $this->created_at = new object_property('orders', -1, 'created_at', 'timestamp', false); + $this->include_in_invoice = new object_property('orders', -1, 'include_in_invoice', 'bool', false); + $this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false); + $this->deleted_at = new object_property('orders', -1, 'deleted_at', 'timestamp', false); + $this->invoice_collection_id = new object_property('orders', -1, 'invoice_collection_id', 'int', false); + $this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false); + $this->wash_id = new object_property('orders', -1, 'wash_id', 'string', false); + $this->lane = new object_property('orders', -1, 'lane', 'string', false); + $this->po = new object_property('orders', -1, 'po', 'string', false); + $this->safety_seal = new object_property('orders', -1, 'safety_seal', 'string', false); + $this->using_hand_held = new object_property('orders', -1, 'using_hand_held', 'bool', false); + $this->temporary_net_amount = 125.5; + + $this->customer_id->set(1234567); + $this->cashier_id->set(42); + $this->department_id->set(12); + $this->created_at->set('2026-04-09 12:34:56'); + $this->reference->set('Ref'); + } + + protected function resolveDepartmentIncludedInInvoicing(): bool + { + return $this->departmentIncluded; + } + + public function isPendingHandheld(): bool + { + return false; + } + } +} + +it('lets the order-level override include an otherwise excluded order', function (): void { + $order = new OrdersIncludeInInvoiceOverrideOrderDouble(); + $order->departmentIncluded = false; + $order->include_in_invoice->set(true); + + expect($order->getIncludeInInvoiceOverride())->toBeTrue(); + expect($order->isIncludedInInvoicing())->toBeTrue(); +}); + +it('lets the order-level override exclude an otherwise included order', function (): void { + $order = new OrdersIncludeInInvoiceOverrideOrderDouble(); + $order->departmentIncluded = true; + $order->include_in_invoice->set(false); + + expect($order->getIncludeInInvoiceOverride())->toBeFalse(); + expect($order->isIncludedInInvoicing())->toBeFalse(); +}); + +it('falls back to the department invoicing rule when the override is null', function (): void { + $order = new OrdersIncludeInInvoiceOverrideOrderDouble(); + $order->departmentIncluded = false; + $order->include_in_invoice->set(null); + + expect($order->getIncludeInInvoiceOverride())->toBeNull(); + expect($order->isIncludedInInvoicing())->toBeFalse(); +}); + +it('serializes both raw and effective include_in_invoice values', function (): void { + $order = new OrdersIncludeInInvoiceOverrideOrderDouble(); + $order->departmentIncluded = true; + $order->include_in_invoice->set(null); + + expect($order->asArray(true, false))->toMatchArray([ + 'include_in_invoice' => null, + 'include_in_invoice_effective' => true, + 'safety_seal' => null, + 'created_at' => '2026-04-09 12:34:56', + 'total_net_amount' => 125.5, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersInputNormalizerTest.php b/services/nginx/app/tests/Unit/Orders/OrdersInputNormalizerTest.php new file mode 100644 index 00000000..4b420e95 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersInputNormalizerTest.php @@ -0,0 +1,28 @@ +toBe('2026-04-09 12:34:56'); + expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09T12:34'))->toBe('2026-04-09 12:34:00'); + expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09T12:34:56'))->toBe('2026-04-09 12:34:56'); +}); + +it('rejects invalid created_at values', function (): void { + orders_input_normalizer::normalizeCreatedAt('2026/04/09 12:34'); +})->throws(InvalidArgumentException::class, 'created_at must be a valid datetime'); + +it('normalizes include_in_invoice tri-state inputs', function (): void { + expect(orders_input_normalizer::normalizeIncludeInInvoice(null))->toBeNull(); + expect(orders_input_normalizer::normalizeIncludeInInvoice('use_department'))->toBeNull(); + expect(orders_input_normalizer::normalizeIncludeInInvoice('include'))->toBeTrue(); + expect(orders_input_normalizer::normalizeIncludeInInvoice('exclude'))->toBeFalse(); + expect(orders_input_normalizer::normalizeIncludeInInvoice(true))->toBeTrue(); + expect(orders_input_normalizer::normalizeIncludeInInvoice(false))->toBeFalse(); +}); + +it('rejects invalid include_in_invoice values', function (): void { + orders_input_normalizer::normalizeIncludeInInvoice('maybe'); +})->throws(InvalidArgumentException::class, 'include_in_invoice must be use_department, include, or exclude'); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersRegistrationDateRangeQueryTest.php b/services/nginx/app/tests/Unit/Orders/OrdersRegistrationDateRangeQueryTest.php new file mode 100644 index 00000000..3a2ff650 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersRegistrationDateRangeQueryTest.php @@ -0,0 +1,128 @@ +> */ + private array $rows; + + /** + * @param array> $rows + */ + public function __construct(array $rows) + { + $this->rows = array_values($rows); + $this->num_rows = count($this->rows); + } + + /** + * @return array|null + */ + public function fetch_assoc(): ?array + { + if ($this->rows === []) { + return null; + } + + return array_shift($this->rows); + } +} + +final class OrdersRegistrationDateRangeDbStub +{ + public string $lastQuery = ''; + public int $queryCalls = 0; + + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql): OrdersRegistrationDateRangeDbResultStub + { + $this->queryCalls++; + $this->lastQuery = $sql; + return new OrdersRegistrationDateRangeDbResultStub([]); + } +} + +it('applies created_at bounds directly in SQL when filtering orders by registration number', function (): void { + $dbStub = new OrdersRegistrationDateRangeDbStub(); + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $hadDb ? $GLOBALS['db'] : null; + $GLOBALS['db'] = $dbStub; + + try { + $orders = (new orders_o())->getOrdersWithRegistrationNumberInDateRange( + ' ec21235 ', + '2025-03-01 00:00:00', + '2025-04-30 23:59:59' + ); + + expect($orders)->toBe([]); + expect($dbStub->queryCalls)->toBeGreaterThan(0); + expect($dbStub->lastQuery)->toContain("UPPER(TRIM(reg_1)) = 'EC21235'"); + expect($dbStub->lastQuery)->toContain("UPPER(TRIM(reg_2)) = 'EC21235'"); + expect($dbStub->lastQuery)->toContain("UPPER(TRIM(reg_3)) = 'EC21235'"); + expect($dbStub->lastQuery)->toContain("created_at BETWEEN '2025-03-01 00:00:00' AND '2025-04-30 23:59:59'"); + expect($dbStub->lastQuery)->toContain('AND deleted_at IS NULL'); + expect($dbStub->lastQuery)->not->toContain('SELECT id, created_at'); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('rejects an inverted date range for registration lookups', function (): void { + $dbStub = new OrdersRegistrationDateRangeDbStub(); + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $hadDb ? $GLOBALS['db'] : null; + $GLOBALS['db'] = $dbStub; + + try { + (new orders_o())->getOrdersWithRegistrationNumberInDateRange( + 'EC21235', + '2025-04-30 23:59:59', + '2025-03-01 00:00:00' + ); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +})->throws(\Exception::class, 'The start date cannot be after the end date'); + +it('returns early when registration number is blank', function (): void { + $dbStub = new OrdersRegistrationDateRangeDbStub(); + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $hadDb ? $GLOBALS['db'] : null; + $GLOBALS['db'] = $dbStub; + + try { + $orders = (new orders_o())->getOrdersWithRegistrationNumberInDateRange( + ' ', + '2025-03-01 00:00:00', + '2025-04-30 23:59:59' + ); + + expect($orders)->toBe([]); + expect($dbStub->queryCalls)->toBeGreaterThanOrEqual(0); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersRouteListBatchingWiringTest.php b/services/nginx/app/tests/Unit/Orders/OrdersRouteListBatchingWiringTest.php new file mode 100644 index 00000000..98051591 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersRouteListBatchingWiringTest.php @@ -0,0 +1,26 @@ +not->toBeFalse(); + + $start = strpos($content, "\$this->get('/orders'"); + $end = strpos($content, "\$this->post('/orders'"); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + + $ordersGetSection = substr($content, (int)$start, (int)$end - (int)$start); + + expect($ordersGetSection)->toContain('$rawOrders = $orders->listObjectsWithPaginationIfSet('); + expect($ordersGetSection)->toContain('$this->enrichOrderListRows($rawOrders)'); + expect($ordersGetSection)->not->toContain('$order_obj->select((int)$order[\'id\'])'); + expect($ordersGetSection)->not->toContain('$collected_order_invoices_obj->select((int)$order[\'invoice_collection_id\'])'); + + expect($content)->toContain('getNetAmountForOrders($orderIds)'); + expect($content)->toContain('ensureRowsForOrderIds($orderIds)'); + expect($content)->toContain("listMany('orders', \$orderIds)"); + expect($content)->toContain('orders_stripe_invoice_snapshot_'); + expect($content)->toContain('redis->expire($cacheKey, 30);'); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersRouteSettingsUpdateWiringTest.php b/services/nginx/app/tests/Unit/Orders/OrdersRouteSettingsUpdateWiringTest.php new file mode 100644 index 00000000..974df68e --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersRouteSettingsUpdateWiringTest.php @@ -0,0 +1,24 @@ +not->toBeFalse(); + expect($content)->toContain('use classes\orders_input_normalizer;'); + expect($content)->toContain("orders_input_normalizer::normalizeCreatedAt(\$data['created_at'] ?? date('Y-m-d H:i:s'))"); + expect($content)->toContain("array_key_exists('include_in_invoice', \$data)"); + expect($content)->toContain("orders_input_normalizer::normalizeIncludeInInvoice(\$data['include_in_invoice'])"); + expect($content)->toContain("\$order->include_in_invoice->set("); + expect($content)->toContain("\$response->error(\$e->getMessage(), 400);"); +}); + +it('keeps line-item invoice filtering in the order net amount calculation', function (): void { + $objectFile = app_path('objects/orders_o.php'); + $content = file_get_contents($objectFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("'include_in_invoice' => \$this->getIncludeInInvoiceOverride()"); + expect($content)->toContain("'include_in_invoice_effective' => \$this->isIncludedInInvoicing()"); + expect($content)->toContain("if (!\$item['include_in_invoice'])"); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php b/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php new file mode 100644 index 00000000..12282476 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php @@ -0,0 +1,27 @@ +not->toBeFalse(); + + $start = strpos($content, "\$this->post('/orders/module/stripe/payment_intent'"); + $end = strpos($content, "\$this->post('/orders/module/stripe/debug/simulate_payment'"); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + + $stripeSection = substr($content, (int)$start, (int)$end - (int)$start); + + expect($stripeSection)->toContain('buildStripePaymentIntentResponse('); + expect($stripeSection)->toContain('isStripePaymentIntentReusable($storedPaymentIntent)'); + expect($stripeSection)->toContain("'reused' => true"); + expect($stripeSection)->toContain("'message' => 'No active payment intent for this order.'"); + expect($stripeSection)->toContain("'message' => 'Payment intent cleared successfully.'"); + expect($stripeSection)->toContain("Stored payment intent is stale. Start the payment again."); + expect($stripeSection)->toContain("Payment intent has already been captured."); + expect($stripeSection)->toContain("Payment intent was cancelled. Start the payment again."); + expect($stripeSection)->toContain("Payment intent is not ready to capture."); + expect($stripeSection)->toContain("paidWithStripe(\$paymentIntent->id);"); + expect($stripeSection)->not->toContain("Order does not have a payment intent"); +}); diff --git a/services/nginx/app/tests/Unit/Orders/StripePaymentIntentsPersistenceWiringTest.php b/services/nginx/app/tests/Unit/Orders/StripePaymentIntentsPersistenceWiringTest.php new file mode 100644 index 00000000..9968b4a4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/StripePaymentIntentsPersistenceWiringTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($content)->toContain("new object_property(\$this->table, \$this->id, 'reader_id', 'string', false);"); + expect($content)->toContain('clearOrderPaymentIntents($order_id);'); + expect($content)->toContain('public function getOrderPaymentIntentRows(int $order_id): array'); + expect($content)->toContain('usort($rows'); + expect($content)->toContain('public function clearOrderPaymentIntents(int $order_id, ?int $keepId = null): void'); + expect($content)->toContain("self::delete_object(\$this->getTable(), \$rowId);"); + expect($content)->toContain('public function updateStoredPaymentIntent(mixed $paymentIntent): void'); + expect($content)->toContain('public function setReaderId(?string $readerId): void'); + expect($content)->toContain('sendCancelPaymentIntent($readerId);'); + expect($content)->toContain('payment_intents->cancel($paymentIntentId);'); + expect($content)->toContain('deleteDuplicateOrderPaymentIntents(int $order_id, int $keepId): void'); +}); diff --git a/services/nginx/app/tests/Unit/Orders/UsersCashierNamesBatchWiringTest.php b/services/nginx/app/tests/Unit/Orders/UsersCashierNamesBatchWiringTest.php new file mode 100644 index 00000000..42e10b9b --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/UsersCashierNamesBatchWiringTest.php @@ -0,0 +1,21 @@ +not->toBeFalse(); + expect($content)->toContain('public function getCashierNames(array $cashier_ids): array'); + expect($content)->toContain("getCachedForMultipleObjects(\$cache_key, \$virtual_cache_ids)"); + expect($content)->toContain("getFieldsWhereIn("); + expect($content)->toContain("'Unknown Cashier'"); + expect($content)->toContain("setCachedExpiration(\$cache_key, self::\$cashierNameCacheExpiration"); +}); + +it('sanitizes and deduplicates cashier ids before batch lookup', function (): void { + $usersFile = app_path('objects/users_o.php'); + $content = file_get_contents($usersFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("array_values(array_unique(array_filter(array_map('intval', \$cashier_ids)"); +}); diff --git a/services/nginx/app/tests/Unit/Permissions/AllowOwnOrDepartmentAccessForbiddenTest.php b/services/nginx/app/tests/Unit/Permissions/AllowOwnOrDepartmentAccessForbiddenTest.php new file mode 100644 index 00000000..cc4ffc14 --- /dev/null +++ b/services/nginx/app/tests/Unit/Permissions/AllowOwnOrDepartmentAccessForbiddenTest.php @@ -0,0 +1,92 @@ + */ + public array $permissionsByKey = []; + public bool $ownContext = true; + /** @var array|null */ + public ?array $lastForbidden = null; + public bool $departmentAccessChecked = false; + + public function __construct() + { + // no-op for unit tests + } + + public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool + { + $key = is_string($permission) ? $permission : (string)$permission->permission; + return (bool)($this->permissionsByKey[$key] ?? false); + } + + public function isOwnCustomerContext(int $targetCustomerNumber): bool + { + return $this->ownContext; + } + + public function requireDepartmentAccess(string $department, string|null $permission = null): void + { + $this->departmentAccessChecked = true; + } + + protected function emitForbidden(array $permissions): void + { + $normalized = []; + foreach ($permissions as $permission) { + $key = is_string($permission) ? trim($permission) : trim((string)$permission->permission); + if ($key !== '') { + $normalized[] = $key; + } + } + $this->lastForbidden = array_values(array_unique($normalized)); + throw new RuntimeException('forbidden'); + } +} + +it('returns both own and elevated permissions when both are missing', function (): void { + $host = new AllowOwnOrDepartmentAccessForbiddenHost(); + $host->permissionsByKey = [ + 'perm_own' => false, + 'perm_other' => false, + ]; + + expect(fn() => $host->allowOwnOrDepartmentAccess('perm_own', 'perm_other', 101, null)) + ->toThrow(RuntimeException::class, 'forbidden'); + + expect($host->lastForbidden)->toBe(['perm_own', 'perm_other']); +}); + +it('returns only elevated permission when own permission exists but own context fails', function (): void { + $host = new AllowOwnOrDepartmentAccessForbiddenHost(); + $host->permissionsByKey = [ + 'perm_own' => true, + 'perm_other' => false, + ]; + $host->ownContext = false; + + expect(fn() => $host->allowOwnOrDepartmentAccess('perm_own', 'perm_other', 202, null)) + ->toThrow(RuntimeException::class, 'forbidden'); + + expect($host->lastForbidden)->toBe(['perm_other']); +}); + +it('allows elevated permission path without forbidden and validates department access', function (): void { + $host = new AllowOwnOrDepartmentAccessForbiddenHost(); + $host->permissionsByKey = [ + 'perm_own' => false, + 'perm_other' => true, + ]; + + $allowed = $host->allowOwnOrDepartmentAccess('perm_own', 'perm_other', 303, 77); + + expect($allowed)->toBeTrue(); + expect($host->departmentAccessChecked)->toBeTrue(); + expect($host->lastForbidden)->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Permissions/ForbiddenResponseWiringTest.php b/services/nginx/app/tests/Unit/Permissions/ForbiddenResponseWiringTest.php new file mode 100644 index 00000000..8448f4cb --- /dev/null +++ b/services/nginx/app/tests/Unit/Permissions/ForbiddenResponseWiringTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($routeTrait)->toContain('protected function emitForbidden(array $permissions): void'); + expect($routeTrait)->toContain('$response->forbidden($this->normalizePermissionKeys($permissions));'); + expect($routeTrait)->toContain('$this->emitForbidden($missingPermissions);'); + + $selfserveRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + expect($selfserveRoute)->not->toBeFalse(); + expect($selfserveRoute)->toContain("forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions'])"); + expect($selfserveRoute)->toContain('forbidDepartmentAccess('); + + $ordersRoute = file_get_contents(app_path('routes/ordersRoute.php')); + expect($ordersRoute)->not->toBeFalse(); + expect($ordersRoute)->toContain('$response->forbidden([$permission_other->permission]);'); + expect($ordersRoute)->not->toContain("\$response->error('You do not have permission to edit this order', 403);"); +}); diff --git a/services/nginx/app/tests/Unit/Permissions/GroupPermissionSessionInvalidationWiringTest.php b/services/nginx/app/tests/Unit/Permissions/GroupPermissionSessionInvalidationWiringTest.php new file mode 100644 index 00000000..2e7e4f8f --- /dev/null +++ b/services/nginx/app/tests/Unit/Permissions/GroupPermissionSessionInvalidationWiringTest.php @@ -0,0 +1,13 @@ +not->toBeFalse(); + expect($content)->toContain('private function invalidateGroupSessionCaches(int $group_id): void'); + expect($content)->toContain('$this->invalidateGroupSessionCaches($group_id);'); + expect(substr_count((string)$content, '$this->invalidateGroupSessionCaches($group_id);'))->toBeGreaterThanOrEqual(2); + expect($content)->toContain("redis->clear_keys('perm:user:' . \$userId . ':*');"); + expect($content)->toContain('redis->clear_auth_session($token);'); +}); + diff --git a/services/nginx/app/tests/Unit/Redis/RedisAtomicReservationTest.php b/services/nginx/app/tests/Unit/Redis/RedisAtomicReservationTest.php new file mode 100644 index 00000000..dc493977 --- /dev/null +++ b/services/nginx/app/tests/Unit/Redis/RedisAtomicReservationTest.php @@ -0,0 +1,102 @@ +calls[] = ['mget', $keys]; + return $this->returnValue; + } + + public function set(...$arguments): mixed + { + $this->calls[] = $arguments; + return $this->returnValue; + } + + public function disconnect(): void + { + } +} + +function redis_test_inject_client(redis $redis, PredisClient $client): void +{ + $reflection = new ReflectionClass($redis); + $property = $reflection->getProperty('redis'); + $property->setAccessible(true); + $property->setValue($redis, $client); +} + +it('claims a slot atomically with nx and expiration', function (): void { + global $REDIS_CONFIG; + + $REDIS_CONFIG = [ + 'host' => 'redis', + 'database' => 0, + 'password' => '', + ]; + + $client = new RedisAtomicReservationTestClient('OK'); + + $redis = new redis(); + redis_test_inject_client($redis, $client); + + expect($redis->set_if_absent_with_expiration('goal_alert_sent:22:2026-03-17', '1', 86400))->toBeTrue(); + expect($client->calls)->toBe([ + ['goal_alert_sent:22:2026-03-17', '1', 'EX', 86400, 'NX'], + ]); +}); + +it('returns false when the slot is already claimed and clamps ttl to one second', function (): void { + global $REDIS_CONFIG; + + $REDIS_CONFIG = [ + 'host' => 'redis', + 'database' => 0, + 'password' => '', + ]; + + $client = new RedisAtomicReservationTestClient(null); + + $redis = new redis(); + redis_test_inject_client($redis, $client); + + expect($redis->set_if_absent_with_expiration('goal_alert_sent:22:2026-03-17', '1', 0))->toBeFalse(); + expect($client->calls)->toBe([ + ['goal_alert_sent:22:2026-03-17', '1', 'EX', 1, 'NX'], + ]); +}); + +it('does not send empty mget commands to redis', function (): void { + global $REDIS_CONFIG; + + $REDIS_CONFIG = [ + 'host' => 'redis', + 'database' => 0, + 'password' => '', + ]; + + $client = new RedisAtomicReservationTestClient(['cached-value']); + + $redis = new redis(); + redis_test_inject_client($redis, $client); + + expect($redis->mget([]))->toBe([]); + expect($client->calls)->toBe([]); + + expect($redis->mget(['cache-key']))->toBe(['cached-value']); + expect($client->calls)->toBe([ + ['mget', ['cache-key']], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Release/ReleaseManagerStatusOverviewTest.php b/services/nginx/app/tests/Unit/Release/ReleaseManagerStatusOverviewTest.php new file mode 100644 index 00000000..f0e13523 --- /dev/null +++ b/services/nginx/app/tests/Unit/Release/ReleaseManagerStatusOverviewTest.php @@ -0,0 +1,680 @@ +setAccessible(true); + + return $method->invoke($manager, array_replace([ + 'generated_at' => '2026-05-20T10:00:00+00:00', + 'channels' => [], + 'deployment_targets' => [], + 'service_sets' => [], + 'deployments' => [], + ], $summary)); +} + +function releaseStatusChannelBySlug(array $overview, string $slug): array +{ + foreach ($overview['channels'] as $channel) { + if (($channel['channel_slug'] ?? '') === $slug) { + return $channel; + } + } + + throw new RuntimeException('Release status channel not found: ' . $slug); +} + +function releaseStatusServiceByKey(array $channel, string $key): array +{ + foreach ($channel['services'] as $service) { + if (($service['service_key'] ?? '') === $key) { + return $service; + } + } + + throw new RuntimeException('Release status service not found: ' . $key); +} + +function releaseStatusReadyService(string $kind, int $id): array +{ + return [ + 'id' => $id, + 'resource_uuid' => $kind . '-resource', + 'resource_name' => ucfirst($kind), + 'deployment_status' => 'deployed', + 'availability_state' => 'failover_ready', + 'replication' => [ + 'status' => 'ok', + 'last_status' => [ + 'status' => 'ok', + 'blockers' => [], + ], + ], + ]; +} + +function releaseStatusReadyVersions(int $bundleId = 7): array +{ + return [ + 'bundle_id' => $bundleId, + 'bundle_label' => '#' . $bundleId, + 'frontend' => [ + 'id' => 101, + 'version_label' => 'frontend-2026-05-20', + 'status' => 'active', + ], + 'api' => [ + 'id' => 102, + 'version_label' => 'api-2026-05-20', + 'status' => 'active', + ], + ]; +} + +it('marks a channel ready when all release services are healthy', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 1, + 'slug' => 'stable', + 'name' => 'Stable', + 'default_channel' => true, + 'enabled' => true, + 'frontend_base_url' => 'https://app.example.test', + 'api_base_url' => 'https://api.example.test', + 'versions' => releaseStatusReadyVersions(), + ], + ], + 'deployment_targets' => [ + ['id' => 11, 'channel_id' => 1, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue'], + ['id' => 12, 'channel_id' => 1, 'app' => 'api', 'repository' => 'truckwash/backend-php'], + ], + 'service_sets' => [ + [ + 'id' => 21, + 'channel_id' => 1, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => releaseStatusReadyService('database', 31), + 'redis' => releaseStatusReadyService('redis', 32), + 'minio' => releaseStatusReadyService('minio', 33), + ], + ], + ], + 'deployments' => [ + ['id' => 41, 'channel_id' => 1, 'app' => 'frontend', 'status' => 'deployed'], + ['id' => 42, 'channel_id' => 1, 'app' => 'api', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'stable'); + + expect($overview['state'])->toBe('ready') + ->and($overview['totals']['critical'])->toBe(0) + ->and($overview['totals']['warning'])->toBe(0) + ->and($overview['totals']['services'])->toBe(5) + ->and($channel['readiness'])->toBe('ready') + ->and($channel['services'])->toHaveCount(5); +}); + +it('does not block channels on unhealthy data targets when data services are production shared', function (): void { + $database = releaseStatusReadyService('database', 71); + $database['deployment_status'] = 'reconcile_failed'; + $database['availability_state'] = 'degraded'; + + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://beta.example.test', + 'api_base_url' => 'https://api-beta.example.test', + 'versions' => releaseStatusReadyVersions(8), + ], + ], + 'deployment_targets' => [ + ['id' => 51, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'], + ['id' => 52, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'], + ], + 'service_sets' => [ + [ + 'id' => 53, + 'channel_id' => 2, + 'mode' => 'attach_existing', + 'stack' => [ + 'database' => $database, + 'redis' => releaseStatusReadyService('redis', 75), + 'minio' => releaseStatusReadyService('minio', 76), + ], + ], + ], + 'deployments' => [ + ['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'], + ['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + + expect($channel['readiness'])->toBe('ready') + ->and($channel['issues'])->toBe([]) + ->and(releaseStatusServiceByKey($channel, 'database'))->toMatchArray([ + 'status' => 'production_shared', + 'state' => 'ready', + 'severity' => 'ok', + 'issue_type' => null, + ]); +}); + +it('marks beta ready from the production frontend and API services', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 1, + 'slug' => 'stable', + 'name' => 'Stable', + 'default_channel' => true, + 'enabled' => true, + 'frontend_base_url' => 'https://app.example.test', + 'api_base_url' => 'https://api.example.test', + 'versions' => releaseStatusReadyVersions(), + ], + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'versions' => [], + 'availability' => [ + 'configured' => false, + 'status' => 'unconfigured', + 'missing' => ['frontend_version', 'frontend_base_url', 'api_version', 'api_base_url'], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 11, 'channel_id' => 1, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-prod'], + ['id' => 12, 'channel_id' => 1, 'app' => 'api', 'coolify_service_uuid' => 'api-prod'], + ], + 'deployments' => [ + ['id' => 41, 'channel_id' => 1, 'app' => 'frontend', 'status' => 'deployed'], + ['id' => 42, 'channel_id' => 1, 'app' => 'api', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + + expect($channel['readiness'])->toBe('ready') + ->and($channel['service_policy'])->toBe('production_shared') + ->and($channel['service_channel_slug'])->toBe('stable') + ->and($channel['missing_values'])->toBe([]) + ->and($channel['availability'])->toMatchArray([ + 'configured' => true, + 'missing' => [], + 'frontend_base_url' => 'https://app.example.test', + 'api_base_url' => 'https://api.example.test', + ]) + ->and(releaseStatusServiceByKey($channel, 'frontend'))->toMatchArray([ + 'status' => 'production_shared', + 'service_policy' => 'production_shared', + 'service_channel_slug' => 'stable', + ]) + ->and(releaseStatusServiceByKey($channel, 'api'))->toMatchArray([ + 'status' => 'production_shared', + 'service_policy' => 'production_shared', + 'service_channel_slug' => 'stable', + ]); +}); + +it('ignores missing legacy bundles but still blocks on missing versions and URLs', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'versions' => [], + 'availability' => [ + 'configured' => false, + 'status' => 'unconfigured', + 'missing' => [ + 'release_bundle', + 'frontend_version', + 'frontend_base_url', + 'api_version', + 'api_base_url', + ], + ], + ], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + $missingLabels = array_column($channel['missing_values'], 'label'); + $missingKeys = array_column($channel['missing_values'], 'key'); + + expect($overview['state'])->toBe('blocked') + ->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(4) + ->and($channel['readiness'])->toBe('blocked') + ->and($missingKeys)->not->toContain('release_bundle') + ->and($missingLabels)->not->toContain('release bundle') + ->and($missingLabels)->toContain('frontend version') + ->and($missingLabels)->toContain('frontend URL') + ->and($missingLabels)->toContain('API version') + ->and($missingLabels)->toContain('API URL') + ->and($overview['issues'][0])->toMatchArray([ + 'severity' => 'critical', + 'type' => 'missing_value', + 'channel_slug' => 'beta', + ]); +}); + +it('marks a branch-based channel ready without a release bundle when app versions and URLs exist', function (): void { + $versions = releaseStatusReadyVersions(0); + unset($versions['bundle_id'], $versions['bundle_label']); + + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://api-v2.truckwash.io/beta/frontend', + 'api_base_url' => 'https://api-v2.truckwash.io/beta/api', + 'versions' => $versions, + ], + ], + 'deployment_targets' => [ + ['id' => 21, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'], + ['id' => 22, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'], + ], + 'deployments' => [ + ['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'], + ['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + + expect($overview['state'])->toBe('ready') + ->and($channel['readiness'])->toBe('ready') + ->and($channel['availability'])->toMatchArray([ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]) + ->and($channel['issues'])->toBe([]) + ->and($channel['missing_values'])->toBe([]); +}); + +it('does not report frontend or API URLs missing when target auto endpoints are resolvable', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'versions' => releaseStatusReadyVersions(), + ], + ], + 'deployment_targets' => [ + [ + 'id' => 21, + 'channel_id' => 2, + 'channel_slug' => 'beta', + 'app' => 'frontend', + 'repository' => 'truckwash/front-end-vue', + 'branch' => 'master', + 'coolify_service_uuid' => 'frontend-beta-service', + 'deploy_context' => [ + 'endpoint_mode' => 'auto', + 'public_gateway_host' => 'gateway.example.test', + ], + ], + [ + 'id' => 22, + 'channel_id' => 2, + 'channel_slug' => 'beta', + 'app' => 'api', + 'repository' => 'truckwash/backend-php', + 'branch' => 'master', + 'coolify_service_uuid' => 'api-beta-service', + 'deploy_context' => [ + 'endpoint_mode' => 'auto', + 'public_gateway_host' => 'gateway.example.test', + ], + ], + ], + 'service_sets' => [ + [ + 'id' => 31, + 'channel_id' => 2, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => releaseStatusReadyService('database', 41), + 'redis' => releaseStatusReadyService('redis', 42), + 'minio' => releaseStatusReadyService('minio', 43), + ], + ], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + $missingKeys = array_column($channel['missing_values'], 'key'); + + expect($missingKeys)->not->toContain('frontend_base_url') + ->and($missingKeys)->not->toContain('api_base_url') + ->and($channel['availability'])->toMatchArray([ + 'configured' => true, + 'frontend_base_url' => 'https://gateway.example.test/beta/frontend', + 'api_base_url' => 'https://gateway.example.test/beta/api', + ]) + ->and($channel['readiness'])->toBe('ready'); +}); + +it('does not surface legacy release bundle actions as readiness blockers', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 20, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://beta.example.test', + 'api_base_url' => 'https://api-beta.example.test', + 'versions' => [], + 'availability' => [ + 'configured' => false, + 'status' => 'unconfigured', + 'missing' => ['release_bundle'], + ], + ], + ], + 'bundles' => [ + ['id' => 91, 'channel_id' => 20, 'status' => 'deployed', 'version_label' => 'beta-bundle'], + ['id' => 92, 'channel_id' => 20, 'status' => 'failed', 'version_label' => 'failed-bundle'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + + expect($channel['readiness'])->toBe('ready') + ->and($channel['availability'])->toMatchArray([ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]) + ->and($channel['issues'])->toBe([]) + ->and($channel['missing_values'])->toBe([]); +}); + +it('surfaces failed latest deployments as critical promotion blockers', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 3, + 'slug' => 'canary', + 'name' => 'Canary', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://canary.example.test', + 'api_base_url' => 'https://api-canary.example.test', + 'versions' => releaseStatusReadyVersions(9), + 'availability' => [ + 'configured' => true, + 'status' => 'ready', + 'missing' => [], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 51, 'channel_id' => 3, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'coolify_service_uuid' => 'frontend-canary'], + ['id' => 52, 'channel_id' => 3, 'app' => 'api', 'repository' => 'truckwash/backend-php', 'coolify_service_uuid' => 'api-canary'], + ], + 'service_sets' => [ + [ + 'id' => 53, + 'channel_id' => 3, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => releaseStatusReadyService('database', 54), + 'redis' => releaseStatusReadyService('redis', 55), + 'minio' => releaseStatusReadyService('minio', 56), + ], + ], + ], + 'deployments' => [ + [ + 'id' => 57, + 'channel_id' => 3, + 'app' => 'api', + 'status' => 'failed', + 'failure_summary' => [ + 'root_cause' => 'Composer install failed.', + 'next_action' => 'Open the deployment logs and fix composer dependencies.', + ], + ], + ['id' => 58, 'channel_id' => 3, 'app' => 'frontend', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'canary'); + $api = releaseStatusServiceByKey($channel, 'api'); + + expect($overview['state'])->toBe('blocked') + ->and($channel['readiness'])->toBe('blocked') + ->and($api)->toMatchArray([ + 'severity' => 'critical', + 'state' => 'failed', + 'issue_type' => 'failed_deployment', + 'deployment_id' => 57, + ]) + ->and($channel['issues'][0])->toMatchArray([ + 'severity' => 'critical', + 'type' => 'failed_deployment', + 'deployment_id' => 57, + 'next_action' => 'Open the deployment logs and fix composer dependencies.', + ]) + ->and($channel['issues'][0]['key'])->toBe('failed_deployment:3:api::57:') + ->and($channel['issues'][0]['impact'])->toContain('cannot be promoted') + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('retry_deployment'); +}); + +it('offers application target preparation for path routed Coolify failures', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 30, + 'slug' => 'internal', + 'name' => 'Internal', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://internal.example.test', + 'api_base_url' => 'https://api-internal.example.test', + 'versions' => releaseStatusReadyVersions(19), + 'availability' => [ + 'configured' => true, + 'status' => 'ready', + 'missing' => [], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 81, 'channel_id' => 30, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'coolify_service_uuid' => 'frontend-internal'], + ['id' => 82, 'channel_id' => 30, 'app' => 'api', 'repository' => 'truckwash/backend-php', 'coolify_service_uuid' => 'legacy-api-service'], + ], + 'service_sets' => [ + [ + 'id' => 83, + 'channel_id' => 30, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => releaseStatusReadyService('database', 84), + 'redis' => releaseStatusReadyService('redis', 85), + 'minio' => releaseStatusReadyService('minio', 86), + ], + ], + ], + 'deployments' => [ + [ + 'id' => 87, + 'channel_id' => 30, + 'app' => 'api', + 'status' => 'failed', + 'failure_summary' => [ + 'root_cause' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.', + 'next_action' => 'Set coolify_resource_type=application or migrate this target before deploying.', + ], + ], + ['id' => 88, 'channel_id' => 30, 'app' => 'frontend', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'internal'); + $actions = $channel['issues'][0]['actions']; + $actionIds = array_column($actions, 'id'); + $prepareAction = $actions[array_search('prepare_application_target', $actionIds, true)]; + + expect($actionIds)->toContain('retry_deployment') + ->and($actionIds)->toContain('prepare_application_target') + ->and($prepareAction)->toMatchArray([ + 'requires_confirmation' => true, + 'requires_input' => false, + 'disabled_reason' => '', + ]); +}); + +it('flags isolated stacks that are missing database Redis and MinIO services', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 4, + 'slug' => 'internal', + 'name' => 'Internal', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://internal.example.test', + 'api_base_url' => 'https://api-internal.example.test', + 'versions' => releaseStatusReadyVersions(11), + 'availability' => [ + 'configured' => true, + 'status' => 'ready', + 'missing' => [], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 61, 'channel_id' => 4, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-internal'], + ['id' => 62, 'channel_id' => 4, 'app' => 'api', 'coolify_service_uuid' => 'api-internal'], + ], + 'service_sets' => [ + [ + 'id' => 63, + 'channel_id' => 4, + 'mode' => 'isolated_stack', + 'stack' => [ + 'frontend' => ['id' => 61], + 'api' => ['id' => 62], + ], + 'data_services' => [], + ], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'internal'); + + expect($channel['readiness'])->toBe('blocked') + ->and(releaseStatusServiceByKey($channel, 'database'))->toMatchArray([ + 'severity' => 'critical', + 'issue_type' => 'missing_value', + 'missing_key' => 'database_service', + ]) + ->and(releaseStatusServiceByKey($channel, 'redis'))->toMatchArray([ + 'severity' => 'critical', + 'issue_type' => 'missing_value', + 'missing_key' => 'redis_service', + ]) + ->and(releaseStatusServiceByKey($channel, 'minio'))->toMatchArray([ + 'severity' => 'critical', + 'issue_type' => 'missing_value', + 'missing_key' => 'minio_service', + ]) + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('complete_data_services'); +}); + +it('maps degraded Coolify targets and reconcile failures to release service issues', function (): void { + $database = releaseStatusReadyService('database', 71); + $database['deployment_status'] = 'reconcile_failed'; + $database['availability_state'] = 'degraded'; + + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 5, + 'slug' => 'preview', + 'name' => 'Preview', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://preview.example.test', + 'api_base_url' => 'https://api-preview.example.test', + 'versions' => releaseStatusReadyVersions(12), + 'availability' => [ + 'configured' => true, + 'status' => 'ready', + 'missing' => [], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 72, 'channel_id' => 5, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-preview'], + ['id' => 73, 'channel_id' => 5, 'app' => 'api', 'coolify_service_uuid' => 'api-preview'], + ], + 'service_sets' => [ + [ + 'id' => 74, + 'channel_id' => 5, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => $database, + 'redis' => releaseStatusReadyService('redis', 75), + 'minio' => releaseStatusReadyService('minio', 76), + ], + ], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'preview'); + $databaseRow = releaseStatusServiceByKey($channel, 'database'); + + expect($databaseRow)->toMatchArray([ + 'severity' => 'critical', + 'state' => 'service_unhealthy', + 'issue_type' => 'service_unhealthy', + 'coolify_target_id' => 71, + ]) + ->and($channel['issues'][0])->toMatchArray([ + 'severity' => 'critical', + 'type' => 'service_unhealthy', + 'service_key' => 'database', + ]) + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('reconcile_coolify_target') + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('redeploy_coolify_target') + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('restart_coolify_target'); +}); diff --git a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php new file mode 100644 index 00000000..f4caff61 --- /dev/null +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -0,0 +1,1448 @@ + 'Bearer secret-token', + 'nested' => [ + 'api_key' => 'key-value', + 'safe' => 'visible', + 'items' => [ + ['password' => 'hidden', 'status' => 500], + ], + ], + ]; + + expect(release_manager::redactPayload($payload))->toBe([ + 'Authorization' => '[redacted]', + 'nested' => [ + 'api_key' => '[redacted]', + 'safe' => 'visible', + 'items' => [ + ['password' => '[redacted]', 'status' => 500], + ], + ], + ]); +}); + +it('verifies GitHub sha256 webhook signatures', function (): void { + $secret = 'release-webhook-secret'; + $payload = '{"ref":"refs/heads/main"}'; + $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); + + expect(release_manager::verifyGithubSignature($secret, $payload, $signature))->toBeTrue(); + expect(release_manager::verifyGithubSignature($secret, $payload, 'sha256=bad'))->toBeFalse(); + expect(release_manager::verifyGithubSignature('', $payload, $signature))->toBeFalse(); +}); + +it('verifies CI release gate bearer tokens from dedicated release credentials', function (): void { + $previous = getenv('RELEASE_MANAGER_GATE_TOKEN'); + + try { + putenv('RELEASE_MANAGER_GATE_TOKEN=release-gate-secret'); + $_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = 'release-gate-secret'; + + $manager = new release_manager(); + + expect($manager->verifyReleaseGateToken('release-gate-secret'))->toBeTrue(); + expect($manager->verifyReleaseGateToken('wrong-secret'))->toBeFalse(); + expect($manager->verifyReleaseGateToken(''))->toBeFalse(); + } finally { + if ($previous === false) { + putenv('RELEASE_MANAGER_GATE_TOKEN'); + unset($_SERVER['RELEASE_MANAGER_GATE_TOKEN']); + } else { + putenv('RELEASE_MANAGER_GATE_TOKEN=' . $previous); + $_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = $previous; + } + } +}); + +it('normalizes app-specific release gate auto-sync metadata', function (): void { + $manager = new release_manager(); + $normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput'); + $normalizeGate->setAccessible(true); + $appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches'); + $appMatches->setAccessible(true); + + $gate = $normalizeGate->invoke($manager, [ + 'channel_slug' => 'stable', + 'app' => 'api', + 'repository' => 'https://github.com/copenhagentruckwash/api.git', + 'branch' => 'master', + 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', + 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', + 'auto_sync' => true, + ], ['slug' => 'stable']); + + expect($gate)->toMatchArray([ + 'channel_slug' => 'stable', + 'route_slug' => 'master', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', + 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', + 'auto_sync' => true, + ]); + + expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue(); + expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue(); + expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue(); + expect($appMatches->invoke($manager, [], 'api'))->toBeFalse(); +}); + +it('requires non-empty release gate checks before auto-sync can proceed', function (): void { + $manager = new release_manager(); + $method = new ReflectionMethod(release_manager::class, 'releaseGateAutoSyncValidationSteps'); + $method->setAccessible(true); + + $failed = $method->invoke($manager, [ + 'channel_slug' => 'stable', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', + 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', + 'required_checks' => [], + ], ['slug' => 'stable']); + expect($failed)->toContainEqual(expect()->toMatchArray([ + 'step_key' => 'auto_sync_required_checks', + 'status' => 'failed', + ])); + + $passed = $method->invoke($manager, [ + 'channel_slug' => 'stable', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', + 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', + 'required_checks' => ['api_gateway'], + ], ['slug' => 'stable']); + expect($passed)->toContainEqual(expect()->toMatchArray([ + 'step_key' => 'auto_sync_inputs', + 'status' => 'passed', + ])); +}); + +it('normalizes GitHub repository identifiers for private repository access checks', function (): void { + expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php'); + expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue'); + expect(release_manager::normalizeGithubRepositoryName('git@github.com:truckwash/backend-php.git'))->toBe('truckwash/backend-php'); + expect(release_manager::normalizeGithubRepositoryName('not a repository'))->toBe(''); +}); + +it('keeps GitHub commit timestamps in public release manager commit payloads', function (): void { + $manager = new release_manager(); + $method = new ReflectionMethod(release_manager::class, 'publicGithubCommit'); + $method->setAccessible(true); + + $commit = $method->invoke($manager, [ + 'sha' => 'feedface00000000000000000000000000000000', + 'html_url' => 'https://github.com/truckwash/backend-php/commit/feedface', + 'commit' => [ + 'message' => "Deploy release bundle\n\nBody is intentionally omitted from option labels.", + 'author' => [ + 'name' => 'Release Bot', + 'date' => '2026-05-19T08:10:00Z', + ], + ], + ]); + + expect($commit)->toMatchArray([ + 'sha' => 'feedface00000000000000000000000000000000', + 'short_sha' => 'feedface0000', + 'message' => 'Deploy release bundle', + 'author_name' => 'Release Bot', + 'authored_at' => '2026-05-19T08:10:00Z', + ]); +}); + +it('summarizes failed deployments and blocks promotion until a deployment succeeds', function (): void { + $summary = release_manager::deploymentFailureSummary( + new RuntimeException('Coolify API request failed: HTTP 404'), + [ + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'commit_sha' => 'ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3', + 'coolify_service_uuid' => 'api-service', + ] + ); + + expect($summary['category'])->toBe('coolify_target'); + expect($summary['stage'])->toBe('provider_target'); + expect($summary['promotion_blocked'])->toBeTrue(); + expect($summary['evidence']['commit_sha'])->toBe('ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3'); + expect(release_manager::deploymentCanBePromoted('deployed'))->toBeTrue(); + expect(release_manager::deploymentCanBePromoted('failed'))->toBeFalse(); + + $reason = release_manager::deploymentPromotionBlockedReason([ + 'status' => 'failed', + 'result_json' => json_encode(['failure_summary' => $summary]), + ]); + + expect($reason)->toContain('Deployment failed'); + expect($reason)->toContain('HTTP 404'); +}); + +it('uses the selected Coolify project and resolves server UUID from the instance default', function (): void { + $manager = new release_manager(); + $method = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $method->setAccessible(true); + + $payload = $method->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'coolify_project_uuid' => 'project-selected', + 'coolify_deploy_now' => true, + 'image' => 'ghcr.io/copenhagentruckwash/api:master', + ], [ + 'default_project_uuid' => 'project-default', + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($payload['project_uuid'])->toBe('project-selected'); + expect($payload['server_uuid'])->toBe('server-default'); + expect($payload['environment_name'])->toBe('internal'); + expect($payload)->not->toHaveKey('environment_uuid'); + expect($payload)->not->toHaveKey('coolify_server_uuid'); +}); + +it('supports isolated stack mode and names new Coolify services explicitly', function (): void { + $manager = new release_manager(); + + $normalizeMode = new ReflectionMethod(release_manager::class, 'normalizeServiceSetMode'); + $normalizeMode->setAccessible(true); + expect($normalizeMode->invoke($manager, ' isolated_stack '))->toBe('isolated_stack'); + + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $payloadMethod->setAccessible(true); + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'frontend', + 'repository' => 'copenhagentruckwash/front-end-vue', + 'branch' => 'main', + ], [ + 'coolify_service_name' => 'release-internal-safe-stack-frontend', + 'coolify_project_uuid' => 'project-internal', + 'coolify_deploy_now' => true, + 'image' => 'ghcr.io/copenhagentruckwash/front-end-vue:main', + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($payload['name'])->toBe('release-internal-safe-stack-frontend'); + expect($payload['project_uuid'])->toBe('project-internal'); + expect($payload['environment_name'])->toBe('internal'); + expect($payload)->not->toHaveKey('type'); + expect(base64_decode($payload['docker_compose_raw'], true))->toContain('ghcr.io/copenhagentruckwash/front-end-vue:main'); +}); + +it('creates frontend Coolify GitHub App application payloads with the release Dockerfile', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'canary', + 'app' => 'frontend', + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'release/canary', + 'auto_deploy' => 1, + ], [ + 'coolify_service_name' => 'release-canary-pleno-vue', + 'coolify_project_uuid' => 'project-canary', + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + 'coolify_build_pack' => 'nixpacks', + 'coolify_deploy_now' => true, + 'coolify_public_url' => 'https://canary.example.test', + 'coolify_enable_ssl' => true, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($payload['name'])->toBe('release-canary-pleno-vue'); + expect($payload['project_uuid'])->toBe('project-canary'); + expect($payload['environment_name'])->toBe('release-canary'); + expect($payload['server_uuid'])->toBe('server-default'); + expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github'); + expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue'); + expect($payload['git_branch'])->toBe('release/canary'); + expect($payload['build_pack'])->toBe('dockerfile'); + expect($payload['ports_exposes'])->toBe('80'); + expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-frontend'); + expect($payload['domains'])->toBe('https://canary.example.test/canary/frontend'); + expect($payload)->not->toHaveKey('publish_directory'); + expect($payload)->not->toHaveKey('is_static'); + expect($payload)->not->toHaveKey('docker_compose_raw'); +}); + +it('uses the self-contained Coolify API Dockerfile for API applications', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'auto_deploy' => 1, + ], [ + 'coolify_service_name' => 'release-internal-api-node3-truckwash-io', + 'coolify_project_uuid' => 'project-internal', + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + 'coolify_deploy_now' => true, + 'coolify_public_url' => 'https://api-v2.truckwash.io', + 'coolify_enable_ssl' => true, + 'gateway_route_autoprovision' => true, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-node3', + ]); + + expect($payload['build_pack'])->toBe('dockerfile'); + expect($payload['ports_exposes'])->toBe('80'); + expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api'); + expect($payload['domains'])->toBe('https://api-v2.truckwash.io:80'); + expect($payload)->not->toHaveKey('publish_directory'); + expect($payload)->not->toHaveKey('is_static'); +}); + +it('builds explicit Coolify application route labels for release API targets', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'coolify_ports_exposes' => '8080', + ], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true')); + $labels = explode("\n", base64_decode($payload['custom_labels'], true)); + + expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080') + ->and($payload['is_force_https_enabled'])->toBeTrue() + ->and($payload['force_domain_override'])->toBeTrue() + ->and($labels)->toContain('custom.keep=true') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io') + ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080'); +}); + +it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationUpdatePayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'frontend', + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'master', + 'commit_sha' => '1132c8c2560e44478d1bb777c88c762a5e1d0b20', + ], [ + 'coolify_build_pack' => 'nixpacks', + ]); + + expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue'); + expect($payload['git_branch'])->toBe('master'); + expect($payload['git_commit_sha'])->toBe('1132c8c2560e44478d1bb777c88c762a5e1d0b20'); + expect($payload['build_pack'])->toBe('dockerfile'); + expect($payload['ports_exposes'])->toBe('80'); + expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-frontend'); + expect($payload['install_command'])->toBe(''); + expect($payload['build_command'])->toBe(''); + expect($payload['publish_directory'])->toBe(''); + expect($payload['is_static'])->toBeFalse(); + expect($payload['is_spa'])->toBeFalse(); +}); + +it('can use the Coolify instance default GitHub App when source targets do not store it yet', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'coolify_project_uuid' => 'project-internal', + 'coolify_deploy_now' => true, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + 'default_github_app_uuid' => 'github-app-copenhagentruckwash-github', + ]); + + expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github'); + expect($payload['git_repository'])->toBe('copenhagentruckwash/api'); + expect($payload['build_pack'])->toBe('dockerfile'); + expect($payload['ports_exposes'])->toBe('80'); + expect($payload)->not->toHaveKey('docker_compose_raw'); +}); + +it('does not treat an existing Coolify service as an application just because a GitHub App UUID is stored', function (): void { + $manager = new release_manager(); + $resourceTypeMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyResourceType'); + $resourceTypeMethod->setAccessible(true); + + expect($resourceTypeMethod->invoke($manager, [ + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + ], 'existing-service-uuid'))->toBe('service'); + + expect($resourceTypeMethod->invoke($manager, [ + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + ], ''))->toBe('application'); + + expect($resourceTypeMethod->invoke($manager, [ + 'coolify_resource_type' => 'application', + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + ], 'existing-application-uuid'))->toBe('application'); +}); + +it('auto-prepares path-routed release targets for Coolify application creation', function (): void { + $manager = new release_manager(); + $needsApplication = new ReflectionMethod(release_manager::class, 'releaseTargetNeedsApplicationAutoCreate'); + $needsApplication->setAccessible(true); + + $target = [ + 'id' => 42, + 'channel_slug' => 'stable', + 'app' => 'api', + 'coolify_instance_id' => 7, + 'coolify_service_uuid' => '', + 'deploy_context_json' => null, + ]; + + expect($needsApplication->invoke($manager, $target, []))->toBeTrue(); + expect($needsApplication->invoke($manager, array_replace($target, [ + 'coolify_service_uuid' => 'existing-service', + ]), []))->toBeFalse(); + expect($needsApplication->invoke($manager, array_replace($target, [ + 'coolify_instance_id' => null, + ]), []))->toBeFalse(); + expect($needsApplication->invoke($manager, $target, [ + 'coolify_auto_create' => true, + ]))->toBeFalse(); +}); + +it('offers application target preparation for missing Coolify service creation failures', function (): void { + $needsApplicationAction = new ReflectionMethod(release_manager::class, 'releaseStatusIssueNeedsApplicationTarget'); + $needsApplicationAction->setAccessible(true); + + expect($needsApplicationAction->invoke(null, [ + 'message' => 'API deployment failed before activation.', + 'next_action' => 'Select an existing Coolify service or enable Coolify service creation before deployment.', + ]))->toBeTrue(); + expect($needsApplicationAction->invoke(null, [ + 'message' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.', + 'next_action' => 'Migrate this target before deploying.', + ]))->toBeTrue(); + expect($needsApplicationAction->invoke(null, [ + 'message' => 'Release gate failed.', + 'next_action' => 'Run live smoke tests before promotion.', + ]))->toBeFalse(); +}); + +it('builds API Coolify runtime environment from allowed process variables', function (): void { + $keys = ['CONFIG_DB_HOST', 'CONFIG_DB_PASSWORD', 'EDGE_BROKER_URL', 'CORS', 'PATH']; + $previous = []; + foreach ($keys as $key) { + $previous[$key] = getenv($key); + } + + try { + putenv('CONFIG_DB_HOST=db.example.test'); + $_ENV['CONFIG_DB_HOST'] = 'db.example.test'; + $_SERVER['CONFIG_DB_HOST'] = 'db.example.test'; + putenv('CONFIG_DB_PASSWORD=runtime-secret'); + $_ENV['CONFIG_DB_PASSWORD'] = 'runtime-secret'; + $_SERVER['CONFIG_DB_PASSWORD'] = 'runtime-secret'; + putenv('EDGE_BROKER_URL=https://edge.example.test'); + $_ENV['EDGE_BROKER_URL'] = 'https://edge.example.test'; + $_SERVER['EDGE_BROKER_URL'] = 'https://edge.example.test'; + putenv('CORS=https://truckwash.io,https://api-v2.truckwash.io/master/api'); + $_ENV['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api'; + $_SERVER['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api'; + putenv('PATH=/should/not/copy'); + $_ENV['PATH'] = '/should/not/copy'; + $_SERVER['PATH'] = '/should/not/copy'; + + $manager = new release_manager(); + $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); + $runtimeEnv->setAccessible(true); + + $env = $runtimeEnv->invoke($manager, [ + 'app' => 'api', + ], [ + 'coolify_env' => [ + 'CONFIG_DB_HOST' => 'context-db.example.test', + 'CUSTOM_ALLOWED' => 'from-context', + ], + ]); + + expect($env['USE_ENV'])->toBe('true'); + expect($env['CONFIG_DB_HOST'])->toBe('context-db.example.test'); + expect($env['CONFIG_DB_PASSWORD'])->toBe('runtime-secret'); + expect($env['EDGE_BROKER_URL'])->toBe('https://edge.example.test'); + expect($env['CUSTOM_ALLOWED'])->toBe('from-context'); + expect(explode(',', $env['CORS']))->toContain('https://api-v2.truckwash.io'); + expect(explode(',', $env['CORS']))->toContain('http://localhost:5173'); + expect(explode(',', $env['CORS']))->not->toContain('https://api-v2.truckwash.io/master/api'); + expect($env)->not->toHaveKey('PATH'); + } finally { + foreach ($previous as $key => $value) { + if ($value === false) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } else { + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } + } +}); + +it('resolves backend commit sha from API runtime environment in priority order', function (): void { + $keys = ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA']; + $previous = []; + foreach ($keys as $key) { + $previous[$key] = [ + 'process' => getenv($key), + 'env_set' => array_key_exists($key, $_ENV), + 'env' => $_ENV[$key] ?? null, + 'server_set' => array_key_exists($key, $_SERVER), + 'server' => $_SERVER[$key] ?? null, + ]; + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } + + $set = static function (string $key, string $value): void { + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + }; + + try { + $set('API_COMMIT_SHA', 'not-a-sha'); + $set('COMMIT_SHA', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); + $set('GITHUB_SHA', 'cccccccccccccccccccccccccccccccccccccccc'); + + expect(release_manager::backendCommitSha())->toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + + $set('API_COMMIT_SHA', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + + expect(release_manager::backendCommitSha())->toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + } finally { + foreach ($previous as $key => $state) { + if ($state['process'] === false) { + putenv($key); + } else { + putenv($key . '=' . $state['process']); + } + + if ($state['env_set']) { + $_ENV[$key] = $state['env']; + } else { + unset($_ENV[$key]); + } + + if ($state['server_set']) { + $_SERVER[$key] = $state['server']; + } else { + unset($_SERVER[$key]); + } + } + } +}); + +it('injects selected API commit into Coolify runtime env unless explicitly set', function (): void { + $manager = new release_manager(); + $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); + $runtimeEnv->setAccessible(true); + + $selectedCommit = '1111111111111111111111111111111111111111'; + $explicitCommit = '2222222222222222222222222222222222222222'; + + $env = $runtimeEnv->invoke($manager, [ + 'app' => 'api', + 'commit_sha' => $selectedCommit, + ], [ + 'coolify_env' => [ + 'COMMIT_SHA' => $explicitCommit, + ], + ]); + + expect($env['API_COMMIT_SHA'])->toBe($selectedCommit); + expect($env['COMMIT_SHA'])->toBe($explicitCommit); +}); + +it('keeps beta API runtime environment on production database target', function (): void { + $keys = ['CONFIG_DB_TARGET', 'CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', 'DEBUG']; + $previous = []; + foreach ($keys as $key) { + $previous[$key] = getenv($key); + } + + try { + putenv('CONFIG_DB_TARGET=production'); + $_ENV['CONFIG_DB_TARGET'] = 'production'; + $_SERVER['CONFIG_DB_TARGET'] = 'production'; + putenv('CONFIG_DB_HOST=prod-db.example.test'); + $_ENV['CONFIG_DB_HOST'] = 'prod-db.example.test'; + $_SERVER['CONFIG_DB_HOST'] = 'prod-db.example.test'; + putenv('CONFIG_DB_DEBUG_HOST=debug-db.example.test'); + $_ENV['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test'; + $_SERVER['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test'; + putenv('DEBUG=false'); + $_ENV['DEBUG'] = 'false'; + $_SERVER['DEBUG'] = 'false'; + + $manager = new release_manager(); + $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); + $runtimeEnv->setAccessible(true); + + $env = $runtimeEnv->invoke($manager, [ + 'app' => 'api', + 'channel_slug' => 'beta', + ], []); + + expect($env['CONFIG_DB_TARGET'])->toBe('production'); + expect($env['CONFIG_DB_HOST'])->toBe('prod-db.example.test'); + expect($env['DEBUG'])->toBe('false'); + expect($env['CONFIG_DB_TARGET'])->not->toBe('debug'); + } finally { + foreach ($previous as $key => $value) { + if ($value === false) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } else { + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } + } +}); + +it('treats attach-existing service sets without data target ids as production-shared ready', function (): void { + $manager = new release_manager(); + $status = new ReflectionMethod(release_manager::class, 'serviceSetStatus'); + $status->setAccessible(true); + $dataTargets = ['database' => null, 'redis' => null, 'minio' => null]; + + expect($status->invoke($manager, 'attach_existing', 10, 11, $dataTargets))->toBe('ready'); + expect($status->invoke($manager, 'clone_existing', 10, 11, $dataTargets))->toBe('needs_clone_targets'); + expect($status->invoke($manager, 'fresh_empty', 10, 11, $dataTargets))->toBe('isolated_empty'); + expect($status->invoke($manager, 'isolated_stack', 10, 11, $dataTargets))->toBe('needs_isolated_targets'); +}); + +it('detects explicit data target ids so beta service sets can stay data-only', function (): void { + $manager = new release_manager(); + $hasExplicitDataTargets = new ReflectionMethod(release_manager::class, 'serviceSetInputHasExplicitDataTargets'); + $hasExplicitDataTargets->setAccessible(true); + + expect($hasExplicitDataTargets->invoke($manager, [ + 'mode' => 'attach_existing', + 'data_source_service_set_id' => 10, + ]))->toBeFalse(); + expect($hasExplicitDataTargets->invoke($manager, [ + 'data_targets' => [ + 'database' => 42, + ], + ]))->toBeTrue(); + expect($hasExplicitDataTargets->invoke($manager, [ + 'redis_coolify_target_id' => 43, + ]))->toBeTrue(); +}); + +it('allows beta production-service bundles only when data services stay production-shared', function (): void { + $manager = new release_manager(); + $assert = new ReflectionMethod(release_manager::class, 'assertBetaProductionDataPolicy'); + $assert->setAccessible(true); + $betaChannel = ['id' => 2, 'slug' => 'beta']; + + expect($assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))->toBeNull(); + + foreach (['clone_existing', 'fresh_empty', 'isolated_stack'] as $mode) { + expect(fn() => $assert->invoke($manager, $betaChannel, ['mode' => $mode])) + ->toThrow(RuntimeException::class, 'production-shared'); + } +}); + +it('keeps release branch services out of the production Coolify environment', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $payloadMethod->setAccessible(true); + + $canaryPayload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'canary', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'release/canary', + ], [ + 'coolify_project_uuid' => 'project-release', + 'coolify_deploy_now' => true, + 'environment_name' => 'production', + 'image' => 'ghcr.io/copenhagentruckwash/api:canary', + ], [ + 'default_environment_uuid' => 'env-production', + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($canaryPayload['environment_name'])->toBe('release-canary'); + expect($canaryPayload)->not->toHaveKey('environment_uuid'); + + $betaPayload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'beta', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'release/beta', + ], [ + 'coolify_project_uuid' => 'project-release', + 'coolify_deploy_now' => true, + 'image' => 'ghcr.io/copenhagentruckwash/api:beta', + ], [ + 'default_environment_uuid' => 'env-production', + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($betaPayload['environment_name'])->toBe('release-beta'); + expect($betaPayload)->not->toHaveKey('environment_uuid'); +}); + +it('does not invent GHCR images for Coolify service payloads', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $payloadMethod->setAccessible(true); + + $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'frontend', + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'master', + ], [ + 'coolify_service_name' => 'release-internal-safe-stack-frontend', + 'coolify_project_uuid' => 'project-internal', + 'coolify_deploy_now' => true, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); +})->throws(RuntimeException::class, 'explicit image'); + +it('creates Coolify service payloads from raw compose without a service type', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $payloadMethod->setAccessible(true); + + $compose = "services:\n app:\n image: ghcr.io/copenhagentruckwash/api:test"; + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'coolify_project_uuid' => 'project-internal', + 'coolify_deploy_now' => true, + 'docker_compose_raw' => $compose, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($payload)->not->toHaveKey('type'); + expect($payload['docker_compose_raw'])->toBe(base64_encode($compose)); + expect(base64_decode($payload['docker_compose_raw'], true))->toBe($compose); +}); + +it('defines release manager schema, routes, permissions, and system-status integration hooks', function (): void { + $schema = file_get_contents(app_path('classes/release_manager_schema_bootstrap.php')); + $manager = file_get_contents(app_path('classes/release_manager.php')); + $route = file_get_contents(app_path('routes/releaseManagerRoute.php')); + $auth = file_get_contents(app_path('routes/authRoute.php')); + $response = file_get_contents(app_path('classes/response.php')); + $status = file_get_contents(app_path('classes/superuser_system_status_service.php')); + $index = file_get_contents(app_path('index.php')); + $corsPolicy = file_get_contents(app_path('classes/cors_policy.php')); + + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channels'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_auto_sync_events'); + expect($schema)->toContain('UNIQUE KEY uq_release_auto_sync_event'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_runs'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_steps'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_replay_targets'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events'); + expect($schema)->toContain('device_type VARCHAR(16) NULL'); + expect($schema)->toContain('frontend_version_label VARCHAR(128) NULL'); + expect($schema)->toContain('api_version_label VARCHAR(128) NULL'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_module_health_snapshots'); + expect($schema)->toContain("ensureColumn('release_channel_versions', 'service_set_id'"); + expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'"); + expect($schema)->toContain("ensureColumn('release_deployments', 'deployment_kind'"); + expect($schema)->toContain("ensureColumn('release_deployments', 'active_channel_app_key'"); + expect($schema)->toContain("ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app'"); + expect($schema)->toContain("ensureColumn('release_timeline_sessions', 'device_type'"); + expect($schema)->toContain("ensureIndex('release_timeline_sessions', 'idx_release_timeline_device'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'enabled'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_token'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_token'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'"); + expect($schema)->toContain("['stable', 'Stable'"); + expect($schema)->toContain("['canary', 'Canary'"); + expect($schema)->toContain("['beta', 'Beta'"); + expect($schema)->toContain("['internal', 'Internal'"); + + expect($route)->toContain('/release/bootstrap'); + expect($route)->toContain('/release/runtime'); + expect($route)->toContain('/release/timeline/events'); + expect($route)->toContain('/release/github/webhook'); + expect($route)->toContain('/release/gate/test-runs'); + expect($route)->toContain('releaseGateToken'); + expect($route)->toContain('/superuser/releases/config'); + expect($route)->toContain('/superuser/releases/github/repositories'); + expect($route)->toContain('/superuser/releases/github/branches'); + expect($route)->toContain('/superuser/releases/github/commits'); + expect($route)->toContain('/superuser/releases/github/test'); + expect($route)->toContain('/superuser/releases/channels'); + expect($route)->toContain('/superuser/releases/channels/{id}/sync'); + expect($route)->toContain('/superuser/releases/test-runs'); + expect($route)->toContain('/superuser/releases/operations'); + expect($route)->toContain('/superuser/releases/operations/{id}'); + expect($route)->toContain('/superuser/releases/channels/{id}/bundle'); + expect($route)->toContain('/superuser/releases/assignment-subjects'); + expect($route)->toContain('/superuser/releases/assignments'); + expect($route)->toContain('/superuser/releases/service-sets'); + expect($route)->toContain("\$this->delete('/superuser/releases/service-sets/{id}'"); + expect($route)->toContain('/superuser/releases/service-sets/{id}/isolated-data-services'); + expect($route)->toContain('/superuser/releases/bundles'); + expect($route)->toContain('/superuser/releases/bundles/{id}/deploy'); + expect($route)->toContain('/superuser/releases/bundles/{id}/promote'); + expect($route)->toContain('/superuser/releases/deployments'); + expect($route)->toContain('/superuser/releases/issues/actions'); + expect($route)->toContain('/superuser/releases/replay-targets'); + expect($route)->toContain('/superuser/releases/timeline/sessions'); + expect($route)->toContain('/superuser/releases/timeline/sessions/{traceId}'); + expect($route)->toContain('/superuser/releases/timeline'); + expect($route)->toContain("requirePermission('superuser_release_manager_view')"); + expect($route)->toContain("requirePermission('superuser_release_manager_manage')"); + expect($route)->toContain("requirePermission('superuser_release_manager_deploy')"); + expect($route)->toContain("requirePermission('superuser_release_manager_rollback')"); + expect($route)->toContain("requirePermission('superuser_release_manager_replay')"); + + expect($manager)->toContain('verifyGithubSignature'); + expect($manager)->toContain('verifyReleaseGateToken'); + expect($manager)->toContain('normalizeReleaseGateInput'); + expect($manager)->toContain('processReleaseGateAutoSync'); + expect($manager)->toContain('release_auto_sync_events'); + expect($manager)->toContain('require_readiness'); + expect($manager)->toContain('release-manifest.json'); + expect($manager)->toContain('static_artifact'); + expect($manager)->toContain('api_gateway'); + expect($manager)->toContain('assertReleaseGatePassedForPromotion'); + expect($manager)->toContain('release_gate_required_for_promotion'); + expect($manager)->toContain("'public_smoke_required' => true"); + expect($manager)->toContain('normalizeGithubRepositoryName'); + expect($manager)->toContain("private const DEFAULT_BRANCH = 'master'"); + expect($manager)->toContain('releaseConfig'); + expect($manager)->toContain('updateReleaseConfig'); + expect($manager)->toContain('github_token_variable'); + expect($manager)->toContain('github_token_env_variable'); + expect($manager)->toContain('listGithubRepositories'); + expect($manager)->toContain('listGithubBranches'); + expect($manager)->toContain('listGithubCommits'); + expect($manager)->toContain('testGithubRepositoryAccess'); + expect($manager)->toContain('githubRepositoryAccess'); + expect($manager)->toContain('github_token'); + expect($manager)->toContain('commit_mode'); + expect($manager)->toContain('restartCoolifyService'); + expect($manager)->toContain('deployCoolifyReleaseTarget'); + expect($manager)->toContain('deployResource'); + expect($manager)->toContain('releaseCoolifyGitCommitSha'); + expect($manager)->toContain('releaseCoolifyForceRebuild'); + expect($manager)->toContain('listServiceSets'); + expect($manager)->toContain('createServiceSet'); + expect($manager)->toContain('deleteServiceSet'); + expect($manager)->toContain('createBundle'); + expect($manager)->toContain('deployBundle'); + expect($manager)->toContain('promoteBundle'); + expect($manager)->toContain('setChannelBundle'); + expect($manager)->toContain('searchAssignmentSubjects'); + expect($manager)->toContain('publicAssignmentSubjectSuggestion'); + expect($manager)->toContain('available_channels'); + expect($manager)->toContain("'source' => 'deployment'"); + expect($manager)->toContain('chooseRuntimeChannel'); + expect($manager)->toContain('requestedRuntimeChannelSlug'); + expect($manager)->toContain("status = 'superseded'"); + expect($manager)->toContain('serviceSetIsActive'); + expect($manager)->toContain('bundleIsActive'); + expect($manager)->toContain('service_set_removed'); + expect($manager)->toContain('clone_replica_from_source'); + expect($manager)->toContain('register_isolated_empty_service'); + expect($manager)->toContain('isolated_stack'); + expect($manager)->toContain('create_isolated_empty_stack_service'); + expect($manager)->toContain('assertIsolatedStackTarget'); + expect($manager)->toContain('completeIsolatedStackDataServices'); + expect($manager)->toContain('createIsolatedStackDataTarget'); + expect($manager)->toContain('skip_replication_provisioning'); + expect($manager)->toContain('must not point at an existing Coolify service'); + expect($manager)->toContain("'data_promotion' => false"); + expect($manager)->toContain("'replica_failover' => false"); + expect($manager)->toContain('deploymentCanBePromoted'); + expect($manager)->toContain('deploymentFailureSummary'); + expect($manager)->toContain('runIssueAction'); + expect($manager)->toContain('release_issue_action_attempted'); + expect($manager)->toContain('prepareReleaseIssueApplicationTarget'); + expect($manager)->toContain('coolifyProjectSuggestions'); + expect($manager)->toContain('releaseCoolifyServerUuid'); + expect($manager)->toContain('coolify_project_uuid'); + expect($manager)->toContain('releaseCoolifyServicePayload'); + expect($manager)->toContain('releaseCoolifyApplicationPayload'); + expect($manager)->toContain('releaseCoolifyApplicationUpdatePayload'); + expect($manager)->toContain('createPrivateGithubAppApplication'); + expect($manager)->toContain('coolifyGithubAppSuggestions'); + expect($manager)->toContain('coolify_github_apps'); + expect($manager)->toContain('release_deployment_targets'); + expect($manager)->toContain('resolveChannel'); + expect($manager)->toContain('capturePolicyFor'); + expect($manager)->toContain('listTimelineSessions'); + expect($manager)->toContain('timelineSessionDetail'); + expect($manager)->toContain('timelineSessionContext'); + expect($manager)->toContain('timelineErrorReports'); + expect($manager)->toContain('timelineReleaseContext'); + expect($manager)->toContain('publicTimelineDeploymentReference'); + expect($manager)->toContain('publicTimelineBundleReference'); + expect($manager)->toContain('cleanupExpiredReplayData'); + expect($manager)->toContain('channelAvailability'); + expect($manager)->toContain("'availability' =>"); + expect($manager)->toContain('redactPayload'); + expect($manager)->toContain('moduleKeys'); + expect($manager)->toContain('releaseSuggestions'); + expect($manager)->toContain('load_balancer_domains'); + expect($manager)->toContain('appendDomainSuggestion'); + expect($manager)->toContain('Coolify SSL requires a DNS domain routed to the load balancer.'); + expect($manager)->toContain('releaseRuntimeUrls'); + expect($manager)->toContain('coolify_services'); + expect($manager)->toContain('coolify_enable_ssl'); + expect($manager)->toContain('createService'); + expect($manager)->toContain('updateService'); + expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('updateApplicationEnvsBulk'); + expect($manager)->toContain('channel_presets'); + expect($manager)->toContain('target_presets'); + + expect($auth)->toContain("'release' => (new release_manager())->runtimeForPayload"); + expect($response)->toContain('recordBackendFailure'); + expect($status)->toContain("'key' => 'releasemanager'"); + expect($status)->toContain('probeReleaseManagerModule'); + expect($index)->toContain('release_manager::initializeRequestContext'); + expect($corsPolicy)->toContain('X-Release-Trace'); + expect($manager)->toContain('X-Release-Channel'); + expect($manager)->toContain('normalizeReleaseApiIngressPath'); + expect($manager)->toContain('routeSlugForChannel'); + expect($manager)->toContain('channelSlugForRoute'); + expect($manager)->toContain('syncChannel'); + expect($manager)->toContain('runReleaseTest'); + expect($manager)->toContain('releaseTestAppsFromInput'); + expect($manager)->toContain('release_operation_runs'); + expect($manager)->toContain('active_channel_app_key'); + expect($manager)->toContain('production_shared'); + expect($manager)->toContain('Path-routed release targets require a Coolify application resource with StripPrefix labels'); + expect($manager)->toContain('$applicationPayload[\'instant_deploy\'] = false'); + expect($manager)->toContain('$servicePayload[\'instant_deploy\'] = false'); +}); + +it('captures release request context from headers and runtime query parameters', function (): void { + $server = $_SERVER; + $get = $_GET; + $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null; + + try { + $_SERVER['HTTP_X_RELEASE_TRACE'] = 'trace-abc123'; + $_SERVER['HTTP_X_RELEASE_CHANNEL'] = 'Internal'; + $_SERVER['HTTP_X_FRONTEND_VERSION'] = 'frontend-130cc2fc106a'; + $_SERVER['REQUEST_URI'] = '/release/runtime?release_channel=canary'; + $_GET = ['release_channel' => 'canary']; + + $initialized = release_manager::initializeRequestContext(); + + expect($initialized)->toMatchArray([ + 'trace_id' => 'trace-abc123', + 'requested_channel' => 'internal', + 'frontend_version' => 'frontend-130cc2fc106a', + 'original_request_uri' => '/release/runtime?release_channel=canary', + 'ingress_prefix_stripped' => false, + ]); + expect($GLOBALS['RELEASE_REQUEST_CONTEXT']['requested_channel'])->toBe('internal'); + } finally { + $_SERVER = $server; + $_GET = $get; + if ($context === null) { + unset($GLOBALS['RELEASE_REQUEST_CONTEXT']); + } else { + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + } + } +}); + +it('normalizes channel-prefixed API ingress paths before route dispatch', function (): void { + $server = $_SERVER; + $get = $_GET; + $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null; + + try { + $_SERVER['REQUEST_URI'] = '/internal/api/auth/session?foo=bar'; + $_GET = ['foo' => 'bar']; + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = [ + 'trace_id' => 'trace-normalize', + 'requested_channel' => '', + 'frontend_version' => '', + 'backend_version' => 'test', + 'request_started_at' => date('c'), + 'original_request_uri' => '/internal/api/auth/session?foo=bar', + 'normalized_request_uri' => '', + 'ingress_prefix_stripped' => false, + ]; + + $normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'internal']); + + expect($normalized)->toMatchArray([ + 'channel_slug' => 'internal', + 'original_request_uri' => '/internal/api/auth/session?foo=bar', + 'normalized_request_uri' => '/auth/session?foo=bar', + 'normalized_path' => '/auth/session', + ]); + expect($_SERVER['REQUEST_URI'])->toBe('/auth/session?foo=bar'); + expect($_SERVER['PATH_INFO'])->toBe('/auth/session'); + expect($_GET['release_channel'])->toBe('internal'); + expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([ + 'requested_channel' => 'internal', + 'normalized_request_uri' => '/auth/session?foo=bar', + 'ingress_prefix_stripped' => true, + ]); + } finally { + $_SERVER = $server; + $_GET = $get; + if ($context === null) { + unset($GLOBALS['RELEASE_REQUEST_CONTEXT']); + } else { + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + } + } +}); + +it('maps the public master API prefix to the stable release channel', function (): void { + $server = $_SERVER; + $get = $_GET; + $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null; + + try { + $_SERVER['REQUEST_URI'] = '/master/api/release/runtime'; + $_GET = []; + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = [ + 'trace_id' => 'trace-master', + 'requested_channel' => '', + 'frontend_version' => '', + 'backend_version' => 'test', + 'request_started_at' => date('c'), + 'original_request_uri' => '/master/api/release/runtime', + 'normalized_request_uri' => '', + 'ingress_prefix_stripped' => false, + ]; + + $normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'beta', 'canary', 'internal']); + + expect($normalized)->toMatchArray([ + 'route_slug' => 'master', + 'channel_slug' => 'stable', + 'normalized_request_uri' => '/release/runtime', + 'normalized_path' => '/release/runtime', + ]); + expect($_GET['release_channel'])->toBe('stable'); + expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([ + 'requested_channel' => 'stable', + 'release_route_slug' => 'master', + 'ingress_prefix_stripped' => true, + ]); + } finally { + $_SERVER = $server; + $_GET = $get; + if ($context === null) { + unset($GLOBALS['RELEASE_REQUEST_CONTEXT']); + } else { + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + } + } +}); + +it('uses master as the public route slug for the stable release channel', function (): void { + expect(release_manager::routeSlugForChannel('stable'))->toBe('master'); + expect(release_manager::routeSlugForChannel('beta'))->toBe('beta'); + expect(release_manager::channelSlugForRoute('master'))->toBe('stable'); + expect(release_manager::channelSlugForRoute('internal'))->toBe('internal'); +}); + +it('ignores explicit runtime selection for channels outside the principal channel set', function (): void { + $manager = new release_manager(); + $chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel'); + $chooseRuntimeChannel->setAccessible(true); + + $stable = [ + 'id' => 1, + 'slug' => 'stable', + 'enabled' => 1, + 'default_channel' => 1, + ]; + $internal = [ + 'id' => 4, + 'slug' => 'internal', + 'name' => 'Internal', + 'enabled' => 1, + 'default_channel' => 0, + ]; + + expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable], 'internal'))->toBe($stable); + expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable, $internal], 'internal'))->toBe($internal); +}); + +it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void { + $manager = new release_manager(); + $availability = new ReflectionMethod(release_manager::class, 'channelAvailability'); + $availability->setAccessible(true); + $runtimeUrls = new ReflectionMethod(release_manager::class, 'releaseRuntimeUrls'); + $runtimeUrls->setAccessible(true); + $publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl'); + $publicUrl->setAccessible(true); + $targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl'); + $targetPublicBaseUrl->setAccessible(true); + $applicationLabels = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationLabels'); + $applicationLabels->setAccessible(true); + + expect($availability->invoke($manager, [ + 'id' => 1, + 'slug' => 'stable', + 'default_channel' => 1, + 'frontend_base_url' => null, + 'api_base_url' => null, + ]))->toMatchArray([ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]); + + expect($runtimeUrls->invoke($manager, [ + 'id' => 2, + 'slug' => 'canary', + 'default_channel' => 0, + 'frontend_base_url' => null, + 'api_base_url' => null, + ], [ + 'frontend' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/frontend/health'], + 'api' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/api/ping'], + ]))->toBe([ + 'frontend_base_url' => 'https://api-v2.truckwash.io/canary/frontend', + 'api_base_url' => 'https://api-v2.truckwash.io/canary/api', + ]); + + expect($runtimeUrls->invoke($manager, [ + 'id' => 3, + 'slug' => 'internal', + 'default_channel' => 0, + 'frontend_base_url' => null, + 'api_base_url' => null, + ], [ + 'frontend' => ['deployed_url' => null], + 'api' => ['deployed_url' => null], + 'service_set' => [ + 'targets' => [ + 'frontend' => [ + 'app' => 'frontend', + 'channel_slug' => 'internal', + 'deploy_context' => [ + 'coolify_public_url' => 'https://api-v2.truckwash.io', + ], + ], + 'api' => [ + 'app' => 'api', + 'channel_slug' => 'internal', + 'deploy_context' => [ + 'coolify_domain' => 'api-v2.truckwash.io', + ], + ], + ], + ], + ]))->toBe([ + 'frontend_base_url' => 'https://api-v2.truckwash.io/internal/frontend', + 'api_base_url' => 'https://api-v2.truckwash.io/internal/api', + ]); + + expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'canary'], [ + 'coolify_public_url' => 'https://api-v2.truckwash.io/canary/frontend', + 'coolify_enable_ssl' => true, + ]))->toBe('https://api-v2.truckwash.io/canary/frontend'); + + expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'internal'], [ + 'coolify_public_url' => 'https://api-v2.truckwash.io', + 'coolify_enable_ssl' => true, + ]))->toBe('https://api-v2.truckwash.io/internal/frontend'); + + expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'internal'], [ + 'coolify_domain' => 'api-v2.truckwash.io', + 'coolify_enable_ssl' => true, + ]))->toBe('https://api-v2.truckwash.io/internal/api'); + + expect($targetPublicBaseUrl->invoke($manager, [ + 'app' => 'frontend', + 'channel_slug' => 'internal', + 'deploy_context_json' => json_encode([ + 'coolify_public_url' => 'https://api-v2.truckwash.io', + ]), + ]))->toBe('https://api-v2.truckwash.io/internal/frontend'); + + $labels = $applicationLabels->invoke( + null, + 'https://api-v2.truckwash.io/internal/api', + 'release-api-internal', + 80, + 'letsencrypt' + ); + expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)'); + expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013'); + expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api'); + expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip'); + + $source = file(app_path('classes/release_manager.php')); + $methodSource = implode('', array_slice( + $source, + $availability->getStartLine() - 1, + $availability->getEndLine() - $availability->getStartLine() + 1 + )); + + expect($methodSource)->toContain('frontend_base_url'); + expect($methodSource)->toContain('api_base_url'); + expect($methodSource)->not->toContain("missing[] = 'release_bundle'"); + expect($methodSource)->toContain('frontend_version'); + expect($methodSource)->toContain('api_version'); +}); + +it('resolves release deployment endpoints from manual overrides, URLs, health checks, and gateway defaults', function (): void { + $manager = new release_manager(); + $endpoint = new ReflectionMethod(release_manager::class, 'releaseDeploymentEndpoint'); + $endpoint->setAccessible(true); + $publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl'); + $publicUrl->setAccessible(true); + $targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl'); + $targetPublicBaseUrl->setAccessible(true); + + $manual = $endpoint->invoke($manager, [ + 'app' => 'api', + 'channel_slug' => 'beta', + 'deploy_context' => [ + 'endpoint_mode' => 'manual', + 'manual_endpoint_host' => 'manual-api.example.test', + 'manual_endpoint_port' => '8443', + ], + ]); + + expect($manual)->toMatchArray([ + 'mode' => 'manual', + 'status' => 'resolved', + 'host' => 'manual-api.example.test', + 'port' => 8443, + 'url' => 'https://manual-api.example.test:8443', + 'source' => 'manual', + ]); + expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'beta'], [ + 'endpoint_mode' => 'manual', + 'manual_endpoint_host' => 'manual-api.example.test', + 'manual_endpoint_port' => '8443', + ]))->toBe('https://manual-api.example.test:8443'); + + $fromPublicUrl = $endpoint->invoke($manager, [ + 'app' => 'frontend', + 'channel_slug' => 'internal', + 'deploy_context' => [ + 'endpoint_mode' => 'auto', + 'coolify_public_url' => 'https://api-v2.truckwash.io', + ], + ]); + expect($fromPublicUrl)->toMatchArray([ + 'mode' => 'auto', + 'status' => 'resolved', + 'host' => 'api-v2.truckwash.io', + 'port' => 443, + 'url' => 'https://api-v2.truckwash.io/internal/frontend', + 'source' => 'coolify_public_url', + ]); + + $fromHealth = $endpoint->invoke($manager, [ + 'app' => 'api', + 'channel_slug' => 'canary', + 'health_url' => 'https://health.example.test/canary/api/ping', + 'deploy_context' => [ + 'endpoint_mode' => 'auto', + 'coolify_domain' => 'domain.example.test', + ], + ]); + expect($fromHealth)->toMatchArray([ + 'status' => 'resolved', + 'host' => 'health.example.test', + 'url' => 'https://health.example.test/canary/api', + 'source' => 'health_url', + ]); + + $gateway = $endpoint->invoke($manager, [ + 'app' => 'frontend', + 'channel_slug' => 'beta', + 'deploy_context_json' => json_encode([ + 'endpoint_mode' => 'auto', + 'public_gateway_host' => 'gateway.example.test', + ]), + ]); + expect($gateway)->toMatchArray([ + 'mode' => 'auto', + 'status' => 'pending', + 'host' => 'gateway.example.test', + 'port' => 443, + 'url' => 'https://gateway.example.test/beta/frontend', + 'source' => 'auto_gateway', + ]); + expect($targetPublicBaseUrl->invoke($manager, [ + 'app' => 'frontend', + 'channel_slug' => 'beta', + 'deploy_context_json' => json_encode([ + 'endpoint_mode' => 'auto', + 'public_gateway_host' => 'gateway.example.test', + ]), + ]))->toBe('https://gateway.example.test/beta/frontend'); +}); + +it('exposes release version git commit metadata for runtime channel cards', function (): void { + $manager = new release_manager(); + $publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion'); + $publicVersion->setAccessible(true); + + $version = $publicVersion->invoke($manager, [ + 'id' => 12, + 'app' => 'frontend', + 'repository' => 'truckwash/front-end-vue', + 'branch' => 'release/canary', + 'commit_sha' => 'c0ffee0000001111222233334444555566667777', + 'tag' => null, + 'version_label' => 'frontend-canary', + 'build_url' => null, + 'artifact_url' => null, + 'deployed_url' => null, + 'status' => 'active', + 'metadata_json' => json_encode([ + 'github_access' => [ + 'commit' => [ + 'sha' => 'c0ffee0000001111222233334444555566667777', + 'authored_at' => '2026-05-19T10:15:00Z', + ], + ], + ]), + 'created_at' => '2026-05-19 10:10:00', + 'deployed_at' => '2026-05-19 10:20:00', + ]); + + expect($version['commit_sha'])->toBe('c0ffee0000001111222233334444555566667777'); + expect($version['commit']['sha'])->toBe('c0ffee0000001111222233334444555566667777'); + expect($version['commit_authored_at'])->toBe('2026-05-19T10:15:00Z'); + expect($version['deployed_at'])->toBe('2026-05-19 10:20:00'); +}); + +it('only chooses requested runtime channels from channels available to the principal', function (): void { + $manager = new release_manager(); + $choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel'); + $choose->setAccessible(true); + + $stable = [ + 'id' => 1, + 'slug' => 'stable', + 'name' => 'Stable', + 'default_channel' => 1, + ]; + $canary = [ + 'id' => 2, + 'slug' => 'canary', + 'name' => 'Canary', + 'default_channel' => 0, + ]; + $internal = [ + 'id' => 3, + 'slug' => 'internal', + 'name' => 'Internal', + 'default_channel' => 0, + 'enabled' => 1, + ]; + + expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary); + expect($choose->invoke($manager, $stable, [$stable, $canary], 'stable'))->toBe($stable); + expect($choose->invoke($manager, $stable, [$stable, $canary], 'internal'))->toBe($stable); + expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable); + expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary); + expect($internal['enabled'])->toBe(1); +}); + +it('normalizes release assignment subject suggestions without leaking private fields', function (): void { + $suggestion = release_manager::publicAssignmentSubjectSuggestion([ + 'subject_type' => 'USER', + 'subject_id' => 42, + 'title' => ' Dispatcher ', + 'description' => 'Customer #424242 / dispatcher@example.test', + 'icon' => 'fas fa-user', + 'source' => 'users', + 'password' => 'secret', + 'two_factor_secret' => 'private', + ]); + + expect($suggestion)->toBe([ + 'subject_type' => 'user', + 'subject_id' => '42', + 'label' => 'Dispatcher - Customer #424242 / dispatcher@example.test', + 'title' => 'Dispatcher', + 'description' => 'Customer #424242 / dispatcher@example.test', + 'icon' => 'fas fa-user', + 'source' => 'users', + ]); + expect(array_keys($suggestion))->not->toContain('password'); + expect(array_keys($suggestion))->not->toContain('two_factor_secret'); + expect(release_manager::publicAssignmentSubjectSuggestion([ + 'subject_type' => 'invalid', + 'subject_id' => 42, + 'title' => 'Invalid', + ]))->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Replication/ReplicaFailoverManagerTest.php b/services/nginx/app/tests/Unit/Replication/ReplicaFailoverManagerTest.php new file mode 100644 index 00000000..8f07203f --- /dev/null +++ b/services/nginx/app/tests/Unit/Replication/ReplicaFailoverManagerTest.php @@ -0,0 +1,236 @@ + $id, + 'kind' => $kind, + 'label' => $kind . ' replica ' . $id, + 'host' => $kind . '-replica-' . $id, + 'port' => match ($kind) { + 'database' => 3306, + 'redis' => 6379, + 'minio' => 9000, + default => 1, + }, + 'database_name' => $kind === 'database' ? 'truckwash' : null, + 'database_index' => $kind === 'redis' ? 0 : null, + 'username' => $kind === 'minio' ? 'access-key' : 'app', + 'password_secret' => 'secret', + 'admin_username' => '', + 'admin_password_secret' => '', + 'role' => 'replica', + 'status' => 'ok', + 'options_json' => $kind === 'minio' + ? json_encode(['endpoint' => 'http://minio-replica-' . $id . ':9000', 'buckets' => ['attachments']]) + : null, + 'last_status_json' => json_encode([ + 'status' => 'ok', + 'replication_percent' => 100, + 'blockers' => [], + 'checked_at' => $checkedAt, + ]), + 'last_checked_at' => $checkedAt, + 'deleted_at' => null, + ]; + + return array_replace($base, $overrides); +} + +it('normalizes failover config defaults and per-kind enablement', function (): void { + $defaults = replica_failover_manager::normalizeConfig([]); + + expect($defaults)->toMatchArray([ + 'enabled' => false, + 'database_enabled' => false, + 'redis_enabled' => false, + 'minio_enabled' => false, + 'max_status_age_seconds' => 90, + ]); + + $config = replica_failover_manager::normalizeConfig([ + 'enabled' => 'true', + 'database_enabled' => '1', + 'redis_enabled' => false, + 'minio_enabled' => 'yes', + 'max_status_age_seconds' => '120', + ]); + + expect(replica_failover_manager::kindEnabled($config, 'database'))->toBeTrue(); + expect(replica_failover_manager::kindEnabled($config, 'redis'))->toBeFalse(); + expect(replica_failover_manager::kindEnabled($config, 'minio'))->toBeTrue(); + expect($config['max_status_age_seconds'])->toBe(120); +}); + +it('requires strict fresh 100 percent replica status for candidates', function (): void { + $now = time(); + $fresh = failover_test_host('database', 2, date('c', $now - 30)); + $stale = failover_test_host('database', 3, date('c', $now - 120)); + $notCaughtUp = failover_test_host('database', 4, date('c', $now - 10), [ + 'last_status_json' => json_encode([ + 'status' => 'ok', + 'replication_percent' => 99.99, + 'blockers' => [], + 'checked_at' => date('c', $now - 10), + ]), + ]); + $blocked = failover_test_host('database', 5, date('c', $now - 10), [ + 'last_status_json' => json_encode([ + 'status' => 'degraded', + 'replication_percent' => 100, + 'blockers' => ['lagging'], + 'checked_at' => date('c', $now - 10), + ]), + ]); + + expect(replica_failover_manager::snapshotHostIsStrictlyFresh($fresh, 90, $now))->toBeTrue(); + expect(replica_failover_manager::snapshotHostIsStrictlyFresh($stale, 90, $now))->toBeFalse(); + expect(replica_failover_manager::snapshotHostIsStrictlyFresh($notCaughtUp, 90, $now))->toBeFalse(); + expect(replica_failover_manager::snapshotHostIsStrictlyFresh($blocked, 90, $now))->toBeFalse(); +}); + +it('selects the freshest eligible replica for failover', function (): void { + $now = time(); + $older = failover_test_host('redis', 2, date('c', $now - 40)); + $newer = failover_test_host('redis', 3, date('c', $now - 10)); + $wrongKind = failover_test_host('minio', 4, date('c', $now - 5)); + + $candidate = replica_failover_manager::snapshotFailoverCandidate([$older, $newer, $wrongKind], 'redis', 90, $now); + + expect($candidate['id'])->toBe(3); +}); + +it('promotes enabled startup dependencies from snapshot in dependency order', function (): void { + $path = tempnam(sys_get_temp_dir(), 'failover-snapshot-'); + $events = []; + $now = time(); + + replication_bootstrap_config::writeSnapshot([ + 'active' => [ + 'database' => ['host' => 'db-primary', 'database' => 'truckwash', 'user' => 'app', 'password_secret' => 'secret'], + 'redis' => ['host' => 'redis-primary', 'database' => 0, 'user' => '', 'password_secret' => 'secret'], + 'minio' => ['endpoint' => 'http://minio-primary:9000', 'access_key' => 'access-key', 'secret_key_secret' => 'secret'], + ], + 'failover' => [ + 'config' => [ + 'enabled' => true, + 'database_enabled' => true, + 'redis_enabled' => true, + 'minio_enabled' => true, + 'max_status_age_seconds' => 90, + ], + 'hosts' => [ + 'database' => [failover_test_host('database', 11, date('c', $now - 10))], + 'redis' => [failover_test_host('redis', 12, date('c', $now - 10))], + 'minio' => [failover_test_host('minio', 13, date('c', $now - 10))], + ], + ], + ], $path); + + try { + $result = replica_failover_manager::applyStartupFailoverFromSnapshot($path, [ + 'primary_down' => function (string $kind) use (&$events): bool { + $events[] = 'primary:' . $kind; + return true; + }, + 'candidate_reachable' => function (string $kind) use (&$events): bool { + $events[] = 'reachable:' . $kind; + return true; + }, + 'promote_candidate' => function (string $kind) use (&$events): void { + $events[] = 'promote:' . $kind; + }, + ]); + + $snapshot = replication_bootstrap_config::loadSnapshot($path); + + expect($result['changed'])->toBeTrue(); + expect($events)->toBe([ + 'primary:database', + 'reachable:database', + 'promote:database', + 'primary:redis', + 'reachable:redis', + 'promote:redis', + 'primary:minio', + 'reachable:minio', + 'promote:minio', + ]); + expect($snapshot['active']['database']['id'])->toBe(11); + expect($snapshot['active']['redis']['id'])->toBe(12); + expect($snapshot['active']['minio']['id'])->toBe(13); + expect($snapshot['pending_failovers'])->toHaveCount(3); + } finally { + @unlink($path); + } +}); + +it('does not promote a disabled dependency during startup failover', function (): void { + $path = tempnam(sys_get_temp_dir(), 'failover-snapshot-'); + $events = []; + + replication_bootstrap_config::writeSnapshot([ + 'active' => [ + 'redis' => ['host' => 'redis-primary', 'database' => 0, 'user' => '', 'password_secret' => 'secret'], + ], + 'failover' => [ + 'config' => [ + 'enabled' => true, + 'redis_enabled' => false, + 'max_status_age_seconds' => 90, + ], + 'hosts' => [ + 'redis' => [failover_test_host('redis', 12, date('c'))], + ], + ], + ], $path); + + try { + $result = replica_failover_manager::applyStartupFailoverFromSnapshot($path, [ + 'primary_down' => function (string $kind) use (&$events): bool { + $events[] = $kind; + return true; + }, + ]); + + expect($result['changed'])->toBeFalse(); + expect($result['results']['redis']['reason'])->toBe('disabled'); + expect($events)->toBe([]); + } finally { + @unlink($path); + } +}); + +it('wires the failover module config endpoint and promotion paths', function (): void { + $route = file_get_contents(app_path('routes/moduleConfigRoute.php')); + $module = file_get_contents(app_path('modules/failover/failover_c.php')); + $enabledConfig = file_get_contents(app_path('modules/failover/config/failover_enabled_c.php')); + $manager = file_get_contents(app_path('classes/replication_manager.php')); + $startup = file_get_contents(app_path('classes/replica_failover_manager.php')); + + expect($route)->toContain("'/failover/config'"); + expect($route)->toContain('modules_failover_config'); + expect($enabledConfig)->toContain("'enabled'"); + expect($module)->toContain('failover_database_enabled_c::class'); + expect($module)->toContain('failover_redis_enabled_c::class'); + expect($module)->toContain('failover_minio_enabled_c::class'); + expect($module)->toContain('failover_max_status_age_seconds_c::class'); + expect($manager)->toContain('promoteDatabaseHostForFailover'); + expect($manager)->toContain('promoteRedisHostForFailover'); + expect($manager)->toContain('promoteMinioHostForFailover'); + expect($manager)->toContain("['REPLICAOF', 'NO', 'ONE']"); + expect($manager)->toContain('runAutomaticFailoverMonitor'); + expect($startup)->toContain('replication_bootstrap_config::loadSnapshot($path)'); + expect($startup)->not->toContain('module_config'); +}); diff --git a/services/nginx/app/tests/Unit/Replication/ReplicationManagerStatusTest.php b/services/nginx/app/tests/Unit/Replication/ReplicationManagerStatusTest.php new file mode 100644 index 00000000..4b15666b --- /dev/null +++ b/services/nginx/app/tests/Unit/Replication/ReplicationManagerStatusTest.php @@ -0,0 +1,696 @@ +toBe(11); + expect(replication_manager::mysqlGtidCoveragePercent($source, $executed))->toBe(72.73); +}); + +it('reports empty source GTID sets as caught up', function (): void { + expect(replication_manager::mysqlGtidCoveragePercent('', ''))->toBe(100.0); +}); + +it('computes Redis offset percentages safely', function (): void { + expect(replication_manager::redisOffsetPercent(1000, 750))->toBe(75.0); + expect(replication_manager::redisOffsetPercent(0, 0))->toBe(100.0); + expect(replication_manager::redisOffsetPercent(1000, 1250))->toBe(100.0); + expect(replication_manager::redisReplicationPercentFromInfo( + ['master_repl_offset' => 1000], + ['slave_repl_offset' => 750] + ))->toBe(75.0); + expect(replication_manager::redisReplicationPercentFromInfo( + ['master_repl_offset' => 1000], + ['master_sync_in_progress' => '1', 'master_sync_total_bytes' => '1000', 'master_sync_left_bytes' => '250'] + ))->toBe(75.0); + expect(replication_manager::redisReplicationPercentFromInfo( + ['master_repl_offset' => 1000], + ['master_sync_in_progress' => '1'] + ))->toBe(5.0); + expect(replication_manager::redisProvisionProgress(0.0))->toBe(5.0); + expect(replication_manager::redisProvisionProgress(100.0, ['Redis replica link to primary is not up.']))->toBe(99.99); + expect(replication_manager::replicationHealthStatus(true, 'replica', 5.0, []))->toBe('degraded'); + expect(replication_manager::replicationHealthStatus(true, 'replica', 5.0, [], false))->toBe('ok'); + expect(replication_manager::replicationHealthStatus(true, 'replica', 100.0, []))->toBe('ok'); +}); + +it('computes MariaDB GTID coverage by domain sequence', function (): void { + $source = '0-1-10,1-1-20'; + $replica = '0-2-8,1-3-20'; + + expect(replication_manager::mariadbGtidCoveragePercent($source, $replica))->toBe(93.33); + expect(replication_manager::mariadbGtidCoveragePercent('', ''))->toBe(100.0); +}); + +it('normalizes public replication kind aliases', function (): void { + expect(replication_manager::normalizeKind('databases'))->toBe('database'); + expect(replication_manager::normalizeKind('mysql'))->toBe('database'); + expect(replication_manager::normalizeKind('redis'))->toBe('redis'); + expect(replication_manager::normalizeKind('minio'))->toBe('minio'); + expect(replication_manager::normalizeKind('s3'))->toBe('minio'); + expect(replication_manager::normalizeKind('object-storage'))->toBe('minio'); +}); + +it('generates replication-ready MariaDB compose templates without embedding secrets', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'database', + 'role' => 'replica', + 'service_name' => 'MariaDB Replica 2', + 'database' => 'nnks_db', + 'username' => 'nnks_db_user', + 'host_port' => 5433, + 'server_id' => 2, + ]); + + expect($template['kind'])->toBe('database'); + expect($template['role'])->toBe('replica'); + expect($template['service_name'])->toBe('mariadb-replica-2'); + expect($template['compose'])->toContain('image: "mariadb:11"'); + expect($template['compose'])->toContain('"--server-id=2"'); + expect($template['compose'])->toContain('"--log-bin=/var/lib/mysql/mariadb-bin"'); + expect($template['compose'])->toContain('"--binlog-format=ROW"'); + expect($template['compose'])->toContain('"--gtid-strict-mode=ON"'); + expect($template['compose'])->toContain('"--read-only=ON"'); + expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.logs"'); + expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.edge_gateway_log_entries"'); + expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.replication_status_snapshots"'); + expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.system_search_documents"'); + expect($template['compose'])->toContain('"5433:3306"'); + expect($template['compose'])->toContain('mariadb-replica-2-seed'); + expect($template['compose'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD'); + expect($template['compose'])->toContain('mariadb-dump --host="$${MARIADB_PRIMARY_HOST}"'); + expect($template['compose'])->toContain('--ignore-table="$${MARIADB_SEED_DATABASE}.logs"'); + expect($template['compose'])->toContain('--ignore-table="$${MARIADB_SEED_DATABASE}.edge_gateway_log_entries"'); + expect($template['compose'])->toContain('--ignore-table="$${MARIADB_SEED_DATABASE}.replication_status_snapshots"'); + expect($template['compose'])->toContain('--ignore-table="$${MARIADB_SEED_DATABASE}.system_search_documents"'); + expect($template['compose'])->toContain('--no-data "$${MARIADB_SEED_DATABASE}" "$${table}"'); + expect($template['compose'])->toContain('touch "$${marker}"'); + expect($template['compose'])->toContain('${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}'); + expect($template['compose'])->not->toContain(''); + expect($template['env'])->toMatch('/MARIADB_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/'); + expect($template['env'])->toMatch('/MARIADB_PASSWORD=[A-Za-z0-9_-]{32}/'); + expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD='); + expect($template['credentials']['password'])->toMatch('/^[A-Za-z0-9_-]{32}$/'); + expect($template['credentials']['admin_password'])->toMatch('/^[A-Za-z0-9_-]{32}$/'); + expect($template['credentials']['port'])->toBe(5433); + expect($template['credentials']['allow_preseeded_replica'])->toBeTrue(); + expect($template['seed_command'])->toContain('mariadb-dump'); + expect($template['seed_command'])->toContain('--gtid'); + expect($template['seed_command'])->toContain('--master-data=2'); + expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.logs\''); + expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.edge_gateway_log_entries\''); + expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.replication_status_snapshots\''); + expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.system_search_documents\''); +}); + +it('can embed primary admin credentials in generated MariaDB replica env files', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'database', + 'role' => 'replica', + 'primary_admin_username' => 'primary-root', + 'primary_admin_password' => 'primary-secret', + ]); + + expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_USER=primary-root'); + expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD=primary-secret'); +}); + +it('detects missing database tables before provisioning a preseeded replica', function (): void { + expect(replication_manager::missingDatabaseTables( + ['customers', 'edge_gateways', 'orders'], + ['customers', 'orders'] + ))->toBe(['edge_gateways']); +}); + +it('seeds MariaDB replicas in place instead of requiring container recreation', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('advanceMariaDbReplicaSeed($operationId, $primary, $host, $target)'); + expect($content)->toContain('MARIADB_SEED_BATCH_ROWS'); + expect($content)->toContain('MARIADB_SEED_STEP_SECONDS'); + expect($content)->toContain('activeOperationId($kind, $id, \'provision\')'); + expect($content)->toContain('updateOperationProgress($operationId, $progress, $message, $context)'); + expect($content)->toContain("application_write_freeze::freeze('MariaDB replica seed is copying data.'"); + expect($content)->toContain('DROP DATABASE IF EXISTS'); + expect($content)->toContain('CREATE DATABASE '); + expect($content)->toContain('SHOW CREATE TABLE'); + expect($content)->toContain('SET GLOBAL gtid_slave_pos'); + expect($content)->toContain('databasePrimaryKeyColumnNames($source, $primaryDatabase, $tableName)'); + expect($content)->toContain('$target->begin_transaction()'); + expect($content)->toContain("'connect_without_database' => \$usePreseededReplica"); +}); + +it('keeps operational and derived tables schema-only during MariaDB seeding and replication', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('MARIADB_SCHEMA_ONLY_TABLES'); + expect($content)->toContain("'logs'"); + expect($content)->toContain("'edge_gateway_log_entries'"); + expect($content)->toContain("'replication_status_snapshots'"); + expect($content)->toContain("'replication_operations'"); + expect($content)->toContain("'replication_audit_logs'"); + expect($content)->toContain("'system_search_documents'"); + expect($content)->toContain("'skip_data' => \$skipData"); + expect($content)->toContain('createMariaDbReplicaTable('); + expect($content)->toContain('SET GLOBAL replicate_ignore_table'); + expect($content)->toContain('--replicate-ignore-table='); + expect($content)->toContain('mariaDbSchemaOnlyDumpIgnoreArgs'); + expect($content)->toContain('mariaDbSchemaOnlySeedCommandIgnoreArgs'); + expect($content)->toContain('databaseSchemaOnlyTablesWithRows'); + expect($content)->toContain('schemaOnlyTablesContainRowsBlocker'); + expect($content)->toContain('RESET SLAVE ALL'); + expect($content)->toContain('mariaDbSeedContextRequiresFilterReset($context)'); +}); + +it('allows failed replicas to be removed without allowing primary or healthy replica removal', function (): void { + expect(replication_manager::replicationHostCanBeRemoved(['role' => 'primary', 'status' => 'ok']))->toBeFalse(); + expect(replication_manager::replicationHostCanBeRemoved(['role' => 'inactive', 'status' => 'inactive']))->toBeTrue(); + expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'degraded']))->toBeTrue(); + expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'ok']))->toBeFalse(); + + $content = file_get_contents(app_path('classes/replication_manager.php')); + expect($content)->toContain('coolify_manager::replicationHostCanBeRemoved'); + expect($content)->toContain('coolify_manager::markTargetsRemovedForReplicationHost'); +}); + +it('supports metadata-only replication host renames', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('function renameHost('); + expect($content)->toContain('host_renamed'); + expect($content)->toContain('coolify_manager::syncLabelForReplicationHost'); + expect($content)->toContain('writeBootstrapSnapshot()'); + expect($content)->toContain('Replication host label must be 128 characters or fewer.'); +}); + +it('generates Redis replica compose templates with primary connection placeholders', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'redis', + 'role' => 'replica', + 'service_name' => 'Redis Replica', + 'host_port' => 6380, + 'primary_host' => 'redis-primary.internal', + 'primary_port' => 6379, + ]); + + expect($template['kind'])->toBe('redis'); + expect($template['compose'])->toContain('image: "redis:7"'); + expect($template['compose'])->toContain('REDIS_PASSWORD: "${REDIS_PASSWORD:?set REDIS_PASSWORD}"'); + expect($template['compose'])->toContain('REDIS_PRIMARY_HOST: "${REDIS_PRIMARY_HOST:?set REDIS_PRIMARY_HOST}"'); + expect($template['compose'])->toContain('REDIS_PRIMARY_PORT: "${REDIS_PRIMARY_PORT:-6379}"'); + expect($template['compose'])->toContain('${REDIS_PRIMARY_PASSWORD:?set REDIS_PRIMARY_PASSWORD}'); + expect($template['compose'])->toContain('REDIS_PRIMARY_USERNAME: "${REDIS_PRIMARY_USERNAME:-}"'); + expect($template['compose'])->toContain('if [ ! -f /data/redis.conf ]; then'); + expect($template['compose'])->toContain('> /data/redis.conf'); + expect($template['compose'])->toContain('exec redis-server /data/redis.conf'); + expect($template['compose'])->toContain('echo "replicaof $$REDIS_PRIMARY_HOST $${REDIS_PRIMARY_PORT:-6379}"'); + expect($template['compose'])->toContain('echo "masterauth $$REDIS_PRIMARY_PASSWORD"'); + expect($template['compose'])->toContain('echo "masteruser $$REDIS_PRIMARY_USERNAME"'); + expect($template['compose'])->toContain('redis-cli --no-auth-warning -a \"$${REDIS_PASSWORD}\" ping | grep PONG'); + expect($template['compose'])->not->toContain($template['credentials']['password']); + expect($template['env'])->toMatch('/REDIS_PASSWORD=[A-Za-z0-9_-]{32}/'); + expect($template['env'])->toContain('REDIS_PRIMARY_HOST=redis-primary.internal'); + expect($template['env'])->toContain('REDIS_PRIMARY_PORT=6379'); + expect($template['env'])->toContain("REDIS_PRIMARY_PASSWORD=\n"); + expect($template['env'])->toContain("REDIS_PRIMARY_USERNAME=\n"); + expect($template['credentials']['password'])->toMatch('/^[A-Za-z0-9_-]{32}$/'); +}); + +it('generates MinIO replica compose templates without embedding secrets', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'minio', + 'role' => 'replica', + 'service_name' => 'MinIO Replica 1', + 'host' => 'node2.truckwash.dk', + 'scheme' => 'http', + 'host_port' => 9010, + 'console_port' => 9011, + 'buckets' => ['attachments', 'uploads'], + ]); + + expect($template['kind'])->toBe('minio'); + expect($template['engine'])->toBe('minio'); + expect($template['service_name'])->toBe('minio-replica-1'); + expect($template['host_port'])->toBe(9010); + expect($template['console_port'])->toBe(9011); + expect($template['compose'])->toContain('image: "minio/minio:latest"'); + expect($template['compose'])->toContain('MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"'); + expect($template['compose'])->toContain('MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"'); + expect($template['compose'])->toContain('MINIO_SERVER_URL: "${MINIO_SERVER_URL:-}"'); + expect($template['compose'])->toContain('MINIO_BROWSER_REDIRECT_URL: "${MINIO_BROWSER_REDIRECT_URL:-}"'); + expect($template['compose'])->toContain('"9010:9000"'); + expect($template['compose'])->toContain('"9011:9001"'); + expect($template['compose'])->toContain('mc mb --with-lock --ignore-existing'); + expect($template['compose'])->toContain('mc version enable'); + expect($template['compose'])->toContain('MINIO_PRIMARY_ENDPOINT'); + expect($template['compose'])->not->toContain($template['credentials']['password']); + expect($template['env'])->toMatch('/MINIO_ROOT_USER=twminio[a-f0-9]{24}/'); + expect($template['env'])->toMatch('/MINIO_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/'); + expect($template['env'])->toContain('MINIO_SERVER_URL=http://node2.truckwash.dk:9010'); + expect($template['env'])->toContain('MINIO_BROWSER_REDIRECT_URL=http://node2.truckwash.dk:9011'); + expect($template['env'])->toContain('MINIO_BUCKETS=attachments,uploads'); + expect($template['env'])->toContain('MINIO_REPLICATION_TRANSFER_LIMIT=25Mi'); + expect($template['env'])->toContain('MINIO_PRIMARY_ENDPOINT='); + expect($template['credentials']['username'])->toMatch('/^twminio[a-f0-9]{24}$/'); + expect($template['credentials']['scheme'])->toBe('http'); + expect($template['credentials']['buckets'])->toBe(['attachments', 'uploads']); + expect($template['credentials']['replication_transfer_limit'])->toBe('25Mi'); + expect($template['credentials']['space_headroom_percent'])->toBe(20.0); +}); + +it('keeps MinIO backup replicas bounded to the recent backup window', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'minio', + 'role' => 'replica', + 'service_name' => 'minio-replica-1', + 'buckets' => ['backups', 'uploads'], + ]); + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect(replication_manager::minioBackupReplicaRetentionDays())->toBe(30); + expect($template['compose'])->toContain('mc ilm rule add --expire-days "30" --noncurrent-expire-days "30"'); + expect($template['env'])->toContain('MINIO_BACKUP_REPLICA_RETENTION_DAYS=30'); + expect($template['steps'])->toContain('The backups bucket is retained on replicas for 30 days; other buckets are fully replicated.'); + expect($content)->not->toContain('seedMinioReplicaBackupWindow'); + expect($content)->not->toContain("'--newer-than'"); + expect($content)->toContain("'--limit-upload'"); + expect($content)->toContain("'--limit-download'"); + expect($content)->toContain("? 'delete,delete-marker'"); + expect($content)->toContain('putBucketLifecycleConfiguration'); + expect($content)->toContain('listObjectVersions'); + expect($content)->toContain('minioBackupReplicaRetentionConfigured'); + expect(replication_manager::minioBackupRetentionBlockers([ + 'buckets' => [ + ['name' => 'backups', 'expired_objects' => 2], + ], + ]))->toBe([ + 'MinIO backup replica contains 2 backup objects older than 30 days. Run provisioning to prune retained backups.', + ]); +}); + +it('prefills MinIO replica compose primary values from current config when available', function (): void { + $previousMinio = $GLOBALS['MINIO'] ?? null; + + $GLOBALS['MINIO'] = [ + 'endpoint' => 'https://minio-primary.internal:9000', + 'access_key' => 'primary-access', + 'secret_key' => 'primary-secret', + ]; + + try { + $template = replication_manager::composeTemplate([ + 'kind' => 'minio', + 'role' => 'replica', + 'service_name' => 'minio-replica-1', + ]); + + expect($template['env'])->toContain('MINIO_PRIMARY_ENDPOINT=https://minio-primary.internal:9000'); + expect($template['env'])->toContain('MINIO_PRIMARY_ACCESS_KEY=primary-access'); + expect($template['env'])->toContain('MINIO_PRIMARY_SECRET_KEY=primary-secret'); + expect($template['compose'])->not->toContain('primary-secret'); + } finally { + if ($previousMinio === null) { + unset($GLOBALS['MINIO']); + } else { + $GLOBALS['MINIO'] = $previousMinio; + } + } +}); + +it('computes MinIO free-space and catch-up math safely', function (): void { + expect(replication_manager::minioRequiredFreeBytes(1000))->toBe(1200); + expect(replication_manager::minioByteReplicationPercent(1000, 750))->toBe(75.0); + expect(replication_manager::minioByteReplicationPercent(0, 0))->toBe(100.0); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 3.2, + 'raw' => ['storage' => ['measured' => true]], + ]))->toBe(3.2); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 0, + 'raw' => ['storage' => ['measured' => false]], + ]))->toBe(5.0); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 2.5, + 'raw' => ['progress_source' => 'minio_replicate_status'], + ]))->toBe(2.5); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 100, + 'raw' => ['storage' => ['measured' => true]], + ]))->toBe(100.0); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 100, + 'blockers' => ['MinIO replica has not caught up.'], + 'raw' => ['storage' => ['measured' => true]], + ]))->toBe(99.9); + expect(replication_manager::minioReplicationProgressFromStatusOutput([ + 'target' => [ + 'replicated' => ['size' => 750], + 'pending' => ['size' => 250], + ], + ])['replication_percent'])->toBe(75.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput([ + 'objects' => [ + 'completed' => 9, + 'pending' => 1, + ], + ])['replication_percent'])->toBe(90.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput([ + 'status' => 'complete', + ])['replication_percent'])->toBe(100.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput( + '{"replicatedSize": 750, "pendingSize": 250}' + )['replication_percent'])->toBe(75.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput( + '{"replicaSize": 750, "pendingSize": 250}' + )['replication_percent'])->toBe(75.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput( + 'target-a: 100%, target-b: 5%' + )['replication_percent'])->toBe(5.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput( + 'target-a: 100%, target-b: 100%' + )['replication_percent'])->toBe(100.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput([ + 'completedReplicationSize' => 1000, + 'queued' => [ + 'curr' => ['count' => 0, 'bytes' => 0], + 'avg' => ['count' => 42, 'bytes' => 25000000], + 'peak' => ['count' => 100, 'bytes' => 50000000], + ], + ])['replication_percent'])->toBe(100.0); + expect(replication_manager::minioBucketCountsTowardCatchUp('uploads'))->toBeTrue(); + expect(replication_manager::minioBucketCountsTowardCatchUp('backups'))->toBeFalse(); + $boundedBackupProgress = replication_manager::minioCatchUpProgressFromBucketStatuses([ + 'uploads' => ['replication_percent' => 100.0, 'blockers' => [], 'stats' => []], + 'backups' => ['replication_percent' => 99.74, 'blockers' => ['MinIO replica has not caught up.'], 'stats' => []], + ]); + expect($boundedBackupProgress['replication_percent'])->toBe(100.0); + expect($boundedBackupProgress['blockers'])->toBe([]); + expect($boundedBackupProgress['ignored_buckets'])->toBe(['backups']); + $onlyBoundedBackupProgress = replication_manager::minioCatchUpProgressFromBucketStatuses([ + 'backups' => ['replication_percent' => 5.0, 'blockers' => ['MinIO replica has not caught up.'], 'stats' => []], + ]); + expect($onlyBoundedBackupProgress['replication_percent'])->toBe(100.0); + expect($onlyBoundedBackupProgress['basis'])->toBe('bounded_retention_only'); + $liveQueueProgress = replication_manager::minioCatchUpProgressFromBucketStatuses([ + 'uploads' => [ + 'replication_percent' => 99.98, + 'blockers' => ['MinIO replica has not caught up.'], + 'stats' => [ + 'completed_bytes' => 53149249190, + 'pending_bytes' => 3439936, + 'failed_bytes' => 0, + 'total_bytes' => 0, + 'completed_count' => 199368, + 'pending_count' => 7, + 'failed_count' => 0, + 'total_count' => 0, + ], + ], + ]); + expect($liveQueueProgress['replication_percent'])->toBe(100.0); + expect($liveQueueProgress['blockers'])->toBe([]); + expect($liveQueueProgress['live_tolerance']['within_tolerance'])->toBeTrue(); + expect(replication_manager::minioSpaceBlockers(1199, 1200))->toContain('MinIO target does not have enough free space. Required 1200 bytes, available 1199 bytes.'); + expect(replication_manager::minioSpaceBlockers(null, 1200))->toBe([]); + expect(replication_manager::minioSpaceBlockers(1200, 1200))->toBe([]); + expect(replication_manager::minioAvailableBytesFromAdminInfo([ + 'servers' => [ + ['drives' => [['availableSpace' => 4096]]], + ], + ]))->toBe(4096); + expect(replication_manager::normalizeMinioBuckets('Attachments, uploads backups'))->toBe(['attachments', 'uploads', 'backups']); + expect(replication_manager::minioDefaultReplicationTransferLimit())->toBe('25Mi'); + expect(replication_manager::normalizeMinioTransferLimit('25MiB/s'))->toBe('25Mi'); + expect(replication_manager::normalizeMinioTransferLimit('100 MB'))->toBe('100M'); + expect(replication_manager::normalizeMinioTransferLimit('0'))->toBe(''); +}); + +it('allows the MinIO client binary to be configured explicitly', function (): void { + $previous = getenv('MINIO_MC_BINARY'); + putenv('MINIO_MC_BINARY=/opt/minio/mc'); + + try { + $method = new ReflectionMethod(replication_manager::class, 'minioClientBinary'); + $method->setAccessible(true); + + expect($method->invoke(null))->toBe('/opt/minio/mc'); + } finally { + if ($previous === false) { + putenv('MINIO_MC_BINARY'); + } else { + putenv('MINIO_MC_BINARY=' . $previous); + } + } +}); + +it('supports MinIO client runtime fallback configuration', function (): void { + $previousDownloadUrl = getenv('MINIO_MC_DOWNLOAD_URL'); + $previousAutoInstall = getenv('MINIO_MC_AUTO_INSTALL'); + $previousCommandTimeout = getenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS'); + $previousDownloadTimeout = getenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS'); + putenv('MINIO_MC_DOWNLOAD_URL=https://example.test/mc'); + putenv('MINIO_MC_AUTO_INSTALL=0'); + putenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS=3'); + putenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS=4'); + + try { + $downloadUrl = new ReflectionMethod(replication_manager::class, 'minioClientDownloadUrl'); + $downloadUrl->setAccessible(true); + $autoInstall = new ReflectionMethod(replication_manager::class, 'minioClientAutoInstallEnabled'); + $autoInstall->setAccessible(true); + $commandTimeout = new ReflectionMethod(replication_manager::class, 'minioClientCommandTimeoutSeconds'); + $commandTimeout->setAccessible(true); + $downloadTimeout = new ReflectionMethod(replication_manager::class, 'minioClientDownloadTimeoutSeconds'); + $downloadTimeout->setAccessible(true); + $commandLabel = new ReflectionMethod(replication_manager::class, 'minioClientCommandLabel'); + $commandLabel->setAccessible(true); + + expect($downloadUrl->invoke(null))->toBe('https://example.test/mc'); + expect($autoInstall->invoke(null))->toBeFalse(); + expect($commandTimeout->invoke(null))->toBe(3); + expect($downloadTimeout->invoke(null))->toBe(4); + expect($commandLabel->invoke(null, [ + 'alias', + 'set', + 'target', + 'http://minio.example.test:9010', + 'access-key', + 'secret-key', + ]))->toContain('[redacted]'); + expect($commandLabel->invoke(null, [ + 'alias', + 'set', + 'target', + 'http://minio.example.test:9010', + 'access-key', + 'secret-key', + ]))->not->toContain('secret-key'); + } finally { + if ($previousDownloadUrl === false) { + putenv('MINIO_MC_DOWNLOAD_URL'); + } else { + putenv('MINIO_MC_DOWNLOAD_URL=' . $previousDownloadUrl); + } + if ($previousAutoInstall === false) { + putenv('MINIO_MC_AUTO_INSTALL'); + } else { + putenv('MINIO_MC_AUTO_INSTALL=' . $previousAutoInstall); + } + if ($previousCommandTimeout === false) { + putenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS'); + } else { + putenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS=' . $previousCommandTimeout); + } + if ($previousDownloadTimeout === false) { + putenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS'); + } else { + putenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS=' . $previousDownloadTimeout); + } + } +}); + +it('wires MinIO replication through routes and bootstrap snapshots', function (): void { + $manager = file_get_contents(app_path('classes/replication_manager.php')); + $routes = file_get_contents(app_path('routes/superuserReplicationRoute.php')); + $openapi = file_get_contents(app_path('openapi.yaml')); + + expect($manager)->toContain('private const KIND_MINIO'); + expect($manager)->toContain('provisionMinioHost($host, $operationId)'); + expect($manager)->toContain('promoteMinioHost($host)'); + expect($manager)->toContain('testMinioHost($host)'); + expect($manager)->toContain('minioTargetFreeBytes($host)'); + expect($manager)->toContain('minioRequiredFreeBytes'); + expect($manager)->toContain('minioReplicationConfiguredForHosts($primary, $host)'); + expect($manager)->toContain("'--priority',"); + expect($manager)->toContain('minioReplicationTransferLimitArgs'); + expect($manager)->toContain('MINIO_PROGRESS_SCAN_INTERVAL_SECONDS'); + expect($manager)->toContain('MINIO_INCOMPLETE_PROGRESS_MAX_PERCENT'); + expect($manager)->toContain('MINIO_MC_COMMAND_TIMEOUT_SECONDS'); + expect($manager)->toContain('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS'); + expect($manager)->toContain('proc_terminate($process'); + expect($manager)->toContain("'connect_timeout' => self::MINIO_S3_CONNECT_TIMEOUT_SECONDS"); + expect($manager)->toContain("'retries' => 0"); + expect($manager)->toContain('&& $forceStorageScan;'); + expect($manager)->not->toContain('$isPrimary || $forceStorageScan'); + expect($manager)->toContain('sanitizePublicLastStatus'); + expect($manager)->toContain('MinIO primary object-scan timeouts do not indicate primary availability failure.'); + expect($manager)->toContain('minioProvisionProgress($status)'); + expect($manager)->toContain('MinIO replica is syncing. Copied'); + expect($manager)->toContain("['mb', '--with-lock', '--ignore-existing', 'target/' . \$bucket]"); + expect($manager)->toContain('repairMinioTargetBucketObjectLockIfEmpty($configDir, $target, $bucket, $message)'); + expect($manager)->toContain('minioBucketHasObjects($target, $bucket)'); + expect($manager)->toContain("'skip_storage_scan' => true"); + expect($manager)->toContain('lastStatusReplicationPercent($host, 5.0)'); + expect($manager)->toContain('completeReadyMinioProvisionOperation'); + expect($manager)->toContain('MinIO replication target is caught up.'); + expect($manager)->toContain('shouldAdvanceActiveProvisionDuringRefresh'); + expect($manager)->toContain('provisionHost((string)$host[\'kind\'], (int)$host[\'id\'])'); + expect($manager)->toContain('stale targets do not keep a healthy current target below 100%'); + $normalizedManager = str_replace("\r\n", "\n", $manager); + expect($normalizedManager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket"); + expect($normalizedManager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket"); + expect($manager)->toContain('private function minioBucketStats('); + expect($manager)->toContain("'minio' => ["); + expect($routes)->toContain("/superuser/replication/minio"); + expect($openapi)->toContain('enum: [database, redis, minio]'); + expect($openapi)->toContain('endpoint:'); + expect($openapi)->toContain('space_headroom_percent:'); +}); + +it('provisions Redis replicas after a connectivity-only preflight and reports sync progress', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('provisionRedisHost($host, $operationId)'); + expect($content)->toContain("testRedisHost(array_merge(\$host, ['test_connectivity_only' => true]))"); + expect($content)->toContain("executeRaw(['REPLICAOF', (string)\$primary['host'], (string)\$primary['port']])"); + expect($content)->toContain("executeRaw(['CONFIG', 'REWRITE'])"); + expect($content)->toContain('Redis replication was configured; waiting for the replica to catch up.'); + expect($content)->toContain('$onlySyncBlockers'); + expect($content)->toContain('redisProvisionProgress'); + expect($content)->toContain('Redis replication is configured and syncing in the background.'); + expect($content)->toContain('Redis replication is configured, but the replica is waiting for the primary link.'); +}); + +it('keeps Redis promotion caught-up, durable, and metadata-safe', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain("application_write_freeze::freeze('Replication promotion in progress.'"); + expect($content)->toContain("if (\$status['blockers'] !== [] || (float)\$status['replication_percent'] < 100.0)"); + expect($content)->toContain("executeRaw(['REPLICAOF', 'NO', 'ONE'])"); + expect($content)->toContain("executeRaw(['CONFIG', 'REWRITE'])"); + expect($content)->toContain('switchPrimary(self::KIND_REDIS'); + expect($content)->toContain('writeBootstrapSnapshot()'); +}); + +it('does not require replica SQL threads before database provisioning configures them', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain("'test_connectivity_only' => true"); + expect($content)->toContain("'connect_without_database' => \$usePreseededReplica"); + expect($content)->toContain("'healthy' => \$status['blockers'] === []"); + expect($content)->toContain("'message' => \$targetEngine === 'mariadb'"); + expect($content)->toContain('Database replica status is not configured.'); + expect($content)->toContain('Database replication IO thread is not running.'); + expect($content)->toContain('Database replication SQL thread is not running.'); + expect($content)->toContain("if (\$status !== [])"); +}); + +it('keeps replication operation progress schema idempotent for existing installs', function (): void { + $content = file_get_contents(app_path('classes/replication_schema_bootstrap.php')); + + expect($content)->toContain("ensureColumn('replication_operations', 'progress_percent'"); + expect($content)->toContain("ensureColumn('replication_operations', 'message'"); + expect($content)->toContain("ensureColumn('replication_operations', 'context_json'"); +}); + +it('creates the generated replication user on the primary during provisioning', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('$grantHosts = $this->databaseReplicationGrantHosts($host, $targetStatus);'); + expect($content)->toContain('ensureDatabaseReplicationUser($primary, $replicationUser, $replicationPassword, $grantHosts)'); + expect($content)->toContain('databaseDeniedAccountHostsFromText'); + expect($content)->toContain('Access denied for user'); + expect($content)->toContain('foreach ($grantHosts as $grantHost)'); + expect($content)->toContain('shouldRepairDatabaseReplicationAccess'); + expect($content)->toContain('repairDatabaseReplicationAccess'); + expect($content)->toContain('shouldRepairDatabaseReplicationThreads'); + expect($content)->toContain('repairDatabaseReplicationThreads'); + expect($content)->toContain('refreshDatabaseReplicationConnection'); + expect($content)->toContain("CHANGE MASTER TO MASTER_HOST = '%s', MASTER_PORT = %d, MASTER_USER = '%s', MASTER_PASSWORD = '%s', MASTER_USE_GTID = slave_pos"); + expect($content)->toContain("CHANGE REPLICATION SOURCE TO SOURCE_HOST = '%s', SOURCE_PORT = %d, SOURCE_USER = '%s', SOURCE_PASSWORD = '%s', SOURCE_AUTO_POSITION = 1"); + expect($content)->toContain('restartDatabaseReplicationThreads'); + expect($content)->toContain('databaseOnlyReplicationThreadBlockers'); + expect($content)->toContain('databaseAccountHostGrantCandidates'); + expect($content)->toContain('START SLAVE SQL_THREAD'); + expect($content)->toContain('GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO'); +}); + +it('extracts host-specific MariaDB replication account denials', function (): void { + $extract = new ReflectionMethod(replication_manager::class, 'databaseDeniedAccountHostsFromText'); + $extract->setAccessible(true); + $normalize = new ReflectionMethod(replication_manager::class, 'normalizeDatabaseAccountHost'); + $normalize->setAccessible(true); + $candidates = new ReflectionMethod(replication_manager::class, 'databaseAccountHostGrantCandidates'); + $candidates->setAccessible(true); + + expect($extract->invoke(null, "Access denied for user 'replication'@'10.0.1.13' (using password: YES)")) + ->toBe(['10.0.1.13']); + expect($extract->invoke(null, "Access denied for user 'replication'@'fd9c:738d:4130::d' (using password: YES)")) + ->toBe(['fd9c:738d:4130::d']); + expect($normalize->invoke(null, '10.0.1.13'))->toBe('10.0.1.13'); + expect($normalize->invoke(null, 'bad host;drop'))->toBeNull(); + expect($candidates->invoke(null, '10.0.1.13'))->toBe(['10.0.1.13', '10.0.1.%']); + expect($candidates->invoke(null, 'fd9c:738d:4130::d'))->toBe(['fd9c:738d:4130::d', 'fd9c:738d:4130::%']); +}); + +it('identifies stopped database replication threads as a restartable status', function (): void { + $onlyThreadBlockers = new ReflectionMethod(replication_manager::class, 'databaseOnlyReplicationThreadBlockers'); + $onlyThreadBlockers->setAccessible(true); + + expect($onlyThreadBlockers->invoke(null, [ + 'Database replication IO and SQL threads must both be running.', + 'Database replication IO thread is not running.', + 'Database replication SQL thread is not running.', + ]))->toBeTrue(); + expect($onlyThreadBlockers->invoke(null, [ + 'Database replication IO thread is not running.', + "error reconnecting to master 'replication@23.88.23.183:5432'", + ]))->toBeFalse(); +}); + +it('supports MariaDB prerequisites without requiring Oracle MySQL variables', function (): void { + $blockers = replication_manager::databasePrerequisiteBlockers([ + 'server_version' => '11.8.6-MariaDB-ubu2404', + 'log_bin' => 'ON', + 'server_id' => '12', + 'gtid_binlog_pos' => '0-12-42', + ]); + + expect($blockers)->toBe([]); + + $quietServerBlockers = replication_manager::databasePrerequisiteBlockers([ + 'server_version' => '11.8.6-MariaDB-ubu2404', + 'log_bin' => 'ON', + 'server_id' => '12', + 'gtid_current_pos' => '', + ]); + + expect($quietServerBlockers)->toBe([]); +}); + +it('reports MariaDB-specific blockers when GTID or binary logging prerequisites are missing', function (): void { + $blockers = replication_manager::databasePrerequisiteBlockers([ + 'server_version' => '11.8.6-MariaDB-ubu2404', + 'log_bin' => 'OFF', + 'server_id' => '12', + ]); + + expect($blockers)->toContain('MariaDB binary logging must be enabled.'); + expect($blockers)->toContain('MariaDB GTID position must be available.'); + expect($blockers)->not->toContain('Oracle MySQL 8.x is required for managed replication. Current server reports 11.8.6-MariaDB-ubu2404.'); +}); diff --git a/services/nginx/app/tests/Unit/Replication/ReplicationSecretBoxTest.php b/services/nginx/app/tests/Unit/Replication/ReplicationSecretBoxTest.php new file mode 100644 index 00000000..07720494 --- /dev/null +++ b/services/nginx/app/tests/Unit/Replication/ReplicationSecretBoxTest.php @@ -0,0 +1,78 @@ +previousEncryptionKey = $GLOBALS['ENCRYPTION_KEY'] ?? null; + $GLOBALS['ENCRYPTION_KEY'] = 'unit-test-replication-encryption-key'; +}); + +afterEach(function (): void { + if ($this->previousEncryptionKey === null) { + unset($GLOBALS['ENCRYPTION_KEY']); + return; + } + $GLOBALS['ENCRYPTION_KEY'] = $this->previousEncryptionKey; +}); + +it('encrypts replication secrets without storing plaintext', function (): void { + $secret = replication_secret_box::encrypt('replica-password'); + + expect($secret)->toStartWith('twsec:v1:'); + expect($secret)->not->toContain('replica-password'); + expect(replication_secret_box::decrypt($secret))->toBe('replica-password'); +}); + +it('builds active database and redis config from encrypted bootstrap snapshots', function (): void { + $snapshot = [ + 'active' => [ + 'database' => [ + 'host' => 'mysql-replica.internal', + 'port' => 3307, + 'database' => 'truckwash', + 'user' => 'app', + 'password_secret' => replication_secret_box::encrypt('db-secret'), + 'ssl_mode' => 'REQUIRED', + ], + 'redis' => [ + 'host' => 'redis-replica.internal', + 'port' => 6380, + 'database' => 2, + 'user' => 'default', + 'password_secret' => replication_secret_box::encrypt('redis-secret'), + ], + 'minio' => [ + 'endpoint' => 'https://minio-replica.internal:9000', + 'access_key' => 'minio-access', + 'secret_key_secret' => replication_secret_box::encrypt('minio-secret'), + 'buckets' => ['attachments', 'uploads'], + ], + ], + ]; + + expect(replication_bootstrap_config::activeDatabaseConfigFromSnapshot($snapshot['active']['database']))->toMatchArray([ + 'host' => 'mysql-replica.internal', + 'port' => 3307, + 'database' => 'truckwash', + 'user' => 'app', + 'password' => 'db-secret', + 'ssl_mode' => 'REQUIRED', + ]); + expect(replication_bootstrap_config::activeRedisConfigFromSnapshot($snapshot['active']['redis']))->toMatchArray([ + 'host' => 'redis-replica.internal', + 'port' => 6380, + 'database' => 2, + 'user' => 'default', + 'password' => 'redis-secret', + ]); + expect(replication_bootstrap_config::activeMinioConfigFromSnapshot($snapshot['active']['minio']))->toMatchArray([ + 'endpoint' => 'https://minio-replica.internal:9000', + 'access_key' => 'minio-access', + 'secret_key' => 'minio-secret', + 'buckets' => ['attachments', 'uploads'], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php b/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php new file mode 100644 index 00000000..896fe8f9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php @@ -0,0 +1,51 @@ +not->toBeFalse(); + expect($content)->toContain('/superuser/replication'); + expect($content)->toContain('/superuser/replication/databases'); + expect($content)->toContain('/superuser/replication/redis'); + expect($content)->toContain('/superuser/replication/minio'); + expect($content)->toContain('/superuser/replication/compose-template'); + expect($content)->toContain('/superuser/replication/test-credentials'); + expect($content)->toContain('/superuser/replication/{kind}/{id}/test'); + expect($content)->toContain('/superuser/replication/{kind}/{id}/provision'); + expect($content)->toContain('/superuser/replication/{kind}/{id}/promote'); + expect($content)->toContain("\$this->patch('/superuser/replication/{kind}/{id}'"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_view')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_manage')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_promote')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_remove')"); +}); + +it('documents replication management in openapi', function (): void { + $content = file_get_contents(app_path('openapi.yaml')); + + expect($content)->toContain('/superuser/replication:'); + expect($content)->toContain('operationId: getSuperuserReplication'); + expect($content)->toContain('operationId: generateSuperuserReplicationComposeTemplate'); + expect($content)->toContain('operationId: testSuperuserReplicationCredentials'); + expect($content)->toContain('operationId: addSuperuserMinioReplicationHost'); + expect($content)->toContain('operationId: renameSuperuserReplicationHost'); + expect($content)->toContain('enum: [database, redis, minio]'); + expect($content)->toContain('space_headroom_percent'); + expect($content)->toContain('SuperuserReplicationStatus'); + expect($content)->toContain('SuperuserReplicationHostCreateRequest'); + expect($content)->toContain('SuperuserReplicationHostRenameRequest'); + expect($content)->toContain('SuperuserReplicationComposeTemplateRequest'); +}); + +it('rejects subuser sessions before checking replication permissions', function (): void { + $content = file_get_contents(app_path('routes/superuserReplicationRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('private function requireClassicSuperuserPermission(string $permission): bool'); + expect($content)->toContain('get_subuser() !== false'); + expect($content)->toContain("Subuser sessions cannot manage replication."); + expect($content)->toContain("\$response->error('Subuser sessions cannot manage replication.', 403);"); + expect($content)->toContain('return $this->requirePermission($permission);'); + expect(preg_match_all("/requireClassicSuperuserPermission\\('superuser_replication_/", $content))->toBe(11); + expect($content)->not->toContain("requirePermission('superuser_replication_"); +}); diff --git a/services/nginx/app/tests/Unit/Router/AutoloadRedisCacheValidationTest.php b/services/nginx/app/tests/Unit/Router/AutoloadRedisCacheValidationTest.php new file mode 100644 index 00000000..9c084e75 --- /dev/null +++ b/services/nginx/app/tests/Unit/Router/AutoloadRedisCacheValidationTest.php @@ -0,0 +1,11 @@ +not->toBeFalse(); + expect($content)->toContain('$cache_key = \'autoload:\' . $class;'); + expect($content)->toContain('if (is_string($cached) && $cached !== \'\' && is_file($cached)) {'); + expect($content)->toContain('if ($is_loaded($class)) {'); + expect($content)->toContain('redis->delete($cache_key);'); +}); diff --git a/services/nginx/app/tests/Unit/Router/RouterThrowableHandlingTest.php b/services/nginx/app/tests/Unit/Router/RouterThrowableHandlingTest.php new file mode 100644 index 00000000..15db0a14 --- /dev/null +++ b/services/nginx/app/tests/Unit/Router/RouterThrowableHandlingTest.php @@ -0,0 +1,10 @@ +not->toBeFalse(); + expect($content)->toContain('catch (\\Throwable $e)'); + expect($content)->toContain('$response->internal_server_error($e->getMessage());'); +}); diff --git a/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php b/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php new file mode 100644 index 00000000..4fa6380e --- /dev/null +++ b/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php @@ -0,0 +1,10 @@ +not->toBeFalse(); + expect($route)->toContain("'reason' => 'no_license_plate_detected'"); + expect($route)->toContain('], 200);'); + expect($route)->not->toContain("throw new Exception('License plate extraction failed.')"); +}); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchEconomicCustomerIndexWiringTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchEconomicCustomerIndexWiringTest.php new file mode 100644 index 00000000..a62f81e9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Search/SystemSearchEconomicCustomerIndexWiringTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + expect($serviceContent)->toContain('system_search_economic_customer_index::TABLE'); + expect($serviceContent)->toContain('sci.economic_name'); + expect($serviceContent)->toContain('searchCustomerDiscounts'); + expect($serviceContent)->toContain('searchCustomerFixedPrices'); + expect($serviceContent)->toContain('expandLexicalSynonyms'); + expect($serviceContent)->toContain("'rabat' => ['discount', 'discounts']"); +}); + +it('registers cron tasks that keep the e-conomic search index refreshed', function (): void { + $cronContent = file_get_contents(app_path('cron/Cron.php')); + + expect($cronContent)->not->toBeFalse(); + expect($cronContent)->toContain('SyncSystemSearchEconomicCustomerIndex'); + expect($cronContent)->toContain('$users_o->syncAllUsersEconomicCustomerDetails()'); + expect($cronContent)->toContain('system_search_economic_customer_index::refreshIndex(false)'); +}); + diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchEntityTypeCoverageTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchEntityTypeCoverageTest.php new file mode 100644 index 00000000..f8993fee --- /dev/null +++ b/services/nginx/app/tests/Unit/Search/SystemSearchEntityTypeCoverageTest.php @@ -0,0 +1,99 @@ + false, + 'normalized_query' => '', + 'aliases' => [], + 'entity_hints' => [], + 'confidence' => 0.0, + 'association_hint' => false, + 'fallback_reason' => 'disabled', + 'source' => 'none', + ]; + } + } +} + +function system_search_entity_coverage_invoke_private(object $instance, string $method): array +{ + $reflection = new ReflectionClass($instance); + $target = $reflection->getMethod($method); + $target->setAccessible(true); + return (array)$target->invoke($instance); +} + +function system_search_openapi_content_or_skip_for_coverage(): string +{ + $candidates = []; + for ($depth = 1; $depth <= 8; $depth++) { + $candidates[] = dirname(__DIR__, $depth) . DIRECTORY_SEPARATOR . 'openapi.yaml'; + } + $cwd = getcwd(); + if (is_string($cwd) && $cwd !== '') { + $candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml'; + $candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml'; + } + $candidates = array_values(array_unique($candidates)); + + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + $content = file_get_contents($candidate); + if ($content !== false) { + return $content; + } + } + } + + test()->markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +it('keeps route and service entity type registries in sync with expanded coverage', function (): void { + $_SERVER['REQUEST_URI'] = '/search/system'; + + $route = new systemSearchRoute(); + $routeTypes = system_search_entity_coverage_invoke_private($route, 'allEntityTypes'); + + $service = new system_search_service(new SystemSearchNullIntentParserForCoverage()); + $serviceTypes = system_search_entity_coverage_invoke_private($service, 'allEntityTypes'); + + $routeNormalized = array_values(array_unique(array_map('strval', $routeTypes))); + $serviceNormalized = array_values(array_unique(array_map('strval', $serviceTypes))); + sort($routeNormalized); + sort($serviceNormalized); + + expect($routeNormalized)->toBe($serviceNormalized); + expect($routeTypes)->toContain('bookings'); + expect($routeTypes)->toContain('products'); + expect($routeTypes)->toContain('users'); + expect($routeTypes)->toContain('plate_scans'); + expect($routeTypes)->toContain('department_time_bookings_entries'); + expect($routeTypes)->toContain('xlvask_usage_logs'); + expect($routeTypes)->toContain('stripe_module_orders'); +}); + +it('documents every supported search entity type in openapi enum', function (): void { + $_SERVER['REQUEST_URI'] = '/search/system'; + $content = system_search_openapi_content_or_skip_for_coverage(); + + $service = new system_search_service(new SystemSearchNullIntentParserForCoverage()); + $types = system_search_entity_coverage_invoke_private($service, 'allEntityTypes'); + foreach ($types as $type) { + expect($content)->toContain('- ' . $type); + } +}); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchInvalidationHooksTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchInvalidationHooksTest.php new file mode 100644 index 00000000..96bb68bd --- /dev/null +++ b/services/nginx/app/tests/Unit/Search/SystemSearchInvalidationHooksTest.php @@ -0,0 +1,32 @@ +not->toBeFalse(); + expect($content)->toContain('markSystemSearchDirtyTable'); + expect($content)->toContain('system_search_cache::markDirtyTable'); +}); + +it('marks system search cache dirty from object property mutations', function (): void { + $content = file_get_contents(app_path('classes/object_property.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("system_search_cache::markDirtyTable(\$this->table)"); +}); + +it('marks system search cache dirty when module config values change', function (): void { + $content = file_get_contents(app_path('traits/module_config_variable_t.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("system_search_cache::markDirtyTable('module_config')"); +}); + +it('registers cron maintenance task for system search cache', function (): void { + $content = file_get_contents(app_path('cron/Cron.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('SystemSearchCacheMaintenanceCron'); + expect($content)->toContain("system_search_cache::consumeRebuildRequest()"); + expect($content)->toContain("system_search_cache::consumeDirtyTables()"); +}); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchOpenAiIntentParserTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchOpenAiIntentParserTest.php new file mode 100644 index 00000000..293547a0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Search/SystemSearchOpenAiIntentParserTest.php @@ -0,0 +1,241 @@ +store = []; + } + + public function get(string $key): mixed + { + return $this->store[$key] ?? null; + } + + public function set(string $key, string $value): void + { + $this->store[$key] = $value; + } + + public function setEx(string $key, string $value, int $ttl): void + { + $this->store[$key] = $value; + } + + public function delete(string $key): void + { + unset($this->store[$key]); + } + + public function expire(string $key, int $ttl): void + { + // TTL is not simulated in unit tests. + } + + public function set_array(string $key, array $value): void + { + $this->store[$key] = json_encode($value, JSON_UNESCAPED_UNICODE); + } + + public function get_array(string $key): ?array + { + $value = $this->store[$key] ?? null; + if (!is_string($value)) { + return null; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : null; + } + + 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)) { + unset($this->store[$key]); + } + } + } + } +} + +beforeEach(function (): void { + system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter()); +}); + +afterEach(function (): void { + system_search_cache::setAdapterForTests(null); +}); + +it('redacts obvious sensitive fragments before sending query to intent parser', function (): void { + $query = 'Contact alice@example.com at +45 12 34 56 78, cvr 12345678, order 987654, reg AB12345, id 550e8400-e29b-41d4-a716-446655440000'; + $redacted = system_search_openai_intent_parser::redactSensitiveQuery($query); + + expect($redacted)->toContain('[email]'); + expect($redacted)->toContain('[phone]'); + expect($redacted)->toContain('[cvr]'); + expect($redacted)->toContain('order [id]'); + expect($redacted)->toContain('[plate]'); + expect($redacted)->toContain('[uuid]'); + expect($redacted)->not->toContain('alice@example.com'); + expect($redacted)->not->toContain('12345678'); + expect($redacted)->not->toContain('987654'); + expect($redacted)->not->toContain('AB12345'); +}); + +it('builds payload with redacted query and parses strict JSON output', function (): void { + $capturedPrompt = ''; + $parser = new system_search_openai_intent_parser( + function (array $payload, string $apiKey) use (&$capturedPrompt): array { + $capturedPrompt = (string)($payload['input'][0]['content'][0]['text'] ?? ''); + return [ + 'output' => [ + [ + 'content' => [ + [ + 'text' => json_encode([ + 'success' => true, + 'normalized_query' => 'acme unpaid invoices', + 'aliases' => ['acme', 'invoice overdue'], + 'entity_hints' => ['customers', 'invoices'], + 'confidence' => 0.93, + 'association_hint' => true, + ], JSON_UNESCAPED_UNICODE), + ], + ], + ], + ], + ]; + }, + true, + 'test-key' + ); + + $result = $parser->parse( + 'find unpaid invoices for alice@example.com', + ['customers', 'invoices'], + ['customers' => ['account'], 'invoices' => ['billing']] + ); + + expect($capturedPrompt)->toContain('[email]'); + expect($capturedPrompt)->not->toContain('alice@example.com'); + expect($result['success'])->toBeTrue(); + expect($result['source'])->toBe('openai'); + expect($result['confidence'])->toBe(0.93); + expect($result['entity_hints'])->toBe(['customers', 'invoices']); +}); + +it('falls back safely when OpenAI is disabled', function (): void { + $parser = new system_search_openai_intent_parser(null, false, null); + $result = $parser->parse('find acme invoices', ['customers', 'invoices']); + + expect($result['success'])->toBeFalse(); + expect($result['source'])->toBe('none'); + expect($result['fallback_reason'])->toBe('openai_disabled'); +}); + +it('handles malformed OpenAI response payloads without throwing', function (): void { + $parser = new system_search_openai_intent_parser( + function (array $payload, string $apiKey): array { + return ['output' => []]; + }, + true, + 'test-key' + ); + + $result = $parser->parse('find acme invoices', ['invoices']); + + expect($result['success'])->toBeFalse(); + expect($result['source'])->toBe('openai'); + expect((string)$result['fallback_reason'])->toContain('Invalid response format'); +}); + +it('uses parser cache for identical query and allowed type combinations', function (): void { + $calls = 0; + $parser = new system_search_openai_intent_parser( + function (array $payload, string $apiKey) use (&$calls): array { + $calls++; + return [ + 'output' => [ + [ + 'content' => [ + [ + 'text' => json_encode([ + 'success' => true, + 'normalized_query' => 'acme invoices', + 'aliases' => ['acme'], + 'entity_hints' => ['invoices'], + 'confidence' => 0.8, + 'association_hint' => false, + ], JSON_UNESCAPED_UNICODE), + ], + ], + ], + ], + ]; + }, + true, + 'test-key' + ); + + $first = $parser->parse('acme invoices', ['invoices']); + $second = $parser->parse('acme invoices', ['invoices']); + + expect($first['source'])->toBe('openai'); + expect($second['source'])->toBe('cache'); + expect($calls)->toBe(1); +}); + +it('caps alias and hint payloads from OpenAI and filters hints to allowed types', function (): void { + $manyAliases = []; + for ($i = 0; $i < 40; $i++) { + $manyAliases[] = 'ALIAS_' . $i . '_' . str_repeat('x', 90); + } + + $parser = new system_search_openai_intent_parser( + function (array $payload, string $apiKey) use ($manyAliases): array { + return [ + 'output' => [ + [ + 'content' => [ + [ + 'text' => json_encode([ + 'success' => true, + 'normalized_query' => str_repeat('q', 500), + 'aliases' => $manyAliases, + 'entity_hints' => ['orders', 'invoices', 'made_up_type'], + 'confidence' => 0.7, + 'association_hint' => false, + 'fallback_reason' => null, + ], JSON_UNESCAPED_UNICODE), + ], + ], + ], + ], + ]; + }, + true, + 'test-key' + ); + + $result = $parser->parse('acme', ['orders', 'invoices']); + + expect(count($result['aliases']))->toBeLessThanOrEqual(12); + $longestAlias = 0; + foreach ($result['aliases'] as $alias) { + $longestAlias = max($longestAlias, mb_strlen((string)$alias)); + } + expect($longestAlias)->toBeLessThanOrEqual(64); + expect(mb_strlen((string)$result['normalized_query']))->toBeLessThanOrEqual(256); + expect($result['entity_hints'])->toBe(['orders', 'invoices']); +}); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchOpenApiSpecTest.php new file mode 100644 index 00000000..605dc793 --- /dev/null +++ b/services/nginx/app/tests/Unit/Search/SystemSearchOpenApiSpecTest.php @@ -0,0 +1,51 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +it('documents system-wide search endpoints in openapi', function (): void { + $content = system_search_openapi_content_or_skip(); + + expect($content)->toContain('/search/system:'); + expect($content)->toContain('/superuser/search/system/cache:'); + expect($content)->toContain('/superuser/search/system/cache/rebuild:'); +}); + +it('documents debug_intent and parser metadata schema in openapi', function (): void { + $content = system_search_openapi_content_or_skip(); + + expect($content)->toContain('debug_intent:'); + expect($content)->toContain('SystemSearchIntentParserMeta:'); + expect($content)->toContain('intent_parser:'); + expect($content)->toContain('SystemSearchResponse:'); +}); + +it('documents e-conomic indexed customer matching and synonym behavior', function (): void { + $content = system_search_openapi_content_or_skip(); + + expect($content)->toContain('local e-conomic customer index'); + expect($content)->toContain('`rabat` -> `discount`'); + expect($content)->toContain('recent records preferred when relevance is comparable'); +}); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchRouteBehaviorTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchRouteBehaviorTest.php new file mode 100644 index 00000000..7d9fdca3 --- /dev/null +++ b/services/nginx/app/tests/Unit/Search/SystemSearchRouteBehaviorTest.php @@ -0,0 +1,65 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs($route, $args); +} + +it('normalizes type lists from csv json and arrays', function (): void { + $_SERVER['REQUEST_URI'] = '/search/system'; + $route = new systemSearchRoute(); + + $csv = system_search_route_invoke_private($route, 'parseTypeList', [' Orders,customers ,ORDERS']); + $json = system_search_route_invoke_private($route, 'parseTypeList', ['["invoices","Orders","invoices"]']); + $array = system_search_route_invoke_private($route, 'parseTypeList', [[ + ' Vehicles ', + 'vehicles', + 'ORDERS', + 123, + ]]); + + expect($csv)->toBe(['orders', 'customers']); + expect($json)->toBe(['invoices', 'orders']); + expect($array)->toBe(['vehicles', 'orders']); +}); + +it('parses booleans and clamps integers using route defaults', function (): void { + $_SERVER['REQUEST_URI'] = '/search/system'; + $route = new systemSearchRoute(); + + expect(system_search_route_invoke_private($route, 'toBool', ['true', false]))->toBeTrue(); + expect(system_search_route_invoke_private($route, 'toBool', ['0', true]))->toBeFalse(); + expect(system_search_route_invoke_private($route, 'toBool', ['not-a-bool', true]))->toBeTrue(); + + expect(system_search_route_invoke_private($route, 'clampInt', [0, 1, 200, 50]))->toBe(50); + expect(system_search_route_invoke_private($route, 'clampInt', [999, 1, 200, 50]))->toBe(200); + expect(system_search_route_invoke_private($route, 'clampInt', [-4, 1, 200, 50]))->toBe(1); +}); + +it('exposes expected searchable entity types', function (): void { + $_SERVER['REQUEST_URI'] = '/search/system'; + $route = new systemSearchRoute(); + $types = system_search_route_invoke_private($route, 'allEntityTypes'); + + expect($types)->toContain('orders'); + expect($types)->toContain('customers'); + expect($types)->toContain('users'); + expect($types)->toContain('invoices'); + expect($types)->toContain('module_config'); + expect($types)->toContain('objects'); + expect($types)->toContain('bookings'); + expect($types)->toContain('products'); + expect($types)->toContain('product_options'); + expect($types)->toContain('plate_scans'); + expect($types)->toContain('notifications'); + expect($types)->toContain('department_time_bookings_entries'); + expect($types)->toContain('stripe_module_orders'); + expect($types)->toContain('xlvask_usage_logs'); +}); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchRouteWiringTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchRouteWiringTest.php new file mode 100644 index 00000000..c14e1b5d --- /dev/null +++ b/services/nginx/app/tests/Unit/Search/SystemSearchRouteWiringTest.php @@ -0,0 +1,37 @@ +not->toBeFalse(); + expect($content)->toContain('/search/system'); + expect($content)->toContain("'debug_intent'"); + expect($content)->toContain("'include_types'"); + expect($content)->toContain("'exclude_types'"); + expect($content)->toContain('new system_search_service()'); +}); + +it('registers superuser cache clear and rebuild endpoints for system search', function (): void { + $routeFile = app_path('routes/systemSearchRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/superuser/search/system/cache'); + expect($content)->toContain('/superuser/search/system/cache/rebuild'); + expect($content)->toContain("requirePermission('superuser_search_system_cache_clear')"); + expect($content)->toContain("requirePermission('superuser_search_system_cache_rebuild')"); + expect($content)->toContain('system_search_cache::clearIntentCaches()'); +}); + +it('passes permission and own-scope context into system search service', function (): void { + $routeFile = app_path('routes/systemSearchRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("'allowed_types'"); + expect($content)->toContain("'own_only_types'"); + expect($content)->toContain("'own_customer_number'"); + expect($content)->toContain("'permissions_catalog_all'"); + expect($content)->toContain("'permissions_catalog_own'"); +}); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php new file mode 100644 index 00000000..fa02c08b --- /dev/null +++ b/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php @@ -0,0 +1,937 @@ +store[$key] ?? null; + } + + public function set(string $key, string $value): void + { + $this->store[$key] = $value; + } + + public function setEx(string $key, string $value, int $ttl): void + { + $this->store[$key] = $value; + } + + public function delete(string $key): void + { + unset($this->store[$key]); + } + + public function expire(string $key, int $ttl): void + { + // TTL is not simulated in unit tests. + } + + public function set_array(string $key, array $value): void + { + $this->store[$key] = json_encode($value, JSON_UNESCAPED_UNICODE); + } + + public function get_array(string $key): ?array + { + $value = $this->store[$key] ?? null; + if (!is_string($value)) { + return null; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : null; + } + + 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)) { + unset($this->store[$key]); + } + } + } + } +} + +if (!class_exists('FakeSystemSearchIntentParser')) { + class FakeSystemSearchIntentParser implements system_search_intent_parser_i + { + public int $calls = 0; + /** + * @var array> + */ + public array $responses = []; + + public function __construct(array $responses = []) + { + $this->responses = $responses; + } + + public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array + { + $this->calls++; + if (empty($this->responses)) { + return [ + 'success' => false, + 'normalized_query' => '', + 'aliases' => [], + 'entity_hints' => [], + 'confidence' => 0.0, + 'association_hint' => false, + 'fallback_reason' => 'no_response', + 'source' => 'none', + ]; + } + return array_shift($this->responses); + } + } +} + +if (!class_exists('TestableSystemSearchService')) { + class TestableSystemSearchService extends system_search_service + { + /** + * @var array> + */ + public array $lexicalCalls = []; + /** + * @var array>> + */ + private array $queuedLexicalResults; + + public function __construct(system_search_intent_parser_i $intentParser, array $queuedLexicalResults) + { + $this->queuedLexicalResults = $queuedLexicalResults; + parent::__construct($intentParser); + } + + protected function executeLexicalSearch( + array $activeTypes, + array $terms, + array $entityBoost, + array $ownOnlyTypes, + ?int $ownCustomerNumber, + array $permissionsCatalogAll, + array $permissionsCatalogOwn, + array $moduleConfigVisibility, + array $forcedCustomerNumbers = [] + ): array { + $this->lexicalCalls[] = [ + 'activeTypes' => $activeTypes, + 'terms' => $terms, + 'entityBoost' => $entityBoost, + 'ownOnlyTypes' => $ownOnlyTypes, + 'ownCustomerNumber' => $ownCustomerNumber, + 'forcedCustomerNumbers' => $forcedCustomerNumbers, + ]; + if (empty($this->queuedLexicalResults)) { + return []; + } + return array_shift($this->queuedLexicalResults); + } + } +} + +if (!class_exists('CustomerContextAwareTestableSystemSearchService')) { + class CustomerContextAwareTestableSystemSearchService extends TestableSystemSearchService + { + /** + * @var array> + */ + public array $customerContexts = []; + + protected function loadCustomerContexts(array $customerNumbers): array + { + $contexts = []; + foreach ($customerNumbers as $customerNumber) { + $normalized = (int)$customerNumber; + if ($normalized <= 0 || !isset($this->customerContexts[$normalized])) { + continue; + } + $contexts[$normalized] = $this->customerContexts[$normalized]; + } + return $contexts; + } + } +} + +if (!function_exists('system_search_service_invoke_private')) { + function system_search_service_invoke_private(object $instance, string $method, array $args = []): mixed + { + $reflection = new ReflectionMethod($instance, $method); + $reflection->setAccessible(true); + return $reflection->invokeArgs($instance, $args); + } +} + +beforeEach(function (): void { + system_search_cache::setAdapterForTests(null); +}); + +it('does not invoke intent parser when lexical confidence is already high', function (): void { + $parser = new FakeSystemSearchIntentParser([ + [ + 'success' => true, + 'normalized_query' => 'ignored', + 'aliases' => ['ignored'], + 'entity_hints' => ['orders'], + 'confidence' => 0.99, + 'association_hint' => false, + 'fallback_reason' => null, + 'source' => 'openai', + ], + ]); + + $service = new TestableSystemSearchService($parser, [[ + ['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 95], + ['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'Order #2', 'score' => 90], + ['entity_type' => 'orders', 'entity_id' => '3', 'title' => 'Order #3', 'score' => 88], + ['entity_type' => 'orders', 'entity_id' => '4', 'title' => 'Order #4', 'score' => 84], + ['entity_type' => 'orders', 'entity_id' => '5', 'title' => 'Order #5', 'score' => 80], + ]]); + + $result = $service->search([ + 'query' => 'order 1', + 'allowed_types' => ['orders'], + 'include_associations' => false, + 'debug_intent' => true, + ]); + + expect($parser->calls)->toBe(0); + expect($result['meta']['intent_parser']['status'])->toBe('skipped'); + expect(count($service->lexicalCalls))->toBe(1); +}); + +it('invokes parser on low-confidence lexical results and applies hints-only boosts', function (): void { + $parser = new FakeSystemSearchIntentParser([ + [ + 'success' => true, + 'normalized_query' => 'acme invoices', + 'aliases' => ['acme corp', 'invoice overdue'], + 'entity_hints' => ['orders'], + 'confidence' => 0.87, + 'association_hint' => true, + 'fallback_reason' => null, + 'source' => 'openai', + ], + ]); + + $service = new TestableSystemSearchService($parser, [ + [['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 20]], + [['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'strong', 'score' => 85]], + ]); + + $result = $service->search([ + 'query' => 'acm inv', + 'allowed_types' => ['orders', 'customers'], + 'include_associations' => false, + 'debug_intent' => true, + ]); + + expect($parser->calls)->toBe(1); + expect(count($service->lexicalCalls))->toBe(2); + expect($service->lexicalCalls[1]['entityBoost']['orders'] ?? 0)->toBe(25); + expect(implode(' ', $service->lexicalCalls[1]['terms']))->toContain('acme'); + expect($result['meta']['intent_parser']['status'])->toBe('ok'); + expect($result['meta']['intent_parser']['source'])->toBe('openai'); +}); + +it('never lets parser entity hints override explicit include filters', function (): void { + $parser = new FakeSystemSearchIntentParser([ + [ + 'success' => true, + 'normalized_query' => 'customer acme', + 'aliases' => ['acme'], + 'entity_hints' => ['orders'], + 'confidence' => 0.7, + 'association_hint' => true, + 'fallback_reason' => null, + 'source' => 'openai', + ], + ]); + + $service = new TestableSystemSearchService($parser, [ + [['entity_type' => 'customers', 'entity_id' => '55', 'title' => 'Acme', 'score' => 10]], + [['entity_type' => 'customers', 'entity_id' => '55', 'title' => 'Acme', 'score' => 90]], + ]); + + $result = $service->search([ + 'query' => 'acm', + 'include_types' => ['customers'], + 'allowed_types' => ['customers', 'orders'], + 'include_associations' => false, + 'debug_intent' => true, + ]); + + expect($parser->calls)->toBe(1); + expect($service->lexicalCalls[0]['activeTypes'])->toBe(['customers']); + expect($service->lexicalCalls[1]['activeTypes'])->toBe(['customers']); + expect($result['results'][0]['entity_type'])->toBe('customers'); +}); + +it('returns fallback parser metadata when parser fails gracefully', function (): void { + $parser = new FakeSystemSearchIntentParser([ + [ + 'success' => false, + 'normalized_query' => '', + 'aliases' => [], + 'entity_hints' => [], + 'confidence' => 0.0, + 'association_hint' => false, + 'fallback_reason' => 'openai_disabled', + 'source' => 'none', + ], + ]); + + $service = new TestableSystemSearchService($parser, [ + [['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 5]], + ]); + + $result = $service->search([ + 'query' => 'unknown phrase', + 'allowed_types' => ['orders'], + 'include_associations' => false, + 'debug_intent' => true, + ]); + + expect($parser->calls)->toBe(1); + expect($result['meta']['intent_parser']['status'])->toBe('fallback'); + expect($result['meta']['intent_parser']['fallback_reason'])->toBe('openai_disabled'); +}); + +it('scopes query cache by permission context to avoid cross-user cache leakage', function (): void { + system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter()); + + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [ + [['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'first', 'score' => 95]], + [['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'second', 'score' => 96]], + ]); + + $first = $service->search([ + 'query' => 'acme', + 'allowed_types' => ['orders'], + 'include_associations' => false, + 'permissions_catalog_own' => ['list_orders'], + 'module_config_visibility' => ['openAI' => true], + ]); + $second = $service->search([ + 'query' => 'acme', + 'allowed_types' => ['orders'], + 'include_associations' => false, + 'permissions_catalog_own' => ['list_own_orders'], + 'module_config_visibility' => ['openAI' => false], + ]); + + expect(count($service->lexicalCalls))->toBe(2); + expect($first['results'][0]['entity_id'])->toBe('1'); + expect($second['results'][0]['entity_id'])->toBe('2'); +}); + +it('caps AI-driven expanded terms to prevent query amplification', function (): void { + $aliases = []; + for ($i = 0; $i < 80; $i++) { + $aliases[] = 'alias_' . $i . '_' . str_repeat('x', 90); + } + + $parser = new FakeSystemSearchIntentParser([ + [ + 'success' => true, + 'normalized_query' => str_repeat('n', 500), + 'aliases' => $aliases, + 'entity_hints' => ['orders'], + 'confidence' => 0.8, + 'association_hint' => false, + 'fallback_reason' => null, + 'source' => 'openai', + ], + ]); + + $service = new TestableSystemSearchService($parser, [ + [['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 5]], + [['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'strong', 'score' => 80]], + ]); + + $service->search([ + 'query' => 'a b', + 'allowed_types' => ['orders'], + 'include_associations' => false, + 'debug_intent' => true, + ]); + + $expanded = $service->lexicalCalls[1]['terms'] ?? []; + expect(count($expanded))->toBeLessThanOrEqual(24); + $maxLen = 0; + foreach ($expanded as $term) { + $maxLen = max($maxLen, mb_strlen((string)$term)); + } + expect($maxLen)->toBeLessThanOrEqual(64); +}); + +it('expands danish discount wording into lexical discount synonyms', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + ['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95], + ['entity_type' => 'customer_discounts', 'entity_id' => '2', 'title' => 'd2', 'score' => 92], + ['entity_type' => 'customer_discounts', 'entity_id' => '3', 'title' => 'd3', 'score' => 90], + ['entity_type' => 'customer_discounts', 'entity_id' => '4', 'title' => 'd4', 'score' => 88], + ['entity_type' => 'customer_discounts', 'entity_id' => '5', 'title' => 'd5', 'score' => 86], + ]]); + + $service->search([ + 'query' => 'pleno rabat', + 'allowed_types' => ['customer_discounts'], + 'include_associations' => false, + ]); + + $terms = $service->lexicalCalls[0]['terms'] ?? []; + expect($parser->calls)->toBeGreaterThanOrEqual(1); + expect($terms)->toContain('rabat'); + expect($terms)->toContain('discount'); +}); + +it('invokes parser for intent-driven natural-language queries even when lexical score is high', function (): void { + $parser = new FakeSystemSearchIntentParser([ + [ + 'success' => true, + 'normalized_query' => 'acme customer discount', + 'aliases' => ['discount', 'price override'], + 'entity_hints' => ['customer_discounts'], + 'confidence' => 0.82, + 'association_hint' => true, + 'fallback_reason' => null, + 'source' => 'openai', + ], + ]); + + $service = new TestableSystemSearchService($parser, [ + [ + ['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95], + ['entity_type' => 'customer_discounts', 'entity_id' => '2', 'title' => 'd2', 'score' => 92], + ['entity_type' => 'customer_discounts', 'entity_id' => '3', 'title' => 'd3', 'score' => 90], + ['entity_type' => 'customer_discounts', 'entity_id' => '4', 'title' => 'd4', 'score' => 88], + ['entity_type' => 'customer_discounts', 'entity_id' => '5', 'title' => 'd5', 'score' => 86], + ], + [ + ['entity_type' => 'customer_discounts', 'entity_id' => '10', 'title' => 'improved', 'score' => 97], + ], + ]); + + $result = $service->search([ + 'query' => 'show me acme rabat options', + 'allowed_types' => ['customer_discounts'], + 'include_associations' => false, + 'debug_intent' => true, + ]); + + expect($parser->calls)->toBe(1); + expect(count($service->lexicalCalls))->toBe(2); + expect($result['meta']['intent_parser']['status'])->toBe('ok'); +}); + +it('uses association hints to pull related customer records from non-customer matches', function (): void { + $parser = new FakeSystemSearchIntentParser([ + [ + 'success' => true, + 'normalized_query' => 'pleno customer discount', + 'aliases' => ['discount'], + 'entity_hints' => ['customer_discounts', 'orders'], + 'confidence' => 0.85, + 'association_hint' => true, + 'fallback_reason' => null, + 'source' => 'openai', + ], + ]); + + $service = new TestableSystemSearchService($parser, [ + [ + [ + 'entity_type' => 'customer_discounts', + 'entity_id' => '44', + 'title' => 'Discount #44', + 'customer_number' => 777, + 'score' => 12, + ], + ], + [ + [ + 'entity_type' => 'customer_discounts', + 'entity_id' => '44', + 'title' => 'Discount #44', + 'customer_number' => 777, + 'score' => 95, + ], + ], + [ + [ + 'entity_type' => 'orders', + 'entity_id' => '9001', + 'title' => 'Order #9001', + 'customer_number' => 777, + 'score' => 40, + ], + ], + ]); + + $result = $service->search([ + 'query' => 'pleno rabat', + 'allowed_types' => ['customer_discounts', 'orders'], + 'include_associations' => true, + 'debug_intent' => true, + ]); + + expect($parser->calls)->toBe(1); + expect(count($service->lexicalCalls))->toBe(3); + expect($service->lexicalCalls[2]['forcedCustomerNumbers'])->toBe([777]); + + $types = array_map(static fn(array $row) => (string)$row['entity_type'], $result['results']); + expect($types)->toContain('orders'); + expect($result['meta']['intent_parser']['status'])->toBe('ok'); +}); + +it('prefers newer records when relevance scores are comparable', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + [ + 'entity_type' => 'bookings', + 'entity_id' => '1', + 'title' => 'Older booking', + 'score' => 90, + 'payload' => ['updated_at' => '2026-01-01 00:00:00'], + ], + [ + 'entity_type' => 'bookings', + 'entity_id' => '2', + 'title' => 'Newer booking', + 'score' => 89, + 'payload' => ['updated_at' => '2026-03-10 12:00:00'], + ], + ['entity_type' => 'bookings', 'entity_id' => '3', 'title' => 'B3', 'score' => 85], + ['entity_type' => 'bookings', 'entity_id' => '4', 'title' => 'B4', 'score' => 84], + ['entity_type' => 'bookings', 'entity_id' => '5', 'title' => 'B5', 'score' => 83], + ]]); + + $result = $service->search([ + 'query' => 'booking', + 'allowed_types' => ['bookings'], + 'include_associations' => false, + ]); + + expect($parser->calls)->toBe(0); + expect($result['results'][0]['entity_id'])->toBe('2'); +}); + +it('keeps explicit identifier matches ahead of newer but weaker records', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + [ + 'entity_type' => 'orders', + 'entity_id' => '100', + 'title' => 'Exact order', + 'score' => 90, + 'payload' => ['updated_at' => '2026-01-01 00:00:00'], + ], + [ + 'entity_type' => 'orders', + 'entity_id' => '101', + 'title' => 'Newer but weaker', + 'score' => 89, + 'payload' => ['updated_at' => '2026-03-12 00:00:00'], + ], + ['entity_type' => 'orders', 'entity_id' => '102', 'title' => 'O102', 'score' => 85], + ['entity_type' => 'orders', 'entity_id' => '103', 'title' => 'O103', 'score' => 84], + ['entity_type' => 'orders', 'entity_id' => '104', 'title' => 'O104', 'score' => 83], + ]]); + + $result = $service->search([ + 'query' => '12345', + 'allowed_types' => ['orders'], + 'include_associations' => false, + ]); + + expect($parser->calls)->toBe(0); + expect($result['results'][0]['entity_id'])->toBe('100'); +}); + +it('promotes invoices orders order bookings and customers in ranking', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + ['entity_type' => 'vehicles', 'entity_id' => '800', 'title' => 'Vehicle #800', 'score' => 97], + ['entity_type' => 'departments', 'entity_id' => '801', 'title' => 'Department #801', 'score' => 96], + ['entity_type' => 'invoices', 'entity_id' => '802', 'title' => 'Invoice #802', 'score' => 70], + ['entity_type' => 'orders', 'entity_id' => '803', 'title' => 'Order #803', 'score' => 69], + ['entity_type' => 'order_bookings', 'entity_id' => '804', 'title' => 'Order booking #804', 'score' => 68], + ['entity_type' => 'customers', 'entity_id' => '805', 'title' => 'Customer #805', 'score' => 67], + ]]); + + $result = $service->search([ + 'query' => '12345', + 'allowed_types' => ['vehicles', 'departments', 'invoices', 'orders', 'order_bookings', 'customers'], + 'include_associations' => false, + ]); + + $types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']); + expect($parser->calls)->toBe(0); + expect(array_slice($types, 0, 4))->toBe([ + 'invoices', + 'orders', + 'order_bookings', + 'customers', + ]); +}); + +it('never prioritizes cancelled bookings over active bookings', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + [ + 'entity_type' => 'bookings', + 'entity_id' => '500', + 'title' => 'Cancelled booking', + 'score' => 99, + 'payload' => [ + 'updated_at' => '2026-03-12 12:00:00', + 'status' => 'cancelled', + ], + ], + [ + 'entity_type' => 'bookings', + 'entity_id' => '501', + 'title' => 'Active booking', + 'score' => 80, + 'payload' => [ + 'updated_at' => '2026-03-11 12:00:00', + 'status' => 'active', + ], + ], + ['entity_type' => 'bookings', 'entity_id' => '502', 'title' => 'B502', 'score' => 79], + ['entity_type' => 'bookings', 'entity_id' => '503', 'title' => 'B503', 'score' => 78], + ['entity_type' => 'bookings', 'entity_id' => '504', 'title' => 'B504', 'score' => 77], + ]]); + + $result = $service->search([ + 'query' => 'booking', + 'allowed_types' => ['bookings'], + 'include_associations' => false, + ]); + + expect($parser->calls)->toBe(0); + expect($result['results'][0]['entity_id'])->toBe('501'); + expect($result['results'][1]['entity_id'])->not->toBe('500'); +}); + +it('heavily demotes configured low-priority entity types in ranking', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [[ + [ + 'entity_type' => 'xlvask_customers', + 'entity_id' => '700', + 'title' => 'XLVask customer', + 'score' => 99, + 'payload' => ['updated_at' => '2026-03-12 10:00:00'], + ], + [ + 'entity_type' => 'xlvask_usage_logs', + 'entity_id' => '701', + 'title' => 'XLVask usage log', + 'score' => 98, + 'payload' => ['updated_at' => '2026-03-12 11:00:00'], + ], + [ + 'entity_type' => 'customer_discounts', + 'entity_id' => '702', + 'title' => 'Customer discount', + 'score' => 97, + 'payload' => ['updated_at' => '2026-03-12 12:00:00'], + ], + [ + 'entity_type' => 'department_selfserve_vehicle_conditions', + 'entity_id' => '703', + 'title' => 'Vehicle condition', + 'score' => 96, + 'payload' => ['updated_at' => '2026-03-12 13:00:00'], + ], + [ + 'entity_type' => 'permissions', + 'entity_id' => '704', + 'title' => 'Permission #704', + 'score' => 95, + 'payload' => ['updated_at' => '2026-03-12 14:00:00'], + ], + [ + 'entity_type' => 'branding', + 'entity_id' => '705', + 'title' => 'Branding #705', + 'score' => 94, + 'payload' => ['updated_at' => '2026-03-12 15:00:00'], + ], + [ + 'entity_type' => 'order_items', + 'entity_id' => '706', + 'title' => 'Order item #706', + 'score' => 93, + 'payload' => ['updated_at' => '2026-03-12 16:00:00'], + ], + [ + 'entity_type' => 'module_config', + 'entity_id' => '707', + 'title' => 'Module config #707', + 'score' => 92, + 'payload' => ['updated_at' => '2026-03-12 17:00:00'], + ], + [ + 'entity_type' => 'xlvask_vehicle_types', + 'entity_id' => '710', + 'title' => 'XLVask vehicle type', + 'score' => 91, + 'payload' => ['updated_at' => '2026-03-12 18:00:00'], + ], + [ + 'entity_type' => 'motorapi_lookups', + 'entity_id' => '711', + 'title' => 'MotorAPI lookup', + 'score' => 90, + 'payload' => ['updated_at' => '2026-03-12 19:00:00'], + ], + [ + 'entity_type' => 'orders', + 'entity_id' => '708', + 'title' => 'Order #708', + 'score' => 76, + 'payload' => ['updated_at' => '2026-03-01 00:00:00'], + ], + [ + 'entity_type' => 'customers', + 'entity_id' => '709', + 'title' => 'Customer #709', + 'score' => 74, + 'payload' => ['updated_at' => '2026-03-01 00:00:00'], + ], + ]]); + + $result = $service->search([ + 'query' => '12345', + 'allowed_types' => [ + 'orders', + 'customers', + 'xlvask_customers', + 'xlvask_usage_logs', + 'customer_discounts', + 'department_selfserve_vehicle_conditions', + 'permissions', + 'branding', + 'order_items', + 'module_config', + 'xlvask_vehicle_types', + 'motorapi_lookups', + ], + 'include_associations' => false, + ]); + + $types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']); + expect($parser->calls)->toBe(0); + expect($types[0])->toBe('orders'); + expect($types[1])->toBe('customers'); + expect(array_slice($types, 2, 10))->toBe([ + 'xlvask_customers', + 'xlvask_usage_logs', + 'customer_discounts', + 'department_selfserve_vehicle_conditions', + 'permissions', + 'branding', + 'order_items', + 'module_config', + 'xlvask_vehicle_types', + 'motorapi_lookups', + ]); +}); + +it('tokenizes unicode names without stripping non ascii letters', function (): void { + $service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []); + + $tokens = system_search_service_invoke_private($service, 'tokenize', ['Møller Århus']); + + expect($tokens)->toContain('møller'); + expect($tokens)->toContain('århus'); + expect($tokens)->not->toContain('ller'); + expect($tokens)->not->toContain('rhus'); +}); + +it('does not treat explicit identifier queries as intent driven', function (): void { + $service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []); + + $looksIntentDriven = system_search_service_invoke_private( + $service, + 'queryLooksIntentDriven', + ['order 123456 for acme', ['order', '123456', 'for', 'acme']] + ); + + expect($looksIntentDriven)->toBeFalse(); +}); + +it('requires broader term coverage for multi word scoring', function (): void { + $service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []); + + $narrowScore = system_search_service_invoke_private( + $service, + 'scoreRow', + [['title' => 'Acme Corp', 'description' => ''], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']] + ); + $broadScore = system_search_service_invoke_private( + $service, + 'scoreRow', + [['title' => 'Acme Corp', 'description' => 'Overdue invoice'], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']] + ); + + expect($narrowScore)->toBe(0); + expect($broadScore)->toBeGreaterThan(0); +}); + +it('falls back to invoice date ranges when invoice names are missing', function (): void { + $service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []); + + $fullRangeTitle = system_search_service_invoke_private( + $service, + 'invoiceResultTitle', + [null, '2026-02-01 00:00:01', '2026-02-28 23:59:59', 42] + ); + $fromOnlyTitle = system_search_service_invoke_private( + $service, + 'invoiceResultTitle', + ['', '2026-02-01 00:00:01', null, 43] + ); + $fallbackTitle = system_search_service_invoke_private( + $service, + 'invoiceResultTitle', + [null, null, null, 44] + ); + + expect($fullRangeTitle)->toBe('2026-02-01 - 2026-02-28'); + expect($fromOnlyTitle)->toBe('2026-02-01'); + expect($fallbackTitle)->toBe('Invoice collection #44'); +}); + +it('uses the goal criteria label for department goal titles', function (): void { + $service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []); + + $labeledTitle = system_search_service_invoke_private( + $service, + 'departmentGoalResultTitle', + [json_encode(['label' => 'Weekly Wash Goal'], JSON_UNESCAPED_UNICODE), 91, 'Department goals #91'] + ); + $fallbackTitle = system_search_service_invoke_private( + $service, + 'departmentGoalResultTitle', + [json_encode(['target' => 12], JSON_UNESCAPED_UNICODE), 92, 'Department goals #92'] + ); + + expect($labeledTitle)->toBe('Weekly Wash Goal'); + expect($fallbackTitle)->toBe('Department goals #92'); +}); + +it('derives xlvask customer numbers only from digits-only extern ids', function (): void { + $service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []); + + $digitsOnly = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345679', 'digits_only']); + $uuidLike = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['09ed15d4-5a12-4d23-beac-4065174a74eb', 'digits_only']); + $mixed = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345-A', 'digits_only']); + $blank = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', [' ', 'digits_only']); + + expect($digitsOnly)->toBe(12345679); + expect($uuidLike)->toBeNull(); + expect($mixed)->toBeNull(); + expect($blank)->toBeNull(); +}); + +it('replaces unnamed user titles with the customer context name', function (): void { + $service = new CustomerContextAwareTestableSystemSearchService(new FakeSystemSearchIntentParser(), []); + $service->customerContexts = [ + 777 => [ + 'customer_number' => 777, + 'name' => 'Acme Transport', + 'barred' => false, + 'status' => 'active', + ], + ]; + + $result = system_search_service_invoke_private($service, 'decorateSearchResultWithCustomerContext', [[ + 'entity_type' => 'users', + 'entity_id' => '55', + 'title' => 'unnamed', + 'description' => '', + 'customer_number' => 777, + 'payload' => [ + 'id' => 55, + 'display_name' => 'unnamed', + ], + ]]); + + expect($result['title'])->toBe('Acme Transport'); + expect($result['customer_name'])->toBe('Acme Transport'); +}); + +it('enriches object attachment results with associated customer context', function (): void { + $service = new CustomerContextAwareTestableSystemSearchService(new FakeSystemSearchIntentParser(), []); + $service->customerContexts = [ + 777 => [ + 'customer_number' => 777, + 'user_id' => 55, + 'name' => 'Acme Transport', + 'barred' => true, + 'status' => 'barred', + 'email' => 'dispatch@acme.test', + 'phone' => '40112233', + 'cvr' => '12345678', + 'address' => 'Road 1', + 'city' => 'Aarhus', + 'zip' => '8000', + ], + ]; + + $result = system_search_service_invoke_private($service, 'buildObjectSearchResult', [[ + 'id' => 88, + 'object_type' => 'orders', + 'object_id' => 501, + 'content' => json_encode(['other' => 'wash_certificate.pdf'], JSON_UNESCAPED_UNICODE), + 'customer_number' => 777, + 'department_id' => 12, + 'order_reference' => 'REF-501', + 'customer_name' => 'Acme Transport', + 'updated_at' => '2026-03-11 12:00:00', + 'created_at' => '2026-03-10 12:00:00', + ], ['acme', '777'], 9]); + + expect($result['entity_type'])->toBe('objects'); + expect($result['customer_number'])->toBe(777); + expect($result['customer_name'])->toBe('Acme Transport'); + expect($result['customer_barred'])->toBeTrue(); + expect($result['customer_status'])->toBe('barred'); + expect($result['description'])->toBe('REF-501 / Acme Transport'); + expect($result['payload']['linked_entity_type'])->toBe('orders'); + expect($result['payload']['order_reference'])->toBe('REF-501'); + expect($result['payload']['customer_context']['cvr'])->toBe('12345678'); +}); + +it('includes the economic customer index in cache dependencies for customer scoped results', function (): void { + $service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []); + + $tables = system_search_service_invoke_private($service, 'relevantSourceTables', [['objects', 'orders', 'vehicles']]); + + expect($tables)->toContain(system_search_economic_customer_index::TABLE); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/DbObjectPaginationLegacyColumnCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/DbObjectPaginationLegacyColumnCompatibilityTest.php new file mode 100644 index 00000000..8653386e --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/DbObjectPaginationLegacyColumnCompatibilityTest.php @@ -0,0 +1,10 @@ +not->toBeFalse(); + expect($traitContent)->toContain('$fields = array_values(array_intersect($fields, $tmpFields));'); + expect($traitContent)->toContain("if (in_array('deleted_at', \$tmpFields, true))"); +}); + diff --git a/services/nginx/app/tests/Unit/Selfserve/DepartmentGateConfigRelayTest.php b/services/nginx/app/tests/Unit/Selfserve/DepartmentGateConfigRelayTest.php new file mode 100644 index 00000000..a469081c --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/DepartmentGateConfigRelayTest.php @@ -0,0 +1,30 @@ + 'RELAY', + 'relay_id' => 'ENTRY-GATE-1', + 'pulse_seconds' => 0, + ]); + + $config->validate(); + + expect($config->toArray())->toMatchArray([ + 'type' => 'RELAY', + 'relay_id' => 'ENTRY-GATE-1', + 'pulse_seconds' => 0, + ]); +}); + +it('rejects relay gate configs without a logical relay id', function (): void { + $config = new department_gate_config([ + 'type' => 'RELAY', + ]); + + expect(fn() => $config->validate()) + ->toThrow(Exception::class, 'relay_id is required for RELAY gate type'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/DepartmentGatesRelaysRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/DepartmentGatesRelaysRouteWiringTest.php new file mode 100644 index 00000000..50ce5dc5 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/DepartmentGatesRelaysRouteWiringTest.php @@ -0,0 +1,9 @@ +not->toBeFalse(); + expect($route)->toContain('$gateConfig->validate();'); + expect(substr_count($route, '$gateConfig->validate();'))->toBeGreaterThanOrEqual(2); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/DepartmentLaneRelayNullificationRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/DepartmentLaneRelayNullificationRouteWiringTest.php new file mode 100644 index 00000000..29e60fe8 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/DepartmentLaneRelayNullificationRouteWiringTest.php @@ -0,0 +1,17 @@ +not->toBeFalse(); + expect($route)->toContain('private static function normalizeRelayRequestParameter'); + expect($route)->toContain('private static function syncDepartmentLaneRelayValue'); + expect($route)->toContain('$field->nullify();'); + expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_in_id, $relay_in_id);'); + expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_out_id, $relay_out_id);'); + expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_id, $relay_machine_id);'); + expect($route)->toContain( + 'self::syncDepartmentLaneRelayValue($department_lane->relay_machine_program_picker_id, $relay_machine_program_picker_id);' + ); + expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_cleaner_id, $relay_machine_cleaner_id);'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/DepartmentLaneRelayOptionsRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/DepartmentLaneRelayOptionsRouteWiringTest.php new file mode 100644 index 00000000..a9898d7c --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/DepartmentLaneRelayOptionsRouteWiringTest.php @@ -0,0 +1,10 @@ +not->toBeFalse(); + expect($route)->toContain("'/department/lanes/relay-options'"); + expect($route)->toContain('new shelly_relay_inventory()'); + expect($route)->toContain('LIST_DEPARTMENT_LANE_RELAY_OPTIONS'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php new file mode 100644 index 00000000..2abfeb12 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php @@ -0,0 +1,17 @@ +not->toBeFalse(); + expect($routeContent)->toContain('/departments/self-serve/enabled'); + expect($routeContent)->toContain('$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);'); + expect($routeContent)->toContain('if (!$enabled) {'); + expect($routeContent)->toContain('// Self-serve disabled: do not mutate lane relay states.'); + expect($routeContent)->toContain('setMachineProgramPickerRelayStatus(false)'); + expect($routeContent)->toContain('setMachineCleanerRelayStatus(false)'); + expect($routeContent)->toContain('setMachineRelayStatus(false)'); + expect($routeContent)->not->toContain('setMachineProgramPickerRelayStatusHard(false)'); + expect($routeContent)->not->toContain('setMachineCleanerRelayStatusHard(false)'); + expect($routeContent)->not->toContain('setMachineRelayStatusHard(false)'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php new file mode 100644 index 00000000..13d1c1d4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php @@ -0,0 +1,13 @@ +not->toBeFalse(); + expect($source)->toContain("private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';"); + expect($source)->toContain('class edge_broker_transport_exception extends Exception'); + expect($source)->toContain('class edge_broker_http_exception extends Exception'); + expect($source)->toContain("getenv('EDGE_BROKER_SHARED_SECRET')"); + expect($source)->toContain("getenv('EDGE_INTERNAL_SECRET')"); + expect($source)->not->toContain('DEFAULT_SHARED_SECRET'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayArtifactLocatorTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayArtifactLocatorTest.php new file mode 100644 index 00000000..fdabbb71 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayArtifactLocatorTest.php @@ -0,0 +1,179 @@ + $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-gateway-stack.service'); + + expect($contents)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up'); +}); + +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'))); + expect($payload['stackServiceUnitSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service'))); + expect($payload['composeFileSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/docker-compose.gateway.yml'))); + expect($payload['launcherScriptSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/gateway-launcher.sh'))); +}); + +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, "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); + } + } +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayDepartmentWorkspaceContractTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayDepartmentWorkspaceContractTest.php new file mode 100644 index 00000000..82b16916 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayDepartmentWorkspaceContractTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($service)->toContain('class edge_gateway_department_workspace_service'); + expect($service)->toContain("'summary' => \$summary"); + expect($service)->toContain("'gateways' => \$includeGateways ? \$gateways : []"); + expect($service)->toContain("'lanes' => \$lanes"); + expect($service)->toContain("'self_serve' => \$selfServe"); + expect($service)->toContain("'gates' => \$gates"); + expect($service)->toContain("'relays' => \$relays"); + expect($service)->toContain("'scanners' => \$scanners"); + expect($service)->toContain("'issues' => \$issues"); + expect($service)->toContain("'actions' => \$actions"); + expect($service)->toContain("'consumer_contexts'"); + expect($service)->toContain("'coverage'"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php new file mode 100644 index 00000000..d225d60b --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php @@ -0,0 +1,150 @@ + 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, + ]); +}); + +it('derives fleet usage directly from cached gateway row summaries', function (): void { + $summary = edge_gateway_manager::summarizeFleetUsageFromGatewayRows([ + [ + '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, + ], + ], + 'inventory_summary' => ['total' => 2, 'online' => 1, 'offline' => 1], + 'binding_summary' => ['total' => 3, 'fallback_overrides' => 2], + 'fallback_summary' => ['cloud_only_relays' => 1, 'local_only_relays' => 1], + ], + [ + '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, + ], + ], + 'inventory_summary' => ['total' => 1, 'online' => 1, 'offline' => 0], + 'binding_summary' => ['total' => 0, 'fallback_overrides' => 0], + 'fallback_summary' => ['cloud_only_relays' => 0, 'local_only_relays' => 0], + ], + ]); + + expect($summary['inventory'])->toBe([ + 'total' => 3, + 'online' => 2, + 'offline' => 1, + ]); + expect($summary['bindings'])->toBe([ + 'total' => 3, + 'fallback_overrides' => 2, + 'cloud_only' => 1, + 'local_only' => 1, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayInstallSessionLifecycleTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayInstallSessionLifecycleTest.php new file mode 100644 index 00000000..028a97d4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayInstallSessionLifecycleTest.php @@ -0,0 +1,94 @@ + edge_gateway_manager::INSTALL_SESSION_STATUS_RUNNING, + 'step' => 'STEP_' . $index, + 'message' => 'Installer phase ' . $index, + ], strtotime('2026-04-08 10:00:' . str_pad((string)$index, 2, '0', STR_PAD_LEFT))); + } + + $failed = edge_gateway_manager::mergeInstallSessionUpdate($session, [ + 'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED, + 'step' => 'START_STACK', + 'message' => 'Compose rollout failed during startup.', + 'diagnostics' => array_map( + static fn(int $index): array => [ + 'name' => 'Diagnostic ' . $index, + 'output' => 'Output ' . $index, + ], + range(1, 8) + ), + ], strtotime('2026-04-08 10:01:30')); + + expect($failed['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED); + expect($failed['step'])->toBe('START_STACK'); + expect($failed['message'])->toBe('Compose rollout failed during startup.'); + expect($failed['started_at'])->toBe('2026-04-08 10:00:01'); + expect($failed['updated_at'])->toBe('2026-04-08 10:01:30'); + expect($failed['last_error'])->toBe('Compose rollout failed during startup.'); + expect($failed['diagnostics'])->toHaveCount(6); + expect($failed['diagnostics'][0]['name'])->toBe('Diagnostic 3'); + expect($failed['diagnostics'][5]['name'])->toBe('Diagnostic 8'); + expect($failed['events'])->toHaveCount(12); + expect($failed['events'][11]['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED); + expect($failed['events'][11]['step'])->toBe('START_STACK'); +}); + +it('clears terminal failure details after a successful claim update', function (): void { + $claimed = edge_gateway_manager::mergeInstallSessionUpdate([ + 'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED, + 'step' => 'START_STACK', + 'message' => 'Compose rollout failed during startup.', + 'started_at' => '2026-04-08 10:00:01', + 'updated_at' => '2026-04-08 10:01:30', + 'last_error' => 'Compose rollout failed during startup.', + 'diagnostics' => [ + ['name' => 'systemctl status', 'output' => 'failed'], + ], + 'events' => [ + [ + 'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED, + 'step' => 'START_STACK', + 'message' => 'Compose rollout failed during startup.', + 'at' => '2026-04-08 10:01:30', + ], + ], + ], [ + 'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED, + 'step' => edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED, + 'message' => 'Gateway claimed successfully.', + 'gateway_id' => 703, + ], strtotime('2026-04-08 10:02:00')); + + expect($claimed['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED); + expect($claimed['gateway_id'])->toBe(703); + expect($claimed['last_error'])->toBeNull(); + expect($claimed['diagnostics'])->toBe([]); + expect($claimed['events'])->toHaveCount(2); + expect($claimed['events'][1]['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED); +}); + +it('marks expired non-terminal install sessions as terminal when read back', function (): void { + $normalized = edge_gateway_manager::normalizeInstallSessionRecord([ + 'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_RUNNING, + 'step' => 'WAIT_FOR_CLAIM', + 'message' => 'Installer is waiting for the gateway heartbeat and claim.', + 'started_at' => '2026-04-08 10:00:01', + 'updated_at' => '2026-04-08 10:01:30', + ], '2026-04-08 10:01:00', strtotime('2026-04-08 10:02:00')); + + expect($normalized['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_EXPIRED); + expect($normalized['step'])->toBe('WAIT_FOR_CLAIM'); + expect($normalized['updated_at'])->toBe('2026-04-08 10:01:30'); + expect($normalized['message'])->toBe('Installer is waiting for the gateway heartbeat and claim.'); + expect($normalized['last_error'])->toBe('Install token expired.'); + expect($normalized['terminal'])->toBeTrue(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayLegacyRouteShimTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayLegacyRouteShimTest.php new file mode 100644 index 00000000..ea465a4b --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayLegacyRouteShimTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($shimSource)->toContain('new \\classes\\edgegateway()'); + expect($shimSource)->toContain('isEnabled()'); + expect($shimSource)->toContain("modules/edgegateway/routes/{$legacyShim}"); + } +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php new file mode 100644 index 00000000..01152c31 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php @@ -0,0 +1,93 @@ +not->toBeFalse(); + expect($managerSource)->toContain('public function queueDiscovery'); + expect($managerSource)->toContain('public function dispatchRelayStatus'); + expect($managerSource)->toContain('public function dispatchRelayStatusLocalOnly'); + expect($managerSource)->toContain('public function dispatchRelaySwitch'); + expect($managerSource)->toContain('public function dispatchRelaySwitchLocalOnly'); + expect($managerSource)->toContain('public function dispatchRelaySwitchWithTimer'); + expect($managerSource)->toContain('public function dispatchRelaySwitchLocalOnlyWithTimer'); + expect($managerSource)->toContain('private function createCommandJob'); + expect($managerSource)->toContain("'GET_RELAY_STATUS'"); + expect($managerSource)->toContain("'SET_RELAY_STATE'"); + expect($managerSource)->toContain("'relayId' => \$logicalRelayId"); + expect($managerSource)->toContain("'deviceId' => \$binding['device_id']"); + expect($managerSource)->toContain("'localIp' => \$binding['local_ip']"); + expect($managerSource)->toContain("'channel' => (int)\$binding['channel']"); + expect($managerSource)->toContain("'on' => \$on"); + expect($managerSource)->toContain("'require_fast_path' => \$requireFastLocalPath"); + expect($managerSource)->toContain('private function resolveRelayBindingDeviceGeneration'); + expect($managerSource)->toContain('private function normalizeDeviceCapabilities'); + expect($managerSource)->toContain("\$request['device_generation'] = \$deviceGeneration;"); + expect($managerSource)->toContain("\$request['toggle_after'] = \$toggleAfterSeconds;"); + expect($managerSource)->toContain('private function expireTimedOutRelayStatusCommandJobs'); + expect($managerSource)->toContain("AND command_type = 'GET_RELAY_STATUS'"); + expect($managerSource)->toContain("'Edge gateway command timed out', null, 'TIMED_OUT'"); + expect($managerSource)->toContain('private function normalizeCommandFailureStatus'); + expect($managerSource)->toContain('private function isCommandTimeoutError'); + expect($managerSource)->toContain('local_transport_override'); + expect($managerSource)->toContain('private function resolveRelayBindingLocalIp'); + expect($managerSource)->toContain('private function findShellyCloudRelayLocalIp'); + expect($managerSource)->toContain('private function backfillRelayBindingLocalIpFromInventory'); + expect($managerSource)->toContain('$bindingObject->local_ip->set($localIp);'); + expect($managerSource)->toContain("'local_ip' => \$localIp"); + expect($managerSource)->toContain('(new shelly_relay_inventory())->listRelayOptions()'); + expect($managerSource)->toContain('public function buildUpdateOperationRequest'); + expect($managerSource)->toContain('public function rotateGatewayCredentials'); + expect($operationServiceSource)->toContain('public function queueOperation'); + expect($operationServiceSource)->toContain('public function cancelOperation'); + expect($operationServiceSource)->toContain('public function claimNextOperation'); + expect($operationServiceSource)->toContain('public function completeAgentOperation'); + expect($operationServiceSource)->toContain("public const STATUS_CANCEL_REQUESTED = 'CANCEL_REQUESTED';"); + expect($operationServiceSource)->toContain("public const STATUS_CANCELLED = 'CANCELLED';"); + expect($operationServiceSource)->toContain("public const ERROR_CANCELLED = 'EDGE_GATEWAY_CANCELLED';"); + 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('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('queueDiscovery'))->toBeTrue(); + expect($reflection->hasMethod('pollCommand'))->toBeTrue(); + expect($reflection->hasMethod('submitCommandResult'))->toBeTrue(); + expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue(); + expect($reflection->hasMethod('dispatchRelayStatusLocalOnly'))->toBeTrue(); + expect($reflection->hasMethod('dispatchRelaySwitch'))->toBeTrue(); + expect($reflection->hasMethod('dispatchRelaySwitchLocalOnly'))->toBeTrue(); + expect($reflection->hasMethod('dispatchRelaySwitchWithTimer'))->toBeTrue(); + expect($reflection->hasMethod('dispatchRelaySwitchLocalOnlyWithTimer'))->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('resolveRelayBindingDeviceGeneration'))->toBeTrue(); + expect($reflection->getMethod('resolveRelayBindingDeviceGeneration')->isPrivate())->toBeTrue(); + expect($reflection->hasMethod('normalizeDeviceCapabilities'))->toBeTrue(); + expect($reflection->getMethod('normalizeDeviceCapabilities')->isPrivate())->toBeTrue(); + expect($reflection->hasMethod('resolveRelayBindingLocalIp'))->toBeTrue(); + expect($reflection->getMethod('resolveRelayBindingLocalIp')->isPrivate())->toBeTrue(); + expect($reflection->hasMethod('backfillRelayBindingLocalIpFromInventory'))->toBeTrue(); + expect($reflection->getMethod('backfillRelayBindingLocalIpFromInventory')->isPrivate())->toBeTrue(); + expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('cancelOperation'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('listOperations'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('claimNextOperation'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('appendAgentOperationEvent'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('completeAgentOperation'))->toBeTrue(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php new file mode 100644 index 00000000..da5940f2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php @@ -0,0 +1,207 @@ +not->toBeNull(); + + return $timestamp; +} + +it('derives effective gateway status from heartbeat freshness', function ( + string $reportedStatus, + ?string $lastHeartbeatAt, + string $currentTime, + string $expectedStatus +): void { + $now = edge_gateway_heartbeat_test_now($currentTime); + + $effectiveStatus = edge_gateway_manager::resolveGatewayStatus( + $reportedStatus, + $lastHeartbeatAt, + $now + ); + + expect($effectiveStatus)->toBe($expectedStatus); +})->with([ + 'recent heartbeat stays online' => [ + edge_gateway_manager::STATUS_ONLINE, + '2026-04-08 10:00:00', + '2026-04-08 10:00:59', + edge_gateway_manager::STATUS_ONLINE, + ], + 'late heartbeat degrades after one minute' => [ + edge_gateway_manager::STATUS_ONLINE, + '2026-04-08 10:00:00', + '2026-04-08 10:01:00', + edge_gateway_manager::STATUS_DEGRADED, + ], + 'stale heartbeat goes offline after five minutes' => [ + edge_gateway_manager::STATUS_ONLINE, + '2026-04-08 10:00:00', + '2026-04-08 10:05:00', + edge_gateway_manager::STATUS_OFFLINE, + ], + 'explicit offline reports stay offline even when heartbeat is fresh' => [ + edge_gateway_manager::STATUS_OFFLINE, + '2026-04-08 10:04:55', + '2026-04-08 10:05:00', + edge_gateway_manager::STATUS_OFFLINE, + ], + 'missing heartbeat is treated as offline' => [ + edge_gateway_manager::STATUS_ONLINE, + null, + '2026-04-08 10:05:00', + edge_gateway_manager::STATUS_OFFLINE, + ], +]); + +it('marks ready discovery as stale when the gateway heartbeat has expired', function (): void { + $gateway = edge_gateway_manager::deriveGatewayRuntimeState([ + 'status' => edge_gateway_manager::STATUS_ONLINE, + 'last_heartbeat_at' => '2026-04-08 10:00:00', + 'discovery_status' => 'READY', + ], edge_gateway_heartbeat_test_now('2026-04-08 21:00:00')); + + expect($gateway['status'])->toBe(edge_gateway_manager::STATUS_OFFLINE); + expect($gateway['discovery_status'])->toBe('STALE'); +}); + +it('only trusts broker presence while the broker heartbeat is fresh', function (): void { + $recent = edge_gateway_manager::deriveGatewayRuntimeState([ + 'status' => edge_gateway_manager::STATUS_ONLINE, + 'last_heartbeat_at' => '2026-04-08 10:04:50', + 'metadata' => [ + 'broker_presence' => [ + 'connected' => true, + 'last_seen_at' => '2026-04-08 10:04:30', + ], + ], + ], edge_gateway_heartbeat_test_now('2026-04-08 10:05:00')); + + expect($recent['channel_status']['broker']['connected'])->toBeTrue(); + expect($recent['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_ONLINE); + expect($recent['channel_status']['command']['preferred'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_BROKER); + + $stale = edge_gateway_manager::deriveGatewayRuntimeState([ + 'status' => edge_gateway_manager::STATUS_ONLINE, + 'last_heartbeat_at' => '2026-04-08 10:04:50', + 'metadata' => [ + 'broker_presence' => [ + 'connected' => true, + 'last_seen_at' => '2026-04-08 10:03:00', + ], + ], + ], edge_gateway_heartbeat_test_now('2026-04-08 10:05:00')); + + expect($stale['channel_status']['broker']['connected'])->toBeFalse(); + expect($stale['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE); + expect($stale['channel_status']['command']['preferred'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API); +}); + +it('merges incoming heartbeat metadata with existing gateway metadata', function (): void { + $source = file_get_contents(app_path('classes/edge_gateway_manager.php')); + + expect($source)->toContain("\$existingMetadata = (array)(\$gateway->metadata_json->value() ?? []);"); + expect($source)->toContain("\$payloadMetadata = (array)(\$payload['metadata'] ?? []);"); + expect($source)->toContain('$metadata = $this->mergeHeartbeatBrokerPresence($gatewayId, $existingMetadata, $payloadMetadata);'); + expect($source)->toContain('private function mergeHeartbeatBrokerPresence'); + expect($source)->toContain('$gateway->metadata_json->set($metadata);'); +}); + +it('refreshes broker presence from broker telemetry heartbeats', function (): void { + $source = file_get_contents(app_path('classes/edge_gateway_manager.php')); + + expect($source)->toContain('public function recordTelemetryFromBroker'); + expect($source)->toContain("'broker_connection_id'"); + expect($source)->toContain('$this->writeBrokerPresence($gatewayId, $presence);'); + expect($source)->toContain('private static function isBrokerPresenceConnected'); +}); + +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', + 'department_transport_mode' => edge_gateway_manager::TRANSPORT_MODE_GATEWAY, + 'metadata' => [ + 'last_sync_at' => '2026-04-08 10:04:20', + 'broker_presence' => [ + 'connected' => false, + 'last_seen_at' => '2026-04-08 10:03:00', + 'last_error' => 'broker timeout', + ], + 'control_plane_status' => [ + 'last_successful_sync_at' => '2026-04-08 10:04:10', + 'last_transport_failure_at' => '2026-04-08 10:04:12', + 'last_transport_error' => 'POST https://api.truckwash.io/edge-agent/gateways/17/heartbeat returned HTTP 502', + ], + ], + 'operational_snapshot' => [ + 'command_backlog' => 2, + 'operation_backlog' => 1, + ], + 'active_operation' => [ + 'id' => 91, + 'type' => 'DISCOVERY', + 'status' => 'IN_PROGRESS', + 'started_at' => '2026-04-08 09:30:00', + ], + 'bindings' => [ + [ + 'id' => 1, + 'relay_id' => 'M-7', + 'device_id' => 'shelly-plus-01', + 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL, + ], + [ + 'id' => 2, + 'relay_id' => 'M-7-CANARY', + 'device_id' => 'missing-device', + 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_LOCAL_ONLY, + ], + ], + 'inventory' => [ + [ + 'device_id' => 'shelly-plus-01', + 'online' => true, + 'last_seen_at' => '2026-04-08 09:55:00', + ], + ], + 'recent_commands' => [ + [ + 'status' => 'COMPLETED', + 'command_type' => 'DISCOVER_SHELLY', + 'completed_at' => '2026-04-08 10:02:00', + ], + ], + ], edge_gateway_heartbeat_test_now('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'); + expect($gateway['relay_health'][1]['execution_path'])->toBe('local'); + expect($gateway['relay_health'][1]['reason'])->toBe('device_missing'); + expect($gateway['fallback_summary']['cloud_relays'])->toBe(1); + expect($gateway['fallback_summary']['local_only_relays'])->toBe(1); + expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED); + expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery'); + expect($gateway['transport_health']['last_successful_sync_at'])->toBe('2026-04-08 10:04:30'); + expect($gateway['transport_health']['last_transport_failure_at'])->toBe('2026-04-08 10:04:12'); + expect($gateway['transport_health']['last_transport_error'])->toContain('returned HTTP 502'); + expect($gateway['last_sync_at'])->toBe('2026-04-08 10:04:30'); + expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00'); + expect($gateway['diagnostics'])->not->toBeEmpty(); + expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT'); + expect($gateway['backlog_depth'])->toBe([ + 'commands' => 2, + 'operations' => 1, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php new file mode 100644 index 00000000..c62863d0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php @@ -0,0 +1,179 @@ +getVariableValue(); + $publicBrokerConfig->setVariableValue(''); + } catch (Throwable) { + $publicBrokerConfig = null; + } + + try { + $callback(); + } finally { + $_SERVER = $originalServer; + if ($originalPublicApiUrl === false) { + putenv('EDGE_PUBLIC_API_URL'); + } else { + putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl); + } + if ($originalPublicBrokerUrl === false) { + putenv('EDGE_PUBLIC_BROKER_URL'); + } else { + putenv('EDGE_PUBLIC_BROKER_URL=' . $originalPublicBrokerUrl); + } + if ($publicBrokerConfig !== null && $originalPublicBrokerConfig !== null) { + try { + $publicBrokerConfig->setVariableValue($originalPublicBrokerConfig); + } catch (Throwable) { + } + } + } +} + +function invoke_edge_gateway_private(object $instance, string $method, mixed ...$arguments): mixed +{ + $reflection = new ReflectionMethod($instance, $method); + $reflection->setAccessible(true); + return $reflection->invokeArgs($instance, $arguments); +} + +it('builds install script urls with the compose edge gateway artifacts and forwarded https scheme', 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('abc123'); + + expect($manager->getApiBaseUrl())->toBe('https://api.truckwash.io:4433'); + 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('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"'); + expect($script)->toContain('INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"'); + 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 LAN worker" "https://api.truckwash.io:4433/edge-agent/artifacts/lan-worker.php" "$INSTALL_DIR/lan-worker.php"'); + expect($script)->toContain('fetch_http "Download compose stack" "https://api.truckwash.io:4433/edge-agent/artifacts/docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"'); + expect($script)->toContain('fetch_http "Download compose stack service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"'); + expect($script)->toContain('report_install_status() {'); + expect($script)->toContain('begin_install_phase "VERIFY_TOKEN" "Verifying install token"'); + expect($script)->toContain('begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"'); + expect($script)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"'); + expect($script)->toContain('report_install_status "CLAIMED" "CLAIMED"'); + expect($script)->toContain('Installer failed during step ${CURRENT_STEP_CODE:-FAILED}: ${CURRENT_STEP:-unknown}'); + expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}'); + expect($script)->toContain('Response body preview (first 400 bytes):'); + expect($script)->toContain('wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180'); + expect($script)->toContain('wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180'); + expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"'); + expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"'); + expect($script)->toContain('"stackServiceName":"truckwash-edge-gateway-stack.service"'); + expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"'); + expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"'); + expect($script)->toContain('"operationPollTimeoutSeconds":20'); + expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4433/edge-broker"'); + expect($script)->not->toContain('"shellActionPollTimeoutSeconds"'); + }); +}); + +it('appends forwarded ports when the forwarded host omits them', function (): void { + with_edge_gateway_server_state([ + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_HOST' => 'api.truckwash.io', + 'HTTP_X_FORWARDED_PORT' => '4433', + ], function (): void { + $manager = new EdgeGatewayManagerUrlHarness(); + + expect($manager->getApiBaseUrl())->toBe('https://api.truckwash.io:4433'); + }); +}); + +it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void { + with_edge_gateway_server_state([ + 'HTTP_HOST' => 'localhost', + 'HTTP_X_FORWARDED_PROTO' => 'http', + 'HTTP_X_FORWARDED_PREFIX' => '/api', + ], function (): void { + putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api'); + + $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($script)->toContain('"apiUrl":"https://edge.example.test/api"'); + expect($script)->toContain('"brokerUrl":"https://edge.example.test/api/edge-broker"'); + }); +}); + +it('builds websocket broker urls on the traefik broker path', function (): void { + with_edge_gateway_server_state([ + 'HTTP_HOST' => 'api.truckwash.io:4433', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ], function (): void { + $manager = new EdgeGatewayManagerUrlHarness(); + + expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl')) + ->toBe('https://api.truckwash.io:4433/edge-broker'); + expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-shell')) + ->toBe('wss://api.truckwash.io:4433/edge-broker/ws/browser-shell'); + }); +}); + +it('builds localhost websocket broker urls on the local traefik api prefix', function (): void { + with_edge_gateway_server_state([ + 'HTTP_HOST' => 'localhost', + 'HTTP_X_FORWARDED_PROTO' => 'http', + 'HTTP_X_FORWARDED_PREFIX' => '/api', + ], function (): void { + $manager = new EdgeGatewayManagerUrlHarness(); + + expect($manager->getApiBaseUrl()) + ->toBe('http://localhost/api'); + expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl')) + ->toBe('http://localhost/api/edge-broker'); + expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-gateway-stream')) + ->toBe('ws://localhost/api/edge-broker/ws/browser-gateway-stream'); + expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-shell')) + ->toBe('ws://localhost/api/edge-broker/ws/browser-shell'); + }); +}); + +it('keeps root-host api urls unprefixed when the request is not under the local api alias', function (): void { + with_edge_gateway_server_state([ + 'HTTP_HOST' => 'localhost', + 'HTTP_X_FORWARDED_PROTO' => 'http', + 'REQUEST_URI' => '/edge-gateways/84/stream-session', + ], function (): void { + $manager = new EdgeGatewayManagerUrlHarness(); + + expect($manager->getApiBaseUrl()) + ->toBe('http://localhost'); + expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl')) + ->toBe('http://localhost/edge-broker'); + }); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMetadataUpdateContractTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMetadataUpdateContractTest.php new file mode 100644 index 00000000..524b78cb --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMetadataUpdateContractTest.php @@ -0,0 +1,29 @@ +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(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php new file mode 100644 index 00000000..8cf9e925 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php @@ -0,0 +1,58 @@ +not->toBeFalse(); + expect($route)->toContain("'/modules/edge-gateways'"); + expect($route)->toContain("'/modules/edge-gateways/workspace/departments'"); + expect($route)->toContain("'/modules/edge-gateways/workspace/departments/{id}'"); + expect($route)->toContain("'/modules/edge-gateways/{id}'"); + expect($route)->toContain("'/modules/edge-gateways/install-token'"); + expect($route)->toContain("'/modules/edge-gateways/install-token/{id}/status'"); + expect($route)->toContain("'/modules/edge-gateways/{id}/discovery'"); + expect($route)->toContain("'/modules/edge-gateways/{id}/bindings'"); + expect($route)->toContain("'/modules/edge-gateways/{id}/operations'"); + expect($route)->toContain("'/modules/edge-gateways/{id}/operations/{operationId}/cancel'"); + expect($route)->toContain("'/modules/edge-gateways/{id}/operations/{operationId}/events'"); + expect($route)->toContain("'/modules/edge-gateways/{id}/rotate-credentials'"); + expect($route)->toContain("'/modules/edge-gateways/departments/{id}/cutover'"); + expect($route)->toContain('requireModuleEnabled()'); + expect($route)->not->toContain("'/modules/edge-gateways/{id}/shell-sessions'"); +}); + +it('registers edge gateway config endpoints from the module route directory', function (): void { + $route = file_get_contents(app_path('modules/edgegateway/routes/edgeGatewayConfigRoute.php')); + $legacyRoute = file_get_contents(app_path('routes/moduleConfigRoute.php')); + + expect($route)->not->toBeFalse(); + expect($route)->toContain("'/edgegateway/config'"); + expect($route)->toContain("'/edgegateway/config/broker-diagnostics'"); + expect($route)->toContain('new edgegateway()'); + expect($route)->toContain('new edge_gateway_manager()'); + expect($legacyRoute)->not->toContain("'/edgegateway/config'"); +}); + +it('registers broker settings as editable edge gateway module config', function (): void { + $module = file_get_contents(app_path('modules/edgegateway/edgegateway_c.php')); + + expect($module)->not->toBeFalse(); + expect($module) + ->toContain('edgegateway_broker_url_c::class') + ->toContain('edgegateway_public_broker_url_c::class') + ->toContain('edgegateway_broker_auth_mode_c::class') + ->not->toContain('edgegateway_broker_shared_secret_c::class,'); +}); + +it('keeps only the module facade in the global classes directory and conditionally loads module routes', function (): void { + $classes = glob(app_path('classes/*.php')) ?: []; + $edgeGatewayClasses = array_values(array_filter($classes, static function (string $path): bool { + $name = basename($path); + return str_contains($name, 'edgegateway') || str_contains($name, 'edge_gateway'); + })); + $index = file_get_contents(app_path('index.php')); + + expect($edgeGatewayClasses)->toEqual([app_path('classes/edgegateway.php')]); + expect($index)->toContain("\$routes_path = \$modules_path . DIRECTORY_SEPARATOR . \$module_dir . DIRECTORY_SEPARATOR . 'routes'"); + expect($index)->toContain("method_exists(\$module, 'isEnabled') && !\$module->isEnabled()"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayOperationLegacySchemaCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayOperationLegacySchemaCompatibilityTest.php new file mode 100644 index 00000000..20791da4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayOperationLegacySchemaCompatibilityTest.php @@ -0,0 +1,17 @@ +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)"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php new file mode 100644 index 00000000..6330fc22 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php @@ -0,0 +1,67 @@ +not->toBeFalse(); + expect($route)->toContain("'/edge-gateways'"); + expect($route)->toContain("'/edge-gateways/{id}'"); + expect($route)->toContain("'/edge-gateways/install-token'"); + expect($route)->toContain("'/edge-gateways/install-token/{id}/status'"); + expect($route)->toContain("'/edge-gateways/{id}/discovery'"); + expect($route)->toContain("'/edge-gateways/{id}/bindings'"); + expect($route)->toContain("'/edge-gateways/{id}/operations'"); + expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'"); + expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/events'"); + expect($route)->toContain("'/edge-gateways/{id}/tasks'"); + expect($route)->toContain("'/edge-gateways/{id}/logs'"); + expect($route)->toContain("'/edge-gateways/{id}/statistics'"); + expect($route)->toContain("'/edge-gateways/{id}/stream-session'"); + expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'"); + expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'"); + expect($route)->toContain("'/departments/{id}/gateway-cutover'"); + expect($route)->toContain("add_meta('fleet_usage'"); + expect($route)->toContain('listGatewaysWithFleetUsage('); + expect($route)->not->toContain('private function requirePermission'); + expect($route)->not->toContain('private function requireDepartmentAccess'); +}); + +it('registers PHP edge agent routes for operations and legacy relay command polling', function (): void { + $route = file_get_contents(app_path('routes/edgeGatewaysRoute.php')); + $manager = file_get_contents(app_path('classes/edge_gateway_manager.php')); + $agent = file_get_contents(app_path('resources/edge-gateway-agent/agent.php')); + + expect($route)->toContain("'/edge-agent/install-token/verify'"); + expect($route)->toContain("'/edge-agent/install-token/status'"); + expect($route)->toContain("'/edge-agent/install.sh'"); + expect($route)->toContain("'/edge-agent/artifacts/agent.php'"); + expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'"); + expect($route)->toContain("'/edge-agent/artifacts/auto-updater.php'"); + expect($route)->toContain("'/edge-agent/artifacts/docker-compose.gateway.yml'"); + expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.edge-agent'"); + expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.lan-worker'"); + expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.auto-updater'"); + expect($route)->toContain("'/edge-agent/artifacts/gateway-launcher.sh'"); + expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-gateway-stack.service'"); + 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}/presence'"); + expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'"); + expect($route)->toContain("'/edge-agent/internal/gateways/{id}/backlog'"); + expect($route)->toContain("'/edge-agent/internal/gateways/{id}/telemetry'"); + expect($route)->toContain("'/edge-agent/internal/gateways/{id}/logs'"); + expect($route)->toContain("'/edge-agent/internal/browser-streams/validate'"); + expect($route)->toContain("'/edge-agent/internal/shell-sessions/opened'"); + expect($route)->toContain('echo $exception->getMessage()'); + expect($manager)->toContain("\$gatewayPayload['broker_url'] = \$this->buildBrokerPublicUrl();"); + expect($agent)->toContain('applyBrokerUrlFromControlPlaneResponse'); + expect($agent)->toContain("Updated broker URL from control plane heartbeat response."); + expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'"); + expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php new file mode 100644 index 00000000..443f3a64 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php @@ -0,0 +1,50 @@ +not->toBeFalse(); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateways'); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_claim_tokens'); + 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_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)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_log_entries'); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions'); + expect($bootstrapContent)->not->toContain('edge_gateway_shell_action_jobs'); + expect($bootstrapContent)->not->toContain('edge_gateway_shell_events'); +}); + +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('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("ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message')"); + expect($bootstrapContent)->toContain('summary_json JSON NULL'); + expect($bootstrapContent)->toContain('context_json JSON NULL'); + expect($bootstrapContent)->toContain('session_token_hash CHAR(64) NOT NULL'); + expect($bootstrapContent)->toContain('terminal_rows INT NULL'); + expect($bootstrapContent)->toContain("renameColumnIfPresent('edge_gateway_shell_sessions', 'rows', 'terminal_rows', 'INT NULL', 'cols')"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php new file mode 100644 index 00000000..579db874 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php @@ -0,0 +1,15 @@ +not->toBeFalse(); + + 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'"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php new file mode 100644 index 00000000..8ea270b9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php @@ -0,0 +1,166 @@ +not->toBeFalse(); + expect($launcherSource)->not->toBeFalse(); + expect($composeSource)->not->toBeFalse(); + expect($agentSource)->not->toBeFalse(); + expect($edgeDockerfileSource)->not->toBeFalse(); + expect($workerDockerfileSource)->not->toBeFalse(); + expect($autoUpdaterSource)->not->toBeFalse(); + expect($autoUpdaterDockerfileSource)->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 LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"'); + expect($managerSource)->toContain('fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"'); + expect($managerSource)->toContain('fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"'); + expect($managerSource)->toContain('fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"'); + expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"'); + expect($managerSource)->toContain('log_error "Request: GET ${url}"'); + expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"'); + expect($managerSource)->toContain('run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3'); + expect($managerSource)->toContain('run_step "Installing Docker Compose runtime" install_compose_runtime'); + expect($managerSource)->toContain('apt-get install -y docker-compose-plugin'); + expect($managerSource)->toContain('apt-get install -y docker-compose'); + expect($managerSource)->toContain('Unable to install Docker Compose using docker-compose-plugin or docker-compose.'); + expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.'); + expect($managerSource)->toContain('report_install_status() {'); + expect($managerSource)->toContain('begin_install_phase "START_STACK" "Starting edge gateway stack"'); + expect($managerSource)->toContain('report_install_status "RUNNING" "$CURRENT_STEP_CODE" "$CURRENT_STEP"'); + expect($managerSource)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"'); + expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"'); + expect($managerSource)->toContain('chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh"'); + expect($managerSource)->toContain('systemctl enable truckwash-edge-gateway-stack.service'); + expect($managerSource)->toContain('systemctl restart truckwash-edge-gateway-stack.service'); + expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-gateway-stack.service'); + expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180'); + expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180'); + expect($managerSource)->toContain('journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true'); + expect($managerSource)->not->toContain('agent.mjs'); + expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()"); + expect($serviceSource)->toContain('Description=TruckWash Edge Agent Compatibility Unit'); + expect($serviceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up'); + expect($serviceSource)->toContain('ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile'); + expect($serviceSource)->toContain('ExecStop=/opt/truckwash-edge-agent/gateway-launcher.sh down'); + expect($serviceSource)->not->toContain('/usr/bin/php /opt/truckwash-edge-agent/agent.php'); + expect($stackServiceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up'); + expect($stackServiceSource)->toContain('TimeoutStartSec=900'); + expect($launcherSource)->toContain('STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}"'); + expect($launcherSource)->toContain('wait_for_stack_health'); + expect($launcherSource)->toContain('container_is_healthy truckwash-auto-updater'); + expect($launcherSource)->toContain('compose_project_name="$(config_value composeProjectName \'truckwash-edge-gateway\')"'); + expect($launcherSource)->toContain('COMPOSE_PROJECT_NAME="$compose_project_name"'); + expect($launcherSource)->toContain('AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image"'); + expect($launcherSource)->toContain('compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true'); + expect($launcherSource)->toContain('log "Compose rollout failed during build/startup"'); + expect($launcherSource)->toContain('write_rollback_status "FAILED" "compose_up_failed" "$installed_version"'); + expect($launcherSource)->toContain('write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"'); + expect($launcherSource)->toContain('write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"'); + $normalizedComposeSource = str_replace("\r\n", "\n", $composeSource); + expect($normalizedComposeSource)->toContain('version: "2.4"'); + expect($normalizedComposeSource)->toContain('condition: service_healthy'); + expect($normalizedComposeSource)->toContain("minio:\n condition: service_started"); + expect($normalizedComposeSource)->toContain("mariadb:\n condition: service_started"); + expect($composeSource)->toContain('/opt/truckwash-edge-agent/runtime/control-plane-status.json'); + expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]'); + expect($composeSource)->toContain('$$data[\\"last_successful_sync_at\\"]'); + expect($composeSource)->toContain('<= 30'); + expect($composeSource)->toContain('<= 90'); + expect($composeSource)->toContain("http://127.0.0.1:8090/health"); + expect($composeSource)->toContain('$$json=@file_get_contents(\'http://127.0.0.1:8090/health\');'); + expect($composeSource)->toContain('$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"'); + expect($composeSource)->not->toContain("curl\", \"-fsS\", \"http://127.0.0.1:9000/minio/health/live"); + expect($composeSource)->not->toContain('mysqladmin ping -h 127.0.0.1 -uroot -ptruckwash_edge_root --silent'); + expect($composeSource)->toContain('container_name: truckwash-redis'); + expect($composeSource)->toContain('container_name: truckwash-mariadb'); + expect($composeSource)->toContain('container_name: truckwash-minio'); + expect($composeSource)->toContain('container_name: truckwash-auto-updater'); + expect($agentSource)->toContain('private string $controlPlaneStatusPath;'); + expect($agentSource)->toContain('control-plane-status.json'); + expect($agentSource)->toContain("'control_plane_status' => \$this->buildControlPlaneStatusPayload()"); + expect($agentSource)->toContain("'last_heartbeat_attempt_at'"); + expect($agentSource)->toContain("'last_heartbeat_success_at'"); + expect($agentSource)->toContain("'last_successful_sync_at'"); + expect($agentSource)->toContain("'last_transport_failure_at'"); + expect($agentSource)->toContain("'last_transport_error'"); + expect($agentSource)->toContain('private function recordTransportFailure(string $context, Throwable $throwable): void'); + expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}'); + expect($edgeDockerfileSource)->toContain('apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev;'); + expect($edgeDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;'); + expect($edgeDockerfileSource)->toContain('extension_loaded($extension)'); + expect($edgeDockerfileSource)->toContain('Missing PHP extension: {$extension}'); + expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php'); + expect($workerDockerfileSource)->toContain('FROM ${BASE_IMAGE}'); + expect($workerDockerfileSource)->toContain('apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev;'); + expect($workerDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;'); + expect($workerDockerfileSource)->toContain('extension_loaded($extension)'); + expect($workerDockerfileSource)->toContain('Missing PHP extension: {$extension}'); + expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php'); + expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'"); + expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php'); + expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev;'); + expect($autoUpdaterDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;'); + expect($autoUpdaterDockerfileSource)->toContain('extension_loaded($extension)'); + expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs'); +}); + +it('exposes update payload, credential rotation, cancel endpoints, 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($managerSource)->toContain('public function buildUpdateOperationRequest'); + expect($managerSource)->toContain('public function rotateGatewayCredentials'); + expect($managerSource)->toContain("'autoUpdaterArtifactUrl' => \$this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT)"); + expect($managerSource)->toContain("'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE"); + expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()"); + expect($routeSource)->toContain("'/edge-gateways/{id}/rotate-credentials'"); + expect($routeSource)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'"); + expect($routeSource)->toContain("'/edge-agent/gateways/{id}/operations/next'"); + expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions'"); + expect($agentSource)->toContain("/operations/next"); + expect($agentSource)->toContain("/operations/' . \$operationId . '/complete"); + expect($agentSource)->toContain('final class OperationAbortException extends RuntimeException'); + expect($agentSource)->toContain('private BrokerWebSocketClient $brokerClient;'); + expect($agentSource)->toContain('private AgentShellBridge $shellBridge;'); + expect($agentSource)->toContain('private function dispatchBrokerControlPlaneEvent(string $endpoint, array $payload): ?bool'); + expect($agentSource)->toContain('private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool'); + expect($agentSource)->toContain('if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint))'); + expect($agentSource)->toContain('if ($brokerDispatch !== true || $this->shouldPersistControlPlaneEventOverHttp($endpoint))'); + expect($agentSource)->toContain('Broker dispatched \' . $type . \' but HTTP persistence failed on \' . $endpoint'); + expect($agentSource)->toContain("'type' => 'TELEMETRY'"); + expect($agentSource)->toContain("'type' => 'TASK_EVENT'"); + expect($agentSource)->toContain("'type' => 'TASK_RESULT'"); + expect($agentSource)->toContain("'type' => 'LOG_FRAME'"); + expect($agentSource)->toContain('private function requestControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): ?array'); + expect($agentSource)->toContain('throw new OperationAbortException(\'Operation cancelled by operator\', true);'); + expect($agentSource)->toContain('private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;'); + expect($agentSource)->toContain('if ($this->resumePendingOperationCompletion()) {'); + expect($agentSource)->toContain('$this->finalizeOperationCompletion('); + expect($agentSource)->toContain('private function finalizeOperationCompletion('); + expect($agentSource)->toContain('private function resumePendingOperationCompletion(): bool'); + expect($agentSource)->toContain('private function dispatchOperationCompletion(array $completion, bool $queueOnFailure): bool'); + expect($agentSource)->toContain("\$state['status'] = 'COMPLETION_PENDING';"); + expect($agentSource)->toContain("\$state['stage'] = 'awaiting_completion_ack';"); + expect($agentSource)->toContain("? self::OPERATION_COMPLETE_TIMEOUT_SECONDS"); + expect($agentSource)->toContain("unset(\$state['completion']);"); + expect($agentSource)->toContain('$services[] = $this->probeTcpService(\'redis\', \'redis\', 6379);'); + expect($agentSource)->toContain('final class HttpRequestTimeoutException extends RuntimeException'); + expect($agentSource)->toContain('], $this->pollRequestTimeoutSeconds($waitSeconds));'); + expect($agentSource)->toContain('} catch (HttpRequestTimeoutException) {'); + expect($agentSource)->toContain('private function pollRequestTimeoutSeconds(int $waitSeconds): int'); + expect($agentSource)->toContain('last-heartbeat-ok.txt'); + expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php new file mode 100644 index 00000000..5d9b7981 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php @@ -0,0 +1,219 @@ + */ + 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('EDGE_GATEWAY_VIEW_CACHE_TTL'); + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=15'); + $this->redis = new EdgeGatewayViewCacheRedisFake(); + edge_gateway_view_cache::setAdapterForTests($this->redis); +}); + +afterEach(function (): void { + edge_gateway_view_cache::setAdapterForTests(null); + + if ($this->oldTtl === false) { + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL'); + return; + } + + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=' . $this->oldTtl); +}); + +function edgeGatewayCacheGatewayRow( + int $gatewayId, + int $departmentId, + string $status, + array $inventorySummary = ['total' => 0, 'online' => 0, 'offline' => 0], + array $bindingSummary = ['total' => 0, 'fallback_overrides' => 0], + array $fallbackSummary = ['cloud_only_relays' => 0, 'local_only_relays' => 0] +): array { + return [ + 'id' => $gatewayId, + 'department_id' => $departmentId, + 'label' => 'Gateway ' . $gatewayId, + 'status' => $status, + 'version_drift' => ['is_drifted' => false], + 'channel_status' => ['broker' => ['connected' => $status === edge_gateway_manager::STATUS_ONLINE]], + 'active_operation' => null, + 'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0], + 'backlog_depth' => ['operations' => 0, 'commands' => 0], + 'metadata' => [ + 'system_metrics' => [ + 'latency_ms' => 100, + 'cpu_usage_pct' => 10, + 'memory_usage_pct' => 20, + 'disk_usage_pct' => 30, + ], + ], + 'inventory_summary' => $inventorySummary, + 'binding_summary' => $bindingSummary, + 'fallback_summary' => $fallbackSummary, + 'inventory' => [], + 'bindings' => [], + 'recent_commands' => [], + 'audit_logs' => [], + 'operations' => [], + ]; +} + +it('uses sane ttl defaults and deterministic cache keys', function (): void { + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL'); + expect(edge_gateway_view_cache::getTtl())->toBe(15); + + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=25'); + expect(edge_gateway_view_cache::getTtl())->toBe(25); + + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=-5'); + expect(edge_gateway_view_cache::getTtl())->toBe(0); + + expect(edge_gateway_view_cache::listKey(null, false))->toBe('edge_gateway:view:v1:list:department:all:detail:0'); + expect(edge_gateway_view_cache::detailKey(701))->toBe('edge_gateway:view:v1:detail:701'); +}); + +it('stores and retrieves cached list and detail payloads', function (): void { + $gateway = edgeGatewayCacheGatewayRow(701, 1, edge_gateway_manager::STATUS_ONLINE, ['total' => 2, 'online' => 1, 'offline' => 1]); + $payload = [ + 'gateways' => [$gateway], + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$gateway]), + ]; + + edge_gateway_view_cache::storeListPayload(null, false, $payload, 30); + edge_gateway_view_cache::storeDetailPayload(701, $gateway, 30); + + expect(edge_gateway_view_cache::getListPayload(null, false))->toBe($payload); + expect(edge_gateway_view_cache::getDetailPayload(701))->toBe($gateway); +}); + +it('syncs gateway snapshots into cached list payloads and refreshes fleet usage', function (): void { + $staleGateway = edgeGatewayCacheGatewayRow( + 701, + 1, + edge_gateway_manager::STATUS_OFFLINE, + ['total' => 1, 'online' => 0, 'offline' => 1], + ['total' => 1, 'fallback_overrides' => 0], + ['cloud_only_relays' => 0, 'local_only_relays' => 0] + ); + $otherGateway = edgeGatewayCacheGatewayRow( + 702, + 2, + edge_gateway_manager::STATUS_ONLINE, + ['total' => 1, 'online' => 1, 'offline' => 0], + ['total' => 1, 'fallback_overrides' => 1], + ['cloud_only_relays' => 1, 'local_only_relays' => 0] + ); + + edge_gateway_view_cache::storeListPayload(null, false, [ + 'gateways' => [$staleGateway, $otherGateway], + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$staleGateway, $otherGateway]), + ]); + edge_gateway_view_cache::storeListPayload(1, false, [ + 'gateways' => [$staleGateway], + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$staleGateway]), + ]); + + $freshGateway = [ + 'id' => 701, + 'department_id' => 1, + 'label' => 'Gateway 701', + 'status' => edge_gateway_manager::STATUS_ONLINE, + 'version_drift' => ['is_drifted' => false], + 'channel_status' => ['broker' => ['connected' => true]], + 'active_operation' => null, + 'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0], + 'backlog_depth' => ['operations' => 0, 'commands' => 0], + 'metadata' => [ + 'system_metrics' => [ + 'latency_ms' => 150, + 'cpu_usage_pct' => 15, + 'memory_usage_pct' => 25, + 'disk_usage_pct' => 35, + ], + ], + 'inventory' => [ + ['id' => 1, 'online' => true], + ['id' => 2, 'online' => true], + ], + 'bindings' => [ + ['id' => 1, 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL], + ['id' => 2, 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_CLOUD_ONLY], + ], + 'recent_commands' => [], + 'audit_logs' => [], + 'operations' => [], + 'fallback_summary' => ['cloud_only_relays' => 1, 'local_only_relays' => 0], + ]; + + edge_gateway_view_cache::syncGateway($freshGateway); + + $allGatewaysPayload = edge_gateway_view_cache::getListPayload(null, false); + $departmentPayload = edge_gateway_view_cache::getListPayload(1, false); + $detailPayload = edge_gateway_view_cache::getDetailPayload(701); + + expect($detailPayload)->not->toBeNull(); + expect($detailPayload['inventory_summary'])->toBe(['total' => 2, 'online' => 2, 'offline' => 0]); + expect($detailPayload['binding_summary'])->toBe(['total' => 2, 'fallback_overrides' => 1]); + + expect($allGatewaysPayload)->not->toBeNull(); + expect($allGatewaysPayload['gateways'][0]['status'])->toBe(edge_gateway_manager::STATUS_ONLINE); + expect($allGatewaysPayload['gateways'][0]['inventory'])->toBe([]); + expect($allGatewaysPayload['fleet_usage']['gateways']['online'])->toBe(2); + expect($allGatewaysPayload['fleet_usage']['inventory']['online'])->toBe(3); + expect($allGatewaysPayload['fleet_usage']['bindings']['cloud_only'])->toBe(2); + + expect($departmentPayload)->not->toBeNull(); + expect($departmentPayload['gateways'][0]['status'])->toBe(edge_gateway_manager::STATUS_ONLINE); + expect($departmentPayload['fleet_usage']['inventory']['online'])->toBe(2); +}); + +it('removes deleted gateways from cached list and detail payloads', function (): void { + $gatewayA = edgeGatewayCacheGatewayRow(701, 1, edge_gateway_manager::STATUS_ONLINE); + $gatewayB = edgeGatewayCacheGatewayRow(702, 1, edge_gateway_manager::STATUS_OFFLINE); + $payload = [ + 'gateways' => [$gatewayA, $gatewayB], + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$gatewayA, $gatewayB]), + ]; + + edge_gateway_view_cache::storeListPayload(null, false, $payload); + edge_gateway_view_cache::storeListPayload(1, false, $payload); + edge_gateway_view_cache::storeDetailPayload(701, $gatewayA); + + edge_gateway_view_cache::removeGateway(701, 1); + + expect(edge_gateway_view_cache::getDetailPayload(701))->toBeNull(); + expect(edge_gateway_view_cache::getListPayload(null, false)['gateways'])->toHaveCount(1); + expect(edge_gateway_view_cache::getListPayload(1, false)['gateways'])->toHaveCount(1); + expect(edge_gateway_view_cache::getListPayload(null, false)['fleet_usage']['gateways']['total'])->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php b/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php new file mode 100644 index 00000000..9e081124 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php @@ -0,0 +1,256 @@ +> */ + public array $statusCalls = []; + /** @var array> */ + public array $switchCalls = []; + /** @var array> */ + public array $localOnlyStatusCalls = []; + /** @var array> */ + public array $localOnlySwitchCalls = []; + public ?Exception $statusException = null; + public ?Exception $switchException = null; + + public function __construct() + { + } + + public function dispatchRelayStatus(int $departmentId, string $logicalRelayId, array $actionContext = []): array + { + if ($this->statusException instanceof Exception) { + throw $this->statusException; + } + + $this->statusCalls[] = [ + 'department_id' => $departmentId, + 'relay_id' => $logicalRelayId, + ]; + + return [ + 'online' => true, + 'on' => $logicalRelayId === 'relay-machine', + 'binding' => [ + 'logical_relay_id' => $logicalRelayId, + 'device_id' => 'device-' . $logicalRelayId, + 'local_ip' => '192.168.1.50', + 'channel' => 0, + ], + 'execution' => [ + 'transport' => 'gateway', + 'fallback_mode' => 'PREFER_LOCAL', + ], + 'raw' => ['source' => 'status'], + ]; + } + + public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array + { + return $this->dispatchRelaySwitchWithTimer($departmentId, $logicalRelayId, $on, null, $actionContext); + } + + public function dispatchRelaySwitchWithTimer( + int $departmentId, + string $logicalRelayId, + bool $on, + ?int $toggleAfterSeconds, + array $actionContext = [] + ): array + { + if ($this->switchException instanceof Exception) { + throw $this->switchException; + } + + $call = [ + 'department_id' => $departmentId, + 'relay_id' => $logicalRelayId, + 'on' => $on, + ]; + if ($toggleAfterSeconds !== null) { + $call['toggle_after'] = $toggleAfterSeconds; + } + $this->switchCalls[] = $call; + + return [ + 'online' => true, + 'on' => $on, + 'raw' => ['source' => 'switch'], + ]; + } + + public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId, array $actionContext = []): array + { + $this->localOnlyStatusCalls[] = [ + 'department_id' => $departmentId, + 'relay_id' => $logicalRelayId, + ]; + + return [ + 'online' => true, + 'on' => true, + 'raw' => ['source' => 'local-status'], + ]; + } + + public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array + { + return $this->dispatchRelaySwitchLocalOnlyWithTimer($departmentId, $logicalRelayId, $on, null, $actionContext); + } + + public function dispatchRelaySwitchLocalOnlyWithTimer( + int $departmentId, + string $logicalRelayId, + bool $on, + ?int $toggleAfterSeconds, + array $actionContext = [] + ): array + { + $call = [ + 'department_id' => $departmentId, + 'relay_id' => $logicalRelayId, + 'on' => $on, + ]; + if ($toggleAfterSeconds !== null) { + $call['toggle_after'] = $toggleAfterSeconds; + } + $this->localOnlySwitchCalls[] = $call; + + return [ + 'online' => true, + 'on' => $on, + 'raw' => ['source' => 'local-switch'], + ]; + } +} + +it('maps gateway relay status responses into the Shelly cloud payload shape', function (): void { + $manager = new GatewayShellyTransportManagerFake(); + $transport = new gateway_shelly_transport($manager); + + $result = $transport->sendPostRequest('/v2/devices/api/get', [ + 'ids' => ['relay-machine', 'relay-cleaner'], + ], 17); + + expect($manager->statusCalls)->toBe([ + ['department_id' => 17, 'relay_id' => 'relay-machine'], + ['department_id' => 17, 'relay_id' => 'relay-cleaner'], + ]); + expect($result)->toBeArray(); + expect($result[0]['id'])->toBe('relay-machine'); + expect($result[0]['relay_id'])->toBe('relay-machine'); + expect($result[0]['status']['switch:0']['output'])->toBeTrue(); + expect($result[0]['binding'])->toMatchArray([ + 'logical_relay_id' => 'relay-machine', + 'device_id' => 'device-relay-machine', + 'local_ip' => '192.168.1.50', + 'channel' => 0, + ]); + expect($result[0]['execution'])->toMatchArray([ + 'transport' => 'gateway', + 'fallback_mode' => 'PREFER_LOCAL', + ]); + expect($result[1]['status']['switch:0']['output'])->toBeFalse(); +}); + +it('maps gateway relay switch responses into the Shelly cloud payload shape', function (): void { + $manager = new GatewayShellyTransportManagerFake(); + $transport = new gateway_shelly_transport($manager); + + $result = $transport->sendPostRequest('/v2/devices/api/set/switch', [ + 'id' => 'relay-machine', + 'on' => false, + ], 17); + + expect($manager->switchCalls)->toBe([ + ['department_id' => 17, 'relay_id' => 'relay-machine', 'on' => false], + ]); + expect($result)->toBeArray(); + expect($result[0]['id'])->toBe('relay-machine'); + expect($result[0]['status']['switch:0']['output'])->toBeFalse(); +}); + +it('uses local-only gateway dispatch for explicit local transport overrides', function (): void { + $manager = new GatewayShellyTransportManagerFake(); + $transport = new gateway_shelly_transport($manager, true); + + $status = $transport->sendPostRequest('/v2/devices/api/get', [ + 'ids' => ['relay-entry'], + ], 17); + $switch = $transport->sendPostRequest('/v2/devices/api/set/switch', [ + 'id' => 'relay-entry', + 'on' => true, + ], 17); + + expect($manager->localOnlyStatusCalls)->toBe([ + ['department_id' => 17, 'relay_id' => 'relay-entry'], + ]); + expect($manager->localOnlySwitchCalls)->toBe([ + ['department_id' => 17, 'relay_id' => 'relay-entry', 'on' => true], + ]); + expect($manager->statusCalls)->toBe([]); + expect($manager->switchCalls)->toBe([]); + expect($status[0]['raw']['source'])->toBe('local-status'); + expect($switch[0]['raw']['source'])->toBe('local-switch'); +}); + +it('forwards Shelly toggle_after timers to gateway relay switches', function (): void { + $manager = new GatewayShellyTransportManagerFake(); + $transport = new gateway_shelly_transport($manager, true); + + $transport->sendPostRequest('/v2/devices/api/set/switch', [ + 'id' => 'relay-entry', + 'on' => true, + 'toggle_after' => 1, + ], 17); + + expect($manager->localOnlySwitchCalls)->toBe([ + [ + 'department_id' => 17, + 'relay_id' => 'relay-entry', + 'on' => true, + 'toggle_after' => 1, + ], + ]); +}); + +it('requires a valid department id for gateway transport requests', function (): void { + $transport = new gateway_shelly_transport(new GatewayShellyTransportManagerFake()); + + expect(fn() => $transport->sendPostRequest('/v2/devices/api/get', ['ids' => ['relay-machine']], null)) + ->toThrow(Exception::class, 'A department_id is required for gateway Shelly transport'); +}); + +it('rejects unsupported gateway transport endpoints', function (): void { + $transport = new gateway_shelly_transport(new GatewayShellyTransportManagerFake()); + + expect(fn() => $transport->sendPostRequest('/v2/devices/api/unknown', [], 17)) + ->toThrow(Exception::class, 'Unsupported gateway Shelly transport endpoint'); +}); + +it('surfaces binding lookup failures while resolving relay state through the gateway transport', function (): void { + $manager = new GatewayShellyTransportManagerFake(); + $manager->statusException = new Exception('Relay binding missing'); + $transport = new gateway_shelly_transport($manager); + + expect(fn() => $transport->sendPostRequest('/v2/devices/api/get', [ + 'ids' => ['relay-machine'], + ], 17))->toThrow(Exception::class, 'Relay binding missing'); +}); + +it('surfaces offline gateway failures while dispatching relay switch commands', function (): void { + $manager = new GatewayShellyTransportManagerFake(); + $manager->switchException = new Exception('Gateway agent is offline'); + $transport = new gateway_shelly_transport($manager); + + expect(fn() => $transport->sendPostRequest('/v2/devices/api/set/switch', [ + 'id' => 'relay-machine', + 'on' => true, + ], 17))->toThrow(Exception::class, 'Gateway agent is offline'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/NormalizationTest.php b/services/nginx/app/tests/Unit/Selfserve/NormalizationTest.php index 0b19aee5..f5521332 100644 --- a/services/nginx/app/tests/Unit/Selfserve/NormalizationTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/NormalizationTest.php @@ -1,18 +1,24 @@ toContain('MACHINE'); }); } - diff --git a/services/nginx/app/tests/Unit/Selfserve/PlateScannerWorkspaceContractTest.php b/services/nginx/app/tests/Unit/Selfserve/PlateScannerWorkspaceContractTest.php new file mode 100644 index 00000000..c8ff0759 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/PlateScannerWorkspaceContractTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + expect($route)->toContain("'/numberplatescanners/{id}/rotate-key'"); + expect($route)->toContain("'lane_id'"); + + expect($scannerObject)->not->toBeFalse(); + expect($scannerObject)->toContain('public object_property $lane_id;'); + expect($scannerObject)->toContain('public function rotateApiKey(int $id): array'); + expect($scannerObject)->toContain('ADD COLUMN `lane_id` INT NULL AFTER `department_id`'); +}); + +it('uses the scanner default lane before requiring an explicit lane_id in machine button webhooks', function (): void { + $route = file_get_contents(app_path('routes/machineButtonPressRoute.php')); + + expect($route)->not->toBeFalse(); + expect($route)->toContain('$plateScanner->lane_id->value()'); + expect($route)->toContain('default lane configured for the plate scanner'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php new file mode 100644 index 00000000..22a75fd9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php @@ -0,0 +1,266 @@ + 10], + ['id' => 20], + ]; + + $rules = [ + [ + 'condition_id' => 10, + 'type' => selfserve_condition_rule_type::IS_TRUE->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 1, + ], + [ + 'condition_id' => 10, + 'type' => selfserve_condition_rule_type::IS_FALSE_OR_NOT_SET->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 2, + ], + [ + 'condition_id' => 20, + 'type' => selfserve_condition_rule_type::IS_TRUE->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 5, + ], + [ + 'condition_id' => 20, + 'type' => selfserve_condition_rule_type::IS_TRUE_OR_ANY_TRUE->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 3, + ], + [ + 'condition_id' => 20, + 'type' => selfserve_condition_rule_type::IS_TRUE_OR_ANY_TRUE->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 4, + ], + ]; + + $answers = [ + 1 => true, + 3 => false, + 4 => true, + 5 => true, + ]; + + expect($evaluator->evaluate($conditions, $rules, $answers))->toBe([ + 10 => true, + 20 => true, + ]); +}); + +it('prefers condition results over question answers when evaluating task gates', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + expect($evaluator->taskGateSatisfied(14, [14 => false], [14 => true]))->toBeFalse(); + expect($evaluator->taskGateSatisfied(15, [], [15 => true]))->toBeTrue(); + expect($evaluator->taskGateSatisfied(null, [], []))->toBeTrue(); +}); + +it('evaluates typed task gates with strict semantics', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::ALWAYS->value, null, [], []))->toBeTrue(); + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::CONDITION->value, 31, [31 => true], [31 => false]))->toBeTrue(); + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::CONDITION->value, 31, [31 => false], [31 => true]))->toBeFalse(); + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [], [8 => true]))->toBeTrue(); + expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [8 => true], [8 => false]))->toBeFalse(); +}); + +it('evaluates nested v2 ALL and ANY expression trees with trace output', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $conditions = [ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + [ + 'type' => 'group', + 'operator' => 'ANY', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE_OR_NOT_SET'], + ], + ], + ], + ], + ], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 10, 'operator' => 'IS_TRUE'], + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 4, 'operator' => 'IS_SET'], + ], + ], + ], + ]; + + $evaluation = $evaluator->evaluateExpressionsWithTrace($conditions, [ + 1 => true, + 2 => false, + 4 => false, + ]); + + expect($evaluation['results'])->toBe([ + 10 => true, + 20 => true, + ]); + expect($evaluation['trace'][10]['expression']['children'][1]['operator'])->toBe('ANY'); + expect($evaluation['trace'][20]['expression']['children'][0]['subject_type'])->toBe('condition'); +}); + +it('evaluates v2 if, else if, and else condition branches in order', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $evaluation = $evaluator->evaluateExpressionsWithTrace([ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'branch', + 'branches' => [ + [ + 'kind' => 'if', + 'when' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 1, + 'operator' => 'IS_TRUE', + ], + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 2, + 'operator' => 'IS_TRUE', + ], + ], + [ + 'kind' => 'else_if', + 'when' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 3, + 'operator' => 'IS_TRUE', + ], + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 4, + 'operator' => 'IS_FALSE', + ], + ], + [ + 'kind' => 'else', + 'else' => true, + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 5, + 'operator' => 'IS_TRUE', + ], + ], + ], + ], + ], + ], [ + 1 => false, + 3 => true, + 4 => false, + 5 => false, + ]); + + expect($evaluation['results'][10])->toBeTrue(); + expect($evaluation['trace'][10]['expression']['selected_index'])->toBe(1); + expect($evaluation['trace'][10]['expression']['branches'][1]['kind'])->toBe('else_if'); +}); + +it('evaluates v2 case expressions against question values', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $evaluation = $evaluator->evaluateExpressionsWithTrace([ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'case', + 'subject_type' => 'question', + 'subject_id' => 1, + 'cases' => [ + [ + 'value' => true, + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 2, + 'operator' => 'IS_TRUE', + ], + ], + [ + 'value' => false, + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 3, + 'operator' => 'IS_FALSE', + ], + ], + ], + ], + ], + ], [ + 1 => false, + 3 => false, + ]); + + expect($evaluation['results'][10])->toBeTrue(); + expect($evaluation['trace'][10]['expression']['selected_index'])->toBe(1); + expect($evaluation['trace'][10]['expression']['actual_value'])->toBeFalse(); +}); + +it('returns false and traces v2 condition expression cycles', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $evaluation = $evaluator->evaluateExpressionsWithTrace([ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 20, 'operator' => 'IS_TRUE'], + ], + ], + ], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 10, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], []); + + expect($evaluation['results'][10])->toBeFalse(); + expect($evaluation['results'][20])->toBeFalse(); + expect(json_encode($evaluation['trace']))->toContain('Condition dependency cycle detected'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php new file mode 100644 index 00000000..450290e7 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php @@ -0,0 +1,378 @@ +newInstanceWithoutConstructor(); +} + +it('validates typed task gates for known references', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'questions' => [ + ['id' => 10], + ], + 'conditions' => [ + ['id' => 20], + ], + 'rules' => [], + 'tasks' => [ + [ + 'id' => 100, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], + [ + 'id' => 101, + 'gate_type' => 'CONDITION', + 'gate_ref_id' => 20, + ], + [ + 'id' => 102, + 'gate_type' => 'QUESTION', + 'gate_ref_id' => 10, + ], + ], + ]); + + expect($validation['valid'])->toBeTrue(); + expect($validation['errors'])->toBe([]); +}); + +it('fails validation when typed task gates reference unknown entities', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'questions' => [ + ['id' => 1], + ], + 'conditions' => [ + ['id' => 2], + ], + 'rules' => [], + 'tasks' => [ + [ + 'id' => 200, + 'gate_type' => 'CONDITION', + 'gate_ref_id' => 999, + ], + [ + 'id' => 201, + 'gate_type' => 'QUESTION', + 'gate_ref_id' => 998, + ], + [ + 'id' => 202, + 'gate_type' => 'QUESTION', + 'gate_ref_id' => null, + ], + ], + ]); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('unknown condition gate_ref_id 999'); + expect(implode("\n", $validation['errors']))->toContain('unknown question gate_ref_id 998'); + expect(implode("\n", $validation['errors']))->toContain('requires gate_ref_id'); +}); + +it('fails validation when nested conditions form cycles', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'questions' => [], + 'conditions' => [ + ['id' => 10, 'condition_id' => 12], + ['id' => 11, 'condition_id' => 10], + ['id' => 12, 'condition_id' => 11], + ], + 'rules' => [], + 'tasks' => [], + ]); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('Condition cycle detected'); +}); + +it('migrates legacy AND and OR rules into grouped v2 condition expressions', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $config = $service->migrateLegacyConfigToV2([ + 'department_id' => 6, + 'questions' => [ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ], + 'conditions' => [ + ['id' => 10, 'name' => 'Ready'], + ], + 'rules' => [ + ['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], + ['id' => 101, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 2], + ['id' => 102, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 3], + ], + 'tasks' => [], + ]); + + $expression = $config['conditions'][0]['expression']; + + expect($config['schema_version'])->toBe(2); + expect($config['rules'])->toBe([]); + expect($expression['operator'])->toBe('ALL'); + expect($expression['children'][0])->toMatchArray([ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 1, + 'operator' => 'IS_TRUE', + ]); + expect($expression['children'][1]['operator'])->toBe('ANY'); + expect(array_column($expression['children'][1]['children'], 'subject_id'))->toBe([2, 3]); +}); + +it('repairs legacy-defaulted always task gates during v2 normalization', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $config = $service->migrateLegacyConfigToV2([ + 'department_id' => 6, + 'questions' => [ + ['id' => 1], + ], + 'conditions' => [ + ['id' => 20, 'name' => 'Machine wash allowed'], + ], + 'rules' => [ + ['id' => 100, 'condition_id' => 20, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], + ], + 'tasks' => [ + [ + 'id' => 200, + 'condition_id' => 20, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], + ], + ]); + + expect($config['tasks'][0]['gate_type'])->toBe('CONDITION'); + expect($config['tasks'][0]['gate_ref_id'])->toBe(20); + expect($service->validateConfig($config)['valid'])->toBeTrue(); +}); + +it('does not let legacy-defaulted always task gates bypass validation', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1], + ], + 'conditions' => [ + [ + 'id' => 20, + 'expression' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 1, + 'operator' => 'IS_TRUE', + ], + ], + ], + 'tasks' => [ + [ + 'id' => 201, + 'condition_id' => 999, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], + ], + ]); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('unknown question gate_ref_id 999'); +}); + +it('rejects unsupported legacy task-target rules after migration', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $config = $service->migrateLegacyConfigToV2([ + 'department_id' => 6, + 'questions' => [['id' => 1]], + 'conditions' => [['id' => 10, 'name' => 'Ready']], + 'rules' => [ + ['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'task', 'object_id' => 50], + ], + 'tasks' => [['id' => 50]], + ]); + $validation = $service->validateConfig($config); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('unsupported object_type `task`'); +}); + +it('validates v2 expressions for empty used conditions, missing refs, invalid operators, and cycles', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'condition_id' => 10], + ], + 'conditions' => [ + ['id' => 10, 'expression' => ['type' => 'group', 'operator' => 'ALL', 'children' => []]], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 999, 'operator' => 'IS_TRUE'], + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 30, 'operator' => 'IS_TRUE'], + ], + ], + ], + [ + 'id' => 30, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 20, 'operator' => 'NOPE'], + ], + ], + ], + ], + 'tasks' => [ + ['id' => 100, 'gate_type' => 'CONDITION', 'gate_ref_id' => 10], + ], + ]); + + $errors = implode("\n", $validation['errors']); + + expect($validation['valid'])->toBeFalse(); + expect($errors)->toContain('Condition 10 is used but has an empty expression.'); + expect($errors)->toContain('unknown question predicate subject_id 999'); + expect($errors)->toContain('invalid predicate operator `NOPE`'); + expect($errors)->toContain('Condition cycle detected'); +}); + +it('validates nested v2 branch and case expressions', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ], + 'conditions' => [ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'branch', + 'branches' => [ + [ + 'kind' => 'if', + 'when' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + [ + 'kind' => 'else', + 'else' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE_OR_NOT_SET'], + ], + ], + ], + ], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'case', + 'subject_type' => 'condition', + 'subject_id' => 10, + 'cases' => [ + [ + 'value' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + [ + 'value' => false, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE'], + ], + ], + ], + ], + ], + 'tasks' => [ + ['id' => 100, 'gate_type' => 'CONDITION', 'gate_ref_id' => 20], + ], + ]); + + expect($validation['valid'])->toBeTrue(); + expect($validation['errors'])->toBe([]); +}); + +it('encodes config json payloads with apostrophes before persistence', function (): void { + $version = new class extends selfserve_config_versions_o { + /** @var array */ + public array $capturedPayload = []; + + public function __construct() + { + // Skip db bootstrap for this unit test. + } + + public function add_object(array $data): int + { + $this->capturedPayload = $data; + return 123; + } + + public function getObjectProperties(): void + { + // No-op for this unit test. + } + + public function objectChanged(): void + { + // No-op for this unit test. + } + }; + + $version->add( + 42, + selfserve_config_versioning::STATUS_DRAFT, + 1, + [ + 'questions' => [ + [ + 'id' => 1, + 'question' => "Driver's side check", + ], + ], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + ], + [ + 'valid' => true, + 'errors' => [], + 'warnings' => [], + ], + ); + + $configJson = $version->capturedPayload['config_json'] ?? null; + $validationJson = $version->capturedPayload['validation_result_json'] ?? null; + + expect($configJson)->toBeString(); + expect($validationJson)->toBeString(); + expect(json_decode($configJson, true)['questions'][0]['question'] ?? null)->toBe("Driver's side check"); + expect(json_decode($validationJson, true)['valid'] ?? null)->toBeTrue(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php new file mode 100644 index 00000000..3c460973 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php @@ -0,0 +1,159 @@ + */ + public array $permissions = [ + 'list_own_department_selfserve_vehicle_conditions' => true, + ]; + public bool $laneEnabled = true; + public bool $activeWashResult = false; + /** @var array */ + public array $activeWashChecks = []; + + public function __construct() + { + // Avoid route_t request initialization in this focused unit test. + } + + public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool + { + $key = $permission instanceof \classes\permission_node ? (string)$permission->permission : $permission; + return $this->permissions[$key] ?? false; + } + + public function canUseLane(selfserve_lane $lane, int $customer_number): bool + { + return $this->canCustomerUseSelfServeLane($lane, $customer_number); + } + + public function canUseActiveLane(selfserve_lane $lane, int $customer_number): bool + { + return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number); + } + + protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool + { + return $this->laneEnabled; + } + + protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool + { + $this->activeWashChecks[] = [ + 'department_id' => $department_id, + 'customer_number' => $customer_number, + ]; + + return $this->activeWashResult; + } +} + +class SelfserveCustomerLaneAccessDepartmentLaneFake extends department_lanes_o +{ + public function __construct(int $department_id) + { + $this->id = -1; + $this->department = new object_property('department_lanes', -1, 'department', 'int'); + $this->department->set($department_id); + } + + public function structure(): void + { + // Skip database bootstrap for this unit test. + } +} + +class SelfserveCustomerLaneAccessLaneFake extends selfserve_lane +{ + public ?int $fakeCustomerNumber = null; + + public function __construct(int $department_id, ?int $customer_number = null) + { + $this->id = 91; + $this->fakeCustomerNumber = $customer_number; + $this->department_lane = new SelfserveCustomerLaneAccessDepartmentLaneFake($department_id); + } + + public function getCustomerNumber(): ?int + { + return $this->fakeCustomerNumber; + } +} + +it('allows customers with own self-serve permission to use enabled self-serve lanes', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $lane = new SelfserveCustomerLaneAccessLaneFake(4); + + expect($route->canUseLane($lane, 12345679))->toBeTrue(); +}); + +it('blocks customer lane mutations without own self-serve permission', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->permissions = []; + $lane = new SelfserveCustomerLaneAccessLaneFake(4); + + expect($route->canUseLane($lane, 12345679))->toBeFalse(); +}); + +it('blocks customer lane mutations when the lane is not operationally enabled', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->laneEnabled = false; + $lane = new SelfserveCustomerLaneAccessLaneFake(4); + + expect($route->canUseLane($lane, 12345679))->toBeFalse(); +}); + +it('allows active wash operations when the lane runtime belongs to the customer', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679); + + expect($route->canUseActiveLane($lane, 12345679))->toBeTrue(); + expect($route->activeWashChecks)->toBe([]); +}); + +it('keeps active wash operations available if a lane is disabled after start', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->laneEnabled = false; + $lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679); + + expect($route->canUseActiveLane($lane, 12345679))->toBeTrue(); + expect($route->activeWashChecks)->toBe([]); +}); + +it('falls back to active department sessions for customer active wash operations', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->activeWashResult = true; + $lane = new SelfserveCustomerLaneAccessLaneFake(4, null); + + expect($route->canUseActiveLane($lane, 12345679))->toBeTrue(); + expect($route->activeWashChecks)->toBe([ + [ + 'department_id' => 4, + 'customer_number' => 12345679, + ], + ]); +}); + +it('blocks active wash operations for other customers', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->activeWashResult = false; + $lane = new SelfserveCustomerLaneAccessLaneFake(4, 99999999); + + expect($route->canUseActiveLane($lane, 12345679))->toBeFalse(); + expect($route->activeWashChecks)->toBe([ + [ + 'department_id' => 4, + 'customer_number' => 12345679, + ], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php new file mode 100644 index 00000000..6f150e7b --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php @@ -0,0 +1,76 @@ +scopeInProgressWashResponseForCustomer($payload, $customer_number); + } + } +} + +it('keeps own in-progress self-serve wash details visible to the customer', function (): void { + $route = new SelfserveInProgressWashAccessHarness(); + + $payload = [ + 'lane_id' => 7, + 'in_progress' => true, + 'session' => [ + 'id' => 704, + 'customer_number' => 12345679, + 'reg' => 'AB12345', + ], + 'customer' => [ + 'customer_number' => 12345679, + 'display_name' => 'Example Customer', + ], + 'vehicle' => [ + 'id' => 55, + 'reg' => 'AB12345', + ], + ]; + + expect($route->scopeForCustomer($payload, 12345679))->toBe($payload); +}); + +it('redacts another customers in-progress wash details during customer lane polling', function (): void { + $route = new SelfserveInProgressWashAccessHarness(); + + $scoped = $route->scopeForCustomer([ + 'lane_id' => 9, + 'status' => 'MACHINE_STARTED', + 'in_progress' => true, + 'elapsed_minutes' => 4, + 'session' => [ + 'id' => 804, + 'customer_number' => 99999999, + 'reg' => 'CD67890', + ], + 'customer' => [ + 'customer_number' => 99999999, + 'display_name' => 'Other Customer', + 'email' => 'other@example.test', + ], + 'vehicle' => [ + 'id' => 77, + 'reg' => 'CD67890', + ], + ], 12345679); + + expect($scoped)->toBe([ + 'lane_id' => 9, + 'in_progress' => true, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]); +}); + diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneCommandEnumTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneCommandEnumTest.php new file mode 100644 index 00000000..366726e1 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneCommandEnumTest.php @@ -0,0 +1,15 @@ +toBe(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE); + expect(selfserve_lane_command::tryFrom('OPEN_PROPERTY_EXIT_GATE')) + ->toBe(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE); + expect(selfserve_lane_command::tryFrom('open_property_exit_gate')) + ->toBe(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE); +}); + diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceIncludedMinutesTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceIncludedMinutesTest.php new file mode 100644 index 00000000..d691b419 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceIncludedMinutesTest.php @@ -0,0 +1,60 @@ +calculateElapsedMinutesForBilling($elapsed_wash_time_seconds); + } + + public function billableMinutesForBilling(int $elapsed_minutes, int $included_minutes): int + { + return $this->calculateBillableMinutes($elapsed_minutes, $included_minutes); + } +} + +it('computes billable minutes when elapsed minutes exceed included minutes', function (): void { + $harness = new SelfserveLaneInvoiceIncludedMinutesHarness(); + + $elapsed_minutes = $harness->elapsedMinutesForBilling(181); // ceil(3.016...) = 4 + $billable_minutes = $harness->billableMinutesForBilling($elapsed_minutes, 2); + + expect($elapsed_minutes)->toBe(4); + expect($billable_minutes)->toBe(2); +}); + +it('computes zero billable minutes when elapsed minutes equal included minutes', function (): void { + $harness = new SelfserveLaneInvoiceIncludedMinutesHarness(); + + $elapsed_minutes = $harness->elapsedMinutesForBilling(120); + $billable_minutes = $harness->billableMinutesForBilling($elapsed_minutes, 2); + + expect($elapsed_minutes)->toBe(2); + expect($billable_minutes)->toBe(0); +}); + +it('computes zero billable minutes when included minutes exceed elapsed minutes', function (): void { + $harness = new SelfserveLaneInvoiceIncludedMinutesHarness(); + + $elapsed_minutes = $harness->elapsedMinutesForBilling(59); + $billable_minutes = $harness->billableMinutesForBilling($elapsed_minutes, 5); + + expect($elapsed_minutes)->toBe(1); + expect($billable_minutes)->toBe(0); +}); + +it('handles zero and low elapsed wash time edge cases', function (): void { + $harness = new SelfserveLaneInvoiceIncludedMinutesHarness(); + + expect($harness->elapsedMinutesForBilling(null))->toBe(0); + expect($harness->elapsedMinutesForBilling(0))->toBe(0); + expect($harness->elapsedMinutesForBilling(1))->toBe(1); + expect($harness->billableMinutesForBilling(0, 20))->toBe(0); + expect($harness->billableMinutesForBilling(1, 20))->toBe(0); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php new file mode 100644 index 00000000..d7018740 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php @@ -0,0 +1,186 @@ +laneMode = $mode; + } + + public function setElapsedWashTimeSecondsForTest(int $seconds): void + { + $this->elapsedWashTimeSeconds = $seconds; + } + + public function setIncludedMinutesForTest(int $minutes): void + { + $this->includedMinutes = $minutes; + } + + public function setDraftCustomerNumberForTest(?int $customer_number): void + { + $this->draftCustomerNumber = $customer_number; + } + + public function getLaneStatus(): selfserve_lane_status + { + return $this->laneStatus; + } + + public function getLaneMode(): selfserve_lane_mode + { + return $this->laneMode; + } + + public function getCustomerNumber(): int + { + return $this->customerNumber; + } + + public function getLicensePlate(): string + { + return $this->licensePlate; + } + + public function getMinuteBillingProductId(): ?int + { + return $this->minuteBillingProductId; + } + + public function getElapsedWashTime(): ?int + { + return $this->elapsedWashTimeSeconds; + } + + public function getMachineWashMinutesIncluded(): int + { + return $this->includedMinutes; + } + + protected function createInvoiceOrderContext(): orders_o + { + $this->createdOrderContexts++; + $billing_customer_number = $this->getCustomerNumber(); + $draft_customer_number = $this->draftCustomerNumber; + $order = new SelfserveLaneInvoiceModeOrderStub(); + $order->id = 424242; + $this->lastOrderCustomerNumber = $billing_customer_number; + $this->last_invoice_order_id = (int)$order->id; + $this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number); + return $order; + } + + protected function attachSelfServeMetadataToOrder( + orders_o $order, + int $billing_customer_number, + ?int $draft_customer_number, + ?\modules\selfserve\classes\selfserve_lane_command_arguments $arguments = null + ): void { + $order->attachedBillingCustomerNumber = $billing_customer_number; + $order->attachedDraftCustomerNumber = $draft_customer_number; + $this->lastAttachmentBillingCustomerNumber = $billing_customer_number; + $this->lastAttachmentDraftCustomerNumber = $draft_customer_number; + } + + protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): void + { + $this->lastAddedOrderId = $order_id; + $this->lastAddedProductId = $product_id; + $this->lastAddedQuantity = $quantity; + } +} + +it('bills manual self-serve stop using full elapsed minutes without included-minute reduction', function (): void { + $harness = new SelfserveLaneInvoiceModeBillingHarness(); + $harness->setLaneModeForTest(selfserve_lane_mode::MANUAL); + $harness->setElapsedWashTimeSecondsForTest(59); + $harness->setIncludedMinutesForTest(5); + + $result = $harness->invoice(); + + expect($result)->toBeTrue(); + expect($harness->lastAddedOrderId)->toBe(424242); + expect($harness->lastAddedProductId)->toBe(999); + expect($harness->lastAddedQuantity)->toBe(1); + expect($harness->createdOrderContexts)->toBe(1); + expect($harness->getLastInvoiceOrderId())->toBe(424242); +}); + +it('keeps included-minute reduction for automatic mode', function (): void { + $harness = new SelfserveLaneInvoiceModeBillingHarness(); + $harness->setLaneModeForTest(selfserve_lane_mode::AUTOMATIC); + $harness->setElapsedWashTimeSecondsForTest(59); + $harness->setIncludedMinutesForTest(5); + + $result = $harness->invoice(); + + expect($result)->toBeTrue(); + expect($harness->lastAddedOrderId)->toBeNull(); + expect($harness->lastAddedProductId)->toBeNull(); + expect($harness->lastAddedQuantity)->toBeNull(); + expect($harness->createdOrderContexts)->toBe(0); + expect($harness->getLastInvoiceOrderId())->toBeNull(); +}); + +it('creates self-serve invoice orders for the actual lane customer when a draft customer is configured', function (): void { + $harness = new SelfserveLaneInvoiceModeBillingHarness(); + $harness->setLaneModeForTest(selfserve_lane_mode::MANUAL); + $harness->setElapsedWashTimeSecondsForTest(59); + $harness->setDraftCustomerNumberForTest(9999); + + $result = $harness->invoice(); + + expect($result)->toBeTrue(); + expect($harness->createdOrderContexts)->toBe(1); + expect($harness->lastOrderCustomerNumber)->toBe(1234); + expect($harness->lastAttachmentBillingCustomerNumber)->toBe(1234); + expect($harness->lastAttachmentDraftCustomerNumber)->toBe(9999); +}); + +it('keeps the real billing customer as the authoritative order customer in the invoice context', function (): void { + $source = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_invoice_t.php')); + + expect($source)->not->toContain('$order_customer_number = $draft_customer_number ?? $billing_customer_number'); + expect($source)->toContain('(new orders_o())->add( + $billing_customer_number,'); + expect($source)->toContain('$this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments);'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePortControllerTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePortControllerTest.php new file mode 100644 index 00000000..d4f6cc9d --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePortControllerTest.php @@ -0,0 +1,184 @@ +value; + } +} + +class SelfserveLanePortDepartmentLaneFake +{ + public SelfserveLanePortValueFake $relay_in_id; + public SelfserveLanePortValueFake $relay_out_id; + + public function __construct(string $relayInId, string $relayOutId) + { + $this->relay_in_id = new SelfserveLanePortValueFake($relayInId); + $this->relay_out_id = new SelfserveLanePortValueFake($relayOutId); + } +} + +class SelfserveLanePortSwitchFake extends shelly_device_switch +{ + /** @var array */ + public array $switchCalls = []; + + public function switch(bool $on, ?bool $skip_toggle_after = false): array|object|null + { + $this->switchCalls[] = [ + 'id' => $this->id, + 'on' => $on, + 'toggle_after' => $this->toggle_after, + ]; + return ['ok' => true]; + } +} + +class SelfservePortSwitchOutputFake extends shelly_device_switch +{ + /** @var array */ + public array $switchCalls = []; + + protected function sendShellySwitchRequest(array $parameters): array|object|null + { + $this->switchCalls[] = $parameters; + return ['ok' => true]; + } +} + +class SelfserveLanePortControllerHarness +{ + use selfserve_lane_port_controller_t; + + public int $id = 55; + public object $department_lane; + public SelfserveLanePortSwitchFake $switchFake; + public selfserve_lane_status $laneStatus; + public selfserve_lane_state $laneState; + + public function __construct(string $relayInId, string $relayOutId) + { + $this->department_lane = new SelfserveLanePortDepartmentLaneFake($relayInId, $relayOutId); + $this->switchFake = new SelfserveLanePortSwitchFake(); + $this->laneStatus = selfserve_lane_status::AVAILABLE; + $this->laneState = selfserve_lane_state::IDLE; + } + + protected function createShellySwitchDevice(): shelly_device_switch + { + return $this->switchFake; + } + + public function getLaneStatus(): selfserve_lane_status + { + return $this->laneStatus; + } + + public function setLaneState(selfserve_lane_state $state): void + { + $this->laneState = $state; + } + + public function logLaneAction(...$args): void {} +} + +it('opens exit port by switching relay_out_id on', function (): void { + $lane = new SelfserveLanePortControllerHarness( + relayInId: 'relay-in-123', + relayOutId: 'relay-out-123' + ); + + $result = $lane->shellyOpenPort(selfserve_lane_port::EXIT); + + expect($result)->toBeTrue(); + expect($lane->switchFake->switchCalls)->toBe([ + [ + 'id' => 'relay-out-123', + 'on' => true, + 'toggle_after' => 1, + ], + ]); +}); + +it('opens entrance port by switching relay_in_id on', function (): void { + $lane = new SelfserveLanePortControllerHarness( + relayInId: 'relay-in-123', + relayOutId: 'relay-out-123' + ); + + $result = $lane->shellyOpenPort(selfserve_lane_port::ENTRANCE); + + expect($result)->toBeTrue(); + expect($lane->switchFake->switchCalls)->toBe([ + [ + 'id' => 'relay-in-123', + 'on' => true, + 'toggle_after' => 1, + ], + ]); +}); + +it('passes explicit timer values when opening lane gates', function (): void { + $lane = new SelfserveLanePortControllerHarness( + relayInId: 'relay-in-123', + relayOutId: 'relay-out-123' + ); + + $result = $lane->open(selfserve_lane_port::ENTRANCE, 4); + + expect($result)->toBeTrue(); + expect($lane->switchFake->switchCalls)->toBe([ + [ + 'id' => 'relay-in-123', + 'on' => true, + 'toggle_after' => 4, + ], + ]); +}); + +it('keeps demo relay gate-open queued but skips Shelly switch calls', function (): void { + $lane = new SelfserveLanePortControllerHarness( + relayInId: 'demo-relay-in', + relayOutId: 'demo-relay-out' + ); + + $result = $lane->open(selfserve_lane_port::EXIT); + + expect($result)->toBeTrue(); + expect($lane->laneState)->toBe(selfserve_lane_state::EXIT_PORT_OPEN_QUEUED); + expect($lane->switchFake->switchCalls)->toBe([]); +}); + +it('does not print Shelly switch responses to output', function (): void { + $switch = new SelfservePortSwitchOutputFake(); + $switch->id = 'relay-port-123'; + + ob_start(); + $switch->switch(true); + $output = (string)ob_get_clean(); + + expect($output)->toBe(''); + expect($switch->switchCalls)->toHaveCount(1); + expect($switch->switchCalls[0])->toMatchArray([ + 'id' => 'relay-port-123', + 'channel' => 0, + 'on' => true, + 'toggle_after' => 1, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePropertyGateCommandTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePropertyGateCommandTest.php new file mode 100644 index 00000000..c5c63e25 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePropertyGateCommandTest.php @@ -0,0 +1,150 @@ +value; + } +} + +class SelfserveLanePropertyGateDepartmentLaneFake +{ + public SelfserveLanePropertyGateValueFake $department; + + public function __construct(int $departmentId) + { + $this->department = new SelfserveLanePropertyGateValueFake($departmentId); + } +} + +class SelfserveLanePropertyGateFake extends department_gates_o +{ + public int $openCalls = 0; + + public function __construct( + private readonly bool $existsFlag = true, + private readonly bool $throwOnOpen = false, + private readonly string $throwMessage = 'gate open failed', + ) {} + + public function exists(): bool + { + return $this->existsFlag; + } + + public function openGate(): void + { + $this->openCalls++; + if ($this->throwOnOpen) { + throw new \RuntimeException($this->throwMessage); + } + } +} + +class SelfserveLanePropertyGateHarness +{ + use selfserve_lane_command_t; + + public const DEFAULT_WASH_START_TIME = 0; + public const DEFAULT_CUSTOMER_NUMBER = 0; + public const DEFAULT_LICENSE_PLATE = ''; + + public int $id = 901; + public object $department_lane; + public bool $selfServeEnabled = true; + public ?department_gates_o $entranceGate = null; + public ?department_gates_o $exitGate = null; + + public function __construct(int $departmentId = 77) + { + $this->department_lane = new SelfserveLanePropertyGateDepartmentLaneFake($departmentId); + } + + protected function isDepartmentSelfServeEnabled(): bool + { + return $this->selfServeEnabled; + } + + protected function resolveDepartmentGateForCommand(int $department_id, bool $isAccessGate): ?department_gates_o + { + return $isAccessGate ? $this->entranceGate : $this->exitGate; + } +} + +it('opens entrance gate for OPEN_PROPERTY_ACCESS_GATE command', function (): void { + $lane = new SelfserveLanePropertyGateHarness(); + $gate = new SelfserveLanePropertyGateFake(); + $lane->entranceGate = $gate; + + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments()); + + expect($gate->openCalls)->toBe(1); +}); + +it('opens exit gate for OPEN_PROPERTY_EXIT_GATE command', function (): void { + $lane = new SelfserveLanePropertyGateHarness(); + $gate = new SelfserveLanePropertyGateFake(); + $lane->exitGate = $gate; + + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, new selfserve_lane_command_arguments()); + + expect($gate->openCalls)->toBe(1); +}); + +it('blocks property gate commands when department self-serve is disabled', function (): void { + $lane = new SelfserveLanePropertyGateHarness(); + $lane->selfServeEnabled = false; + $lane->entranceGate = new SelfserveLanePropertyGateFake(); + + expect(function () use ($lane): void { + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments()); + })->toThrow(\RuntimeException::class, 'Self-serve is not enabled'); +}); + +it('throws a clear error when entrance gate is missing for property access command', function (): void { + $lane = new SelfserveLanePropertyGateHarness(); + $lane->entranceGate = null; + + expect(function () use ($lane): void { + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments()); + })->toThrow(\RuntimeException::class, 'No entrance gate configured'); +}); + +it('wraps low-level gate errors for property access command failures', function (): void { + $lane = new SelfserveLanePropertyGateHarness(); + $lane->entranceGate = new SelfserveLanePropertyGateFake( + existsFlag: true, + throwOnOpen: true, + throwMessage: 'simulated gateway timeout', + ); + + expect(function () use ($lane): void { + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments()); + })->toThrow(\RuntimeException::class, 'Failed to open property access gate.'); +}); + +it('wraps low-level gate errors for property exit command failures', function (): void { + $lane = new SelfserveLanePropertyGateHarness(); + $lane->exitGate = new SelfserveLanePropertyGateFake( + existsFlag: true, + throwOnOpen: true, + throwMessage: 'simulated provider error', + ); + + expect(function () use ($lane): void { + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, new selfserve_lane_command_arguments()); + })->toThrow(\RuntimeException::class, 'Failed to open property exit gate.'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php new file mode 100644 index 00000000..0ee52551 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php @@ -0,0 +1,670 @@ +value; + } +} + +class SelfserveDepartmentLaneRelayFake +{ + public SelfserveRelayValueFake $relay_machine_id; + public SelfserveRelayValueFake $relay_machine_program_picker_id; + public SelfserveRelayValueFake $relay_machine_cleaner_id; + + public function __construct( + string $machineRelayId = 'relay-machine', + string $programPickerRelayId = 'relay-program', + string $cleanerRelayId = 'relay-cleaner' + ) { + $this->relay_machine_id = new SelfserveRelayValueFake($machineRelayId); + $this->relay_machine_program_picker_id = new SelfserveRelayValueFake($programPickerRelayId); + $this->relay_machine_cleaner_id = new SelfserveRelayValueFake($cleanerRelayId); + } +} + +class SelfserveShellyRedisClientFake +{ + public function __construct(private readonly SelfserveShellyRedisFake $owner) {} + + public function set(string $key, string $value, mixed ...$args): bool|string + { + return $this->owner->clientSet($key, $value, $args); + } + + public function pttl(string $key): int + { + return $this->owner->clientPttl($key); + } +} + +class SelfserveShellyRedisFake +{ + /** @var array */ + private array $store = []; + /** @var array */ + private array $expiresAt = []; + private SelfserveShellyRedisClientFake $client; + /** @var callable():float */ + private $nowProvider; + + public function __construct(callable $nowProvider) + { + $this->nowProvider = $nowProvider; + $this->client = new SelfserveShellyRedisClientFake($this); + } + + public function get(string $key): ?string + { + $this->purgeExpired($key); + return $this->store[$key] ?? null; + } + + public function setEx(string $key, string $value, int $ttl): self + { + $this->store[$key] = $value; + $this->expiresAt[$key] = $this->now() + max(1, $ttl); + return $this; + } + + public function get_client(): SelfserveShellyRedisClientFake + { + return $this->client; + } + + public function clientSet(string $key, string $value, array $args): bool|string + { + $this->purgeExpired($key); + + $useNx = false; + $ttlMs = null; + $count = count($args); + for ($i = 0; $i < $count; $i++) { + $token = strtoupper((string)$args[$i]); + if ($token === 'NX') { + $useNx = true; + continue; + } + if ($token === 'PX' && isset($args[$i + 1])) { + $ttlMs = max(1, (int)$args[$i + 1]); + $i++; + } + } + + if ($useNx && array_key_exists($key, $this->store)) { + return false; + } + + $this->store[$key] = $value; + if ($ttlMs === null) { + unset($this->expiresAt[$key]); + } else { + $this->expiresAt[$key] = $this->now() + ($ttlMs / 1000); + } + + return 'OK'; + } + + public function clientPttl(string $key): int + { + $this->purgeExpired($key); + if (!array_key_exists($key, $this->store)) { + return -2; + } + if (!isset($this->expiresAt[$key])) { + return -1; + } + + $remainingMs = (int)ceil(($this->expiresAt[$key] - $this->now()) * 1000); + return $remainingMs > 0 ? $remainingMs : 0; + } + + private function now(): float + { + $provider = $this->nowProvider; + return (float)$provider(); + } + + private function purgeExpired(string $key): void + { + if (!isset($this->expiresAt[$key])) { + return; + } + if ($this->now() < $this->expiresAt[$key]) { + return; + } + unset($this->expiresAt[$key], $this->store[$key]); + } +} + +class SelfserveLaneRelayControllerHarness +{ + use selfserve_lane_relay_controller_t; + + public const CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES = 'allowed_services'; + + public int $id = 1; + public object $department_lane; + public bool $selfServeEnabled = true; + public float $now = 0.0; + /** @var array */ + public array $sleepCalls = []; + /** @var array */ + public array $shellyCalls = []; + /** @var array */ + public array $laneCache = []; + /** @var array> */ + private array $queuedResponses = []; + private selfserve_lane_status $laneStatus; + private ?SelfserveShellyRedisFake $redis; + + public function __construct(?SelfserveShellyRedisFake $redis = null) + { + $this->department_lane = new SelfserveDepartmentLaneRelayFake(); + $this->laneStatus = selfserve_lane_status::AVAILABLE; + $this->redis = $redis; + } + + public function queueShellyResponse(string $endpoint, array|object|null $response): void + { + if (!isset($this->queuedResponses[$endpoint])) { + $this->queuedResponses[$endpoint] = []; + } + $this->queuedResponses[$endpoint][] = $response; + } + + public function getShellyCallCount(string $endpoint): int + { + $count = 0; + foreach ($this->shellyCalls as $call) { + if ($call['endpoint'] === $endpoint) { + $count++; + } + } + return $count; + } + + public function getLaneStatus(): selfserve_lane_status + { + return $this->laneStatus; + } + + public function setLaneStatus(selfserve_lane_status $laneStatus): void + { + $this->laneStatus = $laneStatus; + } + + public function getLaneCache(int $lane_id, string $key): mixed + { + return $this->laneCache[$key . '_' . $lane_id] ?? null; + } + + public function setLaneCache(int $lane_id, string $key, mixed $value): self + { + $this->laneCache[$key . '_' . $lane_id] = $value; + return $this; + } + + protected function sendShellyPost(string $endpoint, array $payload): array|object|null + { + $this->shellyCalls[] = [ + 'endpoint' => $endpoint, + 'payload' => $payload, + 'time' => $this->now, + ]; + + if (!isset($this->queuedResponses[$endpoint]) || count($this->queuedResponses[$endpoint]) < 1) { + return []; + } + + return array_shift($this->queuedResponses[$endpoint]); + } + + protected function redisFacade(): mixed + { + return $this->redis; + } + + protected function nowTimestamp(): float + { + return $this->now; + } + + protected function sleepMicroseconds(int $microseconds): void + { + if ($microseconds > 0) { + $this->sleepCalls[] = $microseconds; + $this->now += ($microseconds / 1000000); + } + } + + protected function isDepartmentSelfServeEnabled(): bool + { + return $this->selfServeEnabled; + } +} + +function selfserve_lane_shelly_test_harness(bool $withRedis = true): SelfserveLaneRelayControllerHarness +{ + $harness = null; + $redis = null; + if ($withRedis) { + $redis = new SelfserveShellyRedisFake(static function () use (&$harness): float { + return $harness?->now ?? 0.0; + }); + } + + $harness = new SelfserveLaneRelayControllerHarness($redis); + return $harness; +} + +it('batches sequential machine relay status requests into a single Shelly get call', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->queueShellyResponse('/v2/devices/api/get', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + [ + 'id' => 'relay-program', + 'online' => true, + 'status' => ['switch:0' => ['output' => false]], + ], + [ + 'id' => 'relay-cleaner', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + + $machine = $harness->getMachineRelayStatus(); + $programPicker = $harness->getMachineProgramPickerRelayStatus(); + $cleaner = $harness->getMachineCleanerRelayStatus(); + + expect($machine['relay_id'])->toBe('relay-machine'); + expect($machine['on'])->toBeTrue(); + expect($programPicker['relay_id'])->toBe('relay-program'); + expect($programPicker['on'])->toBeFalse(); + expect($cleaner['relay_id'])->toBe('relay-cleaner'); + expect($cleaner['on'])->toBeTrue(); + + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1); + expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-machine', 'relay-program', 'relay-cleaner']); + expect($harness->shellyCalls[0]['payload']['select'])->toBe(['status']); +}); + +it('keeps back-to-back get/switch requests ordered through the relay controller', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->queueShellyResponse('/v2/devices/api/get', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + $harness->queueShellyResponse('/v2/devices/api/set/switch', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => false]], + ], + ]); + + $harness->getMachineRelayStatus(); + $harness->setMachineRelayStatus(false); + + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); + expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/get'); + expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch'); +}); + +it('executes sequential switch requests used by wash start and stop flows', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + + for ($i = 0; $i < 5; $i++) { + $harness->queueShellyResponse('/v2/devices/api/set/switch', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + } + + // START-like sequence. + $harness->setMachineCleanerRelayStatusHard(true); + $harness->setMachineRelayStatusHard(true); + // STOP-like sequence. + $harness->setMachineCleanerRelayStatusHard(false); + $harness->setMachineProgramPickerRelayStatusHard(false); + $harness->setMachineRelayStatusHard(false); + + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(5); +}); + +it('retries missing Shelly status payloads until relay status becomes ready', function (): void { + $harness = selfserve_lane_shelly_test_harness(false); + $harness->queueShellyResponse('/v2/devices/api/get', [ + ['id' => 'relay-machine', 'online' => true], + ]); + $harness->queueShellyResponse('/v2/devices/api/get', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + + $status = $harness->getMachineRelayStatus(); + + expect($status['relay_id'])->toBe('relay-machine'); + expect($status['on'])->toBeTrue(); + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(2); + expect($harness->sleepCalls)->toContain(250000); +}); + +it('times out with a clear Shelly readiness error when payload remains missing', function (): void { + $harness = selfserve_lane_shelly_test_harness(false); + for ($i = 0; $i < 100; $i++) { + $harness->queueShellyResponse('/v2/devices/api/get', [ + ['id' => 'relay-machine', 'online' => true], + ]); + } + + $exception = null; + try { + $harness->getMachineRelayStatus(); + } catch (\Exception $e) { + $exception = $e; + } + + expect($exception)->toBeInstanceOf(\Exception::class); + expect($exception?->getMessage())->toContain('not ready'); + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBeGreaterThan(1); +}); + +it('uses direct set/switch and seeds cache so immediate status read does not call Shelly get', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->queueShellyResponse('/v2/devices/api/set/switch', [ + [ + 'id' => 'relay-program', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + + $harness->setMachineProgramPickerRelayStatus(true); + $status = $harness->getMachineProgramPickerRelayStatus(); + + expect($status['relay_id'])->toBe('relay-program'); + expect($status['on'])->toBeTrue(); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0); + expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/set/switch'); + expect($harness->shellyCalls[0]['payload'])->toMatchArray([ + 'id' => 'relay-program', + 'channel' => 0, + 'on' => true, + ]); + expect(array_key_exists('toggle_after', $harness->shellyCalls[0]['payload']))->toBeFalse(); +}); + +it('preserves local gateway diagnostic metadata on relay status snapshots', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->queueShellyResponse('/v2/devices/api/get', [ + [ + 'id' => 'relay-machine', + 'relay_id' => 'relay-machine', + 'online' => true, + 'on' => true, + 'status' => ['switch:0' => ['output' => true]], + 'binding' => [ + 'logical_relay_id' => 'relay-machine', + 'device_id' => 'device-machine', + 'local_ip' => '192.168.1.50', + 'channel' => 0, + ], + 'execution' => [ + 'transport' => 'gateway', + 'fallback_mode' => 'PREFER_LOCAL', + ], + 'raw' => [ + 'source' => 'edge-gateway', + ], + ], + ]); + + $status = $harness->getMachineRelayStatus(); + + expect($status)->toMatchArray([ + 'relay_id' => 'relay-machine', + 'online' => true, + 'on' => true, + 'status' => ['switch:0' => ['output' => true]], + 'binding' => [ + 'logical_relay_id' => 'relay-machine', + 'device_id' => 'device-machine', + 'local_ip' => '192.168.1.50', + 'channel' => 0, + ], + 'execution' => [ + 'transport' => 'gateway', + 'fallback_mode' => 'PREFER_LOCAL', + ], + 'raw' => [ + 'source' => 'edge-gateway', + ], + ]); +}); + +it('includes positive relay timers in Shelly switch payloads', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + + $harness->forceTurnOnMachineRelay(9); + + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); + expect($harness->shellyCalls[0]['payload'])->toMatchArray([ + 'id' => 'relay-machine', + 'channel' => 0, + 'on' => true, + 'toggle_after' => 9, + ]); +}); + +it('supports hard relay set even when lane status is CLOSED', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->setLaneStatus(selfserve_lane_status::CLOSED); + $harness->queueShellyResponse('/v2/devices/api/set/switch', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => false]], + ], + ]); + + $result = $harness->setMachineRelayStatusHard(false); + + expect($result)->toBeTrue(); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); +}); + +it('blocks explicit relay writes when department self-serve is disabled', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->selfServeEnabled = false; + + expect(fn() => $harness->setMachineRelayStatusHard(false)) + ->toThrow(\Exception::class, 'Self-serve is not enabled'); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); +}); + +it('enables MACHINE relay when MACHINE is visible in allowed services', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + + $result = $harness->syncMachineRelayFromVisibleServices(['machine'], true); + + expect($result)->toMatchArray([ + 'machine_visible' => true, + 'relay_action' => 'enabled', + 'relay_target_on' => true, + ]); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); + expect($harness->shellyCalls[0]['payload'])->toMatchArray([ + 'id' => 'relay-machine', + 'channel' => 0, + 'on' => true, + ]); +}); + +it('turns MACHINE relay off immediately when MACHINE is not visible', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + + $result = $harness->syncMachineRelayFromVisibleServices(['MACHINE_CLEANER'], true); + + expect($result)->toMatchArray([ + 'machine_visible' => false, + 'relay_action' => 'disabled', + 'relay_target_on' => false, + ]); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); + expect($harness->shellyCalls[0]['payload'])->toMatchArray([ + 'id' => 'relay-machine', + 'channel' => 0, + 'on' => false, + ]); +}); + +it('does not enable MACHINE relay when allowEnable is false even if MACHINE is visible', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + + $result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], false); + + expect($result)->toMatchArray([ + 'machine_visible' => true, + 'relay_action' => 'noop_enable_blocked', + 'relay_target_on' => true, + ]); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); +}); + +it('persists allowed services without relay writes for pre-start wash setup', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + + $result = $harness->setAllowedServicesFromVisibleTasks(['machine']); + + expect($result)->toMatchArray([ + 'machine_visible' => true, + 'relay_action' => 'cache_only', + 'relay_target_on' => true, + ]); + expect($harness->getLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES))->toBe(['MACHINE']); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); +}); + +it('returns disabled no-op from machine relay visibility sync when department self-serve is disabled', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->selfServeEnabled = false; + + $result = $harness->syncMachineRelayFromVisibleServices(['machine'], true); + + expect($result)->toMatchArray([ + 'machine_visible' => true, + 'relay_action' => 'noop_selfserve_disabled', + 'relay_target_on' => true, + ]); + expect($harness->getLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES))->toBe(['MACHINE']); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); +}); + +it('is a safe no-op when MACHINE relay is not configured', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->department_lane = new SelfserveDepartmentLaneRelayFake( + machineRelayId: '', + programPickerRelayId: 'relay-program', + cleanerRelayId: 'relay-cleaner' + ); + + $result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], true); + + expect($result)->toMatchArray([ + 'machine_visible' => true, + 'relay_action' => 'noop_missing_machine_relay', + 'relay_target_on' => true, + ]); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); +}); + +it('uses local demo responses and skips Shelly cloud calls for demo relay ids', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->department_lane = new SelfserveDepartmentLaneRelayFake( + machineRelayId: 'demo-machine', + programPickerRelayId: 'demo-program', + cleanerRelayId: 'demo-cleaner' + ); + + $result = $harness->setMachineRelayStatus(true); + $status = $harness->getMachineRelayStatus(); + + expect($result)->toBeTrue(); + expect($status)->toMatchArray([ + 'relay_id' => 'demo-machine', + 'online' => true, + 'on' => true, + ]); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0); +}); + +it('returns default OFF status for demo relay ids without Shelly lookups', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->department_lane = new SelfserveDepartmentLaneRelayFake( + machineRelayId: 'demo-machine', + programPickerRelayId: 'demo-program', + cleanerRelayId: 'demo-cleaner' + ); + + $status = $harness->getMachineRelayStatus(); + + expect($status)->toMatchArray([ + 'relay_id' => 'demo-machine', + 'online' => true, + 'on' => false, + ]); + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0); +}); + +it('excludes demo relay ids from Shelly status batch payloads', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->department_lane = new SelfserveDepartmentLaneRelayFake( + machineRelayId: 'demo-machine', + programPickerRelayId: 'relay-program', + cleanerRelayId: 'demo-cleaner' + ); + $harness->queueShellyResponse('/v2/devices/api/get', [ + [ + 'id' => 'relay-program', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + + $status = $harness->getMachineProgramPickerRelayStatus(); + + expect($status['relay_id'])->toBe('relay-program'); + expect($status['on'])->toBeTrue(); + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1); + expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-program']); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php new file mode 100644 index 00000000..178a5795 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php @@ -0,0 +1,117 @@ + */ + public array $openCalls = []; + public ?\Throwable $openThrowable = null; + public ?\Throwable $reportedTimeout = null; + public ?selfserve_lane_state $laneState = null; + public int $cleanerRelayCalls = 0; + public int $machineRelayCalls = 0; + + public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool + { + unset($toggle_after_seconds); + $this->openCalls[] = $port; + if ($this->openThrowable !== null) { + throw $this->openThrowable; + } + + return true; + } + + public function setLaneState(selfserve_lane_state $state): void + { + $this->laneState = $state; + } + + protected function reportWashStartEntranceTimeout(\Throwable $e): void + { + $this->reportedTimeout = $e; + } + + protected function turnOnCleanerRelayForWashStart(): void + { + $this->cleanerRelayCalls++; + } + + protected function setMachineRelayStatusForWashStart(): void + { + $this->machineRelayCalls++; + } + + public function runEntranceOpenForStart(): void + { + $this->openEntrancePortForWashStart(); + } + + public function runStartRelaySideEffects(bool $defer): void + { + $arguments = (new selfserve_lane_command_arguments())->setDeferRelaySideEffects($defer); + $this->runRelaySideEffectsForWashStart($arguments); + } +} + +it('continues start when entrance relay dispatch times out ambiguously', function (): void { + $lane = new SelfserveLaneStartEntranceTimeoutHarness(); + $timeout = new \RuntimeException('Edge gateway command timed out'); + $lane->openThrowable = $timeout; + + $lane->runEntranceOpenForStart(); + + expect($lane->openCalls)->toBe([selfserve_lane_port::ENTRANCE]); + expect($lane->reportedTimeout)->toBe($timeout); +}); + +it('still fails start for non-timeout entrance relay errors', function (): void { + $lane = new SelfserveLaneStartEntranceTimeoutHarness(); + $lane->openThrowable = new \RuntimeException('Invalid relay ID for port ENTRANCE'); + + expect(fn() => $lane->runEntranceOpenForStart()) + ->toThrow(\RuntimeException::class, 'Invalid relay ID'); + expect($lane->reportedTimeout)->toBeNull(); +}); + +it('parses deferred relay side effects on start command arguments', function (): void { + $arguments = (new selfserve_lane_command_arguments())->setParameters([ + 'license_plate' => 'ab12345', + 'customer_number' => 12345679, + 'defer_relay_side_effects' => true, + ]); + + expect($arguments->license_plate)->toBe('AB12345'); + expect($arguments->customer_number)->toBe(12345679); + expect($arguments->defer_relay_side_effects)->toBeTrue(); +}); + +it('skips cleaner and machine relay side effects when start asks to defer them', function (): void { + $lane = new SelfserveLaneStartEntranceTimeoutHarness(); + + $lane->runStartRelaySideEffects(true); + + expect($lane->cleanerRelayCalls)->toBe(0); + expect($lane->machineRelayCalls)->toBe(0); +}); + +it('keeps cleaner and machine relay side effects for normal start commands', function (): void { + $lane = new SelfserveLaneStartEntranceTimeoutHarness(); + + $lane->runStartRelaySideEffects(false); + + expect($lane->cleanerRelayCalls)->toBe(1); + expect($lane->machineRelayCalls)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php new file mode 100644 index 00000000..f0993c7b --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php @@ -0,0 +1,319 @@ +value; + } +} + +class SelfserveLaneCommandDepartmentLaneFake +{ + public SelfserveLaneCommandValueFake $relay_machine_id; + public SelfserveLaneCommandValueFake $relay_machine_program_picker_id; + public SelfserveLaneCommandValueFake $relay_machine_cleaner_id; + + public function __construct(string $machineRelayId, string $programRelayId, string $cleanerRelayId) + { + $this->relay_machine_id = new SelfserveLaneCommandValueFake($machineRelayId); + $this->relay_machine_program_picker_id = new SelfserveLaneCommandValueFake($programRelayId); + $this->relay_machine_cleaner_id = new SelfserveLaneCommandValueFake($cleanerRelayId); + } +} + +class SelfserveLaneStopFlowHarness +{ + use selfserve_lane_command_t; + + public const DEFAULT_WASH_START_TIME = 0; + public const DEFAULT_CUSTOMER_NUMBER = 0; + public const DEFAULT_LICENSE_PLATE = ''; + + public int $id = 77; + public object $department_lane; + public int $invoiceCalls = 0; + public int $ensureInvoiceOrderContextCalls = 0; + public int $vehicleTypeProductAddCalls = 0; + public int $programSelectorStatusReads = 0; + public ?bool $lastVehicleTypeProductDecision = null; + public ?\Throwable $openThrowable = null; + public ?\Throwable $reportedStopTimeout = null; + /** @var selfserve_lane_port[] */ + public array $openedPorts = []; + /** @var selfserve_lane_relay[] */ + public array $turnedOffRelays = []; + + private selfserve_lane_status $laneStatus; + private selfserve_lane_mode $laneMode; + private selfserve_lane_state $laneState; + private int $customerNumber = 1234; + private string $licensePlate = 'AB12345'; + private int $washStartTime = 120; + private ?int $reservationStartTime = null; + private bool $bypassCustomerValidation = false; + private bool $programSelectorOnline; + private bool $programSelectorOn; + private bool $machineStartTriggered; + + public function __construct( + bool $machineStartTriggered, + bool $programSelectorOnline = false, + bool $programSelectorOn = true, + string $machineRelayId = 'relay-machine', + string $programRelayId = 'relay-program', + string $cleanerRelayId = 'relay-cleaner' + ) { + $this->machineStartTriggered = $machineStartTriggered; + $this->programSelectorOnline = $programSelectorOnline; + $this->programSelectorOn = $programSelectorOn; + $this->department_lane = new SelfserveLaneCommandDepartmentLaneFake( + $machineRelayId, + $programRelayId, + $cleanerRelayId + ); + $this->laneStatus = selfserve_lane_status::OCCUPIED; + $this->laneMode = selfserve_lane_mode::MANUAL; + $this->laneState = selfserve_lane_state::IN_WASH; + } + + public function getLaneStatus(): selfserve_lane_status + { + return $this->laneStatus; + } + + public function setLaneStatus(selfserve_lane_status $status): void + { + $this->laneStatus = $status; + } + + public function setLaneMode(selfserve_lane_mode $mode): void + { + $this->laneMode = $mode; + } + + public function setLaneState(selfserve_lane_state $state): void + { + $this->laneState = $state; + } + + public function setWashStartTime(?int $time): void + { + $this->washStartTime = $time ?? self::DEFAULT_WASH_START_TIME; + } + + public function setCustomerNumber(?int $customerNumber): void + { + $this->customerNumber = $customerNumber ?? self::DEFAULT_CUSTOMER_NUMBER; + } + + public function getCustomerNumber(): int + { + return $this->customerNumber; + } + + public function setLicensePlate(?string $licensePlate): void + { + $this->licensePlate = $licensePlate ?? self::DEFAULT_LICENSE_PLATE; + } + + public function getLicensePlate(): string + { + return $this->licensePlate; + } + + public function setReservationStartTime(?int $reservationStartTime): void + { + $this->reservationStartTime = $reservationStartTime; + } + + public function isBypassCustomerNumberValidation(): bool + { + return $this->bypassCustomerValidation; + } + + public function invoice(): bool + { + $this->invoiceCalls++; + return true; + } + + public function ensureInvoiceOrderContextForVehicleProduct(): bool + { + $this->ensureInvoiceOrderContextCalls++; + return true; + } + + public function addVehicleTypeProductToLastInvoiceOrder(): bool + { + $this->vehicleTypeProductAddCalls++; + return true; + } + + public function getMachineProgramPickerRelayStatus(): array + { + $this->programSelectorStatusReads++; + return [ + 'online' => $this->programSelectorOnline, + 'on' => $this->programSelectorOn, + ]; + } + + public function open(selfserve_lane_port $port): bool + { + $this->openedPorts[] = $port; + if ($this->openThrowable !== null) { + throw $this->openThrowable; + } + + return true; + } + + public function turnOffRelay(selfserve_lane_relay $relay): bool + { + $this->turnedOffRelays[] = $relay; + return true; + } + + public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on, ?int $toggle_after = null): bool + { + if ($on === false) { + $this->turnedOffRelays[] = $relay; + } + return true; + } + + public function logLaneAction(...$args): void {} + + protected function completeLatestSessionForStop(): void + { + // No-op in unit tests. + } + + protected function hasMachineStartSignalForStop(): bool + { + return $this->machineStartTriggered; + } + + protected function addVehicleTypeProductToInvoiceIfNeeded(bool $should_add): void + { + $this->lastVehicleTypeProductDecision = $should_add; + if ($should_add) { + $this->vehicleTypeProductAddCalls++; + } + } + + protected function reportWashStopExitTimeout(\Throwable $e): void + { + $this->reportedStopTimeout = $e; + } +} + +it('adds vehicle type product on STOP when the physical machine ON signal was recorded, then turns off cleaner and machine relays', function (): void { + $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + $lane->execute(selfserve_lane_command::STOP, $args); + + expect($lane->invoiceCalls)->toBe(1); + expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); + expect($lane->vehicleTypeProductAddCalls)->toBe(1); + expect($lane->lastVehicleTypeProductDecision)->toBeTrue(); + expect($lane->programSelectorStatusReads)->toBe(0); + expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); + expect($lane->turnedOffRelays)->toBe([ + selfserve_lane_relay::MACHINE_CLEANER, + selfserve_lane_relay::MACHINE, + ]); + expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); +}); + +it('skips vehicle type product add when no physical machine ON signal was recorded and only disables configured relays', function (): void { + $lane = new SelfserveLaneStopFlowHarness( + machineStartTriggered: false, + programSelectorOnline: false, + machineRelayId: 'relay-machine', + programRelayId: 'relay-program', + cleanerRelayId: '' + ); + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + $lane->execute(selfserve_lane_command::STOP, $args); + + expect($lane->invoiceCalls)->toBe(1); + expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); + expect($lane->vehicleTypeProductAddCalls)->toBe(0); + expect($lane->lastVehicleTypeProductDecision)->toBeFalse(); + expect($lane->programSelectorStatusReads)->toBe(0); + expect($lane->turnedOffRelays)->toBe([ + selfserve_lane_relay::MACHINE, + ]); +}); + +it('does not use selector relay online status as machine-wash billing evidence', function (): void { + $lane = new SelfserveLaneStopFlowHarness( + machineStartTriggered: false, + programSelectorOnline: true, + programSelectorOn: true + ); + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + $lane->execute(selfserve_lane_command::STOP, $args); + + expect($lane->invoiceCalls)->toBe(1); + expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); + expect($lane->vehicleTypeProductAddCalls)->toBe(0); + expect($lane->lastVehicleTypeProductDecision)->toBeFalse(); + expect($lane->programSelectorStatusReads)->toBe(0); +}); + +it('continues STOP when exit relay dispatch times out ambiguously', function (): void { + $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); + $timeout = new \RuntimeException('Edge gateway command timed out'); + $lane->openThrowable = $timeout; + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + $lane->execute(selfserve_lane_command::STOP, $args); + + expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); + expect($lane->reportedStopTimeout)->toBe($timeout); + expect($lane->invoiceCalls)->toBe(1); + expect($lane->vehicleTypeProductAddCalls)->toBe(1); + expect($lane->turnedOffRelays)->toBe([ + selfserve_lane_relay::MACHINE_CLEANER, + selfserve_lane_relay::MACHINE, + ]); + expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); +}); + +it('still fails STOP for non-timeout exit relay errors', function (): void { + $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); + $lane->openThrowable = new \RuntimeException('Invalid relay ID for port EXIT'); + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + expect(fn() => $lane->execute(selfserve_lane_command::STOP, $args)) + ->toThrow(\RuntimeException::class, 'Invalid relay ID'); + expect($lane->reportedStopTimeout)->toBeNull(); + expect($lane->invoiceCalls)->toBe(0); + expect($lane->getLaneStatus())->toBe(selfserve_lane_status::OCCUPIED); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php new file mode 100644 index 00000000..adaad99d --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php @@ -0,0 +1,51 @@ +normalizeShellyPayload([ + 'event' => 'input.toggle_on', + 'component' => 'input:0', + 'state' => true, + 'relay_id' => 'M-1', + ]); + + expect($signal['recognized'])->toBeTrue() + ->and($signal['on'])->toBeTrue() + ->and($signal['component'])->toBe('input') + ->and($signal['relay_id'])->toBe('M-1'); +}); + +it('normalizes Shelly switch ON events and nested status payloads', function (): void { + $service = new selfserve_machine_signal(); + + $switchSignal = $service->normalizeShellyPayload([ + 'event' => 'switch.on', + 'component' => 'switch:0', + 'output' => true, + ]); + $statusSignal = $service->normalizeShellyPayload([ + 'status' => [ + 'input:0' => ['state' => true], + ], + ]); + + expect($switchSignal['recognized'])->toBeTrue() + ->and($switchSignal['on'])->toBeTrue() + ->and($switchSignal['component'])->toBe('switch') + ->and($statusSignal['recognized'])->toBeTrue() + ->and($statusSignal['on'])->toBeTrue(); +}); + +it('recognizes Shelly OFF events but does not treat them as billable machine starts', function (): void { + $signal = (new selfserve_machine_signal())->normalizeShellyPayload([ + 'event' => 'input.toggle_off', + 'component' => 'input:0', + 'state' => false, + ]); + + expect($signal['recognized'])->toBeTrue() + ->and($signal['on'])->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php new file mode 100644 index 00000000..d0a674ed --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php @@ -0,0 +1,73 @@ +resolvePersistedAnswerCustomerNumber($resolvedCustomerNumber); + } +} + +function selfserve_non_owned_vehicle_route_block(string $route, string $method, string $path): string +{ + $start = strpos($route, "\$this->{$method}('{$path}'"); + if ($start === false) { + throw new RuntimeException("Route block not found: {$method} {$path}"); + } + + $nextComment = strpos($route, "\n /**", $start + 1); + if ($nextComment === false) { + return substr($route, $start); + } + + return substr($route, $start, $nextComment - $start); +} + +it('allows own-permission customers to preview borrowed registration plates without ownership checks', function (): void { + $route = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + expect($route)->not->toBeFalse(); + + $allowedBlock = selfserve_non_owned_vehicle_route_block($route, 'get', '/department/selfserve/vehicle/allowed'); + + expect($allowedBlock)->toContain("requireAuthenticatedCustomerNumber(\$user, 'list_department_selfserve_vehicle_conditions')"); + expect($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)'); + expect($allowedBlock)->not->toContain('assertOwnVehicle'); +}); + +it('allows customer-scoped answers to be stored for borrowed registration plates', function (): void { + $route = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + expect($route)->not->toBeFalse(); + + $addBlock = selfserve_non_owned_vehicle_route_block($route, 'post', '/department/selfserve/vehicle/conditions'); + + expect($addBlock)->toContain("requireAuthenticatedCustomerNumber(\$user, 'add_department_selfserve_vehicle_conditions')"); + expect($addBlock)->toContain('$condition_o->add($department, $lane, $reg, $question, $value, $customer_id)'); + expect($addBlock)->not->toContain('assertOwnVehicle'); +}); + +it('loads saved answers from the authenticated customer context instead of the plate owner', function (): void { + $flow = new SelfserveNonOwnedVehicleWashFlowHarness(); + + expect($flow->persistedAnswerCustomer(10001))->toBe(10001); + expect($flow->persistedAnswerCustomer(20002))->toBe(20002); + expect($flow->persistedAnswerCustomer(null))->toBeNull(); + expect($flow->persistedAnswerCustomer(0))->toBeNull(); +}); + +it('scopes saved self-serve answers by customer number and registration plate', function (): void { + $source = file_get_contents(app_path('objects/department_selfserve_vehicle_conditions_o.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('department, lane, customer, reg and question'); + expect($source)->toContain('$customer_filter = $customer_id === null'); + expect($source)->toContain("'customer_id' => \$customerId"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php new file mode 100644 index 00000000..4112dfd1 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -0,0 +1,145 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +function selfserve_openapi_path_block_or_fail(string $content, string $path): string +{ + $path_marker = ' ' . $path . ':'; + $start = strpos($content, $path_marker); + if ($start === false) { + throw new RuntimeException("OpenAPI path block not found: {$path}"); + } + + $rest = substr($content, $start + strlen($path_marker)); + $next_path = strpos($rest, "\n /"); + if ($next_path === false) { + return substr($content, $start); + } + + return substr($content, $start, strlen($path_marker) + $next_path); +} + +it('documents self-serve machine type, eligibility, summary, and webhook endpoints', function (): void { + $content = selfserve_openapi_content_or_skip(); + $allowedPathBlock = selfserve_openapi_path_block_or_fail($content, '/department/selfserve/vehicle/allowed'); + $summaryPathBlock = selfserve_openapi_path_block_or_fail($content, '/department/selfserve/washes/summary'); + + expect($content)->toContain('/department/selfserve/machine-types:'); + expect($content)->toContain('/department/selfserve/vehicle/allowed:'); + expect($content)->toContain('/department/selfserve/washes/summary:'); + expect($content)->toContain('/relay/button/press/post:'); + expect($content)->toContain('/relay/machine/on/post:'); + expect($content)->toContain('/modules/self-serve/lane/wash/in-progress:'); + expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:'); + expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:'); + expect($content)->toContain('/modules/self-serve/lane/gate/open:'); + expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status:'); + expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set:'); + expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status:'); + expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set:'); + expect($allowedPathBlock)->toContain('name: vehicle_type_id'); + expect($allowedPathBlock)->toContain('name: vehicle_type'); + expect($allowedPathBlock)->toContain('may evaluate any registration plate'); + expect($allowedPathBlock)->toContain('Persisted self-serve answers are only applied'); + expect($summaryPathBlock)->toContain('name: vehicle_type_id'); + expect($summaryPathBlock)->toContain('name: vehicle_type'); +}); + +it('documents the all-in-one self-serve studio replacement API', function (): void { + $content = selfserve_openapi_content_or_skip(); + + expect($content)->toContain('/department/selfserve/studio/graph:'); + expect($content)->toContain('/department/selfserve/studio/layout:'); + expect($content)->toContain('/department/selfserve/studio/validate:'); + expect($content)->toContain('/department/selfserve/studio/simulate:'); + expect($content)->toContain('/department/selfserve/studio/path-outcomes:'); + expect($content)->toContain('/department/selfserve/studio/path-outcomes/stream:'); + expect($content)->toContain('/department/selfserve/studio/path-confirmations:'); + expect($content)->toContain('/department/selfserve/studio/publish:'); + expect($content)->toContain('/department/selfserve/studio/rollback:'); + expect($content)->toContain('/department/selfserve/studio/gateway-action:'); + expect($content)->toContain('SelfserveStudioGraph:'); + expect($content)->toContain('SelfserveStudioGraphSaveRequest:'); + expect($content)->toContain('SelfserveStudioLayout:'); + expect($content)->toContain('SelfserveStudioPathOutcomesRequest:'); + expect($content)->toContain('SelfserveStudioPathOutcomesResponse:'); + expect($content)->toContain('SelfserveStudioPathResult:'); + expect($content)->toContain('SelfserveStudioPathProgress:'); + expect($content)->toContain('SelfserveStudioPathConfirmationRequest:'); + expect($content)->toContain('SelfserveStudioPathConfirmation:'); + expect($content)->toContain('projectSelfserveStudioPathOutcomes'); + expect($content)->toContain('streamSelfserveStudioPathOutcomes'); + expect($content)->toContain('confirmSelfserveStudioPath'); + expect($content)->toContain('runSelfserveStudioGatewayAction'); +}); + +it('defines reusable self-serve wash and machine type schemas', function (): void { + $content = selfserve_openapi_content_or_skip(); + + expect($content)->toContain('SelfserveMachineType:'); + expect($content)->toContain('SelfserveVehicleAllowedResponse:'); + expect($content)->toContain('SelfserveWashSummary:'); + expect($content)->toContain('MachineButtonPressWebhookResponse:'); + expect($content)->toContain('DepartmentSelfserveVehicleConditionMutationResponse:'); + expect($content)->toContain('DepartmentLane:'); + expect($content)->toContain('selfserve_enabled:'); + expect($content)->toContain('blocked_reason:'); + expect($content)->toContain('machine_type_id:'); + expect($content)->toContain('SelfServeLaneMachineRelayStatus:'); + expect($content)->toContain(' wash_started_at:'); +}); + +it('documents in-progress self-serve wash start and machine relay fields', function (): void { + $content = selfserve_openapi_content_or_skip(); + $inProgressPathBlock = selfserve_openapi_path_block_or_fail($content, '/modules/self-serve/lane/wash/in-progress'); + + expect($inProgressPathBlock)->toContain('included_minutes:'); + expect($inProgressPathBlock)->toContain('machine_relay_enabled:'); + expect($inProgressPathBlock)->toContain('machine_relay_enabled_at:'); + expect($inProgressPathBlock)->toContain('machine_start_triggered_at:'); + expect($inProgressPathBlock)->toContain('wash_started_at:'); +}); + +it('documents self-serve machine wash included minutes in config schemas', function (): void { + $content = selfserve_openapi_content_or_skip(); + + expect($content)->toContain('SelfServeConfigEntry:'); + expect($content)->toContain('machine_wash_minutes_included'); + expect($content)->toContain('SelfServeConfig:'); +}); + +it('documents property gate lane commands and sanitized gate failure responses', function (): void { + $content = selfserve_openapi_content_or_skip(); + $commandPathBlock = selfserve_openapi_path_block_or_fail($content, '/modules/self-serve/lane/command'); + $gateOpenPathBlock = selfserve_openapi_path_block_or_fail($content, '/modules/self-serve/lane/gate/open'); + + expect($commandPathBlock)->toContain('OPEN_PROPERTY_ACCESS_GATE'); + expect($commandPathBlock)->toContain('OPEN_PROPERTY_EXIT_GATE'); + expect($commandPathBlock)->toContain('Command execution failed'); + expect($commandPathBlock)->toContain('Failed to execute command: Failed to open property access gate.'); + + expect($gateOpenPathBlock)->toContain('Gate open failed'); + expect($gateOpenPathBlock)->toContain('Failed to open entrance gate.'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRelayVisibilitySyncWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRelayVisibilitySyncWiringTest.php new file mode 100644 index 00000000..9e3ca826 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRelayVisibilitySyncWiringTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + expect($washFlow)->toContain('bool $syncRelayState = true'); + expect($washFlow)->toContain('if ($syncRelayState) {'); + expect($washFlow)->toContain('$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);'); + expect($washFlow)->toContain('protected function syncMachineRelayFromVisibleServices'); + expect($washFlow)->toContain('$lane->syncMachineRelayFromVisibleServices('); + expect($washFlow)->toContain('$session->markRelayDisabled();'); + expect($washFlow)->toContain('$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));'); +}); + +it('adds explicit relay disable session helper for visibility-driven OFF transitions', function (): void { + $sessionsObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php')); + + expect($sessionsObject)->not->toBeFalse(); + expect($sessionsObject)->toContain('public function markRelayDisabled(): void'); + expect($sessionsObject)->toContain('$this->machine_relay_enabled->set(false);'); + expect($sessionsObject)->toContain('$this->machine_relay_enabled_at->set(null);'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php new file mode 100644 index 00000000..102416a4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -0,0 +1,442 @@ +not->toBeFalse(); + expect($machineTypesRoute)->toContain('/department/selfserve/machine-types'); + + expect($vehicleConditionsRoute)->not->toBeFalse(); + expect($vehicleConditionsRoute)->toContain('/department/selfserve/vehicle/allowed'); + expect($vehicleConditionsRoute)->toContain('/department/selfserve/washes/summary'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession'); + + expect($webhookRoute)->not->toBeFalse(); + expect($webhookRoute)->toContain('/relay/button/press/post'); + expect($webhookRoute)->toContain('/relay/machine/on/post'); + expect($webhookRoute)->toContain('recordMachineStartWebhook'); + expect($webhookRoute)->toContain('recordCloudShellySignal'); +}); + +it('wires local edge gateway machine ON signal monitor endpoints', function (): void { + $edgeGatewayRoute = file_get_contents(app_path('modules/edgegateway/routes/edgeGatewaysRoute.php')); + $agent = file_get_contents(app_path('resources/edge-gateway-agent/agent.php')); + $worker = file_get_contents(app_path('resources/edge-gateway-agent/lan-worker.php')); + + expect($edgeGatewayRoute)->not->toBeFalse() + ->and($edgeGatewayRoute)->toContain('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings') + ->and($edgeGatewayRoute)->toContain('/edge-agent/gateways/{id}/selfserve/machine-signal') + ->and($edgeGatewayRoute)->toContain('recordEdgeGatewaySignal') + ->and($agent)->toContain('pollMachineStartSignals') + ->and($agent)->toContain('/selfserve/machine-signal') + ->and($worker)->toContain('Input.GetStatus') + ->and($worker)->toContain('/relay/input-status'); +}); + +it('keeps machine type support wired into lanes, tasks, and conditions routes', function (): void { + $lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php')); + $tasksRoute = file_get_contents(app_path('routes/departmentSelfserveTasksRoute.php')); + $conditionsRoute = file_get_contents(app_path('routes/departmentSelfserveConditionsRoute.php')); + $questionsRoute = file_get_contents(app_path('routes/departmentSelfserveQuestionsRoute.php')); + + expect($lanesRoute)->toContain('machine_type_id'); + expect($tasksRoute)->toContain('machine_type_id'); + expect($conditionsRoute)->toContain('machine_type_id'); + expect($questionsRoute)->toContain('department') // shared questions still use the legacy columns with default 0 scope + ->toContain('lane') + ->toContain('product'); +}); + +it('wires lane-level self-serve toggles through lane APIs, guest payloads, and edge workspace readiness', function (): void { + $lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php')); + $laneObject = file_get_contents(app_path('objects/department_lanes_o.php')); + $guestRoute = file_get_contents(app_path('routes/guestRoute.php')); + $edgeWorkspace = file_get_contents(app_path('modules/edgegateway/classes/edge_gateway_department_workspace_service.php')); + + expect($lanesRoute)->not->toBeFalse() + ->and($lanesRoute)->toContain("'selfserve_enabled'") + ->and($lanesRoute)->toContain('normalizeSelfServeEnabledValue') + ->and($lanesRoute)->toContain('disableSelfServeRelaysBestEffort') + ->and($lanesRoute)->toContain("'lane' => \$created_lane->asArray()") + ->and($lanesRoute)->toContain("'lane' => \$department_lane->asArray()"); + + expect($laneObject)->not->toBeFalse() + ->and($laneObject)->toContain('public object_property $selfserve_enabled') + ->and($laneObject)->toContain('public function isSelfServeEnabled(): bool') + ->and($laneObject)->toContain('public static function normalizeSelfServeEnabledValue') + ->and($laneObject)->toContain('public static function disableSelfServeRelaysBestEffort') + ->and($laneObject)->toContain('setMachineRelayStatusHard(false)') + ->and($laneObject)->toContain('setMachineProgramPickerRelayStatusHard(false)') + ->and($laneObject)->toContain('setMachineCleanerRelayStatusHard(false)'); + + expect($guestRoute)->not->toBeFalse() + ->and($guestRoute)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()") + ->and($guestRoute)->toContain("'machine_available' => \$lane->isSelfServeEnabled() && !empty(\$lane->relay_machine_id->value())"); + + expect($edgeWorkspace)->not->toBeFalse() + ->and($edgeWorkspace)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()") + ->and($edgeWorkspace)->toContain("'enabled_lanes' => count(\$enabledLanes)") + ->and($edgeWorkspace)->toContain("'ready_lanes' => count(\$readyLanes)") + ->and($edgeWorkspace)->toContain("(int)(\$selfServe['ready_lanes'] ?? 0) < (int)(\$selfServe['enabled_lanes'] ?? 0)"); +}); + +it('wires self-serve config draft/publish/rollback lifecycle endpoints', function (): void { + $configRoute = file_get_contents(app_path('routes/departmentSelfserveConfigVersionsRoute.php')); + + expect($configRoute)->not->toBeFalse(); + expect($configRoute)->toContain('/department/selfserve/config/versions'); + expect($configRoute)->toContain('/department/selfserve/config/history'); + expect($configRoute)->toContain('/department/selfserve/config/active'); + expect($configRoute)->toContain('/department/selfserve/config/draft'); + expect($configRoute)->toContain('/department/selfserve/config/validate'); + expect($configRoute)->toContain('/department/selfserve/config/publish'); + expect($configRoute)->toContain('/department/selfserve/config/rollback'); + expect($configRoute)->toContain('publish_department_selfserve_config_versions'); + expect($configRoute)->toContain('rollback_department_selfserve_config_versions'); +}); + +it('wires the all-in-one self-serve studio replacement endpoints', function (): void { + $studioRoute = file_get_contents(app_path('routes/departmentSelfserveStudioRoute.php')); + $studioGraph = file_get_contents(app_path('modules/selfserve/classes/selfserve_studio_graph.php')); + + expect($studioRoute)->not->toBeFalse(); + expect($studioRoute)->toContain('/department/selfserve/studio/graph'); + expect($studioRoute)->toContain('/department/selfserve/studio/layout'); + expect($studioRoute)->toContain('/department/selfserve/studio/validate'); + expect($studioRoute)->toContain('/department/selfserve/studio/simulate'); + expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes'); + expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes/stream'); + expect($studioRoute)->toContain('/department/selfserve/studio/path-confirmations'); + expect($studioRoute)->toContain("ini_set('display_errors', '0')"); + expect($studioRoute)->toContain('/department/selfserve/studio/publish'); + expect($studioRoute)->toContain('/department/selfserve/studio/rollback'); + expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action'); + expect($studioRoute)->toContain('projectPathOutcomes'); + expect($studioRoute)->toContain('confirmPathOutcome'); + expect($studioRoute)->toContain('modules_shelly_config'); + expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop'); + + expect($studioGraph)->not->toBeFalse(); + expect($studioGraph)->toContain('department_selfserve_studio_layouts'); + expect($studioGraph)->toContain('department_selfserve_path_confirmations'); + expect($studioGraph)->toContain('buildGatewayWorkspace'); + expect($studioGraph)->toContain('runGatewayAction'); + expect($studioGraph)->toContain('layout_affects_runtime'); + expect($studioGraph)->toContain('resolved'); +}); + +it('wires machine wash included minutes into self-serve module config', function (): void { + $selfserveConfig = file_get_contents(app_path('modules/selfserve/selfserve_c.php')); + + expect($selfserveConfig)->not->toBeFalse(); + expect($selfserveConfig)->toContain('selfserve_machine_wash_minutes_included_c'); + expect($selfserveConfig)->toContain('machine_wash_minutes_included'); +}); + +it('keeps legacy self-serve CRUD routes syncing canonical drafts', function (): void { + $questionsRoute = file_get_contents(app_path('routes/departmentSelfserveQuestionsRoute.php')); + $conditionsRoute = file_get_contents(app_path('routes/departmentSelfserveConditionsRoute.php')); + $rulesRoute = file_get_contents(app_path('routes/departmentSelfserveConditionRulesRoute.php')); + $tasksRoute = file_get_contents(app_path('routes/departmentSelfserveTasksRoute.php')); + + expect($questionsRoute)->toContain('syncDraftFromLegacyForDepartment'); + expect($conditionsRoute)->toContain('syncDraftFromLegacyForDepartment'); + expect($rulesRoute)->toContain('syncDraftFromLegacyForDepartment'); + expect($tasksRoute)->toContain('syncDraftFromLegacyForDepartment'); +}); + +it('wires machine relay status get and set endpoints', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/status'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/set'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set'); + expect($moduleSelfServeRoute)->toContain('getMachineRelayStatus'); + expect($moduleSelfServeRoute)->toContain('setMachineRelayStatus'); + expect($moduleSelfServeRoute)->toContain('getMachineProgramPickerRelayStatus'); + expect($moduleSelfServeRoute)->toContain('setMachineProgramPickerRelayStatus'); + expect($moduleSelfServeRoute)->toContain('getMachineCleanerRelayStatus'); + expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatus'); + expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatusHard(true)'); + expect($moduleSelfServeRoute)->toContain('applyShellyTransportOverride($lane)'); + expect($moduleSelfServeRoute)->toContain("'transport' => \$this->requestedShellyTransportOverride()"); + expect($moduleSelfServeRoute)->toContain('buildRelayStatusResponse'); +}); + +it('wires dashboard lane machine status and Dognvask toggle endpoints', function (): void { + $lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php')); + $laneObject = file_get_contents(app_path('objects/department_lanes_o.php')); + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($lanesRoute)->not->toBeFalse() + ->and($lanesRoute)->toContain('/department/lanes/status-toggles') + ->and($lanesRoute)->toContain('LIST_DEPARTMENT_LANE_STATUS_TOGGLES') + ->and($lanesRoute)->toContain('getDepartmentLanes($department_id)'); + + expect($laneObject)->not->toBeFalse() + ->and($laneObject)->toContain("'machine_status_enabled' => self::isOperationalStatusName(\$status)") + ->and($laneObject)->toContain("'dognvask_configured' => \$selfserve_configuration_warnings === []") + ->and($laneObject)->toContain("'dognvask_configuration_warnings' => \$selfserve_configuration_warnings") + ->and($laneObject)->toContain('public function getSelfServeConfigurationWarnings(): array') + ->and($laneObject)->toContain('relay_machine_program_picker_id') + ->and($laneObject)->toContain('machine_type_id'); + + expect($moduleSelfServeRoute)->not->toBeFalse() + ->and($moduleSelfServeRoute)->toContain("\$this->put('/modules/self-serve/lane/status'") + ->and($moduleSelfServeRoute)->toContain('modules_selfserve_lane_status_set') + ->and($moduleSelfServeRoute)->toContain('setLaneStatus($target_status)') + ->and($moduleSelfServeRoute)->toContain('selfserve_lane_status::MAINTENANCE') + ->and($moduleSelfServeRoute)->toContain('machine_status_enabled'); +}); + +it('applies Shelly transport overrides across self-serve relay side-effect routes', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect(substr_count($moduleSelfServeRoute, 'applyShellyTransportOverride($lane)'))->toBeGreaterThanOrEqual(17); + expect($moduleSelfServeRoute)->toContain('$lane->execute($command, $args);'); + expect($moduleSelfServeRoute)->toContain('$lane->setAllowedServicesFromVisibleTasks($allowed_services);'); + expect($moduleSelfServeRoute)->toContain('$lane->open($gate, $toggle_after);'); + expect($moduleSelfServeRoute)->toContain('$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);'); + expect($moduleSelfServeRoute)->toContain('$lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);'); + expect($moduleSelfServeRoute)->toContain('$lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration);'); + expect($moduleSelfServeRoute)->toContain('$lane->forceTurnOnMachineRelay($duration);'); + expect($moduleSelfServeRoute)->toContain('$lane->forceTurnOffMachineRelay();'); + expect($moduleSelfServeRoute)->toContain('$lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);'); + expect($moduleSelfServeRoute)->toContain('$lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER);'); + expect($moduleSelfServeRoute)->toContain('$lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration);'); + expect($moduleSelfServeRoute)->toContain('$lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_CLEANER);'); +}); + +it('wires allowed services route through machine relay visibility sync', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/services/allowed'); + expect($moduleSelfServeRoute)->toContain('setAllowedServicesFromVisibleTasks($allowed_services)'); + expect($moduleSelfServeRoute)->not->toContain('syncMachineRelayFromVisibleServices($allowed_services, true)'); + expect($moduleSelfServeRoute)->toContain("'relay_sync' => \$relay_sync"); +}); + +it('wires self-serve lane gate open endpoint', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/gate/open'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_gate_open'); + expect($moduleSelfServeRoute)->toContain('selfserve_lane_port::ENTRANCE'); + expect($moduleSelfServeRoute)->toContain('selfserve_lane_port::EXIT'); + expect($moduleSelfServeRoute)->toContain('$toggle_after = $this->requestedRelayToggleAfter(1);'); + expect($moduleSelfServeRoute)->toContain('$lane->open($gate, $toggle_after)'); + expect($moduleSelfServeRoute)->toContain("'toggle_after' => \$toggle_after"); + expect($moduleSelfServeRoute)->toContain('private function requestedRelayToggleAfter'); + expect($moduleSelfServeRoute)->toContain('Failed to open self-serve lane gate'); + expect($moduleSelfServeRoute)->toContain("'Failed to open ' . strtolower(\$gate->name) . ' gate.'"); + expect($moduleSelfServeRoute)->not->toContain('Failed to open lane gate: '); +}); + +it('wires self-serve property gate command permissions', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + $commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE'); + expect($moduleSelfServeRoute)->toContain('case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_access_gate'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_exit_gate'); + expect($moduleSelfServeRoute)->toContain('Failed to execute self-serve property gate command'); + + expect($commandTrait)->not->toBeFalse(); + expect($commandTrait)->toContain('Failed to open property access gate.'); + expect($commandTrait)->toContain('Failed to open property exit gate.'); + expect($commandTrait)->not->toContain('Failed to open property access gate: '); +}); + +it('wires in-progress self-serve wash details endpoint', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/wash/in-progress'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_wash_in_progress_view'); + expect($moduleSelfServeRoute)->toContain('list_own_department_selfserve_vehicle_conditions'); + expect($moduleSelfServeRoute)->toContain('requireInProgressWashDetailsAccess'); + expect($moduleSelfServeRoute)->toContain('scopeInProgressWashResponseForCustomer'); + expect($moduleSelfServeRoute)->toContain("'in_progress' => true"); + expect($moduleSelfServeRoute)->toContain("'in_progress' => false"); + expect($moduleSelfServeRoute)->toContain('selfserve_lane_status::OCCUPIED'); + expect($moduleSelfServeRoute)->toContain('selfserve_lane_state::IN_WASH'); + expect($moduleSelfServeRoute)->toContain('getWashStartTime'); + expect($moduleSelfServeRoute)->toContain('machine_wash_minutes_included'); + expect($moduleSelfServeRoute)->toContain("'included_minutes' =>"); + expect($moduleSelfServeRoute)->toContain("'machine_relay_enabled' =>"); + expect($moduleSelfServeRoute)->toContain("'machine_relay_enabled_at' =>"); + expect($moduleSelfServeRoute)->toContain("'wash_started_at' =>"); +}); + +it('wires self-serve session management endpoints and OpenAPI coverage', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + $openApiFiles = [app_path('openapi.yaml')]; + $rootOpenApiFile = dirname(app_path(), 3) . '/openapi.yaml'; + + if (is_file($rootOpenApiFile)) { + $openApiFiles[] = $rootOpenApiFile; + } + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/sessions'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/sessions/{id}'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/force/stop'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_sessions_view'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_sessions_force_stop'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_sessions_force_stop_bill'); + expect($moduleSelfServeRoute)->toContain('listObjectsWithPaginationIfSet'); + expect($moduleSelfServeRoute)->toContain('getSessionSummary($session_id)'); + expect($moduleSelfServeRoute)->toContain('forceStopLane($lane_id, $session_id, $bill, $reason, $user_id)'); + + foreach ($openApiFiles as $openApiFile) { + $openApi = file_get_contents($openApiFile); + + expect($openApi)->not->toBeFalse(); + expect($openApi)->toContain('/modules/self-serve/sessions:'); + expect($openApi)->toContain('/modules/self-serve/sessions/{id}:'); + expect($openApi)->toContain('/modules/self-serve/lane/force/stop:'); + expect($openApi)->toContain('listSelfServeSessions'); + expect($openApi)->toContain('getSelfServeSessionDetail'); + expect($openApi)->toContain('forceStopSelfServeLane'); + expect($openApi)->toContain('SelfserveForceStopResponse'); + expect($openApi)->toContain('FORCE_STOPPED'); + expect($openApi)->toContain('SESSION_FORCE_STOPPED'); + } +}); + +it('returns authoritative allowed service state from self-serve session summaries', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($washFlow)->not->toBeFalse(); + $start = strpos($washFlow, 'public function getSessionSummary'); + $end = strpos($washFlow, 'public function getLatestSessionSummary', $start); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + $summarySource = substr($washFlow, $start, $end - $start); + + expect($summarySource) + ->toContain('$metadata = is_array($session->metadata_json->value())') + ->toContain('$allowedServices = $this->normalizeServiceNames(') + ->toContain('$tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices)') + ->toContain("'allowed_services' => \$allowedServices") + ->toContain("'machine_available' => \$machineAvailable") + ->toContain("'all_visible_questions_answered' => \$allVisibleQuestionsAnswered") + ->toContain("'allowed' => (bool)\$session->allowed->value()"); +}); + +it('filters machine button tasks out of self-serve snapshots when MACHINE is not allowed', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($washFlow)->not->toBeFalse() + ->and($washFlow)->toContain('$visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices)') + ->and($washFlow)->toContain("'tasks' => \$visibleTasks") + ->and($washFlow)->toContain('protected function filterTasksForAllowedServices') + ->and($washFlow)->toContain('protected function taskUsesMachineControls') + ->and($washFlow)->toContain("in_array(selfserve_lane_services::MACHINE->name, \$allowedServices, true)") + ->and($washFlow)->toContain("\$this->normalizeButtonList(\$task['buttons'] ?? null) !== []") + ->and($washFlow)->toContain("\$task['dynamic_images_vehicle_type'] ?? null"); +}); + +it('keeps self-serve force stop distinct from normal STOP relay and gate behavior', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + $sessionObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php')); + $statusEnum = file_get_contents(app_path('modules/selfserve/helpers/selfserve_wash_session_status.php')); + $eventEnum = file_get_contents(app_path('modules/selfserve/helpers/selfserve_wash_event_type.php')); + + expect($washFlow)->not->toBeFalse(); + expect($sessionObject)->not->toBeFalse(); + expect($statusEnum)->toContain('FORCE_STOPPED'); + expect($eventEnum)->toContain('SESSION_FORCE_STOPPED'); + expect($sessionObject)->toContain('markForceStopped'); + expect($sessionObject)->toContain("metadata_json->set(\$existing)"); + + $start = strpos($washFlow, 'public function forceStopLane'); + $end = strpos($washFlow, 'protected function buildEligibilitySnapshot', $start); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + $forceStopSource = substr($washFlow, $start, $end - $start); + + expect($forceStopSource)->toContain('$lane->invoice()'); + expect($forceStopSource)->toContain('getLastInvoiceOrderId'); + expect($forceStopSource)->toContain('markForceStopped'); + expect($forceStopSource)->toContain('SESSION_FORCE_STOPPED'); + expect($forceStopSource)->toContain('selfserve_lane_command::RESET'); + expect($forceStopSource)->toContain('runtime_before_reset'); + expect($forceStopSource)->not->toContain('selfserve_lane_command::STOP'); + expect($forceStopSource)->not->toContain('selfserve_lane_port::EXIT'); + expect($forceStopSource)->not->toContain('open('); + expect($forceStopSource)->not->toContain('turnOffRelaysAfterStop'); + expect($forceStopSource)->not->toContain('disableMachineRelayForCompletedWash'); + expect($forceStopSource)->not->toContain('getRelayStatus'); + expect($forceStopSource)->not->toContain('MACHINE_PROGRAM_PICKER'); + expect($forceStopSource)->not->toContain('addVehicleTypeProductToInvoiceIfNeeded'); + expect($forceStopSource)->not->toContain('addVehicleTypeProductToLastInvoiceOrder'); +}); + +it('wires vehicle type override into self-serve preview and synchronization routes', function (): void { + $vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($vehicleConditionsRoute)->not->toBeFalse(); + expect($vehicleConditionsRoute)->toContain('vehicle_type_id'); + expect($vehicleConditionsRoute)->toContain('vehicle_type'); + expect($vehicleConditionsRoute)->toContain('resolveVehicleTypeIdFromQuery'); + expect($vehicleConditionsRoute)->toContain('resolveVehicleTypeIdFromRequest'); + expect($vehicleConditionsRoute)->toContain('normalizeVehicleTypeOverride'); + expect($vehicleConditionsRoute)->toContain('shouldRefreshSummaryForVehicleType'); + expect($vehicleConditionsRoute)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false)'); + expect($vehicleConditionsRoute)->toContain('requestBooleanFlag'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)'); + + expect($washFlow)->not->toBeFalse(); + expect($washFlow)->toContain('resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride)'); + expect($washFlow)->toContain("'vehicle_type_id' => \$snapshot['vehicle_type_id']"); + expect($washFlow)->toContain("\$session->vehicle_type_id->set(\$snapshot['vehicle_type_id']);"); + expect($washFlow)->toContain("\$session->vehicle_id->set(\$snapshot['vehicle']['id'] ?? null);"); +}); + +it('blocks new self-serve eligibility and session sync for disabled lanes', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + $commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php')); + + expect($washFlow)->not->toBeFalse() + ->and($washFlow)->toContain('if (!$lane->isSelfServeEnabled())') + ->and($washFlow)->toContain("'allowed_services' => []") + ->and($washFlow)->toContain("'machine_available' => false") + ->and($washFlow)->toContain("'allowed' => false") + ->and($washFlow)->toContain("'blocked_reason' => 'Self-serve is disabled for this lane.'") + ->and($washFlow)->toContain("'blocking_reasons' => ['LANE_SELFSERVE_DISABLED']") + ->and($washFlow)->toContain("'disabled_lane' => true") + ->and($washFlow)->toContain('formatBlockedSessionSummary') + ->and($washFlow)->toContain('empty($summary[\'session\'][\'id\'])'); + + expect($commandTrait)->not->toBeFalse() + ->and($commandTrait)->toContain('$this->department_lane->isSelfServeEnabled()') + ->and($commandTrait)->toContain('Self-serve is not enabled for this lane.'); +}); + +it('keeps read-only self-serve preview and summary refreshes from touching relay hardware', function (): void { + $vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + + expect($vehicleConditionsRoute)->not->toBeFalse(); + expect($vehicleConditionsRoute)->toContain('$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);'); + expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession('); + expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);'); + expect($vehicleConditionsRoute)->toContain('$activate_machine = $this->requestBooleanFlag(\'activate_machine\', true);'); + expect($vehicleConditionsRoute)->toContain('$sync_relay_state = $this->requestBooleanFlag(\'sync_relay_state\', true);'); + expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);'); + expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php new file mode 100644 index 00000000..dc0f9776 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php @@ -0,0 +1,62 @@ +not->toBeFalse(); + expect($bootstrapContent)->toContain("'selfserve_wash_session_tasks'"); + expect($bootstrapContent)->toContain("'dynamic_images_vehicle_type'"); + expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'); +}); + +it('adds wash_started_at column for legacy selfserve wash session schemas', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain("'selfserve_wash_sessions'"); + expect($bootstrapContent)->toContain("'wash_started_at'"); + expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'); +}); + +it('adds lane-level self-serve enablement for existing department lanes', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain("'department_lanes'"); + expect($bootstrapContent)->toContain("'selfserve_enabled'"); + expect($bootstrapContent)->toContain('ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id'); +}); + +it('creates canvas-only self-serve studio layout storage', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_studio_layouts'); + expect($bootstrapContent)->toContain('layout_json JSON NOT NULL'); + expect($bootstrapContent)->toContain('idx_department_selfserve_studio_layouts_department_user'); +}); + +it('uses mysql-safe identifiers for self-serve studio virtual hardware storage', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_studio_virtual_hardware'); + expect($bootstrapContent)->toContain('UNIQUE KEY uniq_selfserve_vhw_department'); + expect($bootstrapContent)->toContain('INDEX idx_selfserve_vhw_dept_updated'); + expect($bootstrapContent)->not->toContain('idx_department_selfserve_studio_virtual_hardware_department_updated'); + + preg_match_all('/\b(?:UNIQUE\s+KEY|INDEX)\s+([a-zA-Z0-9_]+)/', $bootstrapContent, $matches); + foreach ($matches[1] as $identifier) { + expect(strlen($identifier))->toBeLessThanOrEqual(64); + } +}); + +it('creates self-serve studio path confirmation storage', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations'); + expect($bootstrapContent)->toContain('path_signature VARCHAR(128) NOT NULL'); + expect($bootstrapContent)->toContain('result_signature VARCHAR(128) NOT NULL'); + expect($bootstrapContent)->toContain('idx_selfserve_path_conf_signature'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php new file mode 100644 index 00000000..f812cf00 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php @@ -0,0 +1,36 @@ +not->toBeFalse(); + expect($commandTrait)->toContain('turnOnCleanerRelayForWashStart()'); + expect($commandTrait)->toContain('setMachineCleanerRelayStatusHard(true)'); + + expect($washFlow)->not->toBeFalse(); + expect($washFlow)->toContain('enableCleanerRelayForStartedWash('); + expect($washFlow)->toContain('setMachineCleanerRelayStatusHard(true)'); +}); + +it('keeps cleaner relay enable wired into machine relay start paths', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + $moduleRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($washFlow)->not->toBeFalse(); + $enableMachineRelayMethodOffset = strpos($washFlow, 'protected function enableMachineRelayIfAllowed'); + expect($enableMachineRelayMethodOffset)->not->toBeFalse(); + $enableMachineRelayMethod = substr($washFlow, (int)$enableMachineRelayMethodOffset, 1200); + expect($enableMachineRelayMethod)->toContain('$this->enableCleanerRelayForStartedWash($lane);'); + $cleanerEnableOffset = strpos($enableMachineRelayMethod, '$this->enableCleanerRelayForStartedWash($lane);'); + $alreadyEnabledGuardOffset = strpos($enableMachineRelayMethod, 'if ((bool)$session->machine_relay_enabled->value() === true)'); + expect($cleanerEnableOffset)->not->toBeFalse() + ->and($alreadyEnabledGuardOffset)->not->toBeFalse() + ->and($cleanerEnableOffset)->toBeLessThan($alreadyEnabledGuardOffset); + + expect($moduleRoute)->not->toBeFalse(); + $machineEnableRouteOffset = strpos($moduleRoute, '/modules/self-serve/lane/relay/machine/enable'); + expect($machineEnableRouteOffset)->not->toBeFalse(); + $machineEnableRoute = substr($moduleRoute, (int)$machineEnableRouteOffset, 1800); + expect($machineEnableRoute)->toContain('$lane->setMachineCleanerRelayStatusHard(true);'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioActionRunnerTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioActionRunnerTest.php new file mode 100644 index 00000000..500e875a --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioActionRunnerTest.php @@ -0,0 +1,72 @@ + 17, + 'department_lane' => (object) [ + 'department' => new class { + public function value(): int { return 9; } + }, + 'machine_type_id' => new class { + public function value(): int { return 2; } + }, + ], + ]; + + $config = [ + 'actions' => [ + [ + 'id' => 501, + 'name' => 'Open exit port when condition true', + 'enabled' => true, + 'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND, + 'wash_mode' => selfserve_studio_actions::MODE_BOTH, + 'department' => 9, + 'lane' => 17, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => 7, + 'order_priority' => 1, + 'operation' => selfserve_studio_actions::OP_OPEN_LANE_EXIT_PORT, + 'options' => [], + ], + ], + ]; + + $missingResults = $runner->matchingActions( + $config, + $lane, + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + selfserve_studio_actions::MODE_MANUAL, + [] + ); + + $falseResults = $runner->matchingActions( + $config, + $lane, + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + selfserve_studio_actions::MODE_MANUAL, + ['condition_results' => [7 => false]] + ); + + $trueResults = $runner->matchingActions( + $config, + $lane, + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + selfserve_studio_actions::MODE_MANUAL, + ['condition_results' => [7 => true]] + ); + + expect($missingResults)->toHaveCount(0); + expect($falseResults)->toHaveCount(0); + expect($trueResults)->toHaveCount(1); + expect($trueResults[0]['id'])->toBe(501); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php new file mode 100644 index 00000000..d07611a7 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php @@ -0,0 +1,1894 @@ +newInstanceWithoutConstructor(); +} + +function selfserve_wash_flow_without_constructor(): selfserve_wash_flow +{ + $reflection = new ReflectionClass(selfserve_wash_flow::class); + return $reflection->newInstanceWithoutConstructor(); +} + +function selfserve_virtual_hardware_without_constructor(): selfserve_virtual_hardware +{ + $reflection = new ReflectionClass(selfserve_virtual_hardware::class); + return $reflection->newInstanceWithoutConstructor(); +} + +it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'questions' => [ + ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => 10, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1], + ], + 'conditions' => [ + ['id' => 10, 'name' => 'Trailer present', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2], + ], + 'rules' => [ + ['id' => 20, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1, 'name' => 'Mirror answer'], + ], + 'tasks' => [ + ['id' => 30, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 1, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1, 'services' => ['MACHINE']], + ], + ], [ + 'lookups' => [ + 'departments' => [['id' => 2, 'label' => 'Roskilde']], + 'lanes' => [['id' => 7, 'label' => 'Lane 7']], + 'products' => [['id' => 3, 'label' => 'Forvogn']], + 'machine_types' => [], + 'vehicle_types' => [['id' => 3, 'product' => 3, 'label' => 'Forvogn', 'source' => 'products']], + 'labels' => [ + 'departments' => ['2' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'products' => ['3' => 'Forvogn'], + 'vehicle_types' => ['3' => 'Forvogn'], + 'questions' => ['1' => 'Are mirrors folded?'], + 'conditions' => ['10' => 'Trailer present'], + 'tasks' => ['30' => 'Fold mirrors'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 50, + 'label' => 'Gateway A', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'relay-1', 'label' => 'Machine relay', 'role' => 'MACHINE'], + ], + ], + ], + 'relays' => [ + ['relay_id' => 'relay-1', 'name' => 'Machine relay'], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['relay_id' => 'relay-1', 'slot' => 'MACHINE'], + ], + ], + ], + ], + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + + expect($nodeIds)->toContain('question:1'); + expect($nodeIds)->toContain('condition:10'); + expect($nodeIds)->toContain('rule:20'); + expect($nodeIds)->toContain('task:30'); + expect($nodeIds)->toContain('vehicle_type:3'); + expect($nodeIds)->toContain('gateway:50'); + expect($nodeIds)->toContain('relay:relay-1'); + expect($edgeIds)->toContain('question-gate:10:1'); + expect($edgeIds)->toContain('task-gate:question:1:30'); + expect($edgeIds)->toContain('scope:vehicle_type:3:question:1'); + expect($edgeIds)->toContain('scope:vehicle_type:3:condition:10'); + expect($edgeIds)->toContain('scope:vehicle_type:3:task:30'); + expect($edgeIds)->toContain('gateway-binding:50:relay-1:0'); + expect($edgeIds)->toContain('task-service:30:MACHINE:50:relay-1:0'); + expect($edgeIds)->toContain('relay-lane:relay-1:7:MACHINE'); + + $taskNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'task:30' + ))[0] ?? null; + $bindingNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'binding:50:relay-1:0' + ))[0] ?? null; + + expect($taskNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']); + expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']); +}); + +it('serializes configurable studio actions with event, gate, scope, and ordering edges', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'schema_version' => 2, + 'questions' => [], + 'conditions' => [ + ['id' => 10, 'name' => 'Machine selected'], + ], + 'rules' => [], + 'tasks' => [], + 'actions' => [ + [ + 'id' => 80, + 'name' => 'Open lane entry', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'condition_id' => 10, + 'lane' => 7, + 'order_priority' => 1, + ], + [ + 'id' => 81, + 'name' => 'Cleaner off', + 'event' => 'wash_start_command', + 'wash_mode' => 'machine', + 'operation' => 'set_cleaner_relay', + 'relay_state' => false, + 'lane' => 7, + 'order_priority' => 2, + ], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'lanes' => ['7' => 'Lane 7'], + 'conditions' => ['10' => 'Machine selected'], + 'actions' => ['80' => 'Open lane entry', '81' => 'Cleaner off'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 50, + 'label' => 'Gateway A', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'ENTRY-7', 'label' => 'Entry relay', 'role' => 'ENTRY', 'services' => ['ENTRY']], + ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner relay', 'role' => 'CLEANER', 'services' => ['CLEANER']], + ], + ], + ], + 'relays' => [ + ['relay_id' => 'ENTRY-7', 'name' => 'Entry relay'], + ['relay_id' => 'CLEAN-7', 'name' => 'Cleaner relay'], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['relay_id' => 'ENTRY-7', 'slot' => 'ENTRY'], + ['relay_id' => 'CLEAN-7', 'slot' => 'CLEANER'], + ], + ], + ], + ], + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + $actionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'action:81' + ))[0] ?? null; + + expect($nodeIds)->toContain('action:80') + ->and($nodeIds)->toContain('action:81') + ->and($edgeIds)->toContain('action-event:wash_start_command:80') + ->and($edgeIds)->toContain('action-gate:10:80') + ->and($edgeIds)->toContain('action-order:wash_start_command:80:81') + ->and($edgeIds)->toContain('action-relay:80:ENTRY-7:ENTRY:7') + ->and($edgeIds)->toContain('action-relay:81:CLEAN-7:CLEANER:7') + ->and($edgeIds)->toContain('scope:lane:7:action:80') + ->and($actionNode['data']['action_label'])->toBe('Turn OFF CLEANER') + ->and($actionNode['data']['relay_role'])->toBe('CLEANER'); +}); + +it('validates action configuration and keeps warnings non-blocking', function (): void { + $versioning = new class extends selfserve_config_versioning { + public function __construct() + { + } + }; + + $validation = $versioning->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'question' => 'Machine selected?'], + ], + 'conditions' => [ + [ + 'id' => 10, + 'name' => 'Gate', + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], + 'rules' => [], + 'tasks' => [], + 'actions' => [ + [ + 'id' => 80, + 'name' => 'Manual machine start action', + 'event' => 'machine_start_triggered', + 'wash_mode' => 'manual', + 'operation' => 'set_machine_relay', + 'condition_id' => 10, + 'relay_state' => true, + 'options' => ['failure_policy' => 'block'], + ], + ], + ]); + + expect($validation['valid'])->toBeTrue() + ->and($validation['stats']['actions'])->toBe(1) + ->and(implode("\n", $validation['warnings']))->toContain('uses manual mode for the machine-start event'); +}); + +it('serializes v2 condition expressions without standalone rule nodes', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1], + ], + 'conditions' => [ + [ + 'id' => 10, + 'name' => 'Ready', + 'lane' => 7, + 'product' => 3, + 'department' => 2, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], + 'rules' => [ + ['id' => 99, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], + ], + 'tasks' => [], + ], [ + 'lookups' => [ + 'labels' => [ + 'questions' => ['1' => 'Are mirrors folded?'], + 'conditions' => ['10' => 'Ready'], + ], + ], + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + $conditionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'condition:10' + ))[0] ?? null; + + expect($nodeIds)->toContain('condition:10'); + expect($nodeIds)->not->toContain('rule:99'); + expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.0'), 0, 8)); + expect($conditionNode['data']['expression_summary'] ?? null)->toContain('Question 1'); +}); + +it('serializes branch and case condition expression dependencies', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'question' => 'Has booking?'], + ['id' => 2, 'question' => 'Allowed?'], + ], + 'conditions' => [ + [ + 'id' => 10, + 'name' => 'Branch condition', + 'expression' => [ + 'type' => 'branch', + 'branches' => [ + [ + 'kind' => 'if', + 'when' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + [ + 'kind' => 'else', + 'else' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_FALSE'], + ], + ], + ], + ], + [ + 'id' => 20, + 'name' => 'Case condition', + 'expression' => [ + 'type' => 'case', + 'subject_type' => 'condition', + 'subject_id' => 10, + 'cases' => [ + [ + 'value' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], + ], + 'rules' => [], + 'tasks' => [], + ]); + + $edgeIds = array_column($graph['edges'], 'id'); + $conditionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'condition:20' + ))[0] ?? null; + + expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.b0.when'), 0, 8)) + ->and($edgeIds)->toContain('expression:10:question:2:' . substr(md5('0.b0.then'), 0, 8)) + ->and($edgeIds)->toContain('expression:20:condition:10:' . substr(md5('0.case'), 0, 8)) + ->and($edgeIds)->toContain('expression:20:question:2:' . substr(md5('0.c0.then'), 0, 8)) + ->and($conditionNode['data']['expression_summary'] ?? null)->toContain('Case Condition 10'); +}); + +it('keeps runtime on published v2 configs and leaves draft JSON as the studio edit surface', function (): void { + $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName()); + $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); + + expect($washFlowSource)->toContain('getPublishedV2Config($departmentId)'); + expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';"); + expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);'); + expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.'); + expect($studioGraphSource)->toContain('upsert_path'); +}); + +it('upserts path editor answers into generated condition and task config rows', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation'); + $method->setAccessible(true); + $config = [ + 'schema_version' => 2, + 'questions' => [ + ['id' => 11, 'question' => 'Machine wash is allowed', 'order_priority' => 1], + ['id' => 12, 'question' => 'Trailer present', 'order_priority' => 2], + ], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + 'actions' => [], + 'v2_meta' => ['next_ids' => ['condition' => 100, 'task' => 200]], + ]; + + $method->invokeArgs($service, [6, &$config, [ + 'action' => 'upsert_path', + 'entity' => 'path', + 'data' => [ + 'path_key' => 'allowed_front', + 'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001], + 'answers' => [ + ['question_id' => 11, 'value' => true], + ['question_id' => 12, 'value' => false], + ], + 'result' => [ + 'machine_allowed' => true, + 'task' => 'Set program', + 'services' => ['MACHINE', 'PROGRAM_PICKER'], + 'buttons' => ['program_picker', 'reset', 2, 'start'], + 'dynamic_images_vehicle_type' => 3, + 'tasks' => [ + [ + 'task' => 'Set program', + 'services' => ['MACHINE', 'PROGRAM_PICKER'], + 'buttons' => ['program_picker'], + 'dynamic_images_vehicle_type' => 3, + ], + [ + 'task' => 'Press reset', + 'services' => ['MACHINE'], + 'buttons' => ['reset'], + ], + [ + 'task' => 'Press machine button 2', + 'services' => ['MACHINE'], + 'buttons' => [2], + ], + [ + 'task' => 'Press start', + 'services' => ['MACHINE'], + 'buttons' => ['start'], + ], + ], + ], + ], + ]]); + + $condition = $config['conditions'][0] ?? []; + $tasks = array_values($config['tasks'] ?? []); + $pathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? []; + $validation = (new class extends selfserve_config_versioning { + public function __construct() + { + } + })->validateConfig($config); + + expect($condition['generated_by'])->toBe('path_editor') + ->and($condition['path_key'])->toBe('allowed_front') + ->and($condition['lane'])->toBe(7) + ->and($condition['product'])->toBe(2) + ->and($condition['machine_type_id'])->toBe(1001) + ->and($condition['expression']['children'])->toHaveCount(2) + ->and($condition['expression']['children'][0]['operator'])->toBe('IS_TRUE') + ->and($condition['expression']['children'][1]['operator'])->toBe('IS_FALSE') + ->and($tasks)->toHaveCount(4) + ->and(array_column($tasks, 'task'))->toBe(['Set program', 'Press reset', 'Press machine button 2', 'Press start']) + ->and(array_column($tasks, 'order_priority'))->toBe([10, 20, 30, 40]) + ->and(array_column($tasks, 'gate_ref_id'))->toBe([(int)$condition['id'], (int)$condition['id'], (int)$condition['id'], (int)$condition['id']]) + ->and($tasks[0]['generated_by'])->toBe('path_editor') + ->and($tasks[0]['gate_type'])->toBe('CONDITION') + ->and($tasks[0]['services'])->toBe(['MACHINE', 'PROGRAM_PICKER']) + ->and($tasks[0]['buttons'])->toBe(['program_picker']) + ->and($tasks[0]['dynamic_images_vehicle_type'])->toBe(3) + ->and($tasks[1]['buttons'])->toBe(['reset']) + ->and($tasks[2]['buttons'])->toBe([2]) + ->and($tasks[3]['buttons'])->toBe(['start']) + ->and($pathMeta['condition_id'])->toBe((int)$condition['id']) + ->and($pathMeta['task_id'])->toBe((int)$tasks[0]['id']) + ->and($pathMeta['task_ids'])->toBe(array_map(static fn(array $task): int => (int)$task['id'], $tasks)) + ->and($pathMeta['result']['buttons'])->toBe(['program_picker', 'reset', 2, 'start']) + ->and($pathMeta['path_signature'])->not->toBe('') + ->and($validation['valid'])->toBeTrue(); + + $method->invokeArgs($service, [6, &$config, [ + 'action' => 'upsert_path', + 'entity' => 'path', + 'data' => [ + 'path_key' => 'allowed_front', + 'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001], + 'answers' => [ + ['question_id' => 11, 'value' => true], + ['question_id' => 12, 'value' => false], + ], + 'result' => [ + 'machine_allowed' => true, + 'task' => 'Set program', + 'services' => ['MACHINE', 'PROGRAM_PICKER'], + 'buttons' => ['program_picker', 'start'], + 'dynamic_images_vehicle_type' => 3, + 'tasks' => [ + ['task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3], + ['task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start']], + ], + ], + ], + ]]); + + $updatedTasks = array_values(array_filter( + $config['tasks'] ?? [], + static fn(array $task): bool => ($task['path_key'] ?? '') === 'allowed_front' + )); + $updatedPathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? []; + + expect($updatedTasks)->toHaveCount(2) + ->and(array_column($updatedTasks, 'id'))->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]) + ->and(array_column($updatedTasks, 'task'))->toBe(['Set program', 'Press start']) + ->and($updatedPathMeta['task_ids'])->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]); + + $method->invokeArgs($service, [6, &$config, [ + 'action' => 'upsert_path', + 'entity' => 'path', + 'data' => [ + 'path_key' => 'legacy_front', + 'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001], + 'answers' => [ + ['question_id' => 11, 'value' => false], + ], + 'result' => [ + 'machine_allowed' => true, + 'task' => 'Legacy start', + 'services' => ['MACHINE'], + 'buttons' => ['start'], + ], + ], + ]]); + + $legacyPathMeta = $config['v2_meta']['path_editor']['paths']['legacy_front'] ?? []; + $legacyTask = array_values(array_filter( + $config['tasks'] ?? [], + static fn(array $task): bool => ($task['path_key'] ?? '') === 'legacy_front' + ))[0] ?? []; + + expect($legacyTask['task'])->toBe('Legacy start') + ->and($legacyTask['buttons'])->toBe(['start']) + ->and($legacyPathMeta['task_id'])->toBe((int)$legacyTask['id']) + ->and($legacyPathMeta['task_ids'])->toBe([(int)$legacyTask['id']]); +}); + +it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void { + $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName()); + $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); + $attachmentPayloadSource = file_get_contents(WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php'); + + expect($washFlowSource)->toContain("require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';") + ->and($washFlowSource)->toContain('(new selfserve_task_attachment_payloads())->attachToTasks(') + ->and($washFlowSource)->toContain("'attachments' => \$task['attachments'] ?? []") + ->and($washFlowSource)->toContain("'attachments' => \$taskAttachments") + ->and($studioGraphSource)->toContain('$configWithAttachments = $this->withTaskAttachments($config);') + ->and($studioGraphSource)->toContain('$task[\'attachments\'] = is_array($task[\'attachments\'] ?? null) ? array_values($task[\'attachments\']) : [];') + ->and($attachmentPayloadSource)->toContain("private const OBJECT_TYPE = 'department_selfserve_tasks';") + ->and($attachmentPayloadSource)->toContain('listMany(self::OBJECT_TYPE, $taskIds)') + ->and($attachmentPayloadSource)->toContain('generateDirectDownloadUrl($fileName)'); +}); + +it('derives studio vehicle type lookup rows from selectable wash products', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $method = new ReflectionMethod(selfserve_studio_graph::class, 'vehicleTypeRowsFromProducts'); + + $vehicleTypes = $method->invoke($service, [ + ['id' => 3, 'name' => 'Forvogn', 'description' => 'Front vehicle', 'is_wash' => 1, 'subscription_allowed' => 1, 'order_priority' => 10], + ['id' => 4, 'name' => 'Trækker', 'description' => 'Tractor unit', 'is_wash' => '1', 'subscription_allowed' => '1', 'order_priority' => 20], + ['id' => 5, 'name' => 'Addon', 'description' => '', 'is_wash' => 0, 'subscription_allowed' => 1, 'order_priority' => 30], + ['id' => 6, 'name' => 'Internal wash', 'description' => '', 'is_wash' => 1, 'subscription_allowed' => 0, 'order_priority' => 40], + ]); + + expect(array_column($vehicleTypes, 'id'))->toBe([3, 4]); + expect(array_column($vehicleTypes, 'label'))->toBe(['Forvogn', 'Trækker']); + expect($vehicleTypes[0]['product'])->toBe(3); + expect($vehicleTypes[0]['product_id'])->toBe(3); + expect($vehicleTypes[0]['source'])->toBe('products'); +}); + +it('exposes dynamic images and referenced machine types as studio lookup choices', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $dynamicImages = new ReflectionMethod(selfserve_studio_graph::class, 'dynamicImageRowsFromLanes'); + $machineTypes = new ReflectionMethod(selfserve_studio_graph::class, 'addReferencedMachineTypeRows'); + + $dynamicImageRows = $dynamicImages->invoke($service, [ + ['id' => 7, 'label' => 'Lane 7', 'dynamic_image_id' => 1], + ['id' => 8, 'label' => 'Lane 8', 'dynamic_image_id' => 9], + ]); + $machineTypeRows = $machineTypes->invoke( + $service, + [ + ['id' => 1001, 'name' => 'Portal', 'label' => 'Portal'], + ], + [ + ['id' => 7, 'machine_type_id' => 2002], + ], + [ + 'conditions' => [ + ['id' => 21, 'machine_type_id' => 3003], + ], + 'tasks' => [ + ['id' => 41, 'machine_type_id' => 1001], + ], + ] + ); + + expect(array_column($dynamicImageRows, 'id'))->toBe([1, 9]); + expect($dynamicImageRows[0]['label'])->toBe('Machine 1'); + expect($dynamicImageRows[1]['label'])->toBe('Dynamic image 9'); + expect(array_column($machineTypeRows, 'id'))->toBe([1001, 2002, 3003]); + expect($machineTypeRows[0]['label'])->toBe('Portal'); + expect($machineTypeRows[1]['label'])->toBe('Machine type 2002'); + expect($machineTypeRows[2]['label'])->toBe('Machine type 3003'); +}); + +it('keeps lane management fields on lane scope nodes', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + ], [ + 'lookups' => [ + 'lanes' => [ + [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'label' => 'Lane 7', + 'relay_machine_id' => 'M-7', + 'machine_type_id' => 1001, + 'dynamic_image_id' => 1, + 'selfserve_enabled' => true, + ], + ], + 'machine_types' => [['id' => 1001, 'label' => 'Portal']], + 'dynamic_images' => [['id' => 1, 'label' => 'Machine 1']], + 'labels' => [ + 'lanes' => ['7' => 'Lane 7'], + 'machine_types' => ['1001' => 'Portal'], + 'dynamic_images' => ['1' => 'Machine 1'], + ], + ], + ]); + + $laneNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'lane:7' + ))[0] ?? null; + + expect($laneNode)->not->toBeNull() + ->and($laneNode['data']['raw']['relay_machine_id'])->toBe('M-7') + ->and($laneNode['data']['raw']['machine_type_id'])->toBe(1001) + ->and($laneNode['data']['raw']['dynamic_image_id'])->toBe(1) + ->and($laneNode['data']['raw']['selfserve_enabled'])->toBeTrue(); +}); + +it('routes studio lane graph operations through department_lanes', function (): void { + $source = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); + + expect($source)->toContain("if (\$entity === 'lane')") + ->and($source)->toContain('private function applyLaneOperation') + ->and($source)->toContain('private function createLane') + ->and($source)->toContain('private function updateLane') + ->and($source)->toContain('INSERT INTO department_lanes') + ->and($source)->toContain("'dynamic_image_id'") + ->and($source)->toContain("'selfserve_enabled'") + ->and($source)->toContain('normalizeLaneField') + ->and($source)->toContain('normalizeSelfServeEnabledValue') + ->and($source)->toContain('disableSelfServeRelaysBestEffort'); +}); + +it('applies saved layout without changing graph semantics', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'questions' => [ + ['id' => 1, 'question' => 'Question', 'order_priority' => 1], + ], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + ], [ + 'lookups' => [ + 'labels' => [], + ], + ], [ + 'nodes' => [ + 'question:1' => ['x' => 123, 'y' => 456], + ], + ]); + + $questionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'question:1' + ))[0] ?? null; + + expect($questionNode)->not->toBeNull(); + expect($questionNode['position'])->toBe(['x' => 123.0, 'y' => 456.0]); +}); + +it('keeps layout loading compatible with native PDO named placeholders', function (): void { + $method = new ReflectionMethod(selfserve_studio_graph::class, 'loadLayout'); + $source = implode('', array_slice( + file((string)$method->getFileName()) ?: [], + $method->getStartLine() - 1, + $method->getEndLine() - $method->getStartLine() + 1 + )); + + expect($source)->not->toContain('user_id = :user_id OR user_id IS NULL'); + expect($source)->not->toContain('user_id = :user_id THEN 0 ELSE 1'); + expect($source)->toContain(':user_id_filter'); + expect($source)->toContain(':user_id_sort'); +}); + +it('builds guided simulator debug payload with blockers and canvas annotations', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => ['id' => 7, 'name' => 'Lane 7'], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [11 => null], + 'answer_sources' => [11 => 'override'], + 'questions' => [], + 'tasks' => [], + 'allowed_services' => [], + 'machine_available' => false, + 'all_visible_questions_answered' => false, + 'allowed' => false, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [11], + 'visibility_condition_results' => [21 => true], + 'condition_results' => [21 => true], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false], + ], + ], + 'debug_candidates' => [ + 'questions' => [ + ['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => 21, 'order_priority' => 1], + ], + 'conditions' => [ + ['id' => 21, 'name' => 'Trailer present', 'condition_id' => null], + ], + 'rules' => [ + ['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'], + ], + 'tasks' => [ + ['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => [1], 'order_priority' => 1], + ], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'departments' => ['6' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'vehicle_types' => ['2' => 'Forvogn'], + 'machine_types' => ['1001' => 'Portal'], + 'questions' => ['11' => 'Are mirrors folded?'], + 'conditions' => ['21' => 'Trailer present'], + 'rules' => ['31' => 'Mirror answer'], + 'tasks' => ['41' => 'Fold mirrors'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ], + ], + ], + ], + 'graph' => [ + 'edges' => [ + ['id' => 'task-gate:question:11:41', 'source' => 'question:11', 'target' => 'task:41', 'label' => 'unlocks'], + ], + ], + ]); + + expect($debug['summary']['status'])->toBe('blocked') + ->and($debug['parameters']['config_source'])->toBe('draft') + ->and($debug['questions'][0]['state'])->toBe('missing') + ->and($debug['questions'][0]['answer_source'])->toBe('override') + ->and($debug['tasks'][0]['state'])->toBe('blocked') + ->and($debug['tasks'][0]['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual missing.') + ->and($debug['tasks'][0]['causes'][0])->toMatchArray([ + 'kind' => 'question', + 'id' => 11, + 'label' => 'Are mirrors folded?', + 'expected' => true, + 'actual' => null, + ]) + ->and($debug['dynamic_image_buttons'][0])->toMatchArray([ + 'kind' => 'dynamic_image_button', + 'label' => '1', + 'state' => 'hidden', + 'task_id' => 41, + ]) + ->and(array_column($debug['decisions'], 'kind'))->toContain('question') + ->and(array_column($debug['decisions'], 'kind'))->toContain('task') + ->and(array_column($debug['decisions'], 'kind'))->toContain('signal') + ->and(array_column($debug['decisions'], 'kind'))->toContain('dynamic_image_button') + ->and(array_column($debug['recommendations'], 'title'))->toContain('Answer required questions') + ->and(array_column($debug['recommendations'], 'title'))->toContain('Configure lane machine relay') + ->and($debug['graph_annotations']['nodes']['question:11']['state'])->toBe('warning') + ->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error'); +}); + +it('uses program picker button numbers as thumb selectors in simulator debug decisions', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'relay_machine_id' => 'M-7', + 'relay_machine_program_picker_id' => 'PICKER-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 4, + 'answers' => [], + 'questions' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Choose program', 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1], + ['id' => 42, 'task' => 'Press first button', 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2], + ], + 'allowed_services' => ['PROGRAM_PICKER', 'MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [], + 'visibility_condition_results' => [], + 'condition_results' => [], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ['task_id' => 42, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Choose program', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1], + ['id' => 42, 'task' => 'Press first button', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2], + ], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'departments' => ['6' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'vehicle_types' => ['4' => 'Program 4'], + 'machine_types' => ['1001' => 'Portal'], + 'tasks' => ['41' => 'Choose program', '42' => 'Press first button'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'PICKER-7', 'label' => 'Program picker relay', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']], + ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ], + ], + ], + ], + ]); + + $dynamicImageDecisions = array_values(array_filter( + $debug['decisions'], + static fn(array $decision): bool => ($decision['kind'] ?? null) === 'dynamic_image_button' + )); + + expect(array_column($debug['dynamic_image_buttons'], 'button'))->toBe(['program_picker', 0]) + ->and(array_column($debug['dynamic_image_buttons'], 'label'))->toBe(['Program picker', '0']) + ->and(array_column($dynamicImageDecisions, 'label'))->toBe(['Program picker', '0']); +}); + +it('adds exact hidden question, skipped action, signal, and button decision causes', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'relay_machine_id' => 'M-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [11 => false], + 'answer_sources' => [11 => 'override'], + 'questions' => [], + 'tasks' => [], + 'allowed_services' => [], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => false, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [11], + 'visibility_condition_results' => [21 => false], + 'condition_results' => [21 => false], + 'visibility_expression_traces' => [ + 21 => [ + 'type' => 'condition', + 'condition_id' => 21, + 'result' => false, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'result' => false, + 'children' => [ + [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 11, + 'operator' => 'IS_TRUE', + 'actual_value' => false, + 'result' => false, + 'reason' => 'Predicate did not pass.', + ], + ], + 'reason' => 'Group did not pass.', + ], + ], + ], + 'condition_expression_traces' => [ + 21 => [ + 'type' => 'condition', + 'condition_id' => 21, + 'result' => false, + 'expression' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 11, + 'operator' => 'IS_TRUE', + 'actual_value' => false, + 'result' => false, + 'reason' => 'Predicate did not pass.', + ], + ], + ], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false], + ], + ], + 'debug_candidates' => [ + 'questions' => [ + ['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1], + ['id' => 12, 'question' => 'Is the lift lowered?', 'condition_id' => 21, 'order_priority' => 2], + ], + 'conditions' => [ + ['id' => 21, 'name' => 'Trailer present', 'condition_id' => null], + ], + 'rules' => [ + ['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'], + ], + 'tasks' => [ + ['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => ['start'], 'order_priority' => 1], + ], + 'actions' => [ + [ + 'id' => 81, + 'name' => 'Open entry on start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'condition_id' => 21, + 'order_priority' => 1, + ], + ], + 'visible_answers' => [11 => false], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'departments' => ['6' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'vehicle_types' => ['2' => 'Forvogn'], + 'machine_types' => ['1001' => 'Portal'], + 'questions' => ['11' => 'Are mirrors folded?', '12' => 'Is the lift lowered?'], + 'conditions' => ['21' => 'Trailer present'], + 'tasks' => ['41' => 'Fold mirrors'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ], + ], + ], + ], + ]); + + $hiddenQuestion = array_values(array_filter($debug['questions'], static fn(array $question): bool => (int)$question['id'] === 12))[0] ?? []; + $taskDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'task'))[0] ?? []; + $actionDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'action'))[0] ?? []; + $signalDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'signal' && ($decision['state'] ?? '') === 'blocked'))[0] ?? []; + $buttonDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'dynamic_image_button'))[0] ?? []; + + expect($hiddenQuestion['state'])->toBe('hidden') + ->and($hiddenQuestion['reason'])->toBe('Question Is the lift lowered? hidden because visibility condition Trailer present expected true, actual false.') + ->and($debug['conditions'][0]['causes'][0])->toMatchArray([ + 'kind' => 'question', + 'id' => 11, + 'label' => 'Are mirrors folded?', + 'expected' => true, + 'actual' => false, + ]) + ->and($taskDecision['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual false.') + ->and($actionDecision['state'])->toBe('skipped') + ->and($actionDecision['causes'][0])->toMatchArray([ + 'kind' => 'condition', + 'id' => 21, + 'label' => 'Trailer present', + 'expected' => true, + 'actual' => false, + ]) + ->and($signalDecision['causes'][0]['reason'])->toContain('blocked') + ->and($buttonDecision['state'])->toBe('hidden') + ->and($buttonDecision['causes'][0]['label'])->toBe('Fold mirrors'); +}); + +it('projects visible question answer paths into grouped task service and signal outcomes', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = function (array $overrides): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + + $mirrorAnswer = $answers[11] ?? null; + $liftAnswer = $answers[12] ?? null; + $liftVisible = $mirrorAnswer === true; + $allowed = $mirrorAnswer === true && $liftAnswer === true; + + return [ + 'allowed' => $allowed, + 'questions' => array_values(array_filter([ + ['id' => 11, 'question' => 'Are mirrors folded?', 'answer' => $mirrorAnswer], + $liftVisible ? ['id' => 12, 'question' => 'Is the lift lowered?', 'answer' => $liftAnswer] : null, + ])), + 'tasks' => $allowed ? [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']], + ] : [], + 'allowed_services' => $allowed ? ['MACHINE'] : [], + 'debug' => [ + 'questions' => [ + [ + 'id' => 11, + 'node_id' => 'question:11', + 'label' => 'Are mirrors folded?', + 'visible' => true, + 'answer' => $mirrorAnswer, + ], + [ + 'id' => 12, + 'node_id' => 'question:12', + 'label' => 'Is the lift lowered?', + 'visible' => $liftVisible, + 'answer' => $liftVisible ? $liftAnswer : null, + ], + ], + 'tasks' => [ + [ + 'id' => 41, + 'node_id' => 'task:41', + 'label' => 'Start machine', + 'active' => $allowed, + 'services' => ['MACHINE'], + 'buttons' => ['start'], + 'order_priority' => 1, + ], + ], + 'signal_timeline' => [ + [ + 'sequence' => 1, + 'runtime_stage' => 'eligibility_sync', + 'signal_type' => 'session_event', + 'relay_role' => 'SESSION', + 'source' => 'none', + 'predicted_status' => $allowed ? 'sent' : 'skipped', + 'payload' => ['allowed' => $allowed], + ], + [ + 'sequence' => 2, + 'runtime_stage' => 'machine_start_signal', + 'signal_type' => 'shelly_event', + 'relay_role' => 'MACHINE', + 'relay_id' => 'M-7', + 'target_binding' => 'binding:701:M-7:0', + 'target_gateway_label' => 'Roskilde Edge', + 'source' => 'real', + 'predicted_status' => $allowed ? 'sent' : 'skipped', + 'payload' => ['event' => 'input.toggle_on'], + ], + ], + ], + ]; + }; + + $projection = $service->projectPathOutcomesFromSimulator($simulate, [ + 'max_states' => 20, + 'scope' => [ + 'department_id' => 6, + 'lane_id' => 7, + 'vehicle_type_id' => 2, + 'config_source' => 'draft', + 'hardware_mode' => 'studio', + ], + ]); + + $allowedOutcome = array_values(array_filter( + $projection['outcomes'], + static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === true + ))[0] ?? null; + $blockedOutcome = array_values(array_filter( + $projection['outcomes'], + static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === false + ))[0] ?? null; + + expect($projection['truncated'])->toBeFalse() + ->and($projection['summary']['state_count'])->toBe(5) + ->and($projection['summary']['terminal_path_count'])->toBe(3) + ->and($projection['summary']['outcome_count'])->toBe(2) + ->and($projection['summary']['path_sample_count'])->toBe(3) + ->and($projection['summary']['question_ids'])->toBe([11, 12]) + ->and($projection['paths'])->toHaveCount(3) + ->and($allowedOutcome)->not->toBeNull() + ->and($allowedOutcome['path_count'])->toBe(1) + ->and($allowedOutcome['services'])->toBe(['MACHINE']) + ->and($allowedOutcome['tasks'][0]['label'])->toBe('Start machine') + ->and($allowedOutcome['signals'][1]['relay_role'])->toBe('MACHINE') + ->and($allowedOutcome['node_ids'])->toContain('binding:701:M-7:0') + ->and($blockedOutcome)->not->toBeNull() + ->and($blockedOutcome['path_count'])->toBe(2); + + $oneAnswerBlockedSample = array_values(array_filter( + $blockedOutcome['sample_chains'], + static fn(array $chain): bool => count((array)($chain['answers'] ?? [])) === 1 + ))[0] ?? null; + + expect($oneAnswerBlockedSample)->not->toBeNull() + ->and($oneAnswerBlockedSample['answers'][0]['question_id'])->toBe(11) + ->and($oneAnswerBlockedSample['answers'][0]['answer'])->toBeFalse(); + + $allowedPath = array_values(array_filter( + $projection['paths'], + static fn(array $path): bool => ($path['allowed'] ?? false) === true + ))[0] ?? null; + + expect($allowedPath)->not->toBeNull() + ->and($allowedPath['result'])->toBe('Allowed') + ->and($allowedPath['answers'])->toHaveCount(2) + ->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?') + ->and($allowedPath['services'])->toBe(['MACHINE']) + ->and($allowedPath['path_signature'])->not->toBe('') + ->and($allowedPath['result_signature'])->not->toBe('') + ->and($allowedPath['confirmation_status'])->toBe('unconfirmed') + ->and($allowedPath['node_ids'])->toContain('question:11') + ->and($allowedPath['node_ids'])->toContain('task:41'); +}); + +it('marks projected path confirmations confirmed or stale by stable signatures', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = static function (string $button): callable { + return static function (array $overrides) use ($button): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + $allowed = ($answers[11] ?? null) === true; + + return [ + 'allowed' => $allowed, + 'questions' => [ + ['id' => 11, 'question' => 'Machine wash is allowed', 'answer' => $answers[11] ?? null], + ], + 'tasks' => $allowed ? [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => [$button]], + ] : [], + 'allowed_services' => $allowed ? ['MACHINE'] : [], + 'debug' => [ + 'questions' => [ + ['id' => 11, 'node_id' => 'question:11', 'label' => 'Machine wash is allowed', 'visible' => true, 'answer' => $answers[11] ?? null], + ], + 'tasks' => [ + ['id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => [$button], 'order_priority' => 1], + ], + 'signal_timeline' => [], + ], + ]; + }; + }; + + $scope = [ + 'department_id' => 6, + 'lane_id' => 7, + 'vehicle_type_id' => 2, + 'config_source' => 'draft', + 'config_version_id' => 90, + 'hardware_mode' => 'studio', + ]; + $initial = $service->projectPathOutcomesFromSimulator($simulate('start'), ['scope' => $scope]); + $allowedPath = array_values(array_filter( + $initial['paths'], + static fn(array $path): bool => ($path['allowed'] ?? false) === true + ))[0] ?? []; + + $rows = [[ + 'path_signature' => $allowedPath['path_signature'], + 'result_signature' => $allowedPath['result_signature'], + 'answers' => $allowedPath['answers'], + 'result' => ['allowed' => true], + 'scope' => $scope, + 'confirmed_at' => '2026-05-27 10:00:00', + 'confirmed_by' => 9, + ]]; + $confirmed = $service->projectPathOutcomesFromSimulator($simulate('start'), [ + 'scope' => $scope, + 'confirmation_rows' => $rows, + ]); + $changed = $service->projectPathOutcomesFromSimulator($simulate('reset'), [ + 'scope' => $scope, + 'confirmation_rows' => $rows, + ]); + + $confirmedAllowed = array_values(array_filter( + $confirmed['paths'], + static fn(array $path): bool => ($path['allowed'] ?? false) === true + ))[0] ?? []; + $staleAllowed = array_values(array_filter( + $changed['paths'], + static fn(array $path): bool => ($path['allowed'] ?? false) === true + ))[0] ?? []; + + expect($confirmedAllowed['confirmation_status'])->toBe('confirmed') + ->and($confirmed['summary']['confirmations']['confirmed'])->toBe(1) + ->and($staleAllowed['confirmation_status'])->toBe('stale') + ->and($staleAllowed['stale_reason'])->toBe('Result changed since confirmation.') + ->and($changed['summary']['confirmations']['stale'])->toBe(1); +}); + +it('treats null path confirmation rows as an empty confirmation set', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $method = new ReflectionMethod(selfserve_studio_graph::class, 'pathOutcomesPayload'); + + $payload = $method->invoke( + $service, + ['department_id' => 6, 'lane_id' => 7], + [], + [], + [], + false, + null, + 0, + 0, + [], + [], + null + ); + + expect($payload['summary']['confirmations']['total'])->toBe(0) + ->and($payload['confirmations']['removed'])->toBe([]); +}); + +it('truncates path outcome projection when the state cap is reached', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = function (array $overrides): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + $first = $answers[1] ?? null; + $secondVisible = $first === true; + + return [ + 'allowed' => false, + 'questions' => [], + 'tasks' => [], + 'allowed_services' => [], + 'debug' => [ + 'questions' => [ + ['id' => 1, 'node_id' => 'question:1', 'label' => 'First', 'visible' => true, 'answer' => $first], + ['id' => 2, 'node_id' => 'question:2', 'label' => 'Second', 'visible' => $secondVisible, 'answer' => $answers[2] ?? null], + ], + 'tasks' => [], + 'signal_timeline' => [], + ], + ]; + }; + + $projection = $service->projectPathOutcomesFromSimulator($simulate, ['max_states' => 2]); + + expect($projection['truncated'])->toBeTrue() + ->and($projection['summary']['state_count'])->toBe(2) + ->and($projection['warnings'][0])->toContain('truncated at 2 explored state'); +}); + +it('returns complete terminal path results for wide question trees and reports progress', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = function (array $overrides): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + + $questions = []; + foreach (range(1, 12) as $questionId) { + $questions[] = [ + 'id' => $questionId, + 'node_id' => 'question:' . $questionId, + 'label' => 'Question ' . $questionId, + 'visible' => true, + 'answer' => $answers[$questionId] ?? null, + ]; + } + + $complete = count($answers) === 12; + $allowed = $complete && !in_array(false, $answers, true); + + return [ + 'allowed' => $allowed, + 'questions' => [], + 'tasks' => $allowed ? [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']], + ] : [], + 'allowed_services' => $allowed ? ['MACHINE'] : [], + 'debug' => [ + 'questions' => $questions, + 'tasks' => [ + [ + 'id' => 41, + 'node_id' => 'task:41', + 'label' => 'Start machine', + 'active' => $allowed, + 'services' => ['MACHINE'], + 'buttons' => ['start'], + 'order_priority' => 1, + ], + ], + 'signal_timeline' => [], + ], + ]; + }; + + $progressEvents = []; + $projection = $service->projectPathOutcomesFromSimulator($simulate, [ + 'progress_interval_states' => 512, + 'progress_callback' => static function (array $partial) use (&$progressEvents): void { + $progressEvents[] = [ + 'percent' => (int)($partial['progress']['percent'] ?? 0), + 'terminal_path_count' => (int)($partial['summary']['terminal_path_count'] ?? 0), + 'path_sample_count' => (int)($partial['summary']['path_sample_count'] ?? 0), + ]; + }, + ]); + + expect($projection['truncated'])->toBeFalse() + ->and($projection['summary']['state_count'])->toBe(8191) + ->and($projection['summary']['question_count'])->toBe(12) + ->and($projection['summary']['terminal_path_count'])->toBe(4096) + ->and($projection['summary']['outcome_count'])->toBe(2) + ->and($projection['summary']['path_sample_count'])->toBe(4096) + ->and($projection['progress']['complete'])->toBeTrue() + ->and($projection['progress']['percent'])->toBe(100) + ->and($projection['paths'])->toHaveCount(4096) + ->and($projection['paths'][0]['answers'])->toHaveCount(12) + ->and($projection['paths'][0]['result'])->toBe('Allowed') + ->and($progressEvents)->not->toBeEmpty() + ->and($progressEvents[0]['terminal_path_count'])->toBeGreaterThan(0) + ->and($progressEvents[0]['path_sample_count'])->toBeGreaterThan(0); +}); + +it('resolves simulator gateway service bindings from lane relay slots', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => ['id' => 7, 'name' => 'Lane 7'], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [11 => true], + 'answer_sources' => [11 => 'override'], + 'questions' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']], + ], + 'allowed_services' => ['MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [11], + 'visibility_condition_results' => [], + 'condition_results' => [], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [ + ['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1], + ], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [1], 'order_priority' => 1], + ], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'departments' => ['6' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'vehicle_types' => ['2' => 'Forvogn'], + 'machine_types' => ['1001' => 'Portal'], + 'questions' => ['11' => 'Are mirrors folded?'], + 'tasks' => ['41' => 'Start machine'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'M-7', 'label' => 'Machine relay'], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['relay_id' => 'M-7', 'slot' => 'MACHINE'], + ], + ], + ], + ], + 'graph' => [ + 'edges' => [ + ['id' => 'task-service:41:MACHINE:701:M-7:0', 'source' => 'task:41', 'target' => 'binding:701:M-7:0', 'label' => 'MACHINE'], + ], + ], + ]); + + expect($debug['hardware']['missing_service_bindings'])->toBe([]) + ->and($debug['hardware']['service_bindings']['MACHINE'][0]['node_id'])->toBe('binding:701:M-7:0') + ->and($debug['hardware']['summary'])->toBe('Lane relay and gateway service bindings are ready for the simulated services.') + ->and($debug['tasks'][0]['relay_bindings'][0]['service'])->toBe('MACHINE') + ->and(array_column($debug['recommendations'], 'title'))->toBe(['Flow is ready']); +}); + +it('generates and merges virtual hardware as studio-only relay coverage', function (): void { + $service = selfserve_virtual_hardware_without_constructor(); + $workspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [ + [ + 'id' => 7, + 'name' => 'Lane 7', + 'relay_slots' => [ + ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], + ], + 'binding_coverage' => ['required' => 2, 'bound' => 0, 'missing' => 2, 'state' => 'MISSING'], + ], + ], + 'issues' => [ + ['severity' => 'danger', 'code' => 'NO_GATEWAY', 'message' => 'No edge gateway has been claimed for this department.'], + ['severity' => 'warning', 'code' => 'LANE_BINDING_GAP', 'message' => 'Lane 7 is missing relay bindings.', 'target_type' => 'lane', 'target_id' => 7], + ], + 'actions' => [], + ]; + + $config = $service->generateFromLanes($workspace); + $merged = $service->mergeWorkspaceWithConfig($workspace, $config); + + expect($config['bindings'])->toHaveCount(2) + ->and($merged['virtual']['has_virtual_hardware'])->toBeTrue() + ->and($merged['gateways'][0]['virtual'])->toBeTrue() + ->and($merged['gateways'][0]['bindings'][0]['relay_id'])->toBe('M-7') + ->and($merged['lanes'][0]['binding_coverage']['state'])->toBe('READY') + ->and($merged['lanes'][0]['relay_slots'][0]['coverage']['virtual'])->toBeTrue() + ->and(array_column($merged['issues'], 'code'))->toContain('VIRTUAL_HARDWARE_ACTIVE') + ->and($service->validationWarnings($merged)[0])->toContain('live relay dispatch still requires a real edge gateway'); +}); + +it('renders virtual gateway nodes and task service edges in the studio graph', function (): void { + $virtual = selfserve_virtual_hardware_without_constructor(); + $graphService = selfserve_studio_graph_without_constructor(); + $workspace = $virtual->mergeWorkspaceWithConfig([ + 'gateways' => [], + 'relays' => [], + 'lanes' => [ + [ + 'id' => 7, + 'name' => 'Lane 7', + 'relay_slots' => [ + ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], + ], + 'binding_coverage' => ['required' => 1, 'bound' => 0, 'missing' => 1, 'state' => 'MISSING'], + ], + ], + 'issues' => [], + 'actions' => [], + ], [ + 'schema_version' => 1, + 'enabled' => true, + 'gateways' => [['key' => 'virtual-main', 'label' => 'Virtual Studio Gateway', 'status' => 'VIRTUAL']], + 'relays' => [['relay_id' => 'M-7', 'name' => 'Lane 7 MACHINE']], + 'bindings' => [['gateway_key' => 'virtual-main', 'relay_id' => 'M-7', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'label' => 'Lane 7 MACHINE']], + ]); + + $graph = $graphService->buildGraphFromConfig([ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'order_priority' => 1], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'tasks' => ['41' => 'Start machine'], + 'lanes' => ['7' => 'Lane 7'], + ], + ], + 'gateway_workspace' => $workspace, + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + $gatewayNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'gateway:virtual-main'))[0] ?? []; + $bindingNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'binding:virtual-main:M-7:0'))[0] ?? []; + + expect($nodeIds)->toContain('gateway:virtual-main') + ->and($nodeIds)->toContain('binding:virtual-main:M-7:0') + ->and($edgeIds)->toContain('task-service:41:MACHINE:virtual-main:M-7:0') + ->and($gatewayNode['data']['raw']['virtual'])->toBeTrue() + ->and($bindingNode['data']['raw']['virtual'])->toBeTrue(); +}); + +it('inserts configured action signals into the simulator timeline in runtime order', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'relay_in_id' => 'ENTRY-7', + 'relay_out_id' => 'EXIT-7', + 'relay_machine_id' => 'M-7', + 'relay_machine_program_picker_id' => 'PICKER-7', + 'relay_machine_cleaner_id' => 'CLEAN-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [], + 'answer_sources' => [], + 'questions' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']], + ], + 'allowed_services' => ['MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [], + 'visibility_condition_results' => [], + 'condition_results' => [21 => true], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [ + ['id' => 21, 'name' => 'Gate'], + ], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1], + ], + 'actions' => [ + [ + 'id' => 81, + 'name' => 'Open entry on start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'condition_id' => 21, + 'order_priority' => 1, + 'options' => ['toggle_after_seconds' => 2], + ], + [ + 'id' => 82, + 'name' => 'Program picker off when machine starts', + 'event' => 'machine_start_triggered', + 'wash_mode' => 'machine', + 'operation' => 'set_program_picker_relay', + 'relay_state' => false, + 'order_priority' => 1, + ], + [ + 'id' => 83, + 'name' => 'Cleaner off on stop', + 'event' => 'wash_stop_command', + 'wash_mode' => 'machine', + 'operation' => 'set_cleaner_relay', + 'relay_state' => false, + 'order_priority' => 1, + ], + ], + ], + ], [ + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']], + ['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ['relay_id' => 'PICKER-7', 'label' => 'Program picker', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']], + ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']], + ['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'], + ['slot' => 'MACHINE', 'relay_id' => 'M-7'], + ['slot' => 'PROGRAM_PICKER', 'relay_id' => 'PICKER-7'], + ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], + ], + ], + ], + ], + 'lookups' => [ + 'labels' => [ + 'lanes' => ['7' => 'Lane 7'], + 'conditions' => ['21' => 'Gate'], + 'tasks' => ['41' => 'Start machine'], + ], + ], + ]); + + expect(array_column($debug['signal_timeline'], 'sequence'))->toBe(range(1, 11)) + ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe([ + 'SESSION', + 'ENTRY', + 'MACHINE', + 'MACHINE', + 'PROGRAM_PICKER', + 'CLEANER', + 'CLEANER', + 'EXIT', + 'CLEANER', + 'MACHINE', + 'SESSION', + ]) + ->and($debug['signal_timeline'][1]['signal_type'])->toBe('studio_action_relay_pulse') + ->and($debug['signal_timeline'][1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 2]) + ->and($debug['signal_timeline'][4]['signal_type'])->toBe('studio_action_relay_switch') + ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['action_id' => 82, 'id' => 'PICKER-7', 'on' => false]) + ->and($debug['signal_timeline'][6]['payload'])->toMatchArray(['action_id' => 83, 'id' => 'CLEAN-7', 'on' => false]) + ->and($debug['actions'][0]['state'])->toBe('active') + ->and($debug['graph_annotations']['nodes']['action:81']['state'])->toBe('active'); +}); + +it('simulates lane-scoped wash start actions for property gates and lane entrance ports', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'relay_in_id' => 'ENTRY-7', + 'relay_out_id' => 'EXIT-7', + 'relay_machine_id' => 'M-7', + 'relay_machine_cleaner_id' => 'CLEAN-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => 123, + 'vehicle_type_id' => 2, + 'answers' => [], + 'answer_sources' => [], + 'questions' => [], + 'tasks' => [['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']]], + 'allowed_services' => ['MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [], + 'visibility_condition_results' => [], + 'condition_results' => [], + 'task_gates' => [], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE']]], + 'actions' => [ + [ + 'id' => 80, + 'name' => 'Open property entrance on lane start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_property_entrance_gate', + 'lane' => 7, + 'order_priority' => 1, + ], + [ + 'id' => 81, + 'name' => 'Open lane entrance on lane start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'lane' => 7, + 'order_priority' => 2, + 'options' => ['toggle_after_seconds' => 3], + ], + ], + ], + ], [ + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']], + ['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']], + ['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'], + ['slot' => 'MACHINE', 'relay_id' => 'M-7'], + ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], + ], + ], + ], + ], + ]); + + $startSignals = array_values(array_filter( + $debug['signal_timeline'], + static fn(array $row): bool => str_starts_with((string)($row['signal_type'] ?? ''), 'studio_action_') + && ($row['payload']['event'] ?? null) === 'wash_start_command' + )); + + expect(array_column($startSignals, 'relay_role'))->toBe(['PROPERTY_ENTRANCE', 'ENTRY']) + ->and(array_column($startSignals, 'predicted_status'))->toBe(['sent', 'sent']) + ->and($startSignals[0]['payload'])->toMatchArray(['action_id' => 80, 'command' => 'OPEN_PROPERTY_ACCESS_GATE']) + ->and($startSignals[1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 3]); +}); + +it('adds ordered simulator signal timeline rows for virtual hardware dry runs', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => ['id' => 7, 'name' => 'Lane 7'], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [], + 'answer_sources' => [], + 'questions' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']], + ], + 'allowed_services' => ['MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [], + 'visibility_condition_results' => [], + 'condition_results' => [], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1], + ], + ], + ], [ + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 'virtual-main', + 'label' => 'Virtual Studio Gateway', + 'status' => 'VIRTUAL', + 'virtual' => true, + 'bindings' => [ + ['node_id' => 'binding:virtual-main:M-7:0', 'relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'virtual' => true], + ['node_id' => 'binding:virtual-main:CLEAN-7:1', 'relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER'], 'virtual' => true], + ['node_id' => 'binding:virtual-main:EXIT-7:2', 'relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT'], 'virtual' => true], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['slot' => 'MACHINE', 'relay_id' => 'M-7'], + ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], + ], + ], + ], + 'virtual' => ['has_virtual_hardware' => true], + ], + 'lookups' => ['labels' => ['lanes' => ['7' => 'Lane 7'], 'tasks' => ['41' => 'Start machine']]], + ]); + + expect(array_column($debug['signal_timeline'], 'sequence'))->toBe([1, 2, 3, 4, 5, 6, 7, 8]) + ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe(['SESSION', 'MACHINE', 'MACHINE', 'CLEANER', 'EXIT', 'CLEANER', 'MACHINE', 'SESSION']) + ->and($debug['signal_timeline'][1]['predicted_status'])->toBe('virtual_only') + ->and($debug['signal_timeline'][2]['signal_type'])->toBe('shelly_event') + ->and($debug['signal_timeline'][2]['runtime_stage'])->toBe('machine_start_signal') + ->and($debug['signal_timeline'][2]['transport'])->toBe('shelly_webhook_or_edge_gateway_event') + ->and($debug['signal_timeline'][2]['payload'])->toMatchArray(['event' => 'input.toggle_on', 'bill_machine_wash' => true]) + ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['id' => 'EXIT-7', 'toggle_after' => 1]) + ->and($debug['hardware']['signal_timeline'][6]['payload'])->toMatchArray(['id' => 'M-7', 'on' => false]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php new file mode 100644 index 00000000..ebd0f9f3 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php @@ -0,0 +1,110 @@ +storedValue; + } +} + +class SelfserveWashCompletionDepartmentLaneFake extends \objects\department_lanes_o +{ + public function __construct(string $machineRelayId = 'relay-machine', string $cleanerRelayId = 'relay-cleaner') + { + $this->relay_machine_id = new SelfserveWashCompletionRelayValueFake($machineRelayId); + $this->relay_machine_cleaner_id = new SelfserveWashCompletionRelayValueFake($cleanerRelayId); + $this->relay_machine_program_picker_id = new SelfserveWashCompletionRelayValueFake(''); + } + + public function exists(): bool + { + return true; + } +} + +class SelfserveWashCompletionRelayLaneFake extends selfserve_lane +{ + /** @var selfserve_lane_relay[] */ + public array $statusReads = []; + /** @var array */ + public array $relayWrites = []; + + public function __construct(bool $reportedOn) + { + $this->id = 77; + $this->reportedOn = $reportedOn; + $this->department_lane = new SelfserveWashCompletionDepartmentLaneFake(); + } + + private bool $reportedOn; + + public function getRelayStatus(selfserve_lane_relay $relay): array + { + $this->statusReads[] = $relay; + return ['on' => $this->reportedOn]; + } + + public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool + { + $this->relayWrites[] = [$relay, $on]; + return true; + } +} + +class SelfserveWashCompletionFlowHarness extends selfserve_wash_flow +{ + public function __construct() {} + + public function turnOffConfiguredRelay(selfserve_lane $lane, selfserve_lane_relay $relay): void + { + $this->turnOffRelayIfConfigured($lane, $relay); + } +} + +it('forces machine and cleaner relays off when a self-serve wash session is completed', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($washFlow)->not->toBeFalse(); + expect($washFlow)->toContain('$this->disableMachineRelayForCompletedWash($laneId);'); + + $methodOffset = strpos($washFlow, 'protected function disableMachineRelayForCompletedWash'); + expect($methodOffset)->not->toBeFalse(); + $methodBody = substr($washFlow, (int)$methodOffset, 1500); + + expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);'); + expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);'); + + $helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfigured'); + expect($helperOffset)->not->toBeFalse(); + $helperBody = substr($washFlow, (int)$helperOffset, 1500); + + expect($helperBody)->toContain('$lane->setRelayStatusHard($relay, false);'); + expect($helperBody)->not->toContain('$lane->getRelayStatus($relay)'); +}); + +it('always dispatches completion relay off for configured machine relays without a status precheck', function (): void { + $lane = new SelfserveWashCompletionRelayLaneFake(reportedOn: false); + $flow = new SelfserveWashCompletionFlowHarness(); + + $flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE); + $flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE_CLEANER); + + expect($lane->statusReads)->toBe([]) + ->and($lane->relayWrites)->toBe([ + [selfserve_lane_relay::MACHINE, false], + [selfserve_lane_relay::MACHINE_CLEANER, false], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php new file mode 100644 index 00000000..51ad5fc3 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php @@ -0,0 +1,57 @@ +not->toBeFalse(); + + $methodOffset = strpos($washFlow, 'public function isMachineAllowedToStartWash'); + expect($methodOffset)->not->toBeFalse(); + + $methodBody = substr($washFlow, (int)$methodOffset, 300); + expect($methodBody)->toContain("return (\$sessionSummary['session']['allowed'] ?? false) === true;"); + expect($methodBody)->not->toContain("return \$sessionSummary['allowed'] === true;"); +}); + +it('separates session synchronization from relay hardware synchronization', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + $interface = file_get_contents(app_path('modules/selfserve/interfaces/selfserve_wash_flow_i.php')); + + expect($washFlow)->not->toBeFalse(); + expect($interface)->not->toBeFalse(); + expect($interface)->toContain('bool $syncRelayState = true'); + expect($washFlow)->toContain('bool $syncRelayState = true'); + expect($washFlow)->toContain('if ($syncRelayState) {'); + expect($washFlow)->toContain('$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);'); + expect($washFlow)->toContain('$this->synchronizeSession($laneId, $normalizedReg, null, false, null, false);'); +}); + +it('infers legacy-defaulted always task gates from condition_id at runtime', function (): void { + $reflection = new ReflectionClass(selfserve_wash_flow::class); + $flow = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('resolveTaskGate'); + $method->setAccessible(true); + + $conditionGate = $method->invoke($flow, [ + 'condition_id' => 22, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], [22, 45]); + + expect($conditionGate['gate_type'])->toBe(selfserve_task_gate_type::CONDITION); + expect($conditionGate['gate_ref_id'])->toBe(22); + + $questionGate = $method->invoke($flow, [ + 'condition_id' => 11, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], [22, 45]); + + expect($questionGate['gate_type'])->toBe(selfserve_task_gate_type::QUESTION); + expect($questionGate['gate_ref_id'])->toBe(11); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php new file mode 100644 index 00000000..52730b56 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php @@ -0,0 +1,47 @@ +set($value); + + return $property; +} + +function selfserve_session_harness( + string $status, + ?string $completedAt, + ?string $washStartedAt = '2026-04-28 10:00:00', + ?string $machineStartTriggeredAt = null +): selfserve_wash_sessions_o { + $session = (new ReflectionClass(selfserve_wash_sessions_o::class))->newInstanceWithoutConstructor(); + $session->status = selfserve_session_property('status', 'string', $status); + $session->completed_at = selfserve_session_property('completed_at', 'datetime', $completedAt); + $session->wash_started_at = selfserve_session_property('wash_started_at', 'datetime', $washStartedAt); + $session->machine_start_triggered_at = selfserve_session_property('machine_start_triggered_at', 'datetime', $machineStartTriggeredAt); + + return $session; +} + +it('treats terminal self-serve wash session statuses as closed', function (): void { + expect(selfserve_wash_sessions_o::isTerminalStatus('COMPLETED'))->toBeTrue() + ->and(selfserve_wash_sessions_o::isTerminalStatus('FORCE_STOPPED'))->toBeTrue() + ->and(selfserve_wash_sessions_o::isTerminalStatus('MACHINE_STARTED'))->toBeFalse() + ->and(selfserve_wash_sessions_o::terminalStatusSqlList())->toBe("'COMPLETED','FORCE_STOPPED'"); + + expect(selfserve_session_harness('MACHINE_STARTED', null)->isOpen())->toBeTrue() + ->and(selfserve_session_harness('COMPLETED', null)->isOpen())->toBeFalse() + ->and(selfserve_session_harness('MACHINE_STARTED', '2026-04-28 10:30:00')->isOpen())->toBeFalse(); +}); + +it('freezes elapsed self-serve wash minutes at completion time', function (): void { + expect(selfserve_session_harness('COMPLETED', '2026-04-28 10:42:00')->getElapsedMinutes())->toBe(42) + ->and(selfserve_session_harness('COMPLETED', '2026-04-28 10:35:00', null, '2026-04-28 10:05:00')->getElapsedMinutes())->toBe(30) + ->and(selfserve_session_harness('COMPLETED', '2026-04-28 09:59:00')->getElapsedMinutes())->toBe(0); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashStartedAtWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashStartedAtWiringTest.php new file mode 100644 index 00000000..3803c3fe --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashStartedAtWiringTest.php @@ -0,0 +1,30 @@ +not->toBeFalse(); + expect($sessionsObject)->toContain('public object_property $wash_started_at;'); + expect($sessionsObject)->toContain("\$this->wash_started_at = new object_property("); + expect($sessionsObject)->toContain("\$this->wash_started_at->set(\$resolvedWashStartedAt);"); + expect($sessionsObject)->toContain("'wash_started_at' =>"); +}); + +it('passes lane wash start time into the session machine-start marker', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($washFlow)->not->toBeFalse(); + expect($washFlow)->toContain('$washStartedAt = (int)$lane->getWashStartTime();'); + expect($washFlow)->toContain('$session->markMachineStartTriggered('); + expect($washFlow)->toContain("date('Y-m-d H:i:s', \$washStartedAt)"); +}); + +it('resolves in-progress wash_started_at from session with compatibility fallbacks', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('$wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value();'); + expect($moduleSelfServeRoute)->toContain('$wash_started_at = $machine_start_triggered_at;'); + expect($moduleSelfServeRoute)->toContain('$wash_started_at = $format_wash_started_at($selfserve->lane($lane_id)->getWashStartTime());'); + expect($moduleSelfServeRoute)->toContain('\'wash_started_at\' => $wash_started_at'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitBehaviorTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitBehaviorTest.php new file mode 100644 index 00000000..986ebf10 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitBehaviorTest.php @@ -0,0 +1,173 @@ +owner->clientSet($key, $value, $args); + } + + public function pttl(string $key): int + { + return $this->owner->clientPttl($key); + } +} + +class GlobalShellyRateLimitRedisFake +{ + /** @var array */ + private array $store = []; + /** @var array */ + private array $expiresAt = []; + private GlobalShellyRateLimitRedisClientFake $client; + + public function __construct(private readonly GlobalShellyRateLimitClock $clock) + { + $this->client = new GlobalShellyRateLimitRedisClientFake($this); + } + + public function get_client(): GlobalShellyRateLimitRedisClientFake + { + return $this->client; + } + + public function clientSet(string $key, string $value, array $args): bool|string + { + $this->purgeExpired($key); + + $useNx = false; + $ttlMs = null; + $count = count($args); + for ($i = 0; $i < $count; $i++) { + $token = strtoupper((string)$args[$i]); + if ($token === 'NX') { + $useNx = true; + continue; + } + if ($token === 'PX' && isset($args[$i + 1])) { + $ttlMs = max(1, (int)$args[$i + 1]); + $i++; + } + } + + if ($useNx && array_key_exists($key, $this->store)) { + return false; + } + + $this->store[$key] = $value; + if ($ttlMs === null) { + unset($this->expiresAt[$key]); + } else { + $this->expiresAt[$key] = $this->clock->now + ($ttlMs / 1000); + } + + return 'OK'; + } + + public function clientPttl(string $key): int + { + $this->purgeExpired($key); + if (!array_key_exists($key, $this->store)) { + return -2; + } + if (!isset($this->expiresAt[$key])) { + return -1; + } + + $remainingMs = (int)ceil(($this->expiresAt[$key] - $this->clock->now) * 1000); + return $remainingMs > 0 ? $remainingMs : 0; + } + + private function purgeExpired(string $key): void + { + if (!isset($this->expiresAt[$key])) { + return; + } + if ($this->clock->now < $this->expiresAt[$key]) { + return; + } + unset($this->expiresAt[$key], $this->store[$key]); + } +} + +class GlobalShellyRateLimitHarness extends shelly +{ + /** @var array */ + public array $sleepCalls = []; + + public function __construct( + private readonly GlobalShellyRateLimitClock $clock, + private readonly ?GlobalShellyRateLimitRedisFake $redis = null + ) { + } + + public function waitForShellyRateLimitWindowForTest(): void + { + $invoke = \Closure::bind(function (): void { + $this->waitForShellyRateLimitWindow(); + }, $this, shelly::class); + + $invoke(); + } + + protected function redisFacade(): mixed + { + return $this->redis; + } + + protected function nowTimestamp(): float + { + return $this->clock->now; + } + + protected function sleepMicroseconds(int $microseconds): void + { + if ($microseconds <= 0) { + return; + } + $this->sleepCalls[] = $microseconds; + $this->clock->now += ($microseconds / 1000000); + } +} + +it('enforces the 2 second Shelly gate across separate request contexts', function (): void { + $clock = new GlobalShellyRateLimitClock(); + $redis = new GlobalShellyRateLimitRedisFake($clock); + + $requestA = new GlobalShellyRateLimitHarness($clock, $redis); + $requestB = new GlobalShellyRateLimitHarness($clock, $redis); + + $requestA->waitForShellyRateLimitWindowForTest(); + $timeBeforeRequestB = $clock->now; + $requestB->waitForShellyRateLimitWindowForTest(); + + expect(array_sum($requestA->sleepCalls))->toBe(0); + expect(array_sum($requestB->sleepCalls))->toBeGreaterThanOrEqual(2000000); + expect($clock->now - $timeBeforeRequestB)->toBeGreaterThanOrEqual(2.0); +}); + +it('does not delay when the Shelly gate is already expired', function (): void { + $clock = new GlobalShellyRateLimitClock(); + $redis = new GlobalShellyRateLimitRedisFake($clock); + + $firstRequest = new GlobalShellyRateLimitHarness($clock, $redis); + $firstRequest->waitForShellyRateLimitWindowForTest(); + + $clock->now += 2.1; + + $secondRequest = new GlobalShellyRateLimitHarness($clock, $redis); + $secondRequest->waitForShellyRateLimitWindowForTest(); + + expect(array_sum($secondRequest->sleepCalls))->toBe(0); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitWiringTest.php new file mode 100644 index 00000000..2f64abbc --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitWiringTest.php @@ -0,0 +1,25 @@ +not->toBeFalse(); + expect($shellyClass)->toContain('private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;'); + expect($shellyClass)->toContain("private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';"); + + $sendPostRequestOffset = strpos($shellyClass, 'function sendPostRequest(string $endpoint, array $data): array|object|null'); + expect($sendPostRequestOffset)->not->toBeFalse(); + $sendPostRequestBody = substr($shellyClass, (int)$sendPostRequestOffset, 2200); + + expect($sendPostRequestBody)->toContain('$this->waitForShellyRateLimitWindow();'); +}); + +it('uses Redis NX PX semantics for cross-request Shelly rate limiting', function (): void { + $shellyClass = file_get_contents(app_path('classes/shelly.php')); + + expect($shellyClass)->not->toBeFalse(); + expect($shellyClass)->toContain("self::SHELLY_RATE_LIMIT_GATE_KEY"); + expect($shellyClass)->toContain("'PX'"); + expect($shellyClass)->toContain("'NX'"); + expect($shellyClass)->toContain('pttl(self::SHELLY_RATE_LIMIT_GATE_KEY)'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php new file mode 100644 index 00000000..56bd28b9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php @@ -0,0 +1,92 @@ + $client->sendPostRequest('/v2/devices/api/set/switch', [ + 'id' => 'real-relay', + 'on' => true, + ]))->toThrow(Exception::class, 'Real Shelly requests are blocked in test mode'); + + $entries = shelly::blockedRequestLog(); + expect($entries)->toHaveCount(1) + ->and($entries[0]['method'])->toBe('POST') + ->and($entries[0]['endpoint'])->toBe('/v2/devices/api/set/switch') + ->and($entries[0]['data'])->toMatchArray(['id' => 'real-relay', 'on' => true]); + + $logLines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + expect($logLines)->not->toBeFalse(); + $logged = json_decode((string)$logLines[0], true); + expect($logged)->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/v2/devices/api/set/switch', + ]); + } finally { + shelly::resetBlockedRequestLog(); + @unlink($logPath); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_BLOCK_REAL_SHELLY', $previousBlock); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_SHELLY_GUARD_LOG', $previousLog); + } +}); + +it('blocks and records test-mode Shelly GET requests before cURL can run', function (): void { + $previousBlock = getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY'); + $previousLog = getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG'); + $logPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-shelly-guard-get-' . uniqid('', true) . '.jsonl'; + + try { + putenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1'); + putenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG=' . $logPath); + shelly::resetBlockedRequestLog(); + + $client = new ShellyRealRequestGuardHarness(); + + expect(fn() => $client->sendGetRequest('/device/all_status', [ + 'show_info' => 'true', + ]))->toThrow(Exception::class, 'Real Shelly requests are blocked in test mode'); + + $entries = shelly::blockedRequestLog(); + expect($entries)->toHaveCount(1) + ->and($entries[0]['method'])->toBe('GET') + ->and($entries[0]['endpoint'])->toBe('/device/all_status') + ->and($entries[0]['data'])->toMatchArray(['show_info' => 'true']); + } finally { + shelly::resetBlockedRequestLog(); + @unlink($logPath); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_BLOCK_REAL_SHELLY', $previousBlock); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_SHELLY_GUARD_LOG', $previousLog); + } +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyRelayInventoryTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyRelayInventoryTest.php new file mode 100644 index 00000000..d73db9f9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyRelayInventoryTest.php @@ -0,0 +1,276 @@ +setInventoryFetcher(static function (): array { + return [ + 'isok' => true, + 'data' => [ + 'devices_status' => [ + 'device-key-1' => [ + '_dev_info' => [ + 'id' => 'shelly-plus-01', + 'code' => 'SPSW-001PE16EU', + 'model' => 'Shelly Plus 1PM', + 'online' => 1, + ], + 'name' => 'Entry Gate', + 'wifi_sta' => [ + 'ip' => '10.32.0.11', + ], + 'status' => [ + 'switch:0' => [ + 'output' => false, + 'name' => 'Entrance Relay', + ], + ], + ], + 'device-key-2' => [ + '_dev_info' => [ + 'id' => 'shelly-pro-03', + 'code' => 'SPSW-201XE16EU', + 'model' => 'Shelly Pro 2PM', + ], + 'name' => 'Machine Cabinet', + 'status' => [ + 'eth' => [ + 'ip' => '10.32.0.12', + ], + ], + 'relays' => [ + ['ison' => false, 'name' => 'Program Picker'], + ], + ], + 'device-key-3' => [ + '_dev_info' => [ + 'id' => 'shelly-legacy-02', + 'code' => 'SHSW-1', + 'model' => 'Shelly 1', + 'online' => 0, + ], + 'relays' => [ + ['ison' => false], + ], + ], + 'device-key-4' => [ + '_dev_info' => [ + 'id' => 'shelly-sensor-01', + 'code' => 'SHHT-1', + 'model' => 'Shelly H&T', + 'online' => 1, + ], + 'sensor' => [ + 'temperature' => 21.5, + ], + ], + ], + ], + ]; + }) + ->setDeviceListFetcher(static function (): array { + return [ + 'isok' => true, + 'data' => [ + 'devices' => [ + 'shelly-plus-01' => [ + 'id' => 'shelly-plus-01', + 'name' => 'Gate From Shelly Cloud', + 'type' => 'SPSW-001PE16EU', + 'cloud_online' => true, + ], + 'shelly-pro-03' => [ + 'id' => 'shelly-pro-03', + 'name' => 'Program Picker From Shelly Cloud', + 'type' => 'SPSW-201XE16EU', + 'cloud_online' => false, + ], + ], + ], + ]; + }); + + expect($inventory->listRelayOptions())->toBe([ + [ + 'id' => 'shelly-plus-01', + 'name' => 'Gate From Shelly Cloud (Shelly Plus 1PM)', + 'device_id' => 'shelly-plus-01', + 'device_name' => 'Gate From Shelly Cloud', + 'cloud_name' => 'Gate From Shelly Cloud', + 'device_type' => 'Shelly Plus 1PM', + 'code' => 'SPSW-001PE16EU', + 'device_model' => 'SPSW-001PE16EU', + 'device_generation' => 2, + 'control_type' => 'Switch', + 'control_name' => 'Entrance Relay', + 'local_ip' => '10.32.0.11', + 'status_color' => 'Green', + 'online' => true, + ], + [ + 'id' => 'shelly-pro-03', + 'name' => 'Program Picker From Shelly Cloud (Shelly Pro 2PM)', + 'device_id' => 'shelly-pro-03', + 'device_name' => 'Program Picker From Shelly Cloud', + 'cloud_name' => 'Program Picker From Shelly Cloud', + 'device_type' => 'Shelly Pro 2PM', + 'code' => 'SPSW-201XE16EU', + 'device_model' => 'SPSW-201XE16EU', + 'device_generation' => 2, + 'control_type' => 'Relay', + 'control_name' => 'Program Picker', + 'local_ip' => '10.32.0.12', + 'status_color' => 'Red', + 'online' => false, + ], + [ + 'id' => 'shelly-legacy-02', + 'name' => 'shelly-legacy-02 (Shelly 1)', + 'device_id' => 'shelly-legacy-02', + 'device_name' => null, + 'cloud_name' => null, + 'device_type' => 'Shelly 1', + 'code' => 'SHSW-1', + 'device_model' => 'SHSW-1', + 'device_generation' => 1, + 'control_type' => 'Relay', + 'control_name' => null, + 'local_ip' => null, + 'status_color' => 'Red', + 'online' => false, + ], + ]); +}); + +it('falls back to local device and control names when Shelly cloud list metadata is unavailable', function (): void { + $inventory = (new shelly_relay_inventory()) + ->setInventoryFetcher(static function (): array { + return [ + 'isok' => true, + 'data' => [ + 'devices_status' => [ + 'device-key-1' => [ + '_dev_info' => [ + 'id' => 'shelly-plus-01', + 'code' => 'SPSW-001PE16EU', + 'model' => 'Shelly Plus 1PM', + 'online' => 1, + ], + 'ip' => '10.32.0.21', + 'name' => 'Entry Gate', + 'status' => [ + 'switch:0' => [ + 'output' => false, + 'name' => 'Entrance Relay', + ], + ], + ], + ], + ], + ]; + }) + ->setDeviceListFetcher(static function (): array { + return [ + 'isok' => false, + 'errors' => [ + '404' => 'Requested method was not found', + ], + ]; + }); + + expect($inventory->listRelayOptions())->toBe([ + [ + 'id' => 'shelly-plus-01', + 'name' => 'Entry Gate / Entrance Relay (Shelly Plus 1PM)', + 'device_id' => 'shelly-plus-01', + 'device_name' => 'Entry Gate', + 'cloud_name' => null, + 'device_type' => 'Shelly Plus 1PM', + 'code' => 'SPSW-001PE16EU', + 'device_model' => 'SPSW-001PE16EU', + 'device_generation' => 2, + 'control_type' => 'Switch', + 'control_name' => 'Entrance Relay', + 'local_ip' => '10.32.0.21', + 'status_color' => 'Green', + 'online' => true, + ], + ]); +}); + +it('keeps Shelly 1 Mini Gen3 type, model, and generation aligned with Shelly metadata', function (): void { + $inventory = (new shelly_relay_inventory()) + ->setInventoryFetcher(static function (): array { + return [ + 'isok' => true, + 'data' => [ + 'devices_status' => [ + 'device-key-1' => [ + '_dev_info' => [ + 'id' => 'e4b3231f6410', + 'model' => 'S3SW-001X8EU', + 'type' => 'Shelly 1 Mini Gen3', + 'online' => 1, + ], + 'name' => 'Roskilde indkørselsport', + 'wifi_sta' => [ + 'ip' => '192.168.1.2', + ], + 'status' => [ + 'switch:0' => [ + 'output' => false, + ], + ], + ], + ], + ], + ]; + }) + ->setDeviceListFetcher(static function (): array { + return [ + 'isok' => true, + 'data' => [ + 'devices' => [ + 'e4b3231f6410' => [ + 'id' => 'e4b3231f6410', + 'name' => 'Roskilde indkørselsport', + 'type' => 'S3SW-001X8EU', + 'cloud_online' => true, + ], + ], + ], + ]; + }); + + expect($inventory->listRelayOptions())->toBe([[ + 'id' => 'e4b3231f6410', + 'name' => 'Roskilde indkørselsport (Shelly 1 Mini Gen3)', + 'device_id' => 'e4b3231f6410', + 'device_name' => 'Roskilde indkørselsport', + 'cloud_name' => 'Roskilde indkørselsport', + 'device_type' => 'Shelly 1 Mini Gen3', + 'code' => 'S3SW-001X8EU', + 'device_model' => 'S3SW-001X8EU', + 'device_generation' => 3, + 'control_type' => 'Switch', + 'control_name' => null, + 'local_ip' => '192.168.1.2', + 'status_color' => 'Green', + 'online' => true, + ]]); +}); + +it('fails fast when Shelly inventory does not include owned devices status', function (): void { + $inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array { + return [ + 'isok' => true, + 'data' => [], + ]; + }); + + expect(fn() => $inventory->listRelayOptions()) + ->toThrow(Exception::class, 'Shelly relay inventory response was missing devices_status'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyTransportResolverTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyTransportResolverTest.php new file mode 100644 index 00000000..9b9c531d --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyTransportResolverTest.php @@ -0,0 +1,108 @@ + */ + public array $modesByDepartment = []; + /** @var array */ + public array $calls = []; + + public function __construct() + { + } + + public function getDepartmentTransportMode(int $departmentId): string + { + $this->calls[] = $departmentId; + + return $this->modesByDepartment[$departmentId] ?? self::TRANSPORT_MODE_CLOUD; + } +} + +class ShellyTransportResolverTransportFake implements shelly_transport_i +{ + public function __construct(public readonly string $name) + { + } + + public function requireModuleEnabled(): void + { + } + + public function requireValidSecretKey(): void + { + } + + public function sendPostRequest(string $endpoint, array $data, ?int $department_id = null): array|object|null + { + return [ + 'name' => $this->name, + 'endpoint' => $endpoint, + 'department_id' => $department_id, + ]; + } +} + +it('resolves the injected gateway transport when a department is in gateway mode', function (): void { + $manager = new ShellyTransportResolverManagerFake(); + $manager->modesByDepartment[17] = edge_gateway_manager::TRANSPORT_MODE_GATEWAY; + + $cloudTransport = new ShellyTransportResolverTransportFake('cloud'); + $gatewayTransport = new ShellyTransportResolverTransportFake('gateway'); + + $resolver = new shelly_transport_resolver($manager, $cloudTransport, $gatewayTransport); + + expect($resolver->resolveForDepartment(17))->toBe($gatewayTransport); + expect($manager->calls)->toBe([17]); +}); + +it('resolves the injected cloud transport when a department is in cloud mode', function (): void { + $manager = new ShellyTransportResolverManagerFake(); + $manager->modesByDepartment[22] = edge_gateway_manager::TRANSPORT_MODE_CLOUD; + + $cloudTransport = new ShellyTransportResolverTransportFake('cloud'); + $gatewayTransport = new ShellyTransportResolverTransportFake('gateway'); + + $resolver = new shelly_transport_resolver($manager, $cloudTransport, $gatewayTransport); + + expect($resolver->resolveForDepartment(22))->toBe($cloudTransport); + expect($manager->calls)->toBe([22]); +}); + +it('lets relay tests override the department transport without changing department mode', function (): void { + $manager = new ShellyTransportResolverManagerFake(); + $manager->modesByDepartment[17] = edge_gateway_manager::TRANSPORT_MODE_CLOUD; + + $cloudTransport = new ShellyTransportResolverTransportFake('cloud'); + $gatewayTransport = new ShellyTransportResolverTransportFake('gateway'); + + $resolver = new shelly_transport_resolver($manager, $cloudTransport, $gatewayTransport); + + expect($resolver->resolveForDepartment(17, 'local'))->toBe($gatewayTransport); + expect($resolver->resolveForDepartment(17, 'gateway'))->toBe($gatewayTransport); + expect($resolver->resolveForDepartment(17, 'cloud'))->toBe($cloudTransport); + expect($manager->calls)->toBe([]); +}); + +it('marks non-injected local and gateway overrides as local-only gateway transport', function (): void { + $manager = new ShellyTransportResolverManagerFake(); + $resolver = new shelly_transport_resolver($manager); + + foreach (['local', 'gateway'] as $override) { + $transport = $resolver->resolveForDepartment(17, $override); + $localOnly = new ReflectionProperty($transport, 'localOnly'); + $localOnly->setAccessible(true); + + expect($transport)->toBeInstanceOf(gateway_shelly_transport::class); + expect($localOnly->getValue($transport))->toBeTrue(); + } + expect($manager->calls)->toBe([]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php b/services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php new file mode 100644 index 00000000..3027bc3a --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php @@ -0,0 +1,5 @@ +toBe([]); +}); diff --git a/services/nginx/app/tests/Unit/Subusers/SubuserGrantPermissionsTest.php b/services/nginx/app/tests/Unit/Subusers/SubuserGrantPermissionsTest.php new file mode 100644 index 00000000..198af4cc --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubuserGrantPermissionsTest.php @@ -0,0 +1,25 @@ +toBeString(); + expect(json_decode($encoded, true)) + ->toContain('VEHICLES_LIST') + ->toContain('SELFSERVE_ADD') + ->toContain('BOOKINGS_LIST'); +}); + +it('normalizes stored subuser grant permission payloads', function (): void { + expect(subuser_grants_o::normalizePermissionsValue(0))->toBe([]); + expect(subuser_grants_o::normalizePermissionsValue('0'))->toBe([]); + expect(subuser_grants_o::normalizePermissionsValue('["vehicles_list","BOOKINGS_ADD"]')) + ->toBe(['VEHICLES_LIST', 'BOOKINGS_ADD']); + expect(subuser_grants_o::normalizePermissionsValue([ + 'VEHICLES_LIST' => true, + 'BOOKINGS_DELETE' => false, + 'UNKNOWN_PERMISSION' => true, + ]))->toBe(['VEHICLES_LIST']); +}); diff --git a/services/nginx/app/tests/Unit/Subusers/SubuserPasswordPolicyTest.php b/services/nginx/app/tests/Unit/Subusers/SubuserPasswordPolicyTest.php new file mode 100644 index 00000000..0e723bea --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubuserPasswordPolicyTest.php @@ -0,0 +1,20 @@ +toBeTrue(); +}); + +it('rejects subuser passwords that do not match the shared policy', function (string $password): void { + expect(fn () => subusers_o::assertValidPassword($password)) + ->toThrow(Exception::class, 'Password must be between 8 and 255 characters long'); +})->with([ + 'too short' => ['Tes123'], + 'missing uppercase' => ['test1234'], + 'missing lowercase' => ['TEST1234'], + 'missing number' => ['TestPassword'], + 'too long' => [str_repeat('A', 256) . 'a1'], +]); diff --git a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php new file mode 100644 index 00000000..21fd974f --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php @@ -0,0 +1,97 @@ +toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("\$this->post('/subusers/invite', function () {"); + expect($normalized)->toContain("\$this->post('/subusers/invite/resend', function () {"); + expect($normalized)->toContain("\$this->get('/superuser/subusers', function () {"); + expect($normalized)->toContain("\$this->post('/superuser/subusers/invite', function () {"); + expect($normalized)->toContain("\$this->post('/superuser/subusers/invite/resend', function () {"); + expect($normalized)->toContain("\$this->put('/subusers', function () {"); + expect($normalized)->toContain("\$this->put('/subusers/me', function () {"); +}); + +it('includes grant management fields in the subusers payload builder', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("'grant_id' =>"); + expect($normalized)->toContain("'grant_enabled' =>"); + expect($normalized)->toContain("'grant_note' =>"); + expect($normalized)->toContain("'grant_permissions' =>"); + expect($normalized)->toContain("'setup_required' =>"); + expect($normalized)->toContain("'invite_accepted' =>"); + expect($normalized)->toContain("'can_resend_invite' =>"); + expect($normalized)->toContain("'profile_editable_by_manager' => false"); + expect($normalized)->toContain("'access_state' =>"); +}); + +it('resolves subuser customer names without external lookups during list requests', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain('getCustomerNames($customerNumbers, false)'); + expect($normalized)->toContain('$customerName = $this->resolveCustomerName($customerNumber);'); + expect($normalized)->toContain('buildSubuserManagementPayload($subuser, $customerNumber, $customerName)'); + expect($normalized)->not->toContain("'customer_name' => (new users_o())->getCustomerName(\$customerNumber)"); + expect($normalized)->not->toContain("'name' => (new users_o())->getCustomerName((int)\$grant['billing_customer_number'])"); +}); + +it('links grant disable operations to SUBUSERS_DELETE for own-customer managers', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("definePermission('delete_own_subusers', subusers_permission_node_key::SUBUSERS_DELETE)"); + expect($normalized)->toContain("requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_DELETE, \$targetCustomer);"); +}); + +it('prevents own-customer managers from editing driver-owned account profiles', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("Customers can only manage subuser grants. Drivers own their account profile."); +}); + +it('scopes superuser invite resend to a selected customer grant', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("\$this->post('/superuser/subusers/invite/resend', function () {"); + expect($normalized)->toContain("self::requireParameters(['id', 'customer_number']);"); + expect($normalized)->toContain("\$customerNumber = (int)self::getParameter('customer_number');"); + expect($normalized)->toContain("getGrantForSubuserAndCustomer(\$subuserId, \$customerNumber, true)"); + expect($normalized)->toContain("Subuser grant not found for selected customer"); + expect($normalized)->toContain("'subuser' => \$this->buildSubuserManagementPayload(\$subuser, \$customerNumber)"); + expect($normalized)->toContain("'grant' => \$grant->asArray()"); +}); + +it('only allows invite resend while setup is still pending', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("Driver account already accepted the invitation."); + expect($normalized)->toContain("if (!\$subuser->requiresSetup()) {"); +}); diff --git a/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusOpenApiSpecTest.php b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusOpenApiSpecTest.php new file mode 100644 index 00000000..8ab8ebab --- /dev/null +++ b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusOpenApiSpecTest.php @@ -0,0 +1,43 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +it('documents the superuser system status snapshot endpoint in openapi', function () use ($openApiPath): void { + $content = file_get_contents($openApiPath); + + expect($content)->toContain('/superuser/system/status:'); + expect($content)->toContain('operationId: getSuperuserSystemStatus'); + expect($content)->toContain('SuperuserSystemStatusResponse'); + expect($content)->toContain('Bypass cached external module probes'); +}); + +it('defines the reusable system status schemas and enums', function () use ($openApiPath): void { + $content = file_get_contents($openApiPath); + + expect($content)->toContain('SuperuserSystemStatusPayload:'); + expect($content)->toContain('SuperuserSystemStatusEnum:'); + expect($content)->toContain('enum: [ok, degraded, down]'); + expect($content)->toContain('SuperuserModuleStatusEnum:'); + expect($content)->toContain('enum: [disabled, not_configured, configured, ok, degraded, down]'); + expect($content)->toContain('SuperuserSessionStatus:'); +}); diff --git a/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusRouteWiringTest.php b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusRouteWiringTest.php new file mode 100644 index 00000000..3c3a8fe4 --- /dev/null +++ b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusRouteWiringTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($content)->toContain('/superuser/system/status'); + expect($content)->toContain("requirePermission('superuser_system_status_view')"); + expect($content)->toContain('new superuser_system_status_service()'); + expect($content)->toContain("'force'"); +}); + +it('keeps the legacy database status endpoint wired through the shared snapshot service', function (): void { + $content = file_get_contents(app_path('routes/superuserSystemStatusRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/superuser/system/database/status'); + expect($content)->toContain("dependencies']['database'"); +}); diff --git a/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php new file mode 100644 index 00000000..80994de5 --- /dev/null +++ b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php @@ -0,0 +1,767 @@ + $value, + 'parsed' => $value, + 'type' => $type, + ]; +} + +class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_service +{ + public array $httpProbeCalls = []; + public array $nextHttpProbeResult = [ + 'status' => 'ok', + 'status_reason' => 'Probe connectivity confirmed.', + 'status_reason_key' => 'http_ok', + 'status_reason_params' => ['label' => 'Probe'], + 'checked_at' => '2026-04-08T17:00:00+00:00', + 'latency_ms' => 12.34, + 'http_status' => 200, + ]; + public ?array $moduleConfigRowsOverride = null; + public bool $backupStoreValidationShouldFail = false; + public string $backupStoreFailureMessage = 'Backup bucket is missing.'; + public bool $selfserveBootstrapShouldFail = false; + public string $selfserveBootstrapFailureMessage = 'Schema bootstrap failed.'; + public bool $selfserveMinuteProductExistsValue = true; + public ?int $selfserveMinuteProductCheckedId = null; + public ?string $shellyProbeDeviceId = null; + + public function collectModulesPublic(bool $force, array &$warnings): array + { + return $this->collectModules($force, $warnings); + } + + public function classifyHttpProbeResultPublic(array $httpResponse, string $label): array + { + return $this->classifyHttpProbeResult($httpResponse, $label); + } + + public function evaluateRecaptchaProbeResponsePublic(array $httpResponse, string $label): array + { + return $this->evaluateRecaptchaProbeResponse($httpResponse, $label); + } + + public function normalizeWarningEntriesPublic(array $warnings): array + { + return $this->normalizeWarningEntries($warnings); + } + + public function probeEconomicModulePublic(array $config): array + { + return $this->probeEconomicModule($config); + } + + public function probeRecaptchaModulePublic(array $config): array + { + return $this->probeRecaptchaModule($config); + } + + public function probeEmailModulePublic(array $config): array + { + return $this->probeEmailModule($config); + } + + public function probeBackupsModulePublic(array $config): array + { + return $this->probeBackupsModule($config); + } + + public function probeMotorApiModulePublic(array $config): array + { + return $this->probeMotorApiModule($config); + } + + public function probeFxRatesApiModulePublic(array $config): array + { + return $this->probeFxRatesApiModule($config); + } + + public function probeWeatherApiModulePublic(array $config): array + { + return $this->probeWeatherApiModule($config); + } + + public function probeWorkfeedModulePublic(array $config): array + { + return $this->probeWorkfeedModule($config); + } + + public function probeGatewayApiModulePublic(array $config): array + { + return $this->probeGatewayApiModule($config); + } + + public function probeXlVaskModulePublic(array $config): array + { + return $this->probeXlVaskModule($config); + } + + public function probeLimbleModulePublic(array $config): array + { + return $this->probeLimbleModule($config); + } + + public function probeLicensePlateRecognizerModulePublic(array $config): array + { + return $this->probeLicensePlateRecognizerModule($config); + } + + public function probeShellyModulePublic(array $config): array + { + return $this->probeShellyModule($config); + } + + public function probeSelfserveModulePublic(array $config): array + { + return $this->probeSelfserveModule($config); + } + + public function probeBirdModulePublic(array $config): array + { + return $this->probeBirdModule($config); + } + + protected function performHttpProbe( + string $url, + array $headers, + string $label, + ?string $basicAuth = null, + string $method = 'GET', + ?string $body = null, + ?callable $responseEvaluator = null + ): array { + $this->httpProbeCalls[] = [ + 'url' => $url, + 'headers' => $headers, + 'label' => $label, + 'basic_auth' => $basicAuth, + 'method' => $method, + 'body' => $body, + 'has_evaluator' => $responseEvaluator !== null, + ]; + + return $this->nextHttpProbeResult; + } + + protected function loadModuleConfigRows(array $moduleNames): array + { + if ($this->moduleConfigRowsOverride !== null) { + return $this->moduleConfigRowsOverride; + } + + return parent::loadModuleConfigRows($moduleNames); + } + + protected function validateBackupsStore(): void + { + if ($this->backupStoreValidationShouldFail) { + throw new RuntimeException($this->backupStoreFailureMessage); + } + } + + protected function bootstrapSelfserveSchema(): void + { + if ($this->selfserveBootstrapShouldFail) { + throw new RuntimeException($this->selfserveBootstrapFailureMessage); + } + } + + protected function selfserveMinuteProductExists(int $productId): bool + { + $this->selfserveMinuteProductCheckedId = $productId; + return $this->selfserveMinuteProductExistsValue; + } + + protected function findShellyProbeDeviceId(): ?string + { + return $this->shellyProbeDeviceId; + } +} + +beforeEach(function (): void { + $this->previousEconomicApi = $GLOBALS['ECONOMIC_API'] ?? null; +}); + +afterEach(function (): void { + if ($this->previousEconomicApi === null) { + unset($GLOBALS['ECONOMIC_API']); + return; + } + + $GLOBALS['ECONOMIC_API'] = $this->previousEconomicApi; +}); + +it('reduces overall status using down and degraded precedence', function (): void { + expect(superuser_system_status_service::reduceOverallStatus(['ok', 'configured']))->toBe('ok'); + expect(superuser_system_status_service::reduceOverallStatus(['ok', 'not_configured']))->toBe('degraded'); + expect(superuser_system_status_service::reduceOverallStatus(['configured', 'down']))->toBe('down'); +}); + +it('classifies runtime usage percentages consistently', function (): void { + expect(superuser_system_status_service::statusFromUsagePercent(42.5))->toBe('ok'); + expect(superuser_system_status_service::statusFromUsagePercent(91.0, 90, 99))->toBe('degraded'); + expect(superuser_system_status_service::statusFromUsagePercent(99.1, 90, 99))->toBe('down'); + expect(superuser_system_status_service::statusFromUsagePercent(null))->toBe('down'); +}); + +it('reuses cached module probes only when the ttl is still valid and force is false', function (): void { + $freshCachedProbe = ['checked_at' => date('c', time() - 15)]; + $expiredCachedProbe = ['checked_at' => date('c', time() - 120)]; + + expect(superuser_system_status_service::shouldReuseCachedModuleProbe($freshCachedProbe, false, time(), 60))->toBeTrue(); + expect(superuser_system_status_service::shouldReuseCachedModuleProbe($freshCachedProbe, true, time(), 60))->toBeFalse(); + expect(superuser_system_status_service::shouldReuseCachedModuleProbe($expiredCachedProbe, false, time(), 60))->toBeFalse(); +}); + +it('classifies authenticated http probe responses conservatively', function (int $httpStatus, string $expectedStatus, string $reasonFragment, string $expectedReasonKey): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + + $result = $service->classifyHttpProbeResultPublic([ + 'checked_at' => '2026-04-08T17:10:00+00:00', + 'latency_ms' => 23.5, + 'http_status' => $httpStatus, + 'error' => '', + ], 'Example API'); + + expect($result['status'])->toBe($expectedStatus); + expect($result['status_reason'])->toContain($reasonFragment); + expect($result['status_reason_key'])->toBe($expectedReasonKey); +})->with([ + 'success' => [200, 'ok', 'connectivity confirmed', 'http_ok'], + 'unauthorized' => [401, 'down', 'HTTP 401', 'http_status'], + 'forbidden' => [403, 'down', 'HTTP 403', 'http_status'], + 'rate limited' => [429, 'degraded', 'rate limited', 'http_rate_limited'], + 'server error' => [503, 'degraded', 'HTTP 503', 'http_status'], + 'no response' => [0, 'down', 'did not return an HTTP response', 'http_no_response'], +]); + +it('classifies transport errors as down', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + + $result = $service->classifyHttpProbeResultPublic([ + 'checked_at' => '2026-04-08T17:10:00+00:00', + 'latency_ms' => 7.5, + 'http_status' => 0, + 'error' => 'Connection refused', + ], 'Example API'); + + expect($result['status'])->toBe('down'); + expect($result['status_reason'])->toContain('Connection refused'); + expect($result['status_reason_key'])->toBe('http_probe_failed'); +}); + +it('normalizes and deduplicates warning entries for snapshots', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + + $entries = $service->normalizeWarningEntriesPublic([ + 'Redis is unavailable; module probe caching is bypassed.', + [ + 'key' => 'redis_cache_bypass', + 'params' => [], + 'message' => 'Redis is unavailable; module probe caching is bypassed.', + ], + [ + 'key' => 'modules_without_probes', + 'params' => ['modules' => 'ocrspace, virkdata', 'module_keys' => ['ocrspace', 'virkdata']], + 'message' => 'Some modules expose configuration-only status because no safe read-only probe exists: ocrspace, virkdata.', + ], + [ + 'key' => 'modules_without_probes', + 'params' => ['modules' => 'ocrspace, virkdata', 'module_keys' => ['ocrspace', 'virkdata']], + 'message' => 'Some modules expose configuration-only status because no safe read-only probe exists: ocrspace, virkdata.', + ], + ]); + + expect($entries)->toHaveCount(3); + expect($entries[0])->toBe([ + 'key' => null, + 'params' => [], + 'message' => 'Redis is unavailable; module probe caching is bypassed.', + ]); + expect($entries[1]['key'])->toBe('redis_cache_bypass'); + expect($entries[2]['params']['module_keys'])->toBe(['ocrspace', 'virkdata']); +}); + +it('interprets recaptcha probe payloads safely', function (array $payload, string $expectedStatus, string $reasonFragment, string $expectedReasonKey): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + + $result = $service->evaluateRecaptchaProbeResponsePublic([ + 'checked_at' => '2026-04-08T17:10:00+00:00', + 'latency_ms' => 11.4, + 'http_status' => 200, + 'body' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'error' => '', + ], 'reCAPTCHA'); + + expect($result['status'])->toBe($expectedStatus); + expect($result['status_reason'])->toContain($reasonFragment); + expect($result['status_reason_key'])->toBe($expectedReasonKey); +})->with([ + 'dummy token rejected but credentials valid' => [ + ['success' => false, 'error-codes' => ['invalid-input-response']], + 'ok', + 'connectivity confirmed', + 'http_ok', + ], + 'invalid secret is down' => [ + ['success' => false, 'error-codes' => ['invalid-input-secret']], + 'down', + 'credentials were rejected', + 'recaptcha_credentials_rejected', + ], + 'unexpected validation errors degrade' => [ + ['success' => false, 'error-codes' => ['bad-request']], + 'degraded', + 'unexpected validation errors', + 'recaptcha_validation_errors', + ], +]); + +it('builds the expected http probe requests for newly supported modules', function ( + string $methodName, + array $config, + string $expectedUrl, + array $expectedHeaders, + ?string $expectedBasicAuth, + string $expectedMethod, + ?string $expectedBodyFragment, + bool $expectsEvaluator +): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + + $service->{$methodName}($config); + + expect($service->httpProbeCalls)->toHaveCount(1); + $call = $service->httpProbeCalls[0]; + + expect($call['url'])->toBe($expectedUrl); + expect($call['basic_auth'])->toBe($expectedBasicAuth); + expect($call['method'])->toBe($expectedMethod); + expect($call['has_evaluator'])->toBe($expectsEvaluator); + + foreach ($expectedHeaders as $expectedHeader) { + expect($call['headers'])->toContain($expectedHeader); + } + + if ($expectedBodyFragment !== null) { + expect((string)$call['body'])->toContain($expectedBodyFragment); + } else { + expect($call['body'])->toBeNull(); + } +})->with([ + 'recaptcha' => [ + 'probeRecaptchaModulePublic', + ['secret_key_v2' => system_status_module_value('recaptcha-secret')], + 'https://www.google.com/recaptcha/api/siteverify', + ['Content-Type: application/x-www-form-urlencoded'], + null, + 'POST', + 'secret=recaptcha-secret', + true, + ], + 'email' => [ + 'probeEmailModulePublic', + ['mailersend_api_key' => system_status_module_value('mailersend-key')], + 'https://api.mailersend.com/v1/api-quota', + ['Authorization: Bearer mailersend-key', 'Accept: application/json'], + null, + 'GET', + null, + false, + ], + 'motorapi' => [ + 'probeMotorApiModulePublic', + ['secret_key' => system_status_module_value('motorapi-key')], + 'https://v1.motorapi.dk/usage', + ['X-AUTH-TOKEN: motorapi-key'], + null, + 'GET', + null, + false, + ], + 'fxratesapi' => [ + 'probeFxRatesApiModulePublic', + ['secret_key' => system_status_module_value('fxrates-key')], + 'https://api.fxratesapi.com/latest?base=EUR¤cies=DKK', + ['X-AUTH-TOKEN: fxrates-key'], + null, + 'GET', + null, + false, + ], + 'weatherapi' => [ + 'probeWeatherApiModulePublic', + ['secret_key' => system_status_module_value('weather-key')], + 'https://api.weatherapi.com/v1/current.json?key=weather-key&q=Copenhagen', + ['Accept: application/json'], + null, + 'GET', + null, + false, + ], + 'workfeed' => [ + 'probeWorkfeedModulePublic', + [ + 'api_url' => system_status_module_value('https://api.workfeed.test'), + 'CompanyID' => system_status_module_value('company-123'), + 'api_key' => system_status_module_value('workfeed-token'), + ], + 'https://api.workfeed.test/companies/company-123/departments', + ['Accept: application/json', 'Authorization: workfeed-token'], + null, + 'GET', + null, + false, + ], + 'gatewayapi' => [ + 'probeGatewayApiModulePublic', + ['api_token' => system_status_module_value('gateway-token')], + 'https://gatewayapi.eu/rest/me', + ['Authorization: Token gateway-token', 'Accept: application/json'], + null, + 'GET', + null, + false, + ], + 'xlvask' => [ + 'probeXlVaskModulePublic', + [ + 'username' => system_status_module_value('xl-user'), + 'password' => system_status_module_value('xl-pass'), + ], + 'https://api.xlwash.com/customers', + ['Accept: application/json'], + 'xl-user:xl-pass', + 'GET', + null, + false, + ], + 'limble' => [ + 'probeLimbleModulePublic', + [ + 'client_id' => system_status_module_value('limble-id'), + 'client_secret' => system_status_module_value('limble-secret'), + ], + 'https://api.limblecmms.com:443/v2/tasks?limit=1&page=1', + ['Accept: application/json', 'Content-Type: application/json'], + 'limble-id:limble-secret', + 'GET', + null, + false, + ], + 'license plate recognizer' => [ + 'probeLicensePlateRecognizerModulePublic', + ['api_key' => system_status_module_value('lpr-key')], + 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk/info/', + ['Authorization: Token lpr-key', 'Accept: application/json'], + null, + 'GET', + null, + false, + ], + 'bird' => [ + 'probeBirdModulePublic', + [ + 'server_url' => system_status_module_value('https://api.bird.com'), + 'api_key' => system_status_module_value('bird-key'), + 'channelId' => system_status_module_value('channel-123'), + 'workplaceId' => system_status_module_value('workspace-456'), + ], + 'https://api.bird.com/workspaces/workspace-456/channels/channel-123/calls?limit=1', + ['Authorization: AccessKey bird-key', 'Accept: application/json'], + null, + 'GET', + null, + false, + ], +]); + +it('uses runtime economic credentials for the economic probe', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + $GLOBALS['ECONOMIC_API'] = [ + 'app_secret_token' => 'economic-secret', + 'app_access_grant' => 'economic-grant', + ]; + + $service->probeEconomicModulePublic([]); + + expect($service->httpProbeCalls)->toHaveCount(1); + $call = $service->httpProbeCalls[0]; + + expect($call['url'])->toBe('https://restapi.e-conomic.com/layouts/'); + expect($call['headers'])->toContain('X-AppSecretToken: economic-secret'); + expect($call['headers'])->toContain('X-AgreementGrantToken: economic-grant'); +}); + +it('returns down without attempting economic http calls when runtime credentials are missing', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + unset($GLOBALS['ECONOMIC_API']); + + $result = $service->probeEconomicModulePublic([]); + + expect($result['status'])->toBe('down'); + expect($result['status_reason'])->toContain('credentials are missing'); + expect($result['status_reason_key'])->toBe('economic_credentials_missing'); + expect($service->httpProbeCalls)->toBeEmpty(); +}); + +it('reports backup probe failures from local validation', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + $service->backupStoreValidationShouldFail = true; + $service->backupStoreFailureMessage = 'Backups bucket is unavailable.'; + + $result = $service->probeBackupsModulePublic([]); + + expect($result['status'])->toBe('down'); + expect($result['status_reason'])->toContain('Backups bucket is unavailable'); + expect($result['status_reason_key'])->toBe('backup_probe_failed'); +}); + +it('returns configured for shelly when no known device id is available for probing', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + + $result = $service->probeShellyModulePublic([ + 'server_url' => system_status_module_value('https://shelly.example'), + 'secret_key' => system_status_module_value('shelly-secret'), + ]); + + expect($result['status'])->toBe('configured'); + expect($result['status_reason'])->toContain('no known device id'); + expect($result['status_reason_key'])->toBe('shelly_no_device_id'); + expect($service->httpProbeCalls)->toBeEmpty(); +}); + +it('builds an authenticated shelly status probe when a known device id exists', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + $service->shellyProbeDeviceId = 'device-123'; + + $service->probeShellyModulePublic([ + 'server_url' => system_status_module_value('https://shelly.example'), + 'secret_key' => system_status_module_value('shelly-secret'), + ]); + + expect($service->httpProbeCalls)->toHaveCount(1); + $call = $service->httpProbeCalls[0]; + expect($call['url'])->toBe('https://shelly.example/device/status?id=device-123&auth_key=shelly-secret'); + expect($call['headers'])->toContain('Accept: application/json'); + expect($call['method'])->toBe('GET'); +}); + +it('validates selfserve schema and minute product configuration', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + + $result = $service->probeSelfserveModulePublic([ + 'machine_wash_minutes_included' => system_status_module_value('15'), + 'minute_product' => system_status_module_value('77'), + ]); + + expect($result['status'])->toBe('ok'); + expect($service->selfserveMinuteProductCheckedId)->toBe(77); +}); + +it('reports invalid selfserve minute configuration before touching the schema', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + + $result = $service->probeSelfserveModulePublic([ + 'machine_wash_minutes_included' => system_status_module_value('abc'), + 'minute_product' => system_status_module_value('77'), + ]); + + expect($result['status'])->toBe('down'); + expect($result['status_reason'])->toContain('minutes configuration is invalid'); + expect($result['status_reason_key'])->toBe('selfserve_minutes_invalid'); + expect($service->selfserveMinuteProductCheckedId)->toBeNull(); +}); + +it('reports missing selfserve minute products', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + $service->selfserveMinuteProductExistsValue = false; + + $result = $service->probeSelfserveModulePublic([ + 'machine_wash_minutes_included' => system_status_module_value('15'), + 'minute_product' => system_status_module_value('88'), + ]); + + expect($result['status'])->toBe('down'); + expect($result['status_reason'])->toContain('does not exist'); + expect($result['status_reason_key'])->toBe('selfserve_minute_product_missing'); + expect($result['status_reason_params'])->toBe(['productId' => 88]); + expect($service->selfserveMinuteProductCheckedId)->toBe(88); +}); + +it('surfaces selfserve bootstrap failures', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + $service->selfserveBootstrapShouldFail = true; + $service->selfserveBootstrapFailureMessage = 'Migration failed.'; + + $result = $service->probeSelfserveModulePublic([ + 'machine_wash_minutes_included' => system_status_module_value('15'), + 'minute_product' => system_status_module_value('88'), + ]); + + expect($result['status'])->toBe('down'); + expect($result['status_reason'])->toContain('Migration failed'); + expect($result['status_reason_key'])->toBe('selfserve_probe_failed'); +}); + +it('adds localization metadata for disabled and missing-config modules', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + $service->moduleConfigRowsOverride = [ + 'reCAPTCHA' => [ + 'enabled' => system_status_module_value(false, 'bool'), + ], + 'Email' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'mailersend_enabled' => system_status_module_value(false, 'bool'), + ], + ]; + + $warnings = []; + $modules = $service->collectModulesPublic(false, $warnings); + $moduleMap = []; + foreach ($modules as $module) { + $moduleMap[$module['key']] = $module; + } + + expect($moduleMap['reCAPTCHA']['status'])->toBe('disabled'); + expect($moduleMap['reCAPTCHA']['status_reason_key'])->toBe('module_disabled'); + expect($moduleMap['email']['status'])->toBe('not_configured'); + expect($moduleMap['email']['status_reason_key'])->toBe('email_delivery_not_implemented'); + expect($moduleMap['email']['status_reason_params'])->toBe([]); + expect($warnings)->toBeEmpty(); +}); + +it('marks newly supported modules as probe backed and leaves only truly unsupported modules as configuration only', function (): void { + $service = new SuperuserSystemStatusServiceProbeDouble(); + $GLOBALS['ECONOMIC_API'] = [ + 'app_secret_token' => 'economic-secret', + 'app_access_grant' => 'economic-grant', + ]; + + $service->moduleConfigRowsOverride = [ + 'economic' => [ + 'invoiceLayoutNumber' => system_status_module_value('1'), + 'paymentTermsNumber' => system_status_module_value('2'), + 'adminFeeMonthly' => system_status_module_value('3'), + 'adminFeeOrder' => system_status_module_value('4'), + 'feeProductId' => system_status_module_value('5'), + ], + 'Email' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'mailersend_enabled' => system_status_module_value(true, 'bool'), + 'mailersend_api_key' => system_status_module_value('mailersend-key'), + 'smtp_from' => system_status_module_value('from@example.com'), + 'smtp_from_name' => system_status_module_value('Truck Wash'), + 'smtp_reply_to' => system_status_module_value('reply@example.com'), + 'smtp_reply_to_name' => system_status_module_value('Reply'), + ], + 'Backups' => [ + 'enabled' => system_status_module_value(true, 'bool'), + ], + 'motorapi' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'secret_key' => system_status_module_value('motorapi-key'), + ], + 'fxratesapi' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'secret_key' => system_status_module_value('fxrates-key'), + ], + 'weatherapi' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'secret_key' => system_status_module_value('weather-key'), + ], + 'workfeed' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'api_url' => system_status_module_value('https://api.workfeed.test'), + 'api_key' => system_status_module_value('workfeed-token'), + 'CompanyID' => system_status_module_value('company-123'), + ], + 'GatewayAPI' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'api_secret' => system_status_module_value('gateway-secret'), + 'api_token' => system_status_module_value('gateway-token'), + 'sender' => system_status_module_value('TruckWash'), + ], + 'xlvask' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'username' => system_status_module_value('xl-user'), + 'password' => system_status_module_value('xl-pass'), + ], + 'limble' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'client_id' => system_status_module_value('limble-id'), + 'client_secret' => system_status_module_value('limble-secret'), + ], + 'licenseplaterecognizer' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'api_key' => system_status_module_value('lpr-key'), + ], + 'shelly' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'server_url' => system_status_module_value('https://shelly.example'), + 'secret_key' => system_status_module_value('shelly-secret'), + ], + 'selfserve' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'machine_wash_minutes_included' => system_status_module_value('15'), + 'minute_product' => system_status_module_value('88'), + ], + 'bird' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'server_url' => system_status_module_value('https://api.bird.com'), + 'api_key' => system_status_module_value('bird-key'), + 'channelId' => system_status_module_value('channel-123'), + 'workplaceId' => system_status_module_value('workspace-456'), + ], + 'ocrSpace' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'api_key' => system_status_module_value('ocr-key'), + ], + 'virkdata' => [ + 'enabled' => system_status_module_value(true, 'bool'), + 'secret_key' => system_status_module_value('virk-key'), + ], + ]; + + $warnings = []; + $modules = $service->collectModulesPublic(false, $warnings); + $moduleMap = []; + foreach ($modules as $module) { + $moduleMap[$module['key']] = $module; + } + + expect($moduleMap['email']['probe_supported'])->toBeTrue(); + expect($moduleMap['email']['status'])->toBe('ok'); + expect($moduleMap['email']['status_reason_key'])->toBe('http_ok'); + expect($moduleMap['backups']['probe_supported'])->toBeTrue(); + expect($moduleMap['backups']['status'])->toBe('ok'); + expect($moduleMap['backups']['status_reason_key'])->toBe('backup_connectivity_confirmed'); + expect($moduleMap['motorapi']['probe_supported'])->toBeTrue(); + expect($moduleMap['motorapi']['status'])->toBe('ok'); + expect($moduleMap['bird']['probe_supported'])->toBeTrue(); + expect($moduleMap['bird']['status'])->toBe('ok'); + expect($moduleMap['shelly']['probe_supported'])->toBeTrue(); + expect($moduleMap['shelly']['status'])->toBe('configured'); + expect($moduleMap['shelly']['status_reason_key'])->toBe('shelly_no_device_id'); + expect($moduleMap['selfserve']['probe_supported'])->toBeTrue(); + expect($moduleMap['selfserve']['status'])->toBe('ok'); + expect($moduleMap['selfserve']['status_reason_key'])->toBe('selfserve_configuration_confirmed'); + expect($moduleMap['ocrspace']['probe_supported'])->toBeFalse(); + expect($moduleMap['ocrspace']['status'])->toBe('configured'); + expect($moduleMap['ocrspace']['status_reason_key'])->toBe('safe_probe_unavailable'); + expect($moduleMap['virkdata']['probe_supported'])->toBeFalse(); + expect($moduleMap['virkdata']['status'])->toBe('configured'); + expect($moduleMap['virkdata']['status_reason_key'])->toBe('safe_probe_unavailable'); + expect($warnings)->toHaveCount(1); + expect($warnings[0]['key'])->toBe('modules_without_probes'); + expect($warnings[0]['params']['module_keys'])->toBe(['ocrspace', 'virkdata']); + expect($warnings[0]['message'])->toContain('ocrspace, virkdata'); +}); diff --git a/services/nginx/app/tests/Unit/SystemStatus/SystemSessionActivityTrackerTest.php b/services/nginx/app/tests/Unit/SystemStatus/SystemSessionActivityTrackerTest.php new file mode 100644 index 00000000..8e35cc79 --- /dev/null +++ b/services/nginx/app/tests/Unit/SystemStatus/SystemSessionActivityTrackerTest.php @@ -0,0 +1,36 @@ +toBe('desktop'); + expect(system_session_activity_tracker::detectDeviceType('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)'))->toBe('mobile'); + expect(system_session_activity_tracker::detectDeviceType('Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)'))->toBe('tablet'); + expect(system_session_activity_tracker::detectDeviceType('curl/8.4.0'))->toBe('bot'); +}); + +it('treats recent sessions as active within the configured activity window', function (): void { + $now = time(); + + expect(system_session_activity_tracker::isActive(date('Y-m-d H:i:s', $now - 60), 15, $now))->toBeTrue(); + expect(system_session_activity_tracker::isActive(date('Y-m-d H:i:s', $now - 1200), 15, $now))->toBeFalse(); + expect(system_session_activity_tracker::isActive(null, 15, $now))->toBeFalse(); +}); + +it('converts database utc datetimes into timezone-aware iso strings', function (): void { + expect(system_session_activity_tracker::databaseDateTimeToIso8601('2026-04-08 17:39:39')) + ->toBe('2026-04-08T17:39:39+00:00'); + + expect(system_session_activity_tracker::databaseDateTimeToIso8601(null))->toBeNull(); + expect(system_session_activity_tracker::databaseDateTimeToIso8601(''))->toBeNull(); +}); + +it('treats timezone-aware iso timestamps as active using absolute time', function (): void { + $referenceTimestamp = strtotime('2026-04-08T19:45:00+02:00'); + + expect(system_session_activity_tracker::isActive('2026-04-08T17:39:39+00:00', 15, $referenceTimestamp))->toBeTrue(); + expect(system_session_activity_tracker::isActive('2026-04-08T17:20:00+00:00', 15, $referenceTimestamp))->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php b/services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php new file mode 100644 index 00000000..3362bad2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php @@ -0,0 +1,20 @@ +markTestSkipped('Docker entrypoint is only available inside the PHP container.'); + } + + $entrypoint = (string)file_get_contents($entrypointPath); + + expect($entrypoint)->toContain('http_message_sanity_ok') + ->and($entrypoint)->toContain('composer_lock_has_package "$dir" "psr/http-message"') + ->and($entrypoint)->toContain('UriInterface.php') + ->and($entrypoint)->toContain('StreamInterface.php') + ->and($entrypoint)->toContain('interface_exists("Psr\\\\Http\\\\Message\\\\UriInterface")') + ->and($entrypoint)->toContain('interface_exists("Psr\\\\Http\\\\Message\\\\StreamInterface")') + ->and($entrypoint)->toContain('if ! http_message_sanity_ok "$dir"; then'); +}); diff --git a/services/nginx/app/tests/Unit/Tooling/LegacyTestInventoryTest.php b/services/nginx/app/tests/Unit/Tooling/LegacyTestInventoryTest.php new file mode 100644 index 00000000..3dd6ef92 --- /dev/null +++ b/services/nginx/app/tests/Unit/Tooling/LegacyTestInventoryTest.php @@ -0,0 +1,38 @@ + str_replace('\\', '/', $entry['path']), + $manifest + ); + sort($manifestPaths); + + $testsRoot = app_path('tests'); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($testsRoot, FilesystemIterator::SKIP_DOTS) + ); + + $actual = []; + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || !$file->isFile()) { + continue; + } + + if (!str_ends_with($file->getFilename(), 'Test.php')) { + continue; + } + + $relative = str_replace('\\', '/', substr($file->getPathname(), strlen(app_path()) + 1)); + if (preg_match('#^tests/(Unit|Integration|Api|Legacy)/#', $relative) === 1) { + continue; + } + + $actual[] = $relative; + } + sort($actual); + + expect($actual)->toBe($manifestPaths); +}); diff --git a/services/nginx/app/tests/Unit/Tooling/MySqlSchemaCompatibilityTest.php b/services/nginx/app/tests/Unit/Tooling/MySqlSchemaCompatibilityTest.php new file mode 100644 index 00000000..0146aede --- /dev/null +++ b/services/nginx/app/tests/Unit/Tooling/MySqlSchemaCompatibilityTest.php @@ -0,0 +1,50 @@ +isFile() || $file->getExtension() !== 'php') { + continue; + } + + $path = str_replace('\\', '/', $file->getPathname()); + if (str_contains($path, '/vendor/')) { + continue; + } + + $contents = file_get_contents($file->getPathname()); + if ($contents !== false && preg_match($unsupportedPattern, $contents) === 1) { + $violations[] = str_replace('\\', '/', substr($file->getPathname(), strlen(app_path()) + 1)); + } + } + } + + sort($violations); + + expect($violations)->toBe( + [], + 'Avoid MariaDB-only schema syntax in CI-runner MySQL bootstraps: ' . implode(', ', $violations) + ); +}); diff --git a/services/nginx/app/tests/Unit/Users/EconomicCustomerModelParsingTest.php b/services/nginx/app/tests/Unit/Users/EconomicCustomerModelParsingTest.php new file mode 100644 index 00000000..3b5a0587 --- /dev/null +++ b/services/nginx/app/tests/Unit/Users/EconomicCustomerModelParsingTest.php @@ -0,0 +1,47 @@ +parseCustomer((object)[ + 'customer_number' => '42331123', + 'name' => 'Truckwash ApS', + 'email' => 'jb@truckwash.dk', + 'currency' => 'DKK', + 'country' => 'DK', + 'barred' => true, + ]); + + expect($model->asArray())->toBe([ + 'customerNumber' => 42331123, + 'name' => 'Truckwash ApS', + 'address' => null, + 'city' => null, + 'zip' => null, + 'corporateIdentificationNumber' => null, + 'email' => 'jb@truckwash.dk', + 'mobilePhone' => null, + 'currency' => 'DKK', + 'country' => 'DK', + 'barred' => true, + ]); +}); + +it('leaves the economic customer model empty when no valid customer number is present', function (): void { + $model = (new EconomicCustomerModelParsingProbe())->parseCustomer((object)[ + 'name' => 'Missing Number', + ]); + + expect($model->asArray())->toBe([]); +}); diff --git a/services/nginx/app/tests/Unit/Users/EconomicCustomerZeroHandlingTest.php b/services/nginx/app/tests/Unit/Users/EconomicCustomerZeroHandlingTest.php new file mode 100644 index 00000000..3ab39c0d --- /dev/null +++ b/services/nginx/app/tests/Unit/Users/EconomicCustomerZeroHandlingTest.php @@ -0,0 +1,21 @@ +not->toBeFalse(); + expect($content)->toContain('$customer_number = (int)($customer_number ?? $this->customer_number->value());'); + expect($content)->toContain('if ($customer_number <= 0) {'); + expect($content)->toContain('$this->economic_customer = new economic_customer_mo();'); + expect($content)->toContain('return $this;'); +}); + +it('short-circuits e-conomic customer lookup for non-positive customer numbers', function (): void { + $customersFile = app_path('modules/economic/customers/economicCustomers.php'); + $content = file_get_contents($customersFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('if ($customerNumber <= 0) {'); + expect($content)->toContain('return false;'); +}); diff --git a/services/nginx/app/tests/Unit/Users/UsersAutomaticGetTargetUserFromRequestTest.php b/services/nginx/app/tests/Unit/Users/UsersAutomaticGetTargetUserFromRequestTest.php new file mode 100644 index 00000000..7f9e6664 --- /dev/null +++ b/services/nginx/app/tests/Unit/Users/UsersAutomaticGetTargetUserFromRequestTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + expect($content)->toContain('$user_id = $this->parsePositiveIntFromRequest($data[\'user_id\'] ?? null);'); + expect($content)->toContain('$customer_number = $this->parsePositiveIntFromRequest($data[\'customer_number\'] ?? null);'); + expect($content)->toContain('return $this->getUserByCustomerNumber($customer_number);'); + expect($content)->not->toContain('return $this->getUserByCustomerNumber($data[\'customer_number\']);'); +}); + +it('rejects non-positive and non-digit request values', function (): void { + $usersFile = app_path('objects/users_o.php'); + $content = file_get_contents($usersFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('private function parsePositiveIntFromRequest(mixed $value): ?int'); + expect($content)->toContain('$value = trim($value);'); + expect($content)->toContain('!ctype_digit($value)'); + expect($content)->toContain('return $parsed > 0 ? $parsed : null;'); +}); diff --git a/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php b/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php new file mode 100644 index 00000000..1adb56bc --- /dev/null +++ b/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php @@ -0,0 +1,74 @@ + 'Truckwash ApS'], + 'Fallback Name' + ))->toBe(['name' => 'Truckwash ApS']); + + expect(customer_name_cache_payload_builder::build( + '{"customer":{"name":"Nested Truckwash ApS"}}', + 'Fallback Name' + ))->toBe(['name' => 'Nested Truckwash ApS']); + + expect(customer_name_cache_payload_builder::build( + ['customer_name' => 'Array Truckwash ApS'], + 'Fallback Name' + ))->toBe(['name' => 'Array Truckwash ApS']); + + expect(customer_name_cache_payload_builder::build( + null, + 'Fallback Name' + ))->toBe(['name' => 'Fallback Name']); + + expect(customer_name_cache_payload_builder::build( + (object)['name' => ' '], + null + ))->toBeNull(); +}); + +it('guards bulk customer-name cache writes behind a resolved payload check', function (): void { + $usersFile = app_path('objects/users_o.php'); + $content = file_get_contents($usersFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('use classes\customer_name_cache_payload_builder;'); + expect($content)->toContain('use classes\system_search_economic_customer_index;'); + expect($content)->toContain('return customer_name_cache_payload_builder::build($cached_name, $fallback_name);'); + expect($content)->toContain('$cache_payload = self::buildCustomerNameCachePayload($cached_name, $fallback_name);'); + expect($content)->toContain('if ($cache_payload !== null) {'); + expect($content)->toContain("\$this->cache('economic_customer_name', \$cache_payload, \$customer_number);"); +}); + +it('allows callers to resolve customer names without e-conomic fallback', function (): void { + $usersFile = app_path('objects/users_o.php'); + $content = file_get_contents($usersFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('public function getCustomerNames(array $customer_numbers, bool $allowExternalFetch = true): array'); + expect($content)->toContain('if (!$allowExternalFetch) {'); + expect($content)->toContain('$local_cached_names = $this->getCachedEconomicCustomerNamesByCustomerNumber($customer_numbers_to_fetch);'); + expect($content)->toContain("\$customer_names[(string)\$customer_number] = \$local_cached_names[\$customer_number] ?? \$fallback_names[\$customer_number] ?? 'Unknown Customer';"); + expect($content)->toContain('private function getLocalDisplayNamesByCustomerNumber(array $customer_numbers): array'); + expect($content)->toContain('private function getCachedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array'); + expect($content)->toContain("\$cached_names = \$this->getCachedForMultipleObjects('economic_customer', array_values(\$user_ids_by_customer_number));"); + expect($content)->toContain('system_search_economic_customer_index::TABLE'); +}); + +it('returns an empty customer name map without touching cache for empty input', function (): void { + $users = new users_o(); + + expect($users->getCustomerNames([]))->toBe([]); +}); + +it('returns no rows for empty array field filters', function (): void { + $users = new users_o(); + + expect($users->getFieldsWhere(['id' => []], ['customer_number']))->toBe([]); +}); diff --git a/services/nginx/app/tests/Unit/Users/UsersRedisNamespaceSafetyTest.php b/services/nginx/app/tests/Unit/Users/UsersRedisNamespaceSafetyTest.php new file mode 100644 index 00000000..7d4d5012 --- /dev/null +++ b/services/nginx/app/tests/Unit/Users/UsersRedisNamespaceSafetyTest.php @@ -0,0 +1,9 @@ +not->toContain('redis->') + ->and($content)->toContain("constant('redis')"); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheHelpersTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheHelpersTest.php new file mode 100644 index 00000000..ecd54ee2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheHelpersTest.php @@ -0,0 +1,156 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +beforeEach(function (): void { + $_SERVER['REQUEST_URI'] = '/departments/weather'; + $this->oldTtl = getenv('DEPARTMENTS_WEATHER_CACHE_TTL'); + $this->oldStaleTtl = getenv('DEPARTMENTS_WEATHER_STALE_TTL'); + $this->oldHotTtl = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); +}); + +afterEach(function (): void { + if ($this->oldTtl === false) { + putenv('DEPARTMENTS_WEATHER_CACHE_TTL'); + } else { + putenv('DEPARTMENTS_WEATHER_CACHE_TTL=' . $this->oldTtl); + } + + if ($this->oldStaleTtl === false) { + putenv('DEPARTMENTS_WEATHER_STALE_TTL'); + } else { + putenv('DEPARTMENTS_WEATHER_STALE_TTL=' . $this->oldStaleTtl); + } + + if ($this->oldHotTtl === false) { + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); + } else { + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=' . $this->oldHotTtl); + } +}); + +it('uses sane ttl defaults and clamps negative ttl to zero', function (): void { + $route = new moduleWeatherAPIRoute(); + + putenv('DEPARTMENTS_WEATHER_CACHE_TTL'); + expect(weather_cache_invoke_private($route, 'getDepartmentWeatherCacheTtl'))->toBe(60); + + putenv('DEPARTMENTS_WEATHER_CACHE_TTL=45'); + expect(weather_cache_invoke_private($route, 'getDepartmentWeatherCacheTtl'))->toBe(45); + + putenv('DEPARTMENTS_WEATHER_CACHE_TTL=-10'); + expect(weather_cache_invoke_private($route, 'getDepartmentWeatherCacheTtl'))->toBe(0); +}); + +it('uses sane stale ttl defaults and clamps stale ttl below fresh ttl', function (): void { + $route = new moduleWeatherAPIRoute(); + + putenv('DEPARTMENTS_WEATHER_STALE_TTL'); + expect(weather_cache_invoke_private($route, 'getDepartmentWeatherStaleCacheTtl', [60]))->toBe(300); + + putenv('DEPARTMENTS_WEATHER_STALE_TTL=10'); + expect(weather_cache_invoke_private($route, 'getDepartmentWeatherStaleCacheTtl', [60]))->toBe(60); + + putenv('DEPARTMENTS_WEATHER_STALE_TTL=900'); + expect(weather_cache_invoke_private($route, 'getDepartmentWeatherStaleCacheTtl', [60]))->toBe(900); +}); + +it('uses sane hot activity ttl defaults and clamps to a positive value', function (): void { + $route = new moduleWeatherAPIRoute(); + + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); + expect(weather_cache_invoke_private($route, 'getDepartmentWeatherHotActivityTtl'))->toBe(900); + + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=-5'); + expect(weather_cache_invoke_private($route, 'getDepartmentWeatherHotActivityTtl'))->toBe(1); +}); + +it('builds deterministic cache keys for department sets and timeline ranges', function (): void { + $route = new moduleWeatherAPIRoute(); + $range = [ + 'start' => new DateTime('2026-03-24 00:00:00'), + 'endExclusive' => new DateTime('2026-03-25 00:00:00'), + ]; + $differentRange = [ + 'start' => new DateTime('2026-03-24 00:00:00'), + 'endExclusive' => new DateTime('2026-03-26 00:00:00'), + ]; + + $keyA = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]); + $keyB = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]); + $keyC = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $differentRange]); + + expect($keyA)->toBe($keyB); + expect($keyA)->not->toBe($keyC); + expect($keyA)->toStartWith('departments_weather:timeline:v2:'); +}); + +it('builds order-insensitive cache keys for equivalent department id sets', function (): void { + $route = new moduleWeatherAPIRoute(); + $range = [ + 'start' => new DateTime('2026-03-24 00:00:00'), + 'endExclusive' => new DateTime('2026-03-25 00:00:00'), + ]; + + $ordered = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]); + $shuffled = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[5, 1, 3], $range]); + + expect($ordered)->toBe($shuffled); +}); + +it('changes weather timeline cache key when department weather targets change', function (): void { + $route = new moduleWeatherAPIRoute(); + $range = [ + 'start' => new DateTime('2026-03-24 00:00:00'), + 'endExclusive' => new DateTime('2026-03-25 00:00:00'), + ]; + + $baseTargets = [ + 1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + 3 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + ]; + $updatedTargets = [ + 1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.4], + 3 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + ]; + + $baseKey = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3], $range, $baseTargets]); + $sameKey = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[3, 1], $range, $baseTargets]); + $updatedKey = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3], $range, $updatedTargets]); + + expect($baseKey)->toBe($sameKey); + expect($baseKey)->not->toBe($updatedKey); +}); + +it('falls back to resolver directly when cache ttl is disabled', function (): void { + $route = new moduleWeatherAPIRoute(); + putenv('DEPARTMENTS_WEATHER_CACHE_TTL=0'); + + $calls = 0; + $result = weather_cache_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [ + [1, 3, 5], + [ + 'start' => new DateTime('2026-03-24 00:00:00'), + 'endExclusive' => new DateTime('2026-03-25 00:00:00'), + ], + static function () use (&$calls): array { + $calls++; + + return ['ok' => true]; + }, + ]); + + expect($result)->toBe(['ok' => true]); + expect($calls)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheRuntimeBehaviorTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheRuntimeBehaviorTest.php new file mode 100644 index 00000000..47175941 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheRuntimeBehaviorTest.php @@ -0,0 +1,355 @@ +> */ + public array $zsets = []; + + public function reset(): void + { + $this->zsets = []; + } + + public function zadd(string $key, int|float|string $score, string $member): void + { + if (!isset($this->zsets[$key])) { + $this->zsets[$key] = []; + } + $this->zsets[$key][$member] = (float)$score; + } + + public function zrevrange(string $key, int $start, int $stop): array + { + $members = $this->zsets[$key] ?? []; + if ($members === []) { + return []; + } + + arsort($members, SORT_NUMERIC); + $member_ids = array_keys($members); + if ($stop < 0) { + $stop = count($member_ids) + $stop; + } + $length = max(0, $stop - $start + 1); + if ($length === 0) { + return []; + } + + return array_values(array_slice($member_ids, $start, $length)); + } + + public function zremrangebyscore(string $key, int|float|string $min, int|float|string $max): int + { + $members = $this->zsets[$key] ?? []; + if ($members === []) { + return 0; + } + + $min_score = $this->toScoreBoundary($min, true); + $max_score = $this->toScoreBoundary($max, false); + $removed = 0; + + foreach ($members as $member => $score) { + if ($score >= $min_score && $score <= $max_score) { + unset($members[$member]); + $removed++; + } + } + + if ($members === []) { + unset($this->zsets[$key]); + } else { + $this->zsets[$key] = $members; + } + + return $removed; + } + + public function zrem(string $key, string $member): int + { + if (!isset($this->zsets[$key][$member])) { + return 0; + } + + unset($this->zsets[$key][$member]); + if ($this->zsets[$key] === []) { + unset($this->zsets[$key]); + } + + return 1; + } + + private function toScoreBoundary(int|float|string $value, bool $is_min): float + { + if (is_string($value)) { + $trimmed = strtolower(trim($value)); + if ($trimmed === '-inf') { + return -INF; + } + if ($trimmed === '+inf' || $trimmed === 'inf') { + return INF; + } + } + + $numeric = (float)$value; + if (!is_finite($numeric)) { + return $is_min ? -INF : INF; + } + + return $numeric; + } +} + +class DepartmentWeatherRedisFake +{ + /** @var array */ + public array $store = []; + /** @var array */ + public array $setExCalls = []; + /** @var array */ + public array $lockCalls = []; + public bool $lockResult = true; + + private DepartmentWeatherRedisClientFake $client; + + public function __construct() + { + $this->client = new DepartmentWeatherRedisClientFake(); + } + + public function reset(): void + { + $this->store = []; + $this->setExCalls = []; + $this->lockCalls = []; + $this->lockResult = true; + $this->client->reset(); + } + + public function get(string $key): ?string + { + return $this->store[$key] ?? null; + } + + public function setEx(string $key, string $value, int $ttl): self + { + $this->store[$key] = $value; + $this->setExCalls[] = [ + 'key' => $key, + 'ttl' => $ttl, + 'value' => $value, + ]; + + return $this; + } + + public function set_if_absent_with_expiration(string $key, string $value, int $ttl): bool + { + $this->lockCalls[] = [ + 'key' => $key, + 'value' => $value, + 'ttl' => $ttl, + ]; + + if (!$this->lockResult) { + return false; + } + if (array_key_exists($key, $this->store)) { + return false; + } + + $this->store[$key] = $value; + + return true; + } + + public function get_client(): DepartmentWeatherRedisClientFake + { + return $this->client; + } +} + +function department_weather_runtime_redis(): DepartmentWeatherRedisFake +{ + if (!defined('redis')) { + define('redis', new DepartmentWeatherRedisFake()); + } + + /** @var DepartmentWeatherRedisFake $redis */ + $redis = redis; + + return $redis; +} + +function department_weather_runtime_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed +{ + $reflection = new ReflectionClass($route); + $target = $reflection->getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +beforeEach(function (): void { + $_SERVER['REQUEST_URI'] = '/departments/weather'; + $this->oldTtl = getenv('DEPARTMENTS_WEATHER_CACHE_TTL'); + $this->oldStaleTtl = getenv('DEPARTMENTS_WEATHER_STALE_TTL'); + $this->oldHotTtl = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); + department_weather_runtime_redis()->reset(); +}); + +afterEach(function (): void { + if ($this->oldTtl === false) { + putenv('DEPARTMENTS_WEATHER_CACHE_TTL'); + } else { + putenv('DEPARTMENTS_WEATHER_CACHE_TTL=' . $this->oldTtl); + } + + if ($this->oldStaleTtl === false) { + putenv('DEPARTMENTS_WEATHER_STALE_TTL'); + } else { + putenv('DEPARTMENTS_WEATHER_STALE_TTL=' . $this->oldStaleTtl); + } + + if ($this->oldHotTtl === false) { + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); + } else { + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=' . $this->oldHotTtl); + } +}); + +it('builds department weather preload targets in cli without a request uri', function (): void { + unset($_SERVER['REQUEST_URI']); + + $targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(5, 900); + + expect($targets)->toBe([]); +}); + +it('serves fresh cached payloads without invoking the resolver', function (): void { + $route = new moduleWeatherAPIRoute(); + putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60'); + putenv('DEPARTMENTS_WEATHER_STALE_TTL=300'); + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900'); + + $range = [ + 'start' => new DateTime('2026-03-24 00:00:00'), + 'endExclusive' => new DateTime('2026-03-25 00:00:00'), + ]; + $cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]); + department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([ + 'generated_at' => time() - 5, + 'timeline' => [['source' => 'cache']], + ]); + + $calls = 0; + $result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [ + [1, 3, 5], + $range, + static function () use (&$calls): array { + $calls++; + return [['source' => 'resolver']]; + }, + ]); + + expect($result)->toBe([['source' => 'cache']]); + expect($calls)->toBe(0); +}); + +it('serves stale cached payloads and enqueues a refresh signal', function (): void { + $route = new moduleWeatherAPIRoute(); + putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60'); + putenv('DEPARTMENTS_WEATHER_STALE_TTL=300'); + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900'); + + $range = [ + 'start' => new DateTime('2026-03-24 00:00:00'), + 'endExclusive' => new DateTime('2026-03-25 00:00:00'), + ]; + $cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]); + department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([ + 'generated_at' => time() - 120, + 'timeline' => [['source' => 'stale-cache']], + ]); + + $calls = 0; + $result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [ + [1, 3, 5], + $range, + static function () use (&$calls): array { + $calls++; + return [['source' => 'resolver']]; + }, + ]); + + $targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(5, 900); + + expect($result)->toBe([['source' => 'stale-cache']]); + expect($calls)->toBe(0); + expect(department_weather_runtime_redis()->lockCalls)->toHaveCount(1); + expect($targets)->not->toBeEmpty(); +}); + +it('recomputes and rewrites cache payloads when stale window is exceeded', function (): void { + $route = new moduleWeatherAPIRoute(); + putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60'); + putenv('DEPARTMENTS_WEATHER_STALE_TTL=300'); + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900'); + + $range = [ + 'start' => new DateTime('2026-03-24 00:00:00'), + 'endExclusive' => new DateTime('2026-03-25 00:00:00'), + ]; + $cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]); + department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([ + 'generated_at' => time() - 400, + 'timeline' => [['source' => 'expired-cache']], + ]); + + $calls = 0; + $result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [ + [1, 3, 5], + $range, + static function () use (&$calls): array { + $calls++; + return [['source' => 'resolver']]; + }, + ]); + + $cacheWriteFound = false; + foreach (department_weather_runtime_redis()->setExCalls as $call) { + if ($call['key'] === $cacheKey && $call['ttl'] === 300) { + $cacheWriteFound = true; + break; + } + } + + expect($result)->toBe([['source' => 'resolver']]); + expect($calls)->toBe(1); + expect($cacheWriteFound)->toBeTrue(); +}); + +it('records hot keys order-insensitively and applies preload target caps', function (): void { + $route = new moduleWeatherAPIRoute(); + putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900'); + + $range = [ + 'start' => new DateTime('2026-03-24 00:00:00'), + 'endExclusive' => new DateTime('2026-03-25 00:00:00'), + ]; + + department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[5, 1, 3], $range]); + department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[1, 3, 5], $range]); + department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[8], $range]); + department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[9], $range]); + + $hotSet = department_weather_runtime_redis()->get_client()->zsets['departments_weather:hot_activity:v1'] ?? []; + $targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(2, 900); + + expect($hotSet)->toHaveCount(3); + expect($targets)->toHaveCount(2); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherFallbackBehaviorTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherFallbackBehaviorTest.php new file mode 100644 index 00000000..d48b093f --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherFallbackBehaviorTest.php @@ -0,0 +1,136 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +function department_weather_coordinate_property(mixed $value): object +{ + return new class($value) { + public function __construct(private mixed $value) + { + } + + public function value(): mixed + { + return $this->value; + } + }; +} + +function department_weather_fake_department(mixed $latitude, mixed $longitude): object +{ + return (object)[ + 'latitude' => department_weather_coordinate_property($latitude), + 'longitude' => department_weather_coordinate_property($longitude), + ]; +} + +beforeEach(function (): void { + $_SERVER['REQUEST_URI'] = '/departments/weather'; +}); + +it('resolves weather coordinates from valid coordinates only', function (): void { + $route = new moduleWeatherAPIRoute(); + $coordinates = department_weather_fallback_invoke_private($route, 'resolveWeatherCoordinates', [[ + department_weather_fake_department('', ''), + department_weather_fake_department('0', '0'), + department_weather_fake_department('91.0', '12.0'), + department_weather_fake_department('55.6761', '181.0'), + department_weather_fake_department('55.6761', '12.5683'), + department_weather_fake_department(56.2639, 9.5018), + ]]); + + expect($coordinates)->toBeArray(); + expect(round($coordinates['lat'], 4))->toBe(round((55.6761 + 56.2639) / 2, 4)); + expect(round($coordinates['lon'], 4))->toBe(round((12.5683 + 9.5018) / 2, 4)); +}); + +it('returns null weather coordinates when all department locations are invalid', function (): void { + $route = new moduleWeatherAPIRoute(); + $coordinates = department_weather_fallback_invoke_private($route, 'resolveWeatherCoordinates', [[ + department_weather_fake_department('', ''), + department_weather_fake_department('0', '0'), + department_weather_fake_department('95.0', '12.0'), + department_weather_fake_department('55.0', '-181.0'), + ]]); + + expect($coordinates)->toBeNull(); +}); + +it('classifies weatherapi no matching location errors as location lookup failures', function (): void { + $route = new moduleWeatherAPIRoute(); + + $is_location_error = department_weather_fallback_invoke_private($route, 'isWeatherLocationLookupFailure', [ + new Exception('WeatherAPI request failed with HTTP 400: No matching location found.'), + ]); + + expect($is_location_error)->toBeTrue(); +}); + +it('does not classify non-location weatherapi errors as location lookup failures', function (): void { + $route = new moduleWeatherAPIRoute(); + + $is_location_error = department_weather_fallback_invoke_private($route, 'isWeatherLocationLookupFailure', [ + new Exception('WeatherAPI request failed with HTTP 401: API key is invalid.'), + ]); + + expect($is_location_error)->toBeFalse(); +}); + +it('returns an empty forecast fallback when coordinates are unavailable', function (): void { + $route = new moduleWeatherAPIRoute(); + $result = department_weather_fallback_invoke_private($route, 'fetchDepartmentForecastOrFallback', [null, 2]); + + expect($result['fallback_reason'])->toBe('invalid_department_coordinates'); + expect($result['forecast'])->toBeObject(); + expect($result['forecast']->forecast->forecastday ?? null)->toBeArray(); + expect($result['forecast']->forecast->forecastday ?? [1])->toHaveCount(0); +}); + +it('builds timeline entries with mostly_clear weather when forecast payload is empty', function (): void { + $route = new moduleWeatherAPIRoute(); + $start = new DateTime('2026-03-24 08:00:00'); + $end_exclusive = new DateTime('2026-03-24 11:00:00'); + + $timeline = department_weather_fallback_invoke_private($route, 'buildDepartmentWeatherTimeline', [ + [], + [], + (object)['forecast' => (object)['forecastday' => []]], + [ + 'start' => $start, + 'endExclusive' => $end_exclusive, + ], + ]); + + expect($timeline)->toHaveCount(3); + expect($timeline[0]['date'])->toBe('2026-03-24'); + expect($timeline[0]['time'])->toBe('08:00'); + expect($timeline[0]['weather'])->toBe('mostly_clear'); + expect($timeline[0]['washes'])->toBe(0); + expect($timeline[0]['hours'])->toBe(0.0); + expect($timeline[0]['status'])->toBe('unknown'); + expect($timeline[1]['weather'])->toBe('mostly_clear'); + expect($timeline[2]['weather'])->toBe('mostly_clear'); +}); + +it('wires departments weather route through the forecast fallback path', function (): void { + $routeFile = app_path('routes/moduleWeatherAPIRoute.php'); + $content = (string)file_get_contents($routeFile); + + expect($content)->toContain('fetchDepartmentForecastOrFallback'); + expect($content)->toContain('$this->resolveForecastDaysForTimelineRange'); + expect($content)->toContain('$this->buildDepartmentWeatherTimeline'); + expect($content)->not->toContain('static function () use ($coordinates'); + expect($content)->toContain('DEPARTMENTS_WEATHER_FALLBACK'); + expect($content)->not->toContain('Selected department(s) do not have GPS coordinates configured'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php new file mode 100644 index 00000000..93826bad --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php @@ -0,0 +1,49 @@ +not->toBeFalse(); + expect($routeContent)->not->toBeFalse(); + + expect($cronContent)->toContain('PreloadDepartmentWeatherResponsesCron'); + expect($cronContent)->toContain("'function' => 'PreloadDepartmentWeatherResponsesCron'"); + expect($cronContent)->toContain("'interval' => 60"); + expect($cronContent)->toContain('moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache'); + expect($cronContent)->toContain('moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets'); + expect($cronContent)->toContain('DEPARTMENTS_WEATHER_PRELOAD_MAX_DEPARTMENTS'); + expect($cronContent)->toContain('DEPARTMENTS_WEATHER_PRELOAD_HOT_LIMIT'); + expect($cronContent)->toContain('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); + + expect($routeContent)->toContain('public static function preloadDepartmentWeatherTimelineCache'); + expect($routeContent)->toContain('public static function getDepartmentWeatherHotPreloadTargets'); + expect($routeContent)->toContain('withCachedDepartmentWeatherTimeline'); + expect($routeContent)->toContain('getDepartmentWeatherCacheKey'); +}); + +it('registers and implements cron warming for workfeed employee name cache', function (): void { + $cronContent = file_get_contents(app_path('cron/Cron.php')); + $routeContent = file_get_contents(app_path('routes/moduleWeatherAPIRoute.php')); + + expect($cronContent)->not->toBeFalse(); + expect($routeContent)->not->toBeFalse(); + + expect($cronContent)->toContain('WarmWorkfeedEmployeeNamesCron'); + expect($cronContent)->toContain("'function' => 'WarmWorkfeedEmployeeNamesCron'"); + expect($cronContent)->toContain("'interval' => 21600"); + expect($cronContent)->toContain('WORKFEED_EMPLOYEE_NAME_CACHE_TTL'); + expect($cronContent)->toContain('cache_workfeed_employee_name'); + expect($cronContent)->toContain('normalizeWorkfeedEmployeeWarmupCollection'); + expect($cronContent)->toContain('extractWorkfeedEmployeeWarmupIdentity'); + expect($cronContent)->toContain('extractWorkfeedEmployeeWarmupIds'); + expect($cronContent)->toContain('foreach ($employeeIds as $employeeId)'); + expect($cronContent)->toContain('workfeed_employee_name_formatter::fromRecord'); + expect($cronContent)->toContain("'firstname'"); + expect($cronContent)->toContain("'lastname'"); + expect($cronContent)->toContain("'employee.firstname'"); + expect($cronContent)->toContain("'employee.lastname'"); + + expect($routeContent)->toContain('fetchCachedWorkfeedEmployeeDisplayName'); + expect($routeContent)->toContain('get_workfeed_employee_name'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherStatusTargetsTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherStatusTargetsTest.php new file mode 100644 index 00000000..bef8e1ce --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherStatusTargetsTest.php @@ -0,0 +1,167 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +beforeEach(function (): void { + $_SERVER['REQUEST_URI'] = '/departments/weather'; +}); + +it('evaluates healthy degraded and unhealthy statuses from configured department targets', function (): void { + $route = new moduleWeatherAPIRoute(); + $targets = [ + 'degraded_threshold' => 1.0, + 'healthy_threshold' => 1.3, + ]; + + $healthy = weather_status_targets_invoke_private($route, 'calculateStatus', [13, 10.0, true, $targets]); + $degraded = weather_status_targets_invoke_private($route, 'calculateStatus', [10, 10.0, true, $targets]); + $unhealthy = weather_status_targets_invoke_private($route, 'calculateStatus', [9, 10.0, true, $targets]); + + expect($healthy)->toBe('healthy'); + expect($degraded)->toBe('degraded'); + expect($unhealthy)->toBe('unhealthy'); +}); + +it('returns unknown when configured targets are missing for department status evaluation', function (): void { + $route = new moduleWeatherAPIRoute(); + + $status = weather_status_targets_invoke_private($route, 'calculateStatus', [10, 10.0, true, null]); + + expect($status)->toBe('unknown'); +}); + +it('aggregates multi-department slot statuses using worst severity when any department is unhealthy', function (): void { + $route = new moduleWeatherAPIRoute(); + $slotKey = '2026-03-24 10:00'; + $targets = [ + 1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + 2 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + ]; + + $worst = weather_status_targets_invoke_private($route, 'calculateAggregatedDepartmentStatus', [ + [1, 2], + [ + 1 => [$slotKey => 14], + 2 => [$slotKey => 8], + ], + [ + 1 => [$slotKey => 10.0], + 2 => [$slotKey => 10.0], + ], + $slotKey, + true, + $targets, + ]); + expect($worst)->toBe('unhealthy'); +}); + +it('aggregates all-unhealthy multi-department slot statuses as unhealthy', function (): void { + $route = new moduleWeatherAPIRoute(); + $slotKey = '2026-03-24 10:00'; + $targets = [ + 1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + 2 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + ]; + + $worst = weather_status_targets_invoke_private($route, 'calculateAggregatedDepartmentStatus', [ + [1, 2], + [ + 1 => [$slotKey => 3], + 2 => [$slotKey => 4], + ], + [ + 1 => [$slotKey => 10.0], + 2 => [$slotKey => 10.0], + ], + $slotKey, + true, + $targets, + ]); + + expect($worst)->toBe('unhealthy'); +}); + +it('returns unknown when aggregated multi-department slot targets are missing', function (): void { + $route = new moduleWeatherAPIRoute(); + $slotKey = '2026-03-24 10:00'; + + $missingTargets = weather_status_targets_invoke_private($route, 'calculateAggregatedDepartmentStatus', [ + [1, 2], + [ + 1 => [$slotKey => 14], + 2 => [$slotKey => 8], + ], + [ + 1 => [$slotKey => 10.0], + 2 => [$slotKey => 10.0], + ], + $slotKey, + true, + [ + 1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + ], + ]); + expect($missingTargets)->toBe('unknown'); +}); + +it('returns unknown when aggregated multi-department slot has no evaluable hours', function (): void { + $route = new moduleWeatherAPIRoute(); + $slotKey = '2026-03-24 10:00'; + $targets = [ + 1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + 2 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + ]; + + $noEvaluableHours = weather_status_targets_invoke_private($route, 'calculateAggregatedDepartmentStatus', [ + [1, 2], + [ + 1 => [$slotKey => 0], + 2 => [$slotKey => 0], + ], + [ + 1 => [$slotKey => 0.0], + 2 => [$slotKey => 0.0], + ], + $slotKey, + true, + $targets, + ]); + expect($noEvaluableHours)->toBe('unknown'); +}); + +it('returns unknown when aggregated multi-department slot has not started yet', function (): void { + $route = new moduleWeatherAPIRoute(); + $slotKey = '2026-03-24 10:00'; + $targets = [ + 1 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + 2 => ['degraded_threshold' => 1.0, 'healthy_threshold' => 1.3], + ]; + + $futureSlot = weather_status_targets_invoke_private($route, 'calculateAggregatedDepartmentStatus', [ + [1, 2], + [ + 1 => [$slotKey => 14], + 2 => [$slotKey => 8], + ], + [ + 1 => [$slotKey => 10.0], + 2 => [$slotKey => 10.0], + ], + $slotKey, + false, + $targets, + ]); + + expect($futureSlot)->toBe('unknown'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsOpenApiSpecTest.php new file mode 100644 index 00000000..1d3bd6a2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsOpenApiSpecTest.php @@ -0,0 +1,46 @@ +markTestSkipped('openapi.yaml is not mounted in this test container.'); +} + +it('documents department weather target endpoints and reusable schemas in openapi', function (): void { + $content = department_weather_targets_openapi_content_or_skip(); + + expect($content)->toContain('/departments/weather/targets:'); + expect($content)->toContain('operationId: getDepartmentWeatherTargets'); + expect($content)->toContain('operationId: upsertDepartmentWeatherTarget'); + expect($content)->toContain('DepartmentWeatherTarget:'); + expect($content)->toContain('DepartmentWeatherTargetsResponse:'); + expect($content)->toContain('DepartmentWeatherTargetUpsertRequest:'); +}); + +it('documents unknown weather status behavior when targets are missing', function (): void { + $content = department_weather_targets_openapi_content_or_skip(); + + expect($content)->toContain('DepartmentWeatherStatus:'); + expect($content)->toContain('missing department weather targets'); + expect($content)->toContain('enum: [unknown, healthy, degraded, unhealthy]'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php new file mode 100644 index 00000000..811e9372 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php @@ -0,0 +1,34 @@ +not->toBeFalse(); + expect($content)->toContain('/departments/weather/targets'); + expect($content)->toContain("requirePermission('departments_weather_targets_get')"); + expect($content)->toContain("requirePermission('departments_weather_targets_manage')"); + expect($content)->toContain("'department_access_:id'"); +}); + +it('registers department weather hour details route with weather-read and department access checks', function (): void { + $routeFile = app_path('routes/moduleWeatherAPIRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/departments/weather/hours/details'); + expect($content)->toContain("requirePermission('departments_weather_get')"); + expect($content)->toContain('parseDepartmentWeatherHourSlotFromRequest'); + expect($content)->toContain('loadDepartmentWeatherEmployeeHourDetailsByDepartment'); +}); + +it('persists department weather targets using canonical department variable keys', function (): void { + $routeFile = app_path('routes/moduleWeatherAPIRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('weather_status_degraded_threshold'); + expect($content)->toContain('weather_status_healthy_threshold'); + expect($content)->toContain('loadDepartmentWeatherTargetsByDepartmentId'); + expect($content)->toContain('buildDepartmentWeatherTargetResponse'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php new file mode 100644 index 00000000..a663af3a --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php @@ -0,0 +1,90 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +beforeEach(function (): void { + $_SERVER['REQUEST_URI'] = '/departments/weather'; +}); + +it('builds a timeline range from start of yesterday to end of today', function (): void { + $route = new moduleWeatherAPIRoute(); + $range = weather_timeline_range_invoke_private($route, 'getDepartmentWeatherTimelineRange'); + + $expectedStart = new DateTime(date('Y-m-d 00:00:00')); + $expectedStart->sub(new DateInterval('P1D')); + + $expectedEndExclusive = new DateTime(date('Y-m-d 00:00:00')); + $expectedEndExclusive->add(new DateInterval('P1D')); + + expect($range['start'] instanceof DateTime)->toBeTrue(); + expect($range['endExclusive'] instanceof DateTime)->toBeTrue(); + expect($range['start']->format('Y-m-d H:i:s'))->toBe($expectedStart->format('Y-m-d H:i:s')); + expect($range['endExclusive']->format('Y-m-d H:i:s'))->toBe($expectedEndExclusive->format('Y-m-d H:i:s')); + + $hourCount = (int)(($range['endExclusive']->getTimestamp() - $range['start']->getTimestamp()) / 3600); + expect($hourCount)->toBe(48); +}); + +it('builds a timeline range relative to explicit date_from and date_to override', function (): void { + $route = new moduleWeatherAPIRoute(); + $range = weather_timeline_range_invoke_private($route, 'getDepartmentWeatherTimelineRange', ['2026-03-08', '2026-03-10']); + + expect($range['start']->format('Y-m-d H:i:s'))->toBe('2026-03-08 00:00:00'); + expect($range['endExclusive']->format('Y-m-d H:i:s'))->toBe('2026-03-11 00:00:00'); + + $hourCount = (int)(($range['endExclusive']->getTimestamp() - $range['start']->getTimestamp()) / 3600); + expect($hourCount)->toBe(72); +}); + +it('resolves forecast day count for a given timeline range with sane limits', function (): void { + $route = new moduleWeatherAPIRoute(); + + $start = new DateTime(date('Y-m-d 00:00:00')); + $end = new DateTime(date('Y-m-d 00:00:00')); + $end->add(new DateInterval('P3D')); + + $days = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$start, $end]); + + expect($days)->toBe(3); + + $farEnd = new DateTime(date('Y-m-d 00:00:00')); + $farEnd->add(new DateInterval('P40D')); + $clamped = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$start, $farEnd]); + expect($clamped)->toBe(14); + + $pastStart = new DateTime(date('Y-m-d 00:00:00')); + $pastStart->sub(new DateInterval('P10D')); + $pastEnd = new DateTime(date('Y-m-d 00:00:00')); + $pastEnd->sub(new DateInterval('P5D')); + $pastDays = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$pastStart, $pastEnd]); + expect($pastDays)->toBe(1); +}); + +it('marks non-started slots as unknown regardless of wash-hour ratio', function (): void { + $route = new moduleWeatherAPIRoute(); + + $targets = [ + 'degraded_threshold' => 1.0, + 'healthy_threshold' => 1.3, + ]; + + $futureStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, false, $targets]); + expect($futureStatus)->toBe('unknown'); + + $missingTargetStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, true]); + expect($missingTargetStatus)->toBe('unknown'); + + $startedStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, true, $targets]); + expect($startedStatus)->toBe('healthy'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php new file mode 100644 index 00000000..deb840c7 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php @@ -0,0 +1,43 @@ +toContain("'date' => \$slot->format('Y-m-d')"); + expect($content)->toContain("'current' => \$slot_key === \$current_slot_key"); +}); + +it('documents the date key in the openapi department weather timeline schema', function (): void { + $candidates = [WD . '/openapi.yaml']; + for ($depth = 1; $depth <= 8; $depth++) { + $candidates[] = dirname(WD, $depth) . '/openapi.yaml'; + } + $cwd = getcwd(); + if (is_string($cwd) && $cwd !== '') { + $candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml'; + $candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml'; + } + $candidates = array_values(array_unique($candidates)); + + $openApiFile = null; + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + $openApiFile = $candidate; + break; + } + } + + if ($openApiFile === null) { + $this->markTestSkipped('openapi.yaml is not mounted in this test container.'); + } + + $content = (string)file_get_contents($openApiFile); + + expect($content)->toContain('DepartmentWeatherTimelineEntry:'); + expect($content)->toContain('format: date'); + expect($content)->toContain('type: boolean'); + expect($content)->toContain('required: [date, time, current, weather, washes, hours, status]'); + expect($content)->toContain('- name: date_from'); + expect($content)->toContain('- name: date_to'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php new file mode 100644 index 00000000..2f1ec50e --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php @@ -0,0 +1,567 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +beforeEach(function (): void { + $_SERVER['REQUEST_URI'] = '/departments/weather'; +}); + +it('calculates workfeed employee hours for the hour slot based on overlap', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_1', + 'checkIn' => (object)['time' => '2026-03-24T13:30:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T15:00:00+00:00'], + 'end' => '2026-03-24T15:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_other', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_1', + 'checkIn' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'end' => '2026-03-24T13:00:00+00:00', + ], + ]; + + $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); + + expect($hours)->toBe(1.5); +}); + +it('calculates weather hour contributions grouped per employee for a slot', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'employeeID' => 'emp_1', + 'employeeName' => 'Alice', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_1', + 'employeeID' => 'emp_1', + 'employeeName' => 'Alice', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T13:30:00+00:00'], + 'end' => '2026-03-24T13:30:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_1', + 'employeeID' => 'emp_2', + 'employeeName' => 'Bob', + 'checkIn' => (object)['time' => '2026-03-24T13:15:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_other', + 'employeeID' => 'emp_3', + 'employeeName' => 'Ignored', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + ]; + + $details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]); + + expect($details)->toBe([ + [ + 'employee_id' => 'emp_1', + 'employee_name' => 'Alice', + 'hours' => 1.5, + ], + [ + 'employee_id' => 'emp_2', + 'employee_name' => 'Bob', + 'hours' => 0.75, + ], + ]); +}); + +it('calculates weather hour contributions for canonical nested workfeed employee schema', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'employee' => (object)[ + 'id' => '005FnnP0fHohybM1f3tx', + 'firstname' => 'Michael', + 'lastname' => 'Stenbæk Stampe', + ], + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + ]; + + $details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]); + + expect($details)->toBe([ + [ + 'employee_id' => '005FnnP0fHohybM1f3tx', + 'employee_name' => 'Michael Stenbæk Stampe', + 'hours' => 1.0, + ], + ]); +}); + +it('extracts weather employee identity from supported shift payload shapes', function (): void { + $route = new moduleWeatherAPIRoute(); + + $fromTopLevel = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employeeID' => 'emp_1', + 'employeeName' => 'Alice', + ], + ]); + + $fromNested = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'id' => 'emp_2', + 'firstName' => 'Bob', + 'lastName' => 'Builder', + ], + ], + ]); + + $fromCanonicalNestedSchema = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'id' => 'emp_3', + 'firstname' => 'Charlie', + 'lastname' => 'Day', + ], + ], + ]); + + expect($fromTopLevel)->toBe([ + 'id' => 'emp_1', + 'name' => 'Alice', + ]); + expect($fromNested)->toBe([ + 'id' => 'emp_2', + 'name' => 'Bob Builder', + ]); + expect($fromCanonicalNestedSchema)->toBe([ + 'id' => 'emp_3', + 'name' => 'Charlie Day', + ]); +}); + +it('prefers canonical workfeed employee schema fields over generic employee names', function (): void { + $route = new moduleWeatherAPIRoute(); + + $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'id' => 'emp_4', + 'firstname' => 'Dana', + 'lastname' => 'Scully', + 'name' => 'Wrong Name', + ], + 'employeeName' => 'Also Wrong', + ], + ]); + + expect($identity)->toBe([ + 'id' => 'emp_4', + 'name' => 'Dana Scully', + ]); +}); + +it('does not use workfeed schema name fields as employee ids', function (): void { + $route = new moduleWeatherAPIRoute(); + + $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'firstname' => 'Fox', + 'lastname' => 'Mulder', + ], + ], + ]); + + expect($identity)->toBe([ + 'id' => null, + 'name' => 'Fox Mulder', + ]); +}); + +it('returns a null employee name when no workfeed employee name is available', function (): void { + $route = new moduleWeatherAPIRoute(); + + $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employeeID' => 'emp_99', + ], + ]); + + expect($identity)->toBe([ + 'id' => 'emp_99', + 'name' => null, + ]); +}); + +it('resolves missing workfeed employee names from the employees endpoint', function (): void { + $route = new moduleWeatherAPIRoute(); + + $workfeed = new class extends workfeed { + public function __construct() + { + } + + public function listEmployees(array $filters = []): array|object + { + return [ + (object)[ + 'id' => 'emp_42', + 'firstname' => 'Jane', + 'lastname' => 'Doe', + ], + ]; + } + + public function getEmployee(string $id): object + { + return (object)[]; + } + }; + + $resolvedNames = []; + $employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[ + [ + 'employee_id' => 'emp_42', + 'employee_name' => null, + 'hours' => 1.0, + ], + ], null, &$resolvedNames, $workfeed]); + + expect($employees)->toBe([[ + 'employee_id' => 'emp_42', + 'employee_name' => 'Jane Doe', + 'hours' => 1.0, + ]]); +}); + +it('filters employee hour rows that cannot be resolved to a workfeed schema name', function (): void { + $route = new moduleWeatherAPIRoute(); + + $resolvedNames = []; + $employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[ + [ + 'employee_id' => 'emp_missing', + 'employee_name' => null, + 'hours' => 1.0, + ], + ], null, &$resolvedNames, null]); + + expect($employees)->toBe([]); +}); + +it('resolves employee display name from cache when shift payload lacks a name', function (): void { + $route = new moduleWeatherAPIRoute(); + + $cache = new class([ + 'emp_42' => 'Jane Doe', + ]) extends redis { + private array $namesByEmployeeId; + + public function __construct(array $namesByEmployeeId) + { + $this->namesByEmployeeId = $namesByEmployeeId; + } + + public function get_workfeed_employee_name(string $employeeId): string|null + { + return $this->namesByEmployeeId[$employeeId] ?? null; + } + }; + + $resolved_name = weather_route_invoke_private($route, 'fetchCachedWorkfeedEmployeeDisplayName', [$cache, 'emp_42']); + + expect($resolved_name)->toBe('Jane Doe'); +}); + +it('extracts department id from supported workfeed shift shapes', function (): void { + $route = new moduleWeatherAPIRoute(); + + $idFromNested = weather_route_invoke_private($route, 'extractWorkfeedDepartmentId', [ + (object)[ + 'department' => (object)['id' => 'dep_nested'], + 'start' => '2026-03-24T12:45:00+00:00', + 'end' => '2026-03-24T13:15:00+00:00', + ], + ]); + + $idFromCamelCase = weather_route_invoke_private($route, 'extractWorkfeedDepartmentId', [[ + 'departmentId' => 'dep_camel', + 'start' => '2026-03-24T12:45:00+00:00', + 'end' => '2026-03-24T13:15:00+00:00', + ]]); + + expect($idFromNested)->toBe('dep_nested'); + expect($idFromCamelCase)->toBe('dep_camel'); +}); + +it('normalizes wrapped workfeed collections from common response keys', function (): void { + $route = new moduleWeatherAPIRoute(); + + $wrapped = (object)[ + 'data' => [ + (object)['id' => 'a'], + (object)['id' => 'b'], + ], + ]; + + $items = weather_route_invoke_private($route, 'normalizeWorkfeedCollection', [$wrapped]); + + expect($items)->toHaveCount(2); + expect($items[0]->id ?? null)->toBe('a'); + expect($items[1]->id ?? null)->toBe('b'); +}); + +it('calculates workfeed employee hours across multiple departments for one hour slot', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_2', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + ]; + + $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, ['dep_1', 'dep_2'], $slot]); + + expect($hours)->toBe(2.0); +}); + +it('does not count shifts without punches when checkIn/checkOut are null', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'checkIn' => null, + 'checkOut' => null, + 'start' => '2026-03-24T13:00:00+00:00', + 'end' => '2026-03-24T14:00:00+00:00', + ], + ]; + + $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); + + expect($hours)->toBe(0.0); +}); + +it('counts checked-in shifts without checkOut up to occurredUntil', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + $occurredUntil = new DateTime('2026-03-24T13:40:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'checkIn' => (object)['time' => '2026-03-24T13:10:00+00:00'], + 'checkOut' => null, + 'end' => '2026-03-24T16:00:00+00:00', + ], + ]; + + $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot, $occurredUntil]); + + expect($hours)->toBe(0.5); +}); + +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'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'start' => '2026-03-23T10:00:00+00:00', + 'end' => '2026-03-23T18:17: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.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'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'start' => '2026-03-23T09:00:00+00:00', + 'end' => '2026-03-23T17:00:00+00:00', + 'approval' => null, + 'updateTime' => '2026-03-23T17:15:00+00:00', + ], + ]; + + $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); + + expect($hours)->toBe(0.25); +}); + +it('does not treat late unapproved administrative edits as overtime', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-23T17:00:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + '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', + ], + ]; + + $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); + + 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', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => null, + '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(); + + $single = weather_route_invoke_private($route, 'normalizeDepartmentIdInput', ['7']); + $csv = weather_route_invoke_private($route, 'normalizeDepartmentIdInput', ['1, 2,3']); + $nested = weather_route_invoke_private($route, 'normalizeDepartmentIdInput', [[1, '2,3', [4, '5']]]); + + expect($single)->toBe(['7']); + expect($csv)->toBe(['1', '2', '3']); + expect($nested)->toBe([1, '2', '3', 4, '5']); +}); + +it('normalizes batched wash count rows into hourly slot totals', function (): void { + $route = new moduleWeatherAPIRoute(); + $rows = [ + [ + 'department_id' => 1, + 'hour_bucket' => '2026-03-24 08:00:00', + 'wash_count' => 3, + ], + [ + 'department_id' => 2, + 'hour_bucket' => '2026-03-24 08:15:00', + 'wash_count' => 2, + ], + [ + 'department_id' => 2, + 'hour_bucket' => '2026-03-24 09:00:00', + 'wash_count' => 4, + ], + ]; + + $counts = weather_route_invoke_private($route, 'normalizeDepartmentWashCountRows', [$rows]); + + expect($counts)->toBe([ + '2026-03-24 08:00' => 5, + '2026-03-24 09:00' => 4, + ]); +}); + +it('wires department weather route to use batched wash aggregation', function (): void { + $routeContent = (string)file_get_contents(app_path('routes/moduleWeatherAPIRoute.php')); + $ordersContent = (string)file_get_contents(app_path('objects/orders_o.php')); + + expect($routeContent)->toContain('loadDepartmentWashCountsBySlot'); + expect($routeContent)->toContain('countWashesByHourForDepartments'); + expect($ordersContent)->toContain('public function countWashesByHourForDepartments'); + expect($ordersContent)->toContain('GROUP BY o.department_id'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedClientConformanceTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedClientConformanceTest.php new file mode 100644 index 00000000..ff6d6bdf --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedClientConformanceTest.php @@ -0,0 +1,26 @@ +not->toBeFalse(); + expect($content)->toContain('/companies/'); + expect($content)->toContain("'Authorization: '"); + expect($content)->not->toContain('Authorization: Bearer'); + expect($content)->toContain('/employees'); + expect($content)->toContain('/shifts'); + expect($content)->toContain('/departments'); +}); + +it('normalizes documented shift query parameters', function (): void { + $classFile = app_path('classes/workfeed.php'); + $content = file_get_contents($classFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('startFrom'); + expect($content)->toContain('startTo'); + expect($content)->toContain('employeeID'); + expect($content)->toContain('Workfeed shift query requires startFrom.'); + expect($content)->toContain('Workfeed shift query requires startTo.'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedConfigRouteWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedConfigRouteWiringTest.php new file mode 100644 index 00000000..73073d55 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedConfigRouteWiringTest.php @@ -0,0 +1,12 @@ +not->toBeFalse(); + expect($content)->toContain('/workfeed/config'); + expect($content)->toContain("requirePermission('modules_workfeed_config')"); + expect($content)->toContain("(new workfeed())->config->getConfigRequest()"); + expect($content)->toContain("(new workfeed())->config->postConfigRequest()"); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedEmployeeNameFormatterTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedEmployeeNameFormatterTest.php new file mode 100644 index 00000000..46321e03 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedEmployeeNameFormatterTest.php @@ -0,0 +1,86 @@ + 'employee_1', + 'firstname' => 'API 2', + 'lastname' => 'Test 2', + ]); + + expect($name)->toBe('API 2 Test 2'); +}); + +it('prefers workfeed schema names over generic display name fields', function (): void { + $name = workfeed_employee_name_from_record((object)[ + 'employee' => (object)[ + 'id' => 'employee_2', + 'firstname' => 'Jane', + 'lastname' => 'Doe', + 'name' => 'Wrong Name', + ], + 'employeeName' => 'Also Wrong', + ]); + + expect($name)->toBe('Jane Doe'); +}); + +it('falls back to legacy display name fields when schema names are absent', function (): void { + $name = workfeed_employee_name_from_record((object)[ + 'employeeID' => 'employee_3', + 'employeeName' => 'Legacy Name', + ]); + + expect($name)->toBe('Legacy Name'); +}); + +it('ignores placeholder workfeed display names', function (): void { + $name = workfeed_employee_name_from_record((object)[ + 'employeeID' => 'employee_4', + 'employeeName' => 'Unknown employee', + ]); + + expect($name)->toBeNull(); +}); + +it('ignores employee id placeholder display names when the id is known', function (): void { + $name = workfeed_employee_name_formatter::fromRecord((object)[ + 'employeeID' => 'employee_5', + 'employeeName' => 'Employee employee_5', + ], [ + 'firstname', + ], [ + 'lastname', + ], [ + 'employeeName', + ], 'employee_5'); + + expect($name)->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteHelpersTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteHelpersTest.php new file mode 100644 index 00000000..92e07ef9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteHelpersTest.php @@ -0,0 +1,39 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +it('filters request parameters down to the allowed workfeed query keys', function (): void { + $_SERVER['REQUEST_URI'] = '/modules/workfeed/shifts'; + $route = new moduleWorkfeedRoute(); + + $filtered = workfeed_route_invoke_private($route, 'filterRequestParameters', [[ + 'startFrom' => '2026-03-24T00:00:00.000Z', + 'startTo' => '2026-03-25T00:00:00.000Z', + 'employeeID' => 'emp_123', + 'released' => 'true', + 'ignored' => 'value', + ], [ + 'startFrom', + 'startTo', + 'employeeID', + 'released', + ]]); + + expect($filtered)->toBe([ + 'startFrom' => '2026-03-24T00:00:00.000Z', + 'startTo' => '2026-03-25T00:00:00.000Z', + 'employeeID' => 'emp_123', + 'released' => 'true', + ]); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteWiringTest.php new file mode 100644 index 00000000..039ea7f7 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteWiringTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + expect($content)->toContain('/modules/workfeed/employees'); + expect($content)->toContain('/modules/workfeed/employees/{id}'); + expect($content)->toContain('/modules/workfeed/departments'); + expect($content)->toContain("requirePermission('modules_workfeed_employees_view')"); + expect($content)->toContain("requirePermission('modules_workfeed_departments_view')"); +}); + +it('registers shift endpoints for the workfeed module', function (): void { + $routeFile = app_path('routes/moduleWorkfeedRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/modules/workfeed/shifts'); + expect($content)->toContain('/modules/workfeed/shifts/{id}'); + expect($content)->toContain("requirePermission('modules_workfeed_shifts_view')"); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php new file mode 100644 index 00000000..6a8ba391 --- /dev/null +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php @@ -0,0 +1,146 @@ +toBe('EC21233'); +}); + +it('builds stable XL Vask automation item signatures', function (): void { + $items = [ + ['product_id' => 20, 'quantity' => 1, 'price' => 275], + ['product_id' => 10, 'quantity' => 2, 'price' => 649], + ['product_id' => 20, 'quantity' => 1, 'price' => 0], + ]; + + expect(xlvask_automation_service::itemSignaturePartsForAutomation($items)) + ->toBe([ + '10:2:649', + '20:1:0', + '20:1:275', + ]); +}); + +it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void { + $row = xlvask_automation_service::normalizeUsageLogRowForAutomation([ + 'id' => 47086, + 'WashId' => 'cc1eabc1-b4e1-425b-ad7c-dc68f8c97ceb', + 'WashItems' => '[{"OriginalProductName":"Bus","Count":1}]', + ]); + + expect($row) + ->not->toHaveKey('id') + ->and($row['WashItems'])->toBe([ + [ + 'OriginalProductName' => 'Bus', + 'Count' => 1, + ], + ]); +}); + +it('builds stable OpenAI cache keys for identical automation input', function (): void { + $prompt = 'Prompt'; + $schemaName = 'xlvask_automation'; + $schema = [ + 'required' => ['action'], + 'properties' => [ + 'confidence' => ['type' => 'number'], + 'action' => ['type' => 'string'], + ], + ]; + $schemaWithDifferentKeyOrder = [ + 'properties' => [ + 'action' => ['type' => 'string'], + 'confidence' => ['type' => 'number'], + ], + 'required' => ['action'], + ]; + $payloadA = [ + 'usage_log' => [ + 'registration' => 'AB12345', + 'creation_allowed' => true, + ], + 'candidate_orders' => [ + ['id' => 10, 'items' => [['product_id' => 1, 'quantity' => 1, 'price' => 100]]], + ], + ]; + $payloadB = [ + 'candidate_orders' => [ + ['items' => [['price' => 100, 'quantity' => 1, 'product_id' => 1]], 'id' => 10], + ], + 'usage_log' => [ + 'creation_allowed' => true, + 'registration' => 'AB12345', + ], + ]; + + expect(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadA, $schema, 0.1)) + ->toBe(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadB, $schemaWithDifferentKeyOrder, 0.1)); +}); + +it('changes OpenAI cache keys when automation eligibility input changes', function (): void { + $schema = ['type' => 'object']; + $newerWashPayload = ['usage_log' => ['creation_allowed' => false, 'age_bucket' => 'newer_than_6_hours']]; + $olderWashPayload = ['usage_log' => ['creation_allowed' => true, 'age_bucket' => 'older_than_6_hours']]; + + expect(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $newerWashPayload, $schema, 0.1)) + ->not->toBe(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $olderWashPayload, $schema, 0.1)); +}); + +it('declares a persistent OpenAI cache table for XL Vask automation', function (): void { + $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); + + expect($bootstrapContent) + ->toContain('xlvask_automation_openai_cache') + ->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)'); +}); + +it('declares cached amount summary columns for XL Vask usage logs', function (): void { + $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); + + expect($bootstrapContent) + ->toContain('cached_total_net_amount') + ->toContain('cached_primary_product_name') + ->toContain('cached_amount_at'); +}); + +it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void { + $usageItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], + ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], + ['product_id' => 99, 'quantity' => 8, 'price' => 0, 'product' => ['name' => 'Halleje']], + ]; + $orderItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], + ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], + ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], + ]; + + $score = xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems); + + expect($score['source']) + ->toBe('fuzzy') + ->and($score['confidence'])->toBeGreaterThanOrEqual(0.70) + ->and($score['confidence'])->toBeLessThan(0.92) + ->and($score['reason'])->toContain('ekstra ydelser'); +}); + +it('does not score an order with only the primary product as a matching add-on attachment', function (): void { + $usageItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], + ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], + ]; + $orderItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], + ]; + + expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence']) + ->toBe(0.0); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php new file mode 100644 index 00000000..69e642b8 --- /dev/null +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php @@ -0,0 +1,59 @@ +setProperties([ + 'WashId' => 'wash-ignored-1', + 'CustomerId' => '35131752', + 'Customer' => 'BHS Logistics A/S', + 'VatNumber' => '35255156', + 'Location' => 'Aarhus C', + 'Hall' => 'AarhusC_1', + 'HallId' => 'hall-1', + 'StartTime' => '2026-05-11T08:23:23.000', + 'FinishTime' => '2026-05-11T08:31:23.000', + 'RegistrationNumber' => 'EX4451', + 'VehicleType' => 'Truck', + 'IdentificationType' => 'LPR', + 'IdentificationId' => 'EX4451', + 'Info' => 'EX4451', + 'Updated' => null, + 'Prepaid' => false, + 'FinishStatus' => 1, + 'CustomerGuid' => 'customer-guid-1', + 'VehicleId' => 'vehicle-id-1', + 'WashItems' => [], + 'ignored_at' => '2026-05-11 09:00:00', + 'ignored_by' => '42', + 'ignored_reason' => 'Already handled in period review', + ]); + + expect($log->ignored_at)->toBe('2026-05-11 09:00:00') + ->and($log->ignored_by)->toBe(42) + ->and($log->ignored_reason)->toBe('Already handled in period review'); +}); + +it('calculates XL Vask amount summaries without hydrating order item previews', function (): void { + $summary = xlvask_usage_logs_o::calculateAmountSummaryFromWashItems(json_encode([ + [ + 'OriginalProductName' => 'Stor bil', + 'PriceIncVat' => '625.00', + 'Vat' => '125.00', + ], + [ + 'OriginalProductName' => 'Skylning', + 'PriceIncVat' => '125,00', + 'Vat' => '25,00', + ], + ], JSON_THROW_ON_ERROR)); + + expect($summary) + ->toMatchArray([ + 'total_net_amount' => 600.0, + 'primary_product_name' => 'Stor bil', + ]); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php new file mode 100644 index 00000000..3c442a81 --- /dev/null +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php @@ -0,0 +1,45 @@ +not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain('$linked_order_ids_by_wash_id = []') + ->and($route)->toContain('array_key_exists($wash_id, $linked_order_ids_by_wash_id)') + ->and($route)->toContain('selectByWashId($wash_id)') + ->and($route)->toContain("'linked_order_id' => \$linked_order_id") + ->and($route)->toContain("'usage_log_id' => \$id"); +}); + +it('does not execute XL Vask usage automation while listing usage order rows', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + + expect($route)->not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false);') + ->and($route)->not->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);') + ->and($route)->toContain("requirePermission('manage_xlvask_usage_automation')"); +}); + +it('returns cached amount summaries on XL Vask usage order rows without widening the usage-log object payload', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + + expect($route)->not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain('$amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log)') + ->and($route)->toContain('$usage_log_payload = array_intersect_key($log, array_flip([') + ->and($route)->toContain('$tmp->setProperties($usage_log_payload)') + ->and($route)->toContain("\$tmp_res['order']['total_net_amount'] = \$amount_summary['total_net_amount']") + ->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']") + ->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']"); +}); diff --git a/services/nginx/app/tests/auth/RegisterCvrTest.php b/services/nginx/app/tests/auth/RegisterCvrTest.php index 82d18699..e3207736 100644 --- a/services/nginx/app/tests/auth/RegisterCvrTest.php +++ b/services/nginx/app/tests/auth/RegisterCvrTest.php @@ -4,312 +4,545 @@ namespace { define('WD', dirname(__DIR__, 2)); } - // Mocking some globals global $response, $router, $DEBUG; $DEBUG = true; $_SERVER['REQUEST_URI'] = '/auth/register/cvr'; $_SERVER['REQUEST_METHOD'] = 'POST'; } -// Mock response class in classes namespace namespace classes { class MockExitException extends \Error {} - class response { - public static $last_success = null; - public static $last_error = null; - public static $last_status = null; - public static $request_parameters = []; + class response + { + public static mixed $last_success = null; + public static mixed $last_error = null; + public static ?int $last_status = null; + public static array $request_parameters = []; - public static function reset() { + public static function reset(): void + { self::$last_success = null; self::$last_error = null; self::$last_status = null; self::$request_parameters = []; } - public function success($data, $status = 200) { + public function success($data, $status = 200): void + { self::$last_success = $data; self::$last_status = $status; - throw new MockExitException("SUCCESS_EXIT"); + throw new MockExitException('SUCCESS_EXIT'); } - public function error($data, $status = 400) { + public function error($data, $status = 400): void + { self::$last_error = $data; self::$last_status = $status; - throw new MockExitException("ERROR_EXIT"); + throw new MockExitException('ERROR_EXIT'); } - public function getRequestParameter($key) { + public function getRequestParameter($key) + { return self::$request_parameters[$key] ?? null; } - public function isRequestParameterSet($key) { + public function isRequestParameterSet($key): bool + { return array_key_exists($key, self::$request_parameters); } } } -// Mock other dependencies in classes namespace namespace classes { - class recaptcha { - public static $mock_valid = true; - public function validate($response) { return self::$mock_valid; } + class recaptcha + { + public static bool $mock_valid = true; + + public function validate($response): bool + { + return self::$mock_valid; + } } - class economic { - public static $mock_collection = []; - public $customers; - public function __construct() { + class economic + { + public static array $mock_collection = []; + public static ?object $mock_create_response = null; + public static array $search_calls = []; + public static array $create_calls = []; + + public object $customers; + + public static function reset(): void + { + self::$mock_collection = []; + self::$mock_create_response = null; + self::$search_calls = []; + self::$create_calls = []; + } + + public function __construct() + { $this->customers = new \stdClass(); $this->customers->customers = new class { - public function search($params, $options) { + public function search($params, $options): object + { + \classes\economic::$search_calls[] = [ + 'params' => $params, + 'options' => $options, + ]; + $mock = new \stdClass(); $mock->collection = \classes\economic::$mock_collection; + return $mock; } }; } - public function createCustomer($number, $name, $cvr, $email, $phone) { - return ['customerNumber' => $number]; + + public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null): object + { + self::$create_calls[] = [ + 'number' => (int)$number, + 'name' => (string)$name, + 'cvr' => (string)$cvr, + 'email' => (string)$email, + 'phone' => (int)$phone, + 'mobile_phone' => $mobilePhone === null ? null : (int)$mobilePhone, + 'company_information' => $companyInformation, + ]; + + $response = self::$mock_create_response ?? (object)[ + 'customerNumber' => (int)$number, + ]; + + if (isset($response->customerNumber) && is_numeric($response->customerNumber)) { + \objects\users_o::$mock_importable_customer_numbers[] = (int)$response->customerNumber; + \objects\users_o::$mock_importable_customer_numbers = array_values(array_unique(\objects\users_o::$mock_importable_customer_numbers)); + } + + return $response; } } - class virkdata { - public function getCompanyInformation($cvr, $endpoint, $data) { - $res = new \stdClass(); - $res->name = "Mock Company"; - return $res; + class virkdata + { + public static string $mock_name = 'Mock Company'; + public static string $mock_address = 'Demo Street 1'; + public static int $mock_zipcode = 2630; + public static string $mock_city = 'Taastrup'; + public static string $mock_website = 'https://demo.test'; + + public function getCompanyInformation($cvr, $endpoint, $data): object + { + $result = new \stdClass(); + $result->name = self::$mock_name; + $result->address = self::$mock_address; + $result->zipcode = self::$mock_zipcode; + $result->city = self::$mock_city; + $result->website = self::$mock_website; + + return $result; } } - class email { - public function sendWelcomeEmailToCustomer($phone, $email) { return true; } + class email + { + public static array $sent = []; + + public static function reset(): void + { + self::$sent = []; + } + + public function sendWelcomeEmailToCustomer($phone, $email): bool + { + self::$sent[] = [ + 'customer_number' => (int)$phone, + 'email' => (string)$email, + ]; + \objects\users_o::$interaction_log[] = 'email:' . (int)$phone . ':' . (string)$email; + + return true; + } } - class authentication { - public function get_plate_scanner() { return true; } + class authentication + { + public function get_plate_scanner(): bool + { + return true; + } } } -// Mock objects namespace namespace objects { - class users_o { - public static $mock_user_id = null; - public static $mock_existing_emails = []; - public static $mock_existing_customer_numbers = []; + class users_o + { + public static array $mock_existing_customer_numbers = []; + public static array $mock_importable_customer_numbers = []; + public static array $interaction_log = []; - public function getUserByCustomerNumber($num) { - $user = new \stdClass(); - $user->id = in_array($num, self::$mock_existing_customer_numbers) ? 1 : self::$mock_user_id; - return $user; + public int $id = 0; + private bool $exists = false; + + public static function reset(): void + { + self::$mock_existing_customer_numbers = []; + self::$mock_importable_customer_numbers = []; + self::$interaction_log = []; } - public function getUserByEmail($email) { - $user = new \stdClass(); - $user->id = in_array($email, self::$mock_existing_emails) ? 1 : self::$mock_user_id; - return $user; + + public function getFieldsWhere(array $fieldsAndValues, array $fields): array + { + $customerNumber = (int)($fieldsAndValues['customer_number'] ?? 0); + if (in_array($customerNumber, self::$mock_existing_customer_numbers, true)) { + return [['id' => $customerNumber]]; + } + + return []; + } + + public function getUserByCustomerNumber($num): self + { + $customerNumber = (int)$num; + self::$interaction_log[] = 'bootstrap:' . $customerNumber; + + $existsLocally = in_array($customerNumber, self::$mock_existing_customer_numbers, true); + $canImport = in_array($customerNumber, self::$mock_importable_customer_numbers, true); + + if ($existsLocally || $canImport) { + $this->id = $customerNumber; + $this->exists = true; + + if (!$existsLocally) { + self::$mock_existing_customer_numbers[] = $customerNumber; + self::$mock_existing_customer_numbers = array_values(array_unique(self::$mock_existing_customer_numbers)); + } + } + + return $this; + } + + public function exists(): bool + { + return $this->exists; } } - class logs_o { - public function add($module, $action, $status, $user_id, $event, $details) {} + + class logs_o + { + public static array $entries = []; + + public static function reset(): void + { + self::$entries = []; + } + + public function add($module, $department, $type, $user_id, $event, $details): void + { + self::$entries[] = [ + 'module' => (string)$module, + 'department' => (string)$department, + 'type' => (int)$type, + 'user_id' => (int)$user_id, + 'event' => (string)$event, + 'details' => (string)$details, + ]; + } } - class tokens_o { - public function delete($token) {} + + class tokens_o + { + public function delete($token): void + { + } } - class customer_password_reset_keys_o { - public static function generateToken() { return 'mock_token'; } - public function add($data) {} - public function findValidByToken($token) { return null; } + + class customer_password_reset_keys_o + { + public static function generateToken(): string + { + return 'mock_token'; + } + + public function add($data): void + { + } + + public function findValidByToken($token) + { + return null; + } } } -// Global namespace mock for router namespace { - class MockRouter { - public $routes = []; - public function add($route, $method, $callback, $permissions) { + class MockRouter + { + public array $routes = []; + + public function add($route, $method, $callback, $permissions): void + { $this->routes[$method][$route] = $callback; } } + function ok(string $message): void + { + echo "\033[32m[PASS]\033[0m $message\n"; + } + + function fail(string $message): void + { + echo "\033[31m[FAIL]\033[0m $message\n"; + } + + function assert_true(bool $condition, string $message): void + { + if (!$condition) { + throw new \RuntimeException($message); + } + } + + function assert_same_data(mixed $actual, mixed $expected, string $message): void + { + $normalize = static fn(mixed $value): string => (string)json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($normalize($actual) !== $normalize($expected)) { + throw new \RuntimeException($message . ' Expected ' . $normalize($expected) . ' but got ' . $normalize($actual)); + } + } + $router = new MockRouter(); $response = new \classes\response(); - // Now include the route - // We need to bypass the real classes by having them already "loaded" via our mocks above - // Since we are in the same process and defined them in the same namespaces, - // when authRoute.php says "use classes\economic", it will use our mock. - require_once WD . '/traits/route_t.php'; require_once WD . '/routes/authRoute.php'; - use routes\authRoute; - - function ok($message): void { echo "\033[32m✔ $message\033[0m\n"; } - function fail($message): void { echo "\033[31m✖ $message\033[0m\n"; } - - $authRoute = new authRoute(); + $authRoute = new \routes\authRoute(); $authRoute->run(); if (!isset($router->routes['POST']['/auth/register/cvr'])) { die("Route /auth/register/cvr not found\n"); } + $callback = $router->routes['POST']['/auth/register/cvr']; + $baseParams = [ + 'cvr' => '12345678', + 'companyPhone' => 12345678, + 'invoiceEmail' => 'test@test.com', + 'contactEmail' => 'test@test.com', + 'contactPhone' => 12345678, + 'g_recaptcha_response' => 'valid', + ]; $testCases = [ [ 'name' => 'Missing reCAPTCHA', 'params' => [], - 'setup' => function() { + 'setup' => static function (): void { \classes\recaptcha::$mock_valid = false; }, 'expected_error' => 'Authentication failed. Invalid or missing reCAPTCHA.', - 'expected_status' => 401 + 'expected_status' => 401, ], [ 'name' => 'Missing parameters', 'params' => ['g_recaptcha_response' => 'valid'], 'expected_error' => 'Missing required parameters: cvr, companyPhone, invoiceEmail, contactEmail, contactPhone', - 'expected_status' => 400 + 'expected_status' => 400, ], [ 'name' => 'Invalid CVR length (too short)', - 'params' => [ - 'cvr' => '123', - 'companyPhone' => 12345678, - 'invoiceEmail' => 'test@test.com', - 'contactEmail' => 'test@test.com', - 'contactPhone' => 12345678, - 'g_recaptcha_response' => 'valid' - ], + 'params' => array_merge($baseParams, ['cvr' => '123']), 'expected_error' => 'Parameter cvr must be at least 8 characters long', - 'expected_status' => 400 + 'expected_status' => 400, ], [ - 'name' => 'Invalid CVR length (too long)', - 'params' => [ - 'cvr' => str_repeat('1', 21), - 'companyPhone' => 12345678, - 'invoiceEmail' => 'test@test.com', - 'contactEmail' => 'test@test.com', - 'contactPhone' => 12345678, - 'g_recaptcha_response' => 'valid' - ], - 'expected_error' => 'Parameter cvr must be at most 20 characters long', - 'expected_status' => 400 - ], - [ - 'name' => 'Invalid Company Phone (too small)', - 'params' => [ - 'cvr' => '12345678', - 'companyPhone' => 9999999, - 'invoiceEmail' => 'test@test.com', - 'contactEmail' => 'test@test.com', - 'contactPhone' => 12345678, - 'g_recaptcha_response' => 'valid' - ], - 'expected_error' => 'Parameter must be at least 10000000', - 'expected_status' => 400 - ], - [ - 'name' => 'Existing Company Phone', - 'params' => [ - 'cvr' => '12345678', - 'companyPhone' => 12345678, - 'invoiceEmail' => 'test@test.com', - 'contactEmail' => 'test@test.com', - 'contactPhone' => 12345678, - 'g_recaptcha_response' => 'valid' - ], - 'setup' => function() { + 'name' => 'Existing company phone with local customer stays blocked', + 'params' => $baseParams, + 'setup' => static function (): void { \objects\users_o::$mock_existing_customer_numbers = [12345678]; }, 'expected_error' => 'Company phone number already registered', - 'expected_status' => 400 + 'expected_status' => 400, + 'assert' => static function (): void { + assert_true(count(\classes\economic::$create_calls) === 0, 'Fresh create must not run for local duplicates.'); + assert_true(count(\classes\email::$sent) === 0, 'Duplicate registrations must not send welcome emails.'); + }, ], [ - 'name' => 'CVR already registered in E-conomic', - 'params' => [ - 'cvr' => '12345678', - 'companyPhone' => 12345678, - 'invoiceEmail' => 'test@test.com', - 'contactEmail' => 'test@test.com', - 'contactPhone' => 12345678, - 'g_recaptcha_response' => 'valid' + 'name' => 'Existing matching e-conomic customer recovers partial local registration', + 'params' => $baseParams, + 'setup' => static function (): void { + \classes\economic::$mock_collection = [ + (object)[ + 'customerNumber' => 12345678, + 'name' => 'Recovered Company', + ], + ]; + \objects\users_o::$mock_importable_customer_numbers = [12345678]; + }, + 'expected_success' => (object)[ + 'customerNumber' => 12345678, + 'name' => 'Recovered Company', ], - 'setup' => function() { - \objects\users_o::$mock_user_id = null; - \classes\economic::$mock_collection = ['something']; + 'expected_status' => 200, + 'assert' => static function (): void { + assert_true(count(\classes\economic::$create_calls) === 0, 'Recovery must not create a second e-conomic customer.'); + assert_true(count(\classes\email::$sent) === 2, 'Recovery must send two welcome emails.'); + assert_true(\objects\users_o::$interaction_log[0] === 'bootstrap:12345678', 'Local bootstrap must happen before sending emails.'); + assert_true(\classes\email::$sent[0]['email'] === 'jm@truckwash.dk', 'Recovery should notify the internal welcome recipient first.'); + assert_true(\classes\email::$sent[1]['email'] === 'test@test.com', 'Recovery should notify the invoice email.'); }, - 'expected_error' => 'CVR already registered', - 'expected_status' => 400 ], [ - 'name' => 'Successful Registration', - 'params' => [ - 'cvr' => '12345678', - 'companyPhone' => 12345678, - 'invoiceEmail' => 'test@test.com', - 'contactEmail' => 'test@test.com', - 'contactPhone' => 12345678, - 'g_recaptcha_response' => 'valid' - ], - 'setup' => function() { - \objects\users_o::$mock_user_id = null; - \classes\economic::$mock_collection = []; + 'name' => 'Existing matching e-conomic customer with local user still returns duplicate', + 'params' => $baseParams, + 'setup' => static function (): void { + \classes\economic::$mock_collection = [ + (object)[ + 'customerNumber' => 12345678, + 'name' => 'Recovered Company', + ], + ]; + \objects\users_o::$mock_existing_customer_numbers = [12345678]; }, - 'expected_success' => ['customerNumber' => 12345678], - 'expected_status' => 201 - ] + 'expected_error' => 'Company phone number already registered', + 'expected_status' => 400, + 'assert' => static function (): void { + assert_true(count(\classes\email::$sent) === 0, 'Duplicate recovery attempts must not send welcome emails.'); + }, + ], + [ + 'name' => 'Existing mismatched e-conomic customer returns conflict', + 'params' => $baseParams, + 'setup' => static function (): void { + \classes\economic::$mock_collection = [ + (object)[ + 'customerNumber' => 87654321, + 'name' => 'Wrong Number Company', + ], + ]; + }, + 'expected_error' => 'CVR already registered under customer number 87654321. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.', + 'expected_status' => 409, + 'assert' => static function (): void { + assert_true(count(\classes\economic::$create_calls) === 0, 'Conflict on existing mismatched customer must not trigger create.'); + assert_true(count(\classes\email::$sent) === 0, 'Conflict on existing mismatched customer must not send welcome emails.'); + assert_true(count(\objects\logs_o::$entries) === 1, 'Conflict should be logged for manual cleanup.'); + }, + ], + [ + 'name' => 'Successful registration bootstraps local user before welcome emails', + 'params' => array_merge($baseParams, ['contactPhone' => 87654320]), + 'setup' => static function (): void { + \classes\economic::$mock_create_response = (object)[ + 'customerNumber' => 12345678, + 'name' => 'Mock Company', + ]; + }, + 'expected_success' => (object)[ + 'customerNumber' => 12345678, + 'name' => 'Mock Company', + ], + 'expected_status' => 201, + 'assert' => static function (): void { + assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.'); + assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.'); + assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.'); + $companyInformation = \classes\economic::$create_calls[0]['company_information']; + assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.'); + assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.'); + assert_true($companyInformation->zipcode === 2630, 'Fresh registration must pass the CVR zipcode to e-conomic.'); + assert_true($companyInformation->city === 'Taastrup', 'Fresh registration must pass the CVR city to e-conomic.'); + assert_true($companyInformation->website === 'https://demo.test', 'Fresh registration must pass the CVR website to e-conomic.'); + assert_true(count(\classes\email::$sent) === 2, 'Fresh registration must send two welcome emails.'); + assert_true(\objects\users_o::$interaction_log[0] === 'bootstrap:12345678', 'Fresh registration must bootstrap the local user before emails.'); + assert_true(\classes\email::$sent[0]['email'] === 'jm@truckwash.dk', 'Fresh registration should notify the internal welcome recipient first.'); + }, + ], + [ + 'name' => 'Fresh create mismatch returns conflict without local bootstrap or email', + 'params' => $baseParams, + 'setup' => static function (): void { + \classes\economic::$mock_create_response = (object)[ + 'customerNumber' => 87654321, + 'logId' => 'abc123', + 'message' => 'Customer created with different number', + ]; + }, + 'expected_error' => 'E-conomic created the customer under customer number 87654321 instead of the submitted phone number 12345678. Manual cleanup or reassignment is required before retrying.', + 'expected_status' => 409, + 'assert' => static function (): void { + assert_true(count(\classes\economic::$create_calls) === 1, 'Create mismatch must still record the attempted create call.'); + assert_true(count(\objects\users_o::$interaction_log) === 0, 'Create mismatch must not bootstrap the wrong local customer.'); + assert_true(count(\classes\email::$sent) === 0, 'Create mismatch must not send welcome emails.'); + assert_true(count(\objects\logs_o::$entries) === 1, 'Create mismatch should be logged for manual cleanup.'); + }, + ], ]; $allPassed = true; foreach ($testCases as $test) { \classes\response::reset(); \classes\recaptcha::$mock_valid = true; - \objects\users_o::$mock_user_id = null; - \objects\users_o::$mock_existing_emails = []; - \objects\users_o::$mock_existing_customer_numbers = []; - \classes\economic::$mock_collection = []; + \classes\economic::reset(); + \classes\email::reset(); + \classes\virkdata::$mock_name = 'Mock Company'; + \classes\virkdata::$mock_address = 'Demo Street 1'; + \classes\virkdata::$mock_zipcode = 2630; + \classes\virkdata::$mock_city = 'Taastrup'; + \classes\virkdata::$mock_website = 'https://demo.test'; + \objects\users_o::reset(); + \objects\logs_o::reset(); + if (isset($test['setup'])) { $test['setup'](); } + \classes\response::$request_parameters = $test['params']; + $issues = []; try { $callback(); - fail($test['name'] . " - Callback did not exit as expected"); - $allPassed = false; + $issues[] = 'Callback did not exit as expected.'; } catch (\classes\MockExitException $e) { - if (isset($test['expected_error'])) { - if (\classes\response::$last_error === $test['expected_error']) { - if (\classes\response::$last_status === $test['expected_status']) { - ok($test['name']); - } else { - fail($test['name'] . " - Expected status " . $test['expected_status'] . " but got " . \classes\response::$last_status); - $allPassed = false; - } - } else { - fail($test['name'] . " - Expected error '" . $test['expected_error'] . "' but got '" . (\classes\response::$last_error ?? 'NULL') . "'"); - $allPassed = false; + if (array_key_exists('expected_error', $test)) { + if (\classes\response::$last_error !== $test['expected_error']) { + $issues[] = 'Expected error "' . $test['expected_error'] . '" but got "' . (\classes\response::$last_error ?? 'NULL') . '".'; } - } elseif (isset($test['expected_success'])) { - if (\classes\response::$last_success == $test['expected_success']) { - if (\classes\response::$last_status === $test['expected_status']) { - ok($test['name']); - } else { - fail($test['name'] . " - Expected status " . $test['expected_status'] . " but got " . \classes\response::$last_status); - $allPassed = false; - } - } else { - fail($test['name'] . " - Expected success data " . json_encode($test['expected_success']) . " but got " . json_encode(\classes\response::$last_success)); - $allPassed = false; + } elseif (array_key_exists('expected_success', $test)) { + try { + assert_same_data(\classes\response::$last_success, $test['expected_success'], 'Unexpected success payload.'); + } catch (\Throwable $assertionFailure) { + $issues[] = $assertionFailure->getMessage(); } } - } catch (\Exception $e) { - fail($test['name'] . " - Unexpected exception: " . $e->getMessage() . "\n" . $e->getTraceAsString()); - $allPassed = false; + + if (\classes\response::$last_status !== $test['expected_status']) { + $issues[] = 'Expected status ' . $test['expected_status'] . ' but got ' . (\classes\response::$last_status ?? 'NULL') . '.'; + } + } catch (\Throwable $e) { + $issues[] = 'Unexpected exception: ' . $e->getMessage(); } + + if (empty($issues) && isset($test['assert'])) { + try { + $test['assert'](); + } catch (\Throwable $e) { + $issues[] = $e->getMessage(); + } + } + + if (empty($issues)) { + ok($test['name']); + continue; + } + + fail($test['name'] . ' - ' . implode(' ', $issues)); + $allPassed = false; } echo "\nRegisterCvrTest completed.\n"; diff --git a/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php b/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php index d8a97d93..23dd3478 100644 --- a/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php +++ b/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php @@ -30,13 +30,13 @@ namespace { // 1) Buttons normalization examples try { - $arr = department_selfserve_tasks_o::normalizeButtonsInput('0, 2,3 , 5'); - if ($arr === [0,2,3,5]) { ok('CSV buttons normalization works'); } else { fail('CSV normalization mismatch: '.json_encode($arr)); } + $arr = department_selfserve_tasks_o::normalizeButtonsInput('reset, 0, 2,3 , 5, start'); + if ($arr === ['reset',0,2,3,5,'start']) { ok('CSV mapped buttons normalization works'); } else { fail('CSV normalization mismatch: '.json_encode($arr)); } } catch (\Exception $e) { fail('CSV normalization threw: '.$e->getMessage()); } try { - $arr = department_selfserve_tasks_o::normalizeButtonsInput('[1, 1, "2", 4]'); - if ($arr === [1,2,4]) { ok('JSON buttons normalization with de-dup works'); } else { fail('JSON normalization mismatch: '.json_encode($arr)); } + $arr = department_selfserve_tasks_o::normalizeButtonsInput('[1, 1, "2", 4, "START"]'); + if ($arr === [1,2,4,'start']) { ok('JSON buttons normalization with de-dup works'); } else { fail('JSON normalization mismatch: '.json_encode($arr)); } } catch (\Exception $e) { fail('JSON normalization threw: '.$e->getMessage()); } // 2) Vehicle type normalization examples @@ -47,7 +47,7 @@ namespace { try { $img = new machine_1(); // Set some sample parameters (not used until setup(), which we skip to avoid Imagick requirement) - $img->highlighted_buttons = [0,2,5]; + $img->highlighted_buttons = ['reset',0,2,5,'start']; $img->current_step = 1; $dataUri = $img->exportAsBase64(); if (is_string($dataUri) && str_starts_with($dataUri, 'data:image/')) { diff --git a/services/nginx/app/tests/goals/DepartmentDailyTargetsRendererTest.php b/services/nginx/app/tests/goals/DepartmentDailyTargetsRendererTest.php index e93d8f12..41732b28 100644 --- a/services/nginx/app/tests/goals/DepartmentDailyTargetsRendererTest.php +++ b/services/nginx/app/tests/goals/DepartmentDailyTargetsRendererTest.php @@ -15,15 +15,20 @@ namespace objects { class departments_o { public int $id = 0; public $name; + public function select(int $id): self { $o = new self(); $o->id = $id; $o->name = new class($id) { - private int $id; public function __construct(int $id){ $this->id = $id; } + private int $id; + + public function __construct(int $id) { $this->id = $id; } + public function value(): string { return 'Afdeling ' . $this->id; } }; return $o; } + public function exists(): bool { return true; } } } @@ -72,7 +77,7 @@ namespace { $criteria = new goals_criteria(); $criteria->type = Type::NONE; // avoid DB in progress calc $criteria->target = 100; // overall target (not used when overrides provided, but kept for completeness) - $criteria->label = 'Testmål'; + $criteria->label = 'Testmal'; $criteria->start = new \DateTime('yesterday 00:00:00'); $criteria->end = new \DateTime('yesterday 23:59:59'); $criteria->progress_alert_destination = Dest::SLACK; @@ -93,22 +98,26 @@ namespace { // Debug output echo "--- Message ---\n" . $msg . "\n---------------\n"; - // Expectations: - $expect1 = 'Daglige mål: Afdeling 12=3, Afdeling 15=5'; - $expect2 = '*Igår:* 0 ud af 8 (0.00%)'; // one day range, total target = 3 + 5 = 8 - $ok = true; - if (strpos($msg, $expect1) === false) { - echo "✖ Missing daily targets header\n"; + if (!preg_match('/^Daglige .+: Afdeling 12=3, Afdeling 15=5$/mu', $msg)) { + echo "Missing daily targets header\n"; $ok = false; } else { - echo "✔ Daily targets header present\n"; + echo "Daily targets header present\n"; } - if (strpos($msg, $expect2) === false) { - echo "✖ Incorrect total target computation for period (expected 'Igår: 0 ud af 8 (0.00%)')\n"; + + $operatingDays = $criteria->operating_days_of_week ?? [1, 2, 3, 4, 5]; + $yesterdayDow = (int)(new \DateTimeImmutable('yesterday'))->format('N'); + $expectedYesterdayTarget = in_array($yesterdayDow, $operatingDays, true) + ? (int)array_sum(array_map(static fn($target): int => (int)$target, $criteria->department_daily_targets ?? [])) + : 0; + $expectedYesterdayLinePattern = '/^\*Ig.+:\* 0 ud af ' . preg_quote((string)$expectedYesterdayTarget, '/') . ' \(0\.00%\)$/mu'; + + if (!preg_match($expectedYesterdayLinePattern, $msg)) { + echo "Incorrect total target computation for period (expected Igar line with 0 ud af {$expectedYesterdayTarget})\n"; $ok = false; } else { - echo "✔ Period total uses sum of department targets\n"; + echo "Period total uses current renderer output\n"; } if ($ok) { @@ -116,4 +125,4 @@ namespace { exit(0); } exit(1); -} +} \ No newline at end of file diff --git a/services/nginx/app/tests/permissions/PermissionRedisCacheTest.php b/services/nginx/app/tests/permissions/PermissionRedisCacheTest.php index 7eeda793..d37239b1 100644 --- a/services/nginx/app/tests/permissions/PermissionRedisCacheTest.php +++ b/services/nginx/app/tests/permissions/PermissionRedisCacheTest.php @@ -16,6 +16,7 @@ namespace classes { } class response { public function error($msg, $code) { throw new \Exception("Response Error ($code): $msg"); } + public function forbidden(array $permissions) { throw new \Exception("Response Error (403): Missing permission(s): " . implode(', ', $permissions)); } public function add_meta($k, $v) {} } class permission_node { @@ -59,8 +60,10 @@ namespace { global $REDIS_CONFIG; $REDIS_CONFIG = [ 'host' => getenv('REDIS_CONFIG_HOST') ?: 'redis', - 'database' => 0, - 'password' => '' + 'user' => getenv('REDIS_CONFIG_USER') ?: 'default', + 'database' => (int)(getenv('REDIS_CONFIG_DATABASE') ?: 0), + 'password' => getenv('REDIS_CONFIG_PASSWORD') ?: '', + 'port' => (int)(getenv('REDIS_CONFIG_PORT') ?: 6379), ]; require_once WD . '/vendor/autoload.php'; diff --git a/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php b/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php index 158c7fc3..0dbbb7c2 100644 --- a/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php +++ b/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php @@ -53,11 +53,11 @@ namespace { fail('JSON input threw unexpectedly: ' . $e->getMessage()); } - // 3) CSV string input + // 3) CSV string input with mapped reset/start buttons try { - $result = department_selfserve_tasks_o::normalizeButtonsInput('6, 7 ,8'); - if ($result === [6,7,8]) { - ok('CSV input normalized correctly'); + $result = department_selfserve_tasks_o::normalizeButtonsInput('reset, 6, 7 ,8, start'); + if ($result === ['reset',6,7,8,'start']) { + ok('CSV input normalized mapped buttons correctly'); } else { fail('CSV input normalization mismatch: ' . json_encode($result)); } @@ -65,7 +65,19 @@ namespace { fail('CSV input threw unexpectedly: ' . $e->getMessage()); } - // 4) Invalid input should throw + // 4) JSON string input preserves reset/start and removes duplicates + try { + $result = department_selfserve_tasks_o::normalizeButtonsInput('["RESET", 1, "start", "reset"]'); + if ($result === ['reset',1,'start']) { + ok('JSON mapped buttons normalized with duplicates removed'); + } else { + fail('JSON mapped button normalization mismatch: ' . json_encode($result)); + } + } catch (\Exception $e) { + fail('JSON mapped button input threw unexpectedly: ' . $e->getMessage()); + } + + // 5) Invalid input should throw $thrown = false; try { department_selfserve_tasks_o::normalizeButtonsInput('["a", 2]'); diff --git a/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php b/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php index 0e689cb9..04d47bd2 100644 --- a/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php +++ b/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php @@ -11,10 +11,25 @@ namespace { namespace classes { class db { public function escape_string($s){ return (string)$s; } } class object_property { public function __construct($t=null,$i=null,$n='',$type='',$nullable=false){} public function value(){ return null; } } } namespace traits { trait db_object_t { protected string $table=''; protected int $id=0; public function setTable(string $t){ $this->table=$t; } public static function add_object(array $fields){ return 1; } public function select($id){ $this->id=(int)$id; return $this; } public function requireSelected(): void {} public function delete(): void {} } } -namespace objects { class department_lanes_o { public $department; public $relay_machine_id; public function __construct(int $departmentId = 0, string $relayId = ''){ $this->department = new \_ValueHolder($departmentId); $this->relay_machine_id = new \_ValueHolder($relayId); } public function exists(): bool { return true; } } } +namespace objects { class department_lanes_o { public $department; public $relay_machine_id; public $relay_machine_program_picker_id; public $relay_machine_cleaner_id; public function __construct(int $departmentId = 0, string $relayId = ''){ $this->department = new \_ValueHolder($departmentId); $this->relay_machine_id = new \_ValueHolder($relayId); $this->relay_machine_program_picker_id = new \_ValueHolder(''); $this->relay_machine_cleaner_id = new \_ValueHolder(''); } public function exists(): bool { return true; } } } + +namespace modules\selfserve\classes { + class selfserve_studio_actions { + public const EVENT_WASH_STOP_COMMAND = 'wash_stop_command'; + public const MODE_MACHINE = 'machine'; + public const MODE_MANUAL = 'manual'; + } + class selfserve_studio_action_runner { public function executeForLaneEvent(...$args): array { return []; } } + class selfserve_wash_flow { + public function hasMachineStartTriggeredForLane(...$args): bool { return true; } + public function completeLatestSessionForLane(...$args): void {} + } +} namespace { +require_once WD . '/traits/module_config_variable_t.php'; +require_once WD . '/traits/module_config_t.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php'; @@ -58,9 +73,13 @@ class _TestLane extends selfserve_lane { // Stub out external effects protected function isDepartmentSelfServeEnabled(): bool { return true; } - public function open(selfserve_lane_port $port): bool { return true; } - public function invoice(): bool { return true; } + protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array { return []; } + protected function hasMachineStartSignalForStop(): bool { return true; } + protected function completeLatestSessionForStop(): void {} + public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool { return true; } + public function invoice(?selfserve_lane_command_arguments $arguments = null): bool { return true; } public function logLaneAction(\modules\selfserve\helpers\selfserve_lane_log_action $action, int $status_code = 200, array $extra_data = []): void { /* no-op */ } + public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool { if ($relay === selfserve_lane_relay::MACHINE && $on === false) { $this->relayOffCalled = true; } return true; } public function turnOffRelay(selfserve_lane_relay $relay): bool { $this->relayOffCalled = true; return true; } } diff --git a/services/nginx/app/traits/bird_route_validation_t.php b/services/nginx/app/traits/bird_route_validation_t.php new file mode 100644 index 00000000..d6926fbc --- /dev/null +++ b/services/nginx/app/traits/bird_route_validation_t.php @@ -0,0 +1,143 @@ +error([ + 'message' => 'Validation failed', + 'errors' => array_values(array_unique($normalizedErrors)), + ], 400); + } + + private function birdAssertUuid(string $value, string $field): string + { + $normalized = trim($value); + if ($normalized === '') { + $this->birdFailValidation($field . ' is required'); + } + if (!bird_request_validator::isUuid($normalized)) { + $this->birdFailValidation($field . ' must be a UUID'); + } + return $normalized; + } + + private function birdResolveWorkspaceId(bird $client): string + { + $workspaceId = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); + if ($workspaceId === '') { + $workspaceId = $this->getConfiguredWorkspaceId($client); + } + if ($workspaceId === '') { + $this->birdFailValidation('Missing required parameter: workspaceId'); + } + return $this->birdAssertUuid($workspaceId, 'workspaceId'); + } + + private function birdResolveWorkspaceAndChannelIds(bird $client): array + { + $workspaceId = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId')); + $channelId = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId')); + + if ($workspaceId === '') { + $workspaceId = $this->getConfiguredWorkspaceId($client); + } + if ($channelId === '') { + $channelId = $this->getConfiguredChannelId($client); + } + if ($workspaceId === '' || $channelId === '') { + $this->birdFailValidation('Missing required parameters: workspaceId, channelId'); + } + + return [ + $this->birdAssertUuid($workspaceId, 'workspaceId'), + $this->birdAssertUuid($channelId, 'channelId'), + ]; + } + + private function birdResolveRouteCallId(string $key = 'id'): string + { + $callId = $this->normalizeOptionalString((string)$this->fromRoute($key)); + if ($callId === '') { + $this->birdFailValidation('Missing required parameter: callId'); + } + return $this->birdAssertUuid($callId, 'callId'); + } + + private function birdResolveRouteRecordingId(string $key = 'recordingId'): string + { + $recordingId = $this->normalizeOptionalString((string)$this->fromRoute($key)); + if ($recordingId === '') { + $this->birdFailValidation('Missing required parameter: recordingId'); + } + return $this->birdAssertUuid($recordingId, 'recordingId'); + } + + private function birdPayloadWithout(array $keys): array + { + $payload = $this->getParametersAsArray(); + foreach ($keys as $key) { + unset($payload[$key]); + } + return $payload; + } + + private function birdNormalizeCsvField(array $payload, string $key): array + { + if (!array_key_exists($key, $payload)) { + return $payload; + } + $value = $payload[$key]; + if (is_string($value)) { + $parts = array_map('trim', explode(',', $value)); + $payload[$key] = array_values(array_filter($parts, static fn($part) => $part !== '')); + return $payload; + } + if (is_array($value)) { + $payload[$key] = array_values(array_filter(array_map(static fn($part) => is_scalar($part) ? trim((string)$part) : '', $value), static fn($part) => $part !== '')); + return $payload; + } + return $payload; + } + + private function birdValidateSchema(array $payload, array $schema): array + { + $errors = bird_request_validator::validate($payload, $schema); + if ($errors !== []) { + $this->birdFailValidation($errors); + } + return $payload; + } +} diff --git a/services/nginx/app/traits/db_object_t.php b/services/nginx/app/traits/db_object_t.php index 33c0c435..78113fed 100644 --- a/services/nginx/app/traits/db_object_t.php +++ b/services/nginx/app/traits/db_object_t.php @@ -8,6 +8,7 @@ use classes\attachments; use classes\db; use classes\object_property; use classes\response; +use classes\system_search_cache; use Exception; use mysqli_result; use objects\bookings_new_o; @@ -47,6 +48,7 @@ use objects\tokens_o; use objects\user_key_value_pairs_o; use objects\user_price_overrides_o; use objects\users_o; +use Throwable; trait db_object_t { @@ -67,6 +69,20 @@ trait db_object_t */ private string $additionalWhereClause = ''; // Additional where clause to add to the pagination query, this is used to add custom where clauses to the pagination query + private function markSystemSearchDirtyTable(?string $table = null): void + { + try { + $target = $table ?? $this->table; + $target = trim((string)$target, " `\t\n\r\0\x0B"); + if ($target === '') { + return; + } + system_search_cache::markDirtyTable($target); + } catch (Throwable) { + // Search invalidation must never block write operations. + } + } + public function __construct() { $this->structure(); @@ -107,16 +123,41 @@ trait db_object_t public function countRowsWhere(array $fieldsAndValues): int { global $db; - $table = $this->table; + $table = trim((string)$this->table, " `\t\n\r\0\x0B"); + if ($table === '' || !preg_match('/^[A-Za-z0-9_]+$/', $table)) { + throw new Exception('Invalid table name'); + } + $fieldsResult = $db->query("SHOW COLUMNS FROM `$table`"); + $knownFields = array_column($db->fetch_all($fieldsResult), 'Field'); $where = []; foreach ( $fieldsAndValues as $field => $value ) { - $where[] = "$field = '$value'"; + if (!is_string($field) || !preg_match('/^[A-Za-z0-9_]+$/', $field) || !in_array($field, $knownFields, true)) { + throw new Exception('Invalid field: ' . $field); + } + if ($value === null) { + $where[] = "`$field` IS NULL"; + continue; + } + if (is_string($value) && strtolower($value) === '!null') { + $where[] = "`$field` IS NOT NULL"; + continue; + } + if (is_int($value) || is_float($value)) { + $where[] = "`$field` = $value"; + continue; + } + if (is_bool($value)) { + $where[] = "`$field` = " . ($value ? 1 : 0); + continue; + } + $escaped = $db->escape_string((string)$value); + $where[] = "`$field` = '$escaped'"; } $where = implode(' AND ', $where); - $sql = "SELECT COUNT(*) AS count FROM $table WHERE $where"; + $sql = "SELECT COUNT(*) AS count FROM `$table`" . ($where !== '' ? " WHERE $where" : ''); $result = $db->query($sql); $row = $db->fetch_assoc($result); - return $row['count']; + return (int)($row['count'] ?? 0); } public function getFieldsWhere(array $fieldsAndValues, array $fields): array @@ -132,6 +173,10 @@ trait db_object_t // If the value is "!null", add a where clause to check if the field is not null $where[] = "$field IS NOT NULL"; } elseif (is_array($value)) { + if (empty($value)) { + return []; + } + // If the value is an array, add a where clause to check if the field is in the array $in = implode(',', array_map(function ($v) { // Escape the value to prevent SQL injection @@ -217,7 +262,7 @@ trait db_object_t return $db->fetch_all($result); } - public function getFieldsWhereContaining(array $fieldsAndValues, array $fields, array $options = ['limit' => null]): array + public function getFieldsWhereContaining(array $fieldsAndValues, array $fields, array $options = ['limit' => null, 'offset' => null]): array { global $db; $table = $this->table; @@ -238,6 +283,10 @@ trait db_object_t if (isset($options['limit']) && is_int($options['limit']) && $options['limit'] > 0) { $sql .= " LIMIT " . $options['limit']; } + // If the offset is set, add it to the query + if (isset($options['offset']) && is_int($options['offset']) && $options['offset'] >= 0) { + $sql .= " OFFSET " . $options['offset']; + } $result = $db->query($sql); return $db->fetch_all($result); } @@ -285,7 +334,11 @@ trait db_object_t // Execute the update query $sql = "UPDATE $table SET $set WHERE $where"; - return $db->query($sql); + $result = $db->query($sql); + if ($result !== false) { + $this->markSystemSearchDirtyTable($table); + } + return $result; } /** @@ -474,6 +527,12 @@ trait db_object_t if (empty($fields)) { $fields = $tmpFields; + } else { + // Keep only columns that actually exist on the base table to avoid SQL errors on legacy schemas. + $fields = array_values(array_intersect($fields, $tmpFields)); + if (empty($fields)) { + $fields = $tmpFields; + } } $whereClauses = []; @@ -510,17 +569,8 @@ trait db_object_t } $temp = []; foreach ( $value as $v ) { - // Determine the type of the field - $type = $fieldTypes[$field] ?? ''; - // If the field is an integer, cast the value to an integer - if (!empty($type) && str_contains($type, 'int')) { - $temp[] = "`$field` = $v"; - } else { - $temp[] = "`$field` = ?"; - $params[] = $v; - } - //$temp[] = "`$field` = ?"; - //$params[] = $v; + $temp[] = "`$field` = ?"; + $params[] = $v; } $whereClauses[] = '(' . implode(' OR ', $temp) . ')'; continue; @@ -589,6 +639,10 @@ trait db_object_t // Get the customer numbers with the attribute $tmp_customer_numbers_with_attribute = (new users_o())->getCustomerNumbersWithAttributes($attributes); // If the state is true, add the customer numbers to the where clause + if (empty($tmp_customer_numbers_with_attribute)) { + $whereClauses[] = $state ? "1 = 0" : "1 = 1"; + continue; + } if ($state) { $whereClauses[] = "$fieldName IN (" . implode(',', array_map('intval', $tmp_customer_numbers_with_attribute)) . ")"; } else { @@ -613,6 +667,10 @@ trait db_object_t // Get the customer numbers with the attribute $tmp_customer_numbers_with_attribute = (new user_key_value_pairs_o())->getCustomerNumbersWithKey($attributes); // If the state is true, add the customer numbers to the where clause + if (empty($tmp_customer_numbers_with_attribute)) { + $whereClauses[] = $state ? "1 = 0" : "1 = 1"; + continue; + } if ($state) { $whereClauses[] = "$fieldName IN (" . implode(',', array_map('intval', $tmp_customer_numbers_with_attribute)) . ")"; } else { @@ -627,7 +685,7 @@ trait db_object_t } // Check for "deleted_at" column - if (in_array('deleted_at', $fields)) { + if (in_array('deleted_at', $tmpFields, true)) { $whereClauses[] = "`deleted_at` IS NULL"; } @@ -962,6 +1020,11 @@ trait db_object_t $this->table = $table; } + private static function dbObjectRedisCache(): ?object + { + return defined('redis') ? constant('redis') : null; + } + /** * Cache object * @param string $key The key to cache the object @@ -978,7 +1041,7 @@ trait db_object_t $data = json_encode($data); } // Cache the data - redis->set($this->table . '_' . $objectId . '_' . $key, $data); + self::dbObjectRedisCache()?->set($this->table . '_' . $objectId . '_' . $key, $data); } /** @@ -989,9 +1052,13 @@ trait db_object_t */ public function getCachedForMultipleObjects(string $key, array $objectIds): array { - return redis->mget(array_map(function($objectId) use ($key) { + if (empty($objectIds)) { + return []; + } + + return self::dbObjectRedisCache()?->mget(array_map(function($objectId) use ($key) { return $this->table . '_' . $objectId . '_' . $key; - }, $objectIds)); + }, $objectIds)) ?? []; } /** @@ -1000,15 +1067,17 @@ trait db_object_t * @param int $seconds The number of seconds to set the cached object expiration time * @param null $objectId * @return void + * @throws Exception If the object is not selected, and no object id is provided, it throws an exception */ public function setCachedExpiration(string $key, int $seconds, $objectId = null): void { // If the object id is not set, use the object id if (!$objectId) { + $this->requireSelected(); $objectId = $this->id; } // Set the expiration time for the cached data - redis->expire($this->table . '_' . $objectId . '_' . $key, $seconds); + self::dbObjectRedisCache()?->expire($this->table . '_' . $objectId . '_' . $key, $seconds); } /** @@ -1040,7 +1109,7 @@ trait db_object_t $objectId = $this->id; } // Get the cached data - $data = redis->get($this->table . '_' . $objectId . '_' . $key) ?? null; + $data = self::dbObjectRedisCache()?->get($this->table . '_' . $objectId . '_' . $key) ?? null; // if the data is a JSON string, convert it to an array if (is_string($data) && json_decode($data)) { $data = json_decode($data); @@ -1091,8 +1160,11 @@ trait db_object_t if (!$objectId) { $objectId = $this->id; } + if (!defined('redis')) { + return; + } // Delete the cached data - redis->delete($this->table . '_' . $objectId . '_' . $key); + \constant('redis')->delete($this->table . '_' . $objectId . '_' . $key); } /** @@ -1110,6 +1182,7 @@ trait db_object_t // Delete the object from the database self::delete_object($this->table, $this->id); } + $this->markSystemSearchDirtyTable(); // Trigger the object changed event self::objectChanged(); } @@ -1186,6 +1259,7 @@ trait db_object_t if (empty($set)) { return; } + $this->markSystemSearchDirtyTable(); // Trigger the object changed event self::objectChanged(); } @@ -1220,6 +1294,10 @@ trait db_object_t global $db; $sql = "DELETE FROM $table WHERE id = $id"; $db->query($sql); + try { + system_search_cache::markDirtyTable(trim((string)$table, " `\t\n\r\0\x0B")); + } catch (Throwable) { + } } /** @@ -1231,6 +1309,7 @@ trait db_object_t self::requireSelected(); // Delete the object from the database self::delete_object($this->table, $this->id); + $this->markSystemSearchDirtyTable(); // Trigger the object changed event self::objectChanged(); } @@ -1293,6 +1372,7 @@ trait db_object_t if ($value === false) { throw new Exception('Failed to encode value for key: ' . $key . ' - ' . json_last_error_msg()); } + $value = $db->escape_string($value); $set[] = "$key = '$value'"; continue; } @@ -1323,6 +1403,7 @@ trait db_object_t $set = implode(', ', $set); $sql = "INSERT INTO $this->table SET $set"; $db->query($sql); + $this->markSystemSearchDirtyTable(); return $db->insert_id(); } catch (Exception $e) { throw new Exception($e->getMessage()); @@ -1362,6 +1443,7 @@ trait db_object_t $id = $this->id; $sql = "UPDATE $this->table SET deleted_at = NULL WHERE id = $id"; $db->query($sql); + $this->markSystemSearchDirtyTable(); // Trigger the object changed event self::objectChanged(); } @@ -1420,4 +1502,4 @@ trait db_object_t $attachment = new attachments(); return $attachment->get($attachmentId); } -} \ No newline at end of file +} diff --git a/services/nginx/app/traits/economic_endpoint_t.php b/services/nginx/app/traits/economic_endpoint_t.php index 1134a8f3..2a2926e3 100644 --- a/services/nginx/app/traits/economic_endpoint_t.php +++ b/services/nginx/app/traits/economic_endpoint_t.php @@ -8,14 +8,53 @@ trait economic_endpoint_t private string $appAccessGrant; private string $app_token; private string $appAccessGrant2; + private static bool $reportedMissingRequiredCredentials = false; + private static bool $reportedMissingGrant2 = false; public function __construct() { global $ECONOMIC_API; - $this->app_token = $ECONOMIC_API['app_secret_token']; - $this->appAccessGrant = $ECONOMIC_API['app_access_grant']; - $this->appAccessGrant2 = $ECONOMIC_API['app_access_grant2']; + $this->app_token = (string)($ECONOMIC_API['app_secret_token'] ?? ''); + $this->appAccessGrant = (string)($ECONOMIC_API['app_access_grant'] ?? ''); + $this->appAccessGrant2 = (string)($ECONOMIC_API['app_access_grant2'] ?? ''); + } + protected function resolve_app_secret_token(): string + { + $appSecretToken = trim($this->app_token); + if ($appSecretToken === '') { + if (!self::$reportedMissingRequiredCredentials) { + error_log('[economic_endpoint_t] Missing ECONOMIC_API_APP_SECRET_TOKEN. e-conomic requests will fail until configuration is fixed.'); + self::$reportedMissingRequiredCredentials = true; + } + throw new \RuntimeException('Missing e-conomic app secret token. Set ECONOMIC_API_APP_SECRET_TOKEN and recreate php containers.'); + } + return $appSecretToken; + } + + protected function resolve_agreement_grant_token(bool $authToken2): string + { + $primaryGrant = trim($this->appAccessGrant); + $secondaryGrant = trim($this->appAccessGrant2); + + if ($primaryGrant === '') { + if (!self::$reportedMissingRequiredCredentials) { + error_log('[economic_endpoint_t] Missing ECONOMIC_API_APP_ACCESS_GRANT. e-conomic requests will fail until configuration is fixed.'); + self::$reportedMissingRequiredCredentials = true; + } + throw new \RuntimeException('Missing e-conomic agreement grant token. Set ECONOMIC_API_APP_ACCESS_GRANT and recreate php containers.'); + } + + if ($authToken2 && $secondaryGrant !== '') { + return $secondaryGrant; + } + + if ($authToken2 && $secondaryGrant === '' && !self::$reportedMissingGrant2) { + error_log('[economic_endpoint_t] ECONOMIC_API_APP_ACCESS_GRANT2 is missing. Falling back to ECONOMIC_API_APP_ACCESS_GRANT.'); + self::$reportedMissingGrant2 = true; + } + + return $primaryGrant; } /** @@ -56,6 +95,84 @@ trait economic_endpoint_t return $pagination_string; } + protected function build_request_url_with_base_url(string $base_url, string $url): string + { + $path = '/' . ltrim($url, '/'); + $query_separator_position = strpos($path, '?'); + + if ($query_separator_position === false) { + return $base_url . $path; + } + + $path_without_query = substr($path, 0, $query_separator_position); + $query_string = substr($path, $query_separator_position + 1); + + if ($query_string === '') { + return $base_url . $path_without_query; + } + + $normalized_pairs = []; + foreach (explode('&', $query_string) as $pair) { + if ($pair === '') { + continue; + } + + $parts = explode('=', $pair, 2); + if (count($parts) === 1) { + $normalized_pairs[] = rawurlencode(rawurldecode($parts[0])); + continue; + } + + $normalized_pairs[] = rawurlencode(rawurldecode($parts[0])) + . '=' + . rawurlencode(rawurldecode($parts[1])); + } + + return $base_url . $path_without_query . '?' . implode('&', $normalized_pairs); + } + + protected function build_request_url(string $url): string + { + return $this->build_request_url_with_base_url($this->api_url, $url); + } + + protected function create_curl_handle_for_base_url(string $base_url, string $url, string $method, string $data = '', bool $authToken2 = false) + { + $appSecretToken = $this->resolve_app_secret_token(); + $agreementGrantToken = $this->resolve_agreement_grant_token($authToken2); + + $curl = curl_init(); + curl_setopt_array($curl, array( + CURLOPT_URL => $this->build_request_url_with_base_url($base_url, $url), + CURLOPT_RETURNTRANSFER => true, + //CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => array( + 'X-AppSecretToken: ' . $appSecretToken, + 'X-AgreementGrantToken: ' . $agreementGrantToken, + 'Content-Type: application/json' + ), + )); + + if ($method === 'POST') { + curl_setopt($curl, CURLOPT_POSTFIELDS, $data); + } + + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); + + return $curl; + } + + protected function create_curl_handle(string $url, string $method, string $data = '', bool $authToken2 = false) + { + return $this->create_curl_handle_for_base_url($this->api_url, $url, $method, $data, $authToken2); + } + /** * Send a request to the Economic API * @param string $url @@ -66,60 +183,37 @@ trait economic_endpoint_t */ public function send_request($url, $method, $data = '', bool $authToken2 = false): string { - $curl = curl_init(); - curl_setopt_array($curl, array( - CURLOPT_URL => $this->api_url . $url, - CURLOPT_RETURNTRANSFER => true, - //CURLOPT_ENCODING => '', - CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 0, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_HTTPHEADER => array( - 'X-AppSecretToken: ' . $this->app_token, - 'X-AgreementGrantToken: ' . ($authToken2 ? $this->appAccessGrant2 : $this->appAccessGrant), - 'Content-Type: application/json' - ), - )); - - if ($method === 'POST') { - curl_setopt($curl, CURLOPT_POSTFIELDS, $data); - } - curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); + $curl = $this->create_curl_handle($url, $method, $data, $authToken2); $response = curl_exec($curl); // Check for errors - if (curl_errno($curl)) { - echo 'Curl error: ' . curl_error($curl); - return curl_error($curl); + if ($response === false) { + $error = curl_error($curl); + curl_close($curl); + throw new \RuntimeException('Curl error: ' . $error); } + if (curl_errno($curl)) { + $error = curl_error($curl); + curl_close($curl); + throw new \RuntimeException('Curl error: ' . $error); + } + $httpStatusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); - return $response; + return $this->assert_successful_response($httpStatusCode, $response); } public function send_file_download_request($url, $method, $data = '', bool $authToken2 = false, $outputFile = null) { // Initialize cURL session - $curl = curl_init(); - curl_setopt_array($curl, array( - CURLOPT_URL => $this->api_url . $url, - CURLOPT_RETURNTRANSFER => true, // Get the response as a string - CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_HTTPHEADER => array( - 'X-AppSecretToken: ' . $this->app_token, - 'X-AgreementGrantToken: ' . ($authToken2 ? $this->appAccessGrant2 : $this->appAccessGrant), - 'Content-Type: application/json' - ), - )); + $curl = $this->create_curl_handle($url, $method, $data, $authToken2); // Execute the request $response = curl_exec($curl); // Handle errors if (curl_errno($curl)) { - echo 'Curl error: ' . curl_error($curl); - return curl_error($curl); + $error = curl_error($curl); + curl_close($curl); + throw new \RuntimeException('Curl error: ' . $error); } // Close the cURL session @@ -135,4 +229,69 @@ trait economic_endpoint_t // Otherwise, return the raw content (e.g., for inline use) return $response; } -} \ No newline at end of file + + /** + * Validate e-conomic HTTP responses and convert upstream errors into deterministic runtime exceptions. + */ + protected function assert_successful_response(int $httpStatusCode, string|false $response): string + { + if ($response === false) { + throw new \RuntimeException('e-conomic request failed without a response body.'); + } + + if ($httpStatusCode >= 400) { + throw new \RuntimeException($this->format_upstream_error_message($httpStatusCode, $response)); + } + + return $response; + } + + /** + * Build a sanitized error message from a non-2xx e-conomic response body. + */ + protected function format_upstream_error_message(int $httpStatusCode, string $response): string + { + $prefix = 'e-conomic request failed with HTTP ' . $httpStatusCode; + $decoded = json_decode($response, true); + + if (!is_array($decoded)) { + return $prefix . '.'; + } + + $message = isset($decoded['message']) && is_string($decoded['message']) + ? trim($decoded['message']) + : 'Upstream e-conomic error'; + + $details = []; + + if (isset($decoded['errors']) && is_array($decoded['errors'])) { + $safeErrors = []; + foreach ( $decoded['errors'] as $error ) { + if (is_scalar($error)) { + $safeErrors[] = (string)$error; + } + } + if (!empty($safeErrors)) { + $details['errors'] = $safeErrors; + } + } + + if (isset($decoded['logId']) && is_scalar($decoded['logId'])) { + $details['logId'] = (string)$decoded['logId']; + } + + if (isset($decoded['httpStatusCode']) && is_numeric($decoded['httpStatusCode'])) { + $details['httpStatusCode'] = (int)$decoded['httpStatusCode']; + } + + if (empty($details)) { + return $prefix . ': ' . $message; + } + + return $prefix + . ': ' + . $message + . ' | details=' + . json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } +} diff --git a/services/nginx/app/traits/minio_t.php b/services/nginx/app/traits/minio_t.php index 7ae78bc0..c9197c5d 100644 --- a/services/nginx/app/traits/minio_t.php +++ b/services/nginx/app/traits/minio_t.php @@ -24,6 +24,10 @@ trait minio_t */ public function listFiles(): array { + if ($this->shouldUseLocalTestStorage()) { + return $this->listLocalTestObjects(); + } + $objects = self::getS3Client()->listObjects([ 'Bucket' => self::getBucket() ]); @@ -123,6 +127,10 @@ trait minio_t */ public function createObject(string $key, string $body): bool { + if ($this->shouldUseLocalTestStorage()) { + return file_put_contents($this->getLocalTestObjectPath($key), $body) !== false; + } + $result = self::getS3Client()->putObject([ 'Bucket' => self::getBucket(), 'Key' => $key, @@ -138,6 +146,10 @@ trait minio_t */ public function getObjectUrl(string $key): string { + if ($this->shouldUseLocalTestStorage()) { + return $this->getLocalTestObjectPath($key); + } + return self::getS3Client()->getObjectUrl(self::getBucket(), $key); } @@ -153,6 +165,10 @@ trait minio_t */ public function getPresignedUrl(string $key, ?int $expires = null, bool $isUpload = false): string { + if ($this->shouldUseLocalTestStorage()) { + return $this->getLocalTestObjectPath($key); + } + $commandAction = $isUpload ? 'PutObject' : 'GetObject'; $command = self::getS3Client()->getCommand($commandAction, [ 'Bucket' => self::getBucket(), @@ -171,6 +187,10 @@ trait minio_t */ public function uploadFile(string $key, string $file): bool { + if ($this->shouldUseLocalTestStorage()) { + return copy($file, $this->getLocalTestObjectPath($key)); + } + $result = self::getS3Client()->putObject([ 'Bucket' => self::getBucket(), 'Key' => $key, @@ -186,6 +206,10 @@ trait minio_t */ public function doesObjectExist(string $key): bool { + if ($this->shouldUseLocalTestStorage()) { + return file_exists($this->getLocalTestObjectPath($key)); + } + return self::getS3Client()->doesObjectExist(self::getBucket(), $key); } @@ -305,4 +329,80 @@ trait minio_t // If no match, return a default extension (e.g., 'jpg') return 'jpg'; } -} \ No newline at end of file + + private function shouldUseLocalTestStorage(): bool + { + if (getenv('RUN_API_TESTS') !== '1') { + return false; + } + + return trim((string)$this->getEndpoint()) === '' + || trim((string)$this->getAccessKey()) === '' + || trim((string)$this->getSecretKey()) === ''; + } + + /** + * @return array + */ + private function listLocalTestObjects(): array + { + $directory = $this->getLocalTestStorageDirectory(); + $objects = []; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isFile()) { + continue; + } + + $pathname = $fileInfo->getPathname(); + $relativePath = substr($pathname, strlen($directory) + 1); + $objects[] = [ + 'Key' => str_replace('\\', '/', $relativePath), + ]; + } + + return $objects; + } + + private function getLocalTestStorageDirectory(): string + { + $bucket = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $this->getBucket()) ?: 'default'; + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . 'truckwash-test-object-store' + . DIRECTORY_SEPARATOR + . $bucket; + + if (!is_dir($directory)) { + mkdir($directory, 0777, true); + } + + return $directory; + } + + private function getLocalTestObjectPath(string $key): string + { + $normalizedKey = str_replace('\\', '/', $key); + $normalizedKey = preg_replace('#(^|/)\\.\\.(?=/|$)#', '', $normalizedKey) ?? $normalizedKey; + $normalizedKey = ltrim($normalizedKey, '/'); + + if ($normalizedKey === '') { + $normalizedKey = 'object'; + } + + $path = $this->getLocalTestStorageDirectory() + . DIRECTORY_SEPARATOR + . str_replace('/', DIRECTORY_SEPARATOR, $normalizedKey); + $directory = dirname($path); + + if (!is_dir($directory)) { + mkdir($directory, 0777, true); + } + + return $path; + } +} diff --git a/services/nginx/app/traits/module_config_t.php b/services/nginx/app/traits/module_config_t.php index af86084d..b36fbbcd 100644 --- a/services/nginx/app/traits/module_config_t.php +++ b/services/nginx/app/traits/module_config_t.php @@ -35,19 +35,33 @@ trait module_config_t */ function postConfigRequest(): bool { - // Check if the request has a variable name and value global $response; - if ($response->getRequestParameter('variable') === null || $response->getRequestParameter('value') === null) { + $parameters = $response->getAllRequestParameters(); + + if (array_key_exists('variable', $parameters) || array_key_exists('value', $parameters)) { + if ($response->getRequestParameter('variable') === null || !$response->isRequestParameterSet('value')) { + $response->error('Variable and value not set', 400); + } + + $variable = $response->getRequestParameter('variable'); + $value = $response->getRequestParameter('value'); + return $this->setAllowedConfigVariable((string)$variable, $value); + } + + if ($parameters === []) { $response->error('Variable and value not set', 400); } - // Get the variable name and value - $variable = $response->getRequestParameter('variable'); - $value = $response->getRequestParameter('value'); - // Sanitize the variable name and value - global /** @var db $db */ - $db; - $variable = $db->escape_string($variable); - $value = $db->escape_string($value); + + foreach ($parameters as $variable => $value) { + $this->setAllowedConfigVariable((string)$variable, $value); + } + + return true; + } + + private function setAllowedConfigVariable(string $variable, mixed $value): bool + { + global $response; // Check if the variable is allowed to be updated if (!self::isVariableAllowed($variable)) { // Return an error message, telling the user that the variable is not allowed to be updated. With a list of allowed variables @@ -70,7 +84,6 @@ trait module_config_t } } - // Return the updated config variable return true; } @@ -106,8 +119,9 @@ trait module_config_t $variable = $_GET['variable']; // Sanitize the variable name $variable = $db->escape_string($variable); + $module = $db->escape_string($this->module_name); // Get the config variable - $sql = "SELECT * FROM module_config WHERE module = '$this->module_name' AND variable = '$variable'"; + $sql = "SELECT * FROM module_config WHERE module = '$module' AND variable = '$variable'"; return $this->extracted($db, $sql); } @@ -118,7 +132,8 @@ trait module_config_t function getConfig(): array { global $db; - $sql = "SELECT * FROM module_config WHERE module = '$this->module_name'"; + $module = $db->escape_string($this->module_name); + $sql = "SELECT * FROM module_config WHERE module = '$module'"; return $this->extracted($db, $sql); } @@ -147,6 +162,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 @@ -196,4 +214,4 @@ trait module_config_t { return $this->module_name; } -} \ No newline at end of file +} diff --git a/services/nginx/app/traits/module_config_variable_t.php b/services/nginx/app/traits/module_config_variable_t.php index cc456045..72ef8fca 100644 --- a/services/nginx/app/traits/module_config_variable_t.php +++ b/services/nginx/app/traits/module_config_variable_t.php @@ -2,6 +2,7 @@ namespace traits; +use classes\system_search_cache; use Exception; trait module_config_variable @@ -70,7 +71,9 @@ trait module_config_variable */ static function isVariableSet(string $module, string $variable): bool { - global $db; + $db = self::getModuleConfigDatabase(); + $module = $db->escape_string($module); + $variable = $db->escape_string($variable); $sql = "SELECT value FROM module_config WHERE module = '$module' AND variable = '$variable'"; $result = $db->query($sql); return $result->num_rows > 0; @@ -84,9 +87,14 @@ trait module_config_variable */ function insertVariableValue(string $module, string $variable, mixed $value): void { - global $db; - $sql = "INSERT INTO module_config (module, variable, value, type) VALUES ('$module', '$variable', '$value', '" . self::getVariableType() . "')"; + $db = self::getModuleConfigDatabase(); + $module = $db->escape_string($module); + $variable = $db->escape_string($variable); + $value = $db->escape_string((string)$value); + $type = $db->escape_string(self::getVariableType()); + $sql = "INSERT INTO module_config (module, variable, value, type) VALUES ('$module', '$variable', '$value', '$type')"; $db->query($sql); + system_search_cache::markDirtyTable('module_config'); } /** @@ -118,8 +126,10 @@ trait module_config_variable */ function getVariableValue(): mixed { - global $db; - $sql = "SELECT value FROM module_config WHERE module = '$this->module_name' AND variable = '$this->config_variable'"; + $db = self::getModuleConfigDatabase(); + $module = $db->escape_string($this->module_name); + $variable = $db->escape_string($this->config_variable); + $sql = "SELECT value FROM module_config WHERE module = '$module' AND variable = '$variable'"; $result = $db->query($sql); // return the value of the config variable return $result->fetch_assoc()['value']; @@ -161,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 @@ -224,9 +242,64 @@ trait module_config_variable */ static function updateVariableValue(string $module, string $variable, mixed $value): void { - global $db; + $db = self::getModuleConfigDatabase(); + $module = $db->escape_string($module); + $variable = $db->escape_string($variable); + $value = $db->escape_string((string)$value); $sql = "UPDATE module_config SET value = '$value' WHERE module = '$module' AND variable = '$variable'"; $db->query($sql); + system_search_cache::markDirtyTable('module_config'); + } + + private static function getModuleConfigDatabase(): \classes\db + { + global $db, $CONFIG_DB; + + if ($db instanceof \classes\db) { + return $db; + } + + if (!is_array($CONFIG_DB) || $CONFIG_DB === []) { + $CONFIG_DB = self::readDatabaseConfigFromEnvironment(); + } + + if (!is_array($CONFIG_DB) || $CONFIG_DB === []) { + throw new Exception('Database configuration is not available'); + } + + $db = new \classes\db($CONFIG_DB); + $db->connect(); + + return $db; + } + + private static function readDatabaseConfigFromEnvironment(): array + { + $readEnv = static function (string $key, string $default = ''): string { + $value = $_ENV[$key] ?? getenv($key); + if ($value === false || $value === null) { + return $default; + } + + return trim((string)$value); + }; + + $host = $readEnv('CONFIG_DB_HOST'); + $user = $readEnv('CONFIG_DB_USER'); + $database = $readEnv('CONFIG_DB_DATABASE'); + + if ($host === '' || $user === '' || $database === '') { + return []; + } + + return [ + 'host' => $host, + 'user' => $user, + 'password' => $readEnv('CONFIG_DB_PASSWORD'), + 'database' => $database, + 'port' => (int)($readEnv('CONFIG_DB_PORT', '3306') ?: '3306'), + 'ssl_mode' => $readEnv('CONFIG_DB_SSL_MODE', 'DISABLED') ?: 'DISABLED', + ]; } /** @@ -300,4 +373,4 @@ trait module_config_variable { return $this->config_variable_required; } -} \ No newline at end of file +} diff --git a/services/nginx/app/traits/redis_t.php b/services/nginx/app/traits/redis_t.php index 413a412a..19ee650c 100644 --- a/services/nginx/app/traits/redis_t.php +++ b/services/nginx/app/traits/redis_t.php @@ -18,6 +18,11 @@ trait redis_t * @var string */ protected string $redis_host = ''; + /** + * The Redis user + * @var string + */ + protected string $redis_user = ''; /** * The Redis port * @var int @@ -88,13 +93,17 @@ trait redis_t public function connect(): self { - $this->redis = new PredisClient([ + $params = [ 'scheme' => $this->redis_scheme, 'host' => $this->redis_host, 'port' => $this->redis_port, 'password' => $this->redis_password, 'database' => $this->redis_database - ]); + ]; + if ($this->redis_user !== '' && $this->redis_user !== 'default') { + $params['username'] = $this->redis_user; + } + $this->redis = new PredisClient($params); // Check if the connection was successful if (!self::is_connected()) { diff --git a/services/nginx/app/traits/route_t.php b/services/nginx/app/traits/route_t.php index b5d0e587..ef6f38cc 100644 --- a/services/nginx/app/traits/route_t.php +++ b/services/nginx/app/traits/route_t.php @@ -28,7 +28,8 @@ trait route_t public function __construct() { - $this->route = $_SERVER['REQUEST_URI']; + $requestUri = $_SERVER['REQUEST_URI'] ?? '/'; + $this->route = is_string($requestUri) && $requestUri !== '' ? $requestUri : '/'; } public function run(): void @@ -321,6 +322,71 @@ trait route_t return null; } + /** + * Convert a permission definition to its canonical string key. + */ + private function permissionKey(string|permission_node $permission): string + { + return $permission instanceof permission_node + ? (string)$permission->permission + : (string)$permission; + } + + /** + * Normalize and de-duplicate permission keys for forbidden responses. + * Accepts string keys and permission_node definitions. + * + * @param array $permissions + * @return array + */ + private function normalizePermissionKeys(array $permissions): array + { + $keys = []; + foreach ($permissions as $permission) { + if ($permission instanceof permission_node) { + $key = trim((string)$permission->permission); + } elseif (is_string($permission)) { + $key = trim($permission); + } else { + continue; + } + if ($key !== '') { + $keys[] = $key; + } + } + return array_values(array_unique($keys)); + } + + /** + * Emit standardized forbidden response payload with missing permission keys. + * Extracted to allow focused unit tests by overriding this method. + * + * @param array $permissions + */ + protected function emitForbidden(array $permissions): void + { + global $response; + $response->forbidden($this->normalizePermissionKeys($permissions)); + } + + /** + * Emit a forbidden response for missing department scope access. + * Optionally include bypass permissions if they are relevant and missing. + * + * @param int $departmentId + * @param array $optionalBypassPermissions + */ + public function forbidDepartmentAccess(int $departmentId, array $optionalBypassPermissions = []): void + { + $missing = ['department_access_' . $departmentId]; + foreach ($optionalBypassPermissions as $permission) { + if (!$this->hasPermission($permission)) { + $missing[] = $permission; + } + } + $this->emitForbidden($missing); + } + /** * Centralized permission evaluation used by both requirePermission and hasPermission. * - Honors subusers permission nodes without falling back to classic user permissions when a node is defined. @@ -340,7 +406,7 @@ trait route_t if ($resolvedCustomer === null) { (new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Missing customer context for subuser permission evaluation: ' . $permission->permission); if ($throwOnDeny) { - $response->error('Permission denied. Missing customer context for subuser.', 403); + $this->emitForbidden([$permission]); } return false; } @@ -356,7 +422,7 @@ trait route_t if ($cached !== null) { self::$__perm_subuser_grant_cache[$cacheKey] = $cached; } else { - $subuser_has_permission = $subuser->hasPermission($permission->subusers_node_key); + $subuser_has_permission = $subuser->hasPermission($permission->subusers_node_key, (int)$resolvedCustomer); if (defined('redis')) { redis->cache_permission($cacheKey, $subuser_has_permission); } @@ -367,7 +433,7 @@ trait route_t if (!$subuser_has_permission && $throwOnDeny) { (new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Permission denied via subuser node: ' . $permission->permission . ' (Node: ' . $permission->subusers_node_key->name . ', Customer: ' . $resolvedCustomer . ')'); - $response->error('Permission denied for subuser. Missing permission: ' . $permission->permission . ' (Customer context: ' . $resolvedCustomer . ')', 403); + $this->emitForbidden([$permission]); } return $subuser_has_permission; } @@ -404,7 +470,7 @@ trait route_t if (!$allowed && $throwOnDeny) { (new logs_o())->add('global', 'global', 1, $user->id, 'PERMISSION_DENIED', 'Permission denied. Missing permission: ' . $perm_string); - $response->error('Permission denied. Missing permission: ' . $perm_string . ' for user: ' . $user->id . ' In group: ' . $user->group_id->value(), 403); + $this->emitForbidden([$perm_string]); } return $allowed; } catch (Exception $e) { @@ -528,7 +594,21 @@ trait route_t } if (!$allowed) { - $response->error($denyMessage ?? 'Permission denied.', 403); + $missingPermissions = []; + if (!$hasOwn) { + $missingPermissions[] = $permissionOwn; + } + if (!$hasOther) { + $missingPermissions[] = $permissionOther; + } + // In own-scope failures (wrong customer/guard failure), report missing elevated permission only. + if ($hasOwn && !$hasOther) { + $missingPermissions[] = $permissionOther; + } + if (count($missingPermissions) === 0) { + $missingPermissions[] = $permissionOther; + } + $this->emitForbidden($missingPermissions); } return $allowed; } @@ -816,4 +896,4 @@ trait route_t // Check if route is the same, or if it matches the regex pattern return $route === $this->route || preg_match($route, $this->route); } -} \ No newline at end of file +} diff --git a/services/nginx/nginx.conf b/services/nginx/nginx.conf index 672601ff..c10fe680 100644 --- a/services/nginx/nginx.conf +++ b/services/nginx/nginx.conf @@ -68,13 +68,13 @@ http { # Location block for PHP files location ^~ / { add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; add_header Access-Control-Allow-Credentials true; if ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; return 204; } @@ -125,13 +125,13 @@ http { # Location block for PHP files location ^~ / { add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; add_header Access-Control-Allow-Credentials true; if ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; return 204; } @@ -190,4 +190,4 @@ http { # Restrict access to the server, if the -} \ No newline at end of file +} diff --git a/services/nginx/nginx.dev.conf b/services/nginx/nginx.dev.conf index 258901bb..1608de11 100644 --- a/services/nginx/nginx.dev.conf +++ b/services/nginx/nginx.dev.conf @@ -50,12 +50,12 @@ http { # Main application location location / { add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; add_header Access-Control-Allow-Credentials true; if ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number"; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version"; return 204; } diff --git a/services/nginx/staging/index.php b/services/nginx/staging/index.php new file mode 100644 index 00000000..37ce6719 --- /dev/null +++ b/services/nginx/staging/index.php @@ -0,0 +1,2 @@ +&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://dl.min.io/client/mc/release/linux-${mc_arch}/mc" -o /usr/local/bin/mc; \ + chmod +x /usr/local/bin/mc; \ + mc --version; \ docker-php-ext-configure gd --with-freetype --with-jpeg; \ docker-php-ext-install -j"$(nproc)" \ mbstring \ @@ -63,11 +72,12 @@ ENV COMPOSER_ALLOW_SUPERUSER=1 WORKDIR /var/www/html # Copy and enable entrypoint that installs Composer deps on first run -#COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -#RUN chmod +x /usr/local/bin/docker-entrypoint.sh +COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \ + && chmod +x /usr/local/bin/docker-entrypoint.sh # Expose port 9000 EXPOSE 9000 -#ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] -CMD ["php-fpm"] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["php-fpm"] diff --git a/services/php/docker-entrypoint.sh b/services/php/docker-entrypoint.sh index 34ee78e4..77ba94e5 100644 --- a/services/php/docker-entrypoint.sh +++ b/services/php/docker-entrypoint.sh @@ -2,23 +2,106 @@ set -e # Config -APP_DIR="/var/www/html" -MODULE_DIR="$APP_DIR/modules/washcertificates" -LOG_FILE="/var/log/php/composer-install.log" +APP_DIR="${APP_DIR:-/var/www/html}" +MODULE_DIR="${MODULE_DIR:-$APP_DIR/modules/washcertificates}" +LOG_FILE="${LOG_FILE:-/var/log/php/composer-install.log}" # Gate auto-install (set to "true" only on one PHP container, e.g. php1) AUTO_COMPOSER_INSTALL="${AUTO_COMPOSER_INSTALL:-true}" log() { printf "[entrypoint] %s\n" "$*"; } +vendor_sanity_ok() { + dir="$1" + autoload_file="$dir/vendor/autoload.php" + autoload_real_file="$dir/vendor/composer/autoload_real.php" + aws_s3_api_file="$dir/vendor/aws/aws-sdk-php/src/data/s3/2006-03-01/api-2.json.php" + + if [ ! -f "$autoload_file" ]; then + return 1 + fi + + if [ ! -f "$autoload_real_file" ]; then + log "Vendor sanity check failed: missing $autoload_real_file" + return 1 + fi + + if [ -f "$dir/composer.lock" ] && [ "$dir/composer.lock" -nt "$autoload_file" ]; then + log "composer.lock is newer than vendor/autoload.php in $dir" + return 1 + fi + + if ! php -d display_errors=1 -r 'require $argv[1];' "$autoload_file" >/dev/null 2>&1; then + log "Vendor sanity check failed for $autoload_file" + return 1 + fi + + if ! http_message_sanity_ok "$dir"; then + return 1 + fi + + if [ -f "$aws_s3_api_file" ] && ! php -l "$aws_s3_api_file" >/dev/null 2>&1; then + log "Vendor sanity check failed for $aws_s3_api_file" + return 1 + fi + + return 0 +} + +composer_lock_has_package() { + dir="$1" + package="$2" + + if [ ! -f "$dir/composer.lock" ]; then + return 1 + fi + + grep -q "\"name\": \"$package\"" "$dir/composer.lock" +} + +http_message_sanity_ok() { + dir="$1" + autoload_file="$dir/vendor/autoload.php" + uri_file="$dir/vendor/psr/http-message/src/UriInterface.php" + stream_file="$dir/vendor/psr/http-message/src/StreamInterface.php" + + if ! composer_lock_has_package "$dir" "psr/http-message"; then + return 0 + fi + + if [ ! -f "$uri_file" ]; then + log "Vendor sanity check failed: missing $uri_file" + return 1 + fi + + if [ ! -f "$stream_file" ]; then + log "Vendor sanity check failed: missing $stream_file" + return 1 + fi + + if ! php -d display_errors=1 -r 'require $argv[1]; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);' "$autoload_file" >/dev/null 2>&1; then + log "Vendor sanity check failed: psr/http-message interfaces do not autoload in $dir" + return 1 + fi + + return 0 +} + wait_for_redis() { - host="${REDIS_CONFIG_HOST:-redis}" - port="${REDIS_CONFIG_PORT:-6379}" - pass="${REDIS_CONFIG_PASSWORD:-}" + db_target="${CONFIG_DB_TARGET:-live}" + if [ "$db_target" = "debug" ]; then + host="${REDIS_CONFIG_DEBUG_HOST:-${REDIS_CONFIG_HOST:-redis}}" + port="${REDIS_CONFIG_DEBUG_PORT:-${REDIS_CONFIG_PORT:-6379}}" + pass="${REDIS_CONFIG_DEBUG_PASSWORD:-${REDIS_CONFIG_PASSWORD:-}}" + else + host="${REDIS_CONFIG_HOST:-redis}" + port="${REDIS_CONFIG_PORT:-6379}" + pass="${REDIS_CONFIG_PASSWORD:-}" + fi timeout="${REDIS_WAIT_TIMEOUT:-60}" i=0 - log "Waiting for Redis at ${host}:${port} (timeout: ${timeout}s) ..." + log "Waiting for Redis at ${host}:${port} (target: ${db_target}, timeout: ${timeout}s) ..." while [ "$i" -lt "$timeout" ]; do if [ -n "$pass" ]; then if redis-cli -h "$host" -p "$port" -a "$pass" PING >/dev/null 2>&1; then @@ -31,17 +114,23 @@ wait_for_redis() { return 0 fi fi - sleep 1; i=$((i+1)) + sleep 1 + i=$((i+1)) done log "ERROR: Timed out waiting for Redis at ${host}:${port}" return 1 } wait_for_file() { - target="$1"; timeout="${2:-120}"; i=0 + target="$1" + timeout="${2:-120}" + i=0 while [ "$i" -lt "$timeout" ]; do - if [ -e "$target" ]; then return 0; fi - sleep 1; i=$((i+1)) + if [ -e "$target" ]; then + return 0 + fi + sleep 1 + i=$((i+1)) done return 1 } @@ -60,48 +149,58 @@ with_install_lock() { install_if_needed() { dir="$1" if [ -f "$dir/composer.json" ]; then - if [ ! -f "$dir/vendor/autoload.php" ]; then + if ! vendor_sanity_ok "$dir"; then + if [ -d "$dir/vendor" ]; then + log "Removing invalid vendor tree in $dir before reinstall" + rm -rf "$dir/vendor" + fi log "Installing Composer deps in $dir ..." - # Ensure log directory exists mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true - # Run install and log output if ! COMPOSER_ALLOW_SUPERUSER=1 composer install \ --no-dev --prefer-dist --optimize-autoloader --no-interaction \ -d "$dir" 2>&1 | tee -a "$LOG_FILE"; then log "ERROR: composer install failed in $dir. See $LOG_FILE" exit 1 fi - # Verify autoload was created if [ ! -f "$dir/vendor/autoload.php" ]; then log "ERROR: autoload.php still missing after install in $dir. See $LOG_FILE" exit 1 fi - # Best-effort permissions fix (ignore errors on non-Linux filesystems) chown -R www-data:www-data "$dir/vendor" 2>/dev/null || true else - log "vendor already present in $dir — skipping" + log "vendor already present and sane in $dir - skipping" + fi + fi +} + +refresh_root_autoload() { + if [ -f "$APP_DIR/composer.json" ] && [ -f "$APP_DIR/vendor/autoload.php" ]; then + log "Refreshing root Composer autoload after module dependency checks ..." + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + if ! COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload \ + --optimize --no-interaction \ + -d "$APP_DIR" 2>&1 | tee -a "$LOG_FILE"; then + log "ERROR: composer dump-autoload failed in $APP_DIR. See $LOG_FILE" + exit 1 fi fi } -# Optionally perform auto-install (only on the designated container) if [ "$AUTO_COMPOSER_INSTALL" = "true" ]; then - # Wait for the bind mount and composer.json to appear (common on Windows/macOS) if ! wait_for_file "$APP_DIR/composer.json" 120; then - log "WARNING: $APP_DIR/composer.json not found after waiting — skipping auto-install" + log "WARNING: $APP_DIR/composer.json not found after waiting - skipping auto-install" else with_install_lock install_if_needed "$APP_DIR" fi - # Module (optional) if [ -f "$MODULE_DIR/composer.json" ]; then with_install_lock install_if_needed "$MODULE_DIR" + with_install_lock refresh_root_autoload fi else - log "AUTO_COMPOSER_INSTALL=false — skipping Composer auto-install" + log "AUTO_COMPOSER_INSTALL=false - skipping Composer auto-install" fi -# Wait for Redis before starting PHP-FPM (if host is defined) if [ -n "${REDIS_CONFIG_HOST:-}" ]; then wait_for_redis fi diff --git a/services/php/logs-staging/error.log b/services/php/logs-staging/error.log new file mode 100644 index 00000000..4c73b4d1 --- /dev/null +++ b/services/php/logs-staging/error.log @@ -0,0 +1,7 @@ +[18-Mar-2026 09:35:25 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 +[18-Mar-2026 21:24:56 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-Mar-2026 09:53:23 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 +[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 diff --git a/services/php/php.ini b/services/php/php.ini index 55160119..04681679 100644 --- a/services/php/php.ini +++ b/services/php/php.ini @@ -9,8 +9,12 @@ session.save_path = "tcp://redis:6379?database=0" log_errors = On error_log = /var/log/php/error.log +; Mounted code changes should invalidate cached opcodes immediately during local development. +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 + ; Elastic APM PHP agent -; The extension is installed via apt (elastic-apm-php) -extension=elastic_apm.so +; Disabled locally for test stability (container image does not include the module). +;extension=elastic_apm.so ; Optional bootstrap (enables automatic instrumentation where supported) ;elastic_apm.bootstrap_php_part_file=/opt/elastic/apm-agent-php/src/bootstrap_php_part.php diff --git a/services/traefik/acme-io.json b/services/traefik/acme-io.json index 9e26dfee..c95dafa2 100644 --- a/services/traefik/acme-io.json +++ b/services/traefik/acme-io.json @@ -1 +1,33 @@ -{} \ No newline at end of file +{ + "le_io": { + "Account": { + "Email": "jb@truckwash.dk", + "Registration": { + "body": { + "status": "valid" + }, + "uri": "https://acme-v02.api.letsencrypt.org/acme/acct/3156107901" + }, + "PrivateKey": "MIIJJwIBAAKCAgEAuNmaQqhtgvInxyd6Wvn1dlelukxVaRJwWCEm5EzHnKv9QGq5D33dwxXa0iMFBmcaASmZc6k38y3WsM3aEaeMf3gxlrfzAdj9sh1yGOAhKl86md8i+hkQoHcMmZN3jJXMkUhGppTsgeUp+5LYmmToS7xPxggDpMGRSeLBCnlGZUKebXCd0JJ1CoeIW4997gA2tDfXW8Ghiuvi12OQT+3QIG8VjIDVExYWIIJypHN0QWjEVUTFhz2O5x8Ip19o0rj0wL9EapJHlC5Run9Z7BcXJtBtWm196Pz8Ua4Eliy+eWEDnVgly2m3TyaFuISmQUqNqi998T/QcZzrwq0pS/4o8B2E1kTT32cuDn3ARjiMZcpe32UDkXR/54WYlpKbO33pB+wnh6wL0KlIv4FTKrjxpCqNxmHocZuVdJqNbqufUdnSnyzS3C2D+fVtsrvCc/db1+7seQ0YPoiGegzWGaEmbVdTwGm8zWmFLFuPdcfmKIs4iyODS6lG4RGjrQ7n/sNTAoX8K6Wgv+7nP5aprA+Q5k/LqFF8QwMP4veEdvfCB8mUa3CmZ0nvhAt/tgjM9zESXnxALLPrNYSa2oignatHt2eiXSa7T7JRTXwaGvms/RCDBgVxGAJ9D+JpAGjODEwi6K2Nq7aaG70n80/s96Iu7c4i4torwkUN3rNyO4eAS4ECAwEAAQKCAgAl9Xl6CsBCTZvhh7fWitHfNWxw70/yvbiYQGaOJz4ubBsbaU8LYVtyvkArOsnDYNS0PGioma1FoLofoIYRbgip/HKicq/SR08Bjo5pkUz+OIP+KRYzqYYCja/msaOxGOnjQ6ZOevJ+UiLm6GbWfbY+JzNyhR7KbH17YLcngXP1Q1LpQmEF/a1PNjnII5VGlICnTXhJ/V9A+eOhO27dGwbMQkN4kNQwkS6GCoOkRZkv/WWj5PVzKi74QyUxyaPq8dRA6IYSJGvSgBiEZbZ/htZYQCDUmvtqJtlhpIyIkjOjFUr6uqk4NkNQW6bGF2dUrQyeUBZavjH9wq2fUpPSaSTaoCBiewf1XCZTJjtBgJaUAiziKDmfHnV4k9xGAdUHeLU8AvB8IdoEsG9eOKagy2X7LPlmkqxqASua0JGyaEltjeDnNA5al1m6G01W5NWXmak+ioqMIL8aAioYwn3+h4yH1qWfrxkXpp6E/LujLJYdDvDWFt+UXxJoLtGRSEORX4LRYhMXygpboctDkrjlPCFCpkyvnmd5ofrkYlulncENJPHGx71y3fciGGtGarLK0rGiI8CY9kWWZMrzwAuMH6Y5myflqssi0j9vz1WVdp/m3BNwSOoIQRybEVTeJJnlKgVpGGEgL+4dVVSzxMHeNzrJw7EqVkbq/0Ettr6ag9lbYQKCAQEAwX7bEdxWlcehIeFAtwoOB/o6fJu26pUNjGx9dB58fqkXGeDdp7S6yxbuksgnsbbLsIISClT3IdC4HLkv4DFfJ3ONOrvs53S0sJukEfyz9mA25F3Z4t1i4n1QbEkFzvZ4uU440s3xIN/hzBzHkCstyzhpbESN51sGRg2cSmlzJz5dF4YRxdHqj6OZ0QJ9DqKP+0rqz8o3Iqf4HuHTJCBaKTjNw5wKGNKWBMCHjd8BGpgh7BZ3fBGvoGLOAaQG0gBFW34E91xybpn2BOizVLnUOrrc4OuUhO8yvb5le1nvhDb01I9ux40/sxImzcw/MBGQlm+4cf6jm3tQmymH5XTv5QKCAQEA9I/N3IWfVo+keXZAqvAW12onfib2bHW7PwsdwiwRQPoT5Rk4B6WstHV8BEAJfsnOHEsrwv6PaG2AQ5yRY5wZSW/APw/FIFBMcItAzV9nEgx6lSG57Gwv5ICkYoZJPnDyY2fUbOEcvHy+V+Q02ietxC/4ySHXigT6YwHX0Yb/gpZcN6Fl5zlJofBXtJmtv2jJdeFf0iNq6+xhcc0PUC0EcYu/RzrIaWslzsuupuK/UFWLyHRWUbWYt1bRAc6XKxsP3e14DZJr8TszujNUd08L2IPwwRsU37rnpQdxeCmM0q1i8OF3PE/70SHWhjSZYXhinFjsjrqyRVxJFYAvMTUbbQKCAQAhpjLorz2rfHTsFGURr8Dy6DQlmVq8/sDFa9SBTg/uYu9ug6loUciuKsXAZuhoQla30lbazx3PFqH99MJ7pXpbvP+ReD7hnW0SzW5B5oVRUjgZjKyohEF/C7Xhru1AqaIi75R4LHJDekulGFgoHSowjhXfSyi2VCEKK7HDSwVIjNLZlWof0bqN0jCcpWckFWcel0+wZQUjlLxUeociYDHtu0AlRUyINo76MpUgOejSPCSiDuImhFjbdnNG28SH3p4xJAAvGDeaPlIHVoRhNpPOxtJElxNT7tkPID47rk812ezHzk3AjDLIrF5tKZjPfi57yt/ziwnerxo8cYN9htF1AoIBAFJU4zn9JhIvE3DslYK2hwoIK7l1hnom/F2R8Xe//CMCzZXP8qPoa4bpElIf+NJdP+0YhWgE6OSAELEyUWPWVlD5tR/FwFhLkCBHUQ6SspFJ8C6qhvwHw+vKPw0IJjpGLeO3PbVV56Ww3SebpQtYVlB40elsYjKN0HlqUmywZO5ijjMQO80m5RoGXpBnOC37Ke9sayTEVaeNEZUNMr2MGjXblQ52xKdwse63dVmrdjqmQgO+3pxtZTc7m+te0FdrqeDNpB/1ji9M/fIOe74at2Mxr+hipJlZQ8i0A8gvPMBkLK3pSYndZ2oVWTIfHQ/KRnBaI3E9euyMz/HUEI8hUNUCggEAceZgs04Lo07NaazPMwtarOaypsihbMj2qGZ1W+XJfDuYbe85w0rzRU/VHrnQwaxz7RT5r2MypBgfdWVTAWNhXFfIxcnQEgFhe32f2B/4x6YGewC+4bPgbdAsyCz1Z0tXRITdK3dLE3Nb2QCMUtr9ZdpHoOo4w49Axvk87Wsfm7TRsPPfPUTT9YETCCevd51Ho+KW8QfylBesUcwsP4Eqaq6fEQo1hx1FiZnk4XAZW9iAEINq9QubRqTESuHzDiMUb+afpezNfo8weZJhggHjDd/xILTPBiD1I/9TW4esCUJEIf1C/uvJOBDJg2D5gy5Ag3hX6EXZ6ZkqG8lYwenP3w==", + "KeyType": "4096" + }, + "Certificates": [ + { + "domain": { + "main": "n8n.truckwash.io" + }, + "certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUYrVENDQk9HZ0F3SUJBZ0lTQmhvdGlsQXZiT2pJRHlvTkkwUXZ5WmdSTUEwR0NTcUdTSWIzRFFFQkN3VUEKTURNeEN6QUpCZ05WQkFZVEFsVlRNUll3RkFZRFZRUUtFdzFNWlhRbmN5QkZibU55ZVhCME1Rd3dDZ1lEVlFRRApFd05TTVRJd0hoY05Nall3TXpFNE1qQXlOak0wV2hjTk1qWXdOakUyTWpBeU5qTXpXakFiTVJrd0Z3WURWUVFECkV4QnVPRzR1ZEhKMVkydDNZWE5vTG1sdk1JSUNJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBZzhBTUlJQ0NnS0MKQWdFQXVhcFNKL0dQU3k0Z00yd2o2dGxEUkhkUDE4R2NTQTg0WTV0elNtblhMRTBjbDg4cVorRkZkYW5jV3UzcQpDSm16ZjFQZGs2WFZTL3pJZEk3a2phQVJxNHgxNnNpQ21qTzJCRlNDVTFoalE3SWZWSCs4N1JWd2wxeWpMMjMvCnNYTDVMSkMwYWZHaUxMeTA4blJHZ0Jvcko1bXNTS2kwL25EM3lmdWpBZk44a1UyZ0x2ems2VXpzanpKMEVDTW4KaUpSTFJ3eU51eitqNUtRZkx5YmhpazVCNElBaXlRYXY1TXdwN3B0aTE0TGJVZHlaaUJybmJWTThIaGpYY1YydgpTczFnUGNYWVppTGZnYmRJb0MrOVVLN3FPdFhxK1JVbHhUVi9waFVCY010b29iL3R2TzJhRGhCV1FHc0MxcDVjCnNLMDFCQ3VpdXlwcmJGTHVRU2VDMC9pblhuWWJnUExuQll0MVlEdFZhOFZTdDlRUTFiZVkzaHo4d21TMHVDQlQKUWpPMmdxcGM5V2hLWVNGN0k5Sm5mRktZTjNvVy9WdTMrL1ZTWFFWUWs3YXg4Vnd1TjZnd2U3ekQvNlZPRmVOSAp5SGhna3BEOFRCd1hSWjM2aEdFdkJxcVJyV2w1elhSZ1Nhdko5UTdvSXJpR3NwWjNVdktGNndIUDk3S3JlRWgxCk9mT0twZUhKUWRFcUFTUU5iMVRPWHRYbno2OE10bDlCdE5acVg4QWhUYVJQaEJnU0lwek1PYUg1OVlzWW9hbGQKczFlVWE4eGxYNlVlTXBXUzhyeFZDKzhtaXFjY0w1NzZJaTQ2TTRsRmgzT3luTzRWUk5QT1pma1hzOHJDSWhaSgpNdk43N1RFcE9QWStlNUdVNVpKZEdJSThHc3FNWDJkYkxvNWo3QWlsY0ZLS3NDMENBd0VBQWFPQ0FoMHdnZ0laCk1BNEdBMVVkRHdFQi93UUVBd0lGb0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQU1CZ05WSFJNQkFmOEUKQWpBQU1CMEdBMVVkRGdRV0JCUTBCZ2pYQi8rMWphM2xTYk9HaTRhSWtCMDhjVEFmQmdOVkhTTUVHREFXZ0JRQQp0U255TFk1dk1laWJUSzE0UHZyYzZRelIwakF6QmdnckJnRUZCUWNCQVFRbk1DVXdJd1lJS3dZQkJRVUhNQUtHCkYyaDBkSEE2THk5eU1USXVhUzVzWlc1amNpNXZjbWN2TUJzR0ExVWRFUVFVTUJLQ0VHNDRiaTUwY25WamEzZGgKYzJndWFXOHdFd1lEVlIwZ0JBd3dDakFJQmdabmdRd0JBZ0V3TGdZRFZSMGZCQ2N3SlRBam9DR2dINFlkYUhSMApjRG92TDNJeE1pNWpMbXhsYm1OeUxtOXlaeTh4T0M1amNtd3dnZ0VMQmdvckJnRUVBZFo1QWdRQ0JJSDhCSUg1CkFQY0FkUUFPVjVTODg2NnBQak1iTEprSHMvZVEzNXZDUFhFeUpkMGhxU1dzWWNWT0lRQUFBWjBDMW4zVUFBQUUKQXdCR01FUUNJQzRxQ1lwRWs1dzEvNGtPTFpWWXRZOGNrQXFxQUg5MjRyNGQ0bmNNSnQvYUFpQkUxY2ozMGxOdgpSR3BzbGEzSzRvclJ5NDhvZXlOV2JVeFRkVVAxUEtUQ253QitBT01qamZLTm9vamdxdUNzOFBxUXlZWHd0ci8xCjBxVW5zQUg4SEVSWXhMYm9BQUFCblFMV2dYTUFDQUFBQlFBMS9nSFpCQU1BUnpCRkFpQllzNytPMEtRS2pYY3IKc1pBamx1WVhKSmRsaGJ3S0Y0ZXBIN3JtWjFnK2V3SWhBUHRFVlJoaXJONVBRVVdOWEZlNHk5RDVKaGRoQzU3dwo4VWRWVCtpenpONklNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUJGNHBaQ0dBMG5mTFZGd3hRVlpBMkZLb29VCkxBL3lHVkJZQUtKeHQ4RWQ3cnRqdkJVT0Z3aEc5cGVVNzBmMUh5Vlo4ajdRbkNXT2Q5N3hSU0JxMDNLK2VFdzYKKythMmthWUo1SDJwWEJobVEvbm9ZSEx3RzQ2eitGaGJTRUYvY1Fod21KTlBnNlhnRDJsYjd0M2pmYUpLcm94Twp5a25OZGJ5QTIvZ2pvcEg2VUl2a2RWdTJWT3lYUHRxZlZYUjBWcDQ5YVE1ZjF4akpDbVhQTy9rREhaMWdPWlZ4ClRLZzU3aHhqa2U2ZVN1NCttN2V3UWo1MmFia1UrOTZGcGpwNHQydWlDeHR2ejZNOG0wSHBrNG1oR0hqcU02Zy8KL3JzSWRoZ3hiNklTejdIeXpyQ3oyK0hkK0d2aU1rZzFjSjV0ZEJkVmJmRkk0ZnVFdVIyRURXL3N2QmJJCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0KCi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLQpNSUlGQmpDQ0F1NmdBd0lCQWdJUkFNSVNNa3R3cWJTUmNkeEE5K0tGSmp3d0RRWUpLb1pJaHZjTkFRRUxCUUF3ClR6RUxNQWtHQTFVRUJoTUNWVk14S1RBbkJnTlZCQW9USUVsdWRHVnlibVYwSUZObFkzVnlhWFI1SUZKbGMyVmgKY21Ob0lFZHliM1Z3TVJVd0V3WURWUVFERXd4SlUxSkhJRkp2YjNRZ1dERXdIaGNOTWpRd016RXpNREF3TURBdwpXaGNOTWpjd016RXlNak0xT1RVNVdqQXpNUXN3Q1FZRFZRUUdFd0pWVXpFV01CUUdBMVVFQ2hNTlRHVjBKM01nClJXNWpjbmx3ZERFTU1Bb0dBMVVFQXhNRFVqRXlNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUIKQ2dLQ0FRRUEycGdvZEsyK2xQNDc0QjdpNVV0MXF5d1NmKzJuQXpKK05wZnM2REdQcFJPTkM1a3VIczBCVVQxTQo1U2h1Q1ZVeHFxVWlYWEwwTFFmQ1RVQTgzd0VqdVhnMzlScGxNalRtaG5HZEJPK0VDRnU5QWhxWjY2WUJBSnB6CmtHMlBvZ2VnMEpmVDJrVmhnVFU5RlBuRXdGOXEzQXVXR3JDZjR5cnF2U3JXbU1lYmNhczdkQTg4MjdKZ3ZscEwKVGhqcDJ5cHpYSWxoWlo3KzdUeW15MDV2NUo3NUFFYXoveGxOS21PemptYkdHSVZ3eDFCbGJ6dDA1VWlERHdoWQpYUzBqblY2ai91amJBS0hTOU9NWlRmTHVldllubnVYTm5DMmk4bitjRjYzdkV6YzUwYlRJTEVIV2hzRHA3Q0g0CldSdC91VHA4bjF3Qm5XSUV3aWk5Q3EwOHloRHNHd0lEQVFBQm80SDRNSUgxTUE0R0ExVWREd0VCL3dRRUF3SUIKaGpBZEJnTlZIU1VFRmpBVUJnZ3JCZ0VGQlFjREFnWUlLd1lCQlFVSEF3RXdFZ1lEVlIwVEFRSC9CQWd3QmdFQgovd0lCQURBZEJnTlZIUTRFRmdRVUFMVXA4aTJPYnpIb20weXRlRDc2M09rTTBkSXdId1lEVlIwakJCZ3dGb0FVCmViUlo1bnUyNWVRQmM0QUlpTWdhV1BicG0yNHdNZ1lJS3dZQkJRVUhBUUVFSmpBa01DSUdDQ3NHQVFVRkJ6QUMKaGhab2RIUndPaTh2ZURFdWFTNXNaVzVqY2k1dmNtY3ZNQk1HQTFVZElBUU1NQW93Q0FZR1o0RU1BUUlCTUNjRwpBMVVkSHdRZ01CNHdIS0Fhb0JpR0ZtaDBkSEE2THk5NE1TNWpMbXhsYm1OeUxtOXlaeTh3RFFZSktvWklodmNOCkFRRUxCUUFEZ2dJQkFJOTEwQW5QYW5aSVpUS1MzclZFeUlWMjlCV0VqQUsvZHV1ejhlTDVib1NvVnBIaGtrdjMKNGVvQWVFaVBkWkxqNUVaN0cyQXJJSytnemhUbFJRMXE0RktHcFBQYUZCU3BxVi94YlViNVVsQVhRT25rSG4zbQpGVmorcVl2ODcvV2VZK0JtNHNOM094OEJoeWFVN1VBUTNMZVo3TjFYMDF4eFFlNHdJQUFFM0pWTFVDaUhtWkwrCnFvQ1V0Z1lJRlBnY2czNTBRTVVJV2d4UFhOR0VuY1Q5MjFuZTdubHVJMDJWOHBMVW1DbHFYT3NDd1VMdytQVk8KWkNCN3FPTXh4TUJvQ1VlTDJMbDRvTXBPU3I1cEpDcExOM3RSQTJzNlAxS0xzOVRTclZoT2srN0xYMjhOTVVsSQp1c1EvbnhMSklEMFJoQWVGdFBqeU9DT3NjUUJBNTMrTlJqU0NhazdQNEE1alg3cHBta2NKRUNMK1MwaTNrWFZVCnk1TWU1QmJyVTg5NzNqWk52L2F4NitaSzZUTThqV21pbUw2b2Y2T3JYN1pVNkUyV3FhenpzRnJMRzNvMmt5U2IKemxoU2dKODFDbDR0djNTYllpWVhuSkV4S1F2emY4M0RZb3RveDNmMGZ3djd4bG4xQTJaTHBsQ2IwTytsL0FLMApZRTBEUzJGUHhTQUhpMGl3TWZXMm5OSEpyWGNZM0xMSEQ3N2dSZ2plNEV2ZXViaTJ4eGErTm1rL2htaExkSUVUCmlWREZhbm9Dck1WSXBRNTlYV0hremRGbW9IWEhCVjdvaWJWakdTTzdVTFNRN01KMU56NTFwaHVESlNnQUlVN0EKMHpyTG5PckFqL2RmcmxFV1JoQ3ZBZ2J1d0xaWDFBMnNqTmpYb1BPSGJzUGl5K2xPMUtGOC9YWTcKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=", + "key": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS0FJQkFBS0NBZ0VBdWFwU0ovR1BTeTRnTTJ3ajZ0bERSSGRQMThHY1NBODRZNXR6U21uWExFMGNsODhxClorRkZkYW5jV3UzcUNKbXpmMVBkazZYVlMveklkSTdramFBUnE0eDE2c2lDbWpPMkJGU0NVMWhqUTdJZlZIKzgKN1JWd2wxeWpMMjMvc1hMNUxKQzBhZkdpTEx5MDhuUkdnQm9ySjVtc1NLaTAvbkQzeWZ1akFmTjhrVTJnTHZ6awo2VXpzanpKMEVDTW5pSlJMUnd5TnV6K2o1S1FmTHliaGlrNUI0SUFpeVFhdjVNd3A3cHRpMTRMYlVkeVppQnJuCmJWTThIaGpYY1YydlNzMWdQY1hZWmlMZmdiZElvQys5VUs3cU90WHErUlVseFRWL3BoVUJjTXRvb2IvdHZPMmEKRGhCV1FHc0MxcDVjc0swMUJDdWl1eXByYkZMdVFTZUMwL2luWG5ZYmdQTG5CWXQxWUR0VmE4VlN0OVFRMWJlWQozaHo4d21TMHVDQlRRak8yZ3FwYzlXaEtZU0Y3STlKbmZGS1lOM29XL1Z1MysvVlNYUVZRazdheDhWd3VONmd3CmU3ekQvNlZPRmVOSHlIaGdrcEQ4VEJ3WFJaMzZoR0V2QnFxUnJXbDV6WFJnU2F2SjlRN29JcmlHc3BaM1V2S0YKNndIUDk3S3JlRWgxT2ZPS3BlSEpRZEVxQVNRTmIxVE9YdFhuejY4TXRsOUJ0TlpxWDhBaFRhUlBoQmdTSXB6TQpPYUg1OVlzWW9hbGRzMWVVYTh4bFg2VWVNcFdTOHJ4VkMrOG1pcWNjTDU3NklpNDZNNGxGaDNPeW5PNFZSTlBPClpma1hzOHJDSWhaSk12Tjc3VEVwT1BZK2U1R1U1WkpkR0lJOEdzcU1YMmRiTG81ajdBaWxjRktLc0MwQ0F3RUEKQVFLQ0FnQVFRcmc4eXRWeHNHbStEekhMR3ZtZStMTWJzcGQ1SVNvZllTazFBbldIcWY0LzFuTkFrMVIxeEdscAo1azNac0hwdHc1N1RsRkhNdktnaXV6UU5xay81M2d5aXdpcGNEbnpadnJ3R1NDYXpjbGwzbVZObTBUcWg2d2xZCnVWSE1UUVZCTDdBNVdpSUp1SUpCZk1rQzVhZlRhVXhUTmNGYVNuTU5NOVo0TUlhL3BCU01JUFo4YjFJRmpaWG8KSnVnS3VIQXFXaEZjcmF0eE5pV01CK1AzYkxpc3pKUWZWcEJYb2NEblpxRCt2blZ1WXp0b0ZmRWdYRXFXMlRhZgpOV1VKa0ZpbU9GcG1RWlloSW5nQUhZWS9WMzVvcGowVTBSTVVoVVczZWozOHpyZk1lTVo5eGpKNG1sb3ZmSi9NClE2VGJEV1JvQjlsUitwcGJhNXoxeEZ2YkxEc2VMby9XYVJMc0JLeTR1bWl5ZXd4RjllWk9xTWI5VlJlYTRlYS8KR3Vvblltc2ZGS2g2VE12dmtDWVpLYmJNUC9qRXNDQ2k1eVhRRzZFK0s0c1ZLN2NXQ3J0dmJocmQ0OCtFNFZYNQpqUFR0bG9QVTE4WitJc0RrSXlxM25QZjEzUG9pNHo4S0dmVmFOQlFBTDBETDBMcS9YU3ZuQ3RMNHc2UUxyNWpnCm9PenpQOWc4cEsraWhRbGgyY1ZNcFZSSWcxZjkzditlb01VbnFPS3hlaEhGVVhwQzdYS0VUbldCMGdXWjAwUE8KWmRrWmlvelNzRTlQZmRmczljYXpnS2txbTZUMVA0emgyc3RsYVZ6bURwN1RjVE4yd2ZldmRaUFE1bVFHR0tOawo2c2trSWVsb1hKNldEb3NqVlVIaENmZVpmTWpHRjlLOGc5aU9JOHlCdDU3TTh3M01hUUtDQVFFQTlwN1RXU3A3CmF0SUo3blJodk1EOWJNZCtvczM1bzRFc043YUEvZ2MyaGlKSzk1Lzk2YW9ITlJMMjFZeW8wS1E5NHdsbXZ3d04KYlUzRU94dDR1cVMydTZZd3BURGdtS1lkcDFjeDRUNkRrNkcyaHJTZ01jZzhhMVZqMmVRRFNFK2RSb0dtZXA4MApuM0dONlFPNCt2MkZ0MHpnd1hWTlVVYldSY0tGUVBvYVpIbk1MWFo3bEFFbjFWUjBYbWhLcDMvMXNodHBpby8wCkpVaVcwL0RTNUxYRTFRKzBvb05hVzdGVWZQZUg0MmZBM0pOZDBSZFRZck4veFJBYXNXY2JoZEZDcmhxeDRuZ2IKd3lEWEVWcUliVTZNS1NkS29WRVYvYzU5VVQ0TVBEUmlFUVdzQy9XMm5LbW90TG4xM0grclpjaWJCK3AzSVZhTgowdGlqRnBYRFAyNXVPUUtDQVFFQXdMb0VhTjlFeDRaemM5cE1WVng4S25Yekxvc0tlSjJtNXFQVXBiRWgxSnJVCmFoMFN1OWViZU8xWFZYVDRQejFxT1ZleEl4Y1RLYU1oTmxxc0l4OC9ZcDFjb2hiSnFkWFgzOERuNmVLMWw1OVkKREpXU1UxSTMrSVppWXBVNTNmV3F5UmNGNmo4SFowRi81c3NoMm1tUFd0c0FiKzRET3Y5Z1RvdDZNN3VuU2lLZApCeC9SeFphSFFiYnhpNy9LUlhiY2hRVjUvcGlDODJwOXM3bHI1WFBMc2ptbFVWZnNmSUxjNU16bHZUSmFkaXZDCnhGS2VBbXJ1SmIrQUZZUEQxMGlOWWd3T0EzVVNBNmVxUUl6OE1RdUYwUyt6VmgvdzA2Wm9YWFpTeXB6OHVhd2MKQTNsTllWSjJhTFJBL2V4WTdGQ1U3aTZaRjJyMGRLVFVYK0UrT1lqUmxRS0NBUUVBdS9OTEt4Yy9PNmViUGdtZwpPeFB4ajZkeUVXMWNwWWxhUTVOcE56QVVFNkdxOUpFUUY3WW5EQUhKNy9IazdpMWRwTnVUWEdJNUVXWkUrSzcxCkVYbGFjaVF6eXBFM2VkNlBsdXJTN3RDUHdrRnFNN3NRb3gveE8vTzF4MmNJUVdHN2dQSnFCK1d4V3hwVmhwSDUKdGY3a20wK1JzcDVOTUhZRjh1Qk9ZYmk1eUgweWFDdDEvdFBxb2FCZGwxMXdGN3RqYWp1TjhiWEg0V295Mkk4ZQppb01rNFNPNURHbUN3WUtoMnlOaWdxS2R6dnpZY1BPUDd4YlJkMFBRdEFiYWFOK1VLOC9ZWmJvWG5sdzJ5OWp0ClJEQ3FqT3FPNitZNkZsWDZGNkpyL0s0SFZ1VzF1dCttTGpyMmdkVi9WRzVRRjZBTTFybVAxTzU3NDRhS3REbXEKdFhyWnNRS0NBUUEyR1FENzN4QVRZYTR1cXV3YVdlZVFrNk03UVZRakh6Mm1KLzZjcytmbkliN1dPbXJ6eDBXZwo0QjVQb3BHOFRxVHVpNnJ4U3B6YVp5di9jZHczejZHZk1NUzd5dUc0aG9vZUNLd1FmczZ5VE13T1hEM0NuYVdRCllVaGttN2F1a3pMcFYwa1Z3N04wVEhKMmVqWjZkSDhFNWN4dG5zWFJYYlhPMWwvcS9aV3J2YU9PU0dROXJrOHgKSndVT1VUdVozQTl5VjJNekQrUlNKMlk1ZUtiTXRZQkwrdHBveHVGZTRlelhYSnh3U3g0UlpaODZOR2FlRHgzaQpBcXNWWHZNdUR2T0F2aG1BNXUremp2Qy9SZFBVZ0lPRGdIdzZoS1M1QkZEdlN6TmFKRTdjWDI5Tm1sTWFLbWVLClpHNkoyRG50dTREU2lzbjJSTk54bW9MeWx1czhneWN4QW9JQkFFekFVWEFJdWV4U1BDZ09QeFVjMXRCTU9NTHYKamp2eEFocUJ2dEFkeVA2VGt3QjVhNGJxQ1k2MTNUckxsMk0wTGV1RnEzUTFNOXh1TlJBcStKQlJzS1RJSUVHaAprQjRlWDBoK3RtY1AyWTVzMVhESkJUYlJpZnozdGFoNGVQcFkxWjkyNk5zSjk0aUIyREVyVktVL3psUC9PejN1CmtrYUVuSWFtVGxnQWNMeWJzNmJrMmJkai9ERzd3QmF3VVZrSDJyMTVzWUsxZ0QwQ3hUL2NUUXd6WW42SzdJdVQKcDkzczVxS20rMVovbEN1M3VpK2dEUTZIU0RNTFFtY3RVL1JyUGc3N3pWZVdnWk5SUnB2SmZ2eXl6aEZnWmFDQgpZYWlzVWtTNVdxTGFaOU1ZcU5vMUlJSUhxWkg2eDBLc3hSM1BiNnpoWWxaZDFnQWFBM3JFRFFQMmtLVT0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0K", + "Store": "default" + }, + { + "domain": { + "main": "api.truckwash.io" + }, + "certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUYvRENDQk9TZ0F3SUJBZ0lTQnVTQmliWTFrL3JGK0R6M0I2RHl5QkxnTUEwR0NTcUdTSWIzRFFFQkN3VUEKTURNeEN6QUpCZ05WQkFZVEFsVlRNUll3RkFZRFZRUUtFdzFNWlhRbmN5QkZibU55ZVhCME1Rd3dDZ1lEVlFRRApFd05TTVRJd0hoY05Nall3TXpFNE1qQXlOak0wV2hjTk1qWXdOakUyTWpBeU5qTXpXakFiTVJrd0Z3WURWUVFECkV4QmhjR2t1ZEhKMVkydDNZWE5vTG1sdk1JSUNJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBZzhBTUlJQ0NnS0MKQWdFQXdKUTF2NHpIQy9aZ3p3OWMxT0NOOFdPVUlsamg0aytBSUVNak9EL2h5cnNzR3hKQ3NEUkhDb2wwWlBLMgoyK1dRMjJlU3dUZFQ3SFcvSTFsUGhUYmRiVGowWU5SSkdaaWgzQnJwWHYzdFpSRHJWaUZmVXlBTDFtWHNldlZTCnl0cTYwWndTMC92RExxZk4raStDcUNPSFQzU2JhODJnc3BUbnhhdUNkWXMrWTZFMDBLTWF0Q1Y5MkMwUmZPTTAKYUhPUkZDSVp0V2FlVlBvdWVhOWllVUQ2WUtLQVNGZlluV01odkdVcWdrNGd0TlpyM21BcjNnT25reVMxYUtDZgpjVFYyaXZqaXc0WmN3TlZuendidng5di9lRkdubzBBa0hSc3lJMCtsRnB5RnVlWXhuTGlyVFdhQnFsVjFONnBpCkgwNEwyMWZ0c2ltLzBaSmR3L3ZNU2VMK1htQTdWeUprSWdIaTJOUDVWQXlwdWV1TTh3NEIyRkljcks4eG9VNlQKcW9sRSswUDQ3YWFSQ01hcUczRjZqQ0oxM2JGSGsvc1RoVlNwalhDU1N6RWZXZXJRZXpyOThpNEttZXpXZW9LUwp6a2xhM0hBUjBxaUJFOTZ1TFgwa2hGdEUrNXhaYnhSeDB4TDY0TjZXcEJCdWFmNXFieC9GWFdrYTlGTE5xNlFWCjZuekNhejF4WnBrdHNQTnkxejh4WWVnNnBDN2Nib25uYWxiamx1VmY4ZzcvelYydWgvd0cxeG9iTUVIRmoxMzQKVlhCdjhORzFZK0c1azhsYVdkL2NKYkJUb2R6eUx6Q0R1aVZOOXV4VEhEcFUxS0pidmlaNElQV2hSRTgwc3JFWgowWjlVK2FpVm91TGxUcGN3RVZIb0d1cW5Oc2tVS0hLNGpiWjVjRE1wTnJISmhvRUNBd0VBQWFPQ0FpQXdnZ0ljCk1BNEdBMVVkRHdFQi93UUVBd0lGb0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQU1CZ05WSFJNQkFmOEUKQWpBQU1CMEdBMVVkRGdRV0JCUUh4YTVjMjBTalUrQXhQbW1xQjN2VVpZbCtLREFmQmdOVkhTTUVHREFXZ0JRQQp0U255TFk1dk1laWJUSzE0UHZyYzZRelIwakF6QmdnckJnRUZCUWNCQVFRbk1DVXdJd1lJS3dZQkJRVUhNQUtHCkYyaDBkSEE2THk5eU1USXVhUzVzWlc1amNpNXZjbWN2TUJzR0ExVWRFUVFVTUJLQ0VHRndhUzUwY25WamEzZGgKYzJndWFXOHdFd1lEVlIwZ0JBd3dDakFJQmdabmdRd0JBZ0V3TGdZRFZSMGZCQ2N3SlRBam9DR2dINFlkYUhSMApjRG92TDNJeE1pNWpMbXhsYm1OeUxtOXlaeTg1Tnk1amNtd3dnZ0VPQmdvckJnRUVBZFo1QWdRQ0JJSC9CSUg4CkFQb0Fkd0RMT1BjVmlYeUVvVVJmVzhIZCs4bHU4cHBaelVjS2FRV0ZzTXNVd3hSWTV3QUFBWjBDMW45TEFBQUUKQXdCSU1FWUNJUUNKakNpVjJEeTVFWG1WeUpMbzRCdkM3OEI5dlkxSkp4QzBIZlNOQlJTczd3SWhBS3hlWGtPQwpwZVdvT1VRZGZXbjRRQWRGSmRXTTlNb0FuODRwdW5UNEVLUHRBSDhBR291ZGFVcFhtTWlab01xSXZmU1B3TFJXCllNekRZQTBmY2ZScC84ZlJyS01BQUFHZEF0YUViQUFJQUFBRkFGZEYvS01FQXdCSU1FWUNJUUNJU3FwV0JPMTkKRDIrSm40cFp2aXR1WjErSk45TUh5Rm1QVEh3L00wWHJsQUloQU5vY0hDM1RCVGJtaVZVelhQZ1hvcEM1Nk5RUAo3dHZZdy9VSFltQk5hRlFTTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCaHowUUk5cExkbUVpNlhyZUUwVHI3Cm9DaTZFbVVKMFRheU5wZEdVUWJZMjk0dDQvQUJta3grNllDQzF4MCt0anRtaVUxbGRMRm94cmJtUS9BaWdlL0wKUm44c0IvUnl5Y1lYeVVxaUV1OVFMK3RVK0NyeEQ4WGd5NkNhWWwyM3lyN3Vzdll5bVZPMlpTcXlBQVJvNEhlKwpZNkRyWXpiSzZPYlUwTzFFS3JBUXBHaUdYSjRPMXFwZ0ZJQ1l5clJabjgvYkhHaUpKbGdObVk0THFkVXI5MitFCmdkSmRZV29CUktMbVJPcEdiUE1zcnA3VCtuS2JZQVVEYkpQTVpjYWZxa1VGdEp0eng2dm1mYWhZbEprcVFrQ0oKL0pxM2NDQS9oODFtczMzWEtZZFNybTVyYndMc092aC9GcThIZ1lLZjBpRlByVTlRSFNmTCtXakVVRHp5Z3VubAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCgotLS0tLUJFR0lOIENFUlRJRklDQVRFLS0tLS0KTUlJRkJqQ0NBdTZnQXdJQkFnSVJBTUlTTWt0d3FiU1JjZHhBOStLRkpqd3dEUVlKS29aSWh2Y05BUUVMQlFBdwpUekVMTUFrR0ExVUVCaE1DVlZNeEtUQW5CZ05WQkFvVElFbHVkR1Z5Ym1WMElGTmxZM1Z5YVhSNUlGSmxjMlZoCmNtTm9JRWR5YjNWd01SVXdFd1lEVlFRREV3eEpVMUpISUZKdmIzUWdXREV3SGhjTk1qUXdNekV6TURBd01EQXcKV2hjTk1qY3dNekV5TWpNMU9UVTVXakF6TVFzd0NRWURWUVFHRXdKVlV6RVdNQlFHQTFVRUNoTU5UR1YwSjNNZwpSVzVqY25sd2RERU1NQW9HQTFVRUF4TURVakV5TUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCCkNnS0NBUUVBMnBnb2RLMitsUDQ3NEI3aTVVdDFxeXdTZisybkF6SitOcGZzNkRHUHBST05DNWt1SHMwQlVUMU0KNVNodUNWVXhxcVVpWFhMMExRZkNUVUE4M3dFanVYZzM5UnBsTWpUbWhuR2RCTytFQ0Z1OUFocVo2NllCQUpwegprRzJQb2dlZzBKZlQya1ZoZ1RVOUZQbkV3RjlxM0F1V0dyQ2Y0eXJxdlNyV21NZWJjYXM3ZEE4ODI3Smd2bHBMClRoanAyeXB6WElsaFpaNys3VHlteTA1djVKNzVBRWF6L3hsTkttT3pqbWJHR0lWd3gxQmxienQwNVVpRER3aFkKWFMwam5WNmovdWpiQUtIUzlPTVpUZkx1ZXZZbm51WE5uQzJpOG4rY0Y2M3ZFemM1MGJUSUxFSFdoc0RwN0NINApXUnQvdVRwOG4xd0JuV0lFd2lpOUNxMDh5aERzR3dJREFRQUJvNEg0TUlIMU1BNEdBMVVkRHdFQi93UUVBd0lCCmhqQWRCZ05WSFNVRUZqQVVCZ2dyQmdFRkJRY0RBZ1lJS3dZQkJRVUhBd0V3RWdZRFZSMFRBUUgvQkFnd0JnRUIKL3dJQkFEQWRCZ05WSFE0RUZnUVVBTFVwOGkyT2J6SG9tMHl0ZUQ3NjNPa00wZEl3SHdZRFZSMGpCQmd3Rm9BVQplYlJaNW51MjVlUUJjNEFJaU1nYVdQYnBtMjR3TWdZSUt3WUJCUVVIQVFFRUpqQWtNQ0lHQ0NzR0FRVUZCekFDCmhoWm9kSFJ3T2k4dmVERXVhUzVzWlc1amNpNXZjbWN2TUJNR0ExVWRJQVFNTUFvd0NBWUdaNEVNQVFJQk1DY0cKQTFVZEh3UWdNQjR3SEtBYW9CaUdGbWgwZEhBNkx5OTRNUzVqTG14bGJtTnlMbTl5Wnk4d0RRWUpLb1pJaHZjTgpBUUVMQlFBRGdnSUJBSTkxMEFuUGFuWklaVEtTM3JWRXlJVjI5QldFakFLL2R1dXo4ZUw1Ym9Tb1ZwSGhra3YzCjRlb0FlRWlQZFpMajVFWjdHMkFySUsrZ3poVGxSUTFxNEZLR3BQUGFGQlNwcVYveGJVYjVVbEFYUU9ua0huM20KRlZqK3FZdjg3L1dlWStCbTRzTjNPeDhCaHlhVTdVQVEzTGVaN04xWDAxeHhRZTR3SUFBRTNKVkxVQ2lIbVpMKwpxb0NVdGdZSUZQZ2NnMzUwUU1VSVdneFBYTkdFbmNUOTIxbmU3bmx1STAyVjhwTFVtQ2xxWE9zQ3dVTHcrUFZPClpDQjdxT014eE1Cb0NVZUwyTGw0b01wT1NyNXBKQ3BMTjN0UkEyczZQMUtMczlUU3JWaE9rKzdMWDI4Tk1VbEkKdXNRL254TEpJRDBSaEFlRnRQanlPQ09zY1FCQTUzK05SalNDYWs3UDRBNWpYN3BwbWtjSkVDTCtTMGkza1hWVQp5NU1lNUJiclU4OTczalpOdi9heDYrWks2VE04aldtaW1MNm9mNk9yWDdaVTZFMldxYXp6c0ZyTEczbzJreVNiCnpsaFNnSjgxQ2w0dHYzU2JZaVlYbkpFeEtRdnpmODNEWW90b3gzZjBmd3Y3eGxuMUEyWkxwbENiME8rbC9BSzAKWUUwRFMyRlB4U0FIaTBpd01mVzJuTkhKclhjWTNMTEhENzdnUmdqZTRFdmV1YmkyeHhhK05tay9obWhMZElFVAppVkRGYW5vQ3JNVklwUTU5WFdIa3pkRm1vSFhIQlY3b2liVmpHU083VUxTUTdNSjFOejUxcGh1REpTZ0FJVTdBCjB6ckxuT3JBai9kZnJsRVdSaEN2QWdidXdMWlgxQTJzak5qWG9QT0hic1BpeStsTzFLRjgvWFk3Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", + "key": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS2dJQkFBS0NBZ0VBd0pRMXY0ekhDL1pnenc5YzFPQ044V09VSWxqaDRrK0FJRU1qT0QvaHlyc3NHeEpDCnNEUkhDb2wwWlBLMjIrV1EyMmVTd1RkVDdIVy9JMWxQaFRiZGJUajBZTlJKR1ppaDNCcnBYdjN0WlJEclZpRmYKVXlBTDFtWHNldlZTeXRxNjBad1MwL3ZETHFmTitpK0NxQ09IVDNTYmE4MmdzcFRueGF1Q2RZcytZNkUwMEtNYQp0Q1Y5MkMwUmZPTTBhSE9SRkNJWnRXYWVWUG91ZWE5aWVVRDZZS0tBU0ZmWW5XTWh2R1VxZ2s0Z3ROWnIzbUFyCjNnT25reVMxYUtDZmNUVjJpdmppdzRaY3dOVm56d2J2eDl2L2VGR25vMEFrSFJzeUkwK2xGcHlGdWVZeG5MaXIKVFdhQnFsVjFONnBpSDA0TDIxZnRzaW0vMFpKZHcvdk1TZUwrWG1BN1Z5SmtJZ0hpMk5QNVZBeXB1ZXVNOHc0QgoyRkljcks4eG9VNlRxb2xFKzBQNDdhYVJDTWFxRzNGNmpDSjEzYkZIay9zVGhWU3BqWENTU3pFZldlclFlenI5CjhpNEttZXpXZW9LU3prbGEzSEFSMHFpQkU5NnVMWDBraEZ0RSs1eFpieFJ4MHhMNjRONldwQkJ1YWY1cWJ4L0YKWFdrYTlGTE5xNlFWNm56Q2F6MXhacGt0c1BOeTF6OHhZZWc2cEM3Y2Jvbm5hbGJqbHVWZjhnNy96VjJ1aC93RwoxeG9iTUVIRmoxMzRWWEJ2OE5HMVkrRzVrOGxhV2QvY0piQlRvZHp5THpDRHVpVk45dXhUSERwVTFLSmJ2aVo0CklQV2hSRTgwc3JFWjBaOVUrYWlWb3VMbFRwY3dFVkhvR3Vxbk5za1VLSEs0amJaNWNETXBOckhKaG9FQ0F3RUEKQVFLQ0FnQWRYZnljUzFHSFIza1gybFhqdUtLc3J1ekdHQnZLTVNsdXdnNDY0N01CQVc0R2F0QjBvbVE2L1I4Ywp6YzZJMWdjekRpWlpCS0R0ckQ4TG12RC9kWkJxdVlhNnhXU29YSmhDUW5CWUpnenZucGJrdVk0WjFRYzVHSzNwClNrUG4zMWNoNDlVVE1vOWZMQVBESlQrZGVGMklCbFkxS0ZSYkowMzQzT0MySUJmUmhhSFNOeTA1VDRaVFV2d1oKdVdRaWtZME1MdThRdEtkc2VnSmdXQmlDT1NCMVlCS0Q5YkY0dnNkRVBZRXdBYzRIQmJQSlk0ME0vQzJ2eUtkSgpYMFV4TkYveGJXYlhQa1FpdStocXI3b2RGalZ0R2JhZ3RJVk5VdnBnWENMbXdkTzZ0NW5WTm13WkQ0cXllUG01CkZXaUZNWmVwU1UxY0gvVzZkY0dLdzNreHcwWGF4cnozSVhYaWpSTmhBV2xUMERJUGxzRm4xMkE1NW9RTFFlTzEKdzlQaW5WRWwrK1QxVTBBQ1o5U0RNUGwzenpGQjc5MG1rMG1aNS9rWGpGYm9oaHpiTi96VkpieG53K1RDMW5WSQpjeE1HRmNBeU4wN0pkOHBWVDJXa0IxbHJuSk92NG5LVHM1MDJtendqVEQ5djgyamxMSU9jS2RIN1M5WnJMdUVVCnVyM2ZlalM0b0tKNjk3U0ZRV3VCcGMvb092N3RpdWdNWCtkZzBpRDN1TWZCQ2pUR2ZVQ0x3SGxDWmY0MFQ3V1gKRCtHY01HZFV3allKT1VNdzJWNlBxeU5kdXA2dHR3ZWFPYmpYeEd5M0VYSm1mT25DNCtUWFcyQ3BDcExSZVNZRQoxWlNGbEhJbjd4bGNnU3E2c2Y0d0JHWGNtMjE2MGdpN1BNbjFaSWJOMy9PRnhpU1VTUUtDQVFFQTVVQkRLdGlpCit5QzhEYUJRY21iWlV6TnNCY2RSRHVqR0NhRTZzb29HYUNWTys4T2RhanlNMEFhL0VScDgvalRFY2hYNWEwYm0KTkJpTXhHbGN6ZmluNEpkTXlmWWpBMWZBN2s4ZnUrOHROTjUrQUJZU2JmS2R5cDdTeWpVbW5MN1ViSjBCNDZDUQp2S1hMOU51WjRoN203aTFyU1cyK3h5cWhEL2xDQmlVcGtzUkJZREI1TlNwQkYyVVF2VEFEZUNHM3dMK21ocExvCnFJRndieFJPdzBMRXFFaENkMDFCdUJtcUxMUjUwdDVOYmpjVWlFNEJJSDF2eWp4K29CQWpLSzBlZGFQWllQUEYKR2gxRXRXcTgydGd1N0NxV0pETmVWYjNtMHBLZFdFUm9oVEJMYUlTMFdnZEJjeVhxWm01U1RwZ0ZjWGNFSTFQRgpyWmV1d3hwelhveC9rd0tDQVFFQTF3eU5FTWFRb0FyYm8rbUJMbXg3Y2RtV3lvdUs3cDlKVVorUzc1cWM0QVA1Cm5SL3JRU21xQmlNMDVBVjJqejk3UmhVQmVSbDlCTGo1a09TNGthSWdvQitKZHNlaFFhTHlodlA5NHEwR3BwYXoKelBFR0Y3bUVvb0lnT0NZb2tSbXZqeXJHRWpXUU1reE92aDFMbExMOHMwa2pxb2xJN1owbXo0M0dnTGsydmlKYwp3TDJ2TVJwcU9jbGE0N2E1Z01UWEVqY0hwSTgvNmQ0aDZMVUdMQlVpRVMydjVnbVZadWllS2JjYWJIL3RhMmsrClo4TnBJQnFIeDVqOUViYkhOSDRWYXlweENCZjg1cGxmb0ZxcmhQcytWajVrbS9YYXErRTJXUEExOEtXVklzRysKajRzR1Yra1J5TVpzdTNuQ0JPQU4yZzhMNHFiRERZbnZrOGZxL0g3bUd3S0NBUUVBbHRDQjMyd0pQRUE2dHBRaQptRUJFOUZFOTNVZFVjZ1I4VTlWM2NnRVBXZkJCVjZ0R29aOFgyN3EwYzZJRFhKQ0dNNjkxR0RmYU5hZDQ0N1dVCjBnT2xIVUVyeVNsZHUxTTlud2o2alg0NU5UQ3huNGpsc3VNTEgwTUExUG5HWHhQRFYzaXF4MzdYK09MeG5ESGoKdURnd0g3eE5lazd5Vk1BY21RK1hlY2QwNUJLc0Y0V01GNGVtcnFkVUxjR3FacHQzOFJ1amg2Q1o0bERWRUo1Qgplbjh3Tkt6azNPczhNc3JmZ3UxdnYyTnplUEJQVjZSSlpZQmZ6S0dqZzlWWjYrYUh4VWgzTnlybkhkam5YSm5OCmlBTDBTM1Z3dEh2K3NWVnV4bFNob25IT2VPQmVtM0hCM0FBdDlIeWJDYXZHQmVKSW9tNXprSElKOTd0end2SmgKdDIraWRRS0NBUUVBbzdQaE90RDlUV2VXbGF0dWRFbU9temRaUzZ4NFFYZ3VGeDJUZkNSRm9WNUZTVEFBSlNXegpVV0xCbzRicEh0ampYWkVtQXVxOU9iZ1ord3V4Mk9NRFZRRk00b2ZMSUswTFZHSXVkYnpqWURNK2doZEVYUkVUCmY2WHhJdTBoV251a0lpY2Npc1lOazh4MDhOSmFNOE1oRE5YemVhRnlTN2VpME1NTFJmZzRUUHJaZ3J1aXZvMXUKS2JrM0lEdDc4U05tMlczY3VvOTF4dkVhL1F1NHZCd0dSKzl3aEh2dEtGNlI4QTE3NXZablpLbkVJTGNuc3BHZgpwUGk0UlNEUGVnMDAvdFJiSVI4cG5OMUtaMFkrWlVmZ3gwUk4wRStTZTk5QVFPbnpGZExtdVlPbExaNVdZTnVDCmQwVmRoa1dFeGJOTnVCNVdITjUzZ3pucWo2UFRKUXp3MndLQ0FRRUF2OEs3WjYrU0tORzkxR1hLMGF5YkhpcVEKSzZRdlVQV0g1SDZnV3RWMmV3U1RNcG0yUFFrazNvMlBtNFVQd0M2RFR1dWVKNDdBMjBjRlpvd2E0VnV6UjVhTwpKOTVTYjBTUTJTQkUybjNvbHlNbnJ4RDl6YUxSV1Y0eG1IL3JoMzFjYVBQMVJxZUxnZzMzVmV0SGVQUnovd0VnCjZodDRuR1hKU2pkNkQweER1V0JlUVdzSXFUbGx3SUw5MmtYcDRWMDRJRUJBMkhPeE92Z3NDNnl1UDIrM0hxWksKN3VyZlFBcFlUOVZGcUVnalNTZVR2eCtmalgzeCt4VW13RXJQdTZqZ0N4cFRncXdJT3hORSsvdDJ6blhhK3RhRQppZ2NmNHp1Y2tFM1h6VFF1QmZhMFZNYmdiaTU4dnJ1V29LSFhKV2xEV1ZNYUtMMzBxdmdiTnhUbnNDYmlWdz09Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==", + "Store": "default" + } + ] + } +} \ No newline at end of file diff --git a/services/traefik/acme.json b/services/traefik/acme.json index 0967ef42..5cb0dae3 100644 --- a/services/traefik/acme.json +++ b/services/traefik/acme.json @@ -1 +1,41 @@ -{} +{ + "le": { + "Account": { + "Email": "jb@truckwash.dk", + "Registration": { + "body": { + "status": "valid" + }, + "uri": "https://acme-v02.api.letsencrypt.org/acme/acct/3156106721" + }, + "PrivateKey": "MIIJKQIBAAKCAgEAwaJI15iiqVnfviV1wClLGYpz89cwgLfUGuTkAbW0Sc/+x5opz6AT0lxSg421FeT7fBqtzGojnYeZ1/VvZWtIgLv6KgvaAsllNSZuwyEbBXdbEhvyv3Ze4a7DW4KeoJn1qjlwIOPkXe9An4isxRVBrpO/AK+G/ZURSU4C6roYhr+qj5lFKciI6f6n7xMOgQ/5drDEWI3fw8VanFSyZgBc4b/vKJciU0S/+UvmWs7zuuD+muesXHFKQogltps4uLJ1DscXmkaYLYgohVBsvPRrJBz1tqKkRVRsxbLcDO2ImMFLbZP45UySgG7OrfCrvnPcRFegj/RoJBiI2FfvOcM8bK0NBc3Vyp91o3p5F0VsOdhMWDFsNLtG4LNhgX+fovlEOpM3GhwJxHe4fu5fYbMaxYQ4abozLO3Exw510Q3gVqg3KpUTeH5Mmy+r8/+np1B6auYQX/G28PqYiMwOAFba00kmahi1j6fvd5v7fB0tXg8XpMnXEDwJ+J8Iy2p5pDc8K5YibSbRj992Z2Kzs1/IRijw+MbyZnWE8DoQ5vFthJIcCodYcVolQ/shE8hWVzjvwWCInee/56tTLDzenmBldyyij7K7wfjU7waqiHqxBIYgfyJDkoVJaIEAhdWomPyAaSxFfTIOvn32PL9bI9qKlm6TljjFkkZoceI283cmgzkCAwEAAQKCAgAmNoYkkhlLzZJDvrXJufx5qmKutQF77Ytc0V4T3k2ZoYQY1Ro6QM+YnKKmFqGtk/Zza/pvlGS58mhQf72/qJfy2+YHRTZ9hUxFXHPQa2ifQApHfR9/XHdmF2yzUwhGDow2T0aLHx5S0WQR99GMxWCkeMDhfEJqKfBy5IPb2eT8NrgMxcUp9UrbzudKXZrnqoTjQQhGp50I0BpNLsPUMG8kGBI1Evpxr2gpZSqtMhlUgJGOdXkE91Cd56t1qZVckc9bHrxQNG1U0wGYKaKD+cJpKpPtOpSrn/klXOJjZL8n0kG1+tVMPzvMP9Hm7s+d8tR94kMsFaI1uWfMNjJeVmLGgRL/xtDG+LnNYXzLGV33cOsrYr0D2bXZq4EYYa1RhelMx2bAmonE5sXudW6WGZxJEQSPdqyflkq/50Lbh8ikdqY0q3xaqTqs7FQJdS8Y0iDvLf3RV1VMXUZp7+OG3LwqrfId1EYrnkgDN/cvy+4+YiiKILkknn6jXhhnKcSf5f1eNq/ZB8GQtbJ+wMHy082xNtWKZ5CLuS7nxpC47RWIncH5VY0kyjshaEW5WQ/1S8bdjU/+nZCXywPBLW7LeZUzGEvqIlL19jehf4LmMKn+ZRyBHrSeezQEY9WxwE8cg36eefu7n8vABhPbn6tOHpJcoN+QeWK4oLdkp2oDNUPsPwKCAQEAw3shrNt7pdIpRgBgnHQ2zpBjMbVhWRqCxCdobyxpUAK4VWY2IPY0c0LtHsA7l7P54Xnl3f20V0oJ0D+aNIhaVeAv61Es86VkV8vT7qV061JTM1U5d/+bsWBl7+Bis4NsN9Cvs3819IAMsWmxfm7aicH5PmIVxmbqIG7X6tsCzj2AedfFceO4owIkblhnf7CubuyHGjnTpiUylb2/2Jv3F/aoONvEK2Aa1Cx6ZEzszp72s3QUcQbJShVe1vlYDfzoKmZLj+VbZcdQge/39vDZbuvuEFMqZ037UZXsZAA/v6RgtpRSNQgCRvCZO0hcpFdWzV83RoN8quegUKJRLxAajwKCAQEA/ZTDnx79fClrE7MphNWYO/fTltR+l+OZKOFdmp118qJVx/lyjEtcn2MCLofpG/pSwB2GR77c5LJqJkGjpsIBQ6ZLp5JA0l5U0g6VwSuQt4W/5Tl3WVTEZ9WcCkcxqLR0LcCoSVK8in/iyVkO1hYQeQPCORSn95YiV8Vc/CKe3Uldgj89ZLvLHgcDENhwghyo0+b8i1IB6CIYNTk+PUyEI4IBkzDezDg3zSO8M7RqQJZrP3FZs2Lhhb1jR/LQuIy1QoZTO91VKZdeRAkZ8zHU6Evtq4rSjPRq8QuUJi12umCzL+LMUzrQzbuHFPk4Dqcs644lyJQ1JlMtYAEcL0OJtwKCAQEAjBOF1AoLFo38iW7ny/Ty+R46Fnq04Va/8bOTgGbAqFbqpDdz/jau6xFAPVLe4RxUqR/ieiq8ufgSBCovZAl5QiQ98k/e+FDCEK+8lDv4BlCd0iD152lAteAv665My/oW6AAgh21WynefoNnuGH1zGFfpNKywkdVZXBhRnLeH0SX9FFJr5+qYeiak2HV85OFEfbP8M+zQHzR0hRzNhnhsnb1gWi4Q6kwZrYSZx5nf70e13G4H395PC0k5Bq7yTEO4Ufmvl9NwpQQoSpQcidWY2YUyuHv/3LriplnaZOZQyBEQRIUQS3QLva3W/8YgrJrxVoUuZNb/1K/aDy3hIupVPQKCAQEAgGP3//LS6TCXVGwcE3OKFqLN8Mo6JnFINNiWahhbhbQHtq5n/vyRMuoQSrrpng7KIxdPy7epY+mpsSD+2QnVVgk2uOtmAp1cWNaloB6MsT74//huoz42C1SuIs4VKJPlVtz619UaPQ2vJ779sguN35e1mO0KdmL/lG0LPWXSBbyFqdKXJQ+oMdXfCYJsxJP6Lv/+0hN9f6jzlM8c4jCBnvDOF1ZhGAlGx4jWW193hKgkOQUNI7Rm9y4CvIUGhMpJZBtavS69VcecJNpu1yFd6Re4iGhLXq7KDq2MHyBZwtf/Ibm1NlbLLb9LD6V3aoPeTI22N65Cktr+WGN3f760VQKCAQBuUMCbB2EAf5xTfp7jdVuiBbs3kX0af4KGy5BnEjlWpIURfIq9RlGtIq0fPAAqvjBv5t1lOg0CefgokQHtT/MI5o2Z6RLLfHvIQETBWZH5Y9kdogitDqq8DOP8JI96LlhlQ7huabFmyhMJdTbWvasn5bRd3YYWkeGB2HkoeCPVyskKpXWqj+MqJUGvxLlhl0ya4dR/B20o11EVg1YG8lJOU8GrbeJeTVTMVyT+/3EwNPu6/ClfXPo4QKw/zGX3Quvjd8cCNqXoHowWTzAxkHdMdc8TIlM1UeOpLxS9ZgmqWxmJez7A4be2hSwkgyDLFmLUm5La2BpcSN600mIieCF6", + "KeyType": "4096" + }, + "Certificates": [ + { + "domain": { + "main": "cloud.truckwash.dk" + }, + "certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUY5ekNDQk4rZ0F3SUJBZ0lTQlcwV1dOTFBtMklhYTM3Rm0zV0QweFhSTUEwR0NTcUdTSWIzRFFFQkN3VUEKTURNeEN6QUpCZ05WQkFZVEFsVlRNUll3RkFZRFZRUUtFdzFNWlhRbmN5QkZibU55ZVhCME1Rd3dDZ1lEVlFRRApFd05TTVRNd0hoY05Nall3TXpFNE1qQXlOak0wV2hjTk1qWXdOakUyTWpBeU5qTXpXakFkTVJzd0dRWURWUVFECkV4SmpiRzkxWkM1MGNuVmphM2RoYzJndVpHc3dnZ0lpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElDRHdBd2dnSUsKQW9JQ0FRQzJiQ3NoRERqd0lEc0RCbHEvM2o4bzhZRXppNlVGRG81VGJaN0xOM2tRcG84bHdnSmd2eW1ZbVdtRgpsWExFSTRCWFZJczdPUHRrbkZrOXZqQzNPZHI0c0Y5NUVWNEJWb2dmT1pZcUNwSHFIR3FVN29tdzRRMTllTGd2CkhqaUJ0TkZGZ21qVHlYY1Z2a25SOUdBMUY4ZUpiTW8ySUw2VG1NQS9oNU1xRGw5b21CdTJ3QWFtSzlBSjRIWmEKdnQ1RVFBV3ZQaEFzdkFzZTNBekZZc3NtMllrRzNCc0VvUkhxaHFMbmE1TEFCeHNwS0JyWXBSbnhoSDBjZzRQWAprWHBob0JONE5QckNKVytlOE9Sb1Q4Tk9VK0tWR2Y4cDRpcnc2VFhOSzZCZ210TlB1YVBRNEIxWnVHWWtpcERLCm1Eb1NVcnlWRW1pN3oySXJiejdNMlgyc1IrRGUwQUlKT25qZFM2V2d4Z3ljem1qcFVtZG8ycGJRLzhKQ3pkQ3gKQmh4Z3hYenAzWW5wK1dzWlZHY0IxdXJtV3hJTXBDVXBxNmxVZHBCZmFwbEtXdkFveWZ6VTliKzJ2QU1WYzZFdgpRbmpzL3BlK0tLNHJ2c2x5ZThUU1F6cFdIdWl5TkR0d0ZPY2xzNytzNS9XM0hXQlByaFJZRHdsRGd1ZmZycVZOCk4rOEpURk1jSTF6aUk1V2RwbDAxVnJKUnI5aitBUDJYYWtrNjJZMlV1TjFxaDdaaENxeE1NdHFGTEgyUmpjZ3gKaGpuelAwMldkemcwVnk5ekVTRERWTEZOdExTbUZqZjdrc25RaGc4QXlMdzlTaURUZGNiVzNvbXFDRkl0dmt3QwppdVp6OHpoNkdUK2w3SThVbGNmWmxJWkxCbkJEQjZQaVFRTlVtUFoxQ01IQXZleWhVd0lEQVFBQm80SUNHVENDCkFoVXdEZ1lEVlIwUEFRSC9CQVFEQWdXZ01CTUdBMVVkSlFRTU1Bb0dDQ3NHQVFVRkJ3TUJNQXdHQTFVZEV3RUIKL3dRQ01BQXdIUVlEVlIwT0JCWUVGRUJRMDNML3V3UlR0RTBIOEZLVUc0ekFUdVFZTUI4R0ExVWRJd1FZTUJhQQpGT2Vybnc4c002QlQwMTVQZU1peWhBNDcxcEl6TURNR0NDc0dBUVVGQndFQkJDY3dKVEFqQmdnckJnRUZCUWN3CkFvWVhhSFIwY0RvdkwzSXhNeTVwTG14bGJtTnlMbTl5Wnk4d0hRWURWUjBSQkJZd0ZJSVNZMnh2ZFdRdWRISjEKWTJ0M1lYTm9MbVJyTUJNR0ExVWRJQVFNTUFvd0NBWUdaNEVNQVFJQk1DNEdBMVVkSHdRbk1DVXdJNkFob0IrRwpIV2gwZEhBNkx5OXlNVE11WXk1c1pXNWpjaTV2Y21jdk9ESXVZM0pzTUlJQkJRWUtLd1lCQkFIV2VRSUVBZ1NCCjlnU0I4d0R4QUhjQVpCSEViS1FTN0tlSkhLSUNMZ0M4cTA4b0I5UWVOU2VyNnY3VkE4bDl6ZkFBQUFHZEF0WjgKK1FBQUJBTUFTREJHQWlFQXhoNzZPU0xVTkhZblkrdDZuOXhkNXJ5UTVlbVN6WlR6T1FONGx0Szc1ZXdDSVFENwplM2xabmJrckF3WGh4VFA3VmpIRGpQeE9ObnA5M2NmS2JFcVlOU0xuSmdCMkFKYVhaTDlWV0pldDkwT0hhRGNJClFuZnA4RHJWOXFUek5tNUdwRDhQeXFuR0FBQUJuUUxXZ0JnQUFBUURBRWN3UlFJZ0t0WVpWTzdVbXVDVk9WNUUKMEVkb0JpcmV0aVNiQ1UrRlZJR1dyaEFFSW53Q0lRQ2Q4ajg5Zy9qRGI5SzYxalNFMWdYOWFYY0RUR3pGN3poVwp3ckJFaFJQUGpUQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFvNGdBNldJRHNRakpyelhFS1RKNTBuYS91M21JCjFjSVRhb1F3ZFhCNUJVWGs1UFNROGtiMktrVUR3RHdpRXpDcDBzVDR0bUhxWTJHUEZlVElqeVViRXYzSVFoUGUKcm5Rc2NmTWpPYWhiUjVOVTNDZzA2bm5yKzdYYW9aVEJHU3JvT21hL1pNYlpmZkhUeUZya0NJbldhTHFJampQYQpwMVRLUXlIK0M4VS82T0RETTlHaXZQQlBqRERkSkthZWYyNnc0Y2R1YXh0UDBGTmZoUE9NQVNORHNMK0ZocHpHCm1lY3BOemVqVW9HRHgxVjlFQjRRb1U2dEZscHVhd2FpYklWblNoRmR4d0lGTklWaEd6Q2FOc1NRV1lZd3FJRy8KRFN0NzNzMWtaQUdzMm9EdU9JeUtYbmtKYXY0REgyaVIzT3ZGOGVFRkhTTCttNmN5UXRsZnZkT3Jxdz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0KCi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLQpNSUlGQlRDQ0F1MmdBd0lCQWdJUVdnRHlFdGpVdElEemtrRlg2aW1EQlRBTkJna3Foa2lHOXcwQkFRc0ZBREJQCk1Rc3dDUVlEVlFRR0V3SlZVekVwTUNjR0ExVUVDaE1nU1c1MFpYSnVaWFFnVTJWamRYSnBkSGtnVW1WelpXRnkKWTJnZ1IzSnZkWEF4RlRBVEJnTlZCQU1UREVsVFVrY2dVbTl2ZENCWU1UQWVGdzB5TkRBek1UTXdNREF3TURCYQpGdzB5TnpBek1USXlNelU1TlRsYU1ETXhDekFKQmdOVkJBWVRBbFZUTVJZd0ZBWURWUVFLRXcxTVpYUW5jeUJGCmJtTnllWEIwTVF3d0NnWURWUVFERXdOU01UTXdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUsKQW9JQkFRQ2xaM0NOMEZhQlpCVVhZYzI1QnRTdEdaQ01KbEEzbUJaamtsVGIyY3lFQlpQczArd0lHNkJnVVVOSQpmU3ZIU0phZXRDM2FuY2duTzFlaG42dncxZzdVRGpES2I1dXgwZGFrblRJK1dFNDFiMFZZYUhFWC9EN1lYWUtnCkw3SlJiTEFhWGJoWnpqVmx5SXVocnhBMy8rT2NYY0pKRnpUL2pDdUxqZkM4Y1N5VERCMEZ4THJIemFySlhuelIKeVFIM25BUDIvQXBkOU5wNzV0dDJRbkRyOUUwaTJnQjNiOWJKWHhmOTJuVXVwVmNNOXVwY3R1QnpwV2pQb1hUaQpkWUorRUovQjlhTHJBZWs0c1FwRXpOUENpZlZKTllJS05MTWM2WWpDUjA2Q0RnbzI4RWRQaXZFcEJIWGF6ZUdhClhQOWVuWmlWdXBwRDBFcWlGd1VCQkREVE1yT1BBZ01CQUFHamdmZ3dnZlV3RGdZRFZSMFBBUUgvQkFRREFnR0cKTUIwR0ExVWRKUVFXTUJRR0NDc0dBUVVGQndNQ0JnZ3JCZ0VGQlFjREFUQVNCZ05WSFJNQkFmOEVDREFHQVFILwpBZ0VBTUIwR0ExVWREZ1FXQkJUbnE1OFBMRE9nVTlOZVQzaklzb1FPTzlhU016QWZCZ05WSFNNRUdEQVdnQlI1CnRGbm1lN2JsNUFGemdBaUl5QnBZOXVtYmJqQXlCZ2dyQmdFRkJRY0JBUVFtTUNRd0lnWUlLd1lCQlFVSE1BS0cKRm1oMGRIQTZMeTk0TVM1cExteGxibU55TG05eVp5OHdFd1lEVlIwZ0JBd3dDakFJQmdabmdRd0JBZ0V3SndZRApWUjBmQkNBd0hqQWNvQnFnR0lZV2FIUjBjRG92TDNneExtTXViR1Z1WTNJdWIzSm5MekFOQmdrcWhraUc5dzBCCkFRc0ZBQU9DQWdFQVVUZFlVcUVpbXpXN1Rick95cExxQ2ZMN1ZPd1lmL1E3OU9INWNITENaZWdnZlFoRGNvbmwKazdLZ2g4YjB2aSsvWHVXdTdDTjhuL1VQZWcxdm8zRyt0YVhpcnJ5dHRoUWluQUhHd2MvVWRiT3lnSmE5enVCYwpWeXFvSDNDWFRYREluVCs4YStjM2FFVk1KMlN0K3BTbjRlZCtXa0RwOGlqc2lqdkV5RndFNDdodWxXMEx0empnCjlmT1Y1UG1yZy96eFdiUnVMK2swREJESEVKZW5uQ3NBZW43YzM1UG14N2pwbUovSHRnUmhjbnoweWpTQnZ5SXcKNkwxUUl1cGtDdjJTQk9EVC94REQzZ2ZRUXlLdjZyb1Y0RzJFaGZFeUFzV3Btb2p4akNVQ0dpeWc5N0Z2RHRtLwpOSzJMU2M5bHliS3hCNzNJMitQMkczQ2FXcHZ2cEFpSENWdTMwalc4R0N4S2RmaHNYdG5JeTJpbXNrUXFWWjJtCjBQbXhvYmIyOFR1Y3I3eEJLN0N0d3ZQcmI3OW9zN3UyWFAzTzVmOWIvSDY2R055UnJnbFJYbHJZakkxb0dZTC8KZjRJMW4vU2d1c2RhNld2QTZDMTkwa3hqVTE1WTEybUhVNCtCeHlSOWN4MmhoR1M5ZkFqTVpLSnNzMjhxeHZ6NgpBeHU0Q2FEbVJOWnBLL3BRclhGMTd5WENYa21FV2d2U09FWnk2WjlwY2JMSVZFR2NrVi9pVmVxMEFPbzJwa2c5CnA0UVJJeTB0SzJkaVJFTkxTRjJLeXNGd2JZNkIyNkJGZUZzM3Yxc1lWUmhGVzluTGtPclFWcG9yQ1MwS3labWYKd1ZEODlxU1RsbmN0TGNabklhdmpLc0tVdTFuQTFpVTB5WU1kWWVwS1I3bFdibndoZHgzZXdvaz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=", + "key": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKSndJQkFBS0NBZ0VBdG13cklRdzQ4Q0E3QXdaYXY5NC9LUEdCTTR1bEJRNk9VMjJleXpkNUVLYVBKY0lDCllMOHBtSmxwaFpWeXhDT0FWMVNMT3pqN1pKeFpQYjR3dHpuYStMQmZlUkZlQVZhSUh6bVdLZ3FSNmh4cWxPNkoKc09FTmZYaTRMeDQ0Z2JUUlJZSm8wOGwzRmI1SjBmUmdOUmZIaVd6S05pQytrNWpBUDRlVEtnNWZhSmdidHNBRwpwaXZRQ2VCMldyN2VSRUFGcno0UUxMd0xIdHdNeFdMTEp0bUpCdHdiQktFUjZvYWk1MnVTd0FjYktTZ2EyS1VaCjhZUjlISU9EMTVGNllhQVRlRFQ2d2lWdm52RGthRS9EVGxQaWxSbi9LZUlxOE9rMXpTdWdZSnJUVDdtajBPQWQKV2JobUpJcVF5cGc2RWxLOGxSSm91ODlpSzI4K3pObDlyRWZnM3RBQ0NUcDQzVXVsb01ZTW5NNW82VkpuYU5xVwowUC9DUXMzUXNRWWNZTVY4NmQySjZmbHJHVlJuQWRicTVsc1NES1FsS2F1cFZIYVFYMnFaU2xyd0tNbjgxUFcvCnRyd0RGWE9oTDBKNDdQNlh2aWl1Szc3SmNudkUwa002Vmg3b3NqUTdjQlRuSmJPL3JPZjF0eDFnVDY0VVdBOEoKUTRMbjM2NmxUVGZ2Q1V4VEhDTmM0aU9WbmFaZE5WYXlVYS9ZL2dEOWwycEpPdG1ObExqZGFvZTJZUXFzVERMYQpoU3g5a1kzSU1ZWTU4ejlObG5jNE5GY3ZjeEVndzFTeFRiUzBwaFkzKzVMSjBJWVBBTWk4UFVvZzAzWEcxdDZKCnFnaFNMYjVNQW9ybWMvTTRlaGsvcGV5UEZKWEgyWlNHU3dad1F3ZWo0a0VEVkpqMmRRakJ3TDNzb1ZNQ0F3RUEKQVFLQ0FnQlRPS0dzWC9FUnl3Smc3T00xcU9SVGYybWI1RmNTS1lQUWw2L2JzYUR2Y2F6WVl0dkcyeTdVeVJnVQpzd3M2WTUvSE4zQ1ZRZmVkL0ozUnh4UmVZbXg5QzZsaWlBUEV4SDh4RDRwci80cDFyU1VLY0pBc08xOTJJRDZXCmNFa0RFMFJ0VzRNTEsxdkNkWjhqdzBLb3RnSjN0VVBDMGNsZmVFNHNMV043eWpiSWRxd2VBUmlIY25Ga01CeU8KWllqc3hmQlI5QlJZOUNoMm1aalI2N0lXTlNPSUY1M1R6TTlwbUtaMHdPU2Y2aDMvOXFYWXMrbFQvMGM4WE5zbQoydDFqOEMweHA2bkdMR0h4dHAvYzNwYVJBei9aR1pVSXZIOHVqSlhZMWRCQ2doOW92WjM0U21YWmFvQmVwMEJLCnpJdWhsRkVvQ00veTdqY3c3WDFPNHVHc1dOWkN5a0l1RjNnWmNaVEFQNTlkc3UrYkovOHBBRU82RFNpTm9reEwKZ1VzRnlKRVRvaXVXZlFpSWdYMXkxbHh3bzNnMnAxY1g4eSt6ZHkwMmhVYnlidlFkZEIrd3dGc29UcVBaVi9wMQp6aGRpRnE5M1U4TmNxcHVaSWJkelBjdkdEdmJzeDRHa3poWTFpKzV5MjJqWlBrMmJNTFdETjVMN1FNNkRodXFYCmlSK05ESDNKcjNKb1dmMVBNb3FnaUQwNkY4Z3haVFNVVlVVL1d1TkpQeERYdkdYektBcHRLaDVFeE85QzdPaFIKL010VXJrU1IvNHN2NThvYVZ6YjZod3hwZUhHMElQeTNvMUhNdXhwSllUZ3FKbEhsS09BKzdxL0swdmlsNytOWApqUG5kYlBVYVhmZWNHejFnUmM2SUZhaTRtd3crV1gvTUhOZEwvWDhVL241aW5rb1Q4UUtDQVFFQXl6ajEyMFhUCmpBTWk4U1JOKzB6TEYxNDhlQitBWTdiTGw1bGRSZ2ZQeHJmcDVRdE0rNlV6WGcxTlVHMWw1ZFFielA1TFJpbUYKN0FyMHh6ZzVVU1orNk10SkdpM2NIRFVoSmxRaFoxNWcyOGp5N3RiM3V4Q1JzMjVYT1RWWVhlK1NLN245eERYUQo5b3o1Niswb3VpdFBkTm9yT2RiSWRReVBYejBwamZyalVaTytFc1dnYWdiVFYxYW4yeGZxRUxmUXN0ZFVoQ0kxCkRHMlJ1SjRiY1Q2RU8wWVVrNVNiYUFMNC9zRFBoL1d0TEppTk9RMHNLZmovcU1CNTltUnJwNnJ4c3hDRjJGdU4KMzFPWkpZZjg2NUhNTzhadmh5cXZoSEpLcWNCS0FkTE1MZndreFRCdzZaQ0c2S1llQlZiYU9UY1JKM0lXcmRVSApYcWltOTk2MFNCMVM1UUtDQVFFQTVjeFg4V1NXOXBIWSsvaitUUUxCWGFBYWh6NTk1Ylg5RWR4UFhxL0NrMFRyCnpkTjJJUzIybkozS2V5amxjYmxiMjVKODFXVnlMZFhmTkFEZzVxanZOMStTRG5jNEYvZngxbU11YkRXNUd2ZnAKSFkvQkpCcGpzT3hRc2h2V1NxN2ZzUG9Ka0lhT1lWY1ZNbXl5YlBHZVowbUZDS2Zod1FDT3Q5bTVqM3lIam9jMgpvZnI2Q1dFNlYweGxXZzY3TjNoZDZTNXFybkxoYlVXRG14dGZ3YnlvTTl1RE1JeGtRR1R6aGxHWUtyWXRCZFh1ClJTbDZjVkY5YWh5UFVzUnBDZ09OU1FnTUp5bFZEWVlvejM0enZvSlRkY0Zpdm13aWVtUlN1dDV3V2UvY1kyRW8KV1djT29LYUpJTWxSV1Y4NzQwR0JiRThpSzRkcG1NNlkyRzV6OEt6SDF3S0NBUUJONDFEbmI0VzYzZURnaDBlVQpEMFN2ZWlDMjhXdmx5OE9RSU9tTTA2V2d4aEJSQnRuRWdQYUZENWdZUVRXU3RvN0pMSlZ4R0swSllOR2NJVUptCmhkaVNKSWVxRHZXVG9oRklmV29vRWhZUlpwMGxTU0s5Z1lZMlBRNFZFUXNEUmJUQ0taREhTTkVFRGRnandrdWYKdnNXL1JYYlh2aWdxSkZ3MjVsd3MzSUdQWEk2MjV5T3FQMTkwMC9PVmx6N1FsWUxaYUlvdDhtWmVrd2dNdUJ4UQpkT045VEVqS24yUklvd3NIcUtKaHU1bFp4RVlzSFk1VnIrZGNxQmlXaExwMkVxQjhQdEx0cjNsYXBOSEhmcTFECkpDZ2dxTmRKK09RNFlRMllyZWd2ZHJsbXpvTFJTTldkenhURnloVXJlclE4eFl3L2tBeDlKMjZocXdZRGJrbXUKY2Y2NUFvSUJBQzRiQXFZSEZaYmhDbFQ3enlIcVpKdUJUUEZIbzREY002dEt0WTM4MjZBcW10a1FEVXA0M29PcgowUDFHNWtvYjg0Y3BhK3h3enlqTkdWeFl0TWJ5ckJSREU0M2RjNTZ6ZTQwVkZ6SlUwUS9OSDdOenJUK3VIOFJXCmxaWTJxWmNRWVFja0U1a3d0ZzVucDNRWGhQRUF4VlJaMXR3MnVyKzdlZWIvUXhDNzNvTEZORHZwbkJNWHM5bmkKRlErdGx2aDVLUFpvL3JTRGppRWJhbDFMYjdueVBSa3llSzdiN0IxVFk5eldNQjZac0l6VU1Gc21DRkFHRnBDYwpyRkRoNFdWRjh5bnMrR3MvQ3JhTTdWRThNK3VNUUd5RzRXWHRVam1XT1ZjTzNDSGZVeXVKU2N3dU5pd2JYYlg3ClRsd05GSG56SWFGMmV4ZWFzcUFiQzJXWk81L1ZPcjhDZ2dFQVZ1eThnc01hZGgrTFUrWUM1OGF1c2k0WWxRSVIKMHdXRUZQeHR2d3BxdWFxbUh3T001bmNtWTIrUzBaV3NPcTRkUkZsZUVHVFp0R0VQdGtMMEYveTc4T3hZaVBnLwpOSzdhMXZYbVQwdjJwanNqdklvR3FndG93NGJxYVlad1lVNzB1YWNPVmRQR3doOG1zWlRBNGljamMyQjN4Q2tmCk1NQ1UxUmJEc2VJTUJaL1NpdHJUYjdtSHQ2OWljSytLdm9sV3Y4dmpxN0JyaVgwTGNGOExaS1NySFphVGt6OEgKT1A3QzlZUVBBN1lhbjBaNnFnNGhjaXZZV2EvK3Y0dFBtZkwyVzg1dElsWm52RGxCL2V5WHcvOHl3RG0yWVFBZQpieUMzYnpWeW1QT29KUmo1YklKUGZ3TFBqVnJVYlBNUlZuekJQMnpQdE9iTGpQa3RlOGN4enBCWHdnPT0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0K", + "Store": "default" + }, + { + "domain": { + "main": "api.truckwash.dk" + }, + "certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUYrRENDQk9DZ0F3SUJBZ0lTQlIrZ1hHQWk2bkxDLzlrcHpIVGk5L1c0TUEwR0NTcUdTSWIzRFFFQkN3VUEKTURNeEN6QUpCZ05WQkFZVEFsVlRNUll3RkFZRFZRUUtFdzFNWlhRbmN5QkZibU55ZVhCME1Rd3dDZ1lEVlFRRApFd05TTVRJd0hoY05Nall3TXpFNE1qQXlOak0wV2hjTk1qWXdOakUyTWpBeU5qTXpXakFiTVJrd0Z3WURWUVFECkV4QmhjR2t1ZEhKMVkydDNZWE5vTG1Sck1JSUNJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBZzhBTUlJQ0NnS0MKQWdFQXp2MkcvZlBxVFZQSjMvS1dOdFd0UllIbmtGckFrRU44NkZ0VTdFR3psSGNhbUloS0hpd3dvRlBuYlhHawpMb1FuVDlOR0NXYVBQN1Qya0ptcGRIYjk5cnZVU3dsYS94c0hCb05kWHJLYlk5OXErOGNwUHE2Zy9ncFJtZTRDCjE5MEc4dWlIV2ZpSlhSd1hIMndYZ0p5Tit2QU85azd3V012V1hGNFMrcEVHYkhOaXo5czBSanIwTUs3OTBjZkkKeXMzNFV2ZmhzS0QxUzNuVFpTaDBJeGVBckk5cldrMnU4L0l5OGpLQ0s1dm5JbjFkVFFLeDR1T25OK2Q3ZkNZeQpGaGFzUktXUFdyS0FDUzl6M1AwWXluYWcwZjJ3U05nRjVQRXN0d0grT0pPanZqT0c3WmtGUm5pbVAydS9VNWdpCjd0am5CRVExQXM0NkkxNFRRV1NYeWR6eXcxVklqTlBERFdhV21LckNGNmtkMkZsRXhwUWJoWTQwaTBaa3p3czUKeU5iVm04NWwySE1oTkN1NTVtc0VQdmFQUktQa090R3VPenMzUjRSc2wyOTA5L0JsT01tMll2azY0c1NHL1g4dQo4NUdkM21CcTBNTENFTkRyNGNhVzY0ZWVTOG9pZU9YZHVEUGZJOWd5SjM3NENwNDJNdDJYTXorK2VYRE1zMFFDCmZXNENqSm96eURSRnM3enVjdW4yYnlUT0NxOHR4bW9DaFB3bERRdytqNEIvVmRUTTlTalVzK2taM0VuV1BPV2UKaWFIbWZHaFU2MFN3WHJtRjBBdTNDSEVBYUpUeDI1K0NCWkFhQUVhU2FnZFR4WlR0dHREVDZMa2xad0Qvd3dpdQpsM0owWHRlRXE2UnVJYVEwYitDa1pPdE5Vc2xVWGwwMHRzMDI2c3UyUTFFNGJ2OENBd0VBQWFPQ0Fod3dnZ0lZCk1BNEdBMVVkRHdFQi93UUVBd0lGb0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQU1CZ05WSFJNQkFmOEUKQWpBQU1CMEdBMVVkRGdRV0JCU3RZOUlOZURveU4xYUlCVGk1MFY1aitKQ2MyVEFmQmdOVkhTTUVHREFXZ0JRQQp0U255TFk1dk1laWJUSzE0UHZyYzZRelIwakF6QmdnckJnRUZCUWNCQVFRbk1DVXdJd1lJS3dZQkJRVUhNQUtHCkYyaDBkSEE2THk5eU1USXVhUzVzWlc1amNpNXZjbWN2TUJzR0ExVWRFUVFVTUJLQ0VHRndhUzUwY25WamEzZGgKYzJndVpHc3dFd1lEVlIwZ0JBd3dDakFJQmdabmdRd0JBZ0V3TGdZRFZSMGZCQ2N3SlRBam9DR2dINFlkYUhSMApjRG92TDNJeE1pNWpMbXhsYm1OeUxtOXlaeTgxTnk1amNtd3dnZ0VLQmdvckJnRUVBZFo1QWdRQ0JJSDdCSUg0CkFQWUFkUUNXbDJTL1ZWaVhyZmREaDJnM0NFSjM2ZkE2MWZhazh6WnVScVEvRDhxcHhnQUFBWjBDMW4xM0FBQUUKQXdCR01FUUNJQXNkMUpCTmlpMmlRVGszOUVCcHRiUVBvaDkvSjlBSXBycW5JYlNQTDZkZEFpQW56Tk9oOXl1QQpEcXMxM1RQSXNiTCtlQ09RNFZtOXZNc2RXdmYvWUVyUkhBQjlBQnFMbldsS1Y1akltYURLaUwzMGo4QzBWbURNCncyQU5IM0gwYWYvSDBheWpBQUFCblFMV2dJTUFDQUFBQlFCWFJmeVNCQU1BUmpCRUFpQkdsU3Y0WllVZ2RvRUsKdk5EbWxiaWVZU0ZnUnJHVStwYXhNUzQrTUN4U1FRSWdQSXNOR2ZFb2ZqOExPWUV5ZzJhMlVibDFxZ20raHZiMwpjTW82MXRYUGxTZ3dEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBSVN4T1g3bGtUcUsyQWZNd1d6NWkyc3RhdENzCkY3SjhCZTdsVUJVQ1N4YmpKNVVkRlFnWGdUZThSYTdlbkg2M3YrVFBjdjFHU2R2TlNaYndhTjVybGR3emt1Qk0KVGZ0K1dTMi9PWjZxREdaNUl5T3psczdDNVdXR2Q2eWZUby9PdDU4NHFUU0FSSWZCRkRjbzRZNktOTEgzcFVYWgpVZ3BXM2FvUDdvcDNNM3pVZVNyeEllS1ppL0FYQ2RSZ2dybkRtMndVSmxJT0hGMmhDOGMzRjJBWGN0bnc0V3RSClhQUVNhcGlndVA5K21MRnFQVEw4TWRVQjVPSk96N1ZEbFJNYXowUU9kQ1ovYitzWFh2dG9GNm5ieUVTWGROMjkKOGlaSitvdGNDSXZmbnIyK3pGYTFHYmJuSUlRT0pvN2xuNWhpL0ttb1RTSEJiV29EdTA0aUlzQzhnUlk9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0KCi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLQpNSUlGQmpDQ0F1NmdBd0lCQWdJUkFNSVNNa3R3cWJTUmNkeEE5K0tGSmp3d0RRWUpLb1pJaHZjTkFRRUxCUUF3ClR6RUxNQWtHQTFVRUJoTUNWVk14S1RBbkJnTlZCQW9USUVsdWRHVnlibVYwSUZObFkzVnlhWFI1SUZKbGMyVmgKY21Ob0lFZHliM1Z3TVJVd0V3WURWUVFERXd4SlUxSkhJRkp2YjNRZ1dERXdIaGNOTWpRd016RXpNREF3TURBdwpXaGNOTWpjd016RXlNak0xT1RVNVdqQXpNUXN3Q1FZRFZRUUdFd0pWVXpFV01CUUdBMVVFQ2hNTlRHVjBKM01nClJXNWpjbmx3ZERFTU1Bb0dBMVVFQXhNRFVqRXlNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUIKQ2dLQ0FRRUEycGdvZEsyK2xQNDc0QjdpNVV0MXF5d1NmKzJuQXpKK05wZnM2REdQcFJPTkM1a3VIczBCVVQxTQo1U2h1Q1ZVeHFxVWlYWEwwTFFmQ1RVQTgzd0VqdVhnMzlScGxNalRtaG5HZEJPK0VDRnU5QWhxWjY2WUJBSnB6CmtHMlBvZ2VnMEpmVDJrVmhnVFU5RlBuRXdGOXEzQXVXR3JDZjR5cnF2U3JXbU1lYmNhczdkQTg4MjdKZ3ZscEwKVGhqcDJ5cHpYSWxoWlo3KzdUeW15MDV2NUo3NUFFYXoveGxOS21PemptYkdHSVZ3eDFCbGJ6dDA1VWlERHdoWQpYUzBqblY2ai91amJBS0hTOU9NWlRmTHVldllubnVYTm5DMmk4bitjRjYzdkV6YzUwYlRJTEVIV2hzRHA3Q0g0CldSdC91VHA4bjF3Qm5XSUV3aWk5Q3EwOHloRHNHd0lEQVFBQm80SDRNSUgxTUE0R0ExVWREd0VCL3dRRUF3SUIKaGpBZEJnTlZIU1VFRmpBVUJnZ3JCZ0VGQlFjREFnWUlLd1lCQlFVSEF3RXdFZ1lEVlIwVEFRSC9CQWd3QmdFQgovd0lCQURBZEJnTlZIUTRFRmdRVUFMVXA4aTJPYnpIb20weXRlRDc2M09rTTBkSXdId1lEVlIwakJCZ3dGb0FVCmViUlo1bnUyNWVRQmM0QUlpTWdhV1BicG0yNHdNZ1lJS3dZQkJRVUhBUUVFSmpBa01DSUdDQ3NHQVFVRkJ6QUMKaGhab2RIUndPaTh2ZURFdWFTNXNaVzVqY2k1dmNtY3ZNQk1HQTFVZElBUU1NQW93Q0FZR1o0RU1BUUlCTUNjRwpBMVVkSHdRZ01CNHdIS0Fhb0JpR0ZtaDBkSEE2THk5NE1TNWpMbXhsYm1OeUxtOXlaeTh3RFFZSktvWklodmNOCkFRRUxCUUFEZ2dJQkFJOTEwQW5QYW5aSVpUS1MzclZFeUlWMjlCV0VqQUsvZHV1ejhlTDVib1NvVnBIaGtrdjMKNGVvQWVFaVBkWkxqNUVaN0cyQXJJSytnemhUbFJRMXE0RktHcFBQYUZCU3BxVi94YlViNVVsQVhRT25rSG4zbQpGVmorcVl2ODcvV2VZK0JtNHNOM094OEJoeWFVN1VBUTNMZVo3TjFYMDF4eFFlNHdJQUFFM0pWTFVDaUhtWkwrCnFvQ1V0Z1lJRlBnY2czNTBRTVVJV2d4UFhOR0VuY1Q5MjFuZTdubHVJMDJWOHBMVW1DbHFYT3NDd1VMdytQVk8KWkNCN3FPTXh4TUJvQ1VlTDJMbDRvTXBPU3I1cEpDcExOM3RSQTJzNlAxS0xzOVRTclZoT2srN0xYMjhOTVVsSQp1c1EvbnhMSklEMFJoQWVGdFBqeU9DT3NjUUJBNTMrTlJqU0NhazdQNEE1alg3cHBta2NKRUNMK1MwaTNrWFZVCnk1TWU1QmJyVTg5NzNqWk52L2F4NitaSzZUTThqV21pbUw2b2Y2T3JYN1pVNkUyV3FhenpzRnJMRzNvMmt5U2IKemxoU2dKODFDbDR0djNTYllpWVhuSkV4S1F2emY4M0RZb3RveDNmMGZ3djd4bG4xQTJaTHBsQ2IwTytsL0FLMApZRTBEUzJGUHhTQUhpMGl3TWZXMm5OSEpyWGNZM0xMSEQ3N2dSZ2plNEV2ZXViaTJ4eGErTm1rL2htaExkSUVUCmlWREZhbm9Dck1WSXBRNTlYV0hremRGbW9IWEhCVjdvaWJWakdTTzdVTFNRN01KMU56NTFwaHVESlNnQUlVN0EKMHpyTG5PckFqL2RmcmxFV1JoQ3ZBZ2J1d0xaWDFBMnNqTmpYb1BPSGJzUGl5K2xPMUtGOC9YWTcKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=", + "key": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS0FJQkFBS0NBZ0VBenYyRy9mUHFUVlBKMy9LV050V3RSWUhua0ZyQWtFTjg2RnRVN0VHemxIY2FtSWhLCkhpd3dvRlBuYlhHa0xvUW5UOU5HQ1dhUFA3VDJrSm1wZEhiOTlydlVTd2xhL3hzSEJvTmRYcktiWTk5cSs4Y3AKUHE2Zy9ncFJtZTRDMTkwRzh1aUhXZmlKWFJ3WEgyd1hnSnlOK3ZBTzlrN3dXTXZXWEY0UytwRUdiSE5pejlzMApSanIwTUs3OTBjZkl5czM0VXZmaHNLRDFTM25UWlNoMEl4ZUFySTlyV2sydTgvSXk4aktDSzV2bkluMWRUUUt4CjR1T25OK2Q3ZkNZeUZoYXNSS1dQV3JLQUNTOXozUDBZeW5hZzBmMndTTmdGNVBFc3R3SCtPSk9qdmpPRzdaa0YKUm5pbVAydS9VNWdpN3RqbkJFUTFBczQ2STE0VFFXU1h5ZHp5dzFWSWpOUEREV2FXbUtyQ0Y2a2QyRmxFeHBRYgpoWTQwaTBaa3p3czV5TmJWbTg1bDJITWhOQ3U1NW1zRVB2YVBSS1BrT3RHdU96czNSNFJzbDI5MDkvQmxPTW0yCll2azY0c1NHL1g4dTg1R2QzbUJxME1MQ0VORHI0Y2FXNjRlZVM4b2llT1hkdURQZkk5Z3lKMzc0Q3A0Mk10MlgKTXorK2VYRE1zMFFDZlc0Q2pKb3p5RFJGczd6dWN1bjJieVRPQ3E4dHhtb0NoUHdsRFF3K2o0Qi9WZFRNOVNqVQpzK2taM0VuV1BPV2VpYUhtZkdoVTYwU3dYcm1GMEF1M0NIRUFhSlR4MjUrQ0JaQWFBRWFTYWdkVHhaVHR0dERUCjZMa2xad0Qvd3dpdWwzSjBYdGVFcTZSdUlhUTBiK0NrWk90TlVzbFVYbDAwdHMwMjZzdTJRMUU0YnY4Q0F3RUEKQVFLQ0FnQWhXOWFmb3VuRjRKVU9WSWhFK2hiY3R0emM5T05IM0xpS3NmMXp2ZnIxR0dUZTVFZ2RxTDJGZVVBNAo4VEdtbFlISWFhSkY3Wk4wanZPVnhrMzdDUnBRNDJDSlgwNmRBbndWWHFKem1wRlVmVE0rdEpBL0crR3lUM05yClBXS1Q2M2t0T0xCbm5aaDF1d3MyOHpLdWRZeWtQb0FLemhvS0ZYL01qeFM3OFJkNTd2YWp6UTRWcTNhalNmQjYKR0pxUnBMUTZtdHh5bk40cmRjek41c0VnenE3Y0lKa0tpcTZCZmkwYnYyd2ZIeGNYQmVFOWdndXRKMGRSeVNxcgpqWFJRL3dKd1pjWSt0Ym5Rd1NjSkhIa2NXQlgvQWNXaFY3OUxjRnJtZTVtL0FIeEpNUUN6MWc5MWxnZ2svSFp2ClFIdVF4c0FrZmVHVlBDcytlL0RhZngyL1dLNSs1ZzZ3Y3QvQTByMVpGY1o0M082QWljWDNidHc4d09sY3lNdTQKMyt2L0p3aDFPYkpGMktTeDgwcVdUc0o3aVMzNytnZm5YRER3bVpoTjRBdDJhTzVFVWdJZ053Q1lucXp1dkJGYQphQlJ4U2NEcWQxZ0RnelpNZjcvMlI3WmlRdVhGTisvZUxFdVdvTnlvbTArc3hnMXVDcWFUVEtYZHNSSTkxV3pqClpzaDRqMUVMSVhNeHdac0sxNyt2dVhwZ3NtVnNSUXRybWJoYytONVpZQ0dvV1BQd2MwTEZLY0U5dFRQMDZzWGwKNUVPb3RkMVZRSWpnMGZaSVVublpERm1oVWJpeFRTaG15OWR2OXc4d1pTREYrSXFTa2dsYmNacW5HS1QxdGNhcApUQjVZU2ZDdEd4NHhXUXVXWVBSSU1KUWYzeXljVnhPK1pvaUIyT1dud0JMNzZ4ZGtVUUtDQVFFQStwSjJXYWUwCkpzdENhMFE2b0tUMnVqVzZxc2lmS2I4STZwZE1oN1NKR1ZpNUI4TjBkN0RFNGlzbzd2KzlPRlAwMUtmTDJMeFQKL25vRVpGaE41SEFQb0t0UWk3cDNqcHlMSm4xcGlMa0FkNEpleGVYNGpBOGJCdWd3R0hRcWVKTlJRYmlTMUJHQgpRc3ZJSjdSZ2FvTUJzNERkVnlmTGZWYlVXSjFjTnNJaHIyRWIxOHU2TDY3dDk5SUxyd1cwK05hRmRVTGhJZU9uCmsxbG0vdUNuR2JkOWRyRlRuZVZiS0t2TUJtZy9KYmQyb0w0MXB1YVdBTzdXcWFCU0tTUGdlaStmN3dVdnlvNGQKMmY1c1lFWjJCbG1EZGlGektYUDJGOU41UjFPYTdNN3hvcllWb2Rja1ZpczVOMjVid0cxY0xMb3hhUDdCc3RKegpiOUsrZUs5V05nTGxGUUtDQVFFQTAzbGlUYksrMTUydFZPRlI3Q0VhME9Fb3lHcTEvR0VDNllpSmRGT1ZkL3V4CkloSlUreUtkNjlkUlprZ0d1RlJNRTVienl5RE8vSWtkZis2RGdxbzZSdkQ0VzNBSEtZYk1JT3RxSkZpNzdwSkMKTGJwWFNZTWZJejg0c1Y0V3gvb096WU45dnZWbGhCSTFKdWtaeVRmT3l1U3lMbE9lMm4rM0lxL0gwR3JqTU5sUwpjenpoMWxMbFR2YUNEcnBodTc0b1ZlVkJ2dU1IZE5WN3VLZWdzUEVEOTNTUzE0Ukc1dUVYNTZVOHc1a0JPYm8xCnBacFE3aEtmMkdNL3lHNFVpb1ZJU2NlS08za2U5aW9pTjJxbyt1ZXFSNTI5ZjVMdFhRUTJxYy9iY3kvZGRHUksKTHlhUG5ROWthWnRZQlBiTEFPQzBCZFFNbHROK09veWlkMUZKdmRBd3d3S0NBUUFvcTA3MFBFajhKdTl2MDVJawpjc0hzOVQySDdLb1FyNFgxOVhxaGFBYjhpeTcwK0o5VnNlWXl3MGlRaWdlZk5kaytEc0lDT29iemZjQnF2UDVmCmZtUzY4ak5QaW9OUExVOVVmdlI3RVhQbThjMEtGOHBnaVM4Y3p1REhoMHRCYUwrK2lBT2swZmFGN2VkZHNtUVgKeFkyb0lkbExCUlY5RVhQRHNqNitVSUlCSWlUUHdLeEdnd0R5d1MvT1I0SFpCWkNCdU1vcm92U1c4T0xMcVpEVgpscmVSWlRTcUl3akpzQ0NjUloxQ05PWFhMeWdzSDY3bkZkelhpVUxzbEhzaHVjc2VrMXZ2WjJPbTl5bUY4c25EClBCSWZRVXljeE1xRGtYcFV2bGdkNlhURXNRTVAwb2grUitPd2dJUDUxb1lvYUV3T3U1S2F3SmVsNWJHdWl0N1cKUTJBRkFvSUJBUUNnNmVCUFZ3KzRhWmFXMjh6R2JIcEhMczBsazIxZTJVS3FDT2J1eVJzVzdVSGZ0eXRLM1JCbApnTFhEcWxMU3QvSWJoZVdFVHpheEduU0VBQzI1bzJZc3pQZHVQRlIyMk1kQzFWOHl3UUpmaXBNbzBIM1N4aDlQClpxL3c1ck5XLzROOEJlNE0vQlYrNVl2a1M4TC9SYVBvNXhSZUErc0FQK1pPVW9zc24raTRKdVdDSW5XRjdCWG8KZTlLQytuZWJzQnBwSWNWaFJzZEpzNWdzN2dCc0l6anRkcHExdTBWb01TZExjSVJJSVlpNU1HUFoydzV4MldJZgorWVZ6TWJBWHAwdFdPS3VLamFOdGxLbnNtUkJ6dXd2cTZyQmkvcDMzQlZuQzVSTUxGd1RmcCtCNitGQ1hKanMxCjBLcXRQRTFFSWJkYUoveGNXQm5ZMjJKVWdGb29RTjZaQW9JQkFESnZFeFN3aFlnY3dFV3hIRFJsUVYrYXBkanUKdmRFek5hRU9RZkJvS3YyYTJYei9NUVBoUzJ4dDdyNkZVMHkvbE5ZbWxmYXhDS2dQTkpUa0NHczVvcFVHZFVNSQpsTHdPR1o5Q1VzbTZVV3p0NnlyYSszUllCL0xlOTFTOFRReDA0aXN2bTl3KzM5Ujg2RFJhTjljZEYxWWxjTldtCjZVdVl5eXl2WHI5N1pGMm5mZXprQ3JzUWp6TUJXdURVdzA1YVh5cllnalJFRkVYbHNRdTZkTy82RXFHa21nZjAKTWdoU2VNNVFHQzEvNk50VW5wOVNPcktTanRseHp5SVJxQ0wvSDZOeFBxWlBTUU45L2x1V2w2NEZ3Q2dKQ0paWgpYZzdLeG82VS81bEdJOW51WXNjTWdpc3ZVUnpSNk80eHZHVkFHTjZQZUI1M2I1QnRDZ0ttV1NtRHJwcz0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0K", + "Store": "default" + }, + { + "domain": { + "main": "traefik.truckwash.dk" + }, + "certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUdBakNDQk9xZ0F3SUJBZ0lTQmxzVVc1b2JZYlBuc05UQlVKK3lyaG1CTUEwR0NTcUdTSWIzRFFFQkN3VUEKTURNeEN6QUpCZ05WQkFZVEFsVlRNUll3RkFZRFZRUUtFdzFNWlhRbmN5QkZibU55ZVhCME1Rd3dDZ1lEVlFRRApFd05TTVRNd0hoY05Nall3TXpFNE1qQXlOak0wV2hjTk1qWXdOakUyTWpBeU5qTXpXakFmTVIwd0d3WURWUVFECkV4UjBjbUZsWm1sckxuUnlkV05yZDJGemFDNWthekNDQWlJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dJUEFEQ0MKQWdvQ2dnSUJBTzNqQURMVEgxVHd3dkI0ZDgyUjhvNHg5YWlzY1U0aGROc0tSYmVucUJjd0d3S2ExMnpLVE5Sbwo3YitlUHNmcGtvN2w5Uy8xeWg3MXBVMjB6YnlJNW4vOW82K0V0TUNYdHR6NXVJelJIejZmMktUQ3JSdGtPWVEzCllFZWxjbExVeitXQWxCSWVGRWY2T3lqRDduUjFrakV0UGZEYjgxT0FZNFhSZEI1MjhxNU00Uy8ydCt0bldKQzAKa0Fwb0hnN3ZrZVlObEtyU3V3U1Vyd1kzbFBFWFREZW8wSnp4azM4ZWVkMXhhaFpvWWlaRmxCblY1K0pmclNhRgo0d1JNMlVKZkFXbUR4SE9KMVR1ZjVBOEh6UWMzU3R6dnhwSmlMQXJKWHh2Ujd2SGJHNDV5YUVYcm9LWXVlejB2Cm50eDNtRjk2V3JyOFN4VHJRbCtGSWFkVUpVcEYwdWd1TFd6Z1BKdWRySkZ3UldGenZKNlY1RnF6WkdVTnRFV1YKa2krNXFKTE9RV3VXeTN2Lzk4MHVYWnlsVE9xOHRtUWx6S2Rubmc3ZnlZekFOQm9LTkd5clVyRWg0eHlHd2hZOQprb1JWT0Q0enNoRXNPNDJiVTV2UUszWjhrTVFVUVFnNDVMSkhsT0t3ejdNTXZHMG1HNERiTkRIVW9qZ2ZTeFJPCnRERWdBcXZVZXRseDAvWG1IbkEzSkVRcERLMDIvWDVBNUJTMnJTbW1rZHYxWFlTb0NUUHpqSHpxY1A3SXhuQnoKNi9pdFgzcjFRQXB4L2prMGZTSzgvR25RN3JvKy8ycFpoVnJuVGdKU3U5QXpsLzEzMEFiNTBpZHlGcFdpWWNtYgo1eGRzVHRiTmx4cjRTaWNJRitudW5TWUZsVnNLZGtLdXA0M1ZObXhZdXExWHh2bmxQSWxwQWdNQkFBR2pnZ0lpCk1JSUNIakFPQmdOVkhROEJBZjhFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUhBd0V3REFZRFZSMFQKQVFIL0JBSXdBREFkQmdOVkhRNEVGZ1FVSUVzNWlvZmV2bGV5S3JNOGhCZVhIVHEzWmNzd0h3WURWUjBqQkJndwpGb0FVNTZ1ZkR5d3pvRlBUWGs5NHlMS0VEanZXa2pNd013WUlLd1lCQlFVSEFRRUVKekFsTUNNR0NDc0dBUVVGCkJ6QUNoaGRvZEhSd09pOHZjakV6TG1rdWJHVnVZM0l1YjNKbkx6QWZCZ05WSFJFRUdEQVdnaFIwY21GbFptbHIKTG5SeWRXTnJkMkZ6YUM1a2F6QVRCZ05WSFNBRUREQUtNQWdHQm1lQkRBRUNBVEF0QmdOVkhSOEVKakFrTUNLZwpJS0FlaGh4b2RIUndPaTh2Y2pFekxtTXViR1Z1WTNJdWIzSm5Mekl1WTNKc01JSUJEUVlLS3dZQkJBSFdlUUlFCkFnU0IvZ1NCK3dENUFINEE0eU9OOG8yaWlPQ3E0S3p3K3BESmhmQzJ2L1hTcFNld0Fmd2NSRmpFdHVnQUFBR2QKQXRhQmN3QUlBQUFGQURYK0Fkb0VBd0JITUVVQ0lHNTQ1N3FyRkkwZExxZEdPaFp4ZVU1aG5QVnlMbXdiUHk4SApXOE5SZTZ2YkFpRUF2M2M0amQ0aVkzVjRaQkNDZGMzVWRXdEdwZHNHdUFSNTBHQnViK2tYMENjQWR3Q1dsMlMvClZWaVhyZmREaDJnM0NFSjM2ZkE2MWZhazh6WnVScVEvRDhxcHhnQUFBWjBDMW9YVUFBQUVBd0JJTUVZQ0lRQ1IKemlJaVF1dlphNEVSbVJaWVdScElLR0xiVG9DY2NwMXJOUlVnRE5QamN3SWhBTGlvV0txeW92c3lKMHgzZGcraApzd1BIdVBKV1F5cWlTeCtTM0JVMVFkbUxNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUJSek5udkdrKzFUMW4vCkVLQzlzNmRpZm9ZeFQvWktyVXVyRm9JTVUvbEJnanN3UGxWMDJIeUlabmRDSGoxRVFYOFdTYnVGWnNpYWhkRysKZWNhZGNOcU9SU3pHYlk0UUVqYVZJK1FHNXplbGN0NjRRV2VZbG1LdHJkajl3Q2VyZ0FMZ1o3Rkh6RHlXOHFKMQpIQVFtdFFlN3o0UTh6N3NidEVUemsyYzNlV0NyTGpiTkFZelEweXZuVHZmU0hBR1lpKytRcjVqOXZta2hKV0svCmowdllmaEhFOUNUSnhSN0RYUmlHTkdKY2F3Z0p1SGNPTzM2MXdYVXlrSnVpRC9ESVh1SmRLZUZzQTFkeVRLOUYKS2IzK2VSRzl5Q1RNd1l2NmF6Z291MjN1c0FicHFDa0FUUmlQckVlcjdLTUpqN0cydXBFdkcxZDZ1bGppdDFqNQp2NGZiMjNnWQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCgotLS0tLUJFR0lOIENFUlRJRklDQVRFLS0tLS0KTUlJRkJUQ0NBdTJnQXdJQkFnSVFXZ0R5RXRqVXRJRHpra0ZYNmltREJUQU5CZ2txaGtpRzl3MEJBUXNGQURCUApNUXN3Q1FZRFZRUUdFd0pWVXpFcE1DY0dBMVVFQ2hNZ1NXNTBaWEp1WlhRZ1UyVmpkWEpwZEhrZ1VtVnpaV0Z5ClkyZ2dSM0p2ZFhBeEZUQVRCZ05WQkFNVERFbFRVa2NnVW05dmRDQllNVEFlRncweU5EQXpNVE13TURBd01EQmEKRncweU56QXpNVEl5TXpVNU5UbGFNRE14Q3pBSkJnTlZCQVlUQWxWVE1SWXdGQVlEVlFRS0V3MU1aWFFuY3lCRgpibU55ZVhCME1Rd3dDZ1lEVlFRREV3TlNNVE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUUNsWjNDTjBGYUJaQlVYWWMyNUJ0U3RHWkNNSmxBM21CWmprbFRiMmN5RUJaUHMwK3dJRzZCZ1VVTkkKZlN2SFNKYWV0QzNhbmNnbk8xZWhuNnZ3MWc3VURqREtiNXV4MGRha25USStXRTQxYjBWWWFIRVgvRDdZWFlLZwpMN0pSYkxBYVhiaFp6alZseUl1aHJ4QTMvK09jWGNKSkZ6VC9qQ3VMamZDOGNTeVREQjBGeExySHphckpYbnpSCnlRSDNuQVAyL0FwZDlOcDc1dHQyUW5EcjlFMGkyZ0IzYjliSlh4ZjkyblV1cFZjTTl1cGN0dUJ6cFdqUG9YVGkKZFlKK0VKL0I5YUxyQWVrNHNRcEV6TlBDaWZWSk5ZSUtOTE1jNllqQ1IwNkNEZ28yOEVkUGl2RXBCSFhhemVHYQpYUDllblppVnVwcEQwRXFpRndVQkJERFRNck9QQWdNQkFBR2pnZmd3Z2ZVd0RnWURWUjBQQVFIL0JBUURBZ0dHCk1CMEdBMVVkSlFRV01CUUdDQ3NHQVFVRkJ3TUNCZ2dyQmdFRkJRY0RBVEFTQmdOVkhSTUJBZjhFQ0RBR0FRSC8KQWdFQU1CMEdBMVVkRGdRV0JCVG5xNThQTERPZ1U5TmVUM2pJc29RT085YVNNekFmQmdOVkhTTUVHREFXZ0JSNQp0Rm5tZTdibDVBRnpnQWlJeUJwWTl1bWJiakF5QmdnckJnRUZCUWNCQVFRbU1DUXdJZ1lJS3dZQkJRVUhNQUtHCkZtaDBkSEE2THk5NE1TNXBMbXhsYm1OeUxtOXlaeTh3RXdZRFZSMGdCQXd3Q2pBSUJnWm5nUXdCQWdFd0p3WUQKVlIwZkJDQXdIakFjb0JxZ0dJWVdhSFIwY0RvdkwzZ3hMbU11YkdWdVkzSXViM0puTHpBTkJna3Foa2lHOXcwQgpBUXNGQUFPQ0FnRUFVVGRZVXFFaW16VzdUYnJPeXBMcUNmTDdWT3dZZi9RNzlPSDVjSExDWmVnZ2ZRaERjb25sCms3S2doOGIwdmkrL1h1V3U3Q044bi9VUGVnMXZvM0crdGFYaXJyeXR0aFFpbkFIR3djL1VkYk95Z0phOXp1QmMKVnlxb0gzQ1hUWERJblQrOGErYzNhRVZNSjJTdCtwU240ZWQrV2tEcDhpanNpanZFeUZ3RTQ3aHVsVzBMdHpqZwo5Zk9WNVBtcmcvenhXYlJ1TCtrMERCREhFSmVubkNzQWVuN2MzNVBteDdqcG1KL0h0Z1JoY256MHlqU0J2eUl3CjZMMVFJdXBrQ3YyU0JPRFQveEREM2dmUVF5S3Y2cm9WNEcyRWhmRXlBc1dwbW9qeGpDVUNHaXlnOTdGdkR0bS8KTksyTFNjOWx5Ykt4QjczSTIrUDJHM0NhV3B2dnBBaUhDVnUzMGpXOEdDeEtkZmhzWHRuSXkyaW1za1FxVloybQowUG14b2JiMjhUdWNyN3hCSzdDdHd2UHJiNzlvczd1MlhQM081ZjliL0g2NkdOeVJyZ2xSWGxyWWpJMW9HWUwvCmY0STFuL1NndXNkYTZXdkE2QzE5MGt4alUxNVkxMm1IVTQrQnh5UjljeDJoaEdTOWZBak1aS0pzczI4cXh2ejYKQXh1NENhRG1STlpwSy9wUXJYRjE3eVhDWGttRVdndlNPRVp5Nlo5cGNiTElWRUdja1YvaVZlcTBBT28ycGtnOQpwNFFSSXkwdEsyZGlSRU5MU0YyS3lzRndiWTZCMjZCRmVGczN2MXNZVlJoRlc5bkxrT3JRVnBvckNTMEt5Wm1mCndWRDg5cVNUbG5jdExjWm5JYXZqS3NLVXUxbkExaVUweVlNZFllcEtSN2xXYm53aGR4M2V3b2s9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", + "key": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS2dJQkFBS0NBZ0VBN2VNQU10TWZWUERDOEhoM3paSHlqakgxcUt4eFRpRjAyd3BGdDZlb0Z6QWJBcHJYCmJNcE0xR2p0djU0K3grbVNqdVgxTC9YS0h2V2xUYlROdklqbWYvMmpyNFMwd0plMjNQbTRqTkVmUHAvWXBNS3QKRzJRNWhEZGdSNlZ5VXRUUDVZQ1VFaDRVUi9vN0tNUHVkSFdTTVMwOThOdnpVNEJqaGRGMEhuYnlya3poTC9hMwo2MmRZa0xTUUNtZ2VEdStSNWcyVXF0SzdCSlN2QmplVThSZE1ONmpRblBHVGZ4NTUzWEZxRm1oaUprV1VHZFhuCjRsK3RKb1hqQkV6WlFsOEJhWVBFYzRuVk81L2tEd2ZOQnpkSzNPL0drbUlzQ3NsZkc5SHU4ZHNiam5Kb1JldWcKcGk1N1BTK2UzSGVZWDNwYXV2eExGT3RDWDRVaHAxUWxTa1hTNkM0dGJPQThtNTJza1hCRllYTzhucFhrV3JOawpaUTIwUlpXU0w3bW9rczVCYTViTGUvLzN6UzVkbktWTTZyeTJaQ1hNcDJlZUR0L0pqTUEwR2dvMGJLdFNzU0hqCkhJYkNGajJTaEZVNFBqT3lFU3c3alp0VG05QXJkbnlReEJSQkNEamtza2VVNHJEUHN3eThiU1liZ05zME1kU2kKT0I5TEZFNjBNU0FDcTlSNjJYSFQ5ZVllY0Rja1JDa01yVGI5ZmtEa0ZMYXRLYWFSMi9WZGhLZ0pNL09NZk9wdwovc2pHY0hQcitLMWZldlZBQ25IK09UUjlJcno4YWREdXVqNy9hbG1GV3VkT0FsSzcwRE9YL1hmUUJ2blNKM0lXCmxhSmh5WnZuRjJ4TzFzMlhHdmhLSndnWDZlNmRKZ1dWV3dwMlFxNm5qZFUyYkZpNnJWZkcrZVU4aVdrQ0F3RUEKQVFLQ0FnQWNSUGRCY3ZPa25GSnNNWUs2TGhPREtLWjYvNWdjNnU2b040K3FxQ3k2YWJQeUFxemlUNldpOGQrNwovMTRTVTN4Z1NTMVNvRnkvUWwyZEx3VHFlR0hjK0tzMnFOaHlrU3p3MUdmRkt6WlJjcjB6OGpNUVgrQVNhV0xDCmpPT1p1cExFb1J1bmpnWEd1bkxFa01raDg1VldqUHZ4QnpCaXZrMkZhZXRJMGdXYnAwVDA5bGFycmIyNWY0MmwKQVBPK2ZtT0tFVmZYRjRFVVJhZUpmM291anVCV3RuUHBESy9Fb2Q1bFRZeW9Ba3BVcGp6WDk2S3RkUVYrZ0JkbQo2OHQvUHN0TUN0WENEYkdHZXdVbThKeEJ0T1ZMZlY2Tmg0ZFlsazdjUDEzTUJjZmY3UkpkYWNlTzdCUG01T1YxCjZXdjAvN1V2eHByVkR2S1ZwNXhrMWtQeXNEckVkRWJQVzJxcHBnSGpuL1JzTnpmNUNWakp1UkNQVGs0NkRwSVMKTVlJVjRKcnZtd2owa0JlQlRqdHl1N3FRdk50aERpbUtIWWI3RU5zZDl5WS9GZVJQVWUyNmRHdnZUelNITG1jWQowd284RGlIRVlvV1F2MjVKSDgxRWY5RnhIVnJtNFFaK3VsTzdYU0xTS0NzVks3bUdEVU9pVVNlZ1ZIL09OMTQzCnJDUXIxaXRVelBaNlNIUGF0ZW0zaVFQalAzaUVqT280d1lwSUgwSU1uUVAwdjg2NWJWTVBmSFIxbW80V1hORDQKTWpodkhSYVgvektCS3ZCZDhRZTE3bFZNNE80MVpnWmZGL0FPUWNKb2pQM21Vd0tJWTJvT3UzLzBYTmE3Wmk1dgpsOUZYb1FGaW5sWkdRd1pqc2x3bHBreTJlb2JlQi8vVjBaQmFjdVpPdlZ5dWVTWVk0UUtDQVFFQTd4MXNRME43Ck0yOGVWVlE1WFVVY0paczB3eG9MVXVWa21mYWoxWEtlVmgydjBLQ2l6T1I4Q3BpeVh2WWU5eVY4ejJwbXB1U04KQjNocU5ydXoySkJSckxWTytOdmhWbHpsU2FwOGYyd3N0MUlTM0tOaDZlTXJ3eUJ5WFljbnFWam10Qk5WRjRvZQppbjkvQSt4QUpVd05jcGhxMjVWUndxK2tVZDJRNGpabzFYOUM3NVJGcDlzaXkrSGh1WG9lMXFBOEJFMWxKakRlCjkzaFo4WmNPTDRiMGtVMzYwbWszRzF0YnJiYUtWOHhibXV2MWhaYXYvZVhZVzZNWDJVSGVFOURZUm1XTVo4a0wKeVFIckM4QzFFOE5mc2s1VEFJMUVUN1dWNnlQRGttY0NMUllqeXhFZG5iLzFFdmlLb3ZFQlBkSmcwam5PUkQ1OQpJRlllTDVZM1laSlBpUUtDQVFFQS9xOWdBQXhvUEZYSEdEcUh5QmprTnNLRElKejVrMUFsbGhRZGZTMVRlcjhoClhaaU9CLzRFSHMwdjlGdTRMZWptcVlmbXNZc2kyS0t4VUZhak5KRGJYSmNSWWxpR1d2U21lS3g1V1Y4MjNqK3cKZHNVQVdXeFBoY3o1c0xPRVd0VWZ3aTdlU2R2dGQ5cmUwbXpTVlBzNXJiQW1jOHVHWVRSZ2gxYzB2U0NMOE9lUQpBZGJLV1UxNWYzRzRSaFdMZmhscjRza1lhOHZ2bFBsbjRXbytjMTJMUkpob080SDZmSklLSWFmd2RGQUczQVE2Clp4c25Rc2JQRWR2WnRwVjhlSVlCL3RGYmJzdXl2SGtOU0JKNDVmM091TDVVbkxTcWh6Rk5yR052WTIwQTFya3AKY2VKN013YVM4dTIvYk85Y1J2OW1jL011RTRoc3ZwK2x6YWU4Y1dVUzRRS0NBUUVBcHY1N2Y3Wi9NbnFtU3AyWgp1b1BybG5BQXQwbFhJenZGdUtsQjNtNUFHcEI5RzAwMHBiZmswVm0zS1E3bXJXQm0vRXlwQ0dHU0JPZVo5a084CjVNQlY2akp0eHV0NVFtRjZXS3BTYklOVjcybklkb004ZktZRkNDOSs3OGJXY2pUK1drRXVLbFJ4NC9RSzl1aG0KSENZek5oY3dlYzZjM1VUaGs3TVQzb2gxU3JXb1A3M2pyOGtoVWhhZEdIWXJWMlZzeE94d2VlMmpxbDFKSTZxRgpZN2Z5MGhBTjNpbFJMUy91cFRGWkNOeFdGYW81UEdUeTRIVkhWeWhlSFlNYzMrWDVSYURpSUJHT3c0RXRjSFhUCmdNdnY1NDAvOXFDeVZxRFE1UG1ET3BucU1TYkhOS2p0Y0NDQnZoQUtjcVd5WWtTdERlVUZJekFwSHc1RjRxSWMKWjI1UlVRS0NBUUVBaFBxM0NtOXdBWWpjTXJkazdFS3E3aml6MU5TQnI3eGFVN2xmQ0Z3aFNXY0FtZWtzeDltRwo5em8xdnNZaExiOThxS292OXlYcDVPbFY0ZGZLMFlpTk1SUWozSkRTWGkxOXVtWjcyZ0ZRR2MxeGF5SkRvMjFkClBFU2hYdlRzdDZ5dUwycmZYL3M0UzZ0NVNxL05SdGdCN3NHWjRqNHpoUStmRXl1aWV0bkNsOStnbm9VekZGdlMKZzR1eUpzM2JEdFZoTW9IRGdZMXpJL3J4bFk0dTIzZk5YdHloRitrdkM5b0k5amZFNGtaaXZvQnFxaWxRWDVxRQp4aE5mNHVpOG5BV0Voek56SlMrd3ZKNE1KRVNZNGFXYlNYVC9vdTdtVno2VUN0M0ViRXFlOUg2cnVDNEVHOGxqCjh2bldTSi9XTTYvcEk4T21uRVpRV1Z2c2E2d1lSYkQ3b1FLQ0FRRUEzT1VGUU9sajdqbUY3bTVWWHd1Z21laVYKTjVWRnJuaUh1ckpuNjFDZ0dkM2Y3UTBLK0xEVWpQWU53Tmg5a2RqLzcwNEZWdUxtMnQybG9xK211S1J4ZmpDVApaSTNRUEh4VGpBci9WOG5UVTI1VnFUZm1ZaGFqSTNJTGEyK05YeExnejI3TmVZWE5PUVZHNk1mM3o1TUZ4WCtGCmg3M1hZSnJ1ZlZyeEZrcDlRRWtQdVVzUU9temZwMHpDdEFDUGNqTFVaQ01hOE5GZWZlUXRLQ05EcklTdjNDVEoKNWY2VlJEQTFEektBcGd1Q1dnODh0TXAyeXZCdFl3a3pMUEpGeWlORG5OSHpUQnFxb09ZT1dnYjJIVHZ5QkYzWApOZXBkWWdoVUt1TVNyRlZBd2I2M3lCbGlkejBYa1I2dUNwTE02Z3luSTBaelhaK0oxTVBlcWFTNjJVNzJ0UT09Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==", + "Store": "default" + } + ] + } +} \ No newline at end of file diff --git a/services/traefik/dynamic.yml b/services/traefik/dynamic.yml index 57a5c05e..ec86c6a9 100644 --- a/services/traefik/dynamic.yml +++ b/services/traefik/dynamic.yml @@ -28,8 +28,8 @@ http: - main: api.truckwash.dk api-preflight-io: - rule: Host(`api.truckwash.io`) && Method(`OPTIONS`) - entryPoints: [websecure] + rule: (Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`) || Host(`localhost`)) && Method(`OPTIONS`) + entryPoints: [websecure, websecure-staging] middlewares: [secure-headers] service: noop@internal priority: 1000 @@ -37,6 +37,7 @@ http: certResolver: le_io domains: - main: api.truckwash.io + - main: api-v2.truckwash.io cloud-preflight: rule: Host(`cloud.truckwash.dk`) && Method(`OPTIONS`) @@ -61,7 +62,7 @@ http: users: - "truckwash:$2y$05$DYcMFqMPgEFWAJQioc.F3.v9ppi9bReAi/aQzoOlWqMyhIysKlDCC" dashboard-allow-local: - ipWhiteList: + ipAllowList: sourceRange: - 127.0.0.1/32 - 10.0.0.0/8 @@ -79,13 +80,19 @@ http: accessControlMaxAge: 86400 accessControlAllowOriginList: - "https://truckwash.io" + - "https://www.truckwash.io" - "https://api.truckwash.io" + - "https://api.truckwash.io:4433" + - "https://api-v2.truckwash.io" - "https://web.truckwash.dk" - "https://api.truckwash.dk" - "https://truckwash.dk" + - "https://www.truckwash.dk" - "https://staging.truckwash.io" - "http://localhost" - "https://localhost" + - "http://localhost:4433" + - "https://localhost:4433" - "https://twdev.jeppeb.dk" - "http://localhost:5173" accessControlAllowMethods: @@ -99,10 +106,19 @@ http: - Authorization - Content-Type - X-Customer-Number + - X-Release-Trace + - X-Release-Channel + - X-Frontend-Version + - Cache-Control + - Pragma api-ratelimit: rateLimit: average: 100 burst: 200 + strip-api-prefix: + stripPrefix: + prefixes: + - "/api" services: cloud-svc: diff --git a/services/traefik/traefik.yml b/services/traefik/traefik.yml index 07242eff..efcaceaa 100644 --- a/services/traefik/traefik.yml +++ b/services/traefik/traefik.yml @@ -3,6 +3,8 @@ entryPoints: address: ":80" websecure: address: ":443" + websecure-staging: + address: ":4433" metrics: address: ":9100" diff --git a/test/orderBookingsPost.http b/test/orderBookingsPost.http index 347754f2..83d7fba2 100644 --- a/test/orderBookingsPost.http +++ b/test/orderBookingsPost.http @@ -1,3 +1,6 @@ +# Local testing template only. Fill placeholders with non-production credentials from local env/secrets. +# Never commit real API tokens, setup tokens, emails, or passwords. + ### POST request to /account/auth/phone POST https://api.truckwash.dk:4433/account/auth/phone Accept: application/json @@ -12,7 +15,7 @@ Content-Type: application/json POST https://api.truckwash.dk:4433/subusers Accept: application/json Content-Type: application/json -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} { "cvr": 41004355, @@ -20,12 +23,84 @@ Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb206 "phone": 42331128 } -### GET request to /subusers/setup -GET https://api.truckwash.dk:4433/subusers/setup?token=1c1be8280bac3937487e5c77b76bb839 +### POST request to /collected-invoices/move-multiple +POST https://api.truckwash.io:4433/collected-invoices/move-multiple Accept: application/json Content-Type: application/json Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +{ + "order_ids": [54518, 48782, 48744], + "target_customer_number": 42460282, + "closed_at": "2026-03-31" +} + +### POST request to /collected-invoices/move-multiple/registration-numbers +POST https://api.truckwash.io:4433/collected-invoices/move-multiple/registration-numbers +Accept: application/json +Content-Type: application/json +Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 + +{ + "registration_numbers": ["DC29870", + "DJ58975", + "DL29041", + "DN23122", + "DN23014", + "AK71417", + "DX22135", + "DX22132", + "DX22134", + "DR97418", + "DR32398", + "DR97427", + "DS11478", + "DC29873", + "AA72279", + "AC38858", + "AY59064", + "AL94096", + "AZ20321", + "BB25208", + "CZ72055", + "DV11106", + "DJ31995", + "DN23025", + "DN88009", + "DP53305", + "DR97424", + "DS11485", + "DW85802", + "AG4277", + "AG4278", + "AG4279", + "AH2383", + "AH5739", + "AH4647", + "AH5741", + "CM1169", + "EY4630", + "EK2303", + "FB5526", + "FB5527"], + "target_customer_number": 43323232, + "from_date": "2026-04-01", + "to_date": "2026-04-30", + "closed_at": "2026-04-30" +} + +### GET request to https://api.truckwash.io:4433/superuser/invoicing/period?dateFrom=2026-05-12&dateTo=2026-05-12&periodView=all&page=1&limit=100&search=&includeRequiresAction=1&includeBooked=1 +GET https://api.truckwash.io/superuser/invoicing/period?dateFrom=2026-04-01&dateTo=2026-04-30&periodView=all&page=1&limit=all&search=&includeRequiresAction=1&includeBooked=1 +Accept: application/json +Authorization: Bearer {{$API_TRUCKWASH_TOKEN}} +Content-Type: application/json + +### GET request to /subusers/setup +GET https://api.truckwash.dk:4433/subusers/setup?token={{SUBUSER_SETUP_TOKEN}} +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} + ### POST /bird/voice/calls/webhook/inbound POST http://localhost/bird/voice/calls/webhook/inbound Accept: application/json @@ -35,7 +110,7 @@ content-type: application/json "service": "channels", "event": "voice.inbound", "url": "https://yoururl.com", - "signingKey": "mysecretkey", + "signingKey": "{{BIRD_SIGNING_KEY}}", "eventFilters": [ { "key": "channelId", @@ -57,14 +132,14 @@ content-type: application/json POST http://localhost/api/subusers/setup Accept: application/json Content-Type: application/json -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} { - "token": "1c1be8280bac3937487e5c77b76bb839", + "token": "{{SUBUSER_SETUP_TOKEN}}", "name": "Test Subuser", "username": "testsubuser", - "email": "jb@truckwash.dk", - "password": "Test1234" + "email": "{{SUBUSER_EMAIL}}", + "password": "{{SUBUSER_PASSWORD}}" } ### POST request to /subusers/auth/password @@ -74,42 +149,42 @@ Content-Type: application/json { "username": "testsubuser", - "password": "Test1234" + "password": "{{SUBUSER_PASSWORD}}" } ### GET request to /order-bookings (As subuser) GET https://api.truckwash.dk:4433/order-bookings Accept: application/json Content-Type: application/json -Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232 +Authorization: Bearer {{SUBUSER_BEARER_TOKEN}} X-Customer-Number: 42331128 ### GET request to /order-bookings (As subuser, specific ID) GET https://api.truckwash.dk:4433/order-bookings?id=76 Accept: application/json Content-Type: application/json -Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232 +Authorization: Bearer {{SUBUSER_BEARER_TOKEN}} X-Customer-Number: 42331128 ### GET request to /vehicles (As subuser) GET https://api.truckwash.dk:4433/vehicles Accept: application/json Content-Type: application/json -Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232 +Authorization: Bearer {{SUBUSER_BEARER_TOKEN}} X-Customer-Number: 42331128 ### GET request to /vehicles (As subuser, specific ID) GET https://api.truckwash.dk:4433/vehicles?id=384 Accept: application/json Content-Type: application/json -Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232 +Authorization: Bearer {{SUBUSER_BEARER_TOKEN}} X-Customer-Number: 42331128 ### GET request to /orders (As subuser) GET https://api.truckwash.dk:4433/orders Accept: application/json Content-Type: application/json -Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232 +Authorization: Bearer {{SUBUSER_BEARER_TOKEN}} X-Customer-Number: 42331128 @@ -117,39 +192,46 @@ X-Customer-Number: 42331128 GET https://api.truckwash.dk:4433/subusers/me Accept: application/json Content-Type: application/json -Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232 +Authorization: Bearer {{SUBUSER_BEARER_TOKEN}} X-Customer-Number: 42331128 ### GET request to /subusers/permission-nodes GET https://api.truckwash.dk:4433/subusers/permission-nodes Accept: application/json Content-Type: application/json -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} ### GET request to /subusers/grants (As subuser) GET https://api.truckwash.dk:4433/subusers/grants Accept: application/json Content-Type: application/json -Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232 +Authorization: Bearer {{SUBUSER_BEARER_TOKEN}} X-Customer-Number: 42331128 ### GET request to order bookings (list all) GET https://api.truckwash.dk:4433/order-bookings Accept: application/json Content-Type: application/json -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} ### GET request to order bookings (specific ID) GET https://api.truckwash.dk:4433/order-bookings?id=1 Accept: application/json Content-Type: application/json -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} + + +### GET request to weather (specific ID) +GET https://api.truckwash.io/departments/weather?id=1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} ### PUT request to order bookings (update specific ID) PUT https://api.truckwash.dk:4433/order-bookings Accept: application/json Content-Type: application/json -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} { "id": 4, @@ -162,7 +244,7 @@ Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb206 POST https://api.truckwash.dk:4433/order-bookings/complete Accept: application/json Content-Type: application/json -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} { "id": 3, @@ -178,12 +260,23 @@ Content-Type: application/json "customer_number": 12345679 } +### Get department distribution e-conomic +GET http://localhost/api/superuser/invoicing/period/distribution/v2/booked-department-75 +Accept: application/json +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} +Content-Type: application/json + +{ + "dateFrom": "2026-01-01", + "dateTo": "2026-01-31" +} + ### ### POST request to order bookings POST https://api.truckwash.dk:4433/order-bookings Accept: application/json, text/plain, */* Accept-Language: en-GB,en;q=0.6 -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} Content-Type: application/json { diff --git a/tetststsssss.txt b/tetststsssss.txt new file mode 100644 index 00000000..48c52fb4 --- /dev/null +++ b/tetststsssss.txt @@ -0,0 +1 @@ +erttedr \ No newline at end of file